scenescout 3.10.0 → 3.11.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 +10 -0
- package/README.md +12 -3
- package/dist/browsers.js +20 -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 +252 -19
- package/dist/engine/check.js +251 -33
- package/dist/engine/flow.js +398 -0
- package/dist/engine/memory.js +10 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +2 -2
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f61141e: `scenescout check` replays saved flows and re-tests open findings. Flows saved as `.scenescout/flows/*.json` use `scout_run_plan`'s steps (navigate, click, type, select, press) plus `expect-text`, `expect-url` and `expect-request`; each is replayed after the crawl with no model, and a flow whose step breaks fails the gate with a high `flow-step-failed` issue naming the flow and the step. A flow file that is not valid stops the check with exit 2, naming the file and the field. Open findings in the project's memory that a page load can reproduce (a failed GET, nothing done on the page but looking) are re-tested and reported as still reproducing or possibly fixed; the memory is never written. New options, also inputs of the GitHub Action: `--flows <dir|off>`, `--retest on|off`, and three settings for what a check may do, whose effective values are printed under the verdict and in `check.json`: `--flow-writes never|allow` (default `never`: flows replay under observe's rule whatever `--mode` says; `allow` replays them under `--mode`, so their form submissions are sent), `--on-refused-step report|stop` (default `report`: a flow whose step was refused is marked "could not run" in the report, JSON and SARIF, every other verdict is kept, and the check exits 2; `stop` exits 2 at once with no results) and `--gate-retests never|high|all` (default `high`: a re-tested finding filed high that still reproduces fails the gate). The defaults are what an unconfigured check does, for a first try or an agent running it unattended: its flows send no HTTP write and it never silently hides a result. A refused beacon or ping (`navigator.sendBeacon`, `<a ping>`) is listed in the flow's result as a background request and charged to no step, whatever its origin, while every other refused write (fetch, XHR, form post) is charged to its step wherever it goes; a write the last step sets off within 750 ms is still charged to it; the WebSocket connections a flow's page opened are listed; a `role=` target must name an ARIA role; anything else in the flows directory is listed as skipped with its reason. Pages loaded only to re-test a finding are measured and nothing else: they are not checked routes, do not count towards `--max-routes`, and add nothing to the route list. The GitHub Action also outputs `could-not-run` and `retests-failing`, and its annotation says when re-tested findings are among what failed the gate. `scout_run_plan` targets accept `role=<role>[name="…"]`, and the `.gitignore` SceneScout writes in `.scenescout/` no longer ignores `flows/*.json`, so flows can be committed.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- a1f8011: The benchmark has a second, held-out app (`npm run holdout:serve`), a library loans desk with its own planted defects and answer key, which nothing in the engine or the skill is tuned against. `npm run bench` takes `--app demo|holdout` to choose the key a run is scored with (the demo stays the default), records the app in every new archive, refuses to score an archive against another app's key, and `--all` scores each archive against its own app's key, one table per app.
|
|
12
|
+
|
|
3
13
|
## 3.10.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -363,7 +363,7 @@ npx scenescout check http://127.0.0.1:3000 --fail-on high
|
|
|
363
363
|
|---|---|
|
|
364
364
|
| 0 | Passed the gate |
|
|
365
365
|
| 1 | Failed it: something at the `--fail-on` severity or worse |
|
|
366
|
-
| 2 | Could not run: a bad argument, an app that never answered,
|
|
366
|
+
| 2 | Could not run, or not all of it: a bad argument, an app that never answered, only the sign-in page reached, a saved flow that is not valid, or a flow step the write policy refused |
|
|
367
367
|
|
|
368
368
|
It writes `report.md`, `check.sarif` (for code-scanning dashboards) and `check.json` to `.scenescout/check/`, and on GitHub Actions it also puts the report on the job's summary page.
|
|
369
369
|
|
|
@@ -377,16 +377,24 @@ On GitHub Actions, this repository is also an action that installs everything an
|
|
|
377
377
|
|
|
378
378
|
[docs/ci.md](docs/ci.md) has a complete workflow (start the app, wait for it, check it), the action's inputs and outputs, code-scanning upload, and the same check on GitLab CI, CircleCI or any shell.
|
|
379
379
|
|
|
380
|
-
|
|
380
|
+
With the default settings its saved flows send no HTTP write (they replay under observe's rule), and its crawl runs under `--mode observe` or `read-only`; `--flow-writes allow` lets flows write as `--mode` allows. By default it fails only on facts that mean a page is broken: a page that did not load, an uncaught exception, a 5xx, a failure shown as success. Other options:
|
|
381
381
|
|
|
382
382
|
- `--fail-on medium` or `low` makes the gate stricter.
|
|
383
383
|
- `--ignore <rule>` drops a rule.
|
|
384
384
|
- `--paths /a,/b` checks only those pages.
|
|
385
385
|
- `--storage-state <file>` checks while signed in.
|
|
386
|
+
- `--flows <dir>` or `off` chooses which saved flows to replay; `--retest off` skips re-testing open findings.
|
|
387
|
+
- `--flow-writes never|allow` (default `never`): `never` replays flows under observe's rule whatever `--mode` says; `allow` replays them under `--mode`, so in `read-only` a flow's form submissions are sent to the target on every run.
|
|
388
|
+
- `--on-refused-step report|stop` (default `report`): `report` marks a flow whose step was refused "could not run", keeps every other verdict and exits 2; `stop` exits 2 at that step with no results.
|
|
389
|
+
- `--gate-retests never|high|all` (default `high`): which still-reproducing re-tested findings fail the gate.
|
|
390
|
+
|
|
391
|
+
The defaults are what an unconfigured check does, for a first try or an AI agent running it unattended: its flows send no HTTP write and it never silently hides a result. Each setting is a choice for the project; the report and `check.json` print the values a check ran with.
|
|
386
392
|
|
|
387
393
|
`scenescout check --help` lists every option. Why the defaults are what they are: [ADR 11](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md).
|
|
388
394
|
|
|
389
|
-
|
|
395
|
+
It also replays the flows saved in `.scenescout/flows/*.json`, with no model: the steps `scout_run_plan` takes (navigate, click, type, select, press) plus `expect-text`, `expect-url` and `expect-request`. A flow whose step breaks fails the gate, naming the flow and the step. And it re-tests the open findings earlier runs left in the project's memory that a page load can reproduce, reporting each as still reproducing or possibly fixed; by default a finding filed high that still reproduces fails the gate. [docs/ci.md](docs/ci.md#saved-flows) has the flow format; [ADR 12](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md) says why it works this way.
|
|
396
|
+
|
|
397
|
+
Beyond those flows it explores nothing and fills no forms. That is the exploratory run's job, and its findings belong in a report, not a gate.
|
|
390
398
|
|
|
391
399
|
---
|
|
392
400
|
|
|
@@ -652,6 +660,7 @@ The load-bearing choices are recorded as ADRs — read the relevant one before c
|
|
|
652
660
|
- [9 · A refused write is answered, not dropped](docs/adr/0009-a-refused-write-is-answered-not-dropped.md)
|
|
653
661
|
- [10 · A lane's confidence is checked, not trusted](docs/adr/0010-a-confidence-is-checked-not-trusted.md)
|
|
654
662
|
- [11 · A gate is deterministic, and fails only on what it can prove](docs/adr/0011-a-gate-is-deterministic-and-fails-only-on-what-it-can-prove.md)
|
|
663
|
+
- [12 · A check replays saved flows and re-tests open findings, within settings whose defaults do the least harm](docs/adr/0012-a-check-replays-saved-flows-and-reports-re-tests.md)
|
|
655
664
|
|
|
656
665
|
---
|
|
657
666
|
|
package/dist/browsers.js
CHANGED
|
@@ -172,6 +172,26 @@ export function focusAdvanceKey(engine, platform) {
|
|
|
172
172
|
export function echoesFailedLoads(engine) {
|
|
173
173
|
return engine !== "firefox";
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* The resource type a navigator.sendBeacon request carries when it is
|
|
177
|
+
* intercepted: Chromium calls it "ping", Firefox and WebKit "beacon". A saved
|
|
178
|
+
* flow excuses a refused beacon from its step (flow.ts `splitRefusals`), so
|
|
179
|
+
* every engine's name for it must be known.
|
|
180
|
+
*/
|
|
181
|
+
export function beaconResourceType(engine) {
|
|
182
|
+
return engine === "chromium" ? "ping" : "beacon";
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether a beacon a page sends as it is being left (sendBeacon, or a
|
|
186
|
+
* keepalive fetch, on `pagehide`) escapes interception. In Chromium it is
|
|
187
|
+
* never routed, so the write policy does not see it and it reaches the server
|
|
188
|
+
* in every mode; Firefox and WebKit route it, and the policy refuses it like
|
|
189
|
+
* any other. A limit of the network-layer policy (ADR 2), reported in ADR 12;
|
|
190
|
+
* the check smoke suite asserts both directions per engine.
|
|
191
|
+
*/
|
|
192
|
+
export function unloadBeaconsEscapePolicy(engine) {
|
|
193
|
+
return engine === "chromium";
|
|
194
|
+
}
|
|
175
195
|
/**
|
|
176
196
|
* Run in every page before its own scripts: takes shared workers away.
|
|
177
197
|
*
|
package/dist/check-run.js
CHANGED
|
@@ -1,17 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Runs `scenescout check`: attach, crawl every route the engine can find,
|
|
3
|
-
* measure each one,
|
|
4
|
-
*
|
|
3
|
+
* measure each one, replay the saved flows, re-test the open findings a page
|
|
4
|
+
* load can reproduce, and write the verdict. The rules live in
|
|
5
|
+
* engine/check.ts, engine/flow.ts and engine/verify.ts; this file only drives
|
|
6
|
+
* the browser and the files.
|
|
5
7
|
*/
|
|
6
8
|
import fs from "node:fs";
|
|
7
9
|
import os from "node:os";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { BrowserEngine } from "./engine/browser.js";
|
|
10
|
-
import { issuesFromRoutes, redactRoute, redactRoutes, withoutOwnResponse } from "./engine/check.js";
|
|
12
|
+
import { issuesFromRoutes, redactFlowRuns, redactRoute, redactRoutes, settingsOf, withoutOwnResponse, } from "./engine/check.js";
|
|
13
|
+
import { loadFlows, resolveFlowsDir } from "./engine/flow.js";
|
|
11
14
|
import { MemoryStore, MEMORY_DIRNAME } from "./engine/memory.js";
|
|
15
|
+
import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
|
|
12
16
|
/** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
|
|
13
17
|
const MAX_ROUNDS = 6;
|
|
14
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Read the flows and the findings, before any browser starts. Throws with a
|
|
20
|
+
* sentence naming the file and the field on anything it cannot read: a flow
|
|
21
|
+
* that is silently skipped is a flow that silently passes.
|
|
22
|
+
*/
|
|
23
|
+
export function readCheckInputs(options) {
|
|
24
|
+
const where = resolveFlowsDir(options.flows, options.projectDir, (p) => fs.existsSync(p) && fs.statSync(p).isDirectory());
|
|
25
|
+
if ("error" in where)
|
|
26
|
+
throw new Error(where.error);
|
|
27
|
+
const { flows, skipped: skippedFlows } = where.dir ? loadFlows(where.dir) : { flows: [], skipped: [] };
|
|
28
|
+
if (!options.retest)
|
|
29
|
+
return { flows, skippedFlows, findings: null };
|
|
30
|
+
// Read, never written: a check leaves the project's memory as it found it.
|
|
31
|
+
const memoryPath = path.join(options.projectDir, MEMORY_DIRNAME, "memory.json");
|
|
32
|
+
if (!fs.existsSync(memoryPath))
|
|
33
|
+
return { flows, skippedFlows, findings: null };
|
|
34
|
+
let findings;
|
|
35
|
+
try {
|
|
36
|
+
findings = JSON.parse(fs.readFileSync(memoryPath, "utf8")).findings;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw new Error(`could not read ${memoryPath} to re-test its findings (${err instanceof Error ? err.message : String(err)}). Pass --retest off to check without it`);
|
|
40
|
+
}
|
|
41
|
+
if (findings !== undefined && !Array.isArray(findings))
|
|
42
|
+
throw new Error(`${memoryPath}: "findings" is not a list. Pass --retest off to check without it`);
|
|
43
|
+
return { flows, skippedFlows, findings: wellFormedFindings(findings ?? []) };
|
|
44
|
+
}
|
|
45
|
+
export async function runCheck(options, log = () => { }, inputs = { flows: [], findings: null }) {
|
|
15
46
|
// A throwaway memory: a check is one run, and a store shared with earlier
|
|
16
47
|
// exploratory runs would count their visits as this check's and skip those routes.
|
|
17
48
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "scenescout-check-"));
|
|
@@ -52,17 +83,60 @@ export async function runCheck(options, log = () => { }) {
|
|
|
52
83
|
log(` ${routes.length} route(s) checked`);
|
|
53
84
|
}
|
|
54
85
|
}
|
|
86
|
+
// Pages of open findings the crawl did not load exactly: loaded now, so each re-test has its own measurement.
|
|
87
|
+
// Kept apart from `routes`: they are measured only to re-test, never checked against the page rules, and do not count
|
|
88
|
+
// towards --max-routes. With --paths the check stays on the paths it was given.
|
|
89
|
+
const plan = inputs.findings ? checkRetestPlan(inputs.findings) : null;
|
|
90
|
+
const retestPages = [];
|
|
91
|
+
if (plan && !options.paths) {
|
|
92
|
+
const missing = [...new Set(plan.candidates.map((c) => c.path))].filter((p) => !routes.some((r) => r.path === p));
|
|
93
|
+
if (missing.length > 0) {
|
|
94
|
+
await engine.crawl(missing, { limit: missing.length, measureOnly: true });
|
|
95
|
+
retestPages.push(...engine.lastCrawlHealth);
|
|
96
|
+
log(` ${missing.length} page(s) of open findings loaded only to re-test them`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const flowRuns = [];
|
|
100
|
+
for (const flow of inputs.flows) {
|
|
101
|
+
// --flow-writes never: observe's rule, whatever --mode lets the crawl do.
|
|
102
|
+
const replay = await engine.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
|
|
103
|
+
flowRuns.push({ name: flow.name, file: flow.file, steps: flow.steps.length, ...replay });
|
|
104
|
+
log(` flow ${flow.name}: ${replay.outcome.status}${replay.outcome.status === "passed" ? "" : ` at step ${replay.outcome.step}`}`);
|
|
105
|
+
// --on-refused-step stop: nothing after a refused step runs, and the check ends without a verdict.
|
|
106
|
+
if (replay.outcome.status === "refused" && options.onRefusedStep === "stop")
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
const retest = plan
|
|
110
|
+
? {
|
|
111
|
+
open: plan.open,
|
|
112
|
+
extraPages: retestPages.length,
|
|
113
|
+
results: retestResults(plan.candidates, [...routes, ...retestPages].map((r) => ({
|
|
114
|
+
path: r.path,
|
|
115
|
+
status: r.status,
|
|
116
|
+
...(r.loadError !== undefined ? { loadError: r.loadError } : {}),
|
|
117
|
+
loginRedirect: r.loginRedirect,
|
|
118
|
+
httpErrors: withoutOwnResponse(r)
|
|
119
|
+
.violations.filter((v) => v.kind === "http_error")
|
|
120
|
+
.map((v) => v.detail),
|
|
121
|
+
}))),
|
|
122
|
+
}
|
|
123
|
+
: null;
|
|
55
124
|
const measured = redactRoutes(routes.map(withoutOwnResponse));
|
|
125
|
+
const flows = redactFlowRuns(flowRuns);
|
|
56
126
|
return {
|
|
57
127
|
url: redactRoute(options.url),
|
|
58
128
|
generatedAt: new Date().toISOString(),
|
|
59
129
|
mode: options.mode,
|
|
60
130
|
failOn: options.failOn,
|
|
61
131
|
routes: measured,
|
|
62
|
-
issues: issuesFromRoutes(measured, start.origin, options.ignore),
|
|
132
|
+
issues: issuesFromRoutes(measured, start.origin, options.ignore, flows),
|
|
63
133
|
// Routes that failed to load are issues already; "not visited" is only what --max-routes left out.
|
|
64
134
|
unvisited: options.paths ? [] : engine.crawlableRoutes().map(redactRoute),
|
|
65
135
|
ignored: options.ignore,
|
|
136
|
+
flows,
|
|
137
|
+
skippedFlows: inputs.skippedFlows ?? [],
|
|
138
|
+
retest,
|
|
139
|
+
settings: settingsOf(options),
|
|
66
140
|
};
|
|
67
141
|
}
|
|
68
142
|
finally {
|
package/dist/cli.js
CHANGED
|
@@ -17,8 +17,8 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import { APPROX_DISK_MB, BROWSER_ENGINES, browserPresence, defaultAttachNote, defaultEngine, launchTarget, parseBrowserSelection, playwrightInstallArgs, } from "./browsers.js";
|
|
18
18
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
19
19
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
20
|
-
import { defaultCheckDir, runCheck } from "./check-run.js";
|
|
21
|
-
import { EXIT, formatCheck, parseCheckArgs,
|
|
20
|
+
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
21
|
+
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
22
22
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
23
23
|
import { formatStatus, liveEngines, liveTokenFileName, localClock, LIVE_TOKEN_FILE, pidAlive, watchTarget, wholeSessions, } from "./engine/live.js";
|
|
24
24
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -53,7 +53,16 @@ Usage:
|
|
|
53
53
|
--max-routes N (default 50); --paths /a,/b to check only those;
|
|
54
54
|
--ignore rule,rule; --storage-state file to check signed in;
|
|
55
55
|
--project dir (default: here); --out dir (default: .scenescout/check);
|
|
56
|
-
--browser chromium|firefox|webkit
|
|
56
|
+
--browser chromium|firefox|webkit;
|
|
57
|
+
--flows dir|off: replay the flows saved there (default: .scenescout/flows);
|
|
58
|
+
--retest on|off: re-test open findings a page load reproduces (default on);
|
|
59
|
+
--flow-writes never|allow: never (default) replays flows under observe's
|
|
60
|
+
rule whatever --mode says; allow replays them under --mode, so their
|
|
61
|
+
form submissions are sent;
|
|
62
|
+
--on-refused-step report|stop: report (default) marks a flow whose step was
|
|
63
|
+
refused "could not run", keeps every other verdict and exits 2; stop exits 2 there;
|
|
64
|
+
--gate-retests never|high|all: which still-reproducing findings fail the gate
|
|
65
|
+
(default high: those filed high))
|
|
57
66
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
58
67
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
59
68
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
@@ -471,10 +480,18 @@ async function check(args) {
|
|
|
471
480
|
}
|
|
472
481
|
const options = parsed.options;
|
|
473
482
|
const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
|
|
483
|
+
let inputs;
|
|
484
|
+
try {
|
|
485
|
+
inputs = readCheckInputs(options);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
console.error(`scenescout check: ${err instanceof Error ? err.message : String(err)}`);
|
|
489
|
+
process.exit(EXIT.error);
|
|
490
|
+
}
|
|
474
491
|
let result;
|
|
475
492
|
try {
|
|
476
|
-
console.log(`Checking ${options.url}
|
|
477
|
-
result = await runCheck(options, (line) => console.log(line));
|
|
493
|
+
console.log(`Checking ${options.url} …${inputs.flows.length > 0 ? ` (and ${inputs.flows.length} saved flow(s))` : ""}`);
|
|
494
|
+
result = await runCheck(options, (line) => console.log(line), inputs);
|
|
478
495
|
}
|
|
479
496
|
catch (err) {
|
|
480
497
|
console.error(`scenescout check: could not run: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -485,6 +502,12 @@ async function check(args) {
|
|
|
485
502
|
console.error(`scenescout check: could not measure ${options.url}: ${unmeasured}`);
|
|
486
503
|
process.exit(EXIT.error);
|
|
487
504
|
}
|
|
505
|
+
const refused = refusedFlowReason(result);
|
|
506
|
+
// --on-refused-step stop: no verdict is written at all.
|
|
507
|
+
if (refused && options.onRefusedStep === "stop") {
|
|
508
|
+
console.error(`scenescout check: could not run a saved flow: ${refused}`);
|
|
509
|
+
process.exit(EXIT.error);
|
|
510
|
+
}
|
|
488
511
|
const markdown = formatCheck(result);
|
|
489
512
|
try {
|
|
490
513
|
const version = packageVersion();
|
|
@@ -505,7 +528,10 @@ async function check(args) {
|
|
|
505
528
|
}
|
|
506
529
|
console.log("\n" + markdown);
|
|
507
530
|
console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
|
|
508
|
-
|
|
531
|
+
// --on-refused-step report: everything else has its verdict in the files, and the exit code still says the run was incomplete.
|
|
532
|
+
if (refused)
|
|
533
|
+
console.error(`scenescout check: could not run a saved flow: ${refused}`);
|
|
534
|
+
process.exit(exitCodeOf(result));
|
|
509
535
|
}
|
|
510
536
|
const [, , command, ...args] = process.argv;
|
|
511
537
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
package/dist/engine/bench.js
CHANGED
|
@@ -79,6 +79,19 @@ const NonDefect = z
|
|
|
79
79
|
counterExamples: z.array(z.string()).default([]),
|
|
80
80
|
})
|
|
81
81
|
.strict();
|
|
82
|
+
const Contextual = z
|
|
83
|
+
.object({
|
|
84
|
+
id: z.string().min(1),
|
|
85
|
+
route: z.string().startsWith("/"),
|
|
86
|
+
title: z.string().min(1),
|
|
87
|
+
category: z.string().min(1),
|
|
88
|
+
/** The convention that decides it: where it would be a defect, and why the run cannot tell whether it holds here. */
|
|
89
|
+
reason: z.string().min(1),
|
|
90
|
+
match: z.array(Pattern).min(1),
|
|
91
|
+
examples: z.array(z.string()).default([]),
|
|
92
|
+
counterExamples: z.array(z.string()).default([]),
|
|
93
|
+
})
|
|
94
|
+
.strict();
|
|
82
95
|
const Key = z
|
|
83
96
|
.object({
|
|
84
97
|
app: z.string().min(1),
|
|
@@ -92,6 +105,14 @@ const Key = z
|
|
|
92
105
|
*/
|
|
93
106
|
alsoReal: z.array(Entry).default([]),
|
|
94
107
|
nonDefects: z.array(NonDefect).default([]),
|
|
108
|
+
/**
|
|
109
|
+
* Observations that are defects only under a convention the run cannot
|
|
110
|
+
* see — a spacing scale the project may or may not declare, say. Neither
|
|
111
|
+
* lane is wrong about them, so they are set aside: a finding matching one
|
|
112
|
+
* is neither correct nor a false positive, and a verdict on one is not
|
|
113
|
+
* scored for calibration.
|
|
114
|
+
*/
|
|
115
|
+
contextual: z.array(Contextual).default([]),
|
|
95
116
|
})
|
|
96
117
|
.strict();
|
|
97
118
|
/**
|
|
@@ -105,7 +126,7 @@ export function parseKey(raw) {
|
|
|
105
126
|
const issue = parsed.error.issues[0];
|
|
106
127
|
throw new Error(`The answer key is not valid at ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
107
128
|
}
|
|
108
|
-
const ids = [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.nonDefects].map((e) => e.id);
|
|
129
|
+
const ids = [...parsed.data.defects, ...parsed.data.alsoReal, ...parsed.data.nonDefects, ...parsed.data.contextual].map((e) => e.id);
|
|
109
130
|
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
|
|
110
131
|
if (dup)
|
|
111
132
|
throw new Error(`The answer key uses the id ${JSON.stringify(dup)} twice.`);
|
|
@@ -119,7 +140,7 @@ export function parseKey(raw) {
|
|
|
119
140
|
}
|
|
120
141
|
/** A short, stable fingerprint of a key, so a scorecard says which key produced it. */
|
|
121
142
|
export function keyHash(key) {
|
|
122
|
-
const canonical = JSON.stringify({ defects: key.defects, alsoReal: key.alsoReal, nonDefects: key.nonDefects });
|
|
143
|
+
const canonical = JSON.stringify({ defects: key.defects, alsoReal: key.alsoReal, nonDefects: key.nonDefects, contextual: key.contextual });
|
|
123
144
|
return createHash("sha256").update(canonical).digest("hex").slice(0, 10);
|
|
124
145
|
}
|
|
125
146
|
const RANK = { minimal: 0, medium: 1, extensive: 2 };
|
|
@@ -138,12 +159,19 @@ function matches(patterns, text) {
|
|
|
138
159
|
* match would quietly count a finding about two defects as one, and letting a
|
|
139
160
|
* non-defect win everywhere turned realistic rewordings of real defects into
|
|
140
161
|
* false positives with a confident explanation beside them.
|
|
162
|
+
*
|
|
163
|
+
* A contextual entry is one more claimant on the same terms as a real one: it
|
|
164
|
+
* wins over nothing, no non-defect can override it, and text it shares with
|
|
165
|
+
* any other entry is ambiguous. Setting a finding aside removes it from
|
|
166
|
+
* precision, so a pattern that let it win would hide real defects from the
|
|
167
|
+
* score as quietly as a greedy non-defect once did.
|
|
141
168
|
*/
|
|
142
169
|
export function classify(text, key) {
|
|
143
170
|
const nd = key.nonDefects.filter((e) => matches(e.match, text));
|
|
144
171
|
const real = [
|
|
145
172
|
...key.defects.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "defect", entry })),
|
|
146
173
|
...key.alsoReal.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "alsoReal", entry })),
|
|
174
|
+
...key.contextual.filter((e) => matches(e.match, text)).map((entry) => ({ kind: "contextual", entry })),
|
|
147
175
|
];
|
|
148
176
|
if (nd.length > 1)
|
|
149
177
|
return { kind: "ambiguous", ids: [...nd, ...real.map((m) => m.entry)].map((e) => e.id) };
|
|
@@ -171,7 +199,7 @@ export function decisionText(d) {
|
|
|
171
199
|
* A key that disagrees with its own examples is wrong before any run is
|
|
172
200
|
* scored. Returns every title or example — non-defects' included — that
|
|
173
201
|
* classifies anywhere but its own entry, and every counter-example that
|
|
174
|
-
* classifies TO its entry.
|
|
202
|
+
* classifies TO its entry. Contextual entries are held to the same rule.
|
|
175
203
|
*/
|
|
176
204
|
export function lintKey(key) {
|
|
177
205
|
const problems = [];
|
|
@@ -195,11 +223,16 @@ export function lintKey(key) {
|
|
|
195
223
|
check(e.id, kind, t, false);
|
|
196
224
|
}
|
|
197
225
|
}
|
|
198
|
-
for (const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
226
|
+
for (const [kind, list] of [
|
|
227
|
+
["nonDefect", key.nonDefects],
|
|
228
|
+
["contextual", key.contextual],
|
|
229
|
+
]) {
|
|
230
|
+
for (const e of list) {
|
|
231
|
+
for (const t of [e.title, ...e.examples])
|
|
232
|
+
check(e.id, kind, t, true);
|
|
233
|
+
for (const t of e.counterExamples)
|
|
234
|
+
check(e.id, kind, t, false);
|
|
235
|
+
}
|
|
203
236
|
}
|
|
204
237
|
return problems;
|
|
205
238
|
}
|
|
@@ -208,8 +241,8 @@ export function lintKey(key) {
|
|
|
208
241
|
*
|
|
209
242
|
* "defect" on a planted or also-real defect is right, and on a known
|
|
210
243
|
* non-defect is wrong. "not_a_defect" is the reverse. An "unsure" verdict, a
|
|
211
|
-
* dismissal as another lane's, a decision the key does not name,
|
|
212
|
-
* names ambiguously are not scored — the same rule the in-product calibration
|
|
244
|
+
* dismissal as another lane's, a decision the key does not name, one it
|
|
245
|
+
* names ambiguously, and one about a contextual entry are not scored — the same rule the in-product calibration
|
|
213
246
|
* follows, for the same reason.
|
|
214
247
|
*/
|
|
215
248
|
export function judgeDecision(d, key) {
|
|
@@ -218,7 +251,7 @@ export function judgeDecision(d, key) {
|
|
|
218
251
|
if (isScopeDismissal(d))
|
|
219
252
|
return null;
|
|
220
253
|
const m = classify(decisionText(d), key);
|
|
221
|
-
if (!m || m.kind === "ambiguous")
|
|
254
|
+
if (!m || m.kind === "ambiguous" || m.kind === "contextual")
|
|
222
255
|
return null;
|
|
223
256
|
const isDefect = m.kind !== "nonDefect";
|
|
224
257
|
return d.verdict === "defect" ? isDefect : !isDefect;
|
|
@@ -233,7 +266,19 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
233
266
|
if (decisions.length === 0)
|
|
234
267
|
return null;
|
|
235
268
|
const buckets = BUCKET_EDGES.map(() => ({ n: 0, conf: 0, right: 0 }));
|
|
236
|
-
const out = {
|
|
269
|
+
const out = {
|
|
270
|
+
judged: 0,
|
|
271
|
+
correct: 0,
|
|
272
|
+
notInKey: 0,
|
|
273
|
+
ambiguous: 0,
|
|
274
|
+
badConfidence: 0,
|
|
275
|
+
unsure: 0,
|
|
276
|
+
outOfScope: 0,
|
|
277
|
+
contextual: 0,
|
|
278
|
+
buckets: [],
|
|
279
|
+
ece: 0,
|
|
280
|
+
brier: 0,
|
|
281
|
+
};
|
|
237
282
|
for (const d of decisions) {
|
|
238
283
|
if (d.verdict === "unsure") {
|
|
239
284
|
out.unsure += 1;
|
|
@@ -257,6 +302,10 @@ export function calibrateAgainstKey(decisions, key) {
|
|
|
257
302
|
out.ambiguous += 1;
|
|
258
303
|
continue;
|
|
259
304
|
}
|
|
305
|
+
if (m.kind === "contextual") {
|
|
306
|
+
out.contextual += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
260
309
|
const right = d.verdict === "defect" ? m.kind !== "nonDefect" : m.kind === "nonDefect";
|
|
261
310
|
out.judged += 1;
|
|
262
311
|
if (right)
|
|
@@ -290,6 +339,7 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
290
339
|
const falsePositives = [];
|
|
291
340
|
const unknown = [];
|
|
292
341
|
const ambiguous = [];
|
|
342
|
+
const contextual = [];
|
|
293
343
|
let correct = 0;
|
|
294
344
|
for (const f of findings) {
|
|
295
345
|
const m = classify(findingText(f), key);
|
|
@@ -305,6 +355,10 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
305
355
|
falsePositives.push({ id: m.entry.id, title: f.title, why: m.entry.why, severity: f.severity });
|
|
306
356
|
continue;
|
|
307
357
|
}
|
|
358
|
+
if (m.kind === "contextual") {
|
|
359
|
+
contextual.push({ id: m.entry.id, title: f.title });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
308
362
|
correct += 1;
|
|
309
363
|
if (m.kind === "defect") {
|
|
310
364
|
const list = hitsByDefect.get(m.entry.id) ?? [];
|
|
@@ -355,6 +409,7 @@ export function score(key, findings, decisions, level = "medium") {
|
|
|
355
409
|
findings: findings.length,
|
|
356
410
|
correct,
|
|
357
411
|
falsePositives,
|
|
412
|
+
contextual,
|
|
358
413
|
unknown,
|
|
359
414
|
ambiguous,
|
|
360
415
|
duplicates,
|
|
@@ -367,12 +422,14 @@ const pct = (n, d) => (d === 0 ? "—" : `${Math.round((n / d) * 100)}%`);
|
|
|
367
422
|
* Precision, with its bounds. The labelled ratio leaves unlabelled findings out
|
|
368
423
|
* of the denominator, so on its own it cannot move when a change adds five new
|
|
369
424
|
* false claims nobody has judged yet. The bounds can: the lower one counts
|
|
370
|
-
* every open finding as wrong, the upper one as right.
|
|
425
|
+
* every open finding as wrong, the upper one as right. Findings set aside as
|
|
426
|
+
* contextual are in neither: they are not claims the run could get right.
|
|
371
427
|
*/
|
|
372
428
|
export function precisionBounds(c) {
|
|
373
429
|
const labelled = c.correct + c.falsePositives.length;
|
|
374
430
|
const open = c.unknown.length + c.ambiguous.length;
|
|
375
|
-
|
|
431
|
+
const scored = c.findings - c.contextual.length;
|
|
432
|
+
return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, scored), high: pct(c.correct + open, scored) };
|
|
376
433
|
}
|
|
377
434
|
/** The scorecard as a person reads it. Leads with the two numbers, then says what each is made of. */
|
|
378
435
|
export function formatScorecard(c) {
|
|
@@ -383,9 +440,13 @@ export function formatScorecard(c) {
|
|
|
383
440
|
``,
|
|
384
441
|
`Recall ${c.found.length}/${c.expected} (${pct(c.found.length, c.expected)}) of the planted defects expected at this level`,
|
|
385
442
|
`Precision ${p.labelled} of the findings the key can label` +
|
|
386
|
-
(open
|
|
387
|
-
|
|
443
|
+
(open
|
|
444
|
+
? ` — ${open} of ${c.findings - c.contextual.length} unlabelled, so between ${p.low} and ${p.high} of ${c.contextual.length ? "the findings not set aside" : "all findings"}`
|
|
445
|
+
: ""),
|
|
388
446
|
];
|
|
447
|
+
if (c.contextual.length)
|
|
448
|
+
lines.push(`Set aside ${c.contextual.length} of ${c.findings} finding(s): defects only under a convention the run cannot see, so in neither count`);
|
|
449
|
+
lines.push(``);
|
|
389
450
|
if (c.missed.length)
|
|
390
451
|
lines.push(`Missed: ${c.missed.join(", ")}`);
|
|
391
452
|
if (c.judgedNotFiled.length)
|
|
@@ -397,6 +458,11 @@ export function formatScorecard(c) {
|
|
|
397
458
|
for (const fp of c.falsePositives)
|
|
398
459
|
lines.push(` [${fp.severity}] ${fp.title} — ${fp.why}`);
|
|
399
460
|
}
|
|
461
|
+
if (c.contextual.length) {
|
|
462
|
+
lines.push(``, `Set aside as contextual (${c.contextual.length}):`);
|
|
463
|
+
for (const x of c.contextual)
|
|
464
|
+
lines.push(` ${x.title} (${x.id})`);
|
|
465
|
+
}
|
|
400
466
|
if (c.ambiguous.length) {
|
|
401
467
|
lines.push(``, `Ambiguous (${c.ambiguous.length}) — the key claims each twice; sharpen it:`);
|
|
402
468
|
for (const a of c.ambiguous)
|
|
@@ -425,6 +491,7 @@ export function formatScorecard(c) {
|
|
|
425
491
|
k.badConfidence && `${k.badConfidence} with an unusable confidence`,
|
|
426
492
|
k.unsure && `${k.unsure} unsure`,
|
|
427
493
|
k.outOfScope && `${k.outOfScope} dismissed as another lane's`,
|
|
494
|
+
k.contextual && `${k.contextual} about things that are defects only under a convention the run cannot see`,
|
|
428
495
|
].filter(Boolean);
|
|
429
496
|
lines.push(``, k.judged === 0
|
|
430
497
|
? `Lane calibration against the key: nothing the key could judge` + (skipped.length ? ` (${skipped.join(", ")})` : "")
|
|
@@ -460,6 +527,13 @@ export function runDate(decisions, given) {
|
|
|
460
527
|
throw new Error(`The run's last decision was made on ${last}; it cannot be dated ${given}.`);
|
|
461
528
|
return given ?? last;
|
|
462
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* The order `--all` lists runs in: by date, then by name with its numbers
|
|
532
|
+
* compared as numbers, so run-10 follows run-9 rather than run-1.
|
|
533
|
+
*/
|
|
534
|
+
export function byRunOrder(a, b) {
|
|
535
|
+
return a.date.localeCompare(b.date) || a.run.localeCompare(b.run, undefined, { numeric: true });
|
|
536
|
+
}
|
|
463
537
|
/**
|
|
464
538
|
* Paths into a machine's own directories, which a finding's evidence can pick
|
|
465
539
|
* up from a stack trace or an upload. An archive is committed, and a home
|
|
@@ -469,8 +543,9 @@ const LOCAL_PATH_RE = /(?:\/Users\/[^/\s"']+|\/home\/[^/\s"']+|\/private\/tmp|\/
|
|
|
469
543
|
export function sanitize(text) {
|
|
470
544
|
return text.replace(LOCAL_PATH_RE, "<path>");
|
|
471
545
|
}
|
|
472
|
-
export function toArchive(run, date, note, findings, decisions) {
|
|
546
|
+
export function toArchive(run, date, note, findings, decisions, app) {
|
|
473
547
|
return {
|
|
548
|
+
app,
|
|
474
549
|
run,
|
|
475
550
|
date,
|
|
476
551
|
note,
|
|
@@ -483,3 +558,65 @@ export function toArchive(run, date, note, findings, decisions) {
|
|
|
483
558
|
decisions: decisions.map((d) => ({ ...d, observation: sanitize(d.observation), evidence: d.evidence === null ? null : sanitize(d.evidence) })),
|
|
484
559
|
};
|
|
485
560
|
}
|
|
561
|
+
// ── which app, and so which key ─────────────────────────────────────────────
|
|
562
|
+
/**
|
|
563
|
+
* The app a run is for when nothing says otherwise. Every archive made before
|
|
564
|
+
* there was a second benchmark app is this one's, and carries no `app`.
|
|
565
|
+
*/
|
|
566
|
+
export const DEFAULT_APP = "demo";
|
|
567
|
+
/** The app an archive was made against. */
|
|
568
|
+
export function archiveApp(a) {
|
|
569
|
+
return a.app ?? DEFAULT_APP;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The app whose key scores a run. Scoring a run against another app's key
|
|
573
|
+
* produces a plausible wrong number — every planted defect "missed", every
|
|
574
|
+
* finding "unlabelled" — so a request that disagrees with what the archive
|
|
575
|
+
* records is refused rather than obeyed.
|
|
576
|
+
*
|
|
577
|
+
* `requested` is what the caller asked for (`--app`), `archived` what the
|
|
578
|
+
* run's archive records, and `known` the apps that have a key.
|
|
579
|
+
*/
|
|
580
|
+
export function chooseApp(opts) {
|
|
581
|
+
const { requested, archived, known } = opts;
|
|
582
|
+
for (const [what, app] of [
|
|
583
|
+
["--app", requested],
|
|
584
|
+
["The archive's app", archived],
|
|
585
|
+
]) {
|
|
586
|
+
if (app !== undefined && !known.includes(app))
|
|
587
|
+
throw new Error(`${what} ${JSON.stringify(app)} is not a benchmark app; the apps are ${known.join(", ")}.`);
|
|
588
|
+
}
|
|
589
|
+
if (requested !== undefined && archived !== undefined && requested !== archived)
|
|
590
|
+
throw new Error(`This run was archived for the ${archived} app; scoring it against the ${requested} app's key would score the wrong answers.`);
|
|
591
|
+
return requested ?? archived ?? DEFAULT_APP;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Which benchmark app a key file belongs to, by the name the key gives its
|
|
595
|
+
* app, or undefined for a key no benchmark app ships (a draft, a test key).
|
|
596
|
+
* A copy of one app's key, edited or not, still names that app.
|
|
597
|
+
*/
|
|
598
|
+
export function appOfKey(key, keys) {
|
|
599
|
+
return Object.keys(keys).find((app) => keys[app].app === key.app);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Refuse a key file that belongs to another benchmark app than the archive
|
|
603
|
+
* being scored: `--key` naming the held-out key scored a demo archive against
|
|
604
|
+
* the wrong answers without a word.
|
|
605
|
+
*/
|
|
606
|
+
export function checkKeyForArchive(key, archive, keys) {
|
|
607
|
+
const keyApp = appOfKey(key, keys);
|
|
608
|
+
const runApp = archiveApp(archive);
|
|
609
|
+
if (keyApp !== undefined && keyApp !== runApp)
|
|
610
|
+
throw new Error(`This run was archived for the ${runApp} app, and that key is the ${keyApp} app's; scoring one against the other would score the wrong answers.`);
|
|
611
|
+
}
|
|
612
|
+
/** Archives grouped by app, in the order each app is first met, each group in `byRunOrder`. */
|
|
613
|
+
export function groupByApp(archives) {
|
|
614
|
+
const groups = new Map();
|
|
615
|
+
for (const a of archives) {
|
|
616
|
+
const app = archiveApp(a);
|
|
617
|
+
groups.set(app, [...(groups.get(app) ?? []), a]);
|
|
618
|
+
}
|
|
619
|
+
for (const list of groups.values())
|
|
620
|
+
list.sort(byRunOrder);
|
|
621
|
+
return groups;
|
|
622
|
+
}
|