scenescout 3.11.1 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Measuring the finding-dedup decision against the answer keys, and an
3
+ * optional model-backed judge to measure beside it.
4
+ *
5
+ * The store's dedup rule (memory.ts, isDuplicateFinding) decides whether a
6
+ * newly filed finding is one it already records. Its mistakes are costly both
7
+ * ways: a wrong merge loses a finding, a missed merge shows one bug twice. This
8
+ * file turns the archived benchmark runs into labelled pairs — two findings on
9
+ * one page, "same" when the key classifies both to one entry and "different"
10
+ * when it classifies them to two — and scores any decider on them with
11
+ * accuracy, Brier and ECE.
12
+ *
13
+ * The model judge asks for a verdict (same / different / unsure) and a
14
+ * probability through one tool call, so its answer is structured, not parsed
15
+ * from prose. It is measured here and not used by the store: the rule stays
16
+ * the only thing that dedups until the judge's Brier beats it on these pairs.
17
+ * Everything here is pure; the network is the caller's, through `ask`.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { archiveApp, classify, findingText } from "./bench.js";
21
+ import { MIN_FOR_A_VERDICT } from "./calibration.js";
22
+ import { findingId, isDuplicateFinding } from "./memory.js";
23
+ const pagesOf = (e) => [e.route, ...(e.alsoOn ?? [])];
24
+ const norm = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
25
+ /**
26
+ * Every pair of findings, within one app across all its archived runs, whose
27
+ * key entries share a page, labelled by the key. Across runs because that is
28
+ * where the store dedups: a finding filed by one run against one an earlier
29
+ * run left. Findings with identical category, title and evidence are collapsed
30
+ * first, so a phrasing repeated in many runs is one finding, not a crowd of
31
+ * trivially-same pairs.
32
+ *
33
+ * The labels come from the key's patterns, so they are only as good as the
34
+ * key: a finding the key misclassifies gives a wrong label.
35
+ */
36
+ export function buildPairs(archives, keys) {
37
+ const out = { pairs: [], findings: 0, identicalText: 0, unmatched: 0, ambiguous: 0, nonDefect: 0, archivesWithoutKey: 0 };
38
+ const byApp = new Map();
39
+ const seen = new Set();
40
+ for (const archive of archives) {
41
+ const app = archiveApp(archive);
42
+ const key = keys[app];
43
+ if (!key) {
44
+ out.archivesWithoutKey += 1;
45
+ continue;
46
+ }
47
+ const list = byApp.get(app) ?? [];
48
+ byApp.set(app, list);
49
+ for (const f of archive.findings) {
50
+ const text = `${app}|${norm(f.category)}|${norm(f.title)}|${norm(f.evidence)}`;
51
+ if (seen.has(text)) {
52
+ out.identicalText += 1;
53
+ continue;
54
+ }
55
+ seen.add(text);
56
+ const c = classify(findingText(f), key);
57
+ if (c === null)
58
+ out.unmatched += 1;
59
+ else if (c.kind === "ambiguous")
60
+ out.ambiguous += 1;
61
+ else if (c.kind === "nonDefect")
62
+ out.nonDefect += 1;
63
+ else {
64
+ out.findings += 1;
65
+ list.push({ f: { title: f.title, category: f.category ?? "", evidence: f.evidence, keyId: c.entry.id, run: archive.run }, pages: pagesOf(c.entry) });
66
+ }
67
+ }
68
+ }
69
+ for (const [app, list] of byApp) {
70
+ for (let i = 0; i < list.length; i++) {
71
+ for (let j = i + 1; j < list.length; j++) {
72
+ const route = list[i].pages.find((p) => list[j].pages.includes(p));
73
+ if (route === undefined)
74
+ continue;
75
+ out.pairs.push({ app, route, a: list[i].f, b: list[j].f, same: list[i].f.keyId === list[j].f.keyId });
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * At most `cap` pairs, chosen by a hash of their texts: the same pairs every
83
+ * time for the same archives, whatever order they were built in, and no
84
+ * preference for the runs that happen to be listed first.
85
+ */
86
+ export function samplePairs(pairs, cap) {
87
+ if (!Number.isInteger(cap) || cap < 1)
88
+ throw new Error(`The pair cap must be a positive whole number, not ${cap}.`);
89
+ const h = (p) => createHash("sha256")
90
+ .update([p.app, p.route, p.a.run, p.a.title, p.a.evidence ?? "", p.b.run, p.b.title, p.b.evidence ?? ""].join("\n"))
91
+ .digest("hex");
92
+ return pairs
93
+ .map((p) => ({ p, k: h(p) }))
94
+ .sort((x, y) => (x.k < y.k ? -1 : x.k > y.k ? 1 : 0))
95
+ .slice(0, cap)
96
+ .map((x) => x.p);
97
+ }
98
+ const asStored = (f, route) => ({ title: f.title, category: f.category, evidence: f.evidence, detail: "", state: route });
99
+ /**
100
+ * The store's own rule on a pair, as a judgement: the earlier finding is the
101
+ * stored one, the later the one being filed. It states no probability, so its
102
+ * probability is its verdict, 1 or 0.
103
+ */
104
+ export function ruleJudgement(p) {
105
+ const existing = { ...asStored(p.a, p.route), id: findingId(asStored(p.a, p.route)) };
106
+ const same = isDuplicateFinding(existing, asStored(p.b, p.route));
107
+ return { verdict: same ? "same" : "different", pSame: same ? 1 : 0 };
108
+ }
109
+ // ── the model judge ─────────────────────────────────────────────────────────
110
+ export const JUDGE_TOOL = {
111
+ name: "judge_pair",
112
+ description: "Record whether the two findings describe one defect.",
113
+ parameters: {
114
+ type: "object",
115
+ properties: {
116
+ verdict: { type: "string", enum: ["same", "different", "unsure"] },
117
+ p_same: { type: "number", minimum: 0, maximum: 1, description: "Probability the two findings are one defect." },
118
+ },
119
+ required: ["verdict", "p_same"],
120
+ additionalProperties: false,
121
+ },
122
+ };
123
+ export const JUDGE_SYSTEM = "You decide whether two findings filed by exploratory testers of one web app describe the SAME defect or DIFFERENT defects. " +
124
+ "Same: one root cause a single fix would close, however differently it is worded, categorised or evidenced. " +
125
+ "Different: two things a developer would fix separately, even on one page, one control or one endpoint. " +
126
+ "Answer only by calling judge_pair once, with no prose. verdict is same, different or unsure; p_same is your calibrated probability that they are one defect: " +
127
+ "about 0.5 when the text cannot tell, and near 0 or 1 only when it plainly can. Use unsure only when the findings give too little to go on.";
128
+ /** What the judge is shown: the fields a tester filed, nothing from the key. */
129
+ export function judgeKickoff(route, a, b) {
130
+ const show = (f) => ({ title: f.title, category: f.category, evidence: f.evidence ?? "" });
131
+ return `Both findings were filed on the page ${route}.\n\nFinding A: ${JSON.stringify(show(a))}\nFinding B: ${JSON.stringify(show(b))}`;
132
+ }
133
+ /**
134
+ * Read the judge's one tool call. Anything else — no call, another tool, a
135
+ * verdict outside the three, a probability outside 0–1 or one that
136
+ * contradicts its own verdict — is an error, never a guess at what was meant.
137
+ */
138
+ export function parseJudgement(turn) {
139
+ const call = turn.calls.find((c) => c.name === JUDGE_TOOL.name);
140
+ if (!call)
141
+ return { ok: false, error: turn.note ?? `the model did not call ${JUDGE_TOOL.name}` };
142
+ if (call.argsError)
143
+ return { ok: false, error: call.argsError };
144
+ const input = call.input;
145
+ const verdict = input?.verdict;
146
+ const p = input?.p_same;
147
+ if (verdict !== "same" && verdict !== "different" && verdict !== "unsure")
148
+ return { ok: false, error: `the verdict ${JSON.stringify(verdict)} is not same, different or unsure` };
149
+ if (typeof p !== "number" || !Number.isFinite(p) || p < 0 || p > 1)
150
+ return { ok: false, error: `p_same ${JSON.stringify(p)} is not a probability` };
151
+ if ((verdict === "same" && p < 0.5) || (verdict === "different" && p > 0.5))
152
+ return { ok: false, error: `the verdict ${verdict} contradicts p_same ${p}` };
153
+ return { ok: true, judgement: { verdict, pSame: p } };
154
+ }
155
+ /**
156
+ * Decide one pair. With no judge the rule decides, as the store does. With a
157
+ * judge, the model decides when it answers same or different; when it fails
158
+ * for any reason, or is unsure, the rule decides and the note says why, so a
159
+ * run never silently becomes a run of the rule.
160
+ */
161
+ export async function decideDuplicate(p, ask) {
162
+ const rule = () => ruleJudgement(p).verdict === "same";
163
+ if (!ask)
164
+ return { duplicate: rule(), by: "rule" };
165
+ let parsed;
166
+ try {
167
+ parsed = parseJudgement(await ask(JUDGE_SYSTEM, [JUDGE_TOOL], judgeKickoff(p.route, p.a, p.b)));
168
+ }
169
+ catch (err) {
170
+ parsed = { ok: false, error: err instanceof Error ? err.message : String(err) };
171
+ }
172
+ if (!parsed.ok)
173
+ return { duplicate: rule(), by: "rule", note: `the model judge failed (${parsed.error}); the current rule decided` };
174
+ if (parsed.judgement.verdict === "unsure")
175
+ return { duplicate: rule(), by: "rule", judgement: parsed.judgement, note: "the model judge was unsure; the current rule decided" };
176
+ return { duplicate: parsed.judgement.verdict === "same", by: "model", judgement: parsed.judgement };
177
+ }
178
+ /**
179
+ * Accuracy, Brier and ECE of a decider over labelled pairs. `judgements[i]`
180
+ * answers `labels[i]`; null is a judge that gave no usable answer. The
181
+ * probability is scored against the key's label directly (a proper score for
182
+ * a yes/no question), not a confidence in the verdict: the two agree whenever
183
+ * the verdict follows the probability, which parseJudgement enforces.
184
+ */
185
+ export function scorePairs(labels, judgements, bucketCount = 5) {
186
+ if (labels.length !== judgements.length)
187
+ throw new Error(`${labels.length} labels but ${judgements.length} judgements.`);
188
+ const out = { judged: 0, correct: 0, unsure: 0, failed: 0, sameInKey: 0, accuracy: null, brier: null, brierRef: null, buckets: [], ece: null };
189
+ const scored = [];
190
+ labels.forEach((same, i) => {
191
+ const j = judgements[i];
192
+ if (j === null)
193
+ return void (out.failed += 1);
194
+ if (j.verdict === "unsure")
195
+ return void (out.unsure += 1);
196
+ out.judged += 1;
197
+ if (same)
198
+ out.sameInKey += 1;
199
+ if ((j.verdict === "same") === same)
200
+ out.correct += 1;
201
+ scored.push({ p: j.pSame, y: same ? 1 : 0 });
202
+ });
203
+ if (out.judged < MIN_FOR_A_VERDICT)
204
+ return out;
205
+ const n = scored.length;
206
+ out.accuracy = out.correct / n;
207
+ out.brier = scored.reduce((s, x) => s + (x.p - x.y) ** 2, 0) / n;
208
+ const base = out.sameInKey / n;
209
+ out.brierRef = base * (1 - base);
210
+ // Equal-count buckets, cut only between different probabilities.
211
+ const sorted = [...scored].sort((x, y) => x.p - y.p);
212
+ const target = n / bucketCount;
213
+ let cur = [];
214
+ const flush = () => {
215
+ if (cur.length === 0)
216
+ return;
217
+ out.buckets.push({ n: cur.length, stated: cur.reduce((s, x) => s + x.p, 0) / cur.length, actual: cur.reduce((s, x) => s + x.y, 0) / cur.length });
218
+ cur = [];
219
+ };
220
+ sorted.forEach((x, i) => {
221
+ if (cur.length >= target && x.p !== sorted[i - 1].p)
222
+ flush();
223
+ cur.push(x);
224
+ });
225
+ flush();
226
+ out.ece = out.buckets.reduce((s, b) => s + (b.n / n) * Math.abs(b.stated - b.actual), 0);
227
+ return out;
228
+ }
229
+ /** One line per score, for the bench output and the results log. */
230
+ export function formatPairScore(name, s) {
231
+ const head = `${name}: ${s.judged} judged (${s.sameInKey} same in the key), ${s.unsure} unsure, ${s.failed} failed`;
232
+ if (s.accuracy === null)
233
+ return `${head}; fewer than ${MIN_FOR_A_VERDICT} judged, no figures`;
234
+ const skill = s.brierRef ? ` (skill ${(1 - s.brier / s.brierRef).toFixed(2)} vs the base rate)` : "";
235
+ const buckets = s.buckets.map((b) => `n=${b.n} stated ${b.stated.toFixed(2)} actual ${b.actual.toFixed(2)}`).join("; ");
236
+ return `${head}; accuracy ${(s.accuracy * 100).toFixed(1)}%, Brier ${s.brier.toFixed(3)}${skill}, ECE ${s.ece.toFixed(3)} [${buckets}]`;
237
+ }
@@ -274,6 +274,14 @@ function contrastFailures(records) {
274
274
  }
275
275
  return out;
276
276
  }
277
+ /** The commonest off-grid values, smallest first and without their counts: "7px, 13px". */
278
+ function gridValues(top) {
279
+ return top
280
+ .map(([v]) => v)
281
+ .sort((a, b) => a - b)
282
+ .map((v) => `${v}px`)
283
+ .join(", ");
284
+ }
277
285
  /** Below the WCAG 2.2 target-size minimum; inline links are exempt by that rule. */
278
286
  function tooSmall(r) {
279
287
  return r.tag !== "a" && (r.rect.h < 24 || r.rect.w < 24) && r.rect.h > 0;
@@ -559,8 +567,8 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
559
567
  if (r.textLen > 40 && r.tag !== "a" && r.color !== "unknown")
560
568
  bodyColorFreq.set(r.color, (bodyColorFreq.get(r.color) ?? 0) + 1);
561
569
  const dominantBody = [...bodyColorFreq.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
570
+ const indistinct = dominantBody ? records.filter((r) => r.tag === "a" && r.textLen > 0 && !r.underline && r.color === dominantBody) : [];
562
571
  if (dominantBody) {
563
- const indistinct = records.filter((r) => r.tag === "a" && r.textLen > 0 && !r.underline && r.color === dominantBody);
564
572
  if (indistinct.length > 0) {
565
573
  affordances.push(`→ ${indistinct.length} link(s) with no underline AND the same color as body text (e.g. ${label(indistinct[0])}) — invisible as links`);
566
574
  }
@@ -726,6 +734,12 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
726
734
  ...(page.scrollW > viewport.width + 8
727
735
  ? [{ rule: "horizontal-scroll", detail: `content ${page.scrollW}px wide in a ${viewport.width}px viewport` }]
728
736
  : []),
737
+ // The same thresholds as the SPACING and AFFORDANCES lines above, so the check and the audit agree on what they saw.
738
+ // Worded without counts or percentages: a spacing scale and a link style are app-wide conventions, so the same
739
+ // values on ten pages are one entry on ten routes, and the entry's fingerprint does not move when content does.
740
+ ...(pad.n > 10 && pad.pct > 20 ? [{ rule: "off-grid-spacing", detail: `paddings off a 4px grid: ${gridValues(pad.top)}` }] : []),
741
+ ...(mar.n > 10 && mar.pct > 20 ? [{ rule: "off-grid-spacing", detail: `vertical margins off a 4px grid: ${gridValues(mar.top)}` }] : []),
742
+ ...(indistinct.length > 0 ? [{ rule: "indistinct-link", detail: `links with no underline in the body-text colour ${dominantBody}` }] : []),
729
743
  ...chromeDefects,
730
744
  ];
731
745
  return { report, score, signatures, defects };
@@ -103,3 +103,60 @@ export function fingerprintState(url, elements) {
103
103
  export function shortHash(input) {
104
104
  return createHash("sha1").update(input).digest("hex").slice(0, 10);
105
105
  }
106
+ /**
107
+ * A route as a lane wrote it, with every query string and every fragment that
108
+ * is not a hash route removed. A lane copies routes out of the address bar, and
109
+ * an address can carry `?access_token=`, `?code=`, `?session_id=` or a signed
110
+ * URL's `X-Amz-Signature=`; none of it identifies the page, and none of it may
111
+ * be stored or archived. A hash route (`#/things`) keeps its path, not its query.
112
+ * A `name=value` pair whose name reads as a credential is removed wherever it is.
113
+ */
114
+ export function stripRouteQuery(route) {
115
+ return route
116
+ .replace(/\?[^\s()[\]]*/g, "")
117
+ .replace(/#(?!\/)[^\s()[\]]*/g, "")
118
+ .replace(/[\w-]*(token|signature|secret|password|session|code|key|auth|credential)[\w-]*=[^\s()[\]]*/gi, "")
119
+ .trim();
120
+ }
121
+ /** Page files a lane may name without a leading slash ("orders.html"). */
122
+ const BARE_PAGE_RE = /^[\w-]+(\/[\w.-]+)*\.(html?|php|aspx?|jsp)$/i;
123
+ /** A host with a port, or a loopback or IPv4 host, written without a scheme ("127.0.0.1:4173/orders.html"). */
124
+ const BARE_HOST_RE = /^(?:[\w.-]+:\d+|localhost|\d{1,3}(?:\.\d{1,3}){3})(\/.*)?$/i;
125
+ /**
126
+ * Every path one route from a lane report names, in the form a benchmark key
127
+ * entry's `route` is written in; empty when it names none.
128
+ *
129
+ * A lane writes its routes as free text — "/order.html?id=1042 (from
130
+ * /orders.html link)", "Orders (/orders.html)", "orders.html",
131
+ * "127.0.0.1:4173/orders.html", "/reports.html and /reports-scheduled.html".
132
+ * EVERY path in it counts, including one in a note: the benchmark uses these
133
+ * to set aside a verdict as another lane's, so a page left out makes a wrong
134
+ * verdict disappear, while a page taken in only keeps a verdict scored. A bare
135
+ * origin is "/", a bare page file gains its slash, the query goes, ids
136
+ * collapse as the engine's route identity collapses them, and "/index.html" is
137
+ * "/".
138
+ */
139
+ export function laneRoutePaths(raw) {
140
+ const out = new Set();
141
+ for (const word of raw.split(/[\s,;|+()[\]]+|→|->|=>/)) {
142
+ const token = word.replace(/^[<"'`]+|[>"'`.:!]+$/g, "");
143
+ if (!token)
144
+ continue;
145
+ let path;
146
+ const url = /^https?:\/\/[^/\s]+(\/.*)?$/i.exec(token) ?? BARE_HOST_RE.exec(token);
147
+ if (url)
148
+ path = url[1] || "/";
149
+ else if (token.startsWith("/") || token.startsWith("#/"))
150
+ path = token;
151
+ else if (BARE_PAGE_RE.test(token.split(/[?#]/)[0]))
152
+ path = `/${token}`;
153
+ if (path === undefined)
154
+ continue;
155
+ let p = normalizePath(path).split("?")[0];
156
+ p = p.replace(/\/index\.html?$/i, "/");
157
+ if (p.length > 1)
158
+ p = p.replace(/\/+$/, "");
159
+ out.add(p || "/");
160
+ }
161
+ return [...out];
162
+ }
@@ -33,6 +33,8 @@ export const LANE_EVIDENCE_MAX = 160;
33
33
  export const LANE_OBSERVATION_MAX = 64;
34
34
  /** One line saying what blocked the lane; the detail belongs in a finding. */
35
35
  export const LANE_BLOCKED_BY_MAX = 200;
36
+ /** One line naming the convention that would decide a "worth_a_look": "a 4px spacing scale", not an essay. */
37
+ export const LANE_CONVENTION_MAX = 160;
36
38
  /** The planner chooses the lane name, so this cap is on the planner's side; it is stated all the same. */
37
39
  export const LANE_NAME_MAX = 40;
38
40
  /** A route as the lane saw it, query string included. */
@@ -40,7 +42,16 @@ export const LANE_ROUTE_MAX = 200;
40
42
  export const LANE_SEVERITIES = ["high", "medium", "low"];
41
43
  /** The same set scout_finding accepts, so a lane can report every finding it filed. */
42
44
  export const LANE_CATEGORIES = FINDING_CATEGORIES;
43
- export const LANE_VERDICTS = ["defect", "not_a_defect", "unsure"];
45
+ /**
46
+ * "worth_a_look" is the third answer beside defect and not a defect: the
47
+ * observation is real, and whether it is a defect depends on a convention of
48
+ * the project that the run cannot see (a spacing scale, how navigation links
49
+ * are styled, whether controls carry test ids, whether the app ships to touch
50
+ * screens). SceneScout is used against any app, so it does not decide those;
51
+ * it names the convention and leaves the call to the project. It is not "I
52
+ * could not tell", which stays "unsure".
53
+ */
54
+ export const LANE_VERDICTS = ["defect", "not_a_defect", "unsure", "worth_a_look"];
44
55
  export const LANE_STATUSES = ["complete", "partial", "blocked"];
45
56
  const Confidence = z.number().min(0).max(1);
46
57
  /**
@@ -58,12 +69,33 @@ export const LaneDecision = z
58
69
  confidence: Confidence,
59
70
  /** The same machine signature scout_finding takes as its cross-run dedup key. */
60
71
  evidence: z.string().min(1).max(LANE_EVIDENCE_MAX).nullable(),
72
+ /**
73
+ * The project convention that would make a "worth_a_look" a defect.
74
+ * Optional so a reply written before the verdict existed still parses;
75
+ * required (non-null) on a worth_a_look. On any other verdict the parser
76
+ * drops it and the fold says so: refusing a whole report over a field
77
+ * nothing reads would cost a round trip for nothing.
78
+ */
79
+ // Any string here: its length is checked only on a worth_a_look (below), because on every other verdict it is ignored.
80
+ convention: z.string().nullable().optional(),
61
81
  })
62
82
  .strict()
63
83
  .superRefine((d, ctx) => {
64
84
  if (d.verdict === "defect" && (d.severity === null || d.category === null)) {
65
85
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a defect needs a severity and a category" });
66
86
  }
87
+ if (d.verdict === "worth_a_look") {
88
+ if (d.convention === undefined || d.convention === null || d.convention.trim() === "") {
89
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a worth_a_look must name the convention that would decide it", path: ["convention"] });
90
+ }
91
+ else if (d.convention.length > LANE_CONVENTION_MAX) {
92
+ ctx.addIssue({
93
+ code: z.ZodIssueCode.custom,
94
+ message: `a convention is at most ${LANE_CONVENTION_MAX} characters`,
95
+ path: ["convention"],
96
+ });
97
+ }
98
+ }
67
99
  });
68
100
  export const LaneReport = z
69
101
  .object({
@@ -150,7 +182,26 @@ export function parseLaneReport(text, expectedLane) {
150
182
  if (expectedLane !== undefined && result.data.lane !== expectedLane) {
151
183
  return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
152
184
  }
153
- return { ok: true, report: result.data, aroundIgnored, entitiesDecoded: decoded.count };
185
+ // A convention means something only on a worth_a_look; elsewhere it is dropped, so nothing stored carries it, and named in the fold.
186
+ const conventionsIgnored = [];
187
+ const decisions = result.data.decisions.map((d) => {
188
+ if (d.verdict === "worth_a_look" || d.convention === undefined || d.convention === null)
189
+ return d;
190
+ conventionsIgnored.push(d.verdict);
191
+ const { convention: _dropped, ...rest } = d;
192
+ return rest;
193
+ });
194
+ return { ok: true, report: { ...result.data, decisions }, aroundIgnored, entitiesDecoded: decoded.count, conventionsIgnored };
195
+ }
196
+ /** The line the fold adds for each verdict whose convention was dropped: "convention ignored on a defect verdict"; empty when none was. */
197
+ export function ignoredConventionsNote(verdicts) {
198
+ if (verdicts.length === 0)
199
+ return "";
200
+ const counts = new Map();
201
+ for (const v of verdicts)
202
+ counts.set(v, (counts.get(v) ?? 0) + 1);
203
+ const parts = [...counts].map(([v, n]) => `convention ignored on ${n === 1 ? `${/^[aeiou]/.test(v) ? "an" : "a"} ${v} verdict` : `${n} ${v} verdicts`}`);
204
+ return `\n(${parts.join("; ")}: only a worth_a_look names a convention.)`;
154
205
  }
155
206
  /**
156
207
  * The HTML character references a relay adds when it escapes text: the five
@@ -246,8 +297,9 @@ export function laneReportInstruction(lane) {
246
297
  const LANE_RUBRIC = [
247
298
  "Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine; anything outside it is discarded unread, so put nothing there you want kept.",
248
299
  `Shape: {"lane":<your lane name>,"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
249
- `Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>}.`,
300
+ `Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>,"convention":<string or null>}.`,
250
301
  `A "defect" must carry a severity and a category. "evidence" is a signature, not a sentence: at most ${LANE_EVIDENCE_MAX} characters. "confidence" is how sure you are of the verdict, calibrated: 0.5 means a coin flip, 0.95 means you would bet on it.`,
302
+ `"worth_a_look" is for an observation that is real but is a defect only under a convention of the project you cannot see (a spacing scale, how navigation links are styled, whether controls carry test ids, whether the app targets touch screens): it must name that convention in "convention", one line of at most ${LANE_CONVENTION_MAX} characters, such as "a 4px spacing scale". On every other verdict leave "convention" null or out: it is ignored there. It is not for "I could not tell": that is "unsure".`,
251
303
  `"routes" lists the routes you covered, each at most ${LANE_ROUTE_MAX} characters. "blocked_by" is one line of at most ${LANE_BLOCKED_BY_MAX} characters saying what stopped you: required when the status is "blocked", allowed with "partial", null with "complete"; the detail belongs in a finding. The lane name is at most ${LANE_NAME_MAX} characters. At most ${LANE_MAX_ITEMS} decisions and ${LANE_MAX_ITEMS} routes. Unknown keys are refused.`,
252
304
  "The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
253
305
  ];
@@ -259,12 +311,14 @@ export function summarizeLaneReport(r) {
259
311
  const defects = r.decisions.filter((d) => d.verdict === "defect");
260
312
  const high = defects.filter((d) => d.severity === "high").length;
261
313
  const unsure = r.decisions.filter((d) => d.verdict === "unsure").length;
314
+ const worthALook = r.decisions.filter((d) => d.verdict === "worth_a_look").length;
262
315
  const mean = r.decisions.length ? r.decisions.reduce((s, d) => s + d.confidence, 0) / r.decisions.length : 0;
263
316
  const parts = [
264
317
  r.status,
265
318
  `${r.decisions.length} judged`,
266
319
  `${defects.length} defects (${high} high)`,
267
320
  `${unsure} unsure`,
321
+ ...(worthALook > 0 ? [`${worthALook} worth a look`] : []),
268
322
  `mean confidence ${mean.toFixed(2)}`,
269
323
  `${r.routes.length} routes`,
270
324
  ];
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { normalizePath, shortHash } from "./fingerprint.js";
3
+ import { laneRoutePaths, normalizePath, shortHash, stripRouteQuery } from "./fingerprint.js";
4
4
  import { isFormBookkeeping } from "./forms.js";
5
5
  /**
6
6
  * The kinds a finding can be. One list: scout_finding's input schema, the lane
@@ -26,6 +26,32 @@ export const FINDING_CATEGORIES = [
26
26
  "missing-testid",
27
27
  "other",
28
28
  ];
29
+ /**
30
+ * Whether a finding is in the "worth a look" tier. Anything else a file holds
31
+ * reads as a defect: a finding is never taken out of the defect count on a
32
+ * value this version does not know.
33
+ */
34
+ export function isWorthALook(f) {
35
+ return f.tier === "worth_a_look";
36
+ }
37
+ /**
38
+ * The tier a finding keeps when it is found again. A defect wins: a finding
39
+ * someone filed as a defect is a decision about the project's convention, and
40
+ * a later "worth a look" for the same thing does not undo it. A worth-a-look
41
+ * filed again as a defect is promoted, and loses its convention. Two
42
+ * worth-a-looks keep the first convention unless it had none.
43
+ */
44
+ /** Drops a finding's tier and convention in place, so a merged tier can be assigned onto it. */
45
+ function withoutTier(f) {
46
+ delete f.tier;
47
+ delete f.convention;
48
+ return f;
49
+ }
50
+ export function mergeTier(existing, incoming) {
51
+ if (!isWorthALook(existing) || !isWorthALook(incoming))
52
+ return {};
53
+ return { tier: "worth_a_look", convention: existing.convention || incoming.convention };
54
+ }
29
55
  /**
30
56
  * Most states a route may keep. A route accumulates one state per distinct
31
57
  * element set, so a register with filters, tabs and paging produces dozens;
@@ -213,12 +239,19 @@ export function mergeMemory(mine, theirs) {
213
239
  // the other side did not go on to re-find it as a regression.
214
240
  const newer = f.foundAt >= other.foundAt ? f : other;
215
241
  const older = newer === f ? other : f;
216
- byId.set(f.id, {
242
+ const merged = {
217
243
  ...newer,
218
244
  runs: Math.max(f.runs, other.runs),
219
245
  evidence: newer.evidence ?? older.evidence,
220
246
  regressedAt: newer.regressedAt ?? older.regressedAt,
221
- });
247
+ };
248
+ // The tier is not "later knowledge wins": a defect on either side is a decision
249
+ // about the convention, and a store still holding the worth-a-look must not undo it.
250
+ const tier = mergeTier(older, newer);
251
+ Object.assign(withoutTier(merged), tier);
252
+ if (!isWorthALook(merged) && isWorthALook(newer))
253
+ merged.severity = older.severity;
254
+ byId.set(f.id, merged);
222
255
  }
223
256
  out.findings = [...byId.values()];
224
257
  const unionRecord = (a, b) => (a || b ? { ...(b ?? {}), ...(a ?? {}) } : undefined);
@@ -264,8 +297,34 @@ export function mergeMemory(mine, theirs) {
264
297
  out.laneDecisions = [...byDecision.values()].sort((a, b) => a.at.localeCompare(b.at)).slice(-MAX_LANE_DECISIONS);
265
298
  if (out.laneDecisions.length === 0)
266
299
  delete out.laneDecisions;
300
+ // The same reason as decisions: each lane folds in its own process. A set
301
+ // union per lane, so merging the same document twice changes nothing.
302
+ out.laneRoutes = { ...(theirs.laneRoutes ?? {}) };
303
+ for (const [lane, routes] of Object.entries(mine.laneRoutes ?? {})) {
304
+ out.laneRoutes[lane] = unionRoutes(out.laneRoutes[lane] ?? [], routes);
305
+ }
306
+ if (Object.keys(out.laneRoutes).length === 0)
307
+ delete out.laneRoutes;
267
308
  return out;
268
309
  }
310
+ /** Most routes kept per lane. A lane report carries at most a few hundred; this keeps a long-lived project's file small. */
311
+ export const MAX_LANE_ROUTES = 255;
312
+ /**
313
+ * `b` added to `a`. Two routes naming the same page(s) are one — the later
314
+ * wording wins — and past the cap the NEWEST are kept: a long-lived project
315
+ * reuses lane names, and what a lane covers now is what a new verdict is
316
+ * judged against.
317
+ */
318
+ function unionRoutes(a, b) {
319
+ const byPage = new Map();
320
+ for (const r of [...a, ...b]) {
321
+ const paths = laneRoutePaths(r);
322
+ const id = paths.length ? paths.sort().join(" ") : `text:${r}`;
323
+ byPage.delete(id);
324
+ byPage.set(id, r);
325
+ }
326
+ return [...byPage.values()].slice(-MAX_LANE_ROUTES);
327
+ }
269
328
  /**
270
329
  * What makes two stored decisions the same judgement.
271
330
  *
@@ -572,6 +631,22 @@ function sameFinding(a, b) {
572
631
  return false;
573
632
  return findingJaccard(findingTokens(a.title), findingTokens(b.title)) >= 0.5;
574
633
  }
634
+ /** A finding's stored id: its category, title and route, so the same finding filed twice hashes the same. */
635
+ export function findingId(f) {
636
+ return shortHash(`${f.category}|${f.title.toLowerCase().trim()}|${f.state.split("#")[0]}`);
637
+ }
638
+ /**
639
+ * The dedup rule `addFinding` applies: whether `incoming` is the finding
640
+ * `existing` already records. The same id; or, on the same route, the same
641
+ * finding by evidence, a quoted literal or title overlap (sameFinding); or,
642
+ * from any route, the same endpoint failing the same way (sameEndpointBug).
643
+ * Exported so the benchmark scores the rule the store runs, not a copy of it.
644
+ */
645
+ export function isDuplicateFinding(existing, incoming) {
646
+ return (existing.id === findingId(incoming) ||
647
+ (existing.state.split("#")[0] === incoming.state.split("#")[0] && sameFinding(existing, incoming)) ||
648
+ sameEndpointBug(existing, incoming));
649
+ }
575
650
  /** Per-project directory for memory, session logs, and reports. */
576
651
  export const MEMORY_DIRNAME = ".scenescout";
577
652
  /** The one directory under it that is committed rather than ignored: saved flows (flow.ts). */
@@ -880,6 +955,11 @@ export class MemoryStore {
880
955
  dupOf.evidence = f.evidence;
881
956
  if (f.status === "resolved")
882
957
  dupOf.status = "resolved";
958
+ const promoted = isWorthALook(dupOf) && !isWorthALook(f);
959
+ const tier = mergeTier(dupOf, f);
960
+ Object.assign(withoutTier(dupOf), tier);
961
+ if (promoted)
962
+ dupOf.severity = f.severity;
883
963
  merged += 1;
884
964
  }
885
965
  else {
@@ -1120,6 +1200,27 @@ export class MemoryStore {
1120
1200
  // every call after the first reported nothing kept — while storing fine.
1121
1201
  return Math.min(added, MAX_LANE_DECISIONS);
1122
1202
  }
1203
+ /** The routes each lane's report said it covered. Empty on a project that has never run one. */
1204
+ get laneRoutes() {
1205
+ return this.data.laneRoutes ?? {};
1206
+ }
1207
+ /**
1208
+ * Record the routes a lane's accepted report listed, added to any it listed
1209
+ * before. Redacted like every other string a lane writes, with query
1210
+ * strings and fragments removed. Returns how many were new.
1211
+ */
1212
+ addLaneRoutes(lane, routes) {
1213
+ const before = this.data.laneRoutes?.[lane] ?? [];
1214
+ // No query string or fragment is stored: a route is copied from the
1215
+ // address bar, and an address can carry a token.
1216
+ const after = unionRoutes(before, routes.map((r) => redactSecrets(stripRouteQuery(r))).filter((r) => r.length > 0));
1217
+ const added = after.filter((r) => !before.includes(r)).length;
1218
+ if (after.length === before.length && after.every((r, i) => r === before[i]))
1219
+ return 0;
1220
+ this.data.laneRoutes = { ...(this.data.laneRoutes ?? {}), [lane]: after };
1221
+ this.flush();
1222
+ return added;
1223
+ }
1123
1224
  /** Mark a finding resolved; returns it or null. */
1124
1225
  resolveFinding(id) {
1125
1226
  const f = this.data.findings.find((x) => x.id === id);
@@ -1389,6 +1490,7 @@ export class MemoryStore {
1389
1490
  * across sessions rarely reuses the exact words — Jaccard catches it).
1390
1491
  * Returns [finding, isNew].
1391
1492
  */
1493
+ /** Returns the finding, whether it is new, and whether an existing worth-a-look was just promoted to a defect by it. */
1392
1494
  addFinding(input) {
1393
1495
  // Redact BEFORE the id is derived, so a re-found finding whose quoted
1394
1496
  // secret differs by a character still hashes to the same id.
@@ -1401,14 +1503,19 @@ export class MemoryStore {
1401
1503
  url: redactSecrets(input.url),
1402
1504
  evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
1403
1505
  };
1404
- const route = f.state.split("#")[0];
1405
- const id = shortHash(`${f.category}|${f.title.toLowerCase().trim()}|${route}`);
1406
- const existing = this.data.findings.find((x) => x.id === id || (x.state.split("#")[0] === route && sameFinding(x, f)) || sameEndpointBug(x, f));
1506
+ const id = findingId(f);
1507
+ const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1407
1508
  if (existing) {
1408
1509
  existing.runs += 1;
1409
1510
  existing.foundAt = new Date().toISOString();
1410
1511
  if (!existing.evidence && f.evidence)
1411
1512
  existing.evidence = f.evidence;
1513
+ // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
1514
+ const promoted = isWorthALook(existing) && !isWorthALook(f);
1515
+ const tier = mergeTier(existing, f);
1516
+ Object.assign(withoutTier(existing), tier);
1517
+ if (promoted)
1518
+ existing.severity = f.severity;
1412
1519
  // Re-finding a RESOLVED finding is a regression — reopen it loudly
1413
1520
  // rather than letting it hide in the report's completed section. But a
1414
1521
  // FUZZY match must never resurrect a fixed bug: telling someone a
@@ -1425,7 +1532,7 @@ export class MemoryStore {
1425
1532
  existing.regressedAt = existing.foundAt;
1426
1533
  }
1427
1534
  this.flush();
1428
- return [existing, false];
1535
+ return [existing, false, promoted];
1429
1536
  }
1430
1537
  // Repro trace scoped to the finding's route: everything since the action
1431
1538
  // that landed there, not 12 lines of unrelated cross-module noise.
@@ -1462,7 +1569,7 @@ export class MemoryStore {
1462
1569
  };
1463
1570
  this.data.findings.push(finding);
1464
1571
  this.flush();
1465
- return [finding, true];
1572
+ return [finding, true, false];
1466
1573
  }
1467
1574
  get findings() {
1468
1575
  return this.data.findings;