scenescout 3.14.1 → 3.16.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -1,7 +1,15 @@
1
1
  import fs from "node:fs";
2
+ import crypto from "node:crypto";
2
3
  import path from "node:path";
3
4
  import { laneRoutePaths, normalizePath, shortHash, stripRouteQuery } from "./fingerprint.js";
4
5
  import { isFormBookkeeping } from "./forms.js";
6
+ import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tickets.js";
7
+ /**
8
+ * The element-name rule current keys are made under. 2: a link or button with
9
+ * no text is named by its image content (an <img>'s alt text, an <svg>'s
10
+ * aria-label or <title>) before its title attribute.
11
+ */
12
+ export const NAME_RULE = 2;
5
13
  /**
6
14
  * The kinds a finding can be. One list: scout_finding's input schema, the lane
7
15
  * report a parallel agent hands back, and the skill text all read it from
@@ -26,6 +34,47 @@ export const FINDING_CATEGORIES = [
26
34
  "missing-testid",
27
35
  "other",
28
36
  ];
37
+ /** Most other routes one finding records it was seen on; the oldest go first. */
38
+ export const MAX_SEEN_ON = 20;
39
+ /** The route class a finding was filed on: its state before the `#`. */
40
+ export const findingRoute = (f) => f.state.split("#")[0];
41
+ /** A finding's other routes that are well formed: the file is read back without a schema. */
42
+ export function seenOnOf(f) {
43
+ return Array.isArray(f.seenOn) ? f.seenOn.filter((r) => typeof r === "string" && r.length > 0) : [];
44
+ }
45
+ /**
46
+ * `f`'s other routes with `routes` added: each once, never its own route,
47
+ * oldest first, at most MAX_SEEN_ON. Undefined when there are none, so a
48
+ * finding seen on one route keeps no empty field. Idempotent.
49
+ */
50
+ function withSeenOn(f, routes) {
51
+ const own = findingRoute(f);
52
+ const out = [];
53
+ for (const r of [...seenOnOf(f), ...routes])
54
+ if (r && r !== own && !out.includes(r))
55
+ out.push(r);
56
+ return out.length > 0 ? out.slice(-MAX_SEEN_ON) : undefined;
57
+ }
58
+ /** Most judged merges one finding keeps; the oldest go first. */
59
+ export const MAX_JUDGED_MERGES = 10;
60
+ /**
61
+ * A finding's judged merges that are well formed. The file is read back
62
+ * without a schema, and the report prints each of these, so a hand-edited
63
+ * entry that is not one is dropped, not trusted.
64
+ */
65
+ export function judgedMergesOf(f) {
66
+ if (!Array.isArray(f.judgedMerges))
67
+ return [];
68
+ return f.judgedMerges.filter((m) => !!m &&
69
+ typeof m === "object" &&
70
+ typeof m.title === "string" &&
71
+ typeof m.category === "string" &&
72
+ typeof m.at === "string" &&
73
+ ["high", "medium", "low"].includes(m.severity) &&
74
+ typeof m.pSame === "number" &&
75
+ Number.isFinite(m.pSame) &&
76
+ (m.evidence === undefined || typeof m.evidence === "string"));
77
+ }
29
78
  /**
30
79
  * Whether a finding is in the "worth a look" tier. Anything else a file holds
31
80
  * reads as a defect: a finding is never taken out of the defect count on a
@@ -34,23 +83,36 @@ export const FINDING_CATEGORIES = [
34
83
  export function isWorthALook(f) {
35
84
  return f.tier === "worth_a_look";
36
85
  }
37
- /**
38
- * The tier a finding keeps when it is found again. A defect wins: a finding
39
- * someone filed as a defect is a decision about the project's convention, and
40
- * a later "worth a look" for the same thing does not undo it. A worth-a-look
41
- * filed again as a defect is promoted, and loses its convention. Two
42
- * worth-a-looks keep the first convention unless it had none.
43
- */
86
+ /** The sentence a merged filing's result adds about what it changed, or "" when the two filings agreed. */
87
+ export function describeMerge(note, convention) {
88
+ const parts = [];
89
+ if (note.seenOn)
90
+ parts.push(` Your page, ${note.seenOn}, is recorded as another route it was seen on.`);
91
+ if (note.updated.length > 0) {
92
+ const what = note.updated.map((f) => (f === "convention" ? `its convention is now "${convention ?? ""}"` : "its detail is now yours")).join(" and ");
93
+ parts.push(` Taken as a correction of your own filing: ${what}.`);
94
+ }
95
+ if (note.kept.length > 0) {
96
+ const what = note.kept.map((f) => (f === "convention" ? `convention ("${convention ?? ""}")` : "detail")).join(" and ");
97
+ parts.push(` The first filing's ${what} ${note.kept.length === 1 ? "was" : "were"} kept: a re-filing replaces them only when it comes from the session that filed it, in the same run.`);
98
+ }
99
+ return parts.join("");
100
+ }
101
+ /** A fresh run identity: unique per process and per run within it. */
102
+ function newRunId() {
103
+ return `${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`;
104
+ }
44
105
  /** Drops a finding's tier and convention in place, so a merged tier can be assigned onto it. */
45
106
  function withoutTier(f) {
46
107
  delete f.tier;
47
108
  delete f.convention;
48
109
  return f;
49
110
  }
