mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
7693c71b96
Two constructions, and one live bug that a red-team of #186/#187 turned up. **THE BUG: #173 was fixed in one compiler out of five.** `compileClaudeToFile` stopped writing on error. The identical three lines — write, THEN check errors — sat unchanged in the skill, subagent, railway and generator compilers, and all four stamped unconditionally. Worse than the original: `spec-refs` skips non-`claude` specs, so there was no lint-side backstop either. Reproduced on the built CLI: $ vigiles compile skills/demo/SKILL.md.spec.ts ✗ [stale-file] File not found: "docs/never-existed.md" skills/demo/SKILL.md — 256 bytes, stamped $ vigiles lint . ✓ skills/demo/SKILL.md — hash valid # exit 0 So the comment closing #173 was right about `CLAUDE.md` and wrong about scope. Guarding four more write sites would leave the class writable — the sixth compiler gets written the same way. `addHash` now mints a branded `StampedMarkdown`, `seal()` hands it out only when `errors` is empty, and `writeArtifact` accepts nothing else. `markdown` stays a plain string because `adoptDiff` legitimately wants the draft to diff against even when a ref is stale. An erroring compile has no stamp to give the writer. **Two constructions from the earlier analysis, kept:** `RepoRelativePath` — an out-of-repo `.gitignore` entry (#176.8) cannot be constructed, so the write-site guard is deleted rather than duplicated. `Record<BlockMechanism, …>` — the block mechanisms are a closed table. `decideHook` was three `||` terms while `HookOutput` declared a fourth nothing read (#174); a `||` chain has no shape that can be incomplete. `blockedBy` reports what fired and `haltsTurn` derives from it, so the next mechanism needs no new boolean. Also removed a stale `continue` in `rule-meta.test.ts` — `orphan-docs` was exempted as "built-in, not in RulesConfig", true when written and false since it moved into `RulesConfig`, and it excused the rule from the severity comparison. **A third construction was written, measured, and REMOVED.** A textual check for "every declared rule's severity is read somewhere" — the #181 class. Five iterations, each fixing what the last missed, and the third is why it is gone: with every real read of `orphan-docs` deleted, the suite stayed GREEN, kept alive by an unrelated `new Set(["orphan-docs"])` in `linters.ts`. It was green-and-dead for the rule that motivated it. The fourth iteration's own comment-stripper then ate real code, because `cli.ts` holds glob strings whose slash-star opens a comment that closes somewhere else. The reasoning is recorded in the file, along with the structural fix that belongs in its own PR: the ~30 `check*` functions already share one signature and are a registry nobody wrote down. `Record<RuleName, RuleRunner>` makes a rule without a runner a tsc error and collapses `lintExitCode`'s hand-maintained 31-line `||` list — which no textual check can see anyway. BREAKING CHANGE: `addHash` returns `StampedMarkdown` rather than `string`, and the four compile results gain a required `artifact`. Reading a result is unaffected; code that constructs one must supply it. Four mutations, each landing and failing on its own assertion: write-before-check restored in the skill compiler; `seal` handing out a stamp on errors; `repoRelative` no longer refusing escapes; a mechanism dropped from the table. ⚠️ The first of those came back green on its first run — it landed on the first of four identical blocks rather than the skill compiler. Third time this session that "prove the patch landed" earned its keep. Claude-Session: https://claude.ai/code/session_01PrS34j2M8XR12P2QHaBer4 Co-authored-by: Claude <noreply@anthropic.com>
198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
/**
|
|
* `compile` must not mint a hash-valid artifact over refs it just called dead
|
|
* (#173) — driven through the REAL built CLI, because the defect is in what the
|
|
* command LEAVES ON DISK, which no unit test of `compileClaude` can observe.
|
|
*
|
|
* The reported repro, verbatim in shape: a spec naming a file and an npm script
|
|
* that do not exist. `compile` printed both errors and exited 1 — and still
|
|
* wrote `CLAUDE.md`, stamped with a valid integrity hash. `lint` verifies that
|
|
* hash, finds it intact, and exits 0. So the command the README calls the CI
|
|
* gate for broken refs went green over breakage that had been printed minutes
|
|
* earlier on the author's own screen.
|
|
*
|
|
* Deterministic, model-free, offline → the free unit tier.
|
|
*/
|
|
import { test, beforeEach, afterEach } from "vitest";
|
|
import assert from "node:assert/strict";
|
|
import { writeFileSync, existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
|
|
import { makeTmpDir, cleanupTmpDir } from "./core/test-utils.js";
|
|
|
|
const CLI = resolve(__dirname, "..", "dist", "cli.js");
|
|
const SPEC_ENTRY = resolve(__dirname, "..", "dist", "core", "spec.js");
|
|
|
|
let dir: string;
|
|
|
|
/** Run the CLI in the fixture, returning exit code + combined output. */
|
|
function run(...args: string[]): { code: number; out: string } {
|
|
try {
|
|
const out = execFileSync("node", [CLI, ...args], {
|
|
cwd: dir,
|
|
encoding: "utf-8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
return { code: 0, out };
|
|
} catch (e) {
|
|
const err = e as { status?: number; stdout?: string; stderr?: string };
|
|
return {
|
|
code: err.status ?? 1,
|
|
out: `${err.stdout ?? ""}${err.stderr ?? ""}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
dir = makeTmpDir();
|
|
writeFileSync(
|
|
join(dir, "package.json"),
|
|
JSON.stringify({ name: "gate-fixture", version: "0.0.0", scripts: {} }),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupTmpDir(dir);
|
|
});
|
|
|
|
/** A spec whose refs are both dead — one file, one npm script. */
|
|
function deadRefSpec(): string {
|
|
return (
|
|
`import { instructionFile, file, cmd } from ${JSON.stringify(SPEC_ENTRY)};\n` +
|
|
`export default instructionFile({\n` +
|
|
` sections: {\n` +
|
|
` Overview: \`Gate reproduction.\`,\n` +
|
|
` Routing: [file("docs/never-existed.md"), cmd("npm run never-existed")],\n` +
|
|
` },\n` +
|
|
` rules: {},\n` +
|
|
`});\n`
|
|
);
|
|
}
|
|
|
|
test("compile does NOT write an artifact when refs are dead (#173)", () => {
|
|
writeFileSync(join(dir, "CLAUDE.md.spec.ts"), deadRefSpec());
|
|
|
|
const compiled = run("compile", "CLAUDE.md.spec.ts");
|
|
|
|
assert.notEqual(compiled.code, 0, "a dead ref must fail the compile");
|
|
assert.match(compiled.out, /never-existed/, "the error names the dead ref");
|
|
assert.equal(
|
|
existsSync(join(dir, "CLAUDE.md")),
|
|
false,
|
|
"no artifact: writing one here mints a valid hash over known-dead refs",
|
|
);
|
|
});
|
|
|
|
test("a failed compile leaves the LAST GOOD artifact alone, not a broken one", () => {
|
|
// Why this half matters: "do not write" must not mean "destroy what is there".
|
|
const good =
|
|
`import { instructionFile } from ${JSON.stringify(SPEC_ENTRY)};\n` +
|
|
`export default instructionFile({\n` +
|
|
` sections: { Overview: \`All refs real.\` },\n` +
|
|
` rules: {},\n` +
|
|
`});\n`;
|
|
writeFileSync(join(dir, "CLAUDE.md.spec.ts"), good);
|
|
assert.equal(run("compile", "CLAUDE.md.spec.ts").code, 0);
|
|
|
|
const before = readFileSync(join(dir, "CLAUDE.md"), "utf-8");
|
|
assert.match(before, /All refs real/);
|
|
|
|
// Now break the spec and recompile.
|
|
writeFileSync(join(dir, "CLAUDE.md.spec.ts"), deadRefSpec());
|
|
assert.notEqual(run("compile", "CLAUDE.md.spec.ts").code, 0);
|
|
|
|
assert.equal(
|
|
readFileSync(join(dir, "CLAUDE.md"), "utf-8"),
|
|
before,
|
|
"the previous good artifact must survive a failed compile untouched",
|
|
);
|
|
|
|
// 🔴 THIS ASSERTION USED TO SAY `lint` MUST BE GREEN HERE, and that expectation
|
|
// was the hole itself. The surviving artifact IS intact — its hash matches what
|
|
// the spec compiled to — but the spec it came from now names a file that does
|
|
// not exist, so calling the repo clean is exactly the false confidence #173 was
|
|
// filed about. The hash is an integrity claim, not a reference claim.
|
|
//
|
|
// `spec-refs` closes that residue, so a dead reference is reported even though
|
|
// the artifact is untouched. What the surviving-artifact half of this test
|
|
// still pins is the FILE: refusing to write must not mean destroying what is
|
|
// there, which is asserted above by comparing the bytes.
|
|
const linted = run("lint", ".");
|
|
assert.equal(linted.code, 2, "the dead reference is caught, not the file");
|
|
assert.match(linted.out, /never-existed/);
|
|
});
|
|
|
|
test("lint no longer goes green over an artifact compile refused to write", () => {
|
|
// The end-to-end shape of the report: the author compiles, sees the error,
|
|
// gets distracted, commits. CI runs `lint`.
|
|
writeFileSync(join(dir, "CLAUDE.md.spec.ts"), deadRefSpec());
|
|
run("compile", "CLAUDE.md.spec.ts");
|
|
|
|
const linted = run("lint", ".");
|
|
assert.equal(
|
|
linted.out.includes("hash valid"),
|
|
false,
|
|
"there is no artifact to call intact, so lint cannot report one",
|
|
);
|
|
});
|
|
|
|
// --- the same hole in the SIBLING compilers (found red-teaming #186) --------
|
|
|
|
test("a SKILL spec with a dead ref writes nothing either", () => {
|
|
// #173 was fixed in `compileClaudeToFile` only. The identical three lines —
|
|
// write, THEN check errors — sat unchanged in the skill, subagent, railway and
|
|
// generator compilers, and all four stamped unconditionally. Worse than the
|
|
// original: `spec-refs` skips non-`claude` specs, so there was no lint-side
|
|
// backstop at all. Reproduced on the built CLI before the fix: a stamped
|
|
// SKILL.md on disk and `lint` reporting `✓ hash valid`, exit 0.
|
|
//
|
|
// Guarding four call sites would have left the fifth writable, so the write
|
|
// takes a `StampedMarkdown` that an erroring compile never mints.
|
|
const specEntry = resolve(__dirname, "..", "dist", "core", "spec.js");
|
|
mkdirSync(join(dir, "skills", "demo"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", "demo", "SKILL.md.spec.ts"),
|
|
`import { experimental_skill, file } from ${JSON.stringify(specEntry)};\n` +
|
|
`export default experimental_skill({\n` +
|
|
` name: "demo",\n` +
|
|
` description: "A demo skill whose body references a file that is gone.",\n` +
|
|
` tools: ["Read"],\n` +
|
|
` body: ["Read the guide: ", file("docs/never-existed.md")],\n` +
|
|
`});\n`,
|
|
);
|
|
|
|
const r = run("compile", "skills/demo/SKILL.md.spec.ts");
|
|
assert.notEqual(r.code, 0, "a dead ref fails the compile");
|
|
assert.match(r.out, /never-existed/);
|
|
assert.equal(
|
|
existsSync(join(dir, "skills", "demo", "SKILL.md")),
|
|
false,
|
|
"no stamped artifact: `lint` would have called it hash-valid",
|
|
);
|
|
});
|
|
|
|
test("a CLEAN skill spec still compiles and writes", () => {
|
|
// The other half — refusing to write must not become refusing to work.
|
|
const specEntry = resolve(__dirname, "..", "dist", "core", "spec.js");
|
|
mkdirSync(join(dir, "skills", "ok"), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, "skills", "ok", "SKILL.md.spec.ts"),
|
|
`import { experimental_skill } from ${JSON.stringify(specEntry)};\n` +
|
|
`export default experimental_skill({\n` +
|
|
` name: "ok",\n` +
|
|
` description: "A skill with no references at all, so nothing can be stale.",\n` +
|
|
` tools: ["Read"],\n` +
|
|
` body: "Do the thing.",\n` +
|
|
`});\n`,
|
|
);
|
|
|
|
assert.equal(run("compile", "skills/ok/SKILL.md.spec.ts").code, 0);
|
|
const out = join(dir, "skills", "ok", "SKILL.md");
|
|
assert.equal(existsSync(out), true);
|
|
assert.match(
|
|
readFileSync(out, "utf-8"),
|
|
/vigiles:sha256/,
|
|
"and it is stamped",
|
|
);
|
|
});
|