@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
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The local "scan → contract → learn" loop, as filesystem operations.
3
+ *
4
+ * Each exported command is a thin IO shell around the pure layers
5
+ * (`detect-stack`, `contract`, `agents-block`): read disk → compute → write
6
+ * disk. The pure pieces are what the tests exercise; this module is what the
7
+ * CLI calls. Nothing here ever writes a secret into `binclusive.json`.
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import { readFile, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { extractBlock, renderBlock, slugify, spliceBlock } from "./agents-block";
14
+ import { collectTsx } from "./collect";
15
+ import {
16
+ type Contract,
17
+ defaultEnforcement,
18
+ emptyDeclarations,
19
+ type LearnedRule,
20
+ parseContract,
21
+ type Stack,
22
+ serializeContract,
23
+ } from "./contract";
24
+ import { detectStack } from "./detect-stack";
25
+ import { resolveComponents } from "./resolve-components";
26
+ import { type SuggestResult, suggestComponentMap } from "./suggest";
27
+ import { ownAliasMatcherFor } from "./tsconfig-aliases";
28
+
29
+ /** The committed contract file name, and the two managed-block targets. */
30
+ export const CONTRACT_FILE = "binclusive.json";
31
+ export const BLOCK_TARGETS = ["AGENTS.md", "CLAUDE.md"] as const;
32
+
33
+ /** Read a UTF-8 file, or `null` if it doesn't exist. */
34
+ async function readMaybe(path: string): Promise<string | null> {
35
+ if (!existsSync(path)) return null;
36
+ return readFile(path, "utf8");
37
+ }
38
+
39
+ /**
40
+ * Load and boundary-parse the contract at `dir/binclusive.json`. Returns `null`
41
+ * when the file is absent; THROWS (loud) when present-but-malformed — a broken
42
+ * committed contract must never be silently overwritten.
43
+ */
44
+ export async function loadContract(dir: string): Promise<Contract | null> {
45
+ const text = await readMaybe(join(dir, CONTRACT_FILE));
46
+ if (text === null) return null;
47
+ let raw: unknown;
48
+ try {
49
+ raw = JSON.parse(text);
50
+ } catch (err) {
51
+ throw new Error(
52
+ `binclusive.json is not valid JSON: ${err instanceof Error ? err.message : err}`,
53
+ );
54
+ }
55
+ return parseContract(raw);
56
+ }
57
+
58
+ /**
59
+ * Merge a freshly-detected stack over the customer's existing one so a re-`init`
60
+ * refreshes what detection KNOWS without clobbering a manual override.
61
+ *
62
+ * `framework` and `language` are hard signals (deps + tsconfig) — the detected
63
+ * value always wins. `router` and `designSystem` are the soft, overridable
64
+ * fields: detection NEVER downgrades a specific committed value to its generic
65
+ * fallback (`null` router / `"custom"` design system). So a customer who hand-set
66
+ * `designSystem: "@acme/ui"` keeps it even on a scan where nothing resolves,
67
+ * while a real detection (`@mui/material`) still refreshes a stale value.
68
+ */
69
+ function mergeStack(detected: Stack, existing: Stack | undefined): Stack {
70
+ if (existing === undefined) return detected;
71
+ return {
72
+ framework: detected.framework,
73
+ language: detected.language,
74
+ router: detected.router ?? existing.router,
75
+ designSystem:
76
+ detected.designSystem === "custom" ? existing.designSystem : detected.designSystem,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The default enforcement policy for a fresh contract. The corpus left the engine
82
+ * (ADR 0041 §G), so there is no frequency signal to auto-select which SCs block —
83
+ * a fresh contract blocks nothing by default and the team opts SCs into `block`
84
+ * explicitly. Impact still gates via the impact-gate; enforcement is the
85
+ * per-SC override on top of it.
86
+ */
87
+ function defaultContractEnforcement(): ReturnType<typeof defaultEnforcement> {
88
+ return defaultEnforcement([], []);
89
+ }
90
+
91
+ /**
92
+ * Write the managed block into every target file (`AGENTS.md`, `CLAUDE.md`),
93
+ * creating the file when absent and preserving all content outside the markers
94
+ * when present. Returns the list of written file paths.
95
+ */
96
+ async function writeBlockTargets(dir: string, contract: Contract): Promise<string[]> {
97
+ const block = renderBlock(contract);
98
+ const written: string[] = [];
99
+ for (const name of BLOCK_TARGETS) {
100
+ const path = join(dir, name);
101
+ const existing = await readMaybe(path);
102
+ const next = spliceBlock(existing, block);
103
+ if (next !== existing) {
104
+ await writeFile(path, next, "utf8");
105
+ }
106
+ written.push(path);
107
+ }
108
+ return written;
109
+ }
110
+
111
+ /** Options for {@link init}. */
112
+ export interface InitOptions {
113
+ /**
114
+ * When `true` (the `--suggest` flag), scaffold the `components` map: guess a
115
+ * host for each unresolved design-system wrapper from its NAME and merge the
116
+ * guesses into the written contract for the user to review. Absent/`false` →
117
+ * plain `init`: no guessed map, behavior byte-for-byte unchanged.
118
+ */
119
+ readonly suggest?: boolean;
120
+ }
121
+
122
+ /** The result of `init`, for the CLI to report. */
123
+ export interface InitResult {
124
+ readonly contract: Contract;
125
+ readonly contractPath: string;
126
+ readonly blockPaths: readonly string[];
127
+ readonly preservedLearned: number;
128
+ /**
129
+ * The component-map suggestions, present ONLY when `init` ran with
130
+ * `suggest: true` (the `--suggest` flag). `null` for a plain `init`, so the
131
+ * default behavior — and its output — is byte-for-byte unchanged. When
132
+ * present, the guessed hosts are ALSO merged into the written contract's
133
+ * `declarations.components` (existing manual entries win on conflict).
134
+ */
135
+ readonly suggestions: SuggestResult | null;
136
+ }
137
+
138
+ /**
139
+ * `init [dir]`: detect the stack, then write `binclusive.json` and refresh the
140
+ * managed block in the target files. If a contract already exists, EVERYTHING
141
+ * the customer declared is PRESERVED — `learned[]`, `enforcement`, and the
142
+ * escape-hatch `declarations` (`components` / `injectsChildren` / `ignore`).
143
+ * Only the auto-derived `stack` is refreshed. Re-running init must never clobber
144
+ * the team's accumulated rules, policy, or manual declarations.
145
+ *
146
+ * The one auto-derived field — `stack` — is also non-clobbering for the parts a
147
+ * customer can override: `detectStack` reads disk, but a hand-set `designSystem`
148
+ * or `router` is re-detected the same way it was first detected, so a stable
149
+ * repo yields a stable stack. (Manual stack overrides survive because they are
150
+ * the detector's own output; a re-init re-derives identical values.)
151
+ */
152
+ export async function init(dir: string, opts: InitOptions = {}): Promise<InitResult> {
153
+ const tsxFiles = await collectTsx(dir);
154
+ const stack = detectStack(dir, tsxFiles);
155
+
156
+ const existing = await loadContract(dir);
157
+ const baseDeclarations = existing?.declarations ?? emptyDeclarations();
158
+
159
+ // `--suggest` scaffolds the `components` map: guess a host for each genuine
160
+ // unknown (declare-bucket) wrapper from its name, for the user to review. The
161
+ // guessed hosts are merged UNDER any the customer already declared — a manual
162
+ // entry always wins (it was a deliberate decision; a guess is not).
163
+ let suggestions: SuggestResult | null = null;
164
+ let declarations = baseDeclarations;
165
+ if (opts.suggest === true) {
166
+ // Resolve against the EXISTING declared map so already-declared wrappers
167
+ // don't reappear as declare-bucket candidates to re-suggest.
168
+ const { resolutions } = resolveComponents(tsxFiles, baseDeclarations.components);
169
+ suggestions = suggestComponentMap(resolutions, {
170
+ isOwnAlias: ownAliasMatcherFor(dir).isOwnAlias,
171
+ designSystem: stack.designSystem,
172
+ });
173
+ const guessed: Record<string, string> = {};
174
+ for (const s of suggestions.suggestions) guessed[s.name] = s.host;
175
+ declarations = {
176
+ ...baseDeclarations,
177
+ // Manual entries last so they OVERRIDE a guess for the same name.
178
+ components: { ...guessed, ...baseDeclarations.components },
179
+ };
180
+ }
181
+
182
+ const contract: Contract = {
183
+ version: 1,
184
+ stack: mergeStack(stack, existing?.stack),
185
+ enforcement: existing?.enforcement ?? defaultContractEnforcement(),
186
+ learned: existing?.learned ?? [],
187
+ declarations,
188
+ };
189
+
190
+ const contractPath = join(dir, CONTRACT_FILE);
191
+ await writeFile(contractPath, serializeContract(contract), "utf8");
192
+ const blockPaths = await writeBlockTargets(dir, contract);
193
+
194
+ return {
195
+ contract,
196
+ contractPath,
197
+ blockPaths,
198
+ preservedLearned: existing?.learned.length ?? 0,
199
+ suggestions,
200
+ };
201
+ }
202
+
203
+ /** Inputs for `learn`, already parsed from CLI flags. */
204
+ export interface LearnInput {
205
+ readonly rule: string;
206
+ readonly wcag: readonly string[];
207
+ readonly fix: string | null;
208
+ readonly source: string;
209
+ }
210
+
211
+ /**
212
+ * Append a learned rule to a contract, deduping on identical rule text (case-
213
+ * and whitespace-normalized). Returns the next contract and whether anything
214
+ * was added — pure, so the dedupe behavior is unit-testable without disk.
215
+ */
216
+ export function appendLearned(
217
+ contract: Contract,
218
+ input: LearnInput,
219
+ addedAt: string,
220
+ ): { readonly next: Contract; readonly added: boolean } {
221
+ const norm = (s: string): string => s.trim().toLowerCase().replace(/\s+/g, " ");
222
+ const exists = contract.learned.some((r) => norm(r.rule) === norm(input.rule));
223
+ if (exists) return { next: contract, added: false };
224
+
225
+ const rule: LearnedRule = {
226
+ id: slugify(input.rule),
227
+ rule: input.rule,
228
+ wcag: [...input.wcag],
229
+ fix: input.fix,
230
+ source: input.source,
231
+ addedAt,
232
+ };
233
+ return { next: { ...contract, learned: [...contract.learned, rule] }, added: true };
234
+ }
235
+
236
+ /** The result of `learn`, for the CLI to report. */
237
+ export interface LearnResult {
238
+ readonly added: boolean;
239
+ readonly id: string;
240
+ readonly contractPath: string;
241
+ readonly blockPaths: readonly string[];
242
+ }
243
+
244
+ /**
245
+ * `learn "<rule>" [flags]`: append the rule to `binclusive.json` `learned[]`
246
+ * (ISO `addedAt`, slug id) and regenerate the managed block. Requires an
247
+ * existing contract — `init` must run first so the stack is real, not guessed.
248
+ * Idempotent on identical rule text: a duplicate is a no-op (no second entry,
249
+ * but the block is still regenerated so the files stay in sync).
250
+ */
251
+ export async function learn(dir: string, input: LearnInput): Promise<LearnResult> {
252
+ const contract = await loadContract(dir);
253
+ if (contract === null) {
254
+ throw new Error(`no ${CONTRACT_FILE} in ${dir} — run \`a11y-checker init\` first`);
255
+ }
256
+ const { next, added } = appendLearned(contract, input, new Date().toISOString());
257
+
258
+ const contractPath = join(dir, CONTRACT_FILE);
259
+ if (added) {
260
+ await writeFile(contractPath, serializeContract(next), "utf8");
261
+ }
262
+ const blockPaths = await writeBlockTargets(dir, next);
263
+ return { added, id: slugify(input.rule), contractPath, blockPaths };
264
+ }
265
+
266
+ /** A per-file drift result from `gen --check`. */
267
+ export interface DriftEntry {
268
+ readonly path: string;
269
+ readonly status: "ok" | "drift" | "missing";
270
+ }
271
+
272
+ /** The result of `gen`. In `--check` mode `inSync` gates the CLI exit code. */
273
+ export interface GenResult {
274
+ readonly check: boolean;
275
+ readonly inSync: boolean;
276
+ readonly entries: readonly DriftEntry[];
277
+ readonly blockPaths: readonly string[];
278
+ }
279
+
280
+ /**
281
+ * `gen [--check]`: regenerate the managed block from `binclusive.json`.
282
+ *
283
+ * Without `--check`: write the block into every target (preserving surrounding
284
+ * content) and report the paths.
285
+ *
286
+ * With `--check`: write NOTHING. Compare each target's on-disk block against a
287
+ * freshly-rendered one and report drift — `inSync` is false if any target's
288
+ * block differs or is missing. This is the CI guard against hand-editing the
289
+ * generated half instead of `binclusive.json`.
290
+ */
291
+ export async function gen(dir: string, check: boolean): Promise<GenResult> {
292
+ const contract = await loadContract(dir);
293
+ if (contract === null) {
294
+ throw new Error(`no ${CONTRACT_FILE} in ${dir} — run \`a11y-checker init\` first`);
295
+ }
296
+ const fresh = renderBlock(contract);
297
+
298
+ if (!check) {
299
+ const blockPaths = await writeBlockTargets(dir, contract);
300
+ return { check: false, inSync: true, entries: [], blockPaths };
301
+ }
302
+
303
+ const entries: DriftEntry[] = [];
304
+ for (const name of BLOCK_TARGETS) {
305
+ const path = join(dir, name);
306
+ const content = await readMaybe(path);
307
+ const onDisk = content === null ? null : extractBlock(content);
308
+ const status: DriftEntry["status"] =
309
+ onDisk === null ? "missing" : onDisk === fresh ? "ok" : "drift";
310
+ entries.push({ path, status });
311
+ }
312
+ const inSync = entries.every((e) => e.status === "ok");
313
+ return { check: true, inSync, entries, blockPaths: entries.map((e) => e.path) };
314
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The bridge between a committed `binclusive.json` and a scan.
3
+ *
4
+ * `scan` is config-OPTIONAL: with no contract it behaves exactly as before.
5
+ * This module is what makes it config-AWARE when a contract IS present — it
6
+ * finds the nearest contract at or above the scanned files, and turns the
7
+ * customer's escape-hatch declarations + enforcement policy into the small,
8
+ * pure predicates `scan` applies:
9
+ *
10
+ * - `ignore` globs -> drop matching files before they are ever linted
11
+ * - `ignore` rule ids -> drop findings for a disabled rule ("off")
12
+ * - `enforcement` -> tag each surviving finding `block` vs `warn`
13
+ *
14
+ * Everything here is a pure function of the contract + a path/rule id, so the
15
+ * scan stays deterministic and the behavior is unit-testable without disk.
16
+ */
17
+
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { dirname, join, sep } from "node:path";
20
+ import { type Contract, parseContract } from "./contract";
21
+
22
+ /** The committed contract file name — single source, shared with `commands.ts`. */
23
+ const CONTRACT_FILE = "binclusive.json";
24
+
25
+ /**
26
+ * Walk UP from `dir` (inclusive) to the nearest ancestor holding a
27
+ * `binclusive.json`, load + boundary-parse it, and return the contract — or
28
+ * `null` when none is found (the zero-config case). A present-but-malformed
29
+ * contract THROWS via `parseContract`: a broken committed config must surface,
30
+ * not be silently ignored mid-scan.
31
+ *
32
+ * This is the package-up rule the rest of the toolchain uses: a scan pointed at
33
+ * a nested `src/` still picks up the app's contract one or more levels above.
34
+ */
35
+ export function findContractFrom(dir: string): Contract | null {
36
+ let cur = dir;
37
+ for (;;) {
38
+ const path = join(cur, CONTRACT_FILE);
39
+ if (existsSync(path)) {
40
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
41
+ return parseContract(raw);
42
+ }
43
+ const parent = dirname(cur);
44
+ if (parent === cur) return null;
45
+ cur = parent;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Given a set of absolute file paths, find the contract that governs them by
51
+ * walking up from their longest common directory. Returns `null` for an empty
52
+ * set or when no contract exists at or above the files.
53
+ */
54
+ export function contractForFiles(filePaths: readonly string[]): Contract | null {
55
+ if (filePaths.length === 0) return null;
56
+ return findContractFrom(commonBaseDir(filePaths));
57
+ }
58
+
59
+ /**
60
+ * Longest common directory of the given absolute paths. Shared by the scan
61
+ * (as ESLint's `cwd`, so targets fall inside the flat-config base path —
62
+ * otherwise ESLint silently reports "outside of base path" and yields zero
63
+ * findings) and by contract find-up (the dir to walk up from). Falls back to
64
+ * `process.cwd()` for empty input; root `sep` when the paths share no prefix.
65
+ */
66
+ export function commonBaseDir(paths: readonly string[]): string {
67
+ if (paths.length === 0) return process.cwd();
68
+ const segmentLists = paths.map((p) => dirname(p).split(sep));
69
+ const first = segmentLists[0] ?? [];
70
+ const common: string[] = [];
71
+ for (let i = 0; i < first.length; i++) {
72
+ const seg = first[i];
73
+ if (segmentLists.every((segs) => segs[i] === seg)) common.push(seg);
74
+ else break;
75
+ }
76
+ const joined = common.join(sep);
77
+ return joined === "" ? sep : joined;
78
+ }
79
+
80
+ /**
81
+ * Compile one `ignore` glob into a matcher. Supported, deliberately small, glob
82
+ * vocabulary (these are file-skip patterns, not a full shell glob):
83
+ *
84
+ * - `**` matches across path separators (any depth)
85
+ * - `*` matches within a single path segment (no separator)
86
+ * - `?` matches one non-separator character
87
+ *
88
+ * Anchored with `(^|/)…$`, so a no-`/` pattern matches the BASENAME at any
89
+ * depth (`*.test.tsx` matches `a/b/x.test.tsx`) and a `/`-bearing pattern
90
+ * matches a path suffix (`src/legacy/*`, `**​/generated/**`). Everything outside
91
+ * the glob tokens is escaped, so `.` is literal — what you want for a filename.
92
+ */
93
+ function globToRegExp(glob: string): RegExp {
94
+ let out = "";
95
+ for (let i = 0; i < glob.length; i++) {
96
+ const c = glob[i];
97
+ if (c === "*") {
98
+ if (glob[i + 1] === "*") {
99
+ // `**` → any chars incl. separator. Swallow a trailing `/` so
100
+ // `**/x` also matches `x` at the root, not just `dir/x`.
101
+ out += "[^]*";
102
+ i++;
103
+ if (glob[i + 1] === "/") i++;
104
+ } else {
105
+ out += "[^/]*"; // single-segment wildcard
106
+ }
107
+ } else if (c === "?") {
108
+ out += "[^/]";
109
+ } else if (c !== undefined && /[.+^${}()|[\]\\]/.test(c)) {
110
+ out += `\\${c}`;
111
+ } else {
112
+ out += c ?? "";
113
+ }
114
+ }
115
+ return new RegExp(`(^|/)${out}$`);
116
+ }
117
+
118
+ /**
119
+ * A reusable file-skip predicate built from the `ignore` globs. Patterns are
120
+ * matched against the path with `/` separators (normalized from the OS sep) so
121
+ * the same contract behaves identically on every platform. Rule-id entries in
122
+ * `ignore` are NOT file globs — they are filtered out here (a rule id has no
123
+ * path separator and is handled by {@link ignoredRuleIds}).
124
+ */
125
+ export function fileIgnoreMatcher(ignore: readonly string[]): (filePath: string) => boolean {
126
+ // A jsx-a11y rule id (e.g. `alt-text`, `jsx-a11y/alt-text`) is not a file
127
+ // glob; only treat an entry as a glob if it looks like a path pattern.
128
+ const globs = ignore.filter(isFileGlob).map(globToRegExp);
129
+ if (globs.length === 0) return () => false;
130
+ return (filePath: string): boolean => {
131
+ const norm = filePath.split(sep).join("/");
132
+ return globs.some((re) => re.test(norm));
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Whether an `ignore` entry is a FILE GLOB rather than a rule id. A rule id is a
138
+ * bare jsx-a11y name (optionally `jsx-a11y/`-prefixed) with no glob/path
139
+ * punctuation; anything carrying `*`, `?`, `.`, or a path separator is a glob.
140
+ * The two namespaces never collide because jsx-a11y ids are kebab-case words.
141
+ */
142
+ function isFileGlob(entry: string): boolean {
143
+ if (entry.startsWith("jsx-a11y/")) return false; // explicit rule id
144
+ return /[*?./\\]/.test(entry);
145
+ }
146
+
147
+ /**
148
+ * The set of jsx-a11y rule ids to drop, normalized to the full `jsx-a11y/<id>`
149
+ * form so a finding's `ruleId` can be tested directly. Accepts both the bare
150
+ * (`alt-text`) and prefixed (`jsx-a11y/alt-text`) spellings in the contract.
151
+ */
152
+ export function ignoredRuleIds(ignore: readonly string[]): ReadonlySet<string> {
153
+ const out = new Set<string>();
154
+ for (const entry of ignore) {
155
+ if (isFileGlob(entry)) continue; // a path glob, not a rule id
156
+ out.add(entry.startsWith("jsx-a11y/") ? entry : `jsx-a11y/${entry}`);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** A finding's enforcement level, decided by the contract's policy. */
162
+ export type EnforcementLevel = "block" | "warn";
163
+
164
+ /**
165
+ * Decide a finding's enforcement level from its WCAG SC against the contract.
166
+ * A finding is `block` iff ANY of its SC is in `enforcement.block`; otherwise
167
+ * `warn`. With no contract the level is `block` for everything — the historical
168
+ * behavior where every finding gated the CLI exit code.
169
+ */
170
+ export function enforcementFor(
171
+ wcag: readonly string[],
172
+ contract: Contract | null,
173
+ ): EnforcementLevel {
174
+ if (contract === null) return "block";
175
+ const block = new Set(contract.enforcement.block);
176
+ return wcag.some((sc) => block.has(sc)) ? "block" : "warn";
177
+ }