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.
@@ -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
- export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
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 issuesFromRoutes(routes, origin, ignore = [], flows = []) {
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: createHash("sha256").update(key).digest("hex").slice(0, 32),
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 [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
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 in CHECK_RULES));
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((r) => r === "(shared chrome)"
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
  }
@@ -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
  *
@@ -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;