scenescout 3.10.0 → 3.11.1

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.
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Pure, so every rule here is table-tested.
22
22
  */
23
+ import { requestsPair, statedRequests, templatedPathsMatch } from "./lane.js";
23
24
  import { failingSignatures } from "./memory.js";
24
25
  /**
25
26
  * Upper edge of each confidence bucket. Five is enough to see a shape and few
@@ -226,45 +227,129 @@ export const MAX_UNFILED_NAMED = 10;
226
227
  * nobody — and it is cheapest to catch at the moment the planner folds the
227
228
  * report, while the lane's session is still open.
228
229
  *
229
- * Matched on the store's failing-endpoint signature where the evidence has
230
- * one, and on the evidence text otherwise. The text match is what the
231
- * calibration join refuses, because there a miss is scored against the lane;
232
- * here a miss only asks someone to check, which costs a look and nothing more.
230
+ * The check exists to catch unfiled defects, so a false "filed" costs more
231
+ * than a false alarm, which only asks someone to look. A decision counts as
232
+ * filed by one of three rules, tried in order:
233
+ *
234
+ * 1. Failing signatures. When the decision's evidence names one or more
235
+ * failing requests (the store's `failingSignatures`), it is filed only when
236
+ * EVERY one of them is covered by a finding's signature: same method, same
237
+ * status, same route, where a path-template segment (`{id}`, `:id`, `*`) on
238
+ * either side stands for one id segment (see `templatedPathsMatch`). So a
239
+ * finding filed as `GET /api/things/{id} 500` covers `GET /api/things/7 500`,
240
+ * and a decision naming two failing requests is not filed by a finding for
241
+ * one of them.
242
+ * 2. A request that did not fail. When the decision names a request with a
243
+ * 2xx or 3xx status, or none, and a finding names the same request
244
+ * (template-aware, same status), the decision is filed only when its
245
+ * evidence, with its path written as the finding's, is the finding's
246
+ * evidence exactly, restates part of it, or is the finding's evidence
247
+ * followed only by that request's other status (`… 200 as role=viewer
248
+ * (expected 403)` against `… 200 as role=viewer`). Word overlap is not enough
249
+ * there: a 200 names a resource, not a defect, and two different defects
250
+ * on one endpoint read alike. A failing signature in the decision does not
251
+ * block this when it names the same request (`POST /api/things/7/archive
252
+ * 200 (expected 403)` reads as a 403 to the store); one naming any other
253
+ * request does, and sends the decision to rule 1's verdict.
254
+ * 3. Text. Evidence naming no failing request is matched on its text: the
255
+ * same evidence, a restatement of part of it, or shared identifiers and
256
+ * near-identical wording. The calibration join refuses a text match,
257
+ * because there a miss is scored against the lane.
233
258
  */
234
259
  export function unfiledDefects(decisions, findings) {
235
- const keys = new Set();
260
+ const keys = [];
236
261
  const filed = [];
237
262
  for (const f of findings) {
238
263
  const text = `${f.evidence ?? ""} ${f.title}`;
264
+ const requests = statedRequests(f.evidence ?? "");
265
+ // The store's signatures, and the finding's own failing requests as
266
+ // written: the store reads no `*` in a path, so `GET /api/things/* 500`
267
+ // has no signature of its own.
239
268
  if (f.evidence)
240
269
  for (const key of joinKeys(f.evidence))
241
- keys.add(key);
242
- filed.push({ ids: identifiers(text), words: words(text), evidenceWords: words(f.evidence ?? ""), evidence: squash(f.evidence ?? "") });
270
+ keys.push(...statedRequests(key));
271
+ keys.push(...requests.filter((q) => q.status !== null && /^[45]/.test(q.status)));
272
+ filed.push({
273
+ ids: identifiers(text),
274
+ words: words(text),
275
+ evidenceWords: words(f.evidence ?? ""),
276
+ evidence: squash(f.evidence ?? ""),
277
+ raw: f.evidence ?? "",
278
+ requests,
279
+ });
243
280
  }
244
281
  const out = [];
245
282
  for (const d of decisions) {
246
283
  if (d.verdict !== "defect")
247
284
  continue;
248
285
  if (d.evidence) {
249
- const joined = [...joinKeys(d.evidence)];
250
- if (joined.some((k) => keys.has(k)))
286
+ const evidence = d.evidence;
287
+ const joined = [...joinKeys(evidence)];
288
+ const failing = joined.flatMap((sig) => statedRequests(sig));
289
+ if (failing.length > 0 && failing.every((sig) => keys.some((k) => requestsPair(sig, k))))
290
+ continue;
291
+ if (filed.some((f) => restatesRequest(evidence, failing, f)))
251
292
  continue;
252
293
  // A failing-endpoint signature is the store's own identity for a bug. When
253
- // the decision has one and no finding shares it, nothing else is a match.
294
+ // the decision has one and no finding covers it, nothing else is a match.
254
295
  if (joined.length > 0) {
255
296
  out.push(`${d.observation} — ${d.evidence}`);
256
297
  continue;
257
298
  }
258
- const ids = identifiers(d.evidence);
259
- const w = words(d.evidence);
260
- const text = squash(d.evidence);
261
- if (filed.some((f) => (text !== "" && f.evidence === text) || restates(text, f) || covers(ids, w, f)))
299
+ if (filed.some((f) => matchesText(evidence, f)))
262
300
  continue;
263
301
  }
264
302
  out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
265
303
  }
266
304
  return out;
267
305
  }
