scenescout 3.11.1 → 3.12.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 +14 -0
- package/README.md +5 -1
- package/dist/check-run.js +4 -2
- package/dist/engine/bench.js +147 -14
- package/dist/engine/calibration.js +23 -6
- package/dist/engine/check.js +86 -14
- 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 +97 -5
- package/dist/engine/report.js +37 -2
- package/dist/engine/verify.js +4 -3
- package/dist/mcp-server.js +23 -9
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +4 -3
package/dist/engine/check.js
CHANGED
|
@@ -81,7 +81,31 @@ export const CHECK_RULES = {
|
|
|
81
81
|
help: "A step of a flow saved in .scenescout/flows could not be done, or what it expected was not there. The evidence names the flow, the step and what happened instead.",
|
|
82
82
|
},
|
|
83
83
|
};
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Rules whose measurement is exact and whose meaning depends on a convention
|
|
86
|
+
* of the project the check cannot see. SceneScout is used against any app, so
|
|
87
|
+
* it does not decide those conventions: these are listed as "worth a look",
|
|
88
|
+
* each naming the convention that would make it a defect, and never counted,
|
|
89
|
+
* given a severity or gated on, at any --fail-on. SARIF reports them at level
|
|
90
|
+
* "note". `convention` finishes the sentence "a defect only if your project
|
|
91
|
+
* uses …". --ignore takes them like any other rule.
|
|
92
|
+
*/
|
|
93
|
+
export const WORTH_A_LOOK_RULES = {
|
|
94
|
+
"off-grid-spacing": {
|
|
95
|
+
title: "Spacing off a 4px grid",
|
|
96
|
+
help: "More than a fifth of the page's paddings or vertical margins are not multiples of 4px. That matters where a project keeps a 4px spacing scale, and not where it uses another scale or none.",
|
|
97
|
+
convention: "a 4px spacing scale",
|
|
98
|
+
},
|
|
99
|
+
"indistinct-link": {
|
|
100
|
+
title: "Link styled like body text",
|
|
101
|
+
help: "Links with no underline, in the same colour as the page's body text. In running text a reader cannot tell them from the text around them; in navigation this styling is common, and the check cannot tell the two apart.",
|
|
102
|
+
convention: "a visible link style (an underline or a distinct colour) wherever links appear, navigation included",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
export const CHECK_RULE_IDS = [...Object.keys(CHECK_RULES), ...Object.keys(WORTH_A_LOOK_RULES)];
|
|
106
|
+
export function isWorthALookRule(rule) {
|
|
107
|
+
return Object.prototype.hasOwnProperty.call(WORTH_A_LOOK_RULES, rule);
|
|
108
|
+
}
|
|
85
109
|
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
86
110
|
/** Snapshot refs (`e12`) are numbered per run; evidence carrying them would never match itself twice. */
|
|
87
111
|
function stripRefs(line) {
|
|
@@ -145,27 +169,36 @@ export function geometryRule(line) {
|
|
|
145
169
|
* oracles caught while it ran. The second kind goes through the same rules as
|
|
146
170
|
* a crawled page's, so a request that fails on load and again inside a flow is
|
|
147
171
|
* one issue, not two.
|
|
172
|
+
*
|
|
173
|
+
* A fact under a worth-a-look rule goes to `worthALook` instead, deduplicated
|
|
174
|
+
* the same way; the two lists never share an entry.
|
|
148
175
|
*/
|
|
149
|
-
export function
|
|
176
|
+
export function checkFindings(routes, origin, ignore = [], flows = []) {
|
|
150
177
|
const byKey = new Map();
|
|
178
|
+
const looks = new Map();
|
|
151
179
|
const add = (rule, evidence, route, opts = {}) => {
|
|
152
180
|
if (ignore.includes(rule))
|
|
153
181
|
return;
|
|
154
182
|
const clean = redactSecrets(withoutOrigin(evidence, origin)).slice(0, 300);
|
|
155
183
|
const key = `${rule}\u0000${clean}`;
|
|
156
|
-
const found = byKey.get(key);
|
|
184
|
+
const found = byKey.get(key) ?? looks.get(key);
|
|
157
185
|
if (found) {
|
|
158
186
|
if (!found.routes.includes(route))
|
|
159
187
|
found.routes.push(route);
|
|
160
188
|
return;
|
|
161
189
|
}
|
|
190
|
+
const fingerprint = createHash("sha256").update(key).digest("hex").slice(0, 32);
|
|
191
|
+
if (isWorthALookRule(rule)) {
|
|
192
|
+
looks.set(key, { rule, evidence: clean, routes: [route], convention: WORTH_A_LOOK_RULES[rule].convention, fingerprint });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
162
195
|
byKey.set(key, {
|
|
163
196
|
rule,
|
|
164
197
|
severity: opts.embed ? "medium" : (opts.severity ?? CHECK_RULES[rule].severity),
|
|
165
198
|
evidence: clean,
|
|
166
199
|
routes: [route],
|
|
167
200
|
...(opts.embed ? { embed: opts.embed } : {}),
|
|
168
|
-
fingerprint
|
|
201
|
+
fingerprint,
|
|
169
202
|
});
|
|
170
203
|
};
|
|
171
204
|
for (const r of routes) {
|
|
@@ -206,7 +239,14 @@ export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
|
|
|
206
239
|
if (f.outcome.status === "failed")
|
|
207
240
|
add("flow-step-failed", flowStepEvidence(f), f.outcome.path);
|
|
208
241
|
}
|
|
209
|
-
return
|
|
242
|
+
return {
|
|
243
|
+
issues: [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule)),
|
|
244
|
+
worthALook: [...looks.values()].sort((a, b) => a.rule.localeCompare(b.rule)),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** The defect tier of `checkFindings`: what counts, and what the gate reads. */
|
|
248
|
+
export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
|
|
249
|
+
return checkFindings(routes, origin, ignore, flows).issues;
|
|
210
250
|
}
|
|
211
251
|
/**
|
|
212
252
|
* Why a check has nothing to give a verdict on, or null when it measured the
|
|
@@ -383,7 +423,7 @@ export function parseCheckArgs(args, cwd) {
|
|
|
383
423
|
if (paths?.some((p) => !p.startsWith("/")))
|
|
384
424
|
return { ok: false, error: "--paths are paths on the app, each starting with /" };
|
|
385
425
|
const ignore = list(flags.get("ignore")) ?? [];
|
|
386
|
-
const unknownRules = ignore.filter((r) => !(r
|
|
426
|
+
const unknownRules = ignore.filter((r) => !CHECK_RULE_IDS.includes(r));
|
|
387
427
|
if (unknownRules.length > 0)
|
|
388
428
|
return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
|
|
389
429
|
const retest = flags.get("retest") ?? "on";
|
|
@@ -489,8 +529,17 @@ const RETEST_SARIF_RULE = {
|
|
|
489
529
|
help: { text: "A finding an earlier run filed and left open failed the same way when its page loaded again. It gates according to --gate-retests." },
|
|
490
530
|
defaultConfiguration: { level: "error" },
|
|
491
531
|
};
|
|
532
|
+
function sarifLocation(route) {
|
|
533
|
+
return route === "(shared chrome)"
|
|
534
|
+
? {
|
|
535
|
+
physicalLocation: { artifactLocation: { uri: "", uriBaseId: "APP" } },
|
|
536
|
+
message: { text: "the app's shared shell, on every page that renders it" },
|
|
537
|
+
}
|
|
538
|
+
: { physicalLocation: { artifactLocation: { uri: route.replace(/^\//, ""), uriBaseId: "APP" } } };
|
|
539
|
+
}
|
|
492
540
|
export function toSarif(result, toolVersion) {
|
|
493
541
|
const used = [...new Set(result.issues.map((i) => i.rule))];
|
|
542
|
+
const lookRules = [...new Set(result.worthALook.map((o) => o.rule))];
|
|
494
543
|
const base = new URL(result.url);
|
|
495
544
|
const reproducing = retestGateFailures(result);
|
|
496
545
|
const refused = result.flows.filter((f) => f.outcome.status === "refused");
|
|
@@ -500,14 +549,20 @@ export function toSarif(result, toolVersion) {
|
|
|
500
549
|
message: {
|
|
501
550
|
text: `${CHECK_RULES[i.rule].title}: ${i.evidence}${i.routes.length > 1 ? ` (on ${i.routes.length} routes)` : ""}${i.embed ? ` (in an embed of ${i.embed})` : ""}`,
|
|
502
551
|
},
|
|
503
|
-
locations: i.routes.slice(0, 10).map(
|
|
504
|
-
? {
|
|
505
|
-
physicalLocation: { artifactLocation: { uri: "", uriBaseId: "APP" } },
|
|
506
|
-
message: { text: "the app's shared shell, on every page that renders it" },
|
|
507
|
-
}
|
|
508
|
-
: { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
|
|
552
|
+
locations: i.routes.slice(0, 10).map(sarifLocation),
|
|
509
553
|
partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
|
|
510
554
|
}));
|
|
555
|
+
// Always "note", whatever --fail-on says: a result a code-scanning dashboard shows, never one that reads as an error.
|
|
556
|
+
const lookResults = result.worthALook.map((o) => ({
|
|
557
|
+
ruleId: o.rule,
|
|
558
|
+
level: "note",
|
|
559
|
+
message: {
|
|
560
|
+
text: `Worth a look — ${WORTH_A_LOOK_RULES[o.rule].title}: ${o.evidence}${o.routes.length > 1 ? ` (on ${o.routes.length} routes)` : ""}. A defect only if your project uses ${o.convention}.`,
|
|
561
|
+
},
|
|
562
|
+
locations: o.routes.slice(0, 10).map(sarifLocation),
|
|
563
|
+
partialFingerprints: { "scenescoutCheck/v1": o.fingerprint },
|
|
564
|
+
properties: { tier: "worth-a-look", convention: o.convention },
|
|
565
|
+
}));
|
|
511
566
|
// Only the re-tests that fail the gate are results: a reviewer reading code scanning sees what failed it.
|
|
512
567
|
const retestResults = reproducing.map((r) => ({
|
|
513
568
|
ruleId: RETEST_SARIF_RULE.id,
|
|
@@ -537,6 +592,14 @@ export function toSarif(result, toolVersion) {
|
|
|
537
592
|
help: { text: CHECK_RULES[id].help },
|
|
538
593
|
defaultConfiguration: { level: SARIF_LEVEL[CHECK_RULES[id].severity] },
|
|
539
594
|
})),
|
|
595
|
+
...lookRules.map((id) => ({
|
|
596
|
+
id,
|
|
597
|
+
name: WORTH_A_LOOK_RULES[id].title,
|
|
598
|
+
shortDescription: { text: WORTH_A_LOOK_RULES[id].title },
|
|
599
|
+
help: { text: `${WORTH_A_LOOK_RULES[id].help} Worth a look: a defect only if your project uses ${WORTH_A_LOOK_RULES[id].convention}.` },
|
|
600
|
+
defaultConfiguration: { level: "note" },
|
|
601
|
+
properties: { tags: ["worth-a-look"] },
|
|
602
|
+
})),
|
|
540
603
|
...(reproducing.length > 0 ? [RETEST_SARIF_RULE] : []),
|
|
541
604
|
],
|
|
542
605
|
},
|
|
@@ -553,7 +616,7 @@ export function toSarif(result, toolVersion) {
|
|
|
553
616
|
},
|
|
554
617
|
],
|
|
555
618
|
originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
|
|
556
|
-
results: [...issueResults, ...retestResults],
|
|
619
|
+
results: [...issueResults, ...lookResults, ...retestResults],
|
|
557
620
|
},
|
|
558
621
|
],
|
|
559
622
|
};
|
|
@@ -596,7 +659,8 @@ export function formatCheck(result) {
|
|
|
596
659
|
(passed
|
|
597
660
|
? `${couldNotRun > 0 ? "passed" : "**PASSED**"} — ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
|
|
598
661
|
: `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
|
|
599
|
-
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : "")
|
|
662
|
+
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : "") +
|
|
663
|
+
(result.worthALook.length > 0 ? ` · ${result.worthALook.length} worth a look, never gated` : ""));
|
|
600
664
|
// Right under the verdict: what a green check was allowed to do is part of what it means.
|
|
601
665
|
lines.push("", `Settings — ${describeSettings(result)}`);
|
|
602
666
|
for (const sev of CHECK_SEVERITIES) {
|
|
@@ -608,6 +672,12 @@ export function formatCheck(result) {
|
|
|
608
672
|
lines.push(`- **${CHECK_RULES[i.rule].title}** \`${i.rule}\`: ${cell(i.evidence)}${i.embed ? ` _(in an embed of ${i.embed}: its behaviour, not the app's)_` : ""} — ${routeList(i.routes)}`);
|
|
609
673
|
}
|
|
610
674
|
}
|
|
675
|
+
if (result.worthALook.length > 0) {
|
|
676
|
+
lines.push("", `## Worth a look (${result.worthALook.length})`, "", "Measured exactly, and defects only under a convention of your project that the check cannot see. They are not counted above and never fail the gate, at any --fail-on.", "");
|
|
677
|
+
for (const o of result.worthALook) {
|
|
678
|
+
lines.push(`- **${WORTH_A_LOOK_RULES[o.rule].title}** \`${o.rule}\`: ${cell(o.evidence)} — a defect only if your project uses ${o.convention} — ${routeList(o.routes)}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
611
681
|
lines.push("", "## Routes", "", "| Route | Status | Controls | Issues |", "|---|---|---|---|");
|
|
612
682
|
for (const r of result.routes) {
|
|
613
683
|
const n = result.issues.filter((i) => i.routes.includes(r.path)).length;
|
|
@@ -701,5 +771,7 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
701
771
|
skippedFlows: result.skippedFlows,
|
|
702
772
|
retest: result.retest,
|
|
703
773
|
issues: result.issues,
|
|
774
|
+
// Apart from `issues` and `counts`, which the gate reads: none of these is counted or gated.
|
|
775
|
+
worthALook: result.worthALook,
|
|
704
776
|
};
|
|
705
777
|
}
|
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
|
*
|
|
@@ -880,6 +939,11 @@ export class MemoryStore {
|
|
|
880
939
|
dupOf.evidence = f.evidence;
|
|
881
940
|
if (f.status === "resolved")
|
|
882
941
|
dupOf.status = "resolved";
|
|
942
|
+
const promoted = isWorthALook(dupOf) && !isWorthALook(f);
|
|
943
|
+
const tier = mergeTier(dupOf, f);
|
|
944
|
+
Object.assign(withoutTier(dupOf), tier);
|
|
945
|
+
if (promoted)
|
|
946
|
+
dupOf.severity = f.severity;
|
|
883
947
|
merged += 1;
|
|
884
948
|
}
|
|
885
949
|
else {
|
|
@@ -1120,6 +1184,27 @@ export class MemoryStore {
|
|
|
1120
1184
|
// every call after the first reported nothing kept — while storing fine.
|
|
1121
1185
|
return Math.min(added, MAX_LANE_DECISIONS);
|
|
1122
1186
|
}
|
|
1187
|
+
/** The routes each lane's report said it covered. Empty on a project that has never run one. */
|
|
1188
|
+
get laneRoutes() {
|
|
1189
|
+
return this.data.laneRoutes ?? {};
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Record the routes a lane's accepted report listed, added to any it listed
|
|
1193
|
+
* before. Redacted like every other string a lane writes, with query
|
|
1194
|
+
* strings and fragments removed. Returns how many were new.
|
|
1195
|
+
*/
|
|
1196
|
+
addLaneRoutes(lane, routes) {
|
|
1197
|
+
const before = this.data.laneRoutes?.[lane] ?? [];
|
|
1198
|
+
// No query string or fragment is stored: a route is copied from the
|
|
1199
|
+
// address bar, and an address can carry a token.
|
|
1200
|
+
const after = unionRoutes(before, routes.map((r) => redactSecrets(stripRouteQuery(r))).filter((r) => r.length > 0));
|
|
1201
|
+
const added = after.filter((r) => !before.includes(r)).length;
|
|
1202
|
+
if (after.length === before.length && after.every((r, i) => r === before[i]))
|
|
1203
|
+
return 0;
|
|
1204
|
+
this.data.laneRoutes = { ...(this.data.laneRoutes ?? {}), [lane]: after };
|
|
1205
|
+
this.flush();
|
|
1206
|
+
return added;
|
|
1207
|
+
}
|
|
1123
1208
|
/** Mark a finding resolved; returns it or null. */
|
|
1124
1209
|
resolveFinding(id) {
|
|
1125
1210
|
const f = this.data.findings.find((x) => x.id === id);
|
|
@@ -1389,6 +1474,7 @@ export class MemoryStore {
|
|
|
1389
1474
|
* across sessions rarely reuses the exact words — Jaccard catches it).
|
|
1390
1475
|
* Returns [finding, isNew].
|
|
1391
1476
|
*/
|
|
1477
|
+
/** Returns the finding, whether it is new, and whether an existing worth-a-look was just promoted to a defect by it. */
|
|
1392
1478
|
addFinding(input) {
|
|
1393
1479
|
// Redact BEFORE the id is derived, so a re-found finding whose quoted
|
|
1394
1480
|
// secret differs by a character still hashes to the same id.
|
|
@@ -1409,6 +1495,12 @@ export class MemoryStore {
|
|
|
1409
1495
|
existing.foundAt = new Date().toISOString();
|
|
1410
1496
|
if (!existing.evidence && f.evidence)
|
|
1411
1497
|
existing.evidence = f.evidence;
|
|
1498
|
+
// A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
|
|
1499
|
+
const promoted = isWorthALook(existing) && !isWorthALook(f);
|
|
1500
|
+
const tier = mergeTier(existing, f);
|
|
1501
|
+
Object.assign(withoutTier(existing), tier);
|
|
1502
|
+
if (promoted)
|
|
1503
|
+
existing.severity = f.severity;
|
|
1412
1504
|
// Re-finding a RESOLVED finding is a regression — reopen it loudly
|
|
1413
1505
|
// rather than letting it hide in the report's completed section. But a
|
|
1414
1506
|
// FUZZY match must never resurrect a fixed bug: telling someone a
|
|
@@ -1425,7 +1517,7 @@ export class MemoryStore {
|
|
|
1425
1517
|
existing.regressedAt = existing.foundAt;
|
|
1426
1518
|
}
|
|
1427
1519
|
this.flush();
|
|
1428
|
-
return [existing, false];
|
|
1520
|
+
return [existing, false, promoted];
|
|
1429
1521
|
}
|
|
1430
1522
|
// Repro trace scoped to the finding's route: everything since the action
|
|
1431
1523
|
// that landed there, not 12 lines of unrelated cross-module noise.
|
|
@@ -1462,7 +1554,7 @@ export class MemoryStore {
|
|
|
1462
1554
|
};
|
|
1463
1555
|
this.data.findings.push(finding);
|
|
1464
1556
|
this.flush();
|
|
1465
|
-
return [finding, true];
|
|
1557
|
+
return [finding, true, false];
|
|
1466
1558
|
}
|
|
1467
1559
|
get findings() {
|
|
1468
1560
|
return this.data.findings;
|