feat(plh): validate plugin.json skills:-array entries (CA-PLH-016)
PLH now validates each entry of a plugin.json `skills` field (string|array):
every entry must resolve to an existing directory inside the plugin root.
One finding per bad entry (medium, plugin-hygiene), problem ∈ {non-string,
escapes-root, not-found, not-a-directory}. Mirrors `claude plugin validate`.
String|array normalized so a non-string top-level value is caught too.
Verifiseringsplikt: the plan's "CC suggests the parent directory" error text
is NOT in the primary docs — dropped. Only the four primary-source-verified
conditions are asserted (escape backed by the path-traversal rule).
Tests +3 (941->944). 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
9b63125f6a
commit
76d5eda101
9 changed files with 189 additions and 2 deletions
6
tests/fixtures/plugin-skills-array/.claude-plugin/plugin.json
vendored
Normal file
6
tests/fixtures/plugin-skills-array/.claude-plugin/plugin.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "skills-array-plugin",
|
||||
"description": "Fixture: plugin.json skills: array entry validation (CA-PLH-016)",
|
||||
"version": "1.0.0",
|
||||
"skills": ["./valid-skill/", "./a-file.md", "./missing-dir/", "../escape", 42]
|
||||
}
|
||||
15
tests/fixtures/plugin-skills-array/CLAUDE.md
vendored
Normal file
15
tests/fixtures/plugin-skills-array/CLAUDE.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Skills Array Plugin
|
||||
|
||||
Fixture for CA-PLH-016 skills:-array validation.
|
||||
|
||||
## Commands
|
||||
|
||||
(none)
|
||||
|
||||
## Agents
|
||||
|
||||
(none)
|
||||
|
||||
## Hooks
|
||||
|
||||
(none)
|
||||
1
tests/fixtures/plugin-skills-array/a-file.md
vendored
Normal file
1
tests/fixtures/plugin-skills-array/a-file.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
This is a plain file, not a skill directory. A skills: entry pointing here is invalid.
|
||||
6
tests/fixtures/plugin-skills-array/valid-skill/SKILL.md
vendored
Normal file
6
tests/fixtures/plugin-skills-array/valid-skill/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
name: skills-array-valid-skill
|
||||
description: A valid skill directory referenced from the skills: array
|
||||
---
|
||||
|
||||
# Valid Skill
|
||||
|
|
@ -12,6 +12,7 @@ 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');
|
||||
const SKILLS_ARR = resolve(FIXTURES, 'plugin-skills-array');
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
it('discovers a single plugin when pointed at plugin dir', async () => {
|
||||
|
|
@ -278,6 +279,48 @@ describe('plugin-folder shadowing (CA-PLH-015)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('skills:-array entry validation (CA-PLH-016)', () => {
|
||||
// Title set by the scanner: `plugin.json "skills" entry <problem>: <entry>`.
|
||||
const SKILLS_RE = /^plugin\.json "skills" entry/;
|
||||
|
||||
it('flags one finding per bad entry (file, missing, escape, non-string) and none for a valid dir', async () => {
|
||||
resetCounter();
|
||||
// skills: ["./valid-skill/", "./a-file.md", "./missing-dir/", "../escape", 42]
|
||||
const result = await scan(SKILLS_ARR);
|
||||
const bad = result.findings.filter(f => f.scanner === 'PLH' && SKILLS_RE.test(f.title || ''));
|
||||
assert.equal(bad.length, 4, `Expected 4 bad-entry findings, got ${bad.length}: ${bad.map(f => f.title).join(' | ')}`);
|
||||
for (const f of bad) {
|
||||
assert.equal(f.severity, 'medium', `skills entry problem is medium: ${f.title}`);
|
||||
assert.equal(f.category, 'plugin-hygiene');
|
||||
assert.ok(/plugin\.json$/.test(f.file || ''), `Finding should point at plugin.json: ${f.file}`);
|
||||
assert.ok(f.details && f.details.field === 'skills', 'details.field should be "skills"');
|
||||
}
|
||||
const problems = bad.map(f => f.details.problem).sort();
|
||||
assert.deepEqual(
|
||||
problems,
|
||||
['escapes-root', 'non-string', 'not-a-directory', 'not-found'],
|
||||
`Each problem type should appear once; got ${problems.join(',')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT flag the valid skill directory', async () => {
|
||||
resetCounter();
|
||||
const result = await scan(SKILLS_ARR);
|
||||
const validFlagged = result.findings.some(f =>
|
||||
f.scanner === 'PLH' && SKILLS_RE.test(f.title || '') && /valid-skill/.test(f.title || '')
|
||||
);
|
||||
assert.equal(validFlagged, false, './valid-skill/ is an existing directory → not flagged');
|
||||
});
|
||||
|
||||
it('does NOT flag a plugin with no skills: key', async () => {
|
||||
resetCounter();
|
||||
// test-plugin declares no skills: field.
|
||||
const result = await scan(TEST_PLUGIN);
|
||||
const skillsFindings = result.findings.filter(f => f.scanner === 'PLH' && SKILLS_RE.test(f.title || ''));
|
||||
assert.equal(skillsFindings.length, 0, 'No skills: key → no skills-entry findings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finding format', () => {
|
||||
it('findings have standard fields', async () => {
|
||||
resetCounter();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue