From c6c5f17752e04a28bba2bd68dcb17a338d0668ca Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 19 Jun 2026 15:13:19 +0200 Subject: [PATCH] feat(plh): flag plugin namespace collisions (same declared name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 23 ++++++++- README.md | 14 +++++- scanners/lib/humanizer-data.mjs | 8 +++ scanners/plugin-health-scanner.mjs | 50 ++++++++++++++++++- .../alpha/.claude-plugin/plugin.json | 5 ++ .../duplicate-plugin-name/alpha/CLAUDE.md | 12 +++++ .../beta/.claude-plugin/plugin.json | 5 ++ .../duplicate-plugin-name/beta/CLAUDE.md | 12 +++++ .../delta/.claude-plugin/plugin.json | 4 ++ .../gamma/.claude-plugin/plugin.json | 4 ++ tests/scanners/plugin-health-scanner.test.mjs | 48 ++++++++++++++++++ 11 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json create mode 100644 tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md create mode 100644 tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json create mode 100644 tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md create mode 100644 tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json create mode 100644 tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json diff --git a/CLAUDE.md b/CLAUDE.md index 6aeab3e..f7cae86 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' ``` -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:', 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 diff --git a/README.md b/README.md index f92c795..fb745c6 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-929+-brightgreen) +![Tests](https://img.shields.io/badge/tests-932+-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. @@ -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: diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs index 10d12b3..37b4ba6 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -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', diff --git a/scanners/plugin-health-scanner.mjs b/scanners/plugin-health-scanner.mjs index 0272a4b..e6db1e2 100644 --- a/scanners/plugin-health-scanner.mjs +++ b/scanners/plugin-health-scanner.mjs @@ -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); } diff --git a/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json new file mode 100644 index 0000000..447f1fa --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "dup", + "description": "Alpha plugin that declares the namespace 'dup'", + "version": "1.0.0" +} diff --git a/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md b/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md new file mode 100644 index 0000000..1541bc0 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md @@ -0,0 +1,12 @@ +# Dup Plugin + +## Commands +| Command | Description | +|---------|-------------| +| `/dup:hello` | say hello | + +## Agents +(none) + +## Hooks +(none) diff --git a/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json new file mode 100644 index 0000000..9d00129 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "dup", + "description": "Beta plugin that declares the SAME namespace 'dup' — collides with alpha", + "version": "2.0.0" +} diff --git a/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md b/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md new file mode 100644 index 0000000..1541bc0 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md @@ -0,0 +1,12 @@ +# Dup Plugin + +## Commands +| Command | Description | +|---------|-------------| +| `/dup:hello` | say hello | + +## Agents +(none) + +## Hooks +(none) diff --git a/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json new file mode 100644 index 0000000..dad8885 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "description": "Plugin with NO name field — must be excluded from the namespace-collision map", + "version": "1.0.0" +} diff --git a/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json new file mode 100644 index 0000000..dad8885 --- /dev/null +++ b/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json @@ -0,0 +1,4 @@ +{ + "description": "Plugin with NO name field — must be excluded from the namespace-collision map", + "version": "1.0.0" +} diff --git a/tests/scanners/plugin-health-scanner.test.mjs b/tests/scanners/plugin-health-scanner.test.mjs index 5b39615..a19c106 100644 --- a/tests/scanners/plugin-health-scanner.test.mjs +++ b/tests/scanners/plugin-health-scanner.test.mjs @@ -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();