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:
Kjell Tore Guttormsen 2026-06-19 15:13:19 +02:00
commit c6c5f17752
11 changed files with 182 additions and 3 deletions

View file

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

View file

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

View file

@ -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',

View file

@ -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);
}

View file

@ -0,0 +1,5 @@
{
"name": "dup",
"description": "Alpha plugin that declares the namespace 'dup'",
"version": "1.0.0"
}

View file

@ -0,0 +1,12 @@
# Dup Plugin
## Commands
| Command | Description |
|---------|-------------|
| `/dup:hello` | say hello |
## Agents
(none)
## Hooks
(none)

View file

@ -0,0 +1,5 @@
{
"name": "dup",
"description": "Beta plugin that declares the SAME namespace 'dup' — collides with alpha",
"version": "2.0.0"
}

View file

@ -0,0 +1,12 @@
# Dup Plugin
## Commands
| Command | Description |
|---------|-------------|
| `/dup:hello` | say hello |
## Agents
(none)
## Hooks
(none)

View file

@ -0,0 +1,4 @@
{
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
"version": "1.0.0"
}

View file

@ -0,0 +1,4 @@
{
"description": "Plugin with NO name field — must be excluded from the namespace-collision map",
"version": "1.0.0"
}

View file

@ -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();