@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,987 @@
1
+ /**
2
+ * Shared XML-ish prose renderer for `capture measure` and `capture motion`.
3
+ *
4
+ * Every value that could originate from the rendered page (DOM/AX names, CSS
5
+ * source URLs, selectors the page influenced, action labels, text content,
6
+ * etc.) is UNTRUSTED and must reach this module only through the `data()`/
7
+ * `fact` constructors below, which escape and length-cap it. Plain JS
8
+ * template literals or string concatenation must never be assigned directly
9
+ * to a `RenderableResult` field — every `FactLine` node is branded (an
10
+ * unexported symbol only this module's own `text`/`data`/`fact`/`line`/
11
+ * `lineList` constructors can attach) and every composition/emission path
12
+ * runtime-rejects an unbranded node, so a leaf command cannot bypass the
13
+ * safe constructors even by hand-constructing an object that structurally
14
+ * matches a `FactLine` — this renderer never needs a per-command switch to
15
+ * know what is safe to emit.
16
+ *
17
+ * Binding posture: this module renders measurements and provenance only. It
18
+ * must never grow diagnosis/prescription/grading language of its own — that
19
+ * is entirely the calling leaf's (and ultimately the agent's) job.
20
+ */
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Trusted tag enum — the ONLY source of a rendered block's tag name. A tag is
24
+ // always one of these literals; it is never derived from page/DOM content.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export const RESULT_TAGS = [
28
+ 'snapshot',
29
+ 'checks',
30
+ 'diff',
31
+ 'census',
32
+ 'explain',
33
+ 'sweep',
34
+ 'focus-map',
35
+ 'scroll-map',
36
+ 'layer-map',
37
+ 'recording',
38
+ 'motion-mask',
39
+ 'timeline',
40
+ 'jank',
41
+ 'response',
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',
63
+ ] as const;
64
+
65
+ export type ResultTag = (typeof RESULT_TAGS)[number];
66
+
67
+ const RESULT_TAG_SET: ReadonlySet<string> = new Set(RESULT_TAGS);
68
+
69
+ function assertTrustedTag(tag: string): asserts tag is ResultTag {
70
+ if (!RESULT_TAG_SET.has(tag)) {
71
+ throw new Error(
72
+ `render.ts: refusing to render untrusted tag ${JSON.stringify(tag)} — tag must be one of ${RESULT_TAGS.join(', ')}`,
73
+ );
74
+ }
75
+ }
76
+
77
+ const ATTR_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*$/;
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Sanitization primitives
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const DEFAULT_DATA_MAX = 200;
84
+ const DEFAULT_ATTR_MAX = 200;
85
+
86
+ /** Strip control characters and collapse newlines/tabs so an untrusted value
87
+ * can never fake a new output line (e.g. a forged `follow_up:` line), smuggle
88
+ * a raw ANSI/terminal escape, or embed invisible content. This strips:
89
+ * - the FULL control range — C0 (\u0000-\u001F) minus \r\n\t (normalized to
90
+ * a space below), DEL (\u007F), and C1 (\u0080-\u009F, which includes
91
+ * \u009B CSI and \u009D OSC — terminals interpret these as ANSI *without*
92
+ * a leading ESC byte, so stripping only C0/DEL is not sufficient);
93
+ * - \u2028/\u2029 (Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR), which act
94
+ * as line breaks to many downstream readers despite not being C0/C1 —
95
+ * normalized to a space alongside \r\n\t rather than merely stripped, so
96
+ * word-adjacency is preserved the same way a real line break would be;
97
+ * - every Unicode *format* control character via the `\p{Cf}` Unicode
98
+ * property escape (requires the regex `u` flag) — this is a general,
99
+ * class-level rule rather than an enumerated codepoint list, so it
100
+ * covers every invisible/bidi-influencing format control in one pass:
101
+ * zero-width space/joiners, bidi embedding/override/isolate controls,
102
+ * U+061C ARABIC LETTER MARK, U+00AD SOFT HYPHEN, U+FEFF BOM/ZWNBSP, and
103
+ * any other current or future codepoint Unicode assigns category Cf.
104
+ * No untrusted value can hide non-printing content or fake a line break. */
105
+ function neutralizeControl(s: string): string {
106
+ return s
107
+ .replace(/[\r\n\t\u2028\u2029]+/g, ' ')
108
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
109
+ .replace(/\p{Cf}/gu, '')
110
+ .replace(/`/g, "'");
111
+ }
112
+
113
+ function capLength(s: string, max: number): string {
114
+ if (s.length <= max) return s;
115
+ return `${s.slice(0, max)}…[+${s.length - max} chars]`;
116
+ }
117
+
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. */
484
+ function escapeXmlText(s: string): string {
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;
495
+ }
496
+
497
+ /** Escape for use inside a double-quoted XML-ish attribute value. */
498
+ function escapeXmlAttr(s: string): string {
499
+ return escapeXmlText(s).replace(/"/g, '&quot;');
500
+ }
501
+
502
+ function sanitizeNumber(n: number): string {
503
+ return Number.isFinite(n) ? String(n) : '0';
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // FactLine — the only shape a leaf command may hand render.ts as body/attr
508
+ // content. Built exclusively via `text`, `data`, `fact`, and `line` below.
509
+ // `text` nodes are developer-authored, STATIC source-code string literals —
510
+ // `text` is a no-substitution tagged template (`` text`Artifacts: ` ``), not
511
+ // an ordinary function, so there is no verbatim-dynamic-string call shape a
512
+ // leaf author could pass an untrusted runtime value through by mistake.
513
+ // `data` nodes are untrusted values and are always escaped + length-capped
514
+ // at render time, in both prose and --json output. `line()` only accepts
515
+ // already-safe FactLine values (never a raw string) for the same reason.
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Unexported brand symbol. `TextNode`/`DataNode` are not exported — only
519
+ * the opaque `FactLine` element type is — and every node this module's own
520
+ * constructors produce carries this symbol key. Because the symbol value
521
+ * itself is never exported, no code outside this module can name it, so no
522
+ * outside object literal can carry this property — not even one that
523
+ * otherwise matches `{kind:'text', value: string}` structurally. This is
524
+ * what makes a hand-constructed `{kind:'text', value: untrusted}` object
525
+ * REJECTED rather than merely "not the intended call shape": TypeScript's
526
+ * structural typing can't be fooled into accepting it (the required brand
527
+ * property is missing), and even a caller who bypasses the type system
528
+ * entirely (`as any`/`as FactLine`) still fails the runtime brand check
529
+ * every composition and emission path below performs before trusting a
530
+ * node. There is no way to construct a validly-branded node without going
531
+ * through `text()`/`data()`/`fact()`/`line()`/`lineList()`. */
532
+ const NODE_BRAND: unique symbol = Symbol('render.ts:factNode');
533
+
534
+ interface TextNode {
535
+ readonly kind: 'text';
536
+ readonly value: string;
537
+ readonly [NODE_BRAND]: true;
538
+ }
539
+
540
+ interface DataNode {
541
+ readonly kind: 'data';
542
+ readonly value: string | number;
543
+ readonly maxLength?: number;
544
+ readonly [NODE_BRAND]: true;
545
+ }
546
+
547
+ type FactNode = TextNode | DataNode;
548
+
549
+ function mkTextNode(value: string): TextNode {
550
+ return { kind: 'text', value, [NODE_BRAND]: true };
551
+ }
552
+
553
+ function mkDataNode(value: string | number, maxLength?: number): DataNode {
554
+ return { kind: 'data', value, maxLength, [NODE_BRAND]: true };
555
+ }
556
+
557
+ function isFactNode(v: unknown): v is FactNode {
558
+ if (typeof v !== 'object' || v === null) return false;
559
+ const n = v as Record<string | symbol, unknown>;
560
+ if (n[NODE_BRAND] !== true) return false;
561
+ if (n.kind === 'text') return typeof n.value === 'string';
562
+ if (n.kind === 'data') return typeof n.value === 'string' || typeof n.value === 'number';
563
+ return false;
564
+ }
565
+
566
+ /** Runtime backstop for every place a leaf command hands render.ts a
567
+ * FactLine — `summary`/`sections`/`followUp`/`attestation.note`, every
568
+ * `line()`/`joinFactLines()` composition, and `lineList()`'s rows. A leaf
569
+ * author (or any caller that bypasses the type system with `as`/`any`) can
570
+ * still hand-construct a plain array of plain objects matching the
571
+ * structural shape of a `FactLine`; this rejects any such value — and any
572
+ * individual node within an otherwise-real `FactLine` that isn't branded —
573
+ * by throwing, so an untrusted value can never reach output verbatim
574
+ * through a structural bypass, regardless of which entry point it targets. */
575
+ function assertFactLine(fl: unknown, context: string): asserts fl is FactLine {
576
+ if (!Array.isArray(fl)) {
577
+ throw new Error(
578
+ `render.ts: ${context} must be a FactLine built via text\`\`/data()/fact\`\`/line()/lineList() — not a raw value`,
579
+ );
580
+ }
581
+ for (const node of fl) {
582
+ if (!isFactNode(node)) {
583
+ throw new Error(
584
+ `render.ts: ${context} contains an unbranded/hand-constructed node — every FactLine node must come from this module's own text\`\`/data()/fact\`\`/line()/lineList() constructors, never a hand-built object literal`,
585
+ );
586
+ }
587
+ }
588
+ }
589
+
590
+ /** A safe, already-composed line/paragraph of output. The only way to obtain
591
+ * one is `text()`, `data()`, `fact` tag, or `line()` — never a raw string,
592
+ * and never a hand-built object literal (see `assertFactLine` above). */
593
+ export type FactLine = readonly FactNode[];
594
+
595
+ /** Marks a value for embedding with a custom (non-default) length cap. Use
596
+ * for long DOM/text/CSS-source values that need a bigger quoted-data cap. */
597
+ export interface Capped {
598
+ readonly __capped: true;
599
+ readonly value: string | number;
600
+ readonly maxLength: number;
601
+ }
602
+
603
+ export function capped(value: string | number, maxLength: number): Capped {
604
+ return { __capped: true, value, maxLength };
605
+ }
606
+
607
+ function isCapped(v: unknown): v is Capped {
608
+ return typeof v === 'object' && v !== null && (v as Capped).__capped === true;
609
+ }
610
+
611
+ /** Static, developer-authored text — emitted verbatim (never treated as
612
+ * page/DOM-derived). Safe by construction: `text` is a no-substitution
613
+ * tagged template literal (`` text`Artifacts: ` ``), not an ordinary
614
+ * function, so a leaf author cannot pass a runtime/page-derived string
615
+ * through it even by mistake — the call shape that would allow it doesn't
616
+ * type-check, and is also rejected at runtime (see below) for callers that
617
+ * bypass the type system. Never pass page content here; use `data()` (or
618
+ * `fact` for prose with untrusted interpolation) instead. */
619
+ export function text(strings: TemplateStringsArray): FactLine {
620
+ if (!Array.isArray(strings) || !('raw' in strings)) {
621
+ throw new Error(
622
+ 'render.ts: text`` must be invoked as a tagged template literal, e.g. text`Artifacts: ` — not called with a runtime string',
623
+ );
624
+ }
625
+ if (strings.length !== 1) {
626
+ throw new Error('render.ts: text`` does not accept interpolation — use fact`` or data() for dynamic values');
627
+ }
628
+ return [mkTextNode(strings[0])];
629
+ }
630
+
631
+ /** An untrusted value (selector, DOM/AX name, CSS source URL, action label,
632
+ * text content, id, path, …). Always escaped and length-capped before it
633
+ * reaches either the rendered prose or the --json output. */
634
+ export function data(value: string | number | Capped, maxLength?: number): FactLine {
635
+ if (isCapped(value)) {
636
+ return [mkDataNode(value.value, value.maxLength)];
637
+ }
638
+ return [mkDataNode(value, maxLength)];
639
+ }
640
+
641
+ /** Concatenate already-safe FactLines into one FactLine. Only accepts
642
+ * FactLine values built via `text`/`data`/`fact`/`line` itself — never a raw
643
+ * string — so there is no call shape through which an unescaped runtime
644
+ * value reaches output. A caller that bypasses the type system and passes a
645
+ * raw string anyway is rejected at runtime rather than silently accepted. */
646
+ export function line(...parts: readonly FactLine[]): FactLine {
647
+ const out: FactNode[] = [];
648
+ for (const p of parts) {
649
+ if (!Array.isArray(p)) {
650
+ throw new Error('render.ts: line() only accepts FactLine values (from text``/data()/fact``/line()) — not raw strings');
651
+ }
652
+ assertFactLine(p, 'line() argument');
653
+ out.push(...p);
654
+ }
655
+ return out;
656
+ }
657
+
658
+ function joinFactLines(lines: readonly FactLine[], sep: string): FactLine {
659
+ const out: FactNode[] = [];
660
+ lines.forEach((l, i) => {
661
+ assertFactLine(l, 'joined FactLine');
662
+ if (i > 0) out.push(mkTextNode(sep));
663
+ out.push(...l);
664
+ });
665
+ return out;
666
+ }
667
+
668
+ /** Join multiple FactLines with a single newline (no blank line) — for
669
+ * preformatted, row-oriented blocks like a motion timeline/response where
670
+ * consecutive rows (e.g. `t=0.000s ...`, `t=3.81s ...`) must NOT be
671
+ * blank-line separated. Assign the result to one `sections` entry (or to
672
+ * `summary`) so `assembleBody`'s blank-line splitting between sections
673
+ * treats the whole block as a single paragraph, matching the design
674
+ * samples' exact layout. Still fully safe — it only concatenates FactLines
675
+ * that were themselves built via `text`/`data`/`fact`. */
676
+ export function lineList(lines: readonly FactLine[]): FactLine {
677
+ return joinFactLines(lines, '\n');
678
+ }
679
+
680
+ type FactValue = string | number | Capped;
681
+
682
+ /** Tagged-template builder for a FactLine. The static template segments
683
+ * (`strings`) are trusted, developer-authored prose and are emitted as-is;
684
+ * every interpolated `${value}` is treated as untrusted data and is escaped
685
+ * + length-capped exactly like `data()`. This is the primary way leaf
686
+ * commands compose readable measurement prose without hand-rolling escaping.
687
+ *
688
+ * fact`\`${selector}\` right edge at x=${rect.x}, ${overflowPx}px past the viewport edge`
689
+ */
690
+ export function fact(strings: TemplateStringsArray, ...values: FactValue[]): FactLine {
691
+ if (!Array.isArray(strings) || !('raw' in strings)) {
692
+ throw new Error('render.ts: fact`` must be invoked as a tagged template literal — not called with a runtime string');
693
+ }
694
+ const out: FactNode[] = [];
695
+ strings.forEach((s, i) => {
696
+ if (s) out.push(mkTextNode(s));
697
+ if (i < values.length) out.push(...data(values[i]));
698
+ });
699
+ return out;
700
+ }
701
+
702
+ function resolveDataNode(node: DataNode, mode: 'xml' | 'json'): string {
703
+ if (typeof node.value === 'number') return sanitizeNumber(node.value);
704
+ const cleaned = neutralizeControl(String(node.value));
705
+ const capped = capLength(cleaned, node.maxLength ?? DEFAULT_DATA_MAX);
706
+ return mode === 'xml' ? escapeXmlText(capped) : capped;
707
+ }
708
+
709
+ function resolveFactLine(fl: FactLine, mode: 'xml' | 'json'): string {
710
+ assertFactLine(fl, 'rendered FactLine');
711
+ let out = '';
712
+ for (const node of fl) {
713
+ out += node.kind === 'text' ? node.value : resolveDataNode(node, mode);
714
+ }
715
+ return out;
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // Attestation — snapshot/recording identity carried by (almost) every result.
720
+ // ---------------------------------------------------------------------------
721
+
722
+ export interface Attestation {
723
+ readonly kind: 'snapshot' | 'recording';
724
+ /** Snapshot or recording id, e.g. "snap-a3f2" / "rec-9f31". */
725
+ readonly id: string;
726
+ /** Absolute artifact directory path. */
727
+ readonly path: string;
728
+ /** Optional method/settledness/timing note rendered as the first body
729
+ * line (e.g. "Settled after 410ms …", "Clock baselines in markers.json …"). */
730
+ readonly note?: FactLine;
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // RenderableResult — the envelope every measure/motion leaf builds and
735
+ // hands to renderResult()/emitResult().
736
+ // ---------------------------------------------------------------------------
737
+
738
+ export type ResultAttrs = Record<string, string | number | boolean | undefined>;
739
+
740
+ export interface RenderableResult {
741
+ /** Trusted block tag — must be one of RESULT_TAGS. */
742
+ readonly tag: ResultTag;
743
+ /** Opening-tag attributes. Values are sanitized/escaped by this module —
744
+ * leaf commands may pass raw strings here (including page-derived ones).
745
+ * If `attestation` is also set, its `path` and id (`snap`/`rec` by kind)
746
+ * are the CANONICAL values for those keys and are always merged in —
747
+ * leaves need not duplicate them manually. Supplying a matching value
748
+ * here is a harmless no-op; supplying a DIFFERENT value for one of these
749
+ * keys throws (attestation identity cannot be overridden or forged). */
750
+ readonly attrs?: ResultAttrs;
751
+ /** Snapshot/recording identity + optional method note. */
752
+ readonly attestation?: Attestation;
753
+ /** Lead sentence(s), rendered immediately after the opening tag. */
754
+ readonly summary?: FactLine;
755
+ /** Pre-formatted artifact listing (typically via formatArtifactList),
756
+ * rendered directly under the summary with no blank line. */
757
+ readonly artifacts?: FactLine;
758
+ /** Additional body paragraphs/blocks, each separated by a blank line. A
759
+ * single FactLine may itself contain internal `\n` (a static `text`
760
+ * literal, or dynamic rows joined via `lineList()`) to represent a
761
+ * multi-line paragraph/preformatted block that should NOT be blank-line
762
+ * split — e.g. a motion timeline's consecutive `t=...` rows. */
763
+ readonly sections?: readonly FactLine[];
764
+ /** A single follow_up line, built only from trusted command templates via
765
+ * `fact`/`text`/`data` — ids/paths are always embedded as escaped data,
766
+ * never as literal instruction text. */
767
+ readonly followUp?: FactLine;
768
+ }
769
+
770
+ function renderAttrValue(raw: string | number | boolean): string {
771
+ if (typeof raw === 'number') return sanitizeNumber(raw);
772
+ if (typeof raw === 'boolean') return String(raw);
773
+ const cleaned = neutralizeControl(raw);
774
+ return escapeXmlAttr(capLength(cleaned, DEFAULT_ATTR_MAX));
775
+ }
776
+
777
+ function jsonAttrValue(raw: string | number | boolean): string | number | boolean {
778
+ if (typeof raw !== 'string') return raw;
779
+ return capLength(neutralizeControl(raw), DEFAULT_ATTR_MAX);
780
+ }
781
+
782
+ /** Shared attribute-key validation — enforced identically by the prose
783
+ * (`renderAttrs`) and --json (`toJsonResult`) paths, so an attribute key
784
+ * the prose renderer would refuse can never sneak through as JSON only. */
785
+ function assertValidAttrKey(key: string): void {
786
+ if (!ATTR_KEY_PATTERN.test(key)) {
787
+ throw new Error(`render.ts: invalid attribute key ${JSON.stringify(key)}`);
788
+ }
789
+ }
790
+
791
+ function renderAttrs(attrs: ResultAttrs): string {
792
+ const parts: string[] = [];
793
+ for (const [key, raw] of Object.entries(attrs)) {
794
+ if (raw === undefined) continue;
795
+ assertValidAttrKey(key);
796
+ parts.push(` ${key}="${renderAttrValue(raw)}"`);
797
+ }
798
+ return parts.join('');
799
+ }
800
+
801
+ /** Merge attestation identity into the opening-tag attrs, matching the
802
+ * design samples' shape (e.g. `<snapshot path="..." ...>`, `<focus-map
803
+ * snap="snap-a3f2" ...>`, `<jank rec="rec-9f31" ...>`). Attestation is the
804
+ * CANONICAL source for the identity keys (`path`, and `snap`/`rec` by
805
+ * kind) — it always wins, so prose and JSON can never disagree at the
806
+ * provenance boundary. A leaf need not duplicate `attestation.path`/`.id`
807
+ * into `attrs` at all; if it does anyway, an identical duplicate is
808
+ * accepted silently (no-op), but a DIFFERENT value is a REJECTED conflict
809
+ * (throws) rather than a silent override — a leaf that could silently
810
+ * suppress the real identity in prose while JSON kept the true one is
811
+ * exactly the forgeable-identity bug this closes, so a mismatch here is
812
+ * treated as a real bug that must surface loudly. Applied identically by
813
+ * the prose and --json paths. */
814
+ function mergeAttestationAttrs(attrs: ResultAttrs, attestation?: Attestation): ResultAttrs {
815
+ if (!attestation) return attrs;
816
+ const idKey = attestation.kind === 'snapshot' ? 'snap' : 'rec';
817
+ const canonical: ResultAttrs = { path: attestation.path, [idKey]: attestation.id };
818
+
819
+ for (const [key, canonicalValue] of Object.entries(canonical)) {
820
+ const existing = attrs[key];
821
+ if (existing !== undefined && existing !== canonicalValue) {
822
+ throw new Error(
823
+ `render.ts: attrs.${key}=${JSON.stringify(existing)} conflicts with the attestation's canonical ${key}=${JSON.stringify(canonicalValue)} — attestation identity is canonical and a leaf must not supply a different value for ${key} (an identical duplicate is fine; omit it entirely and let attestation supply it)`,
824
+ );
825
+ }
826
+ }
827
+
828
+ // Canonical identity keys (path, then snap/rec) come first, matching the
829
+ // design samples' attribute order (e.g. `<focus-map snap="..." stops=...>`);
830
+ // remaining leaf-supplied attrs follow in their original order.
831
+ const merged: ResultAttrs = { ...canonical };
832
+ for (const [key, value] of Object.entries(attrs)) {
833
+ if (key in canonical) continue;
834
+ merged[key] = value;
835
+ }
836
+ return merged;
837
+ }
838
+
839
+ function assembleBody(result: RenderableResult): string {
840
+ const header: string[] = [];
841
+ if (result.attestation?.note) header.push(resolveFactLine(result.attestation.note, 'xml'));
842
+ if (result.summary) header.push(resolveFactLine(result.summary, 'xml'));
843
+ if (result.artifacts) header.push(resolveFactLine(result.artifacts, 'xml'));
844
+
845
+ const sections = (result.sections ?? [])
846
+ .map((s) => resolveFactLine(s, 'xml'))
847
+ .filter((s) => s.length > 0);
848
+
849
+ const blocks = [header.join('\n'), sections.join('\n\n')].filter((b) => b.length > 0);
850
+ return blocks.join('\n\n');
851
+ }
852
+
853
+ /** Render the default (non-JSON) XML-ish prose block for a result. Pure
854
+ * function — never writes to stdout. */
855
+ export function renderResult(result: RenderableResult): string {
856
+ assertTrustedTag(result.tag);
857
+ const attrsStr = renderAttrs(mergeAttestationAttrs(result.attrs ?? {}, result.attestation));
858
+ const body = assembleBody(result);
859
+ const block = `<${result.tag}${attrsStr}>\n${body}\n</${result.tag}>`;
860
+ if (!result.followUp) return block;
861
+ return `${block}\nfollow_up: ${resolveFactLine(result.followUp, 'xml')}`;
862
+ }
863
+
864
+ /** Build the plain-object JSON mirror of a result, applying the same
865
+ * length-cap / control-char redaction as the prose renderer (minus XML
866
+ * entity escaping, which JSON.stringify already makes unnecessary). */
867
+ export function toJsonResult(result: RenderableResult): Record<string, unknown> {
868
+ assertTrustedTag(result.tag);
869
+ const mergedAttrs = mergeAttestationAttrs(result.attrs ?? {}, result.attestation);
870
+ const attrs = Object.fromEntries(
871
+ Object.entries(mergedAttrs)
872
+ .filter(([, v]) => v !== undefined)
873
+ .map(([k, v]) => {
874
+ assertValidAttrKey(k);
875
+ return [k, jsonAttrValue(v as string | number | boolean)];
876
+ }),
877
+ );
878
+
879
+ const out: Record<string, unknown> = { tag: result.tag, attrs };
880
+
881
+ if (result.attestation) {
882
+ out.attestation = {
883
+ kind: result.attestation.kind,
884
+ id: capLength(neutralizeControl(result.attestation.id), DEFAULT_ATTR_MAX),
885
+ path: capLength(neutralizeControl(result.attestation.path), 400),
886
+ note: result.attestation.note ? resolveFactLine(result.attestation.note, 'json') : undefined,
887
+ };
888
+ }
889
+ if (result.summary) out.summary = resolveFactLine(result.summary, 'json');
890
+ if (result.artifacts) out.artifacts = resolveFactLine(result.artifacts, 'json');
891
+ if (result.sections?.length) out.sections = result.sections.map((s) => resolveFactLine(s, 'json'));
892
+ if (result.followUp) out.followUp = resolveFactLine(result.followUp, 'json');
893
+
894
+ return out;
895
+ }
896
+
897
+ /** Render + write a result to stdout: prose by default, pretty JSON mirror
898
+ * under `--json`, both under the same redaction policy. Returns the emitted
899
+ * string (for composability/tests) as well as writing it. */
900
+ export function emitResult(result: RenderableResult, opts: { json?: boolean } = {}): string {
901
+ const output = opts.json ? JSON.stringify(toJsonResult(result), null, 2) : renderResult(result);
902
+ process.stdout.write(`${output}\n`);
903
+ return output;
904
+ }
905
+
906
+ // ---------------------------------------------------------------------------
907
+ // Shared formatting helpers
908
+ // ---------------------------------------------------------------------------
909
+
910
+ export interface CoordinateInput {
911
+ readonly x: number;
912
+ readonly y: number;
913
+ readonly w?: number;
914
+ readonly h?: number;
915
+ }
916
+
917
+ /** "x=360 y=712 w=44 h=44" (w/h omitted when not given, e.g. a bare point). */
918
+ export function formatCoordinate(c: CoordinateInput): FactLine {
919
+ const parts: FactLine[] = [line(text`x=`, data(c.x)), line(text`y=`, data(c.y))];
920
+ if (c.w !== undefined) parts.push(line(text`w=`, data(c.w)));
921
+ if (c.h !== undefined) parts.push(line(text`h=`, data(c.h)));
922
+ return joinFactLines(parts, ' ');
923
+ }
924
+
925
+ export interface ArtifactEntry {
926
+ /** File/dir name or id-relative path, e.g. "geometry.json". */
927
+ readonly name: string;
928
+ /** Optional trailing description, e.g. "214 elements: rect + quads". */
929
+ readonly note?: string;
930
+ }
931
+
932
+ /** "Artifacts: geometry.json (214 elements: …), styles.json (…), …" */
933
+ export function formatArtifactList(entries: readonly ArtifactEntry[]): FactLine {
934
+ if (entries.length === 0) return text`Artifacts: (none)`;
935
+ const items = entries.map((e) =>
936
+ e.note
937
+ ? line(data(e.name, 200), text` (`, data(e.note, 400), text`)`)
938
+ : line(data(e.name, 200)),
939
+ );
940
+ return line(text`Artifacts: `, joinFactLines(items, ', '));
941
+ }
942
+
943
+ export interface ProvenanceInput {
944
+ /** The selector/name identifying the element or property owner. */
945
+ readonly selector: string;
946
+ /** file:line or an equivalent source reference, when available. */
947
+ readonly source?: string;
948
+ /** CSS specificity or an equivalent ranking string, e.g. "0-2-0". */
949
+ readonly specificity?: string;
950
+ /** Extra trailing fact prose (already a FactLine — e.g. built via `fact`). */
951
+ readonly extra?: FactLine;
952
+ }
953
+
954
+ /** "winning declaration for `<selector>` is `<source>` (specificity <n>)" */
955
+ export function formatProvenance(p: ProvenanceInput): FactLine {
956
+ const parts: FactLine[] = [
957
+ line(text`winning declaration for \``, data(p.selector, 300), text`\``),
958
+ ];
959
+ if (p.source) parts.push(line(text`is \``, data(p.source, 300), text`\``));
960
+ if (p.specificity) parts.push(line(text`specificity `, data(p.specificity, 40)));
961
+ let result = joinFactLines(parts, ' ');
962
+ if (p.extra) result = joinFactLines([result, p.extra], ' — ');
963
+ return result;
964
+ }
965
+
966
+ export interface FindingInput {
967
+ /** Short category label from the leaf's own fixed vocabulary, e.g.
968
+ * "offscreen", "overlap", "truncation". Still treated as untrusted data. */
969
+ readonly kind: string;
970
+ /** The finding's headline fact, e.g. built via `fact`. */
971
+ readonly headline: FactLine;
972
+ /** Additional indented detail lines. */
973
+ readonly detail?: readonly FactLine[];
974
+ /** Optional crop/finding artifact path, rendered as "crop: <path>". */
975
+ readonly artifactPath?: string;
976
+ }
977
+
978
+ /** Numbered findings list, one blank-line-separated FactLine per finding —
979
+ * assign the result directly to (or spread into) `sections`. */
980
+ export function formatFindings(findings: readonly FindingInput[]): FactLine[] {
981
+ return findings.map((f, i) => {
982
+ const headlineLine = line(fact`${i + 1}. `, data(f.kind, 60), text` — `, f.headline);
983
+ const detailLines = (f.detail ?? []).map((d) => line(text` `, d));
984
+ const cropLine = f.artifactPath ? [line(text` crop: `, data(f.artifactPath, 400))] : [];
985
+ return joinFactLines([headlineLine, ...detailLines, ...cropLine], '\n');
986
+ });
987
+ }