diff --git a/CLAUDE.md b/CLAUDE.md index f7cae86..2df0f37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full| node --test 'tests/**/*.test.mjs' ``` -932 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. +936 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`. ### CML scanner — context-window-scaled char budget @@ -168,9 +168,18 @@ COL-shaped `details.namespaces` payload (`{ source: 'plugin:', name, path } Two design notes: (1) the check keys on the declared `name` field, **not** `basename(dir)` — the folder name is irrelevant to the namespace; `scanSinglePlugin` now returns `declaredName` for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the -missing-field check and must never group on an `undefined` key). The pre-existing cross-plugin -**command**-name conflict check still uses the folder basename and over-reports given -namespacing — left as a separate backlog candidate, intentionally out of scope here. +missing-field check and must never group on an `undefined` key). + +The sibling cross-plugin **command-name** check was corrected to match the same model. Because +commands are namespaced (`/name:command`), a command name shared by two **differently-named** +plugins is ambiguity — not a hard conflict — so it now mirrors COL's plugin-vs-plugin skill +finding: severity **LOW**, `category: 'plugin-hygiene'`, COL-shaped `details.namespaces`, and a +group-first shape (one finding per command name listing every namespace, not pairwise). It keys +on the declared namespace and fires only when a name spans **2+ distinct** namespaces; when two +plugins share the same declared name, the namespace-collision finding above is the right (more +severe) signal, so the command check stays silent there to avoid a redundant `"dup, dup"` report. +The earlier HIGH `Cross-plugin command name conflict` finding (basename-keyed, "only one wins") +is gone, along with its now-inaccurate humanizer entry. ## Gotchas diff --git a/README.md b/README.md index fb745c6..f265a4a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ![Commands](https://img.shields.io/badge/commands-18-green) ![Agents](https://img.shields.io/badge/agents-6-orange) ![Hooks](https://img.shields.io/badge/hooks-4-red) -![Tests](https://img.shields.io/badge/tests-932+-brightgreen) +![Tests](https://img.shields.io/badge/tests-936+-brightgreen) ![License](https://img.shields.io/badge/license-MIT-lightgrey) A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies. @@ -350,8 +350,11 @@ By default, `/config-audit` auto-detects scope from your git context. Override w > one plugin's commands, skills, and agents are silently shadowed and become unreachable. > The standalone plugin-health scanner (PLH) flags this at **medium** severity, keying on the > declared `name` field rather than the folder name (the folder name is irrelevant to the -> namespace). Skill-name overlaps across *different* namespaces are a separate, lower-severity -> concern owned by the COL scanner. +> namespace). A *command* name shared by two **differently-named** plugins is a milder case — +> namespacing keeps both reachable as `/a:cmd` and `/b:cmd`, so it is only ambiguity in error +> messages, search results, and the command listing. PLH reports that at **low** severity +> (group-first, one finding per command name), mirroring the COL scanner, which owns the +> analogous skill-name overlaps across *different* namespaces. ### CLI Tools diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs index 37b4ba6..e5022b0 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -659,11 +659,6 @@ export const TRANSLATIONS = { description: 'The settings block tells Claude what tools and model the agent should use.', recommendation: 'Add a settings block (delimited by `---`) at the top of the file.', }, - 'Cross-plugin command name conflict': { - title: 'Two plugins both define a command with the same name', - description: 'When two plugins use the same command name, only one wins.', - recommendation: 'Rename the command in one of the plugins, or disable the one you don\'t need.', - }, 'No plugins found': { title: 'No plugins are installed in this location', description: 'The location was checked but contains no plugins (or no plugins Claude Code recognizes).', @@ -723,6 +718,14 @@ export const TRANSLATIONS = { recommendation: 'Add the missing setting shown in the details.', }, }, + { + regex: /^Command name ".+" used by multiple plugins$/, + translation: { + title: 'Several plugins define a command with the same name', + description: 'Each plugin\'s commands are namespaced (like `/plugin:command`), so they all still work — but a shared command name makes error messages, search results, and the command listing ambiguous about which plugin you mean.', + recommendation: 'Rename the command in one of the plugins so each name points to a single plugin.', + }, + }, { regex: /^Plugin namespace collision:/, translation: { diff --git a/scanners/plugin-health-scanner.mjs b/scanners/plugin-health-scanner.mjs index e6db1e2..a1fdb4c 100644 --- a/scanners/plugin-health-scanner.mjs +++ b/scanners/plugin-health-scanner.mjs @@ -350,10 +350,16 @@ export async function scan(targetPath) { allFindings.push(...result.findings); } - // Cross-plugin checks: command name conflicts - const commandNames = new Map(); // name → plugin + // 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 for (let idx = 0; idx < pluginResults.length; idx++) { - const pr = pluginResults[idx]; + const namespace = pluginResults[idx].declaredName || basename(pluginDirs[idx]); const commandsDir = join(pluginDirs[idx], 'commands'); try { const entries = await readdir(commandsDir); @@ -363,22 +369,36 @@ export async function scan(targetPath) { const { frontmatter } = parseFrontmatter(content); if (frontmatter && frontmatter.name) { const cmdName = frontmatter.name; - if (commandNames.has(cmdName)) { - allFindings.push(finding({ - scanner: SCANNER, - severity: SEVERITY.high, - title: 'Cross-plugin command name conflict', - description: `Command "${cmdName}" exists in both "${commandNames.get(cmdName)}" and "${pr.name}"`, - file: filePath, - recommendation: `Rename one of the conflicting commands to avoid ambiguity`, - })); - } else { - commandNames.set(cmdName, pr.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, + 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 diff --git a/tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json b/tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json new file mode 100644 index 0000000..ebe775e --- /dev/null +++ b/tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json @@ -0,0 +1 @@ +{ "name": "plugin-one", "description": "Declares namespace plugin-one", "version": "1.0.0" } diff --git a/tests/fixtures/duplicate-command-name/one/CLAUDE.md b/tests/fixtures/duplicate-command-name/one/CLAUDE.md new file mode 100644 index 0000000..e345f48 --- /dev/null +++ b/tests/fixtures/duplicate-command-name/one/CLAUDE.md @@ -0,0 +1,12 @@ +# Plugin + +## Commands +| Command | Description | +|---------|-------------| +| `/shared-cmd` | shared | + +## Agents +(none) + +## Hooks +(none) diff --git a/tests/fixtures/duplicate-command-name/one/commands/shared.md b/tests/fixtures/duplicate-command-name/one/commands/shared.md new file mode 100644 index 0000000..50e02d0 --- /dev/null +++ b/tests/fixtures/duplicate-command-name/one/commands/shared.md @@ -0,0 +1,7 @@ +--- +name: shared-cmd +description: A command whose name is shared across two differently-named plugins +model: sonnet +allowed-tools: Read +--- +Do the thing. diff --git a/tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json b/tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json new file mode 100644 index 0000000..e6de67d --- /dev/null +++ b/tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json @@ -0,0 +1 @@ +{ "name": "plugin-two", "description": "Declares namespace plugin-two", "version": "1.0.0" } diff --git a/tests/fixtures/duplicate-command-name/two/CLAUDE.md b/tests/fixtures/duplicate-command-name/two/CLAUDE.md new file mode 100644 index 0000000..e345f48 --- /dev/null +++ b/tests/fixtures/duplicate-command-name/two/CLAUDE.md @@ -0,0 +1,12 @@ +# Plugin + +## Commands +| Command | Description | +|---------|-------------| +| `/shared-cmd` | shared | + +## Agents +(none) + +## Hooks +(none) diff --git a/tests/fixtures/duplicate-command-name/two/commands/shared.md b/tests/fixtures/duplicate-command-name/two/commands/shared.md new file mode 100644 index 0000000..50e02d0 --- /dev/null +++ b/tests/fixtures/duplicate-command-name/two/commands/shared.md @@ -0,0 +1,7 @@ +--- +name: shared-cmd +description: A command whose name is shared across two differently-named plugins +model: sonnet +allowed-tools: Read +--- +Do the thing. diff --git a/tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md b/tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md new file mode 100644 index 0000000..cffaea9 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md @@ -0,0 +1,7 @@ +--- +name: hello +description: Shared command name within a colliding namespace — covered by the namespace-collision finding +model: sonnet +allowed-tools: Read +--- +Say hello. diff --git a/tests/fixtures/duplicate-plugin-name/beta/commands/hello.md b/tests/fixtures/duplicate-plugin-name/beta/commands/hello.md new file mode 100644 index 0000000..cffaea9 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/beta/commands/hello.md @@ -0,0 +1,7 @@ +--- +name: hello +description: Shared command name within a colliding namespace — covered by the namespace-collision finding +model: sonnet +allowed-tools: Read +--- +Say hello. diff --git a/tests/scanners/plugin-health-scanner.test.mjs b/tests/scanners/plugin-health-scanner.test.mjs index a19c106..594c597 100644 --- a/tests/scanners/plugin-health-scanner.test.mjs +++ b/tests/scanners/plugin-health-scanner.test.mjs @@ -10,6 +10,7 @@ const FIXTURES = resolve(__dirname, '../fixtures'); const TEST_PLUGIN = resolve(FIXTURES, 'test-plugin'); const BROKEN_PLUGIN = resolve(FIXTURES, 'broken-plugin'); const DUP_NAME = resolve(FIXTURES, 'duplicate-plugin-name'); +const DUP_CMD = resolve(FIXTURES, 'duplicate-command-name'); describe('discoverPlugins', () => { it('discovers a single plugin when pointed at plugin dir', async () => { @@ -176,6 +177,49 @@ describe('plugin namespace collision detection', () => { }); }); +describe('cross-plugin command name ambiguity (COL-level)', () => { + const CMD_RE = /used by multiple plugins/i; + + it('flags a command name shared across different plugin namespaces as low', async () => { + resetCounter(); + const result = await scan(DUP_CMD); + const amb = result.findings.filter(f => f.scanner === 'PLH' && CMD_RE.test(f.title || '')); + assert.equal(amb.length, 1, `Expected one command-ambiguity finding, got ${amb.length}`); + const f = amb[0]; + assert.equal(f.severity, 'low', 'Namespaced commands are ambiguity (low), not a hard conflict (high)'); + assert.equal(f.category, 'plugin-hygiene'); + assert.ok(f.title.includes('shared-cmd'), `Title should name the command: ${f.title}`); + assert.ok(f.details && Array.isArray(f.details.namespaces), 'Should carry details.namespaces'); + assert.equal(f.details.namespaces.length, 2); + }); + + it('labels plugins by declared name, not folder basename', async () => { + resetCounter(); + const result = await scan(DUP_CMD); + const f = result.findings.find(x => x.scanner === 'PLH' && CMD_RE.test(x.title || '')); + const sources = f.details.namespaces.map(n => n.source).sort(); + // Folders are one/two; declared names are plugin-one/plugin-two. + assert.deepEqual(sources, ['plugin:plugin-one', 'plugin:plugin-two']); + }); + + it('does not emit a high-severity command conflict (legacy behavior removed)', async () => { + resetCounter(); + const result = await scan(DUP_CMD); + const high = result.findings.filter(f => + f.scanner === 'PLH' && /command name conflict/i.test(f.title || '') && f.severity === 'high' + ); + assert.equal(high.length, 0, 'The old high-severity command-conflict finding must be gone'); + }); + + it('does not flag a shared command WITHIN a colliding namespace (namespace-collision covers it)', async () => { + resetCounter(); + // alpha + beta both declare name "dup" and both ship a "hello" command. + const result = await scan(DUP_NAME); + const amb = result.findings.filter(f => f.scanner === 'PLH' && CMD_RE.test(f.title || '')); + assert.equal(amb.length, 0, 'Same namespace = one /dup:hello; the namespace collision is the right signal'); + }); +}); + describe('finding format', () => { it('findings have standard fields', async () => { resetCounter();