@crouton-kit/capture 1.3.6 → 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.
- package/bin/capture +3269 -1281
- package/package.json +4 -1
- package/src/capture.ts +30 -0
- package/src/cdp/args.ts +113 -10
- package/src/cdp/bridge/client.ts +49 -0
- package/src/cdp/bridge/protocol.ts +137 -0
- package/src/cdp/bridge/server.ts +241 -0
- package/src/cdp/bridge/spawn.ts +127 -0
- package/src/cdp/client.ts +7 -1
- package/src/cdp/commands/bridge-serve.ts +43 -0
- package/src/cdp/commands/cdp.ts +203 -0
- package/src/cdp/commands/measure/census.ts +31 -0
- package/src/cdp/commands/measure/check.ts +35 -0
- package/src/cdp/commands/measure/diff.ts +32 -0
- package/src/cdp/commands/measure/explain.ts +31 -0
- package/src/cdp/commands/measure/index.ts +114 -0
- package/src/cdp/commands/measure/map-focus.ts +24 -0
- package/src/cdp/commands/measure/map-layers.ts +25 -0
- package/src/cdp/commands/measure/map-scroll.ts +24 -0
- package/src/cdp/commands/measure/snap.ts +36 -0
- package/src/cdp/commands/measure/sweep.ts +30 -0
- package/src/cdp/commands/motion/index.ts +60 -0
- package/src/cdp/commands/motion/jank.ts +22 -0
- package/src/cdp/commands/motion/mask.ts +23 -0
- package/src/cdp/commands/motion/rec.ts +33 -0
- package/src/cdp/commands/motion/response.ts +26 -0
- package/src/cdp/commands/motion/timeline.ts +27 -0
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/dispatch.ts +8 -0
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/recorder-bridge.ts +521 -0
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +8 -1
- package/src/cdp/timing.ts +82 -0
- package/src/cdp/types.ts +71 -0
- package/src/cdp/usage.ts +9 -0
- package/src/output/artifact.ts +520 -0
- package/src/output/diff.ts +111 -0
- package/src/output/render.ts +593 -0
- package/src/output/selector.ts +185 -0
- package/src/session/artifacts.ts +229 -0
- package/src/session/commands.ts +183 -20
- package/src/session-context.ts +50 -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '"');
|
|
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
|
+
}
|