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
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