@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/suggest.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `init --suggest`: scaffold the `binclusive.json` `components` map by GUESSING,
|
|
3
|
+
* from each unresolved wrapper's NAME, the HTML host it most likely renders.
|
|
4
|
+
*
|
|
5
|
+
* This removes the one manual step in adoption. Today a team with a custom
|
|
6
|
+
* design system hand-writes `{ "Button": "button", "TextField": "input", … }`;
|
|
7
|
+
* this pre-fills that map with best-guess hosts for the design-system's leaf
|
|
8
|
+
* primitives, for the team to REVIEW before committing.
|
|
9
|
+
*
|
|
10
|
+
* CONSERVATISM IS THE WHOLE DISCIPLINE (same as `enforce.ts`). A name guess is
|
|
11
|
+
* weaker evidence than a registry hit or a source trace — auto-applying one
|
|
12
|
+
* silently is exactly how you'd manufacture false positives. So this module:
|
|
13
|
+
*
|
|
14
|
+
* - SUGGESTS, never decides: every guess is printed for review, and the
|
|
15
|
+
* uncertain ones carry a ⚠ `verify` flag so the user looks twice.
|
|
16
|
+
* - is OPT-IN behind `--suggest`: plain `init` writes no guessed map. The
|
|
17
|
+
* suggestions only land in `binclusive.json` when the user asked for them.
|
|
18
|
+
* - SKIPS composites: a name that names a multi-element widget (`Modal`,
|
|
19
|
+
* `Dropdown`, `Tabs`, a `*Provider`, …) has NO single host, so it stays in
|
|
20
|
+
* the declare bucket rather than being mis-mapped to one element.
|
|
21
|
+
* - SKIPS toggles: `Checkbox`/`Switch`/`Radio`/`Toggle` are externally
|
|
22
|
+
* labelled — enforce skips them, and so does the suggestion (mapping one to
|
|
23
|
+
* `input`/`button` would invite the same false positives enforce avoids).
|
|
24
|
+
*
|
|
25
|
+
* The guess vocabulary REUSES `enforce.ts` (`NAME_HEURISTICS` / `typeFromName`)
|
|
26
|
+
* for the control types those heuristics already know (button / icon-button /
|
|
27
|
+
* link / image), and extends it with the input-family leaf names enforce
|
|
28
|
+
* deliberately omits. Enforce omits inputs because their accessible name comes
|
|
29
|
+
* from an external label it can't see at the call site — a FALSE-POSITIVE risk
|
|
30
|
+
* for an enforcing check. A SUGGESTION carries no such risk: it only proposes a
|
|
31
|
+
* host for the user to confirm, never fires a finding, so suggesting `input`
|
|
32
|
+
* for `*TextField` / `*Input` / `*Field` is safe and is the whole point.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ControlType } from "./enforce";
|
|
36
|
+
import { leafName, TOGGLE_NAMES, typeFromName } from "./enforce";
|
|
37
|
+
import { familyLabel, isFrameworkPrimitive, isOwnModule, packageNameOf } from "./module-scope";
|
|
38
|
+
import type { ComponentResolution } from "./resolve-components";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How confident the host guess is — the lever that keeps a name guess from
|
|
42
|
+
* being mistaken for a proven host:
|
|
43
|
+
*
|
|
44
|
+
* - `confident` — the leaf name maps UNAMBIGUOUSLY to one host (`*Button` →
|
|
45
|
+
* `button`, `*TextField` → `input`, `*Link` → `a`, `*Avatar` → `img`).
|
|
46
|
+
* Printed with a ✓; still review-only, but a safe default.
|
|
47
|
+
* - `verify` — the name maps to a host that is OFTEN a custom widget rather
|
|
48
|
+
* than the native element (`Select` → could be a combobox, not a native
|
|
49
|
+
* `<select>`), or only loosely matches. Printed with a ⚠ and a reason, so
|
|
50
|
+
* the user looks twice before trusting it.
|
|
51
|
+
*/
|
|
52
|
+
export type SuggestConfidence = "confident" | "verify";
|
|
53
|
+
|
|
54
|
+
/** One suggested wrapper → host mapping, with its confidence and source. */
|
|
55
|
+
export interface ComponentSuggestion {
|
|
56
|
+
/**
|
|
57
|
+
* The `components`-map KEY: the wrapper's LEAF name (`CommandPrimitive.Input`
|
|
58
|
+
* → `Input`), the same key the declared-host lookup matches on, so the
|
|
59
|
+
* scaffolded entry actually takes effect. Usually identical to the JSX name.
|
|
60
|
+
*/
|
|
61
|
+
readonly name: string;
|
|
62
|
+
/** The module it is imported from, e.g. `"@acme/ui"`. */
|
|
63
|
+
readonly module: string;
|
|
64
|
+
/** The guessed HTML host primitive, e.g. `"button"` / `"input"` / `"a"`. */
|
|
65
|
+
readonly host: string;
|
|
66
|
+
readonly confidence: SuggestConfidence;
|
|
67
|
+
/** A short reason, present ONLY for a `verify` guess; `null` for `confident`. */
|
|
68
|
+
readonly reason: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The full outcome of a suggestion pass, for the CLI to print + persist. */
|
|
72
|
+
export interface SuggestResult {
|
|
73
|
+
/** The suggested wrapper → host mappings, sorted for a deterministic report. */
|
|
74
|
+
readonly suggestions: readonly ComponentSuggestion[];
|
|
75
|
+
/**
|
|
76
|
+
* Declare-bucket components left UNMAPPED because they are composites (no
|
|
77
|
+
* single host) or toggles — kept so the CLI can print "left in declare".
|
|
78
|
+
* Names only, deduped, sorted.
|
|
79
|
+
*/
|
|
80
|
+
readonly skipped: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Inputs for {@link suggestComponentMap}, parallel to `detect-stack`'s scoping. */
|
|
84
|
+
export interface SuggestOptions {
|
|
85
|
+
/**
|
|
86
|
+
* Predicate identifying a module specifier that resolves to the repo's OWN
|
|
87
|
+
* code (relative imports, `@/`, `~`, `#`, and tsconfig path aliases that map
|
|
88
|
+
* into own source). Own components are one-offs, not a design system — they
|
|
89
|
+
* are never suggested. Mirror `ownAliasMatcherFor(dir).isOwnAlias`.
|
|
90
|
+
*/
|
|
91
|
+
readonly isOwnAlias: (specifier: string) => boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The detected design-system LABEL (e.g. `"@acme/ui"`, or a collapsed family
|
|
94
|
+
* name like `"Radix"`), or `null`/`"custom"` when none was detected.
|
|
95
|
+
* Suggestions from this design system sort FIRST so the most relevant guesses
|
|
96
|
+
* lead the printed list; it does not gate inclusion. The rank comparison
|
|
97
|
+
* collapses each suggestion's package through {@link familyLabel} so a family
|
|
98
|
+
* label still matches its per-component sub-packages.
|
|
99
|
+
*/
|
|
100
|
+
readonly designSystem: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Composite leaf names — multi-element widgets with NO single host element, so
|
|
105
|
+
* they CANNOT be mapped to one primitive and stay in the declare bucket. Each is
|
|
106
|
+
* a whole-name or suffix match (so `ConfirmDialog`, `UserMenu`, `SidePanel`
|
|
107
|
+
* resolve), never a prefix (a `CardGrid` is its own thing). This is the
|
|
108
|
+
* conservative inverse of the leaf-control vocabulary: when a name is a
|
|
109
|
+
* composite, we REFUSE to guess a host rather than mis-map it.
|
|
110
|
+
*
|
|
111
|
+
* Kept SEPARATE from enforce's `dialog` heuristic on purpose: enforce treats
|
|
112
|
+
* `Dialog`/`Modal` as a recognizable control TYPE (for its dialog rule), but for
|
|
113
|
+
* HOST mapping a dialog has no single element — so here it is a composite to
|
|
114
|
+
* skip, not a host to suggest.
|
|
115
|
+
*/
|
|
116
|
+
const COMPOSITE_KEYWORDS: readonly string[] = [
|
|
117
|
+
"Modal",
|
|
118
|
+
"Dialog",
|
|
119
|
+
"Drawer",
|
|
120
|
+
"Sheet",
|
|
121
|
+
"Popover",
|
|
122
|
+
"Tooltip",
|
|
123
|
+
"Dropdown",
|
|
124
|
+
"Menu",
|
|
125
|
+
"Tabs",
|
|
126
|
+
"Tab",
|
|
127
|
+
"Accordion",
|
|
128
|
+
"Collapse",
|
|
129
|
+
"Carousel",
|
|
130
|
+
"Card",
|
|
131
|
+
"Panel",
|
|
132
|
+
"Layout",
|
|
133
|
+
"Grid",
|
|
134
|
+
"Stack",
|
|
135
|
+
"Box",
|
|
136
|
+
"Flex",
|
|
137
|
+
"Container",
|
|
138
|
+
"Provider",
|
|
139
|
+
"Form",
|
|
140
|
+
"Table",
|
|
141
|
+
"List",
|
|
142
|
+
"Tree",
|
|
143
|
+
"Stepper",
|
|
144
|
+
"Breadcrumb",
|
|
145
|
+
"Pagination",
|
|
146
|
+
"Slider",
|
|
147
|
+
"Calendar",
|
|
148
|
+
"DatePicker",
|
|
149
|
+
"Combobox",
|
|
150
|
+
"Autocomplete",
|
|
151
|
+
"Command",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Input-family leaf keywords enforce deliberately OMITS (its name heuristic
|
|
156
|
+
* never guesses an input host — a false-positive risk for an enforcing check).
|
|
157
|
+
* A SUGGESTION carries no such risk, so it extends the vocabulary here. Order
|
|
158
|
+
* matters: the more specific `Textarea` precedes `Field`/`Input` so a
|
|
159
|
+
* `SearchTextarea` resolves to `textarea`, not `input`.
|
|
160
|
+
*
|
|
161
|
+
* Each entry also carries whether the guess is CONFIDENT or needs ⚠ `verify`:
|
|
162
|
+
* `Select` maps to `select` but is FLAGGED — a "Select" in a design system is
|
|
163
|
+
* frequently a custom combobox (a `<div role="combobox">`), not a native
|
|
164
|
+
* `<select>` — so we suggest the host yet ask the user to confirm.
|
|
165
|
+
*/
|
|
166
|
+
const INPUT_KEYWORDS: ReadonlyArray<{
|
|
167
|
+
readonly keyword: string;
|
|
168
|
+
readonly host: string;
|
|
169
|
+
readonly confidence: SuggestConfidence;
|
|
170
|
+
readonly reason: string | null;
|
|
171
|
+
}> = [
|
|
172
|
+
{ keyword: "Textarea", host: "textarea", confidence: "confident", reason: null },
|
|
173
|
+
{ keyword: "TextArea", host: "textarea", confidence: "confident", reason: null },
|
|
174
|
+
{ keyword: "TextField", host: "input", confidence: "confident", reason: null },
|
|
175
|
+
{ keyword: "TextInput", host: "input", confidence: "confident", reason: null },
|
|
176
|
+
{ keyword: "Input", host: "input", confidence: "confident", reason: null },
|
|
177
|
+
{ keyword: "Field", host: "input", confidence: "confident", reason: null },
|
|
178
|
+
{
|
|
179
|
+
keyword: "Select",
|
|
180
|
+
host: "select",
|
|
181
|
+
confidence: "verify",
|
|
182
|
+
reason: "could be a custom widget, not a native <select>",
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Anchor/image leaf keywords enforce's `Link`/`Image` heuristic doesn't spell
|
|
188
|
+
* out (it matches `Link`/`Image` suffixes; these are the design-system aliases).
|
|
189
|
+
* `Anchor` → `a`; `Avatar`/`Img` → `img`.
|
|
190
|
+
*/
|
|
191
|
+
const EXTRA_KEYWORDS: ReadonlyArray<{ readonly keyword: string; readonly host: string }> = [
|
|
192
|
+
{ keyword: "Anchor", host: "a" },
|
|
193
|
+
{ keyword: "Avatar", host: "img" },
|
|
194
|
+
{ keyword: "Img", host: "img" },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/** Map an enforce {@link ControlType} to the HTML host it denotes for suggesting. */
|
|
198
|
+
const TYPE_TO_HOST: Readonly<Record<ControlType, string | null>> = {
|
|
199
|
+
button: "button",
|
|
200
|
+
"icon-button": "button",
|
|
201
|
+
link: "a",
|
|
202
|
+
image: "img",
|
|
203
|
+
input: "input",
|
|
204
|
+
// A dialog is a composite — no single host. Never suggested (caught earlier as
|
|
205
|
+
// a composite name); mapped to `null` here so the type is exhaustive.
|
|
206
|
+
dialog: null,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Whether `leaf` equals or ends with `keyword` (suffix match, never prefix). */
|
|
210
|
+
function matchesKeyword(leaf: string, keyword: string): boolean {
|
|
211
|
+
return leaf === keyword || leaf.endsWith(keyword);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Guess a host for a single leaf name, or `null` when the name is a composite /
|
|
216
|
+
* toggle / unrecognized (no host to suggest). Precedence is by certainty:
|
|
217
|
+
*
|
|
218
|
+
* 1. toggle → skip (externally labelled; enforce skips them too).
|
|
219
|
+
* 2. composite → skip (multi-element widget, no single host).
|
|
220
|
+
* 3. input family → `input` / `textarea` / `select` (with a ⚠ for `Select`).
|
|
221
|
+
* 4. anchor / image aliases → `a` / `img`.
|
|
222
|
+
* 5. enforce's control-type heuristic → button / link / image (reused).
|
|
223
|
+
*/
|
|
224
|
+
function guessHost(name: string): Omit<ComponentSuggestion, "name" | "module"> | null {
|
|
225
|
+
const leaf = leafName(name);
|
|
226
|
+
|
|
227
|
+
// Toggles are externally labelled — enforce skips them, so does suggestion.
|
|
228
|
+
if (TOGGLE_NAMES.has(leaf)) return null;
|
|
229
|
+
|
|
230
|
+
// Composite widget — no single host. Leave it in declare.
|
|
231
|
+
if (COMPOSITE_KEYWORDS.some((k) => matchesKeyword(leaf, k))) return null;
|
|
232
|
+
|
|
233
|
+
// Input family (enforce omits these; a suggestion can safely propose them).
|
|
234
|
+
for (const { keyword, host, confidence, reason } of INPUT_KEYWORDS) {
|
|
235
|
+
if (matchesKeyword(leaf, keyword)) return { host, confidence, reason };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Anchor / image aliases enforce's Link/Image suffix doesn't name explicitly.
|
|
239
|
+
for (const { keyword, host } of EXTRA_KEYWORDS) {
|
|
240
|
+
if (matchesKeyword(leaf, keyword)) return { host, confidence: "confident", reason: null };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Reuse enforce's name heuristic for button / icon-button / link / image.
|
|
244
|
+
const type = typeFromName(name);
|
|
245
|
+
if (type === null) return null;
|
|
246
|
+
const host = TYPE_TO_HOST[type];
|
|
247
|
+
if (host === null) return null; // dialog → composite, no host
|
|
248
|
+
return { host, confidence: "confident", reason: null };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether a declare-bucket resolution is in scope for suggesting: it must come
|
|
253
|
+
* from an EXTERNAL, non-framework module. Own code (`./`, `@/`, `~`, `#`,
|
|
254
|
+
* tsconfig-alias own source) is one-off app components, not a design system;
|
|
255
|
+
* framework primitives (`next`, `react`, …) are the platform, not a UI library.
|
|
256
|
+
* Mirrors the same two gates `detect-stack.detectDesignSystem` applies.
|
|
257
|
+
*/
|
|
258
|
+
function inScope(r: ComponentResolution, isOwnAlias: (s: string) => boolean): boolean {
|
|
259
|
+
if (isOwnModule(r.module, isOwnAlias)) return false;
|
|
260
|
+
return !isFrameworkPrimitive(packageNameOf(r.module));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Suggest a `components` map from the resolution set: take the DECLARE-bucket
|
|
265
|
+
* components (genuine unknowns — `provenance === "opaque"`, `opaqueKind ===
|
|
266
|
+
* "declare"`), scope them to external non-framework modules, and guess a host
|
|
267
|
+
* for each from its NAME. Composites and toggles are left in declare (no single
|
|
268
|
+
* host); everything else yields a suggestion, ⚠-flagged when uncertain.
|
|
269
|
+
*
|
|
270
|
+
* Pure — no disk, no detection. The caller supplies the own-code matcher and the
|
|
271
|
+
* detected design system; the function does the name reasoning. Each suggestion
|
|
272
|
+
* is keyed by the wrapper's LEAF name (the same key the declared-host lookup
|
|
273
|
+
* uses), deduped so a wrapper used in many files yields one suggestion, and
|
|
274
|
+
* sorted for a deterministic report: design-system suggestions first, then by
|
|
275
|
+
* name.
|
|
276
|
+
*/
|
|
277
|
+
export function suggestComponentMap(
|
|
278
|
+
resolutions: readonly ComponentResolution[],
|
|
279
|
+
opts: SuggestOptions,
|
|
280
|
+
): SuggestResult {
|
|
281
|
+
const designSystem =
|
|
282
|
+
opts.designSystem === null || opts.designSystem === "custom" ? null : opts.designSystem;
|
|
283
|
+
|
|
284
|
+
const suggestions: ComponentSuggestion[] = [];
|
|
285
|
+
const skipped = new Set<string>();
|
|
286
|
+
const seen = new Set<string>();
|
|
287
|
+
|
|
288
|
+
for (const r of resolutions) {
|
|
289
|
+
// Only genuine unknowns are candidates — the design-system primitives the
|
|
290
|
+
// checker could not resolve. trusted / icons / structural are NOT gaps.
|
|
291
|
+
if (r.provenance !== "opaque" || r.opaqueKind !== "declare") continue;
|
|
292
|
+
if (!inScope(r, opts.isOwnAlias)) continue;
|
|
293
|
+
|
|
294
|
+
// Key the suggestion by the LEAF name — the SAME key the declared-host
|
|
295
|
+
// lookup in `resolveComponents` uses (`jsxKeyFor`). A namespace render
|
|
296
|
+
// (`CommandPrimitive.Input`) is matched on its trailing member, so the
|
|
297
|
+
// scaffolded `components` entry must be `{ "Input": "input" }` to take
|
|
298
|
+
// effect, not `{ "CommandPrimitive.Input": ... }` which would never match.
|
|
299
|
+
const key = leafName(r.name);
|
|
300
|
+
|
|
301
|
+
// Dedupe by the map key so a wrapper used widely is suggested once.
|
|
302
|
+
if (seen.has(key)) continue;
|
|
303
|
+
seen.add(key);
|
|
304
|
+
|
|
305
|
+
const guess = guessHost(r.name);
|
|
306
|
+
if (guess === null) {
|
|
307
|
+
// Composite or toggle — no single host; it stays in the declare bucket.
|
|
308
|
+
skipped.add(key);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
suggestions.push({ name: key, module: r.module, ...guess });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Design-system suggestions first, then alphabetical — deterministic output.
|
|
315
|
+
// `designSystem` is the DETECTED label, which is collapsed to a family name for
|
|
316
|
+
// known multi-package families (`@radix-ui/react-checkbox` -> `Radix`), so the
|
|
317
|
+
// suggestion's package is run through the SAME collapse before comparing —
|
|
318
|
+
// otherwise a Radix app's per-component packages would never match `Radix` and
|
|
319
|
+
// the design-system-first sort would silently regress.
|
|
320
|
+
const rank = (s: ComponentSuggestion): number =>
|
|
321
|
+
designSystem !== null && familyLabel(packageNameOf(s.module)) === designSystem ? 0 : 1;
|
|
322
|
+
suggestions.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name));
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
suggestions,
|
|
326
|
+
skipped: [...skipped].sort((a, b) => a.localeCompare(b)),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finding-suppression line ranges: spans of a file where a static finding would
|
|
5
|
+
* be a false positive because the source doesn't reflect the runtime / a11y
|
|
6
|
+
* tree. Two sources — runtime child-injection (Trans / render-delegation,
|
|
7
|
+
* detailed below) and `aria-hidden` subtrees. Callers union the ranges and skip
|
|
8
|
+
* any finding on a covered line.
|
|
9
|
+
*
|
|
10
|
+
* ── Runtime child-injection ──
|
|
11
|
+
*
|
|
12
|
+
* Content rules (`anchor-has-content`, `heading-has-content`,
|
|
13
|
+
* `anchor-is-valid`) assume the element they fire on is empty because it has no
|
|
14
|
+
* JSX children. Two widely-used React patterns break that assumption by
|
|
15
|
+
* injecting children at RUNTIME, so the JSX looks childless but the rendered
|
|
16
|
+
* element is not. Both produce the same false positive, from the same cause:
|
|
17
|
+
*
|
|
18
|
+
* 1. react-i18next `<Trans>` interpolation. Elements passed in
|
|
19
|
+
* `components={[...]}` / `components={{ key: <El/> }}` receive the
|
|
20
|
+
* translated string's `<0>…</0>` placeholder text as their children:
|
|
21
|
+
*
|
|
22
|
+
* <Trans defaults="<0>Create an account</0>"
|
|
23
|
+
* components={[<a href="/register" />]} />
|
|
24
|
+
*
|
|
25
|
+
* The `<a/>` is contentful at runtime.
|
|
26
|
+
*
|
|
27
|
+
* 2. Render-delegation props (`render={<El/>}` in base-ui / MUI Base, the value
|
|
28
|
+
* of `asChild` chains in Radix). The host component renders AS the delegated
|
|
29
|
+
* element and forwards ITS OWN children into it:
|
|
30
|
+
*
|
|
31
|
+
* <BreadcrumbLink render={<Link href="/polls" />}>
|
|
32
|
+
* <Icon /> <Trans defaults="Polls" />
|
|
33
|
+
* </BreadcrumbLink>
|
|
34
|
+
*
|
|
35
|
+
* The `<Link/>` (an anchor) renders with the breadcrumb's icon+text children.
|
|
36
|
+
*
|
|
37
|
+
* The fix is semantic, scoped, and false-negative-safe:
|
|
38
|
+
* - We suppress ONLY content-family findings (the rules whose "no children"
|
|
39
|
+
* premise the injection invalidates). ARIA/role/handler findings on the
|
|
40
|
+
* same element still surface.
|
|
41
|
+
* - For render-delegation we suppress ONLY when the host element actually
|
|
42
|
+
* HAS children to inject — a `<Button render={<Link/>} />` with no children
|
|
43
|
+
* is a genuinely empty anchor and must still flag.
|
|
44
|
+
*
|
|
45
|
+
* Triggers are public component/prop APIs, not per-repo conventions: the
|
|
46
|
+
* react-i18next `Trans` component, and the `render` delegation prop. Both reach
|
|
47
|
+
* us as plain JSX names regardless of which module they were imported from.
|
|
48
|
+
*
|
|
49
|
+
* A customer can name THEIR OWN `<Trans>`-like helpers via `binclusive.json`
|
|
50
|
+
* `injectsChildren`. Those names are treated exactly like the built-in `Trans`:
|
|
51
|
+
* their `components={...}` targets are runtime-contentful, AND the helper element
|
|
52
|
+
* ITSELF is treated as contentful (a custom helper typically replaces its own
|
|
53
|
+
* children with the translated string). This is the escape hatch for the same
|
|
54
|
+
* runtime-injection pattern when the helper isn't literally named `Trans`.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* jsx-a11y rules whose finding is invalidated by runtime child injection,
|
|
59
|
+
* because each one's premise is "this element has no text content." Scoped
|
|
60
|
+
* deliberately: ARIA/role/handler rules are NOT here, so an injected element
|
|
61
|
+
* with a real ARIA bug still surfaces.
|
|
62
|
+
*/
|
|
63
|
+
const CONTENT_RULES: ReadonlySet<string> = new Set([
|
|
64
|
+
"jsx-a11y/anchor-has-content",
|
|
65
|
+
"jsx-a11y/anchor-is-valid",
|
|
66
|
+
"jsx-a11y/heading-has-content",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** The JSX component name react-i18next exposes for interpolation. */
|
|
70
|
+
const TRANS_TAG = "Trans";
|
|
71
|
+
/** The prop on `<Trans>` that carries the interpolation target elements. */
|
|
72
|
+
const COMPONENTS_PROP = "components";
|
|
73
|
+
/** The render-delegation prop (base-ui / MUI Base): host renders AS this element. */
|
|
74
|
+
const RENDER_PROP = "render";
|
|
75
|
+
|
|
76
|
+
/** A 1-based source line range, inclusive, of a suppressed element. */
|
|
77
|
+
interface LineRange {
|
|
78
|
+
readonly start: number;
|
|
79
|
+
readonly end: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Scan a source file for every JSX element whose children are injected at
|
|
84
|
+
* runtime — via `<Trans components={...}>` or a `render={<El/>}` delegation on
|
|
85
|
+
* a host element that supplies children. Returns the 1-based line ranges those
|
|
86
|
+
* elements occupy; a content-rule finding on a covered line is a false positive.
|
|
87
|
+
*
|
|
88
|
+
* `injectsChildren` is the customer's list of THEIR OWN `Trans`-like helper
|
|
89
|
+
* names (from `binclusive.json`). Each is treated identically to the built-in
|
|
90
|
+
* `Trans`: its `components={...}` targets are suppressed, and the helper element
|
|
91
|
+
* itself is suppressed (a custom helper replaces its own children at runtime).
|
|
92
|
+
*/
|
|
93
|
+
export function transInjectedLineRanges(
|
|
94
|
+
sf: ts.SourceFile,
|
|
95
|
+
injectsChildren: readonly string[] = [],
|
|
96
|
+
): LineRange[] {
|
|
97
|
+
const ranges: LineRange[] = [];
|
|
98
|
+
const customTags = new Set(injectsChildren);
|
|
99
|
+
|
|
100
|
+
const lineOf = (pos: number): number => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
101
|
+
|
|
102
|
+
const recordElement = (node: ts.Node): void => {
|
|
103
|
+
ranges.push({ start: lineOf(node.getStart(sf)), end: lineOf(node.getEnd()) });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Every JSX element nested under a `<Trans>` `components` value receives
|
|
107
|
+
// injected text, so both `[<a/>]` and `{ a: <a/> }` are captured.
|
|
108
|
+
const collectInjected = (node: ts.Node): void => {
|
|
109
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) recordElement(node);
|
|
110
|
+
ts.forEachChild(node, collectInjected);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The trailing name of a JSX tag (`NS.Member` -> `Member`), else the name. */
|
|
114
|
+
const tagLeafName = (tagName: ts.JsxTagNameExpression): string | null => {
|
|
115
|
+
if (ts.isIdentifier(tagName)) return tagName.text;
|
|
116
|
+
if (ts.isPropertyAccessExpression(tagName)) return tagName.name.text;
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const visit = (node: ts.Node): void => {
|
|
121
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
122
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
123
|
+
const leaf = tagLeafName(opening.tagName);
|
|
124
|
+
const isCustomInjector = leaf !== null && customTags.has(leaf);
|
|
125
|
+
|
|
126
|
+
if (isTransTag(opening.tagName) || isCustomInjector) {
|
|
127
|
+
// A customer's own helper replaces ITS OWN children at runtime, so the
|
|
128
|
+
// helper element itself is contentful — suppress it directly.
|
|
129
|
+
if (isCustomInjector) recordElement(node);
|
|
130
|
+
for (const attr of opening.attributes.properties) {
|
|
131
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
132
|
+
if (attr.name.getText(sf) !== COMPONENTS_PROP) continue;
|
|
133
|
+
const init = attr.initializer;
|
|
134
|
+
if (init !== undefined && ts.isJsxExpression(init) && init.expression !== undefined) {
|
|
135
|
+
collectInjected(init.expression);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Render-delegation: suppress the delegated element only when the host
|
|
141
|
+
// element supplies children for it to render. A childless host means a
|
|
142
|
+
// genuinely empty delegated element — leave that finding alone.
|
|
143
|
+
if (hasContentfulChildren(node)) {
|
|
144
|
+
const delegated = renderPropElement(opening, sf);
|
|
145
|
+
if (delegated !== null) recordElement(delegated);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ts.forEachChild(node, visit);
|
|
150
|
+
};
|
|
151
|
+
visit(sf);
|
|
152
|
+
|
|
153
|
+
return ranges;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Line ranges of JSX elements marked `aria-hidden` (`aria-hidden={true}` or
|
|
158
|
+
* `aria-hidden="true"`). Such an element is removed from the accessibility tree
|
|
159
|
+
* — a screen reader never reaches it — so the content-family rules whose whole
|
|
160
|
+
* premise is "this element is reachable but empty" (`anchor-has-content`,
|
|
161
|
+
* `anchor-is-valid`, `heading-has-content`) simply do not apply to it.
|
|
162
|
+
*
|
|
163
|
+
* Scope is deliberately narrow: ONLY content-family findings are suppressed
|
|
164
|
+
* (via the shared {@link CONTENT_RULES} set + {@link isContentSuppressed}).
|
|
165
|
+
* Everything else on an aria-hidden element — bad ARIA props, role mismatches,
|
|
166
|
+
* interactive-without-handler — still surfaces, because hiding from the a11y
|
|
167
|
+
* tree doesn't make those correct. And only `aria-hidden` set to a literal
|
|
168
|
+
* truthy value counts: `aria-hidden={false}` / `aria-hidden={someVar}` are NOT
|
|
169
|
+
* suppressed, so a dynamically-shown element keeps flagging.
|
|
170
|
+
*/
|
|
171
|
+
export function ariaHiddenLineRanges(sf: ts.SourceFile): LineRange[] {
|
|
172
|
+
const ranges: LineRange[] = [];
|
|
173
|
+
const lineOf = (pos: number): number => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
174
|
+
|
|
175
|
+
const visit = (node: ts.Node): void => {
|
|
176
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
177
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
178
|
+
if (hasTruthyAriaHidden(opening, sf)) {
|
|
179
|
+
ranges.push({ start: lineOf(node.getStart(sf)), end: lineOf(node.getEnd()) });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
ts.forEachChild(node, visit);
|
|
183
|
+
};
|
|
184
|
+
visit(sf);
|
|
185
|
+
return ranges;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Line ranges of JSX elements whose children are forwarded through a `{...props}`
|
|
190
|
+
* (or `{...rest}`) SPREAD, so the content-family rules cannot prove the element
|
|
191
|
+
* is empty. This is the design-system primitive shape jsx-a11y is structurally
|
|
192
|
+
* blind to:
|
|
193
|
+
*
|
|
194
|
+
* const AlertTitle = forwardRef(({ className, ...props }, ref) => (
|
|
195
|
+
* <h5 ref={ref} className={cn(...)} {...props} /> // children arrive via {...props}
|
|
196
|
+
* ))
|
|
197
|
+
* const TabLink = ({ ...props }) => <a className="…" {...props} />
|
|
198
|
+
*
|
|
199
|
+
* The `<h5/>` / `<a/>` look childless in the JSX, so `heading-has-content` /
|
|
200
|
+
* `anchor-has-content` fire — but the consumer passes the heading text / link
|
|
201
|
+
* label as children, which flow in through the spread. Every shadcn/Radix-style
|
|
202
|
+
* primitive that wraps a native `<h1-6>` / `<a>` and forwards props hits this,
|
|
203
|
+
* turning the checker's own "find bugs inside your design system" surface into a
|
|
204
|
+
* wall of false CRITICALs.
|
|
205
|
+
*
|
|
206
|
+
* Same scope + safety contract as the runtime-injection ranges above: ONLY
|
|
207
|
+
* content-family findings (via {@link isContentSuppressed}) are suppressed, so an
|
|
208
|
+
* ARIA/role/handler bug on the same spread element still surfaces. A spread can
|
|
209
|
+
* carry `children` (or `dangerouslySetInnerHTML`); we cannot prove it doesn't, so
|
|
210
|
+
* the "empty element" premise is unprovable here — exactly as it is for a
|
|
211
|
+
* `render={<El/>}` delegation — and the content-family finding is withheld rather
|
|
212
|
+
* than asserted as a false positive.
|
|
213
|
+
*
|
|
214
|
+
* Recorded only when the element has NO static contentful children of its own: an
|
|
215
|
+
* element that already spells out children needs no spread to be contentful and
|
|
216
|
+
* never fired a content finding anyway, so leaving it unrecorded keeps the range
|
|
217
|
+
* set tight.
|
|
218
|
+
*
|
|
219
|
+
* Scoped to CONTENT-RULE CANDIDATES so the range set stays small: the suppressed
|
|
220
|
+
* {@link CONTENT_RULES} fire only on `<a>` / `<h1-6>`, so a spread on a `<div>` /
|
|
221
|
+
* `<button>` / `<td>` can never have a content finding to suppress. We keep the
|
|
222
|
+
* intrinsic `a` / `h1-6` tags AND any capitalized component — a component may
|
|
223
|
+
* resolve to `a` / a heading via the jsx-a11y `components` map (a shadcn
|
|
224
|
+
* `<Link {...props}/>` is reported as an anchor), and this pass can't see that
|
|
225
|
+
* map, so excluding components would re-open those false positives.
|
|
226
|
+
*/
|
|
227
|
+
function isContentRuleCandidate(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): boolean {
|
|
228
|
+
const name = opening.tagName;
|
|
229
|
+
const tag = ts.isIdentifier(name)
|
|
230
|
+
? name.text
|
|
231
|
+
: ts.isPropertyAccessExpression(name)
|
|
232
|
+
? name.name.text
|
|
233
|
+
: null;
|
|
234
|
+
if (tag === null) return false;
|
|
235
|
+
return /^[A-Z]/.test(tag) || tag === "a" || /^h[1-6]$/.test(tag);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function spreadChildrenLineRanges(sf: ts.SourceFile): LineRange[] {
|
|
239
|
+
const ranges: LineRange[] = [];
|
|
240
|
+
const lineOf = (pos: number): number => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
241
|
+
|
|
242
|
+
const visit = (node: ts.Node): void => {
|
|
243
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
244
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
245
|
+
const hasSpread = opening.attributes.properties.some((p) => ts.isJsxSpreadAttribute(p));
|
|
246
|
+
if (hasSpread && isContentRuleCandidate(opening) && !hasContentfulChildren(node)) {
|
|
247
|
+
ranges.push({ start: lineOf(node.getStart(sf)), end: lineOf(node.getEnd()) });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
ts.forEachChild(node, visit);
|
|
251
|
+
};
|
|
252
|
+
visit(sf);
|
|
253
|
+
return ranges;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The `aria-hidden` attribute name. */
|
|
257
|
+
const ARIA_HIDDEN_PROP = "aria-hidden";
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether an opening element carries `aria-hidden` set to a literal truthy
|
|
261
|
+
* value: bare (`<a aria-hidden>`), `aria-hidden={true}`, or `aria-hidden="true"`.
|
|
262
|
+
* A literal `false`, or any dynamic expression, returns false — we only suppress
|
|
263
|
+
* when the element is unconditionally hidden from the a11y tree.
|
|
264
|
+
*/
|
|
265
|
+
function hasTruthyAriaHidden(
|
|
266
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
267
|
+
sf: ts.SourceFile,
|
|
268
|
+
): boolean {
|
|
269
|
+
for (const attr of opening.attributes.properties) {
|
|
270
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
271
|
+
if (attr.name.getText(sf) !== ARIA_HIDDEN_PROP) continue;
|
|
272
|
+
const init = attr.initializer;
|
|
273
|
+
// Bare `aria-hidden` (no initializer) is `true`.
|
|
274
|
+
if (init === undefined) return true;
|
|
275
|
+
// `aria-hidden="true"`.
|
|
276
|
+
if (ts.isStringLiteral(init)) return init.text === "true";
|
|
277
|
+
// `aria-hidden={...}`.
|
|
278
|
+
if (ts.isJsxExpression(init) && init.expression !== undefined) {
|
|
279
|
+
const expr = init.expression;
|
|
280
|
+
if (expr.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
281
|
+
if (expr.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
282
|
+
// `aria-hidden={"true"}`.
|
|
283
|
+
if (ts.isStringLiteral(expr)) return expr.text === "true";
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Whether a JSX tag-name node is the react-i18next `Trans` component. */
|
|
291
|
+
function isTransTag(tagName: ts.JsxTagNameExpression): boolean {
|
|
292
|
+
if (ts.isIdentifier(tagName)) return tagName.text === TRANS_TAG;
|
|
293
|
+
if (ts.isPropertyAccessExpression(tagName)) return tagName.name.text === TRANS_TAG;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The JSX element passed as a `render={<El/>}` delegation prop on `opening`, or
|
|
299
|
+
* `null` when there is no such prop. The host will render AS this element.
|
|
300
|
+
*/
|
|
301
|
+
function renderPropElement(
|
|
302
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
303
|
+
sf: ts.SourceFile,
|
|
304
|
+
): ts.JsxElement | ts.JsxSelfClosingElement | null {
|
|
305
|
+
for (const attr of opening.attributes.properties) {
|
|
306
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
307
|
+
if (attr.name.getText(sf) !== RENDER_PROP) continue;
|
|
308
|
+
const init = attr.initializer;
|
|
309
|
+
if (init === undefined || !ts.isJsxExpression(init) || init.expression === undefined) continue;
|
|
310
|
+
const expr = init.expression;
|
|
311
|
+
if (ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)) return expr;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Whether a JSX element has contentful children written directly in the JSX —
|
|
318
|
+
* non-whitespace text, a nested element/fragment, or an expression child; a
|
|
319
|
+
* self-closing element has none. Two callers, same predicate: render-delegation
|
|
320
|
+
* uses it as the false-negative guard (only a child-bearing HOST can make an
|
|
321
|
+
* otherwise-empty delegated anchor contentful), and the spread suppression uses
|
|
322
|
+
* its inverse (an element with its own children needs no spread to be contentful,
|
|
323
|
+
* so it is not a spread-injection candidate).
|
|
324
|
+
*/
|
|
325
|
+
function hasContentfulChildren(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
|
|
326
|
+
if (!ts.isJsxElement(node)) return false;
|
|
327
|
+
return node.children.some((child) => {
|
|
328
|
+
if (ts.isJsxText(child)) return child.text.trim() !== "";
|
|
329
|
+
if (ts.isJsxExpression(child)) return child.expression !== undefined;
|
|
330
|
+
return ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Decide whether a content-family finding at `line` is suppressed: true iff the
|
|
336
|
+
* rule is a content-family rule (its "empty element" premise) AND the line falls
|
|
337
|
+
* inside one of the supplied ranges. Both suppression sources share this gate:
|
|
338
|
+
*
|
|
339
|
+
* - runtime child injection ({@link transInjectedLineRanges}) — the element
|
|
340
|
+
* gets content at render time, so "empty" is wrong.
|
|
341
|
+
* - `aria-hidden` ({@link ariaHiddenLineRanges}) — the element is out of the
|
|
342
|
+
* a11y tree, so "empty link/heading" doesn't apply.
|
|
343
|
+
*
|
|
344
|
+
* Non-content rules are NEVER suppressed, regardless of range — only the
|
|
345
|
+
* content-family premise is invalidated by these two patterns.
|
|
346
|
+
*/
|
|
347
|
+
export function isContentSuppressed(
|
|
348
|
+
ruleId: string,
|
|
349
|
+
line: number,
|
|
350
|
+
ranges: readonly LineRange[],
|
|
351
|
+
): boolean {
|
|
352
|
+
if (!CONTENT_RULES.has(ruleId)) return false;
|
|
353
|
+
return ranges.some((r) => line >= r.start && line <= r.end);
|
|
354
|
+
}
|