@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 +18 -12
- package/package.json +8 -3
- package/src/cli.ts +173 -32
- package/src/collect-android-xml.ts +447 -0
- package/src/contract.ts +9 -4
- package/src/core.ts +5 -1
- package/src/decisions-lint.ts +137 -0
- package/src/detect-stack.ts +70 -2
- package/src/enforce.ts +18 -6
- package/src/finding-voice.ts +66 -0
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
|
|
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 **
|
|
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
|
-

|
|
18
18
|
|
|
19
|
-
**▶ [Watch all five demos →](demo/README.md)** — the head-to-head above, a getting-started walkthrough on
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
585
|
-
// Liquid
|
|
586
|
-
// the JSON
|
|
587
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
{
|
|
881
|
-
|
|
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
|
|
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
|
-
/**
|
|
22
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
+
}
|
package/src/detect-stack.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
'
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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: `
|
|
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
|
+
}
|