scenescout 3.2.0 → 3.4.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.
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
5
5
  import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, TASK_SET } from "./memory.js";
6
6
  import { normalizeTask } from "./task.js";
7
+ import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
7
8
  import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
8
9
  import { AuthLossTracker } from "./authloss.js";
9
10
  import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
@@ -11,6 +12,8 @@ import { OracleMonitor, formatViolations } from "./oracles.js";
11
12
  import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
12
13
  import { formatJourney, measureJourney } from "./journey.js";
13
14
  import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
15
+ import { keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
16
+ import { buildRequestScript, formatReplay, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, toReplayResult } from "./request.js";
14
17
  import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
15
18
  import { revealedLines } from "./hover.js";
16
19
  import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
@@ -220,6 +223,69 @@ export class BrowserEngine {
220
223
  framesKept = 0;
221
224
  /** How many frames could not be written. The first one says so in the log; the rest are counted. */
222
225
  framesFailed = 0;
226
+ /**
227
+ * The Authorization header the app itself last sent, replayed by
228
+ * scout_request so a call with the UI bypassed carries the same credential
229
+ * as a click. Nothing here parses it: whatever scheme the app uses is
230
+ * whatever gets replayed.
231
+ */
232
+ lastAuthHeader = null;
233
+ rememberAuthHeader(headers) {
234
+ const value = headers["authorization"] ?? headers["Authorization"];
235
+ if (value && value.trim())
236
+ this.lastAuthHeader = value;
237
+ }
238
+ /**
239
+ * Call the app's own API as this session, with the UI bypassed.
240
+ *
241
+ * The fetch runs IN the page, so it passes through the same interception the
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.
245
+ */
246
+ async apiRequest(input) {
247
+ const page = this.requirePage();
248
+ const method = resolveMethod(input.method);
249
+ if ("problem" in method)
250
+ return `REFUSED: ${method.problem}`;
251
+ const target = resolveRequestUrl(this.baseUrl, input.path);
252
+ if ("problem" in target)
253
+ return `REFUSED: ${target.problem}`;
254
+ const script = buildRequestScript({
255
+ url: target.url,
256
+ method: method.method,
257
+ body: input.body,
258
+ headers: requestHeaders({ given: input.headers, auth: this.lastAuthHeader, body: input.body }),
259
+ });
260
+ let raw;
261
+ try {
262
+ raw = (await page.evaluate(script));
263
+ }
264
+ 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
+ const message = err instanceof Error ? err.message : String(err);
268
+ 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.`;
270
+ }
271
+ const result = toReplayResult(raw);
272
+ this.logAction({
273
+ action: "request",
274
+ target: `${method.method} ${input.path}`,
275
+ url: page.url(),
276
+ result: replaySignature(method.method, result.url, result.status),
277
+ });
278
+ return formatReplay(method.method, result);
279
+ }
280
+ /**
281
+ * The frame field for a log entry, or nothing. Spread into logAction so a
282
+ * caller that is not going through afterAction can still record what the
283
+ * page looked like: `...(await this.frameFor("crawl"))`.
284
+ */
285
+ async frameFor(action) {
286
+ const frame = await this.recordFrame(action);
287
+ return frame ? { frame } : {};
288
+ }
223
289
  /**
224
290
  * The frame for the step just taken, as a path relative to the memory
225
291
  * directory, or undefined when this run is not recorded. Failing to write
@@ -266,16 +332,26 @@ export class BrowserEngine {
266
332
  * until it is replaced — a batch costs a few words, not one per click.
267
333
  */
268
334
  currentTask = null;
269
- /** Set what this session is doing now. An empty value clears it. */
270
- setTask(text) {
335
+ /**
336
+ * Set what this session is doing now. An empty value clears it.
337
+ *
338
+ * `stated` is false for the placeholder attach puts up so a fresh card is
339
+ * not blank. It shows on the board but does NOT satisfy the requirement that
340
+ * an agent say what it is doing before it acts — otherwise every session
341
+ * would run for an hour under "Attaching and taking stock", which is the
342
+ * guard's whole purpose defeated by its own convenience.
343
+ */
344
+ setTask(text, stated = true) {
271
345
  const clean = normalizeTask(text);
272
346
  if (!clean) {
273
347
  this.currentTask = null;
274
348
  return;
275
349
  }
276
- if (this.currentTask?.text === clean)
350
+ if (this.currentTask?.text === clean && this.currentTask.stated === stated)
351
+ return;
352
+ this.currentTask = { text: clean, since: Date.now(), stated };
353
+ if (!stated)
277
354
  return;
278
- this.currentTask = { text: clean, since: Date.now() };
279
355
  // Logged so the feed can group the actions that follow under it, the way
280
356
  // it groups a journey's — the trail is where a watcher reads what
281
357
  // happened, and an ungrouped one says nothing about why.
@@ -283,7 +359,7 @@ export class BrowserEngine {
283
359
  }
284
360
  /** Whether anything is standing that the live view could show as the task. */
285
361
  get hasTask() {
286
- return this.journey !== null || this.currentTask !== null;
362
+ return this.journey !== null || (this.currentTask?.stated ?? false);
287
363
  }
288
364
  /**
289
365
  * Begin measuring a user JOURNEY — the interaction cost of completing one
@@ -333,6 +409,15 @@ export class BrowserEngine {
333
409
  engineName = "chromium";
334
410
  /** Non-GET requests fired since the last action — surfaces silent state mutation in read-only runs (timestamped for attribution). */
335
411
  mutationRequests = [];
412
+ /**
413
+ * Every request answered since the last action, with its status. The HTTP
414
+ * oracle sees each 4xx as it happens and reports it on its own; this ledger
415
+ * exists so the contradiction rules can ask the DIFFERENT question of whether
416
+ * the page then agreed with the refusal. Capped, because a page that polls
417
+ * would otherwise grow it without bound.
418
+ */
419
+ watchedResponses = [];
420
+ static MAX_WATCHED_RESPONSES = 60;
336
421
  /**
337
422
  * Requests the write policy aborted, by identity. The request event fires for
338
423
  * every non-GET the page ATTEMPTS, before the route handler decides its fate,
@@ -374,6 +459,52 @@ export class BrowserEngine {
374
459
  * element on the current page? Runs wherever violations are drained, so the
375
460
  * finding reaches the agent in the result of the action that revealed it.
376
461
  */
462
+ /** Record one answered request for the contradiction rules. Policy-aborted ones are marked, never dropped: the rules need to know they were ours. */
463
+ watchResponse(req, status) {
464
+ if (this.watchedResponses.length >= BrowserEngine.MAX_WATCHED_RESPONSES)
465
+ return;
466
+ this.watchedResponses.push({
467
+ method: req.method(),
468
+ url: req.url(),
469
+ status,
470
+ resourceType: req.resourceType(),
471
+ blockedByPolicy: this.abortedByPolicy.has(req),
472
+ });
473
+ }
474
+ /**
475
+ * Did the page agree with what the network just did? Runs in the same slot
476
+ * as the injection scan, so it sees the DOM the action settled on.
477
+ *
478
+ * The ledger is cleared whether or not anything is found: these are facts
479
+ * about ONE action, and carrying a refusal forward would blame the next
480
+ * action's page for the previous action's request.
481
+ */
482
+ async scanForContradictions() {
483
+ const requests = this.watchedResponses;
484
+ this.watchedResponses = [];
485
+ const page = this.page;
486
+ if (!page || page.isClosed() || requests.length === 0)
487
+ return;
488
+ if (!requests.some((r) => r.status === null || r.status >= 400))
489
+ return;
490
+ const url = page.url();
491
+ let state;
492
+ try {
493
+ state = (await page.evaluate(CLAIM_SCAN_SCRIPT));
494
+ }
495
+ catch {
496
+ // A page mid-navigation has no DOM to ask.
497
+ return;
498
+ }
499
+ for (const found of findContradictions(requests, state)) {
500
+ if (this.contradictionsReported.has(found.evidence))
501
+ continue;
502
+ this.contradictionsReported.add(found.evidence);
503
+ this.oracles.noteContradiction(found, url);
504
+ }
505
+ }
506
+ /** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
507
+ contradictionsReported = new Set();
377
508
  async scanForInjections() {
378
509
  const page = this.page;
379
510
  if (!page || page.isClosed() || this.probes.length === 0)
@@ -406,6 +537,9 @@ export class BrowserEngine {
406
537
  }
407
538
  this.mode = opts.mode ?? "read-only";
408
539
  this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
540
+ // An agent-supplied task counts as stated; the placeholder does not.
541
+ this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
542
+ this.setPace(opts.paceMs);
409
543
  this.recording = opts.record === true;
410
544
  // A re-attached engine starts a new recording: numbering from where the
411
545
  // last one stopped would run into the cap with frames it never took.
@@ -413,6 +547,8 @@ export class BrowserEngine {
413
547
  this.framesFailed = 0;
414
548
  this.headed = opts.headed ?? false;
415
549
  this.blockedRequests = [];
550
+ this.watchedResponses = [];
551
+ this.contradictionsReported = new Set();
416
552
  this.pendingCreations = new Set();
417
553
  this.baseUrl = opts.url.replace(/\/$/, "");
418
554
  // Ownership (ownedIds/createdResources) deliberately NOT reset here: it
@@ -476,7 +612,19 @@ export class BrowserEngine {
476
612
  if (this.page)
477
613
  watchSockets(this.page);
478
614
  this.context.on("page", watchSockets);
615
+ this.context.on("requestfinished", () => {
616
+ this.inFlight = Math.max(0, this.inFlight - 1);
617
+ });
618
+ this.context.on("requestfailed", (req) => {
619
+ this.inFlight = Math.max(0, this.inFlight - 1);
620
+ this.watchResponse(req, null);
621
+ });
622
+ this.context.on("response", (res) => {
623
+ this.watchResponse(res.request(), res.status());
624
+ });
479
625
  this.context.on("request", (req) => {
626
+ this.inFlight += 1;
627
+ this.lastRequestStart = Date.now();
480
628
  const type = req.resourceType();
481
629
  if (type === "xhr" || type === "fetch")
482
630
  this.xhrCount += 1;
@@ -522,6 +670,7 @@ export class BrowserEngine {
522
670
  return route.continue();
523
671
  const url = req.url();
524
672
  const pathname = pathnameOf(url);
673
+ this.rememberAuthHeader(req.headers());
525
674
  const destructiveWire = isDestructiveWire(pathname, req.postData());
526
675
  // Auth/session flows must work in every mode — but never a destructive
527
676
  // one, and in observe only the requests a login itself needs.
@@ -615,17 +764,30 @@ export class BrowserEngine {
615
764
  // is all this used to do — reads as success and names the very file that
616
765
  // just failed, so a run would proceed for hundreds of calls against a
617
766
  // logged-out browser. The file existing was the only thing ever checked.
618
- const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), this.page.url(), this.baseUrl);
767
+ // Whether a bounce verdict can matter later: only a session carrying
768
+ // credentials has any to lose, and only it pays for watching the URL on
769
+ // every navigation. An anonymous crawl keeps its full speed.
770
+ this.watchesForBounce = Boolean(opts.storageStatePath);
771
+ const landed = await this.stableUrl();
772
+ const authFailed = Boolean(opts.storageStatePath) && this.authLoss.isLoginRedirect(normalizePath(opts.url), landed, this.baseUrl);
773
+ // An earlier run may have written down how this app's login state is
774
+ // regenerated. "Regenerate it" is advice the reader already had; the
775
+ // command that worked last time is the part worth keeping.
776
+ const recipe = authFailed ? this.memory.setupRecipe() : [];
619
777
  const authWarning = authFailed
620
778
  ? `\n⚠ AUTH FAILED — the storage state at ${opts.storageStatePath} did not produce a signed-in session: ` +
621
- `attaching landed on ${this.page.url()}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
622
- `Continuing now tests a logged-out app.`
779
+ `attaching landed on ${landed}, a login page. Regenerate it (its token has most likely expired) and re-attach. ` +
780
+ `Continuing now tests a logged-out app.` +
781
+ (recipe.length > 0
782
+ ? `\n Recorded by an earlier run under setup:\n${recipe.map((line) => ` · ${line}`).join("\n")}`
783
+ : `\n Nothing is recorded about how this app's login state is made. Once the user tells you, write it down with scout_note { section: "setup" } so the next run is told instead of asking.`)
623
784
  : "";
624
785
  return (`Attached to ${this.page.url()} (mode=${this.mode}` +
625
786
  `${this.engineName === "chromium" ? "" : `, browser=${this.engineName}, service workers blocked because their requests cannot be intercepted here`}` +
626
787
  `${focusAdvanceKey(this.engineName, process.platform) === "Tab" ? "" : `, keyboard: Tab stops only at text fields in this browser — press Alt+Tab to reach buttons and links`}` +
627
788
  `${opts.storageStatePath ? `, auth=${opts.storageStatePath}` : ""}). ` +
628
789
  `Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
790
+ (this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
629
791
  `${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
630
792
  `${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
631
793
  authWarning);
@@ -651,11 +813,80 @@ export class BrowserEngine {
651
813
  async settle() {
652
814
  const page = this.requirePage();
653
815
  await page.waitForLoadState("domcontentloaded").catch(() => { });
654
- // A fixed floor, deliberately: waitForLoadState("networkidle") is latched
655
- // once reached and resolves instantly forever after, so it cannot be used
656
- // to wait out in-flight requests here — and draining oracles before a
657
- // just-fired request lands would misattribute its violations.
658
- await page.waitForTimeout(SETTLE_MS);
816
+ // Wait on what is actually in flight rather than a flat sleep. The rule is
817
+ // in settle.ts so it can be table-tested; the reasoning for not using
818
+ // networkidle is there too.
819
+ const started = Date.now();
820
+ for (;;) {
821
+ const state = {
822
+ inFlight: this.inFlight,
823
+ sinceLastStartMs: Date.now() - this.lastRequestStart,
824
+ elapsedMs: Date.now() - started,
825
+ paceMs: this.paceMs,
826
+ };
827
+ if (!shouldKeepWaiting(state))
828
+ return;
829
+ await page.waitForTimeout(SETTLE_TICK_MS).catch(() => { });
830
+ if (this.page !== page)
831
+ return;
832
+ }
833
+ }
834
+ /**
835
+ * The URL once it has stopped changing.
836
+ *
837
+ * Only called where a bounce verdict is about to be made. A client-side auth
838
+ * guard redirects on a timer and issues no request until it does, so the
839
+ * request-based settle has nothing to wait on and the page is read while it
840
+ * is still, briefly, the page it was asked for.
841
+ */
842
+ async stableUrl() {
843
+ const page = this.page;
844
+ if (!page || page.isClosed())
845
+ return page ? page.url() : "";
846
+ const started = Date.now();
847
+ let url = page.url();
848
+ let changedAt = started;
849
+ for (;;) {
850
+ if (!keepWatchingUrl({ sinceChangeMs: Date.now() - changedAt, elapsedMs: Date.now() - started }))
851
+ return url;
852
+ await page.waitForTimeout(SETTLE_TICK_MS).catch(() => { });
853
+ if (this.page !== page || page.isClosed())
854
+ return url;
855
+ const now = page.url();
856
+ if (now !== url) {
857
+ url = now;
858
+ changedAt = Date.now();
859
+ }
860
+ }
861
+ }
862
+ /**
863
+ * Where a navigation landed. Watched for a late client-side guard only when
864
+ * this session has credentials to lose. The page is passed in rather than
865
+ * read off the engine so a session torn down mid-navigation still reports
866
+ * the last URL it was on, which is what the outcome is recorded against.
867
+ */
868
+ async landedUrl(page) {
869
+ return this.watchesForBounce ? await this.stableUrl() : page.url();
870
+ }
871
+ /** Set at attach: this session was given credentials, so a bounce to a login page is a verdict worth waiting for. */
872
+ watchesForBounce = false;
873
+ /** Requests started and not yet finished or failed, from the context's own events. */
874
+ inFlight = 0;
875
+ /** When the most recent request started, so a page that fires one late is not read too early. */
876
+ lastRequestStart = 0;
877
+ /**
878
+ * A floor between actions this session was asked for, so a person watching
879
+ * can follow along. Zero means as fast as the page allows, which is the
880
+ * default and what every run wants unless somebody is reading it live.
881
+ */
882
+ paceMs = 0;
883
+ /** Set the deliberate pace. Returns what it became, clamped. */
884
+ setPace(paceMs) {
885
+ this.paceMs = normalizePace(paceMs);
886
+ return this.paceMs;
887
+ }
888
+ get pace() {
889
+ return this.paceMs;
659
890
  }
660
891
  /** Collect the current page's interactables into SnapshotElements with stable refs. */
661
892
  async collect() {
@@ -788,8 +1019,13 @@ export class BrowserEngine {
788
1019
  this.currentFingerprint = fp;
789
1020
  const isNew = memory.visitState(fp, url, route, elements.map((el) => el.key));
790
1021
  memory.recordRoleAccess(this.role, route, "reached");
791
- this.logAction({ action: "snapshot", url, result: fp });
1022
+ // A snapshot is what an agent takes when it wants to LOOK at something, so
1023
+ // it is the frame a reader most wants beside the step. Recording only the
1024
+ // actions that go through afterAction kept 9 frames out of 67 in a real
1025
+ // run, and none of them from the routes a crawl had just swept.
1026
+ this.logAction({ action: "snapshot", url, result: fp, ...(await this.frameFor("snapshot")) });
792
1027
  await this.scanForInjections();
1028
+ await this.scanForContradictions();
793
1029
  const line = (el) => {
794
1030
  const dup = el.key.match(/~(\d+)$/);
795
1031
  const flags = [
@@ -949,6 +1185,7 @@ export class BrowserEngine {
949
1185
  const frame = await this.recordFrame(action);
950
1186
  this.logAction({ action, target, url, ...(frame ? { frame } : {}) });
951
1187
  await this.scanForInjections();
1188
+ await this.scanForContradictions();
952
1189
  const violations = this.oracles.drain();
953
1190
  const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
954
1191
  const navigated = this.snapshotUrl !== "" && url !== this.snapshotUrl;
@@ -1639,14 +1876,14 @@ export class BrowserEngine {
1639
1876
  settled = await this.afterAction("navigate", url);
1640
1877
  }
1641
1878
  catch (err) {
1642
- this.recordNavigationOutcome(url, page.url());
1879
+ this.recordNavigationOutcome(url, await this.landedUrl(page));
1643
1880
  const notice = this.authLoss.take();
1644
1881
  if (!notice)
1645
1882
  throw err;
1646
1883
  const msg = err instanceof Error ? err.message : String(err);
1647
1884
  throw new Error(`${notice}${msg}`);
1648
1885
  }
1649
- this.recordNavigationOutcome(url, page.url());
1886
+ this.recordNavigationOutcome(url, await this.landedUrl(page));
1650
1887
  return this.authLoss.take() + settled;
1651
1888
  }
1652
1889
  /** Where did we ASK to go, where did we END UP, and does that count as coverage? */
@@ -1767,8 +2004,9 @@ export class BrowserEngine {
1767
2004
  // an attempt with the status satisfies the contract.
1768
2005
  if (typeof status === "number" && status >= 400)
1769
2006
  memory.markAttempted(requestedRoute, `status:${status}`, this.role);
1770
- this.logAction({ action: "crawl", target: path, url: finalUrl });
2007
+ this.logAction({ action: "crawl", target: path, url: finalUrl, ...(await this.frameFor("crawl")) });
1771
2008
  await this.scanForInjections();
2009
+ await this.scanForContradictions();
1772
2010
  const violations = this.oracles.drain();
1773
2011
  const deadEnd = elements.length === 0;
1774
2012
  const unnamed = elements.filter((el) => !el.name).length;
@@ -1940,7 +2178,11 @@ export class BrowserEngine {
1940
2178
  }
1941
2179
  }
1942
2180
  await this.settle();
1943
- this.logAction({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url() });
2181
+ // A plan is the RECOMMENDED way to run a mechanical sequence, so its
2182
+ // steps are where most of a recorded run actually happens. Leaving
2183
+ // them unframed reproduced, inside run_plan, the same hole that crawl
2184
+ // had: a form filled and submitted with no picture of any of it.
2185
+ this.logAction({ action: `plan:${step.action}`, target: step.target ?? step.value, url: page.url(), ...(await this.frameFor(`plan-${step.action}`)) });
1944
2186
  // Plans must feed coverage like ref-based actions do: record the
1945
2187
  // state and mark the acted-on element class as exercised.
1946
2188
  // Hover is deliberately excluded: a hover is a look, not an
@@ -1985,6 +2227,7 @@ export class BrowserEngine {
1985
2227
  }
1986
2228
  }
1987
2229
  await this.scanForInjections();
2230
+ await this.scanForContradictions();
1988
2231
  const violations = this.oracles.drain();
1989
2232
  const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
1990
2233
  // Abort only on NEW violations: a known-failing endpoint repeating on
@@ -2048,7 +2291,7 @@ export class BrowserEngine {
2048
2291
  this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
2049
2292
  this.memory?.markRouteFact(route, { audited: true });
2050
2293
  }
2051
- this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined });
2294
+ this.logAction({ action: "design-audit", url: page.url(), result: score ? `score:${score.overall}` : undefined, ...(await this.frameFor("design-audit")) });
2052
2295
  return `URL: ${page.url()}\n` + report;
2053
2296
  }
2054
2297
  /**
@@ -0,0 +1,217 @@
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
+ }