@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/src/core.ts ADDED
@@ -0,0 +1,546 @@
1
+ import type { Impact } from "@binclusive/a11y-contract";
2
+ import tsParser from "@typescript-eslint/parser";
3
+ import { ESLint, type Linter } from "eslint";
4
+ import jsxA11y from "eslint-plugin-jsx-a11y";
5
+ import ts from "typescript";
6
+
7
+ /**
8
+ * The 4-level axe runtime impact a finding can carry — the contract's canonical
9
+ * {@link Impact} vocabulary minus `unknown` (a finding either observed a concrete
10
+ * axe impact or carries none; "not judged" is modeled by ABSENCE, so the wire
11
+ * maps an absent impact to the contract's `unknown` at the boundary). Derived
12
+ * from the contract enum so the runtime scale can never drift from it.
13
+ */
14
+ export type AxeImpact = Exclude<Impact, "unknown">;
15
+ import {
16
+ commonBaseDir,
17
+ contractForFiles,
18
+ type EnforcementLevel,
19
+ enforcementFor,
20
+ fileIgnoreMatcher,
21
+ ignoredRuleIds,
22
+ } from "./config-scan";
23
+ import type { Contract } from "./contract";
24
+ import { enforceContent } from "./enforce";
25
+ import { isRouterLinkControl } from "./registry";
26
+ import {
27
+ type ComponentResolution,
28
+ type Coverage,
29
+ type ResolvedComponents,
30
+ resolveComponents,
31
+ } from "./resolve-components";
32
+ import {
33
+ ariaHiddenLineRanges,
34
+ isContentSuppressed,
35
+ spreadChildrenLineRanges,
36
+ transInjectedLineRanges,
37
+ } from "./suppression-ranges";
38
+ import { wcagForRuleId } from "./wcag-map";
39
+
40
+ /**
41
+ * Where a finding came from. Two producers, deliberately distinct so the report
42
+ * (and dedupe) can tell them apart:
43
+ *
44
+ * - `jsx-a11y` — the structural lint pass. Fires only on elements it can see
45
+ * as a host (intrinsic tags + wrappers resolved to a host via the component
46
+ * map). Misses opaque/trusted design-system components entirely.
47
+ * - `enforce` — the corpus-driven call-site content check. Recognizes the
48
+ * control TYPE at the call site (resolved host / registry / name heuristic)
49
+ * and checks the app-owned content (name/alt/label/link-text) — so it fires
50
+ * on opaque/trusted components the structural pass can't reach. This is the
51
+ * recall win: "trusted" stops being false reassurance.
52
+ * - `axe` — the rendered-DOM collector (see `collect-dom.ts`): a live URL
53
+ * is rendered in a real browser and axe-core runs against the resulting DOM.
54
+ * Source-blind by design — it covers non-React sites and live pages we have
55
+ * no `.tsx` for, and it sees what static analysis can't (color-contrast,
56
+ * computed roles, real rendered text). Anchored by `selector`, not a line.
57
+ * - `swiftui` — the SwiftUI static collector (see `collect-swift.ts`): an
58
+ * out-of-process SwiftSyntax engine parses `.swift` source and applies the
59
+ * SwiftUI accessibility rules (missing image label, unlabeled control) with
60
+ * the ancestor-climb heuristic. Anchored by `file:line` like the source
61
+ * passes; the native counterpart of the jsx-a11y structural pass.
62
+ * - `corpus-agent` — the corpus-grounded RECALL layer (RFC Phase 1): an agent
63
+ * matches the scanned code against the distilled corpus slice and the
64
+ * server-side gate stack disposes. **Never produced by `scan()`** — only by
65
+ * the future `review_a11y` MCP tool, and always quarantined into a SEPARATE
66
+ * `recall` field (see {@link ScanResult}). Advisory only: `enforcement` is
67
+ * always `"warn"`, `layer` is always `"recall"`, and it can never reach the
68
+ * CLI exit code. This keeps `scan()`'s output byte-identical and the floor's
69
+ * precision intact.
70
+ */
71
+ export type FindingProvenance =
72
+ | "jsx-a11y"
73
+ | "enforce"
74
+ | "axe"
75
+ | "swiftui"
76
+ | "liquid"
77
+ | "unity"
78
+ | "corpus-agent";
79
+
80
+ /**
81
+ * Which layer a finding belongs to. `floor` is the deterministic static floor
82
+ * (jsx-a11y / enforce / axe / swiftui) — it gates the CLI exit code. `recall` is
83
+ * the corpus-agent layer — advisory, quarantined, never exit-code-affecting.
84
+ * Floor findings carry no `layer` (it defaults to `floor`); only the recall
85
+ * layer tags it explicitly.
86
+ */
87
+ export type FindingLayer = "floor" | "recall";
88
+
89
+ /**
90
+ * How sure the agent lane is about a DISCOVERED finding — the recall layer's
91
+ * self-reported judgement strength. Set only on `corpus-agent` discoveries (never
92
+ * on a deterministic floor finding, which is reproducible, not a judgement). It is
93
+ * advisory metadata for local rendering; it never reaches the CLI exit code and,
94
+ * like every agent field, has no home on the metadata-only wire contract.
95
+ */
96
+ export type AgentConfidence = "low" | "medium" | "high";
97
+
98
+ /**
99
+ * A single accessibility finding. A jsx-a11y finding is normalized off an
100
+ * ESLint message; an enforce finding is produced by the call-site content check
101
+ * (see `enforce.ts`). Both carry the same shape so the report and enforcement
102
+ * gate treat them uniformly.
103
+ *
104
+ * `wcag` is derived from `ruleId` via {@link wcagForRuleId} for jsx-a11y, or set
105
+ * directly by the enforce rule that fired; an empty array means we recognized
106
+ * the rule fired but have no WCAG mapping for it yet (the finding still surfaces).
107
+ *
108
+ * `enforcement` is the contract's policy for this finding's SC (`block` gates
109
+ * the CLI exit code, `warn` only surfaces). With no `binclusive.json` every
110
+ * finding is `block` — the historical behavior.
111
+ *
112
+ * `provenance` tags which pass produced it (see {@link FindingProvenance}).
113
+ */
114
+ export interface Finding {
115
+ readonly file: string;
116
+ readonly line: number;
117
+ readonly ruleId: string;
118
+ readonly message: string;
119
+ readonly wcag: readonly string[];
120
+ readonly enforcement: EnforcementLevel;
121
+ readonly provenance: FindingProvenance;
122
+ /**
123
+ * Which layer produced this finding (see {@link FindingLayer}). Absent on the
124
+ * static floor passes (treated as `floor`); set to `recall` only on
125
+ * `corpus-agent` findings, the quarantine signal the dedup + result shape rely
126
+ * on.
127
+ */
128
+ readonly layer?: FindingLayer;
129
+ /**
130
+ * The distilled corpus pattern id this finding matched. Set only on
131
+ * `corpus-agent` (recall) findings — it is the key for self-dedup
132
+ * (`file:line:patternId`) and the provenance back to the corpus slice that
133
+ * grounded it. Absent on every static-floor pass.
134
+ */
135
+ readonly patternId?: string;
136
+ /**
137
+ * Where the finding lives in a rendered DOM: the CSS selector axe-core
138
+ * reports for the offending node. Set only on `axe` findings (`file` holds the
139
+ * page URL and `line` is 0 — a live DOM has no source line). Absent on the
140
+ * source-level passes, which anchor with `file:line`.
141
+ */
142
+ readonly selector?: string;
143
+ /**
144
+ * axe-core's per-node runtime IMPACT for this finding — the single most
145
+ * accurate impact, computed by axe against the actual rendered node. Set
146
+ * only on `axe` findings (the source passes have no axe runtime). The corpus
147
+ * enrich step prefers this over the static baseline impact when present.
148
+ * Absent ⇒ not judged (the wire maps that to the contract's `unknown`).
149
+ */
150
+ readonly impact?: AxeImpact;
151
+ /**
152
+ * axe-core's Deque-University help URL for the rule that fired. Set only on
153
+ * `axe` findings; the baseline catalog supplies the same URL for source-pass
154
+ * findings via the SC lookup.
155
+ */
156
+ readonly helpUrl?: string;
157
+ /**
158
+ * The agent lane's IN-PLACE enrichment of a DETERMINISTIC finding — a prose
159
+ * note / fix suggestion the agent attached without changing what the finding
160
+ * IS. The finding stays `provenance: deterministic` (an automated rule still
161
+ * surfaced it); this only adds AI judgement on top. Prose, never a patch —
162
+ * suggestions-not-patches is structural (this is a string, not an edit). Local
163
+ * only: the wire contract's deterministic arm has no field for it, so an
164
+ * enrichment never crosses the metadata-only boundary.
165
+ */
166
+ readonly agentNote?: string;
167
+ /**
168
+ * The agent lane's confidence in a DISCOVERED finding (see {@link AgentConfidence}).
169
+ * Set only on `corpus-agent` findings the agent surfaced that no deterministic
170
+ * pass caught — never on a floor finding.
171
+ */
172
+ readonly confidence?: AgentConfidence;
173
+ }
174
+
175
+ /**
176
+ * The jsx-a11y rules we score, pinned to "error" so every one surfaces
177
+ * regardless of the plugin's recommended on/off defaults. Each id here has a
178
+ * WCAG mapping in `wcag-map.ts`; keeping the two lists aligned is what makes
179
+ * a finding corpus-enrichable.
180
+ */
181
+ const SCORED_RULES: readonly string[] = [
182
+ "label-has-associated-control",
183
+ "alt-text",
184
+ "anchor-has-content",
185
+ "anchor-is-valid",
186
+ "aria-props",
187
+ "role-has-required-aria-props",
188
+ "role-supports-aria-props",
189
+ "interactive-supports-focus",
190
+ "click-events-have-key-events",
191
+ "no-static-element-interactions",
192
+ "heading-has-content",
193
+ ];
194
+ // NOTE: jsx-a11y's `prefer-tag-over-role` is deliberately NOT enabled here. Run
195
+ // wholesale it is ~90% noise — it fires on `<svg role="img" aria-label>` (the
196
+ // correct accessible-SVG pattern), `role="status"`, custom `role="combobox"`
197
+ // widgets, etc. We ship a SCOPED version instead (`enforce/prefer-tag-over-role`
198
+ // in enforce.ts) limited to landmark/structural roles with one clean native tag.
199
+
200
+ /**
201
+ * The router-Link destination prop. react-router / Remix `Link` / `NavLink`
202
+ * render `<a>` but carry the navigation target on `to`, NOT `href` — so once
203
+ * such a wrapper is mapped to host `a` (a `binclusive.json` `components`
204
+ * declaration), `anchor-is-valid` reads the literal missing `href` and false-
205
+ * positives on every valid `<Link to="/route">`. The fix is jsx-a11y's own
206
+ * `specialLink` lever: it ALIASES `to` onto the rule's href check, so a valid
207
+ * `to` satisfies it. It narrows the rule, it does NOT disable it — an empty
208
+ * `to=""` or `to="#"` still lands in the rule's invalid-href branch and flags.
209
+ */
210
+ const ROUTER_LINK_HREF_PROP = "to";
211
+
212
+ /**
213
+ * Whether any resolved wrapper that landed in the jsx-a11y component map is a
214
+ * react-router / Remix link control mapped to host `a` — i.e. the user mapped
215
+ * `Link`/`NavLink` → `a` in `binclusive.json`. ONLY then do we alias `to` onto
216
+ * `anchor-is-valid` (see {@link ROUTER_LINK_HREF_PROP}). When no router Link is
217
+ * mapped, the rule config is byte-identical to before — zero-config and
218
+ * non-router scans (and the matrix baseline) are untouched.
219
+ *
220
+ * Matches on `r.imported` (the original export name), NOT `r.name` (the local
221
+ * JSX alias): a repo may `import { Link as RouterLink }` and map `RouterLink` ->
222
+ * `a`, so the alias is `RouterLink` while the export the registry recognizes is
223
+ * `Link`. Keying off the alias would silently disarm the fix for aliased imports.
224
+ */
225
+ function mapsRouterLinkToAnchor(resolutions: readonly ComponentResolution[]): boolean {
226
+ return resolutions.some(
227
+ (r) => r.host === "a" && isRouterLinkControl(r.module, r.imported),
228
+ );
229
+ }
230
+
231
+ function buildRuleConfig(aliasRouterLinkHref: boolean): Linter.RulesRecord {
232
+ const rules: Linter.RulesRecord = {};
233
+ for (const id of SCORED_RULES) {
234
+ rules[`jsx-a11y/${id}`] =
235
+ id === "anchor-is-valid" && aliasRouterLinkHref
236
+ ? // `specialLink: ['to']` adds `to` to the props that satisfy the href
237
+ // requirement, so a router `<Link to="/route">` is valid — while an
238
+ // empty `to=""`/`to="#"` still flags via the invalid-href aspect.
239
+ ["error", { specialLink: [ROUTER_LINK_HREF_PROP] }]
240
+ : "error";
241
+ }
242
+ return rules;
243
+ }
244
+
245
+ function buildESLint(
246
+ cwd: string,
247
+ components: Readonly<Record<string, string>>,
248
+ aliasRouterLinkHref: boolean,
249
+ ): ESLint {
250
+ return new ESLint({
251
+ cwd,
252
+ // Self-contained: ignore any eslintrc / flat config found on disk so the
253
+ // checker behaves identically wherever it runs.
254
+ overrideConfigFile: true,
255
+ errorOnUnmatchedPattern: false,
256
+ overrideConfig: [
257
+ {
258
+ files: ["**/*.tsx"],
259
+ plugins: { "jsx-a11y": jsxA11y },
260
+ languageOptions: {
261
+ parser: tsParser,
262
+ parserOptions: {
263
+ ecmaFeatures: { jsx: true },
264
+ sourceType: "module",
265
+ },
266
+ },
267
+ settings: {
268
+ "jsx-a11y": {
269
+ polymorphicPropName: "as",
270
+ // Derived per-scan from the registry + source-tracer, never a
271
+ // hardcoded design-system list.
272
+ components,
273
+ },
274
+ },
275
+ rules: buildRuleConfig(aliasRouterLinkHref),
276
+ },
277
+ ],
278
+ });
279
+ }
280
+
281
+ /** The full output of a scan: findings plus the component-map coverage report. */
282
+ export interface ScanResult {
283
+ readonly findings: readonly Finding[];
284
+ readonly coverage: Coverage;
285
+ readonly resolved: ResolvedComponents;
286
+ /**
287
+ * The `binclusive.json` that governed this scan (found at or above the files),
288
+ * or `null` when the scan ran zero-config. Surfaced so the CLI can report what
289
+ * policy was applied without re-loading the file.
290
+ */
291
+ readonly contract: Contract | null;
292
+ /**
293
+ * The source files this run SUCCESSFULLY ANALYZED — ABSOLUTE paths, one per
294
+ * `.tsx` file ESLint parsed without a fatal error (`fatalErrorCount === 0`). This
295
+ * is the source-scan-scope coverage set 4b's reconcile keys on (ADR 0043): a source
296
+ * ticket resolves iff its file is in this set AND its fingerprint wasn't re-emitted.
297
+ *
298
+ * TWO halves, both load-bearing (the whole no-false-resolve guarantee):
299
+ * - INCLUDES zero-finding files — a clean analyzed file MUST be present, else its
300
+ * already-open ticket can never resolve.
301
+ * - EXCLUDES attempted-but-failed files (`fatalErrorCount > 0` — parse error /
302
+ * fatal) — a file we could not analyze must NOT be here, or its ticket
303
+ * false-resolves (a compliance lie: "fixed" when we simply never looked).
304
+ *
305
+ * Paths are ABSOLUTE here (the local-finding convention — a `Finding.file` is
306
+ * likewise absolute); they are narrowed to repo-relative at the emit boundary via
307
+ * the SAME `repoRelativePath(file, root)` that mints a source finding's wire
308
+ * `path`, so `scannedPaths` membership matches `mf.path` by construction.
309
+ */
310
+ readonly analyzedFiles: readonly string[];
311
+ /**
312
+ * QUARANTINE (RFC Phase 1d). The corpus-agent RECALL findings ride here, in a
313
+ * field SEPARATE from `findings` — never mixed in, never gating the CLI exit
314
+ * code, never `enforcement:"block"`. `scan()` itself ALWAYS leaves this empty
315
+ * (`[]`): only the future `review_a11y` recall layer populates it, after the
316
+ * agent returns and the server-side gate stack disposes. Because the recall
317
+ * findings live outside `findings`, `matrix:check` (which snapshots `findings`)
318
+ * is structurally unable to see them — a stochastic count can never flip the
319
+ * regression gate.
320
+ */
321
+ readonly recall: readonly Finding[];
322
+ }
323
+
324
+ const EMPTY_COVERAGE: Coverage = {
325
+ total: 0,
326
+ declared: 0,
327
+ registry: 0,
328
+ traced: 0,
329
+ opaque: 0,
330
+ trusted: 0,
331
+ icons: 0,
332
+ structural: 0,
333
+ declare: 0,
334
+ };
335
+
336
+ /**
337
+ * Scan `.tsx` files: derive the wrapper->host component map (declared +
338
+ * registry + source-trace), run eslint-plugin-jsx-a11y with that map, and return
339
+ * the normalized findings plus the map's coverage report. Non-`.tsx` paths are
340
+ * ignored. Parser errors are skipped (not a11y findings).
341
+ *
342
+ * Config-OPTIONAL. If a `binclusive.json` exists at or above the scanned files
343
+ * its escape-hatch declarations + enforcement policy are applied:
344
+ *
345
+ * - `components` merge into the wrapper map (provenance `declared`, override)
346
+ * - `injectsChildren` extend the runtime child-injection suppression
347
+ * - `ignore` globs drop matching files before they are linted
348
+ * - `ignore` rule ids drop findings for that rule ("off")
349
+ * - `enforcement` tags each finding `block` vs `warn`
350
+ *
351
+ * With NO contract the behavior is exactly the historical one: every wrapper
352
+ * auto-resolved, no extra suppression, no files skipped, every finding `block`.
353
+ */
354
+ export async function scan(filePaths: readonly string[]): Promise<ScanResult> {
355
+ const allTsx = filePaths.filter((p) => p.endsWith(".tsx"));
356
+ const contract = contractForFiles(allTsx);
357
+ const declarations = contract?.declarations ?? null;
358
+
359
+ // Drop ignored files BEFORE linting — they never enter the scan, so they
360
+ // contribute neither findings nor coverage noise.
361
+ const isIgnoredFile =
362
+ declarations === null ? () => false : fileIgnoreMatcher(declarations.ignore);
363
+ const tsxPaths = allTsx.filter((p) => !isIgnoredFile(p));
364
+
365
+ if (tsxPaths.length === 0) {
366
+ const empty: ResolvedComponents = { map: {}, coverage: EMPTY_COVERAGE, resolutions: [], unresolvedPackages: [], sourceFiles: new Map() };
367
+ return { findings: [], coverage: empty.coverage, resolved: empty, contract, analyzedFiles: [], recall: [] };
368
+ }
369
+
370
+ const resolved = resolveComponents(tsxPaths, declarations?.components ?? {});
371
+ const eslint = buildESLint(
372
+ commonBaseDir(tsxPaths),
373
+ resolved.map,
374
+ mapsRouterLinkToAnchor(resolved.resolutions),
375
+ );
376
+ const results = await eslint.lintFiles([...tsxPaths]);
377
+
378
+ const injectsChildren = declarations?.injectsChildren ?? [];
379
+ const dropRules = declarations === null ? new Set<string>() : ignoredRuleIds(declarations.ignore);
380
+
381
+ // Per-file line ranges of elements where a content-family finding is a false
382
+ // positive. Two sources, merged:
383
+ // - runtime child injection (`<Trans>`/`render=` + the customer's own
384
+ // `injectsChildren` helpers) — the element is contentful at render time.
385
+ // - `aria-hidden` — the element is out of the a11y tree, so "empty
386
+ // link/heading" doesn't apply.
387
+ // Computed once per file and reused, since a file can carry many findings.
388
+ const suppressRangesCache = new Map<string, ReturnType<typeof transInjectedLineRanges>>();
389
+ const suppressRangesFor = (filePath: string): ReturnType<typeof transInjectedLineRanges> => {
390
+ const cached = suppressRangesCache.get(filePath);
391
+ if (cached !== undefined) return cached;
392
+ const text = ts.sys.readFile(filePath);
393
+ const ranges =
394
+ text === undefined
395
+ ? []
396
+ : (() => {
397
+ const file = ts.createSourceFile(
398
+ filePath,
399
+ text,
400
+ ts.ScriptTarget.Latest,
401
+ true,
402
+ ts.ScriptKind.TSX,
403
+ );
404
+ return [
405
+ ...transInjectedLineRanges(file, injectsChildren),
406
+ ...ariaHiddenLineRanges(file),
407
+ ...spreadChildrenLineRanges(file),
408
+ ];
409
+ })();
410
+ suppressRangesCache.set(filePath, ranges);
411
+ return ranges;
412
+ };
413
+
414
+ // The source-scan-scope coverage set (ADR 0043): the files ESLint SUCCESSFULLY
415
+ // analyzed. A fatal parse error (`fatalErrorCount > 0`) means the file was
416
+ // attempted but NOT analyzed — excluded, so its open ticket cannot false-resolve.
417
+ // Zero-finding files have `fatalErrorCount === 0` and are kept, so a fixed file
418
+ // is present and its ticket can resolve. This mirrors the Liquid pass's
419
+ // parse-error partition (`collect-liquid.ts`).
420
+ const analyzedFiles = results.filter((r) => r.fatalErrorCount === 0).map((r) => r.filePath);
421
+
422
+ const findings: Finding[] = [];
423
+ for (const result of results) {
424
+ for (const msg of result.messages) {
425
+ // Skip fatal parse errors — not a11y findings.
426
+ if (msg.fatal === true || msg.ruleId === null) continue;
427
+ // Source files carry inline `eslint-disable` directives for rules we
428
+ // don't load (e.g. @next/next/*, react-hooks/*). ESLint surfaces those
429
+ // as "Definition for rule ... was not found" problems. They are not
430
+ // accessibility findings — keep only jsx-a11y hits.
431
+ if (!msg.ruleId.startsWith("jsx-a11y/")) continue;
432
+ // `ignore` rule ids turn a rule OFF — drop every finding for it.
433
+ if (dropRules.has(msg.ruleId)) continue;
434
+ // Drop content-family findings on elements that are contentful at runtime
435
+ // (`<Trans>`, `render=`, declared helper) OR removed from the a11y tree
436
+ // (`aria-hidden`) — the "empty element" premise doesn't hold for either.
437
+ if (isContentSuppressed(msg.ruleId, msg.line, suppressRangesFor(result.filePath))) continue;
438
+ const wcag = wcagForRuleId(msg.ruleId);
439
+ findings.push({
440
+ file: result.filePath,
441
+ line: msg.line,
442
+ ruleId: msg.ruleId,
443
+ message: msg.message,
444
+ wcag,
445
+ enforcement: enforcementFor(wcag, contract),
446
+ provenance: "jsx-a11y",
447
+ });
448
+ }
449
+ }
450
+
451
+ // Corpus-driven call-site content check. Recognizes the control TYPE at the
452
+ // call site (resolved host / registry / name heuristic) and flags app-owned
453
+ // content that is clearly missing — INCLUDING on opaque/trusted components the
454
+ // structural pass above can never reach. Conservative: dynamic/spread/computed
455
+ // content is "incomplete", not a violation, and is never flagged.
456
+ const enforceFindings = enforceContent(tsxPaths, {
457
+ resolutions: resolved.resolutions,
458
+ declarations,
459
+ contract,
460
+ });
461
+ // Dedupe: an element the structural pass already flagged (resolved-to-host)
462
+ // must not be double-reported by the enforce pass.
463
+ const merged = [...findings, ...dedupeEnforce(enforceFindings, findings)];
464
+ // `scan()` produces NO corpus-agent findings — the recall layer is the only
465
+ // producer, and it rides the quarantined `recall` field, never `findings`.
466
+ // Keeping it empty here is what makes `scan()` output byte-identical.
467
+ return { findings: merged, coverage: resolved.coverage, resolved, contract, analyzedFiles, recall: [] };
468
+ }
469
+
470
+ /**
471
+ * Drop every enforce finding that the structural jsx-a11y pass already reported
472
+ * for the SAME element, so a wrapper resolved to a host (and flagged there)
473
+ * isn't double-counted by the call-site check.
474
+ *
475
+ * The match key is (file, line, shared WCAG SC): both passes anchor a finding
476
+ * to the element's opening-tag line and tag it with the same SC family
477
+ * (button-no-name → 4.1.2, img-no-alt → 1.1.1, link-no-name → 2.4.4, ...). When
478
+ * jsx-a11y has already fired on that line for an overlapping SC, the enforce
479
+ * finding is redundant. When it hasn't — the opaque/trusted case the structural
480
+ * pass can't see — the enforce finding is the NEW recall the check exists for.
481
+ */
482
+ function dedupeEnforce(
483
+ enforce: readonly Finding[],
484
+ jsxA11y: readonly Finding[],
485
+ ): readonly Finding[] {
486
+ const covered = new Set<string>();
487
+ for (const f of jsxA11y) {
488
+ for (const sc of f.wcag) covered.add(`${f.file}:${f.line}:${sc}`);
489
+ }
490
+ return enforce.filter((f) => !f.wcag.some((sc) => covered.has(`${f.file}:${f.line}:${sc}`)));
491
+ }
492
+
493
+ /**
494
+ * Dedup the corpus-agent RECALL candidates against the static floor and against
495
+ * each other (RFC Phase 1d). Two mechanical passes, reusing `dedupeEnforce`'s
496
+ * key discipline (`file:line:sc`):
497
+ *
498
+ * 1. CROSS-dedup — drop any candidate that shares `file:line` AND any WCAG SC
499
+ * with a STATIC finding. The floor already caught it; the recall layer
500
+ * exists for what the floor MISSES, so a co-located same-SC hit is
501
+ * redundant. (A missing floor finding is NOT permission to flag — that is
502
+ * G4's abstention veto, not this dedup; this only removes "floor already
503
+ * caught it.")
504
+ * 2. SELF-dedup — collapse PATTERNED candidates by `(file, line, patternId)`,
505
+ * keeping the first (the same pattern matched twice on one element is one
506
+ * finding). A patternless candidate (no `patternId`) is EXEMPT: two distinct
507
+ * patternless discoveries at one `file:line` are genuinely different findings,
508
+ * so each is kept rather than collapsed onto the shared `file:line:` key (#2180).
509
+ *
510
+ * Pure and model-free: a deterministic filter the recall layer applies AFTER the
511
+ * agent returns and BEFORE quarantining the survivors into `recall`. Order is
512
+ * stable — survivors keep their input order.
513
+ */
514
+ export function dedupeRecall(
515
+ candidates: readonly Finding[],
516
+ staticFindings: readonly Finding[],
517
+ ): readonly Finding[] {
518
+ // 1 — CROSS-dedup against the static floor: identical `file:line:sc` covered-set
519
+ // discipline as the enforce pass, so reuse it verbatim rather than re-derive it.
520
+ const crossDeduped = dedupeEnforce(candidates, staticFindings);
521
+ // 2 — SELF-dedup by `(file, line, patternId)`, keeping the first survivor. Only
522
+ // PATTERNED candidates dedup: a patternless discovery has no identity to key on,
523
+ // so two distinct ones at one `file:line` must not collapse onto `file:line:` and
524
+ // silently drop a real finding (#2180) — each patternless candidate is kept.
525
+ const seen = new Set<string>();
526
+ const out: Finding[] = [];
527
+ for (const f of crossDeduped) {
528
+ const patternId = f.patternId ?? "";
529
+ if (patternId !== "") {
530
+ const key = `${f.file}:${f.line}:${patternId}`;
531
+ if (seen.has(key)) continue;
532
+ seen.add(key);
533
+ }
534
+ out.push(f);
535
+ }
536
+ return out;
537
+ }
538
+
539
+ /**
540
+ * Convenience wrapper returning just the findings. Retained for callers that
541
+ * don't need the coverage report.
542
+ */
543
+ export async function checkFiles(filePaths: readonly string[]): Promise<Finding[]> {
544
+ const { findings } = await scan(filePaths);
545
+ return [...findings];
546
+ }