@crouton-kit/capture 1.3.7 → 1.3.8

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 (40) hide show
  1. package/bin/capture +2807 -1312
  2. package/package.json +4 -1
  3. package/src/capture.ts +10 -0
  4. package/src/cdp/args.ts +99 -10
  5. package/src/cdp/bridge/protocol.ts +99 -0
  6. package/src/cdp/bridge/server.ts +105 -43
  7. package/src/cdp/bridge/spawn.ts +62 -0
  8. package/src/cdp/commands/bridge-serve.ts +28 -3
  9. package/src/cdp/commands/cdp.ts +30 -2
  10. package/src/cdp/commands/measure/census.ts +31 -0
  11. package/src/cdp/commands/measure/check.ts +35 -0
  12. package/src/cdp/commands/measure/diff.ts +32 -0
  13. package/src/cdp/commands/measure/explain.ts +31 -0
  14. package/src/cdp/commands/measure/index.ts +114 -0
  15. package/src/cdp/commands/measure/map-focus.ts +24 -0
  16. package/src/cdp/commands/measure/map-layers.ts +25 -0
  17. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  18. package/src/cdp/commands/measure/snap.ts +36 -0
  19. package/src/cdp/commands/measure/sweep.ts +30 -0
  20. package/src/cdp/commands/motion/index.ts +60 -0
  21. package/src/cdp/commands/motion/jank.ts +22 -0
  22. package/src/cdp/commands/motion/mask.ts +23 -0
  23. package/src/cdp/commands/motion/rec.ts +33 -0
  24. package/src/cdp/commands/motion/response.ts +26 -0
  25. package/src/cdp/commands/motion/timeline.ts +27 -0
  26. package/src/cdp/coordinates.ts +158 -0
  27. package/src/cdp/dispatch.ts +4 -0
  28. package/src/cdp/domains.ts +75 -0
  29. package/src/cdp/recorder-bridge.ts +521 -0
  30. package/src/cdp/source-map.ts +309 -0
  31. package/src/cdp/timing.ts +82 -0
  32. package/src/cdp/types.ts +66 -0
  33. package/src/cdp/usage.ts +2 -0
  34. package/src/output/artifact.ts +520 -0
  35. package/src/output/diff.ts +111 -0
  36. package/src/output/render.ts +593 -0
  37. package/src/output/selector.ts +185 -0
  38. package/src/session/artifacts.ts +229 -0
  39. package/src/session/commands.ts +119 -15
  40. package/src/session-context.ts +48 -8
