scenescout 3.8.0 → 3.10.0

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.
@@ -0,0 +1,487 @@
1
+ /**
2
+ * `scenescout check`: the deterministic sweep, as data.
3
+ *
4
+ * The engine visits every route it knows and hands back what it measured on
5
+ * each one (RouteHealth). Everything here turns those measurements into
6
+ * issues, decides whether they fail the gate, and writes them out as a report,
7
+ * SARIF and a JSON summary. No model is involved at any step, so the same app
8
+ * gives the same result, which is what a pull-request gate needs.
9
+ *
10
+ * It lives apart from browser.ts so every rule is table-tested
11
+ * (scripts/check-test.ts) without launching a browser.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { BROWSER_ENGINES } from "../browsers.js";
15
+ import { redactSecrets } from "./memory.js";
16
+ import { httpErrorDetail, httpStatusOf } from "./oracles.js";
17
+ export const CHECK_SEVERITIES = ["high", "medium", "low"];
18
+ export const FAIL_ON = [...CHECK_SEVERITIES, "never"];
19
+ export const CHECK_RULES = {
20
+ "route-load-failed": { severity: "high", title: "Page did not load", help: "Navigating to the route failed or timed out." },
21
+ "route-server-error": { severity: "high", title: "Page answered with a server error", help: "The document itself returned HTTP 5xx." },
22
+ "route-client-error": { severity: "medium", title: "Page answered with a client error", help: "The document itself returned HTTP 4xx." },
23
+ "page-error": { severity: "high", title: "Uncaught exception", help: "The page raised an error nothing caught." },
24
+ "server-error": { severity: "high", title: "Request failed with a server error", help: "A request the page made returned HTTP 5xx." },
25
+ "client-error": { severity: "medium", title: "Request failed with a client error", help: "A request the page made returned HTTP 4xx." },
26
+ "request-failed": { severity: "medium", title: "Request did not complete", help: "A request the page made failed at the network level." },
27
+ "console-error": {
28
+ severity: "medium",
29
+ title: "Console error",
30
+ help: "The page logged an error. Its cause usually shows as its own request or page error, which is why this one does not fail the default gate.",
31
+ },
32
+ "refused-empty": {
33
+ severity: "high",
34
+ title: "Failed request shown as an empty result",
35
+ help: "A request was refused and the page drew an empty state instead of an error.",
36
+ },
37
+ "false-success": { severity: "high", title: "Success shown for a failed request", help: "The page reported success while the server refused the request." },
38
+ "dom-injection": { severity: "high", title: "Markup rendered as an element", help: "Text the engine typed came back as live markup." },
39
+ "auth-redirect": { severity: "medium", title: "Sent to sign-in", help: "The route redirected to a sign-in page; the session is missing or expired." },
40
+ "dead-end": { severity: "medium", title: "Dead end", help: "The page has no controls at all: no navigation and no way back." },
41
+ "blocking-overlay": {
42
+ severity: "high",
43
+ title: "Page blocked by an overlay",
44
+ help: "A backdrop with no dialog, an empty dialog or a leaked scroll lock leaves the user unable to use the page.",
45
+ },
46
+ "dialog-layout": {
47
+ severity: "medium",
48
+ title: "Dialog badly placed",
49
+ help: "An open dialog is far off-centre or runs below the viewport with no scroll of its own; its lower controls may be out of reach.",
50
+ },
51
+ "layout-issue": {
52
+ severity: "low",
53
+ title: "Layout issue",
54
+ help: "A layout check reported something this version of the check has no specific rule for. It is listed rather than dropped.",
55
+ },
56
+ "covered-control": {
57
+ severity: "medium",
58
+ title: "Control covered by pinned chrome",
59
+ help: "A fixed or sticky element sits on top of the control, so clicks land on it instead.",
60
+ },
61
+ "clipped-control": { severity: "medium", title: "Control unreachable", help: "The control is clipped inside a container that cannot scroll." },
62
+ "offpage-control": { severity: "medium", title: "Control outside the page", help: "The control is laid out where no scrolling can reach it." },
63
+ "overlapping-controls": { severity: "low", title: "Controls overlap", help: "Two controls in the same layer cover most of each other." },
64
+ "broken-image": { severity: "medium", title: "Broken image", help: "The browser could not render the image." },
65
+ "unnamed-control": { severity: "medium", title: "Control with no accessible name", help: "Assistive technology announces the control without a name." },
66
+ "placeholder-only-label": {
67
+ severity: "medium",
68
+ title: "Field labelled only by its placeholder",
69
+ help: "The field has no label, aria-label, aria-labelledby or title. Its placeholder is not a label: it disappears as soon as the user types, and some assistive technology does not announce it.",
70
+ },
71
+ contrast: { severity: "low", title: "Text contrast below WCAG", help: "Text needs 4.5:1 (3:1 when large) against its background." },
72
+ "focus-indicator": { severity: "low", title: "No visible focus indicator", help: "Tabbing to the control changes nothing on screen." },
73
+ "horizontal-scroll": { severity: "medium", title: "Page scrolls sideways", help: "Content is wider than the viewport." },
74
+ "tiny-target": { severity: "low", title: "Small click target", help: "Below the 24px WCAG 2.2 target-size minimum." },
75
+ "clipped-text": { severity: "low", title: "Text clipped", help: "Text is wider than its box and cut off without an ellipsis." },
76
+ "image-aspect": { severity: "low", title: "Image distorted", help: "The rendered box does not match the image's proportions." },
77
+ };
78
+ export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
79
+ const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
80
+ /** Snapshot refs (`e12`) are numbered per run; evidence carrying them would never match itself twice. */
81
+ function stripRefs(line) {
82
+ return line.replace(/\be\d+\s+/g, "").trim();
83
+ }
84
+ /** Origins differ between a laptop, a CI runner and a preview deployment; the path is the fact. */
85
+ function withoutOrigin(text, origin) {
86
+ return origin ? text.split(origin).join("") : text;
87
+ }
88
+ function violationRule(v) {
89
+ switch (v.kind) {
90
+ case "page_error":
91
+ return "page-error";
92
+ case "console_error":
93
+ return "console-error";
94
+ case "request_failed":
95
+ return "request-failed";
96
+ case "dom_injection":
97
+ return "dom-injection";
98
+ case "refused_empty":
99
+ return "refused-empty";
100
+ case "false_success":
101
+ return "false-success";
102
+ case "http_error": {
103
+ const status = httpStatusOf(v.detail);
104
+ // An http_error whose status cannot be read is still an error the page hit: the worse reading, not the milder one.
105
+ return status === null || status >= 500 ? "server-error" : "client-error";
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Which rule a GEOMETRY line belongs to; null only for the "…and N more"
111
+ * tails, which repeat a count rather than state a fact. A line no rule knows
112
+ * is kept as a low `layout-issue`, never dropped: check-test builds its inputs
113
+ * from the real geometry and overlay code, so a reworded line shows up there first.
114
+ */
115
+ export function geometryRule(line) {
116
+ if (/^…and \d+ more/.test(line))
117
+ return null;
118
+ if (/^OVERLAY:/.test(line)) {
119
+ // Only the certain cases block the page: a leaked scroll lock, a backdrop with no dialog,
120
+ // an empty dialog ("open dialog …"). A badly placed dialog ("dialog …") may still be usable.
121
+ return /^OVERLAY: (page scrolling is DISABLED|page is covered by a modal backdrop|open dialog )/.test(line) ? "blocking-overlay" : "dialog-layout";
122
+ }
123
+ if (/ is COVERED by pinned chrome /.test(line))
124
+ return "covered-control";
125
+ if (/ is UNREACHABLE /.test(line))
126
+ return "clipped-control";
127
+ if (/ is rendered outside the reachable page area/.test(line))
128
+ return "offpage-control";
129
+ if (/ overlaps /.test(line))
130
+ return "overlapping-controls";
131
+ return "layout-issue";
132
+ }
133
+ /**
134
+ * Turn per-route measurements into issues, one per distinct fact. The same
135
+ * failing request on ten pages is one issue seen on ten routes: the shared
136
+ * shell's defects would otherwise bury the one page that is actually broken.
137
+ */
138
+ export function issuesFromRoutes(routes, origin, ignore = []) {
139
+ const byKey = new Map();
140
+ const add = (rule, evidence, route, opts = {}) => {
141
+ if (ignore.includes(rule))
142
+ return;
143
+ const clean = redactSecrets(withoutOrigin(evidence, origin)).slice(0, 300);
144
+ const key = `${rule}\u0000${clean}`;
145
+ const found = byKey.get(key);
146
+ if (found) {
147
+ if (!found.routes.includes(route))
148
+ found.routes.push(route);
149
+ return;
150
+ }
151
+ byKey.set(key, {
152
+ rule,
153
+ severity: opts.embed ? "medium" : (opts.severity ?? CHECK_RULES[rule].severity),
154
+ evidence: clean,
155
+ routes: [route],
156
+ ...(opts.embed ? { embed: opts.embed } : {}),
157
+ fingerprint: createHash("sha256").update(key).digest("hex").slice(0, 32),
158
+ });
159
+ };
160
+ for (const r of routes) {
161
+ const route = r.path;
162
+ if (r.loadError !== undefined) {
163
+ add("route-load-failed", `${route}: ${r.loadError}`, route);
164
+ continue;
165
+ }
166
+ if (r.status !== null && r.status >= 500)
167
+ add("route-server-error", `${route} → HTTP ${r.status}`, route);
168
+ else if (r.status !== null && r.status >= 400)
169
+ add("route-client-error", `${route} → HTTP ${r.status}`, route);
170
+ if (r.loginRedirect)
171
+ add("auth-redirect", `${route} → ${withoutOrigin(r.url, origin) || "/"}`, route);
172
+ else if (r.elements === 0 && (r.status === null || r.status < 400))
173
+ add("dead-end", `${route}: 0 controls`, route);
174
+ for (const v of r.violations)
175
+ add(violationRule(v), v.detail, route, { embed: v.embed });
176
+ for (const line of r.geometry) {
177
+ const rule = geometryRule(line);
178
+ if (rule)
179
+ add(rule, stripRefs(line), route);
180
+ }
181
+ for (const line of r.brokenImages)
182
+ if (!/^…and \d+ more/.test(line))
183
+ add("broken-image", stripRefs(line), route);
184
+ for (const u of r.unnamed)
185
+ add("unnamed-control", u, route);
186
+ for (const p of r.placeholderOnly)
187
+ add("placeholder-only-label", p, route);
188
+ for (const d of r.design)
189
+ add(d.rule, d.detail, d.chrome ? "(shared chrome)" : route);
190
+ }
191
+ return [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
192
+ }
193
+ /**
194
+ * Why a check has nothing to give a verdict on, or null when it measured the
195
+ * app. Neither case is a pass: a gate that goes green on a login page or a
196
+ * dead server would pass every pull request while the app was down.
197
+ */
198
+ export function unmeasuredReason(routes, startIsFirst) {
199
+ const loaded = routes.filter((r) => r.loadError === undefined);
200
+ if (loaded.length === 0)
201
+ return "no page loaded. Is the app running at that URL?";
202
+ // Without --paths the first route is the start page. When it bounces, what follows is the sign-in
203
+ // page's own links (sign-up, forgot password): pages that load, but not the app.
204
+ // With --paths there is no start page, only a list, and one walled path among public ones is an auth-redirect issue.
205
+ if ((startIsFirst && routes[0]?.loginRedirect) || loaded.every((r) => r.loginRedirect)) {
206
+ return "the start page sent the browser to sign-in, so only sign-in pages were measured. Pass --storage-state with a signed-in session, or regenerate it if it has expired.";
207
+ }
208
+ return null;
209
+ }
210
+ /** Loaded routes whose design audit measured nothing: their contrast, focus and target rules are silent, not clean. */
211
+ export function unauditedRoutes(routes) {
212
+ return routes.filter((r) => r.loadError === undefined && r.auditError !== undefined);
213
+ }
214
+ /**
215
+ * The document's own error status is already the route's issue, so the
216
+ * oracle's record of that same response is not a second one. Run on the raw
217
+ * measurements, before redaction: the oracle truncated the raw URL, and
218
+ * redacting first would make the two stop matching. The #fragment is dropped
219
+ * because the page URL keeps it and a request never carries one.
220
+ */
221
+ export function withoutOwnResponse(r) {
222
+ if (r.status === null || r.status < 400)
223
+ return r;
224
+ const own = httpErrorDetail("GET", r.url.replace(/#.*$/, ""), r.status);
225
+ return { ...r, violations: r.violations.filter((v) => !(v.kind === "http_error" && v.detail === own)) };
226
+ }
227
+ /**
228
+ * Routes are links the app printed, and a link can carry a token
229
+ * (`?reset=…`, `?api_key=…`). Evidence is redacted where issues are built;
230
+ * this does the same for every route string before anything is written.
231
+ */
232
+ export function redactRoute(route) {
233
+ // The trailing "[n secrets redacted]" note belongs to prose; in a route it would read as part of the path.
234
+ return redactSecrets(route).replace(/ \[\d+ secrets? redacted\]$/, "");
235
+ }
236
+ export function redactRoutes(routes) {
237
+ const clean = redactRoute;
238
+ return routes.map((r) => ({
239
+ ...r,
240
+ path: clean(r.path),
241
+ url: clean(r.url),
242
+ violations: r.violations.map((v) => ({ ...v, url: clean(v.url) })),
243
+ }));
244
+ }
245
+ export function countBySeverity(issues) {
246
+ const counts = { high: 0, medium: 0, low: 0 };
247
+ for (const i of issues)
248
+ counts[i.severity] += 1;
249
+ return counts;
250
+ }
251
+ /** The issues that fail the gate: those at `failOn` severity or worse. */
252
+ export function gateFailures(issues, failOn) {
253
+ if (failOn === "never")
254
+ return [];
255
+ return issues.filter((i) => SEVERITY_RANK[i.severity] <= SEVERITY_RANK[failOn]);
256
+ }
257
+ /** Exit codes, stable for scripts: a gate failure is distinguishable from the check not running. */
258
+ export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
259
+ /**
260
+ * Every `--option` `scenescout check` accepts. The GitHub Action at the
261
+ * repository root mirrors this list input for input, and check-test fails when
262
+ * the two drift apart.
263
+ */
264
+ export const CHECK_OPTION_NAMES = ["project", "out", "fail-on", "mode", "storage-state", "browser", "max-routes", "paths", "ignore"];
265
+ export const MAX_CHECK_ROUTES = 150;
266
+ export const DEFAULT_CHECK_ROUTES = 50;
267
+ /** Parse `scenescout check` arguments. Every mistake is a sentence, never a half-configured run. */
268
+ export function parseCheckArgs(args, cwd) {
269
+ const positional = [];
270
+ const flags = new Map();
271
+ for (let i = 0; i < args.length; i++) {
272
+ const a = args[i];
273
+ if (!a.startsWith("--")) {
274
+ positional.push(a);
275
+ continue;
276
+ }
277
+ const eq = a.indexOf("=");
278
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
279
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
280
+ if (value === undefined || (eq < 0 && value.startsWith("--")))
281
+ return { ok: false, error: `--${name} needs a value` };
282
+ if (eq < 0)
283
+ i += 1;
284
+ flags.set(name, value);
285
+ }
286
+ const known = new Set(CHECK_OPTION_NAMES);
287
+ for (const name of flags.keys())
288
+ if (!known.has(name))
289
+ return { ok: false, error: `unknown option --${name}` };
290
+ if (positional.length !== 1)
291
+ return { ok: false, error: "give exactly one URL to check, e.g. scenescout check http://127.0.0.1:3000" };
292
+ let url;
293
+ try {
294
+ url = new URL(positional[0]);
295
+ }
296
+ catch {
297
+ return { ok: false, error: `not a URL: ${positional[0]}` };
298
+ }
299
+ if (url.protocol !== "http:" && url.protocol !== "https:")
300
+ return { ok: false, error: `only http and https URLs can be checked (got ${url.protocol})` };
301
+ // It would be copied into the report, the JSON and the CI job's summary page.
302
+ if (url.username || url.password)
303
+ return { ok: false, error: "put no credentials in the URL: they would be written into the report. Sign in with --storage-state instead" };
304
+ const failOn = (flags.get("fail-on") ?? "high");
305
+ if (!FAIL_ON.includes(failOn))
306
+ return { ok: false, error: `--fail-on must be one of ${FAIL_ON.join(", ")}` };
307
+ const mode = flags.get("mode") ?? "read-only";
308
+ if (mode !== "observe" && mode !== "read-only") {
309
+ return { ok: false, error: "--mode must be observe or read-only: a check only visits pages, so nothing it does needs a write" };
310
+ }
311
+ const browser = flags.get("browser");
312
+ if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
313
+ return { ok: false, error: `--browser must be one of ${BROWSER_ENGINES.join(", ")}` };
314
+ }
315
+ const maxRaw = flags.get("max-routes");
316
+ const maxRoutes = maxRaw === undefined ? DEFAULT_CHECK_ROUTES : Number(maxRaw);
317
+ if (!Number.isInteger(maxRoutes) || maxRoutes < 1 || maxRoutes > MAX_CHECK_ROUTES) {
318
+ return { ok: false, error: `--max-routes must be a whole number from 1 to ${MAX_CHECK_ROUTES}` };
319
+ }
320
+ const list = (v) => v === undefined
321
+ ? undefined
322
+ : v
323
+ .split(",")
324
+ .map((s) => s.trim())
325
+ .filter(Boolean);
326
+ const paths = list(flags.get("paths"));
327
+ if (paths && paths.length === 0)
328
+ return { ok: false, error: "--paths is empty" };
329
+ if (paths?.some((p) => !p.startsWith("/")))
330
+ return { ok: false, error: "--paths are paths on the app, each starting with /" };
331
+ const ignore = list(flags.get("ignore")) ?? [];
332
+ const unknownRules = ignore.filter((r) => !(r in CHECK_RULES));
333
+ if (unknownRules.length > 0)
334
+ return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
335
+ const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
336
+ return {
337
+ ok: true,
338
+ options: {
339
+ url: url.toString(),
340
+ projectDir: resolve(flags.get("project") ?? cwd),
341
+ ...(flags.has("out") ? { outDir: resolve(flags.get("out")) } : {}),
342
+ failOn,
343
+ mode,
344
+ ...(flags.has("storage-state") ? { storageStatePath: resolve(flags.get("storage-state")) } : {}),
345
+ ...(browser ? { browser: browser } : {}),
346
+ maxRoutes,
347
+ ...(paths ? { paths } : {}),
348
+ ignore: ignore,
349
+ },
350
+ };
351
+ }
352
+ export function summarise(result) {
353
+ const failing = gateFailures(result.issues, result.failOn).length;
354
+ return { passed: failing === 0, counts: countBySeverity(result.issues), failing };
355
+ }
356
+ const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
357
+ /**
358
+ * SARIF 2.1.0, for code-scanning dashboards. A UI check has no source file to
359
+ * point at, so each result's location is the route, relative to the checked
360
+ * app (the APP base id). Fingerprints come from the evidence, not the
361
+ * location, so a preview deployment on a new URL does not reopen every alert.
362
+ */
363
+ export function toSarif(result, toolVersion) {
364
+ const used = [...new Set(result.issues.map((i) => i.rule))];
365
+ const base = new URL(result.url);
366
+ return {
367
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
368
+ version: "2.1.0",
369
+ runs: [
370
+ {
371
+ tool: {
372
+ driver: {
373
+ name: "SceneScout",
374
+ version: toolVersion,
375
+ informationUri: "https://github.com/brunoboto96/SceneScout",
376
+ rules: used.map((id) => ({
377
+ id,
378
+ name: CHECK_RULES[id].title,
379
+ shortDescription: { text: CHECK_RULES[id].title },
380
+ help: { text: CHECK_RULES[id].help },
381
+ defaultConfiguration: { level: SARIF_LEVEL[CHECK_RULES[id].severity] },
382
+ })),
383
+ },
384
+ },
385
+ originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
386
+ results: result.issues.map((i) => ({
387
+ ruleId: i.rule,
388
+ level: SARIF_LEVEL[i.severity],
389
+ message: {
390
+ text: `${CHECK_RULES[i.rule].title}: ${i.evidence}${i.routes.length > 1 ? ` (on ${i.routes.length} routes)` : ""}${i.embed ? ` (in an embed of ${i.embed})` : ""}`,
391
+ },
392
+ locations: i.routes.slice(0, 10).map((r) => r === "(shared chrome)"
393
+ ? {
394
+ physicalLocation: { artifactLocation: { uri: "", uriBaseId: "APP" } },
395
+ message: { text: "the app's shared shell, on every page that renders it" },
396
+ }
397
+ : { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
398
+ partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
399
+ })),
400
+ },
401
+ ],
402
+ };
403
+ }
404
+ /**
405
+ * Markdown cells: a pipe or a newline in evidence would break the table it
406
+ * sits in. Backslashes are escaped first, or evidence ending in `\` would
407
+ * turn the pipe's escape into a literal backslash and the pipe back into a column.
408
+ */
409
+ function cell(text) {
410
+ return text
411
+ .replace(/\\/g, "\\\\")
412
+ .replace(/\|/g, "\\|")
413
+ .replace(/\s*\n\s*/g, " ");
414
+ }
415
+ /** A code span that the text cannot close: its fence is one backtick longer than any run of backticks inside it. */
416
+ function code(text) {
417
+ const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
418
+ const fence = "`".repeat(longest + 1);
419
+ const pad = longest > 0 ? " " : "";
420
+ return `${fence}${pad}${text}${pad}${fence}`;
421
+ }
422
+ function routeList(routes) {
423
+ const shown = routes.slice(0, 3).map(code);
424
+ return shown.join(", ") + (routes.length > 3 ? ` and ${routes.length - 3} more` : "");
425
+ }
426
+ /** The human report: the verdict first, then what failed it, then everything else. */
427
+ export function formatCheck(result) {
428
+ const { passed, counts, failing } = summarise(result);
429
+ const lines = [];
430
+ lines.push(`# SceneScout check`);
431
+ lines.push("");
432
+ lines.push(`${result.url} · ${result.routes.length} route(s) · mode ${result.mode} · gate: ${result.failOn === "never" ? "report only" : `fail on ${result.failOn}${result.failOn === "low" ? "" : " or worse"}`}`);
433
+ lines.push("");
434
+ const unaudited = unauditedRoutes(result.routes).length;
435
+ lines.push((passed
436
+ ? `**PASSED** — ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
437
+ : `**FAILED** — ${failing} issue(s) at the gate's severity · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
438
+ (unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : ""));
439
+ for (const sev of CHECK_SEVERITIES) {
440
+ const of = result.issues.filter((i) => i.severity === sev);
441
+ if (of.length === 0)
442
+ continue;
443
+ lines.push("", `## ${sev[0].toUpperCase()}${sev.slice(1)} (${of.length})`, "");
444
+ for (const i of of) {
445
+ lines.push(`- **${CHECK_RULES[i.rule].title}** \`${i.rule}\`: ${cell(i.evidence)}${i.embed ? ` _(in an embed of ${i.embed}: its behaviour, not the app's)_` : ""} — ${routeList(i.routes)}`);
446
+ }
447
+ }
448
+ lines.push("", "## Routes", "", "| Route | Status | Controls | Issues |", "|---|---|---|---|");
449
+ for (const r of result.routes) {
450
+ const n = result.issues.filter((i) => i.routes.includes(r.path)).length;
451
+ const status = (r.loadError !== undefined ? "did not load" : r.loginRedirect ? `${r.status ?? "?"} → sign-in` : String(r.status ?? "?")) +
452
+ (r.auditError ? ` (design not measured: ${cell(r.auditError.slice(0, 80))})` : "");
453
+ // Plain text, not a code span: inside a table cell a code span keeps the backslashes cell() adds.
454
+ lines.push(`| ${cell(r.path)} | ${status} | ${r.elements} | ${n} |`);
455
+ }
456
+ if (result.unvisited.length > 0) {
457
+ lines.push("", `Not visited (over --max-routes): ${result.unvisited.slice(0, 20).map(code).join(", ")}${result.unvisited.length > 20 ? " …" : ""}`);
458
+ }
459
+ if (result.ignored.length > 0)
460
+ lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
461
+ lines.push("", "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that._");
462
+ return lines.join("\n") + "\n";
463
+ }
464
+ /** The machine summary: stable keys for scripts and follow-up jobs. */
465
+ export function toSummaryJson(result, toolVersion) {
466
+ const { passed, counts, failing } = summarise(result);
467
+ return {
468
+ tool: "scenescout-check",
469
+ version: toolVersion,
470
+ url: result.url,
471
+ generatedAt: result.generatedAt,
472
+ mode: result.mode,
473
+ gate: { failOn: result.failOn, passed, failing },
474
+ counts,
475
+ routes: result.routes.map((r) => ({
476
+ path: r.path,
477
+ status: r.status,
478
+ loaded: r.loadError === undefined,
479
+ loginRedirect: r.loginRedirect,
480
+ controls: r.elements,
481
+ ...(r.auditError !== undefined ? { designNotMeasured: r.auditError } : {}),
482
+ })),
483
+ unvisited: result.unvisited,
484
+ ignored: result.ignored,
485
+ issues: result.issues,
486
+ };
487
+ }