@binclusive/a11y 0.1.0 → 0.1.1

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 CHANGED
@@ -4,7 +4,7 @@ A local accessibility checker for React/TSX code, grounded in a real-world audit
4
4
 
5
5
  > **It runs entirely on your machine. No network, no account, no upload — your code never leaves the laptop.** That's not a privacy policy, it's how it's built: there's nothing to upload. Point it at a private repo with zero hesitation.
6
6
 
7
- This is a private review build. Clone it, point it at any React codebase (yours, ours), and see what it finds — no setup, no explanation needed.
7
+ This is a private review build. Clone it, point it at any React codebase (yours or ours), and see what it finds — no setup, no explanation needed.
8
8
 
9
9
  > **New here? Start with the [Getting Started](docs/GETTING-STARTED.md) walkthrough.** Zero to your first fix — install, `init`, wire your editor, read a finding, clear it, gate CI.
10
10
 
@@ -12,11 +12,11 @@ This is a private review build. Clone it, point it at any React codebase (yours,
12
12
 
13
13
  ## See it in 30 seconds
14
14
 
15
- On **an open-source component library's own demo app**, `eslint-plugin-jsx-a11y` (the linter everyone runs) passes the docs search box **clean** — while a11y-checker catches its unlabeled `<Input>`, ranks it (`22/26 orgs`), and hands you the fix.
15
+ On **shadcn/ui's own `taxonomy` app**, `eslint-plugin-jsx-a11y` (the linter everyone runs) passes the docs search box **clean** — while a11y-checker catches its unlabeled `<Input>`, ranks it (`22/26 orgs`), and hands you the fix.
16
16
 
17
- ![a11y-checker vs eslint on a component library demo app](demo/component-library.gif)
17
+ ![a11y-checker vs eslint on shadcn/ui taxonomy](demo/taxonomy.gif)
18
18
 
19
- **▶ [Watch all five demos →](demo/README.md)** — the head-to-head above, a getting-started walkthrough on an open-source monorepo, the `binclusive.json` config reference, the state of accessibility across 31 OSS repos, and the agentic self-fix loop. Each is a replayable [asciinema](https://asciinema.org) cast (`asciinema play demo/<name>.cast`), not just a GIF.
19
+ **▶ [Watch all five demos →](demo/README.md)** — the head-to-head above, a getting-started walkthrough on the cal.com monorepo, the `binclusive.json` config reference, the state of accessibility across 31 OSS repos, and the agentic self-fix loop. Each is a replayable [asciinema](https://asciinema.org) cast (`asciinema play demo/<name>.cast`), not just a GIF.
20
20
 
21
21
  ---
22
22
 
@@ -33,7 +33,7 @@ That's the whole thing. It scans every `.tsx` under the folder and prints a cove
33
33
 
34
34
  No clone handy? Point it at this repo's own test fixtures: `pnpm scan ./test/fixtures`.
35
35
 
36
- > **No React source?** (A live site, an ASP.NET/Razor app, plain HTML.) The same checker can render a real page in a browser and audit the live DOM — `pnpm scan:url https://example.com`. See **[Auditing HTML & live pages (non-React)](#auditing-html--live-pages-non-react)** below.
36
+ > **No React source?** (A live site, an ASP.NET/Razor app, plain HTML.) The same checker can render a real page in a browser and audit the live DOM — `pnpm scan:url https://www.example.com`. See **[Auditing HTML & live pages (non-React)](#auditing-html--live-pages-non-react)** below.
37
37
 
38
38
  > **Using your own design system?** (Almost everyone is.) A cold scan leaves most of your components in `declare` — *that's expected, not a failure.* To turn on its best trick (finding bugs *inside* your own components), it needs to know which of your components are buttons, inputs, etc. You don't write that by hand:
39
39
  >
@@ -88,19 +88,19 @@ There's also a **second producer**: a rendered-DOM collector that drives a real
88
88
 
89
89
  ## Auditing HTML & live pages (non-React)
90
90
 
91
- The scan above works on `.tsx` source. But not every page *has* React source on disk — a deployed customer site, an ASP.NET/Razor app, a plain HTML/Bootstrap/jQuery page. For those, point the checker at the **rendered page** instead of the source:
91
+ The scan above works on `.tsx` source. But not every page *has* React source on disk — a deployed site, an ASP.NET/Razor app, a plain HTML/Bootstrap/jQuery page. For those, point the checker at the **rendered page** instead of the source:
92
92
 
93
93
  ```bash
94
94
  pnpm exec playwright install chromium # one-time: the browser the render path drives
95
95
  ```
96
96
 
97
97
  ```bash
98
- pnpm scan:url https://example.com # any deployed URL
98
+ pnpm scan:url https://www.example.com # a deployed site
99
99
  pnpm scan:url http://localhost:5000 # your local dev server
100
100
  pnpm scan:url ./wwwroot/index.html # a local static .html file (bare path works)
101
101
  ```
102
102
 
103
- `<target>` takes an `http(s)://` URL, a `file://` URL, or a **bare local path** (auto-converted to `file://`). Under the hood it renders the page in real Chromium (via Playwright), runs **axe-core** against the live DOM, then flows every finding through the *same* corpus / WCAG / enforcement machinery as the source scan — so a contrast bug on example.com comes back tiered and gated exactly like a missing label in your `.tsx`.
103
+ `<target>` takes an `http(s)://` URL, a `file://` URL, or a **bare local path** (auto-converted to `file://`). Under the hood it renders the page in real Chromium (via Playwright), runs **axe-core** against the live DOM, then flows every finding through the *same* corpus / WCAG / enforcement machinery as the source scan — so a contrast bug on a live site comes back tiered and gated exactly like a missing label in your `.tsx`.
104
104
 
105
105
  This is the source-less path — one command audits any live site, React or not.
106
106
 
@@ -136,7 +136,7 @@ jobs:
136
136
  steps:
137
137
  - uses: actions/checkout@v4
138
138
  - id: a11y
139
- uses: Binclusive/a11y-checker-plugin@main
139
+ uses: Binclusive/a11y@main
140
140
  - if: always() # advisory gate exits 0; upload regardless of findings
141
141
  uses: github/codeql-action/upload-sarif@v3
142
142
  with:
@@ -148,6 +148,12 @@ provenance (`deterministic` vs `agent`) in the SARIF property bag. The SARIF
148
148
  file exists only to render on **your** GitHub — it carries file/line for local
149
149
  annotation and is never sent to the Binclusive dashboard.
150
150
 
151
+ > **Pin for supply-chain safety.** The examples use `@main` for readability, but
152
+ > production workflows should pin the Action to a commit SHA — `uses:
153
+ > Binclusive/a11y@<sha> # v0.1.0` — rather than a floating tag or
154
+ > branch, so a compromised upstream tag can't silently change what runs in your
155
+ > CI. Dependabot (`github-actions` ecosystem) will bump the pin for you.
156
+
151
157
  ### Optional — opt into a blocking check (default off)
152
158
 
153
159
  The check is **non-blocking by default**: it exits 0 on any severity or volume of
@@ -163,7 +169,7 @@ way.
163
169
 
164
170
  ```yaml
165
171
  - id: a11y
166
- uses: Binclusive/a11y-checker-plugin@main
172
+ uses: Binclusive/a11y@main
167
173
  with:
168
174
  fail-on: critical # optional — block only on critical findings
169
175
  # max-violations: 0 # optional — block on any finding at all
@@ -188,7 +194,7 @@ means "lane off", never an error; the scan still exits 0.
188
194
 
189
195
  ```yaml
190
196
  - id: a11y
191
- uses: Binclusive/a11y-checker-plugin@main
197
+ uses: Binclusive/a11y@main
192
198
  with:
193
199
  llm-api-key: ${{ secrets.LLM_API_KEY }} # optional — your BYOK model key
194
200
  llm-model: "" # optional — override the model
@@ -218,7 +224,7 @@ does not, and nothing new crosses the wire.
218
224
 
219
225
  ```yaml
220
226
  - id: a11y
221
- uses: Binclusive/a11y-checker-plugin@main
227
+ uses: Binclusive/a11y@main
222
228
  with:
223
229
  binclusive-app-id: ${{ vars.BINCLUSIVE_APP_ID }}
224
230
  binclusive-app-private-key: ${{ secrets.BINCLUSIVE_APP_PRIVATE_KEY }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@binclusive/a11y",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Local accessibility checker for React/TSX, grounded in axe-core's published rule catalog. Runs entirely on your machine — no network, no upload.",
6
6
  "main": "./src/index.ts",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "git+https://github.com/Binclusive/a11y-checker-plugin.git"
18
+ "url": "git+https://github.com/Binclusive/a11y.git"
19
19
  },
20
20
  "publishConfig": {
21
21
  "registry": "https://registry.npmjs.org",
@@ -56,6 +56,8 @@
56
56
  "mcp": "tsx ./src/mcp.ts",
57
57
  "gen:baseline": "tsx ./src/baseline/gen-baseline.ts",
58
58
  "typecheck": "tsc --noEmit",
59
+ "decisions:check": "tsx ./scripts/check-decisions.ts",
60
+ "check:action-pin": "node ./scripts/check-action-pin.mjs",
59
61
  "test": "vitest run",
60
62
  "//test:e2e": "Rendered-DOM e2e (real Chromium). Excluded from `test`. CI must run `npx playwright install chromium` first.",
61
63
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
@@ -63,6 +65,9 @@
63
65
  "matrix:run": "tsx experiments/stack-matrix/run.ts",
64
66
  "matrix:report": "tsx experiments/stack-matrix/report.ts",
65
67
  "matrix:baseline": "tsx experiments/stack-matrix/baseline.ts",
66
- "matrix:check": "tsx experiments/stack-matrix/check.ts"
68
+ "matrix:check": "tsx experiments/stack-matrix/check.ts",
69
+ "android:matrix:run": "tsx experiments/android-matrix/run.ts",
70
+ "android:matrix:baseline": "tsx experiments/android-matrix/baseline.ts",
71
+ "android:matrix:check": "tsx experiments/android-matrix/check.ts"
67
72
  }
68
73
  }
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { Effect, Option } from "effect";
6
6
  import type { Impact } from "@binclusive/a11y-contract";
7
7
  import { type AgentLaneOverrides, augmentWithAgentLane } from "./agent-lane";
8
8
  import { collectTsx } from "./collect";
9
+ import { scanAndroidXml } from "./collect-android-xml";
9
10
  // Type-only: the rendered-DOM lane (playwright/@axe-core) is loaded lazily inside
10
11
  // `runCheckUrl` so the static `check` path carries no eager browser-stack import
11
12
  // and the CI image can ship without it (issue #2133).
@@ -17,9 +18,10 @@ import { collectUnityFindings } from "./unity-findings";
17
18
  import { type FindingProvenance, scan } from "./core";
18
19
  import { type Evidence, type EnrichedFinding, enrichAll, evidenceImpact, resolveDisplay } from "./evidence";
19
20
  import { runHookCli } from "./hook";
20
- import { phoneHome } from "./phone-home";
21
+ import { type PhoneHomeDeps, phoneHome } from "./phone-home";
21
22
  import { formatSarif } from "./sarif";
22
23
  import {
24
+ GATE_ADVISORY,
23
25
  GATE_OFF,
24
26
  type GateConfig,
25
27
  gateExitCode,
@@ -47,10 +49,14 @@ export function detailLines(f: EnrichedFinding): string[] {
47
49
  : f.provenance === "swiftui"
48
50
  ? " (SwiftUI static)"
49
51
  : "";
52
+ // Impact-first voice (#14): the message LEADS — it names the harmed user and
53
+ // the human consequence, the first thing a reader sees under the location. The
54
+ // rule id and WCAG SC follow as SECONDARY lines (and the corpus block further
55
+ // below), so the identifiers frame the finding without burying the impact.
50
56
  const lines = [
57
+ ` ${f.message}`,
51
58
  ` rule: ${f.ruleId} [${f.enforcement}]${via}`,
52
59
  ` wcag: ${scList}`,
53
- ` ${f.message}`,
54
60
  ];
55
61
 
56
62
  // The display contract resolves the axe-vs-SC policy once (see resolveDisplay):
@@ -355,6 +361,66 @@ export function buildJsonReport(
355
361
  };
356
362
  }
357
363
 
364
+ /**
365
+ * The zeroed coverage literal for a stack with no component resolver (Liquid,
366
+ * Unity) or an empty scan — the `--json`/`buildJsonReport` shape stays identical
367
+ * to `check` even when there is nothing to resolve.
368
+ */
369
+ const EMPTY_COVERAGE: Coverage = {
370
+ total: 0,
371
+ declared: 0,
372
+ registry: 0,
373
+ traced: 0,
374
+ opaque: 0,
375
+ trusted: 0,
376
+ icons: 0,
377
+ structural: 0,
378
+ declare: 0,
379
+ };
380
+
381
+ /** The run context a machine-readable emit needs, threaded from each stack runner. */
382
+ interface EmitContext {
383
+ readonly root: string;
384
+ readonly runId: string;
385
+ readonly filesScanned: number;
386
+ readonly coverage: Coverage;
387
+ /** ABSOLUTE paths this run analyzed — phone-home relativizes them into `scannedPaths` (ADR 0043). */
388
+ readonly analyzedFiles: readonly string[];
389
+ readonly gate: GateConfig;
390
+ /** Test-only seam: override phone-home transport deps to capture the projected wire batch (#163 tracer). */
391
+ readonly phoneHome?: Partial<PhoneHomeDeps>;
392
+ }
393
+
394
+ /**
395
+ * The ONE machine-readable emit path, shared by every stack runner (#163). SARIF
396
+ * and phone-home are the only two consumers that carry findings off the machine,
397
+ * and both narrow through the canonical `@binclusive/a11y-contract` vocabulary —
398
+ * so a new stack (`check-shopify`, and next `check-swift`/`check-unity`/…) reaches
399
+ * the wire by routing HERE, never by re-forking a bespoke `buildJsonReport`→stdout
400
+ * path. `text` is intentionally excluded: the human report differs per stack and
401
+ * stays in each runner.
402
+ */
403
+ async function emitFindings(
404
+ format: "json" | "sarif",
405
+ findings: readonly EnrichedFinding[],
406
+ ctx: EmitContext,
407
+ ): Promise<void> {
408
+ if (format === "sarif") {
409
+ console.log(formatSarif(findings, ctx.runId, { root: ctx.root }));
410
+ } else {
411
+ console.log(JSON.stringify(buildJsonReport(ctx.root, ctx.filesScanned, ctx.coverage, findings), null, 2));
412
+ // OPTIONAL, non-blocking phone-home (#2108): file metadata-only findings to the
413
+ // dashboard when the CI env carries a `b8e_` token + org/project. Env-gated, so a
414
+ // local run silently skips; a failure is swallowed inside `phoneHome` and never
415
+ // changes the exit code. Inject this run's analyzed set (ADR 0043) as `scannedPaths`.
416
+ await phoneHome(findings, ctx.root, process.env, {
417
+ analyzedFiles: () => [...ctx.analyzedFiles],
418
+ ...ctx.phoneHome,
419
+ });
420
+ }
421
+ process.exitCode = gateExitCode(findings.map(toGateFinding), ctx.gate);
422
+ }
423
+
358
424
  /**
359
425
  * The shared report tail for both runners (source + rendered-DOM): empty-state,
360
426
  * grouped findings, the totals rollup, and the blocking-gated exit code. Each
@@ -430,14 +496,13 @@ export async function runCheck(
430
496
  // Non-blocking — agent findings are warn-only, so the block-gated exit is
431
497
  // computed on the augmented list and can only reflect the deterministic floor.
432
498
  const findings = await augmentWithAgentLane(deterministic, root, process.env, agentOverrides);
433
- console.log(formatSarif(findings, runId, { root }));
434
- process.exitCode = gateExitCode(findings.map(toGateFinding), gate);
499
+ await emitFindings("sarif", findings, { root, runId, filesScanned: files.length, coverage: EMPTY_COVERAGE, analyzedFiles: [], gate });
435
500
  return;
436
501
  }
437
502
 
438
503
  if (json) {
439
504
  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 }, []);
505
+ const report = buildJsonReport(root, 0, EMPTY_COVERAGE, []);
441
506
  console.log(JSON.stringify(report, null, 2));
442
507
  return;
443
508
  }
@@ -445,18 +510,17 @@ export async function runCheck(
445
510
  const deterministic = enrichAll(result.findings);
446
511
  // AI lane (issue #2182): agent findings flow through the SAME JSON report and
447
512
  // phone-home envelope as the deterministic floor. When no key is present this
448
- // returns `deterministic` unchanged.
513
+ // returns `deterministic` unchanged. Inject this run's true analyzed set (ADR
514
+ // 0043) so phone-home emits it as `scannedPaths` — 4b's reconcile keys on it.
449
515
  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);
516
+ await emitFindings("json", findings, {
517
+ root,
518
+ runId,
519
+ filesScanned: files.length,
520
+ coverage: result.coverage,
521
+ analyzedFiles: result.analyzedFiles,
522
+ gate,
523
+ });
460
524
  return;
461
525
  }
462
526
 
@@ -504,8 +568,16 @@ function normalizeTarget(target: string): string {
504
568
  * selectors instead of source lines. This is the source-less path — it inspects
505
569
  * what actually ships, so it covers non-React pages and anything the static
506
570
  * .tsx scan can't see (server-rendered markup, third-party widgets, runtime DOM).
571
+ *
572
+ * The optional {@link PhoneHomeDeps} overrides mirror `runCheck`'s `agentOverrides`
573
+ * seam: the CLI handler never passes them (phone-home resolves its config + fetch
574
+ * from the env), but the behavioral tracer test injects a stub `fetch` to prove a
575
+ * real rendered-URL finding reaches the wire as a page-shaped contract.
507
576
  */
508
- async function runCheckUrl(url: string): Promise<void> {
577
+ export async function runCheckUrl(
578
+ url: string,
579
+ phoneHomeOverrides: Partial<PhoneHomeDeps> = {},
580
+ ): Promise<void> {
509
581
  const target = normalizeTarget(url);
510
582
  console.log(`a11y-checker — rendering ${target} and running axe-core\n`);
511
583
 
@@ -532,6 +604,21 @@ async function runCheckUrl(url: string): Promise<void> {
532
604
  groupHeader: (ruleId) => ruleId,
533
605
  formatItem: (f) => formatUrlFinding(f),
534
606
  });
607
+
608
+ // OPTIONAL, non-blocking phone-home (#2335): route the rendered-URL findings
609
+ // through the SAME emit seam the static `check --json` path uses. Inside
610
+ // `phoneHome`, `toFindingPayloadLenient` → `resolveLocations` branches each
611
+ // `^https?://` finding to the canonical page-shaped `{ location: { kind: "page",
612
+ // url }, … }` contract and POSTs it when the CI env carries a `b8e_` token +
613
+ // org/project. Fully env-gated, swallows its own failures, and never touches the
614
+ // exit code the gate above set. The scanned page IS the scanned target — the
615
+ // same `url` vocabulary platform-side scope-reconcile keys on (#2166). `root` is
616
+ // the cwd: page findings resolve their location from the URL, not `root`, and a
617
+ // URL scan analyzes no source files (`scannedPaths` stays the safe empty set).
618
+ await phoneHome(findings, process.cwd(), process.env, {
619
+ scanTargets: () => [target],
620
+ ...phoneHomeOverrides,
621
+ });
535
622
  }
536
623
 
537
624
  /**
@@ -577,22 +664,32 @@ async function runCheckSwift(dir: string): Promise<void> {
577
664
  * has no component resolver, so coverage is zeroed in the `--json` shape. A file
578
665
  * the parser rejects is skipped (surfaced as a count), never fatal.
579
666
  */
580
- async function runCheckShopify(dir: string, json = false): Promise<void> {
667
+ export async function runCheckShopify(
668
+ dir: string,
669
+ format: OutputFormat = "text",
670
+ runId = "local",
671
+ phoneHomeOverrides: Partial<PhoneHomeDeps> = {},
672
+ ): Promise<void> {
581
673
  const { root, files, findings: raw, parseErrors } = await scanLiquid(dir);
582
674
  const findings = enrichAll(raw);
583
675
 
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(
676
+ if (format !== "text") {
677
+ // Route the Liquid scan through the SAME canonical emit path `check` uses
678
+ // (#163): SARIF or the JSON report + `toContractFinding` phone-home projection,
679
+ // so a Shopify scan hands real `@binclusive/a11y-contract` findings to a real
680
+ // consumer instead of a bespoke `buildJsonReport`→stdout report. Liquid carries
681
+ // no component resolver (zeroed coverage) and the whole scanned set is analyzed.
682
+ // Non-blocking (`GATE_ADVISORY`): the stack scan reports, it doesn't gate (exit
683
+ // 0 even with findings) — the opt-in blocking gate is a noted follow-up (#163 AC).
684
+ await emitFindings(format, findings, {
588
685
  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;
686
+ runId,
687
+ filesScanned: files.length,
688
+ coverage: EMPTY_COVERAGE,
689
+ analyzedFiles: files,
690
+ gate: GATE_ADVISORY,
691
+ phoneHome: phoneHomeOverrides,
692
+ });
596
693
  return;
597
694
  }
598
695
 
@@ -654,6 +751,29 @@ async function runCheckUnity(dir: string, json = false): Promise<void> {
654
751
  });
655
752
  }
656
753
 
754
+ /**
755
+ * The Android counterpart to `runCheck`: statically scan an Android project's
756
+ * `res/layout*` XML in-process (`scanAndroidXml`), enrich through the SAME corpus
757
+ * cross-ref, and report findings anchored on `file:line` like every other
758
+ * producer. No browser, no network, no second toolchain — Android layouts are
759
+ * plain XML parsed in Node. A missing or unreadable project dir is an empty scan,
760
+ * never a throw. The collector returns the canonical `root` it scanned so
761
+ * `relative(root, …)` renders clean `app/src/main/res/layout/…:line` locations.
762
+ */
763
+ async function runCheckAndroid(dir: string): Promise<void> {
764
+ const { root, findings: raw } = await scanAndroidXml(dir);
765
+ console.log(`a11y-checker — scanning res/layout XML under ${root} for Android a11y\n`);
766
+
767
+ const findings = enrichAll(raw);
768
+
769
+ renderReport(findings, {
770
+ emptyMessage: "No Android XML a11y violations found.",
771
+ groupKey: (f) => f.file,
772
+ groupHeader: (file) => relative(root, file),
773
+ formatItem: (f) => formatFinding(f, root),
774
+ });
775
+ }
776
+
657
777
  async function runInit(suggest: boolean, dirArg: string): Promise<void> {
658
778
  const dir = resolve(dirArg);
659
779
  const r = await init(dir, { suggest });
@@ -877,11 +997,21 @@ const checkSwiftCommand = Command.make(
877
997
 
878
998
  const checkShopifyCommand = Command.make(
879
999
  "check-shopify",
880
- { dir: dirArg, json: Options.boolean("json") },
881
- ({ dir, json }) => Effect.promise(() => runCheckShopify(dir, json)),
1000
+ {
1001
+ dir: dirArg,
1002
+ json: Options.boolean("json"),
1003
+ sarif: Options.boolean("sarif"),
1004
+ format: formatOption,
1005
+ runId: Options.text("run-id").pipe(Options.withDefault("local")),
1006
+ },
1007
+ ({ dir, json, sarif, format, runId }) =>
1008
+ // Reuse the CANONICAL `--format text|json|sarif` selector (`--json`/`--sarif`
1009
+ // aliases) the default `check` uses, so the Shopify scan reaches SARIF + the
1010
+ // phone-home contract projection through the SAME path, not a bespoke flag (#163).
1011
+ Effect.promise(() => runCheckShopify(dir, resolveFormat(format, json, sarif), runId)),
882
1012
  ).pipe(
883
1013
  Command.withDescription(
884
- "scan .liquid Shopify theme source for structural a11y findings (static, no browser; --json: machine-readable)",
1014
+ "scan .liquid Shopify theme source for structural a11y findings (static, no browser; --format text|json|sarif, canonical — routes findings through the same canonical contract wire path as `check`)",
885
1015
  ),
886
1016
  );
887
1017
 
@@ -895,6 +1025,16 @@ const checkUnityCommand = Command.make(
895
1025
  ),
896
1026
  );
897
1027
 
1028
+ const checkAndroidCommand = Command.make(
1029
+ "check-android",
1030
+ { dir: dirArg },
1031
+ ({ dir }) => Effect.promise(() => runCheckAndroid(dir)),
1032
+ ).pipe(
1033
+ Command.withDescription(
1034
+ "scan Android res/layout XML for accessibility findings (static, in-process — no browser, no toolchain)",
1035
+ ),
1036
+ );
1037
+
898
1038
  const initCommand = Command.make(
899
1039
  "init",
900
1040
  { suggest: Options.boolean("suggest"), dir: optionalDir },
@@ -976,6 +1116,7 @@ const rootCommand = Command.make("a11y-checker", { dir: rootDir }, ({ dir }) =>
976
1116
  checkSwiftCommand,
977
1117
  checkShopifyCommand,
978
1118
  checkUnityCommand,
1119
+ checkAndroidCommand,
979
1120
  initCommand,
980
1121
  learnCommand,
981
1122
  genCommand,
@@ -0,0 +1,447 @@
1
+ /**
2
+ * The Android XML layout STATIC collector — an IN-PROCESS producer of
3
+ * {@link Finding}s, parallel to `collect-swift.ts` (SwiftUI) and
4
+ * `collect-liquid.ts` (Shopify Liquid).
5
+ *
6
+ * Unlike the SwiftUI collector — which shells to an out-of-process SwiftSyntax
7
+ * binary because SwiftSyntax can't run from Node — Android layouts are plain XML
8
+ * and parse perfectly well in-process, so there is NO second toolchain here: a
9
+ * thin line-aware element scanner ({@link parseAndroidLayout}) walks each
10
+ * layout file into a node tree, and two structural-absence rules read each
11
+ * element's attributes (and, for controls, its subtree) to decide whether it
12
+ * carries an accessible name.
13
+ *
14
+ * The two rules mirror the SwiftUI two-rule shape (#109):
15
+ * - `android-xml/image-no-label` (WCAG 1.1.1) — an image-presenting widget
16
+ * (`ImageView` / `ImageButton`) that exposes no `android:contentDescription`.
17
+ * A decorative opt-out — `contentDescription="@null"` or
18
+ * `importantForAccessibility="no"` — is honored, not flagged.
19
+ * - `android-xml/control-no-name` (WCAG 4.1.2) — an interactive control (a
20
+ * `Button` / `ImageButton`, or any element marked `android:clickable="true"`)
21
+ * that exposes no accessible name. The name may come from the control itself
22
+ * (`contentDescription` / `android:text` / `android:hint`) OR — for a
23
+ * clickable CONTAINER — from a descendant that carries text or a
24
+ * contentDescription (Android announces a clickable group by its labeled
25
+ * children). This descendant-climb is the Android analog of the SwiftUI
26
+ * collector's ancestor-climb: it stops a label on a child from reading as a
27
+ * false positive on the wrapping clickable group.
28
+ *
29
+ * Both rules honor Android lint's own suppression seam: `tools:ignore="ContentDescription"`
30
+ * on an element — or on any of its ancestors, exactly as Android Studio's lint
31
+ * scopes a suppression to a subtree — silences both rules for it, so the
32
+ * collector never re-flags what the team has already deliberately waived.
33
+ *
34
+ * An `ImageButton` is BOTH an image widget and a control, so an unlabeled one
35
+ * yields two findings on the same line (one per rule) — the proven prototype
36
+ * behavior the `experiments/android-matrix` corpus reproduces.
37
+ */
38
+
39
+ import { readdir, readFile } from "node:fs/promises";
40
+ import { join, resolve, sep } from "node:path";
41
+ import { contractForFiles, enforcementFor } from "./config-scan";
42
+ import type { Finding } from "./core";
43
+
44
+ /** The two Android-XML static rule ids this collector emits. */
45
+ export type AndroidXmlRuleId = "android-xml/image-no-label" | "android-xml/control-no-name";
46
+
47
+ /** Build/generated dirs that are never layout source — skipped on the walk. The
48
+ * Android ones (`build`, `.gradle`, `.idea`) hold generated/merged resources;
49
+ * the rest match the other collectors' skip set. */
50
+ const SKIP_DIRS = new Set([
51
+ "node_modules",
52
+ ".git",
53
+ "dist",
54
+ "build",
55
+ ".gradle",
56
+ ".idea",
57
+ ".cxx",
58
+ "intermediates",
59
+ ]);
60
+
61
+ /**
62
+ * A layout XML file is one named `*.xml` living directly inside a `res/layout…`
63
+ * directory: `res/layout/foo.xml`, `res/layout-land/foo.xml`,
64
+ * `res/layout-sw600dp/foo.xml`, etc. The resource-qualifier suffix on `layout`
65
+ * is arbitrary (orientation, size, locale…), so we match any directory whose
66
+ * name is `layout` or starts with `layout-`, and require its parent to be `res`
67
+ * — that pairing is what distinguishes a real Android layout resource from an
68
+ * unrelated `layout/` directory elsewhere in a tree.
69
+ */
70
+ export function isAndroidLayoutFile(absPath: string): boolean {
71
+ if (!absPath.endsWith(".xml")) return false;
72
+ const parts = absPath.split(sep);
73
+ if (parts.length < 3) return false;
74
+ const dir = parts[parts.length - 2] as string;
75
+ const parent = parts[parts.length - 3] as string;
76
+ const isLayoutDir = dir === "layout" || dir.startsWith("layout-");
77
+ return parent === "res" && isLayoutDir;
78
+ }
79
+
80
+ /**
81
+ * Recursively collect the `layout` resource `.xml` files under `dir`, skipping
82
+ * build/generated dirs. A missing or unreadable directory yields `[]` rather
83
+ * than throwing — a non-existent scan target is an empty scan, the forgiving
84
+ * contract the other collectors give the CLI.
85
+ */
86
+ export async function collectAndroidLayoutFiles(dir: string): Promise<string[]> {
87
+ const out: string[] = [];
88
+ let entries: Awaited<ReturnType<typeof readdir>>;
89
+ try {
90
+ entries = await readdir(dir, { withFileTypes: true });
91
+ } catch {
92
+ return out;
93
+ }
94
+ for (const entry of entries) {
95
+ const full = join(dir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ if (SKIP_DIRS.has(entry.name)) continue;
98
+ out.push(...(await collectAndroidLayoutFiles(full)));
99
+ } else if (entry.isFile() && isAndroidLayoutFile(full)) {
100
+ out.push(full);
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * One element from an Android layout, as a tree node: its local widget name (the
108
+ * last `.`-segment of a possibly-qualified tag —
109
+ * `androidx.appcompat.widget.AppCompatImageButton` → `AppCompatImageButton`),
110
+ * its attribute map (raw `name → value`, namespace prefix kept), the 1-based
111
+ * source `line` the `<` sits on (the location the rules report), and its child
112
+ * elements (empty for a self-closing/leaf element).
113
+ */
114
+ export interface AndroidNode {
115
+ readonly name: string;
116
+ readonly line: number;
117
+ readonly attrs: ReadonlyMap<string, string>;
118
+ readonly children: readonly AndroidNode[];
119
+ }
120
+
121
+ interface MutableNode {
122
+ name: string;
123
+ line: number;
124
+ attrs: Map<string, string>;
125
+ children: MutableNode[];
126
+ }
127
+
128
+ /**
129
+ * Parse an Android layout XML string into a tree of element nodes, line-aware.
130
+ *
131
+ * A hand-rolled scanner (not a DOM parser) because the rules need the START-tag
132
+ * line + attributes + nesting, and a streaming scan keeps the exact line number
133
+ * of each `<` — the location the prototype reported and the corpus baseline
134
+ * pins. The scanner skips the `<?xml …?>` declaration, comments, CDATA, and
135
+ * doctypes; it reads attribute values respecting quotes, so a multi-line start
136
+ * tag (ubiquitous in Android layouts) is parsed as one element anchored on the
137
+ * line of its `<`. Returns the top-level node(s) — usually one root, but a
138
+ * `<merge>` or malformed file may yield several.
139
+ */
140
+ export function parseAndroidLayout(source: string): AndroidNode[] {
141
+ const roots: MutableNode[] = [];
142
+ const stack: MutableNode[] = [];
143
+ const n = source.length;
144
+ let i = 0;
145
+ let line = 1;
146
+
147
+ const step = (): void => {
148
+ if (source[i] === "\n") line++;
149
+ i++;
150
+ };
151
+ const push = (node: MutableNode): void => {
152
+ const parent = stack[stack.length - 1];
153
+ if (parent) parent.children.push(node);
154
+ else roots.push(node);
155
+ };
156
+
157
+ while (i < n) {
158
+ if (source[i] !== "<") {
159
+ step();
160
+ continue;
161
+ }
162
+ const next = source[i + 1];
163
+ if (next === "?") {
164
+ while (i < n && !(source[i] === "?" && source[i + 1] === ">")) step();
165
+ step();
166
+ step();
167
+ continue;
168
+ }
169
+ if (next === "!") {
170
+ if (source.startsWith("<!--", i)) {
171
+ while (i < n && !source.startsWith("-->", i)) step();
172
+ for (let k = 0; k < 3 && i < n; k++) step();
173
+ } else {
174
+ while (i < n && source[i] !== ">") step();
175
+ if (i < n) step();
176
+ }
177
+ continue;
178
+ }
179
+ if (next === "/") {
180
+ // closing tag </name> — pop one level
181
+ while (i < n && source[i] !== ">") step();
182
+ if (i < n) step();
183
+ if (stack.length > 0) stack.pop();
184
+ continue;
185
+ }
186
+
187
+ // an element start tag
188
+ const tagLine = line;
189
+ step(); // '<'
190
+ let name = "";
191
+ while (i < n && !/[\s/>]/.test(source[i] as string)) {
192
+ name += source[i];
193
+ step();
194
+ }
195
+ const attrs = new Map<string, string>();
196
+ let selfClosing = false;
197
+ while (i < n) {
198
+ while (i < n && /\s/.test(source[i] as string)) step();
199
+ if (i >= n) break;
200
+ if (source[i] === "/" && source[i + 1] === ">") {
201
+ selfClosing = true;
202
+ step();
203
+ step();
204
+ break;
205
+ }
206
+ if (source[i] === ">") {
207
+ step();
208
+ break;
209
+ }
210
+ let attrName = "";
211
+ while (i < n && !/[\s=/>]/.test(source[i] as string)) {
212
+ attrName += source[i];
213
+ step();
214
+ }
215
+ while (i < n && /\s/.test(source[i] as string)) step();
216
+ let attrValue = "";
217
+ if (source[i] === "=") {
218
+ step();
219
+ while (i < n && /\s/.test(source[i] as string)) step();
220
+ const quote = source[i];
221
+ if (quote === '"' || quote === "'") {
222
+ step();
223
+ while (i < n && source[i] !== quote) {
224
+ attrValue += source[i];
225
+ step();
226
+ }
227
+ if (i < n) step();
228
+ } else {
229
+ while (i < n && !/[\s/>]/.test(source[i] as string)) {
230
+ attrValue += source[i];
231
+ step();
232
+ }
233
+ }
234
+ }
235
+ if (attrName !== "") attrs.set(attrName, attrValue);
236
+ }
237
+
238
+ const local = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name;
239
+ if (local === "") continue;
240
+ const node: MutableNode = { name: local, line: tagLine, attrs, children: [] };
241
+ push(node);
242
+ if (!selfClosing) stack.push(node);
243
+ }
244
+ return roots;
245
+ }
246
+
247
+ /** Image-presenting widget names — the `android-xml/image-no-label` set. Matched
248
+ * on the bare widget name (`ImageView` / `ImageButton`); AppCompat/Material
249
+ * variants are intentionally NOT image widgets here (they are controls, caught by
250
+ * the control rule via their `clickable`/inherent-control status). */
251
+ const IMAGE_WIDGETS = new Set(["ImageView", "ImageButton"]);
252
+
253
+ /** Inherently-interactive control names — half of the `control-no-name` trigger
254
+ * (the other half is `android:clickable="true"`). `Button`/`ImageButton` are
255
+ * interactive regardless of an explicit `clickable` attribute. */
256
+ const INHERENT_CONTROLS = new Set(["Button", "ImageButton"]);
257
+
258
+ /** True when the element waives Android lint's `ContentDescription` check via its
259
+ * OWN `tools:ignore`. The value is a comma/space-separated id list; `ContentDescription`
260
+ * or `all` silences both rules. */
261
+ function ownSuppressed(attrs: ReadonlyMap<string, string>): boolean {
262
+ const ignore = attrs.get("tools:ignore");
263
+ if (ignore === undefined) return false;
264
+ return ignore
265
+ .split(/[\s,]+/)
266
+ .some((id) => id === "ContentDescription" || id === "all");
267
+ }
268
+
269
+ /** An attribute is present and meaningful when set to a non-empty value. */
270
+ const has = (attrs: ReadonlyMap<string, string>, key: string): boolean => {
271
+ const v = attrs.get(key);
272
+ return v !== undefined && v !== "";
273
+ };
274
+
275
+ /** True when an image widget carries a text alternative OR is explicitly marked
276
+ * decorative — either way it must NOT be flagged `image-no-label`. A
277
+ * `contentDescription` (including the `@null` decorative sentinel) or an
278
+ * `importantForAccessibility="no"` opt-out both satisfy 1.1.1. */
279
+ function imageLabeled(attrs: ReadonlyMap<string, string>): boolean {
280
+ if (attrs.has("android:contentDescription")) return true;
281
+ if (attrs.get("android:importantForAccessibility") === "no") return true;
282
+ return false;
283
+ }
284
+
285
+ /** True when a control exposes its OWN accessible name — a `contentDescription`,
286
+ * a visible `android:text`, or a `android:hint`. */
287
+ function controlNamedSelf(attrs: ReadonlyMap<string, string>): boolean {
288
+ if (attrs.has("android:contentDescription")) return true;
289
+ if (has(attrs, "android:text")) return true;
290
+ if (has(attrs, "android:hint")) return true;
291
+ // A design-time `tools:text` preview mirrors the text the control is populated
292
+ // with at runtime — so a control carrying one is named, even with no static
293
+ // `android:text` (the dominant pattern for runtime-bound labels/buttons).
294
+ if (has(attrs, "tools:text")) return true;
295
+ if (attrs.get("android:importantForAccessibility") === "no") return true;
296
+ return false;
297
+ }
298
+
299
+ /** True when a node is a text-presenting widget — a `TextView` (any variant:
300
+ * `AppCompatTextView`, a vendor `…TextView`, `CheckedTextView`) or an `EditText`.
301
+ * Its presence in a clickable group's subtree means the group is announced by
302
+ * that child's text even when the text is populated at runtime (no static
303
+ * `android:text`), which is the dominant pattern in list-item rows. */
304
+ function isTextWidget(name: string): boolean {
305
+ return name.endsWith("TextView") || name.endsWith("EditText");
306
+ }
307
+
308
+ /** True when ANY element in the subtree (excluding `node` itself) provides text a
309
+ * screen reader would announce for the enclosing clickable group: a non-empty
310
+ * `android:text`, a `contentDescription` that is not the `@null` decorative
311
+ * sentinel, or a text-presenting widget (whose text is often set at runtime).
312
+ * This is the descendant-climb that keeps a labeled child from making its
313
+ * wrapping clickable container read as unnamed. */
314
+ function subtreeProvidesName(node: AndroidNode): boolean {
315
+ for (const child of node.children) {
316
+ if (isTextWidget(child.name)) return true;
317
+ if (has(child.attrs, "android:text")) return true;
318
+ if (has(child.attrs, "tools:text")) return true;
319
+ const cd = child.attrs.get("android:contentDescription");
320
+ if (cd !== undefined && cd !== "" && cd !== "@null") return true;
321
+ if (subtreeProvidesName(child)) return true;
322
+ }
323
+ return false;
324
+ }
325
+
326
+ const isImageWidget = (name: string): boolean => IMAGE_WIDGETS.has(name);
327
+ const isControl = (node: AndroidNode): boolean =>
328
+ INHERENT_CONTROLS.has(node.name) || node.attrs.get("android:clickable") === "true";
329
+
330
+ /** One raw rule hit before it is mapped onto the shared `Finding` shape. */
331
+ interface RawAndroidFinding {
332
+ readonly file: string;
333
+ readonly line: number;
334
+ readonly ruleId: AndroidXmlRuleId;
335
+ }
336
+
337
+ /** Per-rule WCAG SC + the human-facing message, keyed by rule id. */
338
+ const RULE_META: Record<AndroidXmlRuleId, { wcag: readonly string[]; message: string }> = {
339
+ "android-xml/image-no-label": {
340
+ wcag: ["1.1.1"],
341
+ message:
342
+ 'Image widget has no android:contentDescription — a screen reader announces nothing (set contentDescription, or contentDescription="@null" if purely decorative).',
343
+ },
344
+ "android-xml/control-no-name": {
345
+ wcag: ["4.1.2"],
346
+ message:
347
+ "Interactive control exposes no accessible name — add android:contentDescription (or android:text, or a labeled child) so assistive tech can announce it.",
348
+ },
349
+ };
350
+
351
+ /**
352
+ * Walk one parsed layout tree and apply the two rules, accumulating raw hits.
353
+ * `suppressedAbove` carries an ancestor's `tools:ignore="ContentDescription"`
354
+ * down the subtree, matching Android lint's subtree-scoped suppression. An
355
+ * `ImageButton` that is both an unlabeled image AND an unnamed control emits two
356
+ * findings on the same line — once per rule.
357
+ */
358
+ function walkTree(
359
+ file: string,
360
+ node: AndroidNode,
361
+ suppressedAbove: boolean,
362
+ out: RawAndroidFinding[],
363
+ ): void {
364
+ const suppressed = suppressedAbove || ownSuppressed(node.attrs);
365
+ if (!suppressed) {
366
+ if (isImageWidget(node.name) && !imageLabeled(node.attrs)) {
367
+ out.push({ file, line: node.line, ruleId: "android-xml/image-no-label" });
368
+ }
369
+ if (isControl(node) && !controlNamedSelf(node.attrs) && !subtreeProvidesName(node)) {
370
+ out.push({ file, line: node.line, ruleId: "android-xml/control-no-name" });
371
+ }
372
+ }
373
+ for (const child of node.children) walkTree(file, child, suppressed, out);
374
+ }
375
+
376
+ /**
377
+ * Apply the two Android-XML rules to one parsed file's root nodes. `file` is
378
+ * carried through unchanged onto each finding so the caller controls the path
379
+ * namespace.
380
+ */
381
+ export function findingsForRoots(file: string, roots: readonly AndroidNode[]): RawAndroidFinding[] {
382
+ const out: RawAndroidFinding[] = [];
383
+ for (const root of roots) walkTree(file, root, false, out);
384
+ return out;
385
+ }
386
+
387
+ /** Parse + rule a single layout source string — the unit-testable seam. */
388
+ export function findingsForSource(file: string, source: string): RawAndroidFinding[] {
389
+ return findingsForRoots(file, parseAndroidLayout(source));
390
+ }
391
+
392
+ /**
393
+ * The full output of an Android-XML scan, parallel to `SwiftScanResult`: the
394
+ * findings plus the canonical `root` the collector scanned in (so the CLI renders
395
+ * `relative(root, …)` against the exact namespace the findings carry) and a
396
+ * `parseErrors` count for files that could not be read.
397
+ */
398
+ export interface AndroidXmlScanResult {
399
+ readonly root: string;
400
+ readonly files: readonly string[];
401
+ readonly findings: readonly Finding[];
402
+ readonly parseErrors: number;
403
+ }
404
+
405
+ /**
406
+ * Scan the `layout` resource `.xml` files under `dir` for static Android-XML
407
+ * accessibility findings, in-process. Collects the layout files, parses each,
408
+ * applies the two rules, and maps every raw hit onto a full {@link Finding} —
409
+ * `provenance: "android-xml"`, the rule's WCAG SC, and the enforcement level the
410
+ * governing `binclusive.json` assigns that SC (or `block` with no contract, the
411
+ * historical default). One unreadable file is counted in `parseErrors` and the
412
+ * scan continues — a single bad file never aborts the scan.
413
+ */
414
+ export async function scanAndroidXml(dir: string): Promise<AndroidXmlScanResult> {
415
+ const root = resolve(dir);
416
+ const files = await collectAndroidLayoutFiles(root);
417
+
418
+ const raw: RawAndroidFinding[] = [];
419
+ let parseErrors = 0;
420
+ for (const file of files) {
421
+ let source: string;
422
+ try {
423
+ source = await readFile(file, "utf8");
424
+ } catch {
425
+ parseErrors++;
426
+ continue;
427
+ }
428
+ raw.push(...findingsForSource(file, source));
429
+ }
430
+
431
+ // The contract that governs these files, found by walking up from them — same
432
+ // package-up rule the other collectors use. With no `binclusive.json` every
433
+ // finding is `block`.
434
+ const contract = contractForFiles(raw.map((f) => f.file));
435
+
436
+ const findings: Finding[] = raw.map((f) => ({
437
+ file: f.file,
438
+ line: f.line,
439
+ ruleId: f.ruleId,
440
+ message: RULE_META[f.ruleId].message,
441
+ wcag: RULE_META[f.ruleId].wcag,
442
+ enforcement: enforcementFor(RULE_META[f.ruleId].wcag, contract),
443
+ provenance: "android-xml",
444
+ }));
445
+
446
+ return { root, files, findings, parseErrors };
447
+ }
package/src/contract.ts CHANGED
@@ -18,8 +18,13 @@ export const CONTRACT_VERSION = 1 as const;
18
18
  /** Which Next.js router the repo uses, or `null` when not a Next app. */
19
19
  export type Router = "app" | "pages" | null;
20
20
 
21
- /** Source language of the repo, decided by tsconfig presence. */
22
- export type Language = "ts" | "js";
21
+ /**
22
+ * Source language of the repo. `ts`/`js` are decided by tsconfig presence for a
23
+ * web project; `android-xml` is set when the repo is detected as an Android
24
+ * project (a Gradle/manifest layout with `res/layout*` resources), which routes
25
+ * it to the Android XML layout collector instead of the React/TSX path.
26
+ */
27
+ export type Language = "ts" | "js" | "android-xml";
23
28
 
24
29
  /**
25
30
  * The detected stack. Every field is a best-effort signal from
@@ -134,8 +139,8 @@ function parseRouter(v: unknown): Router {
134
139
  }
135
140
 
136
141
  function parseLanguage(v: unknown): Language {
137
- if (v === "ts" || v === "js") return v;
138
- throw new ContractParseError(`stack.language must be "ts" or "js"`);
142
+ if (v === "ts" || v === "js" || v === "android-xml") return v;
143
+ throw new ContractParseError(`stack.language must be "ts", "js", or "android-xml"`);
139
144
  }
140
145
 
141
146
  function parseStack(v: unknown): Stack {
package/src/core.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./config-scan";
23
23
  import type { Contract } from "./contract";
24
24
  import { enforceContent } from "./enforce";
25
+ import { impactFirstJsxA11yMessage } from "./finding-voice";
25
26
  import { isRouterLinkControl } from "./registry";
26
27
  import {
27
28
  type ComponentResolution,
@@ -75,6 +76,7 @@ export type FindingProvenance =
75
76
  | "swiftui"
76
77
  | "liquid"
77
78
  | "unity"
79
+ | "android-xml"
78
80
  | "corpus-agent";
79
81
 
80
82
  /**
@@ -440,7 +442,9 @@ export async function scan(filePaths: readonly string[]): Promise<ScanResult> {
440
442
  file: result.filePath,
441
443
  line: msg.line,
442
444
  ruleId: msg.ruleId,
443
- message: msg.message,
445
+ // Impact-first voice (#14): rewrite the upstream eslint message to lead
446
+ // with the harmed user. Unmapped rules keep their original message.
447
+ message: impactFirstJsxA11yMessage(msg.ruleId, msg.message),
444
448
  wcag,
445
449
  enforcement: enforcementFor(wcag, contract),
446
450
  provenance: "jsx-a11y",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ADR-sequence collision gate for `.decisions/` (issue #77, ADR 0008).
3
+ *
4
+ * The why: `write-code` allocates the next ADR number at *branch-creation* time
5
+ * with no reservation on the monotonic `.decisions/` sequence, so two
6
+ * decision-builders fanned out in parallel both derive the same next id and
7
+ * collide silently when their branches reach `main` — a duplicate ADR file id
8
+ * and a duplicate row in `.decisions/index.md`. A per-PR review gate cannot see
9
+ * a cross-PR collision on shared global state, so the collision is invisible
10
+ * until both branches merge. This is the *combined-tree* detection/rejection
11
+ * gate ADR 0008 chooses: it runs over the merged `.decisions/` directory and
12
+ * fails loud on any duplicate sequence number (in the files or the index) and
13
+ * on any file<->index drift, so the collision is rejected instead of shipping.
14
+ *
15
+ * Pure and filesystem-narrow: it reads a `.decisions` directory and returns a
16
+ * structured verdict. No process exit, no console — the CLI shell
17
+ * (`scripts/check-decisions.ts`) and the regression test (`test/
18
+ * decisions-collision.test.ts`) both drive this same function.
19
+ */
20
+ import { readdirSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ export interface DecisionsLintResult {
24
+ /** true iff no collision and no file<->index drift was found. */
25
+ ok: boolean;
26
+ /** Human-readable lines, one per problem; empty when ok. */
27
+ errors: string[];
28
+ /** Sorted unique 4-digit ADR ids discovered among the `.decisions/` files. */
29
+ ids: string[];
30
+ }
31
+
32
+ /** An ADR filename is `NNNN-slug.md` with a zero-padded 4-digit sequence. */
33
+ const ADR_FILE_RE = /^(\d{4})-.*\.md$/;
34
+ /** The optional frontmatter `id:` line, used to cross-check the filename. */
35
+ const FRONTMATTER_ID_RE = /^id:\s*(\d{4})\s*$/m;
36
+
37
+ /** Pull the 4-digit ADR id out of an `index.md` table row's FIRST cell only. */
38
+ function indexRowId(line: string): string | null {
39
+ if (!line.trimStart().startsWith("|")) return null;
40
+ const cells = line.split("|");
41
+ // cells[0] is the empty span before the leading pipe; cells[1] is column 1.
42
+ const firstCell = cells[1];
43
+ if (firstCell === undefined) return null;
44
+ // The header cell is `#` and the separator cell is `---`; neither carries a
45
+ // 4-digit run, so matching \d{4} in column 1 selects only real ADR rows.
46
+ const m = firstCell.match(/(\d{4})/);
47
+ return m ? m[1]! : null;
48
+ }
49
+
50
+ /**
51
+ * Lint a `.decisions` directory for ADR-sequence collisions and index drift.
52
+ * Returns ok:false with one error line per problem; never throws on a
53
+ * malformed-but-present tree (a missing `index.md` is itself reported).
54
+ */
55
+ export function lintDecisions(decisionsDir: string): DecisionsLintResult {
56
+ const errors: string[] = [];
57
+
58
+ // 1. Map every ADR file to its sequence id; flag duplicate sequence numbers.
59
+ const filesById = new Map<string, string[]>();
60
+ for (const name of readdirSync(decisionsDir).sort()) {
61
+ const m = name.match(ADR_FILE_RE);
62
+ if (!m) continue;
63
+ const id = m[1]!;
64
+ const list = filesById.get(id) ?? [];
65
+ list.push(name);
66
+ filesById.set(id, list);
67
+
68
+ // Frontmatter `id:` must agree with the filename, so a renumbered-by-hand
69
+ // file whose frontmatter still claims the old id is caught too.
70
+ const fmId = readFileSync(join(decisionsDir, name), "utf8").match(
71
+ FRONTMATTER_ID_RE,
72
+ )?.[1];
73
+ if (fmId !== undefined && fmId !== id) {
74
+ errors.push(
75
+ `ADR file ${name}: frontmatter id '${fmId}' does not match filename sequence '${id}'`,
76
+ );
77
+ }
78
+ }
79
+ for (const [id, names] of [...filesById].sort()) {
80
+ if (names.length > 1) {
81
+ errors.push(
82
+ `duplicate ADR sequence number ${id}: ${names.join(", ")} — two decisions collide on the same id (issue #77)`,
83
+ );
84
+ }
85
+ }
86
+
87
+ // 2. Parse `index.md` rows; flag duplicate rows for the same id.
88
+ const indexById = new Map<string, number>();
89
+ let indexPresent = true;
90
+ let indexText: string;
91
+ try {
92
+ indexText = readFileSync(join(decisionsDir, "index.md"), "utf8");
93
+ } catch {
94
+ indexPresent = false;
95
+ indexText = "";
96
+ errors.push(`.decisions/index.md is missing — cannot verify ADR rows`);
97
+ }
98
+ if (indexPresent) {
99
+ for (const line of indexText.split("\n")) {
100
+ const id = indexRowId(line);
101
+ if (id === null) continue;
102
+ indexById.set(id, (indexById.get(id) ?? 0) + 1);
103
+ }
104
+ for (const [id, count] of [...indexById].sort()) {
105
+ if (count > 1) {
106
+ errors.push(
107
+ `duplicate index.md row for ADR ${id} (${count} rows) — two decisions appended the same id (issue #77)`,
108
+ );
109
+ }
110
+ }
111
+ }
112
+
113
+ // 3. Cross-check files <-> index 1:1, so a collision that surfaces as a file
114
+ // without a row (or a row without a file) is rejected too.
115
+ if (indexPresent) {
116
+ for (const id of [...filesById.keys()].sort()) {
117
+ if (!indexById.has(id)) {
118
+ errors.push(
119
+ `ADR ${id} has a file but no row in index.md — index out of sync`,
120
+ );
121
+ }
122
+ }
123
+ for (const id of [...indexById.keys()].sort()) {
124
+ if (!filesById.has(id)) {
125
+ errors.push(
126
+ `index.md row for ADR ${id} has no matching .decisions/${id}-*.md file — index out of sync`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ return {
133
+ ok: errors.length === 0,
134
+ errors,
135
+ ids: [...filesById.keys()].sort(),
136
+ };
137
+ }
@@ -12,8 +12,8 @@
12
12
  * builds) so detection and scanning agree on what a "component import" is.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync } from "node:fs";
16
- import { dirname, join } from "node:path";
15
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
16
+ import { dirname, join, sep } from "node:path";
17
17
  import type { Language, Router, Stack } from "./contract";
18
18
  import { familyLabel, isFrameworkPrimitive, isOwnModule, packageNameOf } from "./module-scope";
19
19
  import { resolveComponents } from "./resolve-components";
@@ -125,6 +125,67 @@ function detectLanguage(dir: string): Language {
125
125
  return findUp(dir, "tsconfig.json") !== null ? "ts" : "js";
126
126
  }
127
127
 
128
+ /** Build/output dirs not worth descending into when sniffing for Android markers. */
129
+ const ANDROID_SCAN_SKIP = new Set([
130
+ "node_modules",
131
+ ".git",
132
+ ".gradle",
133
+ "build",
134
+ "dist",
135
+ ".idea",
136
+ ]);
137
+
138
+ /**
139
+ * Bounded find-down for an Android marker — an `AndroidManifest.xml` or a
140
+ * `res/layout…` resource directory — within `maxDepth` levels of `dir`. Android
141
+ * projects nest these under `app/src/main/…`, so a pure package-up probe (used
142
+ * for tsconfig) would miss them; a shallow, skip-pruned descent is the cheap,
143
+ * reliable signal. Short-circuits on the first marker found.
144
+ */
145
+ function hasAndroidMarker(dir: string, maxDepth: number): boolean {
146
+ let entries: ReturnType<typeof readdirSync>;
147
+ try {
148
+ entries = readdirSync(dir, { withFileTypes: true });
149
+ } catch {
150
+ return false;
151
+ }
152
+ for (const entry of entries) {
153
+ if (entry.isFile() && entry.name === "AndroidManifest.xml") return true;
154
+ if (entry.isDirectory()) {
155
+ // a `layout` / `layout-…` dir whose parent is `res` is a layout resource dir
156
+ if ((entry.name === "layout" || entry.name.startsWith("layout-")) && dir.endsWith(`${sep}res`)) {
157
+ return true;
158
+ }
159
+ }
160
+ }
161
+ if (maxDepth <= 0) return false;
162
+ for (const entry of entries) {
163
+ if (!entry.isDirectory() || ANDROID_SCAN_SKIP.has(entry.name)) continue;
164
+ if (hasAndroidMarker(join(dir, entry.name), maxDepth - 1)) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * True when `dir` is an Android project: a Gradle root (a `build.gradle` /
171
+ * `settings.gradle`, in Groovy or Kotlin DSL) paired with an Android marker
172
+ * (`AndroidManifest.xml` or a `res/layout*` dir) reachable a few levels down.
173
+ * Requiring BOTH keeps a plain JVM Gradle project (no Android UI) from being
174
+ * misread as Android.
175
+ */
176
+ export function detectAndroid(dir: string): boolean {
177
+ const gradle =
178
+ existsSync(join(dir, "build.gradle")) ||
179
+ existsSync(join(dir, "build.gradle.kts")) ||
180
+ existsSync(join(dir, "settings.gradle")) ||
181
+ existsSync(join(dir, "settings.gradle.kts"));
182
+ if (!gradle && !existsSync(join(dir, "AndroidManifest.xml"))) {
183
+ // No gradle root and no manifest right here — not the root of an Android repo.
184
+ return false;
185
+ }
186
+ return hasAndroidMarker(dir, 5);
187
+ }
188
+
128
189
  /**
129
190
  * The repo's design system: the published package that contributes the most
130
191
  * components RESOLVING TO A KNOWN INTERACTIVE HOST (registry or traced). That
@@ -193,6 +254,13 @@ export function detectDesignSystem(tsxFiles: readonly string[], rootDir?: string
193
254
  * signal; see the per-field helpers for what each reads.
194
255
  */
195
256
  export function detectStack(dir: string, tsxFiles: readonly string[]): Stack {
257
+ // Android is a distinct platform from the React/TSX path: an Android repo has
258
+ // no `package.json`/tsconfig to read, and its UI is `res/layout*` XML, not
259
+ // `.tsx`. Detect it first so it routes to the Android XML collector instead of
260
+ // degrading to a `framework: unknown · custom · js` web stack.
261
+ if (detectAndroid(dir)) {
262
+ return { framework: "android", router: null, designSystem: "android-views", language: "android-xml" };
263
+ }
196
264
  // Package-up: a scan pointed at a nested `src/` finds the app's package.json
197
265
  // (and the on-disk app/pages layout) one or more levels above.
198
266
  const pkgRoot = findUp(dir, "package.json") ?? dir;
package/src/enforce.ts CHANGED
@@ -605,36 +605,48 @@ interface EnforceRule {
605
605
  readonly message: string;
606
606
  }
607
607
 
608
+ /**
609
+ * Every finding message — here and in the jsx-a11y wrapper (see {@link
610
+ * import("./finding-voice")}) — leads with the IMPACT-FIRST template:
611
+ *
612
+ * [who is affected] can't [do what] because [cause], so [fix].
613
+ *
614
+ * The lead names the harmed user and the human consequence (#14), not the rule
615
+ * id. The rule id, WCAG SC, and corpus "seen-in-the-wild" frequency are SECONDARY
616
+ * lines the report renders separately ({@link import("./cli").detailLines}), so
617
+ * the message carries NO `(corpus: …)` suffix — that data is not dropped, it just
618
+ * lives on its own line rather than inline in the impact sentence.
619
+ */
608
620
  const RULES: Record<string, EnforceRule> = {
609
621
  buttonNoName: {
610
622
  ruleId: "enforce/button-no-name",
611
623
  wcag: ["4.1.2"],
612
624
  message:
613
- "Control resolves to a button but has no accessible name: children are empty or icon-only and there is no aria-label/aria-labelledby/title. Give it discernible text or an aria-label (corpus: 4.1.2-button-no-name).",
625
+ "Screen-reader users can't tell what this button does because it has no accessible name — its children are empty or icon-only and there is no aria-label, aria-labelledby, or title, so add visible text or an aria-label that names its action.",
614
626
  },
615
627
  imageNoAlt: {
616
628
  ruleId: "enforce/image-no-alt",
617
629
  wcag: ["1.1.1"],
618
630
  message:
619
- 'Control resolves to an image but has no alt and no aria-label/aria-labelledby. Add an alt that conveys the image\'s meaning, or alt="" if decorative (corpus: 1.1.1).',
631
+ 'Blind users can\'t perceive this image because it has no text alternative — no alt and no aria-label/aria-labelledby, so add an alt that conveys its meaning, or alt="" if it is purely decorative.',
620
632
  },
621
633
  linkNoName: {
622
634
  ruleId: "enforce/link-no-name",
623
635
  wcag: ["2.4.4"],
624
636
  message:
625
- "Control resolves to a link but has no discernible name: no text child and no aria-label/aria-labelledby/title. Give the link visible text or an aria-label that names its destination (corpus: 2.4.4-link-no-name).",
637
+ "Screen-reader users can't tell where this link goes because it has no discernible name no text child and no aria-label, aria-labelledby, or title, so give it visible text or an aria-label that names its destination.",
626
638
  },
627
639
  dialogNoName: {
628
640
  ruleId: "enforce/dialog-no-name",
629
641
  wcag: ["4.1.2", "1.3.1"],
630
642
  message:
631
- "Control resolves to a dialog/modal but has no accessible name: no aria-label/aria-labelledby, no title/label prop, and no nested title subcomponent. Name the dialog so assistive tech announces it (corpus: 4.1.2).",
643
+ "Screen-reader users can't tell what this dialog is for because it has no accessible name no aria-label/aria-labelledby, no title or label prop, and no nested title subcomponent, so name the dialog and assistive tech will announce it on open.",
632
644
  },
633
645
  inputNoName: {
634
646
  ruleId: "enforce/input-no-name",
635
647
  wcag: ["1.3.1", "3.3.2"],
636
648
  message:
637
- "Control resolves to a form input but has no associated label: no aria-label/aria-labelledby, no label/title prop, and no id to pair with a <label for>. Associate a real label (corpus: 1.3.1 / 3.3.2-form-control-no-name).",
649
+ "Screen-reader users can't tell what to enter in this field because it has no associated label no aria-label/aria-labelledby, no label or title prop, and no id to pair with a <label for>, so associate a real label and the field will be announced.",
638
650
  },
639
651
  };
640
652
 
@@ -703,7 +715,7 @@ function preferTagOverRole(
703
715
  const line = sf.getLineAndCharacterOfPosition(attr.getStart(sf)).line + 1;
704
716
  return {
705
717
  line,
706
- message: `This <${tag}> sets role="${value}", which ${native.suggest} conveys natively. Use ${native.suggest} instead of the role so the semantics work without ARIA (corpus: 1.3.1-prefer-tag-over-role).`,
718
+ message: `Assistive-tech users get fragile semantics here because <${tag}> hand-sets role="${value}" instead of using the native element ${native.suggest} that conveys it, so switch to ${native.suggest} and the role works without relying on ARIA.`,
707
719
  };
708
720
  }
709
721
  return null;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Impact-first message voice (#14).
3
+ *
4
+ * Every finding a11y-checker surfaces leads with one consistent shape:
5
+ *
6
+ * [who is affected] can't [do what] because [cause], so [fix].
7
+ *
8
+ * The lead names the harmed user and the human consequence — the first thing a
9
+ * reader sees — rather than a rule id or SC number. The rule id, WCAG SC, and
10
+ * corpus "seen-in-the-wild" frequency stay, but as SECONDARY lines the report
11
+ * renders separately (see `cli.ts` `detailLines`), never inline in the sentence.
12
+ *
13
+ * The enforce content rules (`enforce.ts`) author their own messages in this
14
+ * shape. The jsx-a11y structural pass does NOT — its findings come straight from
15
+ * eslint-plugin-jsx-a11y, whose upstream messages lead with the rule requirement
16
+ * ("Buttons must have discernible text"), not the impacted user. This module is
17
+ * the wrapper that rewrites those upstream messages into the same impact-first
18
+ * voice, keyed by jsx-a11y rule id. It covers exactly the rules we score
19
+ * (`core.ts` `SCORED_RULES`); any rule not mapped here falls back to its upstream
20
+ * message unchanged, so the remap can only improve voice, never blank a finding.
21
+ *
22
+ * This is a display-only rewrite: it changes the `message` string, nothing about
23
+ * detection, the rule id, the WCAG mapping, the corpus enrichment, or the matrix
24
+ * baseline (which records file/line/ruleId, not message text).
25
+ */
26
+
27
+ /**
28
+ * Impact-first messages for the jsx-a11y rules we score. Keyed by the bare rule
29
+ * name (no `jsx-a11y/` prefix) — the caller strips the namespace before lookup.
30
+ * Each follows the [who] can't [do what] because [cause], so [fix] template.
31
+ */
32
+ const JSX_A11Y_IMPACT_MESSAGES: Readonly<Record<string, string>> = {
33
+ "label-has-associated-control":
34
+ "Screen-reader users can't tell what to enter in this field because its control has no associated <label>, so pair the control with a <label> (htmlFor + id, or wrap it) and the field will be announced.",
35
+ "alt-text":
36
+ 'Blind users can\'t perceive this image because it has no alt text, so add an alt that conveys its meaning, or alt="" if it is purely decorative.',
37
+ "anchor-has-content":
38
+ "Screen-reader users can't tell where this link goes because it has no discernible content, so give the anchor visible text or an aria-label that names its destination.",
39
+ "anchor-is-valid":
40
+ "Keyboard and screen-reader users can't reliably follow this link because it has no valid href, so give it a real href — or use a <button> if it triggers an action rather than navigating.",
41
+ "aria-props":
42
+ "Assistive tech can't interpret this element because it carries an attribute that isn't a valid aria-* property, so remove or correct the invalid ARIA attribute.",
43
+ "role-has-required-aria-props":
44
+ "Screen-reader users can't fully operate this control because its role is missing ARIA properties that role requires, so add the required aria-* attributes for the role.",
45
+ "role-supports-aria-props":
46
+ "Screen-reader users can't rely on this element because it has an aria-* attribute its role doesn't support, so remove the unsupported attribute or change the role.",
47
+ "interactive-supports-focus":
48
+ "Keyboard users can't reach this control because it is interactive but not focusable, so make it focusable (use a native control, or add tabIndex) and it can be operated without a mouse.",
49
+ "click-events-have-key-events":
50
+ "Keyboard users can't activate this element because it has a click handler but no keyboard handler, so add a key handler — or use a <button> — and it will work without a mouse.",
51
+ "no-static-element-interactions":
52
+ "Keyboard and screen-reader users can't operate this element because a click handler sits on a non-interactive element with no role, so use a native control, or add an interactive role plus keyboard support.",
53
+ "heading-has-content":
54
+ "Screen-reader users can't navigate by this heading because it has no text content, so give the heading discernible text (or remove it if it is not really a heading).",
55
+ };
56
+
57
+ /**
58
+ * Rewrite a jsx-a11y finding's upstream eslint message into the impact-first
59
+ * voice. `ruleId` is the full id (`jsx-a11y/alt-text`); `fallback` is the
60
+ * upstream message used verbatim when the rule isn't in the map — so a finding
61
+ * always carries a message, and an unmapped rule degrades to its original text.
62
+ */
63
+ export function impactFirstJsxA11yMessage(ruleId: string, fallback: string): string {
64
+ const bare = ruleId.startsWith("jsx-a11y/") ? ruleId.slice("jsx-a11y/".length) : ruleId;
65
+ return JSX_A11Y_IMPACT_MESSAGES[bare] ?? fallback;
66
+ }