scenescout 1.0.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.
@@ -0,0 +1,1116 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { normalizePath, shortHash } from "./fingerprint.js";
4
+ /** An element class must appear on this many routes at minimum before it can count as shared chrome. */
5
+ const CHROME_MIN_ROUTES = 4;
6
+ /** …and on at least this share of all visited routes (a majority — "it's on every page"). */
7
+ const CHROME_ROUTE_SHARE = 0.6;
8
+ /**
9
+ * …OR on this many routes outright. A section shell (an admin sub-nav across 35
10
+ * of 99 routes) is below the majority share but is still one component repeated,
11
+ * not 35 separate surfaces.
12
+ */
13
+ const CHROME_ABSOLUTE_ROUTES = 10;
14
+ /** Pseudo-route the deduplicated shell surface is reported under. */
15
+ export const SHARED_CHROME_ROUTE = "(shared layout chrome)";
16
+ /**
17
+ * Outcome prefix for "this navigation was bounced to a login page".
18
+ *
19
+ * Distinct from a permission redirect on purpose. A permission redirect is a
20
+ * true fact about the app that should satisfy the completion contract for that
21
+ * role; an auth-loss bounce is a fact about OUR credentials expiring, and
22
+ * counting it as coverage let one dead token certify every route a run had not
23
+ * reached yet.
24
+ */
25
+ export const AUTH_LOSS_PREFIX = "authloss:";
26
+ /**
27
+ * Paths that look like a login screen.
28
+ *
29
+ * Duplicated deliberately rather than imported: memory.ts is the storage layer
30
+ * and must not depend on the engine layer. The duplication is not left on
31
+ * trust — policy-test.ts compares the two patterns' sources directly and fails
32
+ * if they drift, which the previous "kept in sync by the migration test"
33
+ * comment claimed but no test actually did.
34
+ */
35
+ export const LOGIN_ROUTE_RE_STORAGE = /\/(login|signin|sign-in|auth)(\/|$)/;
36
+ const LOGIN_ROUTE_RE = LOGIN_ROUTE_RE_STORAGE;
37
+ /**
38
+ * Key an attempted route by the role that attempted it.
39
+ *
40
+ * No separator character: the role comes from the auth fixture's BASENAME, so
41
+ * it can never contain "/", and every route starts with one. Splitting at the
42
+ * first "/" is therefore unambiguous. A space looked like the obvious
43
+ * separator and was wrong — `qa admin.json` yields the role "qa admin", whose
44
+ * key would split into a role "qa" that does not exist, quietly crediting that
45
+ * route's coverage to the wrong identity.
46
+ */
47
+ function roleRouteKey(role, route) {
48
+ return `${role}${route.startsWith("/") ? "" : "/"}${route}`;
49
+ }
50
+ /**
51
+ * Secrets an app leaks into its own error UI must not be re-published by the
52
+ * tool that found them. Findings quote app output verbatim, and that output has
53
+ * in practice included partial provider API keys; `.scenescout/` is gitignored
54
+ * but still gets zipped and attached to tickets. Keep a short prefix so the
55
+ * finding stays actionable, drop the rest.
56
+ */
57
+ const SECRET_PATTERNS = [
58
+ // Needs the environment marker (sk_live_…) or a digit in the tail, or it
59
+ // eats ordinary identifiers: "pk_customer_identifier" became "pk_[redacted]".
60
+ [/\b(sk|pk|rk)[-_](?:(?:live|test|proj)[-_][A-Za-z0-9]{8,}|(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{8,})/g, "$1_[redacted]"],
61
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, "gh?_[redacted]"],
62
+ [/\bAKIA[0-9A-Z]{12,}/g, "AKIA[redacted]"],
63
+ // Keep the scheme that actually matched: rewriting a Basic-auth finding to
64
+ // say "Bearer" silently changes what the finding claims.
65
+ // Same entropy requirement as the keyword rule: "Basic authentication-required"
66
+ // is prose, and redacting it rewrote a sentence into nonsense.
67
+ [/\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{16,}/gi, "$1 [redacted]"],
68
+ [/\beyJ[A-Za-z0-9._-]{20,}/g, "eyJ[redacted-jwt]"],
69
+ // Keyword-led values need an entropy signal — a digit AND a letter, no
70
+ // internal word breaks. Without it this matched ordinary English: "Password
71
+ // requirements are not enforced" became "Password [redacted] are not
72
+ // enforced", and a mangled finding just reads as the agent writing nonsense.
73
+ [/\b(api[-_]?key|secret|password|token)(["'\s:=]+)(?=[A-Za-z0-9._~+/=-]*\d)(?=[A-Za-z0-9._~+/=-]*[A-Za-z])[A-Za-z0-9._~+/=]{12,}/gi, "$1$2[redacted]"],
74
+ ];
75
+ /**
76
+ * Strip anything that looks like a credential from text we are about to persist.
77
+ *
78
+ * Silent edits are their own hazard, so a redaction announces itself: a reader
79
+ * who sees mangled-looking text needs to know the tool did it deliberately.
80
+ */
81
+ export function redactSecrets(text) {
82
+ let out = text;
83
+ let hits = 0;
84
+ for (const [re, replacement] of SECRET_PATTERNS) {
85
+ out = out.replace(re, (...args) => {
86
+ hits += 1;
87
+ return replacement.replace(/\$(\d)/g, (_, n) => String(args[Number(n)] ?? ""));
88
+ });
89
+ }
90
+ return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
91
+ }
92
+ const EMPTY = { version: 1, states: {}, findings: [] };
93
+ /**
94
+ * Fold another process's memory into ours, losing nothing from either side.
95
+ *
96
+ * Two SceneScout processes on one project each hold a full snapshot and each
97
+ * write the whole document, so whoever flushed last silently erased the
98
+ * other's findings — a run could finish with a report that omits half of what
99
+ * was found, with no error anywhere. Rather than lock (which would have to
100
+ * survive crashes), re-read before writing and merge.
101
+ *
102
+ * Every rule is idempotent, because a flush may merge the same foreign state
103
+ * repeatedly: counters take the MAX rather than summing, booleans OR, and sets
104
+ * union. Summing would inflate run counts on every subsequent flush.
105
+ */
106
+ export function mergeMemory(mine, theirs) {
107
+ const out = { ...theirs, ...mine, version: 1 };
108
+ out.states = { ...theirs.states };
109
+ for (const [fp, ours] of Object.entries(mine.states)) {
110
+ const other = theirs.states[fp];
111
+ if (!other) {
112
+ out.states[fp] = ours;
113
+ continue;
114
+ }
115
+ const elements = { ...other.elements };
116
+ for (const [key, el] of Object.entries(ours.elements)) {
117
+ const prev = elements[key];
118
+ elements[key] = {
119
+ exercised: (prev?.exercised ?? false) || el.exercised,
120
+ // Keep whichever action was actually recorded; ours wins a tie.
121
+ ...((el.lastAction ?? prev?.lastAction) ? { lastAction: el.lastAction ?? prev?.lastAction } : {}),
122
+ absentStreak: Math.min(prev?.absentStreak ?? 0, el.absentStreak ?? 0),
123
+ };
124
+ }
125
+ out.states[fp] = {
126
+ ...ours,
127
+ firstSeen: ours.firstSeen < other.firstSeen ? ours.firstSeen : other.firstSeen,
128
+ visits: Math.max(ours.visits, other.visits),
129
+ elements,
130
+ };
131
+ }
132
+ const byId = new Map();
133
+ for (const f of theirs.findings)
134
+ byId.set(f.id, f);
135
+ for (const f of mine.findings) {
136
+ const other = byId.get(f.id);
137
+ if (!other) {
138
+ byId.set(f.id, f);
139
+ continue;
140
+ }
141
+ // Later knowledge wins on the mutable fields; a resolution is only kept if
142
+ // the other side did not go on to re-find it as a regression.
143
+ const newer = f.foundAt >= other.foundAt ? f : other;
144
+ const older = newer === f ? other : f;
145
+ byId.set(f.id, {
146
+ ...newer,
147
+ runs: Math.max(f.runs, other.runs),
148
+ evidence: newer.evidence ?? older.evidence,
149
+ regressedAt: newer.regressedAt ?? older.regressedAt,
150
+ });
151
+ }
152
+ out.findings = [...byId.values()];
153
+ const unionRecord = (a, b) => (a || b ? { ...(b ?? {}), ...(a ?? {}) } : undefined);
154
+ out.discoveredRoutes = unionRecord(mine.discoveredRoutes, theirs.discoveredRoutes);
155
+ out.attemptedRoutes = unionRecord(mine.attemptedRoutes, theirs.attemptedRoutes);
156
+ out.designElements = unionRecord(mine.designElements, theirs.designElements);
157
+ out.pageScores = { ...(theirs.pageScores ?? {}) };
158
+ for (const [route, score] of Object.entries(mine.pageScores ?? {})) {
159
+ const other = out.pageScores[route];
160
+ if (!other || score.at >= other.at)
161
+ out.pageScores[route] = score;
162
+ }
163
+ if (Object.keys(out.pageScores).length === 0)
164
+ delete out.pageScores;
165
+ out.routeFacts = { ...(theirs.routeFacts ?? {}) };
166
+ for (const [route, facts] of Object.entries(mine.routeFacts ?? {})) {
167
+ const other = out.routeFacts[route] ?? {};
168
+ out.routeFacts[route] = {
169
+ audited: facts.audited || other.audited || undefined,
170
+ mutated: facts.mutated || other.mutated || undefined,
171
+ journeys: Math.max(facts.journeys ?? 0, other.journeys ?? 0) || undefined,
172
+ journeysCompleted: Math.max(facts.journeysCompleted ?? 0, other.journeysCompleted ?? 0) || undefined,
173
+ };
174
+ }
175
+ if (Object.keys(out.routeFacts).length === 0)
176
+ delete out.routeFacts;
177
+ out.roleAccess = { ...(theirs.roleAccess ?? {}) };
178
+ for (const [role, routes] of Object.entries(mine.roleAccess ?? {})) {
179
+ out.roleAccess[role] = { ...(out.roleAccess[role] ?? {}), ...routes };
180
+ }
181
+ if (Object.keys(out.roleAccess).length === 0)
182
+ delete out.roleAccess;
183
+ return out;
184
+ }
185
+ const MAX_DISCOVERED_ROUTES = 300;
186
+ /** Shared finding-similarity helpers (used by live dedup and retro-merge). */
187
+ function findingTokens(s) {
188
+ return new Set(s
189
+ .toLowerCase()
190
+ .replace(/[^a-z0-9/ ]+/g, " ")
191
+ .split(/\s+/)
192
+ .filter((t) => t.length > 2)
193
+ // crude stemming so entries/entry, displays/display collide
194
+ .map((t) => t.replace(/ies$/, "y").replace(/(?<=\w{3})e?s$/, "")));
195
+ }
196
+ function findingJaccard(a, b) {
197
+ const inter = [...a].filter((t) => b.has(t)).length;
198
+ const union = new Set([...a, ...b]).size;
199
+ return union === 0 ? 0 : inter / union;
200
+ }
201
+ /**
202
+ * Distinctive literals — quoted or parenthesized fragments like
203
+ * `"(role not recorded)"` or `"Document not found"`. Two findings on the same
204
+ * route sharing one are the same bug however the prose around it is phrased.
205
+ */
206
+ function findingLiterals(...texts) {
207
+ const out = new Set();
208
+ for (const text of texts) {
209
+ if (!text)
210
+ continue;
211
+ for (const m of text.matchAll(/"([^"]{8,80})"|'([^']{8,80})'|\(([^)]{8,80})\)|“([^”]{8,80})”/g)) {
212
+ const literal = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? "").toLowerCase().trim();
213
+ if (literal)
214
+ out.add(literal);
215
+ }
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * Canonical `METHOD /path STATUS` triples named by a finding's evidence.
221
+ *
222
+ * `evidence` exists to be a MACHINE signature, so when two findings name the
223
+ * same endpoint failing the same way they are the same bug — whichever page
224
+ * the tester happened to be on when they wrote it up. Ids in the path collapse
225
+ * (`/api/users/7` and `/api/users/9` are one endpoint) so a per-record repro
226
+ * does not read as a per-record bug.
227
+ */
228
+ function endpointSignatures(evidence) {
229
+ const out = new Set();
230
+ if (!evidence)
231
+ return out;
232
+ const endpoints = [...evidence.matchAll(/\b(GET|POST|PUT|PATCH|DELETE)\s+(?:https?:\/\/[^/\s]+)?(\/[A-Za-z0-9/_.:{}$-]*)/gi)];
233
+ for (const [i, m] of endpoints.entries()) {
234
+ const method = m[1].toUpperCase();
235
+ const path = normalizePath(m[2].replace(/\/+$/, "") || "/").toLowerCase();
236
+ // Each endpoint takes the status from its OWN window — the text between it
237
+ // and the next endpoint mentioned. Pairing every endpoint with every status
238
+ // in the string invented signatures that were never claimed: evidence
239
+ // reading "list loads (GET /api/x 200) but POST /api/x returns 500" also
240
+ // produced "GET /api/x 500", which then merged — and silently discarded —
241
+ // a genuine, separate finding about the GET. Two-endpoint evidence is
242
+ // ordinary prose ("this works, that doesn't"), not an edge case.
243
+ // The status must FOLLOW its endpoint. Reading one from earlier in the
244
+ // string turned a quantity into a status — "list shows 500 items; GET
245
+ // /api/items 200" claimed a 500 on an endpoint the evidence said returned
246
+ // 200. So `GET /api/x 404` written as "404 on GET /api/x" yields only
247
+ // `GET /api/x` and will not merge with the same bug written the other way
248
+ // round: a visible duplicate, which is the safe direction to fail, since
249
+ // the alternative discards a finding.
250
+ const from = (m.index ?? 0) + m[0].length;
251
+ const to = i + 1 < endpoints.length ? (endpoints[i + 1].index ?? evidence.length) : evidence.length;
252
+ const status = /\b([45]\d{2})\b/.exec(evidence.slice(from, to))?.[1];
253
+ out.add(status ? `${method} ${path} ${status}` : `${method} ${path}`);
254
+ }
255
+ return out;
256
+ }
257
+ /**
258
+ * Same endpoint, same failure — regardless of which route it was filed from.
259
+ * The route-scoped rules below cannot see this: one agent filed the analytics
260
+ * 404 from the page that calls it, another from a different page, so two
261
+ * findings for one bug survived a run. Requires evidence on BOTH sides and an
262
+ * exact triple match, so it stays a machine-signal match, never a fuzzy one.
263
+ */
264
+ function sharesEndpointSignature(a, b) {
265
+ if (!a.evidence || !b.evidence)
266
+ return false;
267
+ const aSigs = endpointSignatures(a.evidence);
268
+ if (aSigs.size === 0)
269
+ return false;
270
+ for (const sig of endpointSignatures(b.evidence))
271
+ if (aSigs.has(sig))
272
+ return true;
273
+ return false;
274
+ }
275
+ /**
276
+ * The cross-route merge rule, with the two guards a bare signature match needs.
277
+ *
278
+ * - NEVER absorb into a RESOLVED finding. Without evidence identical enough to
279
+ * count as a regression, a signature match would increment the old entry's
280
+ * run count and return it — so a genuinely new bug on an endpoint that once
281
+ * had a fixed bug would never appear in the report at all. A duplicate is
282
+ * visible and cheap; a swallowed finding is neither.
283
+ * - Require the same CATEGORY. One endpoint+status can carry two different
284
+ * bugs (a `security` 403 and a `ux-confusing` 403; a 400 for a bad date and a
285
+ * 400 for a zero quantity), and the loser's title, detail and severity are
286
+ * discarded on merge.
287
+ */
288
+ function sameEndpointBug(existing, incoming) {
289
+ if (existing.status === "resolved")
290
+ return false;
291
+ if (existing.category !== incoming.category)
292
+ return false;
293
+ return sharesEndpointSignature(existing, incoming);
294
+ }
295
+ function sameFinding(a, b) {
296
+ const aEv = a.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
297
+ const bEv = b.evidence?.toLowerCase().replace(/\s+/g, " ").trim();
298
+ if (aEv && bEv && aEv === bEv)
299
+ return true;
300
+ // A shared distinctive literal (e.g. "Document not found anymore") marks the
301
+ // same bug even when two sessions wrote DIFFERENT evidence strings. The
302
+ // literal must be one the finding is ABOUT — i.e. quoted in at least one of
303
+ // the two TITLES. Drawing it from detail prose as well merged unrelated bugs
304
+ // that merely described the same screen: a data-integrity finding and a
305
+ // layout finding on one page both quoted the UI strings they saw, shared
306
+ // them, and collapsed into one. Matching a title literal against the other
307
+ // finding's full text keeps the intended case (one states the string in its
308
+ // title, the other mentions it in its detail).
309
+ const aTitleLits = findingLiterals(a.title);
310
+ const bTitleLits = findingLiterals(b.title);
311
+ if (aTitleLits.size > 0 || bTitleLits.size > 0) {
312
+ const aAll = findingLiterals(a.title, a.detail, a.evidence);
313
+ const bAll = findingLiterals(b.title, b.detail, b.evidence);
314
+ for (const lit of aTitleLits)
315
+ if (bAll.has(lit))
316
+ return true;
317
+ for (const lit of bTitleLits)
318
+ if (aAll.has(lit))
319
+ return true;
320
+ }
321
+ // Both carry evidence and neither matched above: distinct bugs, however
322
+ // similar the titles — never fuzzy-merge across differing evidence.
323
+ if (aEv && bEv)
324
+ return false;
325
+ return findingJaccard(findingTokens(a.title), findingTokens(b.title)) >= 0.5;
326
+ }
327
+ /** Per-project directory for memory, session logs, and reports. */
328
+ export const MEMORY_DIRNAME = ".scenescout";
329
+ /** What the directory was called before the tool was renamed. */
330
+ export const LEGACY_MEMORY_DIRNAME = ".scenecraft";
331
+ /**
332
+ * Carry a project's memory across the rename. Coverage, findings and notes are
333
+ * the whole point of cross-run memory; starting an empty `.scenescout/` next to
334
+ * a full legacy directory would silently throw every earlier run away and
335
+ * re-report every known finding as new.
336
+ *
337
+ * Only when the new directory does not exist yet — once both are present the
338
+ * new one is authoritative and the old one is left for the user to delete.
339
+ * Returns a note for the attach output, or null when there was nothing to do.
340
+ */
341
+ export function adoptLegacyMemoryDir(projectDir, isAlive = pidAlive) {
342
+ const current = path.join(projectDir, MEMORY_DIRNAME);
343
+ const legacy = path.join(projectDir, LEGACY_MEMORY_DIRNAME);
344
+ if (fs.existsSync(current) || !fs.existsSync(legacy))
345
+ return null;
346
+ // A pre-rename engine that is still running holds absolute paths into the
347
+ // legacy directory. Moving it away makes every later write of that run fail,
348
+ // and the only trace is a stderr line nobody reads. Leave it until it exits.
349
+ const owner = liveOwnerPid(legacy, isAlive);
350
+ if (owner !== null) {
351
+ return `A pre-rename engine (pid ${owner}) is still using ${LEGACY_MEMORY_DIRNAME}/, so its memory was NOT moved — this run starts empty. Close that session and re-attach to carry the earlier coverage and findings over.`;
352
+ }
353
+ try {
354
+ fs.renameSync(legacy, current);
355
+ }
356
+ catch (err) {
357
+ // Two engines attaching at once both pass the guard above; the loser's
358
+ // rename fails because the winner already moved it. That is success.
359
+ if (fs.existsSync(current))
360
+ return null;
361
+ // A read-only checkout must not break attach; say what was lost instead.
362
+ return `Could not move ${LEGACY_MEMORY_DIRNAME}/ to ${MEMORY_DIRNAME}/ (${err.message}) — this run starts without the earlier memory.`;
363
+ }
364
+ return `Moved this project's memory from ${LEGACY_MEMORY_DIRNAME}/ to ${MEMORY_DIRNAME}/ (the tool was renamed); earlier coverage and findings are kept.`;
365
+ }
366
+ /** Signal 0 probes existence. EPERM means the process exists but is another user's — still alive. */
367
+ export function pidAlive(pid) {
368
+ try {
369
+ process.kill(pid, 0);
370
+ return true;
371
+ }
372
+ catch (err) {
373
+ return err?.code === "EPERM";
374
+ }
375
+ }
376
+ /** The pid named by a memory directory's status.json, when that process is still running and is not us. */
377
+ function liveOwnerPid(dir, isAlive) {
378
+ try {
379
+ const st = JSON.parse(fs.readFileSync(path.join(dir, "status.json"), "utf8"));
380
+ return st.pid && st.pid !== process.pid && isAlive(st.pid) ? st.pid : null;
381
+ }
382
+ catch {
383
+ return null; // no status file, or a truncated one: nothing is provably using it
384
+ }
385
+ }
386
+ /**
387
+ * Keep our artifacts out of the tested project's commits.
388
+ *
389
+ * We write memory.json, rolling session logs, status.json and report.md into
390
+ * the project under test, so `.scenescout/` turns up as untracked and a single
391
+ * `git add -A` sweeps a test tool's scratch data into someone else's history.
392
+ *
393
+ * The ignore rule goes INSIDE our own directory rather than in the project's
394
+ * root .gitignore: git reads nested .gitignore files, `*` makes every file
395
+ * here invisible, and a directory holding only ignored files never appears in
396
+ * `git status`. That gets the same result without editing a file we do not
397
+ * own, and it disappears cleanly when someone deletes the directory.
398
+ *
399
+ * Best-effort and idempotent — a read-only checkout must never break attach,
400
+ * and an existing file is left exactly as the user left it.
401
+ */
402
+ function writeSelfIgnore(dir) {
403
+ try {
404
+ const ignorePath = path.join(dir, ".gitignore");
405
+ if (fs.existsSync(ignorePath))
406
+ return null;
407
+ fs.writeFileSync(ignorePath, "# SceneScout exploratory-test artifacts: memory, session logs, status, report.\n" +
408
+ "# Self-ignoring so a `git add -A` in the tested project can never commit them.\n" +
409
+ "*\n");
410
+ return `Wrote ${MEMORY_DIRNAME}/.gitignore so these test artifacts stay out of your commits.`;
411
+ }
412
+ catch {
413
+ return null; // housekeeping must never break a run
414
+ }
415
+ }
416
+ /**
417
+ * Persistent exploration memory, stored in `.scenescout/` inside the
418
+ * tested project. This is what makes run N+1 not repeat run N: visited state
419
+ * fingerprints, per-element exercise status, and deduplicated findings.
420
+ */
421
+ export class MemoryStore {
422
+ dir;
423
+ data;
424
+ memoryPath;
425
+ sessionLogPath;
426
+ /** Rolling in-session action log (also the repro-trace source). */
427
+ actionLog = [];
428
+ /** Set when the on-disk memory could not be loaded — surfaced to the driver instead of silently resetting. */
429
+ loadWarning = null;
430
+ /** Set when we added ourselves to the project's .gitignore — reported, never silent. */
431
+ gitIgnoreNote;
432
+ /** Set when this attach moved a pre-rename memory directory into place. */
433
+ legacyDirNote;
434
+ /** When this session started — findings re-seen after this are "current", older ones "historical". */
435
+ sessionStart = new Date().toISOString();
436
+ /**
437
+ * Resources created by THIS RUN (safe-write): id → collection paths.
438
+ *
439
+ * Deliberately lives on the shared MemoryStore rather than per-engine:
440
+ * every named session attached to a project shares one store, and a
441
+ * multi-role run is conceptually ONE test. Role A creating a record that
442
+ * role B must then act on (submit → approve, raise → investigate) is the
443
+ * entire point of multi-role testing, so ownership has to be shared or
444
+ * every handoff gets blocked as "not yours". Not persisted to disk —
445
+ * ownership must never outlive the process that did the creating.
446
+ */
447
+ ownedIds = new Map();
448
+ /** Human-readable creation log for this run — becomes the report's cleanup list. */
449
+ createdResources = [];
450
+ constructor(projectDir) {
451
+ this.dir = path.join(projectDir, MEMORY_DIRNAME);
452
+ this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
453
+ fs.mkdirSync(this.dir, { recursive: true });
454
+ this.gitIgnoreNote = writeSelfIgnore(this.dir);
455
+ this.memoryPath = path.join(this.dir, "memory.json");
456
+ this.sessionLogPath = path.join(this.dir, `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`);
457
+ this.sweepStaleTemps();
458
+ this.data = this.load();
459
+ this.fileSig = this.currentSig();
460
+ this.retroMerge();
461
+ this.reclassifyLegacyAuthLoss();
462
+ }
463
+ /**
464
+ * Re-file pre-v0.17 login bounces as auth-loss.
465
+ *
466
+ * Before this release every redirect was written as `landed:<route>`, so a
467
+ * run whose token died mid-way recorded `landed:/login` for each route it
468
+ * failed to reach — and those entries still satisfy the completion contract
469
+ * for every role. Without this migration the upgrade quietly carries the
470
+ * exact defect the release fixes: an old memory.json goes on certifying
471
+ * routes nobody ever saw.
472
+ */
473
+ reclassifyLegacyAuthLoss() {
474
+ const map = this.data.attemptedRoutes;
475
+ if (!map)
476
+ return;
477
+ let changed = false;
478
+ for (const [key, outcome] of Object.entries(map)) {
479
+ if (!outcome.startsWith("landed:"))
480
+ continue;
481
+ if (!LOGIN_ROUTE_RE.test(outcome.slice("landed:".length)))
482
+ continue;
483
+ map[key] = `${AUTH_LOSS_PREFIX}${outcome.slice("landed:".length)}`;
484
+ changed = true;
485
+ }
486
+ if (changed)
487
+ this.flush();
488
+ }
489
+ /**
490
+ * Merge stored duplicate findings recorded before smarter dedup existed
491
+ * (paraphrased titles across sessions). Applies the same three-tier match
492
+ * used by addFinding; keeps the earliest entry, sums runs.
493
+ */
494
+ retroMerge() {
495
+ const findings = this.data.findings;
496
+ if (findings.length < 2)
497
+ return;
498
+ const kept = [];
499
+ let merged = 0;
500
+ for (const f of findings) {
501
+ const fRoute = f.state.split("#")[0];
502
+ const dupOf = kept.find((k) => (k.state.split("#")[0] === fRoute && sameFinding(k, f)) || sameEndpointBug(k, f));
503
+ if (dupOf) {
504
+ dupOf.runs += f.runs;
505
+ if (!dupOf.evidence && f.evidence)
506
+ dupOf.evidence = f.evidence;
507
+ if (f.status === "resolved")
508
+ dupOf.status = "resolved";
509
+ merged += 1;
510
+ }
511
+ else {
512
+ kept.push(f);
513
+ }
514
+ }
515
+ if (merged > 0) {
516
+ this.data.findings = kept;
517
+ this.flush();
518
+ }
519
+ }
520
+ /** Record link-harvested routes (class → navigable example); returns how many were new. */
521
+ addDiscoveredRoutes(entries) {
522
+ const map = this.data.discoveredRoutes ?? {};
523
+ let added = 0;
524
+ for (const { route, example } of entries) {
525
+ if (Object.keys(map).length >= MAX_DISCOVERED_ROUTES)
526
+ break;
527
+ if (!(route in map)) {
528
+ map[route] = example;
529
+ added += 1;
530
+ }
531
+ }
532
+ if (added > 0) {
533
+ this.data.discoveredRoutes = map;
534
+ this.save();
535
+ }
536
+ return added;
537
+ }
538
+ get discoveredRoutes() {
539
+ return this.data.discoveredRoutes ?? {};
540
+ }
541
+ /**
542
+ * Record that a route was attempted but landed elsewhere (e.g. auth-redirect).
543
+ *
544
+ * Keyed by ROLE as well as route. A permission redirect is a fact about one
545
+ * role — "the operator cannot reach /admin" must not also mean "nobody needs
546
+ * to test /admin", which is what a role-blind key silently asserted: the
547
+ * low-privilege role bouncing off an admin route erased that route from the
548
+ * ADMIN's gap ledger permanently.
549
+ */
550
+ markAttempted(route, outcome, role = "default") {
551
+ const map = this.data.attemptedRoutes ?? {};
552
+ const key = roleRouteKey(role, route);
553
+ if (map[key] !== outcome) {
554
+ map[key] = outcome;
555
+ this.data.attemptedRoutes = map;
556
+ this.save();
557
+ }
558
+ }
559
+ /**
560
+ * Routes this role attempted, and how they resolved. `authloss:` outcomes are
561
+ * excluded — a navigation the session's dead credentials bounced to a login
562
+ * page is not evidence the route was covered, and treating it as such let an
563
+ * expired token silently certify the whole remaining route list.
564
+ */
565
+ attemptedByRole(role = "default") {
566
+ const out = {};
567
+ for (const [key, outcome] of Object.entries(this.data.attemptedRoutes ?? {})) {
568
+ if (outcome.startsWith(AUTH_LOSS_PREFIX))
569
+ continue;
570
+ const sep = key.indexOf("/");
571
+ // A key that STARTS with "/" carries no role prefix — that is the shape
572
+ // written before attempts were role-scoped. Honour it for every role
573
+ // rather than dropping coverage a previous run legitimately earned.
574
+ if (sep === 0)
575
+ out[key] = outcome;
576
+ else if (sep > 0 && key.slice(0, sep) === role)
577
+ out[key.slice(sep)] = outcome;
578
+ }
579
+ return out;
580
+ }
581
+ /** Every attempt, flattened to route → outcome, for reporting. */
582
+ get attemptedRoutes() {
583
+ const out = {};
584
+ for (const [key, outcome] of Object.entries(this.data.attemptedRoutes ?? {})) {
585
+ const sep = key.indexOf("/");
586
+ out[sep <= 0 ? key : key.slice(sep)] = outcome;
587
+ }
588
+ return out;
589
+ }
590
+ /** Record the latest design-audit score for a route. */
591
+ setPageScore(route, score) {
592
+ const map = this.data.pageScores ?? {};
593
+ map[route] = score;
594
+ this.data.pageScores = map;
595
+ this.save();
596
+ }
597
+ get pageScores() {
598
+ return this.data.pageScores ?? {};
599
+ }
600
+ /** Merge testing facts for a route (audited / mutated / journey count). */
601
+ markRouteFact(route, fact) {
602
+ const map = this.data.routeFacts ?? {};
603
+ const rec = map[route] ?? {};
604
+ if (fact.audited)
605
+ rec.audited = true;
606
+ if (fact.mutated)
607
+ rec.mutated = true;
608
+ if (fact.journeys)
609
+ rec.journeys = (rec.journeys ?? 0) + fact.journeys;
610
+ if (fact.journeysCompleted)
611
+ rec.journeysCompleted = (rec.journeysCompleted ?? 0) + fact.journeysCompleted;
612
+ map[route] = rec;
613
+ this.data.routeFacts = map;
614
+ this.save();
615
+ }
616
+ get routeFacts() {
617
+ return this.data.routeFacts ?? {};
618
+ }
619
+ /** Record that `role` reached (or was denied) `route`. Denials never overwrite a recorded "reached" — flaky redirects must not erase real access. */
620
+ recordRoleAccess(role, route, outcome) {
621
+ const all = this.data.roleAccess ?? {};
622
+ const forRole = all[role] ?? {};
623
+ if (forRole[route] === "reached" && outcome !== "reached")
624
+ return;
625
+ if (forRole[route] === outcome)
626
+ return;
627
+ forRole[route] = outcome;
628
+ all[role] = forRole;
629
+ this.data.roleAccess = all;
630
+ this.save();
631
+ }
632
+ get roleAccess() {
633
+ return this.data.roleAccess ?? {};
634
+ }
635
+ // ---- ASSUMPTIONS.md — cumulative WRITTEN knowledge about the tested app. ----
636
+ // memory.json stores coverage booleans; this stores understanding: what the
637
+ // app is for, who each role is and what they do, conventions, constraints
638
+ // ("an order can only ship once approved"). It compounds across
639
+ // runs, so run N+1 starts smarter than run N — in prose a human can read
640
+ // and correct.
641
+ get assumptionsPath() {
642
+ return path.join(this.dir, "ASSUMPTIONS.md");
643
+ }
644
+ static ASSUMPTION_SECTIONS = ["app-model", "roles", "conventions", "constraints", "risks", "glossary"];
645
+ static sectionHeading(section) {
646
+ const titles = {
647
+ "app-model": "App model — what this application is and does",
648
+ roles: "Roles & personas — who uses it and what each role is FOR",
649
+ conventions: "Conventions — patterns the app follows (naming, flows, UI idioms)",
650
+ constraints: "Constraints — rules discovered the hard way (gates, preconditions, limits)",
651
+ risks: "Risks & watchpoints — fragile areas worth re-testing every run",
652
+ glossary: "Glossary — domain terms and what they mean here",
653
+ };
654
+ return `## ${titles[section] ?? section}`;
655
+ }
656
+ readAssumptions() {
657
+ if (!fs.existsSync(this.assumptionsPath))
658
+ return "(no ASSUMPTIONS.md yet — record what you learn with scout_note as you explore)";
659
+ return fs.readFileSync(this.assumptionsPath, "utf8");
660
+ }
661
+ /** Append one dated, attributed bullet under a section; exact-duplicate notes are dropped. Returns whether it was new. */
662
+ addAssumption(section, note, attribution) {
663
+ // Notes are free prose the agent often builds from app output; this file is
664
+ // the one most likely to be pasted into a ticket.
665
+ const clean = redactSecrets(note.trim().replace(/\s+/g, " "));
666
+ if (!clean)
667
+ return false;
668
+ let content = fs.existsSync(this.assumptionsPath)
669
+ ? fs.readFileSync(this.assumptionsPath, "utf8")
670
+ : `# Assumptions — cumulative knowledge about this application\n\nWritten by SceneScout runs; corrections welcome — the tester reads this file at the start of every session.\n`;
671
+ // Dedupe on the note text itself, ignoring the date/attribution prefix.
672
+ if (content.includes(`— ${clean}`))
673
+ return false;
674
+ const heading = MemoryStore.sectionHeading(section);
675
+ const bullet = `- (${new Date().toISOString().slice(0, 10)}, ${attribution}) — ${clean}`;
676
+ if (content.includes(heading)) {
677
+ content = content.replace(heading, `${heading}\n${bullet}`);
678
+ }
679
+ else {
680
+ content += `\n${heading}\n${bullet}\n`;
681
+ }
682
+ fs.writeFileSync(this.assumptionsPath, content);
683
+ return true;
684
+ }
685
+ /** Mark a finding resolved; returns it or null. */
686
+ resolveFinding(id) {
687
+ const f = this.data.findings.find((x) => x.id === id);
688
+ if (!f)
689
+ return null;
690
+ f.status = "resolved";
691
+ this.flush();
692
+ return f;
693
+ }
694
+ load() {
695
+ if (!fs.existsSync(this.memoryPath))
696
+ return structuredClone(EMPTY);
697
+ try {
698
+ const raw = JSON.parse(fs.readFileSync(this.memoryPath, "utf8"));
699
+ if (raw.version === 1)
700
+ return raw;
701
+ this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
702
+ }
703
+ catch (err) {
704
+ // Never silently overwrite the (possibly recoverable) history — cross-run
705
+ // memory is the product promise. Preserve the corrupt file and say so.
706
+ const backup = `${this.memoryPath}.corrupt-${Date.now()}`;
707
+ try {
708
+ fs.renameSync(this.memoryPath, backup);
709
+ this.loadWarning = `memory.json was corrupt (${err instanceof Error ? err.message : err}); preserved as ${path.basename(backup)}, starting fresh.`;
710
+ }
711
+ catch {
712
+ this.loadWarning = "memory.json is corrupt and could not be preserved — starting fresh.";
713
+ }
714
+ }
715
+ return structuredClone(EMPTY);
716
+ }
717
+ saveTimer = null;
718
+ /**
719
+ * Debounced save: coverage bookkeeping calls this several times per action,
720
+ * and serialising the whole history synchronously each time is the main
721
+ * per-action latency cost. Coalesce writes; findings and shutdown call
722
+ * flush() directly for durability. The JSONL action log remains the
723
+ * per-event durable trail either way.
724
+ */
725
+ save() {
726
+ if (this.saveTimer)
727
+ return;
728
+ this.saveTimer = setTimeout(() => {
729
+ this.saveTimer = null;
730
+ try {
731
+ this.flush();
732
+ }
733
+ catch (err) {
734
+ // A background debounced write has no caller to report to — a
735
+ // failure here (deleted project dir, disk full, permissions) must
736
+ // not crash the whole engine process over a transient filesystem
737
+ // issue. But it also must not vanish: without this, every write
738
+ // from here on silently stops persisting and nothing downstream
739
+ // (scout_coverage, the final report) would know coverage tracking
740
+ // broke. Log it (stderr — safe under stdio MCP transport, which
741
+ // owns stdout) and store it for the next scout_coverage/scout_close to
742
+ // surface. Explicit flush() callers (resolveFinding, scout_close, …)
743
+ // still throw and are handled at the MCP tool boundary directly.
744
+ const msg = err instanceof Error ? err.message : String(err);
745
+ this.lastSaveError = msg;
746
+ console.error(`[scenescout] background memory write failed: ${msg}`);
747
+ }
748
+ }, 500);
749
+ // Deliberately NOT unref'd: a pending coverage write briefly holds the
750
+ // process open so an exit without scout_close still lands the last save.
751
+ }
752
+ /** Set when a debounced background write failed — cleared on the next successful write. Surfaced by scout_coverage/scout_close so a broken persistence path is never silently invisible. */
753
+ lastSaveError = null;
754
+ /**
755
+ * Write memory.json now (atomic: a crash mid-write must not truncate the
756
+ * history file).
757
+ *
758
+ * The temp file carries this process's pid and a counter. A fixed `.tmp`
759
+ * path meant two SceneScout processes on one project interleaved their
760
+ * writes into a single inode, and whichever rename landed second published
761
+ * a half-and-half document that failed to parse on the next load — the
762
+ * rename made the publish atomic but not the write that fed it. The cost is
763
+ * that an interrupted flush leaks a temp file rather than self-overwriting,
764
+ * so the constructor sweeps them.
765
+ *
766
+ * Not pretty-printed: nobody hand-reads a multi-megabyte history file, and
767
+ * the indentation was ~40% of the bytes written on every single finding.
768
+ *
769
+ * MERGE BEFORE WRITE. Each process holds a full snapshot and writes the whole
770
+ * document, so a straight write makes the last flush win outright and erases
771
+ * whatever another process recorded meanwhile — silently, since the write
772
+ * itself succeeds. If the file changed under us, fold it in first.
773
+ */
774
+ flush() {
775
+ if (this.saveTimer) {
776
+ clearTimeout(this.saveTimer);
777
+ this.saveTimer = null;
778
+ }
779
+ if (this.changedUnderUs()) {
780
+ const theirs = this.readForMerge();
781
+ if (theirs)
782
+ this.data = mergeMemory(this.data, theirs);
783
+ }
784
+ const tmp = `${this.memoryPath}.${process.pid}.${this.tmpCounter++}.tmp`;
785
+ try {
786
+ fs.writeFileSync(tmp, JSON.stringify(this.data));
787
+ fs.renameSync(tmp, this.memoryPath);
788
+ }
789
+ catch (err) {
790
+ try {
791
+ fs.rmSync(tmp, { force: true });
792
+ }
793
+ catch {
794
+ /* the write already failed; a leftover temp file is the lesser problem */
795
+ }
796
+ throw err;
797
+ }
798
+ this.fileSig = this.currentSig();
799
+ this.lastSaveError = null;
800
+ }
801
+ tmpCounter = 0;
802
+ /** mtime+size of the file as WE last left it; a mismatch means someone else wrote. */
803
+ fileSig = "";
804
+ currentSig() {
805
+ try {
806
+ const st = fs.statSync(this.memoryPath);
807
+ return `${st.mtimeMs}:${st.size}`;
808
+ }
809
+ catch {
810
+ return "";
811
+ }
812
+ }
813
+ changedUnderUs() {
814
+ const sig = this.currentSig();
815
+ return sig !== "" && sig !== this.fileSig;
816
+ }
817
+ /** Re-read the on-disk document for merging. Never destructive: a corrupt or
818
+ * half-written file just means there is nothing to merge this time. */
819
+ readForMerge() {
820
+ try {
821
+ const raw = JSON.parse(fs.readFileSync(this.memoryPath, "utf8"));
822
+ return raw.version === 1 ? raw : null;
823
+ }
824
+ catch {
825
+ return null;
826
+ }
827
+ }
828
+ /**
829
+ * Remove temp files a crashed process left behind. The pid-scoped name that
830
+ * fixed interleaved writes also stopped them self-overwriting, so an
831
+ * interrupted flush now leaks one file per crash instead of reusing one.
832
+ */
833
+ sweepStaleTemps() {
834
+ try {
835
+ const prefix = `${path.basename(this.memoryPath)}.`;
836
+ for (const name of fs.readdirSync(this.dir)) {
837
+ if (name.startsWith(prefix) && name.endsWith(".tmp")) {
838
+ fs.rmSync(path.join(this.dir, name), { force: true });
839
+ }
840
+ }
841
+ }
842
+ catch {
843
+ /* housekeeping only — never block a run over a leftover file */
844
+ }
845
+ }
846
+ /**
847
+ * Append to the action log — the choke point where redaction belongs.
848
+ *
849
+ * Redacting only a finding's title/detail/evidence left the same secrets
850
+ * flowing by a parallel route: `repro` is built from these entries, and a
851
+ * `type` step logs the text that was typed (a password, during an auth-flow
852
+ * pass) while every entry carries a full URL that may hold `?token=…`. Both
853
+ * land in memory.json and are rendered into the report. One filter here
854
+ * covers the log, the repro traces and the JSONL trail at once.
855
+ */
856
+ logAction(entry) {
857
+ const full = {
858
+ at: new Date().toISOString(),
859
+ ...entry,
860
+ url: redactSecrets(entry.url),
861
+ ...(entry.target === undefined ? {} : { target: redactSecrets(entry.target) }),
862
+ ...(entry.result === undefined ? {} : { result: redactSecrets(entry.result) }),
863
+ };
864
+ this.actionLog.push(full);
865
+ fs.appendFileSync(this.sessionLogPath, JSON.stringify(full) + "\n");
866
+ }
867
+ /** Record a visit to a state; returns whether it was new. */
868
+ visitState(fingerprint, url, route, elementKeys) {
869
+ let rec = this.data.states[fingerprint];
870
+ const isNew = !rec;
871
+ if (!rec) {
872
+ rec = { url, route, firstSeen: new Date().toISOString(), visits: 0, elements: {} };
873
+ this.data.states[fingerprint] = rec;
874
+ }
875
+ rec.visits += 1;
876
+ const present = new Set(elementKeys);
877
+ for (const key of elementKeys) {
878
+ if (!rec.elements[key])
879
+ rec.elements[key] = { exercised: false };
880
+ rec.elements[key].absentStreak = 0;
881
+ }
882
+ // Prune ghosts: dynamic elements (list rows, ordinal-suffixed duplicates)
883
+ // that vanish for 3 consecutive visits would otherwise make coverage
884
+ // permanently unreachable and steer exploration at phantoms.
885
+ for (const [key, entry] of Object.entries(rec.elements)) {
886
+ if (present.has(key))
887
+ continue;
888
+ entry.absentStreak = (entry.absentStreak ?? 0) + 1;
889
+ if (entry.absentStreak >= 3 && !entry.exercised)
890
+ delete rec.elements[key];
891
+ }
892
+ this.save();
893
+ return isNew;
894
+ }
895
+ markExercised(fingerprint, key, action) {
896
+ const rec = this.data.states[fingerprint];
897
+ if (!rec)
898
+ return;
899
+ // Refuse a key this state never listed. Creating one on demand turned any
900
+ // caller that derived a key slightly differently from the collector into a
901
+ // source of phantom coverage: the invented element counted as exercised
902
+ // (and was never pruned, because pruning skips exercised entries) while the
903
+ // real control stayed an open gap. A miss must record nothing.
904
+ if (!rec.elements[key])
905
+ return;
906
+ rec.elements[key].exercised = true;
907
+ rec.elements[key].lastAction = action;
908
+ this.save();
909
+ }
910
+ wasExercised(fingerprint, key) {
911
+ return this.data.states[fingerprint]?.elements[key]?.exercised ?? false;
912
+ }
913
+ /**
914
+ * Add a finding; dedups against previous runs in three tiers:
915
+ * 1. same route + same normalized `evidence` signature (strongest — survives title rephrasing),
916
+ * 2. exact title hash,
917
+ * 3. same route + high title-token overlap (an LLM re-describing the same bug
918
+ * across sessions rarely reuses the exact words — Jaccard catches it).
919
+ * Returns [finding, isNew].
920
+ */
921
+ addFinding(input) {
922
+ // Redact BEFORE the id is derived, so a re-found finding whose quoted
923
+ // secret differs by a character still hashes to the same id.
924
+ const f = {
925
+ ...input,
926
+ title: redactSecrets(input.title),
927
+ detail: redactSecrets(input.detail),
928
+ // The page URL is persisted AND printed in the report. A finding filed on
929
+ // a reset/invite/magic-link page carries that page's `?token=…`.
930
+ url: redactSecrets(input.url),
931
+ evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
932
+ };
933
+ const route = f.state.split("#")[0];
934
+ const id = shortHash(`${f.category}|${f.title.toLowerCase().trim()}|${route}`);
935
+ const existing = this.data.findings.find((x) => x.id === id || (x.state.split("#")[0] === route && sameFinding(x, f)) || sameEndpointBug(x, f));
936
+ if (existing) {
937
+ existing.runs += 1;
938
+ existing.foundAt = new Date().toISOString();
939
+ if (!existing.evidence && f.evidence)
940
+ existing.evidence = f.evidence;
941
+ // Re-finding a RESOLVED finding is a regression — reopen it loudly
942
+ // rather than letting it hide in the report's completed section. But a
943
+ // FUZZY match must never resurrect a fixed bug: telling someone a
944
+ // regression landed when it did not is far more costly than carrying a
945
+ // visible duplicate, and it corrupts the one signal that says whether a
946
+ // fix held. Demand an exact identity match (same category+title+route) or
947
+ // an identical evidence signature before reopening.
948
+ const exactMatch = existing.id === id ||
949
+ (!!existing.evidence &&
950
+ !!f.evidence &&
951
+ existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
952
+ if (existing.status === "resolved" && exactMatch) {
953
+ existing.status = "open";
954
+ existing.regressedAt = existing.foundAt;
955
+ }
956
+ this.flush();
957
+ return [existing, false];
958
+ }
959
+ // Repro trace scoped to the finding's route: everything since the action
960
+ // that landed there, not 12 lines of unrelated cross-module noise.
961
+ const routeOf = (url) => {
962
+ try {
963
+ return url.split("?")[0].replace(/^https?:\/\/[^/]+/, "") || "/";
964
+ }
965
+ catch {
966
+ return url;
967
+ }
968
+ };
969
+ const log = this.actionLog;
970
+ let start = Math.max(0, log.length - 12);
971
+ for (let i = log.length - 1; i >= 0 && i >= log.length - 12; i--) {
972
+ if (routeOf(log[i].url) !== routeOf(f.url)) {
973
+ start = i; // include the transition action itself
974
+ break;
975
+ }
976
+ start = i;
977
+ }
978
+ const finding = {
979
+ ...f,
980
+ id,
981
+ repro: log
982
+ .slice(start)
983
+ .slice(-12)
984
+ .map((a) => `${a.action}${a.target ? ` ${a.target}` : ""} @ ${a.url}`),
985
+ foundAt: new Date().toISOString(),
986
+ runs: 1,
987
+ };
988
+ this.data.findings.push(finding);
989
+ this.flush();
990
+ return [finding, true];
991
+ }
992
+ get findings() {
993
+ return this.data.findings;
994
+ }
995
+ get states() {
996
+ return this.data.states;
997
+ }
998
+ /**
999
+ * Coverage aggregated by ROUTE, not by state fingerprint: the same sidebar
1000
+ * rendered in 30 states of one route is one set of elements, not 30. An
1001
+ * element counts as exercised when it was exercised in ANY state of the
1002
+ * route. (`state` in the result therefore holds a route.)
1003
+ */
1004
+ coverage() {
1005
+ const byRoute = this.elementsByRoute();
1006
+ // Shared layout CHROME (sidebar nav, header, breadcrumbs) is one set of
1007
+ // components, not one set per route — clicking "nav-documents" on /admin is
1008
+ // the same click as on /. Counting it per route inflated the denominator by
1009
+ // thousands (a 99-route app reported 8329 elements, most of them the same
1010
+ // ~40 shell controls) and turned the unexplored list into a wall of the
1011
+ // identical eight nav links. Fold it into a single global surface,
1012
+ // exercised if it was exercised ANYWHERE.
1013
+ const isChrome = this.chromePredicate(byRoute);
1014
+ const chrome = new Map();
1015
+ let elementsTotal = 0;
1016
+ let elementsExercised = 0;
1017
+ const unexercised = [];
1018
+ for (const [route, elements] of byRoute) {
1019
+ const own = [];
1020
+ // The route's OWN element count — deduped across states and with shared
1021
+ // chrome removed, i.e. exactly the denominator `own` is a subset of.
1022
+ // computeGaps compares these two to decide "nothing was ever touched
1023
+ // here"; deriving the total any other way (e.g. re-counting raw state
1024
+ // elements) makes it larger than `own` can ever be, and a genuinely
1025
+ // untouched route silently drops out of the gap ledger.
1026
+ let ownTotal = 0;
1027
+ for (const [key, done] of elements) {
1028
+ if (isChrome(key)) {
1029
+ chrome.set(key, (chrome.get(key) ?? false) || done);
1030
+ continue;
1031
+ }
1032
+ elementsTotal += 1;
1033
+ ownTotal += 1;
1034
+ if (done)
1035
+ elementsExercised += 1;
1036
+ else
1037
+ own.push(key);
1038
+ }
1039
+ if (own.length > 0)
1040
+ unexercised.push({ state: route, keys: own, total: ownTotal });
1041
+ }
1042
+ // Chrome counted once, at the end, as its own pseudo-route.
1043
+ const chromeLeft = [];
1044
+ for (const [key, done] of chrome) {
1045
+ elementsTotal += 1;
1046
+ if (done)
1047
+ elementsExercised += 1;
1048
+ else
1049
+ chromeLeft.push(key);
1050
+ }
1051
+ if (chromeLeft.length > 0) {
1052
+ unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
1053
+ }
1054
+ return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised };
1055
+ }
1056
+ /**
1057
+ * Remember which styled-element signatures the design audit saw on a route.
1058
+ *
1059
+ * Parallel to the coverage census and thresholded identically, but keyed the
1060
+ * way the audit sees elements (tag/testid + text) rather than by ARIA role —
1061
+ * the two cannot share a key space, and the audit needs its own to recognise
1062
+ * un-testid'd shell elements across routes.
1063
+ */
1064
+ recordDesignElements(route, signatures) {
1065
+ const map = this.data.designElements ?? {};
1066
+ map[route] = [...new Set(signatures)];
1067
+ this.data.designElements = map;
1068
+ this.save();
1069
+ }
1070
+ /** Signatures seen on enough routes to be the shared shell rather than page content. */
1071
+ designChromeKeys() {
1072
+ const map = this.data.designElements ?? {};
1073
+ const routes = Object.keys(map);
1074
+ const perKey = new Map();
1075
+ for (const sigs of Object.values(map)) {
1076
+ for (const sig of sigs)
1077
+ perKey.set(sig, (perKey.get(sig) ?? 0) + 1);
1078
+ }
1079
+ const minRoutes = Math.min(Math.max(CHROME_MIN_ROUTES, Math.ceil(routes.length * CHROME_ROUTE_SHARE)), CHROME_ABSOLUTE_ROUTES);
1080
+ const keys = new Set();
1081
+ if (routes.length < CHROME_MIN_ROUTES)
1082
+ return keys;
1083
+ for (const [sig, n] of perKey)
1084
+ if (n >= minRoutes)
1085
+ keys.add(sig);
1086
+ return keys;
1087
+ }
1088
+ /** Element keys folded per route, exercised-in-any-state. */
1089
+ elementsByRoute() {
1090
+ const byRoute = new Map();
1091
+ for (const rec of Object.values(this.data.states)) {
1092
+ let route = byRoute.get(rec.route);
1093
+ if (!route) {
1094
+ route = new Map();
1095
+ byRoute.set(rec.route, route);
1096
+ }
1097
+ for (const [key, v] of Object.entries(rec.elements)) {
1098
+ route.set(key, (route.get(key) ?? false) || v.exercised);
1099
+ }
1100
+ }
1101
+ return byRoute;
1102
+ }
1103
+ /** "Does this element key belong to the shared shell?" — see coverage(). */
1104
+ chromePredicate(byRoute) {
1105
+ const routeCount = byRoute.size;
1106
+ const routesPerKey = new Map();
1107
+ for (const elements of byRoute.values()) {
1108
+ for (const key of elements.keys())
1109
+ routesPerKey.set(key, (routesPerKey.get(key) ?? 0) + 1);
1110
+ }
1111
+ // Only meaningful once there are enough routes to tell "on every page" from
1112
+ // "on the two pages that exist"; require a majority of them.
1113
+ const chromeMinRoutes = Math.min(Math.max(CHROME_MIN_ROUTES, Math.ceil(routeCount * CHROME_ROUTE_SHARE)), CHROME_ABSOLUTE_ROUTES);
1114
+ return (key) => routeCount >= CHROME_MIN_ROUTES && (routesPerKey.get(key) ?? 0) >= chromeMinRoutes;
1115
+ }
1116
+ }