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