a11ign 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +140 -0
  3. package/dist/action/post-comment.d.ts +20 -0
  4. package/dist/action/post-comment.d.ts.map +1 -0
  5. package/dist/action/post-comment.js +93 -0
  6. package/dist/action/post-comment.js.map +1 -0
  7. package/dist/action/run.d.ts +2 -0
  8. package/dist/action/run.d.ts.map +1 -0
  9. package/dist/action/run.js +119 -0
  10. package/dist/action/run.js.map +1 -0
  11. package/dist/action/summary.d.ts +180 -0
  12. package/dist/action/summary.d.ts.map +1 -0
  13. package/dist/action/summary.js +355 -0
  14. package/dist/action/summary.js.map +1 -0
  15. package/dist/cli.d.ts +279 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +1028 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/fault-remediation.d.ts +87 -0
  20. package/dist/fault-remediation.d.ts.map +1 -0
  21. package/dist/fault-remediation.js +156 -0
  22. package/dist/fault-remediation.js.map +1 -0
  23. package/dist/forms/config.d.ts +69 -0
  24. package/dist/forms/config.d.ts.map +1 -0
  25. package/dist/forms/config.js +185 -0
  26. package/dist/forms/config.js.map +1 -0
  27. package/dist/forms/coverage.d.ts +51 -0
  28. package/dist/forms/coverage.d.ts.map +1 -0
  29. package/dist/forms/coverage.js +80 -0
  30. package/dist/forms/coverage.js.map +1 -0
  31. package/dist/forms/draft.d.ts +62 -0
  32. package/dist/forms/draft.d.ts.map +1 -0
  33. package/dist/forms/draft.js +165 -0
  34. package/dist/forms/draft.js.map +1 -0
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +9 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/report.d.ts +48 -0
  40. package/dist/report.d.ts.map +1 -0
  41. package/dist/report.js +322 -0
  42. package/dist/report.js.map +1 -0
  43. package/dist/scan/axe-results.d.ts +25 -0
  44. package/dist/scan/axe-results.d.ts.map +1 -0
  45. package/dist/scan/axe-results.js +111 -0
  46. package/dist/scan/axe-results.js.map +1 -0
  47. package/dist/scan/axe.d.ts +158 -0
  48. package/dist/scan/axe.d.ts.map +1 -0
  49. package/dist/scan/axe.js +203 -0
  50. package/dist/scan/axe.js.map +1 -0
  51. package/dist/scan/page-title.d.ts +3 -0
  52. package/dist/scan/page-title.d.ts.map +1 -0
  53. package/dist/scan/page-title.js +41 -0
  54. package/dist/scan/page-title.js.map +1 -0
  55. package/dist/scan/run-axe.d.ts +2 -0
  56. package/dist/scan/run-axe.d.ts.map +1 -0
  57. package/dist/scan/run-axe.js +39 -0
  58. package/dist/scan/run-axe.js.map +1 -0
  59. package/package.json +60 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1028 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * a11ign CLI (control plane).
