@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/enforce.ts ADDED
@@ -0,0 +1,1125 @@
1
+ import ts from "typescript";
2
+ import { enforcementFor } from "./config-scan";
3
+ import type { Contract, Declarations } from "./contract";
4
+ import type { Finding } from "./core";
5
+ import {
6
+ isIconLibrary,
7
+ isRouterLinkControl,
8
+ isToggleRole,
9
+ lookupGuaranteed,
10
+ lookupRegistry,
11
+ } from "./registry";
12
+ import type { ComponentResolution } from "./resolve-components";
13
+ import { collectLocalImports, type ImportBinding } from "./source-trace";
14
+ import { ariaHiddenLineRanges, transInjectedLineRanges } from "./suppression-ranges";
15
+ import {
16
+ anyNameAttr,
17
+ attrState,
18
+ isHiddenOrUntabbable,
19
+ isNameExemptInputType,
20
+ LABEL_ATTRS,
21
+ walkAncestorSuppressors,
22
+ } from "./suppressors";
23
+
24
+ /**
25
+ * The enforce check: corpus-driven, call-site CONTENT rules.
26
+ *
27
+ * The structural jsx-a11y pass (see `core.ts`) only fires on elements it can see
28
+ * as a host — intrinsic tags, and wrappers it resolved to a host primitive. A
29
+ * design-system `<Button>` used icon-only (`<Button><TrashIcon/></Button>`,
30
+ * no accessible name) is a real 4.1.2 bug that pass MISSES whenever the Button
31
+ * is opaque/trusted: "trusted" guarantees the library's INTERNAL structure, not
32
+ * the content the app passes in. That false reassurance is exactly the recall
33
+ * gap this check closes.
34
+ *
35
+ * Each rule MIRRORS a distilled corpus pattern (see `data/corpus/patterns-*.json`):
36
+ * - button / icon-button : 4.1.2-button-no-name
37
+ * - image : 1.1.1 image-no-alt family
38
+ * - link : 2.4.4-link-no-name
39
+ * - dialog / modal : 4.1.2 (+ 1.3.1) nameless-dialog
40
+ * - input / field : 1.3.1 / 3.3.2 form-control-no-name
41
+ *
42
+ * It recognizes the control TYPE at the call site — by resolved host, by the
43
+ * registry, or by a conservative NAME heuristic — and so works on opaque/trusted
44
+ * components too (recognized by name/registry, never needing their source).
45
+ *
46
+ * Conservatism is the whole discipline. A rule fires ONLY when the app-owned
47
+ * content is STATICALLY, CLEARLY missing. The moment content could be supplied
48
+ * dynamically — a `{...props}` spread, a computed child, a non-literal attribute
49
+ * — the element is "incomplete", not a violation, and is left alone. That line
50
+ * is what keeps the check from reintroducing false positives. Uncertain → skip.
51
+ */
52
+
53
+ /** A recognized call-site control type, each mapping to one corpus rule family. */
54
+ export type ControlType = "button" | "icon-button" | "link" | "image" | "dialog" | "input";
55
+
56
+ /** Inputs the enforce pass needs from the surrounding scan. */
57
+ export interface EnforceContext {
58
+ /**
59
+ * The per-component resolution outcomes from {@link resolveComponents} —
60
+ * each carrying the wrapper's NAME, its import MODULE, and the host it
61
+ * resolved to (or `null` for opaque). The enforce pass recognizes a resolved
62
+ * host only when the call site's import MODULE matches the module that host
63
+ * was resolved from (see {@link buildResolvedHosts} / {@link classify}).
64
+ *
65
+ * This is what kills the module-blind name collision: jsx-a11y's flat
66
+ * `name -> host` map (what it ACTUALLY lints with) is keyed by leaf name only,
67
+ * so a MUI `<TextField>` (registry -> `input`) anywhere makes EVERY `<TextField>`
68
+ * read as an input — including a react-admin display `<TextField source=…>`,
69
+ * which renders a `<span>`, not a control. Scoping the host lookup to
70
+ * `(name, module)` means the react-admin field (opaque from `react-admin`)
71
+ * never inherits the MUI field's host.
72
+ */
73
+ readonly resolutions: readonly ComponentResolution[];
74
+ /** The governing contract's declarations, or `null` zero-config. */
75
+ readonly declarations: Declarations | null;
76
+ /** The governing contract, for per-finding enforcement level. */
77
+ readonly contract: Contract | null;
78
+ }
79
+
80
+ /**
81
+ * A module-aware resolved-host lookup, keyed by `"<jsx-name>@<module>"` — the
82
+ * SAME identity the call site reconstructs from `(tagName, binding.module)`.
83
+ * Only components that resolved to a concrete host are included; opaque ones
84
+ * are absent, so a call site whose import is the opaque one finds nothing here
85
+ * and falls through to the registry / name heuristic (or to "not a control").
86
+ *
87
+ * This is the module-scoped replacement for jsx-a11y's flat, leaf-keyed map:
88
+ * two different components that share a leaf name (MUI `TextField` vs
89
+ * react-admin `TextField`) get DISTINCT keys, so one can never lend its host to
90
+ * the other.
91
+ */
92
+ /**
93
+ * A resolved host plus the explicit toggle `role` it carries (if any) and
94
+ * whether the wrapper renders the host its OWN static accessible name internally
95
+ * (an `sr-only` span, a literal `aria-label`, or static text — captured by the
96
+ * trace). A host with `rendersOwnName` is named even when the call site looks
97
+ * empty, so the no-name check skips it.
98
+ */
99
+ export interface ResolvedHost {
100
+ readonly host: string;
101
+ readonly role: string | null;
102
+ readonly rendersOwnName: boolean;
103
+ }
104
+
105
+ export function buildResolvedHosts(
106
+ resolutions: readonly ComponentResolution[],
107
+ ): ReadonlyMap<string, ResolvedHost> {
108
+ const out = new Map<string, ResolvedHost>();
109
+ for (const r of resolutions) {
110
+ if (r.host !== null) {
111
+ out.set(`${r.name}@${r.module}`, {
112
+ host: r.host,
113
+ role: r.role,
114
+ rendersOwnName: r.rendersOwnName,
115
+ });
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * How CONFIDENTLY a control was recognized — the lever that keeps the name
123
+ * heuristic from reintroducing false positives:
124
+ *
125
+ * - `host` — recognized via a resolved/registry HOST (`button`/`a`/`img`/
126
+ * `input`). We have PROVEN this is a thin wrapper that forwards props to a
127
+ * single primitive (the tracer/registry/declaration established it), so its
128
+ * content at the call site IS the control's content. A self-closing one with
129
+ * no name is genuinely nameless.
130
+ * - `name` — recognized only by a NAME suffix (`QuickCreateButton`,
131
+ * `SearchInput`). We have NOT seen inside it; it may render its own label.
132
+ * Trustworthy ONLY when we can see its CHILDREN are empty-or-icon (a visible
133
+ * container). A self-closing name-only component is opaque — skip it.
134
+ *
135
+ * GATED ON A GUARANTEED LIBRARY. The name path fires ONLY when the
136
+ * component's import module is a known design system
137
+ * (`lookupGuaranteed(module) !== null` — Radix/MUI/Chakra/Mantine/React
138
+ * Aria). Within such a library, an opaque `Button`/`IconButton`/`ActionIcon`
139
+ * reliably IS a button: the library owns the contract. From an UNRECOGNIZED
140
+ * module (`react-admin`, a customer's own `react-admin`-style re-export), a
141
+ * bare `Button` is a GUESS, not evidence — react-admin's `Button` takes its
142
+ * name from a `label=` prop and renders no children, so a name-only match
143
+ * mass-false-positives. Unrecognized-module bare names therefore never reach
144
+ * the name path; they stay unrecognized (the conservative default). A
145
+ * component the tracer resolved to a real host is `host`-strength and is
146
+ * unaffected — only imported-from-unknown-module bare names lose firing.
147
+ */
148
+ type Strength = "host" | "name";
149
+
150
+ /** A recognized control: its type plus how confidently it was recognized. */
151
+ interface Recognized {
152
+ readonly type: ControlType;
153
+ readonly strength: Strength;
154
+ }
155
+
156
+ /**
157
+ * The outcome of {@link classify}, a three-way split that lets the deterministic
158
+ * shell (G3/G4) inherit enforce's RESOLVED-HOST skips, not just the call-site
159
+ * ones:
160
+ *
161
+ * - `recognized` — a control enforce will `evaluate` (may still emit nothing).
162
+ * - `skip` — enforce CONSIDERED this element and declined for a reason
163
+ * the suppressor map's call-site syntax cannot see: a traced/registry toggle
164
+ * role, a wrapper that renders its own name, an icon-library import. These
165
+ * used to return `null` with NO abstention, so a recall nomination on a Radix
166
+ * Checkbox / rendersOwnName wrapper was NOT vetoed (findings #2/#8). The
167
+ * `wcag` is the SC family enforce would have checked, so {@link
168
+ * controlNameCheck} can record a G4 abstention there.
169
+ * - `null` — genuinely not a recognized control (nothing to consider).
170
+ *
171
+ * `skip` emits NO finding — identical floor output to the prior `null` — it only
172
+ * adds the abstention metadata the recall gate reads.
173
+ */
174
+ type Classification =
175
+ | { readonly kind: "recognized"; readonly recognized: Recognized }
176
+ | { readonly kind: "skip"; readonly wcag: readonly string[] }
177
+ | null;
178
+
179
+ /**
180
+ * The actionable-control SC families an icon-library import could be misread as
181
+ * (button / icon-button / link / image). An icon is content, never a control, so
182
+ * enforce short-circuits it — but a recall nomination might still wrongly flag a
183
+ * no-name button/link/image on the icon's line, so the abstention vetoes that
184
+ * whole family.
185
+ */
186
+ const ICON_SKIP_WCAG: readonly string[] = ["4.1.2", "2.4.4", "1.1.1"];
187
+
188
+ /**
189
+ * The SC family a name-only toggle (`<Checkbox/>`, `<Switch/>` — a {@link
190
+ * TOGGLE_NAMES} match with no resolved host) abstains on. A toggle is shaped like
191
+ * a button (4.1.2) or an input (1.3.1 / 3.3.2) but is externally labelled, so a
192
+ * recall nomination asserting any of those name SCs on its line is a false
193
+ * positive — abstain on the union so G4 vetoes it.
194
+ */
195
+ const TOGGLE_SKIP_WCAG: readonly string[] = ["4.1.2", "1.3.1", "3.3.2"];
196
+
197
+ /**
198
+ * Conservative NAME heuristics: a capitalized JSX name that, by convention,
199
+ * names a control of a given type even when the component is opaque. Matched on
200
+ * the LEAF name (`Foo.Button` -> `Button`) and only when the name IS or ENDS
201
+ * WITH the keyword (so `IconButton`, `PrimaryButton`, `SubmitButton` all read as
202
+ * buttons, but `ButtonGroup` does not — a group is not itself a control).
203
+ *
204
+ * Order matters: `IconButton` must classify as `icon-button` (a stricter rule)
205
+ * before the broader `Button` suffix claims it, so icon-button keywords precede
206
+ * button keywords.
207
+ *
208
+ * `ActionIcon` is Mantine's icon-only button (its whole purpose is an icon with
209
+ * NO text, named by `aria-label`). It carries no `Button` suffix, so it needs
210
+ * its own keyword; it reads as `icon-button` so the stricter icon-only rule
211
+ * applies. It is only ever consulted within a guaranteed library (the name path
212
+ * is gated — see {@link Strength}), so it can't mass-fire on an app that happens
213
+ * to name something `ActionIcon`.
214
+ *
215
+ * INPUTS ARE DELIBERATELY ABSENT. An input's accessible name usually comes from
216
+ * a SIBLING/ANCESTOR label (`<label for>`, `<FormLabel>`, `Box as="label"`) that
217
+ * is invisible at the call site — so a name-heuristic'd `*Input`/`*Field` (the
218
+ * react-hook-form `FormField` controller, cmdk `CommandInput`, a wrapped search
219
+ * box) cannot be PROVEN nameless and would be a false positive. Inputs are only
220
+ * recognized via a real input HOST (registry/resolved), never by name.
221
+ */
222
+ export const NAME_HEURISTICS: ReadonlyArray<{
223
+ readonly type: ControlType;
224
+ readonly keyword: string;
225
+ }> = [
226
+ { type: "icon-button", keyword: "ActionIcon" },
227
+ { type: "icon-button", keyword: "IconButton" },
228
+ { type: "button", keyword: "Button" },
229
+ { type: "link", keyword: "Link" },
230
+ { type: "image", keyword: "Image" },
231
+ { type: "dialog", keyword: "Dialog" },
232
+ { type: "dialog", keyword: "Modal" },
233
+ ];
234
+
235
+ /** Host primitive -> the control type the enforce rules treat it as. */
236
+ const HOST_TO_TYPE: Readonly<Record<string, ControlType>> = {
237
+ button: "button",
238
+ a: "link",
239
+ img: "image",
240
+ input: "input",
241
+ textarea: "input",
242
+ select: "input",
243
+ };
244
+
245
+ /**
246
+ * The native form-control intrinsics that get the input name-check directly —
247
+ * not via a resolved wrapper. jsx-a11y would normally own a bare `<input>`, but
248
+ * we don't run its `control-has-associated-label`, so an unlabeled native input
249
+ * slips through every recognition path in {@link classify}. We recognize EXACTLY
250
+ * these three lowercase tags and nothing else: a `<td>` / `<div>` is not a
251
+ * control, which is precisely why react-doctor's `control-has-associated-label`
252
+ * false-positives on layout cells and this never can.
253
+ */
254
+ const NATIVE_FORM_CONTROLS: ReadonlySet<string> = new Set(["input", "select", "textarea"]);
255
+
256
+
257
+ /**
258
+ * Toggle controls — checkbox / switch / radio / toggle. These have a host of
259
+ * `button` (Radix) or `input` (MUI/Chakra), so they'd otherwise be checked as a
260
+ * button or a text input. But a toggle's accessible name almost always comes
261
+ * from an EXTERNAL label the call site can't see: a sibling `<label>`, the text
262
+ * of the row/cell it sits in, or a `<Text>` child rendered beside the box. We
263
+ * cannot prove a toggle is nameless from its call site, so — per "uncertain →
264
+ * skip" — the enforce check does NOT verify toggles at all. (The structural
265
+ * jsx-a11y pass still checks the ones it can resolve.) Matched on the LEAF name.
266
+ */
267
+ export const TOGGLE_NAMES: ReadonlySet<string> = new Set([
268
+ "Checkbox",
269
+ "Switch",
270
+ "Radio",
271
+ "Toggle",
272
+ "RadioGroupItem",
273
+ "RadioButton",
274
+ ]);
275
+
276
+ /** The leaf of a JSX tag name (`NS.Member` -> `Member`, else the name). */
277
+ export function leafName(name: string): string {
278
+ const dot = name.lastIndexOf(".");
279
+ return dot === -1 ? name : name.slice(dot + 1);
280
+ }
281
+
282
+ /**
283
+ * Classify a name by the conservative heuristics. A name matches when it equals
284
+ * the keyword or ends with it (suffix), so design-system aliases resolve. First
285
+ * match in {@link NAME_HEURISTICS} wins (icon-button before button, etc.).
286
+ */
287
+ export function typeFromName(name: string): ControlType | null {
288
+ const leaf = leafName(name);
289
+ for (const { type, keyword } of NAME_HEURISTICS) {
290
+ if (leaf === keyword || leaf.endsWith(keyword)) {
291
+ // `ButtonGroup`, `FieldArray`, `ImageList` etc. are CONTAINERS, not the
292
+ // control — a keyword must be a SUFFIX (or the whole name), never a prefix.
293
+ return type;
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Classify a JSX element at the call site into a recognized control, or `null`
301
+ * when it is not a recognized control (or recognition is uncertain). Three
302
+ * sources, in confidence order — the first two yield `strength: "host"` (PROVEN
303
+ * thin wrapper), the third `strength: "name"` (weaker, gated harder downstream):
304
+ *
305
+ * 1. resolved host — the MODULE-SCOPED resolved-host map ({@link
306
+ * buildResolvedHosts}), keyed by `(name, import module)`. Authoritative: the
307
+ * tracer/registry/declaration proved a single forwarding host FOR THAT
308
+ * MODULE'S export. Scoping to the module is what stops a same-named export
309
+ * from another library (MUI `TextField` vs react-admin `TextField`) from
310
+ * lending its host across the collision.
311
+ * 2. registry — a known design-system export with one unambiguous host,
312
+ * even if it never landed in the map (e.g. the host is unmappable).
313
+ * 3. name heuristic — a capitalized name that conventionally denotes a control
314
+ * (`Button`, `IconButton`, `ActionIcon`, `Link`, `Image`, `Dialog`,
315
+ * `Modal`). THIS is what reaches opaque/trusted components — but only those
316
+ * we can see the CHILDREN of (see {@link evaluate}); a self-closing
317
+ * name-only component is opaque and left alone.
318
+ *
319
+ * The name heuristic is GATED twice: (a) the element must be an IMPORTED
320
+ * capitalized component — a bare lowercase intrinsic is jsx-a11y's job, and an
321
+ * un-imported capitalized name is a local we don't recognize; and (b) its import
322
+ * module must be a GUARANTEED design system (`lookupGuaranteed(module) !== null`).
323
+ * Outside a known library a bare name is a guess, not evidence — react-admin's
324
+ * `Button` is named by `label=` and renders no children, so a name-only match
325
+ * would mass-false-positive. Icon-library imports are never controls (an icon is
326
+ * content, not a control), so they short-circuit to `null`.
327
+ */
328
+ function classify(
329
+ tagName: string,
330
+ resolvedHosts: ReadonlyMap<string, ResolvedHost>,
331
+ imports: ReadonlyMap<string, ImportBinding>,
332
+ ): Classification {
333
+ const recognized = (r: Recognized): Classification => ({ kind: "recognized", recognized: r });
334
+ // Toggle controls (checkbox/switch/radio/toggle) are externally labelled by
335
+ // convention — we can't verify their name at the call site, so skip them
336
+ // outright before any host/name recognition claims them as button/input. This
337
+ // CONSIDERS a real control and declines, so it abstains on the toggle SC family
338
+ // (G4) — vetoing a recall nomination on a name-only toggle.
339
+ if (TOGGLE_NAMES.has(leafName(tagName))) return { kind: "skip", wcag: TOGGLE_SKIP_WCAG };
340
+
341
+ // Native form-control intrinsics (`<input>`/`<select>`/`<textarea>`) are real
342
+ // host controls jsx-a11y would own — but we don't run its
343
+ // `control-has-associated-label`, so an unlabelled one slips through. Route
344
+ // them through the SAME conservative input name-check `evaluate` runs for input
345
+ // HOSTS. The exact-three set means no other intrinsic (`<td>`/`<div>`) is ever
346
+ // claimed — the structural guard against the layout-cell false positives the
347
+ // stock rule produces. (`tagName` is the bare lowercase tag; a capitalized
348
+ // `Input` component is handled by resolution/registry below.)
349
+ if (NATIVE_FORM_CONTROLS.has(tagName)) return recognized({ type: "input", strength: "host" });
350
+
351
+ // The local binding (namespace local for `NS.Member`).
352
+ const local = tagName.includes(".") ? tagName.slice(0, tagName.indexOf(".")) : tagName;
353
+ const binding = imports.get(local);
354
+
355
+ // 1. Resolved host wins — but ONLY when this call site's import module matches
356
+ // the module the host was resolved from. The lookup key is `(name, module)`,
357
+ // the same identity resolveComponents recorded, so a different module's
358
+ // same-named export can never claim this host. (An un-imported local has no
359
+ // binding and is never in the resolved-host map — it was never covered here.)
360
+ if (binding !== undefined) {
361
+ const resolved = resolvedHosts.get(`${tagName}@${binding.module}`);
362
+ if (resolved !== undefined) {
363
+ // A host carrying a TOGGLE role (`role="checkbox|switch|radio"`) is a
364
+ // toggle reached via trace/host rather than by name — externally labelled,
365
+ // so skip it exactly as TOGGLE_NAMES does. This is the role-aware
366
+ // generalization: a Radix `Checkbox` traced to host `button` (role
367
+ // `checkbox`) is no longer mistaken for a bare button.
368
+ // Both the toggle-role and the rendersOwnName skip CONSIDER a real control
369
+ // and decline — so they abstain on that control's SC family (the resolved
370
+ // host's type), letting G4 veto a recall nomination on the same line+SC.
371
+ const skipHost = HOST_TO_TYPE[resolved.host];
372
+ if (isToggleRole(resolved.role)) {
373
+ return skipHost === undefined ? null : { kind: "skip", wcag: TYPE_TO_WCAG[skipHost] };
374
+ }
375
+ // A wrapper that renders its host an internal STATIC name (an `sr-only`
376
+ // span, a literal `aria-label`, or static text — the shadcn carousel
377
+ // arrows) IS named even though the self-closing call site looks empty.
378
+ // Skip it like a toggle: the name is real, it just lives inside the
379
+ // wrapper. FN-safe — only ever suppresses, never adds a finding.
380
+ if (resolved.rendersOwnName) {
381
+ return skipHost === undefined ? null : { kind: "skip", wcag: TYPE_TO_WCAG[skipHost] };
382
+ }
383
+ if (skipHost !== undefined) return recognized({ type: skipHost, strength: "host" });
384
+ }
385
+ }
386
+
387
+ // An icon-library import is content, never a control — short-circuit. Abstain
388
+ // on the actionable-control family so a recall nomination on an icon's line is
389
+ // vetoed (it cannot be a no-name button/link/image — it's an icon).
390
+ if (binding !== undefined && isIconLibrary(binding.module)) {
391
+ return { kind: "skip", wcag: ICON_SKIP_WCAG };
392
+ }
393
+
394
+ // 1.5. Router link controls — react-router / Remix `Link` / `NavLink`. They
395
+ // render `<a>` but carry the destination on `to`, not `href`, so they are kept
396
+ // OUT of the structural jsx-a11y map (anchor-is-valid would false-positive on
397
+ // the missing href — the 28-FP trap a hand-declared `"Link":"a"` produces).
398
+ // Here in the CONTENT pass the check is name-based, so an icon-only / empty
399
+ // router link with no accessible name is the genuine 2.4.4. host-strength: the
400
+ // library contract guarantees a single `<a>`, so the call-site content IS the
401
+ // link's name (a self-closing nameless one is really nameless).
402
+ if (binding !== undefined && isRouterLinkControl(binding.module, binding.imported)) {
403
+ return recognized({ type: "link", strength: "host" });
404
+ }
405
+
406
+ // 2. Registry: a known export with one unambiguous host.
407
+ if (binding !== undefined) {
408
+ const member = tagName.includes(".") ? leafName(tagName) : binding.imported;
409
+ const reg = lookupRegistry(binding.module, member);
410
+ if (reg !== null) {
411
+ const regHost = HOST_TO_TYPE[reg.host];
412
+ // Same role-aware toggle skip for a registry hit (antd `Switch` → button
413
+ // with role `switch`) reached without a TOGGLE_NAMES match. Abstain on the
414
+ // host's SC family so G4 vetoes a recall nomination there.
415
+ if (isToggleRole(reg.role)) {
416
+ return regHost === undefined ? null : { kind: "skip", wcag: TYPE_TO_WCAG[regHost] };
417
+ }
418
+ if (regHost !== undefined) return recognized({ type: regHost, strength: "host" });
419
+ }
420
+ }
421
+
422
+ // 3. Name heuristic — only for IMPORTED capitalized components from a
423
+ // GUARANTEED design system. This is the opaque/trusted reach; outside a known
424
+ // library a bare name is a guess (the react-admin FP), so it never fires.
425
+ if (binding === undefined) return null;
426
+ if (!/^[A-Z]/.test(local)) return null;
427
+ // GATE: a bare name is trusted ONLY inside a guaranteed design system. From an
428
+ // unrecognized module (react-admin, a custom re-export) the name proves
429
+ // nothing about the rendered control, so we refuse to guess.
430
+ if (lookupGuaranteed(binding.module) === null) return null;
431
+ const fromName = typeFromName(tagName);
432
+ return fromName === null ? null : recognized({ type: fromName, strength: "name" });
433
+ }
434
+
435
+ /** ---- attribute / child inspection (all STATIC-only, by design) ---- */
436
+
437
+ /** Whether a JSX child is whitespace-only text or an empty expression (nothing). */
438
+ function isWhitespace(child: ts.JsxChild): boolean {
439
+ if (ts.isJsxText(child)) return child.text.trim() === "";
440
+ if (ts.isJsxExpression(child)) return child.expression === undefined;
441
+ return false;
442
+ }
443
+
444
+ interface OpeningInfo {
445
+ readonly opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement;
446
+ readonly element: ts.JsxElement | ts.JsxSelfClosingElement;
447
+ }
448
+
449
+ /** Whether the opening element spreads props (`{...props}`) — content unknowable. */
450
+ function spreadsProps(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): boolean {
451
+ return opening.attributes.properties.some((p) => ts.isJsxSpreadAttribute(p));
452
+ }
453
+
454
+ const NAME_ATTRS = [...LABEL_ATTRS, "title"] as const;
455
+ /**
456
+ * Name attributes for an ACTIONABLE control (button / icon-button / link). Adds
457
+ * `label` to {@link NAME_ATTRS}: several design systems name a button/link via a
458
+ * `label=` prop instead of children — react-admin's `<Button label="Import"/>`,
459
+ * Mantine, Blueprint. A `label=` present (or dynamic) means the control COULD be
460
+ * named, so — per "uncertain → skip" — we don't flag. Harmless at worst (a false
461
+ * negative if some library used `label` for something other than the name); it
462
+ * protects against ANY known lib that names via `label=`, not just react-admin.
463
+ *
464
+ * NOT added for images (`alt` is the image name source, handled separately) or
465
+ * inputs (which already honor `label` in their own branch).
466
+ */
467
+ const CONTROL_NAME_ATTRS = [...NAME_ATTRS, "label"] as const;
468
+
469
+ /**
470
+ * The static content verdict for an element's CHILDREN:
471
+ * - `text` — has discernible static text (a real accessible name source).
472
+ * - `iconOnly`— children are exactly known icon component(s)/SVG, no text.
473
+ * - `empty` — no children at all (self-closing or whitespace-only).
474
+ * - `dynamic` — children include a computed expression / non-icon component /
475
+ * spread — content is unknowable, so we must NOT flag.
476
+ *
477
+ * `iconOnly` and `empty` are the only verdicts that, combined with no name attr,
478
+ * justify a "no accessible name" finding. `dynamic` is the conservatism guard.
479
+ */
480
+ type ChildVerdict = "text" | "iconOnly" | "empty" | "dynamic";
481
+
482
+ /**
483
+ * Whether a JSX child element is a known icon — an icon-library import, or an
484
+ * intrinsic `<svg>`. Icons carry no text, so icon-only children are "no name".
485
+ * A capitalized child whose import is NOT an icon library is treated as possible
486
+ * content (it could render text), so it makes the verdict `dynamic`, not iconOnly.
487
+ */
488
+ function isIconChild(
489
+ child: ts.JsxElement | ts.JsxSelfClosingElement,
490
+ imports: ReadonlyMap<string, ImportBinding>,
491
+ ): boolean {
492
+ const opening = ts.isJsxElement(child) ? child.openingElement : child;
493
+ const tag = opening.tagName;
494
+ // Intrinsic <svg> is always an icon.
495
+ if (ts.isIdentifier(tag)) {
496
+ if (tag.text === "svg") return true;
497
+ const binding = imports.get(tag.text);
498
+ return binding !== undefined && isIconLibrary(binding.module);
499
+ }
500
+ if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression)) {
501
+ const binding = imports.get(tag.expression.text);
502
+ return binding !== undefined && isIconLibrary(binding.module);
503
+ }
504
+ return false;
505
+ }
506
+
507
+ /**
508
+ * Read the STATIC content verdict for an element's children. Conservative by
509
+ * construction: any whiff of dynamic/computed/unknown content yields `dynamic`
510
+ * (never flagged). Only literal text → `text`; only known icons → `iconOnly`;
511
+ * nothing → `empty`.
512
+ */
513
+ function childVerdict(
514
+ node: ts.JsxElement | ts.JsxSelfClosingElement,
515
+ imports: ReadonlyMap<string, ImportBinding>,
516
+ ): ChildVerdict {
517
+ if (!ts.isJsxElement(node)) return "empty"; // self-closing: no children
518
+ let sawIcon = false;
519
+ for (const child of node.children) {
520
+ if (ts.isJsxText(child)) {
521
+ if (child.text.trim() !== "") return "text";
522
+ continue; // whitespace-only text is nothing
523
+ }
524
+ if (ts.isJsxExpression(child)) {
525
+ // `{}` (empty/comment) is nothing; `{anything}` is computed -> dynamic.
526
+ if (child.expression === undefined) continue;
527
+ // A string literal child expression (`{"Save"}`) is real static text.
528
+ if (ts.isStringLiteral(child.expression)) return "text";
529
+ return "dynamic";
530
+ }
531
+ if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
532
+ if (isIconChild(child, imports)) {
533
+ sawIcon = true;
534
+ continue;
535
+ }
536
+ // A non-icon child component could render text — unknowable. Don't flag.
537
+ return "dynamic";
538
+ }
539
+ if (ts.isJsxFragment(child)) return "dynamic";
540
+ }
541
+ return sawIcon ? "iconOnly" : "empty";
542
+ }
543
+
544
+ /**
545
+ * Whether an element has a recognizable label/title for a DIALOG. Dialogs are
546
+ * the fuzziest control: a name can come from `aria-label`/`aria-labelledby`, a
547
+ * `title=`/`label=` prop, OR a nested title subcomponent (`<Dialog.Title>`,
548
+ * `<DialogTitle>`, `<ModalHeader>`). We flag ONLY when none of these is present
549
+ * AND the dialog has NO dynamic children — i.e. clearly nameless. Anything
550
+ * fuzzier is skipped (the corpus is least prescriptive here).
551
+ */
552
+ function dialogHasName(info: OpeningInfo, sf: ts.SourceFile): boolean {
553
+ if (anyNameAttr(info.opening, sf, NAME_ATTRS)) return true;
554
+ if (attrState(info.opening, sf, "label") !== "missing") return true;
555
+ if (attrState(info.opening, sf, "aria-describedby") !== "missing") {
556
+ // describedby alone isn't a name, but its presence signals a wired dialog —
557
+ // err toward NOT flagging (conservative for the fuzziest control).
558
+ return true;
559
+ }
560
+ // A nested title subcomponent anywhere inside the dialog gives it a name.
561
+ if (!ts.isJsxElement(info.element)) return false;
562
+ let titled = false;
563
+ const visit = (n: ts.Node): void => {
564
+ if (titled) return;
565
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
566
+ const leaf = ts.isPropertyAccessExpression(n.tagName)
567
+ ? n.tagName.name.text
568
+ : ts.isIdentifier(n.tagName)
569
+ ? n.tagName.text
570
+ : "";
571
+ if (/Title$|Header$|Heading$/.test(leaf)) titled = true;
572
+ }
573
+ ts.forEachChild(n, visit);
574
+ };
575
+ ts.forEachChild(info.element, visit);
576
+ return titled;
577
+ }
578
+
579
+ /** Whether the element has any non-whitespace child (a visible body to inspect). */
580
+ function hasStaticBody(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
581
+ if (!ts.isJsxElement(node)) return false;
582
+ return node.children.some((c) => !isWhitespace(c));
583
+ }
584
+
585
+ /**
586
+ * Whether a dialog body has a COMPUTED child whose tag we can't classify — a
587
+ * `{expr}` interpolation or a fragment that could render the title at runtime.
588
+ * Static element children are fine: {@link dialogHasName} recursively searches
589
+ * THEM for a title subcomponent, so a plain titled body is recognized. We skip
590
+ * only when a title could be hiding inside an expression we can't read.
591
+ */
592
+ function dialogHasDynamicBody(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
593
+ if (!ts.isJsxElement(node)) return false;
594
+ return node.children.some(
595
+ (c) => (ts.isJsxExpression(c) && c.expression !== undefined) || ts.isJsxFragment(c),
596
+ );
597
+ }
598
+
599
+ /** ---- the rules ---- */
600
+
601
+ /** A rule's identity: a stable id + the WCAG SC it carries (mirrors the corpus). */
602
+ interface EnforceRule {
603
+ readonly ruleId: string;
604
+ readonly wcag: readonly string[];
605
+ readonly message: string;
606
+ }
607
+
608
+ const RULES: Record<string, EnforceRule> = {
609
+ buttonNoName: {
610
+ ruleId: "enforce/button-no-name",
611
+ wcag: ["4.1.2"],
612
+ message:
613
+ "Control resolves to a button but has no accessible name: children are empty or icon-only and there is no aria-label/aria-labelledby/title. Give it discernible text or an aria-label (corpus: 4.1.2-button-no-name).",
614
+ },
615
+ imageNoAlt: {
616
+ ruleId: "enforce/image-no-alt",
617
+ wcag: ["1.1.1"],
618
+ message:
619
+ 'Control resolves to an image but has no alt and no aria-label/aria-labelledby. Add an alt that conveys the image\'s meaning, or alt="" if decorative (corpus: 1.1.1).',
620
+ },
621
+ linkNoName: {
622
+ ruleId: "enforce/link-no-name",
623
+ wcag: ["2.4.4"],
624
+ message:
625
+ "Control resolves to a link but has no discernible name: no text child and no aria-label/aria-labelledby/title. Give the link visible text or an aria-label that names its destination (corpus: 2.4.4-link-no-name).",
626
+ },
627
+ dialogNoName: {
628
+ ruleId: "enforce/dialog-no-name",
629
+ wcag: ["4.1.2", "1.3.1"],
630
+ message:
631
+ "Control resolves to a dialog/modal but has no accessible name: no aria-label/aria-labelledby, no title/label prop, and no nested title subcomponent. Name the dialog so assistive tech announces it (corpus: 4.1.2).",
632
+ },
633
+ inputNoName: {
634
+ ruleId: "enforce/input-no-name",
635
+ wcag: ["1.3.1", "3.3.2"],
636
+ message:
637
+ "Control resolves to a form input but has no associated label: no aria-label/aria-labelledby, no label/title prop, and no id to pair with a <label for>. Associate a real label (corpus: 1.3.1 / 3.3.2-form-control-no-name).",
638
+ },
639
+ };
640
+
641
+ /**
642
+ * Landmark / structural ARIA roles that ONE native HTML element provides
643
+ * implicitly — the SAFE subset of `prefer-tag-over-role`. Deliberately excludes
644
+ * widget roles (`combobox`, `img`, `status`, `presentation`, `menu`, `dialog`…):
645
+ * those have no single clean native tag, or the role override IS the correct
646
+ * pattern — an inline `<svg role="img" aria-label>` must NOT become `<img>`.
647
+ * Running the stock jsx-a11y rule over every role is ~90% noise on real apps
648
+ * (it fires on exactly those svg/status/combobox shapes); this is the landmark
649
+ * slice that is unambiguous. `natives` are the tags already carrying the role.
650
+ */
651
+ const SAFE_ROLE_TO_TAG: Readonly<
652
+ Record<string, { readonly suggest: string; readonly natives: readonly string[] }>
653
+ > = {
654
+ region: { suggest: "<section>", natives: ["section"] },
655
+ navigation: { suggest: "<nav>", natives: ["nav"] },
656
+ banner: { suggest: "<header>", natives: ["header"] },
657
+ contentinfo: { suggest: "<footer>", natives: ["footer"] },
658
+ main: { suggest: "<main>", natives: ["main"] },
659
+ article: { suggest: "<article>", natives: ["article"] },
660
+ list: { suggest: "<ul>/<ol>", natives: ["ul", "ol", "menu"] },
661
+ listitem: { suggest: "<li>", natives: ["li"] },
662
+ button: { suggest: "<button>", natives: ["button"] },
663
+ heading: { suggest: "<h1>–<h6>", natives: ["h1", "h2", "h3", "h4", "h5", "h6"] },
664
+ };
665
+
666
+ const PREFER_TAG_RULE_ID = "enforce/prefer-tag-over-role";
667
+ const PREFER_TAG_WCAG: readonly string[] = ["1.3.1"];
668
+
669
+ /**
670
+ * prefer-tag-over-role (scoped): a BARE intrinsic element (`<div>`/`<span>`/…)
671
+ * carrying a static landmark/structural `role` a native tag provides implicitly
672
+ * should use that tag. Fires ONLY on intrinsics (a resolved `<Button
673
+ * role="combobox">` is a component — its semantics are the component's job, and
674
+ * combobox is not a safe role anyway), ONLY for {@link SAFE_ROLE_TO_TAG}, and
675
+ * never when the element ALREADY is a native equivalent (`<section
676
+ * role="region">`). Returns the `role` attribute's line (the precise fix locus)
677
+ * plus a per-role message, or null.
678
+ */
679
+ function preferTagOverRole(
680
+ opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
681
+ sf: ts.SourceFile,
682
+ ): { readonly line: number; readonly message: string } | null {
683
+ if (!ts.isIdentifier(opening.tagName)) return null; // intrinsic only (no NS.Member)
684
+ const tag = opening.tagName.text;
685
+ if (!/^[a-z]/.test(tag)) return null; // lowercase = intrinsic host element
686
+ for (const attr of opening.attributes.properties) {
687
+ if (!ts.isJsxAttribute(attr) || attr.name.getText(sf) !== "role") continue;
688
+ const init = attr.initializer;
689
+ let value: string | null = null;
690
+ if (init !== undefined && ts.isStringLiteral(init)) value = init.text.trim().toLowerCase();
691
+ else if (
692
+ init !== undefined &&
693
+ ts.isJsxExpression(init) &&
694
+ init.expression !== undefined &&
695
+ ts.isStringLiteral(init.expression)
696
+ ) {
697
+ value = init.expression.text.trim().toLowerCase();
698
+ }
699
+ if (value === null) return null; // dynamic / missing role value
700
+ const native = SAFE_ROLE_TO_TAG[value];
701
+ if (native === undefined) return null; // not a safe landmark role
702
+ if (native.natives.includes(tag)) return null; // already the native element
703
+ const line = sf.getLineAndCharacterOfPosition(attr.getStart(sf)).line + 1;
704
+ return {
705
+ line,
706
+ message: `This <${tag}> sets role="${value}", which ${native.suggest} conveys natively. Use ${native.suggest} instead of the role so the semantics work without ARIA (corpus: 1.3.1-prefer-tag-over-role).`,
707
+ };
708
+ }
709
+ return null;
710
+ }
711
+
712
+ /** What `evaluate` needs to know about the element's surroundings. */
713
+ interface EvalContext {
714
+ /** How confidently the control was recognized (host = proven, name = weak). */
715
+ readonly strength: Strength;
716
+ /**
717
+ * Whether a label-bearing component is an ANCESTOR of this element — `<label>`,
718
+ * `Box as="label"`, `<FormLabel>`/`*Label`, or a form-field grouping
719
+ * (`FormItem`/`FormControl`/`FormField`) that conventionally renders a label.
720
+ * When true, an input's name likely comes from that label, so we don't flag.
721
+ */
722
+ readonly hasLabelAncestor: boolean;
723
+ /**
724
+ * Whether a NAME-INJECTING wrapper is an ANCESTOR of this element — a design
725
+ * system `<Tooltip title="…">`. MUI/antd/Mantine Tooltips clone their single
726
+ * child and inject the `title` as the child's `aria-label` by default (MUI:
727
+ * `describeChild=false` ⇒ "the title acts as an accessible label for the
728
+ * child"). So an icon-only `<IconButton>`/`<Button>`/`<Link>` directly inside a
729
+ * titled Tooltip IS named at runtime — invisible at the call site, exactly like
730
+ * an input under a `<label>`. This is the actionable-control counterpart to
731
+ * {@link hasLabelAncestor}: when true, we cannot prove the control nameless, so
732
+ * — per "uncertain → skip" — we don't flag button/icon-button/link.
733
+ */
734
+ readonly hasNameAncestor: boolean;
735
+ }
736
+
737
+ /**
738
+ * The WCAG SC family each control type carries when enforce CONSIDERS it. Used
739
+ * to label a G4 abstention (see {@link EvalOutcome}) with the SC the floor
740
+ * deliberately stayed silent on — the recall layer needs the SC, not the
741
+ * rule-id, since an abstention is "I looked at this SC here and declined".
742
+ */
743
+ const TYPE_TO_WCAG: Readonly<Record<ControlType, readonly string[]>> = {
744
+ button: RULES.buttonNoName.wcag,
745
+ "icon-button": RULES.buttonNoName.wcag,
746
+ link: RULES.linkNoName.wcag,
747
+ image: RULES.imageNoAlt.wcag,
748
+ dialog: RULES.dialogNoName.wcag,
749
+ input: RULES.inputNoName.wcag,
750
+ };
751
+
752
+ /**
753
+ * The verdict `evaluate` reaches for one classified element — a three-way split
754
+ * that distinguishes the floor's two kinds of "no finding":
755
+ *
756
+ * - `finding` — the control is CLEARLY nameless; emit the rule (floor finding).
757
+ * - `abstain` — enforce CONSIDERED this SC here and DELIBERATELY declined
758
+ * because the content is unknowable (a `{...props}` spread, a computed/
759
+ * dynamic child, an opaque name-strength wrapper). This is the G4 abstention
760
+ * signal: "the floor looked and stayed silent — not because it's named, but
761
+ * because it can't tell." A grounded recall finding on such a line is vetoed.
762
+ * - `clean` — the control IS named (a real name attr, static text, an alt, an
763
+ * ancestor label/Tooltip). The floor is silent because there's nothing to
764
+ * find; NOT an abstention (the recall layer may still flag elsewhere, but
765
+ * this line is genuinely fine).
766
+ *
767
+ * Only the `finding` arm produces a floor finding, so the emitted findings are
768
+ * byte-identical to the prior `EnforceRule | null` contract — `abstain` and
769
+ * `clean` both map to "no finding" exactly as `null` did.
770
+ */
771
+ type EvalOutcome =
772
+ | { readonly kind: "finding"; readonly rule: EnforceRule }
773
+ | { readonly kind: "abstain"; readonly wcag: readonly string[] }
774
+ | { readonly kind: "clean" };
775
+
776
+ /**
777
+ * Decide the enforce outcome for one classified element. Returns `finding` when
778
+ * the control is clearly nameless, `abstain` when the content is unknowable
779
+ * (spread / dynamic child / opaque wrapper — the G4 signal), or `clean` when the
780
+ * control is named. EVERY uncertain branch resolves to `abstain` or `clean`,
781
+ * never `finding`.
782
+ *
783
+ * The `strength` lever is the FP killer for the name heuristic: a `name`-strength
784
+ * control (recognized only by suffix, never seen inside) is flagged ONLY when we
785
+ * can see its CHILDREN are empty-or-icon — i.e. it's a visible container, not a
786
+ * self-closing opaque component that may render its own label. A `host`-strength
787
+ * control is a proven thin wrapper, so a self-closing nameless one IS nameless.
788
+ */
789
+ function evaluate(
790
+ type: ControlType,
791
+ info: OpeningInfo,
792
+ sf: ts.SourceFile,
793
+ imports: ReadonlyMap<string, ImportBinding>,
794
+ ctx: EvalContext,
795
+ ): EvalOutcome {
796
+ const { opening, element } = info;
797
+ const abstain: EvalOutcome = { kind: "abstain", wcag: TYPE_TO_WCAG[type] };
798
+ const clean: EvalOutcome = { kind: "clean" };
799
+
800
+ // CONSERVATISM GUARD #1: a spread (`{...props}`) could carry ANY of the name
801
+ // attributes (aria-label, alt, id, label). Content is unknowable -> abstain.
802
+ if (spreadsProps(opening)) return abstain;
803
+
804
+ switch (type) {
805
+ case "button":
806
+ case "icon-button": {
807
+ // A titled design-system Tooltip ancestor injects the child's name at
808
+ // runtime (see EvalContext.hasNameAncestor) — named, not nameless.
809
+ if (ctx.hasNameAncestor) return clean;
810
+ if (anyNameAttr(opening, sf, CONTROL_NAME_ATTRS)) return clean;
811
+ const v = childVerdict(element, imports);
812
+ // A computed/non-icon child could render the name — content unknowable.
813
+ if (v === "dynamic") return abstain;
814
+ if (v === "text") return clean;
815
+ // CONSERVATISM GUARD #2: a `name`-strength control that is SELF-CLOSING /
816
+ // empty is opaque — it likely renders its own label internally (a custom
817
+ // `<QuickCreateButton/>`). Only flag a name-only control when we can SEE
818
+ // an icon-only child (a visible container). A `host`-strength control is a
819
+ // proven thin wrapper, so empty-or-icon both flag.
820
+ if (ctx.strength === "name" && v === "empty") return abstain;
821
+ return { kind: "finding", rule: RULES.buttonNoName };
822
+ }
823
+ case "image": {
824
+ // CONSERVATISM GUARD: only a HOST-strength image (resolved/registry `img`,
825
+ // a proven thin wrapper) is checked. A name-only `*Image`/`AvatarImage`
826
+ // wrapper is opaque — it commonly derives its own alt from a `name`/`title`
827
+ // prop internally (avatar components), so we can't prove it nameless.
828
+ if (ctx.strength !== "host") return abstain;
829
+ // alt present (even empty alt="" = decorative) OR a label attr => named.
830
+ // alt="" is intentional decorative marking, NOT a violation -> not flagged
831
+ // (attrState treats empty alt as "missing", so check alt presence directly).
832
+ if (hasAltAttr(opening, sf)) return clean;
833
+ if (anyNameAttr(opening, sf, LABEL_ATTRS)) return clean;
834
+ return { kind: "finding", rule: RULES.imageNoAlt };
835
+ }
836
+ case "link": {
837
+ if (ctx.hasNameAncestor) return clean; // titled Tooltip ancestor names it
838
+ if (anyNameAttr(opening, sf, CONTROL_NAME_ATTRS)) return clean;
839
+ const v = childVerdict(element, imports);
840
+ if (v === "dynamic") return abstain;
841
+ if (v === "text") return clean;
842
+ if (ctx.strength === "name" && v === "empty") return abstain; // same guard #2
843
+ // A link with only an icon child and no name is the 2.4.4-link-no-name
844
+ // shape; empty likewise (host-strength).
845
+ return { kind: "finding", rule: RULES.linkNoName };
846
+ }
847
+ case "dialog": {
848
+ // Fuzziest control: flag only when CLEARLY nameless. A name-strength dialog
849
+ // is recognized only by a `*Dialog`/`*Modal` suffix — if it's self-closing
850
+ // / bodyless at the call site it's an OPAQUE wrapper that renders its own
851
+ // <DialogTitle> internally (the dominant case), so we can't prove it
852
+ // nameless. Only inspect a dialog whose BODY we can see (a host-strength
853
+ // primitive, or a name-strength container with static children).
854
+ if (ctx.strength === "name" && !hasStaticBody(element)) return abstain;
855
+ if (dialogHasName(info, sf)) return clean;
856
+ if (dialogHasDynamicBody(element)) return abstain;
857
+ return { kind: "finding", rule: RULES.dialogNoName };
858
+ }
859
+ case "input": {
860
+ // Inputs reach here via a real input HOST — a resolved wrapper, or a native
861
+ // `<input>`/`<select>`/`<textarea>` intrinsic (never the name heuristic).
862
+ // A submit/button/reset is named by its `value`, hidden/image/checkbox/radio
863
+ // are not text-name-bearing or are externally-labelled toggles — exempt by
864
+ // `type` before any name check (a `<input type="submit"/>` is not nameless).
865
+ if (isNameExemptInputType(opening, sf)) return clean;
866
+ // Hidden / untabbable controls (display:none, `hidden`, `tabIndex={-1}`
867
+ // sentinels) aren't operable/announced controls — an absent label is moot.
868
+ if (isHiddenOrUntabbable(opening, sf)) return clean;
869
+ // A name can come from aria-label/labelledby, a label/title prop, an id
870
+ // paired with a <label for>, OR a label ANCESTOR (FormLabel/Box as="label").
871
+ // Any of those = "could be labelled" -> conservative skip; placeholder is
872
+ // NOT a label, so its presence does NOT clear the finding.
873
+ if (ctx.hasLabelAncestor) return clean;
874
+ if (anyNameAttr(opening, sf, NAME_ATTRS)) return clean;
875
+ if (attrState(opening, sf, "label") !== "missing") return clean;
876
+ if (attrState(opening, sf, "id") !== "missing") return clean;
877
+ // CHILDREN on a WRAPPER input host mean a composite that labels itself with
878
+ // its content — skip (conservative; a real text input is self-closing). But
879
+ // a NATIVE `<select>`/`<textarea>` ALWAYS has children — its `<option>`s or
880
+ // default value — which are NOT a label, so the guard must not reach them,
881
+ // or every native select/textarea would go unchecked.
882
+ const isNativeFormControl =
883
+ ts.isIdentifier(opening.tagName) && NATIVE_FORM_CONTROLS.has(opening.tagName.text);
884
+ if (
885
+ !isNativeFormControl &&
886
+ ts.isJsxElement(element) &&
887
+ element.children.some((c) => !isWhitespace(c))
888
+ ) {
889
+ // A composite wrapper that labels itself with its own content — not a
890
+ // bare nameless input, but not provably named either: abstain.
891
+ return abstain;
892
+ }
893
+ return { kind: "finding", rule: RULES.inputNoName };
894
+ }
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Whether the element carries an `alt` attribute at all (present, empty, or
900
+ * dynamic). For images, an explicit `alt=""` is a deliberate decorative marking
901
+ * and must NOT be flagged — so we check PRESENCE, not non-emptiness.
902
+ */
903
+ function hasAltAttr(
904
+ opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
905
+ sf: ts.SourceFile,
906
+ ): boolean {
907
+ return opening.attributes.properties.some(
908
+ (p) => ts.isJsxAttribute(p) && p.name.getText(sf) === "alt",
909
+ );
910
+ }
911
+
912
+ /** A located enforce finding before it is decorated with file/enforcement/provenance. */
913
+ interface EnforceFinding {
914
+ readonly line: number;
915
+ readonly ruleId: string;
916
+ readonly message: string;
917
+ readonly wcag: readonly string[];
918
+ }
919
+
920
+ /**
921
+ * A G4 abstention marker: the floor CONSIDERED a control at this `line` for this
922
+ * WCAG `sc` and DELIBERATELY emitted no finding because the content is unknowable
923
+ * (spread / dynamic child / opaque wrapper). Carries no message — it is not a
924
+ * finding, it is the record that the floor looked and declined, which vetoes a
925
+ * grounded recall finding on the same `line` + `sc` (RFC Phase 1, G4).
926
+ */
927
+ export interface AbstentionMarker {
928
+ readonly line: number;
929
+ readonly sc: string;
930
+ }
931
+
932
+ /** An {@link AbstentionMarker} anchored to its file — the exposed G4 record. */
933
+ export interface LocatedAbstention extends AbstentionMarker {
934
+ readonly file: string;
935
+ }
936
+
937
+ /** Everything an element check needs about one JSX element and its surroundings. */
938
+ interface ElementCheckArgs {
939
+ readonly info: OpeningInfo;
940
+ /** The JSX tag (`div`, `Button`, `NS.Member`), already flattened. */
941
+ readonly tag: string;
942
+ readonly sf: ts.SourceFile;
943
+ readonly imports: ReadonlyMap<string, ImportBinding>;
944
+ readonly resolvedHosts: ReadonlyMap<string, ResolvedHost>;
945
+ /** An enclosing `<label>`/form-field is an ancestor ⇒ an input here is labelled by it. */
946
+ readonly hasLabelAncestor: boolean;
947
+ /** An enclosing titled `<Tooltip>` is an ancestor ⇒ a control here is named at runtime. */
948
+ readonly hasNameAncestor: boolean;
949
+ /**
950
+ * Sink for G4 abstentions — a check that CONSIDERED a control here and declined
951
+ * (unknowable content) pushes a {@link AbstentionMarker}. Side-channel only:
952
+ * it never affects the returned finding, so floor findings stay byte-identical.
953
+ */
954
+ readonly abstentions: AbstentionMarker[];
955
+ }
956
+
957
+ /**
958
+ * One enforce rule, as a pure `(element) -> finding | null`. This is the seam
959
+ * that keeps the visitor open/closed: a new rule is a new check appended to
960
+ * {@link ELEMENT_CHECKS}, never a new branch threaded through the walk. Each
961
+ * check owns its own rule id, WCAG, message, AND reported line (control-name
962
+ * reports the element opening; prefer-tag the role attribute).
963
+ */
964
+ type ElementCheck = (args: ElementCheckArgs) => EnforceFinding | null;
965
+
966
+ /**
967
+ * Control-name family: `classify` the tag to a control type, then `evaluate`
968
+ * whether that control lacks an accessible name. The original enforce rule set
969
+ * (button/input/link/image/dialog), sourcing its metadata from {@link RULES}.
970
+ */
971
+ const controlNameCheck: ElementCheck = (a) => {
972
+ const classified = classify(a.tag, a.resolvedHosts, a.imports);
973
+ if (classified === null) return null;
974
+ const line = a.sf.getLineAndCharacterOfPosition(a.info.opening.getStart(a.sf)).line + 1;
975
+ // A resolved-host SKIP (traced/registry toggle, rendersOwnName, icon library):
976
+ // enforce CONSIDERED the control and declined for a reason call-site syntax
977
+ // can't see. Emit a G4 abstention on the SC family it would have checked —
978
+ // WITHOUT a finding (floor output unchanged) — so a recall nomination on this
979
+ // line+SC is vetoed (findings #2/#8).
980
+ if (classified.kind === "skip") {
981
+ for (const sc of classified.wcag) a.abstentions.push({ line, sc });
982
+ return null;
983
+ }
984
+ const recognized = classified.recognized;
985
+ const outcome = evaluate(recognized.type, a.info, a.sf, a.imports, {
986
+ strength: recognized.strength,
987
+ hasLabelAncestor: a.hasLabelAncestor,
988
+ hasNameAncestor: a.hasNameAncestor,
989
+ });
990
+ if (outcome.kind === "abstain") {
991
+ // G4 side-channel: the floor looked at this SC here and declined. Record the
992
+ // abstention WITHOUT emitting a finding — floor output is unchanged.
993
+ for (const sc of outcome.wcag) a.abstentions.push({ line, sc });
994
+ return null;
995
+ }
996
+ if (outcome.kind === "clean") return null;
997
+ const rule = outcome.rule;
998
+ return { line, ruleId: rule.ruleId, message: rule.message, wcag: rule.wcag };
999
+ };
1000
+
1001
+ /** prefer-tag-over-role family: a bare intrinsic with a landmark role + native tag. */
1002
+ const preferTagCheck: ElementCheck = (a) => {
1003
+ const ptr = preferTagOverRole(a.info.opening, a.sf);
1004
+ if (ptr === null) return null;
1005
+ return { line: ptr.line, ruleId: PREFER_TAG_RULE_ID, message: ptr.message, wcag: PREFER_TAG_WCAG };
1006
+ };
1007
+
1008
+ /**
1009
+ * Every enforce element check, run against EVERY JSX element in source order.
1010
+ * To add a rule, append a check here — the visitor neither knows nor cares what
1011
+ * each one does. Order is finding order, so keep the original families first.
1012
+ */
1013
+ const ELEMENT_CHECKS: readonly ElementCheck[] = [controlNameCheck, preferTagCheck];
1014
+
1015
+ /**
1016
+ * Run the enforce content check across the given `.tsx` files. Produces a
1017
+ * `Finding` per clear, static, app-owned content gap on a recognized control —
1018
+ * INCLUDING opaque/trusted components. Findings are tagged `provenance:
1019
+ * "enforce"`; the caller dedupes them against the jsx-a11y findings.
1020
+ *
1021
+ * Suppression mirrors the structural pass: a control on a line covered by a
1022
+ * Trans/render injection or `aria-hidden` is contentful/out-of-tree at runtime,
1023
+ * so the "no content" premise doesn't hold — those are skipped too. The walk
1024
+ * carries a `labelDepth` so an input under a label/form-field grouping isn't
1025
+ * flagged (its name comes from that label).
1026
+ */
1027
+ export function enforceContent(filePaths: readonly string[], ctx: EnforceContext): Finding[] {
1028
+ return enforceContentWithAbstentions(filePaths, ctx).findings;
1029
+ }
1030
+
1031
+ /**
1032
+ * The enforce pass plus its G4 abstention markers (RFC Phase 1, §1b). Identical
1033
+ * to {@link enforceContent} for `findings` — same walk, byte-identical floor
1034
+ * output — but additionally returns every {@link AbstentionMarker}: a line+SC the
1035
+ * floor CONSIDERED a control at and deliberately declined (spread / dynamic child
1036
+ * / opaque wrapper). `enforceContent` is this with the abstentions dropped, so no
1037
+ * existing caller (scan, the CLI) sees any change.
1038
+ *
1039
+ * `sourceFiles` is an OPTIONAL pre-parsed cache (`file -> ts.SourceFile`): when a
1040
+ * caller has already parsed a file (e.g. the recall layer's `buildStaticFacts`,
1041
+ * which shares one parse across this walk and `fileFacts`), pass it here to skip
1042
+ * the redundant read+parse. The SourceFile must be the SAME one this function
1043
+ * would have built (`ScriptKind.TSX`, `setParentNodes`), so the output is
1044
+ * byte-identical whether the cache hits or misses.
1045
+ */
1046
+ export function enforceContentWithAbstentions(
1047
+ filePaths: readonly string[],
1048
+ ctx: EnforceContext,
1049
+ sourceFiles?: ReadonlyMap<string, ts.SourceFile>,
1050
+ ): { readonly findings: Finding[]; readonly abstentions: readonly LocatedAbstention[] } {
1051
+ const injectsChildren = ctx.declarations?.injectsChildren ?? [];
1052
+ // Module-scoped resolved-host lookup — the FP-safe replacement for jsx-a11y's
1053
+ // leaf-keyed flat map (see {@link EnforceContext.resolutions}).
1054
+ const resolvedHosts = buildResolvedHosts(ctx.resolutions);
1055
+ const out: Finding[] = [];
1056
+ const abstentions: LocatedAbstention[] = [];
1057
+
1058
+ for (const filePath of filePaths) {
1059
+ // Reuse a pre-parsed SourceFile when the caller supplied one; else read+parse.
1060
+ let sf = sourceFiles?.get(filePath);
1061
+ if (sf === undefined) {
1062
+ const text = ts.sys.readFile(filePath);
1063
+ if (text === undefined) continue;
1064
+ sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
1065
+ }
1066
+ const imports = collectLocalImports(sf);
1067
+ // Same runtime-injection / aria-hidden ranges the structural pass uses, so a
1068
+ // control made contentful at runtime isn't flagged here either.
1069
+ const suppressed = [
1070
+ ...transInjectedLineRanges(sf, injectsChildren),
1071
+ ...ariaHiddenLineRanges(sf),
1072
+ ];
1073
+ const isSuppressed = (line: number): boolean =>
1074
+ suppressed.some((r) => line >= r.start && line <= r.end);
1075
+
1076
+ // The ancestor `labelDepth` / `nameAncestorDepth` descent is the SHARED
1077
+ // {@link walkAncestorSuppressors} (also driving `buildSuppressorMap`) so the
1078
+ // two cannot drift. It hands each element its enclosing-ancestor flags; this
1079
+ // callback runs the per-element checks against them.
1080
+ walkAncestorSuppressors(sf, ({ node, opening, flags }) => {
1081
+ const info: OpeningInfo = { opening, element: node };
1082
+ const tagNode = opening.tagName;
1083
+ const tag = ts.isIdentifier(tagNode)
1084
+ ? tagNode.text
1085
+ : ts.isPropertyAccessExpression(tagNode) && ts.isIdentifier(tagNode.expression)
1086
+ ? `${tagNode.expression.text}.${tagNode.name.text}`
1087
+ : null;
1088
+ if (tag === null) return;
1089
+ // Per-element abstention buffer: collected by the checks, then kept only if
1090
+ // the element's line is not range-suppressed (same gate as a finding) — so
1091
+ // an abstention never appears where a finding couldn't.
1092
+ const elemAbstentions: AbstentionMarker[] = [];
1093
+ const args: ElementCheckArgs = {
1094
+ info,
1095
+ tag,
1096
+ sf,
1097
+ imports,
1098
+ resolvedHosts,
1099
+ hasLabelAncestor: flags.hasLabelAncestor,
1100
+ hasNameAncestor: flags.hasNameAncestor,
1101
+ abstentions: elemAbstentions,
1102
+ };
1103
+ for (const check of ELEMENT_CHECKS) {
1104
+ const finding = check(args);
1105
+ if (finding !== null && !isSuppressed(finding.line)) {
1106
+ out.push({
1107
+ file: filePath,
1108
+ line: finding.line,
1109
+ ruleId: finding.ruleId,
1110
+ message: finding.message,
1111
+ wcag: finding.wcag,
1112
+ enforcement: enforcementFor(finding.wcag, ctx.contract),
1113
+ provenance: "enforce",
1114
+ });
1115
+ }
1116
+ }
1117
+ // Flush the element's abstentions, same suppression gate as a finding.
1118
+ for (const a of elemAbstentions) {
1119
+ if (!isSuppressed(a.line)) abstentions.push({ file: filePath, ...a });
1120
+ }
1121
+ });
1122
+ }
1123
+
1124
+ return { findings: out, abstentions };
1125
+ }