@hraness/direct 0.7.6 → 0.7.7

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.
@@ -3,6 +3,7 @@ import { readFile, realpath, stat, writeFile } from "node:fs/promises";
3
3
  import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import { createInterface } from "node:readline";
6
+ import { createHash } from "node:crypto";
6
7
 
7
8
  import {
8
9
  parseDirectProbeSnapshot,
@@ -44,6 +45,12 @@ const TRACE_MAX_BYTES = 64 * 1024 * 1024;
44
45
  const TRACE_MAX_LINE_BYTES = 16 * 1024 * 1024;
45
46
  const TRACE_MAX_LINES = 10_000;
46
47
  const TRACE_MAX_SNAPSHOTS_PER_LINE = 4_096;
48
+ const TRACE_MAX_NAMED_SNAPSHOT_NAMES = 128;
49
+ const TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME = 1_024;
50
+ const TRACE_MAX_DISTINCT_URLS = 1_024;
51
+ const TRACE_MAX_PROPERTY_NAMES = 128;
52
+ const TRACE_MAX_CANONICAL_SNAPSHOT_BYTES = 2 * 1024 * 1024;
53
+ const TRACE_MAX_JSON_DEPTH = 64;
47
54
  const RANDOM_RUN_OVERHEAD_MS = 30_000;
48
55
  const REPLAY_WALL_CLOCK_TIMEOUT_MS = MAX_TIME_LIMIT_SECONDS * 1_000 + RANDOM_RUN_OVERHEAD_MS;
49
56
  const PROCESS_TERMINATION_GRACE_MS = 5_000;
@@ -52,6 +59,94 @@ const SERVER_OUTPUT_TIMEOUT_MS = 3_000;
52
59
  const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2";
53
60
  const TRACE_LINE_KEYS = new Set(["action", "snapshots", "state", "timestamp", "violations"]);
54
61
  const TRACE_SNAPSHOT_KEYS = new Set(["index", "name", "time", "value"]);
62
+ const TRACE_STATE_KEYS = new Set([
63
+ "hash_current",
64
+ "hash_previous",
65
+ "resources",
66
+ "screenshot",
67
+ "url",
68
+ ]);
69
+ const TRACE_RESOURCE_KEYS = new Set([
70
+ "documents",
71
+ "dom_nodes",
72
+ "js_event_listeners",
73
+ "js_heap_total",
74
+ "js_heap_used",
75
+ "layout_objects",
76
+ "script_duration",
77
+ "task_duration",
78
+ "thread_time",
79
+ "timestamp",
80
+ ]);
81
+ const TRACE_VIOLATION_KEYS = new Set(["name", "violation"]);
82
+ const TRACE_POINT_KEYS = new Set(["x", "y"]);
83
+ const TRACE_FINGERPRINT_KEYS = new Set([
84
+ "accessible_name",
85
+ "href",
86
+ "id",
87
+ "input_type",
88
+ "name_attr",
89
+ "placeholder",
90
+ "role",
91
+ "structural_path",
92
+ "tag",
93
+ "test_id",
94
+ "text_content",
95
+ ]);
96
+ const TRACE_CLICK_ACTION_KEYS = new Set(["fingerprint", "point"]);
97
+ const TRACE_DOUBLE_CLICK_ACTION_KEYS = new Set([
98
+ "delay_millis",
99
+ "fingerprint",
100
+ "point",
101
+ ]);
102
+ const TRACE_TYPE_TEXT_ACTION_KEYS = new Set(["delay_millis", "text"]);
103
+ const TRACE_PRESS_KEY_ACTION_KEYS = new Set(["code"]);
104
+ const TRACE_SCROLL_ACTION_KEYS = new Set(["distance", "origin"]);
105
+ const TRACE_FILE_INPUT_ACTION_KEYS = new Set(["files", "selector"]);
106
+ const TRACE_MOUSE_DRAG_ACTION_KEYS = new Set([
107
+ "delay_millis",
108
+ "from",
109
+ "steps",
110
+ "to",
111
+ ]);
112
+ const TRACE_VIEWPORT_ACTION_KEYS = new Set(["height", "width"]);
113
+ const VIEWPORT_KEYS = new Set(["deviceScaleFactor", "height", "width"]);
114
+ const EXPLORATION_POLICY_KEYS = new Set([
115
+ "minDistinctNamedSnapshotValues",
116
+ "minNamedSnapshotChangesAfterActionKind",
117
+ "minNamedSnapshotChangesAfterNonWait",
118
+ "minNonWaitActions",
119
+ "requireStableTargetUrl",
120
+ "requiredActionKinds",
121
+ "requiredNamedSnapshots",
122
+ ]);
123
+ const DEFAULT_VIEWPORT_WIDTH = 1_024;
124
+ const DEFAULT_VIEWPORT_HEIGHT = 768;
125
+ const DEFAULT_DEVICE_SCALE_FACTOR = 2;
126
+ const SNAPSHOT_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.:/-]*$/u;
127
+ const TARGET_TAG_PATTERN = /^[a-z][a-z0-9-]*$/u;
128
+ const ACTION_KINDS = [
129
+ "Back",
130
+ "Click",
131
+ "DoubleClick",
132
+ "Forward",
133
+ "MouseDrag",
134
+ "PressKey",
135
+ "Reload",
136
+ "ScrollDown",
137
+ "ScrollUp",
138
+ "SetFileInputFiles",
139
+ "SetViewport",
140
+ "TypeText",
141
+ "Wait",
142
+ ] as const;
143
+ const ACTION_KIND_SET = new Set<string>(ACTION_KINDS);
144
+ const UNIT_ACTION_KINDS = new Set<DirectBombadilActionKind>([
145
+ "Back",
146
+ "Forward",
147
+ "Reload",
148
+ "Wait",
149
+ ]);
55
150
  const DIRECT_OBSERVATION_KEYS = new Set([
56
151
  "activationHash",
57
152
  "activeRoute",
@@ -76,6 +171,88 @@ export interface DirectBombadilServerConfig {
76
171
  readonly startupTimeoutMs?: number;
77
172
  }
78
173
 
174
+ export type DirectBombadilActionKind = (typeof ACTION_KINDS)[number];
175
+
176
+ export interface DirectBombadilViewportConfig {
177
+ readonly deviceScaleFactor?: number;
178
+ readonly height?: number;
179
+ readonly width?: number;
180
+ }
181
+
182
+ export interface DirectBombadilExplorationPolicy {
183
+ readonly minDistinctNamedSnapshotValues?: Readonly<Record<string, number>>;
184
+ readonly minNamedSnapshotChangesAfterActionKind?: Readonly<Record<
185
+ string,
186
+ Readonly<Partial<Record<DirectBombadilActionKind, number>>>
187
+ >>;
188
+ readonly minNamedSnapshotChangesAfterNonWait?: Readonly<Record<string, number>>;
189
+ readonly minNonWaitActions?: number;
190
+ readonly requireStableTargetUrl?: boolean;
191
+ readonly requiredActionKinds?: readonly DirectBombadilActionKind[];
192
+ readonly requiredNamedSnapshots?: readonly string[];
193
+ }
194
+
195
+ export interface DirectBombadilExplorationSummary {
196
+ readonly schema: "direct.bombadil-exploration-summary/v2";
197
+ readonly trace: {
198
+ readonly bytes: number;
199
+ readonly lineCount: number;
200
+ readonly sha256: string;
201
+ };
202
+ readonly actions: {
203
+ readonly byKind: Readonly<Partial<Record<DirectBombadilActionKind, number>>>;
204
+ readonly maxWaitStreak: number;
205
+ readonly nonWaitCount: number;
206
+ readonly targetTags: Readonly<Record<string, number>>;
207
+ readonly total: number;
208
+ };
209
+ readonly urls: {
210
+ readonly distinctFingerprintCount: number;
211
+ readonly fingerprintSha256: readonly string[];
212
+ readonly observationCount: number;
213
+ readonly rawDistinctFingerprintCount: number;
214
+ readonly rawFingerprintSha256: readonly string[];
215
+ readonly rawObservationCount: number;
216
+ readonly stableTarget: boolean;
217
+ };
218
+ readonly transitions: {
219
+ readonly distinctNonNullHashCount: number;
220
+ readonly nonNullHashCount: number;
221
+ readonly rawDistinctNonNullHashCount: number;
222
+ readonly rawNonNullHashCount: number;
223
+ };
224
+ readonly namedSnapshots: readonly {
225
+ readonly changeAfterActionKind: Readonly<
226
+ Partial<Record<DirectBombadilActionKind, number>>
227
+ >;
228
+ readonly changeAfterNonWaitCount: number;
229
+ readonly distinctValueCount: number;
230
+ readonly distinctValueSha256: readonly string[];
231
+ readonly name: string;
232
+ readonly observationCount: number;
233
+ }[];
234
+ readonly propertyViolations: {
235
+ readonly byName: Readonly<Record<string, number>>;
236
+ readonly total: number;
237
+ };
238
+ readonly resourceHighWaterMarks: {
239
+ readonly documents: number;
240
+ readonly domNodes: number;
241
+ readonly jsEventListeners: number;
242
+ readonly jsHeapTotalBytes: number;
243
+ readonly jsHeapUsedBytes: number;
244
+ readonly layoutObjects: number;
245
+ readonly scriptDurationSeconds: number;
246
+ readonly taskDurationSeconds: number;
247
+ readonly threadTimeSeconds: number;
248
+ };
249
+ readonly policy: {
250
+ readonly configured: boolean;
251
+ readonly failures: readonly string[];
252
+ readonly satisfied: boolean;
253
+ };
254
+ }
255
+
79
256
  export interface DirectBombadilFuzzConfig {
80
257
  readonly artifactName: string;
81
258
  readonly baseUrl: string;
@@ -86,6 +263,8 @@ export interface DirectBombadilFuzzConfig {
86
263
  readonly scenario: string;
87
264
  readonly specificationPath: string;
88
265
  readonly targetQuery?: Readonly<Record<string, string>>;
266
+ readonly explorationPolicy?: DirectBombadilExplorationPolicy;
267
+ readonly viewport?: DirectBombadilViewportConfig;
89
268
  readonly server: DirectBombadilServerConfig;
90
269
  }
91
270
 
@@ -107,6 +286,21 @@ export type DirectBombadilFuzzResult =
107
286
  readonly status: "passed";
108
287
  };
109
288
 
289
+ export interface DirectBombadilFuzzCampaign {
290
+ readonly config: DirectBombadilFuzzConfig;
291
+ readonly id: string;
292
+ }
293
+
294
+ export type DirectBombadilFuzzMatrixResult =
295
+ | { readonly kind: "help" }
296
+ | {
297
+ readonly kind: "matrix";
298
+ readonly results: readonly {
299
+ readonly campaignId: string;
300
+ readonly result: Extract<DirectBombadilFuzzResult, { readonly kind: "run" }>;
301
+ }[];
302
+ };
303
+
110
304
  export interface DirectBombadilInvocation {
111
305
  readonly abortSignal?: AbortSignal;
112
306
  readonly command: readonly string[];
@@ -160,6 +354,7 @@ export interface DirectBombadilTraceAttestation {
160
354
 
161
355
  export interface DirectBombadilRunnerDependencies {
162
356
  readonly acquireServer: typeof acquireVerificationServer;
357
+ readonly createAbortController?: () => AbortController;
163
358
  readonly now: () => Date;
164
359
  readonly runBombadil: (
165
360
  invocation: DirectBombadilInvocation,
@@ -184,17 +379,41 @@ interface ProcessSignalEmitter {
184
379
  ) => unknown;
185
380
  }
186
381
 
187
- interface ValidatedConfig extends DirectBombadilFuzzConfig {
382
+ type ValidatedConfig = Omit<
383
+ DirectBombadilFuzzConfig,
384
+ "explorationPolicy" | "server" | "viewport"
385
+ > & {
188
386
  readonly artifactRoot: string;
189
387
  readonly baseUrl: string;
190
388
  readonly bombadilExecutable: string;
191
389
  readonly entryPath: `/${string}`;
390
+ readonly explorationPolicy: ValidatedExplorationPolicy | null;
192
391
  readonly port: string;
193
392
  readonly targetQuery: Readonly<Record<string, string>>;
393
+ readonly viewport: ValidatedViewport;
194
394
  readonly server: DirectBombadilServerConfig & {
195
395
  readonly readinessPath: `/${string}`;
196
396
  readonly startupTimeoutMs: number;
197
397
  };
398
+ };
399
+
400
+ interface ValidatedViewport {
401
+ readonly deviceScaleFactor: number;
402
+ readonly height: number;
403
+ readonly width: number;
404
+ }
405
+
406
+ interface ValidatedExplorationPolicy {
407
+ readonly minDistinctNamedSnapshotValues: Readonly<Record<string, number>>;
408
+ readonly minNamedSnapshotChangesAfterActionKind: Readonly<Record<
409
+ string,
410
+ Readonly<Partial<Record<DirectBombadilActionKind, number>>>
411
+ >>;
412
+ readonly minNamedSnapshotChangesAfterNonWait: Readonly<Record<string, number>>;
413
+ readonly minNonWaitActions: number;
414
+ readonly requireStableTargetUrl: boolean;
415
+ readonly requiredActionKinds: readonly DirectBombadilActionKind[];
416
+ readonly requiredNamedSnapshots: readonly string[];
198
417
  }
199
418
 
200
419
  function readOptionValue(
@@ -283,6 +502,12 @@ function hasExactKeys(
283
502
  return keys.length === expected.size && keys.every((key) => expected.has(key));
284
503
  }
285
504
 
505
+ function compareCodeUnits(left: string, right: string): number {
506
+ if (left < right) return -1;
507
+ if (left > right) return 1;
508
+ return 0;
509
+ }
510
+
286
511
  function parseTraceDirectObservation(value: unknown): TraceDirectObservation {
287
512
  if (!isRecord(value) || !hasExactKeys(value, DIRECT_OBSERVATION_KEYS)) {
288
513
  throw new Error("Bombadil trace has an invalid named direct observation");
@@ -427,7 +652,325 @@ function exactTraceDirectObservation(
427
652
  };
428
653
  }
429
654
 
430
- function parseTraceLine(line: string, lineNumber: number): TraceDirectObservation {
655
+ interface ParsedTraceAction {
656
+ readonly kind: DirectBombadilActionKind;
657
+ readonly targetTag: string | null;
658
+ }
659
+
660
+ interface ParsedTraceState {
661
+ readonly currentHash: number | null;
662
+ readonly resources: Readonly<Record<keyof typeof RESOURCE_FIELD_MAP, number>>;
663
+ readonly url: URL;
664
+ }
665
+
666
+ interface ParsedTraceLine {
667
+ readonly action: ParsedTraceAction | null;
668
+ readonly directObservation: TraceDirectObservation;
669
+ readonly namedSnapshots: readonly {
670
+ readonly name: string;
671
+ readonly valueSha256: string;
672
+ }[];
673
+ readonly propertyViolationNames: readonly string[];
674
+ readonly state: ParsedTraceState;
675
+ }
676
+
677
+ interface ParsedTraceEnvelope {
678
+ readonly action: unknown;
679
+ readonly snapshots: readonly unknown[];
680
+ readonly state: unknown;
681
+ readonly timestamp: number;
682
+ readonly violations: readonly unknown[];
683
+ }
684
+
685
+ interface ParsedDirectTraceObservation {
686
+ readonly observation: TraceDirectObservation;
687
+ readonly value: unknown;
688
+ }
689
+
690
+ const RESOURCE_FIELD_MAP = {
691
+ documents: "documents",
692
+ dom_nodes: "domNodes",
693
+ js_event_listeners: "jsEventListeners",
694
+ js_heap_total: "jsHeapTotalBytes",
695
+ js_heap_used: "jsHeapUsedBytes",
696
+ layout_objects: "layoutObjects",
697
+ script_duration: "scriptDurationSeconds",
698
+ task_duration: "taskDurationSeconds",
699
+ thread_time: "threadTimeSeconds",
700
+ } as const;
701
+
702
+ function canonicalJson(
703
+ value: unknown,
704
+ depth = 0,
705
+ maximumDepth = TRACE_MAX_JSON_DEPTH,
706
+ ): string {
707
+ if (depth > maximumDepth) {
708
+ throw new Error(`Bombadil named snapshot exceeds JSON depth ${String(maximumDepth)}`);
709
+ }
710
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
711
+ return JSON.stringify(value);
712
+ }
713
+ if (typeof value === "number") {
714
+ if (!Number.isFinite(value)) throw new Error("Bombadil named snapshot has a non-finite number");
715
+ return JSON.stringify(value);
716
+ }
717
+ if (Array.isArray(value)) {
718
+ return `[${value.map((entry) => canonicalJson(entry, depth + 1, maximumDepth)).join(",")}]`;
719
+ }
720
+ if (!isRecord(value)) throw new Error("Bombadil named snapshot is not JSON");
721
+ const entries = Object.keys(value).sort(compareCodeUnits).map((key) =>
722
+ `${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1, maximumDepth)}`
723
+ );
724
+ return `{${entries.join(",")}}`;
725
+ }
726
+
727
+ function sha256(value: string | Uint8Array): string {
728
+ return createHash("sha256").update(value).digest("hex");
729
+ }
730
+
731
+ function namedSnapshotValueSha256(
732
+ value: unknown,
733
+ options: {
734
+ readonly maximumBytes?: number;
735
+ readonly maximumDepth?: number;
736
+ } = {},
737
+ ): string {
738
+ const maximumBytes = options.maximumBytes ?? TRACE_MAX_CANONICAL_SNAPSHOT_BYTES;
739
+ const canonical = canonicalJson(
740
+ value,
741
+ 0,
742
+ options.maximumDepth ?? TRACE_MAX_JSON_DEPTH,
743
+ );
744
+ if (Buffer.byteLength(canonical, "utf8") > maximumBytes) {
745
+ throw new Error(
746
+ `Bombadil named snapshot exceeds ${String(maximumBytes)} canonical bytes`,
747
+ );
748
+ }
749
+ return sha256(canonical);
750
+ }
751
+
752
+ function validTracePoint(value: unknown): boolean {
753
+ return isRecord(value)
754
+ && hasExactKeys(value, TRACE_POINT_KEYS)
755
+ && typeof value.x === "number"
756
+ && Number.isFinite(value.x)
757
+ && typeof value.y === "number"
758
+ && Number.isFinite(value.y);
759
+ }
760
+
761
+ function parseTraceFingerprintTag(value: unknown, lineNumber: number): string {
762
+ if (
763
+ !isRecord(value)
764
+ || !Object.keys(value).every((key) => TRACE_FINGERPRINT_KEYS.has(key))
765
+ ) {
766
+ throw new Error(
767
+ `Bombadil trace line ${String(lineNumber)} has an invalid action target`,
768
+ );
769
+ }
770
+ for (const [key, candidate] of Object.entries(value)) {
771
+ if (key !== "tag" && typeof candidate !== "string") {
772
+ throw new Error(
773
+ `Bombadil trace line ${String(lineNumber)} has an invalid action target`,
774
+ );
775
+ }
776
+ }
777
+ const tag = value.tag;
778
+ if (
779
+ typeof tag !== "string"
780
+ || tag.length === 0
781
+ ) {
782
+ throw new Error(
783
+ `Bombadil trace line ${String(lineNumber)} has an invalid action target tag`,
784
+ );
785
+ }
786
+ if (
787
+ typeof value.structural_path === "string"
788
+ && Object.keys(value).some((key) => (
789
+ key !== "tag" && key !== "structural_path"
790
+ ))
791
+ ) {
792
+ throw new Error(
793
+ `Bombadil trace line ${String(lineNumber)} has an invalid action target`,
794
+ );
795
+ }
796
+ return tag.length <= 64 && TARGET_TAG_PATTERN.test(tag)
797
+ ? tag
798
+ : `sha256:${sha256(tag)}`;
799
+ }
800
+
801
+ function isSafeIntegerBetween(
802
+ value: unknown,
803
+ minimum: number,
804
+ maximum: number,
805
+ ): value is number {
806
+ return typeof value === "number"
807
+ && Number.isSafeInteger(value)
808
+ && value >= minimum
809
+ && value <= maximum;
810
+ }
811
+
812
+ function invalidTraceAction(lineNumber: number): never {
813
+ throw new Error(
814
+ `Bombadil trace line ${String(lineNumber)} has an invalid action`,
815
+ );
816
+ }
817
+
818
+ function parseTraceAction(value: unknown, lineNumber: number): ParsedTraceAction | null {
819
+ if (value === null) return null;
820
+ if (typeof value === "string") {
821
+ if (!ACTION_KIND_SET.has(value) || !UNIT_ACTION_KINDS.has(value as DirectBombadilActionKind)) {
822
+ return invalidTraceAction(lineNumber);
823
+ }
824
+ return { kind: value as DirectBombadilActionKind, targetTag: null };
825
+ }
826
+ if (!isRecord(value) || Object.keys(value).length !== 1) {
827
+ return invalidTraceAction(lineNumber);
828
+ }
829
+ const kind = Object.keys(value)[0];
830
+ const payload = kind === undefined ? undefined : value[kind];
831
+ if (
832
+ kind === undefined
833
+ || !ACTION_KIND_SET.has(kind)
834
+ || UNIT_ACTION_KINDS.has(kind as DirectBombadilActionKind)
835
+ || !isRecord(payload)
836
+ ) {
837
+ return invalidTraceAction(lineNumber);
838
+ }
839
+ const actionKind = kind as DirectBombadilActionKind;
840
+ let targetTag: string | null = null;
841
+ switch (actionKind) {
842
+ case "Click":
843
+ if (
844
+ !hasExactKeys(payload, TRACE_CLICK_ACTION_KEYS)
845
+ || !validTracePoint(payload.point)
846
+ ) {
847
+ return invalidTraceAction(lineNumber);
848
+ }
849
+ targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
850
+ break;
851
+ case "DoubleClick":
852
+ if (
853
+ !hasExactKeys(payload, TRACE_DOUBLE_CLICK_ACTION_KEYS)
854
+ || !isSafeIntegerBetween(payload.delay_millis, 0, 1_000)
855
+ || !validTracePoint(payload.point)
856
+ ) return invalidTraceAction(lineNumber);
857
+ targetTag = parseTraceFingerprintTag(payload.fingerprint, lineNumber);
858
+ break;
859
+ case "TypeText":
860
+ if (
861
+ !hasExactKeys(payload, TRACE_TYPE_TEXT_ACTION_KEYS)
862
+ || !isSafeIntegerBetween(payload.delay_millis, 0, Number.MAX_SAFE_INTEGER)
863
+ || typeof payload.text !== "string"
864
+ ) return invalidTraceAction(lineNumber);
865
+ break;
866
+ case "PressKey":
867
+ if (
868
+ !hasExactKeys(payload, TRACE_PRESS_KEY_ACTION_KEYS)
869
+ || !isSafeIntegerBetween(payload.code, 0, 255)
870
+ ) {
871
+ return invalidTraceAction(lineNumber);
872
+ }
873
+ break;
874
+ case "ScrollDown":
875
+ case "ScrollUp":
876
+ if (
877
+ !hasExactKeys(payload, TRACE_SCROLL_ACTION_KEYS)
878
+ || typeof payload.distance !== "number"
879
+ || !Number.isFinite(payload.distance)
880
+ || !validTracePoint(payload.origin)
881
+ ) return invalidTraceAction(lineNumber);
882
+ break;
883
+ case "SetFileInputFiles":
884
+ if (
885
+ !hasExactKeys(payload, TRACE_FILE_INPUT_ACTION_KEYS)
886
+ || typeof payload.selector !== "string"
887
+ || !Array.isArray(payload.files)
888
+ || !payload.files.every((file) => typeof file === "string")
889
+ ) return invalidTraceAction(lineNumber);
890
+ break;
891
+ case "MouseDrag":
892
+ if (
893
+ !hasExactKeys(payload, TRACE_MOUSE_DRAG_ACTION_KEYS)
894
+ || !isSafeIntegerBetween(payload.delay_millis, 0, 1_000)
895
+ || !isSafeIntegerBetween(payload.steps, 1, 255)
896
+ || !validTracePoint(payload.from)
897
+ || !validTracePoint(payload.to)
898
+ ) return invalidTraceAction(lineNumber);
899
+ break;
900
+ case "SetViewport":
901
+ if (
902
+ !hasExactKeys(payload, TRACE_VIEWPORT_ACTION_KEYS)
903
+ || !isSafeIntegerBetween(payload.height, 1, 10_000)
904
+ || !isSafeIntegerBetween(payload.width, 1, 10_000)
905
+ ) return invalidTraceAction(lineNumber);
906
+ break;
907
+ default:
908
+ return invalidTraceAction(lineNumber);
909
+ }
910
+ return { kind: actionKind, targetTag };
911
+ }
912
+
913
+ function parseNonNegativeFiniteNumber(
914
+ value: unknown,
915
+ lineNumber: number,
916
+ field: string,
917
+ ): number {
918
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
919
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
920
+ }
921
+ return value;
922
+ }
923
+
924
+ function parseTraceState(value: unknown, lineNumber: number): ParsedTraceState {
925
+ if (!isRecord(value) || !hasExactKeys(value, TRACE_STATE_KEYS)) {
926
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser state`);
927
+ }
928
+ if (typeof value.url !== "string" || value.url.length === 0 || value.url.length > 8_192) {
929
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
930
+ }
931
+ let url: URL;
932
+ try {
933
+ url = new URL(value.url);
934
+ } catch {
935
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL`);
936
+ }
937
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
938
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid browser URL protocol`);
939
+ }
940
+ if (typeof value.screenshot !== "string" || value.screenshot.length > 8_192) {
941
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid screenshot path`);
942
+ }
943
+ for (const field of ["hash_previous", "hash_current"] as const) {
944
+ const hash = value[field];
945
+ if (hash !== null && (
946
+ typeof hash !== "number"
947
+ || !Number.isFinite(hash)
948
+ || !Number.isInteger(hash)
949
+ || hash < 0
950
+ )) {
951
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid ${field}`);
952
+ }
953
+ }
954
+ if (!isRecord(value.resources) || !hasExactKeys(value.resources, TRACE_RESOURCE_KEYS)) {
955
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid browser resources`);
956
+ }
957
+ const resources: Record<string, number> = {};
958
+ for (const field of Object.keys(RESOURCE_FIELD_MAP) as (keyof typeof RESOURCE_FIELD_MAP)[]) {
959
+ resources[field] = parseNonNegativeFiniteNumber(
960
+ value.resources[field],
961
+ lineNumber,
962
+ `resources.${field}`,
963
+ );
964
+ }
965
+ parseNonNegativeFiniteNumber(value.resources.timestamp, lineNumber, "resources.timestamp");
966
+ return {
967
+ currentHash: value.hash_current as number | null,
968
+ resources: resources as Readonly<Record<keyof typeof RESOURCE_FIELD_MAP, number>>,
969
+ url,
970
+ };
971
+ }
972
+
973
+ function parseTraceEnvelope(line: string, lineNumber: number): ParsedTraceEnvelope {
431
974
  let input: unknown;
432
975
  try {
433
976
  input = JSON.parse(line) as unknown;
@@ -447,9 +990,22 @@ function parseTraceLine(line: string, lineNumber: number): TraceDirectObservatio
447
990
  ) {
448
991
  throw new Error(`Bombadil trace line ${String(lineNumber)} has invalid state fields`);
449
992
  }
450
- const snapshots = input.snapshots as unknown[];
451
- const directSnapshots = snapshots.filter((snapshot): snapshot is Readonly<Record<string, unknown>> =>
452
- isRecord(snapshot) && snapshot.name === "direct"
993
+ return {
994
+ action: input.action,
995
+ snapshots: input.snapshots as unknown[],
996
+ state: input.state,
997
+ timestamp: input.timestamp,
998
+ violations: input.violations,
999
+ };
1000
+ }
1001
+
1002
+ function parseDirectTraceObservation(
1003
+ envelope: ParsedTraceEnvelope,
1004
+ lineNumber: number,
1005
+ ): ParsedDirectTraceObservation {
1006
+ const directSnapshots = envelope.snapshots.filter(
1007
+ (snapshot): snapshot is Readonly<Record<string, unknown>> =>
1008
+ isRecord(snapshot) && snapshot.name === "direct",
453
1009
  );
454
1010
  if (directSnapshots.length !== 1) {
455
1011
  throw new Error(`Bombadil trace line ${String(lineNumber)} must contain one named direct snapshot`);
@@ -465,7 +1021,97 @@ function parseTraceLine(line: string, lineNumber: number): TraceDirectObservatio
465
1021
  ) {
466
1022
  throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid direct snapshot`);
467
1023
  }
468
- return parseTraceDirectObservation(snapshot.value);
1024
+ return {
1025
+ observation: parseTraceDirectObservation(snapshot.value),
1026
+ value: snapshot.value,
1027
+ };
1028
+ }
1029
+
1030
+ function parseDirectTraceLine(line: string, lineNumber: number): TraceDirectObservation {
1031
+ return parseDirectTraceObservation(parseTraceEnvelope(line, lineNumber), lineNumber).observation;
1032
+ }
1033
+
1034
+ function parseTraceLine(
1035
+ line: string,
1036
+ lineNumber: number,
1037
+ strictDiagnosticSnapshotNames: ReadonlySet<string>,
1038
+ ): ParsedTraceLine {
1039
+ const envelope = parseTraceEnvelope(line, lineNumber);
1040
+ const state = parseTraceState(envelope.state, lineNumber);
1041
+ const action = parseTraceAction(envelope.action, lineNumber);
1042
+ const snapshots = envelope.snapshots;
1043
+ const direct = parseDirectTraceObservation(envelope, lineNumber);
1044
+ const namedSnapshots: Array<{ readonly name: string; readonly valueSha256: string }> = [{
1045
+ name: "direct",
1046
+ valueSha256: namedSnapshotValueSha256(direct.value, {
1047
+ maximumBytes: TRACE_MAX_LINE_BYTES,
1048
+ maximumDepth: TRACE_MAX_JSON_DEPTH + 4,
1049
+ }),
1050
+ }];
1051
+ const diagnosticSnapshotValues = new Map<string, unknown[]>();
1052
+ for (const snapshotValue of snapshots) {
1053
+ if (
1054
+ !isRecord(snapshotValue)
1055
+ || !hasExactKeys(snapshotValue, TRACE_SNAPSHOT_KEYS)
1056
+ || !Number.isSafeInteger(snapshotValue.index)
1057
+ || !Number.isSafeInteger(snapshotValue.time)
1058
+ || (snapshotValue.index as number) < 0
1059
+ || (snapshotValue.time as number) < 0
1060
+ || (snapshotValue.name !== null && typeof snapshotValue.name !== "string")
1061
+ ) {
1062
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid snapshot`);
1063
+ }
1064
+ if (snapshotValue.name === null || snapshotValue.name === "direct") continue;
1065
+ let name: string;
1066
+ try {
1067
+ name = validateSnapshotName(
1068
+ snapshotValue.name,
1069
+ `Bombadil trace line ${String(lineNumber)} snapshot name`,
1070
+ );
1071
+ } catch (error) {
1072
+ if (strictDiagnosticSnapshotNames.has(snapshotValue.name)) throw error;
1073
+ continue;
1074
+ }
1075
+ const values = diagnosticSnapshotValues.get(name) ?? [];
1076
+ values.push(snapshotValue.value);
1077
+ diagnosticSnapshotValues.set(name, values);
1078
+ }
1079
+ for (const [name, values] of diagnosticSnapshotValues) {
1080
+ if (values.length !== 1) {
1081
+ if (strictDiagnosticSnapshotNames.has(name)) {
1082
+ throw new Error(`Bombadil trace line ${String(lineNumber)} repeats named snapshot ${name}`);
1083
+ }
1084
+ continue;
1085
+ }
1086
+ try {
1087
+ namedSnapshots.push({
1088
+ name,
1089
+ valueSha256: namedSnapshotValueSha256(values[0]),
1090
+ });
1091
+ } catch (error) {
1092
+ if (strictDiagnosticSnapshotNames.has(name)) throw error;
1093
+ }
1094
+ }
1095
+ const propertyViolationNames: string[] = [];
1096
+ for (const violation of envelope.violations) {
1097
+ if (!isRecord(violation) || !hasExactKeys(violation, TRACE_VIOLATION_KEYS)) {
1098
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
1099
+ }
1100
+ propertyViolationNames.push(validateSnapshotName(
1101
+ violation.name,
1102
+ `Bombadil trace line ${String(lineNumber)} property violation name`,
1103
+ ));
1104
+ if (!isRecord(violation.violation) || Object.keys(violation.violation).length !== 1) {
1105
+ throw new Error(`Bombadil trace line ${String(lineNumber)} has an invalid property violation`);
1106
+ }
1107
+ }
1108
+ return {
1109
+ action,
1110
+ directObservation: direct.observation,
1111
+ namedSnapshots,
1112
+ propertyViolationNames,
1113
+ state,
1114
+ };
469
1115
  }
470
1116
 
471
1117
  /** Exact post-run proof over Bombadil 0.7.2's bounded JSONL trace. */
@@ -499,7 +1145,7 @@ export async function attestDirectBombadilTrace(options: {
499
1145
  if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
500
1146
  throw new Error(`Bombadil trace line ${String(observationCount)} is too large`);
501
1147
  }
502
- const observation = parseTraceLine(line, observationCount);
1148
+ const observation = parseDirectTraceLine(line, observationCount);
503
1149
  const exact = exactTraceDirectObservation(observation);
504
1150
  if (exact === null) {
505
1151
  if (initial !== null) {
@@ -577,6 +1223,332 @@ export async function attestDirectBombadilTrace(options: {
577
1223
  };
578
1224
  }
579
1225
 
1226
+ function sortedCountRecord<K extends string>(
1227
+ values: ReadonlyMap<K, number>,
1228
+ ): Readonly<Partial<Record<K, number>>> {
1229
+ return Object.freeze(Object.fromEntries(
1230
+ [...values.entries()].sort(([left], [right]) => compareCodeUnits(left, right)),
1231
+ ) as Partial<Record<K, number>>);
1232
+ }
1233
+
1234
+ /**
1235
+ * Derives bounded diagnostic exploration metadata from the raw Bombadil trace.
1236
+ * The hashes and counts are navigation aids, not Direct coverage evidence.
1237
+ */
1238
+ export async function summarizeDirectBombadilTrace(options: {
1239
+ readonly explorationPolicy?: DirectBombadilExplorationPolicy;
1240
+ readonly targetUrl: string;
1241
+ readonly tracePath: string;
1242
+ }): Promise<DirectBombadilExplorationSummary> {
1243
+ const metadata = await stat(options.tracePath).catch(() => null);
1244
+ if (metadata === null || !metadata.isFile() || metadata.size === 0) {
1245
+ throw new Error("Bombadil did not produce a nonempty trace.jsonl");
1246
+ }
1247
+ if (metadata.size > TRACE_MAX_BYTES) {
1248
+ throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_BYTES)} bytes`);
1249
+ }
1250
+ let targetUrl: URL;
1251
+ try {
1252
+ targetUrl = new URL(options.targetUrl);
1253
+ } catch {
1254
+ throw new Error("targetUrl must be an absolute URL");
1255
+ }
1256
+ const policy = validateExplorationPolicy(options.explorationPolicy);
1257
+ const strictDiagnosticSnapshotNames = explorationPolicySnapshotNames(policy);
1258
+ const actionCounts = new Map<DirectBombadilActionKind, number>();
1259
+ const targetTags = new Map<string, number>();
1260
+ const urlFingerprints = new Set<string>();
1261
+ const rawUrlFingerprints = new Set<string>();
1262
+ const transitionHashes = new Set<string>();
1263
+ const rawTransitionHashes = new Set<string>();
1264
+ const snapshots = new Map<string, {
1265
+ readonly changeAfterActionKind: Map<DirectBombadilActionKind, number>;
1266
+ changeAfterNonWaitCount: number;
1267
+ lastObservationIndex: number | null;
1268
+ lastValueSha256: string | null;
1269
+ observationCount: number;
1270
+ readonly values: Set<string>;
1271
+ }>();
1272
+ const propertyViolations = new Map<string, number>();
1273
+ const resources = {
1274
+ documents: 0,
1275
+ domNodes: 0,
1276
+ jsEventListeners: 0,
1277
+ jsHeapTotalBytes: 0,
1278
+ jsHeapUsedBytes: 0,
1279
+ layoutObjects: 0,
1280
+ scriptDurationSeconds: 0,
1281
+ taskDurationSeconds: 0,
1282
+ threadTimeSeconds: 0,
1283
+ };
1284
+ let lineCount = 0;
1285
+ let totalActions = 0;
1286
+ let nonWaitCount = 0;
1287
+ let waitStreak = 0;
1288
+ let maxWaitStreak = 0;
1289
+ let nonNullHashCount = 0;
1290
+ let rawNonNullHashCount = 0;
1291
+ let policyObservationCount = 0;
1292
+ let previousObservationWasExact = false;
1293
+ let stableTarget = true;
1294
+ let trackedUnrelatedSnapshotNameCount = 0;
1295
+ const unrelatedSnapshotNameLimit = Math.max(
1296
+ 0,
1297
+ TRACE_MAX_NAMED_SNAPSHOT_NAMES - strictDiagnosticSnapshotNames.size,
1298
+ );
1299
+ const stream = createReadStream(options.tracePath, { encoding: "utf8" });
1300
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
1301
+ try {
1302
+ for await (const line of lines) {
1303
+ lineCount += 1;
1304
+ if (lineCount > TRACE_MAX_LINES) {
1305
+ throw new Error(`Bombadil trace exceeds ${String(TRACE_MAX_LINES)} lines`);
1306
+ }
1307
+ if (Buffer.byteLength(line, "utf8") > TRACE_MAX_LINE_BYTES) {
1308
+ throw new Error(`Bombadil trace line ${String(lineCount)} is too large`);
1309
+ }
1310
+ const parsed = parseTraceLine(line, lineCount, strictDiagnosticSnapshotNames);
1311
+ const rawRelativeUrl =
1312
+ `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
1313
+ rawUrlFingerprints.add(sha256(rawRelativeUrl));
1314
+ if (rawUrlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
1315
+ throw new Error(
1316
+ `Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct raw URL fingerprints`,
1317
+ );
1318
+ }
1319
+ if (parsed.state.currentHash !== null) {
1320
+ rawNonNullHashCount += 1;
1321
+ rawTransitionHashes.add(String(parsed.state.currentHash));
1322
+ }
1323
+ for (const name of parsed.propertyViolationNames) {
1324
+ if (!propertyViolations.has(name) && propertyViolations.size >= TRACE_MAX_PROPERTY_NAMES) {
1325
+ throw new Error(
1326
+ `Bombadil trace exceeds ${String(TRACE_MAX_PROPERTY_NAMES)} property names`,
1327
+ );
1328
+ }
1329
+ propertyViolations.set(name, (propertyViolations.get(name) ?? 0) + 1);
1330
+ }
1331
+ for (const [sourceName, outputName] of Object.entries(RESOURCE_FIELD_MAP)) {
1332
+ resources[outputName] = Math.max(
1333
+ resources[outputName],
1334
+ parsed.state.resources[sourceName as keyof typeof RESOURCE_FIELD_MAP],
1335
+ );
1336
+ }
1337
+ const currentObservationIsExact =
1338
+ exactTraceDirectObservation(parsed.directObservation) !== null;
1339
+ if (!currentObservationIsExact) {
1340
+ previousObservationWasExact = false;
1341
+ continue;
1342
+ }
1343
+ policyObservationCount += 1;
1344
+ const actionFollowsExactObservation = previousObservationWasExact;
1345
+ const recordedActionKind = actionFollowsExactObservation
1346
+ ? parsed.action?.kind ?? null
1347
+ : null;
1348
+
1349
+ if (actionFollowsExactObservation && parsed.action !== null) {
1350
+ totalActions += 1;
1351
+ actionCounts.set(parsed.action.kind, (actionCounts.get(parsed.action.kind) ?? 0) + 1);
1352
+ if (parsed.action.kind === "Wait") {
1353
+ waitStreak += 1;
1354
+ maxWaitStreak = Math.max(maxWaitStreak, waitStreak);
1355
+ } else {
1356
+ nonWaitCount += 1;
1357
+ waitStreak = 0;
1358
+ }
1359
+ if (parsed.action.targetTag !== null) {
1360
+ if (!targetTags.has(parsed.action.targetTag) && targetTags.size >= 128) {
1361
+ throw new Error("Bombadil trace exceeds 128 distinct action target tags");
1362
+ }
1363
+ targetTags.set(
1364
+ parsed.action.targetTag,
1365
+ (targetTags.get(parsed.action.targetTag) ?? 0) + 1,
1366
+ );
1367
+ }
1368
+ } else if (actionFollowsExactObservation) {
1369
+ waitStreak = 0;
1370
+ }
1371
+
1372
+ const relativeUrl = `${parsed.state.url.pathname}${parsed.state.url.search}${parsed.state.url.hash}`;
1373
+ urlFingerprints.add(sha256(relativeUrl));
1374
+ if (urlFingerprints.size > TRACE_MAX_DISTINCT_URLS) {
1375
+ throw new Error(
1376
+ `Bombadil trace exceeds ${String(TRACE_MAX_DISTINCT_URLS)} distinct URL fingerprints`,
1377
+ );
1378
+ }
1379
+ stableTarget &&= parsed.state.url.href === targetUrl.href;
1380
+ if (parsed.state.currentHash !== null) {
1381
+ nonNullHashCount += 1;
1382
+ transitionHashes.add(String(parsed.state.currentHash));
1383
+ }
1384
+
1385
+ for (const snapshot of parsed.namedSnapshots) {
1386
+ let entry = snapshots.get(snapshot.name);
1387
+ if (entry === undefined) {
1388
+ const isStrictSnapshot = snapshot.name === "direct"
1389
+ || strictDiagnosticSnapshotNames.has(snapshot.name);
1390
+ if (!isStrictSnapshot && trackedUnrelatedSnapshotNameCount >= unrelatedSnapshotNameLimit) {
1391
+ continue;
1392
+ }
1393
+ if (snapshots.size >= TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
1394
+ throw new Error(
1395
+ `Bombadil trace exceeds ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES)} named snapshots`,
1396
+ );
1397
+ }
1398
+ entry = {
1399
+ changeAfterActionKind: new Map<DirectBombadilActionKind, number>(),
1400
+ changeAfterNonWaitCount: 0,
1401
+ lastObservationIndex: null,
1402
+ lastValueSha256: null,
1403
+ observationCount: 0,
1404
+ values: new Set<string>(),
1405
+ };
1406
+ snapshots.set(snapshot.name, entry);
1407
+ if (!isStrictSnapshot) trackedUnrelatedSnapshotNameCount += 1;
1408
+ }
1409
+ if (
1410
+ !entry.values.has(snapshot.valueSha256)
1411
+ && entry.values.size >= TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME
1412
+ ) {
1413
+ if (
1414
+ snapshot.name === "direct"
1415
+ || strictDiagnosticSnapshotNames.has(snapshot.name)
1416
+ ) {
1417
+ throw new Error(
1418
+ `Bombadil trace named snapshot ${snapshot.name} exceeds ${String(TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME)} distinct values`,
1419
+ );
1420
+ }
1421
+ continue;
1422
+ }
1423
+ const changedAfterRecordedAction =
1424
+ recordedActionKind !== null
1425
+ && entry.lastObservationIndex === policyObservationCount - 1
1426
+ && entry.lastValueSha256 !== null
1427
+ && entry.lastValueSha256 !== snapshot.valueSha256;
1428
+ if (changedAfterRecordedAction) {
1429
+ entry.changeAfterActionKind.set(
1430
+ recordedActionKind,
1431
+ (entry.changeAfterActionKind.get(recordedActionKind) ?? 0) + 1,
1432
+ );
1433
+ }
1434
+ if (changedAfterRecordedAction && recordedActionKind !== "Wait") {
1435
+ entry.changeAfterNonWaitCount += 1;
1436
+ }
1437
+ entry.lastObservationIndex = policyObservationCount;
1438
+ entry.lastValueSha256 = snapshot.valueSha256;
1439
+ entry.observationCount += 1;
1440
+ entry.values.add(snapshot.valueSha256);
1441
+ }
1442
+ previousObservationWasExact = true;
1443
+ }
1444
+ } finally {
1445
+ lines.close();
1446
+ stream.destroy();
1447
+ }
1448
+ if (lineCount === 0) throw new Error("Bombadil did not produce a nonempty trace.jsonl");
1449
+
1450
+ const policyFailures: string[] = [];
1451
+ if (policy !== null) {
1452
+ if (nonWaitCount < policy.minNonWaitActions) {
1453
+ policyFailures.push("minimum non-Wait action count was not reached");
1454
+ }
1455
+ for (const kind of policy.requiredActionKinds) {
1456
+ if ((actionCounts.get(kind) ?? 0) === 0) {
1457
+ policyFailures.push(`required action kind ${kind} was not observed`);
1458
+ }
1459
+ }
1460
+ for (const name of policy.requiredNamedSnapshots) {
1461
+ if (!snapshots.has(name)) {
1462
+ policyFailures.push(`required named snapshot ${name} was not observed`);
1463
+ }
1464
+ }
1465
+ for (const [name, minimum] of Object.entries(policy.minDistinctNamedSnapshotValues)) {
1466
+ if ((snapshots.get(name)?.values.size ?? 0) < minimum) {
1467
+ policyFailures.push(`named snapshot ${name} did not reach its distinct-value minimum`);
1468
+ }
1469
+ }
1470
+ for (const [name, minimum] of Object.entries(
1471
+ policy.minNamedSnapshotChangesAfterNonWait,
1472
+ )) {
1473
+ if ((snapshots.get(name)?.changeAfterNonWaitCount ?? 0) < minimum) {
1474
+ policyFailures.push(
1475
+ `named snapshot ${name} did not reach its post-non-Wait change minimum`,
1476
+ );
1477
+ }
1478
+ }
1479
+ for (const [name, minimumByKind] of Object.entries(
1480
+ policy.minNamedSnapshotChangesAfterActionKind,
1481
+ )) {
1482
+ for (const [kind, minimum] of Object.entries(minimumByKind) as [
1483
+ DirectBombadilActionKind,
1484
+ number,
1485
+ ][]) {
1486
+ if ((snapshots.get(name)?.changeAfterActionKind.get(kind) ?? 0) < minimum) {
1487
+ policyFailures.push(
1488
+ `named snapshot ${name} did not reach its post-${kind} change minimum`,
1489
+ );
1490
+ }
1491
+ }
1492
+ }
1493
+ if (policy.requireStableTargetUrl && !stableTarget) {
1494
+ policyFailures.push("the browser did not remain on the exact target URL");
1495
+ }
1496
+ }
1497
+ const traceBytes = await readFile(options.tracePath);
1498
+ return Object.freeze({
1499
+ schema: "direct.bombadil-exploration-summary/v2",
1500
+ trace: Object.freeze({
1501
+ bytes: metadata.size,
1502
+ lineCount,
1503
+ sha256: sha256(traceBytes),
1504
+ }),
1505
+ actions: Object.freeze({
1506
+ byKind: sortedCountRecord(actionCounts),
1507
+ maxWaitStreak,
1508
+ nonWaitCount,
1509
+ targetTags: sortedCountRecord(targetTags) as Readonly<Record<string, number>>,
1510
+ total: totalActions,
1511
+ }),
1512
+ urls: Object.freeze({
1513
+ distinctFingerprintCount: urlFingerprints.size,
1514
+ fingerprintSha256: Object.freeze([...urlFingerprints].sort(compareCodeUnits)),
1515
+ observationCount: policyObservationCount,
1516
+ rawDistinctFingerprintCount: rawUrlFingerprints.size,
1517
+ rawFingerprintSha256: Object.freeze(
1518
+ [...rawUrlFingerprints].sort(compareCodeUnits),
1519
+ ),
1520
+ rawObservationCount: lineCount,
1521
+ stableTarget,
1522
+ }),
1523
+ transitions: Object.freeze({
1524
+ distinctNonNullHashCount: transitionHashes.size,
1525
+ nonNullHashCount,
1526
+ rawDistinctNonNullHashCount: rawTransitionHashes.size,
1527
+ rawNonNullHashCount,
1528
+ }),
1529
+ namedSnapshots: Object.freeze([...snapshots.entries()]
1530
+ .sort(([left], [right]) => compareCodeUnits(left, right))
1531
+ .map(([name, entry]) => Object.freeze({
1532
+ changeAfterActionKind: sortedCountRecord(entry.changeAfterActionKind),
1533
+ changeAfterNonWaitCount: entry.changeAfterNonWaitCount,
1534
+ distinctValueCount: entry.values.size,
1535
+ distinctValueSha256: Object.freeze([...entry.values].sort(compareCodeUnits)),
1536
+ name,
1537
+ observationCount: entry.observationCount,
1538
+ }))),
1539
+ propertyViolations: Object.freeze({
1540
+ byName: sortedCountRecord(propertyViolations) as Readonly<Record<string, number>>,
1541
+ total: [...propertyViolations.values()].reduce((total, value) => total + value, 0),
1542
+ }),
1543
+ resourceHighWaterMarks: Object.freeze(resources),
1544
+ policy: Object.freeze({
1545
+ configured: policy !== null,
1546
+ failures: Object.freeze(policyFailures),
1547
+ satisfied: policyFailures.length === 0,
1548
+ }),
1549
+ });
1550
+ }
1551
+
580
1552
  export function parseDirectBombadilFuzzArguments(
581
1553
  arguments_: readonly string[],
582
1554
  defaultBaseUrl: string,
@@ -689,7 +1661,7 @@ function validateTargetQuery(value: unknown): Readonly<Record<string, string>> {
689
1661
  }
690
1662
  const validated: Record<string, string> = {};
691
1663
  for (const [name, queryValue] of [...entries].sort(([left], [right]) =>
692
- left.localeCompare(right)
1664
+ compareCodeUnits(left, right)
693
1665
  )) {
694
1666
  if (
695
1667
  name.length === 0
@@ -714,6 +1686,227 @@ function validateTargetQuery(value: unknown): Readonly<Record<string, string>> {
714
1686
  return Object.freeze(validated);
715
1687
  }
716
1688
 
1689
+ function validateSnapshotName(value: unknown, label: string): string {
1690
+ if (
1691
+ typeof value !== "string"
1692
+ || value.length === 0
1693
+ || value.length > 128
1694
+ || !SNAPSHOT_NAME_PATTERN.test(value)
1695
+ || PROTOTYPE_PROPERTY_NAMES.has(value)
1696
+ || hasControlCharacters(value)
1697
+ ) {
1698
+ throw new Error(`${label} must be a safe bounded snapshot name`);
1699
+ }
1700
+ return value;
1701
+ }
1702
+
1703
+ function validateViewport(value: unknown): ValidatedViewport {
1704
+ if (value === undefined) {
1705
+ return Object.freeze({
1706
+ deviceScaleFactor: DEFAULT_DEVICE_SCALE_FACTOR,
1707
+ height: DEFAULT_VIEWPORT_HEIGHT,
1708
+ width: DEFAULT_VIEWPORT_WIDTH,
1709
+ });
1710
+ }
1711
+ if (!isRecord(value) || !Object.keys(value).every((key) => VIEWPORT_KEYS.has(key))) {
1712
+ throw new Error("viewport must contain only width, height, and deviceScaleFactor");
1713
+ }
1714
+ const validateDimension = (name: "height" | "width", input: unknown): number => {
1715
+ if (
1716
+ typeof input !== "number"
1717
+ || !Number.isSafeInteger(input)
1718
+ || input < 1
1719
+ || input > 65_535
1720
+ ) {
1721
+ throw new Error(`viewport.${name} must be an integer between 1 and 65535`);
1722
+ }
1723
+ return input;
1724
+ };
1725
+ const width = validateDimension("width", value.width ?? DEFAULT_VIEWPORT_WIDTH);
1726
+ const height = validateDimension("height", value.height ?? DEFAULT_VIEWPORT_HEIGHT);
1727
+ const deviceScaleFactor = value.deviceScaleFactor ?? DEFAULT_DEVICE_SCALE_FACTOR;
1728
+ if (
1729
+ typeof deviceScaleFactor !== "number"
1730
+ || !Number.isFinite(deviceScaleFactor)
1731
+ || deviceScaleFactor < 0.1
1732
+ || deviceScaleFactor > 10
1733
+ ) {
1734
+ throw new Error("viewport.deviceScaleFactor must be a finite number between 0.1 and 10");
1735
+ }
1736
+ return Object.freeze({ deviceScaleFactor, height, width });
1737
+ }
1738
+
1739
+ function validateSnapshotMinimumMap(options: {
1740
+ readonly label: string;
1741
+ readonly maximum: number;
1742
+ readonly value: unknown;
1743
+ }): Readonly<Record<string, number>> {
1744
+ if (!isRecord(options.value) || Object.keys(options.value).length > 32) {
1745
+ throw new Error(`${options.label} must be a bounded object`);
1746
+ }
1747
+ const validated: Record<string, number> = {};
1748
+ for (const [rawName, minimum] of Object.entries(options.value).sort(([left], [right]) =>
1749
+ compareCodeUnits(left, right)
1750
+ )) {
1751
+ const name = validateSnapshotName(rawName, `${options.label} key`);
1752
+ if (
1753
+ typeof minimum !== "number"
1754
+ || !Number.isSafeInteger(minimum)
1755
+ || minimum < 1
1756
+ || minimum > options.maximum
1757
+ ) {
1758
+ throw new Error(
1759
+ `${options.label} ${name} must be an integer between 1 and ${String(options.maximum)}`,
1760
+ );
1761
+ }
1762
+ validated[name] = minimum;
1763
+ }
1764
+ return Object.freeze(validated);
1765
+ }
1766
+
1767
+ function validateSnapshotActionMinimumMap(options: {
1768
+ readonly label: string;
1769
+ readonly value: unknown;
1770
+ }): Readonly<Record<
1771
+ string,
1772
+ Readonly<Partial<Record<DirectBombadilActionKind, number>>>
1773
+ >> {
1774
+ if (!isRecord(options.value) || Object.keys(options.value).length > 32) {
1775
+ throw new Error(`${options.label} must be a bounded object`);
1776
+ }
1777
+ const validated: Record<
1778
+ string,
1779
+ Readonly<Partial<Record<DirectBombadilActionKind, number>>>
1780
+ > = {};
1781
+ for (const [rawName, rawMinimumByKind] of Object.entries(options.value)
1782
+ .sort(([left], [right]) => compareCodeUnits(left, right))) {
1783
+ const name = validateSnapshotName(rawName, `${options.label} key`);
1784
+ if (
1785
+ !isRecord(rawMinimumByKind)
1786
+ || Object.keys(rawMinimumByKind).length === 0
1787
+ || Object.keys(rawMinimumByKind).length > ACTION_KINDS.length
1788
+ ) {
1789
+ throw new Error(`${options.label} ${name} must be a bounded action map`);
1790
+ }
1791
+ const minimumByKind: Partial<Record<DirectBombadilActionKind, number>> = {};
1792
+ for (const [rawKind, minimum] of Object.entries(rawMinimumByKind)
1793
+ .sort(([left], [right]) => compareCodeUnits(left, right))) {
1794
+ if (!ACTION_KIND_SET.has(rawKind)) {
1795
+ throw new Error(`${options.label} ${name} contains an unknown action kind`);
1796
+ }
1797
+ if (
1798
+ typeof minimum !== "number"
1799
+ || !Number.isSafeInteger(minimum)
1800
+ || minimum < 1
1801
+ || minimum > TRACE_MAX_LINES
1802
+ ) {
1803
+ throw new Error(
1804
+ `${options.label} ${name}.${rawKind} must be an integer between 1 and ${String(TRACE_MAX_LINES)}`,
1805
+ );
1806
+ }
1807
+ minimumByKind[rawKind as DirectBombadilActionKind] = minimum;
1808
+ }
1809
+ validated[name] = Object.freeze(minimumByKind);
1810
+ }
1811
+ return Object.freeze(validated);
1812
+ }
1813
+
1814
+ function explorationPolicySnapshotNames(
1815
+ policy: ValidatedExplorationPolicy | null,
1816
+ ): ReadonlySet<string> {
1817
+ const names = new Set<string>(["direct"]);
1818
+ if (policy === null) return names;
1819
+ for (const name of policy.requiredNamedSnapshots) names.add(name);
1820
+ for (const name of Object.keys(policy.minDistinctNamedSnapshotValues)) names.add(name);
1821
+ for (const name of Object.keys(policy.minNamedSnapshotChangesAfterNonWait)) names.add(name);
1822
+ for (const name of Object.keys(policy.minNamedSnapshotChangesAfterActionKind)) names.add(name);
1823
+ return names;
1824
+ }
1825
+
1826
+ function validateExplorationPolicy(
1827
+ value: unknown,
1828
+ ): ValidatedExplorationPolicy | null {
1829
+ if (value === undefined) return null;
1830
+ if (
1831
+ !isRecord(value)
1832
+ || !Object.keys(value).every((key) => EXPLORATION_POLICY_KEYS.has(key))
1833
+ ) {
1834
+ throw new Error("explorationPolicy contains an unknown field");
1835
+ }
1836
+ const minNonWaitActions = value.minNonWaitActions ?? 0;
1837
+ if (
1838
+ typeof minNonWaitActions !== "number"
1839
+ || !Number.isSafeInteger(minNonWaitActions)
1840
+ || minNonWaitActions < 0
1841
+ || minNonWaitActions > TRACE_MAX_LINES
1842
+ ) {
1843
+ throw new Error(
1844
+ `explorationPolicy.minNonWaitActions must be an integer between 0 and ${String(TRACE_MAX_LINES)}`,
1845
+ );
1846
+ }
1847
+ const requiredActionKindsInput = value.requiredActionKinds ?? [];
1848
+ if (!Array.isArray(requiredActionKindsInput) || requiredActionKindsInput.length > ACTION_KINDS.length) {
1849
+ throw new Error("explorationPolicy.requiredActionKinds must be a bounded array");
1850
+ }
1851
+ const requiredActionKinds = [...requiredActionKindsInput];
1852
+ if (
1853
+ !requiredActionKinds.every((kind): kind is DirectBombadilActionKind =>
1854
+ typeof kind === "string" && ACTION_KIND_SET.has(kind)
1855
+ )
1856
+ || new Set(requiredActionKinds).size !== requiredActionKinds.length
1857
+ ) {
1858
+ throw new Error("explorationPolicy.requiredActionKinds contains an unknown or duplicate kind");
1859
+ }
1860
+ requiredActionKinds.sort(compareCodeUnits);
1861
+
1862
+ const requiredNamedSnapshotsInput = value.requiredNamedSnapshots ?? [];
1863
+ if (!Array.isArray(requiredNamedSnapshotsInput) || requiredNamedSnapshotsInput.length > 32) {
1864
+ throw new Error("explorationPolicy.requiredNamedSnapshots must be a bounded array");
1865
+ }
1866
+ const requiredNamedSnapshots = requiredNamedSnapshotsInput.map((name) =>
1867
+ validateSnapshotName(name, "explorationPolicy.requiredNamedSnapshots entry")
1868
+ );
1869
+ if (new Set(requiredNamedSnapshots).size !== requiredNamedSnapshots.length) {
1870
+ throw new Error("explorationPolicy.requiredNamedSnapshots contains a duplicate name");
1871
+ }
1872
+ requiredNamedSnapshots.sort(compareCodeUnits);
1873
+
1874
+ const minDistinctNamedSnapshotValues = validateSnapshotMinimumMap({
1875
+ label: "explorationPolicy.minDistinctNamedSnapshotValues",
1876
+ maximum: TRACE_MAX_DISTINCT_SNAPSHOT_VALUES_PER_NAME,
1877
+ value: value.minDistinctNamedSnapshotValues ?? {},
1878
+ });
1879
+ const minNamedSnapshotChangesAfterActionKind =
1880
+ validateSnapshotActionMinimumMap({
1881
+ label: "explorationPolicy.minNamedSnapshotChangesAfterActionKind",
1882
+ value: value.minNamedSnapshotChangesAfterActionKind ?? {},
1883
+ });
1884
+ const minNamedSnapshotChangesAfterNonWait = validateSnapshotMinimumMap({
1885
+ label: "explorationPolicy.minNamedSnapshotChangesAfterNonWait",
1886
+ maximum: TRACE_MAX_LINES,
1887
+ value: value.minNamedSnapshotChangesAfterNonWait ?? {},
1888
+ });
1889
+ const requireStableTargetUrl = value.requireStableTargetUrl ?? false;
1890
+ if (typeof requireStableTargetUrl !== "boolean") {
1891
+ throw new Error("explorationPolicy.requireStableTargetUrl must be a boolean");
1892
+ }
1893
+ const validated: ValidatedExplorationPolicy = Object.freeze({
1894
+ minDistinctNamedSnapshotValues,
1895
+ minNamedSnapshotChangesAfterActionKind,
1896
+ minNamedSnapshotChangesAfterNonWait,
1897
+ minNonWaitActions,
1898
+ requireStableTargetUrl,
1899
+ requiredActionKinds: Object.freeze(requiredActionKinds),
1900
+ requiredNamedSnapshots: Object.freeze(requiredNamedSnapshots),
1901
+ });
1902
+ if (explorationPolicySnapshotNames(validated).size > TRACE_MAX_NAMED_SNAPSHOT_NAMES) {
1903
+ throw new Error(
1904
+ `explorationPolicy may reference at most ${String(TRACE_MAX_NAMED_SNAPSHOT_NAMES - 1)} distinct non-Direct snapshots`,
1905
+ );
1906
+ }
1907
+ return validated;
1908
+ }
1909
+
717
1910
  export function validateDirectBombadilFuzzConfig(
718
1911
  config: DirectBombadilFuzzConfig,
719
1912
  baseUrlOverride?: string,
@@ -782,6 +1975,8 @@ export function validateDirectBombadilFuzzConfig(
782
1975
  const entryPath = config.entryPath ?? "/";
783
1976
  validateEntryPath(entryPath);
784
1977
  const targetQuery = validateTargetQuery(config.targetQuery ?? {});
1978
+ const viewport = validateViewport(config.viewport);
1979
+ const explorationPolicy = validateExplorationPolicy(config.explorationPolicy);
785
1980
  const startupTimeoutMs = config.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
786
1981
  if (
787
1982
  !Number.isSafeInteger(startupTimeoutMs)
@@ -803,8 +1998,10 @@ export function validateDirectBombadilFuzzConfig(
803
1998
  artifactRoot: join(repositoryRoot, "artifacts", "direct-bombadil", config.artifactName),
804
1999
  bombadilExecutable: bombadilNativeBinary(repositoryRoot),
805
2000
  entryPath,
2001
+ explorationPolicy,
806
2002
  port,
807
2003
  targetQuery,
2004
+ viewport,
808
2005
  server: {
809
2006
  ...config.server,
810
2007
  cwd: serverCwd,
@@ -834,7 +2031,9 @@ export function createDirectBombadilInvocation(options: {
834
2031
  readonly specificationPath: string;
835
2032
  readonly targetQuery?: Readonly<Record<string, string>>;
836
2033
  readonly timeLimitSeconds: number;
2034
+ readonly viewport?: DirectBombadilViewportConfig;
837
2035
  }): DirectBombadilInvocation {
2036
+ const viewport = validateViewport(options.viewport);
838
2037
  const target = new URL(options.entryPath ?? "/", `${options.baseUrl}/`);
839
2038
  target.searchParams.set(SCENARIO_QUERY_KEY, options.scenario);
840
2039
  for (const [name, value] of Object.entries(options.targetQuery ?? {})) {
@@ -850,6 +2049,12 @@ export function createDirectBombadilInvocation(options: {
850
2049
  options.outputPath,
851
2050
  "--headless",
852
2051
  "--instrument-javascript=",
2052
+ "--width",
2053
+ String(viewport.width),
2054
+ "--height",
2055
+ String(viewport.height),
2056
+ "--device-scale-factor",
2057
+ String(viewport.deviceScaleFactor),
853
2058
  ];
854
2059
  if (options.replayPath === null) {
855
2060
  command.push(
@@ -1007,6 +2212,7 @@ export async function runBombadilNativeProcess(
1007
2212
 
1008
2213
  const defaultDependencies: DirectBombadilRunnerDependencies = {
1009
2214
  acquireServer: acquireVerificationServer,
2215
+ createAbortController: () => new AbortController(),
1010
2216
  now: () => new Date(),
1011
2217
  runBombadil: runBombadilNativeProcess,
1012
2218
  serverOutputTimeoutMs: SERVER_OUTPUT_TIMEOUT_MS,
@@ -1181,6 +2387,113 @@ function helpText(defaultBaseUrl: string): string {
1181
2387
  ].join("\n");
1182
2388
  }
1183
2389
 
2390
+ function parseMatrixCampaignArgument(arguments_: readonly string[]): {
2391
+ readonly arguments: readonly string[];
2392
+ readonly campaignId: string | null;
2393
+ readonly help: boolean;
2394
+ } {
2395
+ const forwarded: string[] = [];
2396
+ let campaignId: string | null = null;
2397
+ let help = false;
2398
+ for (let index = 0; index < arguments_.length; index += 1) {
2399
+ const argument = arguments_[index];
2400
+ if (argument === undefined) continue;
2401
+ if (argument === "--help" || argument === "-h") help = true;
2402
+ if (argument === "--campaign" || argument.startsWith("--campaign=")) {
2403
+ if (campaignId !== null) throw new Error("--campaign may be provided only once");
2404
+ if (argument === "--campaign") {
2405
+ const next = readOptionValue(arguments_, index, "--campaign");
2406
+ campaignId = next.value;
2407
+ index = next.index;
2408
+ } else {
2409
+ campaignId = argument.slice("--campaign=".length);
2410
+ }
2411
+ if (campaignId.length === 0) throw new Error("--campaign requires a value");
2412
+ continue;
2413
+ }
2414
+ forwarded.push(argument);
2415
+ }
2416
+ return { arguments: Object.freeze(forwarded), campaignId, help };
2417
+ }
2418
+
2419
+ function validateCampaignMatrix(
2420
+ campaigns: readonly DirectBombadilFuzzCampaign[],
2421
+ ): readonly DirectBombadilFuzzCampaign[] {
2422
+ if (campaigns.length === 0 || campaigns.length > 32) {
2423
+ throw new Error("Bombadil campaign matrix must contain 1-32 campaigns");
2424
+ }
2425
+ const ids = new Set<string>();
2426
+ for (const campaign of campaigns) {
2427
+ if (!ARTIFACT_NAME_PATTERN.test(campaign.id) || ids.has(campaign.id)) {
2428
+ throw new Error("Bombadil campaign IDs must be unique lowercase kebab identifiers");
2429
+ }
2430
+ ids.add(campaign.id);
2431
+ }
2432
+ return campaigns;
2433
+ }
2434
+
2435
+ /** Runs a bounded product-owned campaign matrix serially. */
2436
+ export async function runDirectBombadilFuzzMatrix(
2437
+ campaignsInput: readonly DirectBombadilFuzzCampaign[],
2438
+ arguments_: readonly string[] = process.argv.slice(2),
2439
+ dependencyOverrides: Partial<DirectBombadilRunnerDependencies> = {},
2440
+ ): Promise<DirectBombadilFuzzMatrixResult> {
2441
+ const campaigns = validateCampaignMatrix(campaignsInput);
2442
+ const parsed = parseMatrixCampaignArgument(arguments_);
2443
+ if (parsed.help) {
2444
+ process.stdout.write(`${[
2445
+ helpText(campaigns[0]?.config.baseUrl ?? ""),
2446
+ " --campaign <id> Run one campaign; required with --replay",
2447
+ "",
2448
+ `Campaigns: ${campaigns.map((campaign) => campaign.id).join(", ")}`,
2449
+ ].join("\n")}\n`);
2450
+ return { kind: "help" };
2451
+ }
2452
+ const selected = parsed.campaignId === null
2453
+ ? campaigns
2454
+ : campaigns.filter((campaign) => campaign.id === parsed.campaignId);
2455
+ if (selected.length === 0) {
2456
+ throw new Error(`Unknown Bombadil campaign ${parsed.campaignId ?? ""}`);
2457
+ }
2458
+ if (
2459
+ parsed.campaignId === null
2460
+ && parsed.arguments.some((argument) =>
2461
+ argument === "--replay" || argument.startsWith("--replay=")
2462
+ )
2463
+ ) {
2464
+ throw new Error("--replay requires exactly one --campaign in matrix mode");
2465
+ }
2466
+ const results: Array<{
2467
+ readonly campaignId: string;
2468
+ readonly result: Extract<DirectBombadilFuzzResult, { readonly kind: "run" }>;
2469
+ }> = [];
2470
+ for (const campaign of selected) {
2471
+ const result = await runDirectBombadilFuzz(
2472
+ campaign.config,
2473
+ parsed.arguments,
2474
+ dependencyOverrides,
2475
+ );
2476
+ if (result.kind !== "run") {
2477
+ throw new Error("Bombadil campaign unexpectedly returned help during matrix execution");
2478
+ }
2479
+ results.push({ campaignId: campaign.id, result });
2480
+ }
2481
+ return { kind: "matrix", results: Object.freeze(results) };
2482
+ }
2483
+
2484
+ function throwIfBombadilRunAborted(signal: AbortSignal): void {
2485
+ if (signal.aborted) throw new Error("Bombadil fuzzing was interrupted");
2486
+ }
2487
+
2488
+ function terminateAbortedOwnedServer(
2489
+ signal: AbortSignal,
2490
+ server: ManagedVerificationServer,
2491
+ ): void {
2492
+ if (!signal.aborted) return;
2493
+ if (server.exitCode() === null) server.terminate();
2494
+ throwIfBombadilRunAborted(signal);
2495
+ }
2496
+
1184
2497
  /** Runs one bounded diagnostic Bombadil campaign and always releases its server lease. */
1185
2498
  export async function runDirectBombadilFuzz(
1186
2499
  config: DirectBombadilFuzzConfig,
@@ -1212,7 +2525,7 @@ export async function runDirectBombadilFuzz(
1212
2525
  });
1213
2526
  const outputPath = join(artifactRun.runDirectory, "bombadil");
1214
2527
  const tracePath = join(outputPath, "trace.jsonl");
1215
- const abortController = new AbortController();
2528
+ const abortController = dependencies.createAbortController?.() ?? new AbortController();
1216
2529
  const invocation = createDirectBombadilInvocation({
1217
2530
  baseUrl: validated.baseUrl,
1218
2531
  bombadilExecutable: validated.bombadilExecutable,
@@ -1224,6 +2537,7 @@ export async function runDirectBombadilFuzz(
1224
2537
  specificationPath: validated.specificationPath,
1225
2538
  targetQuery: validated.targetQuery,
1226
2539
  timeLimitSeconds: parsed.timeLimitSeconds,
2540
+ viewport: validated.viewport,
1227
2541
  });
1228
2542
  const abortableInvocation = { ...invocation, abortSignal: abortController.signal };
1229
2543
  const serverCommand = validated.server.command.map((argument) =>
@@ -1236,6 +2550,8 @@ export async function runDirectBombadilFuzz(
1236
2550
  let processResult: BombadilProcessResult | null = null;
1237
2551
  let attestation: DirectBombadilTraceAttestation | null = null;
1238
2552
  let attestationFailure: unknown = null;
2553
+ let explorationSummary: DirectBombadilExplorationSummary | null = null;
2554
+ let explorationSummaryFailure: unknown = null;
1239
2555
  let rawTracePath: string | null = null;
1240
2556
  let serverOutput = "";
1241
2557
  let serverOutputFailure: unknown = null;
@@ -1255,23 +2571,36 @@ export async function runDirectBombadilFuzz(
1255
2571
  try {
1256
2572
  await requireRegularFile(validated.bombadilExecutable, "The root Bombadil executable");
1257
2573
  bombadilVersion = await readExactBombadilVersion(validated.repositoryRoot);
1258
- if (abortController.signal.aborted) throw new Error("Bombadil fuzzing was interrupted");
1259
-
1260
- lease = await dependencies.acquireServer({
1261
- baseUrl: validated.baseUrl,
1262
- label: validated.label,
1263
- readinessPath: validated.server.readinessPath,
1264
- reuseExistingLocalServer: false,
1265
- startupTimeoutMs: validated.server.startupTimeoutMs,
1266
- startServer: () => {
1267
- ownedServer = dependencies.spawnServer({
1268
- command: serverCommand,
1269
- cwd: validated.server.cwd,
1270
- ...(validated.server.env === undefined ? {} : { env: validated.server.env }),
1271
- });
1272
- return ownedServer;
1273
- },
1274
- });
2574
+ throwIfBombadilRunAborted(abortController.signal);
2575
+
2576
+ try {
2577
+ lease = await dependencies.acquireServer({
2578
+ abortSignal: abortController.signal,
2579
+ baseUrl: validated.baseUrl,
2580
+ label: validated.label,
2581
+ readinessPath: validated.server.readinessPath,
2582
+ reuseExistingLocalServer: false,
2583
+ startupTimeoutMs: validated.server.startupTimeoutMs,
2584
+ startServer: () => {
2585
+ throwIfBombadilRunAborted(abortController.signal);
2586
+ ownedServer = dependencies.spawnServer({
2587
+ command: serverCommand,
2588
+ cwd: validated.server.cwd,
2589
+ ...(validated.server.env === undefined ? {} : { env: validated.server.env }),
2590
+ });
2591
+ terminateAbortedOwnedServer(abortController.signal, ownedServer);
2592
+ return ownedServer;
2593
+ },
2594
+ });
2595
+ } catch (error) {
2596
+ if (abortController.signal.aborted) throwIfBombadilRunAborted(abortController.signal);
2597
+ throw error;
2598
+ }
2599
+ if (abortController.signal.aborted) {
2600
+ const acquiredOwnedServer = ownedServer as ManagedVerificationServer | null;
2601
+ if (acquiredOwnedServer?.exitCode() === null) acquiredOwnedServer.terminate();
2602
+ throwIfBombadilRunAborted(abortController.signal);
2603
+ }
1275
2604
  let processFailure: unknown = null;
1276
2605
  try {
1277
2606
  processResult = await dependencies.runBombadil(abortableInvocation);
@@ -1291,6 +2620,17 @@ export async function runDirectBombadilFuzz(
1291
2620
  } catch (error) {
1292
2621
  attestationFailure = error;
1293
2622
  }
2623
+ try {
2624
+ explorationSummary = await summarizeDirectBombadilTrace({
2625
+ ...(validated.explorationPolicy === null
2626
+ ? {}
2627
+ : { explorationPolicy: validated.explorationPolicy }),
2628
+ targetUrl: invocation.targetUrl,
2629
+ tracePath,
2630
+ });
2631
+ } catch (error) {
2632
+ explorationSummaryFailure = error;
2633
+ }
1294
2634
  if (processFailure !== null) {
1295
2635
  throw processFailure instanceof Error
1296
2636
  ? processFailure
@@ -1313,6 +2653,16 @@ export async function runDirectBombadilFuzz(
1313
2653
  ? attestationFailure
1314
2654
  : new Error(renderUnknown(attestationFailure));
1315
2655
  }
2656
+ if (explorationSummaryFailure !== null) {
2657
+ throw explorationSummaryFailure instanceof Error
2658
+ ? explorationSummaryFailure
2659
+ : new Error(renderUnknown(explorationSummaryFailure));
2660
+ }
2661
+ if (explorationSummary?.policy.satisfied !== true) {
2662
+ throw new Error(
2663
+ `Bombadil exploration policy was not satisfied: ${explorationSummary?.policy.failures.join("; ") ?? "summary unavailable"}`,
2664
+ );
2665
+ }
1316
2666
  } catch (error) {
1317
2667
  failure = error;
1318
2668
  }
@@ -1351,6 +2701,10 @@ export async function runDirectBombadilFuzz(
1351
2701
  const status = failure === null ? "passed" : "failed";
1352
2702
  const logPath = join(artifactRun.runDirectory, "bombadil.log");
1353
2703
  const serverLogPath = join(artifactRun.runDirectory, "server.log");
2704
+ const explorationSummaryPath = join(
2705
+ artifactRun.runDirectory,
2706
+ "exploration-summary.json",
2707
+ );
1354
2708
  const record = {
1355
2709
  schema: ARTIFACT_SCHEMA,
1356
2710
  evidenceClass: "diagnostic-fuzz",
@@ -1366,6 +2720,8 @@ export async function runDirectBombadilFuzz(
1366
2720
  entryPath: validated.entryPath,
1367
2721
  targetQuery: validated.targetQuery,
1368
2722
  targetUrl: invocation.targetUrl,
2723
+ viewport: validated.viewport,
2724
+ explorationPolicy: validated.explorationPolicy,
1369
2725
  specificationPath: validated.specificationPath,
1370
2726
  replayPath,
1371
2727
  timeLimitSeconds: replayPath === null ? parsed.timeLimitSeconds : null,
@@ -1387,6 +2743,11 @@ export async function runDirectBombadilFuzz(
1387
2743
  },
1388
2744
  attestation,
1389
2745
  attestationFailure: attestationFailure === null ? null : renderUnknown(attestationFailure),
2746
+ explorationSummary,
2747
+ explorationSummaryPath: explorationSummary === null ? null : explorationSummaryPath,
2748
+ explorationSummaryFailure: explorationSummaryFailure === null
2749
+ ? null
2750
+ : renderUnknown(explorationSummaryFailure),
1390
2751
  initialDirect: attestation?.initial ?? null,
1391
2752
  interruptedSignal: capturedSignal,
1392
2753
  failure: failure === null ? null : renderUnknown(failure),
@@ -1401,10 +2762,28 @@ export async function runDirectBombadilFuzz(
1401
2762
  `${serverOutput}${serverOutput.length > 0 ? "\n" : ""}`,
1402
2763
  "utf8",
1403
2764
  );
2765
+ if (explorationSummary !== null) {
2766
+ await writeJsonAtomically(explorationSummaryPath, explorationSummary);
2767
+ }
1404
2768
  await writeJsonAtomically(join(artifactRun.runDirectory, "run.json"), record);
1405
2769
  await writeJsonAtomically(artifactRun.manifestPath, record);
1406
2770
 
1407
- const summary = `${status === "passed" ? "PASS" : "FAIL"} ${validated.label}; artifacts: ${artifactRun.runDirectory}; log: ${logPath}`;
2771
+ const exploration = explorationSummary === null
2772
+ ? "exploration=unavailable"
2773
+ : [
2774
+ `nonWait=${String(explorationSummary.actions.nonWaitCount)}`,
2775
+ `maxWaitStreak=${String(explorationSummary.actions.maxWaitStreak)}`,
2776
+ `namedChanges=${explorationSummary.namedSnapshots
2777
+ .map((snapshot) => `${snapshot.name}:${String(snapshot.changeAfterNonWaitCount)}`)
2778
+ .join(",") || "none"}`,
2779
+ `policy=${explorationSummary.policy.satisfied ? "satisfied" : "failed"}`,
2780
+ ].join("; ");
2781
+ const summary = [
2782
+ `${status === "passed" ? "PASS" : "FAIL"} ${validated.label}`,
2783
+ exploration,
2784
+ `artifacts: ${artifactRun.runDirectory}`,
2785
+ `log: ${logPath}`,
2786
+ ].join("; ");
1408
2787
  (status === "passed" ? process.stdout : process.stderr).write(`${summary}\n`);
1409
2788
 
1410
2789
  if (failure !== null) {