scenescout 3.3.0 → 3.5.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.
@@ -92,8 +92,17 @@ export function planLanes(routes, laneCount, opts = {}) {
92
92
  objective: laneObjective(lane.modules, opts.goal),
93
93
  modules: lane.modules,
94
94
  routes: lane.routes,
95
+ landing: landingOf(lane.routes),
95
96
  }));
96
97
  }
98
+ /**
99
+ * The first of a lane's routes a browser can open as it stands. A route the
100
+ * engine normalised (`/orders/:id`) is a pattern, not an address; a lane with
101
+ * nothing else lands on the app's root and navigates from there.
102
+ */
103
+ export function landingOf(routes) {
104
+ return routes.find((r) => !/(^|\/)[:*]|\[[^\]]+\]/.test(r)) ?? "/";
105
+ }
97
106
  function laneObjective(modules, goal) {
98
107
  const owned = modules.length === 1 ? modules[0] : `${modules.slice(0, -1).join(", ")} and ${modules[modules.length - 1]}`;
99
108
  const what = `Own ${owned}`;
@@ -114,19 +123,25 @@ export function formatBriefs(briefs, opts = {}) {
114
123
  const lines = [
115
124
  `LANE PLAN — ${briefs.length} lane(s) over ${briefs.reduce((n, b) => n + b.routes.length, 0)} route(s).`,
116
125
  ``,
117
- `Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel:`,
118
- ` scout_attach { session: "<lane>", url, projectPath, mode: "${mode}"${opts.role ? `, storageStatePath: "<${opts.role}>"` : ""}, objective: "<objective>" }`,
126
+ `Give each lane its own agent. Every lane attaches with its own session name, so the browsers run genuinely in parallel, and lands on its own first route rather than the home page:`,
127
+ ` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${opts.role ? `, storageStatePath: "<${opts.role}>"` : ""}, objective: "<objective>" }`,
119
128
  ``,
120
- `Rules to pass on, both of which a hand-written brief tends to drop:`,
121
- ` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way.`,
129
+ `Rules to pass on, which a hand-written brief tends to drop:`,
130
+ ` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
122
131
  ` · Every acting tool takes a \`task\`. A lane that acts with none is refused, and the person watching the live view would otherwise see a browser clicking through their app with nothing to say why.`,
123
- ` · A lane reports back with scout_lane_report, not prose. Call scout_lane_report with no reply to get the instruction to put in its prompt.`,
132
+ ` · File each defect with scout_finding the moment it is judged, before writing the report. A defect only in the report is not in the run's report.`,
133
+ ` · Before calling a list empty, stale or stuck, read the status of the request behind it: a correct empty result and a failed one can render identically.`,
134
+ ` · On a create form, submit one markup-shaped value, then open where that record is listed. Any lane's session catches it rendering as markup, so the list may belong to another lane.`,
135
+ ` · When a control is withheld from this role, call the endpoint behind it with scout_request: hiding a button is not enforcing a rule. When a sort or filter runs, check the result is what it claims.`,
136
+ ` · Check scout_coverage before finishing.`,
137
+ ` · A lane reports back with scout_lane_report, not prose, and does NOT close its session: the planner folds the report, which checks each judged defect was filed, then closes it. Call scout_lane_report with no reply to get the instruction to put in its prompt.`,
124
138
  ``,
125
139
  ];
126
140
  for (const b of briefs) {
127
141
  lines.push(`── ${b.lane} ──`);
128
142
  lines.push(`objective: ${b.objective}`);
129
143
  lines.push(`owns: ${b.modules.join(", ")} (${b.routes.length} route(s))`);
144
+ lines.push(`landing: ${b.landing}`);
130
145
  lines.push(`routes: ${b.routes.slice(0, 20).join(", ")}${b.routes.length > 20 ? ` … and ${b.routes.length - 20} more` : ""}`);
131
146
  lines.push(``);
132
147
  }
@@ -7,7 +7,7 @@ import { normalizeTask } from "./task.js";
7
7
  import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
8
8
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
9
9
  import { AuthLossTracker } from "./authloss.js";
10
- import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
10
+ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, displayName, missingName, } from "./collector.js";
11
11
  import { OracleMonitor, formatViolations } from "./oracles.js";
12
12
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
13
13
  import { formatJourney, measureJourney } from "./journey.js";
@@ -20,7 +20,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
20
20
  import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
21
21
  import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
22
22
  import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
23
- import { destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, isAuthExempt } from "./policy.js";
23
+ import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, isAuthExempt, } from "./policy.js";
24
24
  import { scanProject } from "../scan.js";
