@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.
- package/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1894 -0
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +302 -34
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -77
- 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 `<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 `<`) 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 > 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 `<` — is escaped to `>`, so hostile, page-derived markup can
|
|
474
|
+
* never render as a live tag or leave a half-escaped `<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 `]]>` 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 += '&';
|
|
490
|
+
else if (c === '<') out += '<';
|
|
491
|
+
else if (c === '>') out += marks.has(i) ? '>' : '>';
|
|
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, '"');
|
|
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
|
+
}
|