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:
Kjell Tore Guttormsen 2026-06-19 21:56:57 +02:00
commit 76d5eda101
9 changed files with 189 additions and 2 deletions

View 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]
}

View file

@ -0,0 +1,15 @@
# Skills Array Plugin
Fixture for CA-PLH-016 skills:-array validation.
## Commands
(none)
## Agents
(none)
## Hooks
(none)

View file

@ -0,0 +1 @@
This is a plain file, not a skill directory. A skills: entry pointing here is invalid.

View file

@ -0,0 +1,6 @@
---
name: skills-array-valid-skill
description: A valid skill directory referenced from the skills: array
---
# Valid Skill

View file

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