mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
4acfd31fc9
* fix(audit): coverage caveats printed by lint never reached the audit report `formatUntestedReport` grew two qualifier lines — a test file still carrying the retired `vigiles:covers` marker, and a run record older than the text it measured. `audit` assembles its own fact block from the SAME UntestedReport and carried neither, so the note that exists to explain a silent migration was itself silent for anyone whose habit is `audit` rather than `lint`. Measured before the fix on a fixture whose only harness carries the marker: lint → "1 test file(s) still carry the retired `vigiles:covers` marker …" audit → "Untested surfaces: 0" and nothing else The caveats now come from one exported builder (`coverageCaveats`) that both renderers read, so a third qualifier reaches both callers or neither — a renderer can no longer carry half the list. `ScanReport.coverageCaveats` is absent rather than `[]` when empty, because the browser twin structurally cannot compute these (no `.vigiles/coverage.json` without a filesystem) and byte-parity between the two engines is asserted field-for-field. Both halves tested at the product level (`node dist/cli.js audit`): it fires on a repo carrying the marker and stays silent on one that does not. Proven by two mutations, each with the patched source grepped first: dropping the render line fails the "fires" assertion; emitting a caveat unconditionally fails the "silent" one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * fix(coverage): the suffix that stopped counting now says so `*.test.*` left DEFAULT_TEST_GLOBS in 15.x because a default vitest/jest run collects exactly those names and a skill test spawns a model — so a misnamed harness was being executed, and paid for, by somebody else's runner. The removal was announced to nobody. Its own comment argues "the migration is a rename, and the untested finding prints the exact path", but the path printed is the SURFACE's, together with a suggestion to add `<surface>.eval.mjs`. An author with `foo.test.mjs` already lying beside `foo/SKILL.md` is therefore told to write the test they wrote, with neither the file nor the reason named. The sibling half of the same migration — the retired `vigiles:covers` marker — got its explanatory line at the time. This is the half that did not. Scoped to surfaces that are otherwise UNCOVERED: that is the only position where the count contradicts what the author can see in the directory. A stray `*.test.*` beside a properly covered surface is somebody's ordinary unit test and none of our business — asserted, because an advisory line that fires on the innocent case gets muted within a day. Nothing about coverage changes: the name still grants nothing. Measured silent on all seven real corpora available here (this repo, six vendored dogfood plugins) and on the `mine` knowledge base; it fires on a planted fixture in both `lint` and `audit`, the latter through the shared caveat list added in the previous commit rather than a second wiring. Three mutations, each with the patched source grepped before the run and each dying on its own assertion: dropping the note from the caveat list · widening the scope from untested to every considered surface · dropping the suffix test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * refactor(scan): compute the caveat list once Cosmetic follow-up to the two commits above: `coverageCaveats(coverage)` was called twice in the same object literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * fix(coverage): the retired-suffix note claimed a cost the guard already closes The note said a `*.test.*` harness "spawns a model" under someone else's runner. `refuseUnderForeignRunner` guards every spawn door and refuses under vitest/jest — measured here against the detector: argv of `node_modules/vitest/vitest.mjs` yields "vigiles refused to spawn a model: this process is running under vitest." What actually happens is that the foreign run FAILS on the file, which is the true and smaller claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * feat(eval)!: an eval file DESCRIBES its eval; `vigiles eval` runs it Importing a `*.eval.*` file spent real money. These files did their work in the module body, and in ESM `import` IS execution — so the cheapest imaginable question, "does this file even parse?", launched a paid run against a live model when it was asked with `import()`. That happened, and was paid for. The one guard that exists does not close the door and cannot be made to. Under `node -e 'import(f)'` there is no `process.argv[1]` at all, so `foreignRunner(undefined)` is `null` and `refuseUnderForeignRunner` stays silent; and no process fact distinguishes that from a legitimate runner doing the same import, so an argv-shaped guard would have to refuse the correct invocation to catch the wrong one — the exact failure that got `process.env.VITEST` rejected in `core/foreign-runner.ts`. So the shape changed instead. An eval file default-exports a description: export default defineEval({ measureTriggerRate: { …spec… }, assert: (report) => assertTriggerRate(report, { min: 0.8 }), }); `defineEval` builds a plain value; there is no call in it to run. The five paid runners are reached only by `dist/eval-entry.js`, the program `vigiles eval` now spawns per file — not a new verb, the interpreter the existing verb uses. `defineEval` is exported from the FREE root barrel, not `vigiles/eval`, and that is the point: after this change no eval file imports a runner at all. Three loud edges, because a silent no-op would be the same defect in a new place: `node x.eval.mjs` refuses and names `vigiles eval`; a file that declares nothing (or two measurements) is reported by the runner instead of exiting green; and a half-migrated file — a description plus a leftover top-level `measure(…)` — hits `refuseDuringEvalLoad` during the runner's own import and throws a migration message instead of billing. `node --check` still works and is what the free syntax check should be. Subtractions: every eval file loses its `process.env.VIGILES_TRIALS || process.argv[2]` line, its `console.log(format…Report(report))`, its `if (report.n === 0) throw`, its top-level `await`, and its `vigiles/eval` import. The runner does all five, once. BREAKING CHANGE: `*.eval.*` files must now default-export `defineEval({…})` instead of calling a runner at the top level, and `node <file>.eval.mjs` no longer runs an eval. To migrate: move the runner call into a key named after that runner (`runEval` · `measure` · `measureArms` · `measureTriggerRate` · `measureSelectionMatrix` — declare exactly one), move anything you did with the report into `assert(report)`, and delete the trials line, the `format…` print and the `report.n === 0` check — `vigiles eval` does those. A capability probe that sat at the top of the file becomes `skipIf()`; `measureSelectionMatrix`'s directory argument becomes a `pluginDir` field; and `measureTriggerRate(spec, { evalDriver })`'s second argument becomes a top-level `evalDriver` key. `vigiles eval --trials=N` still overrides trials. Run with `vigiles eval <file>`; a file left on the old shape fails with a message naming docs/harness-testing.md, never silently. All 19 eval files in this repository were migrated in this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * chore: regenerate the browser-parity fixture and generated types The eval-definition change rewrote one report hint (`paid_measureTriggerRate` → `export default defineEval({ measureTriggerRate: … })`), which is exactly the divergence the parity test is built to catch. Regenerated per that test's own instruction for an intentional engine change; the diff is that one line — grade and every other field are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd --------- Co-authored-by: Claude <noreply@anthropic.com>
267 lines
9.0 KiB
TypeScript
267 lines
9.0 KiB
TypeScript
/**
|
|
* Corpus gate: every `*.eval.*` file in this repository is a DESCRIPTION.
|
|
*
|
|
* 🔴 STATIC ONLY — nothing here imports or runs an eval file. That import is the
|
|
* defect under repair; the property is checked from the parsed source instead,
|
|
* with TypeScript's own parser rather than a regex, because "is this `await` at
|
|
* the top level or inside a function?" is a question about the syntax tree and a
|
|
* regex has no way to ask it.
|
|
*
|
|
* Three properties, and each would have FAILED on every one of these files
|
|
* before this change:
|
|
*
|
|
* 1. no `await` in the module body — the five paid runners all return
|
|
* promises, so a module that awaits nothing cannot have run one;
|
|
* 2. no import of a paid runner at all — after the migration the paid subpath
|
|
* is not part of an eval file's vocabulary;
|
|
* 3. a `defineEval({…})` default export declaring EXACTLY ONE measurement.
|
|
*
|
|
* None is the whole property alone. (1) without (2) would pass a file that
|
|
* called a runner and dropped the promise; (2) without (1) would pass a file
|
|
* that awaited some other expensive thing; and (3) catches a typo'd key at test
|
|
* time rather than at the moment somebody is trying to spend money.
|
|
*/
|
|
import { describe, test } from "vitest";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { globSync } from "glob";
|
|
import ts from "typescript";
|
|
|
|
import { EVAL_KINDS } from "./eval-define.js";
|
|
|
|
/** The runners that spend. An eval file must not name any of them. */
|
|
const PAID = [
|
|
"runEval",
|
|
"measure",
|
|
"measureArms",
|
|
"measureTriggerRate",
|
|
"measureSelectionMatrix",
|
|
"judge",
|
|
] as const;
|
|
|
|
function parse(file: string, src: string): ts.SourceFile {
|
|
return ts.createSourceFile(file, src, ts.ScriptTarget.ES2022, true);
|
|
}
|
|
|
|
/** `await` reached without crossing into a function body. Pure, exported shape. */
|
|
export function topLevelAwaits(sf: ts.SourceFile): string[] {
|
|
const found: string[] = [];
|
|
const walk = (n: ts.Node): void => {
|
|
if (
|
|
ts.isFunctionDeclaration(n) ||
|
|
ts.isFunctionExpression(n) ||
|
|
ts.isArrowFunction(n) ||
|
|
ts.isMethodDeclaration(n) ||
|
|
ts.isClassDeclaration(n)
|
|
)
|
|
return; // a promise created in there is created when IT is called
|
|
if (ts.isAwaitExpression(n))
|
|
found.push(n.getText().split("\n")[0]?.slice(0, 60) ?? "");
|
|
ts.forEachChild(n, walk);
|
|
};
|
|
ts.forEachChild(sf, walk);
|
|
return found;
|
|
}
|
|
|
|
/** Names imported from anywhere, as written. Pure. */
|
|
export function importedNames(sf: ts.SourceFile): string[] {
|
|
const out: string[] = [];
|
|
for (const s of sf.statements) {
|
|
if (!ts.isImportDeclaration(s)) continue;
|
|
const b = s.importClause?.namedBindings;
|
|
if (b && ts.isNamedImports(b))
|
|
for (const e of b.elements) out.push(e.propertyName?.text ?? e.name.text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The `defineEval(…)` argument object of a default export, if there is one. */
|
|
function defineEvalArg(sf: ts.SourceFile): ts.ObjectLiteralExpression | null {
|
|
for (const s of sf.statements) {
|
|
if (
|
|
ts.isExportAssignment(s) &&
|
|
s.isExportEquals !== true &&
|
|
ts.isCallExpression(s.expression) &&
|
|
s.expression.expression.getText() === "defineEval"
|
|
) {
|
|
const a = s.expression.arguments[0];
|
|
return a && ts.isObjectLiteralExpression(a) ? a : null;
|
|
}
|
|
// `.cjs` files say `module.exports = defineEval({…})`.
|
|
if (
|
|
ts.isExpressionStatement(s) &&
|
|
ts.isBinaryExpression(s.expression) &&
|
|
ts.isCallExpression(s.expression.right) &&
|
|
s.expression.right.expression.getText() === "defineEval"
|
|
) {
|
|
const a = s.expression.right.arguments[0];
|
|
return a && ts.isObjectLiteralExpression(a) ? a : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Does the module default-export a `defineEval(…)` call? Pure. */
|
|
export function defaultExportsDefineEval(sf: ts.SourceFile): boolean {
|
|
return defineEvalArg(sf) !== null;
|
|
}
|
|
|
|
/**
|
|
* The measurement keys a `defineEval({…})` literal declares. Pure.
|
|
*
|
|
* Catching "exactly one" HERE and not only at run time is the point: a typo'd
|
|
* `measureTrigerRate:` is a file the runner would call "declares nothing" — a
|
|
* correct verdict, delivered at the moment somebody is trying to spend money.
|
|
*/
|
|
export function declaredKeys(sf: ts.SourceFile): string[] {
|
|
const arg = defineEvalArg(sf);
|
|
if (arg === null) return [];
|
|
return arg.properties
|
|
.map((pr) => pr.name?.getText().replace(/^["']|["']$/g, "") ?? "")
|
|
.filter((n) => (KINDS as readonly string[]).includes(n));
|
|
}
|
|
|
|
/** The five measurements, mirrored from `EVAL_KINDS` (asserted below). */
|
|
const KINDS = [
|
|
"runEval",
|
|
"measure",
|
|
"measureArms",
|
|
"measureTriggerRate",
|
|
"measureSelectionMatrix",
|
|
] as const;
|
|
|
|
const FILES = globSync("**/*.eval.{mjs,cjs,js,mts,cts,ts}", {
|
|
ignore: ["node_modules/**", "dist/**"],
|
|
dot: true,
|
|
}).sort();
|
|
|
|
describe("every eval file in this repo describes its eval", () => {
|
|
test("there are eval files to check", () => {
|
|
// A glob that silently matches nothing turns this whole gate green.
|
|
assert.ok(FILES.length >= 19, `only found ${String(FILES.length)}`);
|
|
});
|
|
|
|
test.each(FILES)("%s", (f) => {
|
|
const src = readFileSync(f, "utf8");
|
|
const sf = parse(f, src);
|
|
assert.ok(
|
|
defaultExportsDefineEval(sf),
|
|
`${f}: must \`export default defineEval({…})\``,
|
|
);
|
|
assert.deepEqual(
|
|
topLevelAwaits(sf),
|
|
[],
|
|
`${f}: the module body awaits — importing this file does work`,
|
|
);
|
|
assert.deepEqual(
|
|
declaredKeys(sf).length,
|
|
1,
|
|
`${f}: must declare EXACTLY one measurement (found ${JSON.stringify(declaredKeys(sf))})`,
|
|
);
|
|
const paid = importedNames(sf).filter((n) =>
|
|
(PAID as readonly string[]).includes(n),
|
|
);
|
|
assert.deepEqual(
|
|
paid,
|
|
[],
|
|
`${f}: imports a paid runner (${paid.join(", ")})`,
|
|
);
|
|
// Free, and the thing people should reach for instead of `import()`.
|
|
execFileSync("node", ["--check", f], { stdio: "pipe" });
|
|
});
|
|
});
|
|
|
|
// --- fires on a planted defect ------------------------------------------------
|
|
//
|
|
// "Silent on a clean corpus" alone is indistinguishable from dead code. These
|
|
// plant each half of the old shape and assert the checks catch it.
|
|
|
|
describe("the gate fires on the shape it replaced", () => {
|
|
test("a top-level await is caught, and one inside a function is NOT", () => {
|
|
const bad = parse(
|
|
"x.eval.mjs",
|
|
`import { measure } from "vigiles/eval";\nconst report = await measure({});\n`,
|
|
);
|
|
assert.equal(topLevelAwaits(bad).length, 1);
|
|
|
|
// The quiet half: `assert` and `skipIf` are allowed to be async — that is
|
|
// the entire point of moving the work into them.
|
|
const good = parse(
|
|
"x.eval.mjs",
|
|
`export default defineEval({ measure: {}, assert: async (r) => { await check(r); } });\n`,
|
|
);
|
|
assert.deepEqual(topLevelAwaits(good), []);
|
|
});
|
|
|
|
test("a paid import is caught, and the free ones are not", () => {
|
|
assert.deepEqual(
|
|
importedNames(
|
|
parse(
|
|
"x.eval.mjs",
|
|
`import { measureTriggerRate, formatTriggerRateReport } from "../../dist/eval.js";\n`,
|
|
),
|
|
).filter((n) => (PAID as readonly string[]).includes(n)),
|
|
["measureTriggerRate"],
|
|
);
|
|
assert.deepEqual(
|
|
importedNames(
|
|
parse(
|
|
"x.eval.mjs",
|
|
`import { defineEval, assertRates } from "vigiles";\nimport { skillResolved } from "vigiles";\n`,
|
|
),
|
|
).filter((n) => (PAID as readonly string[]).includes(n)),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("a file that runs its eval at top level fails the default-export check too", () => {
|
|
assert.equal(
|
|
defaultExportsDefineEval(
|
|
parse(
|
|
"x.eval.mjs",
|
|
`const r = await measure({});\nexport default r;\n`,
|
|
),
|
|
),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
defaultExportsDefineEval(
|
|
parse("x.eval.mjs", `export default defineEval({ measure: {} });\n`),
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
test("the key list here mirrors the one the runner reads", () => {
|
|
// Two lists of the same five names is how a sixth measurement gets declared,
|
|
// shipped, and silently never checked.
|
|
assert.deepEqual([...KINDS].sort(), [...EVAL_KINDS].sort());
|
|
});
|
|
|
|
test("the exactly-one check fires on a typo and on a double declaration", () => {
|
|
const one = parse(
|
|
"x.eval.mjs",
|
|
`export default defineEval({ measure: {} });\n`,
|
|
);
|
|
assert.deepEqual(declaredKeys(one), ["measure"]);
|
|
// A typo is not a measurement — the file would declare nothing.
|
|
const typo = parse(
|
|
"x.eval.mjs",
|
|
`export default defineEval({ measureTrigerRate: {} });\n`,
|
|
);
|
|
assert.deepEqual(declaredKeys(typo), []);
|
|
const two = parse(
|
|
"x.eval.mjs",
|
|
`export default defineEval({ measure: {}, runEval: {} });\n`,
|
|
);
|
|
assert.deepEqual(declaredKeys(two), ["measure", "runEval"]);
|
|
// `module.exports = defineEval({…})` — the .cjs spelling — is seen too.
|
|
const cjs = parse(
|
|
"x.eval.cjs",
|
|
`module.exports = defineEval({ measureTriggerRate: {} });\n`,
|
|
);
|
|
assert.deepEqual(declaredKeys(cjs), ["measureTriggerRate"]);
|
|
});
|