diff --git a/tools/zflash/test-harness/README.md b/tools/zflash/test-harness/README.md new file mode 100644 index 0000000000..619aff3493 --- /dev/null +++ b/tools/zflash/test-harness/README.md @@ -0,0 +1,81 @@ +# `tools/zflash/test-harness/` — B-0891 5-scenario test harness (PoC scaffold) + +PoC scaffold for the zflash "done" acceptance criteria — the 5-scenario QEMU test matrix the operator named in [B-0891](../../../docs/backlog/P1/B-0891-zflash-done-acceptance-criteria-qemu-test-harness-5-scenarios-initial-format-cluster-up-reformat-with-retention-reformat-from-scratch-cluster-joining-aaron-2026-05-28.md). + +## Scope + +**PoC**: declarative scenario definitions + CLI dispatcher contract + invariant tests. + +**NOT in PoC** (deferred to follow-up): QEMU snapshot/restart logic for scenarios 3-5 (state preservation between boots); multi-VM orchestration for scenario 5 (cluster-joining); GitHub Actions workflow integration. + +## Scenarios + +| # | Scenario | Status | Composes-with | +|---|---|---|---| +| 1 | Initial format (USB-bake from zero) | composes-with-existing | `tools/ci/qemu-boot-test.ts` + `tools/ci/audit-installer-iso-content.ts` | +| 2 | Initial boot + cluster comes up | composes-with-existing | `tools/ci/qemu-full-install-test.ts` (B-0831 Slice 1) | +| 3 | Reformat WITH key + selection retention | scaffolded | B-0737 Touch ID + B-0852 USB-bound creds (requires QEMU state preservation) | +| 4 | Reformat from scratch (wipe + fresh keys) | scaffolded | B-0852 USB-bound creds + B-0884 PQ git-crypt (requires test-harness path-fork) | +| 5 | Cluster joining (new node) | scaffolded | B-0831 cluster-auto-join + B-0852.3 cred-picker (requires multi-VM QEMU orchestration) | + +## CLI + +```bash +# List the scenario matrix as JSON +bun tools/zflash/test-harness/run.ts --list + +# Validate scenarios + report dispatcher plan without executing QEMU +bun tools/zflash/test-harness/run.ts --dry-run + +# Dry-run a specific scenario +bun tools/zflash/test-harness/run.ts --dry-run --scenario initial-format + +# Run one scenario (composes-with-existing scenarios shell out to tools/ci/ substrate) +bun tools/zflash/test-harness/run.ts --scenario initial-format + +# Run all 5 in orderIndex order; gate failures skip dependent scenarios +bun tools/zflash/test-harness/run.ts --all +``` + +Exit codes: + +- `0` — all requested scenarios passed (or all skipped/scaffolded) +- `1` — one or more requested scenarios FAILED +- `2` — usage error OR scenario-definition invariant violation + +## Tests + +```bash +bun test tools/zflash/test-harness/ +``` + +Invariants checked: 5-scenario count, unique ids, orderIndex 1..5 unique, gate references valid, composes-with-existing scenarios cite `tools/ci/` paths, non-empty acceptance criteria. + +## Extending the harness + +To add or modify a scenario, edit `scenarios.ts` only — `run.ts` dispatches based on the declarative definitions; tests verify invariants. The scaffolded → composes-with-existing transition happens when the implementation substrate lands (QEMU snapshot/restart for scenarios 3-5; multi-VM orchestration for scenario 5). + +When a scenario transitions to composes-with-existing: + +1. Update `scenarios.ts` status field +2. Update `composesWith` array to reference the new harness path +3. Update `runComposingScenario` in `run.ts` to dispatch to the new harness if not already covered +4. Add a unit test for the new dispatch path + +## Composes-with substrate + +- [`tools/ci/qemu-full-install-test.ts`](../../ci/qemu-full-install-test.ts) — B-0831 Slice 1 starter; existing QEMU full-install harness +- [`tools/ci/qemu-boot-test.ts`](../../ci/qemu-boot-test.ts) — cascade #5 boot smoke-test +- [`tools/ci/audit-installer-iso-content.ts`](../../ci/audit-installer-iso-content.ts) — cascade #4 ISO content audit +- [`full-ai-cluster/tools/zflash.ts`](../../../full-ai-cluster/tools/zflash.ts) — the zflash CLI under test +- [`full-ai-cluster/tools/zflash-lib.ts`](../../../full-ai-cluster/tools/zflash-lib.ts) — library substrate +- [`docs/runbooks/zflash-end-to-end.md`](../../../docs/runbooks/zflash-end-to-end.md) — operator-facing runbook +- [`docs/research/2026-05-28-zflash-and-usb-credential-substrate-next-steps-plan.md`](../../../docs/research/2026-05-28-zflash-and-usb-credential-substrate-next-steps-plan.md) — CP-1..CP-6 critical-path sequence +- [B-0891](../../../docs/backlog/P1/B-0891-zflash-done-acceptance-criteria-qemu-test-harness-5-scenarios-initial-format-cluster-up-reformat-with-retention-reformat-from-scratch-cluster-joining-aaron-2026-05-28.md) — backlog row this PoC implements +- [B-0892](../../../docs/backlog/P1/B-0892-three-lanes-concurrent-operating-discipline-encryption-plus-zflash-plus-state-machine-substrate-until-each-lane-backlog-drains-per-operator-2026-05-28.md) — zflash lane this advances + +## Operator-collaborative testing + +Per B-0891 framing, USB-side validation (after QEMU green) is operator-collaborative: physical USB confirms QEMU-validated behavior survives real hardware; operator demos at work need physical USB; KVM substrate enables remote USB-boot tests. + +The harness ships QEMU-side iteration; operator handles physical-USB validation in parallel. diff --git a/tools/zflash/test-harness/run.ts b/tools/zflash/test-harness/run.ts new file mode 100644 index 0000000000..e090a63526 --- /dev/null +++ b/tools/zflash/test-harness/run.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env bun +/** + * tools/zflash/test-harness/run.ts + * + * B-0891 — zflash 5-scenario test-harness CLI dispatcher (PoC scaffold) + * + * Usage: + * bun tools/zflash/test-harness/run.ts --list + * bun tools/zflash/test-harness/run.ts --dry-run [--scenario ] + * bun tools/zflash/test-harness/run.ts --scenario initial-format + * bun tools/zflash/test-harness/run.ts --all + * + * Modes: + * --list Print scenario matrix as structured table; exit 0 + * --dry-run Validate scenarios + report dispatcher plan without + * executing QEMU. Exit 0 on valid plan, non-zero on + * misconfiguration. + * --scenario Run one scenario by id. For composes-with-existing + * scenarios, delegates to tools/ci/qemu-full-install-test.ts + * or tools/ci/qemu-boot-test.ts. For scaffolded scenarios, + * reports "not yet implemented" with the implementation + * substrate path documented. + * --all Run all 5 scenarios in orderIndex order; gate failures + * skip dependent scenarios. + * + * Output: + * JSON-structured per-scenario result to stdout + * Human-readable summary to stderr + * + * Exit codes: + * 0 all requested scenarios passed (or all skipped due to scaffolded status) + * 1 one or more requested scenarios FAILED + * 2 usage error OR scenario-definition invariant violation + * + * Per .claude/rules/rule-0-no-sh-files.md (TS-first for cross-platform DST). + * PoC scope: dispatcher contract + --list + --dry-run paths fully wired; + * --scenario + --all paths shell out to existing QEMU harnesses for + * composes-with-existing scenarios + return scaffolded status for the + * remaining 3. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { SCENARIOS, validateScenarios, findScenario, type Scenario, type ScenarioId } from "./scenarios"; + +type Mode = "list" | "dry-run" | "scenario" | "all"; + +interface ParsedArgs { + readonly mode: Mode; + readonly scenarioId?: ScenarioId; + readonly isoPath?: string; +} + +interface ScenarioResult { + readonly id: ScenarioId; + readonly status: "passed" | "failed" | "skipped" | "scaffolded"; + readonly durationMs?: number; + readonly message?: string; +} + +function parseArgs(argv: ReadonlyArray): ParsedArgs | { error: string } { + const args = argv.slice(2); + if (args.length === 0) { + return { error: "no mode specified — use --list, --dry-run, --scenario, or --all" }; + } + if (args.includes("--list")) { + return { mode: "list" }; + } + if (args.includes("--dry-run")) { + const scenarioIdx = args.indexOf("--scenario"); + if (scenarioIdx >= 0 && scenarioIdx + 1 < args.length) { + const id = args[scenarioIdx + 1] as ScenarioId; + return { mode: "dry-run", scenarioId: id }; + } + return { mode: "dry-run" }; + } + if (args.includes("--scenario")) { + const scenarioIdx = args.indexOf("--scenario"); + if (scenarioIdx + 1 >= args.length) { + return { error: "--scenario requires a scenario id" }; + } + const id = args[scenarioIdx + 1] as ScenarioId; + const positional = args.filter((a, i) => i !== scenarioIdx && i !== scenarioIdx + 1 && !a.startsWith("--")); + if (positional.length === 0) { + return { error: "--scenario requires an ISO path positional argument" }; + } + return { mode: "scenario", scenarioId: id, isoPath: positional[0] }; + } + if (args.includes("--all")) { + const positional = args.filter((a) => !a.startsWith("--")); + if (positional.length === 0) { + return { error: "--all requires an ISO path positional argument" }; + } + return { mode: "all", isoPath: positional[0] }; + } + return { error: `unrecognized arguments: ${args.join(" ")}` }; +} + +function emitListing(): void { + console.log( + JSON.stringify( + { + rowId: "B-0891", + scenarioCount: SCENARIOS.length, + scenarios: SCENARIOS.map((s) => ({ + orderIndex: s.orderIndex, + id: s.id, + title: s.title, + status: s.status, + gates: s.gates, + })), + }, + null, + 2, + ), + ); +} + +function emitDryRun(scenarioId?: ScenarioId): number { + try { + validateScenarios(SCENARIOS); + } catch (e) { + console.error(`scenarios.ts invariant violated: ${(e as Error).message}`); + return 2; + } + const targets = scenarioId + ? SCENARIOS.filter((s) => s.id === scenarioId) + : SCENARIOS; + if (targets.length === 0) { + console.error(`scenario not found: ${scenarioId}`); + return 2; + } + console.log( + JSON.stringify( + { + rowId: "B-0891", + mode: "dry-run", + targets: targets.map((s) => ({ + id: s.id, + status: s.status, + plan: + s.status === "composes-with-existing" + ? `would delegate to existing tools/ci/ substrate: ${s.composesWith[0]}` + : `would report scaffolded — implementation pending; composes-with: ${s.composesWith.join(", ")}`, + })), + }, + null, + 2, + ), + ); + return 0; +} + +function runComposingScenario(scenario: Scenario, isoPath: string): ScenarioResult { + const harnessPath = scenario.id === "initial-format" + ? "tools/ci/qemu-boot-test.ts" + : "tools/ci/qemu-full-install-test.ts"; + const repoRoot = resolve(import.meta.dir, "../../.."); + const absHarnessPath = resolve(repoRoot, harnessPath); + if (!existsSync(absHarnessPath)) { + return { + id: scenario.id, + status: "failed", + message: `composes-with harness not found: ${absHarnessPath}`, + }; + } + const start = Date.now(); + const result = spawnSync("bun", [absHarnessPath, isoPath], { + cwd: repoRoot, + stdio: "inherit", + }); + const durationMs = Date.now() - start; + return { + id: scenario.id, + status: result.status === 0 ? "passed" : "failed", + durationMs, + message: result.status === 0 ? undefined : `delegated harness exited ${result.status}`, + }; +} + +function reportScaffolded(scenario: Scenario): ScenarioResult { + return { + id: scenario.id, + status: "scaffolded", + message: `scenario definition only — implementation pending; composes-with: ${scenario.composesWith.join(", ")}`, + }; +} + +function runScenario(scenarioId: ScenarioId, isoPath: string): ScenarioResult { + const scenario = findScenario(scenarioId); + if (!scenario) { + return { + id: scenarioId, + status: "failed", + message: `scenario not found: ${scenarioId}`, + }; + } + switch (scenario.status) { + case "composes-with-existing": + return runComposingScenario(scenario, isoPath); + case "scaffolded": + return reportScaffolded(scenario); + case "operator-runtime": + return { + id: scenarioId, + status: "skipped", + message: "operator-runtime scenario — physical USB / operator-collaborative testing required", + }; + } +} + +function emitResults(results: ReadonlyArray): void { + console.log( + JSON.stringify( + { + rowId: "B-0891", + summary: { + total: results.length, + passed: results.filter((r) => r.status === "passed").length, + failed: results.filter((r) => r.status === "failed").length, + scaffolded: results.filter((r) => r.status === "scaffolded").length, + skipped: results.filter((r) => r.status === "skipped").length, + }, + results, + }, + null, + 2, + ), + ); +} + +function main(argv: ReadonlyArray): number { + const parsed = parseArgs(argv); + if ("error" in parsed) { + console.error(`usage error: ${parsed.error}`); + console.error("see file header for usage examples"); + return 2; + } + + try { + validateScenarios(SCENARIOS); + } catch (e) { + console.error(`scenarios.ts invariant violated at startup: ${(e as Error).message}`); + return 2; + } + + switch (parsed.mode) { + case "list": + emitListing(); + return 0; + case "dry-run": + return emitDryRun(parsed.scenarioId); + case "scenario": { + if (!parsed.scenarioId || !parsed.isoPath) { + console.error("--scenario requires scenario id + iso path"); + return 2; + } + const result = runScenario(parsed.scenarioId, parsed.isoPath); + emitResults([result]); + return result.status === "failed" ? 1 : 0; + } + case "all": { + if (!parsed.isoPath) { + console.error("--all requires iso path"); + return 2; + } + const sorted = [...SCENARIOS].sort((a, b) => a.orderIndex - b.orderIndex); + const results: ScenarioResult[] = []; + const failedIds = new Set(); + for (const scenario of sorted) { + const gatedBy = sorted.find((g) => g.gates.includes(scenario.id) && failedIds.has(g.id)); + if (gatedBy) { + results.push({ + id: scenario.id, + status: "skipped", + message: `gated by failed scenario: ${gatedBy.id}`, + }); + continue; + } + const result = runScenario(scenario.id, parsed.isoPath); + results.push(result); + if (result.status === "failed") { + failedIds.add(scenario.id); + } + } + emitResults(results); + return failedIds.size > 0 ? 1 : 0; + } + } +} + +if (import.meta.main) { + process.exit(main(process.argv)); +} diff --git a/tools/zflash/test-harness/scenarios.test.ts b/tools/zflash/test-harness/scenarios.test.ts new file mode 100644 index 0000000000..ce4fb89d09 --- /dev/null +++ b/tools/zflash/test-harness/scenarios.test.ts @@ -0,0 +1,83 @@ +/** + * tools/zflash/test-harness/scenarios.test.ts + * + * B-0891 PoC — invariant tests for scenario definitions. + * + * Run via: bun test tools/zflash/test-harness/ + */ + +import { describe, expect, it } from "bun:test"; +import { SCENARIOS, validateScenarios, findScenario } from "./scenarios"; + +describe("B-0891 scenarios.ts invariants", () => { + it("has exactly 5 scenarios per operator-named matrix", () => { + expect(SCENARIOS.length).toBe(5); + }); + + it("validates without throwing", () => { + expect(() => validateScenarios(SCENARIOS)).not.toThrow(); + }); + + it("orderIndex values are 1..5", () => { + const orders = SCENARIOS.map((s) => s.orderIndex).sort(); + expect(orders).toEqual([1, 2, 3, 4, 5]); + }); + + it("ids are unique", () => { + const ids = new Set(SCENARIOS.map((s) => s.id)); + expect(ids.size).toBe(SCENARIOS.length); + }); + + it("findScenario returns correct entry", () => { + expect(findScenario("initial-format")?.orderIndex).toBe(1); + expect(findScenario("boot-cluster-up")?.orderIndex).toBe(2); + expect(findScenario("reformat-with-retention")?.orderIndex).toBe(3); + expect(findScenario("reformat-from-scratch")?.orderIndex).toBe(4); + expect(findScenario("cluster-joining")?.orderIndex).toBe(5); + }); + + it("findScenario returns undefined for unknown id", () => { + expect(findScenario("nonexistent" as never)).toBeUndefined(); + }); + + it("gates only reference defined ids", () => { + const ids = new Set(SCENARIOS.map((s) => s.id)); + for (const s of SCENARIOS) { + for (const gate of s.gates) { + expect(ids.has(gate)).toBe(true); + } + } + }); + + it("composes-with-existing scenarios cite tools/ci/ paths", () => { + const composers = SCENARIOS.filter((s) => s.status === "composes-with-existing"); + expect(composers.length).toBeGreaterThan(0); + for (const s of composers) { + const hasToolsCi = s.composesWith.some((dep) => dep.startsWith("tools/ci/")); + expect(hasToolsCi).toBe(true); + } + }); + + it("all scenarios have non-empty acceptanceCriteria", () => { + for (const s of SCENARIOS) { + expect(s.acceptanceCriteria.length).toBeGreaterThan(0); + } + }); + + it("validateScenarios catches duplicate id", () => { + const dup = [...SCENARIOS, { ...SCENARIOS[0] }]; + expect(() => validateScenarios(dup)).toThrow(); + }); + + it("validateScenarios catches wrong count", () => { + const short = SCENARIOS.slice(0, 4); + expect(() => validateScenarios(short)).toThrow(); + }); + + it("validateScenarios catches unknown gate reference", () => { + const broken = SCENARIOS.map((s, i) => + i === 0 ? { ...s, gates: ["nonexistent" as never] } : s, + ); + expect(() => validateScenarios(broken)).toThrow(); + }); +}); diff --git a/tools/zflash/test-harness/scenarios.ts b/tools/zflash/test-harness/scenarios.ts new file mode 100644 index 0000000000..deafa74b32 --- /dev/null +++ b/tools/zflash/test-harness/scenarios.ts @@ -0,0 +1,208 @@ +/** + * tools/zflash/test-harness/scenarios.ts + * + * B-0891 — zflash "done" acceptance criteria + QEMU test harness + * + * Declarative definitions for the 5-scenario test matrix the operator + * named as the acceptance criteria for zflash "done": + * + * 1. Initial format (USB-bake from zero) + * 2. Initial boot + cluster comes up + * 3. Reformat WITH key + selection retention + * 4. Reformat from scratch (wipe + fresh keys) + * 5. Cluster joining (new node) + * + * PoC scope: declarative scenario definitions + dispatcher contract + + * status field for partial implementation. Scenarios 1 + 2 can compose + * with existing `tools/ci/qemu-full-install-test.ts` (B-0831 Slice 1) + * substrate today; scenarios 3-5 require state-preservation between QEMU + * boots which the existing harness does not have — marked as + * "scaffolded" pending implementation. + * + * Composes with: + * - tools/ci/qemu-full-install-test.ts (existing QEMU full-install starter) + * - tools/ci/qemu-boot-test.ts (cascade #5 boot smoke-test) + * - tools/ci/audit-installer-iso-content.ts (cascade #4 ISO content audit) + * - full-ai-cluster/tools/zflash-lib.ts (the zflash library under test) + * - docs/runbooks/zflash-end-to-end.md (operator-facing runbook) + * - docs/research/2026-05-28-zflash-and-usb-credential-substrate-next-steps-plan.md (CP-1..CP-6) + * + * Per .claude/rules/rule-0-no-sh-files.md (TS-first for cross-platform DST) + * + .claude/rules/verify-existing-substrate-before-authoring.md (composes + * with existing tools/ci/ substrate; does not duplicate). + */ + +export type ScenarioId = + | "initial-format" + | "boot-cluster-up" + | "reformat-with-retention" + | "reformat-from-scratch" + | "cluster-joining"; + +export type ImplStatus = + | "composes-with-existing" // can run today via existing qemu-full-install-test.ts substrate + | "scaffolded" // declarative definition only; QEMU integration pending + | "operator-runtime"; // requires physical USB OR operator-collaborative testing + +export interface Scenario { + readonly id: ScenarioId; + readonly title: string; + readonly orderIndex: number; + readonly status: ImplStatus; + readonly acceptanceCriteria: ReadonlyArray; + readonly composesWith: ReadonlyArray; + readonly gates: ReadonlyArray; + readonly notes: string; +} + +export const SCENARIOS: ReadonlyArray = [ + { + id: "initial-format", + title: "Initial format (USB-bake from zero)", + orderIndex: 1, + status: "composes-with-existing", + acceptanceCriteria: [ + "zflash script runs cleanly to completion", + "produces bootable USB image with operator-chosen credentials baked in", + "passes ISO content audit (tools/ci/audit-installer-iso-content.ts)", + "QEMU boots the produced image to a usable state", + ], + composesWith: [ + "tools/ci/audit-installer-iso-content.ts", + "tools/ci/qemu-boot-test.ts", + "full-ai-cluster/tools/zflash.ts", + "full-ai-cluster/tools/zflash-lib.ts", + ], + gates: ["boot-cluster-up"], + notes: + "Existing qemu-boot-test.ts performs the boot-to-usable-state check; existing audit-installer-iso-content.ts performs the content check. This scenario sequences them in one harness invocation.", + }, + { + id: "boot-cluster-up", + title: "Initial boot + cluster comes up", + orderIndex: 2, + status: "composes-with-existing", + acceptanceCriteria: [ + "USB boots in QEMU", + "cluster nodes (mini-PC fleet per B-0590) come up successfully", + "reach steady-state with all expected services running", + "observability backend reports healthy", + ], + composesWith: [ + "tools/ci/qemu-full-install-test.ts", + "B-0831 (CI cascade-6 cluster-auto-join)", + "B-0590 (fleet replication 20 machines)", + ], + gates: ["reformat-with-retention", "cluster-joining"], + notes: + "qemu-full-install-test.ts already watches for [iter-5.1] marker proving nixos-install reached post-install phase. This scenario extends with cluster-auto-join verification per B-0831 Slice 2 (deferred to follow-up; PoC scaffolds the dispatcher contract).", + }, + { + id: "reformat-with-retention", + title: "Reformat WITH key + selection retention", + orderIndex: 3, + status: "scaffolded", + acceptanceCriteria: [ + "re-bake USB with existing operator-chosen credentials preserved", + "Touch ID pairing per B-0737 preserved (no re-pair required)", + "passphrase per B-0852 preserved (no re-enter required)", + "UUID-bound keys preserved across re-bake", + "existing cluster recognizes the re-baked USB", + ], + composesWith: [ + "B-0737 (Touch ID + PAM + ISO-auto-discovery)", + "B-0852 (USB-bound credential substrate)", + "B-0852.3 (cred-picker integration)", + ], + gates: ["reformat-from-scratch"], + notes: + "Requires state-preservation between QEMU boots (TPM-equivalent or persisted KV store on virtual disk). Existing qemu-full-install-test.ts does NOT have this; QEMU snapshot/restart logic deferred to follow-up. PoC defines the contract; implementation work pending.", + }, + { + id: "reformat-from-scratch", + title: "Reformat from scratch (wipe + fresh keys)", + orderIndex: 4, + status: "scaffolded", + acceptanceCriteria: [ + "wipe-and-rebake from zero state produces fresh keys + new USB UUID", + "operator can choose path: migrate existing cluster's credentials to new USB", + "operator can choose path: start fresh cluster with new keys", + "both paths supported + tested", + ], + composesWith: [ + "B-0737 (Touch ID + PAM)", + "B-0852 (USB-bound credential substrate)", + "B-0884 (PQ git-crypt + zflash integration — future PQ-credential path)", + ], + gates: ["cluster-joining"], + notes: + "Dual-path test: same starting state, two operator choices. Requires test-harness path-fork support. PoC defines the contract.", + }, + { + id: "cluster-joining", + title: "Cluster joining (new node)", + orderIndex: 5, + status: "scaffolded", + acceptanceCriteria: [ + "new node boots from USB", + "joins existing running cluster cleanly", + "gets credentials provisioned per B-0852.3 cred-picker integration", + "appears in cluster state within bounded time", + ], + composesWith: [ + "B-0831 (CI cascade-6 cluster-auto-join)", + "B-0852.3 (cred-picker integration)", + "B-0590 (fleet replication 20 machines)", + "B-0889 (symbiotic cross-track self-healing)", + ], + gates: [], + notes: + "Requires multi-VM QEMU orchestration (one existing cluster VM + one joining VM). Existing harness is single-VM; multi-VM orchestration deferred to follow-up. PoC defines the contract.", + }, +]; + +/** + * Lookup helper — find scenario by id. + */ +export function findScenario(id: ScenarioId): Scenario | undefined { + return SCENARIOS.find((s) => s.id === id); +} + +/** + * Validate scenario definitions at harness-init time. Invariants: + * - exactly 5 scenarios (per operator-named matrix) + * - ids are unique + * - orderIndex values are 1..5 unique + * - gates only reference defined ids + * + * Thrown invariants would indicate the matrix is broken — fail fast. + */ +export function validateScenarios(scenarios: ReadonlyArray): void { + if (scenarios.length !== 5) { + throw new Error( + `expected exactly 5 scenarios per B-0891 matrix; got ${scenarios.length}`, + ); + } + const ids = new Set(); + const orders = new Set(); + for (const s of scenarios) { + if (ids.has(s.id)) { + throw new Error(`duplicate scenario id: ${s.id}`); + } + ids.add(s.id); + if (s.orderIndex < 1 || s.orderIndex > 5) { + throw new Error(`scenario ${s.id} orderIndex out of range: ${s.orderIndex}`); + } + if (orders.has(s.orderIndex)) { + throw new Error(`duplicate orderIndex: ${s.orderIndex}`); + } + orders.add(s.orderIndex); + } + for (const s of scenarios) { + for (const gate of s.gates) { + if (!ids.has(gate)) { + throw new Error(`scenario ${s.id} gates unknown scenario: ${gate}`); + } + } + } +}