50
- export function mergeTier(existing, incoming) {
111
+ export function mergeTier(existing, incoming, prefer = "existing") {
51
112
  if (!isWorthALook(existing) || !isWorthALook(incoming))
52
113
  return {};
53
- return { tier: "worth_a_look", convention: existing.convention || incoming.convention };
114
+ const convention = prefer === "incoming" ? incoming.convention || existing.convention : existing.convention || incoming.convention;
115
+ return { tier: "worth_a_look", convention };
54
116
  }
55
117
  /**
56
118
  * Most states a route may keep. A route accumulates one state per distinct
@@ -181,12 +243,41 @@ export function redactSecrets(text) {
181
243
  }
182
244
  return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
183
245
  }
246
+ /**
247
+ * Routes are links the app printed, and a link can carry a token
248
+ * (`?reset=…`, `?api_key=…`). Evidence is redacted where issues are built;
249
+ * this does the same for every route string before anything is written.
250
+ */
251
+ export function redactRoute(route) {
252
+ // The trailing "[n secrets redacted]" note belongs to prose; in a route it would read as part of the path.
253
+ return redactSecrets(route).replace(/ \[\d+ secrets? redacted\]$/, "");
254
+ }
184
255
  /** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
185
256
  export const JOURNEY_START = "journey:start";
186
257
  export const JOURNEY_END = "journey:end";
187
258
  /** Logged when a session states the task it is starting, so the feed can group the actions that follow under it. */
188
259
  export const TASK_SET = "task";
260
+ /** The most pages, and endpoints per page, the refused-POST record keeps. */
261
+ const MAX_REFUSED_POST_ROUTES = 50;
262
+ const MAX_REFUSED_POSTS_PER_ROUTE = 5;
189
263
  const EMPTY = { version: 1, states: {}, findings: [] };
264
+ /**
265
+ * A state's elements under current keys: each key the route's aliases name
266
+ * (earlier key → current key) is moved, all at once, so two keys that swap
267
+ * places do not chain. Where two entries land on one key, exercised is kept
268
+ * from either.
269
+ */
270
+ export function rekeyed(elements, aliases) {
271
+ if (!aliases)
272
+ return elements;
273
+ const out = {};
274
+ for (const [raw, entry] of Object.entries(elements)) {
275
+ const key = Object.hasOwn(aliases, raw) ? aliases[raw] : raw;
276
+ const prev = out[key];
277
+ out[key] = prev ? { ...prev, ...entry, exercised: prev.exercised || entry.exercised } : entry;
278
+ }
279
+ return out;
280
+ }
190
281
  /**
191
282
  * Fold another process's memory into ours, losing nothing from either side.
192
283
  *
@@ -202,6 +293,13 @@ const EMPTY = { version: 1, states: {}, findings: [] };
202
293
  */
203
294
  export function mergeMemory(mine, theirs) {
204
295
  const out = { ...theirs, ...mine, version: 1 };
296
+ out.keyAliases = { ...(theirs.keyAliases ?? {}) };
297
+ for (const [route, aliases] of Object.entries(mine.keyAliases ?? {})) {
298
+ // The first reading stands (MemoryStore learnAliases): what is already on disk wins.
299
+ out.keyAliases[route] = { ...aliases, ...(out.keyAliases[route] ?? {}) };
300
+ }
301
+ if (Object.keys(out.keyAliases).length === 0)
302
+ delete out.keyAliases;
205
303
  out.states = { ...theirs.states };
206
304
  for (const [fp, ours] of Object.entries(mine.states)) {
207
305
  const other = theirs.states[fp];
@@ -209,14 +307,21 @@ export function mergeMemory(mine, theirs) {
209
307
  out.states[fp] = ours;
210
308
  continue;
211
309
  }
212
- const elements = { ...other.elements };
213
- for (const [key, el] of Object.entries(ours.elements)) {
310
+ const nameRule = Math.max(ours.nameRule ?? 0, other.nameRule ?? 0);
311
+ // When one side is under the current name rule and the other is not, the
312
+ // other's keys are moved to current ones before the union, as a revisit
313
+ // moves them (MemoryStore visitState).
314
+ const current = (side) => nameRule >= NAME_RULE && (side.nameRule ?? 1) < NAME_RULE ? rekeyed(side.elements, out.keyAliases?.[routeIdentity(side.route)]) : side.elements;
315
+ const elements = { ...current(other) };
316
+ for (const [key, el] of Object.entries(current(ours))) {
214
317
  const prev = elements[key];
215
318
  elements[key] = {
216
319
  exercised: (prev?.exercised ?? false) || el.exercised,
217
320
  // Keep whichever action was actually recorded; ours wins a tie.
218
321
  ...((el.lastAction ?? prev?.lastAction) ? { lastAction: el.lastAction ?? prev?.lastAction } : {}),
219
322
  absentStreak: Math.min(prev?.absentStreak ?? 0, el.absentStreak ?? 0),
323
+ // Ours is the latest reading of whether a user can act on it.
324
+ ...(el.inert ? { inert: true } : {}),
220
325
  };
221
326
  }
222
327
  out.states[fp] = {
@@ -224,6 +329,7 @@ export function mergeMemory(mine, theirs) {
224
329
  firstSeen: ours.firstSeen < other.firstSeen ? ours.firstSeen : other.firstSeen,
225
330
  visits: Math.max(ours.visits, other.visits),
226
331
  elements,
332
+ ...(nameRule ? { nameRule } : {}),
227
333
  };
228
334
  }
229
335
  const byId = new Map();
@@ -245,12 +351,33 @@ export function mergeMemory(mine, theirs) {
245
351
  evidence: newer.evidence ?? older.evidence,
246
352
  regressedAt: newer.regressedAt ?? older.regressedAt,
247
353
  };
354
+ // A picture and its shot travel together, from whichever side has one, the newer first.
355
+ const pictured = newer.picture ? newer : older.picture ? older : null;
356
+ delete merged.picture;
357
+ delete merged.pictureShot;
358
+ if (pictured?.picture) {
359
+ merged.picture = pictured.picture;
360
+ if (pictured.pictureShot)
361
+ merged.pictureShot = pictured.pictureShot;
362
+ }
248
363
  // The tier is not "later knowledge wins": a defect on either side is a decision
249
364
  // about the convention, and a store still holding the worth-a-look must not undo it.
250
365
  const tier = mergeTier(older, newer);
251
366
  Object.assign(withoutTier(merged), tier);
252
367
  if (!isWorthALook(merged) && isWorthALook(newer))
253
368
  merged.severity = older.severity;
369
+ // Each side's judged merges are a record of filings, not later knowledge: kept from both.
370
+ const trail = unionJudgedMerges(f, other);
371
+ if (trail)
372
+ merged.judgedMerges = trail;
373
+ else
374
+ delete merged.judgedMerges;
375
+ // So are the routes each side saw it on.
376
+ const seenOn = withSeenOn(older, seenOnOf(newer));
377
+ if (seenOn)
378
+ merged.seenOn = seenOn;
379
+ else
380
+ delete merged.seenOn;
254
381
  byId.set(f.id, merged);
255
382
  }
256
383
  out.findings = [...byId.values()];
@@ -278,6 +405,17 @@ export function mergeMemory(mine, theirs) {
278
405
  }
279
406
  if (Object.keys(out.routeFacts).length === 0)
280
407
  delete out.routeFacts;
408
+ // Per endpoint, the later of the two: a clear in one process outlasts an older refusal in another.
409
+ out.observeRefusedPosts = { ...(theirs.observeRefusedPosts ?? {}) };
410
+ for (const [route, endpoints] of Object.entries(mine.observeRefusedPosts ?? {})) {
411
+ const merged = { ...(out.observeRefusedPosts[route] ?? {}) };
412
+ for (const [endpoint, rec] of Object.entries(endpoints))
413
+ if (!merged[endpoint] || rec.at >= merged[endpoint].at)
414
+ merged[endpoint] = rec;
415
+ out.observeRefusedPosts[route] = merged;
416
+ }
417
+ if (Object.keys(out.observeRefusedPosts).length === 0)
418
+ delete out.observeRefusedPosts;
281
419
  out.roleAccess = { ...(theirs.roleAccess ?? {}) };
282
420
  for (const [role, routes] of Object.entries(mine.roleAccess ?? {})) {
283
421
  out.roleAccess[role] = { ...(out.roleAccess[role] ?? {}), ...routes };
@@ -305,8 +443,28 @@ export function mergeMemory(mine, theirs) {
305
443
  }
306
444
  if (Object.keys(out.laneRoutes).length === 0)
307
445
  delete out.laneRoutes;
446
+ // Lanes record criterion verdicts in their own processes too.
447
+ const ticketData = mergeTicketData(mine, theirs);
448
+ if (ticketData.tickets)
449
+ out.tickets = ticketData.tickets;
450
+ else
451
+ delete out.tickets;
452
+ if (ticketData.criterionVerdicts)
453
+ out.criterionVerdicts = ticketData.criterionVerdicts;
454
+ else
455
+ delete out.criterionVerdicts;
308
456
  return out;
309
457
  }
458
+ /** Two findings' judged merges as one list, each filing once, oldest first, at most MAX_JUDGED_MERGES. Idempotent. */
459
+ function unionJudgedMerges(a, b) {
460
+ const all = [...judgedMergesOf(b), ...judgedMergesOf(a)];
461
+ if (all.length === 0)
462
+ return undefined;
463
+ const byKey = new Map();
464
+ for (const m of all)
465
+ byKey.set(`${m.at}\u0000${m.title}`, m);
466
+ return [...byKey.values()].sort((x, y) => x.at.localeCompare(y.at)).slice(-MAX_JUDGED_MERGES);
467
+ }
310
468
  /** Most routes kept per lane. A lane report carries at most a few hundred; this keeps a long-lived project's file small. */
311
469
  export const MAX_LANE_ROUTES = 255;
312
470
  /**
@@ -337,11 +495,54 @@ function unionRoutes(a, b) {
337
495
  function decisionKey(d) {
338
496
  return [d.lane, d.observation, d.verdict, d.severity ?? "", d.category ?? "", d.confidence, d.evidence ?? ""].join("|");
339
497
  }
498
+ /** Route identity of a stored route, memoised: states written before an id shape collapsed fold into today's form. */
499
+ const routeIdentityCache = new Map();
500
+ export function routeIdentity(route) {
501
+ let out = routeIdentityCache.get(route);
502
+ if (out === undefined) {
503
+ out = normalizePath(route);
504
+ if (routeIdentityCache.size > 10_000)
505
+ routeIdentityCache.clear();
506
+ routeIdentityCache.set(route, out);
507
+ }
508
+ return out;
509
+ }
510
+ /** A route's base: its path, without the UI-state query naming a tab or section of it. */
511
+ export function baseRoute(route) {
512
+ return route.split("?")[0] || "/";
513
+ }
340
514
  /**
341
- * Most lane decisions kept. Calibration wants a few dozen; a long-lived
342
- * project would otherwise accumulate every decision ever made and re-serialise
343
- * them on each save, which is what made an old history slow to open.
515
+ * Whether a contract route was reached, given the routes states were recorded
516
+ * on: the route itself, by today's route identity, or, for a base path, one of
517
+ * its tabs or sections (`/things/:id?section=history` reaches `/things/:id`).
518
+ * One rule for the "never visited" list and the gap ledger, so a route is not
519
+ * both reached and never visited.
344
520
  */
521
+ export function reachedRoutes(stateRoutes) {
522
+ const visited = new Set();
523
+ for (const r of stateRoutes)
524
+ visited.add(routeIdentity(r));
525
+ const bases = new Set([...visited].map(baseRoute));
526
+ return (route) => {
527
+ const n = routeIdentity(route);
528
+ return visited.has(n) || (!n.includes("?") && bases.has(n));
529
+ };
530
+ }
531
+ /**
532
+ * Link-discovered routes re-keyed by today's route identity. A memory written
533
+ * before an id shape collapsed holds one route per record (`/things/AB-1001`,
534
+ * `/things/AB-1002`); folded, they are the one route they always were, and
535
+ * the first example seen stays its navigable path.
536
+ */
537
+ export function renormalizeRoutes(map) {
538
+ const out = {};
539
+ for (const [route, example] of Object.entries(map)) {
540
+ const key = routeIdentity(route);
541
+ if (!(key in out))
542
+ out[key] = example;
543
+ }
544
+ return out;
545
+ }
345
546
  /**
346
547
  * Whether a coverage key belongs to a control inside another site's frame:
347
548
  * those keys carry the frame's origin (collector.ts frameElementKey), where
@@ -350,6 +551,11 @@ function decisionKey(d) {
350
551
  export function isEmbedKey(key) {
351
552
  return /^frame:https?:\/\//.test(key);
352
553
  }
554
+ /**
555
+ * Most lane decisions kept. Calibration wants a few dozen; a long-lived
556
+ * project would otherwise accumulate every decision ever made and re-serialise
557
+ * them on each save, which is what made an old history slow to open.
558
+ */
353
559
  export const MAX_LANE_DECISIONS = 1000;
354
560
  /**
355
561
  * Most options a dropdown may have and still be tracked for unchosen options.
@@ -374,6 +580,10 @@ function findingJaccard(a, b) {
374
580
  const union = new Set([...a, ...b]).size;
375
581
  return union === 0 ? 0 : inter / union;
376
582
  }
583
+ /** How alike two titles are, 0 to 1, by the token overlap the rule's last tier uses. Orders the dedup judge's candidates. */
584
+ export function titleSimilarity(a, b) {
585
+ return findingJaccard(findingTokens(a), findingTokens(b));
586
+ }
377
587
  /**
378
588
  * Distinctive literals — quoted or parenthesized fragments like
379
589
  * `"(role not recorded)"` or `"Document not found"`. Two findings on the same
@@ -611,12 +821,24 @@ function sameFinding(a, b) {
611
821
  // that answered 2xx counts too — a false success names one — so the same bug
612
822
  // described once by its page load and once by its failing call stays as two
613
823
  // findings: a visible duplicate, the direction ADR 4 accepts.
824
+ //
825
+ // And when both findings carry evidence (and it differs, or it would have
826
+ // matched above), the detail does not count: the literal must be in the
827
+ // other finding's title or evidence, the two places a finding states what
828
+ // it is about. A filter option's label ("Last 7 days") quoted in one title
829
+ // and in passing in the other finding's detail names the control both
830
+ // defects were found through, not one defect: an undated row listed under
831
+ // the wrong group and four widgets ignoring the filter shared only that
832
+ // label, and the second filing was lost to the first. Without evidence on
833
+ // one side the detail still counts — nothing machine-written says they
834
+ // differ.
614
835
  if (sameFamily(a.category, b.category) && !requestsDisagree(a.evidence, b.evidence)) {
615
836
  const aTitleLits = findingLiterals(a.title);
616
837
  const bTitleLits = findingLiterals(b.title);
617
838
  if (aTitleLits.size > 0 || bTitleLits.size > 0) {
618
- const aAll = findingLiterals(a.title, a.detail, a.evidence);
619
- const bAll = findingLiterals(b.title, b.detail, b.evidence);
839
+ const bothEvidenced = !!(aEv && bEv);
840
+ const aAll = findingLiterals(a.title, bothEvidenced ? undefined : a.detail, a.evidence);
841
+ const bAll = findingLiterals(b.title, bothEvidenced ? undefined : b.detail, b.evidence);
620
842
  for (const lit of aTitleLits)
621
843
  if (bAll.has(lit))
622
844
  return true;
@@ -809,7 +1031,58 @@ export class MemoryStore {
809
1031
  this.auditsThisRun = 0;
810
1032
  this.selectChoices.clear();
811
1033
  this.emptySubmits.clear();
1034
+ this.dedupJudge = null;
1035
+ this.dedupChoice = undefined;
1036
+ this.dedupOff = undefined;
1037
+ this.sessionRoutes.clear();
1038
+ this.sessionStates.clear();
1039
+ this.runStates.clear();
1040
+ this.runId = newRunId();
812
1041
  }
1042
+ /**
1043
+ * This run's identity. A finding remembers the last run that filed it, so
1044
+ * "seen in N runs" counts runs and not filings: a lane re-filing the same
1045
+ * finding minutes later in the same run is one run. A new store (a new
1046
+ * process) and every endRun start a new one.
1047
+ */
1048
+ runId = newRunId();
1049
+ /**
1050
+ * The routes each session reached this run, so scout_coverage can give a
1051
+ * lane in a parallel run its own remaining work rather than every lane's.
1052
+ * Per run, like the other per-run tallies.
1053
+ */
1054
+ sessionRoutes = new Map();
1055
+ /**
1056
+ * The state fingerprints each session recorded this run. A route's states
1057
+ * differ by who looked: an admin's table and a viewer's access-denied alert
1058
+ * are two states of one route, and a session's own coverage lists only the
1059
+ * controls on the states it saw.
1060
+ */
1061
+ sessionStates = new Map();
1062
+ /** Every state fingerprint recorded this run, by any session: the gap ledger's default scope. */
1063
+ runStates = new Set();
1064
+ /** The states this session recorded this run. */
1065
+ statesVisitedBy(session) {
1066
+ return this.sessionStates.get(session) ?? new Set();
1067
+ }
1068
+ /** Routes this session reached this run, in the order first reached. */
1069
+ routesVisitedBy(session) {
1070
+ return this.sessionRoutes.get(session) ?? new Set();
1071
+ }
1072
+ /** The sessions that reached this route this run. */
1073
+ sessionsOnRoute(route) {
1074
+ return [...this.sessionRoutes].filter(([, routes]) => routes.has(route)).map(([session]) => session);
1075
+ }
1076
+ /**
1077
+ * The dedup judge filings go through (fileFinding), or null for the rule
1078
+ * alone. Per run, like the probes: the server sets it when a session
1079
+ * attaches, and a run that never asks for it dedups by the rule only.
1080
+ */
1081
+ dedupJudge = null;
1082
+ /** What an attach of this run asked for by name; unset means the server's environment decides. */
1083
+ dedupChoice;
1084
+ /** Why the judge this run asked for could not be set up (no key), for the report; unset when it was not asked for or is on. */
1085
+ dedupOff;
813
1086
  /**
814
1087
  * Each dropdown's options and the ones chosen in THIS run, by any session,
815
1088
  * keyed by route and element. A select counts as exercised after one choice,
@@ -821,7 +1094,12 @@ export class MemoryStore {
821
1094
  * one reported.
822
1095
  */
823
1096
  selectChoices = new Map();
824
- recordSelectChoice(fingerprint, key, options, chosen) {
1097
+ /**
1098
+ * `loaded` names the options already selected before the choice: the value
1099
+ * the page loaded with has already been asked of the server, so it is not
1100
+ * owed a choice.
1101
+ */
1102
+ recordSelectChoice(fingerprint, key, options, chosen, loaded = []) {
825
1103
  const route = fingerprint.split("#")[0];
826
1104
  const id = `${route}\u0000${key}`;
827
1105
  const distinct = [...new Set(options)];
@@ -835,6 +1113,9 @@ export class MemoryStore {
835
1113
  entry.options = distinct;
836
1114
  if (chosen)
837
1115
  entry.chosen.add(chosen);
1116
+ for (const label of loaded)
1117
+ if (label)
1118
+ entry.chosen.add(label);
838
1119
  this.selectChoices.set(id, entry);
839
1120
  }
840
1121
  /** Dropdowns with options no session chose this run, in the order they were first used. */
@@ -862,10 +1143,12 @@ export class MemoryStore {
862
1143
  * takes it off the list for good: the page refuses the empty submit itself,
863
1144
  * and no one could clear an entry whose submit cannot be pressed.
864
1145
  */
865
- recordForm(fingerprint, key, guarded = false) {
1146
+ recordForm(fingerprint, key, guarded = false, session) {
866
1147
  const entry = this.formEntry(fingerprint, key);
867
1148
  if (entry && guarded)
868
1149
  entry.guarded = true;
1150
+ if (entry && session)
1151
+ entry.seenBy.add(session);
869
1152
  }
870
1153
  /**
871
1154
  * A submit of a form already seen; `empty` when every text field was blank
@@ -887,14 +1170,21 @@ export class MemoryStore {
887
1170
  const id = `${route}\u0000${key}`;
888
1171
  let entry = this.emptySubmits.get(id) ?? null;
889
1172
  if (!entry && create) {
890
- entry = { route, key, triedEmpty: false };
1173
+ entry = { route, key, triedEmpty: false, seenBy: new Set() };
891
1174
  this.emptySubmits.set(id, entry);
892
1175
  }
893
1176
  return entry;
894
1177
  }
895
- /** Forms seen this run that no session has submitted empty, in the order they were first seen. */
896
- formsNeverSubmittedEmpty() {
897
- return [...this.emptySubmits.values()].filter((f) => !f.triedEmpty && !f.guarded).map(({ route, key }) => ({ route, key }));
1178
+ /**
1179
+ * Forms seen this run that no session has submitted empty, in the order they
1180
+ * were first seen, each with the sessions that saw it. Given a session, only
1181
+ * the forms that session saw: in a parallel run another lane's form may sit
1182
+ * on a route this one never opens, or behind a role it does not have.
1183
+ */
1184
+ formsNeverSubmittedEmpty(session) {
1185
+ return [...this.emptySubmits.values()]
1186
+ .filter((f) => !f.triedEmpty && !f.guarded && (session === undefined || f.seenBy.has(session)))
1187
+ .map(({ route, key, seenBy }) => ({ route, key, seenBy: [...seenBy] }));
898
1188
  }
899
1189
  constructor(projectDir) {
900
1190
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
@@ -953,6 +1243,13 @@ export class MemoryStore {
953
1243
  dupOf.runs += f.runs;
954
1244
  if (!dupOf.evidence && f.evidence)
955
1245
  dupOf.evidence = f.evidence;
1246
+ // Each copy's judged merges are filings: folding the copies keeps them all.
1247
+ const trail = unionJudgedMerges(dupOf, f);
1248
+ if (trail)
1249
+ dupOf.judgedMerges = trail;
1250
+ const seenOn = withSeenOn(dupOf, [findingRoute(f), ...seenOnOf(f)]);
1251
+ if (seenOn)
1252
+ dupOf.seenOn = seenOn;
956
1253
  if (f.status === "resolved")
957
1254
  dupOf.status = "resolved";
958
1255
  const promoted = isWorthALook(dupOf) && !isWorthALook(f);
@@ -1070,6 +1367,45 @@ export class MemoryStore {
1070
1367
  get routeFacts() {
1071
1368
  return this.data.routeFacts ?? {};
1072
1369
  }
1370
+ /** Record that observe refused a script's POST to `endpoint` on `route`. Deduplicated; saved only when something changed. */
1371
+ noteObserveRefusedPost(route, endpoint, now = Date.now()) {
1372
+ const all = this.data.observeRefusedPosts ?? {};
1373
+ const forRoute = all[route] ?? {};
1374
+ const rec = forRoute[endpoint];
1375
+ if (rec && !rec.cleared)
1376
+ return;
1377
+ if (!rec && !all[route] && Object.keys(all).length >= MAX_REFUSED_POST_ROUTES)
1378
+ return;
1379
+ if (!rec && Object.keys(forRoute).length >= MAX_REFUSED_POSTS_PER_ROUTE)
1380
+ return;
1381
+ forRoute[endpoint] = { at: now };
1382
+ all[route] = forRoute;
1383
+ this.data.observeRefusedPosts = all;
1384
+ this.save();
1385
+ }
1386
+ /** Clear every open refusal whose endpoint `went` says has since gone out. Saved only when something changed. */
1387
+ clearObserveRefusedPosts(went, now = Date.now()) {
1388
+ let changed = false;
1389
+ for (const endpoints of Object.values(this.data.observeRefusedPosts ?? {}))
1390
+ for (const [endpoint, rec] of Object.entries(endpoints))
1391
+ if (!rec.cleared && went(endpoint)) {
1392
+ endpoints[endpoint] = { at: now, cleared: true };
1393
+ changed = true;
1394
+ }
1395
+ if (changed)
1396
+ this.save();
1397
+ }
1398
+ /** Pages with a POST observe refused and nothing has since let out, for the gap ledger. */
1399
+ get observeRefusedPosts() {
1400
+ return Object.entries(this.data.observeRefusedPosts ?? {})
1401
+ .map(([route, endpoints]) => ({
1402
+ route,
1403
+ endpoints: Object.entries(endpoints)
1404
+ .filter(([, rec]) => !rec.cleared)
1405
+ .map(([endpoint]) => endpoint),
1406
+ }))
1407
+ .filter((p) => p.endpoints.length > 0);
1408
+ }
1073
1409
  /** Record that `role` reached (or was denied) `route`. Denials never overwrite a recorded "reached" — flaky redirects must not erase real access. */
1074
1410
  recordRoleAccess(role, route, outcome) {
1075
1411
  const all = this.data.roleAccess ?? {};
@@ -1200,6 +1536,45 @@ export class MemoryStore {
1200
1536
  // every call after the first reported nothing kept — while storing fine.
1201
1537
  return Math.min(added, MAX_LANE_DECISIONS);
1202
1538
  }
1539
+ /** Every ticket the project has been given, oldest reading first. */
1540
+ get tickets() {
1541
+ return this.data.tickets ?? [];
1542
+ }
1543
+ /** Every verdict recorded on a criterion, oldest first. */
1544
+ get criterionVerdicts() {
1545
+ return this.data.criterionVerdicts ?? [];
1546
+ }
1547
+ /**
1548
+ * Keep tickets read in this run. A ticket read again replaces its earlier
1549
+ * reading (see addReading). Titles and criteria are redacted like any other
1550
+ * stored text: a ticket is pasted by a person and may carry a credential.
1551
+ * Returns the tickets as stored, with the ids to judge them by.
1552
+ */
1553
+ addTickets(parsed) {
1554
+ const clean = parsed.map((t) => ({
1555
+ ...t,
1556
+ title: redactSecrets(t.title),
1557
+ criteria: t.criteria.map((c) => ({ ...c, text: redactSecrets(c.text) })),
1558
+ }));
1559
+ const all = this.tickets;
1560
+ const thisRun = all.filter((t) => t.loadedAt >= this.sessionStart);
1561
+ const { tickets, added } = addReading(thisRun, clean, new Date().toISOString());
1562
+ const ids = new Set(tickets.map((t) => t.id));
1563
+ this.data.tickets = [...all.filter((t) => t.loadedAt < this.sessionStart && !ids.has(t.id)), ...tickets].slice(-MAX_TICKETS_KEPT);
1564
+ this.flush();
1565
+ return added;
1566
+ }
1567
+ /** Record one session's verdict on a criterion, replacing that session's earlier verdict on it. */
1568
+ addCriterionVerdict(record) {
1569
+ this.data.criterionVerdicts = addVerdict(this.criterionVerdicts, { ...record, reason: redactSecrets(record.reason) });
1570
+ this.flush();
1571
+ }
1572
+ /** What this run answers: the tickets read in it, or judged in it, and this run's verdicts. */
1573
+ ticketsThisRun() {
1574
+ const verdicts = this.criterionVerdicts.filter((v) => v.at >= this.sessionStart);
1575
+ const judged = new Set(verdicts.map((v) => v.ticket));
1576
+ return { tickets: this.tickets.filter((t) => t.loadedAt >= this.sessionStart || judged.has(t.id)), verdicts };
1577
+ }
1203
1578
  /** The routes each lane's report said it covered. Empty on a project that has never run one. */
1204
1579
  get laneRoutes() {
1205
1580
  return this.data.laneRoutes ?? {};
@@ -1221,6 +1596,16 @@ export class MemoryStore {
1221
1596
  this.flush();
1222
1597
  return added;
1223
1598
  }
1599
+ /** Keep a finding's picture: its path relative to .scenescout/, and what it shows. Returns the finding, or null when there is no such id. */
1600
+ setPicture(id, picture, shot) {
1601
+ const f = this.data.findings.find((x) => x.id === id);
1602
+ if (!f)
1603
+ return null;
1604
+ f.picture = picture;
1605
+ f.pictureShot = shot;
1606
+ this.flush();
1607
+ return f;
1608
+ }
1224
1609
  /** Mark a finding resolved; returns it or null. */
1225
1610
  resolveFinding(id) {
1226
1611
  const f = this.data.findings.find((x) => x.id === id);
@@ -1265,6 +1650,8 @@ export class MemoryStore {
1265
1650
  raw.states = kept;
1266
1651
  this.prunedStates = dropped;
1267
1652
  }
1653
+ if (raw.discoveredRoutes)
1654
+ raw.discoveredRoutes = renormalizeRoutes(raw.discoveredRoutes);
1268
1655
  return raw;
1269
1656
  }
1270
1657
  this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
@@ -1435,8 +1822,25 @@ export class MemoryStore {
1435
1822
  this.actionLog.push(full);
1436
1823
  fs.appendFileSync(this.sessionLogPath, JSON.stringify(full) + "\n");
1437
1824
  }
1438
- /** Record a visit to a state; returns whether it was new. */
1439
- visitState(fingerprint, url, route, elementKeys) {
1825
+ /**
1826
+ * Record a visit to a state; returns whether it was new. `inertKeys` are the
1827
+ * listed keys a user cannot act on (collector inertKeys): they stay known,
1828
+ * so a click on one still registers, but coverage does not count them.
1829
+ * `session` names who visited, for this run's per-session coverage.
1830
+ * `aliases` maps a listed key to the key the same control had under the
1831
+ * earlier name rule (fingerprint.ts keyAliases); the route keeps them so
1832
+ * states written before the rule changed are read under current keys.
1833
+ */
1834
+ visitState(fingerprint, url, route, elementKeys, inertKeys = [], session, aliases = {}) {
1835
+ this.runStates.add(fingerprint);
1836
+ if (session) {
1837
+ const routes = this.sessionRoutes.get(session) ?? new Set();
1838
+ routes.add(route);
1839
+ this.sessionRoutes.set(session, routes);
1840
+ const states = this.sessionStates.get(session) ?? new Set();
1841
+ states.add(fingerprint);
1842
+ this.sessionStates.set(session, states);
1843
+ }
1440
1844
  let rec = this.data.states[fingerprint];
1441
1845
  const isNew = !rec;
1442
1846
  if (!rec) {
@@ -1445,11 +1849,25 @@ export class MemoryStore {
1445
1849
  }
1446
1850
  rec.visits += 1;
1447
1851
  rec.lastSeen = new Date().toISOString();
1852
+ this.learnAliases(route, aliases);
1853
+ // A record written under the earlier rule can be reached again under the
1854
+ // same fingerprint with its keys renamed: the fingerprint hashes the set
1855
+ // of base keys, so a rename that only moves ordinals (an image button
1856
+ // named like a text button beside it) leaves it unchanged. Its keys are
1857
+ // moved to the current ones before it is marked current.
1858
+ if ((rec.nameRule ?? 1) < NAME_RULE)
1859
+ rec.elements = rekeyed(rec.elements, this.data.keyAliases?.[routeIdentity(route)]);
1860
+ rec.nameRule = NAME_RULE;
1448
1861
  const present = new Set(elementKeys);
1862
+ const inert = new Set(inertKeys);
1449
1863
  for (const key of elementKeys) {
1450
1864
  if (!rec.elements[key])
1451
1865
  rec.elements[key] = { exercised: false };
1452
1866
  rec.elements[key].absentStreak = 0;
1867
+ if (inert.has(key))
1868
+ rec.elements[key].inert = true;
1869
+ else
1870
+ delete rec.elements[key].inert;
1453
1871
  }
1454
1872
  // Prune ghosts: dynamic elements (list rows, ordinal-suffixed duplicates)
1455
1873
  // that vanish for 3 consecutive visits would otherwise make coverage
@@ -1464,6 +1882,23 @@ export class MemoryStore {
1464
1882
  this.save();
1465
1883
  return isNew;
1466
1884
  }
1885
+ /**
1886
+ * Keep each earlier key → current key the route has not learned yet. The
1887
+ * first reading stands: a later snapshot of another state of the route can
1888
+ * hand out ordinals differently, and the states written under the earlier
1889
+ * rule do not change, so neither should what their keys are read as.
1890
+ */
1891
+ learnAliases(route, aliases) {
1892
+ const pairs = Object.entries(aliases);
1893
+ if (pairs.length === 0)
1894
+ return;
1895
+ const byRoute = (this.data.keyAliases ??= {});
1896
+ const id = routeIdentity(route);
1897
+ const known = (byRoute[id] ??= {});
1898
+ for (const [key, prior] of pairs)
1899
+ if (!Object.hasOwn(known, prior))
1900
+ known[prior] = key;
1901
+ }
1467
1902
  markExercised(fingerprint, key, action) {
1468
1903
  const rec = this.data.states[fingerprint];
1469
1904
  if (!rec)
@@ -1490,11 +1925,73 @@ export class MemoryStore {
1490
1925
  * across sessions rarely reuses the exact words — Jaccard catches it).
1491
1926
  * Returns [finding, isNew].
1492
1927
  */
1493
- /** Returns the finding, whether it is new, and whether an existing worth-a-look was just promoted to a defect by it. */
1928
+ /**
1929
+ * Returns the finding, whether it is new, whether an existing worth-a-look
1930
+ * was just promoted to a defect by it, and how a merge treated what the two
1931
+ * filings disagree on (MergeNote).
1932
+ */
1494
1933
  addFinding(input) {
1495
- // Redact BEFORE the id is derived, so a re-found finding whose quoted
1496
- // secret differs by a character still hashes to the same id.
1497
- const f = {
1934
+ const f = this.redacted(input);
1935
+ const id = findingId(f);
1936
+ const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1937
+ if (existing) {
1938
+ const { promoted, merge } = this.mergeInto(existing, f, id);
1939
+ return [existing, false, promoted, merge];
1940
+ }
1941
+ return [this.append(f, id), true, false, { sameRun: false, updated: [], kept: [] }];
1942
+ }
1943
+ /**
1944
+ * File a finding, asking the dedup judge when one is set (dedupJudge) and
1945
+ * the rule keeps the filing apart from everything stored. The rule decides
1946
+ * first, exactly as addFinding: a merge the rule makes is never put to the
1947
+ * model, so the judge can only add merges, never undo one, and the rule's
1948
+ * merge at the next load (retroMerge) cannot split what the judge joined.
1949
+ * Without a judge this is addFinding.
1950
+ */
1951
+ async fileFinding(input) {
1952
+ const f = this.redacted(input);
1953
+ const id = findingId(f);
1954
+ const byRule = this.data.findings.find((x) => isDuplicateFinding(x, f));
1955
+ if (byRule)
1956
+ return { finding: byRule, isNew: false, ...this.mergeInto(byRule, f, id) };
1957
+ const judge = this.dedupJudge;
1958
+ if (!judge)
1959
+ return { finding: this.append(f, id), isNew: true, promoted: false };
1960
+ const before = new Set(this.data.findings.map((x) => x.id));
1961
+ let verdict = null;
1962
+ let judgeError;
1963
+ try {
1964
+ verdict = await judge.judge(f, [...this.data.findings]);
1965
+ }
1966
+ catch (err) {
1967
+ judgeError = err instanceof Error ? err.message : String(err);
1968
+ }
1969
+ const extra = judgeError ? { judgeError } : {};
1970
+ // The list may have changed while the judge was asked. A filing that
1971
+ // landed meanwhile is compared by the rule, as it would have been with no
1972
+ // judge; the finding the judge chose is looked up again by id, since a
1973
+ // merge with another process's memory may have replaced its object.
1974
+ const landed = this.data.findings.find((x) => !before.has(x.id) && isDuplicateFinding(x, f));
1975
+ if (landed)
1976
+ return { finding: landed, isNew: false, ...this.mergeInto(landed, f, id), ...extra };
1977
+ // Only what a judge may answer is taken: an open finding on the filing's page, called the same at better than even.
1978
+ const route = f.state.split("#")[0];
1979
+ const chosen = verdict && verdict.pSame >= 0.5
1980
+ ? this.data.findings.find((x) => x.id === verdict.sameAs && x.status !== "resolved" && x.state.split("#")[0] === route)
1981
+ : undefined;
1982
+ if (chosen && verdict) {
1983
+ const judged = { pSame: verdict.pSame };
1984
+ return { finding: chosen, isNew: false, ...this.mergeInto(chosen, f, id, judged), judged, ...extra };
1985
+ }
1986
+ return { finding: this.append(f, id), isNew: true, promoted: false, ...extra };
1987
+ }
1988
+ /**
1989
+ * The filing with secrets redacted. BEFORE the id is derived, so a re-found
1990
+ * finding whose quoted secret differs by a character still hashes to the
1991
+ * same id; and before the dedup judge is shown it.
1992
+ */
1993
+ redacted(input) {
1994
+ return {
1498
1995
  ...input,
1499
1996
  title: redactSecrets(input.title),
1500
1997
  detail: redactSecrets(input.detail),
@@ -1503,37 +2000,84 @@ export class MemoryStore {
1503
2000
  url: redactSecrets(input.url),
1504
2001
  evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
1505
2002
  };
1506
- const id = findingId(f);
1507
- const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1508
- if (existing) {
2003
+ }
2004
+ /**
2005
+ * Fold a filing into the finding it duplicates. Returns whether a
2006
+ * worth-a-look was promoted to a defect by it. `judged`: the dedup judge
2007
+ * made this merge, so the filing is kept on the finding (judgedMerges).
2008
+ */
2009
+ mergeInto(existing, f, id, judged) {
2010
+ // Counted once per run: a lane filing the same thing again minutes
2011
+ // later is not the bug recurring.
2012
+ const sameRun = existing.lastRun === this.runId;
2013
+ if (!sameRun)
1509
2014
  existing.runs += 1;
1510
- existing.foundAt = new Date().toISOString();
1511
- if (!existing.evidence && f.evidence)
1512
- existing.evidence = f.evidence;
1513
- // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
1514
- const promoted = isWorthALook(existing) && !isWorthALook(f);
1515
- const tier = mergeTier(existing, f);
1516
- Object.assign(withoutTier(existing), tier);
1517
- if (promoted)
1518
- existing.severity = f.severity;
1519
- // Re-finding a RESOLVED finding is a regression — reopen it loudly
1520
- // rather than letting it hide in the report's completed section. But a
1521
- // FUZZY match must never resurrect a fixed bug: telling someone a
1522
- // regression landed when it did not is far more costly than carrying a
1523
- // visible duplicate, and it corrupts the one signal that says whether a
1524
- // fix held. Demand an exact identity match (same category+title+route) or
1525
- // an identical evidence signature before reopening.
1526
- const exactMatch = existing.id === id ||
1527
- (!!existing.evidence &&
1528
- !!f.evidence &&
1529
- existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
1530
- if (existing.status === "resolved" && exactMatch) {
1531
- existing.status = "open";
1532
- existing.regressedAt = existing.foundAt;
2015
+ existing.lastRun = this.runId;
2016
+ existing.foundAt = new Date().toISOString();
2017
+ if (!existing.evidence && f.evidence)
2018
+ existing.evidence = f.evidence;
2019
+ // A filing from another page is another place the defect shows.
2020
+ const route = findingRoute(f);
2021
+ const knew = route === findingRoute(existing) || seenOnOf(existing).includes(route);
2022
+ const seenOn = withSeenOn(existing, [route]);
2023
+ if (seenOn)
2024
+ existing.seenOn = seenOn;
2025
+ // The same session filing it again in the same run is correcting its
2026
+ // own filing: its newer convention and detail are taken. Anyone else's
2027
+ // filing is a second sighting, which keeps the first wording. A merge the
2028
+ // dedup judge made is never a correction: the judge saw two filings.
2029
+ const correction = !judged && sameRun && !!f.session && existing.session === f.session;
2030
+ const merge = { sameRun, updated: [], kept: [], ...(knew ? {} : { seenOn: route }) };
2031
+ // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
2032
+ const promoted = isWorthALook(existing) && !isWorthALook(f);
2033
+ const tier = mergeTier(existing, f, correction ? "incoming" : "existing");
2034
+ // A convention filled in where there was none is not a disagreement, so it says nothing.
2035
+ if (isWorthALook(existing) && isWorthALook(f) && f.convention && existing.convention && f.convention !== existing.convention) {
2036
+ (correction ? merge.updated : merge.kept).push("convention");
2037
+ }
2038
+ if (f.detail && f.detail !== existing.detail) {
2039
+ if (correction) {
2040
+ existing.detail = f.detail;
2041
+ merge.updated.push("detail");
2042
+ }
2043
+ else {
2044
+ merge.kept.push("detail");
1533
2045
  }
1534
- this.flush();
1535
- return [existing, false, promoted];
1536
2046
  }
2047
+ Object.assign(withoutTier(existing), tier);
2048
+ if (promoted)
2049
+ existing.severity = f.severity;
2050
+ // Re-finding a RESOLVED finding is a regression — reopen it loudly
2051
+ // rather than letting it hide in the report's completed section. But a
2052
+ // FUZZY match must never resurrect a fixed bug: telling someone a
2053
+ // regression landed when it did not is far more costly than carrying a
2054
+ // visible duplicate, and it corrupts the one signal that says whether a
2055
+ // fix held. Demand an exact identity match (same category+title+route) or
2056
+ // an identical evidence signature before reopening.
2057
+ const exactMatch = existing.id === id ||
2058
+ (!!existing.evidence &&
2059
+ !!f.evidence &&
2060
+ existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
2061
+ if (existing.status === "resolved" && exactMatch) {
2062
+ existing.status = "open";
2063
+ existing.regressedAt = existing.foundAt;
2064
+ }
2065
+ if (judged) {
2066
+ const entry = {
2067
+ title: f.title,
2068
+ category: f.category,
2069
+ severity: f.severity,
2070
+ ...(f.evidence ? { evidence: f.evidence } : {}),
2071
+ pSame: judged.pSame,
2072
+ at: existing.foundAt,
2073
+ };
2074
+ existing.judgedMerges = [...(existing.judgedMerges ?? []), entry].slice(-MAX_JUDGED_MERGES);
2075
+ }
2076
+ this.flush();
2077
+ return { promoted, merge };
2078
+ }
2079
+ /** Store a filing as a new finding, with its repro trace. */
2080
+ append(f, id) {
1537
2081
  // Repro trace scoped to the finding's route: everything since the action
1538
2082
  // that landed there, not 12 lines of unrelated cross-module noise.
1539
2083
  const routeOf = (url) => {
@@ -1566,10 +2110,11 @@ export class MemoryStore {
1566
2110
  .map((a) => `${a.action}${a.target ? ` ${a.target}` : ""} @ ${a.url}`),
1567
2111
  foundAt: new Date().toISOString(),
1568
2112
  runs: 1,
2113
+ lastRun: this.runId,
1569
2114
  };
1570
2115
  this.data.findings.push(finding);
1571
2116
  this.flush();
1572
- return [finding, true, false];
2117
+ return finding;
1573
2118
  }
1574
2119
  get findings() {
1575
2120
  return this.data.findings;
@@ -1582,9 +2127,15 @@ export class MemoryStore {
1582
2127
  * rendered in 30 states of one route is one set of elements, not 30. An
1583
2128
  * element counts as exercised when it was exercised in ANY state of the
1584
2129
  * route. (`state` in the result therefore holds a route.)
2130
+ *
2131
+ * `scope.routes` narrows the routes counted; `scope.states` narrows the
2132
+ * elements listed to the ones those states hold (a session's own states, or
2133
+ * this run's), while whether one was exercised still comes from every state
2134
+ * of the route.
1585
2135
  */
1586
- coverage() {
2136
+ coverage(scope) {
1587
2137
  const byRoute = this.elementsByRoute();
2138
+ const listed = scope?.states ? this.elementsByRoute(scope.states) : byRoute;
1588
2139
  // Shared layout CHROME (sidebar nav, header, breadcrumbs) is one set of
1589
2140
  // components, not one set per route — clicking "nav-documents" on /admin is
1590
2141
  // the same click as on /. Counting it per route inflated the denominator by
@@ -1597,8 +2148,14 @@ export class MemoryStore {
1597
2148
  let elementsTotal = 0;
1598
2149
  let elementsExercised = 0;
1599
2150
  const unexercised = [];
2151
+ const perRoute = new Map();
1600
2152
  const embeds = { total: 0, exercised: 0 };
1601
- for (const [route, elements] of byRoute) {
2153
+ for (const [route, elements] of listed) {
2154
+ // A scope narrows what is counted, never what counts as chrome: that is
2155
+ // decided over every route, so a lane on two pages does not see the
2156
+ // project's sidebar as those pages' own controls.
2157
+ if (scope?.routes && !scope.routes.has(route))
2158
+ continue;
1602
2159
  const own = [];
1603
2160
  // The route's OWN element count — deduped across states and with shared
1604
2161
  // chrome removed, i.e. exactly the denominator `own` is a subset of.
@@ -1625,6 +2182,7 @@ export class MemoryStore {
1625
2182
  else
1626
2183
  own.push(key);
1627
2184
  }
2185
+ perRoute.set(route, { total: ownTotal, exercised: ownTotal - own.length });
1628
2186
  if (own.length > 0)
1629
2187
  unexercised.push({ state: route, keys: own, total: ownTotal });
1630
2188
  }
@@ -1640,7 +2198,10 @@ export class MemoryStore {
1640
2198
  if (chromeLeft.length > 0) {
1641
2199
  unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
1642
2200
  }
1643
- return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised, embeds };
2201
+ const states = scope
2202
+ ? Object.entries(this.data.states).filter(([fp, st]) => (!scope.routes || scope.routes.has(routeIdentity(st.route))) && (!scope.states || scope.states.has(fp))).length
2203
+ : Object.keys(this.data.states).length;
2204
+ return { states, elementsTotal, elementsExercised, unexercised, perRoute, embeds };
1644
2205
  }
1645
2206
  /**
1646
2207
  * Remember which styled-element signatures the design audit saw on a route.
@@ -1674,18 +2235,55 @@ export class MemoryStore {
1674
2235
  keys.add(sig);
1675
2236
  return keys;
1676
2237
  }
1677
- /** Element keys folded per route, exercised-in-any-state. */
1678
- elementsByRoute() {
1679
- const byRoute = new Map();
1680
- for (const rec of Object.values(this.data.states)) {
1681
- let route = byRoute.get(rec.route);
1682
- if (!route) {
1683
- route = new Map();
1684
- byRoute.set(rec.route, route);
2238
+ /**
2239
+ * Element keys folded per route, exercised-in-any-state. With `only`, the
2240
+ * keys listed are the ones those states hold; exercised still reads every
2241
+ * state of the route.
2242
+ *
2243
+ * A key is not a control when ANY state of the route recorded it as inert.
2244
+ * Whether a user can act on an element depends on the element, not on the
2245
+ * state it was seen in, and states recorded before the flag existed carry
2246
+ * an unflagged copy that would otherwise keep a wrapper in the count until
2247
+ * that exact state is visited again.
2248
+ *
2249
+ * Routes are re-read through route identity, so states recorded under an
2250
+ * older form of a route (before an id shape collapsed) fold into it.
2251
+ */
2252
+ elementsByRoute(only) {
2253
+ const inert = new Map();
2254
+ const exercised = new Map();
2255
+ const listed = new Map();
2256
+ const bucket = (map, route) => {
2257
+ let set = map.get(route);
2258
+ if (!set) {
2259
+ set = new Set();
2260
+ map.set(route, set);
2261
+ }
2262
+ return set;
2263
+ };
2264
+ for (const [fp, rec] of Object.entries(this.data.states)) {
2265
+ const route = routeIdentity(rec.route);
2266
+ const keys = only && !only.has(fp) ? null : bucket(listed, route);
2267
+ // A state written under an earlier name rule is read under current keys.
2268
+ const elements = (rec.nameRule ?? 1) < NAME_RULE ? rekeyed(rec.elements, this.data.keyAliases?.[route]) : rec.elements;
2269
+ for (const [key, v] of Object.entries(elements)) {
2270
+ if (v.inert)
2271
+ bucket(inert, route).add(key);
2272
+ if (v.exercised)
2273
+ bucket(exercised, route).add(key);
2274
+ keys?.add(key);
1685
2275
  }
1686
- for (const [key, v] of Object.entries(rec.elements)) {
1687
- route.set(key, (route.get(key) ?? false) || v.exercised);
2276
+ }
2277
+ const byRoute = new Map();
2278
+ for (const [route, keys] of listed) {
2279
+ const elements = new Map();
2280
+ for (const key of keys) {
2281
+ // Not a control: nothing to exercise, so not counted as a gap or a total.
2282
+ if (inert.get(route)?.has(key))
2283
+ continue;
2284
+ elements.set(key, exercised.get(route)?.has(key) ?? false);
1688
2285
  }
2286
+ byRoute.set(route, elements);
1689
2287
  }
1690
2288
  return byRoute;
1691
2289
  }