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

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