feat(plh): flag plugin.json paths that shadow default folders (CA-PLH-015)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
Kjell Tore Guttormsen 2026-06-19 21:46:22 +02:00
commit 7abc5a1dcb
10 changed files with 217 additions and 1 deletions

View file

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

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

View file

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

View file

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

View file

@ -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/"
}

View file

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

View file

@ -0,0 +1,8 @@
---
name: shadow-fixture-agent
description: An agent in the default agents/ folder
model: sonnet
tools: ["Read"]
---
# Shadow Fixture Agent

View file

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

View file

@ -0,0 +1,6 @@
---
name: shadow-fixture-skill
description: A skill in the default skills/ folder
---
# Shadow Fixture Skill

View file

@ -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 "<field>" path shadows the default <dir>/ 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();