@hraness/direct 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +436 -0
  3. package/dist/core/index.js +162 -0
  4. package/dist/index-1csg00w4.js +1167 -0
  5. package/dist/index-6mdfd2ey.js +464 -0
  6. package/dist/index-7n1h75n6.js +616 -0
  7. package/dist/index.js +232 -0
  8. package/dist/react.js +32 -0
  9. package/dist/testing/index.js +1069 -0
  10. package/dist/tooling/bombadil.js +2117 -0
  11. package/dist/tooling/browser-verification-entry.js +1499 -0
  12. package/dist/tooling/bundle-boundary.js +119 -0
  13. package/dist/web.js +605 -0
  14. package/package.json +179 -0
  15. package/skills/direct/AGENTS.md +13 -0
  16. package/skills/direct/SKILL.md +49 -0
  17. package/skills/direct/agents/openai.yaml +4 -0
  18. package/skills/direct/references/adoption.md +131 -0
  19. package/skills/direct/references/install.md +91 -0
  20. package/skills/direct/references/verification.md +247 -0
  21. package/src/core/coverage.ts +336 -0
  22. package/src/core/definition.ts +378 -0
  23. package/src/core/effects.ts +88 -0
  24. package/src/core/fixture.ts +185 -0
  25. package/src/core/ids.ts +77 -0
  26. package/src/core/index.ts +13 -0
  27. package/src/core/json-value.ts +7 -0
  28. package/src/core/json.ts +593 -0
  29. package/src/core/query.ts +230 -0
  30. package/src/core/reason.ts +16 -0
  31. package/src/core/resource.ts +10 -0
  32. package/src/core/result.ts +19 -0
  33. package/src/core/runtime.ts +229 -0
  34. package/src/core/scenario.ts +149 -0
  35. package/src/core/store.ts +784 -0
  36. package/src/index.ts +51 -0
  37. package/src/react.ts +54 -0
  38. package/src/testing/activity.ts +228 -0
  39. package/src/testing/coverage-binding.ts +99 -0
  40. package/src/testing/evidence.ts +59 -0
  41. package/src/testing/index.ts +22 -0
  42. package/src/testing/manifest.ts +559 -0
  43. package/src/testing/probe.ts +446 -0
  44. package/src/testing/scripted-transport.ts +775 -0
  45. package/src/testing/session.ts +525 -0
  46. package/src/tooling/bombadil-campaign.ts +288 -0
  47. package/src/tooling/bombadil-internal.d.ts +46 -0
  48. package/src/tooling/bombadil-runner.ts +1424 -0
  49. package/src/tooling/bombadil.ts +27 -0
  50. package/src/tooling/browser-verification-entry.ts +32 -0
  51. package/src/tooling/browser-verification.ts +916 -0
  52. package/src/tooling/bundle-boundary.ts +159 -0
  53. package/src/web/browser-bridge.ts +296 -0
  54. package/src/web/browser.ts +277 -0
  55. package/src/web/fetch-firewall.ts +251 -0
  56. package/src/web.ts +27 -0
