scenescout 3.15.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- 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 +181 -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 +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- 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/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -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 +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
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,27 @@ export const FINDING_CATEGORIES = [
|
|
|
26
34
|
"missing-testid",
|
|
27
35
|
"other",
|
|
28
36
|
];
|
|
37
|
+
/** Most other routes one finding records it was seen on; the oldest go first. */
|
|
38
|
+
export const MAX_SEEN_ON = 20;
|
|
39
|
+
/** The route class a finding was filed on: its state before the `#`. */
|
|
40
|
+
export const findingRoute = (f) => f.state.split("#")[0];
|
|
41
|
+
/** A finding's other routes that are well formed: the file is read back without a schema. */
|
|
42
|
+
export function seenOnOf(f) {
|
|
43
|
+
return Array.isArray(f.seenOn) ? f.seenOn.filter((r) => typeof r === "string" && r.length > 0) : [];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `f`'s other routes with `routes` added: each once, never its own route,
|
|
47
|
+
* oldest first, at most MAX_SEEN_ON. Undefined when there are none, so a
|
|
48
|
+
* finding seen on one route keeps no empty field. Idempotent.
|
|
49
|
+
*/
|
|
50
|
+
function withSeenOn(f, routes) {
|
|
51
|
+
const own = findingRoute(f);
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const r of [...seenOnOf(f), ...routes])
|
|
54
|
+
if (r && r !== own && !out.includes(r))
|
|
55
|
+
out.push(r);
|
|
56
|
+
return out.length > 0 ? out.slice(-MAX_SEEN_ON) : undefined;
|
|
57
|
+
}
|
|
29
58
|
/** Most judged merges one finding keeps; the oldest go first. */
|
|
30
59
|
export const MAX_JUDGED_MERGES = 10;
|
|
31
60
|
/**
|
|
@@ -54,23 +83,36 @@ export function judgedMergesOf(f) {
|
|
|
54
83
|
export function isWorthALook(f) {
|
|
55
84
|
return f.tier === "worth_a_look";
|
|
56
85
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
86
|
+
/** The sentence a merged filing's result adds about what it changed, or "" when the two filings agreed. */
|
|
87
|
+
export function describeMerge(note, convention) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
if (note.seenOn)
|
|
90
|
+
parts.push(` Your page, ${note.seenOn}, is recorded as another route it was seen on.`);
|
|
91
|
+
if (note.updated.length > 0) {
|
|
92
|
+
const what = note.updated.map((f) => (f === "convention" ? `its convention is now "${convention ?? ""}"` : "its detail is now yours")).join(" and ");
|
|
93
|
+
parts.push(` Taken as a correction of your own filing: ${what}.`);
|
|
94
|
+
}
|
|
95
|
+
if (note.kept.length > 0) {
|
|
96
|
+
const what = note.kept.map((f) => (f === "convention" ? `convention ("${convention ?? ""}")` : "detail")).join(" and ");
|
|
97
|
+
parts.push(` The first filing's ${what} ${note.kept.length === 1 ? "was" : "were"} kept: a re-filing replaces them only when it comes from the session that filed it, in the same run.`);
|
|
98
|
+
}
|
|
99
|
+
return parts.join("");
|
|
100
|
+
}
|
|
101
|
+
/** A fresh run identity: unique per process and per run within it. */
|
|
102
|
+
function newRunId() {
|
|
103
|
+
return `${process.pid}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`;
|
|
104
|
+
}
|
|
64
105
|
/** Drops a finding's tier and convention in place, so a merged tier can be assigned onto it. */
|
|
65
106
|
function withoutTier(f) {
|
|
66
107
|
delete f.tier;
|
|
67
108
|
delete f.convention;
|
|
68
109
|
return f;
|
|
69
110
|
}
|
|
70
|
-
export function mergeTier(existing, incoming) {
|
|
111
|
+
export function mergeTier(existing, incoming, prefer = "existing") {
|
|
71
112
|
if (!isWorthALook(existing) || !isWorthALook(incoming))
|
|
72
113
|
return {};
|
|
73
|
-
|
|
114
|
+
const convention = prefer === "incoming" ? incoming.convention || existing.convention : existing.convention || incoming.convention;
|
|
115
|
+
return { tier: "worth_a_look", convention };
|
|
74
116
|
}
|
|
75
117
|
/**
|
|
76
118
|
* Most states a route may keep. A route accumulates one state per distinct
|
|
@@ -201,12 +243,41 @@ export function redactSecrets(text) {
|
|
|
201
243
|
}
|
|
202
244
|
return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
|
|
203
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Routes are links the app printed, and a link can carry a token
|
|
248
|
+
* (`?reset=…`, `?api_key=…`). Evidence is redacted where issues are built;
|
|
249
|
+
* this does the same for every route string before anything is written.
|
|
250
|
+
*/
|
|
251
|
+
export function redactRoute(route) {
|
|
252
|
+
// The trailing "[n secrets redacted]" note belongs to prose; in a route it would read as part of the path.
|
|
253
|
+
return redactSecrets(route).replace(/ \[\d+ secrets? redacted\]$/, "");
|
|
254
|
+
}
|
|
204
255
|
/** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
|
|
205
256
|
export const JOURNEY_START = "journey:start";
|
|
206
257
|
export const JOURNEY_END = "journey:end";
|
|
207
258
|
/** Logged when a session states the task it is starting, so the feed can group the actions that follow under it. */
|
|
208
259
|
export const TASK_SET = "task";
|
|
260
|
+
/** The most pages, and endpoints per page, the refused-POST record keeps. */
|
|
261
|
+
const MAX_REFUSED_POST_ROUTES = 50;
|
|
262
|
+
const MAX_REFUSED_POSTS_PER_ROUTE = 5;
|
|
209
263
|
const EMPTY = { version: 1, states: {}, findings: [] };
|
|
264
|
+
/**
|
|
265
|
+
* A state's elements under current keys: each key the route's aliases name
|
|
266
|
+
* (earlier key → current key) is moved, all at once, so two keys that swap
|
|
267
|
+
* places do not chain. Where two entries land on one key, exercised is kept
|
|
268
|
+
* from either.
|
|
269
|
+
*/
|
|
270
|
+
export function rekeyed(elements, aliases) {
|
|
271
|
+
if (!aliases)
|
|
272
|
+
return elements;
|
|
273
|
+
const out = {};
|
|
274
|
+
for (const [raw, entry] of Object.entries(elements)) {
|
|
275
|
+
const key = Object.hasOwn(aliases, raw) ? aliases[raw] : raw;
|
|
276
|
+
const prev = out[key];
|
|
277
|
+
out[key] = prev ? { ...prev, ...entry, exercised: prev.exercised || entry.exercised } : entry;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
210
281
|
/**
|
|
211
282
|
* Fold another process's memory into ours, losing nothing from either side.
|
|
212
283
|
*
|
|
@@ -222,6 +293,13 @@ const EMPTY = { version: 1, states: {}, findings: [] };
|
|
|
222
293
|
*/
|
|
223
294
|
export function mergeMemory(mine, theirs) {
|
|
224
295
|
const out = { ...theirs, ...mine, version: 1 };
|
|
296
|
+
out.keyAliases = { ...(theirs.keyAliases ?? {}) };
|
|
297
|
+
for (const [route, aliases] of Object.entries(mine.keyAliases ?? {})) {
|
|
298
|
+
// The first reading stands (MemoryStore learnAliases): what is already on disk wins.
|
|
299
|
+
out.keyAliases[route] = { ...aliases, ...(out.keyAliases[route] ?? {}) };
|
|
300
|
+
}
|
|
301
|
+
if (Object.keys(out.keyAliases).length === 0)
|
|
302
|
+
delete out.keyAliases;
|
|
225
303
|
out.states = { ...theirs.states };
|
|
226
304
|
for (const [fp, ours] of Object.entries(mine.states)) {
|
|
227
305
|
const other = theirs.states[fp];
|
|
@@ -229,8 +307,13 @@ export function mergeMemory(mine, theirs) {
|
|
|
229
307
|
out.states[fp] = ours;
|
|
230
308
|
continue;
|
|
231
309
|
}
|
|
232
|
-
const
|
|
233
|
-
|
|
310
|
+
const nameRule = Math.max(ours.nameRule ?? 0, other.nameRule ?? 0);
|
|
311
|
+
// When one side is under the current name rule and the other is not, the
|
|
312
|
+
// other's keys are moved to current ones before the union, as a revisit
|
|
313
|
+
// moves them (MemoryStore visitState).
|
|
314
|
+
const current = (side) => nameRule >= NAME_RULE && (side.nameRule ?? 1) < NAME_RULE ? rekeyed(side.elements, out.keyAliases?.[routeIdentity(side.route)]) : side.elements;
|
|
315
|
+
const elements = { ...current(other) };
|
|
316
|
+
for (const [key, el] of Object.entries(current(ours))) {
|
|
234
317
|
const prev = elements[key];
|
|
235
318
|
elements[key] = {
|
|
236
319
|
exercised: (prev?.exercised ?? false) || el.exercised,
|
|
@@ -246,6 +329,7 @@ export function mergeMemory(mine, theirs) {
|
|
|
246
329
|
firstSeen: ours.firstSeen < other.firstSeen ? ours.firstSeen : other.firstSeen,
|
|
247
330
|
visits: Math.max(ours.visits, other.visits),
|
|
248
331
|
elements,
|
|
332
|
+
...(nameRule ? { nameRule } : {}),
|
|
249
333
|
};
|
|
250
334
|
}
|
|
251
335
|
const byId = new Map();
|
|
@@ -267,6 +351,15 @@ export function mergeMemory(mine, theirs) {
|
|
|
267
351
|
evidence: newer.evidence ?? older.evidence,
|
|
268
352
|
regressedAt: newer.regressedAt ?? older.regressedAt,
|
|
269
353
|
};
|
|
354
|
+
// A picture and its shot travel together, from whichever side has one, the newer first.
|
|
355
|
+
const pictured = newer.picture ? newer : older.picture ? older : null;
|
|
356
|
+
delete merged.picture;
|
|
357
|
+
delete merged.pictureShot;
|
|
358
|
+
if (pictured?.picture) {
|
|
359
|
+
merged.picture = pictured.picture;
|
|
360
|
+
if (pictured.pictureShot)
|
|
361
|
+
merged.pictureShot = pictured.pictureShot;
|
|
362
|
+
}
|
|
270
363
|
// The tier is not "later knowledge wins": a defect on either side is a decision
|
|
271
364
|
// about the convention, and a store still holding the worth-a-look must not undo it.
|
|
272
365
|
const tier = mergeTier(older, newer);
|
|
@@ -279,6 +372,12 @@ export function mergeMemory(mine, theirs) {
|
|
|
279
372
|
merged.judgedMerges = trail;
|
|
280
373
|
else
|
|
281
374
|
delete merged.judgedMerges;
|
|
375
|
+
// So are the routes each side saw it on.
|
|
376
|
+
const seenOn = withSeenOn(older, seenOnOf(newer));
|
|
377
|
+
if (seenOn)
|
|
378
|
+
merged.seenOn = seenOn;
|
|
379
|
+
else
|
|
380
|
+
delete merged.seenOn;
|
|
282
381
|
byId.set(f.id, merged);
|
|
283
382
|
}
|
|
284
383
|
out.findings = [...byId.values()];
|
|
@@ -306,6 +405,17 @@ export function mergeMemory(mine, theirs) {
|
|
|
306
405
|
}
|
|
307
406
|
if (Object.keys(out.routeFacts).length === 0)
|
|
308
407
|
delete out.routeFacts;
|
|
408
|
+
// Per endpoint, the later of the two: a clear in one process outlasts an older refusal in another.
|
|
409
|
+
out.observeRefusedPosts = { ...(theirs.observeRefusedPosts ?? {}) };
|
|
410
|
+
for (const [route, endpoints] of Object.entries(mine.observeRefusedPosts ?? {})) {
|
|
411
|
+
const merged = { ...(out.observeRefusedPosts[route] ?? {}) };
|
|
412
|
+
for (const [endpoint, rec] of Object.entries(endpoints))
|
|
413
|
+
if (!merged[endpoint] || rec.at >= merged[endpoint].at)
|
|
414
|
+
merged[endpoint] = rec;
|
|
415
|
+
out.observeRefusedPosts[route] = merged;
|
|
416
|
+
}
|
|
417
|
+
if (Object.keys(out.observeRefusedPosts).length === 0)
|
|
418
|
+
delete out.observeRefusedPosts;
|
|
309
419
|
out.roleAccess = { ...(theirs.roleAccess ?? {}) };
|
|
310
420
|
for (const [role, routes] of Object.entries(mine.roleAccess ?? {})) {
|
|
311
421
|
out.roleAccess[role] = { ...(out.roleAccess[role] ?? {}), ...routes };
|
|
@@ -333,6 +443,16 @@ export function mergeMemory(mine, theirs) {
|
|
|
333
443
|
}
|
|
334
444
|
if (Object.keys(out.laneRoutes).length === 0)
|
|
335
445
|
delete out.laneRoutes;
|
|
446
|
+
// Lanes record criterion verdicts in their own processes too.
|
|
447
|
+
const ticketData = mergeTicketData(mine, theirs);
|
|
448
|
+
if (ticketData.tickets)
|
|
449
|
+
out.tickets = ticketData.tickets;
|
|
450
|
+
else
|
|
451
|
+
delete out.tickets;
|
|
452
|
+
if (ticketData.criterionVerdicts)
|
|
453
|
+
out.criterionVerdicts = ticketData.criterionVerdicts;
|
|
454
|
+
else
|
|
455
|
+
delete out.criterionVerdicts;
|
|
336
456
|
return out;
|
|
337
457
|
}
|
|
338
458
|
/** Two findings' judged merges as one list, each filing once, oldest first, at most MAX_JUDGED_MERGES. Idempotent. */
|
|
@@ -375,11 +495,54 @@ function unionRoutes(a, b) {
|
|
|
375
495
|
function decisionKey(d) {
|
|
376
496
|
return [d.lane, d.observation, d.verdict, d.severity ?? "", d.category ?? "", d.confidence, d.evidence ?? ""].join("|");
|
|
377
497
|
}
|
|
498
|
+
/** Route identity of a stored route, memoised: states written before an id shape collapsed fold into today's form. */
|
|
499
|
+
const routeIdentityCache = new Map();
|
|
500
|
+
export function routeIdentity(route) {
|
|
501
|
+
let out = routeIdentityCache.get(route);
|
|
502
|
+
if (out === undefined) {
|
|
503
|
+
out = normalizePath(route);
|
|
504
|
+
if (routeIdentityCache.size > 10_000)
|
|
505
|
+
routeIdentityCache.clear();
|
|
506
|
+
routeIdentityCache.set(route, out);
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
/** A route's base: its path, without the UI-state query naming a tab or section of it. */
|
|
511
|
+
export function baseRoute(route) {
|
|
512
|
+
return route.split("?")[0] || "/";
|
|
513
|
+
}
|
|
378
514
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
515
|
+
* Whether a contract route was reached, given the routes states were recorded
|
|
516
|
+
* on: the route itself, by today's route identity, or, for a base path, one of
|
|
517
|
+
* its tabs or sections (`/things/:id?section=history` reaches `/things/:id`).
|
|
518
|
+
* One rule for the "never visited" list and the gap ledger, so a route is not
|
|
519
|
+
* both reached and never visited.
|
|
520
|
+
*/
|
|
521
|
+
export function reachedRoutes(stateRoutes) {
|
|
522
|
+
const visited = new Set();
|
|
523
|
+
for (const r of stateRoutes)
|
|
524
|
+
visited.add(routeIdentity(r));
|
|
525
|
+
const bases = new Set([...visited].map(baseRoute));
|
|
526
|
+
return (route) => {
|
|
527
|
+
const n = routeIdentity(route);
|
|
528
|
+
return visited.has(n) || (!n.includes("?") && bases.has(n));
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Link-discovered routes re-keyed by today's route identity. A memory written
|
|
533
|
+
* before an id shape collapsed holds one route per record (`/things/AB-1001`,
|
|
534
|
+
* `/things/AB-1002`); folded, they are the one route they always were, and
|
|
535
|
+
* the first example seen stays its navigable path.
|
|
382
536
|
*/
|
|
537
|
+
export function renormalizeRoutes(map) {
|
|
538
|
+
const out = {};
|
|
539
|
+
for (const [route, example] of Object.entries(map)) {
|
|
540
|
+
const key = routeIdentity(route);
|
|
541
|
+
if (!(key in out))
|
|
542
|
+
out[key] = example;
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
383
546
|
/**
|
|
384
547
|
* Whether a coverage key belongs to a control inside another site's frame:
|
|
385
548
|
* those keys carry the frame's origin (collector.ts frameElementKey), where
|
|
@@ -388,6 +551,11 @@ function decisionKey(d) {
|
|
|
388
551
|
export function isEmbedKey(key) {
|
|
389
552
|
return /^frame:https?:\/\//.test(key);
|
|
390
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* Most lane decisions kept. Calibration wants a few dozen; a long-lived
|
|
556
|
+
* project would otherwise accumulate every decision ever made and re-serialise
|
|
557
|
+
* them on each save, which is what made an old history slow to open.
|
|
558
|
+
*/
|
|
391
559
|
export const MAX_LANE_DECISIONS = 1000;
|
|
392
560
|
/**
|
|
393
561
|
* Most options a dropdown may have and still be tracked for unchosen options.
|
|
@@ -653,12 +821,24 @@ function sameFinding(a, b) {
|
|
|
653
821
|
// that answered 2xx counts too — a false success names one — so the same bug
|
|
654
822
|
// described once by its page load and once by its failing call stays as two
|
|
655
823
|
// findings: a visible duplicate, the direction ADR 4 accepts.
|
|
824
|
+
//
|
|
825
|
+
// And when both findings carry evidence (and it differs, or it would have
|
|
826
|
+
// matched above), the detail does not count: the literal must be in the
|
|
827
|
+
// other finding's title or evidence, the two places a finding states what
|
|
828
|
+
// it is about. A filter option's label ("Last 7 days") quoted in one title
|
|
829
|
+
// and in passing in the other finding's detail names the control both
|
|
830
|
+
// defects were found through, not one defect: an undated row listed under
|
|
831
|
+
// the wrong group and four widgets ignoring the filter shared only that
|
|
832
|
+
// label, and the second filing was lost to the first. Without evidence on
|
|
833
|
+
// one side the detail still counts — nothing machine-written says they
|
|
834
|
+
// differ.
|
|
656
835
|
if (sameFamily(a.category, b.category) && !requestsDisagree(a.evidence, b.evidence)) {
|
|
657
836
|
const aTitleLits = findingLiterals(a.title);
|
|
658
837
|
const bTitleLits = findingLiterals(b.title);
|
|
659
838
|
if (aTitleLits.size > 0 || bTitleLits.size > 0) {
|
|
660
|
-
const
|
|
661
|
-
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);
|
|
662
842
|
for (const lit of aTitleLits)
|
|
663
843
|
if (bAll.has(lit))
|
|
664
844
|
return true;
|
|
@@ -854,6 +1034,44 @@ export class MemoryStore {
|
|
|
854
1034
|
this.dedupJudge = null;
|
|
855
1035
|
this.dedupChoice = undefined;
|
|
856
1036
|
this.dedupOff = undefined;
|
|
1037
|
+
this.sessionRoutes.clear();
|
|
1038
|
+
this.sessionStates.clear();
|
|
1039
|
+
this.runStates.clear();
|
|
1040
|
+
this.runId = newRunId();
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* This run's identity. A finding remembers the last run that filed it, so
|
|
1044
|
+
* "seen in N runs" counts runs and not filings: a lane re-filing the same
|
|
1045
|
+
* finding minutes later in the same run is one run. A new store (a new
|
|
1046
|
+
* process) and every endRun start a new one.
|
|
1047
|
+
*/
|
|
1048
|
+
runId = newRunId();
|
|
1049
|
+
/**
|
|
1050
|
+
* The routes each session reached this run, so scout_coverage can give a
|
|
1051
|
+
* lane in a parallel run its own remaining work rather than every lane's.
|
|
1052
|
+
* Per run, like the other per-run tallies.
|
|
1053
|
+
*/
|
|
1054
|
+
sessionRoutes = new Map();
|
|
1055
|
+
/**
|
|
1056
|
+
* The state fingerprints each session recorded this run. A route's states
|
|
1057
|
+
* differ by who looked: an admin's table and a viewer's access-denied alert
|
|
1058
|
+
* are two states of one route, and a session's own coverage lists only the
|
|
1059
|
+
* controls on the states it saw.
|
|
1060
|
+
*/
|
|
1061
|
+
sessionStates = new Map();
|
|
1062
|
+
/** Every state fingerprint recorded this run, by any session: the gap ledger's default scope. */
|
|
1063
|
+
runStates = new Set();
|
|
1064
|
+
/** The states this session recorded this run. */
|
|
1065
|
+
statesVisitedBy(session) {
|
|
1066
|
+
return this.sessionStates.get(session) ?? new Set();
|
|
1067
|
+
}
|
|
1068
|
+
/** Routes this session reached this run, in the order first reached. */
|
|
1069
|
+
routesVisitedBy(session) {
|
|
1070
|
+
return this.sessionRoutes.get(session) ?? new Set();
|
|
1071
|
+
}
|
|
1072
|
+
/** The sessions that reached this route this run. */
|
|
1073
|
+
sessionsOnRoute(route) {
|
|
1074
|
+
return [...this.sessionRoutes].filter(([, routes]) => routes.has(route)).map(([session]) => session);
|
|
857
1075
|
}
|
|
858
1076
|
/**
|
|
859
1077
|
* The dedup judge filings go through (fileFinding), or null for the rule
|
|
@@ -876,7 +1094,12 @@ export class MemoryStore {
|
|
|
876
1094
|
* one reported.
|
|
877
1095
|
*/
|
|
878
1096
|
selectChoices = new Map();
|
|
879
|
-
|
|
1097
|
+
/**
|
|
1098
|
+
* `loaded` names the options already selected before the choice: the value
|
|
1099
|
+
* the page loaded with has already been asked of the server, so it is not
|
|
1100
|
+
* owed a choice.
|
|
1101
|
+
*/
|
|
1102
|
+
recordSelectChoice(fingerprint, key, options, chosen, loaded = []) {
|
|
880
1103
|
const route = fingerprint.split("#")[0];
|
|
881
1104
|
const id = `${route}\u0000${key}`;
|
|
882
1105
|
const distinct = [...new Set(options)];
|
|
@@ -890,6 +1113,9 @@ export class MemoryStore {
|
|
|
890
1113
|
entry.options = distinct;
|
|
891
1114
|
if (chosen)
|
|
892
1115
|
entry.chosen.add(chosen);
|
|
1116
|
+
for (const label of loaded)
|
|
1117
|
+
if (label)
|
|
1118
|
+
entry.chosen.add(label);
|
|
893
1119
|
this.selectChoices.set(id, entry);
|
|
894
1120
|
}
|
|
895
1121
|
/** Dropdowns with options no session chose this run, in the order they were first used. */
|
|
@@ -917,10 +1143,12 @@ export class MemoryStore {
|
|
|
917
1143
|
* takes it off the list for good: the page refuses the empty submit itself,
|
|
918
1144
|
* and no one could clear an entry whose submit cannot be pressed.
|
|
919
1145
|
*/
|
|
920
|
-
recordForm(fingerprint, key, guarded = false) {
|
|
1146
|
+
recordForm(fingerprint, key, guarded = false, session) {
|
|
921
1147
|
const entry = this.formEntry(fingerprint, key);
|
|
922
1148
|
if (entry && guarded)
|
|
923
1149
|
entry.guarded = true;
|
|
1150
|
+
if (entry && session)
|
|
1151
|
+
entry.seenBy.add(session);
|
|
924
1152
|
}
|
|
925
1153
|
/**
|
|
926
1154
|
* A submit of a form already seen; `empty` when every text field was blank
|
|
@@ -942,14 +1170,21 @@ export class MemoryStore {
|
|
|
942
1170
|
const id = `${route}\u0000${key}`;
|
|
943
1171
|
let entry = this.emptySubmits.get(id) ?? null;
|
|
944
1172
|
if (!entry && create) {
|
|
945
|
-
entry = { route, key, triedEmpty: false };
|
|
1173
|
+
entry = { route, key, triedEmpty: false, seenBy: new Set() };
|
|
946
1174
|
this.emptySubmits.set(id, entry);
|
|
947
1175
|
}
|
|
948
1176
|
return entry;
|
|
949
1177
|
}
|
|
950
|
-
/**
|
|
951
|
-
|
|
952
|
-
|
|
1178
|
+
/**
|
|
1179
|
+
* Forms seen this run that no session has submitted empty, in the order they
|
|
1180
|
+
* were first seen, each with the sessions that saw it. Given a session, only
|
|
1181
|
+
* the forms that session saw: in a parallel run another lane's form may sit
|
|
1182
|
+
* on a route this one never opens, or behind a role it does not have.
|
|
1183
|
+
*/
|
|
1184
|
+
formsNeverSubmittedEmpty(session) {
|
|
1185
|
+
return [...this.emptySubmits.values()]
|
|
1186
|
+
.filter((f) => !f.triedEmpty && !f.guarded && (session === undefined || f.seenBy.has(session)))
|
|
1187
|
+
.map(({ route, key, seenBy }) => ({ route, key, seenBy: [...seenBy] }));
|
|
953
1188
|
}
|
|
954
1189
|
constructor(projectDir) {
|
|
955
1190
|
this.dir = path.join(projectDir, MEMORY_DIRNAME);
|
|
@@ -1012,6 +1247,9 @@ export class MemoryStore {
|
|
|
1012
1247
|
const trail = unionJudgedMerges(dupOf, f);
|
|
1013
1248
|
if (trail)
|
|
1014
1249
|
dupOf.judgedMerges = trail;
|
|
1250
|
+
const seenOn = withSeenOn(dupOf, [findingRoute(f), ...seenOnOf(f)]);
|
|
1251
|
+
if (seenOn)
|
|
1252
|
+
dupOf.seenOn = seenOn;
|
|
1015
1253
|
if (f.status === "resolved")
|
|
1016
1254
|
dupOf.status = "resolved";
|
|
1017
1255
|
const promoted = isWorthALook(dupOf) && !isWorthALook(f);
|
|
@@ -1129,6 +1367,45 @@ export class MemoryStore {
|
|
|
1129
1367
|
get routeFacts() {
|
|
1130
1368
|
return this.data.routeFacts ?? {};
|
|
1131
1369
|
}
|
|
1370
|
+
/** Record that observe refused a script's POST to `endpoint` on `route`. Deduplicated; saved only when something changed. */
|
|
1371
|
+
noteObserveRefusedPost(route, endpoint, now = Date.now()) {
|
|
1372
|
+
const all = this.data.observeRefusedPosts ?? {};
|
|
1373
|
+
const forRoute = all[route] ?? {};
|
|
1374
|
+
const rec = forRoute[endpoint];
|
|
1375
|
+
if (rec && !rec.cleared)
|
|
1376
|
+
return;
|
|
1377
|
+
if (!rec && !all[route] && Object.keys(all).length >= MAX_REFUSED_POST_ROUTES)
|
|
1378
|
+
return;
|
|
1379
|
+
if (!rec && Object.keys(forRoute).length >= MAX_REFUSED_POSTS_PER_ROUTE)
|
|
1380
|
+
return;
|
|
1381
|
+
forRoute[endpoint] = { at: now };
|
|
1382
|
+
all[route] = forRoute;
|
|
1383
|
+
this.data.observeRefusedPosts = all;
|
|
1384
|
+
this.save();
|
|
1385
|
+
}
|
|
1386
|
+
/** Clear every open refusal whose endpoint `went` says has since gone out. Saved only when something changed. */
|
|
1387
|
+
clearObserveRefusedPosts(went, now = Date.now()) {
|
|
1388
|
+
let changed = false;
|
|
1389
|
+
for (const endpoints of Object.values(this.data.observeRefusedPosts ?? {}))
|
|
1390
|
+
for (const [endpoint, rec] of Object.entries(endpoints))
|
|
1391
|
+
if (!rec.cleared && went(endpoint)) {
|
|
1392
|
+
endpoints[endpoint] = { at: now, cleared: true };
|
|
1393
|
+
changed = true;
|
|
1394
|
+
}
|
|
1395
|
+
if (changed)
|
|
1396
|
+
this.save();
|
|
1397
|
+
}
|
|
1398
|
+
/** Pages with a POST observe refused and nothing has since let out, for the gap ledger. */
|
|
1399
|
+
get observeRefusedPosts() {
|
|
1400
|
+
return Object.entries(this.data.observeRefusedPosts ?? {})
|
|
1401
|
+
.map(([route, endpoints]) => ({
|
|
1402
|
+
route,
|
|
1403
|
+
endpoints: Object.entries(endpoints)
|
|
1404
|
+
.filter(([, rec]) => !rec.cleared)
|
|
1405
|
+
.map(([endpoint]) => endpoint),
|
|
1406
|
+
}))
|
|
1407
|
+
.filter((p) => p.endpoints.length > 0);
|
|
1408
|
+
}
|
|
1132
1409
|
/** Record that `role` reached (or was denied) `route`. Denials never overwrite a recorded "reached" — flaky redirects must not erase real access. */
|
|
1133
1410
|
recordRoleAccess(role, route, outcome) {
|
|
1134
1411
|
const all = this.data.roleAccess ?? {};
|
|
@@ -1259,6 +1536,45 @@ export class MemoryStore {
|
|
|
1259
1536
|
// every call after the first reported nothing kept — while storing fine.
|
|
1260
1537
|
return Math.min(added, MAX_LANE_DECISIONS);
|
|
1261
1538
|
}
|
|
1539
|
+
/** Every ticket the project has been given, oldest reading first. */
|
|
1540
|
+
get tickets() {
|
|
1541
|
+
return this.data.tickets ?? [];
|
|
1542
|
+
}
|
|
1543
|
+
/** Every verdict recorded on a criterion, oldest first. */
|
|
1544
|
+
get criterionVerdicts() {
|
|
1545
|
+
return this.data.criterionVerdicts ?? [];
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Keep tickets read in this run. A ticket read again replaces its earlier
|
|
1549
|
+
* reading (see addReading). Titles and criteria are redacted like any other
|
|
1550
|
+
* stored text: a ticket is pasted by a person and may carry a credential.
|
|
1551
|
+
* Returns the tickets as stored, with the ids to judge them by.
|
|
1552
|
+
*/
|
|
1553
|
+
addTickets(parsed) {
|
|
1554
|
+
const clean = parsed.map((t) => ({
|
|
1555
|
+
...t,
|
|
1556
|
+
title: redactSecrets(t.title),
|
|
1557
|
+
criteria: t.criteria.map((c) => ({ ...c, text: redactSecrets(c.text) })),
|
|
1558
|
+
}));
|
|
1559
|
+
const all = this.tickets;
|
|
1560
|
+
const thisRun = all.filter((t) => t.loadedAt >= this.sessionStart);
|
|
1561
|
+
const { tickets, added } = addReading(thisRun, clean, new Date().toISOString());
|
|
1562
|
+
const ids = new Set(tickets.map((t) => t.id));
|
|
1563
|
+
this.data.tickets = [...all.filter((t) => t.loadedAt < this.sessionStart && !ids.has(t.id)), ...tickets].slice(-MAX_TICKETS_KEPT);
|
|
1564
|
+
this.flush();
|
|
1565
|
+
return added;
|
|
1566
|
+
}
|
|
1567
|
+
/** Record one session's verdict on a criterion, replacing that session's earlier verdict on it. */
|
|
1568
|
+
addCriterionVerdict(record) {
|
|
1569
|
+
this.data.criterionVerdicts = addVerdict(this.criterionVerdicts, { ...record, reason: redactSecrets(record.reason) });
|
|
1570
|
+
this.flush();
|
|
1571
|
+
}
|
|
1572
|
+
/** What this run answers: the tickets read in it, or judged in it, and this run's verdicts. */
|
|
1573
|
+
ticketsThisRun() {
|
|
1574
|
+
const verdicts = this.criterionVerdicts.filter((v) => v.at >= this.sessionStart);
|
|
1575
|
+
const judged = new Set(verdicts.map((v) => v.ticket));
|
|
1576
|
+
return { tickets: this.tickets.filter((t) => t.loadedAt >= this.sessionStart || judged.has(t.id)), verdicts };
|
|
1577
|
+
}
|
|
1262
1578
|
/** The routes each lane's report said it covered. Empty on a project that has never run one. */
|
|
1263
1579
|
get laneRoutes() {
|
|
1264
1580
|
return this.data.laneRoutes ?? {};
|
|
@@ -1280,6 +1596,16 @@ export class MemoryStore {
|
|
|
1280
1596
|
this.flush();
|
|
1281
1597
|
return added;
|
|
1282
1598
|
}
|
|
1599
|
+
/** Keep a finding's picture: its path relative to .scenescout/, and what it shows. Returns the finding, or null when there is no such id. */
|
|
1600
|
+
setPicture(id, picture, shot) {
|
|
1601
|
+
const f = this.data.findings.find((x) => x.id === id);
|
|
1602
|
+
if (!f)
|
|
1603
|
+
return null;
|
|
1604
|
+
f.picture = picture;
|
|
1605
|
+
f.pictureShot = shot;
|
|
1606
|
+
this.flush();
|
|
1607
|
+
return f;
|
|
1608
|
+
}
|
|
1283
1609
|
/** Mark a finding resolved; returns it or null. */
|
|
1284
1610
|
resolveFinding(id) {
|
|
1285
1611
|
const f = this.data.findings.find((x) => x.id === id);
|
|
@@ -1324,6 +1650,8 @@ export class MemoryStore {
|
|
|
1324
1650
|
raw.states = kept;
|
|
1325
1651
|
this.prunedStates = dropped;
|
|
1326
1652
|
}
|
|
1653
|
+
if (raw.discoveredRoutes)
|
|
1654
|
+
raw.discoveredRoutes = renormalizeRoutes(raw.discoveredRoutes);
|
|
1327
1655
|
return raw;
|
|
1328
1656
|
}
|
|
1329
1657
|
this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
|
|
@@ -1498,8 +1826,21 @@ export class MemoryStore {
|
|
|
1498
1826
|
* Record a visit to a state; returns whether it was new. `inertKeys` are the
|
|
1499
1827
|
* listed keys a user cannot act on (collector inertKeys): they stay known,
|
|
1500
1828
|
* so a click on one still registers, but coverage does not count them.
|
|
1829
|
+
* `session` names who visited, for this run's per-session coverage.
|
|
1830
|
+
* `aliases` maps a listed key to the key the same control had under the
|
|
1831
|
+
* earlier name rule (fingerprint.ts keyAliases); the route keeps them so
|
|
1832
|
+
* states written before the rule changed are read under current keys.
|
|
1501
1833
|
*/
|
|
1502
|
-
visitState(fingerprint, url, route, elementKeys, inertKeys = []) {
|
|
1834
|
+
visitState(fingerprint, url, route, elementKeys, inertKeys = [], session, aliases = {}) {
|
|
1835
|
+
this.runStates.add(fingerprint);
|
|
1836
|
+
if (session) {
|
|
1837
|
+
const routes = this.sessionRoutes.get(session) ?? new Set();
|
|
1838
|
+
routes.add(route);
|
|
1839
|
+
this.sessionRoutes.set(session, routes);
|
|
1840
|
+
const states = this.sessionStates.get(session) ?? new Set();
|
|
1841
|
+
states.add(fingerprint);
|
|
1842
|
+
this.sessionStates.set(session, states);
|
|
1843
|
+
}
|
|
1503
1844
|
let rec = this.data.states[fingerprint];
|
|
1504
1845
|
const isNew = !rec;
|
|
1505
1846
|
if (!rec) {
|
|
@@ -1508,6 +1849,15 @@ export class MemoryStore {
|
|
|
1508
1849
|
}
|
|
1509
1850
|
rec.visits += 1;
|
|
1510
1851
|
rec.lastSeen = new Date().toISOString();
|
|
1852
|
+
this.learnAliases(route, aliases);
|
|
1853
|
+
// A record written under the earlier rule can be reached again under the
|
|
1854
|
+
// same fingerprint with its keys renamed: the fingerprint hashes the set
|
|
1855
|
+
// of base keys, so a rename that only moves ordinals (an image button
|
|
1856
|
+
// named like a text button beside it) leaves it unchanged. Its keys are
|
|
1857
|
+
// moved to the current ones before it is marked current.
|
|
1858
|
+
if ((rec.nameRule ?? 1) < NAME_RULE)
|
|
1859
|
+
rec.elements = rekeyed(rec.elements, this.data.keyAliases?.[routeIdentity(route)]);
|
|
1860
|
+
rec.nameRule = NAME_RULE;
|
|
1511
1861
|
const present = new Set(elementKeys);
|
|
1512
1862
|
const inert = new Set(inertKeys);
|
|
1513
1863
|
for (const key of elementKeys) {
|
|
@@ -1532,6 +1882,23 @@ export class MemoryStore {
|
|
|
1532
1882
|
this.save();
|
|
1533
1883
|
return isNew;
|
|
1534
1884
|
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Keep each earlier key → current key the route has not learned yet. The
|
|
1887
|
+
* first reading stands: a later snapshot of another state of the route can
|
|
1888
|
+
* hand out ordinals differently, and the states written under the earlier
|
|
1889
|
+
* rule do not change, so neither should what their keys are read as.
|
|
1890
|
+
*/
|
|
1891
|
+
learnAliases(route, aliases) {
|
|
1892
|
+
const pairs = Object.entries(aliases);
|
|
1893
|
+
if (pairs.length === 0)
|
|
1894
|
+
return;
|
|
1895
|
+
const byRoute = (this.data.keyAliases ??= {});
|
|
1896
|
+
const id = routeIdentity(route);
|
|
1897
|
+
const known = (byRoute[id] ??= {});
|
|
1898
|
+
for (const [key, prior] of pairs)
|
|
1899
|
+
if (!Object.hasOwn(known, prior))
|
|
1900
|
+
known[prior] = key;
|
|
1901
|
+
}
|
|
1535
1902
|
markExercised(fingerprint, key, action) {
|
|
1536
1903
|
const rec = this.data.states[fingerprint];
|
|
1537
1904
|
if (!rec)
|
|
@@ -1558,14 +1925,20 @@ export class MemoryStore {
|
|
|
1558
1925
|
* across sessions rarely reuses the exact words — Jaccard catches it).
|
|
1559
1926
|
* Returns [finding, isNew].
|
|
1560
1927
|
*/
|
|
1561
|
-
/**
|
|
1928
|
+
/**
|
|
1929
|
+
* Returns the finding, whether it is new, whether an existing worth-a-look
|
|
1930
|
+
* was just promoted to a defect by it, and how a merge treated what the two
|
|
1931
|
+
* filings disagree on (MergeNote).
|
|
1932
|
+
*/
|
|
1562
1933
|
addFinding(input) {
|
|
1563
1934
|
const f = this.redacted(input);
|
|
1564
1935
|
const id = findingId(f);
|
|
1565
1936
|
const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
|
|
1566
|
-
if (existing)
|
|
1567
|
-
|
|
1568
|
-
|
|
1937
|
+
if (existing) {
|
|
1938
|
+
const { promoted, merge } = this.mergeInto(existing, f, id);
|
|
1939
|
+
return [existing, false, promoted, merge];
|
|
1940
|
+
}
|
|
1941
|
+
return [this.append(f, id), true, false, { sameRun: false, updated: [], kept: [] }];
|
|
1569
1942
|
}
|
|
1570
1943
|
/**
|
|
1571
1944
|
* File a finding, asking the dedup judge when one is set (dedupJudge) and
|
|
@@ -1580,7 +1953,7 @@ export class MemoryStore {
|
|
|
1580
1953
|
const id = findingId(f);
|
|
1581
1954
|
const byRule = this.data.findings.find((x) => isDuplicateFinding(x, f));
|
|
1582
1955
|
if (byRule)
|
|
1583
|
-
return { finding: byRule, isNew: false,
|
|
1956
|
+
return { finding: byRule, isNew: false, ...this.mergeInto(byRule, f, id) };
|
|
1584
1957
|
const judge = this.dedupJudge;
|
|
1585
1958
|
if (!judge)
|
|
1586
1959
|
return { finding: this.append(f, id), isNew: true, promoted: false };
|
|
@@ -1600,7 +1973,7 @@ export class MemoryStore {
|
|
|
1600
1973
|
// merge with another process's memory may have replaced its object.
|
|
1601
1974
|
const landed = this.data.findings.find((x) => !before.has(x.id) && isDuplicateFinding(x, f));
|
|
1602
1975
|
if (landed)
|
|
1603
|
-
return { finding: landed, isNew: false,
|
|
1976
|
+
return { finding: landed, isNew: false, ...this.mergeInto(landed, f, id), ...extra };
|
|
1604
1977
|
// Only what a judge may answer is taken: an open finding on the filing's page, called the same at better than even.
|
|
1605
1978
|
const route = f.state.split("#")[0];
|
|
1606
1979
|
const chosen = verdict && verdict.pSame >= 0.5
|
|
@@ -1608,7 +1981,7 @@ export class MemoryStore {
|
|
|
1608
1981
|
: undefined;
|
|
1609
1982
|
if (chosen && verdict) {
|
|
1610
1983
|
const judged = { pSame: verdict.pSame };
|
|
1611
|
-
return { finding: chosen, isNew: false,
|
|
1984
|
+
return { finding: chosen, isNew: false, ...this.mergeInto(chosen, f, id, judged), judged, ...extra };
|
|
1612
1985
|
}
|
|
1613
1986
|
return { finding: this.append(f, id), isNew: true, promoted: false, ...extra };
|
|
1614
1987
|
}
|
|
@@ -1634,13 +2007,43 @@ export class MemoryStore {
|
|
|
1634
2007
|
* made this merge, so the filing is kept on the finding (judgedMerges).
|
|
1635
2008
|
*/
|
|
1636
2009
|
mergeInto(existing, f, id, judged) {
|
|
1637
|
-
|
|
2010
|
+
// Counted once per run: a lane filing the same thing again minutes
|
|
2011
|
+
// later is not the bug recurring.
|
|
2012
|
+
const sameRun = existing.lastRun === this.runId;
|
|
2013
|
+
if (!sameRun)
|
|
2014
|
+
existing.runs += 1;
|
|
2015
|
+
existing.lastRun = this.runId;
|
|
1638
2016
|
existing.foundAt = new Date().toISOString();
|
|
1639
2017
|
if (!existing.evidence && f.evidence)
|
|
1640
2018
|
existing.evidence = f.evidence;
|
|
2019
|
+
// A filing from another page is another place the defect shows.
|
|
2020
|
+
const route = findingRoute(f);
|
|
2021
|
+
const knew = route === findingRoute(existing) || seenOnOf(existing).includes(route);
|
|
2022
|
+
const seenOn = withSeenOn(existing, [route]);
|
|
2023
|
+
if (seenOn)
|
|
2024
|
+
existing.seenOn = seenOn;
|
|
2025
|
+
// The same session filing it again in the same run is correcting its
|
|
2026
|
+
// own filing: its newer convention and detail are taken. Anyone else's
|
|
2027
|
+
// filing is a second sighting, which keeps the first wording. A merge the
|
|
2028
|
+
// dedup judge made is never a correction: the judge saw two filings.
|
|
2029
|
+
const correction = !judged && sameRun && !!f.session && existing.session === f.session;
|
|
2030
|
+
const merge = { sameRun, updated: [], kept: [], ...(knew ? {} : { seenOn: route }) };
|
|
1641
2031
|
// A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
|
|
1642
2032
|
const promoted = isWorthALook(existing) && !isWorthALook(f);
|
|
1643
|
-
const tier = mergeTier(existing, f);
|
|
2033
|
+
const tier = mergeTier(existing, f, correction ? "incoming" : "existing");
|
|
2034
|
+
// A convention filled in where there was none is not a disagreement, so it says nothing.
|
|
2035
|
+
if (isWorthALook(existing) && isWorthALook(f) && f.convention && existing.convention && f.convention !== existing.convention) {
|
|
2036
|
+
(correction ? merge.updated : merge.kept).push("convention");
|
|
2037
|
+
}
|
|
2038
|
+
if (f.detail && f.detail !== existing.detail) {
|
|
2039
|
+
if (correction) {
|
|
2040
|
+
existing.detail = f.detail;
|
|
2041
|
+
merge.updated.push("detail");
|
|
2042
|
+
}
|
|
2043
|
+
else {
|
|
2044
|
+
merge.kept.push("detail");
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
1644
2047
|
Object.assign(withoutTier(existing), tier);
|
|
1645
2048
|
if (promoted)
|
|
1646
2049
|
existing.severity = f.severity;
|
|
@@ -1671,7 +2074,7 @@ export class MemoryStore {
|
|
|
1671
2074
|
existing.judgedMerges = [...(existing.judgedMerges ?? []), entry].slice(-MAX_JUDGED_MERGES);
|
|
1672
2075
|
}
|
|
1673
2076
|
this.flush();
|
|
1674
|
-
return promoted;
|
|
2077
|
+
return { promoted, merge };
|
|
1675
2078
|
}
|
|
1676
2079
|
/** Store a filing as a new finding, with its repro trace. */
|
|
1677
2080
|
append(f, id) {
|
|
@@ -1707,6 +2110,7 @@ export class MemoryStore {
|
|
|
1707
2110
|
.map((a) => `${a.action}${a.target ? ` ${a.target}` : ""} @ ${a.url}`),
|
|
1708
2111
|
foundAt: new Date().toISOString(),
|
|
1709
2112
|
runs: 1,
|
|
2113
|
+
lastRun: this.runId,
|
|
1710
2114
|
};
|
|
1711
2115
|
this.data.findings.push(finding);
|
|
1712
2116
|
this.flush();
|
|
@@ -1723,9 +2127,15 @@ export class MemoryStore {
|
|
|
1723
2127
|
* rendered in 30 states of one route is one set of elements, not 30. An
|
|
1724
2128
|
* element counts as exercised when it was exercised in ANY state of the
|
|
1725
2129
|
* route. (`state` in the result therefore holds a route.)
|
|
2130
|
+
*
|
|
2131
|
+
* `scope.routes` narrows the routes counted; `scope.states` narrows the
|
|
2132
|
+
* elements listed to the ones those states hold (a session's own states, or
|
|
2133
|
+
* this run's), while whether one was exercised still comes from every state
|
|
2134
|
+
* of the route.
|
|
1726
2135
|
*/
|
|
1727
|
-
coverage() {
|
|
2136
|
+
coverage(scope) {
|
|
1728
2137
|
const byRoute = this.elementsByRoute();
|
|
2138
|
+
const listed = scope?.states ? this.elementsByRoute(scope.states) : byRoute;
|
|
1729
2139
|
// Shared layout CHROME (sidebar nav, header, breadcrumbs) is one set of
|
|
1730
2140
|
// components, not one set per route — clicking "nav-documents" on /admin is
|
|
1731
2141
|
// the same click as on /. Counting it per route inflated the denominator by
|
|
@@ -1738,8 +2148,14 @@ export class MemoryStore {
|
|
|
1738
2148
|
let elementsTotal = 0;
|
|
1739
2149
|
let elementsExercised = 0;
|
|
1740
2150
|
const unexercised = [];
|
|
2151
|
+
const perRoute = new Map();
|
|
1741
2152
|
const embeds = { total: 0, exercised: 0 };
|
|
1742
|
-
for (const [route, elements] of
|
|
2153
|
+
for (const [route, elements] of listed) {
|
|
2154
|
+
// A scope narrows what is counted, never what counts as chrome: that is
|
|
2155
|
+
// decided over every route, so a lane on two pages does not see the
|
|
2156
|
+
// project's sidebar as those pages' own controls.
|
|
2157
|
+
if (scope?.routes && !scope.routes.has(route))
|
|
2158
|
+
continue;
|
|
1743
2159
|
const own = [];
|
|
1744
2160
|
// The route's OWN element count — deduped across states and with shared
|
|
1745
2161
|
// chrome removed, i.e. exactly the denominator `own` is a subset of.
|
|
@@ -1766,6 +2182,7 @@ export class MemoryStore {
|
|
|
1766
2182
|
else
|
|
1767
2183
|
own.push(key);
|
|
1768
2184
|
}
|
|
2185
|
+
perRoute.set(route, { total: ownTotal, exercised: ownTotal - own.length });
|
|
1769
2186
|
if (own.length > 0)
|
|
1770
2187
|
unexercised.push({ state: route, keys: own, total: ownTotal });
|
|
1771
2188
|
}
|
|
@@ -1781,7 +2198,10 @@ export class MemoryStore {
|
|
|
1781
2198
|
if (chromeLeft.length > 0) {
|
|
1782
2199
|
unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
|
|
1783
2200
|
}
|
|
1784
|
-
|
|
2201
|
+
const states = scope
|
|
2202
|
+
? Object.entries(this.data.states).filter(([fp, st]) => (!scope.routes || scope.routes.has(routeIdentity(st.route))) && (!scope.states || scope.states.has(fp))).length
|
|
2203
|
+
: Object.keys(this.data.states).length;
|
|
2204
|
+
return { states, elementsTotal, elementsExercised, unexercised, perRoute, embeds };
|
|
1785
2205
|
}
|
|
1786
2206
|
/**
|
|
1787
2207
|
* Remember which styled-element signatures the design audit saw on a route.
|
|
@@ -1815,21 +2235,55 @@ export class MemoryStore {
|
|
|
1815
2235
|
keys.add(sig);
|
|
1816
2236
|
return keys;
|
|
1817
2237
|
}
|
|
1818
|
-
/**
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2238
|
+
/**
|
|
2239
|
+
* Element keys folded per route, exercised-in-any-state. With `only`, the
|
|
2240
|
+
* keys listed are the ones those states hold; exercised still reads every
|
|
2241
|
+
* state of the route.
|
|
2242
|
+
*
|
|
2243
|
+
* A key is not a control when ANY state of the route recorded it as inert.
|
|
2244
|
+
* Whether a user can act on an element depends on the element, not on the
|
|
2245
|
+
* state it was seen in, and states recorded before the flag existed carry
|
|
2246
|
+
* an unflagged copy that would otherwise keep a wrapper in the count until
|
|
2247
|
+
* that exact state is visited again.
|
|
2248
|
+
*
|
|
2249
|
+
* Routes are re-read through route identity, so states recorded under an
|
|
2250
|
+
* older form of a route (before an id shape collapsed) fold into it.
|
|
2251
|
+
*/
|
|
2252
|
+
elementsByRoute(only) {
|
|
2253
|
+
const inert = new Map();
|
|
2254
|
+
const exercised = new Map();
|
|
2255
|
+
const listed = new Map();
|
|
2256
|
+
const bucket = (map, route) => {
|
|
2257
|
+
let set = map.get(route);
|
|
2258
|
+
if (!set) {
|
|
2259
|
+
set = new Set();
|
|
2260
|
+
map.set(route, set);
|
|
1826
2261
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
2262
|
+
return set;
|
|
2263
|
+
};
|
|
2264
|
+
for (const [fp, rec] of Object.entries(this.data.states)) {
|
|
2265
|
+
const route = routeIdentity(rec.route);
|
|
2266
|
+
const keys = only && !only.has(fp) ? null : bucket(listed, route);
|
|
2267
|
+
// A state written under an earlier name rule is read under current keys.
|
|
2268
|
+
const elements = (rec.nameRule ?? 1) < NAME_RULE ? rekeyed(rec.elements, this.data.keyAliases?.[route]) : rec.elements;
|
|
2269
|
+
for (const [key, v] of Object.entries(elements)) {
|
|
1829
2270
|
if (v.inert)
|
|
2271
|
+
bucket(inert, route).add(key);
|
|
2272
|
+
if (v.exercised)
|
|
2273
|
+
bucket(exercised, route).add(key);
|
|
2274
|
+
keys?.add(key);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const byRoute = new Map();
|
|
2278
|
+
for (const [route, keys] of listed) {
|
|
2279
|
+
const elements = new Map();
|
|
2280
|
+
for (const key of keys) {
|
|
2281
|
+
// Not a control: nothing to exercise, so not counted as a gap or a total.
|
|
2282
|
+
if (inert.get(route)?.has(key))
|
|
1830
2283
|
continue;
|
|
1831
|
-
|
|
2284
|
+
elements.set(key, exercised.get(route)?.has(key) ?? false);
|
|
1832
2285
|
}
|
|
2286
|
+
byRoute.set(route, elements);
|
|
1833
2287
|
}
|
|
1834
2288
|
return byRoute;
|
|
1835
2289
|
}
|