scenescout 3.10.0 → 3.11.1
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 +18 -0
- package/README.md +12 -3
- package/dist/browsers.js +64 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +426 -26
- package/dist/engine/calibration.js +99 -14
- package/dist/engine/check.js +251 -33
- package/dist/engine/fingerprint.js +12 -7
- package/dist/engine/flow.js +398 -0
- package/dist/engine/lane.js +81 -0
- package/dist/engine/memory.js +48 -6
- package/dist/engine/unload.js +201 -0
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +4 -4
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Pure, so every rule here is table-tested.
|
|
22
22
|
*/
|
|
23
|
+
import { requestsPair, statedRequests, templatedPathsMatch } from "./lane.js";
|
|
23
24
|
import { failingSignatures } from "./memory.js";
|
|
24
25
|
/**
|
|
25
26
|
* Upper edge of each confidence bucket. Five is enough to see a shape and few
|
|
@@ -226,45 +227,129 @@ export const MAX_UNFILED_NAMED = 10;
|
|
|
226
227
|
* nobody — and it is cheapest to catch at the moment the planner folds the
|
|
227
228
|
* report, while the lane's session is still open.
|
|
228
229
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
230
|
+
* The check exists to catch unfiled defects, so a false "filed" costs more
|
|
231
|
+
* than a false alarm, which only asks someone to look. A decision counts as
|
|
232
|
+
* filed by one of three rules, tried in order:
|
|
233
|
+
*
|
|
234
|
+
* 1. Failing signatures. When the decision's evidence names one or more
|
|
235
|
+
* failing requests (the store's `failingSignatures`), it is filed only when
|
|
236
|
+
* EVERY one of them is covered by a finding's signature: same method, same
|
|
237
|
+
* status, same route, where a path-template segment (`{id}`, `:id`, `*`) on
|
|
238
|
+
* either side stands for one id segment (see `templatedPathsMatch`). So a
|
|
239
|
+
* finding filed as `GET /api/things/{id} 500` covers `GET /api/things/7 500`,
|
|
240
|
+
* and a decision naming two failing requests is not filed by a finding for
|
|
241
|
+
* one of them.
|
|
242
|
+
* 2. A request that did not fail. When the decision names a request with a
|
|
243
|
+
* 2xx or 3xx status, or none, and a finding names the same request
|
|
244
|
+
* (template-aware, same status), the decision is filed only when its
|
|
245
|
+
* evidence, with its path written as the finding's, is the finding's
|
|
246
|
+
* evidence exactly, restates part of it, or is the finding's evidence
|
|
247
|
+
* followed only by that request's other status (`… 200 as role=viewer
|
|
248
|
+
* (expected 403)` against `… 200 as role=viewer`). Word overlap is not enough
|
|
249
|
+
* there: a 200 names a resource, not a defect, and two different defects
|
|
250
|
+
* on one endpoint read alike. A failing signature in the decision does not
|
|
251
|
+
* block this when it names the same request (`POST /api/things/7/archive
|
|
252
|
+
* 200 (expected 403)` reads as a 403 to the store); one naming any other
|
|
253
|
+
* request does, and sends the decision to rule 1's verdict.
|
|
254
|
+
* 3. Text. Evidence naming no failing request is matched on its text: the
|
|
255
|
+
* same evidence, a restatement of part of it, or shared identifiers and
|
|
256
|
+
* near-identical wording. The calibration join refuses a text match,
|
|
257
|
+
* because there a miss is scored against the lane.
|
|
233
258
|
*/
|
|
234
259
|
export function unfiledDefects(decisions, findings) {
|
|
235
|
-
const keys =
|
|
260
|
+
const keys = [];
|
|
236
261
|
const filed = [];
|
|
237
262
|
for (const f of findings) {
|
|
238
263
|
const text = `${f.evidence ?? ""} ${f.title}`;
|
|
264
|
+
const requests = statedRequests(f.evidence ?? "");
|
|
265
|
+
// The store's signatures, and the finding's own failing requests as
|
|
266
|
+
// written: the store reads no `*` in a path, so `GET /api/things/* 500`
|
|
267
|
+
// has no signature of its own.
|
|
239
268
|
if (f.evidence)
|
|
240
269
|
for (const key of joinKeys(f.evidence))
|
|
241
|
-
keys.
|
|
242
|
-
|
|
270
|
+
keys.push(...statedRequests(key));
|
|
271
|
+
keys.push(...requests.filter((q) => q.status !== null && /^[45]/.test(q.status)));
|
|
272
|
+
filed.push({
|
|
273
|
+
ids: identifiers(text),
|
|
274
|
+
words: words(text),
|
|
275
|
+
evidenceWords: words(f.evidence ?? ""),
|
|
276
|
+
evidence: squash(f.evidence ?? ""),
|
|
277
|
+
raw: f.evidence ?? "",
|
|
278
|
+
requests,
|
|
279
|
+
});
|
|
243
280
|
}
|
|
244
281
|
const out = [];
|
|
245
282
|
for (const d of decisions) {
|
|
246
283
|
if (d.verdict !== "defect")
|
|
247
284
|
continue;
|
|
248
285
|
if (d.evidence) {
|
|
249
|
-
const
|
|
250
|
-
|
|
286
|
+
const evidence = d.evidence;
|
|
287
|
+
const joined = [...joinKeys(evidence)];
|
|
288
|
+
const failing = joined.flatMap((sig) => statedRequests(sig));
|
|
289
|
+
if (failing.length > 0 && failing.every((sig) => keys.some((k) => requestsPair(sig, k))))
|
|
290
|
+
continue;
|
|
291
|
+
if (filed.some((f) => restatesRequest(evidence, failing, f)))
|
|
251
292
|
continue;
|
|
252
293
|
// A failing-endpoint signature is the store's own identity for a bug. When
|
|
253
|
-
// the decision has one and no finding
|
|
294
|
+
// the decision has one and no finding covers it, nothing else is a match.
|
|
254
295
|
if (joined.length > 0) {
|
|
255
296
|
out.push(`${d.observation} — ${d.evidence}`);
|
|
256
297
|
continue;
|
|
257
298
|
}
|
|
258
|
-
|
|
259
|
-
const w = words(d.evidence);
|
|
260
|
-
const text = squash(d.evidence);
|
|
261
|
-
if (filed.some((f) => (text !== "" && f.evidence === text) || restates(text, f) || covers(ids, w, f)))
|
|
299
|
+
if (filed.some((f) => matchesText(evidence, f)))
|
|
262
300
|
continue;
|
|
263
301
|
}
|
|
264
302
|
out.push(d.evidence ? `${d.observation} — ${d.evidence}` : d.observation);
|
|
265
303
|
}
|
|
266
304
|
return out;
|
|
267
305
|
}
|
|
306
|
+
/** Rule 3: the same evidence, a restatement of part of it, or shared identifiers and wording. */
|
|
307
|
+
function matchesText(reported, f) {
|
|
308
|
+
const text = squash(reported);
|
|
309
|
+
return (text !== "" && f.evidence === text) || restates(text, f) || covers(identifiers(reported), words(reported), f);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Rule 2 of unfiledDefects: the decision names a request that did not fail,
|
|
313
|
+
* the finding names the same request, and the decision's evidence — its path
|
|
314
|
+
* written as the finding's, so a template and the id it stands for are not a
|
|
315
|
+
* difference — is the finding's evidence exactly, restates part of it, or
|
|
316
|
+
* adds only the status the request should have returned.
|
|
317
|
+
* `failing` is the decision's failing signatures; each must name this same
|
|
318
|
+
* request, or the pair is refused.
|
|
319
|
+
*/
|
|
320
|
+
function restatesRequest(evidence, failing, f) {
|
|
321
|
+
for (const r of statedRequests(evidence)) {
|
|
322
|
+
if (r.status !== null && /^[45]/.test(r.status))
|
|
323
|
+
continue;
|
|
324
|
+
const paired = f.requests.find((q) => requestsPair(r, q));
|
|
325
|
+
if (!paired)
|
|
326
|
+
continue;
|
|
327
|
+
if (!failing.every((sig) => sig.method === r.method && templatedPathsMatch(sig.segments, r.segments)))
|
|
328
|
+
continue;
|
|
329
|
+
const aligned = squash(evidence.slice(0, r.start) + f.raw.slice(paired.start, paired.end) + evidence.slice(r.end));
|
|
330
|
+
if (aligned !== "" && (f.evidence === aligned || restates(aligned, f) || addsOnlyExpectedStatus(aligned, f.evidence, r.status)))
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The status a lane adds after the call it quotes, to say what the call
|
|
337
|
+
* should have returned: "(expected 403)", "(reject 403)", "reject 403". One
|
|
338
|
+
* status, an optional verb, optional brackets, and nothing else.
|
|
339
|
+
*/
|
|
340
|
+
const EXPECTED_STATUS_RE = /^[\s,;]*\(?\s*(?:(?:expected|expect|rejected|reject)\s*:?\s*)?([1-5]\d{2})\s*\)?[\s.,;]*$/;
|
|
341
|
+
/**
|
|
342
|
+
* Whether the reported evidence is the filed evidence followed by nothing but
|
|
343
|
+
* the status the request should have returned, which differs from the one it
|
|
344
|
+
* did. Anything else after it — another request, another role, another
|
|
345
|
+
* claim — is something the finding does not say, so it is not covered.
|
|
346
|
+
*/
|
|
347
|
+
function addsOnlyExpectedStatus(reported, filed, status) {
|
|
348
|
+
if (filed === "" || !reported.startsWith(filed))
|
|
349
|
+
return false;
|
|
350
|
+
const expected = EXPECTED_STATUS_RE.exec(reported.slice(filed.length))?.[1];
|
|
351
|
+
return expected !== undefined && expected !== status;
|
|
352
|
+
}
|
|
268
353
|
/**
|
|
269
354
|
* Shortest reported evidence that counts as restating a filing it is part of.
|
|
270
355
|
*/
|
package/dist/engine/check.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { BROWSER_ENGINES } from "../browsers.js";
|
|
15
|
+
import { flowStepEvidence } from "./flow.js";
|
|
15
16
|
import { redactSecrets } from "./memory.js";
|
|
16
17
|
import { httpErrorDetail, httpStatusOf } from "./oracles.js";
|
|
17
18
|
export const CHECK_SEVERITIES = ["high", "medium", "low"];
|
|
@@ -74,6 +75,11 @@ export const CHECK_RULES = {
|
|
|
74
75
|
"tiny-target": { severity: "low", title: "Small click target", help: "Below the 24px WCAG 2.2 target-size minimum." },
|
|
75
76
|
"clipped-text": { severity: "low", title: "Text clipped", help: "Text is wider than its box and cut off without an ellipsis." },
|
|
76
77
|
"image-aspect": { severity: "low", title: "Image distorted", help: "The rendered box does not match the image's proportions." },
|
|
78
|
+
"flow-step-failed": {
|
|
79
|
+
severity: "high",
|
|
80
|
+
title: "Saved flow broke",
|
|
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
|
+
},
|
|
77
83
|
};
|
|
78
84
|
export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
|
|
79
85
|
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
@@ -134,8 +140,13 @@ export function geometryRule(line) {
|
|
|
134
140
|
* Turn per-route measurements into issues, one per distinct fact. The same
|
|
135
141
|
* failing request on ten pages is one issue seen on ten routes: the shared
|
|
136
142
|
* shell's defects would otherwise bury the one page that is actually broken.
|
|
143
|
+
*
|
|
144
|
+
* Saved flows add two kinds: the step a flow broke at, and anything the
|
|
145
|
+
* oracles caught while it ran. The second kind goes through the same rules as
|
|
146
|
+
* a crawled page's, so a request that fails on load and again inside a flow is
|
|
147
|
+
* one issue, not two.
|
|
137
148
|
*/
|
|
138
|
-
export function issuesFromRoutes(routes, origin, ignore = []) {
|
|
149
|
+
export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
|
|
139
150
|
const byKey = new Map();
|
|
140
151
|
const add = (rule, evidence, route, opts = {}) => {
|
|
141
152
|
if (ignore.includes(rule))
|
|
@@ -188,6 +199,13 @@ export function issuesFromRoutes(routes, origin, ignore = []) {
|
|
|
188
199
|
for (const d of r.design)
|
|
189
200
|
add(d.rule, d.detail, d.chrome ? "(shared chrome)" : route);
|
|
190
201
|
}
|
|
202
|
+
for (const f of flows) {
|
|
203
|
+
for (const { path, violation } of f.violations)
|
|
204
|
+
add(violationRule(violation), violation.detail, path, { embed: violation.embed });
|
|
205
|
+
// A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
|
|
206
|
+
if (f.outcome.status === "failed")
|
|
207
|
+
add("flow-step-failed", flowStepEvidence(f), f.outcome.path);
|
|
208
|
+
}
|
|
191
209
|
return [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
@@ -242,6 +260,18 @@ export function redactRoutes(routes) {
|
|
|
242
260
|
violations: r.violations.map((v) => ({ ...v, url: clean(v.url) })),
|
|
243
261
|
}));
|
|
244
262
|
}
|
|
263
|
+
/** The same redaction for what flows measured: the page paths and the request URLs their violations quote. */
|
|
264
|
+
export function redactFlowRuns(runs) {
|
|
265
|
+
return runs.map((f) => ({
|
|
266
|
+
...f,
|
|
267
|
+
outcome: f.outcome.status === "passed"
|
|
268
|
+
? f.outcome
|
|
269
|
+
: { ...f.outcome, path: redactRoute(f.outcome.path), did: redactRoute(f.outcome.did), reason: redactRoute(f.outcome.reason) },
|
|
270
|
+
refusedBackground: f.refusedBackground.map(redactRoute),
|
|
271
|
+
websockets: f.websockets.map(redactRoute),
|
|
272
|
+
violations: f.violations.map(({ path, violation }) => ({ path: redactRoute(path), violation: { ...violation, url: redactRoute(violation.url) } })),
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
245
275
|
export function countBySeverity(issues) {
|
|
246
276
|
const counts = { high: 0, medium: 0, low: 0 };
|
|
247
277
|
for (const i of issues)
|
|
@@ -256,12 +286,36 @@ export function gateFailures(issues, failOn) {
|
|
|
256
286
|
}
|
|
257
287
|
/** Exit codes, stable for scripts: a gate failure is distinguishable from the check not running. */
|
|
258
288
|
export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
|
|
289
|
+
/**
|
|
290
|
+
* The settings a project chooses for what a check may do beyond visiting
|
|
291
|
+
* pages. Each default is the one that does the least harm on an unfamiliar
|
|
292
|
+
* project: it never writes and never silently hides a result (ADR 12).
|
|
293
|
+
*/
|
|
294
|
+
export const FLOW_WRITES = ["never", "allow"];
|
|
295
|
+
export const ON_REFUSED_STEP = ["report", "stop"];
|
|
296
|
+
export const GATE_RETESTS = ["never", "high", "all"];
|
|
297
|
+
export const DEFAULT_SETTINGS = { flowWrites: "never", onRefusedStep: "report", gateRetests: "high", retest: true };
|
|
259
298
|
/**
|
|
260
299
|
* Every `--option` `scenescout check` accepts. The GitHub Action at the
|
|
261
300
|
* repository root mirrors this list input for input, and check-test fails when
|
|
262
301
|
* the two drift apart.
|
|
263
302
|
*/
|
|
264
|
-
export const CHECK_OPTION_NAMES = [
|
|
303
|
+
export const CHECK_OPTION_NAMES = [
|
|
304
|
+
"project",
|
|
305
|
+
"out",
|
|
306
|
+
"fail-on",
|
|
307
|
+
"mode",
|
|
308
|
+
"storage-state",
|
|
309
|
+
"browser",
|
|
310
|
+
"max-routes",
|
|
311
|
+
"paths",
|
|
312
|
+
"ignore",
|
|
313
|
+
"flows",
|
|
314
|
+
"retest",
|
|
315
|
+
"flow-writes",
|
|
316
|
+
"on-refused-step",
|
|
317
|
+
"gate-retests",
|
|
318
|
+
];
|
|
265
319
|
export const MAX_CHECK_ROUTES = 150;
|
|
266
320
|
export const DEFAULT_CHECK_ROUTES = 50;
|
|
267
321
|
/** Parse `scenescout check` arguments. Every mistake is a sentence, never a half-configured run. */
|
|
@@ -306,7 +360,7 @@ export function parseCheckArgs(args, cwd) {
|
|
|
306
360
|
return { ok: false, error: `--fail-on must be one of ${FAIL_ON.join(", ")}` };
|
|
307
361
|
const mode = flags.get("mode") ?? "read-only";
|
|
308
362
|
if (mode !== "observe" && mode !== "read-only") {
|
|
309
|
-
return { ok: false, error: "--mode must be observe or read-only:
|
|
363
|
+
return { ok: false, error: "--mode must be observe or read-only: the write policy for the crawl, and for saved flows under --flow-writes allow" };
|
|
310
364
|
}
|
|
311
365
|
const browser = flags.get("browser");
|
|
312
366
|
if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
|
|
@@ -332,6 +386,25 @@ export function parseCheckArgs(args, cwd) {
|
|
|
332
386
|
const unknownRules = ignore.filter((r) => !(r in CHECK_RULES));
|
|
333
387
|
if (unknownRules.length > 0)
|
|
334
388
|
return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
|
|
389
|
+
const retest = flags.get("retest") ?? "on";
|
|
390
|
+
if (retest !== "on" && retest !== "off")
|
|
391
|
+
return { ok: false, error: "--retest must be on or off" };
|
|
392
|
+
const flows = flags.get("flows");
|
|
393
|
+
if (flows !== undefined && flows.trim() === "")
|
|
394
|
+
return { ok: false, error: "--flows needs a directory, or off" };
|
|
395
|
+
const oneOf = (name, allowed, fallback) => {
|
|
396
|
+
const v = flags.get(name) ?? fallback;
|
|
397
|
+
return allowed.includes(v) ? v : null;
|
|
398
|
+
};
|
|
399
|
+
const flowWrites = oneOf("flow-writes", FLOW_WRITES, DEFAULT_SETTINGS.flowWrites);
|
|
400
|
+
if (!flowWrites)
|
|
401
|
+
return { ok: false, error: `--flow-writes must be one of ${FLOW_WRITES.join(", ")}` };
|
|
402
|
+
const onRefusedStep = oneOf("on-refused-step", ON_REFUSED_STEP, DEFAULT_SETTINGS.onRefusedStep);
|
|
403
|
+
if (!onRefusedStep)
|
|
404
|
+
return { ok: false, error: `--on-refused-step must be one of ${ON_REFUSED_STEP.join(", ")}` };
|
|
405
|
+
const gateRetests = oneOf("gate-retests", GATE_RETESTS, DEFAULT_SETTINGS.gateRetests);
|
|
406
|
+
if (!gateRetests)
|
|
407
|
+
return { ok: false, error: `--gate-retests must be one of ${GATE_RETESTS.join(", ")}` };
|
|
335
408
|
const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
|
|
336
409
|
return {
|
|
337
410
|
ok: true,
|
|
@@ -346,12 +419,61 @@ export function parseCheckArgs(args, cwd) {
|
|
|
346
419
|
maxRoutes,
|
|
347
420
|
...(paths ? { paths } : {}),
|
|
348
421
|
ignore: ignore,
|
|
422
|
+
...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
|
|
423
|
+
retest: retest === "on",
|
|
424
|
+
flowWrites,
|
|
425
|
+
onRefusedStep,
|
|
426
|
+
gateRetests,
|
|
349
427
|
},
|
|
350
428
|
};
|
|
351
429
|
}
|
|
430
|
+
/** The settings part of the options, as the result records them. */
|
|
431
|
+
export function settingsOf(options) {
|
|
432
|
+
return { flowWrites: options.flowWrites, onRefusedStep: options.onRefusedStep, gateRetests: options.gateRetests, retest: options.retest };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Why a check has no verdict because of a flow, or null. A step the write
|
|
436
|
+
* policy refused asks for something a check will not do; the flow did not
|
|
437
|
+
* run, so it neither passed nor broke, and the check exits "could not run"
|
|
438
|
+
* rather than reporting a verdict on the rest as if the flow were fine.
|
|
439
|
+
*/
|
|
440
|
+
export function refusedFlowReason(result) {
|
|
441
|
+
const refused = result.flows.filter((f) => f.outcome.status === "refused");
|
|
442
|
+
if (refused.length === 0)
|
|
443
|
+
return null;
|
|
444
|
+
const why = result.settings.flowWrites === "never"
|
|
445
|
+
? "Flows replay with --flow-writes never, so no step may send a write whatever --mode says: remove the step, or pass --flow-writes allow to replay flows under --mode"
|
|
446
|
+
: `Flows replay under --mode ${result.mode}, which refuses that write: remove the step, or leave the flow to an exploratory run`;
|
|
447
|
+
return `${refused.map(flowStepEvidence).join("; ")}. ${why}.`;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Re-tested findings that fail the gate: those still reproducing that
|
|
451
|
+
* --gate-retests covers. "possibly fixed" and "not re-tested" never do, and
|
|
452
|
+
* --fail-on never turns every gate off, this one included.
|
|
453
|
+
*/
|
|
454
|
+
export function retestGateFailures(result) {
|
|
455
|
+
const { gateRetests } = result.settings;
|
|
456
|
+
if (!result.retest || gateRetests === "never" || result.failOn === "never")
|
|
457
|
+
return [];
|
|
458
|
+
return result.retest.results.filter((r) => r.verdict === "reproduces" && (gateRetests === "all" || r.severity === "high"));
|
|
459
|
+
}
|
|
352
460
|
export function summarise(result) {
|
|
353
|
-
const
|
|
354
|
-
|
|
461
|
+
const retestsFailing = retestGateFailures(result).length;
|
|
462
|
+
const failing = gateFailures(result.issues, result.failOn).length + retestsFailing;
|
|
463
|
+
const couldNotRun = result.flows.filter((f) => f.outcome.status === "refused").length;
|
|
464
|
+
return { passed: failing === 0, counts: countBySeverity(result.issues), failing, retestsFailing, couldNotRun };
|
|
465
|
+
}
|
|
466
|
+
/** The exit code a finished check ends with. A flow that could not run outranks the gate: the verdict is incomplete. */
|
|
467
|
+
export function exitCodeOf(result) {
|
|
468
|
+
const { passed, couldNotRun } = summarise(result);
|
|
469
|
+
if (couldNotRun > 0)
|
|
470
|
+
return EXIT.error;
|
|
471
|
+
return passed ? EXIT.pass : EXIT.gateFailed;
|
|
472
|
+
}
|
|
473
|
+
/** One line of the settings, for the report and the job summary. */
|
|
474
|
+
export function describeSettings(result) {
|
|
475
|
+
const s = result.settings;
|
|
476
|
+
return `flow writes: ${s.flowWrites === "never" ? "never (flows replay under observe)" : `allow (flows replay under ${result.mode})`} · refused step: ${s.onRefusedStep} · re-tests: ${s.retest ? `on, gating ${s.gateRetests === "never" ? "nothing" : s.gateRetests === "all" ? "every one still reproducing" : "those filed high"}` : "off"}`;
|
|
355
477
|
}
|
|
356
478
|
const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
357
479
|
/**
|
|
@@ -360,9 +482,43 @@ const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
|
360
482
|
* app (the APP base id). Fingerprints come from the evidence, not the
|
|
361
483
|
* location, so a preview deployment on a new URL does not reopen every alert.
|
|
362
484
|
*/
|
|
485
|
+
const RETEST_SARIF_RULE = {
|
|
486
|
+
id: "open-finding-reproduces",
|
|
487
|
+
name: "Open finding still reproduces",
|
|
488
|
+
shortDescription: { text: "Open finding still reproduces" },
|
|
489
|
+
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
|
+
defaultConfiguration: { level: "error" },
|
|
491
|
+
};
|
|
363
492
|
export function toSarif(result, toolVersion) {
|
|
364
493
|
const used = [...new Set(result.issues.map((i) => i.rule))];
|
|
365
494
|
const base = new URL(result.url);
|
|
495
|
+
const reproducing = retestGateFailures(result);
|
|
496
|
+
const refused = result.flows.filter((f) => f.outcome.status === "refused");
|
|
497
|
+
const issueResults = result.issues.map((i) => ({
|
|
498
|
+
ruleId: i.rule,
|
|
499
|
+
level: SARIF_LEVEL[i.severity],
|
|
500
|
+
message: {
|
|
501
|
+
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
|
+
},
|
|
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" } } }),
|
|
509
|
+
partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
|
|
510
|
+
}));
|
|
511
|
+
// Only the re-tests that fail the gate are results: a reviewer reading code scanning sees what failed it.
|
|
512
|
+
const retestResults = reproducing.map((r) => ({
|
|
513
|
+
ruleId: RETEST_SARIF_RULE.id,
|
|
514
|
+
// The severity the finding was filed at, as the page rules map theirs; an unknown one reads as the worst.
|
|
515
|
+
level: SARIF_LEVEL[CHECK_SEVERITIES.includes(r.severity) ? r.severity : "high"],
|
|
516
|
+
message: {
|
|
517
|
+
text: `Open finding still reproduces: [${r.severity}] ${r.title} (${r.id}) — ${r.signatures.join(", ")} (gated by --gate-retests ${result.settings.gateRetests})`,
|
|
518
|
+
},
|
|
519
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: r.path.replace(/^\//, ""), uriBaseId: "APP" } } }],
|
|
520
|
+
partialFingerprints: { "scenescoutCheck/v1": createHash("sha256").update(`retest\u0000${r.id}`).digest("hex").slice(0, 32) },
|
|
521
|
+
}));
|
|
366
522
|
return {
|
|
367
523
|
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
368
524
|
version: "2.1.0",
|
|
@@ -373,30 +529,31 @@ export function toSarif(result, toolVersion) {
|
|
|
373
529
|
name: "SceneScout",
|
|
374
530
|
version: toolVersion,
|
|
375
531
|
informationUri: "https://github.com/brunoboto96/SceneScout",
|
|
376
|
-
rules:
|
|
377
|
-
id
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
532
|
+
rules: [
|
|
533
|
+
...used.map((id) => ({
|
|
534
|
+
id,
|
|
535
|
+
name: CHECK_RULES[id].title,
|
|
536
|
+
shortDescription: { text: CHECK_RULES[id].title },
|
|
537
|
+
help: { text: CHECK_RULES[id].help },
|
|
538
|
+
defaultConfiguration: { level: SARIF_LEVEL[CHECK_RULES[id].severity] },
|
|
539
|
+
})),
|
|
540
|
+
...(reproducing.length > 0 ? [RETEST_SARIF_RULE] : []),
|
|
541
|
+
],
|
|
383
542
|
},
|
|
384
543
|
},
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
544
|
+
// A flow that could not run is not a result about the app: it is the tool saying its run was incomplete.
|
|
545
|
+
invocations: [
|
|
546
|
+
{
|
|
547
|
+
executionSuccessful: refused.length === 0,
|
|
548
|
+
toolExecutionNotifications: refused.map((f) => ({
|
|
549
|
+
level: "error",
|
|
550
|
+
descriptor: { id: "flow-could-not-run" },
|
|
551
|
+
message: { text: `Could not run: ${flowStepEvidence(f)}` },
|
|
552
|
+
})),
|
|
391
553
|
},
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
message: { text: "the app's shared shell, on every page that renders it" },
|
|
396
|
-
}
|
|
397
|
-
: { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
|
|
398
|
-
partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
|
|
399
|
-
})),
|
|
554
|
+
],
|
|
555
|
+
originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
|
|
556
|
+
results: [...issueResults, ...retestResults],
|
|
400
557
|
},
|
|
401
558
|
],
|
|
402
559
|
};
|
|
@@ -425,17 +582,23 @@ function routeList(routes) {
|
|
|
425
582
|
}
|
|
426
583
|
/** The human report: the verdict first, then what failed it, then everything else. */
|
|
427
584
|
export function formatCheck(result) {
|
|
428
|
-
const { passed, counts, failing } = summarise(result);
|
|
585
|
+
const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
|
|
429
586
|
const lines = [];
|
|
430
587
|
lines.push(`# SceneScout check`);
|
|
431
588
|
lines.push("");
|
|
432
589
|
lines.push(`${result.url} · ${result.routes.length} route(s) · mode ${result.mode} · gate: ${result.failOn === "never" ? "report only" : `fail on ${result.failOn}${result.failOn === "low" ? "" : " or worse"}`}`);
|
|
433
590
|
lines.push("");
|
|
434
591
|
const unaudited = unauditedRoutes(result.routes).length;
|
|
435
|
-
|
|
436
|
-
?
|
|
437
|
-
:
|
|
592
|
+
const failingText = retestsFailing > 0
|
|
593
|
+
? `${failing} failing the gate (${failing - retestsFailing} issue(s), ${retestsFailing} re-tested finding(s))`
|
|
594
|
+
: `${failing} issue(s) at the gate's severity`;
|
|
595
|
+
lines.push((couldNotRun > 0 ? `**COULD NOT RUN** — ${couldNotRun} flow(s) had a step refused (see Flows); the rest ` : "") +
|
|
596
|
+
(passed
|
|
597
|
+
? `${couldNotRun > 0 ? "passed" : "**PASSED**"} — ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
|
|
598
|
+
: `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
|
|
438
599
|
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : ""));
|
|
600
|
+
// Right under the verdict: what a green check was allowed to do is part of what it means.
|
|
601
|
+
lines.push("", `Settings — ${describeSettings(result)}`);
|
|
439
602
|
for (const sev of CHECK_SEVERITIES) {
|
|
440
603
|
const of = result.issues.filter((i) => i.severity === sev);
|
|
441
604
|
if (of.length === 0)
|
|
@@ -456,21 +619,65 @@ export function formatCheck(result) {
|
|
|
456
619
|
if (result.unvisited.length > 0) {
|
|
457
620
|
lines.push("", `Not visited (over --max-routes): ${result.unvisited.slice(0, 20).map(code).join(", ")}${result.unvisited.length > 20 ? " …" : ""}`);
|
|
458
621
|
}
|
|
622
|
+
if (result.flows.length > 0 || result.skippedFlows.length > 0) {
|
|
623
|
+
lines.push("", `## Flows (${result.flows.length})`, "");
|
|
624
|
+
for (const f of result.flows) {
|
|
625
|
+
const o = f.outcome;
|
|
626
|
+
lines.push(o.status === "passed"
|
|
627
|
+
? `- ✓ ${cell(f.name)} (${code(f.file)}): ${f.steps} step(s) passed`
|
|
628
|
+
: o.status === "refused"
|
|
629
|
+
? `- ⊘ ${cell(f.name)} (${code(f.file)}): could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
|
|
630
|
+
: `- ✗ ${cell(f.name)} (${code(f.file)}): step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
|
|
631
|
+
if (f.refusedBackground.length > 0)
|
|
632
|
+
lines.push(` - refused background requests (beacons and pings, not charged to a step): ${f.refusedBackground.map(code).join(", ")}`);
|
|
633
|
+
if (f.websockets.length > 0)
|
|
634
|
+
lines.push(` - WebSocket connections opened (not covered by the write rule): ${f.websockets.map(code).join(", ")}`);
|
|
635
|
+
}
|
|
636
|
+
for (const s of result.skippedFlows)
|
|
637
|
+
lines.push(`- skipped ${code(s.file)}: ${cell(s.reason)}`);
|
|
638
|
+
}
|
|
639
|
+
if (result.retest && result.retest.open > 0) {
|
|
640
|
+
const { open, results } = result.retest;
|
|
641
|
+
lines.push("", `## Open findings re-tested (${results.length} of ${open})`, "");
|
|
642
|
+
const says = {
|
|
643
|
+
reproduces: "still reproduces",
|
|
644
|
+
"possibly-fixed": "possibly fixed: the page loaded and the request did not fail",
|
|
645
|
+
"not-reached": "not re-tested",
|
|
646
|
+
};
|
|
647
|
+
const gating = new Set(retestGateFailures(result));
|
|
648
|
+
for (const r of results) {
|
|
649
|
+
lines.push(`- [${r.severity}] ${cell(r.title)} (${r.id}) on ${code(r.path)}: ${says[r.verdict]}${r.note ? ` (${r.note})` : ""}${gating.has(r) ? " — **fails the gate**" : ""} — ${r.signatures.map(code).join(", ")}`);
|
|
650
|
+
}
|
|
651
|
+
if (result.retest.extraPages) {
|
|
652
|
+
lines.push("", `${result.retest.extraPages} page(s) were loaded only to re-test these findings; they are not in the routes above and no page rule was applied to them.`);
|
|
653
|
+
}
|
|
654
|
+
if (open > results.length) {
|
|
655
|
+
lines.push("", `${open - results.length} open finding(s) need an interaction to reproduce, or name no failed GET, so a check cannot re-test them; \`scout_verify\` in an exploratory run can.`);
|
|
656
|
+
}
|
|
657
|
+
lines.push("", `_Re-tests gate by --gate-retests ${result.settings.gateRetests}: ${result.settings.gateRetests === "never"
|
|
658
|
+
? "none of them"
|
|
659
|
+
: result.settings.gateRetests === "all"
|
|
660
|
+
? "every finding still reproducing"
|
|
661
|
+
: "a finding filed high that still reproduces"} fails the gate; "possibly fixed" and "not re-tested" never do. A check never writes the project's memory, so nothing is resolved here._`);
|
|
662
|
+
}
|
|
459
663
|
if (result.ignored.length > 0)
|
|
460
664
|
lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
|
|
461
|
-
lines.push("",
|
|
665
|
+
lines.push("", result.flows.length > 0
|
|
666
|
+
? "_A check visits pages, measures what loads and replays the flows saved for it. It does not explore, fill forms of its own accord or compare roles; an exploratory run does that._"
|
|
667
|
+
: "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that. Flows saved in .scenescout/flows are replayed._");
|
|
462
668
|
return lines.join("\n") + "\n";
|
|
463
669
|
}
|
|
464
670
|
/** The machine summary: stable keys for scripts and follow-up jobs. */
|
|
465
671
|
export function toSummaryJson(result, toolVersion) {
|
|
466
|
-
const { passed, counts, failing } = summarise(result);
|
|
672
|
+
const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
|
|
467
673
|
return {
|
|
468
674
|
tool: "scenescout-check",
|
|
469
675
|
version: toolVersion,
|
|
470
676
|
url: result.url,
|
|
471
677
|
generatedAt: result.generatedAt,
|
|
472
678
|
mode: result.mode,
|
|
473
|
-
gate: { failOn: result.failOn, passed, failing },
|
|
679
|
+
gate: { failOn: result.failOn, passed, failing, retestsFailing, couldNotRun },
|
|
680
|
+
settings: result.settings,
|
|
474
681
|
counts,
|
|
475
682
|
routes: result.routes.map((r) => ({
|
|
476
683
|
path: r.path,
|
|
@@ -482,6 +689,17 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
482
689
|
})),
|
|
483
690
|
unvisited: result.unvisited,
|
|
484
691
|
ignored: result.ignored,
|
|
692
|
+
flows: result.flows.map((f) => ({
|
|
693
|
+
name: f.name,
|
|
694
|
+
file: f.file,
|
|
695
|
+
steps: f.steps,
|
|
696
|
+
status: f.outcome.status,
|
|
697
|
+
...(f.outcome.status === "passed" ? {} : { step: f.outcome.step, did: f.outcome.did, reason: f.outcome.reason, path: f.outcome.path }),
|
|
698
|
+
refusedBackground: f.refusedBackground,
|
|
699
|
+
websockets: f.websockets,
|
|
700
|
+
})),
|
|
701
|
+
skippedFlows: result.skippedFlows,
|
|
702
|
+
retest: result.retest,
|
|
485
703
|
issues: result.issues,
|
|
486
704
|
};
|
|
487
705
|
}
|
|
@@ -4,6 +4,17 @@ const HEX_RE = /^[0-9a-f]{12,}$/i;
|
|
|
4
4
|
const NUM_RE = /^\d+$/;
|
|
5
5
|
/** Query params that name a UI state (a different screen), not transient data. */
|
|
6
6
|
const UI_STATE_PARAM_RE = /^(tab|view|mode|step|panel|section)$/i;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a path segment is an id that route identity collapses to `:id`: a
|
|
9
|
+
* UUID or long hex string anywhere, a number from the second real segment on.
|
|
10
|
+
* `index` counts as `path.split("/")` does, so the first real segment of a
|
|
11
|
+
* leading-slash path is 1 (see the note on numeric first segments below).
|
|
12
|
+
*/
|
|
13
|
+
export function isIdSegment(seg, index) {
|
|
14
|
+
if (UUID_RE.test(seg) || HEX_RE.test(seg))
|
|
15
|
+
return true;
|
|
16
|
+
return NUM_RE.test(seg) && index > 1;
|
|
17
|
+
}
|
|
7
18
|
/**
|
|
8
19
|
* Normalize a URL into a route-class identity:
|
|
9
20
|
* - path ids collapse (/orders/123 → /orders/:id)
|
|
@@ -38,13 +49,7 @@ export function normalizePath(rawUrl) {
|
|
|
38
49
|
}
|
|
39
50
|
// segments[0] is always "" for a leading-slash path, so the first REAL
|
|
40
51
|
// segment is index 1 — that is the one a bare numeric page occupies.
|
|
41
|
-
const segments = path.split("/").map((seg, i) =>
|
|
42
|
-
if (UUID_RE.test(seg) || HEX_RE.test(seg))
|
|
43
|
-
return ":id";
|
|
44
|
-
if (NUM_RE.test(seg) && i > 1)
|
|
45
|
-
return ":id";
|
|
46
|
-
return seg;
|
|
47
|
-
});
|
|
52
|
+
const segments = path.split("/").map((seg, i) => (isIdSegment(seg, i) ? ":id" : seg));
|
|
48
53
|
let normalized = segments.join("/") || "/";
|
|
49
54
|
if (normalized === "")
|
|
50
55
|
normalized = "/";
|