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