@crouton-kit/capture 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Frozen shared contract primitives for the Capture measurement/help hard cut
3
+ * (build unit U1). This module owns the small, purely-computational validators
4
+ * and shape vocabulary that every other `src/contracts/*` module builds on:
5
+ * argv grammar, ID grammars, integer/decimal grammars, byte bounds, coordinate
6
+ * spaces, and availability shapes.
7
+ *
8
+ * CONTRACT: this file is type/interface + pure functions ONLY. It imports no
9
+ * browser, CLI, filesystem-effect, or caller-facing print/exit API. It never
10
+ * emits to stdout/stderr and never exits the process. Downstream units freeze
11
+ * against these types; changing a shape here is a contract change.
12
+ *
13
+ * EVIDENCE POLICY: nothing here deletes, substitutes, hashes, or redacts a code
14
+ * point. Sanitation, where it exists downstream, is lossless/reversible and
15
+ * protects the result protocol, never secrecy (see `taste/no-redaction`).
16
+ */
17
+
18
+ /** Structured, side-effect-free validation outcome. Validators never throw for invalid data. */
19
+ export interface ValidationResult {
20
+ readonly valid: boolean;
21
+ /** Human-readable, machine-stable reasons. Empty iff `valid`. */
22
+ readonly errors: readonly string[];
23
+ }
24
+
25
+ /** A passing result. */
26
+ export const OK: ValidationResult = { valid: true, errors: [] };
27
+
28
+ /** Build a failing result from one or more reasons. */
29
+ export function fail(...errors: string[]): ValidationResult {
30
+ return { valid: false, errors };
31
+ }
32
+
33
+ /** Merge child results; valid iff every child is valid, accumulating all reasons in order. */
34
+ export function combine(...results: readonly ValidationResult[]): ValidationResult {
35
+ const errors: string[] = [];
36
+ for (const r of results) errors.push(...r.errors);
37
+ return errors.length === 0 ? OK : { valid: false, errors };
38
+ }
39
+
40
+ /** Prefix every reason of a result with `context: `. Useful for nested field paths. */
41
+ export function contextualize(context: string, result: ValidationResult): ValidationResult {
42
+ if (result.valid) return OK;
43
+ return { valid: false, errors: result.errors.map((e) => `${context}: ${e}`) };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Byte bounds (all measured as UTF-8 unless a contract narrows to ASCII).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Maximum bytes of a single argv value (unless a tighter per-flag bound applies). */
51
+ export const MAX_ARGV_BYTES = 4096;
52
+ /** Maximum bytes of any single bounded-leaf success payload, independently in prose and JSON. */
53
+ export const MAX_BOUNDED_BYTES = 16_384;
54
+ /** Maximum ASCII bytes of an opaque pagination cursor token. */
55
+ export const MAX_CURSOR_BYTES = 2048;
56
+ /** Maximum bytes of one progress line on a declared-progress leaf. */
57
+ export const MAX_PROGRESS_LINE_BYTES = 256;
58
+ /** Maximum total progress bytes across a whole sweep run. */
59
+ export const MAX_PROGRESS_TOTAL_BYTES = 25_600;
60
+ /** Default and maximum page size for every immutable-cursor leaf. */
61
+ export const DEFAULT_PAGE_SIZE = 20;
62
+ export const MAX_PAGE_SIZE = 20;
63
+
64
+ /** UTF-8 byte length of a string without allocating a Buffer view of the caller's data. */
65
+ export function utf8ByteLength(value: string): number {
66
+ return Buffer.byteLength(value, 'utf8');
67
+ }
68
+
69
+ /** True iff every code unit is ASCII (0x00–0x7F). */
70
+ export function isAscii(value: string): boolean {
71
+ for (let i = 0; i < value.length; i++) {
72
+ if (value.charCodeAt(i) > 0x7f) return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ /** True iff the string contains no NUL. */
78
+ export function isNulFree(value: string): boolean {
79
+ return value.indexOf('\u0000') === -1;
80
+ }
81
+
82
+ /** Validate a raw argv value: NUL-free UTF-8 within the declared byte bound. */
83
+ export function validateArgvValue(value: string, maxBytes: number = MAX_ARGV_BYTES): ValidationResult {
84
+ if (!isNulFree(value)) return fail('argv value contains NUL');
85
+ const bytes = utf8ByteLength(value);
86
+ if (bytes > maxBytes) return fail(`argv value is ${bytes} bytes, exceeds ${maxBytes}`);
87
+ return OK;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // ID and number grammars (case-sensitive; no coercion; safe integers only).
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** Public container IDs: session, HAR, snapshot, tree, recording. */
95
+ export const CONTAINER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
96
+ /** Evidence IDs: fact IDs, occurrence IDs, subject IDs, AX IDs. */
97
+ export const EVIDENCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,127}$/;
98
+ /** `uint` grammar: 0 or a no-leading-zero positive. */
99
+ export const UINT_RE = /^(?:0|[1-9][0-9]*)$/;
100
+ /** `positive-int` grammar: a no-leading-zero positive. */
101
+ export const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
102
+ /** `signed-int` grammar: 0 or a signed no-leading-zero integer (no `-0`). */
103
+ export const SIGNED_INT_RE = /^(?:0|-?[1-9][0-9]*)$/;
104
+ /** Non-exponent decimal grammar (no `+`, `-0`, leading zero, NaN, or infinity). Sweep endpoints override this. */
105
+ export const DECIMAL_RE = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
106
+
107
+ export function isContainerId(value: string): boolean {
108
+ return CONTAINER_ID_RE.test(value);
109
+ }
110
+ export function isEvidenceId(value: string): boolean {
111
+ return EVIDENCE_ID_RE.test(value);
112
+ }
113
+
114
+ /** Parse a `uint` token to a safe integer; null on grammar or safe-integer failure. */
115
+ export function parseUint(token: string): number | null {
116
+ if (!UINT_RE.test(token)) return null;
117
+ const n = Number(token);
118
+ return Number.isSafeInteger(n) ? n : null;
119
+ }
120
+
121
+ /** Parse a `positive-int` token to a safe integer; null on failure. */
122
+ export function parsePositiveInt(token: string): number | null {
123
+ if (!POSITIVE_INT_RE.test(token)) return null;
124
+ const n = Number(token);
125
+ return Number.isSafeInteger(n) && n >= 1 ? n : null;
126
+ }
127
+
128
+ /** Parse a `signed-int` token to a safe integer; null on failure. */
129
+ export function parseSignedInt(token: string): number | null {
130
+ if (!SIGNED_INT_RE.test(token)) return null;
131
+ const n = Number(token);
132
+ return Number.isSafeInteger(n) ? n : null;
133
+ }
134
+
135
+ /** True iff `value` is an absolute WHATWG http:/https: URL. */
136
+ export function isAbsoluteHttpUrl(value: string): boolean {
137
+ let u: URL;
138
+ try {
139
+ u = new URL(value);
140
+ } catch {
141
+ return false;
142
+ }
143
+ return u.protocol === 'http:' || u.protocol === 'https:';
144
+ }
145
+
146
+ /** Lowercase 64-hex SHA-256 digest grammar. */
147
+ export const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
148
+ export function isSha256Hex(value: string): boolean {
149
+ return SHA256_HEX_RE.test(value);
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Coordinate spaces and availability.
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * The three declared coordinate spaces. Layers have NO Capture geometric
158
+ * rectangle or coordinate space and therefore never carry one of these.
159
+ */
160
+ export type CoordinateSpace =
161
+ | 'top-visual-viewport/css-px'
162
+ | 'top-page/css-px'
163
+ | 'screenshot/device-px';
164
+
165
+ export const COORDINATE_SPACES: readonly CoordinateSpace[] = [
166
+ 'top-visual-viewport/css-px',
167
+ 'top-page/css-px',
168
+ 'screenshot/device-px',
169
+ ];
170
+
171
+ export function isCoordinateSpace(value: string): value is CoordinateSpace {
172
+ return (COORDINATE_SPACES as readonly string[]).includes(value);
173
+ }
174
+
175
+ /** A retained rectangle always names its coordinate space and unit. */
176
+ export interface GeometricRect {
177
+ readonly space: CoordinateSpace;
178
+ readonly x: number;
179
+ readonly y: number;
180
+ readonly width: number;
181
+ readonly height: number;
182
+ }
183
+
184
+ /**
185
+ * An availability wrapper. A dependent fact is either present with a value or
186
+ * absent with a fixed machine-stable reason and optional detail. Zero is NEVER
187
+ * used to stand in for unavailable evidence.
188
+ */
189
+ export type Availability<T> =
190
+ | { readonly available: true; readonly value: T }
191
+ | { readonly available: false; readonly reason: string; readonly details?: string };
192
+
193
+ export function isAvailable<T>(a: Availability<T>): a is { available: true; value: T } {
194
+ return a.available === true;
195
+ }
196
+
197
+ /** True iff `x` is a finite number (never NaN/±Infinity). Availability, not zero, marks missing facts. */
198
+ export function isFiniteNumber(x: unknown): x is number {
199
+ return typeof x === 'number' && Number.isFinite(x);
200
+ }
201
+
202
+ /** True iff `x` is a plain (non-null, non-array) object. */
203
+ export function isObject(x: unknown): x is Record<string, unknown> {
204
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
205
+ }
@@ -0,0 +1,17 @@
1
+ /** Frozen hard-cut release metadata contract (U1), pure types/validators only. */
2
+ import { OK, ValidationResult, combine, fail, isObject, isSha256Hex } from './primitives.js';
3
+ /** Canonical JSON stored in the annotated release tag message. */
4
+ export interface ReleaseTagMetadata { readonly schemaVersion: 1; readonly package: '@crouton-kit/capture'; readonly version: string; readonly baseSha: string; readonly releaseSha: string; readonly gateSha: string; readonly tarball: { readonly filename: string; readonly bytes: number; readonly sha256: string; readonly integrity: string }; readonly runtime: { readonly node: string; readonly npm: string; readonly pnpm: string }; }
5
+ /** Persisted release controller state; conflicts are nonmutating. */
6
+ export type ReleaseDisposition = 'candidate' | 'release_already_published' | 'release_publish_retryable' | 'registry_integrity_conflict' | 'release_state_conflict';
7
+ export function validateReleaseTagMetadata(value: unknown): ValidationResult {
8
+ if (!isObject(value)) return fail('release tag metadata must be an object');
9
+ const errs: ValidationResult[] = [];
10
+ if (value.schemaVersion !== 1) errs.push(fail('release tag schemaVersion must be 1'));
11
+ if (value.package !== '@crouton-kit/capture') errs.push(fail('release tag package must be @crouton-kit/capture'));
12
+ if (typeof value.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value.version)) errs.push(fail('invalid version'));
13
+ for (const key of ['baseSha', 'releaseSha', 'gateSha']) if (typeof value[key] !== 'string' || !/^[0-9a-f]{40,64}$/.test(value[key] as string)) errs.push(fail(`invalid ${key}`));
14
+ if (!isObject(value.tarball) || typeof value.tarball.filename !== 'string' || !(typeof value.tarball.bytes === 'number' && value.tarball.bytes >= 0) || typeof value.tarball.integrity !== 'string' || typeof value.tarball.sha256 !== 'string' || !isSha256Hex(value.tarball.sha256)) errs.push(fail('invalid tarball metadata'));
15
+ if (!isObject(value.runtime) || !['node', 'npm', 'pnpm'].every((key) => typeof value.runtime[key] === 'string' && value.runtime[key])) errs.push(fail('invalid runtime metadata'));
16
+ return combine(...errs);
17
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Frozen result-lane contracts (U1). Two lanes only:
3
+ *
4
+ * - `bounded:<domain>` — one typed projection through the bounded output
5
+ * kernel: at most {@link MAX_BOUNDED_BYTES} UTF-8 bytes independently in
6
+ * prose and JSON, with fixed identity/summary/scope/coverage/count/artifact
7
+ * metadata that is NEVER dropped, and optional records removed only at
8
+ * record boundaries (never byte-sliced) until both encodings fit.
9
+ * - `exact-raw:<payload>` — handler-produced bytes/text for one named payload,
10
+ * unbounded and preserved exactly (including final-newline presence); empty
11
+ * stderr on success. Global `--json` is rejected before effects.
12
+ *
13
+ * This module is type/interface + pure validators only. It imports no print or
14
+ * exit API. The "no direct stdout" rule is a contract on the handlers that
15
+ * produce these values, encoded in the descriptor layer (see `routes.ts`).
16
+ */
17
+
18
+ import {
19
+ MAX_BOUNDED_BYTES,
20
+ OK,
21
+ ValidationResult,
22
+ combine,
23
+ contextualize,
24
+ fail,
25
+ isObject,
26
+ utf8ByteLength,
27
+ } from './primitives.js';
28
+
29
+ export { MAX_BOUNDED_BYTES };
30
+
31
+ /** Discriminator for the two public result lanes plus the effect-only branch/launcher rows. */
32
+ export type ResultLaneKind = 'bounded' | 'exact-raw' | 'branch' | 'launcher-metadata';
33
+
34
+ /** A `bounded:<domain>` lane naming exactly one bounded projection domain. */
35
+ export interface BoundedLane {
36
+ readonly kind: 'bounded';
37
+ readonly domain: string;
38
+ /** The JSON/prose schema name the bounded kernel serializes. */
39
+ readonly schema: string;
40
+ }
41
+
42
+ /** An exact raw lane naming its handler-owned unbounded payload. */
43
+ export interface ExactRawLane {
44
+ readonly kind: 'exact-raw';
45
+ /** The payload type, e.g. `recorded HAR bytes`. */
46
+ readonly payload: string;
47
+ }
48
+
49
+ /** A branch row: help + child assembly, no result. */
50
+ export interface BranchLane {
51
+ readonly kind: 'branch';
52
+ }
53
+
54
+ /** The launcher `--version` metadata row. */
55
+ export interface LauncherMetadataLane {
56
+ readonly kind: 'launcher-metadata';
57
+ }
58
+
59
+ export type ResultLane = BoundedLane | ExactRawLane | BranchLane | LauncherMetadataLane;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Byte/list bounds descriptor a bounded leaf declares.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Every bounded leaf declares its collection and byte bounds up front so the
67
+ * registry can reject a growing collection that lacks an omission contract.
68
+ */
69
+ export interface BoundedBounds {
70
+ /** Independent max UTF-8 bytes of prose and JSON. Always {@link MAX_BOUNDED_BYTES}. */
71
+ readonly maxBytes: number;
72
+ /** Max displayed records for the primary collection (e.g. 20). */
73
+ readonly maxRecords: number;
74
+ /**
75
+ * Whether the primary collection can grow with the underlying source. A
76
+ * growing collection MUST declare `paginated` so a cursor covers every row.
77
+ */
78
+ readonly growing: boolean;
79
+ /** True iff this leaf adopts the immutable-cursor pagination service. */
80
+ readonly paginated: boolean;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Bounded projection payload shape (the kernel's typed input, pre-encoding).
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /** Fixed metadata every bounded projection carries and never drops when bounding. */
88
+ export interface BoundedFixedMeta {
89
+ readonly domain: string;
90
+ readonly schema: string;
91
+ /** Identity of the subject the projection describes (snapshot id, session id, tree id, …). */
92
+ readonly identity: Record<string, string>;
93
+ /** Scope and source coverage (populations/caps/availability), preserved verbatim. */
94
+ readonly coverage: unknown;
95
+ /** total/displayed/omitted/limit for the primary collection. */
96
+ readonly counts: BoundedCollectionCounts;
97
+ /** Absolute artifact paths a caller can re-read for the exhaustive evidence. */
98
+ readonly artifacts: readonly string[];
99
+ }
100
+
101
+ /** total/displayed/omitted/limit accounting for one bounded collection. */
102
+ export interface BoundedCollectionCounts {
103
+ readonly total: number;
104
+ readonly displayed: number;
105
+ readonly omitted: number;
106
+ readonly limit: number;
107
+ /** Machine-stable reasons the omitted records were dropped (e.g. `byte-bound`, `record-limit`). */
108
+ readonly omissionCauses: readonly string[];
109
+ }
110
+
111
+ /**
112
+ * A capped string reports byte accounting and an exact RFC-6901 pointer into
113
+ * the exhaustive artifact rather than silently truncating.
114
+ */
115
+ export interface CappedStringMeta {
116
+ readonly sourceBytes: number;
117
+ readonly displayedBytes: number;
118
+ readonly omittedBytes: number;
119
+ /** Absolute artifact path + RFC-6901 JSON Pointer to the full value. */
120
+ readonly artifactPath: string;
121
+ readonly jsonPointer: string;
122
+ }
123
+
124
+ /**
125
+ * A structured public error (bounded, factual). Written to stderr by the result
126
+ * owner, never by a handler. Exit 2 = invalid route/grammar/selection/cursor
127
+ * input; exit 3 = source/runtime/publication failure.
128
+ */
129
+ export interface BoundedError {
130
+ readonly message: string;
131
+ readonly exit: 2 | 3;
132
+ readonly details?: Record<string, unknown>;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Validators.
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export function validateResultLane(lane: ResultLane): ValidationResult {
140
+ switch (lane.kind) {
141
+ case 'bounded':
142
+ if (!lane.domain) return fail('bounded lane missing domain');
143
+ if (!lane.schema) return fail('bounded lane missing schema');
144
+ return OK;
145
+ case 'exact-raw':
146
+ if (!lane.payload) return fail('exact-raw lane missing payload type');
147
+ return OK;
148
+ case 'branch':
149
+ case 'launcher-metadata':
150
+ return OK;
151
+ default:
152
+ return fail(`unknown result lane kind: ${(lane as { kind: string }).kind}`);
153
+ }
154
+ }
155
+
156
+ export function validateBoundedBounds(bounds: BoundedBounds): ValidationResult {
157
+ const errs: ValidationResult[] = [];
158
+ if (bounds.maxBytes !== MAX_BOUNDED_BYTES) {
159
+ errs.push(fail(`bounded maxBytes must be ${MAX_BOUNDED_BYTES}, got ${bounds.maxBytes}`));
160
+ }
161
+ if (!(bounds.maxRecords >= 0)) errs.push(fail('bounded maxRecords must be >= 0'));
162
+ if (bounds.growing && !bounds.paginated) {
163
+ errs.push(fail('a growing bounded collection must declare pagination (cursor coverage)'));
164
+ }
165
+ return combine(...errs);
166
+ }
167
+
168
+ /** True iff both encodings fit the independent byte bound. */
169
+ export function fitsBoundedBounds(prose: string, json: string): ValidationResult {
170
+ const errs: ValidationResult[] = [];
171
+ const p = utf8ByteLength(prose);
172
+ const j = utf8ByteLength(json);
173
+ if (p > MAX_BOUNDED_BYTES) errs.push(fail(`prose is ${p} bytes, exceeds ${MAX_BOUNDED_BYTES}`));
174
+ if (j > MAX_BOUNDED_BYTES) errs.push(fail(`json is ${j} bytes, exceeds ${MAX_BOUNDED_BYTES}`));
175
+ return combine(...errs);
176
+ }
177
+
178
+ /** Validate collection counts are internally consistent: displayed+omitted==total, displayed<=limit. */
179
+ export function validateCollectionCounts(counts: BoundedCollectionCounts): ValidationResult {
180
+ const errs: ValidationResult[] = [];
181
+ if (counts.displayed + counts.omitted !== counts.total) {
182
+ errs.push(fail(`displayed(${counts.displayed}) + omitted(${counts.omitted}) != total(${counts.total})`));
183
+ }
184
+ if (counts.displayed > counts.limit) {
185
+ errs.push(fail(`displayed(${counts.displayed}) exceeds limit(${counts.limit})`));
186
+ }
187
+ if (counts.omitted > 0 && counts.omissionCauses.length === 0) {
188
+ errs.push(fail('omitted records present but no omissionCauses declared'));
189
+ }
190
+ return combine(...errs);
191
+ }
192
+
193
+ /** Validate the fixed metadata block that must precede any bounded records. */
194
+ export function validateBoundedFixedMeta(meta: unknown): ValidationResult {
195
+ if (!isObject(meta)) return fail('bounded fixed meta must be an object');
196
+ const errs: ValidationResult[] = [];
197
+ if (typeof meta.domain !== 'string' || !meta.domain) errs.push(fail('missing domain'));
198
+ if (typeof meta.schema !== 'string' || !meta.schema) errs.push(fail('missing schema'));
199
+ if (!isObject(meta.identity)) errs.push(fail('missing identity object'));
200
+ if (!('coverage' in meta)) errs.push(fail('missing coverage'));
201
+ if (!Array.isArray(meta.artifacts)) errs.push(fail('artifacts must be an array of absolute paths'));
202
+ if (isObject(meta.counts)) {
203
+ errs.push(contextualize('counts', validateCollectionCounts(meta.counts as BoundedCollectionCounts)));
204
+ } else {
205
+ errs.push(fail('missing counts'));
206
+ }
207
+ return combine(...errs);
208
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Frozen public route/leaf descriptor contract. A descriptor is the sole truth
3
+ * for one canonical public path. It names inputs, effects, output lane, bounds,
4
+ * stderr, exits, and an opaque handler slot; it never imports a handler.
5
+ */
6
+ import { OK, ValidationResult, combine, contextualize, fail } from './primitives.js';
7
+ import { BoundedBounds, ResultLane, validateBoundedBounds, validateResultLane } from './results.js';
8
+
9
+ export interface RouteEffects {
10
+ readonly browser: boolean;
11
+ readonly session: boolean;
12
+ readonly artifact: boolean;
13
+ readonly environment: boolean;
14
+ }
15
+ export const NO_EFFECTS: RouteEffects = { browser: false, session: false, artifact: false, environment: false };
16
+
17
+ export interface PositionalSpec { readonly name: string; readonly grammar: string; readonly required: boolean; readonly variadic?: boolean; }
18
+ export interface FlagSpec {
19
+ readonly name: string;
20
+ readonly grammar: string | 'boolean';
21
+ /** Required singleton unless repeatable is explicitly declared. */
22
+ readonly required?: boolean;
23
+ /** Repetition is accepted; required means at least one occurrence. */
24
+ readonly repeatable?: boolean;
25
+ readonly values?: readonly string[];
26
+ readonly default?: string | number | boolean;
27
+ readonly units?: string;
28
+ }
29
+ export type StderrPolicy = 'empty-on-success' | 'declared-progress';
30
+ export type LeafExit = 0 | 2 | 3 | 10 | 11 | 130 | 143;
31
+
32
+ export interface BranchDescriptor { readonly kind: 'branch'; readonly path: string; readonly summary: string; }
33
+ export interface LeafDescriptor {
34
+ readonly kind: 'leaf'; readonly path: string; readonly summary: string;
35
+ readonly positionals: readonly PositionalSpec[]; readonly flags: readonly FlagSpec[];
36
+ readonly mutualExclusions: readonly (readonly string[])[]; readonly effects: RouteEffects;
37
+ readonly result: ResultLane; readonly bounds?: BoundedBounds; readonly stderr: StderrPolicy;
38
+ readonly exits: readonly LeafExit[]; readonly handler: string;
39
+ }
40
+ export type RouteDescriptor = BranchDescriptor | LeafDescriptor;
41
+
42
+ /** All public non-root branches in the seven-noun Capture topology. */
43
+ export const EXPECTED_BRANCH_PATHS: readonly string[] = [
44
+ 'session', 'page', 'measure', 'measure map', 'measure variation', 'motion',
45
+ 'traffic', 'traffic har', 'browser', 'library',
46
+ ];
47
+
48
+ /** Every public runnable path, exactly once. There are no aliases. */
49
+ export const EXPECTED_LEAF_PATHS: readonly string[] = [
50
+ 'session start', 'session stop', 'session list', 'session view', 'session log',
51
+ 'page a11y', 'page screenshot', 'page click', 'page type', 'page navigate', 'page exec',
52
+ 'measure snap', 'measure check', 'measure geometry', 'measure map focus', 'measure map scroll', 'measure map layers', 'measure explain', 'measure variation diff', 'measure variation census', 'measure variation sweep',
53
+ 'motion rec', 'motion mask', 'motion timeline', 'motion jank', 'motion response',
54
+ 'traffic record', 'traffic har create', 'traffic har read', 'traffic har delete',
55
+ 'browser detect', 'browser list', 'browser open', 'browser reset', 'browser network', 'browser cdp',
56
+ 'library list', 'library search', 'library show', 'library read',
57
+ ];
58
+
59
+ export const PAGINATED_LEAF_PATHS: readonly string[] = [
60
+ 'browser detect', 'browser list', 'library list', 'library search', 'library show',
61
+ ];
62
+
63
+ /** Exact raw leaves are handler-owned bytes/text, never global JSON envelopes. */
64
+ export const EXACT_RAW_LEAF_PAYLOADS: Readonly<Record<string, string>> = {
65
+ 'session log': 'recorded session log bytes',
66
+ 'page a11y': 'full accessibility-tree text',
67
+ 'page exec': 'handler evaluation bytes/text',
68
+ 'traffic har read': 'stored HAR bytes',
69
+ 'browser cdp': 'raw protocol response bytes',
70
+ 'library read': 'bundled function source bytes',
71
+ };
72
+
73
+ /** Legacy spellings are deliberately unreachable after the U15 cutover. */
74
+ export const FORBIDDEN_PUBLIC_PATHS: readonly string[] = [
75
+ '-v', 'version', 'log', 'detect', 'list', 'open', 'reset-tab', 'screenshot',
76
+ 'click', 'type', 'a11y', 'record', 'navigate', 'network', 'har', 'lib', 'cdp',
77
+ 'exec', 'motion observations', '__bridge-serve',
78
+ ];
79
+
80
+ function flagNames(leaf: LeafDescriptor): Set<string> { return new Set(leaf.flags.map((flag) => flag.name)); }
81
+
82
+ export function validateLeafDescriptor(leaf: LeafDescriptor): ValidationResult {
83
+ const errors: ValidationResult[] = [];
84
+ if (!leaf.path) errors.push(fail('leaf has empty path'));
85
+ const fixed = leaf.positionals.filter((positional) => !positional.variadic);
86
+ const required = fixed.filter((positional) => positional.required);
87
+ const optional = fixed.filter((positional) => !positional.required);
88
+ if (required.length > 1 || optional.length > 1 || fixed.length > 2 || (fixed.length === 2 && (!fixed[0].required || fixed[1].required))) {
89
+ errors.push(fail(`leaf ${leaf.path} permits only one required positional followed by one optional trailing positional`));
90
+ }
91
+ const names = flagNames(leaf);
92
+ if (names.size !== leaf.flags.length) errors.push(fail(`leaf ${leaf.path} declares duplicate flag names`));
93
+ for (const flag of leaf.flags) {
94
+ if (!flag.name.startsWith('--')) errors.push(fail(`leaf ${leaf.path} flag ${flag.name} is not a long option`));
95
+ if (flag.grammar === 'boolean' && (flag.values || flag.default !== undefined || flag.units)) errors.push(fail(`boolean flag ${flag.name} must not declare value metadata`));
96
+ if (flag.repeatable && flag.default !== undefined) errors.push(fail(`repeatable flag ${flag.name} must not declare a default`));
97
+ }
98
+ for (const group of leaf.mutualExclusions) for (const name of group) if (!names.has(name)) errors.push(fail(`leaf ${leaf.path} mutual-exclusion references undeclared flag ${name}`));
99
+ errors.push(contextualize(`leaf ${leaf.path} result`, validateResultLane(leaf.result)));
100
+ if (leaf.result.kind === 'bounded') {
101
+ if (!leaf.bounds) errors.push(fail(`bounded leaf ${leaf.path} missing byte/list bounds`));
102
+ else errors.push(contextualize(`leaf ${leaf.path} bounds`, validateBoundedBounds(leaf.bounds)));
103
+ } else if (leaf.result.kind === 'exact-raw') {
104
+ if (leaf.bounds) errors.push(fail(`exact raw leaf ${leaf.path} must not declare bounded bounds`));
105
+ if (leaf.stderr !== 'empty-on-success') errors.push(fail(`exact raw leaf ${leaf.path} must have empty-on-success stderr`));
106
+ } else errors.push(fail(`leaf ${leaf.path} must use bounded or exact-raw result lane`));
107
+ if (!leaf.handler) errors.push(fail(`leaf ${leaf.path} missing handler slot`));
108
+ if (!leaf.exits.length) errors.push(fail(`leaf ${leaf.path} declares no exits`));
109
+ return combine(...errors);
110
+ }
111
+
112
+ export function validateBranchDescriptor(branch: BranchDescriptor): ValidationResult {
113
+ if (!branch.path) return fail('branch has empty path');
114
+ if ((branch as unknown as { handler?: unknown }).handler !== undefined) return fail(`branch ${branch.path} must not declare a handler`);
115
+ return OK;
116
+ }
117
+
118
+ /** Validate an assembled public registry against the canonical seven-noun census. */
119
+ export function validateRegistry(descriptors: readonly RouteDescriptor[]): ValidationResult {
120
+ const errors: ValidationResult[] = [];
121
+ const seen = new Set<string>(); const leaves = new Set<string>(); const branches = new Set<string>();
122
+ for (const descriptor of descriptors) {
123
+ if (seen.has(descriptor.path)) errors.push(fail(`duplicate route path: ${descriptor.path}`));
124
+ seen.add(descriptor.path);
125
+ if (FORBIDDEN_PUBLIC_PATHS.includes(descriptor.path)) errors.push(fail(`forbidden public path present: ${descriptor.path}`));
126
+ if (descriptor.kind === 'leaf') {
127
+ leaves.add(descriptor.path); errors.push(validateLeafDescriptor(descriptor));
128
+ const expectedPayload = EXACT_RAW_LEAF_PAYLOADS[descriptor.path];
129
+ if (expectedPayload && (descriptor.result.kind !== 'exact-raw' || descriptor.result.payload !== expectedPayload)) errors.push(fail(`leaf ${descriptor.path} must be exact-raw:${expectedPayload}`));
130
+ if (!expectedPayload && descriptor.result.kind === 'exact-raw') errors.push(fail(`leaf ${descriptor.path} is not declared exact-raw by the public output contract`));
131
+ const paginated = descriptor.result.kind === 'bounded' && descriptor.bounds?.paginated === true;
132
+ if (PAGINATED_LEAF_PATHS.includes(descriptor.path) !== paginated) errors.push(fail(`leaf ${descriptor.path} pagination declaration disagrees with census`));
133
+ } else { branches.add(descriptor.path); errors.push(validateBranchDescriptor(descriptor)); }
134
+ }
135
+ for (const path of EXPECTED_LEAF_PATHS) if (!leaves.has(path)) errors.push(fail(`missing expected leaf path: ${path}`));
136
+ for (const path of EXPECTED_BRANCH_PATHS) if (!branches.has(path)) errors.push(fail(`missing expected branch path: ${path}`));
137
+ for (const path of leaves) if (!EXPECTED_LEAF_PATHS.includes(path)) errors.push(fail(`unexpected leaf path (not in census): ${path}`));
138
+ for (const path of branches) if (!EXPECTED_BRANCH_PATHS.includes(path)) errors.push(fail(`unexpected branch path (not in census): ${path}`));
139
+ return combine(...errors);
140
+ }