25
25
  import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
26
26
  import { acceptMatches, generatedUpload } from "./fixtures.js";
@@ -206,7 +206,7 @@ export class BrowserEngine {
206
206
  const list = this.newlyCreated.splice(0);
207
207
  return `\n(created: ${list.join(", ")} — this session may edit or delete ${list.length === 1 ? "it" : "them"})`;
208
208
  }
209
- /** Design audits run this session — the report gate requires at least one. */
209
+ /** Design audits run by this session. The report gate counts the whole run's, on the shared store (MemoryStore.auditsThisRun). */
210
210
  designAuditCount = 0;
211
211
  /** Active task-efficiency measurement (scout_journey), if any. */
212
212
  journey = null;
@@ -240,8 +240,9 @@ export class BrowserEngine {
240
240
  *
241
241
  * The fetch runs IN the page, so it passes through the same interception the
242
242
  * write policy is enforced on: a safe-write session cannot reach past the
243
- * policy by calling an endpoint instead of clicking it. The refusal comes
244
- * back as the policy's own error, exactly as it would for a click.
243
+ * policy by calling an endpoint instead of clicking it. The policy answers
244
+ * the fetch with its stand-in 403, and the result says so rather than
245
+ * printing it as the server's status.
245
246
  */
246
247
  async apiRequest(input) {
247
248
  const page = this.requirePage();
@@ -260,23 +261,41 @@ export class BrowserEngine {
260
261
  let raw;
261
262
  try {
262
263
  raw = (await page.evaluate(script));
264
+ this.forgetReplay(method.method, target.url);
263
265
  }
264
266
  catch (err) {
265
- // A blocked request rejects the fetch inside the page. That is the write
266
- // policy doing its job, not an app fault, and it is reported as such.
267
+ // The page could not run the fetch at all (a navigation mid-call, a
268
+ // closed page). The policy answers rather than rejects, so this is not it.
267
269
  const message = err instanceof Error ? err.message : String(err);
268
270
  this.logAction({ action: "request", target: `${method.method} ${input.path}`, url: page.url(), result: `blocked: ${message.split("\n")[0]}` });
269
- return `${method.method} ${input.path} — the request did not complete: ${message.split("\n")[0]}\nIn a write-limited mode this is usually the policy refusing it, which is the engine's safety net and not a finding about the app.`;
271
+ return `${method.method} ${input.path} — the request did not complete: ${message.split("\n")[0]}`;
270
272
  }
271
273
  const result = toReplayResult(raw);
272
274
  this.logAction({
273
275
  action: "request",
274
276
  target: `${method.method} ${input.path}`,
275
277
  url: page.url(),
276
- result: replaySignature(method.method, result.url, result.status),
278
+ // Not a status signature: the trail must not record the stand-in as the server's answer.
279
+ result: result.refusedByPolicy ? `blocked: write policy (${result.refusedByPolicy})` : replaySignature(method.method, result.url, result.status),
277
280
  });
278
281
  return formatReplay(method.method, result);
279
282
  }
283
+ /**
284
+ * Take the replayed request back out of the contradiction ledger. It was the
285
+ * agent's call, not the page's, so whatever the page says next is not its
286
+ * answer — and left in, a replay the policy refused was blamed on the next
287
+ * click as that click's false success, in place of the click's own request.
288
+ * Only this request: anything else the page fetched meanwhile stays.
289
+ */
290
+ forgetReplay(method, url) {
291
+ for (let i = this.watchedResponses.length - 1; i >= 0; i -= 1) {
292
+ const r = this.watchedResponses[i];
293
+ if (r.method === method && r.url === url) {
294
+ this.watchedResponses.splice(i, 1);
295
+ return;
296
+ }
297
+ }
298
+ }
280
299
  /**
281
300
  * The frame field for a log entry, or nothing. Spread into logAction so a
282
301
  * caller that is not going through afterAction can still record what the
@@ -419,7 +438,8 @@ export class BrowserEngine {
419
438
  watchedResponses = [];
420
439
  static MAX_WATCHED_RESPONSES = 60;
421
440
  /**
422
- * Requests the write policy aborted, by identity. The request event fires for
441
+ * Requests the write policy stopped, by identity — whether it dropped them or
442
+ * answered them with a stand-in refusal. The request event fires for
423
443
  * every non-GET the page ATTEMPTS, before the route handler decides its fate,
424
444
  * so without this the same DELETE was reported twice with opposite meanings:
425
445
  * "server state may have mutated despite read-only mode" and "WRITE-POLICY
@@ -427,11 +447,27 @@ export class BrowserEngine {
427
447
  * meet different fates, and a blocked /items/7/archive must not hide an
428
448
  * allowed POST /items that shares its prefix.
429
449
  */
430
- abortedByPolicy = new WeakSet();
431
- /** Markup-shaped values this session typed, so every later page can be checked for them rendering as elements. */
432
- probes = [];
433
- /** Injections already reported, by payload and route, so a page is not reported on every snapshot. */
434
- injectionsReported = new Set();
450
+ refusedByPolicy = new WeakSet();
451
+ /**
452
+ * Markup-shaped values typed by any session of this run, so every later page
453
+ * — in this browser or another lane's — can be checked for them rendering as
454
+ * elements. On the shared MemoryStore, like ownership; local before attach.
455
+ */
456
+ get probes() {
457
+ return this.memory?.probes ?? this.localProbes;
458
+ }
459
+ set probes(list) {
460
+ if (this.memory)
461
+ this.memory.probes = list;
462
+ else
463
+ this.localProbes = list;
464
+ }
465
+ localProbes = [];
466
+ /** Injections already reported, by payload and route, so a page is not reported on every snapshot — nor by a second lane that opens it. */
467
+ get injectionsReported() {
468
+ return this.memory?.injectionsReported ?? this.localInjectionsReported;
469
+ }
470
+ localInjectionsReported = new Set();
435
471
  /**
436
472
  * Remember a typed value when it holds an element worth watching for. What
437
473
  * to type is the agent's choice; this only watches for the outcome. The
@@ -459,7 +495,7 @@ export class BrowserEngine {
459
495
  * element on the current page? Runs wherever violations are drained, so the
460
496
  * finding reaches the agent in the result of the action that revealed it.
461
497
  */
462
- /** Record one answered request for the contradiction rules. Policy-aborted ones are marked, never dropped: the rules need to know they were ours. */
498
+ /** Record one answered request for the contradiction rules. Ones the policy stopped are marked, never dropped: the rules need to know they were ours. */
463
499
  watchResponse(req, status) {
464
500
  if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
465
501
  return;
@@ -468,7 +504,7 @@ export class BrowserEngine {
468
504
  url: req.url(),
469
505
  status,
470
506
  resourceType: req.resourceType(),
471
- blockedByPolicy: this.abortedByPolicy.has(req),
507
+ blockedByPolicy: this.refusedByPolicy.has(req),
472
508
  });
473
509
  }
474
510
  /**
@@ -507,18 +543,22 @@ export class BrowserEngine {
507
543
  contradictionsReported = new Set();
508
544
  async scanForInjections() {
509
545
  const page = this.page;
510
- if (!page || page.isClosed() || this.probes.length === 0)
546
+ // One copy for the whole scan: the list is shared with every lane, and
547
+ // another lane typing a payload during the await below would shift the
548
+ // indices the hits are matched back to.
549
+ const probes = this.probes;
550
+ if (!page || page.isClosed() || probes.length === 0)
511
551
  return;
512
552
  const url = page.url();
513
553
  let hits;
514
554
  try {
515
- hits = (await page.evaluate(probeScript(probeQueries(this.probes))));
555
+ hits = (await page.evaluate(probeScript(probeQueries(probes))));
516
556
  }
517
557
  catch {
518
558
  // A page mid-navigation has no DOM to ask; the next drain looks again.
519
559
  return;
520
560
  }
521
- for (const found of newInjections(this.probes, hits, url, this.injectionsReported)) {
561
+ for (const found of newInjections(probes, hits, url, this.injectionsReported)) {
522
562
  this.oracles.noteInjection(describeInjection(found.probe, url, found.outer), url);
523
563
  }
524
564
  }
@@ -571,7 +611,7 @@ export class BrowserEngine {
571
611
  this.projectDirNote = ` (its real path could not be resolved: ${err instanceof Error ? err.message : String(err)} — a symlinked project path may be wrongly refused)`;
572
612
  }
573
613
  this.oracles = new OracleMonitor();
574
- this.oracles.setPolicyAbortCheck((req) => this.abortedByPolicy.has(req));
614
+ this.oracles.setPolicyRefusalCheck((req) => this.refusedByPolicy.has(req));
575
615
  this.lastSnap = null;
576
616
  this.designAuditCount = 0;
577
617
  // The engine learns the route list itself so completion is an objective,
@@ -642,7 +682,7 @@ export class BrowserEngine {
642
682
  // not just looked at — the difference between visited and tested.
643
683
  //
644
684
  // Only for requests the policy will actually let through. This event
645
- // fires BEFORE the route handler aborts a blocked one, so counting it
685
+ // fires BEFORE the route handler stops a blocked one, so counting it
646
686
  // here let a REFUSED destructive POST mark the route as mutated — a form
647
687
  // that was never submitted reading as tested, in read-only mode where by
648
688
  // definition nothing is.
@@ -711,11 +751,16 @@ export class BrowserEngine {
711
751
  }
712
752
  return route.continue();
713
753
  }
754
+ const answered = answersWithRefusal(req.resourceType());
714
755
  if (this.blockedRequests.length < 20)
715
- this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}` });
756
+ this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered });
716
757
  this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}`, url: this.page?.url() ?? "" });
717
- this.abortedByPolicy.add(req);
758
+ this.refusedByPolicy.add(req);
718
759
  this.oracles.notePolicyBlock();
760
+ // A script's request is answered with a refusal, so the page's handling
761
+ // of one actually runs; a navigation is dropped (policy.ts says why).
762
+ if (answered)
763
+ return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"]));
719
764
  return route.abort("blockedbyclient");
720
765
  });
721
766
  }
@@ -1036,7 +1081,7 @@ export class BrowserEngine {
1036
1081
  memory.wasExercised(fp, el.key) ? "done" : null,
1037
1082
  el.href ? `href=${el.href.slice(0, 60)}` : null,
1038
1083
  ].filter(Boolean);
1039
- return `${el.ref} ${el.role} "${el.name || "(unnamed)"}"${flags.length ? ` [${flags.join(", ")}]` : ""}`;
1084
+ return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}`;
1040
1085
  };
1041
1086
  // Diff mode: when re-snapshotting the same route, report only what
1042
1087
  // changed — same idea as UI reconciliation, applied to agent context.
@@ -1254,9 +1299,13 @@ export class BrowserEngine {
1254
1299
  .map((e) => this.lateMark(e))
1255
1300
  .join("; ");
1256
1301
  const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
1302
+ const answered = this.blockedRequests.some((e) => e.answered);
1257
1303
  this.blockedRequests = [];
1258
1304
  return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
1259
1305
  `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
1306
+ (answered
1307
+ ? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
1308
+ : "") +
1260
1309
  (this.mode === "observe"
1261
1310
  ? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target.`
1262
1311
  : this.mode === "read-only"
@@ -1281,7 +1330,7 @@ export class BrowserEngine {
1281
1330
  // to see the DUPLICATES that the reporting dedup below intentionally hides.
1282
1331
  this.lastActionMutationSigs = this.mutationRequests.map((e) => e.sig);
1283
1332
  const fresh = this.mutationRequests
1284
- .filter((entry) => !this.abortedByPolicy.has(entry.req))
1333
+ .filter((entry) => !this.refusedByPolicy.has(entry.req))
1285
1334
  .filter((entry) => {
1286
1335
  const key = entry.sig.split("?")[0];
1287
1336
  if (this.reportedMutationSigs.has(key))
@@ -2009,7 +2058,7 @@ export class BrowserEngine {
2009
2058
  await this.scanForContradictions();
2010
2059
  const violations = this.oracles.drain();
2011
2060
  const deadEnd = elements.length === 0;
2012
- const unnamed = elements.filter((el) => !el.name).length;
2061
+ const unnamed = elements.filter(missingName).length;
2013
2062
  const missingTestid = elements.filter((el) => !el.testid && !el.disabled).length;
2014
2063
  const flags = [loginRedirect ? "AUTH-REDIRECT" : null, deadEnd ? "DEAD-END" : null, violations.length > 0 ? `${violations.length}⚠` : null].filter(Boolean);
2015
2064
  summary.push(`${path} — ${status} · ${elements.length} el` +
@@ -2276,6 +2325,8 @@ export class BrowserEngine {
2276
2325
  const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
2277
2326
  payload.page.focusSamples = await probeFocusIndicators(page);
2278
2327
  this.designAuditCount += 1;
2328
+ if (this.memory)
2329
+ this.memory.auditsThisRun += 1;
2279
2330
  // The census is built from previous audits, so the first few pages of a run
2280
2331
  // score with chrome included and later ones don't. That is the same warm-up
2281
2332
  // the coverage census has: nothing is knowable as "shared" until it has been
@@ -2506,6 +2557,12 @@ export class BrowserEngine {
2506
2557
  }
2507
2558
  }
2508
2559
  async close() {
2560
+ // Marks the end of the time this session held a browser, so the pace
2561
+ // section can say how long it was held with nothing happening. Only when a
2562
+ // browser is actually open: attach() closes first, and a close of nothing
2563
+ // is not an event.
2564
+ if (this.page)
2565
+ this.logAction({ action: "close", url: this.page.isClosed() ? "" : this.page.url() });
2509
2566
  // Pending debounced coverage writes must land before the process can exit.
2510
2567
  try {
2511
2568
  this.memory?.flush();
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Whether a lane's confidence means anything.
3
+ *
4
+ * `lane.ts` tells every lane its confidence must be calibrated — "0.5 means a
5
+ * coin flip, 0.95 means you would bet on it" — and then the number was
6
+ * averaged into one line and thrown away. Nothing was stored, so nothing could
7
+ * ever be checked, and a confidence nobody checks is decoration. Decision-only
8
+ * models report an expected calibration error for exactly this reason; asking
9
+ * for calibration without measuring it is the half of the idea that costs
10
+ * nothing and buys nothing.
11
+ *
12
+ * WHAT THIS MEASURES, precisely, because the number is easy to over-read: a
13
+ * lane says "defect" about an observation and attaches a machine signature.
14
+ * The run either went on to file a finding carrying that signature or it did
15
+ * not. That is agreement between the lane and the bar the run actually
16
+ * applied — NOT ground truth about the app. A lane can be perfectly calibrated
17
+ * against a planner that files the wrong things. Where a finding has since
18
+ * been re-tested through `scout_verify`, the verdict is reported beside it,
19
+ * and that IS evidence about the app.
20
+ *
21
+ * Pure, so every rule here is table-tested.
22
+ */
23
+ import { failingSignatures } from "./memory.js";
24
+ /**
25
+ * Upper edge of each confidence bucket. Five is enough to see a shape and few
26
+ * enough that each holds a usable count on a run of a few dozen decisions;
27
+ * twenty buckets of one decision each measure nothing.
28
+ */
29
+ export const BUCKET_EDGES = [0.2, 0.4, 0.6, 0.8, 1.0];
30
+ /**
31
+ * Which bucket a confidence falls in. The top bucket is closed so 1.0 has
32
+ * somewhere to go.
33
+ *
34
+ * A value that is not a number lands in the LOWEST bucket, not the highest.
35
+ * The schema refuses those, but stored history is read back without one, and
36
+ * falling through the comparisons put a NaN in the 0.8–1.0 bucket — a
37
+ * confidence nobody stated, reported as near-certainty.
38
+ */
39
+ export function bucketOf(confidence) {
40
+ if (!Number.isFinite(confidence))
41
+ return 0;
42
+ const c = Math.min(1, Math.max(0, confidence));
43
+ for (let i = 0; i < BUCKET_EDGES.length; i += 1) {
44
+ if (c <= BUCKET_EDGES[i])
45
+ return i;
46
+ }
47
+ return BUCKET_EDGES.length - 1;
48
+ }
49
+ export function bucketLabel(i) {
50
+ const low = i === 0 ? 0 : BUCKET_EDGES[i - 1];
51
+ return `${low.toFixed(1)}–${BUCKET_EDGES[i].toFixed(1)}`;
52
+ }
53
+ /**
54
+ * The keys a piece of evidence can be joined on, or nothing when it cannot be
55
+ * joined at all.
56
+ *
57
+ * Findings MERGE: the store treats `GET /api/things/3 500` and
58
+ * `GET /api/things/7 500` as one bug, because the path normalises to
59
+ * `/api/things/:id`. Joining on the literal string reported five lane
60
+ * decisions about one endpoint as one filed and four dropped — the lanes
61
+ * understated fivefold, and the number read as a finding about them rather
62
+ * than a bug in this join. So the join uses the store's own rule.
63
+ *
64
+ * Evidence carrying no failure signature returns NOTHING rather than falling
65
+ * back to its literal text. That fallback looked harmless and was the single
66
+ * worst thing here: `500 on GET /api/r0` — ordinary English, and whichever
67
+ * agent wrote the finding chose the word order — produced a key that matched
68
+ * nothing, and a lane that was right about a bug that WAS filed published an
69
+ * expected calibration error of 0.90. An unjoinable decision is now counted
70
+ * and disclosed as unjoinable, not scored as a lane being wrong.
71
+ */
72
+ export function joinKeys(evidence) {
73
+ return failingSignatures(evidence);
74
+ }
75
+ /** A confidence as stored may be anything; the schema guards the wire, not the file. */
76
+ function usableConfidence(value) {
77
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
78
+ }
79
+ /**
80
+ * Join lane decisions to what the run filed.
81
+ *
82
+ * Only a "defect" carrying a signature has a checkable outcome. An "unsure" is
83
+ * a request to look closer rather than a claim, and a decision with no
84
+ * signature cannot be joined to anything — counting either would measure the
85
+ * lane's willingness to attach evidence, not its judgement.
86
+ */
87
+ export function calibrate(decisions, findings) {
88
+ const filedKeys = new Map();
89
+ for (const f of findings) {
90
+ if (!f.evidence)
91
+ continue;
92
+ // A finding verified as still present is the most informative match, so it
93
+ // wins a key two findings share; otherwise first write wins and the result
94
+ // does not depend on the order findings came back in.
95
+ for (const key of joinKeys(f.evidence)) {
96
+ const prev = filedKeys.get(key);
97
+ if (!prev || (f.verdict && f.verifiedAt && !(prev.verdict && prev.verifiedAt)))
98
+ filedKeys.set(key, f);
99
+ }
100
+ }
101
+ const claims = decisions.filter((d) => d.verdict === "defect" && d.evidence !== null);
102
+ const checkable = claims.filter((d) => usableConfidence(d.confidence) !== null && joinKeys(d.evidence).size > 0);
103
+ const unjoinable = claims.length - checkable.length;
104
+ if (checkable.length === 0)
105
+ return unjoinable > 0 ? { checkable: 0, filed: 0, stated: 0, buckets: [], ece: 0, verified: { present: 0, gone: 0, changed: 0 }, unjoinable } : null;
106
+ const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, hits: 0 }));
107
+ const verified = { present: 0, gone: 0, changed: 0 };
108
+ const matched = new Map();
109
+ let filed = 0;
110
+ let stated = 0;
111
+ for (const d of checkable) {
112
+ let match;
113
+ for (const key of joinKeys(d.evidence)) {
114
+ match = filedKeys.get(key);
115
+ if (match)
116
+ break;
117
+ }
118
+ const hit = match !== undefined;
119
+ const confidence = usableConfidence(d.confidence);
120
+ const b = buckets[bucketOf(confidence)];
121
+ b.n += 1;
122
+ b.conf += confidence;
123
+ if (hit)
124
+ b.hits += 1;
125
+ if (hit)
126
+ filed += 1;
127
+ stated += confidence;
128
+ // By finding, not by decision: several decisions can match one finding,
129
+ // and this sentence counts findings.
130
+ if (match)
131
+ matched.set(match.id, match);
132
+ }
133
+ for (const f of matched.values()) {
134
+ // A verdict read from disk is not guaranteed to be one of the three.
135
+ if (f.verifiedAt && f.verdict && f.verdict in verified)
136
+ verified[f.verdict] += 1;
137
+ }
138
+ const n = checkable.length;
139
+ let ece = 0;
140
+ const out = [];
141
+ buckets.forEach((b, i) => {
142
+ if (b.n === 0)
143
+ return;
144
+ const meanConf = b.conf / b.n;
145
+ const rate = b.hits / b.n;
146
+ ece += (b.n / n) * Math.abs(meanConf - rate);
147
+ out.push({ label: bucketLabel(i), decisions: b.n, stated: meanConf, filed: rate });
148
+ });
149
+ return { checkable: n, filed, stated: stated / n, buckets: out, ece, verified, unjoinable };
150
+ }
151
+ const pct = (x) => `${Math.round(x * 100)}%`;
152
+ /**
153
+ * The report section. It leads with what the number is not, because "ECE 0.24"
154
+ * above a table of percentages reads as a verdict on the app rather than on
155
+ * the lanes that judged it.
156
+ */
157
+ export function formatCalibration(c) {
158
+ if (!c)
159
+ return [];
160
+ if (c.checkable < MIN_FOR_A_VERDICT) {
161
+ // Suppress the NUMBER, not the fact. A run that recorded seven decisions
162
+ // and a run that recorded none look identical when the section simply
163
+ // vanishes, and the reader concludes the feature is broken — which is this
164
+ // project's own definition of a silent path.
165
+ const had = c.checkable + c.unjoinable;
166
+ if (had === 0)
167
+ return [];
168
+ return [
169
+ `## How well the lanes judged`,
170
+ ``,
171
+ `Not enough to say yet: ${c.checkable} lane decision(s) could be checked${c.unjoinable > 0 ? ` (and ${c.unjoinable} could not be looked up at all)` : ""}, ` +
172
+ `and ${MIN_FOR_A_VERDICT} are needed before a calibration figure survives one of them changing.`,
173
+ ``,
174
+ ];
175
+ }
176
+ const lines = [
177
+ `## How well the lanes judged`,
178
+ ``,
179
+ `${c.checkable} lane decision(s) called a defect on an endpoint that failed; ${c.filed} of them match a finding this project holds (${pct(c.filed / c.checkable)}), ` +
180
+ `against a mean stated confidence of ${c.stated.toFixed(2)}.`,
181
+ ``,
182
+ `**What this is not.** It covers the project's whole history, not this run, and it measures agreement between the lanes and the bar this project applies — not whether the app is broken. ` +
183
+ `A lane can be perfectly calibrated against a planner that files the wrong things. Two things also count against a lane through no fault of its own: a defect filed with no machine signature, ` +
184
+ `and a finding whose signature was dropped when the store merged it into another. Read the figure as an ordering, not a measurement.`,
185
+ ``,
186
+ `| Stated confidence | Decisions | Said | Filed |`,
187
+ `|---|---:|---:|---:|`,
188
+ ];
189
+ for (const b of c.buckets) {
190
+ lines.push(`| ${b.label} | ${b.decisions} | ${b.stated.toFixed(2)} | ${pct(b.filed)} |`);
191
+ }
192
+ lines.push(``, `Expected calibration error **${c.ece.toFixed(2)}** — the gap between what the lanes said and what happened, weighted by bucket. ${readEce(c.ece)}`);
193
+ if (c.unjoinable > 0) {
194
+ lines.push(``, `${c.unjoinable} further decision(s) called a defect but named no failing endpoint, so nothing could be looked up for them. They are excluded above rather than counted as wrong.`);
195
+ }
196
+ const seen = c.verified.present + c.verified.gone + c.verified.changed;
197
+ if (seen > 0) {
198
+ lines.push(``, `${seen} of the findings those decisions matched ${seen === 1 ? "has" : "have"} since been re-tested with \`scout_verify\`: ${c.verified.present} still present, ${c.verified.gone} gone, ${c.verified.changed} changed. ` +
199
+ `Those verdicts are evidence about the app, unlike the table above. Findings no lane decision matched are not counted here, so this will not agree with the re-tested column elsewhere in the report.`);
200
+ }
201
+ lines.push(``);
202
+ return lines;
203
+ }
204
+ /** Below this, the number swings on a single decision and is worse than no number. */
205
+ export const MIN_FOR_A_VERDICT = 8;
206
+ /**
207
+ * What the figure suggests, hedged on purpose. The join loses decisions it
208
+ * cannot key and findings whose signature a merge discarded, and both push the
209
+ * error upward — so a confident "these numbers are wrong" can itself be wrong.
210
+ */
211
+ function readEce(ece) {
212
+ if (ece <= 0.05)
213
+ return "The lanes' numbers track what happened closely.";
214
+ if (ece <= 0.15)
215
+ return "Close enough to compare lanes by.";
216
+ return "Far enough apart that a lane's confidence is worth reading as an ordering rather than a rate — after checking the exclusions above, which push this upward.";
217
+ }
218
+ /** Most unfiled defects named when a lane report is folded; the rest are counted. */
219
+ export const MAX_UNFILED_NAMED = 10;
220
+ /**
221
+ * The defects a lane judged that no finding in the store covers yet.
222
+ *
223
+ * A lane's report is a verdict, not a filing: a defect it judged and never
224
+ * passed to scout_finding never reaches the report. That happened in a
225
+ * measured run — a covered Save button, judged a defect at 0.75 and filed by
226
+ * nobody — and it is cheapest to catch at the moment the planner folds the
227
+ * report, while the lane's session is still open.
228
+ *
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.
233
+ */
234
+ export function unfiledDefects(decisions, findings) {
235
+ const keys = new Set();
236
+ const texts = [];
237
+ for (const f of findings) {
238
+ if (!f.evidence)
239
+ continue;
240
+ for (const key of joinKeys(f.evidence))
241
+ keys.add(key);
242
+ texts.push(squash(f.evidence));
243
+ }
244
+ const out = [];
245
+ for (const d of decisions) {
246
+ if (d.verdict !== "defect")
247
+ continue;
248
+ if (d.evidence) {
249
+ const joined = [...joinKeys(d.evidence)];
250
+ if (joined.some((k) => keys.has(k)))
251
+ continue;
252
+ // A failing-endpoint signature is the store's own identity for a bug. When
253
+ // the decision has one and no finding shares it, text overlap is not a
254
+ // match: "GET /api/items" inside "... GET /api/items returned 500" is.
255
+ if (joined.length > 0) {
256
+ out.push(`${d.observation} — ${d.evidence}`);
257
+ continue;
258
+ }
259
+ const text = squash(d.evidence);
260
+ if (text && texts.some((t) => t === text || contains(t, text) || contains(text, t)))
261
+ continue;
262
+ }
263
+ out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
264
+ }
265
+ return out;
266
+ }
267
+ /** One signature inside another, when the inner one is long enough to mean something: "404" is inside half the findings in a run. */
268
+ function contains(outer, inner) {
269
+ return inner.length >= MIN_CONTAINED && outer.includes(inner);
270
+ }
271
+ const MIN_CONTAINED = 12;
272
+ function squash(text) {
273
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
274
+ }
@@ -41,7 +41,7 @@ const WRITING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
41
41
  const DATA_RESOURCES = new Set(["xhr", "fetch", "document"]);
