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:
parent
c6992cad57
commit
7abc5a1dcb
10 changed files with 217 additions and 1 deletions
20
CLAUDE.md
20
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
|
||||
|
|
|
|||
13
README.md
13
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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
9
tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json
vendored
Normal file
9
tests/fixtures/plugin-shadow-folder/.claude-plugin/plugin.json
vendored
Normal 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/"
|
||||
}
|
||||
21
tests/fixtures/plugin-shadow-folder/CLAUDE.md
vendored
Normal file
21
tests/fixtures/plugin-shadow-folder/CLAUDE.md
vendored
Normal 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) | — | — |
|
||||
8
tests/fixtures/plugin-shadow-folder/agents/real.md
vendored
Normal file
8
tests/fixtures/plugin-shadow-folder/agents/real.md
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: shadow-fixture-agent
|
||||
description: An agent in the default agents/ folder
|
||||
model: sonnet
|
||||
tools: ["Read"]
|
||||
---
|
||||
|
||||
# Shadow Fixture Agent
|
||||
8
tests/fixtures/plugin-shadow-folder/commands/real.md
vendored
Normal file
8
tests/fixtures/plugin-shadow-folder/commands/real.md
vendored
Normal 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
|
||||
6
tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md
vendored
Normal file
6
tests/fixtures/plugin-shadow-folder/skills/some-skill/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: shadow-fixture-skill
|
||||
description: A skill in the default skills/ folder
|
||||
---
|
||||
|
||||
# Shadow Fixture Skill
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue