Files
zernie__vigiles/src/eval-file-shape.test.ts
T
zernie 4acfd31fc9 feat(eval)!: an eval file describes, the runner performs — importing one no longer spends (#159)
* 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>
2026-08-18 20:34:40 +05:00

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"]);
});