inspector/scripts/verify-skills.mjs
cliffhall 9cff77e6d9 fix: address Copilot review round 4
- Reword the CI guarantee in AGENTS.md, docs/quality-gate.md and the
  pre-push-gate skill: a green gate means every check CI applies has
  passed here — the strongest predictor of green CI, not a proof — and
  name the residual (a test that passes only when slowed) as #1596's
  class to fix rather than headroom to keep.
- docs/quality-gate.md: cli's validate DID build, through `test`'s
  `pretest` hook; say that the build goes with the dropped `test` leg and
  `coverage:cli` builds once, later — in both places.
- Update the two source comments that still described `local:gate` as
  running `validate` (verify-skills.mjs, verify-build-gate.mjs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 20:25:00 -04:00

362 lines
15 KiB
JavaScript
Executable file

#!/usr/bin/env node
// Durable guard for `.claude/skills/**/SKILL.md` (#2163).
//
// `AGENTS.md` is loaded in full on every turn, so its rules are unconditionally
// present. A skill is conditional — its body loads only when it is invoked — so
// if a skill stops being reachable we lose behavior SILENTLY, and nothing in the
// normal workflow surfaces that. This is the check that does.
//
// What it asserts, cheapest first:
// 1. Every SKILL.md's frontmatter parses the way Claude Code parses it. This
// is the whole-class failure: malformed YAML (or a fence that is not the
// first line) loads the body with EMPTY metadata, so `/name` still works —
// a manual spot check passes — while the description the model matches
// against is gone and the skill never auto-fires again.
// 2. Every skill declares its invocation mode explicitly, so the set that has
// to fire on its own is knowable by reading the files rather than inferred.
// 3. Every model-invoked skill has committed eval cases, including negatives.
// Cases are run by `npm run skills:eval`, which needs the Claude CLI and is
// therefore NOT in the gate; the cases existing is what this checks.
// 4. The skill LISTING stays inside its recorded character budget. Claude Code
// truncates the listing when it overflows, dropping the least-invoked
// entries first — which are exactly the model-invoked skills.
//
// It additionally runs `claude plugin validate` when that CLI is on PATH. That
// is the authoritative parse, but it cannot be a requirement: the CLI is absent
// in CI and on a contributor's machine, and a guard that skips itself wherever
// it actually runs is worse than one that reimplements the check.
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { claudeSpawnArgs, probeClaudeVersion } from "./lib/claude-cli.mjs";
import { rootReachesScript, scriptChainRuns } from "./lib/npm-scripts.mjs";
import { parse as parseYaml } from "yaml";
import {
formatClaudeVersion,
isPinnedVersion,
parseClaudeVersion,
PINNED_CLI_VERSION,
parseSkill,
validateEvalCases,
listingCost,
LISTING_BUDGET,
} from "./lib/skill-manifest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_SKILLS_DIR = path.join(ROOT, ".claude", "skills");
/** Directory names under `.claude/skills` that contain a SKILL.md. */
export function skillDirs(
dir,
{ readdir = readdirSync, stat = statSync } = {},
) {
return readdir(dir)
.filter((n) => !n.startsWith("."))
.filter((n) => stat(path.join(dir, n)).isDirectory())
.sort();
}
/**
* Whether a shell script invokes exactly `command`.
*
* A substring test is not enough, and my own justification for one was wrong:
* it accepts a LONGER script name (`npm run verify:skills:cli:disabled`) and a
* script merely named by another command (`echo npm run verify:skills:cli`),
* either of which would let the real validator be removed while this guard
* stayed green (Copilot).
*
* So the script is split on the separators that start a new command, and a
* segment must equal the invocation exactly. That is precise for the commands
* this guard checks, which take no arguments; a command that did would need
* prefix matching with an argument boundary instead.
*
* Splitting is on `\n`, `;` and `&&` only — deliberately NOT on `|` or a bare
* `&`. `||` is the failure-masking form, and both of its shapes must be
* rejected: `npm run X || true` swallows a rejection so CI stays green, and
* `true || npm run X` never runs the validator at all (Copilot). Leaving `||`
* inside the segment means the segment does not equal the invocation, so both
* are refused without special-casing either.
*
* @param {string} script A step's `run:` block.
* @param {string} command e.g. `npm run verify:skills:cli`.
*/
export function runsCommand(script, command) {
return script
.split(/\n|;|&&/)
.map((segment) => segment.trim())
.some((segment) => segment === command);
}
/**
* Whether an Actions boolean-ish value means "yes".
*
* YAML gives `true`, but a workflow may equally write `"true"` — and an
* expression (`${{ … }}`) is unevaluable here, so it is treated as masking on
* the same conservative principle the `if:` checks use.
*
* @param {unknown} value
*/
function isTruthy(value) {
if (value === undefined || value === false) return false;
return value !== "false";
}
/**
* Whether a workflow's `on:` includes an event that fires for ordinary changes.
*
* `on:` takes three shapes — a bare string, a list, or a map of event names.
* Note the YAML 1.1 `on` → `true` pitfall does not apply here: the `yaml`
* package parses to the 1.2 core schema, where the key stays the string `on`.
*
* @param {unknown} on
*/
function triggersOnPush(on) {
const events =
typeof on === "string"
? [on]
: Array.isArray(on)
? on
: on !== null && typeof on === "object"
? Object.keys(on)
: [];
return events.some((e) => e === "push" || e === "pull_request");
}
/**
* Whether the workflow has a step that runs `command` on every push.
*
* Four things must hold, and the guard has needed each one added in turn:
* the WORKFLOW must fire on a push or pull_request (one switched to `on:
* release` still contains the step but never runs it on a PR), the JOB must be
* unconditional, so must the STEP, and neither may carry `continue-on-error` —
* a step whose failure cannot fail the job asserts nothing, so a rejected skill
* would leave the workflow green (Copilot).
*
* Deliberately conservative: a job or step carrying **any** `if:` is skipped.
* This guard cannot evaluate an Actions expression, and the property being
* asserted is "CI validates the skills on a PR" — which a conditional step does
* not establish. Over-strictness costs a false failure that is obvious to fix;
* under-strictness costs a guard that reports green while nothing runs.
*
* @param {string} workflowText
* @param {string} command
* @returns {boolean}
*/
export function ciRunsUnconditionally(workflowText, command) {
let doc;
try {
doc = parseYaml(workflowText);
} catch {
return false;
}
if (!triggersOnPush(doc?.on)) return false;
const jobs = doc?.jobs;
if (jobs === null || typeof jobs !== "object") return false;
for (const job of Object.values(jobs)) {
if (job === null || typeof job !== "object") continue;
if ("if" in job || isTruthy(job["continue-on-error"])) continue;
const steps = Array.isArray(job.steps) ? job.steps : [];
for (const step of steps) {
if (step === null || typeof step !== "object") continue;
if ("if" in step || isTruthy(step["continue-on-error"])) continue;
if (typeof step.run === "string" && runsCommand(step.run, command))
return true;
}
}
return false;
}
/**
* Assert the two skill gates are still WIRED, not merely present.
*
* A gate that stops being invoked fails silently and every fixture test stays
* green — which is the same failure the sibling guards' vouch cycle exists to
* prevent, so this joins it (Copilot). Two distinct links:
*
* - `verify:skills` (this file) must be reachable from the root `validate`.
* It cannot detect its own absence, so `verify:format-coverage` vouches for
* it and this vouches back — the established cycle.
* - `verify:skills:cli` must be in `local:gate` AND in the workflow. That is
* the step that actually runs the authoritative validator, and it is
* reachable from neither `validate` nor any test, so nothing else would
* notice it going missing.
*
* The workflow half must find a step that **actually runs on a PR**, which takes
* two narrowings beyond a text search:
*
* - Executable position. A raw substring match is satisfied by a comment, a
* step `name:`, or a workflow input default that merely mentions the
* command — so deleting the real step while leaving this docblock's wording
* nearby would keep the guard green.
* - Unconditional execution. A `run:` inside a release-only job, or behind
* `if: false`, is still syntactically a `run:` — so a step moved there
* would report as wired while PR CI validated nothing (Copilot). Any `if:`
* on the job or the step disqualifies it: this guard cannot evaluate an
* expression, and "runs sometimes" is not the property being asserted.
*/
export function checkWiring(rootScripts, workflowText) {
const problems = [];
if (!rootReachesScript(rootScripts, "verify:format-coverage")) {
problems.push(
"the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.",
);
}
if (!scriptChainRuns(rootScripts, "local:gate", "verify:skills:cli")) {
problems.push(
"`npm run local:gate` no longer runs `verify:skills:cli`, so the authoritative validator never runs locally. Restore it.",
);
}
if (!ciRunsUnconditionally(workflowText, "npm run verify:skills:cli")) {
problems.push(
"`.github/workflows/main.yml` has no unconditional step that runs `npm run verify:skills:cli`, so the authoritative validator does not run on a PR. Restore it (a step behind an `if:`, or in a release-only job, does not count).",
);
}
return problems;
}
function main(argv = process.argv.slice(2)) {
// An explicit directory is the seam the fixture tests drive; without it this
// guard could only ever be exercised against the repo's own (green) skills,
// so it could stop enforcing anything while every unit test stayed green.
const override = argv[0];
const SKILLS_DIR = override ? path.resolve(override) : DEFAULT_SKILLS_DIR;
const failures = [];
// Only meaningful against the real repo; a fixture run has no wiring.
if (!override) {
const rootScripts = JSON.parse(
readFileSync(path.join(ROOT, "package.json"), "utf8"),
).scripts;
const workflow = readFileSync(
path.join(ROOT, ".github", "workflows", "main.yml"),
"utf8",
);
for (const problem of checkWiring(rootScripts, workflow)) {
failures.push(problem);
}
}
if (!existsSync(SKILLS_DIR)) {
console.error(
`verify:skills — ${path.relative(ROOT, SKILLS_DIR)} does not exist.`,
);
process.exit(1);
}
const dirs = skillDirs(SKILLS_DIR);
if (dirs.length === 0) {
console.error("verify:skills — no skills found; the skill set is gone.");
process.exit(1);
}
const parsed = [];
const evalFiles = [];
const modelInvokedDirs = new Set();
for (const dir of dirs) {
const file = path.join(SKILLS_DIR, dir, "SKILL.md");
if (!existsSync(file)) {
failures.push(`${dir}: no SKILL.md`);
continue;
}
const skill = parseSkill(dir, readFileSync(file, "utf8"));
for (const e of skill.errors) failures.push(`${dir}/SKILL.md: ${e}`);
if (skill.errors.length > 0) continue;
parsed.push(skill);
if (skill.modelInvoked) {
modelInvokedDirs.add(dir);
const evalsFile = path.join(SKILLS_DIR, dir, "evals", "evals.json");
if (!existsSync(evalsFile)) {
failures.push(
`${dir}: model-invoked skills need committed eval cases at evals/evals.json`,
);
continue;
}
let cases;
try {
cases = JSON.parse(readFileSync(evalsFile, "utf8"));
} catch (e) {
failures.push(`${dir}/evals/evals.json: not valid JSON — ${e.message}`);
continue;
}
evalFiles.push({ dir, cases });
}
}
// Validated after the loop, not inside it: a hand-off case names other
// skills, and checking those links needs the model-invoked set complete.
// Inside the loop the check would depend on directory order — `test-servers`
// sorts before `testing`, so a chain through `testing` would be rejected as
// unknown purely because of where the alphabet put it.
// Keyed on the DIRECTORY name, which is what a chain link names and what a
// skill is addressed by; the frontmatter `name` can be absent or disagree,
// and either would silently shrink the set a chain is checked against.
for (const { dir, cases } of evalFiles) {
for (const e of validateEvalCases(dir, cases, modelInvokedDirs)) {
failures.push(`${dir}/evals/evals.json: ${e}`);
}
}
const cost = listingCost(parsed);
if (cost > LISTING_BUDGET) {
failures.push(
`this repo contributes ${cost} chars to the skill listing, over its recorded ` +
`budget of ${LISTING_BUDGET}. Tighten a description, or raise the budget ` +
"deliberately in scripts/lib/skill-manifest.mjs — but note the real listing " +
"budget is SHARED with bundled skills and the contributor's own " +
"~/.claude/skills, none of which is visible from here.",
);
}
if (failures.length > 0) {
console.error(`verify:skills — ${failures.length} problem(s):\n`);
for (const f of failures) console.error(" " + f);
console.error("\nSee AGENTS.md → Maintaining the skills.");
process.exit(1);
}
const modelInvoked = parsed.filter((s) => s.modelInvoked).map((s) => s.name);
console.log(
`verify:skills — OK: ${parsed.length} skills, ${modelInvoked.length} model-invoked ` +
`(${modelInvoked.join(", ")}); this repo's listing share ${cost}/${LISTING_BUDGET} chars.`,
);
if (override) return;
// The authoritative parse, when it is available — strictly a bonus, and only
// from the EXACT pinned CLI.
//
// `local:gate` runs `local:validate` (and therefore this) before the pinned
// `verify:skills:cli` step, so a local CLI on any other version could reject
// skills that CI's pinned validator accepts — and the gate would exit here,
// never reaching the reproducible step (Copilot). Accepting merely "new enough"
// would reintroduce exactly the cross-machine schema drift the pin removes.
// Anything else is left to `verify:skills:cli`, which fetches the pin.
const version = probeClaudeVersion(parseClaudeVersion);
if (!isPinnedVersion(version, PINNED_CLI_VERSION)) {
console.log(
`verify:skills — local \`claude\` is ${formatClaudeVersion(version)}, not the ` +
`pinned ${PINNED_CLI_VERSION}; \`verify:skills:cli\` runs the pinned validator.`,
);
return;
}
const { command, args, options } = claudeSpawnArgs(
["plugin", "validate", ".claude/skills"],
{ cwd: ROOT, stdio: "inherit" },
);
if (spawnSync(command, args, options).status !== 0) {
console.error("\nverify:skills — `claude plugin validate` failed.");
process.exit(1);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}