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