@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.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- 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
|
+
}
|