42
42
  /** Whether this request is one whose refusal the page should be admitting to. */
43
43
  export function isRefused(req) {
44
- if (req.blockedByPolicy)
44
+ if (req.blockedByPolicy && req.status === null)
45
45
  return false;
46
46
  if (!DATA_RESOURCES.has(req.resourceType))
47
47
  return false;
@@ -100,6 +100,12 @@ function shortUrl(url) {
100
100
  function say(req) {
101
101
  return `${req.method} ${shortUrl(req.url)} ${req.status === null ? "failed" : req.status}`;
102
102
  }
103
+ /** Said after a contradiction whose refusal the write policy wrote, so nobody goes looking for it in the server's logs. */
104
+ function standIn(req) {
105
+ return req.blockedByPolicy
106
+ ? ` The refusal was the write policy's stand-in (the server never received the request); what failed is the page's handling of a refusal, which a real one would meet the same way.`
107
+ : "";
108
+ }
103
109
  /**
104
110
  * The contradictions this action produced, if any.
105
111
  *
@@ -125,7 +131,8 @@ export function findContradictions(requests, page) {
125
131
  out.push({
126
132
  kind: "refused_empty",
127
133
  detail: `${say(worst)} was refused, and the page shows ${how} with no error. ` +
128
- `The user is told there is nothing to see when the truth is that nothing could be loaded.`,
134
+ `The user is told there is nothing to see when the truth is that nothing could be loaded.` +
135
+ standIn(worst),
129
136
  evidence: `refused-empty ${say(worst)}`,
130
137
  });
131
138
  }
@@ -135,7 +142,7 @@ export function findContradictions(requests, page) {
135
142
  const message = page.texts[claims.indexOf("success")];
136
143
  out.push({
137
144
  kind: "false_success",
138
- detail: `${say(worst)} was refused, and the page says ${JSON.stringify(message.trim().slice(0, 80))}. The user is told their change was kept when the server rejected it.`,
145
+ detail: `${say(worst)} was refused, and the page says ${JSON.stringify(message.trim().slice(0, 80))}. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
139
146
  evidence: `false-success ${say(worst)}`,
140
147
  });
141
148
  }
@@ -276,6 +276,23 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
276
276
  }
277
277
  return out;
278
278
  })()`;
279
+ /**
280
+ * Roles that announce content rather than take input: a live region is empty
281
+ * until it has something to say, and ARIA does not require it to have a name.
282
+ * Reporting an empty status line as an unnamed control sent lanes to file it
283
+ * as an accessibility defect — twice in one measured run.
284
+ */
285
+ 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. */
287
+ export function missingName(el) {
288
+ return !el.name && !LIVE_REGION_ROLES.has(el.role);
289
+ }
290
+ /** How the snapshot shows an element's name: an empty live region says so rather than reading as an unnamed control. */
291
+ export function displayName(el) {
292
+ if (el.name)
293
+ return el.name;
294
+ return LIVE_REGION_ROLES.has(el.role) ? "(empty live region)" : "(unnamed)";
295
+ }
279
296
  /**
280
297
  * Deterministic geometry oracles — the checks people reach for screenshots to
281
298
  * do, computed from layout boxes instead: interactables rendered fully outside