fix(plh): downgrade cross-plugin command-name overlap to low ambiguity
Commands are namespaced (/name:command), so a command name shared by two differently-named plugins keeps both reachable — it is ambiguity, not a hard conflict. The check now mirrors COL's plugin-vs-plugin skill finding: severity LOW (was HIGH), 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 (was folder basename) and fires only across 2+ distinct namespaces — when plugins share a declared name, the namespace-collision finding (medium) is the right signal, so this stays silent. Removes the inaccurate "only one wins" humanizer entry. Adds fixtures (duplicate-command-name; a shared command in duplicate-plugin-name's colliding namespace) and 4 tests. Suite 932->936. self-audit A 97 / A 100, scanners 13.
This commit is contained in:
parent
c6c5f17752
commit
0874188fe4
13 changed files with 160 additions and 27 deletions
17
CLAUDE.md
17
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:<dir>', 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
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<namespace, { path }>
|
||||
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
|
||||
|
|
|
|||
1
tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json
vendored
Normal file
1
tests/fixtures/duplicate-command-name/one/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "plugin-one", "description": "Declares namespace plugin-one", "version": "1.0.0" }
|
||||
12
tests/fixtures/duplicate-command-name/one/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-command-name/one/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Plugin
|
||||
|
||||
## Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/shared-cmd` | shared |
|
||||
|
||||
## Agents
|
||||
(none)
|
||||
|
||||
## Hooks
|
||||
(none)
|
||||
7
tests/fixtures/duplicate-command-name/one/commands/shared.md
vendored
Normal file
7
tests/fixtures/duplicate-command-name/one/commands/shared.md
vendored
Normal file
|
|
@ -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.
|
||||
1
tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json
vendored
Normal file
1
tests/fixtures/duplicate-command-name/two/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "plugin-two", "description": "Declares namespace plugin-two", "version": "1.0.0" }
|
||||
12
tests/fixtures/duplicate-command-name/two/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-command-name/two/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Plugin
|
||||
|
||||
## Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/shared-cmd` | shared |
|
||||
|
||||
## Agents
|
||||
(none)
|
||||
|
||||
## Hooks
|
||||
(none)
|
||||
7
tests/fixtures/duplicate-command-name/two/commands/shared.md
vendored
Normal file
7
tests/fixtures/duplicate-command-name/two/commands/shared.md
vendored
Normal file
|
|
@ -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.
|
||||
7
tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md
vendored
Normal file
7
tests/fixtures/duplicate-plugin-name/alpha/commands/hello.md
vendored
Normal file
|
|
@ -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.
|
||||
7
tests/fixtures/duplicate-plugin-name/beta/commands/hello.md
vendored
Normal file
7
tests/fixtures/duplicate-plugin-name/beta/commands/hello.md
vendored
Normal file
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue