@binclusive/a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@binclusive/a11y",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "bin": {
9
+ "a11y-checker": "./bin/a11y.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "data/baseline-rules.json"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Binclusive/a11y-checker-plugin.git"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org",
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "node": ">=20.18.1"
26
+ },
27
+ "dependencies": {
28
+ "@axe-core/playwright": "^4.10.2",
29
+ "@binclusive/a11y-contract": "^0.4.1",
30
+ "@binclusive/code-graph": "^0.0.1",
31
+ "@effect/cli": "0.75.2",
32
+ "@effect/platform": "0.96.1",
33
+ "@effect/platform-node": "0.107.0",
34
+ "@modelcontextprotocol/sdk": "1.26.0",
35
+ "@shopify/liquid-html-parser": "^2.9.2",
36
+ "@typescript-eslint/parser": "^8.21.0",
37
+ "chalk": "^5.3.0",
38
+ "commander": "^11.1.0",
39
+ "effect": "3.21.3",
40
+ "eslint": "^9.18.0",
41
+ "eslint-plugin-jsx-a11y": "^6.10.2",
42
+ "playwright": "^1.50.1",
43
+ "ts-morph": "28.0.0",
44
+ "tsx": "^4.21.0",
45
+ "typescript": "5.8.3",
46
+ "zod": "4.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "22.13.5",
50
+ "fast-check": "^4.8.0",
51
+ "vitest": "3.0.7"
52
+ },
53
+ "scripts": {
54
+ "scan": "tsx ./src/cli.ts check",
55
+ "scan:url": "tsx ./src/cli.ts check-url",
56
+ "mcp": "tsx ./src/mcp.ts",
57
+ "gen:baseline": "tsx ./src/baseline/gen-baseline.ts",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "//test:e2e": "Rendered-DOM e2e (real Chromium). Excluded from `test`. CI must run `npx playwright install chromium` first.",
61
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
62
+ "matrix:discover": "tsx experiments/stack-matrix/discover.ts",
63
+ "matrix:run": "tsx experiments/stack-matrix/run.ts",
64
+ "matrix:report": "tsx experiments/stack-matrix/report.ts",
65
+ "matrix:baseline": "tsx experiments/stack-matrix/baseline.ts",
66
+ "matrix:check": "tsx experiments/stack-matrix/check.ts"
67
+ }
68
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The AI-lane WIRE-IN — the vertical seam that makes the runner (#2095), the
3
+ * skills reasoner (#2096), the code-graph lookup (#2097), and enrich/discover
4
+ * (#2098) actually RUN from the CI scan path (epic #2083, issue #2182).
5
+ *
6
+ * The library pieces all shipped clean but disconnected: nothing invoked
7
+ * `runAgentLane`, and no concrete `Provider` existed, so a bare `LLM_API_KEY`
8
+ * drove nothing. This module is the missing invocation. It sits AFTER the
9
+ * deterministic scan in `runCheck` and, WHEN a key is present, drives the agent
10
+ * lane over the deterministic findings and folds the result back into the SAME
11
+ * `EnrichedFinding[]` every render path already consumes — the PR-comment JSON,
12
+ * the SARIF, and the phone-home envelope. One list in, an augmented list out.
13
+ *
14
+ * NON-BLOCKING BY CONSTRUCTION, at the integration level (not just the unit
15
+ * level): {@link augmentWithAgentLane} NEVER throws and NEVER returns fewer
16
+ * findings than it was given. No key → the deterministic floor, unchanged. A
17
+ * provider that throws, times out, hits the token ceiling, or returns garbage →
18
+ * the runner degrades it to "no agent findings", and this returns the
19
+ * deterministic findings (some possibly carrying an in-place `agentNote`). The
20
+ * caller can always `exit 0`.
21
+ *
22
+ * ADVISORY BY CONSTRUCTION: discovered agent findings are `provenance:
23
+ * "corpus-agent"`, `enforcement: "warn"` — they can never raise the blocking
24
+ * count, so merging them can never flip the exit code. Metadata-only on the wire
25
+ * is preserved: agent findings project through the same `toContractFinding` /
26
+ * phone-home path as the deterministic floor, so no file/line ever crosses.
27
+ */
28
+ import type { EnrichedFinding } from "./evidence";
29
+ import { createAnthropicProvider } from "./runner/providers/anthropic";
30
+ import { createOpenAIProvider } from "./runner/providers/openai";
31
+ import { createCodeGraphLookup } from "./runner/codegraph-lookup";
32
+ import type { Provider } from "./runner/provider";
33
+ import { createSkillsReasoner } from "./runner/reasoning/skills-reasoner";
34
+ import { type RunnerConfig, runAgentLane } from "./runner/runner";
35
+
36
+ /** The provider ids this engine ships a concrete implementation for. */
37
+ const SHIPPED_PROVIDERS = new Set(["anthropic", "openai"]);
38
+
39
+ /**
40
+ * Resolve the concrete {@link Provider} from the CI env, or `null` when the AI
41
+ * lane should not run. Absence of `LLM_API_KEY` is the deterministic-floor
42
+ * signal — NOT an error. `LLM_PROVIDER` (default `anthropic`) selects the
43
+ * implementation; an unrecognized value degrades to `null` (floor only) rather
44
+ * than throwing, keeping the wire-in non-blocking. `LLM_MODEL` overrides the
45
+ * provider's default model.
46
+ */
47
+ export function resolveProvider(env: NodeJS.ProcessEnv): Provider | null {
48
+ const apiKey = env.LLM_API_KEY;
49
+ if (apiKey === undefined || apiKey.trim() === "") return null;
50
+
51
+ // Empty is treated as absent (→ default `anthropic`), same as LLM_MODEL below.
52
+ // The GitHub Action plumbs LLM_PROVIDER with a `default: ""`, so a bare BYOK key
53
+ // arrives with LLM_PROVIDER="" — an empty string that must not select "no provider".
54
+ const providerId = ((env.LLM_PROVIDER ?? "").trim() || "anthropic").toLowerCase();
55
+ if (!SHIPPED_PROVIDERS.has(providerId)) return null;
56
+
57
+ const model = env.LLM_MODEL !== undefined && env.LLM_MODEL.trim() !== "" ? env.LLM_MODEL.trim() : undefined;
58
+ // `LLM_TIMEOUT_MS` overrides the per-request abort bound (#2192); a bad value
59
+ // stays `undefined` so the provider keeps its safe default — can't disable it.
60
+ const timeoutMs = parseTimeoutMs(env.LLM_TIMEOUT_MS);
61
+ // Both shipped providers take the same vendor-neutral config shape; `providerId`
62
+ // is already gated to a shipped id above, so this two-arm switch is total.
63
+ const config = {
64
+ apiKey,
65
+ ...(model !== undefined ? { model } : {}),
66
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
67
+ };
68
+ return providerId === "openai" ? createOpenAIProvider(config) : createAnthropicProvider(config);
69
+ }
70
+
71
+ /** Parse `LLM_TIMEOUT_MS` to a positive number of milliseconds, else `undefined` (use the provider default). */
72
+ function parseTimeoutMs(raw: string | undefined): number | undefined {
73
+ if (raw === undefined || raw.trim() === "") return undefined;
74
+ const parsed = Number(raw);
75
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
76
+ }
77
+
78
+ /** Seams the caller may override — the provider is injected in the tracer test. */
79
+ export interface AgentLaneOverrides {
80
+ /**
81
+ * Inject a provider directly, bypassing env resolution. When provided, the AI
82
+ * lane runs with THIS provider (the key check is skipped). A test drives the
83
+ * real `runCheck` end to end with a stub provider through this seam.
84
+ */
85
+ readonly provider?: Provider;
86
+ readonly config?: RunnerConfig;
87
+ }
88
+
89
+ /**
90
+ * Drive the AI lane over the deterministic findings and return the augmented
91
+ * list: every deterministic finding (some now carrying an in-place `agentNote`)
92
+ * followed by the DISCOVERED `corpus-agent` findings. When the AI lane does not
93
+ * run — no key, unknown provider, or an override that is absent — the input list
94
+ * is returned unchanged.
95
+ *
96
+ * NEVER throws: `runAgentLane` is already total, and the outer guard folds any
97
+ * unexpected construction failure back to the deterministic floor. NEVER shrinks
98
+ * the list: the returned `enrichedFindings` is the input in order, so the
99
+ * deterministic floor always survives.
100
+ */
101
+ export async function augmentWithAgentLane(
102
+ findings: readonly EnrichedFinding[],
103
+ root: string,
104
+ env: NodeJS.ProcessEnv,
105
+ overrides: AgentLaneOverrides = {},
106
+ ): Promise<readonly EnrichedFinding[]> {
107
+ const provider = overrides.provider ?? resolveProvider(env);
108
+ if (provider === null) return findings;
109
+ // Nothing to reason about — skip the lane entirely rather than spin it up.
110
+ if (findings.length === 0) return findings;
111
+
112
+ try {
113
+ const reasoner = createSkillsReasoner();
114
+ const lookup = createCodeGraphLookup({ root });
115
+ // A human breadcrumb stamped on emitted agent findings; matches phone-home's
116
+ // scope vocabulary. Not load-bearing for local SARIF/JSON render.
117
+ const scope = env.B8E_SCOPE !== undefined && env.B8E_SCOPE.trim() !== "" ? env.B8E_SCOPE.trim() : "ci-diff";
118
+
119
+ const outcome = await runAgentLane({
120
+ findings,
121
+ reasoner,
122
+ provider,
123
+ lookup,
124
+ scope,
125
+ ...(overrides.config !== undefined ? { config: overrides.config } : {}),
126
+ });
127
+
128
+ // `enrichedFindings` IS the deterministic floor in order (some with a note);
129
+ // `findings` are the deduped agent discoveries. Concatenation merges the two
130
+ // lanes into the one list every render path already handles.
131
+ return [...outcome.enrichedFindings, ...outcome.findings];
132
+ } catch {
133
+ // Belt-and-suspenders: `runAgentLane` never rejects, but a construction
134
+ // failure (lookup wiring, etc.) must still degrade to the deterministic
135
+ // floor — the AI lane can never fail the run.
136
+ return findings;
137
+ }
138
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The AGENTS.md / CLAUDE.md managed-block generator — one-way from
3
+ * `binclusive.json`.
4
+ *
5
+ * The block is delimited by BEGIN/END markers and is the ONLY region the
6
+ * checker owns in those files: everything outside the markers is preserved
7
+ * byte-for-byte. Never hand-maintain both halves — regenerate from the
8
+ * contract (`gen`), and CI guards drift with `gen --check`.
9
+ *
10
+ * Content is terse, imperative, and carries only NON-INFERABLE signal: the
11
+ * repo's stack, its enforcement policy, and any team-learned rules. Generic
12
+ * advice ("use semantic HTML") is deliberately excluded — an AI tool can infer
13
+ * that. (Frequency framing left with the corpus — ADR 0041 §G.)
14
+ */
15
+
16
+ import type { Contract, Stack } from "./contract";
17
+
18
+ export const BLOCK_BEGIN = "<!-- BEGIN binclusive (generated — edit binclusive.json, not here) -->";
19
+ export const BLOCK_END = "<!-- END binclusive -->";
20
+
21
+ /**
22
+ * Derive a slug id from free rule text: lowercase, non-alphanumerics to
23
+ * hyphens, collapsed and trimmed, capped so ids stay readable. Deterministic
24
+ * (no counter, no clock) so the same rule text always yields the same id —
25
+ * that's what makes `learn` dedupe and the block diff stable.
26
+ */
27
+ export function slugify(text: string): string {
28
+ const base = text
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 60)
33
+ .replace(/-+$/g, "");
34
+ return base === "" ? "rule" : base;
35
+ }
36
+
37
+ /** One-line stack header, e.g. `next (app router) · @b8e/design · ts`. */
38
+ function stackHeader(stack: Stack): string {
39
+ const router = stack.router === null ? "" : ` (${stack.router} router)`;
40
+ return `Stack: ${stack.framework}${router} · ${stack.designSystem} · ${stack.language}`;
41
+ }
42
+
43
+ /**
44
+ * Render the team's learned rules. Each line leads with the SC mapping (if
45
+ * any) and trails with the specific fix and source — a terse, non-inferable
46
+ * shape.
47
+ */
48
+ function learnedLines(contract: Contract): string[] {
49
+ return contract.learned.map((r) => {
50
+ const sc = r.wcag.length > 0 ? `SC ${r.wcag.join(", ")}` : "no SC";
51
+ const fix = r.fix === null ? "" : ` Fix: ${r.fix}`;
52
+ return `- [${sc} · learned · ${r.source}] ${r.rule}${fix}`;
53
+ });
54
+ }
55
+
56
+ /**
57
+ * The ROBOT MODE protocol — how an agent should drive the MCP tools to remediate
58
+ * a11y. Deliberately NON-inferable (which tools, re-scan to verify): an AI can't
59
+ * guess the tool names, so this carries real signal. Emitted at the top of every
60
+ * generated block, so ANY
61
+ * agent that reads AGENTS.md/CLAUDE.md — Cursor, Copilot, Codex, Windsurf, Cline —
62
+ * runs the same loop as the Claude Code `grind` skill, with no file to copy by
63
+ * hand. Lines stay terse and single-blank-separated (the block is re-read every
64
+ * turn, and the splice tests forbid blank-line accumulation).
65
+ */
66
+ const PROTOCOL_LINES: readonly string[] = [
67
+ "Use the local `binclusive-a11y` MCP tools — `check_a11y`, `get_a11y_rules` —",
68
+ "whenever accessibility is in scope. Don't guess from generic a11y knowledge.",
69
+ "",
70
+ "To fix or clean up accessibility:",
71
+ "1. `check_a11y` the directory; work findings by impact — blocking first.",
72
+ "2. Apply mechanical fixes (missing `type`, role typo, `href`, `tabIndex`).",
73
+ "3. For semantic fixes (alt text, `aria-label`, link text, labels) derive a real",
74
+ ' value from context — never filler like `alt="image"`; flag low-confidence ones.',
75
+ "4. Re-scan after each change: a fix counts only when the checker agrees it cleared.",
76
+ "5. Stop after 2 failed tries on a finding; report opaque `coverage` as blind spots.",
77
+ "Remediate findings — never claim compliance. Before adding components, check",
78
+ "`get_a11y_rules({ component })` and apply the rules first.",
79
+ ];
80
+
81
+ /**
82
+ * Build the managed block body (between, not including, the markers) from the
83
+ * contract. Pure function of its inputs — same inputs, same bytes — which is
84
+ * what makes regeneration idempotent and `--check` meaningful.
85
+ */
86
+ export function renderBlock(contract: Contract): string {
87
+ const enforcement = contract.enforcement;
88
+ const lines: string[] = [
89
+ BLOCK_BEGIN,
90
+ "## Accessibility (Binclusive)",
91
+ "",
92
+ ...PROTOCOL_LINES,
93
+ "",
94
+ "### Contract",
95
+ "",
96
+ stackHeader(contract.stack),
97
+ `Enforcement — block: ${enforcement.block.join(", ") || "(none)"} · warn: ${
98
+ enforcement.warn.join(", ") || "(none)"
99
+ }`,
100
+ ];
101
+
102
+ const learned = learnedLines(contract);
103
+ if (learned.length > 0) {
104
+ lines.push("", "### Learned (this repo)", ...learned);
105
+ }
106
+
107
+ lines.push(BLOCK_END);
108
+ return lines.join("\n");
109
+ }
110
+
111
+ /**
112
+ * Splice the freshly-rendered block into existing file content, preserving
113
+ * everything outside the markers. Three cases:
114
+ *
115
+ * - markers present : replace exactly the marked region (idempotent).
116
+ * - file has content but no markers : append the block after a blank line.
117
+ * - file empty/absent (`existing === null`) : the block IS the file.
118
+ *
119
+ * The result always ends with a single trailing newline. Re-splicing the
120
+ * output of a previous splice yields byte-identical content — the property the
121
+ * idempotence test pins.
122
+ */
123
+ export function spliceBlock(existing: string | null, block: string): string {
124
+ const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
125
+
126
+ if (existing === null || existing.trim() === "") {
127
+ return withNewline(block);
128
+ }
129
+
130
+ const begin = existing.indexOf(BLOCK_BEGIN);
131
+ const end = existing.indexOf(BLOCK_END);
132
+ if (begin !== -1 && end !== -1 && end > begin) {
133
+ const before = existing.slice(0, begin);
134
+ const after = existing.slice(end + BLOCK_END.length);
135
+ // Trim a stray newline immediately around the old block so the rebuilt
136
+ // content doesn't accumulate blank lines on repeated regeneration.
137
+ const head = before.replace(/\n*$/, "");
138
+ const tail = after.replace(/^\n*/, "");
139
+ const parts = [head, block, tail].filter((p) => p !== "");
140
+ return withNewline(parts.join("\n\n"));
141
+ }
142
+
143
+ // No managed block yet — append after existing content.
144
+ return withNewline(`${existing.replace(/\n*$/, "")}\n\n${block}`);
145
+ }
146
+
147
+ /**
148
+ * Extract just the managed block (markers inclusive) from file content, or
149
+ * `null` when no block is present. Used by `gen --check` to compare the
150
+ * on-disk block against a freshly-rendered one without diffing the whole file.
151
+ */
152
+ export function extractBlock(content: string): string | null {
153
+ const begin = content.indexOf(BLOCK_BEGIN);
154
+ const end = content.indexOf(BLOCK_END);
155
+ if (begin === -1 || end === -1 || end < begin) return null;
156
+ return content.slice(begin, end + BLOCK_END.length);
157
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Generate the BASELINE RULE CATALOG (`data/baseline-rules.json`) from
3
+ * axe-core's own published per-rule metadata.
4
+ *
5
+ * WHY this exists — the two-source-of-truth split:
6
+ * 1. CORPUS frequency (the moat) — `data/corpus-snapshot.json` + the distilled
7
+ * `data/corpus/patterns-*.json` — comes from REAL audits of ~26 orgs. It is
8
+ * truthful, partial (~15 SCs), and carries org counts / frequency tiers.
9
+ * 2. BASELINE catalog (this file's output) — derived MECHANICALLY from
10
+ * axe-core's rule metadata. Covers EVERY axe rule, so a finding for an SC
11
+ * the corpus has never seen still surfaces with a WCAG SC, an impact, and a
12
+ * concrete fix instead of dead-ending at `unknown`.
13
+ *
14
+ * These two NEVER mix: the baseline carries NO org count and NO frequency tier.
15
+ * It is honest because every field is axe's published per-rule data, not our
16
+ * audit data.
17
+ *
18
+ * SOURCES (all from the installed axe-core package, nothing fabricated):
19
+ * - `axe.getRules()` → `ruleId`, `help`, `helpUrl`, `tags`. The SC array is
20
+ * parsed from the `wcag<NNN>` tags via the SHARED `scFromTags` (the same
21
+ * function the live-DOM collector uses), so axe tags and corpus keys line up.
22
+ * - `axe._audit.rules[].impact` → the per-rule default IMPACT
23
+ * (`minor|moderate|serious|critical`). `getRules()` does NOT expose impact,
24
+ * but the audit's rule objects do, and every one of axe's rules carries a
25
+ * valid level (verified: 104/104, none null). This is axe's own published
26
+ * default impact — NOT a fabricated value. The runtime per-node impact on an
27
+ * axe finding (see `collect-dom.ts`) is still more accurate and wins when
28
+ * present; this static value is the fallback for source-pass findings that
29
+ * fall back to the baseline by SC.
30
+ *
31
+ * DETERMINISTIC + RE-RUNNABLE: rules are sorted by `ruleId`, SC arrays are
32
+ * deduped and sorted, and the JSON is written with stable 2-space formatting +
33
+ * a trailing newline, so re-running with the same axe-core version is a no-op
34
+ * diff. Run with: `pnpm gen:baseline` (or `tsx src/baseline/gen-baseline.ts`).
35
+ */
36
+
37
+ import { createRequire } from "node:module";
38
+ import { dirname, join } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { writeFileSync } from "node:fs";
41
+ import { scFromTags } from "../wcag-tags";
42
+
43
+ /** One baseline rule entry as written to `data/baseline-rules.json`. */
44
+ export interface BaselineRule {
45
+ /** The axe rule id, e.g. `color-contrast`. */
46
+ readonly ruleId: string;
47
+ /** WCAG SC(s) parsed from the rule's `wcag<NNN>` tags (deduped, sorted). */
48
+ readonly sc: readonly string[];
49
+ /** axe's default per-rule impact. */
50
+ readonly impact: "minor" | "moderate" | "serious" | "critical";
51
+ /** axe's short, imperative remediation summary (`help`). */
52
+ readonly help: string;
53
+ /** axe's Deque-University help URL for the rule. */
54
+ readonly helpUrl: string;
55
+ }
56
+
57
+ /** The shape `data/baseline-rules.json` carries: provenance meta + the rules. */
58
+ export interface BaselineCatalogFile {
59
+ readonly _meta: {
60
+ readonly note: string;
61
+ readonly source: string;
62
+ readonly axeVersion: string;
63
+ readonly ruleCount: number;
64
+ };
65
+ readonly rules: readonly BaselineRule[];
66
+ }
67
+
68
+ const VALID_IMPACTS = new Set(["minor", "moderate", "serious", "critical"]);
69
+
70
+ /**
71
+ * Resolve and load axe-core through the same module graph the runtime uses
72
+ * (`@axe-core/playwright` → `axe-core`), so the generator and the live collector
73
+ * read identical rule metadata. Returns the axe module typed loosely — we only
74
+ * touch `getRules()`, `version`, and `_audit.rules`.
75
+ */
76
+ interface AxeRuleMeta {
77
+ readonly ruleId: string;
78
+ readonly help: string;
79
+ readonly helpUrl: string;
80
+ readonly tags: readonly string[];
81
+ }
82
+ interface AxeAuditRule {
83
+ readonly id: string;
84
+ readonly impact?: string | null;
85
+ }
86
+ interface AxeModule {
87
+ readonly version: string;
88
+ getRules(): AxeRuleMeta[];
89
+ readonly _audit?: { readonly rules: readonly AxeAuditRule[] };
90
+ }
91
+
92
+ function loadAxe(require: NodeJS.Require): AxeModule {
93
+ // Prefer the axe-core that @axe-core/playwright pulls in (the runtime path),
94
+ // so the catalog matches the version that actually scans live pages.
95
+ try {
96
+ const apMain = require.resolve("@axe-core/playwright");
97
+ const axePath = require.resolve("axe-core", { paths: [dirname(apMain)] });
98
+ return require(axePath) as AxeModule;
99
+ } catch {
100
+ return require("axe-core") as AxeModule;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Build the baseline catalog from a loaded axe module. Pure transform — no I/O —
106
+ * so a test can call it directly to assert the output shape without writing the
107
+ * file. The per-rule default impact comes from `_audit.rules`, keyed by id.
108
+ */
109
+ export function buildBaselineCatalog(axe: AxeModule): BaselineCatalogFile {
110
+ const impactById = new Map<string, "minor" | "moderate" | "serious" | "critical">();
111
+ for (const r of axe._audit?.rules ?? []) {
112
+ if (typeof r.impact === "string" && VALID_IMPACTS.has(r.impact)) {
113
+ impactById.set(r.id, r.impact as "minor" | "moderate" | "serious" | "critical");
114
+ }
115
+ }
116
+
117
+ const rules: BaselineRule[] = [];
118
+ for (const r of axe.getRules()) {
119
+ const impact = impactById.get(r.ruleId);
120
+ // Every axe rule carries a valid default impact; if a future axe version
121
+ // ever omits one, default to the conservative middle ("moderate") rather
122
+ // than fabricating a high/low signal or dropping the rule from coverage.
123
+ const sc = [...new Set(scFromTags(r.tags))].sort();
124
+ rules.push({
125
+ ruleId: r.ruleId,
126
+ sc,
127
+ impact: impact ?? "moderate",
128
+ help: r.help,
129
+ helpUrl: r.helpUrl,
130
+ });
131
+ }
132
+ rules.sort((a, b) => (a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0));
133
+
134
+ return {
135
+ _meta: {
136
+ note: "BASELINE rule catalog — coverage layer, NOT audit-frequency data. Generated mechanically from axe-core's published per-rule metadata (getRules() + axe._audit.rules[].impact). Covers every axe/WCAG rule with a WCAG SC, axe's default impact, a standard fix (help), and a helpUrl. Carries NO org count and NO frequency tier — those live only in the corpus snapshot (the real-audit moat). Regenerate with `pnpm gen:baseline`.",
137
+ source: "axe-core getRules() + axe._audit.rules[].impact",
138
+ axeVersion: axe.version,
139
+ ruleCount: rules.length,
140
+ },
141
+ rules,
142
+ };
143
+ }
144
+
145
+ /** Stable serialization: 2-space JSON + trailing newline for a clean diff. */
146
+ function serialize(catalog: BaselineCatalogFile): string {
147
+ return `${JSON.stringify(catalog, null, 2)}\n`;
148
+ }
149
+
150
+ function main(): void {
151
+ const here = dirname(fileURLToPath(import.meta.url));
152
+ const require = createRequire(import.meta.url);
153
+ const axe = loadAxe(require);
154
+ const catalog = buildBaselineCatalog(axe);
155
+ const outPath = join(here, "..", "..", "data", "baseline-rules.json");
156
+ writeFileSync(outPath, serialize(catalog));
157
+ console.log(
158
+ `wrote ${catalog.rules.length} baseline rules (axe-core ${catalog._meta.axeVersion}) → ${outPath}`,
159
+ );
160
+ }
161
+
162
+ // Run only when invoked directly (the generator), not on import — so a test can
163
+ // import `buildBaselineCatalog` without triggering a file write.
164
+ if (import.meta.url === `file://${process.argv[1]}`) {
165
+ main();
166
+ }