scenescout 3.9.0 → 3.11.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,705 @@
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 { flowStepEvidence } from "./flow.js";
16
+ import { redactSecrets } from "./memory.js";
17
+ import { httpErrorDetail, httpStatusOf } from "./oracles.js";
18
+ export const CHECK_SEVERITIES = ["high", "medium", "low"];
19
+ export const FAIL_ON = [...CHECK_SEVERITIES, "never"];
20
+ export const CHECK_RULES = {
21
+ "route-load-failed": { severity: "high", title: "Page did not load", help: "Navigating to the route failed or timed out." },
22
+ "route-server-error": { severity: "high", title: "Page answered with a server error", help: "The document itself returned HTTP 5xx." },
23
+ "route-client-error": { severity: "medium", title: "Page answered with a client error", help: "The document itself returned HTTP 4xx." },
24
+ "page-error": { severity: "high", title: "Uncaught exception", help: "The page raised an error nothing caught." },
25
+ "server-error": { severity: "high", title: "Request failed with a server error", help: "A request the page made returned HTTP 5xx." },
26
+ "client-error": { severity: "medium", title: "Request failed with a client error", help: "A request the page made returned HTTP 4xx." },
27
+ "request-failed": { severity: "medium", title: "Request did not complete", help: "A request the page made failed at the network level." },
28
+ "console-error": {
29
+ severity: "medium",
30
+ title: "Console error",
31
+ 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.",
32
+ },
33
+ "refused-empty": {
34
+ severity: "high",
35
+ title: "Failed request shown as an empty result",
36
+ help: "A request was refused and the page drew an empty state instead of an error.",
37
+ },
38
+ "false-success": { severity: "high", title: "Success shown for a failed request", help: "The page reported success while the server refused the request." },
39
+ "dom-injection": { severity: "high", title: "Markup rendered as an element", help: "Text the engine typed came back as live markup." },
40
+ "auth-redirect": { severity: "medium", title: "Sent to sign-in", help: "The route redirected to a sign-in page; the session is missing or expired." },
41
+ "dead-end": { severity: "medium", title: "Dead end", help: "The page has no controls at all: no navigation and no way back." },
42
+ "blocking-overlay": {
43
+ severity: "high",
44
+ title: "Page blocked by an overlay",
45
+ help: "A backdrop with no dialog, an empty dialog or a leaked scroll lock leaves the user unable to use the page.",
46
+ },
47
+ "dialog-layout": {
48
+ severity: "medium",
49
+ title: "Dialog badly placed",
50
+ 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.",
51
+ },
52
+ "layout-issue": {
53
+ severity: "low",
54
+ title: "Layout issue",
55
+ help: "A layout check reported something this version of the check has no specific rule for. It is listed rather than dropped.",
56
+ },
57
+ "covered-control": {
58
+ severity: "medium",
59
+ title: "Control covered by pinned chrome",
60
+ help: "A fixed or sticky element sits on top of the control, so clicks land on it instead.",
61
+ },
62
+ "clipped-control": { severity: "medium", title: "Control unreachable", help: "The control is clipped inside a container that cannot scroll." },
63
+ "offpage-control": { severity: "medium", title: "Control outside the page", help: "The control is laid out where no scrolling can reach it." },
64
+ "overlapping-controls": { severity: "low", title: "Controls overlap", help: "Two controls in the same layer cover most of each other." },
65
+ "broken-image": { severity: "medium", title: "Broken image", help: "The browser could not render the image." },
66
+ "unnamed-control": { severity: "medium", title: "Control with no accessible name", help: "Assistive technology announces the control without a name." },
67
+ "placeholder-only-label": {
68
+ severity: "medium",
69
+ title: "Field labelled only by its placeholder",
70
+ 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.",
71
+ },
72
+ contrast: { severity: "low", title: "Text contrast below WCAG", help: "Text needs 4.5:1 (3:1 when large) against its background." },
73
+ "focus-indicator": { severity: "low", title: "No visible focus indicator", help: "Tabbing to the control changes nothing on screen." },
74
+ "horizontal-scroll": { severity: "medium", title: "Page scrolls sideways", help: "Content is wider than the viewport." },
75
+ "tiny-target": { severity: "low", title: "Small click target", help: "Below the 24px WCAG 2.2 target-size minimum." },
76
+ "clipped-text": { severity: "low", title: "Text clipped", help: "Text is wider than its box and cut off without an ellipsis." },
77
+ "image-aspect": { severity: "low", title: "Image distorted", help: "The rendered box does not match the image's proportions." },
78
+ "flow-step-failed": {
79
+ severity: "high",
80
+ title: "Saved flow broke",
81
+ help: "A step of a flow saved in .scenescout/flows could not be done, or what it expected was not there. The evidence names the flow, the step and what happened instead.",
82
+ },
83
+ };
84
+ export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
85
+ const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
86
+ /** Snapshot refs (`e12`) are numbered per run; evidence carrying them would never match itself twice. */
87
+ function stripRefs(line) {
88
+ return line.replace(/\be\d+\s+/g, "").trim();
89
+ }
90
+ /** Origins differ between a laptop, a CI runner and a preview deployment; the path is the fact. */
91
+ function withoutOrigin(text, origin) {
92
+ return origin ? text.split(origin).join("") : text;
93
+ }
94
+ function violationRule(v) {
95
+ switch (v.kind) {
96
+ case "page_error":
97
+ return "page-error";
98
+ case "console_error":
99
+ return "console-error";
100
+ case "request_failed":
101
+ return "request-failed";
102
+ case "dom_injection":
103
+ return "dom-injection";
104
+ case "refused_empty":
105
+ return "refused-empty";
106
+ case "false_success":
107
+ return "false-success";
108
+ case "http_error": {
109
+ const status = httpStatusOf(v.detail);
110
+ // An http_error whose status cannot be read is still an error the page hit: the worse reading, not the milder one.
111
+ return status === null || status >= 500 ? "server-error" : "client-error";
112
+ }
113
+ }
114
+ }
115
+ /**
116
+ * Which rule a GEOMETRY line belongs to; null only for the "…and N more"
117
+ * tails, which repeat a count rather than state a fact. A line no rule knows
118
+ * is kept as a low `layout-issue`, never dropped: check-test builds its inputs
119
+ * from the real geometry and overlay code, so a reworded line shows up there first.
120
+ */
121
+ export function geometryRule(line) {
122
+ if (/^…and \d+ more/.test(line))
123
+ return null;
124
+ if (/^OVERLAY:/.test(line)) {
125
+ // Only the certain cases block the page: a leaked scroll lock, a backdrop with no dialog,
126
+ // an empty dialog ("open dialog …"). A badly placed dialog ("dialog …") may still be usable.
127
+ return /^OVERLAY: (page scrolling is DISABLED|page is covered by a modal backdrop|open dialog )/.test(line) ? "blocking-overlay" : "dialog-layout";
128
+ }
129
+ if (/ is COVERED by pinned chrome /.test(line))
130
+ return "covered-control";
131
+ if (/ is UNREACHABLE /.test(line))
132
+ return "clipped-control";
133
+ if (/ is rendered outside the reachable page area/.test(line))
134
+ return "offpage-control";
135
+ if (/ overlaps /.test(line))
136
+ return "overlapping-controls";
137
+ return "layout-issue";
138
+ }
139
+ /**
140
+ * Turn per-route measurements into issues, one per distinct fact. The same
141
+ * failing request on ten pages is one issue seen on ten routes: the shared
142
+ * shell's defects would otherwise bury the one page that is actually broken.
143
+ *
144
+ * Saved flows add two kinds: the step a flow broke at, and anything the
145
+ * oracles caught while it ran. The second kind goes through the same rules as
146
+ * a crawled page's, so a request that fails on load and again inside a flow is
147
+ * one issue, not two.
148
+ */
149
+ export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
150
+ const byKey = new Map();
151
+ const add = (rule, evidence, route, opts = {}) => {
152
+ if (ignore.includes(rule))
153
+ return;
154
+ const clean = redactSecrets(withoutOrigin(evidence, origin)).slice(0, 300);
155
+ const key = `${rule}\u0000${clean}`;
156
+ const found = byKey.get(key);
157
+ if (found) {
158
+ if (!found.routes.includes(route))
159
+ found.routes.push(route);
160
+ return;
161
+ }
162
+ byKey.set(key, {
163
+ rule,
164
+ severity: opts.embed ? "medium" : (opts.severity ?? CHECK_RULES[rule].severity),
165
+ evidence: clean,
166
+ routes: [route],
167
+ ...(opts.embed ? { embed: opts.embed } : {}),
168
+ fingerprint: createHash("sha256").update(key).digest("hex").slice(0, 32),
169
+ });
170
+ };
171
+ for (const r of routes) {
172
+ const route = r.path;
173
+ if (r.loadError !== undefined) {
174
+ add("route-load-failed", `${route}: ${r.loadError}`, route);
175
+ continue;
176
+ }
177
+ if (r.status !== null && r.status >= 500)
178
+ add("route-server-error", `${route} → HTTP ${r.status}`, route);
179
+ else if (r.status !== null && r.status >= 400)
180
+ add("route-client-error", `${route} → HTTP ${r.status}`, route);
181
+ if (r.loginRedirect)
182
+ add("auth-redirect", `${route} → ${withoutOrigin(r.url, origin) || "/"}`, route);
183
+ else if (r.elements === 0 && (r.status === null || r.status < 400))
184
+ add("dead-end", `${route}: 0 controls`, route);
185
+ for (const v of r.violations)
186
+ add(violationRule(v), v.detail, route, { embed: v.embed });
187
+ for (const line of r.geometry) {
188
+ const rule = geometryRule(line);
189
+ if (rule)
190
+ add(rule, stripRefs(line), route);
191
+ }
192
+ for (const line of r.brokenImages)
193
+ if (!/^…and \d+ more/.test(line))
194
+ add("broken-image", stripRefs(line), route);
195
+ for (const u of r.unnamed)
196
+ add("unnamed-control", u, route);
197
+ for (const p of r.placeholderOnly)
198
+ add("placeholder-only-label", p, route);
199
+ for (const d of r.design)
200
+ add(d.rule, d.detail, d.chrome ? "(shared chrome)" : route);
201
+ }
202
+ for (const f of flows) {
203
+ for (const { path, violation } of f.violations)
204
+ add(violationRule(violation), violation.detail, path, { embed: violation.embed });
205
+ // A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
206
+ if (f.outcome.status === "failed")
207
+ add("flow-step-failed", flowStepEvidence(f), f.outcome.path);
208
+ }
209
+ return [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
210
+ }
211
+ /**
212
+ * Why a check has nothing to give a verdict on, or null when it measured the
213
+ * app. Neither case is a pass: a gate that goes green on a login page or a
214
+ * dead server would pass every pull request while the app was down.
215
+ */
216
+ export function unmeasuredReason(routes, startIsFirst) {
217
+ const loaded = routes.filter((r) => r.loadError === undefined);
218
+ if (loaded.length === 0)
219
+ return "no page loaded. Is the app running at that URL?";
220
+ // Without --paths the first route is the start page. When it bounces, what follows is the sign-in
221
+ // page's own links (sign-up, forgot password): pages that load, but not the app.
222
+ // With --paths there is no start page, only a list, and one walled path among public ones is an auth-redirect issue.
223
+ if ((startIsFirst && routes[0]?.loginRedirect) || loaded.every((r) => r.loginRedirect)) {
224
+ 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.";
225
+ }
226
+ return null;
227
+ }
228
+ /** Loaded routes whose design audit measured nothing: their contrast, focus and target rules are silent, not clean. */
229
+ export function unauditedRoutes(routes) {
230
+ return routes.filter((r) => r.loadError === undefined && r.auditError !== undefined);
231
+ }
232
+ /**
233
+ * The document's own error status is already the route's issue, so the
234
+ * oracle's record of that same response is not a second one. Run on the raw
235
+ * measurements, before redaction: the oracle truncated the raw URL, and
236
+ * redacting first would make the two stop matching. The #fragment is dropped
237
+ * because the page URL keeps it and a request never carries one.
238
+ */
239
+ export function withoutOwnResponse(r) {
240
+ if (r.status === null || r.status < 400)
241
+ return r;
242
+ const own = httpErrorDetail("GET", r.url.replace(/#.*$/, ""), r.status);
243
+ return { ...r, violations: r.violations.filter((v) => !(v.kind === "http_error" && v.detail === own)) };
244
+ }
245
+ /**
246
+ * Routes are links the app printed, and a link can carry a token
247
+ * (`?reset=…`, `?api_key=…`). Evidence is redacted where issues are built;
248
+ * this does the same for every route string before anything is written.
249
+ */
250
+ export function redactRoute(route) {
251
+ // The trailing "[n secrets redacted]" note belongs to prose; in a route it would read as part of the path.
252
+ return redactSecrets(route).replace(/ \[\d+ secrets? redacted\]$/, "");
253
+ }
254
+ export function redactRoutes(routes) {
255
+ const clean = redactRoute;
256
+ return routes.map((r) => ({
257
+ ...r,
258
+ path: clean(r.path),
259
+ url: clean(r.url),
260
+ violations: r.violations.map((v) => ({ ...v, url: clean(v.url) })),
261
+ }));
262
+ }
263
+ /** The same redaction for what flows measured: the page paths and the request URLs their violations quote. */
264
+ export function redactFlowRuns(runs) {
265
+ return runs.map((f) => ({
266
+ ...f,
267
+ outcome: f.outcome.status === "passed"
268
+ ? f.outcome
269
+ : { ...f.outcome, path: redactRoute(f.outcome.path), did: redactRoute(f.outcome.did), reason: redactRoute(f.outcome.reason) },
270
+ refusedBackground: f.refusedBackground.map(redactRoute),
271
+ websockets: f.websockets.map(redactRoute),
272
+ violations: f.violations.map(({ path, violation }) => ({ path: redactRoute(path), violation: { ...violation, url: redactRoute(violation.url) } })),
273
+ }));
274
+ }
275
+ export function countBySeverity(issues) {
276
+ const counts = { high: 0, medium: 0, low: 0 };
277
+ for (const i of issues)
278
+ counts[i.severity] += 1;
279
+ return counts;
280
+ }
281
+ /** The issues that fail the gate: those at `failOn` severity or worse. */
282
+ export function gateFailures(issues, failOn) {
283
+ if (failOn === "never")
284
+ return [];
285
+ return issues.filter((i) => SEVERITY_RANK[i.severity] <= SEVERITY_RANK[failOn]);
286
+ }
287
+ /** Exit codes, stable for scripts: a gate failure is distinguishable from the check not running. */
288
+ export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
289
+ /**
290
+ * The settings a project chooses for what a check may do beyond visiting
291
+ * pages. Each default is the one that does the least harm on an unfamiliar
292
+ * project: it never writes and never silently hides a result (ADR 12).
293
+ */
294
+ export const FLOW_WRITES = ["never", "allow"];
295
+ export const ON_REFUSED_STEP = ["report", "stop"];
296
+ export const GATE_RETESTS = ["never", "high", "all"];
297
+ export const DEFAULT_SETTINGS = { flowWrites: "never", onRefusedStep: "report", gateRetests: "high", retest: true };
298
+ /**
299
+ * Every `--option` `scenescout check` accepts. The GitHub Action at the
300
+ * repository root mirrors this list input for input, and check-test fails when
301
+ * the two drift apart.
302
+ */
303
+ export const CHECK_OPTION_NAMES = [
304
+ "project",
305
+ "out",
306
+ "fail-on",
307
+ "mode",
308
+ "storage-state",
309
+ "browser",
310
+ "max-routes",
311
+ "paths",
312
+ "ignore",
313
+ "flows",
314
+ "retest",
315
+ "flow-writes",
316
+ "on-refused-step",
317
+ "gate-retests",
318
+ ];
319
+ export const MAX_CHECK_ROUTES = 150;
320
+ export const DEFAULT_CHECK_ROUTES = 50;
321
+ /** Parse `scenescout check` arguments. Every mistake is a sentence, never a half-configured run. */
322
+ export function parseCheckArgs(args, cwd) {
323
+ const positional = [];
324
+ const flags = new Map();
325
+ for (let i = 0; i < args.length; i++) {
326
+ const a = args[i];
327
+ if (!a.startsWith("--")) {
328
+ positional.push(a);
329
+ continue;
330
+ }
331
+ const eq = a.indexOf("=");
332
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
333
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
334
+ if (value === undefined || (eq < 0 && value.startsWith("--")))
335
+ return { ok: false, error: `--${name} needs a value` };
336
+ if (eq < 0)
337
+ i += 1;
338
+ flags.set(name, value);
339
+ }
340
+ const known = new Set(CHECK_OPTION_NAMES);
341
+ for (const name of flags.keys())
342
+ if (!known.has(name))
343
+ return { ok: false, error: `unknown option --${name}` };
344
+ if (positional.length !== 1)
345
+ return { ok: false, error: "give exactly one URL to check, e.g. scenescout check http://127.0.0.1:3000" };
346
+ let url;
347
+ try {
348
+ url = new URL(positional[0]);
349
+ }
350
+ catch {
351
+ return { ok: false, error: `not a URL: ${positional[0]}` };
352
+ }
353
+ if (url.protocol !== "http:" && url.protocol !== "https:")
354
+ return { ok: false, error: `only http and https URLs can be checked (got ${url.protocol})` };
355
+ // It would be copied into the report, the JSON and the CI job's summary page.
356
+ if (url.username || url.password)
357
+ return { ok: false, error: "put no credentials in the URL: they would be written into the report. Sign in with --storage-state instead" };
358
+ const failOn = (flags.get("fail-on") ?? "high");
359
+ if (!FAIL_ON.includes(failOn))
360
+ return { ok: false, error: `--fail-on must be one of ${FAIL_ON.join(", ")}` };
361
+ const mode = flags.get("mode") ?? "read-only";
362
+ if (mode !== "observe" && mode !== "read-only") {
363
+ return { ok: false, error: "--mode must be observe or read-only: the write policy for the crawl, and for saved flows under --flow-writes allow" };
364
+ }
365
+ const browser = flags.get("browser");
366
+ if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
367
+ return { ok: false, error: `--browser must be one of ${BROWSER_ENGINES.join(", ")}` };
368
+ }
369
+ const maxRaw = flags.get("max-routes");
370
+ const maxRoutes = maxRaw === undefined ? DEFAULT_CHECK_ROUTES : Number(maxRaw);
371
+ if (!Number.isInteger(maxRoutes) || maxRoutes < 1 || maxRoutes > MAX_CHECK_ROUTES) {
372
+ return { ok: false, error: `--max-routes must be a whole number from 1 to ${MAX_CHECK_ROUTES}` };
373
+ }
374
+ const list = (v) => v === undefined
375
+ ? undefined
376
+ : v
377
+ .split(",")
378
+ .map((s) => s.trim())
379
+ .filter(Boolean);
380
+ const paths = list(flags.get("paths"));
381
+ if (paths && paths.length === 0)
382
+ return { ok: false, error: "--paths is empty" };
383
+ if (paths?.some((p) => !p.startsWith("/")))
384
+ return { ok: false, error: "--paths are paths on the app, each starting with /" };
385
+ const ignore = list(flags.get("ignore")) ?? [];
386
+ const unknownRules = ignore.filter((r) => !(r in CHECK_RULES));
387
+ if (unknownRules.length > 0)
388
+ return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
389
+ const retest = flags.get("retest") ?? "on";
390
+ if (retest !== "on" && retest !== "off")
391
+ return { ok: false, error: "--retest must be on or off" };
392
+ const flows = flags.get("flows");
393
+ if (flows !== undefined && flows.trim() === "")
394
+ return { ok: false, error: "--flows needs a directory, or off" };
395
+ const oneOf = (name, allowed, fallback) => {
396
+ const v = flags.get(name) ?? fallback;
397
+ return allowed.includes(v) ? v : null;
398
+ };
399
+ const flowWrites = oneOf("flow-writes", FLOW_WRITES, DEFAULT_SETTINGS.flowWrites);
400
+ if (!flowWrites)
401
+ return { ok: false, error: `--flow-writes must be one of ${FLOW_WRITES.join(", ")}` };
402
+ const onRefusedStep = oneOf("on-refused-step", ON_REFUSED_STEP, DEFAULT_SETTINGS.onRefusedStep);
403
+ if (!onRefusedStep)
404
+ return { ok: false, error: `--on-refused-step must be one of ${ON_REFUSED_STEP.join(", ")}` };
405
+ const gateRetests = oneOf("gate-retests", GATE_RETESTS, DEFAULT_SETTINGS.gateRetests);
406
+ if (!gateRetests)
407
+ return { ok: false, error: `--gate-retests must be one of ${GATE_RETESTS.join(", ")}` };
408
+ const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
409
+ return {
410
+ ok: true,
411
+ options: {
412
+ url: url.toString(),
413
+ projectDir: resolve(flags.get("project") ?? cwd),
414
+ ...(flags.has("out") ? { outDir: resolve(flags.get("out")) } : {}),
415
+ failOn,
416
+ mode,
417
+ ...(flags.has("storage-state") ? { storageStatePath: resolve(flags.get("storage-state")) } : {}),
418
+ ...(browser ? { browser: browser } : {}),
419
+ maxRoutes,
420
+ ...(paths ? { paths } : {}),
421
+ ignore: ignore,
422
+ ...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
423
+ retest: retest === "on",
424
+ flowWrites,
425
+ onRefusedStep,
426
+ gateRetests,
427
+ },
428
+ };
429
+ }
430
+ /** The settings part of the options, as the result records them. */
431
+ export function settingsOf(options) {
432
+ return { flowWrites: options.flowWrites, onRefusedStep: options.onRefusedStep, gateRetests: options.gateRetests, retest: options.retest };
433
+ }
434
+ /**
435
+ * Why a check has no verdict because of a flow, or null. A step the write
436
+ * policy refused asks for something a check will not do; the flow did not
437
+ * run, so it neither passed nor broke, and the check exits "could not run"
438
+ * rather than reporting a verdict on the rest as if the flow were fine.
439
+ */
440
+ export function refusedFlowReason(result) {
441
+ const refused = result.flows.filter((f) => f.outcome.status === "refused");
442
+ if (refused.length === 0)
443
+ return null;
444
+ const why = result.settings.flowWrites === "never"
445
+ ? "Flows replay with --flow-writes never, so no step may send a write whatever --mode says: remove the step, or pass --flow-writes allow to replay flows under --mode"
446
+ : `Flows replay under --mode ${result.mode}, which refuses that write: remove the step, or leave the flow to an exploratory run`;
447
+ return `${refused.map(flowStepEvidence).join("; ")}. ${why}.`;
448
+ }
449
+ /**
450
+ * Re-tested findings that fail the gate: those still reproducing that
451
+ * --gate-retests covers. "possibly fixed" and "not re-tested" never do, and
452
+ * --fail-on never turns every gate off, this one included.
453
+ */
454
+ export function retestGateFailures(result) {
455
+ const { gateRetests } = result.settings;
456
+ if (!result.retest || gateRetests === "never" || result.failOn === "never")
457
+ return [];
458
+ return result.retest.results.filter((r) => r.verdict === "reproduces" && (gateRetests === "all" || r.severity === "high"));
459
+ }
460
+ export function summarise(result) {
461
+ const retestsFailing = retestGateFailures(result).length;
462
+ const failing = gateFailures(result.issues, result.failOn).length + retestsFailing;
463
+ const couldNotRun = result.flows.filter((f) => f.outcome.status === "refused").length;
464
+ return { passed: failing === 0, counts: countBySeverity(result.issues), failing, retestsFailing, couldNotRun };
465
+ }
466
+ /** The exit code a finished check ends with. A flow that could not run outranks the gate: the verdict is incomplete. */
467
+ export function exitCodeOf(result) {
468
+ const { passed, couldNotRun } = summarise(result);
469
+ if (couldNotRun > 0)
470
+ return EXIT.error;
471
+ return passed ? EXIT.pass : EXIT.gateFailed;
472
+ }
473
+ /** One line of the settings, for the report and the job summary. */
474
+ export function describeSettings(result) {
475
+ const s = result.settings;
476
+ return `flow writes: ${s.flowWrites === "never" ? "never (flows replay under observe)" : `allow (flows replay under ${result.mode})`} · refused step: ${s.onRefusedStep} · re-tests: ${s.retest ? `on, gating ${s.gateRetests === "never" ? "nothing" : s.gateRetests === "all" ? "every one still reproducing" : "those filed high"}` : "off"}`;
477
+ }
478
+ const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
479
+ /**
480
+ * SARIF 2.1.0, for code-scanning dashboards. A UI check has no source file to
481
+ * point at, so each result's location is the route, relative to the checked
482
+ * app (the APP base id). Fingerprints come from the evidence, not the
483
+ * location, so a preview deployment on a new URL does not reopen every alert.
484
+ */
485
+ const RETEST_SARIF_RULE = {
486
+ id: "open-finding-reproduces",
487
+ name: "Open finding still reproduces",
488
+ shortDescription: { text: "Open finding still reproduces" },
489
+ help: { text: "A finding an earlier run filed and left open failed the same way when its page loaded again. It gates according to --gate-retests." },
490
+ defaultConfiguration: { level: "error" },
491
+ };
492
+ export function toSarif(result, toolVersion) {
493
+ const used = [...new Set(result.issues.map((i) => i.rule))];
494
+ const base = new URL(result.url);
495
+ const reproducing = retestGateFailures(result);
496
+ const refused = result.flows.filter((f) => f.outcome.status === "refused");
497
+ const issueResults = result.issues.map((i) => ({
498
+ ruleId: i.rule,
499
+ level: SARIF_LEVEL[i.severity],
500
+ message: {
501
+ 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})` : ""}`,
502
+ },
503
+ locations: i.routes.slice(0, 10).map((r) => r === "(shared chrome)"
504
+ ? {
505
+ physicalLocation: { artifactLocation: { uri: "", uriBaseId: "APP" } },
506
+ message: { text: "the app's shared shell, on every page that renders it" },
507
+ }
508
+ : { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
509
+ partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
510
+ }));
511
+ // Only the re-tests that fail the gate are results: a reviewer reading code scanning sees what failed it.
512
+ const retestResults = reproducing.map((r) => ({
513
+ ruleId: RETEST_SARIF_RULE.id,
514
+ // The severity the finding was filed at, as the page rules map theirs; an unknown one reads as the worst.
515
+ level: SARIF_LEVEL[CHECK_SEVERITIES.includes(r.severity) ? r.severity : "high"],
516
+ message: {
517
+ text: `Open finding still reproduces: [${r.severity}] ${r.title} (${r.id}) — ${r.signatures.join(", ")} (gated by --gate-retests ${result.settings.gateRetests})`,
518
+ },
519
+ locations: [{ physicalLocation: { artifactLocation: { uri: r.path.replace(/^\//, ""), uriBaseId: "APP" } } }],
520
+ partialFingerprints: { "scenescoutCheck/v1": createHash("sha256").update(`retest\u0000${r.id}`).digest("hex").slice(0, 32) },
521
+ }));
522
+ return {
523
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
524
+ version: "2.1.0",
525
+ runs: [
526
+ {
527
+ tool: {
528
+ driver: {
529
+ name: "SceneScout",
530
+ version: toolVersion,
531
+ informationUri: "https://github.com/brunoboto96/SceneScout",
532
+ rules: [
533
+ ...used.map((id) => ({
534
+ id,
535
+ name: CHECK_RULES[id].title,
536
+ shortDescription: { text: CHECK_RULES[id].title },
537
+ help: { text: CHECK_RULES[id].help },
538
+ defaultConfiguration: { level: SARIF_LEVEL[CHECK_RULES[id].severity] },
539
+ })),
540
+ ...(reproducing.length > 0 ? [RETEST_SARIF_RULE] : []),
541
+ ],
542
+ },
543
+ },
544
+ // A flow that could not run is not a result about the app: it is the tool saying its run was incomplete.
545
+ invocations: [
546
+ {
547
+ executionSuccessful: refused.length === 0,
548
+ toolExecutionNotifications: refused.map((f) => ({
549
+ level: "error",
550
+ descriptor: { id: "flow-could-not-run" },
551
+ message: { text: `Could not run: ${flowStepEvidence(f)}` },
552
+ })),
553
+ },
554
+ ],
555
+ originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
556
+ results: [...issueResults, ...retestResults],
557
+ },
558
+ ],
559
+ };
560
+ }
561
+ /**
562
+ * Markdown cells: a pipe or a newline in evidence would break the table it
563
+ * sits in. Backslashes are escaped first, or evidence ending in `\` would
564
+ * turn the pipe's escape into a literal backslash and the pipe back into a column.
565
+ */
566
+ function cell(text) {
567
+ return text
568
+ .replace(/\\/g, "\\\\")
569
+ .replace(/\|/g, "\\|")
570
+ .replace(/\s*\n\s*/g, " ");
571
+ }
572
+ /** A code span that the text cannot close: its fence is one backtick longer than any run of backticks inside it. */
573
+ function code(text) {
574
+ const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
575
+ const fence = "`".repeat(longest + 1);
576
+ const pad = longest > 0 ? " " : "";
577
+ return `${fence}${pad}${text}${pad}${fence}`;
578
+ }
579
+ function routeList(routes) {
580
+ const shown = routes.slice(0, 3).map(code);
581
+ return shown.join(", ") + (routes.length > 3 ? ` and ${routes.length - 3} more` : "");
582
+ }
583
+ /** The human report: the verdict first, then what failed it, then everything else. */
584
+ export function formatCheck(result) {
585
+ const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
586
+ const lines = [];
587
+ lines.push(`# SceneScout check`);
588
+ lines.push("");
589
+ 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"}`}`);
590
+ lines.push("");
591
+ const unaudited = unauditedRoutes(result.routes).length;
592
+ const failingText = retestsFailing > 0
593
+ ? `${failing} failing the gate (${failing - retestsFailing} issue(s), ${retestsFailing} re-tested finding(s))`
594
+ : `${failing} issue(s) at the gate's severity`;
595
+ lines.push((couldNotRun > 0 ? `**COULD NOT RUN** — ${couldNotRun} flow(s) had a step refused (see Flows); the rest ` : "") +
596
+ (passed
597
+ ? `${couldNotRun > 0 ? "passed" : "**PASSED**"} — ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
598
+ : `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
599
+ (unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : ""));
600
+ // Right under the verdict: what a green check was allowed to do is part of what it means.
601
+ lines.push("", `Settings — ${describeSettings(result)}`);
602
+ for (const sev of CHECK_SEVERITIES) {
603
+ const of = result.issues.filter((i) => i.severity === sev);
604
+ if (of.length === 0)
605
+ continue;
606
+ lines.push("", `## ${sev[0].toUpperCase()}${sev.slice(1)} (${of.length})`, "");
607
+ for (const i of of) {
608
+ 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)}`);
609
+ }
610
+ }
611
+ lines.push("", "## Routes", "", "| Route | Status | Controls | Issues |", "|---|---|---|---|");
612
+ for (const r of result.routes) {
613
+ const n = result.issues.filter((i) => i.routes.includes(r.path)).length;
614
+ const status = (r.loadError !== undefined ? "did not load" : r.loginRedirect ? `${r.status ?? "?"} → sign-in` : String(r.status ?? "?")) +
615
+ (r.auditError ? ` (design not measured: ${cell(r.auditError.slice(0, 80))})` : "");
616
+ // Plain text, not a code span: inside a table cell a code span keeps the backslashes cell() adds.
617
+ lines.push(`| ${cell(r.path)} | ${status} | ${r.elements} | ${n} |`);
618
+ }
619
+ if (result.unvisited.length > 0) {
620
+ lines.push("", `Not visited (over --max-routes): ${result.unvisited.slice(0, 20).map(code).join(", ")}${result.unvisited.length > 20 ? " …" : ""}`);
621
+ }
622
+ if (result.flows.length > 0 || result.skippedFlows.length > 0) {
623
+ lines.push("", `## Flows (${result.flows.length})`, "");
624
+ for (const f of result.flows) {
625
+ const o = f.outcome;
626
+ lines.push(o.status === "passed"
627
+ ? `- ✓ ${cell(f.name)} (${code(f.file)}): ${f.steps} step(s) passed`
628
+ : o.status === "refused"
629
+ ? `- ⊘ ${cell(f.name)} (${code(f.file)}): could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
630
+ : `- ✗ ${cell(f.name)} (${code(f.file)}): step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
631
+ if (f.refusedBackground.length > 0)
632
+ lines.push(` - refused background requests (beacons and pings, not charged to a step): ${f.refusedBackground.map(code).join(", ")}`);
633
+ if (f.websockets.length > 0)
634
+ lines.push(` - WebSocket connections opened (not covered by the write rule): ${f.websockets.map(code).join(", ")}`);
635
+ }
636
+ for (const s of result.skippedFlows)
637
+ lines.push(`- skipped ${code(s.file)}: ${cell(s.reason)}`);
638
+ }
639
+ if (result.retest && result.retest.open > 0) {
640
+ const { open, results } = result.retest;
641
+ lines.push("", `## Open findings re-tested (${results.length} of ${open})`, "");
642
+ const says = {
643
+ reproduces: "still reproduces",
644
+ "possibly-fixed": "possibly fixed: the page loaded and the request did not fail",
645
+ "not-reached": "not re-tested",
646
+ };
647
+ const gating = new Set(retestGateFailures(result));
648
+ for (const r of results) {
649
+ lines.push(`- [${r.severity}] ${cell(r.title)} (${r.id}) on ${code(r.path)}: ${says[r.verdict]}${r.note ? ` (${r.note})` : ""}${gating.has(r) ? " — **fails the gate**" : ""} — ${r.signatures.map(code).join(", ")}`);
650
+ }
651
+ if (result.retest.extraPages) {
652
+ lines.push("", `${result.retest.extraPages} page(s) were loaded only to re-test these findings; they are not in the routes above and no page rule was applied to them.`);
653
+ }
654
+ if (open > results.length) {
655
+ lines.push("", `${open - results.length} open finding(s) need an interaction to reproduce, or name no failed GET, so a check cannot re-test them; \`scout_verify\` in an exploratory run can.`);
656
+ }
657
+ lines.push("", `_Re-tests gate by --gate-retests ${result.settings.gateRetests}: ${result.settings.gateRetests === "never"
658
+ ? "none of them"
659
+ : result.settings.gateRetests === "all"
660
+ ? "every finding still reproducing"
661
+ : "a finding filed high that still reproduces"} fails the gate; "possibly fixed" and "not re-tested" never do. A check never writes the project's memory, so nothing is resolved here._`);
662
+ }
663
+ if (result.ignored.length > 0)
664
+ lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
665
+ lines.push("", result.flows.length > 0
666
+ ? "_A check visits pages, measures what loads and replays the flows saved for it. It does not explore, fill forms of its own accord or compare roles; an exploratory run does that._"
667
+ : "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that. Flows saved in .scenescout/flows are replayed._");
668
+ return lines.join("\n") + "\n";
669
+ }
670
+ /** The machine summary: stable keys for scripts and follow-up jobs. */
671
+ export function toSummaryJson(result, toolVersion) {
672
+ const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
673
+ return {
674
+ tool: "scenescout-check",
675
+ version: toolVersion,
676
+ url: result.url,
677
+ generatedAt: result.generatedAt,
678
+ mode: result.mode,
679
+ gate: { failOn: result.failOn, passed, failing, retestsFailing, couldNotRun },
680
+ settings: result.settings,
681
+ counts,
682
+ routes: result.routes.map((r) => ({
683
+ path: r.path,
684
+ status: r.status,
685
+ loaded: r.loadError === undefined,
686
+ loginRedirect: r.loginRedirect,
687
+ controls: r.elements,
688
+ ...(r.auditError !== undefined ? { designNotMeasured: r.auditError } : {}),
689
+ })),
690
+ unvisited: result.unvisited,
691
+ ignored: result.ignored,
692
+ flows: result.flows.map((f) => ({
693
+ name: f.name,
694
+ file: f.file,
695
+ steps: f.steps,
696
+ status: f.outcome.status,
697
+ ...(f.outcome.status === "passed" ? {} : { step: f.outcome.step, did: f.outcome.did, reason: f.outcome.reason, path: f.outcome.path }),
698
+ refusedBackground: f.refusedBackground,
699
+ websockets: f.websockets,
700
+ })),
701
+ skippedFlows: result.skippedFlows,
702
+ retest: result.retest,
703
+ issues: result.issues,
704
+ };
705
+ }