306
+ /** Rule 3: the same evidence, a restatement of part of it, or shared identifiers and wording. */
307
+ function matchesText(reported, f) {
308
+ const text = squash(reported);
309
+ return (text !== "" && f.evidence === text) || restates(text, f) || covers(identifiers(reported), words(reported), f);
310
+ }
311
+ /**
312
+ * Rule 2 of unfiledDefects: the decision names a request that did not fail,
313
+ * the finding names the same request, and the decision's evidence — its path
314
+ * written as the finding's, so a template and the id it stands for are not a
315
+ * difference — is the finding's evidence exactly, restates part of it, or
316
+ * adds only the status the request should have returned.
317
+ * `failing` is the decision's failing signatures; each must name this same
318
+ * request, or the pair is refused.
319
+ */
320
+ function restatesRequest(evidence, failing, f) {
321
+ for (const r of statedRequests(evidence)) {
322
+ if (r.status !== null && /^[45]/.test(r.status))
323
+ continue;
324
+ const paired = f.requests.find((q) => requestsPair(r, q));
325
+ if (!paired)
326
+ continue;
327
+ if (!failing.every((sig) => sig.method === r.method && templatedPathsMatch(sig.segments, r.segments)))
328
+ continue;
329
+ const aligned = squash(evidence.slice(0, r.start) + f.raw.slice(paired.start, paired.end) + evidence.slice(r.end));
330
+ if (aligned !== "" && (f.evidence === aligned || restates(aligned, f) || addsOnlyExpectedStatus(aligned, f.evidence, r.status)))
331
+ return true;
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * The status a lane adds after the call it quotes, to say what the call
337
+ * should have returned: "(expected 403)", "(reject 403)", "reject 403". One
338
+ * status, an optional verb, optional brackets, and nothing else.
339
+ */
340
+ const EXPECTED_STATUS_RE = /^[\s,;]*\(?\s*(?:(?:expected|expect|rejected|reject)\s*:?\s*)?([1-5]\d{2})\s*\)?[\s.,;]*$/;
341
+ /**
342
+ * Whether the reported evidence is the filed evidence followed by nothing but
343
+ * the status the request should have returned, which differs from the one it
344
+ * did. Anything else after it — another request, another role, another
345
+ * claim — is something the finding does not say, so it is not covered.
346
+ */
347
+ function addsOnlyExpectedStatus(reported, filed, status) {
348
+ if (filed === "" || !reported.startsWith(filed))
349
+ return false;
350
+ const expected = EXPECTED_STATUS_RE.exec(reported.slice(filed.length))?.[1];
351
+ return expected !== undefined && expected !== status;
352
+ }
268
353
  /**
269
354
  * Shortest reported evidence that counts as restating a filing it is part of.
270
355
  */
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
14
  import { BROWSER_ENGINES } from "../browsers.js";
15
+ import { flowStepEvidence } from "./flow.js";
15
16
  import { redactSecrets } from "./memory.js";
16
17
  import { httpErrorDetail, httpStatusOf } from "./oracles.js";
17
18
  export const CHECK_SEVERITIES = ["high", "medium", "low"];
@@ -74,6 +75,11 @@ export const CHECK_RULES = {
74
75
  "tiny-target": { severity: "low", title: "Small click target", help: "Below the 24px WCAG 2.2 target-size minimum." },
75
76
  "clipped-text": { severity: "low", title: "Text clipped", help: "Text is wider than its box and cut off without an ellipsis." },
76
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
+ },
77
83
  };
78
84
  export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
79
85
  const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
@@ -134,8 +140,13 @@ export function geometryRule(line) {
134
140
  * Turn per-route measurements into issues, one per distinct fact. The same
135
141
  * failing request on ten pages is one issue seen on ten routes: the shared
136
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.
137
148
  */
138
- export function issuesFromRoutes(routes, origin, ignore = []) {
149
+ export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
139
150
  const byKey = new Map();
140
151
  const add = (rule, evidence, route, opts = {}) => {
141
152
  if (ignore.includes(rule))
@@ -188,6 +199,13 @@ export function issuesFromRoutes(routes, origin, ignore = []) {
188
199
  for (const d of r.design)
189
200
  add(d.rule, d.detail, d.chrome ? "(shared chrome)" : route);
190
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
+ }
191
209
  return [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
192
210
  }
193
211
  /**
@@ -242,6 +260,18 @@ export function redactRoutes(routes) {
242
260
  violations: r.violations.map((v) => ({ ...v, url: clean(v.url) })),
243
261
  }));
244
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
+ }
245
275
  export function countBySeverity(issues) {
246
276
  const counts = { high: 0, medium: 0, low: 0 };
247
277
  for (const i of issues)
@@ -256,12 +286,36 @@ export function gateFailures(issues, failOn) {
256
286
  }
257
287
  /** Exit codes, stable for scripts: a gate failure is distinguishable from the check not running. */
258
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 };
259
298
  /**
260
299
  * Every `--option` `scenescout check` accepts. The GitHub Action at the
261
300
  * repository root mirrors this list input for input, and check-test fails when
262
301
  * the two drift apart.
263
302
  */
264
- export const CHECK_OPTION_NAMES = ["project", "out", "fail-on", "mode", "storage-state", "browser", "max-routes", "paths", "ignore"];
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
+ ];
265
319
  export const MAX_CHECK_ROUTES = 150;
266
320
  export const DEFAULT_CHECK_ROUTES = 50;
267
321
  /** Parse `scenescout check` arguments. Every mistake is a sentence, never a half-configured run. */
@@ -306,7 +360,7 @@ export function parseCheckArgs(args, cwd) {
306
360
  return { ok: false, error: `--fail-on must be one of ${FAIL_ON.join(", ")}` };
307
361
  const mode = flags.get("mode") ?? "read-only";
308
362
  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" };
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" };
310
364
  }
311
365
  const browser = flags.get("browser");
312
366
  if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
@@ -332,6 +386,25 @@ export function parseCheckArgs(args, cwd) {
332
386
  const unknownRules = ignore.filter((r) => !(r in CHECK_RULES));
333
387
  if (unknownRules.length > 0)
334
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(", ")}` };
335
408
  const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
336
409
  return {
337
410
  ok: true,
@@ -346,12 +419,61 @@ export function parseCheckArgs(args, cwd) {
346
419
  maxRoutes,
347
420
  ...(paths ? { paths } : {}),
348
421
  ignore: ignore,
422
+ ...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
423
+ retest: retest === "on",
424
+ flowWrites,
425
+ onRefusedStep,
426
+ gateRetests,
349
427
  },
350
428
  };
351
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
+ }
352
460
  export function summarise(result) {
353
- const failing = gateFailures(result.issues, result.failOn).length;
354
- return { passed: failing === 0, counts: countBySeverity(result.issues), failing };
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"}`;
355
477
  }
356
478
  const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
357
479
  /**
@@ -360,9 +482,43 @@ const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
360
482
  * app (the APP base id). Fingerprints come from the evidence, not the
361
483
  * location, so a preview deployment on a new URL does not reopen every alert.
362
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
+ };
363
492
  export function toSarif(result, toolVersion) {
364
493
  const used = [...new Set(result.issues.map((i) => i.rule))];
365
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
+ }));
366
522
  return {
367
523
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
368
524
  version: "2.1.0",
@@ -373,30 +529,31 @@ export function toSarif(result, toolVersion) {
373
529
  name: "SceneScout",
374
530
  version: toolVersion,
375
531
  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
- })),
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
+ ],
383
542
  },
384
543
  },
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})` : ""}`,
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
+ })),
391
553
  },
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
- })),
554
+ ],
555
+ originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
556
+ results: [...issueResults, ...retestResults],
400
557
  },
401
558
  ],
402
559
  };
@@ -425,17 +582,23 @@ function routeList(routes) {
425
582
  }
426
583
  /** The human report: the verdict first, then what failed it, then everything else. */
427
584
  export function formatCheck(result) {
428
- const { passed, counts, failing } = summarise(result);
585
+ const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
429
586
  const lines = [];
430
587
  lines.push(`# SceneScout check`);
431
588
  lines.push("");
432
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"}`}`);
433
590
  lines.push("");
434
591
  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`) +
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`) +
438
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)}`);
439
602
  for (const sev of CHECK_SEVERITIES) {
440
603
  const of = result.issues.filter((i) => i.severity === sev);
441
604
  if (of.length === 0)
@@ -456,21 +619,65 @@ export function formatCheck(result) {
456
619
  if (result.unvisited.length > 0) {
457
620
  lines.push("", `Not visited (over --max-routes): ${result.unvisited.slice(0, 20).map(code).join(", ")}${result.unvisited.length > 20 ? " …" : ""}`);
458
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
+ }
459
663
  if (result.ignored.length > 0)
460
664
  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._");
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._");
462
668
  return lines.join("\n") + "\n";
463
669
  }
464
670
  /** The machine summary: stable keys for scripts and follow-up jobs. */
465
671
  export function toSummaryJson(result, toolVersion) {
466
- const { passed, counts, failing } = summarise(result);
672
+ const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
467
673
  return {
468
674
  tool: "scenescout-check",
469
675
  version: toolVersion,
470
676
  url: result.url,
471
677
  generatedAt: result.generatedAt,
472
678
  mode: result.mode,
473
- gate: { failOn: result.failOn, passed, failing },
679
+ gate: { failOn: result.failOn, passed, failing, retestsFailing, couldNotRun },
680
+ settings: result.settings,
474
681
  counts,
475
682
  routes: result.routes.map((r) => ({
476
683
  path: r.path,
@@ -482,6 +689,17 @@ export function toSummaryJson(result, toolVersion) {
482
689
  })),
483
690
  unvisited: result.unvisited,
484
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,
485
703
  issues: result.issues,
486
704
  };
487
705
  }
@@ -4,6 +4,17 @@ const HEX_RE = /^[0-9a-f]{12,}$/i;
4
4
  const NUM_RE = /^\d+$/;
5
5
  /** Query params that name a UI state (a different screen), not transient data. */
6
6
  const UI_STATE_PARAM_RE = /^(tab|view|mode|step|panel|section)$/i;
7
+ /**
8
+ * Whether a path segment is an id that route identity collapses to `:id`: a
9
+ * UUID or long hex string anywhere, a number from the second real segment on.
10
+ * `index` counts as `path.split("/")` does, so the first real segment of a
11
+ * leading-slash path is 1 (see the note on numeric first segments below).
12
+ */
13
+ export function isIdSegment(seg, index) {
14
+ if (UUID_RE.test(seg) || HEX_RE.test(seg))
15
+ return true;
16
+ return NUM_RE.test(seg) && index > 1;
17
+ }
7
18
  /**
8
19
  * Normalize a URL into a route-class identity:
9
20
  * - path ids collapse (/orders/123 → /orders/:id)
@@ -38,13 +49,7 @@ export function normalizePath(rawUrl) {
38
49
  }
39
50
  // segments[0] is always "" for a leading-slash path, so the first REAL
40
51
  // segment is index 1 — that is the one a bare numeric page occupies.
41
- const segments = path.split("/").map((seg, i) => {
42
- if (UUID_RE.test(seg) || HEX_RE.test(seg))
43
- return ":id";
44
- if (NUM_RE.test(seg) && i > 1)
45
- return ":id";
46
- return seg;
47
- });
52
+ const segments = path.split("/").map((seg, i) => (isIdSegment(seg, i) ? ":id" : seg));
48
53
  let normalized = segments.join("/") || "/";
49
54
  if (normalized === "")
50
55
  normalized = "/";