Files
zernie__vigiles/src/harness-assert.ts
T
zernie 900dc7e06a refactor(spec)!: name things for what they are; retire the vigiles/experimental subpath (#169)
Three exported symbols were named for something other than what they are, and
two mechanisms were doing one job.

RENAMES (old names kept one major, @deprecated, removed one major AFTER this one):
  claude()          → instructionFile()     compiles CLAUDE.md AND AGENTS.md, so a
                                            name from one harness was never right
  instructions``    → prose``               builds a prose FRAGMENT; the plural read
                                            as "the instruction file", which is what
                                            instructionFile() builds
  agent()           → experimental_agent()  the shape is not settled, and the tag was
                                            never there (git log -S empty)
  SkillSpec.result: → postcondition:        `result` already meant the subagent output
                                            contract one screen down; the doc comment
                                            had to spend a line disambiguating them

The alias window is not politeness. Measured: the previous rename shipped with
ZERO overlap (18.1.1 old names only, 19.0.0 new only). A container rollback put
the old node_modules under new sources, PreToolUse stopped loading, and a
PreToolUse that does not load denies EVERY Bash command — including the one that
fixes it. One window per major makes that pair unrepresentable.

SUBPATH RETIRED. vigiles/experimental is gone; its members moved to the door
their feature already uses. Of two mechanisms for one promise the subpath was the
weaker: it marks the import line, out of view by the time anyone reads the call.
Measured on the only user-facing example — with the prefix aliased away at
import, the marker survived at 0 of 5 call sites. So the prefix stays, the advice
to alias it away became an explicit prohibition, and the six define* entry points
are DECLARED prefixed rather than gaining it at re-export.

That de-aliasing is what unblocked moving the naming check into ESLint, and the
ORDER was load-bearing: measured before it, dropping the public/internal
exemption produced 14 findings, all from the re-export indirection. Measured
after, across all of src/ with no exemption: 22 tagged declarations, 0
unprefixed. The rule turns on silent.

EXPORT CLEANUP. vigiles/linting export * → curated, 103 → 79 symbols; its header
claimed to be curated with an `export *` one line below, and claimed the builders
were also at the package root (191 exports there, zero matches). ClaudeTool and
HookEvent deleted — zero references, and both had drifted off the dialect they
claimed to mirror; HookEvent listed 5 events, two of which do not exist, against
the 31 the vendor documents.

DOCS. experimental.md deleted and distributed per feature: the skill half into
skills.md §Status, the hook half into a new compiled-hooks.md §Status, and the
emit half — 111 of its 177 lines, a topic doc wearing a stability page's clothes
— into docs/emit-channel.md, which gains the "which channel when" comparison it
never had. The one cross-cutting sentence went to the README.

TWO GATES WERE GREEN AND WRONG, both found by this work:
  - api:check reported "verified for 11 entries — no drift" while reading
    dist/experimental.d.ts from a PREVIOUS build (tsc does not clean dist/) for a
    source file that no longer existed. Now asserted against package.json
    exports, with a mutation proving exit 1 on a phantom entry.
  - check-export-prefixes caught MY error: the R3 services first landed on
    vigiles/eval, where `paid_` means "this call can bill you" — and
    startServices takes a ContainerRuntime and calls no model. Naming it
    paid_experimental_startServices would have redefined paid_ as "expensive in
    some sense". They went to the package root.

SEVENTEEN review findings landed on this branch, all real. Two mechanical
closures came out of them: src/example-imports.test.ts resolves every relative
import under examples/ instead of matching a string, and the naming rule now
ENUMERATES the three export paths it visits rather than describing its coverage
in the abstract. One attempt is recorded as REFUTED: teaching the doc gate to
flag our own exports as free variables measured 66 findings across 93 blocks, so
that gate's original scoping was right.

BREAKING CHANGE: vigiles/experimental is removed. Its exports are unchanged in
name and behaviour and are now reached from `vigiles` (the emit channel, the R3
disposable-service tier). Every renamed symbol keeps a working @deprecated alias
for this major.
2026-08-21 07:54:51 +05:00

994 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* vigiles — runner-agnostic helpers for harness tests / evals.
*
* `runHarnessTest` and `runEval` are plain async functions that return data, so
* they already work inside any runner (node:test, vitest, jest, mocha). These
* helpers remove the last bit of boilerplate without coupling to a runner:
*
* - `withHarness` — run a harness test and auto-clean the sandbox (try/finally),
* so you don't leak temp dirs in `afterEach`.
* - plain `assert*` helpers that throw — usable in every runner, including
* node:test which has no `expect.extend`.
* - `vigilesMatchers` — register with `expect.extend(vigilesMatchers)` for
* `expect(...).toHaveCreated(...)` sugar. The signature is identical for
* vitest and jest, so the same object supports both.
*/
import {
runHarnessTest,
type HarnessTestSpec,
type HarnessTestResult,
type ToolCall,
type Trace,
} from "./harness-test.js";
import type { EvalReport, TriggerRateReport } from "./eval.js";
import type { HookRunResult, EgressAttempt } from "./run-hook.js";
import { hookSource, runHookProgram } from "./core/hook-program.js";
import type {
AnyHook,
RawHookEvent,
HookProgramOutcome,
} from "./core/hook-program.js";
import { evalChecks, type Check } from "./check.js";
import { recordCheck, recordSurfaceProbe } from "./check-count.js";
import type { OutputContract } from "./core/spec.js";
import {
parseAgentResult,
type ParsedAgentResult,
} from "./adapters/claude-code/agent-result.js";
import { compareArms } from "./stats.js";
import {
diffReports,
type BaselineFile,
type DiffOptions,
} from "./eval-baseline.js";
// Re-export the significance primitives so the whole eval-analysis surface lives
// re-exported from the `vigiles` root (there is no `vigiles/harness-assert` entry
// point — it was advertised in this very comment and never existed).
export { compareArms } from "./stats.js";
export type { Comparison } from "./stats.js";
export {
diffReports,
toBaselineFile,
parseBaselineFile,
readBaseline,
writeBaseline,
formatBaselineDiff,
diffToJUnit,
} from "./eval-baseline.js";
export type {
BaselineFile,
BaselineDiff,
MetricDiff,
DiffStatus,
DiffOptions,
} from "./eval-baseline.js";
/**
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
* hand — it survives assertion failures.
*/
/* v8 ignore start -- thin wrapper over runHarnessTest (spawns the real CLI) */
export async function withHarness<T>(
spec: HarnessTestSpec,
fn: (r: HarnessTestResult) => T | Promise<T>,
): Promise<T> {
const r = await runHarnessTest(spec);
try {
return await fn(r);
} finally {
r.cleanup();
}
}
/* v8 ignore stop */
// --- Plain throwing assertions (any runner) --------------------------------
function fail(message: string): never {
throw new Error(message);
}
/**
* Mark the current `*.harness.*` / `*.eval.*` SCRIPT as SKIPPED and exit, so
* `vigiles test` / `vigiles eval` report a loud `⊘ SKIPPED` instead of a silent
* `✓` — e.g. the deterministic tier when the `claude` CLI isn't installed. A skip
* never fails the run. Exit 77 is the runner's `SKIP_EXIT_CODE` (run-scripts.ts).
*
* For standalone CLI-fallback scripts only — inside a runner (vitest / jest /
* node:test) use that runner's own skip, not a process exit.
*/
/* v8 ignore start -- process.exit can't be exercised in-process (tested via a child in cli.test.ts / run-scripts.test.ts) */
export function skip(reason?: string): never {
console.log(`SKIPPED${reason ? `: ${reason}` : ""}`);
process.exit(77);
}
/* v8 ignore stop */
/** Assert the sandbox contains `path` (a hook/agent side-effect file). */
export function assertCreated(r: HarnessTestResult, path: string): void {
if (r.file(path) === null) fail(`expected the run to create ${path}`);
}
/** Assert the sandbox does NOT contain `path` (e.g. a blocked action's output). */
export function assertNotCreated(r: HarnessTestResult, path: string): void {
if (r.file(path) !== null) fail(`expected the run NOT to create ${path}`);
}
/** Assert the scripted model served at least `n` turns (e.g. a Stop hook forced more). */
export function assertServedTurns(r: HarnessTestResult, n: number): void {
if (r.turns < n) {
fail(`expected ≥ ${String(n)} model turns, got ${String(r.turns)}`);
}
}
/** Assert a `runHook` result blocked (exit 2 / decision:block / permission:deny). */
export function assertHookBlocked(r: HookRunResult): void {
if (!r.blocked) {
fail(
`expected the hook to block, but it allowed (exit ${String(r.exitCode)})`,
);
}
}
/** Assert a `runHook` result allowed (did not block). */
export function assertHookAllowed(r: HookRunResult): void {
if (r.blocked) {
fail(
`expected the hook to allow, but it blocked (exit ${String(r.exitCode)}, decision ${String(r.decision)})`,
);
}
}
/**
* `runHookProgram`, counted AND attributed. An in-process hook decision is an
* observation the CLI runner can see — without it, a `*.harness.*` file that only
* tests compiled hooks would look like it did nothing at all. See check-count.ts.
*
* 🔴 THE PROBE BELONGS HERE, AND NOWHERE EARLIER OR LATER. The documented
* in-process path — `loadHook(file)` then `assertHookDenies(hook, event)` — used
* to record a check and no SURFACE, so the child exited a non-vacuous pass with
* an empty `surfaces` list, `runsFromResults` discarded it, and a harness that
* genuinely executed a compiled hook produced NO execution record. The hook then
* showed as untested unless filename colocation happened to cover it. Unlike
* every other coverage finding in this review that is a false NEGATIVE — real
* execution going unrecorded — which is why the bar for what gets recorded here
* is the high one, not the cheap one.
*
* Not at LOAD: `loadHook` only remembers the path. Someone loading a hook to
* inspect its shape has not run it, and crediting that would be the "an empty
* file counts" substitution the execution tier exists to remove.
*
* Not in `runHookProgram`: that is the PURE evaluator, and it is what the CLI's
* `hook-runtime run-program` calls on every live event in production. Recording
* there would make the pure decision impure and would fire outside any test.
*
* This function is the only place that has both facts — the hook is about to be
* evaluated, and (when it came from `loadHook`) we know its file. Four public
* assertions funnel through it, so none of them can be added without inheriting
* the attribution.
*
* ⚠️ WHAT THIS DELIBERATELY DOES NOT ATTRIBUTE — run, not asserted. A hook built
* in-process rather than loaded from disk has no file to name, so nothing is
* recorded and nothing is invented:
*
* const h = experimental_defineHook({…}); assertHookDenies(h, e); → surfacesRecorded() === []
*
* …and a direct `runHookProgram(hook, event)` call (the pure evaluator, public
* via `vigiles/hook`) records nothing either, for the reason above. Both cost a
* missed record, never a false one.
*/
function runCountedHookProgram(
hook: AnyHook,
event: RawHookEvent,
): HookProgramOutcome {
recordCheck();
// `command`, the same shape a parsed hook ref gets — but note the provenance
// differs: that one is inferred from a command string and is best-effort; this
// one is the path `loadHook` resolved, so it is exact by construction.
const source = hookSource(hook);
if (source !== undefined) recordSurfaceProbe("command", source);
return runHookProgram(hook, event);
}
/** Render a {@link HookProgramOutcome} for an assertion message. */
function describeOutcome(o: HookProgramOutcome): string {
if (o.kind === "decision") return `${o.decision.kind} (a gate decision)`;
if (o.kind === "injection") return `an injection`;
return `${o.reaction.kind} (a reaction)`;
}
/**
* Assert a COMPILED hook (a `vigiles/hook` program) denies an event — evaluated
* in-process, no subprocess, no model. The cheapest way to test a gate's logic:
* pass the hook's default export and a raw event. (For the wired-into-the-real-CLI
* check, use {@link assertHookBlocked} over `runHook`.)
*/
export function assertHookDenies(hook: AnyHook, event: RawHookEvent): void {
const o = runCountedHookProgram(hook, event);
if (o.kind !== "decision" || o.decision.kind !== "deny") {
fail(`expected the hook to deny, got ${describeOutcome(o)}`);
}
}
/** Assert a COMPILED hook allows an event (in-process). The twin of {@link assertHookDenies}. */
export function assertHookAllows(hook: AnyHook, event: RawHookEvent): void {
const o = runCountedHookProgram(hook, event);
if (o.kind !== "decision" || o.decision.kind !== "allow") {
fail(`expected the hook to allow, got ${describeOutcome(o)}`);
}
}
/**
* Assert a COMPILED **react** hook emits a `notice(…)` for an event, optionally
* matching its message — the react-tier twin of {@link assertHookDenies}.
*
* A react hook can't block, so the gate assertions don't apply to it, and there
* was no assertion that did. That left `notice()` a live trap: its message goes
* to **stderr**, so a probe built on `execFileSync` (which returns stdout only)
* sees nothing and reports a perfectly healthy react hook as DEAD — measured
* against three real hooks on 2026-08-03. Reading the reaction in-process means
* stdout-vs-stderr never enters into it.
*/
export function assertHookNotices(
hook: AnyHook,
event: RawHookEvent,
matcher?: string | RegExp,
): void {
const o = runCountedHookProgram(hook, event);
if (o.kind !== "reaction" || o.reaction.kind !== "notice") {
fail(`expected the hook to notice, got ${describeOutcome(o)}`);
}
if (matcher === undefined) return;
const { message } = o.reaction;
const matched =
typeof matcher === "string"
? message.includes(matcher)
: matcher.test(message);
if (!matched) {
fail(
`expected the notice to match ${String(matcher)}, got ${JSON.stringify(message)}`,
);
}
}
/**
* Assert a COMPILED **react** hook stays silent for an event — it returns
* `nothing()` (a `"none"` reaction). The twin of {@link assertHookNotices}: together they pin both
* halves of a react hook's behaviour — it fires when it should, and (the half
* that actually regresses) it does NOT fire when it shouldn't.
*
* A `run(…)` reaction is not silent for this purpose: the hook still reacted.
*/
export function assertHookSilent(hook: AnyHook, event: RawHookEvent): void {
const o = runCountedHookProgram(hook, event);
if (o.kind !== "reaction") {
fail(`expected a react hook, got ${describeOutcome(o)}`);
}
if (o.reaction.kind !== "none") {
fail(`expected the hook to stay silent, got ${describeOutcome(o)}`);
}
}
// --- egress (network) — recordEgress runs ----------------------------------
//
// A `runHook(..., { recordEgress: true })` confines the hook AND records every
// host:port a proxy-honoring tool tried to reach (then blocks it). These assert
// over that record: a hook should phone home to nothing, or only to an allowlist.
/** Anything carrying recorded egress attempts (a runHook recordEgress result). */
interface HasEgress {
readonly egress: readonly EgressAttempt[];
}
const hostPort = (e: EgressAttempt): string => `${e.host}:${String(e.port)}`;
/** The `host:port` strings a run attempted, e.g. `["registry.npmjs.org:443"]`. */
export function egressHosts(r: HasEgress): string[] {
return r.egress.map(hostPort);
}
/** Assert the confined run made NO network egress attempt at all. */
export function assertNoEgress(r: HasEgress): void {
if (r.egress.length > 0) {
fail(
`expected no egress, but it tried to reach: ${egressHosts(r).join(", ")}`,
);
}
}
/**
* Assert every egress attempt went to an allowed host. `allowed` matches a host
* (exact string or regex), or a specific `host:port`. Any attempt outside the
* allowlist fails, naming the offender — exfil / unexpected-registry detection.
*/
export function assertEgressOnly(
r: HasEgress,
allowed: ReadonlyArray<string | RegExp>,
): void {
const ok = (e: EgressAttempt): boolean =>
allowed.some((a) =>
typeof a === "string"
? a === e.host || a === hostPort(e)
: a.test(e.host) || a.test(hostPort(e)),
);
const bad = r.egress.filter((e) => !ok(e));
if (bad.length > 0) {
fail(`egress to non-allowlisted host(s): ${bad.map(hostPort).join(", ")}`);
}
}
// --- file writes (IO) — confined runs --------------------------------------
//
// A confined `runHook` records what the hook wrote to its work dir
// (`r.filesWritten`, relative paths). These assert a hook touched only the files
// it should — e.g. "wrote nothing but its own state cache".
/**
* Anything carrying recorded file writes (a confined `runHook`/`runScript`
* result). `filesWritten` is OPTIONAL on purpose: `undefined` means the run
* never recorded writes at all, which is a different fact from `[]` ("recorded,
* and it wrote nothing"). See {@link requireWrites}.
*/
interface HasWrites {
readonly filesWritten?: readonly string[];
}
const matches = (f: string, p: string | RegExp): boolean =>
typeof p === "string" ? f.includes(p) : p.test(f);
/**
* The recorded write list, or a THROW when the run never recorded one.
*
* Writes are captured by diffing the work dir, which only happens on a confined
* run. An unconfined run therefore knows nothing about what the program wrote —
* and an assertion that silently treats "nobody looked" as "nothing happened"
* reports success while inspecting no evidence. That is the failure mode this
* whole library exists to catch in other people's harnesses, so it must not be
* this library's own default. Refuse instead, and name the fix.
*/
function requireWrites(r: HasWrites, assertion: string): readonly string[] {
if (r.filesWritten !== undefined) return r.filesWritten;
return fail(
`${assertion} cannot run: this result never recorded file writes, so ` +
`passing it would assert nothing. Writes are captured by diffing the work ` +
`dir, which only a CONFINED run does — pass \`{ sandbox: "auto" }\` (or ` +
`\`"strict"\`) to record them. Note confinement needs Linux + bubblewrap.`,
);
}
/** Assert the run wrote NO file matching `pattern` (substring or regex). */
export function assertNoWrite(r: HasWrites, pattern: string | RegExp): void {
const bad = requireWrites(r, "assertNoWrite").filter((f) =>
matches(f, pattern),
);
if (bad.length > 0) {
fail(
`expected no write matching ${String(pattern)}, but wrote: ${bad.join(", ")}`,
);
}
}
/** Assert every file the run wrote matches one of `allowed` (substring or regex). */
export function assertWroteOnly(
r: HasWrites,
allowed: ReadonlyArray<string | RegExp>,
): void {
const bad = requireWrites(r, "assertWroteOnly").filter(
(f) => !allowed.some((a) => matches(f, a)),
);
if (bad.length > 0) {
fail(`run wrote file(s) outside the allowlist: ${bad.join(", ")}`);
}
}
// --- subagent railway outcome (parse the worker's result block) ------------
//
// A subagent with a result() contract ends its turn with a vigiles:ok/err block.
// These wrap parseAgentResult so a test can assert the worker's *outcome* the
// same way it asserts a hook decision — the testing-framework payoff of the
// railway contract: `assertAgentOk(r.output)` instead of substring-matching prose.
/**
* Assert the worker's output is a success result, and return its `value`. With a
* `contract`, the value is validated against the success shape (a wrong/missing
* field fails the assertion). A malformed or error result throws.
*/
export function assertAgentOk(
output: string,
contract?: OutputContract,
): Record<string, unknown> {
const r = parseAgentResult(output, contract);
if (r.kind === "ok") return r.value;
const why = r.kind === "err" ? "returned an error result" : r.reason;
return fail(`expected a success result from the subagent, but ${why}`);
}
/**
* Assert the worker's output is an error result, and return its `error`. The
* railway's error track — proves the worker reported failure with rich detail
* (not that it crashed or returned prose). A malformed or success result throws.
*/
export function assertAgentErr(
output: string,
contract?: OutputContract,
): Record<string, unknown> {
const r = parseAgentResult(output, contract);
if (r.kind === "err") return r.error;
const why = r.kind === "ok" ? "returned a success result" : r.reason;
return fail(`expected an error result from the subagent, but ${why}`);
}
/**
* Assert the parsed result satisfies `predicate` — the general form, for
* checking rich detail (e.g. `(r) => r.kind === "ok" && r.value.files.length > 0`).
*/
export function assertAgentResult(
output: string,
predicate: (r: ParsedAgentResult) => boolean,
contract?: OutputContract,
): void {
const r = parseAgentResult(output, contract);
if (!predicate(r)) {
const detail = r.kind === "malformed" ? ` (${r.reason})` : "";
fail(`subagent result did not satisfy the predicate: ${r.kind}${detail}`);
}
}
function nameMatches(name: string, pat: string | RegExp): boolean {
return typeof pat === "string" ? name === pat : pat.test(name);
}
function toolNames(trace: Trace): string {
return trace.toolCalls.map((c) => c.name).join(", ") || "none";
}
// --- bare predicates over a Trace (the shared vocabulary, no throw) ---------
//
// Pure fns returning a value, so the SAME vocabulary runs in both consumers:
// the throwing `assert*` helpers below wrap them for the testing tier, and an
// eval `measure` reuses them directly as metrics (`measure: (t) => ({ safe:
// !usedTool(t, /merge|delete/) })`). Never one dual-purpose function.
/**
* Did the agent invoke a tool whose name matches `name` (string = exact,
* RegExp = test)? The predicate behind `assertToolUsed` / `assertToolNotUsed`.
*/
export function usedTool(trace: Trace, name: string | RegExp): boolean {
return trace.toolCalls.some((c) => nameMatches(c.name, name));
}
/** How many tools matching `name` the agent invoked. Behind `assertToolCount`. */
export function toolCount(trace: Trace, name: string | RegExp): number {
return trace.toolCalls.filter((c) => nameMatches(c.name, name)).length;
}
/**
* Did the `Skill` tool resolve `skill` (e.g. `"superpowers:test-driven-development"`)
* without error? The skill-activation predicate behind `assertSkillResolved`.
*/
export function skillResolved(trace: Trace, skill: string): boolean {
const call = trace.toolCalls.find(
(c) =>
c.name === "Skill" && (c.input as { skill?: string })?.skill === skill,
);
return call !== undefined && !call.isError;
}
/**
* Did the agent invoke a tool matching `name` whose INPUT satisfies
* `inputMatcher` — a tool-ARGUMENT predicate (DeepEval-style), e.g. an `Edit`
* that targeted the right file. The predicate behind `assertToolUsedWith`.
*/
export function toolUsedWith(
trace: Trace,
name: string | RegExp,
inputMatcher: (input: unknown) => boolean,
): boolean {
return trace.toolCalls.some(
(c) => nameMatches(c.name, name) && inputMatcher(c.input),
);
}
/**
* Does the agent's final answer (`trace.output`) contain `needle` (string =
* substring, RegExp = test)? The output predicate behind `assertOutputContains`
* — the DeepEval-style "what did the agent actually say" check.
*/
export function outputContains(trace: Trace, needle: string | RegExp): boolean {
return typeof needle === "string"
? trace.output.includes(needle)
: needle.test(trace.output);
}
/** All text the model received across every request (system + every message). */
function requestText(trace: Trace): string {
return trace.modelRequests
.map((r) => [r.system, ...r.messages.map((m) => m.text)].join("\n"))
.join("\n");
}
/**
* Did ANY request the model received contain `needle` — searching the system
* prompt and every message across all requests? The predicate that proves
* injected context *reached the model*: a SessionStart hook's `additionalContext`
* or a slash command's expansion. Harness tier only — the eval tier drives the
* real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
*/
export function requestContains(
trace: Trace,
needle: string | RegExp,
): boolean {
const text = requestText(trace);
return typeof needle === "string" ? text.includes(needle) : needle.test(text);
}
/**
* Did a hook matching `name` fire? Matches against both the hook label
* (`"PreToolUse:Edit"`) and the bare event (`"PreToolUse"`), so `/PreToolUse/`
* or `"PreToolUse:Edit"` both work. The predicate behind `assertHookFired`.
*/
export function hookFired(trace: Trace, name: string | RegExp): boolean {
return trace.hooks.some(
(h) => nameMatches(h.name, name) || nameMatches(h.event, name),
);
}
/** Did a hook matching `name` fire AND block (exit ≠ 0 / outcome "error")? */
export function hookBlocked(trace: Trace, name: string | RegExp): boolean {
return trace.hooks.some(
(h) =>
(nameMatches(h.name, name) || nameMatches(h.event, name)) && h.blocked,
);
}
/**
* Assert the agent invoked a tool whose name matches `name` (string = exact,
* RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
* skill/MCP/command surfaces are really about.
*/
export function assertToolUsed(trace: Trace, name: string | RegExp): void {
if (!usedTool(trace, name)) {
fail(
`expected a tool matching ${String(name)} to be used; tools used: [${toolNames(trace)}] (did you set transcript:true?)`,
);
}
}
/**
* Assert the agent did NOT invoke any tool matching `name` — the safety negative
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
*/
export function assertToolNotUsed(trace: Trace, name: string | RegExp): void {
// `find` is the negative of `usedTool` and narrows the hit for the message.
const hit = trace.toolCalls.find((c) => nameMatches(c.name, name));
if (hit) {
fail(
`expected no tool matching ${String(name)} to be used, but ${hit.name} was`,
);
}
}
/**
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
* without error — the correct skill-activation invariant, vs. grepping the body.
*/
export function assertSkillResolved(trace: Trace, skill: string): void {
if (skillResolved(trace, skill)) return;
// skillResolved is false → either no matching Skill call, or it errored.
// Reconstruct which, for a useful message.
const call = trace.toolCalls.find(
(c) =>
c.name === "Skill" && (c.input as { skill?: string })?.skill === skill,
);
if (!call) {
const seen = trace.toolCalls
.filter((c) => c.name === "Skill")
.map((c) => (c.input as { skill?: string })?.skill ?? "?")
.join(", ");
fail(
`expected the Skill tool to resolve "${skill}"; Skill calls: [${seen || "none"}]`,
);
}
fail(
`the Skill "${skill}" was invoked but errored: ${call.resultText.slice(0, 200)}`,
);
}
/**
* Assert the agent invoked a tool matching `name` whose INPUT satisfies
* `inputMatcher` — a tool-ARGUMENT invariant (DeepEval-style). Asserts not just
* *that* a tool ran but *with what args*, e.g. an `Edit` that targeted the right
* file: `assertToolUsedWith(r, "Edit", (i) => (i as { file_path?: string })
* .file_path === "src/x.ts")`. Needs `transcript`.
*/
export function assertToolUsedWith(
trace: Trace,
name: string | RegExp,
inputMatcher: (input: unknown) => boolean,
message?: string,
): void {
if (!toolUsedWith(trace, name, inputMatcher)) {
const seen = trace.toolCalls
.filter((c) => nameMatches(c.name, name))
.map((c) => JSON.stringify(c.input))
.join(", ");
fail(
message ??
`expected a ${String(name)} call whose input matches; ${String(name)} inputs: [${seen || "none"}]`,
);
}
}
/** Assert the agent's final answer contains `needle` (string substring / RegExp). */
export function assertOutputContains(
trace: Trace,
needle: string | RegExp,
): void {
if (!outputContains(trace, needle)) {
const shown = trace.output.slice(0, 200) || "(empty)";
fail(
`expected the agent's final answer to contain ${String(needle)}; got: ${shown}`,
);
}
}
/**
* Assert some request the model received contained `needle` — the "did the
* injected context land" invariant (SessionStart `additionalContext`, slash
* command expansion). Harness tier only; a zero-request trace fails with a hint
* that the eval tier can't capture requests.
*/
export function assertRequestContains(
trace: Trace,
needle: string | RegExp,
): void {
if (!requestContains(trace, needle)) {
const n = trace.modelRequests.length;
const hint =
n === 0
? " (no requests captured — modelRequests is harness-tier only)"
: "";
fail(
`expected a model request to contain ${String(needle)}; ${String(n)} request(s) captured${hint}`,
);
}
}
function hookNames(trace: Trace): string {
return trace.hooks.map((h) => h.name).join(", ") || "none";
}
/**
* Assert a hook matching `name` fired (and, with `{ blocked: true }`, that it
* blocked) — the honest hook-firing check, recorded from the run's stream rather
* than inferred from a marker file the hook had to write. Needs `transcript`.
*/
export function assertHookFired(
trace: Trace,
name: string | RegExp,
opts: { blocked?: boolean } = {},
): void {
if (!hookFired(trace, name)) {
fail(
`expected a hook matching ${String(name)} to fire; hooks fired: [${hookNames(trace)}] (did you set transcript:true?)`,
);
}
if (opts.blocked === true && !hookBlocked(trace, name)) {
fail(
`expected a hook matching ${String(name)} to block, but none did; hooks fired: [${hookNames(trace)}]`,
);
}
}
// --- sequence / budget invariants over the agent's actions -----------------
/**
* Assert how many tools matching `name` the agent invoked is within bounds — a
* budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
*/
export function assertToolCount(
trace: Trace,
name: string | RegExp,
bounds: { min?: number; max?: number; exactly?: number },
): void {
const n = toolCount(trace, name);
const ok =
(bounds.exactly === undefined || n === bounds.exactly) &&
(bounds.min === undefined || n >= bounds.min) &&
(bounds.max === undefined || n <= bounds.max);
if (!ok) {
fail(
`expected count of ${String(name)} to satisfy ${JSON.stringify(bounds)}, got ${String(n)} (tools: [${toolNames(trace)}])`,
);
}
}
/**
* Assert the named tools occurred in this order (as a subsequence — gaps allowed)
* — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
* Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
* Needs `transcript`.
*/
export function assertToolSequence(
trace: Trace,
names: ReadonlyArray<string | RegExp>,
): void {
let i = 0;
for (const c of trace.toolCalls) {
const want = names[i];
if (want !== undefined && nameMatches(c.name, want)) i++;
}
if (i < names.length) {
fail(
`expected tools in order [${names.map((n) => String(n)).join(" → ")}]; got [${toolNames(trace)}]`,
);
}
}
/**
* The escape hatch: assert any custom invariant over the full list of tool calls
* the agent made — for rules the helpers above don't express, e.g. "every Edit
* was preceded by a Read of that file". Needs `transcript`.
*/
export function assertToolCalls(
trace: Trace,
predicate: (calls: readonly ToolCall[]) => boolean,
message = "tool-call invariant failed",
): void {
if (!predicate(trace.toolCalls)) {
fail(`${message}; tools used: [${toolNames(trace)}]`);
}
}
/**
* Did `arm` succeed on EVERY trial for `metric` — τ-bench pass^k = 1? The
* reliability predicate over an eval report (vs. `improvement`, which reads the
* mean gap). Reads `report.arms[arm].stats[metric].passK`.
*/
export function reliable(
report: EvalReport,
arm: string,
metric: string,
): boolean {
return report.arms[arm]?.stats[metric]?.passK === 1;
}
/**
* Assert `arm` passed `metric` on every trial (pass^k = 1) — the reliability
* gate for a non-deterministic harness ("worked every time", not "on average").
*/
export function assertReliable(
report: EvalReport,
opts: { arm: string; metric: string },
): void {
if (!reliable(report, opts.arm, opts.metric)) {
const pk = report.arms[opts.arm]?.stats[opts.metric]?.passK;
fail(
`expected ${opts.arm} to pass ${opts.metric} on every trial (pass^k=1), got pass^k=${String(pk ?? "n/a")}`,
);
}
}
/** The gap on `metric` between two arms (arm − baseline). */
export function improvement(
report: EvalReport,
baseline: string,
arm: string,
metric: string,
): number {
const a = report.arms[arm]?.metrics[metric] ?? 0;
const b = report.arms[baseline]?.metrics[metric] ?? 0;
return a - b;
}
/**
* Did `arm` *significantly* beat `baseline` on `metric` — a positive gap whose
* two-sided Welch t-test p-value is below `alpha` (default 0.05)? The grounded
* upgrade over `improvement`: the noise floor is computed from the arms' spread,
* not hand-fed. False when either arm/metric is missing. See `src/stats.ts`.
*/
// eslint-disable-next-line max-params -- positional predicate mirrors `improvement` + alpha
export function significantlyBeats(
report: EvalReport,
baseline: string,
arm: string,
metric: string,
alpha = 0.05,
): boolean {
const c = compareArms(report, baseline, arm, metric, alpha);
return c !== null && c.delta > 0 && c.significant;
}
/**
* Assert `arm` significantly beats `baseline` on `metric` (positive gap, p < α).
* The statistical gate for a non-deterministic A/B — "the gap clears the noise",
* with the noise floor computed, not supplied. The honest version of
* `assertImproves(..., { by: se })`.
*/
export function assertSignificant(
report: EvalReport,
opts: { baseline: string; arm: string; metric: string; alpha?: number },
): void {
const c = compareArms(
report,
opts.baseline,
opts.arm,
opts.metric,
opts.alpha,
);
if (c === null) {
fail(
`no data to compare ${opts.arm} vs ${opts.baseline} on ${opts.metric}`,
);
}
const alpha = opts.alpha ?? 0.05;
if (!(c.delta > 0 && c.significant)) {
fail(
`expected ${opts.arm} to significantly beat ${opts.baseline} on ${opts.metric} (α=${String(alpha)}); Δ=${c.delta.toFixed(3)}, p=${c.pValue.toFixed(3)}`,
);
}
}
/**
* Assert `arm` beats `baseline` on `metric`. By default just a positive gap > `by`
* (pass the combined se to clear the noise floor by hand). Pass `{ significant:
* true }` to demand a Welch t-test at `alpha` instead — the computed noise floor.
*/
export function assertImproves(
report: EvalReport,
opts: {
baseline: string;
arm: string;
metric: string;
by?: number;
significant?: boolean;
alpha?: number;
},
): void {
if (opts.significant === true) {
assertSignificant(report, opts);
return;
}
const by = opts.by ?? 0;
const delta = improvement(report, opts.baseline, opts.arm, opts.metric);
if (delta <= by) {
fail(
`expected ${opts.arm} to beat ${opts.baseline} on ${opts.metric} by > ${String(by)}, got ${delta.toFixed(3)}`,
);
}
}
/**
* Assert the current run has not *regressed* against a committed baseline — the
* CI gate (Phase C). A regression is an arm×metric that moved **significantly in
* the bad direction** vs. `baseline` (Welch t-test, so sampling noise doesn't
* trip it; see `src/eval-baseline.ts`). Higher is better by default; list
* `lowerIsBetter` metrics (cost/latency) to flip them. Load the baseline with
* `readBaseline(path)` and record a fresh one with `writeBaseline(path, reports)`.
*/
export function assertNoRegression(
current: EvalReport | readonly EvalReport[],
baseline: BaselineFile,
opts?: DiffOptions,
): void {
const reports = Array.isArray(current)
? (current as readonly EvalReport[])
: [current as EvalReport];
const diff = diffReports(baseline, reports, opts);
if (!diff.passed) {
const detail = diff.regressions
.map(
(r) =>
`${r.report}/${r.arm}/${r.metric} Δ=${r.comparison.delta.toFixed(3)} p=${r.comparison.pValue.toFixed(3)}`,
)
.join("; ");
fail(`regression vs baseline: ${detail}`);
}
}
/**
* Assert a skill/behaviour triggered reliably — the gate for a skill's
* *activation*, over a {@link TriggerRateReport} from `measureTriggerRate`.
* `min` gates recall (fires when it should). With irrelevant prompts measured,
* `maxFalsePositive` gates the precision side (does NOT fire when it shouldn't)
* and `minPrecision` gates `firedRight / firedTotal` — so a too-broad
* description that hijacks unrelated work fails, not just a too-narrow one.
*/
export function assertTriggerRate(
report: TriggerRateReport,
opts: { min?: number; maxFalsePositive?: number; minPrecision?: number },
): void {
if (opts.min !== undefined && report.rate < opts.min) {
fail(
`expected a trigger rate ≥ ${String(opts.min)}, got ${report.rate.toFixed(2)} (${String(report.n)} runs)`,
);
}
if (
opts.maxFalsePositive !== undefined &&
(report.falsePositiveRate ?? 0) > opts.maxFalsePositive
) {
fail(
`expected a false-positive rate ≤ ${String(opts.maxFalsePositive)}, got ${(report.falsePositiveRate ?? 0).toFixed(2)}`,
);
}
if (
opts.minPrecision !== undefined &&
(report.precision ?? 0) < opts.minPrecision
) {
fail(
`expected precision ≥ ${String(opts.minPrecision)}, got ${report.precision === undefined ? "n/a (nothing fired)" : report.precision.toFixed(2)}`,
);
}
}
// --- jest / vitest matchers (expect.extend) --------------------------------
interface MatcherOutput {
pass: boolean;
message: () => string;
}
/**
* Custom matchers compatible with both vitest and jest. Register once:
*
* import { expect } from "vitest"; // or "@jest/globals"
* import { vigilesMatchers } from "vigiles";
* expect.extend(vigilesMatchers);
*
* expect(result).toHaveCreated("RESULT");
* expect(report).toBeatBaseline("vanilla", "gated", "caught");
*/
export const vigilesMatchers = {
toHaveCreated(received: HarnessTestResult, path: string): MatcherOutput {
const pass = received.file(path) !== null;
return {
pass,
message: () => `expected the run ${pass ? "not " : ""}to create ${path}`,
};
},
toBlock(received: HookRunResult): MatcherOutput {
const pass = received.blocked;
return {
pass,
message: () =>
`expected the hook ${pass ? "not " : ""}to block (exit ${String(received.exitCode)}, decision ${String(received.decision)})`,
};
},
// eslint-disable-next-line max-params -- jest/vitest matchers take positional args
toBeatBaseline(
received: EvalReport,
baseline: string,
arm: string,
metric: string,
by = 0,
): MatcherOutput {
const delta = improvement(received, baseline, arm, metric);
const pass = delta > by;
return {
pass,
message: () =>
`expected ${arm} ${pass ? "not " : ""}to beat ${baseline} on ${metric} by > ${String(by)} (got ${delta.toFixed(3)})`,
};
},
// The check-vocabulary veneer (Phase 1): ONE matcher works for EVERY check,
// carrying the check's own failure message. `expect(r).toPass(tool("Bash"))`.
toPass<T>(received: T, check: Check<T>): MatcherOutput {
const r = check.eval(received);
return { pass: r.pass, message: () => r.message };
},
toPassAll<T>(received: T, checks: readonly Check<T>[]): MatcherOutput {
const failed = evalChecks(received, checks).filter((x) => !x.pass);
return {
pass: failed.length === 0,
message: () =>
failed.length === 0
? "all checks passed"
: failed.map((f) => ` ✗ ${f.message}`).join("\n"),
};
},
};