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

@ -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();