@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.
- package/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/core/index.js +162 -0
- package/dist/index-1csg00w4.js +1167 -0
- package/dist/index-6mdfd2ey.js +464 -0
- package/dist/index-7n1h75n6.js +616 -0
- package/dist/index.js +232 -0
- package/dist/react.js +32 -0
- package/dist/testing/index.js +1069 -0
- package/dist/tooling/bombadil.js +2117 -0
- package/dist/tooling/browser-verification-entry.js +1499 -0
- package/dist/tooling/bundle-boundary.js +119 -0
- package/dist/web.js +605 -0
- package/package.json +179 -0
- package/skills/direct/AGENTS.md +13 -0
- package/skills/direct/SKILL.md +49 -0
- package/skills/direct/agents/openai.yaml +4 -0
- package/skills/direct/references/adoption.md +131 -0
- package/skills/direct/references/install.md +91 -0
- package/skills/direct/references/verification.md +247 -0
- package/src/core/coverage.ts +336 -0
- package/src/core/definition.ts +378 -0
- package/src/core/effects.ts +88 -0
- package/src/core/fixture.ts +185 -0
- package/src/core/ids.ts +77 -0
- package/src/core/index.ts +13 -0
- package/src/core/json-value.ts +7 -0
- package/src/core/json.ts +593 -0
- package/src/core/query.ts +230 -0
- package/src/core/reason.ts +16 -0
- package/src/core/resource.ts +10 -0
- package/src/core/result.ts +19 -0
- package/src/core/runtime.ts +229 -0
- package/src/core/scenario.ts +149 -0
- package/src/core/store.ts +784 -0
- package/src/index.ts +51 -0
- package/src/react.ts +54 -0
- package/src/testing/activity.ts +228 -0
- package/src/testing/coverage-binding.ts +99 -0
- package/src/testing/evidence.ts +59 -0
- package/src/testing/index.ts +22 -0
- package/src/testing/manifest.ts +559 -0
- package/src/testing/probe.ts +446 -0
- package/src/testing/scripted-transport.ts +775 -0
- package/src/testing/session.ts +525 -0
- package/src/tooling/bombadil-campaign.ts +288 -0
- package/src/tooling/bombadil-internal.d.ts +46 -0
- package/src/tooling/bombadil-runner.ts +1424 -0
- package/src/tooling/bombadil.ts +27 -0
- package/src/tooling/browser-verification-entry.ts +32 -0
- package/src/tooling/browser-verification.ts +916 -0
- package/src/tooling/bundle-boundary.ts +159 -0
- package/src/web/browser-bridge.ts +296 -0
- package/src/web/browser.ts +277 -0
- package/src/web/fetch-firewall.ts +251 -0
- 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
|
+
}
|