Files
zernie__vigiles/docs/testing-matrix.md
T
zernie 9413061d9b feat!: split the public exports by COST — vigiles/test (free) vs vigiles/eval (spends money) (#149)
* refactor(exports)!: split the testing surface on COST, and name the cost

The exports map split the testing API by TEST TIER — `vigiles/testing`,
`vigiles/unit`, `vigiles/integration`, `vigiles/e2e`. That taxonomy already had
a home: the vitest projects, keyed on test-file NAMES. The exports map was a
second, competing implementation of it, and zero of 19 comparable packages
publish `./unit` / `./integration` / `./e2e` subpaths.

It splits on COST now, on two axes at once — the PATH and the NAME.

  "."             -> ./dist/test.js          (was ./dist/core/spec.js)
  "./spec"        -> ./dist/core/spec.js     unchanged
  "./eval"        -> ./dist/eval-surface.js  NEW
  ./linting ./hook ./claude-code ./codex ./adapter ./experimental
  ./vitest ./jest                            unchanged

DELETED: ./testing, ./unit, ./integration, ./e2e.  14 subpaths -> 11.

No `./test` subpath: the bare package name IS the testing surface. Two names for
one door is the duplication being removed (`.` + `./spec` used to be exactly
that). The cost: CLI-verb symmetry is now one-sided — `vigiles/eval` rhymes with
`vigiles eval`, the free half answers to the plain package name. Accepted;
`@playwright/test` publishes no subpath naming a test type at all.

THE NAME AXIS. Every runtime export on `./eval` is prefixed `paid_`:
paid_runEval, paid_measure, paid_measureArms, paid_measureTriggerRate,
paid_judge, paid_judged, paid_claudeEvalDriver. The path warns ONCE at the top
of a file; the name warns EVERY time, at the call site. Reading
`await judged(trace, "did it refuse?")` on line 140, the import is long out of
view. Not a new idiom here — `vigiles/experimental` already pairs a quarantined
path with an `experimental_` prefix; this applies the device to a second axis.

TYPES ARE NOT PREFIXED, deliberately: the prefix warns about CALLING something,
and a type is never called. `paid_EvalReport` would be noise — and since the 29
report types are re-exported from BOTH barrels, prefixing them would put `paid_`
names on the FREE surface.

⚠️ THE PREFIX OVERSTATES SLIGHTLY, AND THIS IS RECORDED, NOT HIDDEN.
`paid_judged(rubric, { judge: myFn })` calls your function and spends nothing;
the `measure*` family takes an injectable `evalDriver`. Only the DEFAULT path
bills (`paid_judge` is the one where `paid_` is exact — no injection seam).
`metered_` would be precise but reads a beat slower, and a warning not absorbed
at a glance is not a warning. Clarity won; the imprecision is written into the
module docstring, each symbol's JSDoc, and docs/testing-api.md.

ALSO FIXED: a PRE-EXISTING violation of the older convention. `makeDockerRuntime`
sat unprefixed among six `experimental_` siblings on `./experimental`. One
missing prefix is worse than none: the six that comply teach the reader that an
unprefixed name is safe. Renamed at source to `experimental_makeDockerRuntime`.

BOTH CONVENTIONS NOW HAVE A GATE, because that violation is exactly what prose
buys you. `scripts/check-export-prefixes.mjs` reads the built `.d.ts` with the
TypeScript compiler (so it can tell a value from a type instead of grepping) and
FAILS with exit 1 — it is a gate, not a nudge. Wired as `npm run exports:check`
and a CI step beside `docs:check`.

ITS TEST HAS BOTH HALVES, and mutation says so. `scripts/check-export-prefixes.test.ts`
(12 cases, colocated) fires on planted violations AND is silent on the legitimate
surface next door. Four mutations, each failing on its OWN assertion:

  drop alias resolution        -> 2 fail  (the vacuous-pass: `./eval` is built
                                  entirely from `export { x as paid_x }`, and an
                                  alias reports Alias, not Value — a check that
                                  skipped resolution would read every export as a
                                  type and pass on a fully unprefixed barrel)
  exit(1) -> exit(0)           -> 1 fail  (gate degrades to nudge)
  delete the ./experimental
    entry from SURFACES        -> 1 fail  (see below)
  never push a violation       -> 4 fail

🔴 THE THIRD MUTATION PASSED GREEN AT FIRST — a hole in the TEST, not the check.
An entire axis could stop being policed with nothing turning red: the axis test
passes its own surface list, the "real barrels" test is satisfied by a SHORTER
list, and the CLI test still exits 1 on its `./eval` defect. Fixed by asserting
SURFACES covers exactly the quarantined subpaths. The CLI test was vacuous the
same way (its fixture lacked `./experimental`, so it exited 1 either way) and now
runs a clean twin that must exit 0.

NOTHING WAS LOST, AND IT IS MEASURED. Union of the four old barrels: 190 symbols.
New: 183 on the root + 36 on ./eval, overlapping in 29 report types re-exported
from both on purpose. 183 + 36 - 29 = 190; set difference empty in BOTH
directions (comparing `paid_x` to `x` by identity). `vigiles/testing` was NOT a
superset of `vigiles/unit`: 11 symbols (all of guardrail-check.ts, plus
decideHook / parseHookOutput) existed only on `unit` and are on the root now.

TWO HONEST COSTS, in the docstrings rather than hidden:
1. The type coupling crosses the boundary and cannot be removed. ~27 helpers
   (assertImproves, compareArms, diffReports, cost/latency/tokens,
   formatEvalReport, …) read an eval RESULT and spend nothing, so they are free
   and unprefixed — but their argument types are defined on the paid side. Hence
   the 29 duplicated types, so nobody imports `./eval` for a type alone.
2. Free is not fast. The old "real CLI, no API key" tier collapses into the root,
   so the import no longer signals that `runHarnessTest` spawns a real claude
   under bubblewrap for ~40s. Duration lives in the runner config now.

`.` moving off `core/spec` means every doc example importing a spec builder from
bare "vigiles" now says "vigiles/spec" (11 blocks). `docs:check` is what caught
those.

Entry points are enumerated in four places independently of package.json; all
updated: scripts/api-extractor.mjs (1:1 with exports now — `vigiles.api.md` is
the test surface, `vigiles-spec.api.md` is new), typedoc.json, eslint.config.mjs
(AGNOSTIC_SURFACE + the no-barrel-imports list; both rules verified by mutation
to still fire on the new filenames), vitest.config.mjs comment.

Gates, by name and in order: npx vitest run (2939 passed, 25 skipped, 174 files)
· npm run lint (0 errors, 206 warnings — unchanged baseline) · npx prettier
--check . · npm run api:check (11 entries, no drift) · npm run build · plus
npm run docs:check (91 blocks, 0 findings) and npm run exports:check. Also
`node dist/cli.js lint` exits 0 with every compiled-markdown hash valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* ci: закрыть класс «сгенерированный файл протух, а CI зелёный»

Шаг «Check generated types compile» перегенерирует файл и компилирует результат.
Свежесгенерированный компилируется всегда — поэтому закоммиченная копия в проверке
НЕ УЧАСТВУЕТ. Замер 16.08: отслеживаемый `.vigiles/generated.d.ts` отставал на
314 строк / ~147 записей, и CI всё это время была зелёной.

Название шага честное — он проверяет, что типы КОМПИЛИРУЮТСЯ, а не что они
актуальны. Именно поэтому дыра пережила ревью: шаг делает ровно то, что обещает.

Предсказание записано за восемь дней до: в .gitignore репозитория zernie/mine
08.08 этим обосновано, почему тот репозиторий свою копию не отслеживает — «drift
in a generated file is invisible unless something regenerates it with --check.
Upstream vigiles tracks its own copy and it IS stale on main for exactly that
reason». Через восемь дней стало хуже. Запись предсказания не помогла; помогает
строка в CI.

Проверено обеими половинами: на актуальном файле проходит, на подложенном
дрейфе (одна строка в конец) падает.

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-16 22:46:26 +05:00

22 KiB

Testing matrix — what's covered, and how

Every use case of the harness-testing API, mapped to the tier that tests it and the file that proves it. Two kinds of coverage:

  • Unit — deterministic, model-free, runs in npm test / npm run test:vitest / npm run test:jest / npm run test:types. These are the contract.
  • Integration (CI) — needs the real claude CLI and/or a model, so it can't be a unit test. Covered by the canonical examples run in the harness CI job (deterministic tier, no API key) or by bench/ + examples evals (real model).

Coverage

Use case Tier Where
Hook unit tier — runHook (exit codes / stdin event / env / JSON decision) unit src/run-hook.test.ts
Hook decision logic (parseHookOutput / decideHook) unit src/run-hook.test.ts
Subagent PreToolUse tool-contract rail — hook-runtime agent (parse contract / allow-deny / hook ⇄ allowlist agree / real built-CLI hook via runHook; grounded on the vendored ui-visual-validator) unit src/adapters/claude-code/agent-runtime.test.ts
Plugin loader — inline plugin.json hooks unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — hooks string-path unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — hooks/hooks.json convention unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — repo .claude/settings.json unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — manifest-wins precedence unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — bare dir (no hooks) unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — agents/ + commands/ materialized + surface warnings unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — empty-machine + MCP + dangling-intra-plugin-ref warnings unit src/adapters/claude-code/plugin-loader.test.ts
Plugin loader — in-repo dogfood unit src/adapters/claude-code/plugin-loader.test.ts
Vendored real-plugin conformance (loadPlugin invariants, pinned + offline) unit src/adapters/claude-code/vendor.test.ts
Dogfood conformance over vigiles's own skills (every SKILL.md loads + has name + non-empty description; hook scripts resolve at the plugin root) unit src/adapters/claude-code/skills-dogfood.test.ts
Untested-surface detector (untested-skill/untested-subagent/untested-hook rules: colocation + content-reference coverage, vigiles:ignore-test opt-out, hook-script discovery) unit src/test-coverage.test.ts
resolveHarness — merge / passthrough / undefined unit src/adapters/claude-code/plugin-loader.test.ts
Eval aggregation (mean / std / se / n / pass^k) + report formatting unit src/eval.test.ts
Eval usage capture + aggregation (parseUsage / aggregateUsage, cost/latency/tokens) unit src/eval.test.ts
Eval record/replay cache (cacheKey / snapshot+restore / replay skips the model) unit src/eval-cache.test.ts, src/eval.test.ts
Eval concurrency + rate-limit retry + maxCostUsd abort (runPool / isRateLimited) unit src/eval.test.ts
Trigger-rate orchestration (measureTriggerRateWith) unit src/eval.test.ts
Significance stats (welchTTest / compareArms / tPValueTwoSided / incomplete-beta vs t-table) unit src/stats.test.ts
Assert/predicate helpers (create/turns/hook-block; tool used/notUsed/count/sequence/with; skillResolved; output/request-contains; hookFired; improvement/improves; reliable; significant; triggerRate) unit src/harness-assert.test.ts
Matchers register + pass under vitest (via the vigiles/vitest entry) unit test/runners/matchers.vitest.mjs
Matchers register + pass under jest (via the vigiles/jest entry) unit test/runners/matchers.jest.cjs
Matcher types augment vitest/jest expect unit test/types/smoke.vitest.ts, smoke.jest.ts
CLI runner (discoverScripts/runScripts/summary) unit src/adapters/claude-code/run-scripts.test.ts
Judge verdict parsing (parseJudgeOutput) unit src/judge.test.ts
runHarnessTest end-to-end, incl. plugin: integration (CI) examples/harness/policy-gate.harness.mjs, plugin-cohesion.harness.mjs
withHarness (auto-cleanup wrapper) integration (CI) examples/harness/plugin-cohesion.harness.mjs
runEval end-to-end, incl. plugin arm integration (real model) bench/evals/refs-hook.eval.mjs, examples/harness/skill-outcome.eval.mjs
measureTriggerRate end-to-end (skill activation via pluginDir) integration (real model) examples/harness/skill-trigger-rate.eval.mjs
Dogfood trigger-rate on vigiles's own model-invocable skills (recall + precision via irrelevantPrompts) integration (real model) examples/harness/dogfood/{test-harness,generate-logo}.trigger.eval.mjs
judge() model call integration (real model) (parsing is unit-tested; the spawn is not)

Surface coverage — which plugin surface is reachable at which tier

A Claude Code plugin/repo has several surfaces. They are reachable at different tiers, because some only do anything under a real model:

Surface Hook unit (runHook) Deterministic (runHarnessTest) Eval (runEval)
Hooks — SessionStart / Stop / UserPromptSubmit ✅ logic ✅ fires in machine ✅
Hooks — Bash PreToolUse / PostToolUse ✅ logic ✅ fires in machine ✅
Hooks — Edit/Write PreToolUse / PostToolUse ✅ logic ⚠ headless-gated (drive via Bash) ✅
Hooks — PreCompact / Notification / SessionEnd / SubagentStop ✅ logic — (mock can't trigger) partial
CLAUDE.md / instruction files — ✅ present in context ✅ moves behaviour
Skills — ✅ resolves via pluginDir ✅ activation (measureTriggerRate)
Subagents (agents/) ✅ tool-contract rail ⚠ materialized; rail not live-armed ✅ (Task)
Slash commands (commands/) — ⚠ materialized, not invoked ✅
MCP servers — — (not wired; warned) bring-your-own

loadPlugin(...).warnings surfaces the ⚠ rows for a given plugin, so a "load the whole plugin" test never silently runs an empty machine (e.g. a subagents-only plugin like wshobson/agents tdd-workflows, which ships 2 agents + 4 commands and no hooks).

Below the three runtime tiers sits a static, model-free floor the table doesn't have a column for: skills load and resolve with a usable description (src/adapters/claude-code/skills-dogfood.test.ts), and vigiles lint's per-kind untested-skill / untested-subagent / untested-hook rules flag any skill/hook/subagent that ships with no test or eval at all — the "is there even a test?" check that precedes "does the test pass?".

The subagent tool-contract rail (hook-runtime agent) makes the agents/ Hook-unit cell ✅: the generated PreToolUse hook that enforces an agent's declared tools: is runHook-testable like any other hook, and the declared list and the enforced rail are compiled from one source (proven by a round-trip test). The deterministic cell stays ⚠ for an honest reason — Claude Code doesn't surface the active subagent to hooks, so arming the rail in a live session (.vigiles/active-agent.json) is still unsolved; the logic is proven at the unit tier, not yet end-to-end in a real dispatch.

What is intentionally not unit-tested

Only the real-claude subprocess is out of the gate: runHarnessTest, withHarness, judge()'s model call, and runEval/measureTriggerRate's default spawnAgent runner spawn the CLI (and, for evals, a real model). A unit test can't drive that deterministically — but everything around it is pinned. The eval orchestration (runEvalWith / measureTriggerRateWith) takes an injected runner, so the loop, cache, concurrency, budget, usage aggregation, and significance run against canned stream-json with no model; the mock model (src/mock-model.ts), the loader, the parsing, and the matchers are unit-tested too. End-to-end behaviour is exercised by the example suite in CI: the deterministic examples (*.harness.mjs) run with no API key (real claude + scripted mock model), so they're CI-affordable; the evals (*.eval.mjs) cost real model calls and run manually / in a keyed job.

JavaScript or TypeScript? Both.

vigiles test / vigiles eval accept harness/eval scripts in either language — the glob is *.harness.* / *.eval.* over .mjs .cjs .js .mts .cts .ts (src/adapters/claude-code/run-scripts.ts). Discovery is name-only and scoped to those infixes — never *.test.* / *.spec.* — so it won't pick up your vitest/jest/Playwright files (and it ignores node_modules + dist). A JavaScript file runs as a plain node <file>; a TypeScript file is run through tsx when it's installed, else Node's native type stripping (Node ≥ 22.6), with an actionable error if neither is available. So you can author your own tests in TS and get types end-to-end, or in JS for zero setup.

A bare vigiles eval (no target) discovers the whole tree, and each eval runs the real model on your subscription — so it asks before fanning out: name the eval(s), pass --all, or answer the terminal prompt; headless with none of those, it refuses (exit 2) rather than spend quota. vigiles test is free/deterministic and always runs. And the deterministic API folds into an existing vitest/jest suite (vigiles/vitest · vigiles/jest matchers) — the CLI is the zero-dep floor, not a required second runner; eval stays its own surface (real-model, statistical).

The API itself is fully TypeScript — src/*.ts, shipped with .d.ts; the whole unit suite (src/*.test.ts) and the type smokes (test/types/*.ts) are TypeScript, so the project dogfoods the typed path. Under a runner (vitest / jest) the vigiles/vitest / vigiles/jest entries add typed matchers.

The canonical examples under examples/harness/ stay .mjs on purpose — not because TS is unsupported, but because they're the lowest-common-denominator demos: they must run with nothing but Node (no tsx, no loader, no build) so a copy-paste into any repo Just Works and the CLI-fallback tier stays dependency-free. Your own scripts are under no such constraint — reach for .ts whenever you want types.

  • Zero-dep CLI tier → .mjs (or .ts if you have tsx/Node ≥ 22.6), via vigiles test.
  • Runner tier → .ts, full types via vigiles/vitest / vigiles/jest.

If you want a typed worked example, the type smokes in test/types/ show the API used from .ts with the matcher types applied.

Running the tiers in CI

CI runs every tier except evals — not one at a time, and not all crammed into one job. Each tier runs where it's cheapest, so a fast unit failure surfaces before the slow, privileged ones.

Tier Run it with This repo's CI job Needs
Unit (+ coverage gate) npm test / npm run coverage test nothing (model-free)
Reference verification vigiles lint (+ the Action via uses: ./) check nothing
Deterministic harness vigiles test (*.harness.{mjs,ts}) harness the real claude CLI, no key
e2e (allowlisted egress) npm run test:e2e e2e (privileged container) bubblewrap + slirp4netns
Eval (real model) npm run test:eval (*.eval.{mjs,ts}) manual — not in CI model auth ($)

Best practice (what this maps to): keep the model-free tiers (unit / reference / deterministic) on every push and PR so feedback is fast and free; isolate the slow or privileged tiers (real-egress e2e) in their own jobs; and run the paid eval tier on demand, not on every commit — its non-determinism and cost make it a release/regression gate, not a per-push check. The per-tier npm run test:unit | test:integration | test:e2e scripts exist for exactly this split (see vitest.config.mjs).

Skips are loud, never a silent green. vigiles test classifies each script pass / skip / fail. A unit-tier runHook test needs no claude and always runs; a tier that can't run (deterministic with no claude, e2e with no bubblewrap) reports a loud ⊘ SKIPPED, tallied separately as "N skipped" — it never counts as a ✓ passed. A skip passes by default (capabilities differ per job — this repo runs the egress tier under e2e, not harness), but in a CI job that asserts the capability is present, add vigiles test --no-skip so a skipped tier fails: a green-with-skips is itself untested surface. A standalone script emits a skip with skip(reason) from vigiles.

And so is a file that verified nothing. A script that exits clean having recorded zero checks reports ∅ … 0 CHECKS, tallied apart from passed — the shape being a harness that defines tests (export default { … }) and never calls them, which no exit code can distinguish from a pass. Non-fatal by design: it never turns a build red, and a script that doesn't import vigiles cannot report, so it stays a plain pass. recordCheck() reports assertions made another way.

See also

  • docs/harness-testing.md — the full guide (four layers: verify-refs / hook unit / deterministic / eval, runner-agnostic usage, plugin loader, variance, judge, CLI fallback).