inspector/scripts/lib/skill-manifest.test.mjs
cliffhall ca6828916f chore: address Copilot review round 2
- verify-skills: version-gate the optional `claude plugin validate`. Probing
  only for the executable failed the MANDATORY gate for anyone on a CLI older
  than 2.1.233, which has no such subcommand and exits nonzero on it. Skips
  below the floor; a failure now means a supported validator rejected the skills.
- skill-eval: match the invoked skill structurally. A substring test counted
  `{"skill":"not-testing"}` as a hit for `testing` and inflated the rate.
- skill-eval: `runOnce` → exported `runPrompt` with an injectable `spawnFn`, so
  the nonzero-exit branch is actually pinned. Reverting it now fails a test
  (verified by mutation) rather than leaving the suite green.
- board-ops: filter the item lookups by repository. Issue numbers are repo-local
  and these are org boards — #11 really does carry a `servers` card — so an
  unfiltered `select` can move or delete the wrong card, or pass two ids.
- issue-triage: the audit's `own()` filter dropped draft cards, which carry no
  `.content.repository`, so the "non-Issue on a board" check reported 0 while
  the invariant was being violated. Verified against a fixture.
- pr-flow: cut the branch from the base it will target. The blanket "cut from
  v2/main" was wrong for v1, whose history is unrelated.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 01:07:41 -04:00

