@binclusive/a11y 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 (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1026 @@
1
+ import { relative, resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { Args, Command, Options } from "@effect/cli";
4
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
5
+ import { Effect, Option } from "effect";
6
+ import type { Impact } from "@binclusive/a11y-contract";
7
+ import { type AgentLaneOverrides, augmentWithAgentLane } from "./agent-lane";
8
+ import { collectTsx } from "./collect";
9
+ // Type-only: the rendered-DOM lane (playwright/@axe-core) is loaded lazily inside
10
+ // `runCheckUrl` so the static `check` path carries no eager browser-stack import
11
+ // and the CI image can ship without it (issue #2133).
12
+ import type { DomScanResult } from "./collect-dom";
13
+ import { scanLiquid } from "./collect-liquid";
14
+ import { scanSwift } from "./collect-swift";
15
+ import { gen, init, type LearnInput, learn } from "./commands";
16
+ import { collectUnityFindings } from "./unity-findings";
17
+ import { type FindingProvenance, scan } from "./core";
18
+ import { type Evidence, type EnrichedFinding, enrichAll, evidenceImpact, resolveDisplay } from "./evidence";
19
+ import { runHookCli } from "./hook";
20
+ import { phoneHome } from "./phone-home";
21
+ import { formatSarif } from "./sarif";
22
+ import {
23
+ GATE_OFF,
24
+ type GateConfig,
25
+ gateExitCode,
26
+ toGateFinding,
27
+ } from "./impact-gate";
28
+ import type { ComponentResolution, Coverage } from "./resolve-components";
29
+ import type { SuggestResult } from "./suggest";
30
+
31
+ /**
32
+ * The body of a finding report — everything below the location line. Shared by
33
+ * the source report (`formatFinding`, anchored `file:line`) and the rendered-DOM
34
+ * report (`formatUrlFinding`, anchored on the axe selector) so the baseline
35
+ * cross-ref and fix render identically regardless of which collector produced the
36
+ * finding. The `via` tag names the non-structural producers so each one's
37
+ * distinct reach is legible.
38
+ */
39
+ export function detailLines(f: EnrichedFinding): string[] {
40
+ const scList =
41
+ f.wcag.length > 0 ? f.wcag.map((sc) => `WCAG ${sc}`).join(", ") : "no WCAG mapping";
42
+ const via =
43
+ f.provenance === "enforce"
44
+ ? " (call-site content check)"
45
+ : f.provenance === "axe"
46
+ ? " (rendered-DOM / axe)"
47
+ : f.provenance === "swiftui"
48
+ ? " (SwiftUI static)"
49
+ : "";
50
+ const lines = [
51
+ ` rule: ${f.ruleId} [${f.enforcement}]${via}`,
52
+ ` wcag: ${scList}`,
53
+ ` ${f.message}`,
54
+ ];
55
+
56
+ // The display contract resolves the axe-vs-SC policy once (see resolveDisplay):
57
+ // WHAT impact / fix-line / ref / patterns to show. This printer only places
58
+ // those resolved values in each source's layout — no policy, no provenance
59
+ // checks.
60
+ const d = resolveDisplay(f);
61
+ if (d.impactLabel !== null) lines.push(` impact: ${d.impactLabel}`);
62
+
63
+ if (d.fixLine !== null) lines.push(` fix: ${d.fixLine}`);
64
+ if (d.refUrl !== null) lines.push(` ref: ${d.refUrl}`);
65
+
66
+ const c = f.corpus;
67
+ switch (c.source) {
68
+ case "baseline":
69
+ // Coverage: axe's published per-rule data (impact + standard fix + help).
70
+ if (c.bestPractice) {
71
+ // An axe best-practice rule with no WCAG SC — honestly NOT a WCAG failure.
72
+ lines.push(" rule: best-practice (no WCAG SC)");
73
+ } else {
74
+ lines.push(` coverage: axe baseline rule SC ${c.sc}`);
75
+ }
76
+ break;
77
+ case "none":
78
+ // The baseline catalog doesn't know the SC — but never a bare dead-end: the
79
+ // ref above surfaces whatever runtime help the finding itself carries.
80
+ lines.push(" coverage: no SC mapping — not in the axe baseline catalog");
81
+ break;
82
+ }
83
+ return lines;
84
+ }
85
+
86
+ function formatFinding(f: EnrichedFinding, root: string): string {
87
+ return [` ${relative(root, f.file)}:${f.line}`, ...detailLines(f)].join("\n");
88
+ }
89
+
90
+ /** A rendered-DOM finding, anchored on the axe CSS selector instead of a line. */
91
+ function formatUrlFinding(f: EnrichedFinding): string {
92
+ return [` ${f.selector ?? "(document)"}`, ...detailLines(f)].join("\n");
93
+ }
94
+
95
+ /**
96
+ * The two summary lines every report ends on: the evidence rollup and the
97
+ * enforcement split. Returns the blocking count too, since that gates the exit
98
+ * code. Shared by the source and rendered-DOM reports.
99
+ *
100
+ * Audit-corpus hits roll up under their real frequency tier (the moat).
101
+ * Baseline hits — covered by axe's catalog but absent from audit-frequency data,
102
+ * INCLUDING the best-practice rules matched by ruleId — roll up under `BASELINE`.
103
+ * `UNMAPPED` is left only for findings whose ruleId is absent from the catalog,
104
+ * so the coverage layer is visible without being mistaken for moat data.
105
+ */
106
+ function reportTotals(findings: readonly EnrichedFinding[]): {
107
+ readonly lines: readonly string[];
108
+ readonly blocking: number;
109
+ } {
110
+ const sourceCounts = new Map<string, number>();
111
+ for (const f of findings) {
112
+ const key = f.corpus.source === "baseline" ? "BASELINE" : "UNMAPPED";
113
+ sourceCounts.set(key, (sourceCounts.get(key) ?? 0) + 1);
114
+ }
115
+ const rollup = [...sourceCounts.entries()].map(([src, n]) => `${src}: ${n}`).join(" | ");
116
+ const blocking = findings.filter((f) => f.enforcement === "block").length;
117
+ const warning = findings.length - blocking;
118
+ return {
119
+ lines: [
120
+ `${findings.length} finding(s) ${rollup}`,
121
+ `enforcement: ${blocking} blocking · ${warning} warning`,
122
+ ],
123
+ blocking,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Render the a11y-coverage report in three HONEST buckets — the reframe of the
129
+ * old `mapped | opaque` line, which lumped opaque-but-fine library components in
130
+ * with genuine unknowns and made a design-system app look ~94% blind:
131
+ *
132
+ * - checked — the mapped set (declared + registry + traced). jsx-a11y runs on
133
+ * these; every finding comes from here. (Unchanged behavior.)
134
+ * - trusted — OPAQUE components from a known-accessible design system. The
135
+ * library guarantees their internal structure; opaque is fine.
136
+ * - declare — OPAQUE genuine unknowns. The real gap — and the ONLY bucket that
137
+ * keeps the actionable "declare it in binclusive.json" hints.
138
+ *
139
+ * Icon / structural / no-host components get a one-line tail note (no host
140
+ * exists to check; dumping them in `declare` would be a false to-do — `Fragment`,
141
+ * providers, router layout, charts and email components are plumbing, not
142
+ * controls). A standing honesty note
143
+ * keeps `trusted` from reading as "fully verified": the library guarantees the
144
+ * STRUCTURE, but the content the customer passes (names, labels, alt) is checked
145
+ * in a follow-up pass.
146
+ */
147
+ function formatCoverage(
148
+ coverage: Coverage,
149
+ resolutions: readonly ComponentResolution[],
150
+ unresolvedPackages: readonly string[] = [],
151
+ ): string {
152
+ const checked = coverage.declared + coverage.registry + coverage.traced;
153
+ const lines = [
154
+ "a11y coverage:",
155
+ ` checked ${checked} — elements we inspected (findings come from here)`,
156
+ ];
157
+
158
+ if (coverage.trusted > 0) {
159
+ lines.push(
160
+ ` trusted ${coverage.trusted} — from a known-accessible design system (${trustedLibraries(resolutions)}) — the library handles these`,
161
+ );
162
+ }
163
+
164
+ // `declare` is the prominent, actionable bucket: one copy-paste config to-do
165
+ // per genuine unknown. Always shown when non-zero, even if trusted dominates.
166
+ const declare = resolutions.filter(
167
+ (r) => r.provenance === "opaque" && r.opaqueKind === "declare",
168
+ );
169
+ if (declare.length > 0) {
170
+ lines.push(
171
+ ` declare ${coverage.declare} — unrecognized; declare in binclusive.json to inspect them:`,
172
+ );
173
+ for (const r of declare) {
174
+ lines.push(` ${formatOpaqueHint(r)}`);
175
+ }
176
+ }
177
+
178
+ // Icons have no interactive host — surface as a count, never as a to-do.
179
+ if (coverage.icons > 0) {
180
+ lines.push(` (+ ${coverage.icons} icon/no-host component(s), nothing to check)`);
181
+ }
182
+
183
+ // Structural plumbing (Fragment / providers / router layout / charts / email)
184
+ // has no interactive host either — a count, never an actionable declare to-do.
185
+ if (coverage.structural > 0) {
186
+ lines.push(
187
+ ` (+ ${coverage.structural} structural/plumbing component(s) — no interactive host, nothing to check)`,
188
+ );
189
+ }
190
+
191
+ // Honesty guard: `trusted` is a STRUCTURE guarantee, not a content pass — but
192
+ // the enforce call-site check DOES inspect the content the app passes to these
193
+ // components (names, labels, alt), so "trusted" is no longer a blind spot.
194
+ if (coverage.trusted > 0) {
195
+ lines.push(
196
+ " note: trusted = the library guarantees the structure; the content YOU pass (names, labels, alt) is checked by the call-site content check.",
197
+ );
198
+ }
199
+
200
+ // Cold-scan signal: components that are declare-opaque because their package
201
+ // isn't installed on disk (no node_modules). This is NOT a false declare — the
202
+ // component is genuinely unresolved — but the ROOT CAUSE is missing deps, not
203
+ // a missing declaration. Tell the user so they can act.
204
+ if (unresolvedPackages.length > 0) {
205
+ lines.push(
206
+ ` note: ${coverage.declare} component(s) are opaque because their package isn't resolved on disk (${unresolvedPackages.join(", ")}) — install dependencies for deeper tracing.`,
207
+ );
208
+ }
209
+
210
+ return lines.join("\n");
211
+ }
212
+
213
+ /**
214
+ * The distinct guaranteeing-library names across the TRUSTED bucket, comma-
215
+ * joined for the `trusted` line (`Radix, MUI`). Sorted for a deterministic
216
+ * report. Reads only the `library` carried on each trusted resolution.
217
+ */
218
+ function trustedLibraries(resolutions: readonly ComponentResolution[]): string {
219
+ const libs = new Set<string>();
220
+ for (const r of resolutions) {
221
+ if (r.provenance === "opaque" && r.opaqueKind === "trusted" && r.library !== null) {
222
+ libs.add(r.library);
223
+ }
224
+ }
225
+ return [...libs].sort().join(", ");
226
+ }
227
+
228
+ /**
229
+ * Turn one DECLARE-bucket component into a copy-paste config to-do. We can't
230
+ * know the host — that's why it's unresolved — so we list the realistic host
231
+ * options and tell the customer to pick ONE. This is the line that turns a
232
+ * genuine unknown from a dead end into an actionable entry.
233
+ */
234
+ export function formatOpaqueHint(r: ComponentResolution): string {
235
+ return (
236
+ `${r.name} (from ${r.module}) — unrecognized. ` +
237
+ `Declare it: binclusive.json → "components": { "${r.name}": "<host>" } ` +
238
+ `— pick ONE of: ${HOST_OPTIONS}`
239
+ );
240
+ }
241
+
242
+ /** The interactive host primitives a wrapper most often resolves to. */
243
+ const HOST_OPTIONS = "button | a | input | textarea | select | label | div";
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // JSON report contract
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export interface JsonFinding {
250
+ readonly id: string;
251
+ readonly file: string;
252
+ readonly line: number;
253
+ readonly ruleId: string;
254
+ readonly enforcement: "block" | "warn";
255
+ readonly provenance: FindingProvenance;
256
+ readonly wcag: readonly string[];
257
+ /**
258
+ * The contract's canonical impact (`critical`/`serious`/`moderate`/`minor`, or
259
+ * `unknown` when the finding carries no axe impact), read through the ONE
260
+ * {@link evidenceImpact} accessor so this field can never disagree with SARIF.
261
+ * Emitted here so downstream consumers (the CI PR-summary rollup, #2132) count
262
+ * by the canonical impact rather than re-deriving it from the evidence.
263
+ */
264
+ readonly impact: Impact;
265
+ /** The WCAG success-criterion id the finding maps to (contract `criterion` = the first `wcag` tag), e.g. "1.4.3"; "" when the rule carries no SC. */
266
+ readonly criterion: string;
267
+ /** The coverage-catalog cross-reference: which source matched, the SC, and whether it is an axe best-practice rule (no WCAG SC). Frequency is platform-derived (ADR 0041 §G), never carried here. */
268
+ readonly evidence: { readonly source: Evidence["source"]; readonly sc: string | null; readonly bestPractice: boolean };
269
+ readonly message: string;
270
+ }
271
+
272
+ export interface JsonReport {
273
+ readonly tool: "a11y-checker";
274
+ readonly root: string;
275
+ readonly filesScanned: number;
276
+ readonly coverage: {
277
+ readonly checked: number;
278
+ readonly trusted: number;
279
+ readonly declare: number;
280
+ readonly icons: number;
281
+ readonly structural: number;
282
+ readonly total: number;
283
+ };
284
+ readonly findings: readonly JsonFinding[];
285
+ readonly summary: {
286
+ readonly findings: number;
287
+ readonly blocking: number;
288
+ readonly warning: number;
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Project the source-discriminated `Evidence` union into the flat
294
+ * `{ source, sc, bestPractice }` shape the JSON contract exposes. The corpus left
295
+ * the engine (ADR 0041 §G), so no frequency tier or org count is carried —
296
+ * frequency is platform-derived and read-joined onto the ticket.
297
+ */
298
+ function jsonEvidence(c: Evidence): {
299
+ readonly source: Evidence["source"];
300
+ readonly sc: string | null;
301
+ readonly bestPractice: boolean;
302
+ } {
303
+ switch (c.source) {
304
+ case "baseline":
305
+ return { source: "baseline", sc: c.sc, bestPractice: c.bestPractice };
306
+ case "none":
307
+ return { source: "none", sc: null, bestPractice: false };
308
+ }
309
+ }
310
+
311
+ export function buildJsonReport(
312
+ root: string,
313
+ filesScanned: number,
314
+ coverage: Coverage,
315
+ findings: readonly EnrichedFinding[],
316
+ ): JsonReport {
317
+ const checked = coverage.declared + coverage.registry + coverage.traced;
318
+ const blocking = findings.filter((f) => f.enforcement === "block").length;
319
+ const warning = findings.length - blocking;
320
+
321
+ const jsonFindings: JsonFinding[] = findings.map((f) => ({
322
+ id: `${f.ruleId}|${relative(root, f.file)}|${f.line}|${f.wcag.join(",")}`,
323
+ file: relative(root, f.file),
324
+ line: f.line,
325
+ ruleId: f.ruleId,
326
+ enforcement: f.enforcement,
327
+ provenance: f.provenance,
328
+ wcag: f.wcag,
329
+ // Read impact + criterion through the ONE evidence accessor so the report's
330
+ // counts match SARIF and never re-derive a second mapping. Absent ⇒ `unknown`.
331
+ impact: evidenceImpact(f) ?? "unknown",
332
+ criterion: f.wcag[0] ?? "",
333
+ evidence: jsonEvidence(f.corpus),
334
+ message: f.message,
335
+ }));
336
+
337
+ return {
338
+ tool: "a11y-checker",
339
+ root,
340
+ filesScanned,
341
+ coverage: {
342
+ checked,
343
+ trusted: coverage.trusted,
344
+ declare: coverage.declare,
345
+ icons: coverage.icons,
346
+ structural: coverage.structural,
347
+ total: coverage.total,
348
+ },
349
+ findings: jsonFindings,
350
+ summary: {
351
+ findings: findings.length,
352
+ blocking,
353
+ warning,
354
+ },
355
+ };
356
+ }
357
+
358
+ /**
359
+ * The shared report tail for both runners (source + rendered-DOM): empty-state,
360
+ * grouped findings, the totals rollup, and the blocking-gated exit code. Each
361
+ * runner keeps its own PREAMBLE (the scan header / coverage block) and supplies
362
+ * only what differs — the empty-state text, how findings group, the per-group
363
+ * header line, and which formatter renders each finding. Output is identical to
364
+ * the inlined tails this replaced.
365
+ */
366
+ function renderReport(
367
+ findings: readonly EnrichedFinding[],
368
+ opts: {
369
+ readonly emptyMessage: string;
370
+ readonly groupKey: (f: EnrichedFinding) => string;
371
+ readonly groupHeader: (key: string) => string;
372
+ readonly formatItem: (f: EnrichedFinding) => string;
373
+ },
374
+ gate: GateConfig = GATE_OFF,
375
+ ): void {
376
+ if (findings.length === 0) {
377
+ console.log(opts.emptyMessage);
378
+ return;
379
+ }
380
+
381
+ const groups = new Map<string, EnrichedFinding[]>();
382
+ for (const f of findings) {
383
+ const key = opts.groupKey(f);
384
+ const list = groups.get(key) ?? [];
385
+ list.push(f);
386
+ groups.set(key, list);
387
+ }
388
+
389
+ for (const [key, group] of groups) {
390
+ console.log(opts.groupHeader(key));
391
+ for (const f of group) {
392
+ console.log(opts.formatItem(f));
393
+ console.log("");
394
+ }
395
+ }
396
+
397
+ // Tier rollup + enforcement split — the two summary lines every report ends on.
398
+ const totals = reportTotals(findings);
399
+ for (const line of totals.lines) console.log(line);
400
+
401
+ // Default (unset gate): exit non-zero only when something the contract BLOCKS
402
+ // fired — a warn-only scan is a clean build. When the opt-in gate is set, the
403
+ // exit reflects the gate (impact threshold / max-violations) instead (#2134).
404
+ process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
405
+ }
406
+
407
+ /**
408
+ * The `check` command's runner. The optional {@link AgentLaneOverrides} is the AI
409
+ * lane's ONLY injection seam: the CLI handler never passes it (the lane resolves
410
+ * its provider from `LLM_API_KEY`), but the tracer test drives this same function
411
+ * with a stub provider to prove an agent finding reaches rendered output.
412
+ */
413
+ export async function runCheck(
414
+ dir: string,
415
+ json = false,
416
+ sarif = false,
417
+ runId = "local",
418
+ agentOverrides: AgentLaneOverrides = {},
419
+ gate: GateConfig = GATE_OFF,
420
+ ): Promise<void> {
421
+ const root = resolve(dir);
422
+ const files = await collectTsx(root);
423
+
424
+ // SARIF is a machine format like --json, but rendered for GitHub code-scanning
425
+ // (uris relativized against `root`, provenance-tagged). It takes precedence
426
+ // over --json when both are set; the CI Action asks for one format per run.
427
+ if (sarif) {
428
+ const deterministic = files.length === 0 ? [] : enrichAll((await scan(files)).findings);
429
+ // AI lane (issue #2182): fold in agent findings when LLM_API_KEY is present.
430
+ // Non-blocking — agent findings are warn-only, so the block-gated exit is
431
+ // computed on the augmented list and can only reflect the deterministic floor.
432
+ const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
433
+ console.log(formatSarif(findings, runId, { root }));
434
+ process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
435
+ return;
436
+ }
437
+
438
+ if (json) {
439
+ if (files.length === 0) {
440
+ const report = buildJsonReport(root, 0, { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 }, []);
441
+ console.log(JSON.stringify(report, null, 2));
442
+ return;
443
+ }
444
+ const result = await scan(files);
445
+ const deterministic = enrichAll(result.findings);
446
+ // AI lane (issue #2182): agent findings flow through the SAME JSON report and
447
+ // phone-home envelope as the deterministic floor. When no key is present this
448
+ // returns `deterministic` unchanged.
449
+ const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
450
+ const report = buildJsonReport(root, files.length, result.coverage, findings);
451
+ console.log(JSON.stringify(report, null, 2));
452
+ // OPTIONAL, non-blocking phone-home (#2108): file metadata-only findings to
453
+ // the dashboard when the CI env carries a `b8e_` token + org/project. Fully
454
+ // env-gated, so a local `check --json` (no such env) silently skips; a
455
+ // failure here is swallowed inside `phoneHome` and never changes exit code.
456
+ // Inject this run's true analyzed set (ADR 0043) so phone-home emits it as
457
+ // `scannedPaths` — the source-scan-scope coverage 4b's reconcile keys on.
458
+ await phoneHome(findings, root, process.env, { analyzedFiles: () => result.analyzedFiles });
459
+ process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
460
+ return;
461
+ }
462
+
463
+ if (files.length === 0) {
464
+ console.log(`No .tsx files under ${root}`);
465
+ return;
466
+ }
467
+
468
+ const result = await scan(files);
469
+ const deterministic = enrichAll(result.findings);
470
+ const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
471
+
472
+ console.log(`a11y-checker — scanned ${files.length} .tsx file(s) under ${root}\n`);
473
+
474
+ // Coverage first — it frames how much of the codebase the findings cover.
475
+ console.log(formatCoverage(result.coverage, result.resolved.resolutions, result.resolved.unresolvedPackages));
476
+ console.log("");
477
+
478
+ // Group by file for a readable report.
479
+ renderReport(
480
+ findings,
481
+ {
482
+ emptyMessage: "No jsx-a11y violations found.",
483
+ groupKey: (f) => f.file,
484
+ groupHeader: (file) => relative(root, file),
485
+ formatItem: (f) => formatFinding(f, root),
486
+ },
487
+ gate,
488
+ );
489
+ }
490
+
491
+ /**
492
+ * Accept a bare filesystem path (`./dist/index.html`) as well as a real URL.
493
+ * If the arg already carries a scheme (`http://`, `https://`, `file://`) pass it
494
+ * through; otherwise it's a local path — resolve it and convert to a `file://`
495
+ * URL so Playwright can navigate to it.
496
+ */
497
+ function normalizeTarget(target: string): string {
498
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : pathToFileURL(resolve(target)).href;
499
+ }
500
+
501
+ /**
502
+ * The rendered-DOM counterpart to `runCheck`: drive a real browser to the URL,
503
+ * run axe-core against the live page, and report findings anchored on CSS
504
+ * selectors instead of source lines. This is the source-less path — it inspects
505
+ * what actually ships, so it covers non-React pages and anything the static
506
+ * .tsx scan can't see (server-rendered markup, third-party widgets, runtime DOM).
507
+ */
508
+ async function runCheckUrl(url: string): Promise<void> {
509
+ const target = normalizeTarget(url);
510
+ console.log(`a11y-checker — rendering ${target} and running axe-core\n`);
511
+
512
+ let result: DomScanResult;
513
+ try {
514
+ // Load the browser lane on demand so `check` never pulls playwright/@axe-core.
515
+ const { scanUrl } = await import("./collect-dom");
516
+ result = await scanUrl(target);
517
+ } catch (err) {
518
+ // scanUrl re-throws a load/launch failure as an actionable one-line Error;
519
+ // print just that message (no stack) and exit 2 — a typo'd URL is a usage
520
+ // error, not a crash.
521
+ console.error(err instanceof Error ? err.message : String(err));
522
+ process.exitCode = 2;
523
+ return;
524
+ }
525
+ const findings = enrichAll(result.findings);
526
+
527
+ // Group by axe rule for a readable report — the DOM path has no file to group
528
+ // on (every finding shares the URL), so the ruleId is the natural section key.
529
+ renderReport(findings, {
530
+ emptyMessage: "No axe-core violations found.",
531
+ groupKey: (f) => f.ruleId,
532
+ groupHeader: (ruleId) => ruleId,
533
+ formatItem: (f) => formatUrlFinding(f),
534
+ });
535
+ }
536
+
537
+ /**
538
+ * The native counterpart to `runCheck`: shell to the out-of-process SwiftSyntax
539
+ * engine, which parses `.swift` source under `dir` and applies the static
540
+ * SwiftUI accessibility rules (with the ancestor-climb heuristic). Findings are
541
+ * anchored on `file:line` like the jsx-a11y pass, so the report groups by file
542
+ * exactly as `runCheck` does. The Swift toolchain may be missing — `scanSwift`
543
+ * surfaces that as a one-line Error, handled like `runCheckUrl`'s launch failure.
544
+ */
545
+ async function runCheckSwift(dir: string): Promise<void> {
546
+ let result: Awaited<ReturnType<typeof scanSwift>>;
547
+ try {
548
+ result = await scanSwift(dir);
549
+ } catch (err) {
550
+ // The Swift toolchain (or the prebuilt binary) may be absent — print just
551
+ // the actionable one-line message and exit 2, same discipline as a bad URL.
552
+ console.error(err instanceof Error ? err.message : String(err));
553
+ process.exitCode = 2;
554
+ return;
555
+ }
556
+ // The collector owns its path namespace: it returns the canonical, symlink-free
557
+ // `root` it scanned in, so `relative(root, …)` here renders clean
558
+ // `Sources/…/X.swift:line` locations that agree with the engine's emitted paths.
559
+ const { root } = result;
560
+ console.log(`a11y-checker — scanning .swift under ${root} for SwiftUI a11y\n`);
561
+
562
+ const findings = enrichAll(result.findings);
563
+
564
+ renderReport(findings, {
565
+ emptyMessage: "No SwiftUI a11y violations found.",
566
+ groupKey: (f) => f.file,
567
+ groupHeader: (file) => relative(root, file),
568
+ formatItem: (f) => formatFinding(f, root),
569
+ });
570
+ }
571
+
572
+ /**
573
+ * The Liquid counterpart to `runCheck`: statically scan `.liquid` theme source
574
+ * (in-process — `@shopify/liquid-html-parser` + the structural-absence rules),
575
+ * enrich through the SAME corpus cross-ref, and report findings anchored on
576
+ * `file:line` like the jsx-a11y and SwiftUI passes. No browser, no network. Liquid
577
+ * has no component resolver, so coverage is zeroed in the `--json` shape. A file
578
+ * the parser rejects is skipped (surfaced as a count), never fatal.
579
+ */
580
+ async function runCheckShopify(dir: string, json = false): Promise<void> {
581
+ const { root, files, findings: raw, parseErrors } = await scanLiquid(dir);
582
+ const findings = enrichAll(raw);
583
+
584
+ if (json) {
585
+ // Liquid carries no resolver coverage — emit the zeroed coverage literal so
586
+ // the JSON shape stays identical to `check`.
587
+ const report = buildJsonReport(
588
+ root,
589
+ files.length,
590
+ { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 },
591
+ findings,
592
+ );
593
+ console.log(JSON.stringify(report, null, 2));
594
+ const blocking = findings.filter((f) => f.enforcement === "block").length;
595
+ process.exitCode = blocking > 0 ? 1 : 0;
596
+ return;
597
+ }
598
+
599
+ if (files.length === 0) {
600
+ console.log(`No .liquid files under ${root}`);
601
+ return;
602
+ }
603
+
604
+ console.log(`a11y-checker — scanned ${files.length} .liquid file(s) under ${root}`);
605
+ if (parseErrors.length > 0) {
606
+ console.log(` (${parseErrors.length} file(s) skipped — could not parse)`);
607
+ }
608
+ console.log("");
609
+
610
+ renderReport(findings, {
611
+ emptyMessage: "No Liquid a11y violations found.",
612
+ groupKey: (f) => f.file,
613
+ groupHeader: (file) => relative(root, file),
614
+ formatItem: (f) => formatFinding(f, root),
615
+ });
616
+ }
617
+
618
+ /**
619
+ * The Unity counterpart to `runCheck`: statically scan a Unity project's Force-Text
620
+ * scenes (`.prefab`/`.unity`) via the in-process aggregator (`collectUnityFindings`),
621
+ * enrich through the SAME corpus cross-ref, and report findings anchored on `file:line`
622
+ * like every other producer. No browser, no network. The aggregator owns the scan and
623
+ * returns one flat `Finding[]` (all `provenance: "unity"`, `layer: "floor"`); a missing
624
+ * or unreadable project dir is an empty scan, never a throw. Unity has no component
625
+ * resolver, so coverage is zeroed in the `--json` shape — identical structure to
626
+ * `check-shopify --json`.
627
+ */
628
+ async function runCheckUnity(dir: string, json = false): Promise<void> {
629
+ const root = resolve(dir);
630
+ const findings = enrichAll(await collectUnityFindings(root));
631
+
632
+ if (json) {
633
+ // Unity carries no resolver coverage — emit the zeroed coverage literal so the
634
+ // JSON shape stays identical to `check` / `check-shopify`.
635
+ const report = buildJsonReport(
636
+ root,
637
+ 0,
638
+ { total: 0, declared: 0, registry: 0, traced: 0, opaque: 0, trusted: 0, icons: 0, structural: 0, declare: 0 },
639
+ findings,
640
+ );
641
+ console.log(JSON.stringify(report, null, 2));
642
+ const blocking = findings.filter((f) => f.enforcement === "block").length;
643
+ process.exitCode = blocking > 0 ? 1 : 0;
644
+ return;
645
+ }
646
+
647
+ console.log(`a11y-checker — scanning Unity Force-Text scenes under ${root}\n`);
648
+
649
+ renderReport(findings, {
650
+ emptyMessage: "No Unity a11y violations found.",
651
+ groupKey: (f) => f.file,
652
+ groupHeader: (file) => relative(root, file),
653
+ formatItem: (f) => formatFinding(f, root),
654
+ });
655
+ }
656
+
657
+ async function runInit(suggest: boolean, dirArg: string): Promise<void> {
658
+ const dir = resolve(dirArg);
659
+ const r = await init(dir, { suggest });
660
+ const s = r.contract.stack;
661
+ const router = s.router === null ? "" : ` (${s.router} router)`;
662
+ const title = suggest ? "init --suggest" : "init";
663
+ console.log(`a11y-checker ${title} — ${dir}`);
664
+ console.log(` stack: ${s.framework}${router} · ${s.designSystem} · ${s.language}`);
665
+ if (r.suggestions !== null) printSuggestions(r.suggestions);
666
+ if (r.suggestions === null) {
667
+ console.log(` enforcement: block ${r.contract.enforcement.block.join(", ") || "(none)"}`);
668
+ }
669
+ if (r.suggestions === null) {
670
+ console.log(` wrote: ${relative(dir, r.contractPath)}`);
671
+ } else {
672
+ console.log(
673
+ ` wrote: ${relative(dir, r.contractPath)} (components map included — review before committing)`,
674
+ );
675
+ }
676
+ for (const p of r.blockPaths) console.log(` block: ${relative(dir, p)}`);
677
+ if (r.preservedLearned > 0) {
678
+ console.log(` preserved: ${r.preservedLearned} learned rule(s)`);
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Render the `--suggest` block: every guessed host, aligned, with a confidence
684
+ * marker (✓ confident, ⚠ verify + reason) so the user REVIEWS each one — the
685
+ * whole point of suggesting rather than silently applying. Composites/toggles
686
+ * left in declare are listed too, so nothing the guesser skipped is invisible.
687
+ */
688
+ function printSuggestions(result: SuggestResult): void {
689
+ const { suggestions, skipped } = result;
690
+ if (suggestions.length === 0) {
691
+ console.log(
692
+ " no leaf primitives to hand-map — they're already recognized (registry / trace / trusted library) or composite",
693
+ );
694
+ } else {
695
+ console.log(
696
+ ` suggested ${suggestions.length} component mapping${suggestions.length === 1 ? "" : "s"} (review them — especially the ⚠):`,
697
+ );
698
+ const nameW = Math.max(...suggestions.map((s) => s.name.length));
699
+ const hostW = Math.max(...suggestions.map((s) => s.host.length));
700
+ for (const s of suggestions) {
701
+ const marker =
702
+ s.confidence === "confident" ? "✓" : `⚠ verify — ${s.reason ?? "double-check"}`;
703
+ console.log(` ${s.name.padEnd(nameW)} → ${s.host.padEnd(hostW)} ${marker}`);
704
+ }
705
+ }
706
+ if (skipped.length > 0) {
707
+ console.log(` left in declare (composite — no single host): ${skipped.join(", ")}`);
708
+ }
709
+ }
710
+
711
+ async function runLearn(
712
+ rule: string,
713
+ wcag: readonly string[],
714
+ fix: string | null,
715
+ source: string,
716
+ dirArg: string,
717
+ ): Promise<void> {
718
+ // The rule is the FIRST positional; an optional dir may follow it.
719
+ const dir = resolve(dirArg);
720
+ const input: LearnInput = {
721
+ rule,
722
+ wcag: [...wcag],
723
+ fix,
724
+ source,
725
+ };
726
+ const r = await learn(dir, input);
727
+ if (r.added) {
728
+ console.log(`learned "${r.id}" → ${relative(dir, r.contractPath)}`);
729
+ } else {
730
+ console.log(`already known (no-op): "${r.id}"`);
731
+ }
732
+ for (const p of r.blockPaths) console.log(` block: ${relative(dir, p)}`);
733
+ }
734
+
735
+ async function runGen(check: boolean, dirArg: string): Promise<void> {
736
+ const dir = resolve(dirArg);
737
+ const r = await gen(dir, check);
738
+ if (!r.check) {
739
+ console.log(`a11y-checker gen — ${dir}`);
740
+ for (const p of r.blockPaths) console.log(` block: ${relative(dir, p)}`);
741
+ return;
742
+ }
743
+ console.log(`a11y-checker gen --check — ${dir}`);
744
+ for (const e of r.entries) {
745
+ console.log(` ${e.status.toUpperCase().padEnd(8)} ${relative(dir, e.path)}`);
746
+ }
747
+ if (!r.inSync) {
748
+ console.error(
749
+ "DRIFT: the on-disk block differs from binclusive.json — run `a11y-checker gen`.",
750
+ );
751
+ process.exitCode = 1;
752
+ } else {
753
+ console.log("in sync.");
754
+ }
755
+ }
756
+
757
+ // ---------------------------------------------------------------------------
758
+ // @effect/cli command tree
759
+ // ---------------------------------------------------------------------------
760
+ //
761
+ // The PARSING + DISPATCH layer. Each subcommand declares its flags/args with
762
+ // `@effect/cli` `Options`/`Args`, then its handler parses them and calls the
763
+ // matching `runX` runner unchanged. Effect stays ISOLATED to this layer — the
764
+ // runners are still plain async functions that own their own `process.exitCode`
765
+ // side effects (blocking findings → 1, bad URL → 2), and `NodeRuntime.runMain`
766
+ // reads that exit code after a clean run, so the findings-based exit codes the
767
+ // CI gate depends on survive untouched.
768
+ //
769
+ // The runner bodies are wrapped in `Effect.promise` (not `tryPromise`): a runner
770
+ // that throws is a genuine bug, and letting it reject surfaces the stack via
771
+ // `runMain` exactly as the old top-level `.catch` did.
772
+
773
+ const dirArg = Args.text({ name: "dir" });
774
+ const optionalDir = Args.text({ name: "dir" }).pipe(Args.withDefault("."));
775
+
776
+ // OPT-IN blocking gate (issue #2134), default OFF. `--fail-on` is a choice over
777
+ // the four ACTIONABLE contract impact levels (`unknown` is excluded — a "fail at
778
+ // or above unknown" threshold would fail on everything). The `satisfies readonly
779
+ // Impact[]` binds the choice to the contract enum, so a future rename of a level
780
+ // fails to compile here rather than drifting. Both knobs are `optional` (no
781
+ // default) so an unset gate is the safe state — findings never fail on
782
+ // impact/volume alone.
783
+ const FAIL_ON_CHOICES = ["critical", "serious", "moderate", "minor"] as const satisfies readonly Impact[];
784
+ const failOnOption = Options.choice("fail-on", FAIL_ON_CHOICES).pipe(
785
+ Options.optional,
786
+ Options.withDescription(
787
+ "OPT-IN blocking gate (default OFF): fail the check when any finding's impact is at or above this threshold (critical | serious | moderate | minor). Unset ⇒ non-blocking — findings never fail the check on impact.",
788
+ ),
789
+ );
790
+ const maxViolationsOption = Options.integer("max-violations").pipe(
791
+ Options.optional,
792
+ Options.withDescription(
793
+ "OPT-IN blocking gate (default OFF): fail the check when the total finding count exceeds N. Unset ⇒ no volume gate.",
794
+ ),
795
+ );
796
+
797
+ /** The `check` command's machine-readable output formats. */
798
+ const OUTPUT_FORMATS = ["text", "json", "sarif"] as const;
799
+ type OutputFormat = (typeof OUTPUT_FORMATS)[number];
800
+
801
+ // `--format` is the CANONICAL output selector (issue #2236) — one flag, one of
802
+ // text | json | sarif — the generic `--ci` mode and every CI config point at.
803
+ // The legacy `--json` / `--sarif` booleans remain as back-compat aliases (the
804
+ // shipped GitHub Action and older docs still pass them); an explicit `--format`
805
+ // wins, else the booleans resolve with sarif taking precedence over json, which
806
+ // mirrors runCheck's own sarif-over-json precedence.
807
+ const formatOption = Options.choice("format", OUTPUT_FORMATS).pipe(
808
+ Options.optional,
809
+ Options.withDescription(
810
+ "Output format: text (human report, default) | json | sarif (SARIF 2.1.0 for code-scanning). Canonical machine-readable selector; --json / --sarif remain as aliases.",
811
+ ),
812
+ );
813
+
814
+ // The generic CI runner mode (issue #2236): NON-BLOCKING by default. The run
815
+ // exits 0 even when contract-blocking findings are present, so any CI can emit
816
+ // and consume the SARIF/JSON artifact without the check failing. This is a
817
+ // first-class engine mode, not a shell `|| true` swallow — opt back into a
818
+ // failing exit with --fail-on / --max-violations, which still apply here.
819
+ const ciOption = Options.boolean("ci").pipe(
820
+ Options.withDescription(
821
+ "Generic CI runner mode: NON-BLOCKING — always exit 0 even with blocking findings, so any CI/CD can consume the SARIF/JSON artifact without failing the build. Combine with --format sarif|json. Opt into a failing exit via --fail-on / --max-violations.",
822
+ ),
823
+ );
824
+
825
+ /**
826
+ * Resolve the one output format from the canonical `--format` and the legacy
827
+ * `--json` / `--sarif` aliases. Explicit `--format` wins; otherwise sarif > json
828
+ * > text (runCheck applies the same sarif-over-json precedence internally).
829
+ */
830
+ function resolveFormat(format: Option.Option<OutputFormat>, json: boolean, sarif: boolean): OutputFormat {
831
+ return Option.getOrElse(format, (): OutputFormat => (sarif ? "sarif" : json ? "json" : "text"));
832
+ }
833
+
834
+ const checkCommand = Command.make(
835
+ "check",
836
+ {
837
+ dir: dirArg,
838
+ json: Options.boolean("json"),
839
+ sarif: Options.boolean("sarif"),
840
+ format: formatOption,
841
+ ci: ciOption,
842
+ runId: Options.text("run-id").pipe(Options.withDefault("local")),
843
+ failOn: failOnOption,
844
+ maxViolations: maxViolationsOption,
845
+ },
846
+ ({ dir, json, sarif, format, ci, runId, failOn, maxViolations }) => {
847
+ const resolved = resolveFormat(format, json, sarif);
848
+ return Effect.promise(() =>
849
+ runCheck(dir, resolved === "json", resolved === "sarif", runId, {}, {
850
+ failOn: Option.getOrNull(failOn),
851
+ maxViolations: Option.getOrNull(maxViolations),
852
+ advisory: ci,
853
+ }),
854
+ );
855
+ },
856
+ ).pipe(
857
+ Command.withDescription(
858
+ "scan .tsx for a11y findings (--format text|json|sarif, canonical; --json / --sarif aliases; --ci: non-blocking exit-0 runner mode; --run-id names the SARIF run; --fail-on / --max-violations: OPT-IN blocking gate, default off)",
859
+ ),
860
+ );
861
+
862
+ const checkUrlCommand = Command.make(
863
+ "check-url",
864
+ { target: Args.text({ name: "target" }) },
865
+ ({ target }) => Effect.promise(() => runCheckUrl(target)),
866
+ ).pipe(
867
+ Command.withDescription(
868
+ "render a live URL (or local path) and run axe-core (non-React / source-less pages)",
869
+ ),
870
+ );
871
+
872
+ const checkSwiftCommand = Command.make(
873
+ "check-swift",
874
+ { dir: dirArg },
875
+ ({ dir }) => Effect.promise(() => runCheckSwift(dir)),
876
+ ).pipe(Command.withDescription("scan .swift for SwiftUI accessibility findings (static)"));
877
+
878
+ const checkShopifyCommand = Command.make(
879
+ "check-shopify",
880
+ { dir: dirArg, json: Options.boolean("json") },
881
+ ({ dir, json }) => Effect.promise(() => runCheckShopify(dir, json)),
882
+ ).pipe(
883
+ Command.withDescription(
884
+ "scan .liquid Shopify theme source for structural a11y findings (static, no browser; --json: machine-readable)",
885
+ ),
886
+ );
887
+
888
+ const checkUnityCommand = Command.make(
889
+ "check-unity",
890
+ { dir: dirArg, json: Options.boolean("json") },
891
+ ({ dir, json }) => Effect.promise(() => runCheckUnity(dir, json)),
892
+ ).pipe(
893
+ Command.withDescription(
894
+ "scan Unity Force-Text scenes (.prefab/.unity) for accessibility findings (static, no browser; --json: machine-readable)",
895
+ ),
896
+ );
897
+
898
+ const initCommand = Command.make(
899
+ "init",
900
+ { suggest: Options.boolean("suggest"), dir: optionalDir },
901
+ ({ suggest, dir }) => Effect.promise(() => runInit(suggest, dir)),
902
+ ).pipe(
903
+ Command.withDescription(
904
+ "detect stack, write binclusive.json + AGENTS/CLAUDE block (--suggest scaffolds the components map)",
905
+ ),
906
+ );
907
+
908
+ // `--wcag a,b` is one flag carrying a comma list; split it in the option so the
909
+ // handler sees an Array (canon: options.md "Comma / repeated value flag", form B).
910
+ const wcagOption = Options.text("wcag").pipe(
911
+ Options.map((s) =>
912
+ s
913
+ .split(",")
914
+ .map((x) => x.trim())
915
+ .filter((x) => x !== ""),
916
+ ),
917
+ Options.withDefault([] as readonly string[]),
918
+ );
919
+
920
+ const learnCommand = Command.make(
921
+ "learn",
922
+ {
923
+ rule: Args.text({ name: "rule" }),
924
+ wcag: wcagOption,
925
+ fix: Options.text("fix").pipe(Options.optional),
926
+ source: Options.text("source").pipe(Options.withDefault("manual")),
927
+ dir: optionalDir,
928
+ },
929
+ ({ rule, wcag, fix, source, dir }) =>
930
+ Effect.promise(() =>
931
+ runLearn(rule, wcag, Option.getOrNull(fix), source, dir),
932
+ ),
933
+ ).pipe(Command.withDescription(`record a team rule into binclusive.json and the AGENTS/CLAUDE block`));
934
+
935
+ const genCommand = Command.make(
936
+ "gen",
937
+ { check: Options.boolean("check"), dir: optionalDir },
938
+ ({ check, dir }) => Effect.promise(() => runGen(check, dir)),
939
+ ).pipe(Command.withDescription("regenerate the block (--check exits non-zero on drift)"));
940
+
941
+ const mcpCommand = Command.make("mcp", {}, () =>
942
+ // Lazy so the MCP SDK stays off the static `check` path (issue #2133).
943
+ Effect.promise(() => import("./mcp").then((m) => m.startStdioServer())),
944
+ ).pipe(
945
+ Command.withDescription("start a local stdio MCP server exposing the checker to MCP clients"),
946
+ );
947
+
948
+ const hookCommand = Command.make("hook", {}, () =>
949
+ Effect.promise(() => runHookCli()),
950
+ ).pipe(
951
+ Command.withDescription(
952
+ "PostToolUse hook: scan the just-edited .tsx (reads event JSON from stdin)",
953
+ ),
954
+ );
955
+
956
+ // Back-compat: a bare `a11y-checker <dir>` (no subcommand) still runs `check` on
957
+ // that dir — the shortcut `origin/main`'s `main()` carried explicitly. The root
958
+ // gets an OPTIONAL positional dir + a handler (canon: subcommands.md "the root's
959
+ // own handler still runs when no subcommand is given"; args.md optional-arg). A
960
+ // supplied dir → runCheck; absent → print the root help/usage. All 10 subcommands
961
+ // still bind via withSubcommands and take precedence when a known verb is typed.
962
+ const rootDir = Args.text({ name: "dir" }).pipe(Args.optional);
963
+
964
+ const rootCommand = Command.make("a11y-checker", { dir: rootDir }, ({ dir }) =>
965
+ Option.match(dir, {
966
+ onNone: () => Effect.promise(() => printRootHelp()),
967
+ onSome: (d) => Effect.promise(() => runCheck(d)),
968
+ }),
969
+ ).pipe(
970
+ Command.withDescription(
971
+ "Local accessibility checker for React/TSX, Swift, and live URLs — grounded in a real-world audit corpus.",
972
+ ),
973
+ Command.withSubcommands([
974
+ checkCommand,
975
+ checkUrlCommand,
976
+ checkSwiftCommand,
977
+ checkShopifyCommand,
978
+ checkUnityCommand,
979
+ initCommand,
980
+ learnCommand,
981
+ genCommand,
982
+ mcpCommand,
983
+ hookCommand,
984
+ ]),
985
+ );
986
+
987
+ /**
988
+ * Turn the root command into a runnable `(argv) => Effect`. Exported so tests
989
+ * can drive a subcommand with a synthetic argv (no process spawn) by providing
990
+ * `NodeContext.layer` themselves — see `.patterns/effect-cli/running.md`
991
+ * ("Running a command in a test (no process)").
992
+ */
993
+ export const runCli = Command.run(rootCommand, {
994
+ name: "a11y-checker",
995
+ version: "0.1.0",
996
+ });
997
+
998
+ /**
999
+ * The no-subcommand, no-dir case: print the root help/usage. effect/cli owns the
1000
+ * help printer (canon: help.md "you never write a help printer") — so we delegate
1001
+ * to the built-in `--help` by re-entering the parser, providing the same Node
1002
+ * platform context. This renders the description + the full `COMMANDS` list, the
1003
+ * back-compat replacement for `origin/main`'s `console.error(USAGE)`.
1004
+ */
1005
+ function printRootHelp(): Promise<void> {
1006
+ return Effect.runPromise(
1007
+ runCli(["node", "a11y-checker", "--help"]).pipe(Effect.provide(NodeContext.layer)),
1008
+ );
1009
+ }
1010
+
1011
+ /**
1012
+ * The published-bin entry point: hand the whole `process.argv` to the parser,
1013
+ * provide the Node platform context (FileSystem | Path | Terminal), and let
1014
+ * `NodeRuntime.runMain` execute it, wire SIGINT, and set the process exit code.
1015
+ * A runner that already set `process.exitCode` (blocking findings → 1, bad URL
1016
+ * → 2) keeps it: `runMain` only overrides it on an Effect failure.
1017
+ */
1018
+ export function startCli(argv: readonly string[] = process.argv): void {
1019
+ runCli(argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
1020
+ }
1021
+
1022
+ // Run only when invoked directly (the `a11y-checker` bin), not on import — so
1023
+ // the pure render helpers above stay unit-testable without firing the CLI.
1024
+ if (import.meta.url === `file://${process.argv[1]}`) {
1025
+ startCli();
1026
+ }