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
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue