The CI half of the wiring vouch matched the workflow's raw text, so a YAML comment, a step `name:`, or an unused anchor mentioning `npm run verify:skills:cli` satisfied it — including this guard's own docblock wording. Deleting the real step would then have left `verify:skills` green, which is the vacuous-guard failure the vouch exists to prevent. It now reads the workflow's executable regions via `extractExecutableRegions`, the same helper the #2146 workflow gate is built on, and matches only a `run:`. Covered by three should-not-count cases (comment, step name, workflow input) plus a multi-line `run:` that must count; verified by mutation — restoring the substring check fails them. Signed-off-by: cliffhall <cliff@futurescale.com>
243 lines
9.7 KiB
JavaScript
Executable file
243 lines
9.7 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 { reachableScripts, rootReachesScript } from "./lib/npm-scripts.mjs";
|
|
import { extractExecutableRegions } from "./lib/workflow-gate.mjs";
|
|
import {
|
|
compareVersions,
|
|
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");
|
|
|
|
/** `PINNED_CLI_VERSION` as a comparable triple. */
|
|
const PINNED_VERSION_PARTS = parseClaudeVersion(PINNED_CLI_VERSION) ?? [];
|
|
|
|
/** 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();
|
|
}
|
|
|
|
/**
|
|
* 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 reads the **executable** regions rather than the file's
|
|
* text. A raw substring match is satisfied by a comment, a step `name:`, or an
|
|
* unused anchor that merely mentions the command — so deleting the real step
|
|
* while leaving this very docblock's wording nearby would keep the guard green
|
|
* (Copilot). `extractExecutableRegions` already descends only the schema paths
|
|
* Actions actually runs, with aliases resolved, and it is the same helper the
|
|
* #2146 workflow gate is built on.
|
|
*/
|
|
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 (!reachableScripts(rootScripts, "local:gate").has("verify:skills:cli")) {
|
|
problems.push(
|
|
"`npm run local:gate` no longer runs `verify:skills:cli`, so the authoritative validator never runs locally. Restore it.",
|
|
);
|
|
}
|
|
const runsInCi = extractExecutableRegions(workflowText, "main.yml").some(
|
|
(region) =>
|
|
region.kind === "run" &&
|
|
/\bnpm run verify:skills:cli\b/.test(region.text),
|
|
);
|
|
if (!runsInCi) {
|
|
problems.push(
|
|
"`.github/workflows/main.yml` has no step that runs `npm run verify:skills:cli`, so the authoritative validator never runs in CI. Restore it.",
|
|
);
|
|
}
|
|
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 = [];
|
|
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) {
|
|
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;
|
|
}
|
|
for (const e of validateEvalCases(dir, cases)) {
|
|
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 `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 (version === null) {
|
|
console.log(
|
|
"verify:skills — no usable `claude` CLI here; `verify:skills:cli` runs the pinned validator.",
|
|
);
|
|
return;
|
|
}
|
|
if (compareVersions(version, PINNED_VERSION_PARTS) !== 0) {
|
|
console.log(
|
|
`verify:skills — local \`claude\` is ${version.join(".")}, 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();
|
|
}
|