BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.
Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.
scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.
IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.
unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.
Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.
Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.
Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).
Suite 1535 -> 1573, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
843 lines
34 KiB
JavaScript
843 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* PLH Scanner — Plugin Health
|
|
* Validates Claude Code plugin structure, frontmatter, and cross-plugin coherence.
|
|
* Finding IDs: CA-PLH-NNN
|
|
* NOT included in scan-orchestrator — runs independently on plugin directories.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
import { writeOutputFile } from './lib/write-output.mjs';
|
|
import { requireTargetDir } from './lib/require-target-dir.mjs';
|
|
import { join, basename, resolve, sep } from 'node:path';
|
|
import { finding, scannerResult } from './lib/output.mjs';
|
|
import { SEVERITY } from './lib/severity.mjs';
|
|
import { parseFrontmatter } from './lib/yaml-parser.mjs';
|
|
import { humanizeFindings } from './lib/humanizer.mjs';
|
|
|
|
const SCANNER = 'PLH';
|
|
|
|
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
|
|
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
|
|
|
|
// A CLAUDE.md need only document the component types the plugin actually ships. Mirrors the
|
|
// optional-frontmatter rule: do not demand docs for commands/agents/hooks that do not exist.
|
|
async function pluginShipsComponent(pluginDir, section) {
|
|
if (section === 'hooks') {
|
|
try { await readFile(join(pluginDir, 'hooks', 'hooks.json'), 'utf-8'); return true; }
|
|
catch { return false; }
|
|
}
|
|
try {
|
|
const entries = await readdir(join(pluginDir, section));
|
|
return entries.some(f => f.endsWith('.md'));
|
|
} catch { return false; }
|
|
}
|
|
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
|
|
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
|
|
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
|
|
// `description` is recommended." `name` defaults to the directory name; `model` and
|
|
// `allowed-tools` are optional. So only `description` is flagged.
|
|
// - Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required."
|
|
// `model` (defaults to `inherit`) and `tools` (inherits all) are optional.
|
|
const REQUIRED_COMMAND_FRONTMATTER = [
|
|
{ key: 'description', display: 'description' },
|
|
];
|
|
const REQUIRED_AGENT_FRONTMATTER = [
|
|
{ key: 'name', display: 'name' },
|
|
{ key: 'description', display: 'description' },
|
|
];
|
|
|
|
// Plugin subagents silently ignore these frontmatter keys — they are honored
|
|
// ONLY for user/project agents in .claude/agents/ (code.claude.com/docs
|
|
// sub-agents, "ignored for plugin subagents"). Setting them in a plugin agent
|
|
// is dead config; permissionMode is MEDIUM because it implies a restriction
|
|
// that Claude Code does not actually apply (false sense of security).
|
|
const PLUGIN_AGENT_IGNORED_FIELDS = [
|
|
{ key: 'permissionMode', severity: SEVERITY.medium },
|
|
{ key: 'hooks', severity: SEVERITY.low },
|
|
{ key: 'mcpServers', severity: SEVERITY.low },
|
|
];
|
|
|
|
// Component-path keys that REPLACE the default folder (per code.claude.com/docs
|
|
// plugins-reference#path-behavior-rules). When such a key is set, Claude Code
|
|
// stops scanning the default folder; if that folder still exists, its contents
|
|
// are silently ignored (dead config). CC v2.1.140+ flags this in /doctor and
|
|
// `claude plugin list`. Excluded by design: `skills` (ADDS to the default —
|
|
// both load, never a shadow), and `hooks`/`mcpServers`/`lspServers` (own merge
|
|
// rules, not a folder shadow). Experimental themes/monitors are omitted: the
|
|
// docs warn their manifest schema may change between releases.
|
|
const SHADOWING_PATH_FIELDS = [
|
|
{ key: 'commands', defaultDir: 'commands' },
|
|
{ key: 'agents', defaultDir: 'agents' },
|
|
{ key: 'outputStyles', defaultDir: 'output-styles' },
|
|
];
|
|
|
|
/** Normalize a manifest path: strip a leading "./" and trailing slashes. */
|
|
function normalizeManifestPath(p) {
|
|
return String(p).replace(/^\.\//, '').replace(/\/+$/, '');
|
|
}
|
|
|
|
/**
|
|
* True when a custom manifest path addresses the default folder (equals it or
|
|
* points inside it) — Claude Code shows no warning in that case because the
|
|
* folder is referenced explicitly (e.g. "commands": ["./commands/deploy.md"]).
|
|
*/
|
|
function addressesDefaultDir(customPath, defaultDir) {
|
|
const norm = normalizeManifestPath(customPath);
|
|
return norm === defaultDir || norm.startsWith(defaultDir + '/');
|
|
}
|
|
|
|
/** True when `p` exists and is a directory. */
|
|
async function dirExists(p) {
|
|
try {
|
|
return (await stat(p)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Stat `p`, or null when it does not exist (distinguishes missing from file/dir). */
|
|
async function statOrNull(p) {
|
|
try {
|
|
return await stat(p);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when a `skills` entry resolves outside the plugin root. Installed plugins
|
|
* cannot reference files outside their own directory (docs: path-traversal
|
|
* limitations), so "../shared" or an absolute path will not load.
|
|
*/
|
|
function skillsEntryEscapesRoot(pluginDir, entry) {
|
|
const resolved = resolve(pluginDir, entry.replace(/^\.\//, ''));
|
|
return resolved !== pluginDir && !resolved.startsWith(pluginDir + sep);
|
|
}
|
|
|
|
// Per-problem prose for a malformed `skills` entry. Each `title` starts with
|
|
// `plugin.json "skills" entry` so the family is greppable.
|
|
const SKILLS_ENTRY_MESSAGES = {
|
|
'non-string': {
|
|
title: e => `plugin.json "skills" entry is not a string: ${JSON.stringify(e)}`,
|
|
description: 'Each "skills" entry must be a relative path string (starting with "./") to a skill directory.',
|
|
recommendation: 'Replace the non-string entry with a path like "./my-skill/", or remove it.',
|
|
},
|
|
'escapes-root': {
|
|
title: e => `plugin.json "skills" entry escapes the plugin root: ${e}`,
|
|
description: 'Installed plugins cannot reference files outside their own directory, so a skills path that traverses outside the plugin root (e.g. "../shared") will not load.',
|
|
recommendation: 'Point the entry at a directory inside the plugin, or vendor the skill into the plugin.',
|
|
},
|
|
'not-found': {
|
|
title: e => `plugin.json "skills" entry does not exist: ${e}`,
|
|
description: 'The "skills" entry points at a path that does not exist in the plugin, so no skill loads from it.',
|
|
recommendation: 'Create the directory, fix the path, or remove the entry.',
|
|
},
|
|
'not-a-directory': {
|
|
title: e => `plugin.json "skills" entry is a file, not a directory: ${e}`,
|
|
description: 'A "skills" entry must be a directory containing a SKILL.md (or <name>/SKILL.md), not a file.',
|
|
recommendation: 'Point the entry at the skill directory (the folder that contains SKILL.md), not the file.',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Discover plugins under a path.
|
|
* Looks for .claude-plugin/plugin.json pattern.
|
|
* @param {string} targetPath
|
|
* @returns {Promise<string[]>} Array of plugin root directories
|
|
*/
|
|
export async function discoverPlugins(targetPath) {
|
|
const plugins = [];
|
|
|
|
// Check if targetPath itself is a plugin
|
|
if (await isPlugin(targetPath)) {
|
|
plugins.push(targetPath);
|
|
return plugins;
|
|
}
|
|
|
|
// Look for plugins in subdirectories (marketplace layout: plugins/<name>/)
|
|
try {
|
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const subDir = join(targetPath, entry.name);
|
|
if (await isPlugin(subDir)) {
|
|
plugins.push(subDir);
|
|
continue;
|
|
}
|
|
// Also check one level deeper (plugins/<name>/ layout)
|
|
try {
|
|
const subEntries = await readdir(subDir, { withFileTypes: true });
|
|
for (const subEntry of subEntries) {
|
|
if (!subEntry.isDirectory()) continue;
|
|
const deepDir = join(subDir, subEntry.name);
|
|
if (await isPlugin(deepDir)) {
|
|
plugins.push(deepDir);
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
return plugins;
|
|
}
|
|
|
|
/**
|
|
* Check if a directory is a Claude Code plugin.
|
|
* @param {string} dir
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async function isPlugin(dir) {
|
|
try {
|
|
await stat(join(dir, '.claude-plugin', 'plugin.json'));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan a single plugin for health issues.
|
|
* @param {string} pluginDir - Plugin root directory
|
|
* @returns {Promise<{ name: string, findings: object[], commandCount: number, agentCount: number }>}
|
|
*/
|
|
async function scanSinglePlugin(pluginDir) {
|
|
const findings = [];
|
|
const pluginName = basename(pluginDir);
|
|
let commandCount = 0;
|
|
let agentCount = 0;
|
|
// Declared namespace from plugin.json `name` (the prefix for /name:command,
|
|
// name:skill, agent "name"). Folder basename is NOT the namespace.
|
|
let declaredName = null;
|
|
|
|
// 1. Validate plugin.json
|
|
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
|
|
try {
|
|
const content = await readFile(pluginJsonPath, 'utf-8');
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(content);
|
|
} catch {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'invalid-plugin-json',
|
|
severity: SEVERITY.critical,
|
|
title: 'Invalid plugin.json',
|
|
description: `plugin.json is not valid JSON in ${pluginName}`,
|
|
file: pluginJsonPath,
|
|
}));
|
|
parsed = null;
|
|
}
|
|
|
|
if (parsed) {
|
|
if (typeof parsed.name === 'string' && parsed.name.trim()) {
|
|
declaredName = parsed.name.trim();
|
|
}
|
|
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
|
|
if (!parsed[field]) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'missing-required-field',
|
|
severity: SEVERITY.high,
|
|
title: `Missing required field in plugin.json: ${field}`,
|
|
description: `Plugin "${pluginName}" plugin.json is missing required field "${field}"`,
|
|
file: pluginJsonPath,
|
|
recommendation: `Add "${field}" to plugin.json`,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Shadow check: a manifest component-path key that REPLACES a default
|
|
// folder which still exists → that folder is silently ignored (dead config).
|
|
for (const { key, defaultDir } of SHADOWING_PATH_FIELDS) {
|
|
const value = parsed[key];
|
|
if (value === undefined || value === null) continue;
|
|
const customPaths = (Array.isArray(value) ? value : [value]).filter(p => typeof p === 'string');
|
|
if (customPaths.length === 0) continue;
|
|
// If any custom path addresses the default folder, CC keeps scanning it → no shadow.
|
|
if (customPaths.some(p => addressesDefaultDir(p, defaultDir))) continue;
|
|
if (!(await dirExists(join(pluginDir, defaultDir)))) continue;
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'plugin-json-shadows-default',
|
|
severity: SEVERITY.medium,
|
|
title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`,
|
|
description:
|
|
`Plugin "${pluginName}" sets "${key}" in plugin.json to ${customPaths.map(p => `"${p}"`).join(', ')}, ` +
|
|
`which replaces the default ${defaultDir}/ folder. That folder still exists but Claude Code no longer ` +
|
|
`scans it, so its contents are silently ignored (dead config). Claude Code flags this in /doctor and ` +
|
|
'`claude plugin list` (v2.1.140+).',
|
|
file: pluginJsonPath,
|
|
evidence: `${key}=${JSON.stringify(value)}; ignored folder=${defaultDir}/`,
|
|
recommendation:
|
|
`Either remove the unused ${defaultDir}/ folder, or keep it by listing it explicitly in "${key}" ` +
|
|
`(e.g. "${key}": ["./${defaultDir}/", ...]).`,
|
|
category: 'plugin-hygiene',
|
|
details: { field: key, ignoredDir: defaultDir, customPaths },
|
|
}));
|
|
}
|
|
|
|
// skills:-array validation: each entry must resolve to an existing
|
|
// directory inside the plugin root. Mirrors `claude plugin validate`.
|
|
// skills is string|array (a single string is one entry). Unlike the
|
|
// shadow check, skills ADDS to the default skills/ scan, so a custom path
|
|
// here is never a shadow — it just has to be a real directory.
|
|
if (parsed.skills !== undefined && parsed.skills !== null) {
|
|
const entries = Array.isArray(parsed.skills) ? parsed.skills : [parsed.skills];
|
|
for (const entry of entries) {
|
|
let problem = null;
|
|
if (typeof entry !== 'string') {
|
|
problem = 'non-string';
|
|
} else if (skillsEntryEscapesRoot(pluginDir, entry)) {
|
|
problem = 'escapes-root';
|
|
} else {
|
|
const st = await statOrNull(resolve(pluginDir, entry.replace(/^\.\//, '')));
|
|
if (!st) problem = 'not-found';
|
|
else if (!st.isDirectory()) problem = 'not-a-directory';
|
|
}
|
|
if (!problem) continue;
|
|
const m = SKILLS_ENTRY_MESSAGES[problem];
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'skills-array-entry',
|
|
severity: SEVERITY.medium,
|
|
title: m.title(entry),
|
|
description: `Plugin "${pluginName}": ${m.description}`,
|
|
file: pluginJsonPath,
|
|
evidence: `skills entry=${JSON.stringify(entry)}; problem=${problem}`,
|
|
recommendation: m.recommendation,
|
|
category: 'plugin-hygiene',
|
|
details: { field: 'skills', entry, problem },
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'missing-plugin-json',
|
|
severity: SEVERITY.critical,
|
|
title: 'Missing plugin.json',
|
|
description: `No .claude-plugin/plugin.json found in ${pluginName}`,
|
|
file: pluginDir,
|
|
recommendation: 'Create .claude-plugin/plugin.json with name, description, version',
|
|
}));
|
|
}
|
|
|
|
// 2. Validate CLAUDE.md
|
|
const claudeMdPath = join(pluginDir, 'CLAUDE.md');
|
|
try {
|
|
const content = await readFile(claudeMdPath, 'utf-8');
|
|
const lower = content.toLowerCase();
|
|
|
|
for (const section of RECOMMENDED_CLAUDE_MD_SECTIONS) {
|
|
// Only require a section for a component the plugin actually ships (mirrors the
|
|
// optional-frontmatter rule — no docs demanded for absent commands/agents/hooks).
|
|
if (!(await pluginShipsComponent(pluginDir, section))) continue;
|
|
// Look for markdown table header or section header
|
|
const hasSection = lower.includes(`## ${section}`) ||
|
|
lower.includes(`| ${section}`) ||
|
|
lower.includes(`|${section}`);
|
|
if (!hasSection) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'claude-md-missing-section',
|
|
severity: SEVERITY.medium,
|
|
title: `CLAUDE.md missing ${section} section`,
|
|
description: `Plugin "${pluginName}" CLAUDE.md should have a ${section} table or section`,
|
|
file: claudeMdPath,
|
|
recommendation: `Add a "## ${section.charAt(0).toUpperCase() + section.slice(1)}" section with a table`,
|
|
}));
|
|
}
|
|
}
|
|
} catch {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'missing-claude-md',
|
|
severity: SEVERITY.high,
|
|
title: 'Missing CLAUDE.md',
|
|
description: `Plugin "${pluginName}" has no CLAUDE.md`,
|
|
file: pluginDir,
|
|
recommendation: 'Create CLAUDE.md with Commands, Agents, and Hooks tables',
|
|
}));
|
|
}
|
|
|
|
// 3. Validate commands frontmatter
|
|
const commandsDir = join(pluginDir, 'commands');
|
|
try {
|
|
const entries = await readdir(commandsDir);
|
|
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
|
commandCount = mdFiles.length;
|
|
|
|
for (const file of mdFiles) {
|
|
const filePath = join(commandsDir, file);
|
|
const content = await readFile(filePath, 'utf-8');
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
|
|
if (!frontmatter) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'command-missing-frontmatter',
|
|
severity: SEVERITY.high,
|
|
title: 'Command missing frontmatter',
|
|
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
|
|
file: filePath,
|
|
recommendation: 'Add YAML frontmatter with a description (other command fields are optional)',
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
for (const { key, display } of REQUIRED_COMMAND_FRONTMATTER) {
|
|
if (!frontmatter[key]) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'command-missing-field',
|
|
severity: SEVERITY.medium,
|
|
title: `Command missing frontmatter field: ${display}`,
|
|
description: `Command "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
|
file: filePath,
|
|
recommendation: `Add "${display}" to frontmatter`,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
} catch { /* no commands dir */ }
|
|
|
|
// 4. Validate agents frontmatter
|
|
const agentsDir = join(pluginDir, 'agents');
|
|
try {
|
|
const entries = await readdir(agentsDir);
|
|
const mdFiles = entries.filter(f => f.endsWith('.md'));
|
|
agentCount = mdFiles.length;
|
|
|
|
for (const file of mdFiles) {
|
|
const filePath = join(agentsDir, file);
|
|
const content = await readFile(filePath, 'utf-8');
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
|
|
if (!frontmatter) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'agent-missing-frontmatter',
|
|
severity: SEVERITY.high,
|
|
title: 'Agent missing frontmatter',
|
|
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
|
|
file: filePath,
|
|
recommendation: 'Add YAML frontmatter with name and description (model and tools are optional)',
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
for (const { key, display } of REQUIRED_AGENT_FRONTMATTER) {
|
|
if (!frontmatter[key]) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'agent-missing-field',
|
|
severity: SEVERITY.medium,
|
|
title: `Agent missing frontmatter field: ${display}`,
|
|
description: `Agent "${file}" in plugin "${pluginName}" is missing "${display}" in frontmatter`,
|
|
file: filePath,
|
|
recommendation: `Add "${display}" to frontmatter`,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Plugin subagents ignore hooks/mcpServers/permissionMode frontmatter (V15)
|
|
// — dead config (permissionMode = medium: false sense of restriction).
|
|
for (const { key, severity } of PLUGIN_AGENT_IGNORED_FIELDS) {
|
|
if (frontmatter[key] !== undefined) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'agent-ignored-key',
|
|
severity,
|
|
title: `Plugin agent sets "${key}", which Claude Code ignores`,
|
|
description: `Agent "${file}" in plugin "${pluginName}" sets "${key}" in frontmatter, but Claude Code ignores ${key} for plugin subagents — ${key === 'permissionMode' ? 'the agent runs with default permissions, not the restricted mode this implies' : 'this configuration has no effect'}.`,
|
|
file: filePath,
|
|
evidence: `${key}: ${JSON.stringify(frontmatter[key])}`,
|
|
recommendation: `Remove "${key}" from the agent frontmatter, or ship the agent as a user/project agent in .claude/agents/, where ${key} is honored.`,
|
|
autoFixable: false,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
} catch { /* no agents dir */ }
|
|
|
|
// 5. Validate hooks.json (if exists)
|
|
const hooksJsonPath = join(pluginDir, 'hooks', 'hooks.json');
|
|
try {
|
|
const content = await readFile(hooksJsonPath, 'utf-8');
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
if (!parsed.hooks || typeof parsed.hooks !== 'object') {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'hooks-json-invalid-structure',
|
|
severity: SEVERITY.high,
|
|
title: 'Invalid hooks.json structure',
|
|
description: `hooks.json in "${pluginName}" missing "hooks" object`,
|
|
file: hooksJsonPath,
|
|
recommendation: 'hooks.json must have a "hooks" key with event-keyed object',
|
|
}));
|
|
} else if (Array.isArray(parsed.hooks)) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'hooks-json-array',
|
|
severity: SEVERITY.high,
|
|
title: 'hooks.json uses array instead of object',
|
|
description: `hooks.json "hooks" in "${pluginName}" is an array — must be object with event keys`,
|
|
file: hooksJsonPath,
|
|
recommendation: 'Change hooks from array to object: { "PreToolUse": [...], ... }',
|
|
}));
|
|
}
|
|
} catch {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'hooks-json-invalid',
|
|
severity: SEVERITY.high,
|
|
title: 'Invalid hooks.json',
|
|
description: `hooks.json is not valid JSON in "${pluginName}"`,
|
|
file: hooksJsonPath,
|
|
}));
|
|
}
|
|
} catch { /* no hooks.json — fine */ }
|
|
|
|
// 6. Check for unknown files in .claude-plugin/
|
|
const pluginMetaDir = join(pluginDir, '.claude-plugin');
|
|
try {
|
|
const entries = await readdir(pluginMetaDir);
|
|
// `marketplace.json` belongs here: it is the documented, required location
|
|
// for a marketplace catalog (code.claude.com/docs plugin-marketplaces —
|
|
// "Create `.claude-plugin/marketplace.json` in your repository root"), and a
|
|
// marketplace entry with `"source": "./"` makes the repo root its own
|
|
// plugin. Such a repo legitimately carries both files, so flagging the
|
|
// catalog as an unknown file was a false positive.
|
|
const known = new Set(['plugin.json', 'marketplace.json']);
|
|
for (const entry of entries) {
|
|
if (!known.has(entry)) {
|
|
findings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'unknown-plugin-file',
|
|
severity: SEVERITY.low,
|
|
title: 'Unknown file in .claude-plugin/',
|
|
description: `Unexpected file "${entry}" in .claude-plugin/ of "${pluginName}"`,
|
|
file: join(pluginMetaDir, entry),
|
|
recommendation: 'Only plugin.json should be in .claude-plugin/',
|
|
}));
|
|
}
|
|
}
|
|
} catch { /* skip */ }
|
|
|
|
return { name: pluginName, declaredName, findings, commandCount, agentCount };
|
|
}
|
|
|
|
/**
|
|
* Per-plugin score and grade. Single source for both the terminal report and
|
|
* the --output-file payload — the grade formula used to live only inside
|
|
* `formatPluginHealthReport`, which nothing called.
|
|
* @param {number} issueCount
|
|
* @returns {{ score: number, grade: string }}
|
|
*/
|
|
export function pluginGrade(issueCount) {
|
|
const score = Math.max(0, 100 - issueCount * 10);
|
|
const grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 40 ? 'D' : 'F';
|
|
return { score, grade };
|
|
}
|
|
|
|
/**
|
|
* Scan one or more plugins and return aggregated results.
|
|
*
|
|
* The envelope is frozen at the v5.0.0 shape (byte-stable `--raw`/`--json`), so
|
|
* per-plugin rows and the cross-plugin/per-plugin split are NOT in it. Callers
|
|
* that need those — the `--output-file` payload, and therefore
|
|
* `/config-audit plugin-health` — use `scanDetailed`.
|
|
*
|
|
* @param {string} targetPath - Plugin dir or marketplace root
|
|
* @returns {Promise<object>} Scanner result
|
|
*/
|
|
export async function scan(targetPath) {
|
|
return (await scanDetailed(targetPath)).result;
|
|
}
|
|
|
|
/**
|
|
* Scan, and also return what `scan()`'s frozen envelope cannot carry: one row
|
|
* per plugin (name, declared namespace, component counts, grade) and the
|
|
* cross-plugin findings as a distinct set.
|
|
*
|
|
* @param {string} targetPath - Plugin dir or marketplace root
|
|
* @returns {Promise<{ result: object, plugins: object[], crossPluginFindings: object[] }>}
|
|
*/
|
|
export async function scanDetailed(targetPath) {
|
|
const start = Date.now();
|
|
|
|
const pluginDirs = await discoverPlugins(resolve(targetPath));
|
|
|
|
if (pluginDirs.length === 0) {
|
|
return {
|
|
result: scannerResult(SCANNER, 'ok', [
|
|
finding({
|
|
scanner: SCANNER,
|
|
code: 'no-plugins-found',
|
|
severity: SEVERITY.info,
|
|
title: 'No plugins found',
|
|
description: `No Claude Code plugins found under ${targetPath}`,
|
|
recommendation: 'Ensure plugins have .claude-plugin/plugin.json',
|
|
}),
|
|
], 0, Date.now() - start),
|
|
plugins: [],
|
|
crossPluginFindings: [],
|
|
};
|
|
}
|
|
|
|
const allFindings = [];
|
|
const pluginResults = [];
|
|
|
|
for (const dir of pluginDirs) {
|
|
const result = await scanSinglePlugin(dir);
|
|
pluginResults.push(result);
|
|
allFindings.push(...result.findings);
|
|
}
|
|
|
|
// Everything pushed from here on is a cross-plugin finding — the boundary the
|
|
// payload uses to split them out (they are flattened into `findings` in the
|
|
// frozen envelope, where `category: 'plugin-hygiene'` cannot tell them apart
|
|
// from the per-plugin shadow/skills findings that share it).
|
|
const crossPluginStart = allFindings.length;
|
|
|
|
// Cross-plugin checks: command-name ambiguity across DIFFERENT plugin namespaces.
|
|
// Commands are namespaced by the plugin's declared name (/name:command), so a
|
|
// shared command name across DIFFERENT plugins is ambiguity — not a hard
|
|
// conflict — mirroring COL's plugin-vs-plugin skill check (low). When two
|
|
// plugins share the SAME declared namespace, the namespace-collision finding
|
|
// below already covers it, so this check keys on the namespace and fires only
|
|
// when a command name spans 2+ DISTINCT namespaces.
|
|
const commandsByNamespace = new Map(); // cmdName → Map<namespace, { path }>
|
|
for (let idx = 0; idx < pluginResults.length; idx++) {
|
|
const namespace = pluginResults[idx].declaredName || basename(pluginDirs[idx]);
|
|
const commandsDir = join(pluginDirs[idx], 'commands');
|
|
try {
|
|
const entries = await readdir(commandsDir);
|
|
for (const file of entries.filter(f => f.endsWith('.md'))) {
|
|
const filePath = join(commandsDir, file);
|
|
const content = await readFile(filePath, 'utf-8');
|
|
const { frontmatter } = parseFrontmatter(content);
|
|
if (frontmatter && frontmatter.name) {
|
|
const cmdName = frontmatter.name;
|
|
if (!commandsByNamespace.has(cmdName)) commandsByNamespace.set(cmdName, new Map());
|
|
const nsMap = commandsByNamespace.get(cmdName);
|
|
if (!nsMap.has(namespace)) nsMap.set(namespace, { path: filePath });
|
|
}
|
|
}
|
|
} catch { /* no commands dir */ }
|
|
}
|
|
for (const [cmdName, nsMap] of commandsByNamespace) {
|
|
if (nsMap.size < 2) continue; // single namespace → no cross-plugin ambiguity
|
|
const entries = [...nsMap.entries()].map(([namespace, v]) => ({ namespace, path: v.path }));
|
|
const namespaceList = entries.map(e => e.namespace).join(', ');
|
|
allFindings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'command-name-collision',
|
|
severity: SEVERITY.low,
|
|
title: `Command name "${cmdName}" used by multiple plugins`,
|
|
description:
|
|
`${entries.length} plugins (${namespaceList}) expose a command named "${cmdName}". ` +
|
|
'Even when invocation is namespaced via /plugin:command, shared names create ambiguity ' +
|
|
'in error messages, search results, and the command listing.',
|
|
file: entries[0].path,
|
|
evidence: `name="${cmdName}"; plugins=${entries.map(e => e.namespace).join(',')}`,
|
|
recommendation:
|
|
'Coordinate command naming across plugins, or rename one to clarify intent. The shared ' +
|
|
'name forces every reader to disambiguate by plugin.',
|
|
category: 'plugin-hygiene',
|
|
details: {
|
|
namespaces: entries.map(e => ({ source: `plugin:${e.namespace}`, name: cmdName, path: e.path })),
|
|
},
|
|
}));
|
|
}
|
|
|
|
// Cross-plugin checks: plugin namespace (declared name) collisions.
|
|
// Claude Code namespaces every plugin component by the plugin's declared
|
|
// `name` (/name:command, name:skill, agent "name"). Two plugins that declare
|
|
// the SAME name collapse into one namespace; the resolution between two
|
|
// installed plugins is undocumented, so one plugin's components are silently
|
|
// shadowed. Name-less plugins are flagged elsewhere and never grouped here.
|
|
const byDeclaredName = new Map(); // declaredName → string[] of plugin dirs
|
|
for (let idx = 0; idx < pluginResults.length; idx++) {
|
|
const declaredName = pluginResults[idx].declaredName;
|
|
if (!declaredName) continue;
|
|
if (!byDeclaredName.has(declaredName)) byDeclaredName.set(declaredName, []);
|
|
byDeclaredName.get(declaredName).push(pluginDirs[idx]);
|
|
}
|
|
for (const [declaredName, dirs] of byDeclaredName) {
|
|
if (dirs.length < 2) continue;
|
|
allFindings.push(finding({
|
|
scanner: SCANNER,
|
|
code: 'namespace-collision',
|
|
severity: SEVERITY.medium,
|
|
title: `Plugin namespace collision: "${declaredName}"`,
|
|
description:
|
|
`${dirs.length} plugins declare the same name "${declaredName}" in plugin.json. ` +
|
|
`Claude Code namespaces every plugin component by that name ` +
|
|
`(/${declaredName}:command, ${declaredName}:skill, agent "${declaredName}"), so the ` +
|
|
'namespaces collapse into one. Resolution between two installed plugins of the same ' +
|
|
"name is undocumented — one plugin's commands, skills, and agents are silently shadowed " +
|
|
'and become unreachable.',
|
|
file: join(dirs[0], '.claude-plugin', 'plugin.json'),
|
|
evidence: `name="${declaredName}"; plugins=${dirs.map(d => basename(d)).join(',')}`,
|
|
recommendation:
|
|
'Rename one plugin\'s "name" in plugin.json so each plugin owns a distinct namespace. ' +
|
|
'The folder name does not matter — the declared "name" field is the namespace.',
|
|
category: 'plugin-hygiene',
|
|
details: {
|
|
namespaces: dirs.map(d => ({
|
|
source: `plugin:${basename(d)}`,
|
|
name: declaredName,
|
|
path: join(d, '.claude-plugin', 'plugin.json'),
|
|
})),
|
|
},
|
|
}));
|
|
}
|
|
|
|
return {
|
|
result: scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start),
|
|
plugins: pluginResults.map((p, idx) => ({
|
|
name: p.name,
|
|
declaredName: p.declaredName,
|
|
path: pluginDirs[idx],
|
|
commandCount: p.commandCount,
|
|
agentCount: p.agentCount,
|
|
findingCount: p.findings.length,
|
|
...pluginGrade(p.findings.length),
|
|
})),
|
|
crossPluginFindings: allFindings.slice(crossPluginStart),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format a plugin health report for terminal output.
|
|
* @param {object} scanResult - Scanner result from scan()
|
|
* @param {Array<{ name: string, findings: object[], commandCount: number, agentCount: number }>} pluginResults
|
|
* @returns {string}
|
|
*/
|
|
export function formatPluginHealthReport(pluginResults, crossPluginFindings) {
|
|
const lines = [];
|
|
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
lines.push(' Plugin Health Report');
|
|
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
lines.push('');
|
|
|
|
for (const p of pluginResults) {
|
|
const { score, grade } = pluginGrade(p.findings.length);
|
|
const padding = '.'.repeat(Math.max(1, 25 - p.name.length));
|
|
lines.push(` ${p.name} ${padding} ${grade} (${score}) ${p.commandCount} commands, ${p.agentCount} agents`);
|
|
}
|
|
|
|
lines.push('');
|
|
|
|
if (crossPluginFindings.length > 0) {
|
|
lines.push(` Cross-plugin issues (${crossPluginFindings.length}):`);
|
|
for (const f of crossPluginFindings) {
|
|
lines.push(` - [${f.severity}] ${f.title}`);
|
|
}
|
|
} else {
|
|
lines.push(' Cross-plugin issues (0):');
|
|
lines.push(' (none)');
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// --- CLI entry point ---
|
|
const BOOL_FLAGS = ['--json', '--raw'];
|
|
const VALUE_FLAGS = ['--output-file'];
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let targetPath = '.';
|
|
let jsonMode = false;
|
|
let rawMode = false;
|
|
let outputFile = null;
|
|
|
|
// M-BUG-21, third arm: this loop used to end in
|
|
// `else if (!args[i].startsWith('-')) targetPath = args[i]` with no
|
|
// unknown-flag branch. An unrecognised flag was dropped silently and its
|
|
// VALUE became the scan target, so `--output-file /tmp/x.json` scanned
|
|
// /tmp/x.json. Unlike drift-cli, the result LOOKS fine: a non-existent path
|
|
// discovers no plugins, so the scanner reported "No plugins found" (info) and
|
|
// exit 0 — a green answer to a question nobody asked. Now it fails loudly.
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (BOOL_FLAGS.includes(arg)) {
|
|
if (arg === '--json') jsonMode = true;
|
|
else if (arg === '--raw') rawMode = true;
|
|
} else if (VALUE_FLAGS.includes(arg)) {
|
|
const value = args[i + 1];
|
|
if (value === undefined || value.startsWith('-')) {
|
|
throw new Error(`Option ${arg} requires a value.`);
|
|
}
|
|
outputFile = value;
|
|
i++;
|
|
} else if (arg.startsWith('-')) {
|
|
throw new Error(
|
|
`Unknown option: ${arg}\n` +
|
|
`Valid options: ${[...BOOL_FLAGS, ...VALUE_FLAGS].join(' ')}`
|
|
);
|
|
} else {
|
|
targetPath = arg;
|
|
}
|
|
}
|
|
|
|
if (!(await requireTargetDir(resolve(targetPath)))) {
|
|
process.exitCode = 3;
|
|
return;
|
|
}
|
|
|
|
const humanizedProgress = !jsonMode && !rawMode;
|
|
process.stderr.write(humanizedProgress ? `Plugin Health v2.1.0\n` : `Plugin Health Scanner v2.1.0\n`);
|
|
process.stderr.write(`Target: ${resolve(targetPath)}\n\n`);
|
|
|
|
const { result, plugins, crossPluginFindings } = await scanDetailed(targetPath);
|
|
|
|
if (jsonMode || rawMode) {
|
|
// --json and --raw both write the v5.0.0-shape result (byte-identical).
|
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
} else {
|
|
// Default mode humanizes finding titles before writing the brief summary.
|
|
const findings = humanizeFindings(result.findings);
|
|
const count = findings.length;
|
|
process.stderr.write(`Findings: ${count}\n`);
|
|
for (const f of findings) {
|
|
process.stderr.write(` [${f.severity}] ${f.title}\n`);
|
|
}
|
|
|
|
// ux-rules rule 2: the command runs with `2>/dev/null`, so anything it must
|
|
// ACT on has to ride in the --output-file payload. Everything above this
|
|
// point is stderr, i.e. invisible to `/config-audit plugin-health`.
|
|
if (outputFile) {
|
|
const crossIds = new Set(crossPluginFindings.map(f => f.id));
|
|
for (const f of findings) {
|
|
if (crossIds.has(f.id)) f.crossPlugin = true;
|
|
}
|
|
const payload = {
|
|
...result,
|
|
findings,
|
|
plugins,
|
|
cross_plugin_findings: findings.filter(f => crossIds.has(f.id)),
|
|
};
|
|
await writeOutputFile(outputFile, JSON.stringify(payload, null, 2), 'utf-8');
|
|
process.stderr.write(`\nResults written to ${outputFile}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
if (isDirectRun) {
|
|
main().catch(err => {
|
|
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
process.exitCode = 3;
|
|
});
|
|
}
|