feat(plh): flag plugin namespace collisions (same declared name)
Two plugins that declare the same `name` in plugin.json collapse into one component namespace (/name:command, name:skill, agent "name"). Resolution between two installed same-name plugins is undocumented, so one plugin's commands/skills/agents are silently shadowed and unreachable. PLH now flags this at medium severity, keying on the declared `name` (not folder basename, via new declaredName on scanSinglePlugin) with a COL-shaped details.namespaces payload. Name-less plugins are excluded from the collision map. Search-first (code.claude.com/docs/en/plugins): plugin components are namespaced by the declared name, so a plugin component can never shadow a user/project one — only a same-name collision loses components. This refutes the original "plugin vs user vs project shadowing" framing in the backlog. Adds humanizer pattern, fixture (duplicate-plugin-name: 2 colliding + 2 name-less), and 3 tests. Suite 929->932. self-audit A 97 / A 100, scanners 13.
This commit is contained in:
parent
d678765fad
commit
c6c5f17752
11 changed files with 182 additions and 3 deletions
23
CLAUDE.md
23
CLAUDE.md
|
|
@ -109,7 +109,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
|
|||
node --test 'tests/**/*.test.mjs'
|
||||
```
|
||||
|
||||
929 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`.
|
||||
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`.
|
||||
|
||||
### CML scanner — context-window-scaled char budget
|
||||
|
||||
|
|
@ -151,6 +151,27 @@ Beyond deny/allow overlap, the DIS scanner now also flags:
|
|||
These predicates live in `scanners/lib/permission-rules.mjs` (shared with the CNF
|
||||
conflict-detector). Behavior verified against `code.claude.com/docs/en/permissions`.
|
||||
|
||||
### PLH scanner — plugin namespace collision
|
||||
|
||||
The standalone PLH scanner (cross-plugin checks in `scan()`) flags **plugin namespace
|
||||
collisions**: two or more discovered plugins that declare the **same `name`** in
|
||||
`plugin.json`. The search-first finding that shaped this check: Claude Code namespaces
|
||||
every plugin component by the declared `name` — `/name:command`, `name:skill`, agent
|
||||
`name` (verified against `code.claude.com/docs/en/plugins`, and observable in any session's
|
||||
namespaced skill listing). A plugin component therefore can **never** shadow a user- or
|
||||
project-level one; the only shadow that loses components is a same-`name` collision, where
|
||||
the namespaces collapse into one and CC must pick a winner. Resolution between two installed
|
||||
same-name plugins is **undocumented**, so the loser's commands/skills/agents go silently
|
||||
unreachable — hence severity **MEDIUM** (dead config), `category: 'plugin-hygiene'`, with a
|
||||
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.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Session directories accumulate — use `/config-audit cleanup` to manage
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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.
|
||||
|
|
@ -341,6 +341,18 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
|
|||
> predicate lives in `scanners/lib/permission-rules.mjs`; valid forms like `Bash(npm:*)`,
|
||||
> `WebFetch(domain:host)`, and `Agent(model:opus)` are never flagged.
|
||||
|
||||
> **Plugin namespace collisions — the one shadow that actually loses components.**
|
||||
> Claude Code namespaces every plugin component by the plugin's declared `name`
|
||||
> (`/name:command`, `name:skill`, agent `name`), so a plugin component can never shadow a
|
||||
> user- or project-level one — they live in separate namespaces. The real hazard is two
|
||||
> plugins that declare the **same** `name` in `plugin.json`: their namespaces collapse into
|
||||
> one, and because the resolution between two installed same-name plugins is undocumented,
|
||||
> 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.
|
||||
|
||||
### CLI Tools
|
||||
|
||||
All tools work standalone — no Claude Code session needed:
|
||||
|
|
|
|||
|
|
@ -723,6 +723,14 @@ export const TRANSLATIONS = {
|
|||
recommendation: 'Add the missing setting shown in the details.',
|
||||
},
|
||||
},
|
||||
{
|
||||
regex: /^Plugin namespace collision:/,
|
||||
translation: {
|
||||
title: 'Two plugins share the same namespace, so one hides the other',
|
||||
description: 'Claude Code names a plugin\'s commands, skills, and agents after the plugin (like `/name:command`). When two plugins declare the same name, they share one namespace and only one is reachable — the other\'s commands, skills, and agents silently disappear.',
|
||||
recommendation: 'Give each plugin a distinct `name` in its `plugin.json`. The folder name doesn\'t matter — the `name` field is what forms the namespace.',
|
||||
},
|
||||
},
|
||||
],
|
||||
_default: {
|
||||
title: 'A plugin has a configuration issue',
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ async function scanSinglePlugin(pluginDir) {
|
|||
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');
|
||||
|
|
@ -119,6 +122,9 @@ async function scanSinglePlugin(pluginDir) {
|
|||
}
|
||||
|
||||
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({
|
||||
|
|
@ -309,7 +315,7 @@ async function scanSinglePlugin(pluginDir) {
|
|||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
return { name: pluginName, findings, commandCount, agentCount };
|
||||
return { name: pluginName, declaredName, findings, commandCount, agentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -374,6 +380,48 @@ export async function scan(targetPath) {
|
|||
} catch { /* no commands dir */ }
|
||||
}
|
||||
|
||||
// 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,
|
||||
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 scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
|
||||
}
|
||||
|
||||
|
|
|
|||
5
tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "dup",
|
||||
"description": "Alpha plugin that declares the namespace 'dup'",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
12
tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Dup Plugin
|
||||
|
||||
## Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/dup:hello` | say hello |
|
||||
|
||||
## Agents
|
||||
(none)
|
||||
|
||||
## Hooks
|
||||
(none)
|
||||
5
tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "dup",
|
||||
"description": "Beta plugin that declares the SAME namespace 'dup' — collides with alpha",
|
||||
"version": "2.0.0"
|
||||
}
|
||||
12
tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
vendored
Normal file
12
tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Dup Plugin
|
||||
|
||||
## Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/dup:hello` | say hello |
|
||||
|
||||
## Agents
|
||||
(none)
|
||||
|
||||
## Hooks
|
||||
(none)
|
||||
4
tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
vendored
Normal file
4
tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
4
tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
vendored
Normal file
4
tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|||
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');
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
it('discovers a single plugin when pointed at plugin dir', async () => {
|
||||
|
|
@ -128,6 +129,53 @@ describe('cross-plugin command conflict detection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('plugin namespace collision detection', () => {
|
||||
const COLLISION_RE = /namespace collision/i;
|
||||
|
||||
it('flags two plugins that declare the same name', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(DUP_NAME);
|
||||
const collisions = result.findings.filter(f =>
|
||||
f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
|
||||
);
|
||||
assert.equal(collisions.length, 1, `Expected exactly one namespace collision, got ${collisions.length}`);
|
||||
const f = collisions[0];
|
||||
assert.equal(f.severity, 'medium', 'Namespace collision is medium severity');
|
||||
assert.equal(f.category, 'plugin-hygiene');
|
||||
assert.ok(f.title.includes('dup'), `Title should name the colliding namespace: ${f.title}`);
|
||||
assert.ok(f.details && Array.isArray(f.details.namespaces), 'Should carry details.namespaces');
|
||||
assert.equal(f.details.namespaces.length, 2, 'Two plugins collide on "dup"');
|
||||
for (const ns of f.details.namespaces) {
|
||||
assert.equal(ns.name, 'dup');
|
||||
assert.ok(ns.source.startsWith('plugin:'), `source should be plugin-scoped: ${ns.source}`);
|
||||
assert.ok(ns.path, 'each namespace entry carries a path');
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes name-less plugins from the collision map', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(DUP_NAME);
|
||||
// gamma + delta declare no name; they must NOT form an undefined/empty collision.
|
||||
const collisions = result.findings.filter(f =>
|
||||
f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
|
||||
);
|
||||
assert.equal(collisions.length, 1, 'Only the real "dup" collision; name-less plugins are ignored');
|
||||
assert.ok(
|
||||
!collisions.some(f => /undefined|""|''|null/.test(f.title)),
|
||||
'No collision finding for an empty/undefined name'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not flag a single plugin as a collision', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
const collisions = result.findings.filter(f =>
|
||||
f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
|
||||
);
|
||||
assert.equal(collisions.length, 0, 'A single plugin must not collide with itself');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finding format', () => {
|
||||
it('findings have standard fields', async () => {
|
||||
resetCounter();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue