scenescout 3.9.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
+ }
@@ -19,21 +19,12 @@ export const VISIBLE_SRC = `(el) => {
19
19
  return style.visibility !== "hidden" && style.display !== "none";
20
20
  }`;
21
21
  /**
22
- * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
23
- * than the ARIA set: a hand-rolled role-less modal must still count as "an
24
- * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
25
- * against every healthy modal that locks the page behind it.
26
- */
27
- export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
28
- // Declared above the collector script because that script interpolates it.
29
- /**
30
- * Page-side interactable collector. Shipped as a STRING, not a function:
31
- * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
32
- * serialized functions inside the browser, where the helper doesn't exist.
33
- * A string expression is immune to any build/loader instrumentation.
22
+ * The collector's element path, as page-side source. Exported so a page-side
23
+ * probe that names an element (forms.ts) names it with the SAME path the
24
+ * snapshot listed it under, and the engine can map it back to that element's
25
+ * coverage key.
34
26
  */
35
- export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
36
- const xpathOf = (el) => {
27
+ export const XPATH_OF_SRC = `(el) => {
37
28
  const parts = [];
38
29
  let node = el;
39
30
  while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== "html") {
@@ -47,9 +38,13 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
47
38
  node = node.parentElement;
48
39
  }
49
40
  return "/html/" + parts.join("/");
50
- };
51
- const visible = ${VISIBLE_SRC};
52
- const accessibleName = (el) => {
41
+ }`;
42
+ /**
43
+ * The collector's accessible name, as page-side source. Exported for the same
44
+ * reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
45
+ * must name it exactly as the snapshot did, or the two cannot be compared.
46
+ */
47
+ export const ACCESSIBLE_NAME_SRC = `(el) => {
53
48
  const aria = el.getAttribute("aria-label");
54
49
  if (aria) return aria.trim();
55
50
  // aria-labelledby before any fallback: it is the standard way to name an
@@ -83,6 +78,51 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
83
78
  }
84
79
  const text = el.innerText || el.textContent || "";
85
80
  return text.trim().replace(/\\s+/g, " ").slice(0, 80);
81
+ }`;
82
+ /**
83
+ * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
84
+ * than the ARIA set: a hand-rolled role-less modal must still count as "an
85
+ * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
86
+ * against every healthy modal that locks the page behind it.
87
+ */
88
+ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
89
+ // Declared above the collector script because that script interpolates it.
90
+ /**
91
+ * Page-side interactable collector. Shipped as a STRING, not a function:
92
+ * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
93
+ * serialized functions inside the browser, where the helper doesn't exist.
94
+ * A string expression is immune to any build/loader instrumentation.
95
+ */
96
+ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
97
+ const xpathOf = ${XPATH_OF_SRC};
98
+ const visible = ${VISIBLE_SRC};
99
+ const accessibleName = ${ACCESSIBLE_NAME_SRC};
100
+ // Whether a field's accessibleName came from something that is not a label. The
101
+ // name falls back to the placeholder, then the name attribute, then the type,
102
+ // so every field can be told apart and targeted, and it is half of the
103
+ // element's key, which project memory keeps across runs: it stays as it is.
104
+ // This says what the fallback hides. A real label is aria-label,
105
+ // aria-labelledby, a <label> (for= or wrapping, read through el.labels),
106
+ // title, or for a button-like input its value or the browser's default text.
107
+ // "placeholder" when the placeholder is all there is, "fallback" when not even
108
+ // that, null when the field is labelled or is not a field.
109
+ const nameFrom = (el) => {
110
+ const tag = el.tagName.toLowerCase();
111
+ if (tag !== "input" && tag !== "textarea") return null;
112
+ if ((el.getAttribute("aria-label") || "").trim()) return null;
113
+ const labelledBy = (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean);
114
+ if (labelledBy.some((id) => { const n = document.getElementById(id); return n && (n.textContent || "").trim(); })) return null;
115
+ for (const label of Array.from(el.labels || [])) {
116
+ // A wrapping label's text includes a textarea's own content; that is not a label.
117
+ const own = label.contains(el) ? el.textContent || "" : "";
118
+ if ((label.textContent || "").replace(own, "").trim()) return null;
119
+ }
120
+ if ((el.getAttribute("title") || "").trim()) return null;
121
+ const type = tag === "input" ? el.type : "";
122
+ if (type === "submit" || type === "reset") return null;
123
+ if (type === "button" && (el.value || "").trim()) return null;
124
+ if (type === "image" && (el.getAttribute("alt") || "").trim()) return null;
125
+ return (el.getAttribute("placeholder") || "").trim() ? "placeholder" : "fallback";
86
126
  };
87
127
  const selector =
88
128
  'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
@@ -257,6 +297,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
257
297
  tag,
258
298
  role,
259
299
  name: accessibleName(el),
300
+ nameFrom: nameFrom(el),
260
301
  testid: el.getAttribute("data-testid"),
261
302
  xpath: xpathOf(el),
262
303
  disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
@@ -283,10 +324,45 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
283
324
  * as an accessibility defect — twice in one measured run.
284
325
  */
285
326
  const LIVE_REGION_ROLES = new Set(["status", "alert", "log", "timer", "marquee"]);
286
- /** Whether an element with this role and no accessible name is an unnamed control, as opposed to a live region with nothing in it yet. */
327
+ /**
328
+ * Whether an element is an unnamed control, as opposed to a live region with
329
+ * nothing in it yet. A field whose shown name is only its name attribute or
330
+ * type is unnamed too: the collector fills those in so the field can be
331
+ * targeted, but nothing announces them.
332
+ */
287
333
  export function missingName(el) {
334
+ if (el.nameFrom === "fallback")
335
+ return true;
288
336
  return !el.name && !LIVE_REGION_ROLES.has(el.role);
289
337
  }
338
+ /**
339
+ * Whether a field's only label is its placeholder. Kept apart from missingName
340
+ * because browsers do announce a placeholder when nothing else names the
341
+ * field, so it is not nameless; but the text is gone as soon as the user
342
+ * types, and it was never a label. The shown name must be non-empty: a blank
343
+ * aria-label ends the name before the placeholder is reached, so that field is
344
+ * unnamed (missingName) and only that.
345
+ */
346
+ export function placeholderOnly(el) {
347
+ return el.nameFrom === "placeholder" && !!el.name;
348
+ }
349
+ /** How a crawl names a control in the check's evidence: by role and test id, or role and XPath. */
350
+ export function describeControl(el) {
351
+ return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
352
+ }
353
+ /** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
354
+ export function placeholderEvidence(el) {
355
+ const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
356
+ return `${describeControl(el)} "${text}"`;
357
+ }
358
+ /** The snapshot's flag for a field with no label, or null when it has one. */
359
+ export function labelFlag(el) {
360
+ if (placeholderOnly(el))
361
+ return "no label: placeholder only";
362
+ if (el.nameFrom)
363
+ return "no label";
364
+ return null;
365
+ }
290
366
  /** How the snapshot shows an element's name: an empty live region says so rather than reading as an unnamed control. */
291
367
  export function displayName(el) {
292
368
  if (el.name)