From 7abc5a1dcbed90db5144f64f216e8a7ba4dc94a8 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 19 Jun 2026 21:46:22 +0200 Subject: [PATCH] feat(plh): flag plugin.json paths that shadow default folders (CA-PLH-015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLH now flags a plugin.json component-path key (commands/agents/outputStyles) that replaces a default folder still present on disk — Claude Code stops scanning that folder, so its contents are silently ignored (dead config). Mirrors CC's /doctor & `claude plugin list` warning (v2.1.140+). Field set pinned to the docs' "replaces" category only (Verifiseringsplikt, code.claude.com/docs path-behavior-rules): skills is excluded (adds to the default skills/ scan — both load) as are hooks/mcpServers/lspServers (own merge rules); a custom path that addresses the default folder is not flagged. Tests +5 (936->941). Scanner count unchanged (13). --json/--raw byte-stable. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8 --- CLAUDE.md | 20 ++++++ README.md | 13 +++- scanners/lib/humanizer-data.mjs | 8 +++ scanners/plugin-health-scanner.mjs | 67 +++++++++++++++++++ .../.claude-plugin/plugin.json | 9 +++ tests/fixtures/plugin-shadow-folder/CLAUDE.md | 21 ++++++ .../plugin-shadow-folder/agents/real.md | 8 +++ .../plugin-shadow-folder/commands/real.md | 8 +++ .../skills/some-skill/SKILL.md | 6 ++ tests/scanners/plugin-health-scanner.test.mjs | 58 ++++++++++++++++ 10 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json create mode 100644 tests/fixtures/plugin-shadow-folder/CLAUDE.md create mode 100644 tests/fixtures/plugin-shadow-folder/agents/real.md create mode 100644 tests/fixtures/plugin-shadow-folder/commands/real.md create mode 100644 tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md diff --git a/CLAUDE.md b/CLAUDE.md index 2df0f37..bb3b0be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,26 @@ severe) signal, so the command check stays silent there to avoid a redundant `"d The earlier HIGH `Cross-plugin command name conflict` finding (basename-keyed, "only one wins") is gone, along with its now-inaccurate humanizer entry. +### PLH scanner — plugin-folder shadowing (`CA-PLH-015`) + +Per-plugin check (in `scanSinglePlugin`, right after the required-field loop): a `plugin.json` +component-path key that **replaces** its default folder while that folder still exists on disk → +the folder is silently ignored (dead config). Severity **MEDIUM**, `category: 'plugin-hygiene'`, +`details: { field, ignoredDir, customPaths }`. Mirrors Claude Code's own warning in `/doctor`, +`claude plugin list`, and the `/plugin` detail view (v2.1.140+). + +The field set is **primary-source-pinned** to the *replaces* category only — +`SHADOWING_PATH_FIELDS` = `commands`/`agents`/`outputStyles` (defaults `commands/`, `agents/`, +`output-styles/`). Deliberately excluded: **`skills`** (per +`code.claude.com/docs/.../path-behavior-rules` it *adds to* the default `skills/` scan — both +load, never a shadow), and **`hooks`/`mcpServers`/`lspServers`** (own merge rules, not a +folder-shadow). Experimental `themes`/`monitors` are omitted because the docs warn their manifest +schema may change between releases. The check also honors the doc's explicit-address exception: a +custom path that resolves *into* the default folder (`"commands": ["./commands/x.md"]`) is not +flagged, because Claude Code keeps scanning the folder in that case (`addressesDefaultDir` +predicate). The v5.4.0 plan originally listed `commands/agents/skills/hooks`; that set was +corrected here against the live docs (Verifiseringsplikt). + ## Gotchas - Session directories accumulate — use `/config-audit cleanup` to manage diff --git a/README.md b/README.md index b47291e..ca97aa2 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-936+-brightgreen) +![Tests](https://img.shields.io/badge/tests-941+-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. @@ -372,6 +372,17 @@ By default, `/config-audit` auto-detects scope from your git context. Override w > (group-first, one finding per command name), mirroring the COL scanner, which owns the > analogous skill-name overlaps across *different* namespaces. +> **Plugin-folder shadowing — when a manifest path silently buries a default folder.** +> A plugin's `plugin.json` can point a component type at a custom path — `commands`, +> `agents`, and `outputStyles` all *replace* their default folder when set. So if a plugin +> declares `"commands": "./custom/"` while a `commands/` folder still exists, Claude Code +> stops scanning `commands/` entirely and everything in it silently disappears (dead config). +> PLH flags this at **medium** severity (`CA-PLH-015`), mirroring Claude Code's own warning in +> `/doctor` and `claude plugin list` (v2.1.140+). It does **not** flag `skills` — that key +> *adds to* the default `skills/` scan rather than replacing it, so both load — nor does it +> flag a custom path that points back into the default folder (e.g. +> `"commands": ["./commands/x.md"]`), because the folder is then addressed explicitly. + ### 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 e5022b0..0c6c330 100644 --- a/scanners/lib/humanizer-data.mjs +++ b/scanners/lib/humanizer-data.mjs @@ -734,6 +734,14 @@ export const TRANSLATIONS = { 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.', }, }, + { + regex: /^plugin\.json ".+" path shadows the default /, + translation: { + title: 'A plugin folder is silently ignored because the manifest points elsewhere', + description: 'The plugin\'s `plugin.json` points a component type (like commands or agents) at a custom path. When it does, Claude Code stops scanning the default folder of the same name — so everything still sitting in that folder silently disappears.', + recommendation: 'Either delete the unused default folder, or keep it by listing it explicitly alongside the custom path. The details show which folder and field.', + }, + }, ], _default: { title: 'A plugin has a configuration issue', diff --git a/scanners/plugin-health-scanner.mjs b/scanners/plugin-health-scanner.mjs index a1fdb4c..1b7a820 100644 --- a/scanners/plugin-health-scanner.mjs +++ b/scanners/plugin-health-scanner.mjs @@ -33,6 +33,44 @@ const REQUIRED_AGENT_FRONTMATTER = [ { key: 'tools', display: 'tools' }, ]; +// Component-path keys that REPLACE the default folder (per code.claude.com/docs +// plugins-reference#path-behavior-rules). When such a key is set, Claude Code +// stops scanning the default folder; if that folder still exists, its contents +// are silently ignored (dead config). CC v2.1.140+ flags this in /doctor and +// `claude plugin list`. Excluded by design: `skills` (ADDS to the default — +// both load, never a shadow), and `hooks`/`mcpServers`/`lspServers` (own merge +// rules, not a folder shadow). Experimental themes/monitors are omitted: the +// docs warn their manifest schema may change between releases. +const SHADOWING_PATH_FIELDS = [ + { key: 'commands', defaultDir: 'commands' }, + { key: 'agents', defaultDir: 'agents' }, + { key: 'outputStyles', defaultDir: 'output-styles' }, +]; + +/** Normalize a manifest path: strip a leading "./" and trailing slashes. */ +function normalizeManifestPath(p) { + return String(p).replace(/^\.\//, '').replace(/\/+$/, ''); +} + +/** + * True when a custom manifest path addresses the default folder (equals it or + * points inside it) — Claude Code shows no warning in that case because the + * folder is referenced explicitly (e.g. "commands": ["./commands/deploy.md"]). + */ +function addressesDefaultDir(customPath, defaultDir) { + const norm = normalizeManifestPath(customPath); + return norm === defaultDir || norm.startsWith(defaultDir + '/'); +} + +/** True when `p` exists and is a directory. */ +async function dirExists(p) { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} + /** * Discover plugins under a path. * Looks for .claude-plugin/plugin.json pattern. @@ -137,6 +175,35 @@ async function scanSinglePlugin(pluginDir) { })); } } + + // Shadow check: a manifest component-path key that REPLACES a default + // folder which still exists → that folder is silently ignored (dead config). + for (const { key, defaultDir } of SHADOWING_PATH_FIELDS) { + const value = parsed[key]; + if (value === undefined || value === null) continue; + const customPaths = (Array.isArray(value) ? value : [value]).filter(p => typeof p === 'string'); + if (customPaths.length === 0) continue; + // If any custom path addresses the default folder, CC keeps scanning it → no shadow. + if (customPaths.some(p => addressesDefaultDir(p, defaultDir))) continue; + if (!(await dirExists(join(pluginDir, defaultDir)))) continue; + findings.push(finding({ + scanner: SCANNER, + severity: SEVERITY.medium, + title: `plugin.json "${key}" path shadows the default ${defaultDir}/ folder`, + description: + `Plugin "${pluginName}" sets "${key}" in plugin.json to ${customPaths.map(p => `"${p}"`).join(', ')}, ` + + `which replaces the default ${defaultDir}/ folder. That folder still exists but Claude Code no longer ` + + `scans it, so its contents are silently ignored (dead config). Claude Code flags this in /doctor and ` + + '`claude plugin list` (v2.1.140+).', + file: pluginJsonPath, + evidence: `${key}=${JSON.stringify(value)}; ignored folder=${defaultDir}/`, + recommendation: + `Either remove the unused ${defaultDir}/ folder, or keep it by listing it explicitly in "${key}" ` + + `(e.g. "${key}": ["./${defaultDir}/", ...]).`, + category: 'plugin-hygiene', + details: { field: key, ignoredDir: defaultDir, customPaths }, + })); + } } } catch { findings.push(finding({ diff --git a/tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json b/tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json new file mode 100644 index 0000000..dcc8b4d --- /dev/null +++ b/tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "shadow-folder-plugin", + "description": "Fixture: plugin.json manifest paths that shadow default folders (CA-PLH-015)", + "version": "1.0.0", + "commands": "./custom-cmds/", + "agents": ["./agents/", "./more-agents/"], + "skills": "./custom-skills/", + "outputStyles": "./styles/" +} diff --git a/tests/fixtures/plugin-shadow-folder/CLAUDE.md b/tests/fixtures/plugin-shadow-folder/CLAUDE.md new file mode 100644 index 0000000..5426fce --- /dev/null +++ b/tests/fixtures/plugin-shadow-folder/CLAUDE.md @@ -0,0 +1,21 @@ +# Shadow Folder Plugin + +Fixture for CA-PLH-015 shadow-folder detection. + +## Commands + +| Command | Description | +|---------|-------------| +| `/shadow-folder-plugin:shadow-fixture-cmd` | A command | + +## Agents + +| Agent | Role | Model | +|-------|------|-------| +| shadow-fixture-agent | Test | sonnet | + +## Hooks + +| Event | Script | Purpose | +|-------|--------|---------| +| (none) | — | — | diff --git a/tests/fixtures/plugin-shadow-folder/agents/real.md b/tests/fixtures/plugin-shadow-folder/agents/real.md new file mode 100644 index 0000000..09d3046 --- /dev/null +++ b/tests/fixtures/plugin-shadow-folder/agents/real.md @@ -0,0 +1,8 @@ +--- +name: shadow-fixture-agent +description: An agent in the default agents/ folder +model: sonnet +tools: ["Read"] +--- + +# Shadow Fixture Agent diff --git a/tests/fixtures/plugin-shadow-folder/commands/real.md b/tests/fixtures/plugin-shadow-folder/commands/real.md new file mode 100644 index 0000000..d66c019 --- /dev/null +++ b/tests/fixtures/plugin-shadow-folder/commands/real.md @@ -0,0 +1,8 @@ +--- +name: shadow-fixture-cmd +description: A command that lives in the default commands/ folder +allowed-tools: Read +model: sonnet +--- + +# Shadow Fixture Command diff --git a/tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md b/tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md new file mode 100644 index 0000000..70253f0 --- /dev/null +++ b/tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: shadow-fixture-skill +description: A skill in the default skills/ folder +--- + +# Shadow Fixture Skill diff --git a/tests/scanners/plugin-health-scanner.test.mjs b/tests/scanners/plugin-health-scanner.test.mjs index 594c597..d2747ee 100644 --- a/tests/scanners/plugin-health-scanner.test.mjs +++ b/tests/scanners/plugin-health-scanner.test.mjs @@ -11,6 +11,7 @@ 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'); +const SHADOW = resolve(FIXTURES, 'plugin-shadow-folder'); describe('discoverPlugins', () => { it('discovers a single plugin when pointed at plugin dir', async () => { @@ -220,6 +221,63 @@ describe('cross-plugin command name ambiguity (COL-level)', () => { }); }); +describe('plugin-folder shadowing (CA-PLH-015)', () => { + // Title set by the scanner: `plugin.json "" path shadows the default / folder`. + const SHADOW_RE = /shadows the default/i; + + it('flags a manifest path that shadows the default commands/ folder', async () => { + resetCounter(); + const result = await scan(SHADOW); + const shadows = result.findings.filter(f => f.scanner === 'PLH' && SHADOW_RE.test(f.title || '')); + assert.equal(shadows.length, 1, `Expected exactly one shadow finding, got ${shadows.length}: ${shadows.map(f => f.title).join(' | ')}`); + const f = shadows[0]; + assert.equal(f.severity, 'medium', 'Shadowed default folder is medium (dead config)'); + assert.equal(f.category, 'plugin-hygiene'); + assert.ok(f.title.includes('commands'), `Title should name the shadowed field: ${f.title}`); + assert.ok(/plugin\.json$/.test(f.file || ''), `Finding should point at plugin.json: ${f.file}`); + assert.ok(f.details && f.details.field === 'commands', 'details.field should be the manifest key'); + assert.ok(f.details.ignoredDir === 'commands', `details.ignoredDir should be the default folder: ${f.details.ignoredDir}`); + }); + + it('does NOT flag a default folder addressed explicitly in the manifest array', async () => { + resetCounter(); + // agents: ["./agents/", "./more-agents/"] addresses the default agents/ folder → no warning. + const result = await scan(SHADOW); + const agentShadows = result.findings.filter(f => + f.scanner === 'PLH' && SHADOW_RE.test(f.title || '') && /agents/.test(f.title || '') + ); + assert.equal(agentShadows.length, 0, 'agents/ is addressed explicitly via ./agents/ → not shadowed'); + }); + + it('does NOT flag skills (adds to default, not replace)', async () => { + resetCounter(); + // skills: "./custom-skills/" ADDS to the default skills/ scan; both load → no shadow. + const result = await scan(SHADOW); + const skillShadows = result.findings.filter(f => + f.scanner === 'PLH' && SHADOW_RE.test(f.title || '') && /skills/.test(f.title || '') + ); + assert.equal(skillShadows.length, 0, 'skills adds-to-default; never a shadow'); + }); + + it('does NOT flag when the default folder is absent', async () => { + resetCounter(); + // outputStyles: "./styles/" is declared, but there is no output-styles/ folder → nothing ignored. + const result = await scan(SHADOW); + const osShadows = result.findings.filter(f => + f.scanner === 'PLH' && SHADOW_RE.test(f.title || '') && /output-styles/.test(f.title || '') + ); + assert.equal(osShadows.length, 0, 'No default output-styles/ folder exists → no shadow'); + }); + + it('does NOT flag a plugin with no component-path keys', async () => { + resetCounter(); + // test-plugin has commands/ and agents/ folders but declares no custom paths → nothing shadowed. + const result = await scan(TEST_PLUGIN); + const shadows = result.findings.filter(f => f.scanner === 'PLH' && SHADOW_RE.test(f.title || '')); + assert.equal(shadows.length, 0, 'No manifest path keys → no shadow findings'); + }); +}); + describe('finding format', () => { it('findings have standard fields', async () => { resetCounter();