scenescout 3.15.0 → 3.17.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 +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -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,27 @@ 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
+ }
29
58
  /** Most judged merges one finding keeps; the oldest go first. */
30
59
  export const MAX_JUDGED_MERGES = 10;
31
60
  /**
@@ -54,23 +83,36 @@ export function judgedMergesOf(f) {
54
83
  export function isWorthALook(f) {
55
84
  return f.tier === "worth_a_look";
56
85
  }
57
- /**
58
- * The tier a finding keeps when it is found again. A defect wins: a finding
59
- * someone filed as a defect is a decision about the project's convention, and
60
- * a later "worth a look" for the same thing does not undo it. A worth-a-look
61
- * filed again as a defect is promoted, and loses its convention. Two
62
- * worth-a-looks keep the first convention unless it had none.
63
- */
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
+ }
64
105
  /** Drops a finding's tier and convention in place, so a merged tier can be assigned onto it. */
65
106
  function withoutTier(f) {
66
107
  delete f.tier;
67
108
  delete f.convention;
68
109
  return f;
69
110
  }
70
- export function mergeTier(existing, incoming) {
111
+ export function mergeTier(existing, incoming, prefer = "existing") {
71
112
  if (!isWorthALook(existing) || !isWorthALook(incoming))
72
113
  return {};
73
- 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 };
74
116
  }
75
117
  /**
76
118
  * Most states a route may keep. A route accumulates one state per distinct
@@ -201,12 +243,41 @@ export function redactSecrets(text) {
201
243
  }
202
244
  return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
203
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
+ }
204
255
  /** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
205
256
  export const JOURNEY_START = "journey:start";
206
257
  export const JOURNEY_END = "journey:end";
207
258
  /** Logged when a session states the task it is starting, so the feed can group the actions that follow under it. */
208
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;
209
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
+ }
210
281
  /**
211
282
  * Fold another process's memory into ours, losing nothing from either side.
212
283
  *
@@ -222,6 +293,13 @@ const EMPTY = { version: 1, states: {}, findings: [] };
222
293
  */
223
294
  export function mergeMemory(mine, theirs) {
224
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;
225
303
  out.states = { ...theirs.states };
226
304
  for (const [fp, ours] of Object.entries(mine.states)) {
227
305
  const other = theirs.states[fp];
@@ -229,8 +307,13 @@ export function mergeMemory(mine, theirs) {
229
307
  out.states[fp] = ours;
230
308
  continue;
231
309
  }
232
- const elements = { ...other.elements };
233
- 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))) {
234
317
  const prev = elements[key];
235
318
  elements[key] = {
236
319
  exercised: (prev?.exercised ?? false) || el.exercised,
@@ -246,6 +329,7 @@ export function mergeMemory(mine, theirs) {
246
329
  firstSeen: ours.firstSeen < other.firstSeen ? ours.firstSeen : other.firstSeen,
247
330
  visits: Math.max(ours.visits, other.visits),
248
331
  elements,
332
+ ...(nameRule ? { nameRule } : {}),
249
333
  };
250
334
  }
251
335
  const byId = new Map();
@@ -267,6 +351,15 @@ export function mergeMemory(mine, theirs) {
267
351
  evidence: newer.evidence ?? older.evidence,
268
352
  regressedAt: newer.regressedAt ?? older.regressedAt,
269
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
+ }
270
363
  // The tier is not "later knowledge wins": a defect on either side is a decision
271
364
  // about the convention, and a store still holding the worth-a-look must not undo it.
272
365
  const tier = mergeTier(older, newer);
@@ -279,6 +372,12 @@ export function mergeMemory(mine, theirs) {
279
372
  merged.judgedMerges = trail;
280
373
  else
281
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;
282
381
  byId.set(f.id, merged);
283
382
  }
284
383
  out.findings = [...byId.values()];
@@ -306,6 +405,17 @@ export function mergeMemory(mine, theirs) {
306
405
  }
307
406
  if (Object.keys(out.routeFacts).length === 0)
308
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;
309
419
  out.roleAccess = { ...(theirs.roleAccess ?? {}) };
310
420
  for (const [role, routes] of Object.entries(mine.roleAccess ?? {})) {
311
421
  out.roleAccess[role] = { ...(out.roleAccess[role] ?? {}), ...routes };
@@ -333,6 +443,16 @@ export function mergeMemory(mine, theirs) {
333
443
  }
334
444
  if (Object.keys(out.laneRoutes).length === 0)
335
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;
336
456
  return out;
337
457
  }
338
458
  /** Two findings' judged merges as one list, each filing once, oldest first, at most MAX_JUDGED_MERGES. Idempotent. */
@@ -375,11 +495,54 @@ function unionRoutes(a, b) {
375
495
  function decisionKey(d) {
376
496
  return [d.lane, d.observation, d.verdict, d.severity ?? "", d.category ?? "", d.confidence, d.evidence ?? ""].join("|");
377
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
+ }
378
514
  /**
379
- * Most lane decisions kept. Calibration wants a few dozen; a long-lived
380
- * project would otherwise accumulate every decision ever made and re-serialise
381
- * 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.
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.
382
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
+ }
383
546
  /**
384
547
  * Whether a coverage key belongs to a control inside another site's frame:
385
548
  * those keys carry the frame's origin (collector.ts frameElementKey), where
@@ -388,6 +551,11 @@ function decisionKey(d) {
388
551
  export function isEmbedKey(key) {
389
552
  return /^frame:https?:\/\//.test(key);
390
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
+ */
391
559
  export const MAX_LANE_DECISIONS = 1000;
392
560
  /**
393
561
  * Most options a dropdown may have and still be tracked for unchosen options.
@@ -653,12 +821,24 @@ function sameFinding(a, b) {
653
821
  // that answered 2xx counts too — a false success names one — so the same bug
654
822
  // described once by its page load and once by its failing call stays as two
655
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.
656
835
  if (sameFamily(a.category, b.category) && !requestsDisagree(a.evidence, b.evidence)) {
657
836
  const aTitleLits = findingLiterals(a.title);
658
837
  const bTitleLits = findingLiterals(b.title);
659
838
  if (aTitleLits.size > 0 || bTitleLits.size > 0) {
660
- const aAll = findingLiterals(a.title, a.detail, a.evidence);
661
- 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);
662
842
  for (const lit of aTitleLits)
663
843
  if (bAll.has(lit))
664
844
  return true;
@@ -854,6 +1034,44 @@ export class MemoryStore {
854
1034
  this.dedupJudge = null;
855
1035
  this.dedupChoice = undefined;
856
1036
  this.dedupOff = undefined;
1037
+ this.sessionRoutes.clear();
1038
+ this.sessionStates.clear();
1039
+ this.runStates.clear();
1040
+ this.runId = newRunId();
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);
857
1075
  }
858
1076
  /**
859
1077
  * The dedup judge filings go through (fileFinding), or null for the rule
@@ -876,7 +1094,12 @@ export class MemoryStore {
876
1094
  * one reported.
877
1095
  */
878
1096
  selectChoices = new Map();
879
- 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 = []) {
880
1103
  const route = fingerprint.split("#")[0];
881
1104
  const id = `${route}\u0000${key}`;
882
1105
  const distinct = [...new Set(options)];
@@ -890,6 +1113,9 @@ export class MemoryStore {
890
1113
  entry.options = distinct;
891
1114
  if (chosen)
892
1115
  entry.chosen.add(chosen);
1116
+ for (const label of loaded)
1117
+ if (label)
1118
+ entry.chosen.add(label);
893
1119
  this.selectChoices.set(id, entry);
894
1120
  }
895
1121
  /** Dropdowns with options no session chose this run, in the order they were first used. */
@@ -917,10 +1143,12 @@ export class MemoryStore {
917
1143
  * takes it off the list for good: the page refuses the empty submit itself,
918
1144
  * and no one could clear an entry whose submit cannot be pressed.
919
1145
  */
920
- recordForm(fingerprint, key, guarded = false) {
1146
+ recordForm(fingerprint, key, guarded = false, session) {
921
1147
  const entry = this.formEntry(fingerprint, key);
922
1148
  if (entry && guarded)
923
1149
  entry.guarded = true;
1150
+ if (entry && session)
1151
+ entry.seenBy.add(session);
924
1152
  }
925
1153
  /**
926
1154
  * A submit of a form already seen; `empty` when every text field was blank
@@ -942,14 +1170,21 @@ export class MemoryStore {
942
1170
  const id = `${route}\u0000${key}`;
943
1171
  let entry = this.emptySubmits.get(id) ?? null;
944
1172
  if (!entry && create) {
945
- entry = { route, key, triedEmpty: false };
1173
+ entry = { route, key, triedEmpty: false, seenBy: new Set() };
946
1174
  this.emptySubmits.set(id, entry);
947
1175
  }
948
1176
  return entry;
949
1177
  }
950
- /** Forms seen this run that no session has submitted empty, in the order they were first seen. */
951
- formsNeverSubmittedEmpty() {
952
- 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] }));
953
1188
  }
954
1189
  constructor(projectDir) {
955
1190
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
@@ -1012,6 +1247,9 @@ export class MemoryStore {
1012
1247
  const trail = unionJudgedMerges(dupOf, f);
1013
1248
  if (trail)
1014
1249
  dupOf.judgedMerges = trail;
1250
+ const seenOn = withSeenOn(dupOf, [findingRoute(f), ...seenOnOf(f)]);
1251
+ if (seenOn)
1252
+ dupOf.seenOn = seenOn;
1015
1253
  if (f.status === "resolved")
1016
1254
  dupOf.status = "resolved";
1017
1255
  const promoted = isWorthALook(dupOf) && !isWorthALook(f);
@@ -1129,6 +1367,45 @@ export class MemoryStore {
1129
1367
  get routeFacts() {
1130
1368
  return this.data.routeFacts ?? {};
1131
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
+ }
1132
1409
  /** Record that `role` reached (or was denied) `route`. Denials never overwrite a recorded "reached" — flaky redirects must not erase real access. */
1133
1410
  recordRoleAccess(role, route, outcome) {
1134
1411
  const all = this.data.roleAccess ?? {};
@@ -1259,6 +1536,45 @@ export class MemoryStore {
1259
1536
  // every call after the first reported nothing kept — while storing fine.
1260
1537
  return Math.min(added, MAX_LANE_DECISIONS);
1261
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
+ }
1262
1578
  /** The routes each lane's report said it covered. Empty on a project that has never run one. */
1263
1579
  get laneRoutes() {
1264
1580
  return this.data.laneRoutes ?? {};
@@ -1280,6 +1596,16 @@ export class MemoryStore {
1280
1596
  this.flush();
1281
1597
  return added;
1282
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
+ }
1283
1609
  /** Mark a finding resolved; returns it or null. */
1284
1610
  resolveFinding(id) {
1285
1611
  const f = this.data.findings.find((x) => x.id === id);
@@ -1324,6 +1650,8 @@ export class MemoryStore {
1324
1650
  raw.states = kept;
1325
1651
  this.prunedStates = dropped;
1326
1652
  }
1653
+ if (raw.discoveredRoutes)
1654
+ raw.discoveredRoutes = renormalizeRoutes(raw.discoveredRoutes);
1327
1655
  return raw;
1328
1656
  }
1329
1657
  this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
@@ -1498,8 +1826,21 @@ export class MemoryStore {
1498
1826
  * Record a visit to a state; returns whether it was new. `inertKeys` are the
1499
1827
  * listed keys a user cannot act on (collector inertKeys): they stay known,
1500
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.
1501
1833
  */
1502
- visitState(fingerprint, url, route, elementKeys, inertKeys = []) {
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
+ }
1503
1844
  let rec = this.data.states[fingerprint];
1504
1845
  const isNew = !rec;
1505
1846
  if (!rec) {
@@ -1508,6 +1849,15 @@ export class MemoryStore {
1508
1849
  }
1509
1850
  rec.visits += 1;
1510
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;
1511
1861
  const present = new Set(elementKeys);
1512
1862
  const inert = new Set(inertKeys);
1513
1863
  for (const key of elementKeys) {
@@ -1532,6 +1882,23 @@ export class MemoryStore {
1532
1882
  this.save();
1533
1883
  return isNew;
1534
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
+ }
1535
1902
  markExercised(fingerprint, key, action) {
1536
1903
  const rec = this.data.states[fingerprint];
1537
1904
  if (!rec)
@@ -1558,14 +1925,20 @@ export class MemoryStore {
1558
1925
  * across sessions rarely reuses the exact words — Jaccard catches it).
1559
1926
  * Returns [finding, isNew].
1560
1927
  */
1561
- /** 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
+ */
1562
1933
  addFinding(input) {
1563
1934
  const f = this.redacted(input);
1564
1935
  const id = findingId(f);
1565
1936
  const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1566
- if (existing)
1567
- return [existing, false, this.mergeInto(existing, f, id)];
1568
- return [this.append(f, id), true, false];
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: [] }];
1569
1942
  }
1570
1943
  /**
1571
1944
  * File a finding, asking the dedup judge when one is set (dedupJudge) and
@@ -1580,7 +1953,7 @@ export class MemoryStore {
1580
1953
  const id = findingId(f);
1581
1954
  const byRule = this.data.findings.find((x) => isDuplicateFinding(x, f));
1582
1955
  if (byRule)
1583
- return { finding: byRule, isNew: false, promoted: this.mergeInto(byRule, f, id) };
1956
+ return { finding: byRule, isNew: false, ...this.mergeInto(byRule, f, id) };
1584
1957
  const judge = this.dedupJudge;
1585
1958
  if (!judge)
1586
1959
  return { finding: this.append(f, id), isNew: true, promoted: false };
@@ -1600,7 +1973,7 @@ export class MemoryStore {
1600
1973
  // merge with another process's memory may have replaced its object.
1601
1974
  const landed = this.data.findings.find((x) => !before.has(x.id) && isDuplicateFinding(x, f));
1602
1975
  if (landed)
1603
- return { finding: landed, isNew: false, promoted: this.mergeInto(landed, f, id), ...extra };
1976
+ return { finding: landed, isNew: false, ...this.mergeInto(landed, f, id), ...extra };
1604
1977
  // Only what a judge may answer is taken: an open finding on the filing's page, called the same at better than even.
1605
1978
  const route = f.state.split("#")[0];
1606
1979
  const chosen = verdict && verdict.pSame >= 0.5
@@ -1608,7 +1981,7 @@ export class MemoryStore {
1608
1981
  : undefined;
1609
1982
  if (chosen && verdict) {
1610
1983
  const judged = { pSame: verdict.pSame };
1611
- return { finding: chosen, isNew: false, promoted: this.mergeInto(chosen, f, id, judged), judged, ...extra };
1984
+ return { finding: chosen, isNew: false, ...this.mergeInto(chosen, f, id, judged), judged, ...extra };
1612
1985
  }
1613
1986
  return { finding: this.append(f, id), isNew: true, promoted: false, ...extra };
1614
1987
  }
@@ -1634,13 +2007,43 @@ export class MemoryStore {
1634
2007
  * made this merge, so the filing is kept on the finding (judgedMerges).
1635
2008
  */
1636
2009
  mergeInto(existing, f, id, judged) {
1637
- existing.runs += 1;
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)
2014
+ existing.runs += 1;
2015
+ existing.lastRun = this.runId;
1638
2016
  existing.foundAt = new Date().toISOString();
1639
2017
  if (!existing.evidence && f.evidence)
1640
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 }) };
1641
2031
  // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
1642
2032
  const promoted = isWorthALook(existing) && !isWorthALook(f);
1643
- const tier = mergeTier(existing, 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");
2045
+ }
2046
+ }
1644
2047
  Object.assign(withoutTier(existing), tier);
1645
2048
  if (promoted)
1646
2049
  existing.severity = f.severity;
@@ -1671,7 +2074,7 @@ export class MemoryStore {
1671
2074
  existing.judgedMerges = [...(existing.judgedMerges ?? []), entry].slice(-MAX_JUDGED_MERGES);
1672
2075
  }
1673
2076
  this.flush();
1674
- return promoted;
2077
+ return { promoted, merge };
1675
2078
  }
1676
2079
  /** Store a filing as a new finding, with its repro trace. */
1677
2080
  append(f, id) {
@@ -1707,6 +2110,7 @@ export class MemoryStore {
1707
2110
  .map((a) => `${a.action}${a.target ? ` ${a.target}` : ""} @ ${a.url}`),
1708
2111
  foundAt: new Date().toISOString(),
1709
2112
  runs: 1,
2113
+ lastRun: this.runId,
1710
2114
  };
1711
2115
  this.data.findings.push(finding);
1712
2116
  this.flush();
@@ -1723,9 +2127,15 @@ export class MemoryStore {
1723
2127
  * rendered in 30 states of one route is one set of elements, not 30. An
1724
2128
  * element counts as exercised when it was exercised in ANY state of the
1725
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.
1726
2135
  */
1727
- coverage() {
2136
+ coverage(scope) {
1728
2137
  const byRoute = this.elementsByRoute();
2138
+ const listed = scope?.states ? this.elementsByRoute(scope.states) : byRoute;
1729
2139
  // Shared layout CHROME (sidebar nav, header, breadcrumbs) is one set of
1730
2140
  // components, not one set per route — clicking "nav-documents" on /admin is
1731
2141
  // the same click as on /. Counting it per route inflated the denominator by
@@ -1738,8 +2148,14 @@ export class MemoryStore {
1738
2148
  let elementsTotal = 0;
1739
2149
  let elementsExercised = 0;
1740
2150
  const unexercised = [];
2151
+ const perRoute = new Map();
1741
2152
  const embeds = { total: 0, exercised: 0 };
1742
- 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;
1743
2159
  const own = [];
1744
2160
  // The route's OWN element count — deduped across states and with shared
1745
2161
  // chrome removed, i.e. exactly the denominator `own` is a subset of.
@@ -1766,6 +2182,7 @@ export class MemoryStore {
1766
2182
  else
1767
2183
  own.push(key);
1768
2184
  }
2185
+ perRoute.set(route, { total: ownTotal, exercised: ownTotal - own.length });
1769
2186
  if (own.length > 0)
1770
2187
  unexercised.push({ state: route, keys: own, total: ownTotal });
1771
2188
  }
@@ -1781,7 +2198,10 @@ export class MemoryStore {
1781
2198
  if (chromeLeft.length > 0) {
1782
2199
  unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
1783
2200
  }
1784
- 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 };
1785
2205
  }
1786
2206
  /**
1787
2207
  * Remember which styled-element signatures the design audit saw on a route.
@@ -1815,21 +2235,55 @@ export class MemoryStore {
1815
2235
  keys.add(sig);
1816
2236
  return keys;
1817
2237
  }
1818
- /** Element keys folded per route, exercised-in-any-state. */
1819
- elementsByRoute() {
1820
- const byRoute = new Map();
1821
- for (const rec of Object.values(this.data.states)) {
1822
- let route = byRoute.get(rec.route);
1823
- if (!route) {
1824
- route = new Map();
1825
- 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);
1826
2261
  }
1827
- for (const [key, v] of Object.entries(rec.elements)) {
1828
- // Not a control: nothing to exercise, so not counted as a gap or a total.
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)) {
1829
2270
  if (v.inert)
2271
+ bucket(inert, route).add(key);
2272
+ if (v.exercised)
2273
+ bucket(exercised, route).add(key);
2274
+ keys?.add(key);
2275
+ }
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))
1830
2283
  continue;
1831
- route.set(key, (route.get(key) ?? false) || v.exercised);
2284
+ elements.set(key, exercised.get(route)?.has(key) ?? false);
1832
2285
  }
2286
+ byRoute.set(route, elements);
1833
2287
  }
1834
2288
  return byRoute;
1835
2289
  }