221 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Table-driven tests for the `.claude/skills` frontmatter contract (#2163),
// one case per rule the parsers encode. The guard that reads them
// (`verify-skills.mjs`) walks the real skills directory, so a rule it enforces
// is only observable there when a skill happens to violate it — which, by
// construction, none does once the guard is green.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
splitFrontmatter,
parseSkill,
validateEvalCases,
listingCost,
parseClaudeVersion,
compareVersions,
PLUGIN_VALIDATE_MIN_VERSION,
} from "./skill-manifest.mjs";
const fm = (body) => `---\n${body}\n---\n\n# Body\n`;
test("splitFrontmatter requires the fence on the very first line", () => {
// This is the whole failure class: anything before the fence and Claude Code
// treats the WHOLE file, `---` markers included, as body content.
for (const text of [
"\n---\nname: x\n---\n",
"---\nname: x\n---\n",
"# Title\n---\nname: x\n---\n",
]) {
assert.match(splitFrontmatter(text).error ?? "", /first line/);
}
assert.equal(splitFrontmatter(fm("name: x")).error, undefined);
});
test("splitFrontmatter requires the closing fence to be a whole line", () => {
// `---oops` is not a terminator. Accepting it would silently truncate the
// frontmatter to whatever preceded it and report the file as valid.
assert.match(
splitFrontmatter("---\nname: x\n---oops\nbody\n").error ?? "",
/never closed/,
);
// Trailing whitespace on the fence is still a fence, and so is EOF.
assert.equal(
splitFrontmatter("---\nname: x\n--- \nbody\n").frontmatter,
"name: x",
);
assert.equal(splitFrontmatter("---\nname: x\n---").frontmatter, "name: x");
assert.equal(
splitFrontmatter("---\nname: x\n---\r\nbody").frontmatter,
"name: x",
);
});
test("splitFrontmatter reports an unterminated block", () => {
assert.match(splitFrontmatter("---\nname: x\n").error ?? "", /never closed/);
});
test("parseSkill accepts a well-formed skill", () => {
const r = parseSkill(
"local-dev",
fm(
"name: local-dev\ndescription: Does a thing.\ndisable-model-invocation: false",
),
);
assert.deepEqual(r.errors, []);
assert.equal(r.modelInvoked, true);
assert.equal(r.userInvocable, true);
});
test("parseSkill rejects frontmatter that is not valid YAML", () => {
// An unquoted colon in a description is enough, and this is exactly what
// silently strips the description Claude matches against.
const r = parseSkill(
"x",
fm(
"name: x\ndescription: Use this: always\ndisable-model-invocation: true",
),
);
assert.equal(r.errors.length, 1);
assert.match(r.errors[0], /not valid YAML/);
});
test("parseSkill requires name to match the directory, kebab-case", () => {
assert.match(
parseSkill(
"board-ops",
fm("name: boardOps\ndescription: d\ndisable-model-invocation: true"),
).errors.join(),
/does not match its directory/,
);
assert.match(
parseSkill(
"Board_Ops",
fm("name: Board_Ops\ndescription: d\ndisable-model-invocation: true"),
).errors.join(),
/kebab-case/,
);
});
test("parseSkill requires a non-empty description", () => {
for (const d of ["", " "]) {
assert.match(
parseSkill(
"x",
fm(`name: x\ndescription: "${d}"\ndisable-model-invocation: true`),
).errors.join(),
/description. is required/,
);
}
});
test("parseSkill caps the description at the listing-entry limit", () => {
const long = "a".repeat(1537);
assert.match(
parseSkill(
"x",
fm(`name: x\ndescription: ${long}\ndisable-model-invocation: true`),
).errors.join(),
/caps a listing entry/,
);
});
test("parseSkill requires the invocation mode to be declared", () => {
assert.match(
parseSkill("x", fm("name: x\ndescription: d")).errors.join(),
/must be declared explicitly/,
);
assert.match(
parseSkill(
"x",
fm("name: x\ndescription: d\ndisable-model-invocation: yes-please"),
).errors.join(),
/must be a boolean/,
);
});
test("parseSkill rejects a skill nothing can reach", () => {
const r = parseSkill(
"x",
fm(
"name: x\ndescription: d\ndisable-model-invocation: true\nuser-invocable: false",
),
);
assert.match(r.errors.join(), /unreachable/);
});
test("parseSkill validates the paths list", () => {
assert.match(
parseSkill(
"x",
fm(
"name: x\ndescription: d\ndisable-model-invocation: false\npaths: '**/*.ts'",
),
).errors.join(),
/list of glob strings/,
);
assert.deepEqual(
parseSkill(
"x",
fm(
"name: x\ndescription: d\ndisable-model-invocation: false\npaths:\n - '**/*.ts'",
),
).paths,
["**/*.ts"],
);
});
test("listingCost counts only the skills that occupy the listing", () => {
const skills = [
{ name: "aa", description: "bbb", modelInvoked: true },
{ name: "cccc", description: "ddddd", modelInvoked: false },
];
assert.equal(listingCost(skills), 5);
});
test("validateEvalCases requires a positive and a negative", () => {
assert.deepEqual(
validateEvalCases("testing", [
{ prompt: "a", expect: "testing" },
{ prompt: "b", expect: null },
]),
[],
);
assert.match(
validateEvalCases("testing", [{ prompt: "a", expect: "testing" }]).join(),
/no negative case/,
);
assert.match(
validateEvalCases("testing", [{ prompt: "a", expect: null }]).join(),
/no positive case/,
);
});
test("validateEvalCases rejects malformed cases", () => {
assert.match(validateEvalCases("x", []).join(), /non-empty array/);
assert.match(
validateEvalCases("x", [{ expect: null }]).join(),
/prompt.*non-empty string/,
);
assert.match(
validateEvalCases("x", [{ prompt: "a", expect: 7 }]).join(),
/expect. must be a skill name or null/,
);
});
test("parseClaudeVersion reads the CLI's version banner", () => {
assert.deepEqual(parseClaudeVersion("2.1.250 (Claude Code)"), [2, 1, 250]);
assert.deepEqual(parseClaudeVersion("claude 10.0.4\n"), [10, 0, 4]);
assert.equal(parseClaudeVersion("no version here"), null);
});
test("compareVersions orders the plugin-validate floor correctly", () => {
// An older CLI has no `plugin validate` and exits nonzero on it as an unknown
// command; failing the mandatory gate for that would be wrong, so the probe
// must skip below the floor rather than run and fail.
assert.ok(compareVersions([2, 1, 232], PLUGIN_VALIDATE_MIN_VERSION) < 0);
assert.equal(compareVersions([2, 1, 233], PLUGIN_VALIDATE_MIN_VERSION), 0);
assert.ok(compareVersions([2, 1, 250], PLUGIN_VALIDATE_MIN_VERSION) > 0);
assert.ok(compareVersions([2, 2, 0], PLUGIN_VALIDATE_MIN_VERSION) > 0);
assert.ok(compareVersions([3], PLUGIN_VALIDATE_MIN_VERSION) > 0);
assert.ok(compareVersions([1, 9, 999], PLUGIN_VALIDATE_MIN_VERSION) < 0);
});