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