- 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>
362 lines
15 KiB
JavaScript
Executable file
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();
|
|
}
|