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.
- package/CHANGELOG.md +31 -0
- package/README.md +29 -3
- package/dist/check-run.js +4 -2
- package/dist/ci-run.js +366 -0
- package/dist/cli.js +69 -0
- package/dist/engine/bench.js +147 -14
- package/dist/engine/browser.js +63 -34
- package/dist/engine/calibration.js +23 -6
- package/dist/engine/check.js +89 -15
- package/dist/engine/ci.js +633 -0
- package/dist/engine/dedup.js +237 -0
- package/dist/engine/design.js +15 -1
- package/dist/engine/fingerprint.js +57 -0
- package/dist/engine/lane.js +57 -3
- package/dist/engine/memory.js +115 -8
- package/dist/engine/policy.js +42 -0
- package/dist/engine/provider.js +193 -0
- package/dist/engine/report.js +37 -2
- package/dist/engine/verify.js +4 -3
- package/dist/mcp-server.js +23 -9
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +4 -3
|
@@ -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
|
+
}
|
package/dist/engine/design.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/engine/lane.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
];
|
package/dist/engine/memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1405
|
-
const
|
|
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;
|