@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,185 @@
1
+ /**
2
+ * Selector helpers — map a query leaf's `--selector`/`--element` input
3
+ * string to the stored per-element records a snapshot's collector files
4
+ * carry. Pure lookups over an already-read array; this module never reads
5
+ * an artifact file itself (see `src/output/artifact.ts` for that).
6
+ *
7
+ * Contract this module fixes for upstream collector writers: any collector
8
+ * file that indexes elements (`geometry.json` first and foremost — see the
9
+ * contract note at the top of `artifact.ts`) should shape each element's
10
+ * record to satisfy {@link ElementRecord} — a stable `id`, and whichever of
11
+ * `selector`/`backendNodeId`/`axId`/`axName`/`text` it has facts for — so a
12
+ * query leaf's selector lookup works the same way regardless of which
13
+ * collector file it pulled the array from.
14
+ */
15
+
16
+ // ============================================================================
17
+ // Element records
18
+ // ============================================================================
19
+
20
+ export interface ElementRecord {
21
+ /** Stable per-snapshot element id (e.g. `el-14`) collectors key off of. */
22
+ readonly id: string;
23
+ /** The collector-computed CSS-ish selector string for this element
24
+ * (e.g. `.message-card:nth-child(4)`), when known. */
25
+ readonly selector?: string;
26
+ readonly backendNodeId?: number;
27
+ /** Accessibility node id, when the element has a non-ignored AX node. */
28
+ readonly axId?: string;
29
+ /** Accessibility computed name, when known. */
30
+ readonly axName?: string;
31
+ /** Text content associated with the element, when known. */
32
+ readonly text?: string;
33
+ readonly [key: string]: unknown;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Direct lookups
38
+ // ============================================================================
39
+
40
+ /** Elements whose stored `selector` exactly equals `selector`. */
41
+ export function findBySelector(elements: readonly ElementRecord[], selector: string): ElementRecord[] {
42
+ return elements.filter((e) => e.selector === selector);
43
+ }
44
+
45
+ /** The element with this `backendNodeId`, if any. */
46
+ export function findByBackendNodeId(elements: readonly ElementRecord[], backendNodeId: number): ElementRecord | undefined {
47
+ return elements.find((e) => e.backendNodeId === backendNodeId);
48
+ }
49
+
50
+ /** The element with this exact `axId`, if any. */
51
+ export function findByAxId(elements: readonly ElementRecord[], axId: string): ElementRecord | undefined {
52
+ return elements.find((e) => e.axId === axId);
53
+ }
54
+
55
+ /** Elements whose `axName` matches `name` — exact (case-insensitive) by
56
+ * default, or a case-insensitive substring match with `{ exact: false }`. */
57
+ export function findByAxName(
58
+ elements: readonly ElementRecord[],
59
+ name: string,
60
+ opts: { exact?: boolean } = {},
61
+ ): ElementRecord[] {
62
+ const exact = opts.exact ?? true;
63
+ const needle = name.toLowerCase();
64
+ return elements.filter((e) => {
65
+ if (e.axName === undefined) return false;
66
+ const hay = e.axName.toLowerCase();
67
+ return exact ? hay === needle : hay.includes(needle);
68
+ });
69
+ }
70
+
71
+ /** Elements whose `text` matches `needle` — case-insensitive substring by
72
+ * default, or an exact (case-insensitive) match with `{ exact: true }`. */
73
+ export function findByText(
74
+ elements: readonly ElementRecord[],
75
+ needle: string,
76
+ opts: { exact?: boolean } = {},
77
+ ): ElementRecord[] {
78
+ const exact = opts.exact ?? false;
79
+ const target = needle.toLowerCase();
80
+ return elements.filter((e) => {
81
+ if (e.text === undefined) return false;
82
+ const hay = e.text.toLowerCase();
83
+ return exact ? hay === target : hay.includes(target);
84
+ });
85
+ }
86
+
87
+ // ============================================================================
88
+ // Selector-input grammar
89
+ // ============================================================================
90
+
91
+ export type SelectorInputKind = 'backend' | 'axid' | 'ax' | 'text' | 'css';
92
+
93
+ export interface ParsedSelectorInput {
94
+ readonly kind: SelectorInputKind;
95
+ readonly value: string;
96
+ }
97
+
98
+ /**
99
+ * Parses a `--selector`/`--element` input string into its lookup kind:
100
+ * - `backend:1234` — backend node id
101
+ * - `axid:<id>` — accessibility node id
102
+ * - `ax:<name>` — accessibility name (substring)
103
+ * - `text:<needle>`— text content (substring)
104
+ * - anything else — a CSS-ish selector, matched exactly against the
105
+ * collector's stored `selector` string (the form every design sample
106
+ * uses, e.g. `--selector ".toast-container"`)
107
+ */
108
+ export function parseSelectorInput(input: string): ParsedSelectorInput {
109
+ const prefixes: Array<[string, SelectorInputKind]> = [
110
+ ['backend:', 'backend'],
111
+ ['axid:', 'axid'],
112
+ ['ax:', 'ax'],
113
+ ['text:', 'text'],
114
+ ];
115
+ for (const [prefix, kind] of prefixes) {
116
+ if (input.startsWith(prefix)) {
117
+ return { kind, value: input.slice(prefix.length) };
118
+ }
119
+ }
120
+ return { kind: 'css', value: input };
121
+ }
122
+
123
+ /**
124
+ * Resolves a `--selector`/`--element` input string to every matching
125
+ * element record, dispatching on {@link parseSelectorInput}'s grammar. This
126
+ * is the one entry point query leaves should call for user-supplied
127
+ * selector input rather than picking a `findBy*` helper themselves.
128
+ */
129
+ export function resolveSelectorInput(elements: readonly ElementRecord[], input: string): ElementRecord[] {
130
+ const parsed = parseSelectorInput(input);
131
+ switch (parsed.kind) {
132
+ case 'backend': {
133
+ const id = Number(parsed.value);
134
+ if (!Number.isFinite(id)) return [];
135
+ const found = findByBackendNodeId(elements, id);
136
+ return found ? [found] : [];
137
+ }
138
+ case 'axid': {
139
+ const found = findByAxId(elements, parsed.value);
140
+ return found ? [found] : [];
141
+ }
142
+ case 'ax':
143
+ return findByAxName(elements, parsed.value, { exact: false });
144
+ case 'text':
145
+ return findByText(elements, parsed.value, { exact: false });
146
+ case 'css':
147
+ return findBySelector(elements, parsed.value);
148
+ }
149
+ }
150
+
151
+ // ============================================================================
152
+ // Recovery hints
153
+ // ============================================================================
154
+
155
+ export interface SelectorHints {
156
+ readonly selectors: readonly string[];
157
+ readonly axNames: readonly string[];
158
+ readonly texts: readonly string[];
159
+ }
160
+
161
+ function uniqueTruncated(values: readonly (string | undefined)[], limit: number): string[] {
162
+ const seen = new Set<string>();
163
+ for (const v of values) {
164
+ if (v === undefined) continue;
165
+ seen.add(v);
166
+ if (seen.size >= limit) break;
167
+ }
168
+ return [...seen];
169
+ }
170
+
171
+ /**
172
+ * Example selector/AX-name/text values present in `elements`, for a query
173
+ * leaf to embed in a "missing selector" structured recovery error (per the
174
+ * design: "missing selector returns a structured recovery error listing
175
+ * available selector forms"). This module only gathers the raw examples —
176
+ * the calling leaf renders them through `src/output/render.ts`'s `data()`
177
+ * so untrusted DOM/AX/text content is escaped before it reaches output.
178
+ */
179
+ export function selectorHints(elements: readonly ElementRecord[], limit = 10): SelectorHints {
180
+ return {
181
+ selectors: uniqueTruncated(elements.map((e) => e.selector), limit),
182
+ axNames: uniqueTruncated(elements.map((e) => e.axName), limit),
183
+ texts: uniqueTruncated(elements.map((e) => e.text), limit),
184
+ };
185
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Unreachable structured-output owner for the hard cut. Leaves give this
3
+ * module one semantic projection builder and two canonical encoders; this
4
+ * module alone decides which optional records are admitted.
5
+ */
6
+ import { MAX_BOUNDED_BYTES, utf8ByteLength } from '../contracts/primitives.js';
7
+
8
+ export type OutputEncoding = 'prose' | 'json';
9
+
10
+ export interface CollectionOmittedBy {
11
+ readonly count_limit: number;
12
+ readonly byte_budget: number;
13
+ }
14
+
15
+ export interface DisplayedCollection<T> {
16
+ readonly retained_total: number;
17
+ readonly displayed: number;
18
+ readonly omitted: number;
19
+ readonly limit: number;
20
+ readonly omitted_by: CollectionOmittedBy;
21
+ readonly records: readonly T[];
22
+ }
23
+
24
+ export interface ProjectionCandidate<T> {
25
+ /** Stable ID used by both encoders and later artifact lookup. */
26
+ readonly id: string;
27
+ /** Canonical per-collection display order, independent of admission priority. */
28
+ readonly displayOrder: number;
29
+ readonly record: T;
30
+ }
31
+
32
+ export interface ProjectionCollection<T> {
33
+ readonly name: string;
34
+ readonly retainedTotal: number;
35
+ readonly limit: number;
36
+ /** Eligible candidates in this collection's canonical record order. */
37
+ readonly candidates: readonly ProjectionCandidate<T>[];
38
+ }
39
+
40
+ export type DisplayedCollections<T> = Readonly<Record<string, DisplayedCollection<T>>>;
41
+
42
+ export interface CanonicalStructuredEncoders<S> {
43
+ readonly prose: (semantic: S) => string;
44
+ readonly json: (semantic: S) => string;
45
+ }
46
+
47
+ export interface AllocateStructuredProjection<S, T> {
48
+ readonly collections: readonly ProjectionCollection<T>[];
49
+ /**
50
+ * Every eligible candidate exactly once, in the leaf's declared global
51
+ * consideration order. This represents priority groups and cross-collection
52
+ * round robin without letting the allocator invent command semantics.
53
+ */
54
+ readonly candidateOrder: readonly { readonly collection: string; readonly candidate: ProjectionCandidate<T> }[];
55
+ /** Builds the one semantic input shared by the two encoders. */
56
+ readonly build: (collections: DisplayedCollections<T>) => S;
57
+ readonly encoders: CanonicalStructuredEncoders<S>;
58
+ readonly maxBytes?: number;
59
+ }
60
+
61
+ export interface AllocatedStructuredProjection<S, T> {
62
+ readonly semantic: S;
63
+ readonly collections: DisplayedCollections<T>;
64
+ readonly prose: string;
65
+ readonly json: string;
66
+ readonly bytes: Readonly<Record<OutputEncoding, number>>;
67
+ }
68
+
69
+ interface MutableCollection<T> {
70
+ readonly definition: ProjectionCollection<T>;
71
+ readonly eligible: readonly ProjectionCandidate<T>[];
72
+ readonly displayed: ProjectionCandidate<T>[];
73
+ byteBudget: number;
74
+ }
75
+
76
+ function assertSafeUint(value: number, field: string): void {
77
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${field} must be a nonnegative safe integer`);
78
+ }
79
+
80
+ function verifyDefinitions<T>(collections: readonly ProjectionCollection<T>[]): void {
81
+ const names = new Set<string>();
82
+ const ids = new Set<string>();
83
+ for (const collection of collections) {
84
+ if (!collection.name) throw new Error('collection name must not be empty');
85
+ if (names.has(collection.name)) throw new Error(`duplicate collection name: ${collection.name}`);
86
+ names.add(collection.name);
87
+ assertSafeUint(collection.retainedTotal, `${collection.name}.retainedTotal`);
88
+ assertSafeUint(collection.limit, `${collection.name}.limit`);
89
+ if (collection.candidates.length > collection.retainedTotal) throw new Error(`${collection.name} has more candidates than retainedTotal`);
90
+ const eligible = Math.min(collection.retainedTotal, collection.limit);
91
+ if (collection.candidates.length !== eligible) throw new Error(`${collection.name} candidates must contain exactly min(retainedTotal, limit) records`);
92
+ const displayOrders = new Set<number>();
93
+ for (const candidate of collection.candidates) {
94
+ if (!candidate.id) throw new Error(`${collection.name} candidate has empty stable id`);
95
+ assertSafeUint(candidate.displayOrder, `${collection.name}.${candidate.id}.displayOrder`);
96
+ if (displayOrders.has(candidate.displayOrder)) throw new Error(`${collection.name} repeats displayOrder ${candidate.displayOrder}`);
97
+ displayOrders.add(candidate.displayOrder);
98
+ if (ids.has(candidate.id)) throw new Error(`duplicate stable candidate id: ${candidate.id}`);
99
+ ids.add(candidate.id);
100
+ }
101
+ }
102
+ }
103
+
104
+ function displayedCollections<T>(states: readonly MutableCollection<T>[]): DisplayedCollections<T> {
105
+ return Object.fromEntries(states.map((state) => {
106
+ const { retainedTotal, limit, name } = state.definition;
107
+ const displayed = state.displayed.length;
108
+ const countLimit = Math.max(0, retainedTotal - limit);
109
+ const omitted = retainedTotal - displayed;
110
+ const byteBudget = Math.min(retainedTotal, limit) - displayed;
111
+ if (byteBudget !== state.byteBudget) throw new Error(`allocator corruption for ${name}`);
112
+ return [name, {
113
+ retained_total: retainedTotal,
114
+ displayed,
115
+ omitted,
116
+ limit,
117
+ omitted_by: { count_limit: countLimit, byte_budget: byteBudget },
118
+ records: [...state.displayed]
119
+ .sort((left, right) => left.displayOrder - right.displayOrder)
120
+ .map((candidate) => candidate.record),
121
+ } satisfies DisplayedCollection<T>];
122
+ }));
123
+ }
124
+
125
+ function encode<S, T>(states: readonly MutableCollection<T>, build: (collections: DisplayedCollections<T>) => S, encoders: CanonicalStructuredEncoders<S>): AllocatedStructuredProjection<S, T> {
126
+ const collections = displayedCollections(states);
127
+ const semantic = build(collections);
128
+ const prose = encoders.prose(semantic);
129
+ const json = encoders.json(semantic);
130
+ return { semantic, collections, prose, json, bytes: { prose: utf8ByteLength(prose), json: utf8ByteLength(json) } };
131
+ }
132
+
133
+ function fits<S, T>(projection: AllocatedStructuredProjection<S, T>, maxBytes: number): boolean {
134
+ return projection.bytes.prose <= maxBytes && projection.bytes.json <= maxBytes;
135
+ }
136
+
137
+ /**
138
+ * Allocate whole records once for both encodings. A candidate is tested against
139
+ * the projection with every not-yet-considered eligible record counted as a
140
+ * byte-budget omission; this makes the final omission metadata part of every
141
+ * admission decision. A record that does not fit never blocks later records.
142
+ */
143
+ export function allocateStructuredProjection<S, T>(input: AllocateStructuredProjection<S, T>): AllocatedStructuredProjection<S, T> {
144
+ const maxBytes = input.maxBytes ?? MAX_BOUNDED_BYTES;
145
+ assertSafeUint(maxBytes, 'maxBytes');
146
+ if (maxBytes > MAX_BOUNDED_BYTES) throw new Error(`maxBytes must not exceed ${MAX_BOUNDED_BYTES}`);
147
+ verifyDefinitions(input.collections);
148
+ const states = input.collections.map<MutableCollection<T>>((definition) => ({
149
+ definition,
150
+ eligible: definition.candidates,
151
+ displayed: [],
152
+ byteBudget: definition.candidates.length,
153
+ }));
154
+ const stateByName = new Map(states.map((state) => [state.definition.name, state]));
155
+ const expectedCandidates = new Map(states.flatMap((state) => state.eligible.map((candidate) => [candidate.id, { state, candidate }] as const)));
156
+ const orderedIds = new Set<string>();
157
+ for (const entry of input.candidateOrder) {
158
+ const expected = expectedCandidates.get(entry.candidate.id);
159
+ if (!expected || expected.state !== stateByName.get(entry.collection) || expected.candidate !== entry.candidate) {
160
+ throw new Error(`candidateOrder contains an unknown or mismatched candidate: ${entry.candidate.id}`);
161
+ }
162
+ if (orderedIds.has(entry.candidate.id)) throw new Error(`candidateOrder repeats candidate: ${entry.candidate.id}`);
163
+ orderedIds.add(entry.candidate.id);
164
+ }
165
+ if (orderedIds.size !== expectedCandidates.size) throw new Error('candidateOrder must contain every eligible candidate exactly once');
166
+
167
+ for (const { collection, candidate } of input.candidateOrder) {
168
+ const state = stateByName.get(collection)!;
169
+ state.displayed.push(candidate);
170
+ state.byteBudget--;
171
+ if (!fits(encode(states, input.build, input.encoders), maxBytes)) {
172
+ state.displayed.pop();
173
+ state.byteBudget++;
174
+ }
175
+ }
176
+
177
+ let projection = encode(states, input.build, input.encoders);
178
+ // Decimal omission metadata can grow after later candidates are rejected.
179
+ // Remove whole records (never encoded prefixes) until the final semantic
180
+ // projection itself satisfies both independent caps.
181
+ while (!fits(projection, maxBytes)) {
182
+ const state = [...states].reverse().find((candidate) => candidate.displayed.length > 0);
183
+ if (!state) throw new Error(`mandatory structured projection exceeds ${maxBytes} bytes`);
184
+ state.displayed.pop();
185
+ state.byteBudget++;
186
+ projection = encode(states, input.build, input.encoders);
187
+ }
188
+ return projection;
189
+ }
190
+
191
+ /** Cap a UTF-8 string at a code-point boundary and report exact byte omission arithmetic. */
192
+ export function capUtf8(value: string, maxBytes: number): { readonly value: string; readonly bytes_omitted: number } {
193
+ assertSafeUint(maxBytes, 'maxBytes');
194
+ const sourceBytes = utf8ByteLength(value);
195
+ if (sourceBytes <= maxBytes) return { value, bytes_omitted: 0 };
196
+ let end = 0;
197
+ let bytes = 0;
198
+ for (const codePoint of value) {
199
+ const next = utf8ByteLength(codePoint);
200
+ if (bytes + next > maxBytes) break;
201
+ bytes += next;
202
+ end += codePoint.length;
203
+ }
204
+ return { value: value.slice(0, end), bytes_omitted: sourceBytes - bytes };
205
+ }
206
+
207
+ /** Exact scalar and collection exhaustion calculation shared by both encoders. */
208
+ export function projectionExhaustive<T>(collections: DisplayedCollections<T>, scalarBytesOmittedTotal: number): boolean {
209
+ assertSafeUint(scalarBytesOmittedTotal, 'scalarBytesOmittedTotal');
210
+ return scalarBytesOmittedTotal === 0 && Object.values(collections).every((collection) => collection.omitted === 0);
211
+ }