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