- ciRunsUnconditionally never looked at the workflow-level `on:` trigger, so a
workflow switched to `on: release` still contained the step and reported as
wired while no PR ever ran the validator. It now requires `push` or
`pull_request`, handling all three shapes `on:` takes. The positive fixtures
had no trigger at all, so they could not assert the "on every push" half of
the contract; they carry one now, alongside a release-only negative. Verified
by mutation.
- Stale JSDoc from the round-16 version change: `probeClaudeVersion` is now
generic in its parser's result rather than hard-coding `number[]`,
`validatorCommand` documents `{ parts, prerelease }`, and `runValidator`
documents the `platform` option it already accepted. Annotations that lie make
valid call sites look type-invalid.
Signed-off-by: cliffhall <cliff@futurescale.com>
129 lines
4.7 KiB
JavaScript
129 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// Runs `claude plugin validate` — the AUTHORITATIVE skill schema — as a
|
|
// guaranteed step, resolving the CLI rather than hoping it is installed (#2163).
|
|
//
|
|
// `verify:skills` (inside `validate`) reimplements the parse and treats a
|
|
// missing CLI as a skip, deliberately: `npm run validate` is the fast inner
|
|
// loop, it must work offline, and a Node contributor should not need Claude Code
|
|
// installed to run it. But "skips when absent" plus "usually absent" adds up to
|
|
// "never runs", which made the acceptance criterion aspirational (Copilot).
|
|
//
|
|
// So the authoritative check gets its own step, in `local:gate` and in CI, and
|
|
// it does NOT skip: an already-installed CLI is used only when it matches the
|
|
// pin EXACTLY, and otherwise the pinned package is fetched with `npx -y`.
|
|
// Pinned rather than @latest because a validator that moves on its own can start
|
|
// failing a PR that changed nothing, which is how a gate loses its credibility —
|
|
// and exact rather than a floor because a newer local CLI is a DIFFERENT schema
|
|
// from CI's, which would let the same `local:gate` disagree across machines.
|
|
//
|
|
// It needs no authentication — verified against a scrubbed environment and a
|
|
// clean HOME.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { probeClaudeVersion } from "./lib/claude-cli.mjs";
|
|
import { winShellArgs } from "./lib/win-shell-args.mjs";
|
|
import {
|
|
formatClaudeVersion,
|
|
isPinnedVersion,
|
|
parseClaudeVersion,
|
|
PINNED_CLI_VERSION,
|
|
} from "./lib/skill-manifest.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const SKILLS_DIR = ".claude/skills";
|
|
|
|
/**
|
|
* The argv that runs the pinned validator.
|
|
*
|
|
* A local CLI is used only when it matches `PINNED_CLI_VERSION` **exactly** —
|
|
* same release triple and no prerelease. Accepting anything at or above a floor
|
|
* would defeat the pin: a maintainer on a different CLI would validate against a
|
|
* different schema than CI's, so the same `local:gate` could disagree across
|
|
* machines, which is the failure a pin exists to prevent (Copilot). Everyone
|
|
* else runs the pinned package.
|
|
*
|
|
* @param {{ version: { parts: number[], prerelease: string | null } | null }} local
|
|
* @returns {{ command: string, args: string[], via: string }}
|
|
*/
|
|
export function validatorCommand({ version }) {
|
|
if (isPinnedVersion(version, PINNED_CLI_VERSION)) {
|
|
return {
|
|
command: "claude",
|
|
args: ["plugin", "validate", SKILLS_DIR],
|
|
via: `local claude ${formatClaudeVersion(version)} (matches the pin)`,
|
|
};
|
|
}
|
|
const why =
|
|
version === null
|
|
? "no local CLI"
|
|
: `local CLI is ${formatClaudeVersion(version)}, not the pinned ${PINNED_CLI_VERSION}`;
|
|
return {
|
|
command: "npx",
|
|
args: [
|
|
"-y",
|
|
`@anthropic-ai/claude-code@${PINNED_CLI_VERSION}`,
|
|
"plugin",
|
|
"validate",
|
|
SKILLS_DIR,
|
|
],
|
|
via: `npx @anthropic-ai/claude-code@${PINNED_CLI_VERSION} (${why})`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run the authoritative validator.
|
|
*
|
|
* The probe and the spawn are injected so the orchestration itself is tested:
|
|
* on a machine with a working CLI, an actual run only ever walks the happy
|
|
* path, so a regression in the probe, the spawn-error branch, or the
|
|
* propagation of a rejected validator would leave `test:scripts` green while
|
|
* this gate quietly stopped gating (Copilot).
|
|
*
|
|
* @param {{ probe?: () => { parts: number[], prerelease: string | null } | null,
|
|
* spawn?: (cmd: string, args: string[], opts: object) => {error?: Error, status?: number|null},
|
|
* log?: (msg: string) => void, error?: (msg: string) => void,
|
|
* platform?: string }} [io]
|
|
* @returns {number} Process exit code.
|
|
*/
|
|
export function runValidator(io = {}) {
|
|
const {
|
|
probe = () => probeClaudeVersion(parseClaudeVersion),
|
|
spawn = (cmd, args, opts) => spawnSync(cmd, args, opts),
|
|
log = console.log,
|
|
error = console.error,
|
|
platform = process.platform,
|
|
} = io;
|
|
|
|
const version = probe();
|
|
|
|
const { command, args, via } = validatorCommand({ version });
|
|
log(`verify:skills:cli — validating ${SKILLS_DIR} via ${via}…`);
|
|
|
|
const res = spawn(command, winShellArgs(args, platform), {
|
|
cwd: ROOT,
|
|
stdio: "inherit",
|
|
shell: platform === "win32",
|
|
});
|
|
if (res.error) {
|
|
error(
|
|
`\nverify:skills:cli — could not run \`${command}\`: ${res.error.message}`,
|
|
);
|
|
return 1;
|
|
}
|
|
if (res.status !== 0) {
|
|
error(
|
|
"\nverify:skills:cli — `claude plugin validate` rejected the skills.",
|
|
);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
process.exit(runValidator());
|
|
}
|