- verify:skills:cli — the authoritative validator is now a GUARANTEED step in `local:gate` and CI, not a best-effort hand-off. It resolves the CLI itself (an installed one at 2.1.233+, else a pinned `npx -y`), so both tiers run the same script and cannot drift. `verify:skills` keeps skipping when the CLI is absent, which is right for `validate`: fast, offline, no Claude Code required. - skill-manifest: the 1,536-char listing-entry cap covers `description` + `when_to_use` together; checking the description alone passed an over-cap entry. `when_to_use` is now parsed, validated and counted, with boundary tests at 1,536/1,537. - verify-skills: report the budget as *this repo's contribution*. The real listing budget is shared with bundled skills and the contributor's own ~/.claude/skills, none of which is visible from here, so the number was claiming more than it measured. - skill-eval: a negative case now means "no skill OF THIS REPO'S fired". It failed on any Skill invocation, so a contributor with an unrelated global skill got a false failure about their environment rather than these skills. - docs/architecture + local-dev: the Node backend runner gets its `@inspector/core` alias from `clients/web/tsup.runner.config.ts`, not from Vite. The old text sent runner work to the wrong config. - local-dev: the tarball does not ship "only each client's build/" — the allowlist also carries `clients/web/dist`, `clients/web/static` and the install helper. States the invariant that actually matters instead. - issue-triage: the test for needing triage is "no board card". Adding "and no milestone" would skip the approved-but-unboarded case the same section then handles. Also corrected the `$M` note: the lookup is `--state all`, so closed issues DO have entries — `isopen` is about the invariants, not the lookup. - issue-create: replaced "all five" with a per-version table. Steps 3 and 6 (milestone, Priority) do not exist for v1, so the old list opened with a rule the same skill later tells you to violate. Signed-off-by: cliffhall <cliff@futurescale.com>
177 lines
6.2 KiB
JavaScript
177 lines
6.2 KiB
JavaScript
// Fixture-level tests for `verify-skills`'s orchestration (#2163, Copilot).
|
|
//
|
|
// `skill-manifest.test.mjs` covers the parsers; this covers `main()` — that the
|
|
// guard actually *reports* what those parsers find, and exits nonzero when it
|
|
// does. Without it, the mandatory gate could stop enforcing its checks while
|
|
// every parser test stayed green, which is the same "a gate that stops gating"
|
|
// failure the sibling guards' `*.main.test.mjs` suites exist to prevent.
|
|
//
|
|
// Each case builds a throwaway skills directory and runs the real script
|
|
// against it via the directory argument. Run via `npm run test:scripts`.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SCRIPT = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"verify-skills.mjs",
|
|
);
|
|
|
|
/**
|
|
* Build a skills directory.
|
|
*
|
|
* @param {Record<string, {skill?: string, evals?: string}>} skills
|
|
*/
|
|
function fixture(skills) {
|
|
const root = mkdtempSync(path.join(tmpdir(), "verify-skills-"));
|
|
for (const [name, { skill, evals }] of Object.entries(skills)) {
|
|
const dir = path.join(root, name);
|
|
mkdirSync(dir, { recursive: true });
|
|
if (skill !== undefined) writeFileSync(path.join(dir, "SKILL.md"), skill);
|
|
if (evals !== undefined) {
|
|
mkdirSync(path.join(dir, "evals"), { recursive: true });
|
|
writeFileSync(path.join(dir, "evals", "evals.json"), evals);
|
|
}
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function run(dir) {
|
|
const res = spawnSync(process.execPath, [SCRIPT, dir], { encoding: "utf8" });
|
|
return { code: res.status, out: (res.stdout ?? "") + (res.stderr ?? "") };
|
|
}
|
|
|
|
const byName = (name, extra = "") =>
|
|
`---\nname: ${name}\ndescription: A description.\ndisable-model-invocation: true\n${extra}---\n\nBody\n`;
|
|
|
|
const modelInvoked = (name) =>
|
|
`---\nname: ${name}\ndescription: A description.\ndisable-model-invocation: false\n---\n\nBody\n`;
|
|
|
|
const goodEvals = (name) =>
|
|
JSON.stringify([
|
|
{ prompt: "fires", expect: name },
|
|
{ prompt: "does not", expect: null },
|
|
]);
|
|
|
|
test("passes a well-formed directory", () => {
|
|
const dir = fixture({
|
|
alpha: { skill: byName("alpha") },
|
|
beta: { skill: modelInvoked("beta"), evals: goodEvals("beta") },
|
|
});
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 0, out);
|
|
assert.match(out, /2 skills, 1 model-invoked \(beta\)/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails an empty or missing directory", () => {
|
|
const empty = mkdtempSync(path.join(tmpdir(), "verify-skills-empty-"));
|
|
assert.match(run(empty).out, /no skills found/);
|
|
assert.equal(run(empty).code, 1);
|
|
assert.equal(run(path.join(empty, "nope")).code, 1);
|
|
rmSync(empty, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails a skill directory with no SKILL.md", () => {
|
|
const dir = fixture({ alpha: {} });
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /alpha: no SKILL\.md/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails frontmatter that would load with empty metadata", () => {
|
|
// The failure the guard exists for: `/alpha` still works, so a spot check
|
|
// passes, while the description the model matches against is gone.
|
|
const dir = fixture({
|
|
alpha: {
|
|
skill:
|
|
"---\nname: alpha\ndescription: Use this: always\ndisable-model-invocation: true\n---\n",
|
|
},
|
|
});
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /not valid YAML/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails a skill that does not declare its invocation mode", () => {
|
|
const dir = fixture({
|
|
alpha: { skill: "---\nname: alpha\ndescription: d\n---\n" },
|
|
});
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /must be declared explicitly/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails a model-invoked skill with no eval cases", () => {
|
|
const dir = fixture({ beta: { skill: modelInvoked("beta") } });
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /need committed eval cases/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fails eval cases that are unreadable, all-positive, or all-negative", () => {
|
|
for (const [evals, pattern] of [
|
|
["{ not json", /not valid JSON/],
|
|
[JSON.stringify([{ prompt: "a", expect: "beta" }]), /no negative case/],
|
|
[JSON.stringify([{ prompt: "a", expect: null }]), /no positive case/],
|
|
[JSON.stringify([{ expect: null }]), /prompt/],
|
|
]) {
|
|
const dir = fixture({ beta: { skill: modelInvoked("beta"), evals } });
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1, out);
|
|
assert.match(out, pattern);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("fails when the skill listing exceeds its budget", () => {
|
|
// Only model-invoked skills occupy the listing, so the same text behind
|
|
// `disable-model-invocation: true` must NOT trip it.
|
|
const long = "d".repeat(1500);
|
|
const many = {};
|
|
for (let i = 0; i < 4; i++) {
|
|
many[`skill-${i}`] = {
|
|
skill: `---\nname: skill-${i}\ndescription: ${long}\ndisable-model-invocation: false\n---\n`,
|
|
evals: goodEvals(`skill-${i}`),
|
|
};
|
|
}
|
|
const over = fixture(many);
|
|
const { code, out } = run(over);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /over its recorded budget/);
|
|
rmSync(over, { recursive: true, force: true });
|
|
|
|
const quiet = {};
|
|
for (let i = 0; i < 4; i++) {
|
|
quiet[`skill-${i}`] = {
|
|
skill: `---\nname: skill-${i}\ndescription: ${long}\ndisable-model-invocation: true\n---\n`,
|
|
};
|
|
}
|
|
const under = fixture(quiet);
|
|
assert.equal(run(under).code, 0);
|
|
rmSync(under, { recursive: true, force: true });
|
|
});
|
|
|
|
test("reports every offender in one pass rather than dying on the first", () => {
|
|
const dir = fixture({
|
|
alpha: {},
|
|
beta: { skill: modelInvoked("beta") },
|
|
gamma: {
|
|
skill:
|
|
"---\nname: wrong-name\ndescription: d\ndisable-model-invocation: true\n---\n",
|
|
},
|
|
});
|
|
const { code, out } = run(dir);
|
|
assert.equal(code, 1);
|
|
assert.match(out, /3 problem\(s\)/);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|