← Back to home

Testing — what goes where

Loom has many test tiers because it generates code for eleven targets (five backends, six frontends) and the interesting failures live at different altitudes. This doc is the placement guide: given a change, which tier proves it, and where a new test belongs. For the exact run commands and env-var gates, CLAUDE.md → "Build & test commands" is the authoritative list; the CI column here maps each tier to its workflow.

The mental model

Two axes decide where a test lives:

Per-PR coverage is mostly structural + the docker-free behavioral tiers; the heavy cross-backend/runtime behavioral coverage is nightly.

The tiers

Tier Proves Run CI workflow Per-PR?
Fast vitest suite Parsing, validation, macro expansion, lowering, IR validate, per-backend emission (string-match), printer round-trips, layering invariants. The bulk of the suite. npm test (npm run test:changed = only the files whose import graph reaches a change vs origin/main; opt-in — the graph costs ~90s to compute and a generator edit still selects most of the suite) test.yml ✅
Langium drift ddd.langium ↔ committed src/language/generated/ are in sync. npm run langium:generate langium-generated.yml ✅
test/ typecheck test/** — and src/** under the test project's options — COMPILES. tsconfig.json excludes test/, so nothing else reads it, which is why an exhaustiveness Record<Union, …> written in a test could silently prove nothing. Landed as a shrink-only RATCHET over 768 errors; drained to zero and promoted to a plain gate (M-T9.50), so any error now fails. ~35s. npm run test:typecheck test.yml (lint job) ✅
Behavioral — api / unit The generated Hono backend behaves: boots on PGlite (in-process, no docker) and runs the DSL-emitted test e2e … against <node> (api) + aggregate test "…" (unit) suites. DoD rollup onto the requirements graph. cd test/behavioral && npm ci && node run.mjs behavioral-e2e.yml ✅
Behavioral — ui The generated React frontend behaves: vite build it, serve it + the Hono backend from one in-process origin, run the emitted test e2e … against <react> Playwright round-trips in headless Chromium. cd test/behavioral && npm ci && node run-ui.mjs behavioral-ui-e2e.yml ✅
Behavioral — HEEx ui The rendered LiveView behaves: boot the generated Phoenix app against a real postgres, then drive a create → LIST → detail round-trip through the rendered DOM in headless Chromium (the emitted .ui.spec.ts + the hand-written list leg). Asserts on rendered TEXT, so a 200 with an empty page fails. npm run test:behavioral-heex-ui behavioral-heex-ui-e2e.yml ✅
Per-backend build Generated backend compiles clean. TS (tsc --noEmit + tsup), .NET (build /warnaserror, and the emitted xUnit project built and run), Java (gradle bootJar with javac at -Xlint:all -Werror), Python (uv + ruff + mypy + pytest), Elixir (plain Ecto/Phoenix mix compile --warnings-as-errors). npm run test:tsc / :dotnet / :java / :python / :phoenix hono-build / dotnet-build / java-build / python-build / elixir-vanilla-build ✅
Generated frontend build Generated frontend typechecks + vite builds, per {example × pack}. React (tsc), Svelte (svelte-check + build), Vue (vue-tsc + build). npm run test:tsc-react / :svelte-build / :vue-build generated-react-build / generated-svelte-build / generated-vue-build ✅
Generated frontend runtime The built React/Vue/Svelte/Angular bundle actually runs — vite build (ng build for Angular) + preview + the emitted Playwright smoke spec (every param-less route loads). Pure client-side, no backend. npm run test:react-e2e / :vue-e2e / :svelte-e2e / :angular-e2e generated-{react,vue,svelte,angular}-e2e ✅ path-scoped (each fires on its own framework's emitters, the shared frontend/walker/pack seams and its example + stack — see the file's pull_request.paths; it carries a !src/generator/{java,dotnet,python,elixir}/** negation tail so a backend-only PR doesn't wake it)
Observability e2e The generated backend emits the catalog envelope on stdout (per backend). npm run test:obs (+ :obs-dotnet/-phoenix/-java/-python) *-obs-e2e.yml ✅ path-scoped (src/ir/** + that backend's emitters + src/generator/_obs/** + its own test file; plus a nightly run on main)
OIDC auth e2e The generated backend really performs the OIDC code flow against a dockerized Keycloak — login → callback → token → User mapping — plus the negative-authz half (a requires-gated find 403s the authorized-but-not-permitted caller, 401s the anonymous one). npm run test:auth-e2e (+ :-dotnet/-java/-python/-phoenix, :-compose) {hono,dotnet,java,python}-oidc-e2e ✅ path-scoped. The two compose legs (elixir-oidc-e2e, auth-oidc-compose-e2e) build images inside the runner and stay post-merge / run-oidc label.
Runtime feature e2e One feature booted per backend against a real postgres: tenant isolation (tenancy-e2e), migrate-chain ≡ fresh-create + data survival (migration-evolution-e2e), cross-deployable broker eventing (channels-e2e), typed in-system calls (api-call-e2e), the LiveView route smoke + the Phoenix-embeds-React serve (phoenix-ui-e2e). see "Running any CI gate locally" below tenancy-e2e / migration-evolution-e2e / channels-e2e / api-call-e2e / phoenix-ui-e2e ⚖️ split (2026-10-05): phoenix-ui-e2e, migration-evolution-e2e and tenancy-e2e's flat cells are per-PR path-scoped; the rest of tenancy-e2e, channels-e2e and api-call-e2e run in the merge queue (their only pre-merge run unless a run-* label asks earlier), as does migration-evolution-e2e
Conformance — parity Cross-backend OpenAPI / wire-shape parity (the contract is identical across backends). part of conformance conformance-parity.yml ✅
k8s build generate system --k8s → helm lint + helm template | kubeconform. npm run test:k8s k8s-build.yml ✅
Pairwise corpus Generated feature×feature / feature×adapter crossings (capability × storage shape × authz × persistence adapter) get an answer, not an exception — plus node tsc and psql -f schema-load over an all-pairs cover. The intersections the one-fixture-per-feature corpus structurally cannot reach (#2412, #2387/#2391, #2492). See docs/audits/pairwise-corpus-findings-2026-08.md. npm run test:pairwise-corpus (+ :-tsc / :-schema-load) pairwise.yml ⚖️ split: the generation sweep is per-PR path-scoped (draft-gated, ~19s, no toolchain); the tsc compile oracle and the psql schema-load oracle run nightly / on the run-pairwise label (not in the merge queue — a discovery instrument)
Pack spacing — DOM The cross-pack spacing contract measured in a real browser (getComputedStyle) instead of read off the templates: the distances a library actually renders, plus the phone-width check that no pack scrolls the DOCUMENT sideways. The template half runs in the fast suite (test/generator/_packs/pack-spacing-contract.test.ts); this half needs a booted generated app per pack, so it takes the URLs of ones you already have running. LOOM_PACK_SPACING_URLS='{"react-mantine":4200}' npm run test:pack-spacing-dom (or node scripts/measure-pack-spacing.mjs '{…}' --out measures.json) — (local / on-demand) ❌ opt-in
Conformance — full The DSL-emitted behavioral test e2e suites against the full docker stack (all backends + Postgres). LOOM_E2E=1 npm run test:e2e conformance-full.yml ❌ nightly / run-conformance label
k8s cluster e2e One backend's chart installed into a kind cluster + Postgres; real read/write round-trips through the migrated DB. npm run test:k8s-e2e k8s-e2e.yml ❌ nightly / e2e-k8s label
Playground e2e The browser playground end to end (editor → generate → in-browser bundle → boot → preview) against the production build. Network-gated (esm.sh / jsdelivr / npm). cd web && npx playwright test playground-e2e.yml ❌ post-merge / daily / dispatch

Per-PR column verified against the workflow on: blocks 2026-10-05, after wave C3 packet 3e moved the last never-gating runtime legs pre-merge (docs/ci-gating.md → "The never-gating legs, re-measured (2026-10-05)"); wave G1 of docs/new-plan/verification-waves-2026-09.md had promoted the obs, native-OIDC, SPA-smoke and VO legs before it. Nothing re-tests on push: main since 2026-10-20 (docs/ci-gating.md → "Post-merge net").

Choosing where a new test goes

Walk these in order; stop at the first match.

  1. New/changed grammar, validator, macro, lowering, or IR shape → fast vitest suite (test/{language,macro,ir,...}). Add a parsing test

    • a negative validator test where relevant. After a grammar edit, re-run langium:generate and commit the output.
  2. A backend/frontend now emits something new or different → fast vitest suite, one generator test per affected target (test/generator/<platform>/), string-matching the emitted source. This is the default home for generator changes — it's fast and exact. If the emitted shape is captured in a baseline fixture (test/fixtures/baseline-output/, regenerated via scripts/capture-baseline-fixture.mjs), regenerate it.

  3. The emitted code might not compile (a new import, type, or dependency) → the matching per-backend / generated-frontend build gate. Run at least one LOOM_TS_BUILD=1 / LOOM_REACT_BUILD=1 pass locally.

  4. The emitted code must behave — domain logic, an api round-trip, a page form → a behavioral tier:

    • api or pure-domain on the Hono backend → behavioral api/unit (it's already exercised if the example is in test/behavioral/corpus.json; add a test e2e … against <node> or aggregate test to the .ddd).
    • a React page/form round-trip → behavioral ui (add a test e2e … against <react>; the corpus case needs "ui": true).
    • cross-backend behaviour (.NET/Java/Phoenix/Python) → it rides the conformance-full docker leg; keep the assertion in the emitted test e2e so every backend runs it.
  5. Cross-backend contract (OpenAPI / wire shape) → conformance parity (per-PR). It's mostly automatic from wireShape; a new contract dimension goes in the parity harness.

  6. Something only observable in the real browser playground (worker IO, service-worker handoff, iframe synthesis, IDB, in-browser npm bundle) → playground e2e (web/e2e/). Remember it's post-merge, network-gated, and self-skips when the sandbox can't reach the registry — so it's a signal, not a per-PR gate.

Behavioral corpus constraint

The docker-free behavioral tiers (test/behavioral/) require each corpus system to have exactly one platform: node (Hono) deployable, so the host-agnostic, path-matched dispatch is unambiguous. Multi-backend systems (examples/showcase.ddd, examples/acme.ddd) stay in the docker conformance-full leg. See test/behavioral/README.md for the full runner mechanics (one-origin serving, async-spawn, the DoD rollup).

Why so many gates

Structural tests prove code is emitted; they can't catch a generated form that submits a malformed value, a backend that 500s on a real round-trip, or a duplicate-React bundle. Those only fail when the code runs — which is what the behavioral/runtime/conformance tiers exist for. Conversely, booting a docker stack to assert a string appears in a file would be absurdly slow. Put each assertion at the lowest altitude that can actually catch the failure.

In a fresh git worktree, run npm install first. test/platform/packaging-split-fs-discovery.test.ts and packaging-split-core-pkg.test.ts call discoverBackendsFs(repoRoot), which resolves the node_modules/@loom/backend-hono-v4|v5 workspace symlinks; a worktree with no node_modules fails both although the code is fine.

Proving a toolchain change neutral — scripts/compare-toolchains.mjs

A refactor that claims "no behaviour change" (a typing-pass cutover, an IR reshape, an emitter dedup) proves it against the models the repo already has:

node scripts/compare-toolchains.mjs                       # base = merge-base with origin/main
node scripts/compare-toolchains.mjs --match examples/     # a subset, for a quick look
node scripts/compare-toolchains.mjs --only diagnostics    # skip the generate leg

It builds the base in a detached worktree, then compares two legs over every tracked .ddd. The first is ddd parse --json diagnostics; the second is ddd generate system output, byte for byte, for each model the base validates. Each side reads its own copy of the sources, because generate system writes <dir>/.loom/<model>.migration-history.json next to the model and reads it back on the next run. Exit 0 means identical, 1 means a difference (listed in <out>/report.json). The full run takes about an hour on 4 cores. Put the result in the PR body; if the change is meant to move some output, list the intended differences there.

Running any CI gate locally — the reverse index

Every CI gate runs locally. Do not push a commit just to see whether a gate passes — that burns the shared ~20-slot runner pool and turns a 3-minute local check into an hour of queue. This table is the reverse index: given a workflow (check) name, the local command that runs the same thing. Prerequisites: docker = start the daemon first (dockerd >/tmp/dockerd.log 2>&1 &, then wait for docker info); pg = a Postgres on 127.0.0.1:5432 with user/password postgres (one docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18-alpine serves every pg row below, or the suite starts its own sidecar where noted); mirror = LOOM_HEX_MIRROR=1 when hex.pm is unreachable through the proxy (docs/tools.md → "Compiling generated backends in Docker" has the full per-toolchain write-ups).

Completeness is pinned by test/system/local-run-mapping.test.ts — every workflow file must have a row here, so a new gate without a local recipe fails the fast suite.

Workflow Local command Needs
test.yml npm test (shards: -- --project unit --project mocked --shard=i/4; corpus job: -- --project corpus; nightly coverage: npm run test:coverage); lint job: npm run lint && npm run test:biome-gen && npm run test:typecheck && npm run test:contrast && cd web && npx tsc -b && npm run test:ddl —
langium-generated.yml npm run langium:generate && git diff --exit-code src/language/generated —
workflow-lint.yml docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest -color docker
pr-gate.yml nothing to run — it aggregates the other checks; its decision core: npx vitest run test/system/pr-gate.test.ts —
hono-build.yml npm run test:tsc —
dotnet-build.yml npm run test:dotnet (no host SDK? build in mcr.microsoft.com/dotnet/sdk:10.0) docker
java-build.yml npm run test:java — host JDK is too old; build in gradle:9-jdk25 per docs/tools.md. CI compiles at javac -Xlint:all -Werror through test/e2e/support/java-werror.init.gradle: pass it with -I on a hand-run gradle, or copy it into ~/.gradle/init.d/ for npm run test:java (and remove it afterwards — init.d applies to every Gradle build on the machine) docker
python-build.yml npm run test:python (uv on host) —
elixir-vanilla-build.yml LOOM_HEX_MIRROR=1 npm run test:phoenix (one job, PARALLELISM fixtures at a time, dependencies built once per dependency set — test/e2e/support/mix-workspace.ts; narrow with LOOM_PHOENIX_VANILLA_BUILD_CASE=<f>.ddd[,…]) docker, mirror
corpus-build.yml npm run test:tsc-corpus / test:dotnet-corpus / test:java-corpus / test:python-corpus (narrow: LOOM_CORPUS_<BACKEND>_CASE=<feature>; the tsc and python legs run PARALLELISM features at a time — tsc installs each distinct dependency set once, python seeds every mypy run from one warm cache; the java leg runs javac at -Xlint:all -Werror — reproduce as in the java-build.yml row; the dotnet leg also builds the emitted Tests/<Ns>.Tests xUnit project /warnaserror and runs it with dotnet test, *IntegrationTests filtered out — waivers in DOTNET_TEST_WAIVERS, stale entries fail) docker (java/dotnet)
corpus-elixir-build.yml LOOM_HEX_MIRROR=1 npm run test:elixir-corpus (one job, PARALLELISM features at a time, dependencies built once per dependency set; narrow with LOOM_CORPUS_ELIXIR_CASE=<id>[,<id>…]; per project: prod mix compile --warnings-as-errors + ecto.migrate, then MIX_ENV=test mix test --warnings-as-errors on the emitted ExUnit suite — waivers in ELIXIR_TEST_WAIVERS, stale entries fail) docker, mirror
generated-react-build.yml npm run test:tsc-react (shard: LOOM_REACT_BUILD_CASE=<ddd>:<pack>) —
generated-vue-build.yml npm run test:vue-build —
generated-svelte-build.yml npm run test:svelte-build —
generated-angular-build.yml npm run test:angular-build —
generated-feliz-build.yml node scripts/feliz-scaffold-smoke.mjs (+ the other scripts/feliz-*-smoke.mjs scenarios; needs dotnet 10 — container works) docker
generated-flutter-build.yml flutter analyze + flutter build web over generated output — Flutter SDK container recipe in docs/tools.md → "Compiling generated FRONTENDS locally" docker
generated-react-e2e.yml npm run test:react-e2e (pack via LOOM_REACT_E2E_PACK) —
generated-vue-e2e.yml npm run test:vue-e2e —
generated-svelte-e2e.yml npm run test:svelte-e2e —
generated-angular-e2e.yml npm run test:angular-e2e —
behavioral-e2e.yml cd test/behavioral && npm ci && node run.mjs (PGlite — no docker) —
behavioral-e2e-python.yml cd test/behavioral && node run-python.mjs pg
behavioral-e2e-dotnet.yml cd test/behavioral && node run-dotnet.mjs pg
behavioral-e2e-dapper.yml cd test/behavioral && node run-dapper.mjs pg
behavioral-e2e-java.yml cd test/behavioral && node run-java.mjs pg
behavioral-e2e-elixir.yml cd test/behavioral && node run-elixir.mjs pg, mirror
behavioral-e2e-mikroorm.yml cd test/behavioral && node run-mikroorm.mjs pg
behavioral-ui-e2e.yml cd test/behavioral && node run-ui.mjs —
behavioral-ui-vue-e2e.yml cd test/behavioral && node run-ui.mjs sales-system-vue (the Vue/Vuetify round-trip + the numeric LIST probe; same harness as the React cell) —
behavioral-heex-ui-e2e.yml npm run test:behavioral-heex-ui (add LOOM_HEX_MIRROR=1 behind a TLS-fingerprint proxy) pg, mirror
conformance-parity.yml LOOM_E2E_STRICT_PARITY=1 npx vitest run test/e2e/e2e.test.ts (spec-level parity, no stack boot) —
conformance-full.yml LOOM_E2E=1 npm run test:e2e docker
differential-report.yml LOOM_DIFF_REPORT=1 npx vitest run test/e2e/e2e.test.ts docker
schema-load.yml npm run test:schema-load (or point LOOM_MIGRATION_PG_URL at a running pg) docker
pairwise.yml npm run test:pairwise-corpus (per-PR leg) · test:pairwise-corpus-tsc · test:pairwise-corpus-schema-load (or LOOM_MIGRATION_PG_URL); shard the compile leg with LOOM_PAIRWISE_COMPILE_CASE=<case-id>, dump the composed sources with LOOM_PAIRWISE_DUMP=<dir>, get the verdict census with LOOM_PAIRWISE_REPORT=<file> docker (schema-load leg)
schemathesis.yml npm run test:schemathesis{,-python,-java,-dotnet,-elixir} (needs uv tool install schemathesis==4.24.3 --with jsonschema-rs==0.49.1 (the workflow's pins — an unpinned jsonschema-rs ≥ 0.56 crashes the fuzzer on every parameterised operation) + cd test/behavioral && npm ci; the four booted legs need a pg and must run ONE AT A TIME against it — each resets the schemas per case) docker (all but node)
migration-evolution-e2e.yml npm run test:migration-evolution{,-python,-java,-dotnet,-elixir} docker
tenancy-e2e.yml npm run test:tenancy{,-python,-java,-dotnet,-elixir} + test:tenancy-hierarchy{,-python,-java,-dotnet,-elixir,-mikroorm,-dapper} + test:tenancy-org-context{,-python,-java,-dotnet,-elixir} + test:tenancy-subtree-explain (or LOOM_TENANCY_PG_URL) docker
hono-obs-e2e.yml npm run test:obs —
dotnet-obs-e2e.yml npm run test:obs-dotnet docker
java-obs-e2e.yml npm run test:obs-java (or LOOM_OBS_PG_URL) docker
python-obs-e2e.yml npm run test:obs-python (or LOOM_OBS_PG_URL) docker
elixir-vanilla-obs-e2e.yml npm run test:obs-phoenix docker
hono-oidc-e2e.yml npm run test:auth-e2e (dockerized Keycloak) docker
dotnet-oidc-e2e.yml npm run test:auth-e2e-dotnet docker
java-oidc-e2e.yml npm run test:auth-e2e-java docker
python-oidc-e2e.yml npm run test:auth-e2e-python docker
elixir-oidc-e2e.yml npm run test:auth-e2e-phoenix docker, mirror
auth-oidc-compose-e2e.yml npm run test:auth-e2e-compose (full generated compose stack + dev Keycloak) docker
elixir-vanilla-vo-e2e.yml LOOM_VO_E2E_PHOENIX_VANILLA=1 npx vitest run test/e2e/vo-roundtrip-elixir-vanilla.test.ts docker, mirror
phoenix-ui-e2e.yml npm run test:phoenix-ui-e2e docker, mirror
api-call-e2e.yml LOOM_API_CALL_CALLER=<node|python|dotnet|java|elixir> npm run test:api-call (pg via LOOM_API_CALL_PG_URL; elixir caller adds mirror) pg
channels-e2e.yml npm run test:channels family — test:channels{,-mikroorm,-python,-dotnet,-java,-elixir}, -rabbit* (incl. -rabbit-mikroorm), -kafka*, -auth (pg via LOOM_CHANNELS_PG_URL; redis/rabbit/kafka via docker; the two *-mikroorm legs are the redis/rabbit suites with LOOM_CHANNELS_PERSISTENCE=mikroorm and shifted ports, so they run beside the default ones — the RABBIT one is the durable/outbox-relay leg) docker, pg
email-e2e.yml npm run test:email{,-python,-dotnet,-java,-elixir} (pg + a Mailpit container: LOOM_MAILPIT_SMTP/LOOM_MAILPIT_API) docker, pg
context-integration-e2e.yml bash scripts/context-integration-e2e.sh <node|python|dotnet|java|elixir> pg
k8s-build.yml npm run test:k8s (helm + kubeconform on PATH) —
k8s-e2e.yml kind create cluster then npm run test:k8s-e2e (kind + kubectl + helm) docker
generated-a11y.yml LOOM_A11Y_E2E=1 LOOM_A11Y_PACK=<pack> npx vitest run test/e2e/generated-a11y-e2e.test.ts (Playwright chromium) —
size-boot-budget.yml LOOM_SIZE_BOOT=1 npx vitest run test/e2e/size-boot-budget.test.ts — one cell with LOOM_SIZE_BOOT_CASE=<bundle:react/mantine | boot:node | …> (the cells: test/budget/size-boot-cells.ts), the measurements with LOOM_SIZE_BOOT_REPORT=<file>; the ratchet core alone is the fast test/budget/size-boot-ratchet.test.ts docker (boot cells)
sast-generated.yml LOOM_SAST=1 npx vitest run test/e2e/sast-generated.test.ts (needs semgrep on PATH — pip install semgrep, or point LOOM_SEMGREP=<bin> at one); the registers + wiring alone are the fast test/sast/sast-ruleset.test.ts semgrep
frontend-fullstack-e2e.yml cd test/behavioral && node run-ui.mjs <case> (non-React cases); the flutter cell is npm run test:ui-flutter (= node test/behavioral/run-ui-flutter.mjs) and needs the Flutter SDK on PATH (or FLUTTER=/path/to/flutter) plus npx playwright install chromium. Both SDK cells run on the sandbox host — docs/tools.md § "Compiling generated FRONTENDS locally" has the two recipes (a scratchpad dotnet-install.sh --channel 8.0 for the feliz cell, a FLUTTER= shim forwarding to ghcr.io/cirruslabs/flutter:stable for the flutter one), both measured green 2026-09-13; neither is CI-only. The feliz-auth / flutter-auth cells are node run-ui-auth.mjs feliz (needs dotnet on PATH; the image's /opt/dotnet SDK 10 works with DOTNET_ROLL_FORWARD=Major) and FLUTTER=<sdk>/bin/flutter node run-ui-auth.mjs flutter —
playground-e2e.yml cd web && npm ci && npm run e2e (network-gated: esm.sh/jsdelivr/npm) network
playground-e2e-no-network.yml cd web && npx playwright test --project=chromium <workspace/history/builder/requirements/editor specs> + node scripts/check-eager-chunks.mjs —
playground-realm-check.yml cd web && npm run e2e:realm —
pages.yml node docs/build.mjs && cd web && npm ci && npm run typecheck && npm run test:ddl && npm run e2e:smoke && npm run build (deploy half is CI-only) —
ci-red-alarm.yml CI-only housekeeping (main-red notifier) — nothing to reproduce locally —
quality-delta.yml node scripts/quality-delta.mjs --dry-run (prints the full report, posts nothing; the Actions-API and R12 sections need GITHUB_TOKEN + GITHUB_REPOSITORY) —
cleanup-artifacts.yml CI-only housekeeping (artifact tidy) — nothing to reproduce locally —
cancel-dead-queue-runs.yml CI-only housekeeping (cancels runs of discarded merge-queue groups) — node scripts/cancel-dead-queue-runs.mjs --dry-run with GITHUB_TOKEN + GITHUB_REPOSITORY set lists what it would cancel —
flake-budget.yml GITHUB_TOKEN=<token> node scripts/flake-budget.mjs (prints the report; add --out r.json for the issue payloads). Classification logic alone: npx vitest run test/system/flake-budget.test.ts. The issue-filing half is CI-only network