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 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, or only the sign-in page reached |
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
- It never writes to the app (`--mode observe` or `read-only`), and 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:
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
- The check fills no forms and follows no flows. That is the exploratory run's job, and its findings belong in a report, not a gate.
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, and write the verdict. The rules live in engine/check.ts;
4
- * this file only drives the browser and the files.
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
- export async function runCheck(options, log = () => { }) {
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, summarise, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
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
- process.exit(summarise(result).passed ? EXIT.pass : EXIT.gateFailed);
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
@@ -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 e of key.nonDefects) {
199
- for (const t of [e.title, ...e.examples])
200
- check(e.id, "nonDefect", t, true);
201
- for (const t of e.counterExamples)
202
- check(e.id, "nonDefect", t, false);
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, and one it
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 = { judged: 0, correct: 0, notInKey: 0, ambiguous: 0, badConfidence: 0, unsure: 0, outOfScope: 0, buckets: [], ece: 0, brier: 0 };
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
- return { labelled: `${c.correct}/${labelled} (${pct(c.correct, labelled)})`, low: pct(c.correct, c.findings), high: pct(c.correct + open, c.findings) };
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 ? ` — ${open} of ${c.findings} unlabelled, so between ${p.low} and ${p.high} of all findings` : ""),
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
+ }