@crouton-kit/capture 1.3.8 → 1.3.10

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -40,6 +40,26 @@ export const RESULT_TAGS = [
40
40
  'jank',
41
41
  'response',
42
42
  'error',
43
+ 'session',
44
+ 'session-stopped',
45
+ 'sessions',
46
+ 'session-har',
47
+ 'log-tail',
48
+ 'clicked',
49
+ 'typed',
50
+ 'scrolled',
51
+ 'navigated',
52
+ 'exec-result',
53
+ 'elements',
54
+ 'screenshot',
55
+ 'tabs',
56
+ 'tab-opened',
57
+ 'tab-reset',
58
+ 'network',
59
+ 'cdp-result',
60
+ 'libs',
61
+ 'lib',
62
+ 'ax-map',
43
63
  ] as const;
44
64
 
45
65
  export type ResultTag = (typeof RESULT_TAGS)[number];
@@ -95,9 +115,383 @@ function capLength(s: string, max: number): string {
95
115
  return `${s.slice(0, max)}…[+${s.length - max} chars]`;
96
116
  }
97
117
 
98
- /** Escape for use inside XML-ish element text content. */
118
+ // XML name-start characters this renderer treats as beginning a tag-like span.
119
+ // This is the FULL XML 1.0 NameStartChar production, including the astral
120
+ // #x10000-#xEFFFF plane: ASCII letters, `_`/`:`, the Unicode letter/name-start
121
+ // BMP ranges XML permits, and astral name-start code points. The `u` flag makes
122
+ // the class code-point-aware so an astral name-start char (a surrogate pair such
123
+ // as `𐀀`, U+10000) is matched as one code point, not a lone surrogate. So
124
+ // hostile, page-derived but XML-valid names — `<_hostile>`, `<évil>`, `<Ω>`,
125
+ // `<名>`, `<𐀀>` — are neutralized like any `<tag>` rather than slipping through
126
+ // because they aren't ASCII-letter-led or sit outside the BMP.
127
+ const TAG_NAME_START =
128
+ /[:A-Z_a-z\u{C0}-\u{D6}\u{D8}-\u{F6}\u{F8}-\u{2FF}\u{370}-\u{37D}\u{37F}-\u{1FFF}\u{200C}\u{200D}\u{2070}-\u{218F}\u{2C00}-\u{2FEF}\u{3001}-\u{D7FF}\u{F900}-\u{FDCF}\u{FDF0}-\u{FFFD}\u{10000}-\u{EFFFF}]/u;
129
+
130
+ // The additional characters XML 1.0 NameChar permits BEYOND NameStartChar
131
+ // (used only to continue a name, never to begin one): `-`, `.`, ASCII digits,
132
+ // U+00B7 MIDDLE DOT, and the combining/connector ranges U+0300-U+036F and
133
+ // U+203F-U+2040. Combined with TAG_NAME_START this is the full NameChar set.
134
+ const TAG_NAME_CONT = /[-.0-9\u{B7}\u{0300}-\u{036F}\u{203F}-\u{2040}]/u;
135
+
136
+ /** True iff the code point may appear inside an XML tag name (NameStartChar or
137
+ * the NameChar-only continuation set). Code-point-aware so astral name chars
138
+ * are matched whole. */
139
+ function isTagNameChar(cp: number): boolean {
140
+ const c = String.fromCodePoint(cp);
141
+ return TAG_NAME_START.test(c) || TAG_NAME_CONT.test(c);
142
+ }
143
+
144
+ /** True iff the `<` at `ltIndex` opens a markup-like span — i.e. it is
145
+ * immediately (after an optional `/`) followed by an XML 1.0 NameStartChar.
146
+ * Code-point-aware: `codePointAt` reads a full astral char (a surrogate pair
147
+ * such as `𐀀`, U+10000) so an XML-valid astral name is recognized as a real
148
+ * tag opener rather than dismissed as an unmatched lone surrogate. */
149
+ function startsMarkupName(s: string, ltIndex: number): boolean {
150
+ let j = ltIndex + 1;
151
+ if (s[j] === '/') j++;
152
+ if (j >= s.length) return false;
153
+ const cp = s.codePointAt(j);
154
+ if (cp === undefined) return false;
155
+ return TAG_NAME_START.test(String.fromCodePoint(cp));
156
+ }
157
+
158
+ /** From a `<` at `start` (its name-start already confirmed by
159
+ * `startsMarkupName`), return the index of the `>` that TRULY closes the
160
+ * markup-like span, or -1 if `<start` is not genuine tag syntax. The scan is
161
+ * CONTEXTUAL — `insideCssString` selects between two models, because the two
162
+ * contexts genuinely tokenize the same bytes differently:
163
+ *
164
+ * - Top level / prose (`insideCssString === false`) → `scanMarkupCloseHtml`,
165
+ * faithful HTML tag-token semantics. A tag name runs until whitespace, `/`,
166
+ * or `>` (HTML consumes any other byte — backslash, quote — INTO the name),
167
+ * and quotes suppress `>` only when they open a quoted attribute value after
168
+ * `=`. So HTML-tokenizable data whose real `>` an XML-name gate would miss
169
+ * (`<img\foo>`, `<img " >`, `<img alt=foo" >`) is fully neutralized.
170
+ * - Inside a CSS attribute-selector quoted string (`insideCssString === true`)
171
+ * → `scanMarkupCloseCss`, the strict XML-name gate. There backslash is a CSS
172
+ * string escape, so inert selector data like `<value\"` (name run ended by
173
+ * `\`, not whitespace/`/`/`>`) is NOT a tag and returns -1 — its `<` is
174
+ * still escaped, but a later child-combinator `>` stays raw/copy-pastable.
175
+ * A genuine buried tag (`<img alt="x">`, name ended by whitespace) is still
176
+ * recognized and its real `>` neutralized. Applying HTML name semantics
177
+ * here instead would let a run-on name swallow the `] > b` combinator. */
178
+ function scanMarkupClose(s: string, start: number, insideCssString: boolean): number {
179
+ let k = start + 1;
180
+ if (s[k] === '/') k++;
181
+ return insideCssString ? scanMarkupCloseCss(s, k) : scanMarkupCloseHtml(s, k);
182
+ }
183
+
184
+ function isTagWhitespace(ch: string | undefined): boolean {
185
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
186
+ }
187
+
188
+ /** Strict-XML markup close for a `<` nested inside a CSS attribute-selector
189
+ * quoted string. `k` is the first name char (past any `/`). See
190
+ * `scanMarkupClose`. */
191
+ function scanMarkupCloseCss(s: string, startName: number): number {
192
+ let k = startName;
193
+ // Gate 1 — walk the tag name (first char already confirmed a NameStartChar
194
+ // by startsMarkupName) and require a valid tag-name terminator after it.
195
+ let nameLen = 0;
196
+ while (k < s.length) {
197
+ const cp = s.codePointAt(k)!;
198
+ if (!isTagNameChar(cp)) break;
199
+ k += cp > 0xffff ? 2 : 1;
200
+ nameLen++;
201
+ }
202
+ if (nameLen === 0) return -1;
203
+ const boundary = s[k];
204
+ if (boundary !== ' ' && boundary !== '\t' && boundary !== '/' && boundary !== '>') return -1;
205
+ // Gate 2 — find the real `>` with XML attribute-quote semantics. Backslash is
206
+ // a LITERAL attribute character (XML attrs do not backslash-escape), so
207
+ // `<img alt="1 > 0\">` closes at the `>` right after the `\"`.
208
+ let attrQuote: string | null = null;
209
+ while (k < s.length) {
210
+ const ck = s[k];
211
+ if (attrQuote) {
212
+ if (ck === attrQuote) attrQuote = null;
213
+ k++;
214
+ continue;
215
+ }
216
+ if (ck === '"' || ck === "'") {
217
+ attrQuote = ck;
218
+ k++;
219
+ continue;
220
+ }
221
+ if (ck === '>') return k;
222
+ if (ck === '<') return -1; // a new span opens before this one closed — abort
223
+ k++;
224
+ }
225
+ return -1;
226
+ }
227
+
228
+ /** HTML tag-token close for a top-level / prose `<`. `k` is the first name
229
+ * char (past any `/`). Implements enough of the HTML tokenizer's tag states to
230
+ * find the real `>`: the name runs until whitespace/`/`/`>` (every other byte,
231
+ * backslash and quote included, is part of the name), and a quote suppresses
232
+ * `>` ONLY inside a quoted attribute value it opened after `=`. A malformed
233
+ * quote that is not a value delimiter (a stray `"` in before-attribute-name, or
234
+ * a `"` inside an unquoted value) does NOT hide the real close. See
235
+ * `scanMarkupClose`. */
236
+ function scanMarkupCloseHtml(s: string, startName: number): number {
237
+ const n = s.length;
238
+ let k = startName;
239
+ // Tag-name state: consume until a name terminator (HTML absorbs any other
240
+ // byte, e.g. `\` or `"`, into the name).
241
+ let nameLen = 0;
242
+ while (k < n) {
243
+ const ch = s[k];
244
+ if (isTagWhitespace(ch) || ch === '/' || ch === '>') break;
245
+ k++;
246
+ nameLen++;
247
+ }
248
+ if (nameLen === 0) return -1;
249
+ // Attribute states. `reconsume` re-reads the current char in the next state
250
+ // without advancing (mirrors the HTML tokenizer's reconsume steps).
251
+ type St = 'beforeName' | 'name' | 'afterName' | 'beforeValue' | 'valueDQ' | 'valueSQ' | 'valueUnquoted' | 'afterValueQuoted';
252
+ let state: St = 'beforeName';
253
+ while (k < n) {
254
+ const ch = s[k];
255
+ switch (state) {
256
+ case 'beforeName':
257
+ if (isTagWhitespace(ch) || ch === '/') { k++; break; }
258
+ if (ch === '>') return k;
259
+ state = 'name'; // reconsume ch as the start of an attribute name
260
+ break;
261
+ case 'name':
262
+ if (isTagWhitespace(ch)) { state = 'afterName'; k++; break; }
263
+ if (ch === '/') { state = 'afterName'; break; }
264
+ if (ch === '>') return k;
265
+ if (ch === '=') { state = 'beforeValue'; k++; break; }
266
+ k++;
267
+ break;
268
+ case 'afterName':
269
+ if (isTagWhitespace(ch) || ch === '/') { k++; break; }
270
+ if (ch === '=') { state = 'beforeValue'; k++; break; }
271
+ if (ch === '>') return k;
272
+ state = 'name'; // reconsume as a new attribute name
273
+ break;
274
+ case 'beforeValue':
275
+ if (isTagWhitespace(ch)) { k++; break; }
276
+ if (ch === '"') { state = 'valueDQ'; k++; break; }
277
+ if (ch === "'") { state = 'valueSQ'; k++; break; }
278
+ if (ch === '>') return k; // attribute with a missing value
279
+ state = 'valueUnquoted'; // reconsume ch as the first unquoted-value byte
280
+ break;
281
+ case 'valueDQ':
282
+ if (ch === '"') state = 'afterValueQuoted';
283
+ k++;
284
+ break;
285
+ case 'valueSQ':
286
+ if (ch === "'") state = 'afterValueQuoted';
287
+ k++;
288
+ break;
289
+ case 'valueUnquoted':
290
+ if (isTagWhitespace(ch)) { state = 'beforeName'; k++; break; }
291
+ if (ch === '>') return k;
292
+ k++; // `"`/`'` here are ordinary unquoted-value bytes, not delimiters
293
+ break;
294
+ case 'afterValueQuoted':
295
+ if (isTagWhitespace(ch)) { state = 'beforeName'; k++; break; }
296
+ if (ch === '/') { k++; break; }
297
+ if (ch === '>') return k;
298
+ state = 'beforeName'; // reconsume as the next attribute name
299
+ break;
300
+ }
301
+ }
302
+ return -1;
303
+ }
304
+
305
+ /** True iff a CSS identifier character (an attribute-selector name body):
306
+ * ASCII letters, digits, `-`, `_`, or any non-ASCII codepoint (CSS idents
307
+ * allow those). Deliberately excludes `.`, `#`, `:`, `(`, `[`, whitespace.
308
+ * Used only by `opensAttrSelectorString`'s walk-back over `[ident=`. */
309
+ function isCssIdentChar(ch: string | undefined): boolean {
310
+ if (ch === undefined) return false;
311
+ const cp = ch.codePointAt(0)!;
312
+ if (cp > 127) return true;
313
+ return /[A-Za-z0-9_-]/.test(ch);
314
+ }
315
+
316
+ /** True iff the quote at `quoteIndex` opens a CSS ATTRIBUTE-SELECTOR string —
317
+ * the ONE syntactic shape that switches the scan out of its prose/HTML default
318
+ * into XML-strict CSS-string context. It is anchored on exactly one form:
319
+ *
320
+ * `[` ident (optional one of `~ | ^ $ *`) `=` `"`/`'`
321
+ *
322
+ * The quote must be preceded by `=` (optionally one operator char), then an
323
+ * identifier (the attribute name), then an unclosed `[`. Nothing else opens CSS
324
+ * context. A bare prose `=` (`x = "…"`, or `said, x="…"` with no bracket) does
325
+ * NOT qualify — there is no enclosing `[`. Function-call notation (`url("…")`,
326
+ * `foo("…")`) is DELIBERATELY not recognized: `fn("…")` is inherently ambiguous
327
+ * between CSS functional notation and ordinary prose, so it stays prose and its
328
+ * nested markup is neutralized with faithful HTML tokenization.
329
+ *
330
+ * Why anchoring here is closed-form, not another heuristic (the safety argument
331
+ * is asymmetric):
332
+ * - `<` is escaped UNCONDITIONALLY in every context, so no output can ever
333
+ * carry a tag whose opener is live. This `>`-marking machinery exists only
334
+ * to avoid the half-escaped `&lt;tag …>` shape.
335
+ * - Misclassifying a real CSS string as prose (the only direction this shrunk
336
+ * recognizer can err for genuine selectors, e.g. `url("<value\"") > span`)
337
+ * at worst OVER-escapes a later combinator `>` — a copy-paste nit, never a
338
+ * security hole.
339
+ * - Misclassifying prose as CSS is now only possible when the prose LITERALLY
340
+ * contains `[ident="…"]` attribute-selector syntax. Inside that shape the
341
+ * behavior is the required copy-pastable-selector behavior: `<` is dead, an
342
+ * XML-tokenizable buried tag still gets its `>` escaped, and only a
343
+ * non-XML-tokenizable span (`<value\"`) stays inert.
344
+ *
345
+ * So the boundary is: prose ⇒ full HTML tokenization (escape-happy); a
346
+ * syntactic `[ident="…"]` string ⇒ XML-strict (copy-paste-preserving). There is
347
+ * no third context and no punctuation heuristic left to relitigate. */
348
+ function opensAttrSelectorString(s: string, quoteIndex: number): boolean {
349
+ let j = quoteIndex - 1;
350
+ while (j >= 0 && (s[j] === ' ' || s[j] === '\t')) j--;
351
+ if (j < 0) return false;
352
+ if (s[j] !== '=') return false;
353
+ // Attribute selector `[name="v"]`: walk back over an optional operator char,
354
+ // the attribute name, and require an unclosed `[`.
355
+ let k = j - 1;
356
+ if (k >= 0 && '~|^$*'.includes(s[k]!)) k--;
357
+ while (k >= 0 && (s[k] === ' ' || s[k] === '\t')) k--;
358
+ if (!isCssIdentChar(s[k])) return false; // no attribute name → prose `=`
359
+ while (k >= 0 && isCssIdentChar(s[k])) k--;
360
+ while (k >= 0 && (s[k] === ' ' || s[k] === '\t')) k--;
361
+ return k >= 0 && s[k] === '[';
362
+ }
363
+
364
+ /** Scan over the ORIGINAL (pre-escape) text returning the set of `>` indices
365
+ * that must be escaped. A `>` qualifies iff it is either:
366
+ * - the REAL closing delimiter of a markup-like span (found via
367
+ * `scanMarkupClose`, which skips a `>` inside a quoted attribute so only
368
+ * the tag's true terminator is marked); or
369
+ * - the `>` completing `]]>`, which XML forbids as literal character data.
370
+ * Because the scan runs on the original text, a later `<` (still a literal `<`
371
+ * here, not yet `&lt;`) correctly delimits spans.
372
+ *
373
+ * The scan tracks ONE piece of outer context: whether the cursor is inside a
374
+ * CSS attribute-selector quoted string, decided by `opensAttrSelectorString`
375
+ * from the one unambiguous syntactic shape `[ident="…"]` — NOT prose
376
+ * punctuation and NOT function-call notation. That is the distinction the
377
+ * security fix turns on — a `<` inside an attribute-selector string obeys the
378
+ * strict-XML-name gate with CSS backslash semantics, a `<` anywhere else (the
379
+ * prose/HTML default, including one nested inside an ordinary prose quote or a
380
+ * `fn("…")` argument) obeys faithful HTML tag-token semantics (see
381
+ * `scanMarkupClose`). The acceptance cases:
382
+ * - #1 `The function foo("<img\foo>payload") returned` — `foo("` is prose, not
383
+ * selector syntax, so `<img\foo>` is HTML-tokenized: the `\` is part of the
384
+ * name and the `>` is the real terminator, marked (fully neutralized).
385
+ * - #2 `foo("<img " >payload")` and #3 `foo("<img alt=foo" >payload")` — also
386
+ * prose; a quote that does not open a value after `=` never hides the real
387
+ * close, so the `>` is marked.
388
+ * - #4 `div[data-x="<value\""] > span` — the `"` sits inside `[data-x=…]`, so
389
+ * it IS an attribute-selector string, but `<value\"` is not real tag syntax:
390
+ * its name run ends on `\`, so the strict-XML name-boundary gate rejects it
391
+ * as inert CSS data. The `<` is still escaped, but the child-combinator `>`
392
+ * after `]` stays raw/copy-pastable.
393
+ * - #5 `a[data-x="<img alt="1 > 0\">"] > b` — also a real selector string, but
394
+ * here `<img …>` IS genuine tag syntax (name terminated by whitespace). The
395
+ * scan finds the real closing `>` (right after the literal `\"`, since XML
396
+ * attributes do not backslash-escape) and marks it, while the
397
+ * child-combinator `>` after `]` stays raw.
398
+ * - #8 `<img alt="1 > 0" onerror="x">` — top-level markup; the `>` inside a
399
+ * quoted attribute stays raw and only the true terminator is marked.
400
+ * - #10 `</response> matched main > section` — the tag close is marked; the
401
+ * `main > section` combinator stays raw.
402
+ * - #12 `x[a="<img alt=foo" >payload"]` — CSS context by design (it has
403
+ * `[a=`): under the XML gate the span is inert, `<` escaped, the `>` stays
404
+ * raw. The boundary carve-out, pinned so it is not rediscovered.
405
+ * - #13 `url("<value\"") > span` — prose (function-call notation is not
406
+ * recognized), so the trailing combinator `>` MAY be over-escaped; safe
407
+ * direction, no live markup survives.
408
+ * Crucially, a genuine tag inside an attribute-selector string is still marked
409
+ * — a hostile `<tag>` wrapped in a real selector string is neutralized rather
410
+ * than swallowed by the quote and left half-escaped. */
411
+ function markupCloseIndices(s: string): ReadonlySet<number> {
412
+ const marks = new Set<number>();
413
+ const n = s.length;
414
+ let i = 0;
415
+ while (i < n) {
416
+ const c = s[i];
417
+ if ((c === '"' || c === "'") && opensAttrSelectorString(s, i)) {
418
+ // A CSS attribute-selector quoted string (CSS backslash semantics). A `<` buried
419
+ // inside is markup only when it genuinely self-closes (its own attribute
420
+ // quotes toggle and a top-level `>` is reached); otherwise it is inert
421
+ // CSS data and any `>` outside stays raw.
422
+ const delim = c;
423
+ let k = i + 1;
424
+ while (k < n) {
425
+ const ck = s[k];
426
+ if (ck === '\\') {
427
+ k += 2;
428
+ continue;
429
+ }
430
+ if (ck === delim) {
431
+ k++;
432
+ break;
433
+ }
434
+ if (ck === '<' && startsMarkupName(s, k)) {
435
+ const close = scanMarkupClose(s, k, true);
436
+ if (close >= 0) {
437
+ marks.add(close);
438
+ k = close + 1;
439
+ continue;
440
+ }
441
+ }
442
+ k++;
443
+ }
444
+ i = k;
445
+ continue;
446
+ }
447
+ if (c === '<' && startsMarkupName(s, i)) {
448
+ const close = scanMarkupClose(s, i, false);
449
+ if (close >= 0) {
450
+ marks.add(close);
451
+ i = close + 1;
452
+ continue;
453
+ }
454
+ }
455
+ i++;
456
+ }
457
+ for (let p = 0; p + 2 < n; p++) {
458
+ if (s[p] === ']' && s[p + 1] === ']' && s[p + 2] === '>') marks.add(p + 2);
459
+ }
460
+ return marks;
461
+ }
462
+
463
+ /** Escape for use inside XML-ish element text/attribute content. `&` and `<`
464
+ * are always escaped — `<` is what could open a forged tag and `&` what could
465
+ * open an entity. `>` is escaped SELECTIVELY, resolving two competing needs:
466
+ * - A bare `>` cannot break out of text content and, left verbatim, keeps
467
+ * page-derived CSS selectors (`div#root > main`, even `div[data-x="<value"]
468
+ * > span`) copy-pastable straight out of the rendered prose instead of
469
+ * printing as `div#root &gt; main`. So an ordinary combinator `>` (one that
470
+ * does NOT close a markup-like tag span) is emitted raw.
471
+ * - The `>` that CLOSES a markup-like tag span (`</response>`, `<script>`,
472
+ * `<img alt="1 > 0" onerror="x">`, `<_hostile>`) — whose opening `<` is
473
+ * already `&lt;` — is escaped to `&gt;`, so hostile, page-derived markup can
474
+ * never render as a live tag or leave a half-escaped `&lt;tag>` that a
475
+ * lenient XML/HTML reader might still parse. The real closing delimiter is
476
+ * found by a quote-aware scan (`markupCloseIndices`), so a `>` inside a
477
+ * quoted attribute is left raw and only the tag's true terminator escapes.
478
+ * - The sequence `]]>`, which XML forbids as literal character data even
479
+ * outside CDATA, is neutralized to `]]&gt;` wherever it survives, so
480
+ * page-derived text carrying it cannot make an XML consumer reject the
481
+ * block.
482
+ * The net rule: a `>` is escaped iff it terminates a `<…>` tag-like span or
483
+ * completes `]]>`; every other `>` (the selector-combinator case) stays raw. */
99
484
  function escapeXmlText(s: string): string {
100
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
485
+ const marks = markupCloseIndices(s);
486
+ let out = '';
487
+ for (let i = 0; i < s.length; i++) {
488
+ const c = s[i];
489
+ if (c === '&') out += '&amp;';
490
+ else if (c === '<') out += '&lt;';
491
+ else if (c === '>') out += marks.has(i) ? '&gt;' : '>';
492
+ else out += c;
493
+ }
494
+ return out;
101
495
  }
102
496
 
103
497
  /** Escape for use inside a double-quoted XML-ish attribute value. */
@@ -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
+ }