@@ -0,0 +1,593 @@
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
+ ] as const;
44
+
45
+ export type ResultTag = (typeof RESULT_TAGS)[number];
46
+
47
+ const RESULT_TAG_SET: ReadonlySet<string> = new Set(RESULT_TAGS);
48
+
49
+ function assertTrustedTag(tag: string): asserts tag is ResultTag {
50
+ if (!RESULT_TAG_SET.has(tag)) {
51
+ throw new Error(
52
+ `render.ts: refusing to render untrusted tag ${JSON.stringify(tag)} — tag must be one of ${RESULT_TAGS.join(', ')}`,
53
+ );
54
+ }
55
+ }
56
+
57
+ const ATTR_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9-]*$/;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Sanitization primitives
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const DEFAULT_DATA_MAX = 200;
64
+ const DEFAULT_ATTR_MAX = 200;
65
+
66
+ /** Strip control characters and collapse newlines/tabs so an untrusted value
67
+ * can never fake a new output line (e.g. a forged `follow_up:` line), smuggle
68
+ * a raw ANSI/terminal escape, or embed invisible content. This strips:
69
+ * - the FULL control range — C0 (\u0000-\u001F) minus \r\n\t (normalized to
70
+ * a space below), DEL (\u007F), and C1 (\u0080-\u009F, which includes
71
+ * \u009B CSI and \u009D OSC — terminals interpret these as ANSI *without*
72
+ * a leading ESC byte, so stripping only C0/DEL is not sufficient);
73
+ * - \u2028/\u2029 (Unicode LINE SEPARATOR / PARAGRAPH SEPARATOR), which act
74
+ * as line breaks to many downstream readers despite not being C0/C1 —
75
+ * normalized to a space alongside \r\n\t rather than merely stripped, so
76
+ * word-adjacency is preserved the same way a real line break would be;
77
+ * - every Unicode *format* control character via the `\p{Cf}` Unicode
78
+ * property escape (requires the regex `u` flag) — this is a general,
79
+ * class-level rule rather than an enumerated codepoint list, so it
80
+ * covers every invisible/bidi-influencing format control in one pass:
81
+ * zero-width space/joiners, bidi embedding/override/isolate controls,
82
+ * U+061C ARABIC LETTER MARK, U+00AD SOFT HYPHEN, U+FEFF BOM/ZWNBSP, and
83
+ * any other current or future codepoint Unicode assigns category Cf.
84
+ * No untrusted value can hide non-printing content or fake a line break. */
85
+ function neutralizeControl(s: string): string {
86
+ return s
87
+ .replace(/[\r\n\t\u2028\u2029]+/g, ' ')
88
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
89
+ .replace(/\p{Cf}/gu, '')
90
+ .replace(/`/g, "'");
91
+ }
92
+
93
+ function capLength(s: string, max: number): string {
94
+ if (s.length <= max) return s;
95
+ return `${s.slice(0, max)}…[+${s.length - max} chars]`;
96
+ }
97
+
98
+ /** Escape for use inside XML-ish element text content. */
99
+ function escapeXmlText(s: string): string {
100
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
101
+ }
102
+
103
+ /** Escape for use inside a double-quoted XML-ish attribute value. */
104
+ function escapeXmlAttr(s: string): string {
105
+ return escapeXmlText(s).replace(/"/g, '&quot;');
106
+ }
107
+
108
+ function sanitizeNumber(n: number): string {
109
+ return Number.isFinite(n) ? String(n) : '0';
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // FactLine — the only shape a leaf command may hand render.ts as body/attr
114
+ // content. Built exclusively via `text`, `data`, `fact`, and `line` below.
115
+ // `text` nodes are developer-authored, STATIC source-code string literals —
116
+ // `text` is a no-substitution tagged template (`` text`Artifacts: ` ``), not
117
+ // an ordinary function, so there is no verbatim-dynamic-string call shape a
118
+ // leaf author could pass an untrusted runtime value through by mistake.
119
+ // `data` nodes are untrusted values and are always escaped + length-capped
120
+ // at render time, in both prose and --json output. `line()` only accepts
121
+ // already-safe FactLine values (never a raw string) for the same reason.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** Unexported brand symbol. `TextNode`/`DataNode` are not exported — only
125
+ * the opaque `FactLine` element type is — and every node this module's own
126
+ * constructors produce carries this symbol key. Because the symbol value
127
+ * itself is never exported, no code outside this module can name it, so no
128
+ * outside object literal can carry this property — not even one that
129
+ * otherwise matches `{kind:'text', value: string}` structurally. This is
130
+ * what makes a hand-constructed `{kind:'text', value: untrusted}` object
131
+ * REJECTED rather than merely "not the intended call shape": TypeScript's
132
+ * structural typing can't be fooled into accepting it (the required brand
133
+ * property is missing), and even a caller who bypasses the type system
134
+ * entirely (`as any`/`as FactLine`) still fails the runtime brand check
135
+ * every composition and emission path below performs before trusting a
136
+ * node. There is no way to construct a validly-branded node without going
137
+ * through `text()`/`data()`/`fact()`/`line()`/`lineList()`. */
138
+ const NODE_BRAND: unique symbol = Symbol('render.ts:factNode');
139
+
140
+ interface TextNode {
141
+ readonly kind: 'text';
142
+ readonly value: string;
143
+ readonly [NODE_BRAND]: true;
144
+ }
145
+
146
+ interface DataNode {
147
+ readonly kind: 'data';
148
+ readonly value: string | number;
149
+ readonly maxLength?: number;
150
+ readonly [NODE_BRAND]: true;
151
+ }
152
+
153
+ type FactNode = TextNode | DataNode;
154
+
155
+ function mkTextNode(value: string): TextNode {
156
+ return { kind: 'text', value, [NODE_BRAND]: true };
157
+ }
158
+
159
+ function mkDataNode(value: string | number, maxLength?: number): DataNode {
160
+ return { kind: 'data', value, maxLength, [NODE_BRAND]: true };
161
+ }
162
+
163
+ function isFactNode(v: unknown): v is FactNode {
164
+ if (typeof v !== 'object' || v === null) return false;
165
+ const n = v as Record<string | symbol, unknown>;
166
+ if (n[NODE_BRAND] !== true) return false;
167
+ if (n.kind === 'text') return typeof n.value === 'string';
168
+ if (n.kind === 'data') return typeof n.value === 'string' || typeof n.value === 'number';
169
+ return false;
170
+ }
171
+
172
+ /** Runtime backstop for every place a leaf command hands render.ts a
173
+ * FactLine — `summary`/`sections`/`followUp`/`attestation.note`, every
174
+ * `line()`/`joinFactLines()` composition, and `lineList()`'s rows. A leaf
175
+ * author (or any caller that bypasses the type system with `as`/`any`) can
176
+ * still hand-construct a plain array of plain objects matching the
177
+ * structural shape of a `FactLine`; this rejects any such value — and any
178
+ * individual node within an otherwise-real `FactLine` that isn't branded —
179
+ * by throwing, so an untrusted value can never reach output verbatim
180
+ * through a structural bypass, regardless of which entry point it targets. */
181
+ function assertFactLine(fl: unknown, context: string): asserts fl is FactLine {
182
+ if (!Array.isArray(fl)) {
183
+ throw new Error(
184
+ `render.ts: ${context} must be a FactLine built via text\`\`/data()/fact\`\`/line()/lineList() — not a raw value`,
185
+ );
186
+ }
187
+ for (const node of fl) {
188
+ if (!isFactNode(node)) {
189
+ throw new Error(
190
+ `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`,
191
+ );
192
+ }
193
+ }
194
+ }
195
+
196
+ /** A safe, already-composed line/paragraph of output. The only way to obtain
197
+ * one is `text()`, `data()`, `fact` tag, or `line()` — never a raw string,
198
+ * and never a hand-built object literal (see `assertFactLine` above). */
199
+ export type FactLine = readonly FactNode[];
200
+
201
+ /** Marks a value for embedding with a custom (non-default) length cap. Use
202
+ * for long DOM/text/CSS-source values that need a bigger quoted-data cap. */
203
+ export interface Capped {
204
+ readonly __capped: true;
205
+ readonly value: string | number;
206
+ readonly maxLength: number;
207
+ }
208
+
209
+ export function capped(value: string | number, maxLength: number): Capped {
210
+ return { __capped: true, value, maxLength };
211
+ }
212
+
213
+ function isCapped(v: unknown): v is Capped {
214
+ return typeof v === 'object' && v !== null && (v as Capped).__capped === true;
215
+ }
216
+
217
+ /** Static, developer-authored text — emitted verbatim (never treated as
218
+ * page/DOM-derived). Safe by construction: `text` is a no-substitution
219
+ * tagged template literal (`` text`Artifacts: ` ``), not an ordinary
220
+ * function, so a leaf author cannot pass a runtime/page-derived string
221
+ * through it even by mistake — the call shape that would allow it doesn't
222
+ * type-check, and is also rejected at runtime (see below) for callers that
223
+ * bypass the type system. Never pass page content here; use `data()` (or
224
+ * `fact` for prose with untrusted interpolation) instead. */
225
+ export function text(strings: TemplateStringsArray): FactLine {
226
+ if (!Array.isArray(strings) || !('raw' in strings)) {
227
+ throw new Error(
228
+ 'render.ts: text`` must be invoked as a tagged template literal, e.g. text`Artifacts: ` — not called with a runtime string',
229
+ );
230
+ }
231
+ if (strings.length !== 1) {
232
+ throw new Error('render.ts: text`` does not accept interpolation — use fact`` or data() for dynamic values');
233
+ }
234
+ return [mkTextNode(strings[0])];
235
+ }
236
+
237
+ /** An untrusted value (selector, DOM/AX name, CSS source URL, action label,
238
+ * text content, id, path, …). Always escaped and length-capped before it
239
+ * reaches either the rendered prose or the --json output. */
240
+ export function data(value: string | number | Capped, maxLength?: number): FactLine {
241
+ if (isCapped(value)) {
242
+ return [mkDataNode(value.value, value.maxLength)];
243
+ }
244
+ return [mkDataNode(value, maxLength)];
245
+ }
246
+
247
+ /** Concatenate already-safe FactLines into one FactLine. Only accepts
248
+ * FactLine values built via `text`/`data`/`fact`/`line` itself — never a raw
249
+ * string — so there is no call shape through which an unescaped runtime
250
+ * value reaches output. A caller that bypasses the type system and passes a
251
+ * raw string anyway is rejected at runtime rather than silently accepted. */
252
+ export function line(...parts: readonly FactLine[]): FactLine {
253
+ const out: FactNode[] = [];
254
+ for (const p of parts) {
255
+ if (!Array.isArray(p)) {
256
+ throw new Error('render.ts: line() only accepts FactLine values (from text``/data()/fact``/line()) — not raw strings');
257
+ }
258
+ assertFactLine(p, 'line() argument');
259
+ out.push(...p);
260
+ }
261
+ return out;
262
+ }
263
+
264
+ function joinFactLines(lines: readonly FactLine[], sep: string): FactLine {
265
+ const out: FactNode[] = [];
266
+ lines.forEach((l, i) => {
267
+ assertFactLine(l, 'joined FactLine');
268
+ if (i > 0) out.push(mkTextNode(sep));
269
+ out.push(...l);
270
+ });
271
+ return out;
272
+ }
273
+
274
+ /** Join multiple FactLines with a single newline (no blank line) — for
275
+ * preformatted, row-oriented blocks like a motion timeline/response where
276
+ * consecutive rows (e.g. `t=0.000s ...`, `t=3.81s ...`) must NOT be
277
+ * blank-line separated. Assign the result to one `sections` entry (or to
278
+ * `summary`) so `assembleBody`'s blank-line splitting between sections
279
+ * treats the whole block as a single paragraph, matching the design
280
+ * samples' exact layout. Still fully safe — it only concatenates FactLines
281
+ * that were themselves built via `text`/`data`/`fact`. */
282
+ export function lineList(lines: readonly FactLine[]): FactLine {
283
+ return joinFactLines(lines, '\n');
284
+ }
285
+
286
+ type FactValue = string | number | Capped;
287
+
288
+ /** Tagged-template builder for a FactLine. The static template segments
289
+ * (`strings`) are trusted, developer-authored prose and are emitted as-is;
290
+ * every interpolated `${value}` is treated as untrusted data and is escaped
291
+ * + length-capped exactly like `data()`. This is the primary way leaf
292
+ * commands compose readable measurement prose without hand-rolling escaping.
293
+ *
294
+ * fact`\`${selector}\` right edge at x=${rect.x}, ${overflowPx}px past the viewport edge`
295
+ */
296
+ export function fact(strings: TemplateStringsArray, ...values: FactValue[]): FactLine {
297
+ if (!Array.isArray(strings) || !('raw' in strings)) {
298
+ throw new Error('render.ts: fact`` must be invoked as a tagged template literal — not called with a runtime string');
299
+ }
300
+ const out: FactNode[] = [];
301
+ strings.forEach((s, i) => {
302
+ if (s) out.push(mkTextNode(s));
303
+ if (i < values.length) out.push(...data(values[i]));
304
+ });
305
+ return out;
306
+ }
307
+
308
+ function resolveDataNode(node: DataNode, mode: 'xml' | 'json'): string {
309
+ if (typeof node.value === 'number') return sanitizeNumber(node.value);
310
+ const cleaned = neutralizeControl(String(node.value));
311
+ const capped = capLength(cleaned, node.maxLength ?? DEFAULT_DATA_MAX);
312
+ return mode === 'xml' ? escapeXmlText(capped) : capped;
313
+ }
314
+
315
+ function resolveFactLine(fl: FactLine, mode: 'xml' | 'json'): string {
316
+ assertFactLine(fl, 'rendered FactLine');
317
+ let out = '';
318
+ for (const node of fl) {
319
+ out += node.kind === 'text' ? node.value : resolveDataNode(node, mode);
320
+ }
321
+ return out;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Attestation — snapshot/recording identity carried by (almost) every result.
326
+ // ---------------------------------------------------------------------------
327
+
328
+ export interface Attestation {
329
+ readonly kind: 'snapshot' | 'recording';
330
+ /** Snapshot or recording id, e.g. "snap-a3f2" / "rec-9f31". */
331
+ readonly id: string;
332
+ /** Absolute artifact directory path. */
333
+ readonly path: string;
334
+ /** Optional method/settledness/timing note rendered as the first body
335
+ * line (e.g. "Settled after 410ms …", "Clock baselines in markers.json …"). */
336
+ readonly note?: FactLine;
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // RenderableResult — the envelope every measure/motion leaf builds and
341
+ // hands to renderResult()/emitResult().
342
+ // ---------------------------------------------------------------------------
343
+
344
+ export type ResultAttrs = Record<string, string | number | boolean | undefined>;
345
+
346
+ export interface RenderableResult {
347
+ /** Trusted block tag — must be one of RESULT_TAGS. */
348
+ readonly tag: ResultTag;
349
+ /** Opening-tag attributes. Values are sanitized/escaped by this module —
350
+ * leaf commands may pass raw strings here (including page-derived ones).
351
+ * If `attestation` is also set, its `path` and id (`snap`/`rec` by kind)
352
+ * are the CANONICAL values for those keys and are always merged in —
353
+ * leaves need not duplicate them manually. Supplying a matching value
354
+ * here is a harmless no-op; supplying a DIFFERENT value for one of these
355
+ * keys throws (attestation identity cannot be overridden or forged). */
356
+ readonly attrs?: ResultAttrs;
357
+ /** Snapshot/recording identity + optional method note. */
358
+ readonly attestation?: Attestation;
359
+ /** Lead sentence(s), rendered immediately after the opening tag. */
360
+ readonly summary?: FactLine;
361
+ /** Pre-formatted artifact listing (typically via formatArtifactList),
362
+ * rendered directly under the summary with no blank line. */
363
+ readonly artifacts?: FactLine;
364
+ /** Additional body paragraphs/blocks, each separated by a blank line. A
365
+ * single FactLine may itself contain internal `\n` (a static `text`
366
+ * literal, or dynamic rows joined via `lineList()`) to represent a
367
+ * multi-line paragraph/preformatted block that should NOT be blank-line
368
+ * split — e.g. a motion timeline's consecutive `t=...` rows. */
369
+ readonly sections?: readonly FactLine[];
370
+ /** A single follow_up line, built only from trusted command templates via
371
+ * `fact`/`text`/`data` — ids/paths are always embedded as escaped data,
372
+ * never as literal instruction text. */
373
+ readonly followUp?: FactLine;
374
+ }
375
+
376
+ function renderAttrValue(raw: string | number | boolean): string {
377
+ if (typeof raw === 'number') return sanitizeNumber(raw);
378
+ if (typeof raw === 'boolean') return String(raw);
379
+ const cleaned = neutralizeControl(raw);
380
+ return escapeXmlAttr(capLength(cleaned, DEFAULT_ATTR_MAX));
381
+ }
382
+
383
+ function jsonAttrValue(raw: string | number | boolean): string | number | boolean {
384
+ if (typeof raw !== 'string') return raw;
385
+ return capLength(neutralizeControl(raw), DEFAULT_ATTR_MAX);
386
+ }
387
+
388
+ /** Shared attribute-key validation — enforced identically by the prose
389
+ * (`renderAttrs`) and --json (`toJsonResult`) paths, so an attribute key
390
+ * the prose renderer would refuse can never sneak through as JSON only. */
391
+ function assertValidAttrKey(key: string): void {
392
+ if (!ATTR_KEY_PATTERN.test(key)) {
393
+ throw new Error(`render.ts: invalid attribute key ${JSON.stringify(key)}`);
394
+ }
395
+ }
396
+
397
+ function renderAttrs(attrs: ResultAttrs): string {
398
+ const parts: string[] = [];
399
+ for (const [key, raw] of Object.entries(attrs)) {
400
+ if (raw === undefined) continue;
401
+ assertValidAttrKey(key);
402
+ parts.push(` ${key}="${renderAttrValue(raw)}"`);
403
+ }
404
+ return parts.join('');
405
+ }
406
+
407
+ /** Merge attestation identity into the opening-tag attrs, matching the
408
+ * design samples' shape (e.g. `<snapshot path="..." ...>`, `<focus-map
409
+ * snap="snap-a3f2" ...>`, `<jank rec="rec-9f31" ...>`). Attestation is the
410
+ * CANONICAL source for the identity keys (`path`, and `snap`/`rec` by
411
+ * kind) — it always wins, so prose and JSON can never disagree at the
412
+ * provenance boundary. A leaf need not duplicate `attestation.path`/`.id`
413
+ * into `attrs` at all; if it does anyway, an identical duplicate is
414
+ * accepted silently (no-op), but a DIFFERENT value is a REJECTED conflict
415
+ * (throws) rather than a silent override — a leaf that could silently
416
+ * suppress the real identity in prose while JSON kept the true one is
417
+ * exactly the forgeable-identity bug this closes, so a mismatch here is
418
+ * treated as a real bug that must surface loudly. Applied identically by
419
+ * the prose and --json paths. */
420
+ function mergeAttestationAttrs(attrs: ResultAttrs, attestation?: Attestation): ResultAttrs {
421
+ if (!attestation) return attrs;
422
+ const idKey = attestation.kind === 'snapshot' ? 'snap' : 'rec';
423
+ const canonical: ResultAttrs = { path: attestation.path, [idKey]: attestation.id };
424
+
425
+ for (const [key, canonicalValue] of Object.entries(canonical)) {
426
+ const existing = attrs[key];
427
+ if (existing !== undefined && existing !== canonicalValue) {
428
+ throw new Error(
429
+ `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)`,
430
+ );
431
+ }
432
+ }
433
+
434
+ // Canonical identity keys (path, then snap/rec) come first, matching the
435
+ // design samples' attribute order (e.g. `<focus-map snap="..." stops=...>`);
436
+ // remaining leaf-supplied attrs follow in their original order.
437
+ const merged: ResultAttrs = { ...canonical };
438
+ for (const [key, value] of Object.entries(attrs)) {
439
+ if (key in canonical) continue;
440
+ merged[key] = value;
441
+ }
442
+ return merged;
443
+ }
444
+
445
+ function assembleBody(result: RenderableResult): string {
446
+ const header: string[] = [];
447
+ if (result.attestation?.note) header.push(resolveFactLine(result.attestation.note, 'xml'));
448
+ if (result.summary) header.push(resolveFactLine(result.summary, 'xml'));
449
+ if (result.artifacts) header.push(resolveFactLine(result.artifacts, 'xml'));
450
+
451
+ const sections = (result.sections ?? [])
452
+ .map((s) => resolveFactLine(s, 'xml'))
453
+ .filter((s) => s.length > 0);
454
+
455
+ const blocks = [header.join('\n'), sections.join('\n\n')].filter((b) => b.length > 0);
456
+ return blocks.join('\n\n');
457
+ }
458
+
459
+ /** Render the default (non-JSON) XML-ish prose block for a result. Pure
460
+ * function — never writes to stdout. */
461
+ export function renderResult(result: RenderableResult): string {
462
+ assertTrustedTag(result.tag);
463
+ const attrsStr = renderAttrs(mergeAttestationAttrs(result.attrs ?? {}, result.attestation));
464
+ const body = assembleBody(result);
465
+ const block = `<${result.tag}${attrsStr}>\n${body}\n</${result.tag}>`;
466
+ if (!result.followUp) return block;
467
+ return `${block}\nfollow_up: ${resolveFactLine(result.followUp, 'xml')}`;
468
+ }
469
+
470
+ /** Build the plain-object JSON mirror of a result, applying the same
471
+ * length-cap / control-char redaction as the prose renderer (minus XML
472
+ * entity escaping, which JSON.stringify already makes unnecessary). */
473
+ export function toJsonResult(result: RenderableResult): Record<string, unknown> {
474
+ assertTrustedTag(result.tag);
475
+ const mergedAttrs = mergeAttestationAttrs(result.attrs ?? {}, result.attestation);
476
+ const attrs = Object.fromEntries(
477
+ Object.entries(mergedAttrs)
478
+ .filter(([, v]) => v !== undefined)
479
+ .map(([k, v]) => {
480
+ assertValidAttrKey(k);
481
+ return [k, jsonAttrValue(v as string | number | boolean)];
482
+ }),
483
+ );
484
+
485
+ const out: Record<string, unknown> = { tag: result.tag, attrs };
486
+
487
+ if (result.attestation) {
488
+ out.attestation = {
489
+ kind: result.attestation.kind,
490
+ id: capLength(neutralizeControl(result.attestation.id), DEFAULT_ATTR_MAX),
491
+ path: capLength(neutralizeControl(result.attestation.path), 400),
492
+ note: result.attestation.note ? resolveFactLine(result.attestation.note, 'json') : undefined,
493
+ };
494
+ }
495
+ if (result.summary) out.summary = resolveFactLine(result.summary, 'json');
496
+ if (result.artifacts) out.artifacts = resolveFactLine(result.artifacts, 'json');
497
+ if (result.sections?.length) out.sections = result.sections.map((s) => resolveFactLine(s, 'json'));
498
+ if (result.followUp) out.followUp = resolveFactLine(result.followUp, 'json');
499
+
500
+ return out;
501
+ }
502
+
503
+ /** Render + write a result to stdout: prose by default, pretty JSON mirror
504
+ * under `--json`, both under the same redaction policy. Returns the emitted
505
+ * string (for composability/tests) as well as writing it. */
506
+ export function emitResult(result: RenderableResult, opts: { json?: boolean } = {}): string {
507
+ const output = opts.json ? JSON.stringify(toJsonResult(result), null, 2) : renderResult(result);
508
+ process.stdout.write(`${output}\n`);
509
+ return output;
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Shared formatting helpers
514
+ // ---------------------------------------------------------------------------
515
+
516
+ export interface CoordinateInput {
517
+ readonly x: number;
518
+ readonly y: number;
519
+ readonly w?: number;
520
+ readonly h?: number;
521
+ }
522
+
523
+ /** "x=360 y=712 w=44 h=44" (w/h omitted when not given, e.g. a bare point). */
524
+ export function formatCoordinate(c: CoordinateInput): FactLine {
525
+ const parts: FactLine[] = [line(text`x=`, data(c.x)), line(text`y=`, data(c.y))];
526
+ if (c.w !== undefined) parts.push(line(text`w=`, data(c.w)));
527
+ if (c.h !== undefined) parts.push(line(text`h=`, data(c.h)));
528
+ return joinFactLines(parts, ' ');
529
+ }
530
+
531
+ export interface ArtifactEntry {
532
+ /** File/dir name or id-relative path, e.g. "geometry.json". */
533
+ readonly name: string;
534
+ /** Optional trailing description, e.g. "214 elements: rect + quads". */
535
+ readonly note?: string;
536
+ }
537
+
538
+ /** "Artifacts: geometry.json (214 elements: …), styles.json (…), …" */
539
+ export function formatArtifactList(entries: readonly ArtifactEntry[]): FactLine {
540
+ if (entries.length === 0) return text`Artifacts: (none)`;
541
+ const items = entries.map((e) =>
542
+ e.note
543
+ ? line(data(e.name, 200), text` (`, data(e.note, 400), text`)`)
544
+ : line(data(e.name, 200)),
545
+ );
546
+ return line(text`Artifacts: `, joinFactLines(items, ', '));
547
+ }
548
+
549
+ export interface ProvenanceInput {
550
+ /** The selector/name identifying the element or property owner. */
551
+ readonly selector: string;
552
+ /** file:line or an equivalent source reference, when available. */
553
+ readonly source?: string;
554
+ /** CSS specificity or an equivalent ranking string, e.g. "0-2-0". */
555
+ readonly specificity?: string;
556
+ /** Extra trailing fact prose (already a FactLine — e.g. built via `fact`). */
557
+ readonly extra?: FactLine;
558
+ }
559
+
560
+ /** "winning declaration for `<selector>` is `<source>` (specificity <n>)" */
561
+ export function formatProvenance(p: ProvenanceInput): FactLine {
562
+ const parts: FactLine[] = [
563
+ line(text`winning declaration for \``, data(p.selector, 300), text`\``),
564
+ ];
565
+ if (p.source) parts.push(line(text`is \``, data(p.source, 300), text`\``));
566
+ if (p.specificity) parts.push(line(text`specificity `, data(p.specificity, 40)));
567
+ let result = joinFactLines(parts, ' ');
568
+ if (p.extra) result = joinFactLines([result, p.extra], ' — ');
569
+ return result;
570
+ }
571
+
572
+ export interface FindingInput {
573
+ /** Short category label from the leaf's own fixed vocabulary, e.g.
574
+ * "offscreen", "overlap", "truncation". Still treated as untrusted data. */
575
+ readonly kind: string;
576
+ /** The finding's headline fact, e.g. built via `fact`. */
577
+ readonly headline: FactLine;
578
+ /** Additional indented detail lines. */
579
+ readonly detail?: readonly FactLine[];
580
+ /** Optional crop/finding artifact path, rendered as "crop: <path>". */
581
+ readonly artifactPath?: string;
582
+ }
583
+
584
+ /** Numbered findings list, one blank-line-separated FactLine per finding —
585
+ * assign the result directly to (or spread into) `sections`. */
586
+ export function formatFindings(findings: readonly FindingInput[]): FactLine[] {
587
+ return findings.map((f, i) => {
588
+ const headlineLine = line(fact`${i + 1}. `, data(f.kind, 60), text` — `, f.headline);
589
+ const detailLines = (f.detail ?? []).map((d) => line(text` `, d));
590
+ const cropLine = f.artifactPath ? [line(text` crop: `, data(f.artifactPath, 400))] : [];
591
+ return joinFactLines([headlineLine, ...detailLines, ...cropLine], '\n');
592
+ });
593
+ }