@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/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
|
+
}
|