@@ -0,0 +1,247 @@
1
+ # Verify Direct
2
+
3
+ ## Discover the declared contract
4
+
5
+ 1. Read applicable `AGENTS.md` files, package scripts, Direct definition, world parser, deterministic adapters, session construction, browser installation, verifier, and production-boundary policy.
6
+ 2. If a Direct page is running, read and parse
7
+ `window.__direct.manifest` first. Use it to list every scenario and coverage
8
+ entry, then compare it with the authored definition when source is
9
+ available.
10
+ 3. Map each `fixture`, `mixed`, and `direct` claim to the evidence required to close it.
11
+ 4. Identify the production adapter, service, host, operating system, or device behavior replaced by each deterministic port.
12
+ 5. Inspect each command before treating it as evidence. A script named `verify` may build and scan boundaries without driving a browser.
13
+
14
+ Do not infer a stronger proof mode from a passing screenshot or fixture interaction.
15
+
16
+ ## Run deterministic checks
17
+
18
+ Run the repository's narrow Direct typecheck, unit tests, property tests, and Direct build when those commands exist. Report a missing property suite or browser verifier as `not present`; do not synthesize evidence. Prefer an existing isolated verifier or temporary output directory so builds do not dirty the source tree.
19
+
20
+ Verify that tests cover malformed worlds, explicit activation failures, adapter failures, cancellation, cleanup, and exact-script drain behavior when applicable.
21
+
22
+ When a browser verifier exists, drive stable scenario URLs and interact in
23
+ product terms. Read only the canonical `window.__direct` bridge. Parse the
24
+ complete manifest and every probe; bind `manifest.coverage` to the authored
25
+ definition with `parseDefinitionCoverageSnapshot`. Do not accept compatibility
26
+ or product-specific globals as equivalent evidence.
27
+
28
+ The manifest and browser runtime remain driver-neutral. Prefer the optional
29
+ `@hraness/direct/tooling/browser-verification` Bun/Node helpers for exact
30
+ package-bound bridge reads, bounded agent-browser commands, server leases, and
31
+ artifacts when they fit the repository. They invoke the consumer's local
32
+ agent-browser installation; they do not bundle a driver, coordinate parallel
33
+ work, supervise cleanup, or own product commands and evidence.
34
+
35
+ When a product already has a Bombadil campaign, prefer the shared
36
+ `@hraness/direct/tooling/bombadil-campaign` factories and
37
+ `@hraness/direct/tooling/bombadil` host runner instead of copying Direct
38
+ extractors, temporal formulas, trace parsers, server leases, process-group
39
+ cleanup, or artifact code. Pin `@antithesishq/bombadil@0.7.2` directly in the
40
+ consumer. Keep the default browser properties, exported Direct formulas, and
41
+ conservative Direct action generator in the campaign; keep product-specific
42
+ actions and assertions local. Random runs must be 12 to 300 seconds. Require
43
+ the runner's canonical post-run trace attestation even when Bombadil exits
44
+ zero, and retain raw trace, process log, server log, and failure artifacts.
45
+ Treat the result as diagnostic fuzz evidence, not as a semantic product check
46
+ or proof of any replaced system.
47
+
48
+ For the agent-browser path, use one task-owned local Chromium session and process for a sequential batch of
49
+ at most eight scenarios. Before each scenario, call `window new` for a fresh
50
+ BrowserContext. Inventory its tabs and attempt to close scenario-owned tabs,
51
+ retaining each command result and the post-attempt inventory. agent-browser
52
+ 0.32.3 can ignore `Target.closeTarget` errors, so do not claim proven per-tab
53
+ closure. Keep the inert no-URL bootstrap tab until the final whole-browser
54
+ close, which is the stronger disposal boundary. Do not reuse a context,
55
+ substitute `tab new`, or launch one process per scenario. Capture semantic and
56
+ visual evidence in the same exact Chromium context.
57
+
58
+ Pass an exact `--allowed-domains` list for the target and required asset hosts
59
+ before the first navigation. Direct's application-`fetch` firewall is
60
+ instrumentation, not full egress containment for navigation, subresources,
61
+ WebSockets, workers, service workers, beacons, WebRTC, native traffic, or
62
+ another realm. Forbid ordinary browser-wide `--cdp` attachment. Multiple
63
+ session names do not isolate contexts, and agent-browser 0.32.3 rejects
64
+ `--allowed-domains` with `--cdp`.
65
+
66
+ Run serially unless a real external coordinator enforces a shared host-wide
67
+ limit. Direct does not enforce a process cap. Parallel-admission or crash-safe
68
+ cleanup claims require an external supervisor that owns both the agent-browser
69
+ daemon and Chromium roots, or one containing job. The roots can occupy
70
+ different process groups, so daemon exit alone is not cleanup proof.
71
+
72
+ Create an empty task-owned config and fresh socket directory. Remove inherited
73
+ agent-browser and proxy settings, set a bounded idle timeout, and use the same
74
+ wrapper and session for every batch command:
75
+
76
+ ```sh
77
+ set -eu
78
+ DIRECT_AGENT_BROWSER_BIN="$(command -v agent-browser)"
79
+ test -x "$DIRECT_AGENT_BROWSER_BIN"
80
+ DIRECT_BROWSER_SESSION='direct-chromium'
81
+ DIRECT_BROWSER_BACKEND='local-chromium'
82
+ DIRECT_BROWSER_ALLOWED_DOMAINS='127.0.0.1'
83
+ DIRECT_BROWSER_SCENARIO_URL='http://127.0.0.1:5173/direct/?__direct_scenario=todos.populated'
84
+ DIRECT_BROWSER_IDLE_TIMEOUT_MS=60000
85
+ DIRECT_BROWSER_CONFIG_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-config.XXXXXX")"
86
+ DIRECT_BROWSER_SOCKET_DIRECTORY="$(mktemp -d "${TMPDIR:-/tmp}/direct-browser-socket.XXXXXX")"
87
+ DIRECT_BROWSER_CONFIG="$DIRECT_BROWSER_CONFIG_DIRECTORY/agent-browser.json"
88
+ printf '%s\n' '{}' > "$DIRECT_BROWSER_CONFIG"
89
+ test "$(tr -d '[:space:]' < "$DIRECT_BROWSER_CONFIG")" = '{}'
90
+
91
+ direct_agent_browser() {
92
+ env -i \
93
+ PATH="$PATH" \
94
+ HOME="$HOME" \
95
+ TMPDIR="${TMPDIR:-/tmp}" \
96
+ AGENT_BROWSER_SOCKET_DIR="$DIRECT_BROWSER_SOCKET_DIRECTORY" \
97
+ AGENT_BROWSER_IDLE_TIMEOUT_MS="$DIRECT_BROWSER_IDLE_TIMEOUT_MS" \
98
+ "$DIRECT_AGENT_BROWSER_BIN" \
99
+ --config "$DIRECT_BROWSER_CONFIG" \
100
+ --allowed-domains "$DIRECT_BROWSER_ALLOWED_DOMAINS" "$@"
101
+ }
102
+
103
+ direct_agent_browser --version
104
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --engine chrome \
105
+ --json open
106
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --json tab
107
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --json window new
108
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" \
109
+ --json open "$DIRECT_BROWSER_SCENARIO_URL"
110
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --json tab
111
+ ```
112
+
113
+ The no-URL `open` installs the allowlist and creates the inert internal
114
+ `about:blank` tab. Do not pass `about:blank` as an explicit URL; agent-browser
115
+ 0.32.3 rejects that hostname-free navigation under the allowlist. Repeat
116
+ fresh-context creation, navigation, evidence, and tab-close attempts for each
117
+ scenario, rejecting a ninth before launch. Assign each scenario context a
118
+ verifier label and retain the fresh `window new` command and result. Keep the
119
+ inert no-URL bootstrap tab until final whole-browser close because
120
+ agent-browser 0.32.3 cannot close the last tab. Permissions, IndexedDB, Cache
121
+ Storage, and service workers cannot be reset reliably enough to reuse a
122
+ context.
123
+
124
+ Read one synchronous bridge sample through the same wrapper and session:
125
+
126
+ ```sh
127
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --json eval "(() => { const bridge = window.__direct; return { browserIdentity: { userAgent: navigator.userAgent, platform: navigator.platform }, bridgeSchema: bridge?.schema, manifest: bridge?.manifest, probe: typeof bridge?.snapshot === 'function' ? bridge.snapshot() : undefined }; })()"
128
+ ```
129
+
130
+ The JSON command envelope stores that sample at `data.result`; parse the
131
+ result, not the envelope, as the Direct contract. With Playwright MCP, call
132
+ `browser_evaluate` with the same page function:
133
+
134
+ ```json
135
+ {
136
+ "function": "() => { const bridge = window.__direct; return { browserIdentity: { userAgent: navigator.userAgent, platform: navigator.platform }, bridgeSchema: bridge?.schema, manifest: bridge?.manifest, probe: typeof bridge?.snapshot === 'function' ? bridge.snapshot() : undefined }; }"
137
+ }
138
+ ```
139
+
140
+ No Direct-specific browser plugin or MCP server is required. Add
141
+ `manifest.queries.scenario` to the product's known Direct entry URL, navigate,
142
+ and reacquire the complete sample because navigation replaces the document.
143
+ An alternative driver must independently establish the same pre-navigation
144
+ containment, fresh-context isolation, execution mode, and final-close policy;
145
+ reading the Direct bridge does not prove those properties.
146
+ The published scenario `route` is the product route under review, not
147
+ necessarily the wrapper workbench's entry path. Record the exact agent-browser
148
+ version, configured backend, allowed hosts, execution mode, verifier-assigned
149
+ scenario/context label, fresh `window new` command and result, tab inventories
150
+ and close-attempt results, and the sample's `browserIdentity`. A user-agent
151
+ string is metadata, not proof of browser or context custody. After each
152
+ scenario, run `tab close <id>` for its scenario-owned IDs and retain every
153
+ command result plus a post-attempt inventory. agent-browser 0.32.3 can ignore
154
+ `Target.closeTarget` errors, so do not report those attempts as proven closure.
155
+
156
+ After every scenario navigation, require:
157
+
158
+ - `bridgeSchema` equals `direct.browser-bridge/v2`;
159
+ - `manifest.active.source` equals the requested activation source;
160
+ - `manifest.active.scenario` equals the requested scenario;
161
+ - `manifest.active.route` equals the expected product route; and
162
+ - the parsed manifest's `active.selectionHash` binds that public selection to
163
+ its activation hash; and
164
+ - the parsed probe activation hash equals
165
+ `manifest.active.activationHash`.
166
+
167
+ Retain the complete initial manifest and probe with each result. After product
168
+ interactions, atomically sample the bridge again and require unchanged public
169
+ catalog metadata, coverage, catalog hash, full active selection, and probe
170
+ activation identity. Bind every sampled `manifest.coverage` to the authored
171
+ definition.
172
+
173
+ ## Join the probe
174
+
175
+ Wait until the same generation, revision, activity totals, and pending counters
176
+ remain quiet for the verifier's bounded settle interval. A quiet probe requires
177
+ zero current activity and zero pending counters. One successful
178
+ `wait --fn "window.__direct?.snapshot().isQuiescent === true"` observes only a
179
+ single quiet sample; it does not prove stability. Parse another probe after the
180
+ settle interval and compare the complete quiet state.
181
+
182
+ After each interaction:
183
+
184
+ 1. Join quiescence again.
185
+ 2. Reject relevant nonzero violation counters.
186
+ 3. Reject page errors, unexpected console errors, unmapped or failed network calls, malformed transport values, leaked activity, and required script steps left unused.
187
+ 4. Assert the route, visible semantics, accessibility state, and product result required by the scenario.
188
+
189
+ Never replace the probe join with a fixed sleep. Treat remaining work as a diagnostic unless the declared claim requires it to drain.
190
+
191
+ Definition activation, parser tests, and adapter unit tests do not close a claim about the real rendered interface. Such a claim requires the declared semantic and accessibility assertions against that interface.
192
+
193
+ ## Tear down the browser batch
194
+
195
+ After the final scenario evidence, retain one last tab inventory and every
196
+ tab-close attempt result. Keep the inert no-URL bootstrap tab open, then use
197
+ final whole-browser close as the stronger batch disposal boundary:
198
+
199
+ ```sh
200
+ direct_agent_browser --session "$DIRECT_BROWSER_SESSION" --json close
201
+ ```
202
+
203
+ Treat a nonzero final close as a failed batch. Preserve task metadata and do
204
+ not claim disposal or performance evidence. Remove only task-created temporary
205
+ directories, and only after close succeeds. The idle timeout is a backstop,
206
+ not crash-safe cleanup proof.
207
+
208
+ ## Verify production exclusion
209
+
210
+ Build the real production graph independently. Run its emitted-boundary scanner across every declared production surface. Prefer `@hraness/direct/tooling/bundle-boundary` for shared scan mechanics while keeping included paths, product markers, and positive production identity evidence product-owned. Require at least one executable bundle and reject package names, wire schemas, reserved query keys, fixtures, workbench strings, and browser bridge globals.
211
+
212
+ When a bundler selects platform variants, require a paired source map for every executable and every production platform. Positively match the declared shared behavior, native composition, and production-adapter modules in each map; reject Direct and web-fixture paths. Verify the inverse selection for the fixture graph. A clean marker scan proves only absence of those markers in those files, and a clean unrelated bundle proves nothing. Source selection still does not prove native linkage, service behavior, runtime loading, or device behavior.
213
+
214
+ ## Classify the evidence
215
+
216
+ Report every coverage entry as one of:
217
+
218
+ - `verified` when every fixture scenario and direct gate required by the claim's declared mode ran through the named behavior and passed its claim-specific assertions;
219
+ - `fixture-verified` when every declared fixture scenario for a mixed claim passed while its direct half remains open;
220
+ - `partial` when some required evidence passed;
221
+ - `not-exercised` when the run produced no evidence for the claim; or
222
+ - `direct-required` when deterministic evidence cannot close the claim.
223
+
224
+ A browser-only run keeps a direct claim `direct-required` and can report at most `fixture-verified` for a mixed claim. A wider run may report a mixed or direct claim as `verified` after every named direct behavior is exercised. Note supporting unit or structural evidence separately when it does not close the direct gate.
225
+
226
+ Use `classifyCoverageEvidence` from `@hraness/direct/testing`. Pass only scenario IDs whose claim-specific assertions succeeded, and set direct evidence to verified only for a current passing direct gate. Do not hand-roll a looser status promotion.
227
+
228
+ Include `HEAD` plus dirty or clean working-tree status, commands, scenario
229
+ results, exact browser-driver version, browser backend and observed identity,
230
+ allowed hosts, batch index and size, verifier-assigned scenario/context labels,
231
+ fresh `window new` commands and results, tab inventories and close-attempt
232
+ results, execution mode, final close result, catalog hash, activation hashes,
233
+ final probes, production surfaces scanned, retained artifacts, and exact
234
+ failures. Treat the catalog hash as a drift
235
+ fingerprint, not a security digest or deployed-bundle identity. Report absent
236
+ property tests, browser probes, or artifacts as `not present` or `not
237
+ observed`. State skipped direct gates once. Do not use credentials, contact
238
+ live services, or expand into device testing unless the user placed those
239
+ systems in scope.
240
+
241
+ Direct contains no browser-run or performance evidence for this policy. Only
242
+ when an external product comparison is explicitly in scope, report end-to-end
243
+ wall time separately from local host CPU and peak resident memory. Keep target,
244
+ scenarios, actions, assertions, batch bound, admitted concurrency, context
245
+ policy, containment, and cold or warm policy equal. Report browser launches,
246
+ contexts created, and the final close result. State leakage or incomplete
247
+ disposal is not a valid speed improvement.
@@ -0,0 +1,336 @@
1
+ import { parseCoverageKey, parseScenarioId, type CoverageKey, type ScenarioId } from "./ids.js";
2
+ import {
3
+ DEFAULT_JSON_LIMITS,
4
+ parseJsonValue,
5
+ type JsonLimits,
6
+ } from "./json.js";
7
+ import { err, isRecord, ok, type Result } from "./result.js";
8
+
9
+ export const DIRECT_COVERAGE_SCHEMA = "direct.coverage/v2" as const;
10
+ /** Maximum proof claims retained by one definition and discovery manifest. */
11
+ export const MAX_DIRECT_COVERAGE_ENTRIES = 256 as const;
12
+ const DIRECT_COVERAGE_JSON_LIMITS = Object.freeze({
13
+ ...DEFAULT_JSON_LIMITS,
14
+ maxStringBytes: 16_777_216,
15
+ }) satisfies JsonLimits;
16
+
17
+ export type CoverageMode = "fixture" | "mixed" | "direct";
18
+
19
+ type CoverageScenarioInput<Scenario extends string = string> = ScenarioId | Scenario;
20
+
21
+ interface CoverageEntryInputBase {
22
+ readonly key: string;
23
+ readonly claim: string;
24
+ }
25
+
26
+ export type CoverageEntryInput<Scenario extends string = string> =
27
+ | (CoverageEntryInputBase & {
28
+ readonly mode: "direct";
29
+ readonly scenarios: readonly [];
30
+ })
31
+ | (CoverageEntryInputBase & {
32
+ readonly mode: "fixture" | "mixed";
33
+ readonly scenarios: readonly [CoverageScenarioInput<Scenario>, ...CoverageScenarioInput<Scenario>[]];
34
+ });
35
+
36
+ interface CoverageEntryBase {
37
+ readonly key: CoverageKey;
38
+ readonly claim: string;
39
+ }
40
+
41
+ export type CoverageEntry =
42
+ | (CoverageEntryBase & {
43
+ readonly mode: "direct";
44
+ readonly scenarios: readonly [];
45
+ })
46
+ | (CoverageEntryBase & {
47
+ readonly mode: "fixture" | "mixed";
48
+ readonly scenarios: readonly [ScenarioId, ...ScenarioId[]];
49
+ });
50
+
51
+ export type CoverageErrorCode =
52
+ | "duplicate-coverage"
53
+ | "duplicate-expected-key"
54
+ | "invalid-claim"
55
+ | "invalid-coverage"
56
+ | "invalid-mode"
57
+ | "invalid-scenario"
58
+ | "missing-coverage"
59
+ | "too-many-coverage-entries"
60
+ | "unexpected-coverage"
61
+ | "unknown-coverage"
62
+ | "unknown-scenario";
63
+
64
+ export interface CoverageError {
65
+ readonly code: CoverageErrorCode;
66
+ readonly message: string;
67
+ readonly keys: readonly string[];
68
+ }
69
+
70
+ export interface CoverageCatalog {
71
+ readonly size: number;
72
+ readonly keys: () => readonly CoverageKey[];
73
+ readonly list: () => readonly CoverageEntry[];
74
+ readonly get: (key: CoverageKey) => CoverageEntry | undefined;
75
+ readonly resolve: (key: unknown) => Result<CoverageEntry, CoverageError>;
76
+ readonly requireExactKeys: (expected: readonly (CoverageKey | string)[]) => Result<true, CoverageError>;
77
+ }
78
+
79
+ export interface CoverageCatalogSnapshot {
80
+ readonly schema: typeof DIRECT_COVERAGE_SCHEMA;
81
+ readonly entries: readonly CoverageEntry[];
82
+ }
83
+
84
+ export const EMPTY_COVERAGE_CATALOG_SNAPSHOT = Object.freeze({
85
+ schema: DIRECT_COVERAGE_SCHEMA,
86
+ entries: Object.freeze([]),
87
+ }) satisfies CoverageCatalogSnapshot;
88
+
89
+ function coverageError(code: CoverageErrorCode, message: string, keys: readonly string[] = []): CoverageError {
90
+ return { code, message, keys };
91
+ }
92
+
93
+ function hasControlCharacters(value: string): boolean {
94
+ for (const character of value) {
95
+ const code = character.charCodeAt(0);
96
+ if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127) {
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
103
+ const COVERAGE_ENTRY_KEYS = new Set(["key", "mode", "claim", "scenarios"]);
104
+ const COVERAGE_SNAPSHOT_KEYS = new Set(["schema", "entries"]);
105
+
106
+ function isStringArray(value: unknown): value is readonly string[] {
107
+ return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string");
108
+ }
109
+
110
+ /** Create the exact versioned JSON snapshot published to verification tooling. */
111
+ export function createCoverageCatalogSnapshot(
112
+ catalog: CoverageCatalog,
113
+ ): CoverageCatalogSnapshot {
114
+ return Object.freeze({
115
+ schema: DIRECT_COVERAGE_SCHEMA,
116
+ entries: catalog.list(),
117
+ });
118
+ }
119
+
120
+ /** Parse an exact versioned coverage snapshot read from verification tooling. */
121
+ export function parseCoverageCatalogSnapshot(
122
+ input: unknown,
123
+ limits: JsonLimits = DIRECT_COVERAGE_JSON_LIMITS,
124
+ ): Result<CoverageCatalogSnapshot, CoverageError> {
125
+ const parsed = parseJsonValue(input, limits);
126
+ if (!parsed.ok || !isRecord(parsed.value)) {
127
+ return err(coverageError(
128
+ "invalid-coverage",
129
+ parsed.ok ? "Coverage snapshot must be an object" : parsed.error.message,
130
+ ));
131
+ }
132
+ for (const key of Object.keys(parsed.value)) {
133
+ if (!COVERAGE_SNAPSHOT_KEYS.has(key)) {
134
+ return err(coverageError("invalid-coverage", `Unknown coverage snapshot key: ${key}`));
135
+ }
136
+ }
137
+ if (parsed.value.schema !== DIRECT_COVERAGE_SCHEMA) {
138
+ return err(coverageError(
139
+ "invalid-coverage",
140
+ `Coverage snapshot schema must be ${DIRECT_COVERAGE_SCHEMA}`,
141
+ ));
142
+ }
143
+ if (!Array.isArray(parsed.value.entries)) {
144
+ return err(coverageError("invalid-coverage", "Coverage snapshot entries must be an array"));
145
+ }
146
+ const entries: CoverageEntryInput[] = [];
147
+ for (const [index, candidate] of parsed.value.entries.entries()) {
148
+ if (!isRecord(candidate)) {
149
+ return err(coverageError("invalid-coverage", `Coverage entry ${String(index)} must be an object`));
150
+ }
151
+ for (const key of Object.keys(candidate)) {
152
+ if (!COVERAGE_ENTRY_KEYS.has(key)) {
153
+ return err(coverageError(
154
+ "invalid-coverage",
155
+ `Unknown coverage entry key at ${String(index)}: ${key}`,
156
+ ));
157
+ }
158
+ }
159
+ if (
160
+ typeof candidate.key !== "string"
161
+ || typeof candidate.claim !== "string"
162
+ || (candidate.mode !== "fixture" && candidate.mode !== "mixed" && candidate.mode !== "direct")
163
+ || !isStringArray(candidate.scenarios)
164
+ ) {
165
+ return err(coverageError(
166
+ "invalid-coverage",
167
+ `Coverage entry ${String(index)} has an invalid wire shape`,
168
+ ));
169
+ }
170
+ if (candidate.mode === "direct") {
171
+ if (candidate.scenarios.length > 0) {
172
+ return err(coverageError(
173
+ "invalid-mode",
174
+ `Direct coverage ${candidate.key} cannot cite fixture scenarios`,
175
+ [candidate.key],
176
+ ));
177
+ }
178
+ entries.push({
179
+ key: candidate.key,
180
+ mode: candidate.mode,
181
+ claim: candidate.claim,
182
+ scenarios: [],
183
+ });
184
+ } else {
185
+ const firstScenario = candidate.scenarios[0];
186
+ if (typeof firstScenario !== "string") {
187
+ return err(coverageError(
188
+ "invalid-mode",
189
+ `${candidate.mode} coverage ${candidate.key} must cite at least one scenario`,
190
+ [candidate.key],
191
+ ));
192
+ }
193
+ entries.push({
194
+ key: candidate.key,
195
+ mode: candidate.mode,
196
+ claim: candidate.claim,
197
+ scenarios: [firstScenario, ...candidate.scenarios.slice(1)],
198
+ });
199
+ }
200
+ }
201
+ const catalog = createCoverageCatalog(entries);
202
+ return catalog.ok ? ok(createCoverageCatalogSnapshot(catalog.value)) : catalog;
203
+ }
204
+
205
+ export function createCoverageCatalog<Scenario extends string = string>(
206
+ inputs: readonly CoverageEntryInput<Scenario>[],
207
+ scenarios?: { readonly get: (id: ScenarioId) => unknown },
208
+ ): Result<CoverageCatalog, CoverageError> {
209
+ if (inputs.length > MAX_DIRECT_COVERAGE_ENTRIES) {
210
+ return err(coverageError(
211
+ "too-many-coverage-entries",
212
+ `Direct definitions support at most ${String(MAX_DIRECT_COVERAGE_ENTRIES)} coverage entries`,
213
+ ));
214
+ }
215
+ const entries: CoverageEntry[] = [];
216
+ const byKey = new Map<CoverageKey, CoverageEntry>();
217
+
218
+ for (const input of inputs) {
219
+ const key = parseCoverageKey(input.key);
220
+ if (!key.ok) {
221
+ return err(coverageError("invalid-coverage", key.error.message, [String(input.key)]));
222
+ }
223
+ if (byKey.has(key.value)) {
224
+ return err(coverageError("duplicate-coverage", `Duplicate coverage key: ${key.value}`, [key.value]));
225
+ }
226
+ if (
227
+ input.claim.trim().length === 0
228
+ || input.claim.length > 1_000
229
+ || hasControlCharacters(input.claim)
230
+ ) {
231
+ return err(coverageError("invalid-claim", `Coverage ${key.value} needs a 1-1000 character claim`, [key.value]));
232
+ }
233
+ if (input.mode !== "fixture" && input.mode !== "mixed" && input.mode !== "direct") {
234
+ return err(coverageError("invalid-mode", `Coverage ${key.value} has an unknown proof mode`, [key.value]));
235
+ }
236
+ if (input.mode === "direct" && input.scenarios.length > 0) {
237
+ return err(coverageError("invalid-mode", `Direct coverage ${key.value} cannot cite fixture scenarios`, [key.value]));
238
+ }
239
+ if (input.mode !== "direct" && input.scenarios.length === 0) {
240
+ return err(coverageError("invalid-mode", `${input.mode} coverage ${key.value} must cite at least one scenario`, [key.value]));
241
+ }
242
+
243
+ const scenarioIds: ScenarioId[] = [];
244
+ const seenScenarios = new Set<ScenarioId>();
245
+ for (const candidate of input.scenarios) {
246
+ const id = parseScenarioId(candidate);
247
+ if (!id.ok) {
248
+ return err(coverageError("invalid-scenario", id.error.message, [String(candidate)]));
249
+ }
250
+ if (seenScenarios.has(id.value)) {
251
+ return err(coverageError("invalid-scenario", `Coverage ${key.value} repeats scenario ${id.value}`, [id.value]));
252
+ }
253
+ if (scenarios !== undefined && scenarios.get(id.value) === undefined) {
254
+ return err(coverageError("unknown-scenario", `Coverage ${key.value} cites unknown scenario ${id.value}`, [id.value]));
255
+ }
256
+ seenScenarios.add(id.value);
257
+ scenarioIds.push(id.value);
258
+ }
259
+
260
+ let entry: CoverageEntry;
261
+ if (input.mode === "direct") {
262
+ const scenarios: readonly [] = Object.freeze([]);
263
+ entry = Object.freeze({
264
+ key: key.value,
265
+ mode: input.mode,
266
+ claim: input.claim,
267
+ scenarios,
268
+ } satisfies CoverageEntry);
269
+ } else {
270
+ const firstScenarioId = scenarioIds[0];
271
+ if (firstScenarioId === undefined) {
272
+ return err(coverageError(
273
+ "invalid-mode",
274
+ `${input.mode} coverage ${key.value} must cite at least one scenario`,
275
+ [key.value],
276
+ ));
277
+ }
278
+ const scenarios: readonly [ScenarioId, ...ScenarioId[]] = Object.freeze([
279
+ firstScenarioId,
280
+ ...scenarioIds.slice(1),
281
+ ]);
282
+ entry = Object.freeze({
283
+ key: key.value,
284
+ mode: input.mode,
285
+ claim: input.claim,
286
+ scenarios,
287
+ } satisfies CoverageEntry);
288
+ }
289
+ entries.push(entry);
290
+ byKey.set(key.value, entry);
291
+ }
292
+
293
+ const frozenEntries = Object.freeze(entries);
294
+ const keys = Object.freeze(frozenEntries.map((entry) => entry.key));
295
+ const catalog: CoverageCatalog = {
296
+ size: frozenEntries.length,
297
+ keys: () => keys,
298
+ list: () => frozenEntries,
299
+ get: (key: CoverageKey) => byKey.get(key),
300
+ resolve: (input: unknown) => {
301
+ const key = parseCoverageKey(input);
302
+ if (!key.ok) {
303
+ return err(coverageError("invalid-coverage", key.error.message, [String(input)]));
304
+ }
305
+ const entry = byKey.get(key.value);
306
+ return entry === undefined
307
+ ? err(coverageError("unknown-coverage", `Unknown coverage key: ${key.value}`, [key.value]))
308
+ : ok(entry);
309
+ },
310
+ requireExactKeys: (expected: readonly (CoverageKey | string)[]) => {
311
+ const expectedKeys: CoverageKey[] = [];
312
+ const seen = new Set<CoverageKey>();
313
+ for (const candidate of expected) {
314
+ const parsed = parseCoverageKey(candidate);
315
+ if (!parsed.ok) {
316
+ return err(coverageError("invalid-coverage", parsed.error.message, [String(candidate)]));
317
+ }
318
+ if (seen.has(parsed.value)) {
319
+ return err(coverageError("duplicate-expected-key", `Expected coverage repeats ${parsed.value}`, [parsed.value]));
320
+ }
321
+ seen.add(parsed.value);
322
+ expectedKeys.push(parsed.value);
323
+ }
324
+ const missing = expectedKeys.filter((key) => !byKey.has(key));
325
+ if (missing.length > 0) {
326
+ return err(coverageError("missing-coverage", `Missing coverage keys: ${missing.join(", ")}`, missing));
327
+ }
328
+ const unexpected = keys.filter((key) => !seen.has(key));
329
+ if (unexpected.length > 0) {
330
+ return err(coverageError("unexpected-coverage", `Unexpected coverage keys: ${unexpected.join(", ")}`, unexpected));
331
+ }
332
+ return ok<true>(true);
333
+ },
334
+ };
335
+ return ok(Object.freeze(catalog));
336
+ }