@binclusive/a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Known-library wrapper -> host-primitive registry.
3
+ *
4
+ * This is the deterministic fast path: for the design systems most customer
5
+ * codebases actually use, we know the wrapper->primitive mapping up front and
6
+ * never need to trace source. Coverage here is pure DATA — adding a library is
7
+ * a new `RegistryRule` entry, never a code change.
8
+ *
9
+ * A rule matches on (import module specifier, imported export name). The
10
+ * specifier is matched as a prefix so scoped sub-paths resolve too
11
+ * (`@mui/material/Button` matches the `@mui/material` rule). `exportName` is
12
+ * the *imported* name as written in source; for namespace/sub-path imports
13
+ * (Radix `* as LabelPrimitive` -> `LabelPrimitive.Root`) the member after the
14
+ * dot is what we match, captured by the resolver as `Root`.
15
+ *
16
+ * Each rule yields the WAI-ARIA / HTML host element name jsx-a11y understands
17
+ * (an intrinsic tag like `input`, `button`, `a`, `label`, `img`, `textarea`,
18
+ * `select`). Components with no single accessible host (Card, Dialog wrappers,
19
+ * layout primitives) are deliberately NOT listed — they should fall through to
20
+ * tracing or land as OPAQUE rather than be mis-mapped.
21
+ */
22
+ export interface RegistryRule {
23
+ /** Library display name, for the coverage report / provenance. */
24
+ readonly library: string;
25
+ /** Module specifier prefix, e.g. "@mui/material" or "@radix-ui/react-label". */
26
+ readonly module: string;
27
+ /** Imported export (or namespace member) name, e.g. "Button", "Root", "TextField". */
28
+ readonly exportName: string;
29
+ /** Host primitive this wrapper ultimately renders. */
30
+ readonly host: string;
31
+ /**
32
+ * The explicit ARIA `role` the library sets on that host, when it differs from
33
+ * the host's implicit role — present ONLY for the TOGGLE primitives whose host
34
+ * is `button`/`input` but which render `role="checkbox"|"switch"|"radio"`
35
+ * (Radix `Checkbox`/`Switch`, antd `Switch`). The host alone would read as a
36
+ * bare button/input downstream and fire role-support rules that don't apply to
37
+ * the real role (e.g. `aria-invalid` is invalid on a bare button but valid on
38
+ * `role="checkbox"`). Carrying the role lets the resolver treat it as the
39
+ * toggle it is. Absent ⇒ the host's implicit role; nothing changes.
40
+ */
41
+ readonly role?: string;
42
+ }
43
+
44
+ /**
45
+ * Seed rules for the common stacks. Intentionally conservative: only wrappers
46
+ * with one unambiguous accessible host. Extend by appending rows.
47
+ */
48
+ export const REGISTRY: readonly RegistryRule[] = [
49
+ // ---- Radix UI (primitives are per-package; component is usually `Root`) ----
50
+ { library: "Radix", module: "@radix-ui/react-label", exportName: "Root", host: "label" },
51
+ { library: "Radix", module: "@radix-ui/react-label", exportName: "Label", host: "label" },
52
+ // Radix renders these toggles as `<button role="checkbox|switch">` — carry the
53
+ // role so downstream treats them as toggles, not bare buttons (otherwise a
54
+ // `<Checkbox aria-invalid>` fires `role-supports-aria-props` against `button`,
55
+ // a false positive — `aria-invalid` IS valid on `role="checkbox"`).
56
+ {
57
+ library: "Radix",
58
+ module: "@radix-ui/react-checkbox",
59
+ exportName: "Root",
60
+ host: "button",
61
+ role: "checkbox",
62
+ },
63
+ {
64
+ library: "Radix",
65
+ module: "@radix-ui/react-switch",
66
+ exportName: "Root",
67
+ host: "button",
68
+ role: "switch",
69
+ },
70
+ { library: "Radix", module: "@radix-ui/react-toggle", exportName: "Root", host: "button" },
71
+ // Radix Slot is polymorphic (renders its child) — no fixed host, left opaque.
72
+
73
+ // ---- MUI (@mui/material) ----
74
+ { library: "MUI", module: "@mui/material", exportName: "Button", host: "button" },
75
+ { library: "MUI", module: "@mui/material", exportName: "IconButton", host: "button" },
76
+ { library: "MUI", module: "@mui/material", exportName: "Link", host: "a" },
77
+ { library: "MUI", module: "@mui/material", exportName: "TextField", host: "input" },
78
+ { library: "MUI", module: "@mui/material", exportName: "InputBase", host: "input" },
79
+ { library: "MUI", module: "@mui/material", exportName: "OutlinedInput", host: "input" },
80
+ { library: "MUI", module: "@mui/material", exportName: "FilledInput", host: "input" },
81
+ { library: "MUI", module: "@mui/material", exportName: "Input", host: "input" },
82
+ { library: "MUI", module: "@mui/material", exportName: "Checkbox", host: "input" },
83
+ { library: "MUI", module: "@mui/material", exportName: "Radio", host: "input" },
84
+ { library: "MUI", module: "@mui/material", exportName: "Switch", host: "input" },
85
+ { library: "MUI", module: "@mui/material", exportName: "TextareaAutosize", host: "textarea" },
86
+ { library: "MUI", module: "@mui/material", exportName: "InputLabel", host: "label" },
87
+ { library: "MUI", module: "@mui/material", exportName: "FormLabel", host: "label" },
88
+ { library: "MUI", module: "@mui/material", exportName: "Select", host: "select" },
89
+
90
+ // ---- MUI v4 (@material-ui/core) — same component->host mapping as v5, just
91
+ // the pre-v5 package namespace. Repos that haven't migrated (Saleor)
92
+ // import the identical exports from here. ----
93
+ { library: "MUI", module: "@material-ui/core", exportName: "Button", host: "button" },
94
+ { library: "MUI", module: "@material-ui/core", exportName: "IconButton", host: "button" },
95
+ { library: "MUI", module: "@material-ui/core", exportName: "Link", host: "a" },
96
+ { library: "MUI", module: "@material-ui/core", exportName: "TextField", host: "input" },
97
+ { library: "MUI", module: "@material-ui/core", exportName: "InputBase", host: "input" },
98
+ { library: "MUI", module: "@material-ui/core", exportName: "OutlinedInput", host: "input" },
99
+ { library: "MUI", module: "@material-ui/core", exportName: "FilledInput", host: "input" },
100
+ { library: "MUI", module: "@material-ui/core", exportName: "Input", host: "input" },
101
+ { library: "MUI", module: "@material-ui/core", exportName: "Checkbox", host: "input" },
102
+ { library: "MUI", module: "@material-ui/core", exportName: "Radio", host: "input" },
103
+ { library: "MUI", module: "@material-ui/core", exportName: "Switch", host: "input" },
104
+ { library: "MUI", module: "@material-ui/core", exportName: "TextareaAutosize", host: "textarea" },
105
+ { library: "MUI", module: "@material-ui/core", exportName: "InputLabel", host: "label" },
106
+ { library: "MUI", module: "@material-ui/core", exportName: "FormLabel", host: "label" },
107
+ { library: "MUI", module: "@material-ui/core", exportName: "Select", host: "select" },
108
+
109
+ // ---- Chakra UI (@chakra-ui/react) ----
110
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Button", host: "button" },
111
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "IconButton", host: "button" },
112
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Link", host: "a" },
113
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Input", host: "input" },
114
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Checkbox", host: "input" },
115
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Radio", host: "input" },
116
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Switch", host: "input" },
117
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Textarea", host: "textarea" },
118
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Select", host: "select" },
119
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "FormLabel", host: "label" },
120
+ { library: "Chakra", module: "@chakra-ui/react", exportName: "Image", host: "img" },
121
+
122
+ // ---- Ant Design (antd) ----
123
+ // Only the single-identifier leaf controls whose host is unambiguous. antd's
124
+ // COMPOUND members (`Input.Search`, `Input.Password`, `Input.TextArea`,
125
+ // `Radio.Button`, `Typography.Link`) are deliberately absent: a named import
126
+ // (`import { Input } from "antd"`) collapses `Input.Search` to a lookup on
127
+ // `Input`, while the jsx-a11y map keys it by its LEAF (`Search`) — so mapping
128
+ // the namespace root would lend a wrong host to every `<Search>`/`<Password>`/
129
+ // `<Link>` in the tree. Those, plus the custom-combobox controls (`Select`,
130
+ // `DatePicker`, `TimePicker` — div-based, NOT native `<select>`/`<input>`) and
131
+ // `Avatar` (a `<span>` unless `src` is set), fall through to `guaranteedBy`.
132
+ { library: "Antd", module: "antd", exportName: "Button", host: "button" },
133
+ { library: "Antd", module: "antd", exportName: "Input", host: "input" },
134
+ { library: "Antd", module: "antd", exportName: "InputNumber", host: "input" },
135
+ { library: "Antd", module: "antd", exportName: "Checkbox", host: "input" },
136
+ { library: "Antd", module: "antd", exportName: "Radio", host: "input" },
137
+ // antd `Switch` renders a `<button role="switch">` (rc-switch), like Radix —
138
+ // not the `<input>` MUI/Chakra use. enforce SKIPS toggles (TOGGLE_NAMES); the
139
+ // `role` keeps the structural jsx-a11y pass from reading it as a bare button.
140
+ { library: "Antd", module: "antd", exportName: "Switch", host: "button", role: "switch" },
141
+ { library: "Antd", module: "antd", exportName: "Image", host: "img" },
142
+
143
+ // ---- Medusa UI (@medusajs/ui) ----
144
+ // Single-host leaf primitives only. `Button`/`IconButton` render a `<button>`
145
+ // (the `asChild` Slot form is polymorphic, but the default host is `button`,
146
+ // matching the registered MUI/Chakra `IconButton`). `Input` wraps a single
147
+ // `<input>` in a layout `<div>` with optional decorative search/password
148
+ // affordances — the accessible control is the one input, exactly the MUI
149
+ // `TextField` shape already mapped to `input`. `Textarea` -> `<textarea>`.
150
+ // `Checkbox`/`Switch` are Radix primitives under the hood, so (like Radix/antd)
151
+ // they render `<button role="checkbox|switch">` — carry the toggle role so the
152
+ // host doesn't read as a bare button and fire role-support rules that don't
153
+ // apply to the real role.
154
+ //
155
+ // DELIBERATELY ABSENT: `Select` (a Radix-Select custom combobox — renders a
156
+ // `<button>` + a popover of divs, NOT a native `<select>`, the antd `Select`
157
+ // lesson), `RadioGroup` (a composite of radios, no single host), and every
158
+ // compound / value component (`Table`, `Badge`, `IconBadge`, `Container`,
159
+ // `Heading`, `Text`, `Label`, `Toaster`) — none is one unambiguous interactive
160
+ // host. The library is marked guaranteed below so these stay TRUSTED.
161
+ { library: "Medusa", module: "@medusajs/ui", exportName: "Button", host: "button" },
162
+ { library: "Medusa", module: "@medusajs/ui", exportName: "IconButton", host: "button" },
163
+ { library: "Medusa", module: "@medusajs/ui", exportName: "Input", host: "input" },
164
+ { library: "Medusa", module: "@medusajs/ui", exportName: "Textarea", host: "textarea" },
165
+ {
166
+ library: "Medusa",
167
+ module: "@medusajs/ui",
168
+ exportName: "Checkbox",
169
+ host: "button",
170
+ role: "checkbox",
171
+ },
172
+ {
173
+ library: "Medusa",
174
+ module: "@medusajs/ui",
175
+ exportName: "Switch",
176
+ host: "button",
177
+ role: "switch",
178
+ },
179
+
180
+ // ---- Headless UI (@headlessui/react) ----
181
+ // Headless UI v2 exposes FLAT leaf exports for the bare-control primitives;
182
+ // each is documented as "a light wrapper around the native <X> element".
183
+ // `Button` -> `<button>`, `Input` -> `<input>`, `Textarea` -> `<textarea>`,
184
+ // `Select` -> a native `<select>` (NOT a div combobox — that is `Listbox`, a
185
+ // separate composite). `Switch` renders a `<button>` with switch semantics, so
186
+ // it carries `role="switch"` like Radix/antd/Medusa.
187
+ //
188
+ // DELIBERATELY ABSENT: every COMPOSITE — `Menu`, `Listbox`, `Combobox`,
189
+ // `Tab`/`TabGroup`, `Disclosure`, `RadioGroup`, `Dialog`, `Popover`,
190
+ // `Transition` — is a bundle of elements with no single host. Their dot-members
191
+ // (`Menu.Button`, `Dialog.Panel`, `Transition.Child`, `PopoverPanel`, …) are
192
+ // also out: a named import (`import { Menu }`) collapses `Menu.Button` to a
193
+ // lookup on `Menu`, and the jsx-a11y map keys by the LEAF (`Button`), so
194
+ // mapping the root would lend a wrong host to every leaf — the antd compound
195
+ // lesson. The library is marked guaranteed below so these stay TRUSTED.
196
+ { library: "HeadlessUI", module: "@headlessui/react", exportName: "Button", host: "button" },
197
+ { library: "HeadlessUI", module: "@headlessui/react", exportName: "Input", host: "input" },
198
+ {
199
+ library: "HeadlessUI",
200
+ module: "@headlessui/react",
201
+ exportName: "Textarea",
202
+ host: "textarea",
203
+ },
204
+ { library: "HeadlessUI", module: "@headlessui/react", exportName: "Select", host: "select" },
205
+ {
206
+ library: "HeadlessUI",
207
+ module: "@headlessui/react",
208
+ exportName: "Switch",
209
+ host: "button",
210
+ role: "switch",
211
+ },
212
+
213
+ // ---- React Aria Components (react-aria-components) ----
214
+ { library: "ReactAria", module: "react-aria-components", exportName: "Button", host: "button" },
215
+ { library: "ReactAria", module: "react-aria-components", exportName: "Link", host: "a" },
216
+ { library: "ReactAria", module: "react-aria-components", exportName: "Input", host: "input" },
217
+ {
218
+ library: "ReactAria",
219
+ module: "react-aria-components",
220
+ exportName: "TextArea",
221
+ host: "textarea",
222
+ },
223
+ { library: "ReactAria", module: "react-aria-components", exportName: "Label", host: "label" },
224
+
225
+ // ---- Next.js (very common, technically not a design system) ----
226
+ { library: "Next", module: "next/link", exportName: "default", host: "a" },
227
+ { library: "Next", module: "next/image", exportName: "default", host: "img" },
228
+ ] as const;
229
+
230
+ /**
231
+ * A design system whose primitives are accessible BY CONSTRUCTION when used
232
+ * correctly — Radix, MUI, React Aria, Chakra, Mantine. The library owns the
233
+ * internal a11y of these components (roles, focus management, ARIA wiring), so
234
+ * a wrapper that stays OPAQUE to the source-tracer is still trustworthy: the
235
+ * structure is guaranteed even though the checker can't see a single host.
236
+ *
237
+ * This is the reporting counterpart to {@link REGISTRY}: REGISTRY maps the few
238
+ * primitives with ONE unambiguous host (so jsx-a11y can run on them); this list
239
+ * recognizes the WHOLE library so its composite/opaque components (`Dialog`,
240
+ * `HoverCard.Root`, `Tabs`) are reported as TRUSTED rather than as unknown gaps.
241
+ *
242
+ * Pure data: marking a library guaranteed is a new `GuaranteedLibrary` row,
243
+ * never a code change. `guaranteedBy` is always `true` here — the flag exists so
244
+ * the type reads as a deliberate accessibility claim at every call site, not an
245
+ * incidental string match.
246
+ */
247
+ export interface GuaranteedLibrary {
248
+ /** Library display name, surfaced in the coverage report's "trusted" line. */
249
+ readonly library: string;
250
+ /**
251
+ * Module specifier prefix. Matched as a prefix so every sub-path resolves:
252
+ * `@radix-ui` covers `@radix-ui/react-hover-card`, `@mui/material` covers
253
+ * `@mui/material/Button`. Use the scope (`@radix-ui`) when EVERY package under
254
+ * it is a primitive; use the package (`@mui/material`) when only that one is.
255
+ */
256
+ readonly module: string;
257
+ /** Always `true` — present so the accessibility guarantee is explicit data. */
258
+ readonly guaranteedBy: true;
259
+ }
260
+
261
+ /**
262
+ * The known-accessible design systems. A component imported from any of these
263
+ * is TRUSTED even when opaque: the library guarantees its internal structure.
264
+ *
265
+ * Scope-wide entries (`@radix-ui`, `@chakra-ui`, `@mantine`) are safe because
266
+ * every published package under those scopes is a UI primitive from that one
267
+ * library. Package-level entries (`@mui/material`, `@material-ui/core`,
268
+ * `react-aria-components`) name the exact component package, not the scope,
269
+ * because the scope also carries non-primitive packages (`@mui/system`,
270
+ * `@mui/x-*`) we don't want to vouch for.
271
+ */
272
+ export const GUARANTEED_LIBRARIES: readonly GuaranteedLibrary[] = [
273
+ // Radix: every @radix-ui/react-* package is an accessible primitive.
274
+ { library: "Radix", module: "@radix-ui", guaranteedBy: true },
275
+ // MUI v5 + v4 component packages (the scope also has @mui/system etc., so pin
276
+ // the component package, not the scope).
277
+ { library: "MUI", module: "@mui/material", guaranteedBy: true },
278
+ { library: "MUI", module: "@material-ui/core", guaranteedBy: true },
279
+ // React Aria Components — the single accessible-components package.
280
+ { library: "ReactAria", module: "react-aria-components", guaranteedBy: true },
281
+ // Chakra + Mantine: scope-wide, every package is the design system's UI.
282
+ { library: "Chakra", module: "@chakra-ui", guaranteedBy: true },
283
+ { library: "Mantine", module: "@mantine", guaranteedBy: true },
284
+ // Ant Design — the single `antd` package. NOT the `@ant-design` scope: that
285
+ // also carries `@ant-design/icons` (an SVG pack, matched in ICON_LIBRARIES)
286
+ // and `@ant-design/pro-components` (heavier composites), neither of which is
287
+ // the core accessible primitive set we vouch for here.
288
+ { library: "Antd", module: "antd", guaranteedBy: true },
289
+ // Medusa UI — the single `@medusajs/ui` package (Radix-based primitives with
290
+ // a11y owned by the library). NOT the `@medusajs` scope: that also carries
291
+ // `@medusajs/icons` (an SVG pack, matched in ICON_LIBRARIES) and many non-UI
292
+ // packages (`@medusajs/js-sdk`, `@medusajs/types`, …) we don't vouch for.
293
+ { library: "Medusa", module: "@medusajs/ui", guaranteedBy: true },
294
+ // Headless UI — the single `@headlessui/react` package; every export is an
295
+ // accessible-by-construction primitive of this one design system.
296
+ { library: "HeadlessUI", module: "@headlessui/react", guaranteedBy: true },
297
+ // cmdk — the command-menu primitive (`Command`, `Command.Input/List/Item/…`)
298
+ // shadcn's `command.tsx` wraps. An accessible-by-construction combobox/listbox;
299
+ // the single `cmdk` package, every export a primitive of this one library.
300
+ { library: "cmdk", module: "cmdk", guaranteedBy: true },
301
+ ] as const;
302
+
303
+ /**
304
+ * The library name that GUARANTEES the accessibility of a component imported
305
+ * from `moduleSpecifier`, or `null` when the module is not a known-accessible
306
+ * design system. Prefix-matched like {@link lookupRegistry}, so scoped sub-paths
307
+ * resolve against the library entry. This is what splits OPAQUE into "trusted"
308
+ * (from a guaranteed library) vs "declare" (genuinely unknown) for the report —
309
+ * it changes NO checking behavior, only how an opaque component is bucketed.
310
+ */
311
+ export function lookupGuaranteed(moduleSpecifier: string): string | null {
312
+ for (const lib of GUARANTEED_LIBRARIES) {
313
+ if (moduleSpecifier === lib.module || moduleSpecifier.startsWith(`${lib.module}/`)) {
314
+ return lib.library;
315
+ }
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Icon libraries: components that render an `<svg>`, which has NO interactive
322
+ * accessible host. There is nothing for jsx-a11y to check at the call site (an
323
+ * icon's accessibility is decided by its consumer — the button or link that
324
+ * labels it), so these must NOT land in the "declare" bucket as if a host
325
+ * declaration would help. They are surfaced as a separate "no interactive host"
326
+ * note: opaque-but-not-actionable.
327
+ *
328
+ * Pure data, prefix-matched. Add a row to recognize another icon pack.
329
+ */
330
+ export const ICON_LIBRARIES: readonly string[] = [
331
+ "lucide-react",
332
+ "@heroicons/react",
333
+ "react-icons",
334
+ "@tabler/icons-react",
335
+ "@phosphor-icons/react",
336
+ "@radix-ui/react-icons",
337
+ "react-feather",
338
+ // Design-system icon packs. Each is the SVG sibling of a guaranteed library —
339
+ // matched HERE (not as a guaranteed primitive) so its imports read as `icons`,
340
+ // not `trusted`. `@ant-design/icons` in particular must be checked before the
341
+ // `antd` guarantee would otherwise be tempted to claim it.
342
+ "@ant-design/icons",
343
+ "@mui/icons-material",
344
+ "@chakra-ui/icons",
345
+ // Medusa's SVG icon pack — sibling of the guaranteed `@medusajs/ui`. Matched
346
+ // HERE so its imports read as `icons`, not as trusted primitives.
347
+ "@medusajs/icons",
348
+ ] as const;
349
+
350
+ /**
351
+ * Whether `moduleSpecifier` is a known icon library — an SVG-only package with
352
+ * no interactive host to check. Prefix-matched like the other lookups. Note
353
+ * `@radix-ui/react-icons` is an icon pack, NOT an accessible primitive, so it is
354
+ * matched HERE; {@link lookupGuaranteed}'s `@radix-ui` entry would otherwise
355
+ * claim it as trusted, so icon classification must be checked FIRST.
356
+ */
357
+ export function isIconLibrary(moduleSpecifier: string): boolean {
358
+ return ICON_LIBRARIES.some(
359
+ (lib) => moduleSpecifier === lib || moduleSpecifier.startsWith(`${lib}/`),
360
+ );
361
+ }
362
+
363
+ /**
364
+ * STRUCTURAL plumbing recognition — components that render nothing interactive
365
+ * (or nothing at all): they wire up context, lay out routes, draw a chart, or
366
+ * compose an email. Like an icon library, a structural component has NO
367
+ * interactive accessible host to check, so counting it as a `declare` gap
368
+ * manufactures a false to-do. This data backs {@link isStructural}, which
369
+ * {@link resolveComponents} consults BEFORE the trusted/declare split so the
370
+ * coverage report doesn't paint plumbing as an actionable unknown.
371
+ *
372
+ * CONSERVATIVE BY CONSTRUCTION. A false "structural" HIDES a real gap, so every
373
+ * rule below is either a framework-level certainty (React `Fragment`) or an
374
+ * allowlisted export from a KNOWN module — never a broad guess. The one risk a
375
+ * structural rule must never take is swallowing a CONTROL: `Link`/`NavLink`
376
+ * render `<a>` and are deliberately ABSENT from every router allowlist here.
377
+ */
378
+
379
+ /**
380
+ * Framework structural names recognized regardless of module — React's own
381
+ * non-rendering composition primitives. `Fragment` (and the `React.Fragment`
382
+ * namespace form, keyed on its leaf), `Suspense`, `StrictMode`, `Profiler`:
383
+ * none renders a host element. Matched on the LEAF name so both the named
384
+ * (`<Fragment>`) and namespace (`<React.Fragment>`) spellings resolve.
385
+ */
386
+ const STRUCTURAL_FRAMEWORK_NAMES: ReadonlySet<string> = new Set([
387
+ "Fragment",
388
+ "Suspense",
389
+ "StrictMode",
390
+ "Profiler",
391
+ ]);
392
+
393
+ /**
394
+ * Router modules whose STRUCTURAL exports lay out the route tree / document but
395
+ * render no interactive control. Recognition is an explicit allowlist of export
396
+ * NAMES (below), gated to these modules — so a same-named export elsewhere is
397
+ * unaffected, and (critically) the CONTROLS these same modules export — `Link`,
398
+ * `NavLink` (both render `<a>`) — are simply absent from the allowlist and stay
399
+ * classified as the controls they are.
400
+ */
401
+ const ROUTER_MODULES: readonly string[] = [
402
+ "react-router",
403
+ "react-router-dom",
404
+ "@remix-run/react",
405
+ // umi re-exports react-router's structural exports under its own module
406
+ // (antd-pro's `Outlet` comes from here). The name allowlist still gates it:
407
+ // umi's `Link` / `SelectLang` / `FormattedMessage` are NOT structural names,
408
+ // so they stay in `declare` as before.
409
+ "@umijs/max",
410
+ ];
411
+
412
+ /**
413
+ * The STRUCTURAL export names of the router modules in {@link ROUTER_MODULES}:
414
+ * route-tree layout (`Routes`/`Route`/`Outlet`/`Navigate`/`RouterProvider` and
415
+ * the `*Router` history providers) and the Remix/RRv7 document components
416
+ * (`Meta`/`Links`/`Scripts`/`ScrollRestoration`/`LiveReload`, plus the server/
417
+ * hydration entry components). `Link`/`NavLink` are POINTEDLY excluded — they
418
+ * are `<a>` controls, not plumbing. Matched on the leaf name.
419
+ */
420
+ const ROUTER_STRUCTURAL_NAMES: ReadonlySet<string> = new Set([
421
+ "Routes",
422
+ "Route",
423
+ "Outlet",
424
+ "Navigate",
425
+ "RouterProvider",
426
+ "BrowserRouter",
427
+ "MemoryRouter",
428
+ "HashRouter",
429
+ "ServerRouter",
430
+ "HydratedRouter",
431
+ "Meta",
432
+ "Links",
433
+ "Scripts",
434
+ "ScrollRestoration",
435
+ "LiveReload",
436
+ ]);
437
+
438
+ /**
439
+ * Non-router modules whose every export is structural plumbing — nothing to
440
+ * check at the call site. Prefix-matched like the other lookups. These are the
441
+ * widely-used cases SEEN in the real declare buckets of the validation catalog:
442
+ *
443
+ * - chart libraries (`recharts`, `@nivo/*`, `@ant-design/plots`,
444
+ * `@ant-design/charts`) — every export draws an `<svg>`/`<canvas>`; the
445
+ * a11y of a chart is a data-table/`aria` concern, not a call-site host
446
+ * check. (antd-pro's declare was full of `@ant-design/plots` `Line`/`Pie`/
447
+ * `Gauge`/…; react-admin's of `@nivo/bar` `ResponsiveBar`.)
448
+ * - `@react-email/*` — email document components (`Html`, `Container`,
449
+ * `Text`, …) compose an email, not an interactive web surface (seen in
450
+ * Epic Stack's email templates as `E.Html`/`E.Container`/`E.Text`).
451
+ *
452
+ * NOTHING here renders an interactive control; if a future module under one of
453
+ * these prefixes did, it would need to move OUT — conservatism over coverage.
454
+ */
455
+ const STRUCTURAL_MODULES: readonly string[] = [
456
+ "recharts",
457
+ "@nivo",
458
+ "@ant-design/plots",
459
+ "@ant-design/charts",
460
+ "@react-email",
461
+ // sonner — the `Toaster` is a transient toast region mounted once at the app
462
+ // root, not an interactive control on the page; its only other export is the
463
+ // imperative `toast()` fn. No host to check → plumbing, not a `declare` gap.
464
+ "sonner",
465
+ // nextjs-toploader — a top-of-page route-progress bar. Renders no interactive
466
+ // control; mounted once at the root, like the toast region above.
467
+ "nextjs-toploader",
468
+ // @next/third-parties — Google Analytics / Tag Manager `<script>` injectors.
469
+ // They mount tracking scripts, not interactive UI; no host to check.
470
+ "@next/third-parties",
471
+ ];
472
+
473
+ /** The leaf of a JSX name (`NS.Member` -> `Member`, else the name itself). */
474
+ function leafOf(name: string): string {
475
+ const dot = name.lastIndexOf(".");
476
+ return dot === -1 ? name : name.slice(dot + 1);
477
+ }
478
+
479
+ /**
480
+ * Whether a component used as `name` (the JSX tag, possibly a `NS.Member` form)
481
+ * imported from `moduleSpecifier` is STRUCTURAL plumbing — non-rendering /
482
+ * non-interactive, with no host to check. Recognized when ANY holds:
483
+ *
484
+ * - the leaf name is a framework primitive ({@link STRUCTURAL_FRAMEWORK_NAMES}):
485
+ * `Fragment` / `React.Fragment` / `Suspense` / `StrictMode` / `Profiler`;
486
+ * - the leaf name ENDS WITH `Provider` (`ThemeProvider`, `QueryClientProvider`,
487
+ * react-admin's `*ContextProvider`, …) — a context provider renders its
488
+ * children, no host of its own. The namespace `<X.Provider>` form is the
489
+ * same shape (leaf `Provider`), so it is covered too;
490
+ * - the leaf name ENDS WITH `ErrorBoundary` (`ErrorBoundary`,
491
+ * `GeneralErrorBoundary`) — a render-or-fallback wrapper, no fixed host;
492
+ * - it is a STRUCTURAL export of a router module ({@link ROUTER_MODULES} ×
493
+ * {@link ROUTER_STRUCTURAL_NAMES}) — route/document layout, NOT `Link`/`NavLink`;
494
+ * - its module is an all-structural module ({@link STRUCTURAL_MODULES}) —
495
+ * chart / email packages.
496
+ *
497
+ * CONSERVATIVE: a suffix rule is a SUFFIX (or whole name), never a prefix, so a
498
+ * `ProviderList` / `ErrorBoundaryConfig` container is NOT swept in. When unsure
499
+ * whether something renders a control, it is NOT listed here — it stays in
500
+ * `declare`, where a real gap belongs.
501
+ */
502
+ export function isStructural(name: string, moduleSpecifier: string): boolean {
503
+ const leaf = leafOf(name);
504
+
505
+ // Framework + name-shape recognition (module-agnostic).
506
+ if (STRUCTURAL_FRAMEWORK_NAMES.has(leaf)) return true;
507
+ // `<X.Provider>` and any `*Provider` name; `*ErrorBoundary`. Whole-name OK,
508
+ // suffix OK, prefix NOT (a container like `ProviderRegistry` is not plumbing).
509
+ if (leaf === "Provider" || leaf.endsWith("Provider")) return true;
510
+ if (leaf.endsWith("ErrorBoundary")) return true;
511
+
512
+ // Router structural exports — allowlisted name, gated to a router module.
513
+ const fromRouter = ROUTER_MODULES.some(
514
+ (m) => moduleSpecifier === m || moduleSpecifier.startsWith(`${m}/`),
515
+ );
516
+ if (fromRouter && ROUTER_STRUCTURAL_NAMES.has(leaf)) return true;
517
+
518
+ // All-structural modules — chart / email packages, prefix-matched.
519
+ return STRUCTURAL_MODULES.some(
520
+ (m) => moduleSpecifier === m || moduleSpecifier.startsWith(`${m}/`),
521
+ );
522
+ }
523
+
524
+ /**
525
+ * Router modules whose `Link` / `NavLink` are `<a>`-rendering LINK CONTROLS (not
526
+ * the structural plumbing in {@link ROUTER_STRUCTURAL_NAMES}). They render an
527
+ * anchor, but the destination rides a `to` prop — NOT `href` — so they are
528
+ * pointedly kept OUT of the jsx-a11y component map: the structural
529
+ * `anchor-is-valid` rule reads a missing `href` literally and would false-
530
+ * positive on every `<Link to=…>`. Recognition therefore lives only in the
531
+ * content pass (enforce), where the check is NAME-based, not href-based: an
532
+ * icon-only `<Link to><Icon/></Link>` with no accessible name is the real 2.4.4
533
+ * it always was. Scoped to the genuine react-router / Remix packages — NOT
534
+ * `@umijs/max`, whose `Link` is a distinct re-export we don't vouch for.
535
+ */
536
+ const ROUTER_LINK_MODULES: readonly string[] = [
537
+ "react-router",
538
+ "react-router-dom",
539
+ "@remix-run/react",
540
+ ];
541
+
542
+ /** The router link CONTROL export names (render `<a>`), matched on the leaf. */
543
+ const ROUTER_LINK_NAMES: ReadonlySet<string> = new Set(["Link", "NavLink"]);
544
+
545
+ /**
546
+ * Whether `exportName` imported from `moduleSpecifier` is a react-router / Remix
547
+ * link control (`Link` / `NavLink`). Consumed ONLY by the enforce content pass —
548
+ * never by `resolveComponents`, so these never enter the structural jsx-a11y map
549
+ * (see {@link ROUTER_LINK_MODULES} for why that separation matters). Module is
550
+ * matched exactly or as a sub-path (`react-router/dom`), name on its leaf.
551
+ */
552
+ export function isRouterLinkControl(moduleSpecifier: string, exportName: string): boolean {
553
+ const fromRouterLinkModule = ROUTER_LINK_MODULES.some(
554
+ (m) => moduleSpecifier === m || moduleSpecifier.startsWith(`${m}/`),
555
+ );
556
+ return fromRouterLinkModule && ROUTER_LINK_NAMES.has(leafOf(exportName));
557
+ }
558
+
559
+ /**
560
+ * ARIA roles that make an otherwise button/input host a TOGGLE — externally
561
+ * labelled, so the same "uncertain → skip" rule that exempts {@link
562
+ * TOGGLE_NAMES} applies. When a resolved host carries one of these (Radix
563
+ * Checkbox `role="checkbox"`, a homegrown `<button role="switch">`), it must be
564
+ * treated as a toggle, not a bare button/input: kept out of the jsx-a11y map
565
+ * (so role-support rules for the bare host don't fire) and skipped by enforce.
566
+ * NON-toggle roles are NOT here — they change nothing (a `role="tab"` host still
567
+ * gets its normal treatment), keeping the suppression tight.
568
+ */
569
+ export const TOGGLE_ROLES: ReadonlySet<string> = new Set([
570
+ "checkbox",
571
+ "switch",
572
+ "radio",
573
+ "menuitemcheckbox",
574
+ "menuitemradio",
575
+ ]);
576
+
577
+ /** Whether `role` is a toggle role — the role-aware analogue of `TOGGLE_NAMES`. */
578
+ export function isToggleRole(role: string | null | undefined): boolean {
579
+ return role !== null && role !== undefined && TOGGLE_ROLES.has(role);
580
+ }
581
+
582
+ /** A registry hit, carrying provenance for the coverage report. */
583
+ export interface RegistryHit {
584
+ readonly host: string;
585
+ readonly library: string;
586
+ /** The library's explicit toggle `role` on the host, when it has one (see {@link RegistryRule.role}). */
587
+ readonly role?: string;
588
+ }
589
+
590
+ /**
591
+ * Look up a registry mapping for a wrapper imported as `exportName` from
592
+ * `moduleSpecifier`. The specifier matches by prefix so scoped sub-paths
593
+ * (`@mui/material/Button`) resolve against the package-level rule. Returns
594
+ * `null` for no match — the caller then falls back to source-tracing.
595
+ */
596
+ export function lookupRegistry(moduleSpecifier: string, exportName: string): RegistryHit | null {
597
+ for (const rule of REGISTRY) {
598
+ const moduleMatches =
599
+ moduleSpecifier === rule.module || moduleSpecifier.startsWith(`${rule.module}/`);
600
+ if (moduleMatches && rule.exportName === exportName) {
601
+ return { host: rule.host, library: rule.library, role: rule.role };
602
+ }
603
+ }
604
+ return null;
605
+ }