4
+ *
5
+ * Runs the whole pipeline in one command: ask a capture worker to drive a real
6
+ * screen reader through the page, then judge the announcement transcript here
7
+ * (the judge is our OWN trained scorer by default -- `JUDGE_BACKEND` has defaulted to `local` since
8
+ * 2026-08-04, so there is no metered API cost and no rented model in the path. This line said "the
9
+ * local Codex login", which was true of the previous default and had outlived it; `codex`,
10
+ * `anthropic` and `openai` remain available for comparison and are never the default.)
11
+ *
12
+ * Usage:
13
+ * npm run witness -- <url> --task "..." [--worker http://host:port] [--json]
14
+ * The worker URL also reads from A11Y_WORKER.
15
+ *
16
+ * With neither set, the run manages a local UTM worker VM on demand: it starts one if
17
+ * needed and puts it back how it found it afterwards. See leaseWorker in @a11ign/worker-fleet.
18
+ * Set A11Y_SHADOW_MODEL=1 to run the verified local screen-reader scorer beside the existing
19
+ * judge. Shadow output is log-only and never changes findings.
20
+ */
21
+ import { spawn } from "node:child_process";
22
+ import { pathToFileURL } from "node:url";
23
+ import { judge } from "@a11ign/judge";
24
+ import { scanWithAxe, axeAvailable } from "./scan/axe.js";
25
+ import { fetchPageTitle } from "./scan/page-title.js";
26
+ import { loadAxeResults, warnOnUrlMismatch } from "./scan/axe-results.js";
27
+ import { layerOf } from "@a11ign/judge/layers";
28
+ import { reportLines } from "./report.js";
29
+ import { formatFaultMessage, formatDoubtMessage, formatEarlyContainmentNotice } from "./fault-remediation.js";
30
+ import { leaseWorker, isAfterRun } from "@a11ign/worker-fleet";
31
+ import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "@a11ign/worker-fleet/worker-http";
32
+ import { captureTolerantly } from "@a11ign/worker-fleet/capture-client";
33
+ import { workerIsUsable } from "@a11ign/worker-fleet/health";
34
+ // `annotateCapture` is a VALUE (the shadow scorer calls it); the rest are types. Split rather than
35
+ // combined into one `import {...}` so `import type` stays type-only and cannot pull evidence into a
36
+ // runtime graph that does not need it.
37
+ import { annotateCapture, leftSite, withinTheSite } from "@a11ign/evidence";
38
+ import { captureDoubt, captureMentionsTitle, oracleCounts, earlyContainmentVerdict } from "@a11ign/evidence/verify";
39
+ import { scorerPaths as scorerArtefact } from "@a11ign/scorer";
40
+ import { conformanceScope, sweepOutcomes, truncatedSweeps, censusFromDiagnostics, censusElementCounts, activationBudgetFromDiagnostics, censusCountsDistinctNames, censusTargetMismatchReason } from "@a11ign/evidence/conformance";
41
+ import { assessedCriteria } from "@a11ign/judge/coverage";
42
+ import { earlReport } from "@a11ign/evidence/earl";
43
+ import { documentIdentity } from "@a11ign/evidence/document-identity";
44
+ import { criterionOutcomes } from "@a11ign/judge/outcomes";
45
+ import { realpathSync, mkdirSync, writeFileSync } from "node:fs";
46
+ import { readFile } from "node:fs/promises";
47
+ import { relative, resolve as resolvePath } from "node:path";
48
+ import { parseFormsConfig, refuseIfWrongOrigin, FormsConfigError } from "./forms/config.js";
49
+ import { submissionPlan, formCoverage } from "./forms/coverage.js";
50
+ import { draftFormsConfig } from "./forms/draft.js";
51
+ function parsedAfterRun() {
52
+ const v = process.env.A11Y_VM_AFTER;
53
+ if (!v)
54
+ return "restore";
55
+ if (isAfterRun(v))
56
+ return v;
57
+ console.error(`A11Y_VM_AFTER must be restore|stop|pause|leave (got "${v}")`);
58
+ process.exit(1);
59
+ }
60
+ const USAGE = 'Usage: npm run witness -- <url> --task "..." [--worker http://host:port] ' +
61
+ "[--after restore|stop|pause|leave] [--json] [--debug] [--probe-forms] [--no-probe-focus] "
62
+ + "[--no-probe-navigation] [--no-probe-focus-context] "
63
+ + "[--forms <file>] [--emit-form-config] [--plan] "
64
+ + "[--no-axe] [--axe-results <file>] [--no-keep]";
65
+ function defaultArgs() {
66
+ return {
67
+ url: "",
68
+ task: "Read and understand this page",
69
+ worker: process.env.A11Y_WORKER ?? null,
70
+ after: parsedAfterRun(),
71
+ json: false,
72
+ debug: false,
73
+ // OFF here, ON in the GitHub Action, and the asymmetry is deliberate. `--probe-forms` ACTIVATES
74
+ // controls: a submit-like button, or one your task names. A workflow runs against your own
75
+ // application, where submitting is the intended act and an unannounced error is only reachable by
76
+ // submitting. This CLI can be aimed at any URL on the internet, and pressing *Book* or *Send* on
77
+ // somebody else's production site is not a review. So the risky default follows who owns the page.
78
+ probeForms: false,
79
+ // ON, unlike probe-forms, and the difference is side effects: Tab moves focus and activates nothing,
80
+ // so it is safe on a page you do not own. 2.1.2 is also a NON-INTERFERENCE criterion — WCAG §5.2.5
81
+ // applies it to all content whether or not it is relied upon — and a keyboard trap is total: a user
82
+ // who cannot leave a control cannot use the rest of the page. It costs ~8 s per capture.
83
+ probeFocus: true,
84
+ // ON, on the same side of the consent line as `probeFocus` and for the same reason: following a
85
+ // link is ordinary browsing -- the thing this tool already did to reach the page -- where
86
+ // submitting a form writes to somebody's system. On essentially every real page the first link IS
87
+ // the skip link, which is exactly what 2.4.1 exists to test.
88
+ //
89
+ // These two defaulted to ABSENT until 2026-09-02, and the cost was the shape this repo names most
90
+ // often: a gate that does not exercise what ships. `capture-real-pages.mjs` has set both since
91
+ // 2026-08-24, so the 86-conformant-page validation behind `addInertSkipLink`, `addStaleRouteTitle`
92
+ // and 3.2.1 was gathered with flags THE PRODUCT COULD NOT SEND. Three criteria the README headlines
93
+ // as unreachable by a static analyser were unreachable by this CLI too, silently, because an
94
+ // un-asked probe returns an empty channel and an empty channel is what a clean page looks like.
95
+ // `observed` is why that was merely invisible rather than a false pass -- it recorded `asked: false`
96
+ // the whole time, and nothing in the product read it back to the user.
97
+ probeNavigation: true,
98
+ probeFocusContext: true,
99
+ // ON as of 2026-09-05, and the FIRST probe here that presses ESCAPE on a page the user does not own.
100
+ // The Tab half is free — `probeFocus` already walks the whole ring. Escape is the new keystroke and
101
+ // it sits with Tab rather than with typing: it enters nothing into a field, submits nothing, and
102
+ // writes to nobody's system; the most it can do is dismiss a dialog, which is a thing a visitor does.
103
+ //
104
+ // SET HERE AND IN `capture-real-pages.mjs` IN THE SAME COMMIT, because `probe-consent.test.ts`
105
+ // requires it and its own header says what happens otherwise: those two copies drifted for nine days
106
+ // and three criteria were validated on real pages through a path the product does not take.
107
+ probeFocusReveal: true,
108
+ formsConfig: null,
109
+ emitFormConfig: false,
110
+ plan: false,
111
+ axe: process.env.A11Y_AXE !== "0",
112
+ axeResults: process.env.A11Y_AXE_RESULTS ?? null,
113
+ keep: true,
114
+ };
115
+ }
116
+ // Applies one argument and returns the index it consumed up to, so value-taking flags can
117
+ // swallow their value. Split out of parseArgs to keep each side simple: this one knows the
118
+ // flags, parseArgs knows the loop and the validation.
119
+ /**
120
+ * Apply one argv token. EXPORTED for tests, and that is the whole reason this file had none: it exported
121
+ * nothing, so nothing could import it. Argument handling is pure and this project has already paid for
122
+ * getting it wrong — `--worker=http://:8765` was accepted and burned 29 minutes before anything noticed.
123
+ *
124
+ * The two halves are split by SHAPE, not by taste: a flag that swallows the next token has to move `i`,
125
+ * and one that does not cannot. Keeping the value-taking four in the switch means `i` is only reassigned
126
+ * where that is the point, and the boolean flags become a table that grows without touching control flow
127
+ * — which is what pushed this function past the complexity gate when 3.2.1's and 2.4.1's arrived.
128
+ */
129
+ const BOOLEAN_FLAGS = Object.freeze({
130
+ "--json": (a) => { a.json = true; },
131
+ "--debug": (a) => { a.debug = true; },
132
+ "--probe-forms": (a) => { a.probeForms = true; },
133
+ "--no-probe-focus": (a) => { a.probeFocus = false; },
134
+ "--no-probe-navigation": (a) => { a.probeNavigation = false; },
135
+ "--no-probe-focus-context": (a) => { a.probeFocusContext = false; },
136
+ "--no-axe": (a) => { a.axe = false; },
137
+ "--emit-form-config": (a) => { a.emitFormConfig = true; },
138
+ "--plan": (a) => { a.plan = true; },
139
+ "--no-keep": (a) => { a.keep = false; },
140
+ });
141
+ export function applyArg(args, argv, i) {
142
+ const v = argv[i];
143
+ const setBoolean = BOOLEAN_FLAGS[v];
144
+ if (setBoolean) {
145
+ setBoolean(args);
146
+ return i;
147
+ }
148
+ switch (v) {
149
+ case "--task":
150
+ args.task = argv[++i] ?? args.task;
151
+ return i;
152
+ case "--worker":
153
+ args.worker = argv[++i] ?? args.worker;
154
+ return i;
155
+ case "--after":
156
+ args.after = afterRunArg(argv[++i]);
157
+ return i;
158
+ case "--axe-results":
159
+ args.axeResults = argv[++i] ?? args.axeResults;
160
+ return i;
161
+ case "--forms":
162
+ args.formsConfig = argv[++i] ?? args.formsConfig;
163
+ return i;
164
+ default:
165
+ if (!v.startsWith("--"))
166
+ args.url = v;
167
+ return i;
168
+ }
169
+ }
170
+ /** ARGV as a parameter, so a test needs no `process.argv`, and `main()` passes the real one. */
171
+ export function parseArgs(argv = process.argv.slice(2)) {
172
+ const args = defaultArgs();
173
+ for (let i = 0; i < argv.length; i++)
174
+ i = applyArg(args, argv, i);
175
+ if (!args.url) {
176
+ console.error(USAGE);
177
+ process.exit(1);
178
+ }
179
+ return args;
180
+ }
181
+ function afterRunArg(v) {
182
+ if (v && isAfterRun(v))
183
+ return v;
184
+ console.error(`--after must be restore|stop|pause|leave (got "${v ?? ""}")`);
185
+ process.exit(1);
186
+ }
187
+ const MAX_CAPTURE_ATTEMPTS = 3;
188
+ /**
189
+ * Where the shadow scorer lives — same shape as `local-judge.ts`'s `scorerPaths()`, and for the same
190
+ * reason: the SCRIPT comes from `@a11ign/scorer`, resolved from its own module, so it never
191
+ * depended on the cwd. The INTERPRETER did: it defaulted to `packages/cli/.venv/bin/python`, a path
192
+ * nothing ever creates — the same defect M0 found in `local-judge.ts`'s own interpreter default, here
193
+ * one level removed. `local-judge.ts` settled on `A11Y_PYTHON` (falling back to `python3` on the PATH)
194
+ * as the shared answer; this mirrors it, with `A11Y_SHADOW_PYTHON` still able to point the shadow run at
195
+ * a different interpreter from the real judge's when that is deliberate.
196
+ *
197
+ * A function, not a module-level constant, so a test can assert the resolution the same way
198
+ * `local-judge.paths.test.ts` does — the module-level version could only ever be checked against
199
+ * whatever `process.env` happened to hold at import time.
200
+ */
201
+ export function shadowScorerPaths() {
202
+ return {
203
+ python: process.env.A11Y_SHADOW_PYTHON ?? process.env.A11Y_PYTHON ?? "python3",
204
+ script: scorerArtefact().scoreScript,
205
+ };
206
+ }
207
+ /**
208
+ * `--plan`: say what would be submitted, and submit nothing.
209
+ *
210
+ * BEFORE the worker is leased, and that ordering is the whole point. A dry run that first starts a
211
+ * Windows guest has already done something, and the question this answers — "what is this file about to
212
+ * do to my site?" — must be answerable without doing any of it. It is also why this reads the config and
213
+ * nothing else: no capture, no network, no worker.
214
+ *
215
+ * @returns true when the run is over
216
+ */
217
+ async function planOnly(args) {
218
+ if (!args.plan)
219
+ return false;
220
+ if (!args.formsConfig) {
221
+ process.stderr.write("--plan describes what a forms config would submit, so it needs --forms <file>.\n");
222
+ process.exit(2);
223
+ }
224
+ const config = parseFormsConfig(await readFile(args.formsConfig, "utf8"), args.formsConfig);
225
+ // The origin guard runs HERE too, not only on the real path. A plan against the wrong site would print
226
+ // a reassuring page of intentions that describe a run which would have been refused.
227
+ refuseIfWrongOrigin(config, args.url);
228
+ console.log(submissionPlan(config.forms, config.origin).join("\n"));
229
+ return true;
230
+ }
231
+ /**
232
+ * The states this run will drive, in the order it will drive them.
233
+ *
234
+ * ERROR STATES FIRST, then file order. A success submission may navigate away, and the less destructive
235
+ * state should have been observed before the one that completes the form — so a run that dies midway has
236
+ * done the safer thing. `submissionPlan` sorts identically, which matters: a `--plan` that listed a
237
+ * different order from the run would be a dry run describing something else.
238
+ */
239
+ async function configuredStates(args) {
240
+ if (!args.formsConfig)
241
+ return [];
242
+ const config = parseFormsConfig(await readFile(args.formsConfig, "utf8"), args.formsConfig);
243
+ refuseIfWrongOrigin(config, args.url);
244
+ return config.forms.flatMap((form) => {
245
+ for (const line of coverageLines(formCoverage(form)))
246
+ process.stderr.write(`${line}\n`);
247
+ return [...form.states]
248
+ .sort((a, b) => Number(a.state === "success") - Number(b.state === "success"))
249
+ .map((state) => ({ state: state.state, submit: form.submit, fields: state.fields }));
250
+ });
251
+ }
252
+ /**
253
+ * What this configuration can and cannot answer, said BEFORE the run rather than inferred after it.
254
+ *
255
+ * A criterion nobody supplied a state for is not a finding and not a pass; it is unconfigured, and the
256
+ * reader needs to know which of the three they are looking at. Printing it up front also means a
257
+ * misconfigured file is visible before any form is submitted.
258
+ */
259
+ function coverageLines(coverage) {
260
+ return [
261
+ `Form "${coverage.form}" — states configured: ${coverage.states.join(", ") || "none"}`,
262
+ ...coverage.criteria.map((entry) => ` ${entry.criterion} ${entry.readiness}: ${entry.why}`),
263
+ ];
264
+ }
265
+ /** One line, printed before anything else touches the worker, so a wrong guess is visible immediately. */
266
+ function describeSource(source) {
267
+ if (source === "explicit")
268
+ return "A11Y_WORKER";
269
+ if (source === "inventory.yml")
270
+ return "inventory.yml";
271
+ if (source === "local-vm")
272
+ return "local worker VM";
273
+ return "default";
274
+ }
275
+ /** How long the one-shot sanity probe waits for /health before concluding nobody is there. */
276
+ const WORKER_PROBE_TIMEOUT_MS = 5_000;
277
+ /**
278
+ * A one-line reason a stranger can read, from whatever a socket-level failure actually threw.
279
+ *
280
+ * `.message` is EMPTY for a raw `ECONNREFUSED` on this Node version — not a rare edge case, it is the
281
+ * common shape of "nothing is listening" — so printing `error.message` alone produces a bare, unexplained
282
+ * `()`. Measured directly: a real `http.request` failure against a closed port has `message: ""`,
283
+ * `code: "ECONNREFUSED"`. Falls back through `.code`, then `String(error)`, so there is always something.
284
+ */
285
+ export function errorReason(error) {
286
+ const nodeError = error;
287
+ return nodeError?.message || nodeError?.code || String(error);
288
+ }
289
+ /**
290
+ * REFUSE FAST WHEN NOBODY CONFIGURED ANYTHING AND NOTHING IS LISTENING.
291
+ *
292
+ * `source: "default"` means the user set no `A11Y_WORKER`, declared no fleet, and has no local VM --
293
+ * we GUESSED `http://localhost:8765` because the historical local-worker setup uses that address. If a
294
+ * real worker is there, this guess is exactly right and must behave as it always has. If nothing is
295
+ * there, `ECONNREFUSED` is a TRANSIENT network code (`transient-fault.mjs`), so `captureTolerantly`'s
296
+ * lost-acceptance recovery -- correct for a real worker that dropped one socket -- reconciles for the
297
+ * FULL `CAPTURE_CLIENT_TIMEOUT_MS` (620 s) against an address nothing has ever answered. Measured: a
298
+ * first-time user with no worker sees "Scanning ..." and then silence for over ten minutes.
299
+ *
300
+ * The fix is not in the retry classification -- `ECONNREFUSED` really is transient for a worker that
301
+ * might restart, and 620 s is the correct budget for one that legitimately dropped a socket
302
+ * (architecture-audit.md §14.5). The fix is to ask, once, whether anyone is even there before
303
+ * committing to that budget -- which this repo's own capture path could always have done and never did,
304
+ * because nothing upstream of the retry loop knew the address had been GUESSED rather than GIVEN.
305
+ *
306
+ * A response of ANY kind -- 200, busy, not yet ready -- means something is listening at this address,
307
+ * and the existing recovery machinery is exactly the right tool for whatever state it is in. Only a
308
+ * connection that never completes (nothing listening, or a firewall dropping it silently) is refused
309
+ * here; `workerIsUsable` is not the gate; that predicate answers "should I dispatch a capture to this
310
+ * worker RIGHT NOW", not "does an answer exist at all", and conflating the two would refuse a real
311
+ * worker that is merely busy or still warming up -- exactly the documented local-worker-on-8765 setup
312
+ * this must not break.
313
+ */
314
+ async function refuseIfNothingListening(worker) {
315
+ let health;
316
+ try {
317
+ health = (await requestJson(`${worker}/health`, { timeoutMs: WORKER_PROBE_TIMEOUT_MS })).json;
318
+ }
319
+ catch (error) {
320
+ const reason = errorReason(error);
321
+ throw new Error(`No capture worker answered at ${worker} (nothing was configured, so this address was a guess).\n`
322
+ + `A screen reader is a Windows application, so nothing runs here without one. Set A11Y_WORKER to `
323
+ + `point at a worker you have, or see docs/getting-started.md to set one up (~20 minutes with a `
324
+ + `Windows machine already, or use the GitHub Action if you have none).\n(${reason})`, { cause: error });
325
+ }
326
+ // It answered, so something is really there -- proceed exactly as before this fix existed. A worker
327
+ // reporting busy or still warming up is NOT refused: `workerIsUsable` decides whether to DISPATCH a
328
+ // capture right now, which is a different question from whether anyone answered at all, and the
329
+ // existing retry machinery already handles "busy, try again" correctly. Surfaced only as a heads-up,
330
+ // because a user staring at a silent terminal deserves to know the wait has a reason.
331
+ if (!workerIsUsable(health)) {
332
+ process.stderr.write(` (that worker answered but is not immediately ready -- waiting for it)\n`);
333
+ }
334
+ }
335
+ async function main() {
336
+ const args = parseArgs();
337
+ if (await planOnly(args))
338
+ return;
339
+ const lease = await leaseWorker(args);
340
+ process.stderr.write(`Using ${lease.worker} (${describeSource(lease.source)})\n`);
341
+ if (lease.source === "default")
342
+ await refuseIfNothingListening(lease.worker);
343
+ // finally, not a catch: the VM must be released whether the run succeeded, threw, or the
344
+ // judge rejected the capture. Leaking a running Windows guest is the failure mode this
345
+ // whole module exists to prevent.
346
+ try {
347
+ const states = await configuredStates(args);
348
+ if (states.length === 0) {
349
+ await runWitness({ ...args, worker: lease.worker });
350
+ }
351
+ else {
352
+ // ONE RUN PER STATE, through the ordinary pipeline. Reusing `runWitness` rather than building a
353
+ // second reporting path is deliberate: a configured run and an unconfigured one must produce
354
+ // evidence and a report of the same shape, or every consumer downstream needs to know which it is
355
+ // holding — which is the fact-stated-twice defect with a report attached.
356
+ for (const [index, formState] of states.entries()) {
357
+ process.stderr.write(`\n=== form state ${index + 1}/${states.length}: `
358
+ + `"${formState.state}" via "${formState.submit}" ===\n`);
359
+ await runWitness({ ...args, worker: lease.worker, formState });
360
+ }
361
+ }
362
+ }
363
+ finally {
364
+ await lease.release();
365
+ }
366
+ }
367
+ /** Run the local scorer beside the existing judge without changing findings. */
368
+ async function shadowScreenReaderCapture(capture) {
369
+ if (process.env.A11Y_SHADOW_MODEL !== "1")
370
+ return;
371
+ try {
372
+ const { python, script } = shadowScorerPaths();
373
+ const child = spawn(python, [script, "--shadow", "--stdin"], {
374
+ cwd: process.cwd(),
375
+ env: process.env,
376
+ stdio: ["pipe", "pipe", "pipe"],
377
+ });
378
+ let stdout = "";
379
+ let stderr = "";
380
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
381
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
382
+ // Annotated, exactly as `local-judge.ts`'s `scoreCapture` does before it reaches the same script:
383
+ // the featurizer reads the `parsed` block rather than re-deriving it, so unannotated input either
384
+ // fails loudly or scores a shape the real judge path never produces -- either way "beside the
385
+ // existing judge" was comparing two different inputs, not one input through two scorers.
386
+ child.stdin.end(JSON.stringify(annotateCapture(capture)));
387
+ const exitCode = await new Promise((resolveExit, reject) => {
388
+ child.once("error", reject);
389
+ child.once("close", (code) => resolveExit(code ?? 1));
390
+ });
391
+ if (exitCode !== 0) {
392
+ process.stderr.write(`Shadow scorer unavailable; existing judge unchanged. ${stderr.trim()}\n`);
393
+ return;
394
+ }
395
+ const report = JSON.parse(stdout);
396
+ process.stderr.write(`Shadow scorer (${report.mode ?? "unknown"}, ${report.decisionAction ?? "unknown"}): ` +
397
+ JSON.stringify({
398
+ predictedPositiveCounts: report.predictedPositiveCounts ?? {},
399
+ artifact: report.artifact ?? null,
400
+ }) + "\n");
401
+ }
402
+ catch (error) {
403
+ process.stderr.write(`Shadow scorer failed; existing judge unchanged. ${String(error)}\n`);
404
+ }
405
+ }
406
+ /**
407
+ * Say which kind of doubt it is, in words that match the cause — and #398, say what to DO about it, the
408
+ * same WHAT/TRY/WHERE treatment a worker fault gets (`formatDoubtMessage`), not a bare sentence. Telling
409
+ * someone their page "read browser chrome" when a consent dialog held the screen reader inside their page
410
+ * sends them looking in the wrong place entirely; leaving it at that sends them nowhere at all.
411
+ */
412
+ export function warnUnverified(reason, title) {
413
+ const detail = reason === "wrong-content"
414
+ ? `after ${MAX_CAPTURE_ATTEMPTS} attempts the capture still doesn't match the page title `
415
+ + `"${title ?? ""}"`
416
+ : "the screen reader reached almost none of this page";
417
+ process.stderr.write(`WARNING: ${formatDoubtMessage(reason, detail)}\n`);
418
+ }
419
+ /**
420
+ * Re-capture while the transcript does not appear to be about the page.
421
+ *
422
+ * axe gives us the page title; a capture that never says it probably read something else — Edge's image
423
+ * magnifier overlay did exactly this on gov.uk, three attempts running. A retry is worth it because that fault
424
+ * is usually transient (a window that had not taken focus yet).
425
+ *
426
+ * Reachability is deliberately NOT checked here: a consent wall is present on every attempt, so retrying buys
427
+ * three captures and the same answer. `captureDoubt` handles that afterwards, once, and carries it in the
428
+ * result rather than only warning.
429
+ */
430
+ async function recaptureUntilItReadsThePage(first, title, options) {
431
+ let cap = first;
432
+ const { url, ...captureOptions } = options;
433
+ for (let attempt = 2; attempt <= MAX_CAPTURE_ATTEMPTS && !captureMentionsTitle(cap, title); attempt++) {
434
+ process.stderr.write(`Capture did not appear to read "${title}" (wrong content?); re-capturing (attempt ${attempt}/${MAX_CAPTURE_ATTEMPTS}) ...\n`);
435
+ cap = await captureViaWorker(url, captureOptions);
436
+ }
437
+ return cap;
438
+ }
439
+ /**
440
+ * Obtain the evidence — both layers, verified against the page we asked for.
441
+ *
442
+ * Extracted when `runWitness` crossed the physical-line budget, and it earns a name: everything here is
443
+ * ACQUISITION, and everything after it is interpretation. The two run on different clocks (this half is
444
+ * network- and worker-bound; the other is pure) and fail for unrelated reasons, which is the seam.
445
+ */
446
+ async function captureAndScan({ url, task, worker, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal, wantAxe, axeResults, formState }) {
447
+ const ruleLayer = await chooseRuleLayer({ wantAxe, axeResults });
448
+ process.stderr.write(`Scanning ${url} (${ruleLayer === "none" ? "" : "rule-based axe-core + "}real screen reader) ...\n`);
449
+ // Layer 1 (rule-based, local) and capture (lived-experience, remote worker)
450
+ // load the same URL independently, so run them concurrently. axe failure is
451
+ // non-fatal: we still report the lived-experience layer.
452
+ const [firstCap, axe] = await Promise.all([
453
+ captureViaWorker(url, { task, worker, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal, formState }),
454
+ pageContext(url, ruleLayer, axeResults),
455
+ ]);
456
+ // `null` when the rule layer did not run, so "unchecked" can never be mistaken for "clean". Both
457
+ // output paths must use THIS, not `axe.findings`: the human report already did
458
+ // (`ruleLayer === "none" ? null : ...`) while the --json path emitted the bare array, so `--no-axe`
459
+ // produced `"ruleBased": []` and any consumer rendered it as "0 violations". The text report and the
460
+ // JSON disagreed about whether contrast had been checked, and the JSON was the one that lied.
461
+ // `pageContext` decides this now — see its header. The ternary that used to live here knew only about
462
+ // `--no-axe` and rendered a FAILED scan as "0 violations". The caller reads it off the returned result
463
+ // for that reason: there must be exactly one place this is derived.
464
+ // Verify-and-retry (the Root-1 fix, brought to the product). Browser focus on
465
+ // the worker can be racy, so NVDA sometimes reads chrome instead of the page.
466
+ const cap = await recaptureUntilItReadsThePage(firstCap, axe.title, { url, task, worker, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal, formState });
467
+ return { cap, axe };
468
+ }
469
+ /**
470
+ * What the run says about the capture BEFORE judging it — diagnostics on request, and the one warning that
471
+ * has to survive an empty transcript.
472
+ *
473
+ * Extracted because `function-size.test.ts` refused `runWitness` at 92 physical lines against a budget of
474
+ * 90, and it was right to: this is a distinct phase, it reads as one, and the gate exists precisely
475
+ * because ESLint's `skipComments: true` lets a comment-dense function grow to twice its stated budget
476
+ * without complaint.
477
+ *
478
+ * @param cap the capture, already verified or not
479
+ * @param debug whether the caller asked for the diagnostic marks
480
+ */
481
+ function reportOnTheCapture(cap, debug) {
482
+ if (debug && cap.diagnostics) {
483
+ process.stderr.write("-- capture diagnostics --\n");
484
+ for (const e of cap.diagnostics)
485
+ process.stderr.write(" " + JSON.stringify(e) + "\n");
486
+ }
487
+ if (cap.transcript.length === 0) {
488
+ process.stderr.write("WARNING: 0 announcements captured. Run with --debug; if afterStart.lastSpoken is empty, " +
489
+ "NVDA is running but not producing speech (the worker likely needs a clean restart/reboot).\n");
490
+ }
491
+ }
492
+ /**
493
+ * `runs/witness/`, resolved LOCALLY rather than imported from `@a11ign/lab`'s `dataset-paths.mjs` -- the
494
+ * identical cycle `cli.test.ts`'s own header documents (#199, chairman's ruling): `@a11ign/lab` depends on
495
+ * the published `a11ign` package (`public-api.test.ts` imports it), so `cli` importing `dataset-paths.mjs`
496
+ * the other way would recreate the boundary defect ADR 0004 exists to prevent. See `dataset-paths.test.ts`'s
497
+ * `EXEMPT` entry for this file, added alongside this function.
498
+ *
499
+ * Anchored on `process.cwd()`, not this module's own location -- unlike `worker-fleet`'s `doctor.mjs`
500
+ * exemption a few files over. Those are internal tools that run inside this checkout; `witness` is the
501
+ * PUBLISHED, consumer-facing command #431 is about, run by someone outside this repo entirely, and their
502
+ * `runs/` is wherever they typed the command, not wherever npm happened to install the package.
503
+ */
504
+ export function witnessArtifactRoot() {
505
+ const override = process.env.RUNS_ROOT ?? process.env.A11Y_RUNS_ROOT;
506
+ return resolvePath(process.cwd(), override ?? "runs", "witness");
507
+ }
508
+ /** The `<slug>` half of `<stamp>-<slug>.json` -- the URL with nothing a filesystem would reject. */
509
+ export function witnessArtifactSlug(url) {
510
+ const host = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/[/?#].*$/, "");
511
+ const slug = host.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
512
+ return slug || "page";
513
+ }
514
+ /**
515
+ * #431: the ONLY artefact a `witness` run produces besides its own printed report -- so the reader whose
516
+ * run went wrong has something to send, and `capture:explain` (built to answer "what actually happened on
517
+ * a page" from a capture's own diagnostic marks) has a real product-path file to open rather than only the
518
+ * synthetic corpus under `runs/real-page-corpus`.
519
+ *
520
+ * Written BEFORE judging, not after: the raw capture is real evidence the moment the screen reader
521
+ * finishes, and a judge crash or a doubtful capture must not cost the one thing a bad run could otherwise
522
+ * still hand somebody. THROWS on a write failure rather than reporting a path that is not really there --
523
+ * the acceptance's own mutation is "delete the write and confirm `capture:explain` has nothing to open",
524
+ * because a path that is printed but not written is worse than no path.
525
+ */
526
+ export function writeWitnessArtifact(cap, task) {
527
+ const dir = witnessArtifactRoot();
528
+ mkdirSync(dir, { recursive: true });
529
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
530
+ const path = resolvePath(dir, `${stamp}-${witnessArtifactSlug(cap.url)}.json`);
531
+ writeFileSync(path, `${JSON.stringify({ capturedAt: new Date().toISOString(), task, capture: cap }, null, 2)}\n`);
532
+ return path;
533
+ }
534
+ /** The path line, printed relative to where the reader is standing rather than an absolute machine path. */
535
+ export function reportWitnessArtifact(path) {
536
+ console.log(path
537
+ ? `capture written to ${relative(process.cwd(), path)}`
538
+ : "capture not written (--no-keep)");
539
+ }
540
+ async function runWitness({ url, task, worker, json, debug, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal, emitFormConfig, formState, axe: wantAxe, axeResults, keep }) {
541
+ const { cap, axe } = await captureAndScan({ url, task, worker, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal,
542
+ wantAxe, axeResults, formState });
543
+ const artifactPath = keep ? writeWitnessArtifact(cap, task) : null;
544
+ const ruleFindings = axe.findings;
545
+ // A draft needs the ANNOUNCEMENTS and nothing downstream of them, so it returns before the judge runs.
546
+ // Scoring a page in order to print a config skeleton would spend a model pass on an answer nobody asked
547
+ // for, and would make `--emit-form-config` fail on a page the scorer abstains from. The capture is
548
+ // written above regardless of this early return -- a draft run is still a real capture -- so this still
549
+ // says where, rather than leaving a file on disk nobody was told about.
550
+ if (emitFormConfig) {
551
+ emitDraft(cap, url);
552
+ reportWitnessArtifact(artifactPath);
553
+ return;
554
+ }
555
+ // Carry the verdict, do not just warn about it.
556
+ //
557
+ // This wrote a WARNING and carried on. On gov.uk the capture read Edge's image-magnifier overlay
558
+ // ("Image Magnify, document"), the retry fired all three times and said so — and the run then judged
559
+ // that chrome and reported a 4.1.2 finding about the browser's own Zoom In / Rotate buttons as though
560
+ // it were the site's fault. The check knew it had failed and the pipeline downstream could not tell.
561
+ //
562
+ // A stderr line is not a signal. Anything consuming the result has to be able to see this.
563
+ //
564
+ // Two independent ways a capture can fail to be about the page, and they need different words. Reading
565
+ // the WRONG thing (browser chrome, an interstitial) is caught by the title; reading only PART of the
566
+ // right thing is not caught by anything the title can see — on theregister.com the consent modal traps
567
+ // focus, so the URL, the title and a title word all check out while the sweep reached 1 of 463 headings.
568
+ //
569
+ // Reachability is deliberately NOT in the retry loop above. A consent wall is on every attempt, so
570
+ // retrying buys three captures and the same answer.
571
+ const unverifiedReason = captureDoubt(cap, axe.title) ?? undefined;
572
+ const captureVerified = unverifiedReason === undefined;
573
+ if (unverifiedReason)
574
+ warnUnverified(unverifiedReason, axe.title);
575
+ reportOnTheCapture(cap, debug);
576
+ const { left, notExamined, examined } = examineWithinTheSite(cap);
577
+ process.stderr.write(`Captured ${cap.transcript.length} announcements; judging ...\n`);
578
+ await shadowScreenReaderCapture(examined);
579
+ const verdict = await judge({
580
+ url: examined.url,
581
+ task,
582
+ screenReader: examined.screenReader,
583
+ transcript: examined.transcript,
584
+ structure: examined.structure,
585
+ interaction: examined.interaction,
586
+ // The oracle counts, so the rules that assert an ABSENCE can corroborate it. Without these a page
587
+ // with no headings and a capture that failed to reach them are the same input.
588
+ ...oracleCounts(examined),
589
+ });
590
+ const conformance = conformanceFor(examined, ruleFindings, left && { control: left.control, notExamined });
591
+ // Per-criterion ACT outcomes. `truncatedSweeps` is what turns Conformance Requirement 2 into something
592
+ // per-criterion: a link sweep that stopped at its cap makes 2.4.4 `cantTell`, not `passed`.
593
+ const outcomes = criterionOutcomes({
594
+ capture: examined, notExamined: left && { control: left.control, channels: notExamined },
595
+ findings: verdict.findings,
596
+ abstained: verdict.abstained === true,
597
+ truncatedSweeps: truncatedSweeps(sweepOutcomes(examined.diagnostics ?? [])),
598
+ // The SECOND way a sweep is short: it ended cleanly and still missed something. Without this a
599
+ // capture whose landmark sweep found 0 of 1 reported 1.3.1 as "examined in full".
600
+ completeness: oracleCounts(examined).completeness,
601
+ // The SECOND assessor. Without it every criterion outside the screen-reader layer printed "No
602
+ // assessor in this tool covers this criterion" -- in a run that had just started by announcing
603
+ // "rule-based axe-core + real screen reader".
604
+ ruleLayer: axe.coverage,
605
+ });
606
+ if (json) {
607
+ printJson({
608
+ url, task, cap: examined, verdict, ruleFindings, captureVerified, unverifiedReason, conformance, outcomes,
609
+ leftSite: left,
610
+ artifactPath: artifactPath ? relative(process.cwd(), artifactPath) : null,
611
+ });
612
+ }
613
+ else {
614
+ printReport({
615
+ url, task, screenReader: cap.screenReader, announcements: cap.transcript.length,
616
+ verdict, axe: ruleFindings, conformance, outcomes, environment: cap.environment,
617
+ });
618
+ // THE LAST LINE, per #431's acceptance -- printed after the report, never folded into `--json`'s one
619
+ // JSON blob, which carries `artifactPath` as a field instead so a machine consumer still gets one
620
+ // parseable value on stdout.
621
+ reportWitnessArtifact(artifactPath);
622
+ }
623
+ }
624
+ /**
625
+ * What this run establishes against WCAG's five conformance requirements (§5.2).
626
+ *
627
+ * Built from the capture rather than assumed, because Requirement 2 (Full pages) turns on whether the
628
+ * sweeps actually reached the end of the page — and `environment` carries the exact screen-reader and
629
+ * browser versions Requirement 4 scopes the claim to.
630
+ *
631
+ * `assessedCriteria` is what the shipped assessors can return a finding for, and NOT what we captured
632
+ * evidence about. `interaction.focusOrder` is the cautionary case: the worker records it, no rule or
633
+ * scorer head reads it, so counting it would report a criterion as covered while a keyboard trap in that
634
+ * array reached nobody.
635
+ */
636
+ /**
637
+ * What this run can and cannot claim, from a capture. EXPORTED because it is pure over a capture object,
638
+ * and this repo has 2,122 real captures on disk — so it is testable against evidence a real screen reader
639
+ * produced, not against a hand-written shape somebody imagined.
640
+ */
641
+ export function conformanceFor(cap, axe, left) {
642
+ const env = cap.environment ?? {};
643
+ const version = (name, ver) => env[name] ? `${env[name]}${env[ver] ? ` ${env[ver]}` : ""}` : null;
644
+ // Read from the DIAGNOSTIC MARK, not from a capture field, and deliberately so. The census is the AX tree's
645
+ // own element count, and `capture-core` keeps it out of the evidence on purpose: "a completeness ORACLE and
646
+ // never evidence -- the accessibility tree is barred from being a model feature". Promoting it to a field
647
+ // would breach that boundary and invalidate every cached capture for a number already on the wire.
648
+ //
649
+ // `crossCheckStructure` computes the same sweep-versus-census comparison for the diagnostics. This is the
650
+ // REPORTING path for it, and it existed unread: the disagreement that answers "how much of the page did you
651
+ // examine?" was being written to a diagnostic every run and shown to nobody.
652
+ // Named once. Three readers of the same cast-and-default is repetition the complexity gate correctly
653
+ // refused at 16, and the third was added the moment a fourth reader would have been.
654
+ const diagnostics = cap.diagnostics ?? [];
655
+ const census = censusFromDiagnostics(diagnostics);
656
+ const structure = (cap.structure ?? {});
657
+ // Singular keys to match the census vocabulary; the structure fields are plural.
658
+ const swept = {
659
+ heading: structure.headings?.length ?? 0,
660
+ landmark: structure.landmarks?.length ?? 0,
661
+ link: structure.links?.length ?? 0,
662
+ graphic: structure.graphics?.length ?? 0,
663
+ };
664
+ return conformanceScope({
665
+ assessedCriteria: assessedCriteria(),
666
+ // #1363: where the examination ended, when an activation left the site. `cap` is then already cut to what
667
+ // was observed before it, and Requirement 2 names what was not examined.
668
+ leftSite: left,
669
+ sweeps: sweepOutcomes(diagnostics),
670
+ censusCountsDistinctNames: censusCountsDistinctNames(diagnostics),
671
+ screenReader: version("screenReader", "screenReaderVersion") ?? cap.screenReader,
672
+ browser: version("browser", "browserVersion"),
673
+ ruleLayerRan: axe !== null,
674
+ census: census ?? null,
675
+ // THE RAW COUNTS TOO (#677). `censusFromDiagnostics` overlays `distinct`, which is right for reach and
676
+ // wrong for "how much went unlooked-at"; both readers now exist and both are supplied.
677
+ censusElements: censusElementCounts(diagnostics),
678
+ // WHICH FORM CONTROLS THE ACTIVATION NEVER REACHED (#677 part 2). Read from the mark for the same
679
+ // reason the census is: the worker records the counts and this layer decides what they mean.
680
+ activationBudget: activationBudgetFromDiagnostics(diagnostics),
681
+ // WHICH DOCUMENT THIS REPORT IS ABOUT (#687). Read from the same diagnostics, for the same reason the
682
+ // census is: the served URL and the title are already on the record and nothing consumed them.
683
+ documentIdentity: documentIdentity(cap),
684
+ swept,
685
+ // #685/#691: the calendly case where a probe navigated to accounts.google.com before the census ran,
686
+ // and "reach 44/1" got printed and quoted as though 1 were this page's real heading count.
687
+ censusMismatchReason: censusTargetMismatchReason(diagnostics, swept, cap
688
+ .interaction?.routeChange),
689
+ });
690
+ }
691
+ /**
692
+ * Say on stderr, in the run's own log, that the examination ended early (#1363) -- before the judge's lines, so
693
+ * nobody reads a finding count as a verdict on everything the probe touched.
694
+ */
695
+ function warnLeftSite(left, notExamined) {
696
+ process.stderr.write(`a11ign: examination ENDED -- activating ${JSON.stringify(left.control)} left the site`
697
+ + `${left.to ? ` (to ${left.to})` : ""}. Nothing observed after it is attributed to ${left.from}; NOT `
698
+ + `EXAMINED: ${notExamined.join(", ") || "nothing further was recorded"}.\n`);
699
+ }
700
+ /**
701
+ * #1363: WHERE THE EXAMINATION ENDED. Rehearsal 2's probe opened the W3C's embedded YouTube player, the tab became
702
+ * youtube.com, and every probe after it -- the rest of the form-field sweep, the links, the focus pass, the
703
+ * route-change finding -- was judged as w3.org's. What this returns as `examined` is only what was observed ON the
704
+ * page, and it is what the judge, the conformance scope, the outcomes and the JSON are given. `captureDoubt` keeps
705
+ * the whole capture: whether the run read the requested page at all is a question about everything it read.
706
+ */
707
+ export function examineWithinTheSite(cap) {
708
+ const left = leftSite(cap);
709
+ if (!left)
710
+ return { left: null, notExamined: [], examined: cap };
711
+ const { capture, notExamined } = withinTheSite(cap, left);
712
+ warnLeftSite(left, notExamined);
713
+ return { left, notExamined, examined: capture };
714
+ }
715
+ /**
716
+ * The machine-readable result, for CI and for anything downstream of this tool.
717
+ *
718
+ * `structure` and `interaction` are included DELIBERATELY. They were omitted once, so this output carried only
719
+ * the read-through and dropped every structural sweep and interaction probe — the evidence behind most
720
+ * findings. A consumer reading it could not tell "this page has no links" from "links were never recorded",
721
+ * and the local judge's evidence guard, given exactly that, suppressed a correct 4.1.2 finding scored at 0.993.
722
+ *
723
+ * Exported so `result-json-fields-documented.test.ts` (#1637) can drive it with a real recorded result and
724
+ * pin its emitted top-level keys to the Action guide's field table, the same way `examineWithinTheSite` and
725
+ * `conformanceFor` are exported for their own acceptance tests.
726
+ */
727
+ export function printJson({ url, task, cap, verdict, ruleFindings, captureVerified, unverifiedReason, conformance, outcomes, leftSite: left, artifactPath }) {
728
+ const layered = { ...verdict, findings: verdict.findings.map((f) => ({ ...f, layer: layerOf(f.wcag) })) };
729
+ console.log(JSON.stringify({
730
+ url, task, screenReader: cap.screenReader, transcript: cap.transcript,
731
+ // #1363: where the examination ENDED, as its own field -- `null` when every activation stayed on the page.
732
+ // `structure` and `interaction` below are then only what was observed before it.
733
+ leftSite: left,
734
+ structure: cap.structure, interaction: cap.interaction,
735
+ // #431: where the capture behind this JSON was written, or `null` under `--no-keep` -- a machine
736
+ // consumer's equivalent of the plain-text report's last line, so it never has to scrape stdout for it.
737
+ artifactPath,
738
+ // The RUNNING capture's own environment (screenReaderVersion, guidepupVersion, browserVersion, ...),
739
+ // never a pin or an installer manifest — publish blocker B4. A disputed finding has to be traceable
740
+ // to the NVDA build and client that actually produced it, the same principle the scorer's own
741
+ // `verdict.runtime` block applies to the inference side.
742
+ environment: cap.environment ?? null,
743
+ ruleBased: ruleFindings, verdict: layered,
744
+ // False when the capture could not be confirmed to have read the requested page. Findings from an
745
+ // unverified capture may describe browser chrome, so a consumer must be able to refuse them.
746
+ captureVerified,
747
+ // WHY it is unverified, because the two causes need different explanations to a reader: reading the
748
+ // wrong thing entirely, versus reading only a modal dialog that sat in front of the right page.
749
+ ...(unverifiedReason ? { captureUnverifiedReason: unverifiedReason } : {}),
750
+ // WCAG §5.2's five conformance requirements, each with what this run established and what it did
751
+ // NOT. In the machine-readable output as well as the printed report, because a CI job deciding
752
+ // whether to fail a build needs the limits as much as a human does — and a consumer that sees only
753
+ // `findings: []` is the reader most likely to conclude the page is fine.
754
+ conformance,
755
+ // Per-criterion ACT outcomes: failed / cantTell / passed / inapplicable / untested. This matters most
756
+ // in the MACHINE-readable output, because a CI job reading `findings: []` has no other way to tell
757
+ // "clean" from "we could not check it" — and it will fail or pass a build on that difference.
758
+ outcomes,
759
+ // The same outcomes as EARL, the W3C's vendor-neutral vocabulary for test results, so a team already
760
+ // aggregating axe or Lighthouse can merge these without writing a parser for our shape. Emitted
761
+ // always rather than behind a flag, because an export nobody can reach is the defect this session
762
+ // already found twice: `probeFocus` and `focusOrder` were both exactly that.
763
+ earl: earlReport({
764
+ url,
765
+ date: new Date().toISOString(),
766
+ environment: [
767
+ cap.environment?.screenReader, cap.environment?.screenReaderVersion,
768
+ cap.environment?.browser, cap.environment?.browserVersion,
769
+ ].filter(Boolean).join(" "),
770
+ toolVersion: process.env.npm_package_version ?? "0.1.0",
771
+ outcomes,
772
+ }),
773
+ }, null, 2));
774
+ }
775
+ // Imported results win over running our own. Someone who supplies a file has already run
776
+ // axe; scanning again would give them two differently-versioned opinions on one page.
777
+ //
778
+ // EXPORTED, and `isAvailable` is INJECTABLE, for the same reason `axeAvailable` itself takes a `deps`
779
+ // parameter: a test must drive the REAL decision, not a copy of it. This is the one place that decides
780
+ // whether an unavailable rule layer gets REPORTED (a stderr line naming the exact fix, and `findings:
781
+ // null` -- never `[]` -- flowing through to "not run. Visual criteria are unchecked, not clean." in the
782
+ // printed report) or is silently absorbed. `chooseRuleLayer.test.ts` reproduces the chain end to end
783
+ // rather than asserting on this function's shape.
784
+ export async function chooseRuleLayer({ wantAxe, axeResults }, isAvailable = axeAvailable) {
785
+ if (axeResults)
786
+ return "import";
787
+ if (!wantAxe)
788
+ return "none";
789
+ if (await isAvailable())
790
+ return "run";
791
+ process.stderr.write("axe-core layer skipped: its optional dependencies are not installed " +
792
+ "(npm install playwright @axe-core/playwright && npx playwright install chromium), " +
793
+ "or pass --axe-results <file> to use results you already have.\n");
794
+ return "none";
795
+ }
796
+ // The rule-based findings plus the page title, from whichever source is available. The
797
+ // title is NOT optional the way the rule layer is: without it the capture cannot be checked
798
+ // for having read the wrong page, so it falls back to a plain fetch.
799
+ /**
800
+ * `findings: null` means THE RULE LAYER PRODUCED NO RESULTS, which is not the same as finding none.
801
+ *
802
+ * A FAILED axe scan returned `[]`, and the caller decided nullness from the layer NAME alone — so a scan
803
+ * that was requested, ran and threw rendered as "Rule layer (axe-core): 0 violations". A clean bill of
804
+ * health for a scan that did not happen. The caller's own comment describes that defect and had fixed it
805
+ * for `--no-axe` only: the remedy reached one of the two paths producing no results.
806
+ *
807
+ * Decided here now, by the function that knows. There is no second place to get it wrong.
808
+ */
809
+ async function pageContext(url, layer, axeResults) {
810
+ if (layer === "import" && axeResults) {
811
+ const imported = await loadAxeResults(axeResults);
812
+ warnOnUrlMismatch(imported.scannedUrl, url);
813
+ process.stderr.write(`Using ${imported.findings.length} imported axe violation(s) from ${axeResults}\n`);
814
+ return { findings: imported.findings, title: await fetchPageTitle(url), coverage: imported.coverage,
815
+ browserChannel: null };
816
+ }
817
+ if (layer === "none") {
818
+ return { findings: null, title: await fetchPageTitle(url), coverage: {}, browserChannel: null };
819
+ }
820
+ return scanWithAxe(url).then((result) => {
821
+ // WHICH BROWSER ANSWERED, reported rather than assumed — see `launchBrowser`. The Action skips the
822
+ // bundled download deliberately, so seeing "msedge" there is the fallback working as designed, not a
823
+ // warning; seeing it locally on a machine with no Edge would be the warning.
824
+ process.stderr.write(`axe-core: ran via ${result.browserChannel === "chromium"
825
+ ? "the bundled Chromium" : "the system Edge (channel: msedge)"}\n`);
826
+ return result;
827
+ }).catch(async (e) => {
828
+ process.stderr.write(`axe-core scan failed (continuing without it): ${e.message}\n`);
829
+ // NULL, not []. The visual criteria are unchecked, and saying "0 violations" here would be the one
830
+ // thing this tool must never do. `coverage: {}` is the same statement per criterion: a scan that
831
+ // THREW examined nothing, so nothing may be reported as examined-and-clean.
832
+ return { findings: null, title: await fetchPageTitle(url), coverage: {}, browserChannel: null };
833
+ });
834
+ }
835
+ /**
836
+ * How long to wait for a capture, measured against the WORKER's own bound rather than guessed.
837
+ *
838
+ * The margin is deliberate: the worker is the component that knows why a capture failed, and it must always
839
+ * be the one that gets to say so — a client that gives up first replaces a diagnosis with "no answer".
840
+ *
841
+ * This comment used to state the real defect and then not fix it: `fetch`'s ~300 s headers timeout sits BELOW
842
+ * the worker's 520 s hard timeout, so `scan` died with `UND_ERR_HEADERS_TIMEOUT` on any page that took longer
843
+ * than five minutes, and the number below never applied. `AbortSignal.timeout()` does not govern that cap;
844
+ * only a different client does. Measured, and the reason `requestJson` exists — see worker-http.mjs.
845
+ */
846
+ // The SHARED ceiling, imported rather than recomputed. This was
847
+ // `CAPTURE_HARD_TIMEOUT_DEFAULT_MS + 40_000`, which was 560_000 at the time -- byte for byte the value
848
+ // `worker-http.mjs` already exported, arrived at a second way and paid for with an import of
849
+ // `@a11ign/nvda-worker`. That package is NOT a dependency of this one (isolation-smoke.mjs asserts
850
+ // it must not be, "the CLI speaks HTTP to a worker"), so the published bundle imported something npm
851
+ // never installed -- and it reached guidepup, which throws at import wherever there is no screen reader.
852
+ // Found by `no-win32-imports.test.ts`; `budget-ladder.test.ts` already treats an unresolvable ceiling as
853
+ // "it comes from the shared constant", which is now true here.
854
+ //
855
+ // The number moved to 620_000 on architecture-audit.md §14.5, for a reason that has nothing to do with
856
+ // the story above: the worker's true worst case also includes desktop preparation, not just the hard
857
+ // timeout. Importing rather than recomputing is what makes that a one-file change.
858
+ /**
859
+ * A SENTENCE, not the wire body — a worker's error response is JSON meant for a program, and printing it
860
+ * verbatim at a person (`Worker error 429: {"error":"a capture is already in progress"}`) makes them parse
861
+ * it themselves. 429 in particular has a real, immediate remedy that the raw body does not say out loud.
862
+ */
863
+ export function describeWorkerError(status, body) {
864
+ const parsed = body && typeof body === "object"
865
+ ? body
866
+ : {};
867
+ if (status === 429) {
868
+ return `That worker is busy with another capture right now. Wait for it to finish, or point `
869
+ + `--worker (or A11Y_WORKER) at a different one.`;
870
+ }
871
+ if (parsed.fault) {
872
+ // `reachedPhase`/`diagnostics` are the worker's OWN record of how far a partial capture got --
873
+ // already on the wire (see server.mjs's `runCapture`), and unused here until #336 gave a caller a
874
+ // reason to read them: "we ran out of time after N marks" and "we could not read your page at all"
875
+ // are different findings, and only one of them invites a retry.
876
+ return formatFaultMessage(parsed.fault, parsed.error, {
877
+ reachedPhase: parsed.reachedPhase,
878
+ markCount: Array.isArray(parsed.diagnostics) ? parsed.diagnostics.length : undefined,
879
+ });
880
+ }
881
+ if (parsed.error) {
882
+ return `The worker returned an error (HTTP ${status}): ${parsed.error}`;
883
+ }
884
+ return `The worker returned HTTP ${status} with no readable error body.`;
885
+ }
886
+ /**
887
+ * A fresh `onProgress` callback per capture, so the notice fires at most ONCE — #426's second half. The
888
+ * plumbing already existed and was unused for this caller (`captureTolerantly` has always accepted
889
+ * `onProgress` and polled `/progress` while a capture is in flight; this was the one caller that never
890
+ * passed one). `/progress`'s `phases` array carries the SAME two marks a finished capture's `captureDoubt`
891
+ * reads, so `earlyContainmentVerdict` applies the identical threshold early rather than a different,
892
+ * unvalidated one — see that function's own comment in `@a11ign/evidence/verify`.
893
+ *
894
+ * NEVER a rejection: this only ever prints to stderr. `onProgress`'s return value is unused by
895
+ * `capture-client.mjs`, so there is no path from here back into whether the capture continues — the rule
896
+ * this project has paid for once already ("a check must never reject evidence whose absence is the
897
+ * finding"), applied to a warning instead of a gate.
898
+ *
899
+ * A closure rather than a module-level flag: two concurrent captures (this CLI's own `Promise.all` in
900
+ * `captureAndScan` runs the capture beside axe, and a caller could invoke `captureViaWorker` more than
901
+ * once) must not share one "already notified" bit.
902
+ */
903
+ export function earlyContainmentWatcher() {
904
+ let notified = false;
905
+ return (progress) => {
906
+ if (notified)
907
+ return;
908
+ const phases = progress.phases;
909
+ if (!Array.isArray(phases))
910
+ return;
911
+ const verdict = earlyContainmentVerdict(phases);
912
+ if (!verdict.decided || !verdict.contained)
913
+ return;
914
+ notified = true;
915
+ process.stderr.write(`${formatEarlyContainmentNotice(verdict.observedAtMs)}\n`);
916
+ };
917
+ }
918
+ /**
919
+ * THROUGH `captureTolerantly` NOW, not a bare `requestJson` POST — architecture-audit.md §5, item 6.
920
+ *
921
+ * This was the one caller of ten that sent no `captureId`, so the async-dispatch, poll and lost-response
922
+ * recovery every lab client already had (see `@a11ign/worker-fleet/capture-client`) was unavailable
923
+ * to the one caller that is a real user: a dropped response here used to mean the page was silently never
924
+ * examined, on a capture that may already have completed. `captureTolerantly` mints its own id, so this
925
+ * function's only job is the request BODY and turning a transport failure into a message about the page,
926
+ * not about a Map or a protocol.
927
+ */
928
+ export async function captureViaWorker(url, { task, worker, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal, formState }) {
929
+ let res;
930
+ try {
931
+ res = await captureTolerantly({
932
+ worker,
933
+ body: { url, task, probeForms, probeFocus, probeNavigation, probeFocusContext, probeFocusReveal,
934
+ // Omitted rather than sent as null when absent: an older worker reads known fields only, so an
935
+ // absent key is the same "no configured form" it has always understood. Additive, like `fault`.
936
+ ...(formState ? { formState } : {}) },
937
+ timeoutMs: CAPTURE_CLIENT_TIMEOUT_MS,
938
+ onProgress: earlyContainmentWatcher(),
939
+ });
940
+ }
941
+ catch (error) {
942
+ // A transport failure is not an accessibility finding, and it must not read like one.
943
+ const reason = error instanceof Error ? error.message : String(error);
944
+ throw new Error(`Could not reach the capture worker at ${worker} (${reason}).\n`
945
+ + `The page was not examined, so this report would say nothing about it. Check the worker is `
946
+ + `answering: curl ${worker}/health`, { cause: error });
947
+ }
948
+ if (!res.ok) {
949
+ throw new Error(describeWorkerError(res.status, res.json));
950
+ }
951
+ return res.json;
952
+ }
953
+ /**
954
+ * Print a forms-config skeleton drawn from what NVDA announced on this page.
955
+ *
956
+ * To STDOUT with the diagnostics on stderr, so `--emit-form-config > forms.yml` produces a file that
957
+ * loads. A draft printed with a banner in front of it is a draft the author has to edit before the parser
958
+ * will take it, which defeats the point of generating it.
959
+ */
960
+ function emitDraft(cap, url) {
961
+ const fields = (cap.structure?.formFields ?? []);
962
+ const draft = draftFormsConfig(fields, { origin: new URL(url).origin });
963
+ if (draft.unparsed.length) {
964
+ // Ours, and said as ours. An author cannot fix this tool's announcement grammar and must not be sent
965
+ // looking for a defect on their page that belongs to us.
966
+ process.stderr.write(`${draft.unparsed.length} announcement(s) could not be read by this tool's `
967
+ + "grammar and are listed in the draft. That is a gap in a11ign, not a finding about the "
968
+ + "page.\n");
969
+ }
970
+ if (draft.unnamed.length) {
971
+ // On STDERR so it survives a redirect to a file, because it is the half of the output that is a
972
+ // FINDING rather than a template. A field NVDA announced with no name cannot be addressed by this
973
+ // config and cannot be addressed by a screen reader user either — that is 4.1.2, reported whether or
974
+ // not this form is ever configured.
975
+ process.stderr.write(`${draft.unnamed.length} form field(s) have NO accessible name and are named in `
976
+ + "comments in the draft. That is a 4.1.2 finding about the page, not a gap in the config.\n");
977
+ }
978
+ console.log(draft.yaml);
979
+ }
980
+ function printReport(report) {
981
+ console.log(reportLines(report).join("\n"));
982
+ }
983
+ /**
984
+ * Run ONLY when this file is the program, never when it is imported.
985
+ *
986
+ * Without this guard, importing `cli.ts` runs the CLI: a test that merely imported it printed USAGE and
987
+ * exited 1 before a single assertion. That is the structural reason this file had no tests — not that its
988
+ * logic is hard to test. `entry-points.test.ts` asserts this property for scripts reached through
989
+ * `package.json`; the CLI is reached through a bin and slipped past it.
990
+ *
991
+ * `process.argv[1]` is REALPATH'D before comparison, and this is not optional. `import.meta.url` is
992
+ * canonicalised by Node's ESM loader (it resolves symlinks), while `process.argv[1]` is the raw invocation
993
+ * path — so on any path that passes through a symlink they disagree and this guard silently reads FALSE.
994
+ * `/var` and `/tmp` are themselves symlinks to `/private/var` and `/private/tmp` on every macOS install,
995
+ * and `os.tmpdir()` returns a `/var/folders/...` path — which is where `npx` stages a package before running
996
+ * it. So the installed bin ran, loaded, and did NOTHING: no output, exit 0, because `main()` was never
997
+ * called and nothing downstream knew a check had even been skipped. Reproduced with a three-line script
998
+ * invoked through `/tmp/...` instead of its `/private/tmp/...` realpath before this was believed; the
999
+ * isolation smoke test only ever checked the bin FILE EXISTS, never that running it does anything, which is
1000
+ * exactly how this survived every `gate:isolation` run there has ever been.
1001
+ */
1002
+ const isProgram = process.argv[1] !== undefined
1003
+ && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
1004
+ if (isProgram)
1005
+ main().catch((err) => {
1006
+ // A shipped-artefact/runtime-schema mismatch (#81) reaches here as an Error carrying `.fault` --
1007
+ // `local-judge.ts`'s `scoreCapture` attaches it after reading score.py's own parseable fault line, so
1008
+ // by the time it is caught here it is indistinguishable in SHAPE from a worker fault, and gets the
1009
+ // same WHAT/TRY/WHERE treatment `describeWorkerError` already gives those, rather than the bare
1010
+ // `err.message` every other failure prints.
1011
+ const fault = err instanceof Error ? err.fault : undefined;
1012
+ // `console.error(err)` printed a Node stack trace as the entire user-facing output on the first real
1013
+ // website this was pointed at. A stack is for whoever is fixing the tool; a user needs the reason.
1014
+ const message = fault
1015
+ ? formatFaultMessage(fault, err instanceof Error ? err.message : undefined)
1016
+ : err instanceof Error ? err.message : String(err);
1017
+ process.stderr.write(`\n${message}\n`);
1018
+ if (process.argv.includes("--debug") && err instanceof Error && err.stack) {
1019
+ process.stderr.write(`\n${err.stack}\n`);
1020
+ }
1021
+ // A CONFIG error is the author's to fix and a tool failure is ours, so they exit differently. A CI job
1022
+ // that treats every non-zero exit as "the scan broke" will retry a malformed forms file for ever;
1023
+ // exit 2 says the input is wrong and retrying it will not help. A named FAULT (currently only
1024
+ // artifact-schema-mismatch) is a third thing again: not the caller's mistake and not an ordinary tool
1025
+ // bug, so exit 3 says "wait for a release" rather than inviting a retry loop the way exit 1 would.
1026
+ process.exit(err instanceof FormsConfigError ? 2 : fault ? 3 : 1);
1027
+ });
1028
+ //# sourceMappingURL=cli.js.map