@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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { ResolvedHost } from "./enforce";
|
|
3
|
+
import { isToggleRole } from "./registry";
|
|
4
|
+
import { collectLocalImports, type ImportBinding } from "./source-trace";
|
|
5
|
+
import {
|
|
6
|
+
isHiddenOrUntabbable,
|
|
7
|
+
isNameExemptInputType,
|
|
8
|
+
walkAncestorSuppressors,
|
|
9
|
+
} from "./suppressors";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The deterministic G3 input (RFC Phase 1, §"The FP discipline").
|
|
13
|
+
*
|
|
14
|
+
* `buildSuppressorMap` runs the SAME ancestor walk `enforceContent` runs — the
|
|
15
|
+
* shared {@link walkAncestorSuppressors} descent that decides `hasLabelAncestor`
|
|
16
|
+
* / `hasNameAncestor` — and records, per JSX line, WHICH of the static floor's
|
|
17
|
+
* suppressor predicates ({@link src/suppressors}) apply there. The map is the
|
|
18
|
+
* floor's hard-won precision, re-expressed as data the corpus-recall layer can
|
|
19
|
+
* (a) feed the model so it self-suppresses and (b) enforce server-side so a
|
|
20
|
+
* grounded-but-misclassified finding on a suppressed line is vetoed.
|
|
21
|
+
*
|
|
22
|
+
* It REUSES the suppressor predicates verbatim — it never re-implements them —
|
|
23
|
+
* so any drift in the floor's suppression logic ripples here automatically. The
|
|
24
|
+
* RESOLVED-HOST skips enforce performs (a traced/registry toggle role, a wrapper
|
|
25
|
+
* that renders its own name, a `type`-exempt INPUT host) need the resolved-host
|
|
26
|
+
* map enforce uses, so it is threaded in: the same `${name}@${module}` lookup
|
|
27
|
+
* {@link buildResolvedHosts} builds. It emits no findings and has no side
|
|
28
|
+
* effects: a pure `(source, resolved hosts) → line → names` read.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The suppressor predicates, named. The string IS the wire name fed to G3. */
|
|
32
|
+
export type SuppressorName =
|
|
33
|
+
| "label-ancestor"
|
|
34
|
+
| "name-injecting-wrapper"
|
|
35
|
+
| "hidden-untabbable"
|
|
36
|
+
| "name-exempt-input-type"
|
|
37
|
+
| "toggle-role"
|
|
38
|
+
| "renders-own-name";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A per-line view of which suppressors apply. Keyed by 1-based line number (the
|
|
42
|
+
* same line a finding anchors to — an element's opening-tag line). The value is
|
|
43
|
+
* the set of suppressor names live AT that line:
|
|
44
|
+
*
|
|
45
|
+
* - `label-ancestor` / `name-injecting-wrapper` are ANCESTOR suppressors — a
|
|
46
|
+
* line carries them when an enclosing `<label>` / form-field grouping
|
|
47
|
+
* (`isLabelContainer`) or a titled `<Tooltip>` (`isNameInjectingWrapper`) is
|
|
48
|
+
* an ancestor of the element on that line. They mirror the `labelDepth` /
|
|
49
|
+
* `nameAncestorDepth` the shared {@link walkAncestorSuppressors} threads down.
|
|
50
|
+
* - `hidden-untabbable` / `name-exempt-input-type` / `toggle-role` /
|
|
51
|
+
* `renders-own-name` are ELEMENT-LOCAL — the predicate (or the element's
|
|
52
|
+
* resolved host) holds for the element opening ON that line.
|
|
53
|
+
*
|
|
54
|
+
* One line can carry several (an exempt input under a label). Lines with no live
|
|
55
|
+
* suppressor are absent, so `map.get(line) ?? EMPTY` is the safe read.
|
|
56
|
+
*/
|
|
57
|
+
export type SuppressorMap = ReadonlyMap<number, ReadonlySet<SuppressorName>>;
|
|
58
|
+
|
|
59
|
+
/** A toggle role on the element, mirroring `enforce`'s `isToggleRole` skip. */
|
|
60
|
+
const TOGGLE_ROLES: ReadonlySet<string> = new Set(["checkbox", "switch", "radio"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the element opening carries a STATIC toggle `role` (`checkbox` /
|
|
64
|
+
* `switch` / `radio`). The enforce pass skips toggles reached via a traced/
|
|
65
|
+
* registry host whose `role` is a toggle (captured by the resolved-host lookup
|
|
66
|
+
* below); here we ALSO read the role straight off the call-site JSX so the map
|
|
67
|
+
* captures the call-site-visible toggle case. A dynamic `role={x}` is not
|
|
68
|
+
* asserted (uncertain → not a suppressor we can name).
|
|
69
|
+
*/
|
|
70
|
+
function hasToggleRole(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement, sf: ts.SourceFile): boolean {
|
|
71
|
+
for (const attr of opening.attributes.properties) {
|
|
72
|
+
if (!ts.isJsxAttribute(attr) || attr.name.getText(sf) !== "role") continue;
|
|
73
|
+
const init = attr.initializer;
|
|
74
|
+
let value: string | null = null;
|
|
75
|
+
if (init !== undefined && ts.isStringLiteral(init)) value = init.text.trim().toLowerCase();
|
|
76
|
+
else if (
|
|
77
|
+
init !== undefined &&
|
|
78
|
+
ts.isJsxExpression(init) &&
|
|
79
|
+
init.expression !== undefined &&
|
|
80
|
+
ts.isStringLiteral(init.expression)
|
|
81
|
+
) {
|
|
82
|
+
value = init.expression.text.trim().toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
if (value !== null && TOGGLE_ROLES.has(value)) return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The bare lowercase intrinsic tag of an opening element, or null (a component). */
|
|
90
|
+
function intrinsicTag(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): string | null {
|
|
91
|
+
const tag = opening.tagName;
|
|
92
|
+
return ts.isIdentifier(tag) && /^[a-z]/.test(tag.text) ? tag.text : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The flattened JSX tag (`Button`, `NS.Member`), or null if not a name we key on. */
|
|
96
|
+
function tagNameOf(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): string | null {
|
|
97
|
+
const tag = opening.tagName;
|
|
98
|
+
if (ts.isIdentifier(tag)) return tag.text;
|
|
99
|
+
if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression)) {
|
|
100
|
+
return `${tag.expression.text}.${tag.name.text}`;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The resolved host enforce would see for this call site, or null. Mirrors
|
|
107
|
+
* `enforce.classify`'s step 1: key the resolved-host map by `(jsx tag, import
|
|
108
|
+
* module)` so a same-named export from another module can never lend its host.
|
|
109
|
+
*/
|
|
110
|
+
function resolvedHostOf(
|
|
111
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
112
|
+
imports: ReadonlyMap<string, ImportBinding>,
|
|
113
|
+
resolvedHosts: ReadonlyMap<string, ResolvedHost>,
|
|
114
|
+
): ResolvedHost | null {
|
|
115
|
+
const tag = tagNameOf(opening);
|
|
116
|
+
if (tag === null) return null;
|
|
117
|
+
const local = tag.includes(".") ? tag.slice(0, tag.indexOf(".")) : tag;
|
|
118
|
+
const binding = imports.get(local);
|
|
119
|
+
if (binding === undefined) return null;
|
|
120
|
+
return resolvedHosts.get(`${tag}@${binding.module}`) ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build the per-line suppressor map for one source file. Mirrors the
|
|
125
|
+
* `enforceContent` ancestor walk via the shared {@link walkAncestorSuppressors}
|
|
126
|
+
* (label / name-injecting depth), and reads the element-local predicates — plus
|
|
127
|
+
* the RESOLVED-HOST skips enforce performs — off each opening:
|
|
128
|
+
*
|
|
129
|
+
* - `name-exempt-input-type` is emitted ONLY for a form intrinsic
|
|
130
|
+
* (`input`/`select`/`textarea`) OR a component that RESOLVES to an `input`
|
|
131
|
+
* host — the exact gate enforce applies (`isNameExemptInputType` runs only on
|
|
132
|
+
* an input host). A capitalized non-input component is never marked.
|
|
133
|
+
* - `toggle-role` / `renders-own-name` cover the resolved-host toggle and
|
|
134
|
+
* own-name skips (a Radix Checkbox traced to `button[role=checkbox]`, a
|
|
135
|
+
* shadcn wrapper that renders its own `sr-only` name) that call-site syntax
|
|
136
|
+
* alone cannot see.
|
|
137
|
+
*/
|
|
138
|
+
export function buildSuppressorMap(
|
|
139
|
+
sf: ts.SourceFile,
|
|
140
|
+
resolvedHosts: ReadonlyMap<string, ResolvedHost> = new Map(),
|
|
141
|
+
): SuppressorMap {
|
|
142
|
+
const out = new Map<number, Set<SuppressorName>>();
|
|
143
|
+
const imports = collectLocalImports(sf);
|
|
144
|
+
|
|
145
|
+
const add = (line: number, name: SuppressorName): void => {
|
|
146
|
+
let set = out.get(line);
|
|
147
|
+
if (set === undefined) {
|
|
148
|
+
set = new Set<SuppressorName>();
|
|
149
|
+
out.set(line, set);
|
|
150
|
+
}
|
|
151
|
+
set.add(name);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
walkAncestorSuppressors(sf, ({ opening, line, flags }) => {
|
|
155
|
+
// Ancestor suppressors: live if an ENCLOSING container set the depth.
|
|
156
|
+
if (flags.hasLabelAncestor) add(line, "label-ancestor");
|
|
157
|
+
if (flags.hasNameAncestor) add(line, "name-injecting-wrapper");
|
|
158
|
+
// Element-local suppressors read straight off the opening.
|
|
159
|
+
if (isHiddenOrUntabbable(opening, sf)) add(line, "hidden-untabbable");
|
|
160
|
+
|
|
161
|
+
const resolved = resolvedHostOf(opening, imports, resolvedHosts);
|
|
162
|
+
// `type`-exemption is only meaningful for a real form control — a form
|
|
163
|
+
// intrinsic, OR a component enforce RESOLVES to an `input` host. Enforce runs
|
|
164
|
+
// `isNameExemptInputType` only there; a capitalized non-input component is
|
|
165
|
+
// NEVER marked (the prior over-broad `tag === null` gate was finding #3).
|
|
166
|
+
const tag = intrinsicTag(opening);
|
|
167
|
+
const isFormIntrinsic = tag === "input" || tag === "select" || tag === "textarea";
|
|
168
|
+
// enforce treats input/select/textarea hosts all as ControlType "input" (its
|
|
169
|
+
// HOST_TO_TYPE), so mirror the full set, not just "input", to provably match.
|
|
170
|
+
const isInputHost =
|
|
171
|
+
resolved !== null &&
|
|
172
|
+
(resolved.host === "input" || resolved.host === "select" || resolved.host === "textarea");
|
|
173
|
+
if ((isFormIntrinsic || isInputHost) && isNameExemptInputType(opening, sf)) {
|
|
174
|
+
add(line, "name-exempt-input-type");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Toggle: a call-site `role="checkbox|switch|radio"`, OR a resolved host
|
|
178
|
+
// whose role is a toggle (Radix Checkbox → button[role=checkbox]) — the same
|
|
179
|
+
// skip enforce performs via `isToggleRole(resolved.role)`.
|
|
180
|
+
if (hasToggleRole(opening, sf) || (resolved !== null && isToggleRole(resolved.role))) {
|
|
181
|
+
add(line, "toggle-role");
|
|
182
|
+
}
|
|
183
|
+
// A wrapper that renders its host an internal STATIC name is named even when
|
|
184
|
+
// the call site looks empty — enforce skips it (`resolved.rendersOwnName`).
|
|
185
|
+
if (resolved !== null && resolved.rendersOwnName) add(line, "renders-own-name");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The static floor's shared SUPPRESSOR predicates.
|
|
5
|
+
*
|
|
6
|
+
* Per ADR 0003 ("A Deterministic Shell Around Every Stochastic Capability"),
|
|
7
|
+
* the static floor's suppressors live in their own module so a later phase can
|
|
8
|
+
* reuse them, unchanged, as a precision PRE-FILTER on a corpus-grounded recall
|
|
9
|
+
* layer. Each predicate answers a single "uncertain → skip" question about one
|
|
10
|
+
* JSX element (is its `type` exempt? is it hidden? is it a label/name-injecting
|
|
11
|
+
* container?), built on the small name-reading primitives below
|
|
12
|
+
* ({@link attrState}, {@link anyNameAttr}). Keep these FN-safe and side-effect
|
|
13
|
+
* free: a recall layer composes them, so any behavior drift here ripples.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Native `<input>` `type` values that exempt it from the name check. submit /
|
|
17
|
+
* button / reset are named by their `value`; hidden / image are not text-name-
|
|
18
|
+
* bearing (an image input's name is alt's job); checkbox / radio are externally
|
|
19
|
+
* labelled toggles, skipped exactly as {@link TOGGLE_NAMES} are. A DYNAMIC
|
|
20
|
+
* `type={x}` is unknowable, so — uncertain → skip — it is exempt too. A MISSING
|
|
21
|
+
* `type` defaults to `"text"` and is NOT exempt: a bare text input must be named.
|
|
22
|
+
*/
|
|
23
|
+
const NAME_EXEMPT_INPUT_TYPES: ReadonlySet<string> = new Set([
|
|
24
|
+
"hidden",
|
|
25
|
+
"submit",
|
|
26
|
+
"button",
|
|
27
|
+
"reset",
|
|
28
|
+
"image",
|
|
29
|
+
"checkbox",
|
|
30
|
+
"radio",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** The accessible-name attributes that, if present/dynamic, satisfy a control. */
|
|
34
|
+
export const LABEL_ATTRS = ["aria-label", "aria-labelledby"] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `attr` is present AND carries a NON-EMPTY value we can read statically.
|
|
38
|
+
* A dynamic expression (`aria-label={x}`) counts as present-and-unknowable, so
|
|
39
|
+
* we treat it as "could be a name" — conservatism: never flag when uncertain.
|
|
40
|
+
* Returns `"missing" | "present" | "dynamic"`.
|
|
41
|
+
*/
|
|
42
|
+
export type AttrState = "missing" | "present" | "dynamic";
|
|
43
|
+
|
|
44
|
+
export function attrState(
|
|
45
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
46
|
+
sf: ts.SourceFile,
|
|
47
|
+
attrName: string,
|
|
48
|
+
): AttrState {
|
|
49
|
+
for (const attr of opening.attributes.properties) {
|
|
50
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
51
|
+
if (attr.name.getText(sf) !== attrName) continue;
|
|
52
|
+
const init = attr.initializer;
|
|
53
|
+
// Bare attribute (`hidden`) — present, treated as a (truthy) value.
|
|
54
|
+
if (init === undefined) return "present";
|
|
55
|
+
if (ts.isStringLiteral(init)) return init.text.trim() === "" ? "missing" : "present";
|
|
56
|
+
if (ts.isJsxExpression(init)) {
|
|
57
|
+
const expr = init.expression;
|
|
58
|
+
if (expr === undefined) return "missing"; // `aria-label={}`
|
|
59
|
+
if (ts.isStringLiteral(expr)) return expr.text.trim() === "" ? "missing" : "present";
|
|
60
|
+
// Any other expression is dynamic/computed — unknowable, so "could name it".
|
|
61
|
+
return "dynamic";
|
|
62
|
+
}
|
|
63
|
+
return "dynamic";
|
|
64
|
+
}
|
|
65
|
+
return "missing";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether ANY of the named attributes resolves a name (present or dynamic). */
|
|
69
|
+
export function anyNameAttr(
|
|
70
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
71
|
+
sf: ts.SourceFile,
|
|
72
|
+
names: readonly string[],
|
|
73
|
+
): boolean {
|
|
74
|
+
return names.some((n) => attrState(opening, sf, n) !== "missing");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether an input's `type` exempts it from the name check (see
|
|
79
|
+
* {@link NAME_EXEMPT_INPUT_TYPES}). A static exempt value or a dynamic
|
|
80
|
+
* `type={x}` (unknowable → skip) exempts; a missing or non-exempt static `type`
|
|
81
|
+
* does not. Only meaningful for inputs — `<select>`/`<textarea>` carry no `type`,
|
|
82
|
+
* so this is always `false` for them (they are always checked).
|
|
83
|
+
*/
|
|
84
|
+
export function isNameExemptInputType(
|
|
85
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
86
|
+
sf: ts.SourceFile,
|
|
87
|
+
): boolean {
|
|
88
|
+
for (const attr of opening.attributes.properties) {
|
|
89
|
+
if (!ts.isJsxAttribute(attr) || attr.name.getText(sf) !== "type") continue;
|
|
90
|
+
const init = attr.initializer;
|
|
91
|
+
if (init === undefined) return false; // bare `type` — degenerate, treat as text
|
|
92
|
+
if (ts.isStringLiteral(init)) return NAME_EXEMPT_INPUT_TYPES.has(init.text.trim().toLowerCase());
|
|
93
|
+
if (ts.isJsxExpression(init)) {
|
|
94
|
+
const expr = init.expression;
|
|
95
|
+
if (expr !== undefined && ts.isStringLiteral(expr)) {
|
|
96
|
+
return NAME_EXEMPT_INPUT_TYPES.has(expr.text.trim().toLowerCase());
|
|
97
|
+
}
|
|
98
|
+
return true; // `type={x}` — unknowable, exempt (uncertain → skip)
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
return false; // no `type` → defaults to "text" → checked
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether a control is statically HIDDEN or removed from the tab order, so an
|
|
107
|
+
* absent label is not a real finding (uncertain → skip, FN-safe):
|
|
108
|
+
* - `tabIndex={-1}` / `tabIndex="-1"` — not keyboard-reachable in normal flow;
|
|
109
|
+
* in practice a hidden sentinel (react-select's required-field `<input>`) or
|
|
110
|
+
* a programmatically-focused target, externally driven, not a typed control;
|
|
111
|
+
* - the HTML `hidden` attribute (bare or `={true}`) — not rendered;
|
|
112
|
+
* - a `display:none` utility class (the standalone `hidden` token, Tailwind &
|
|
113
|
+
* co.) — removed from the accessibility tree, so it is never announced.
|
|
114
|
+
* This mirrors the wide-sample false positives the native-control path would
|
|
115
|
+
* otherwise produce (~7%): all six were one of these three shapes.
|
|
116
|
+
*/
|
|
117
|
+
export function isHiddenOrUntabbable(
|
|
118
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
119
|
+
sf: ts.SourceFile,
|
|
120
|
+
): boolean {
|
|
121
|
+
for (const attr of opening.attributes.properties) {
|
|
122
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
123
|
+
const name = attr.name.getText(sf);
|
|
124
|
+
const init = attr.initializer;
|
|
125
|
+
if (name === "hidden") {
|
|
126
|
+
if (init === undefined) return true; // bare `hidden`
|
|
127
|
+
if (ts.isJsxExpression(init) && init.expression?.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (name === "tabIndex" && init !== undefined) {
|
|
131
|
+
if (init.getText(sf).replace(/[{}"'\s]/g, "") === "-1") return true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (name === "className" || name === "class") {
|
|
135
|
+
let str: string | null = null;
|
|
136
|
+
if (init !== undefined && ts.isStringLiteral(init)) str = init.text;
|
|
137
|
+
else if (init !== undefined && ts.isJsxExpression(init) && init.expression !== undefined && ts.isStringLiteral(init.expression)) {
|
|
138
|
+
str = init.expression.text;
|
|
139
|
+
}
|
|
140
|
+
if (str !== null && /(^|\s)hidden(\s|$)/.test(str)) return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a JSX element is (or renders) a LABEL container — so an input nested
|
|
148
|
+
* under it likely gets its name from that label and must NOT be flagged:
|
|
149
|
+
*
|
|
150
|
+
* - intrinsic `<label>`;
|
|
151
|
+
* - `<X as="label">` / `<X component="label">` (Saleor `Box as="label"`, MUI);
|
|
152
|
+
* - a component whose leaf name ends with `Label` (`FormLabel`, `InputLabel`);
|
|
153
|
+
* - a form-field grouping (`FormItem`/`FormControl`/`FormField`/`FormGroup`)
|
|
154
|
+
* — the react-hook-form / shadcn / MUI convention that pairs a label with
|
|
155
|
+
* the control it wraps. Recognizing the GROUP is conservative: it suppresses
|
|
156
|
+
* even when the label sibling is rendered conditionally or further out.
|
|
157
|
+
*/
|
|
158
|
+
export function isLabelContainer(
|
|
159
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
160
|
+
sf: ts.SourceFile,
|
|
161
|
+
): boolean {
|
|
162
|
+
const tag = opening.tagName;
|
|
163
|
+
if (ts.isIdentifier(tag) && tag.text === "label") return true;
|
|
164
|
+
// `as`/`component` polymorphic prop set to the string "label".
|
|
165
|
+
for (const attr of opening.attributes.properties) {
|
|
166
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
167
|
+
const n = attr.name.getText(sf);
|
|
168
|
+
if (n !== "as" && n !== "component") continue;
|
|
169
|
+
const init = attr.initializer;
|
|
170
|
+
if (init !== undefined && ts.isStringLiteral(init) && init.text === "label") return true;
|
|
171
|
+
if (
|
|
172
|
+
init !== undefined &&
|
|
173
|
+
ts.isJsxExpression(init) &&
|
|
174
|
+
init.expression !== undefined &&
|
|
175
|
+
ts.isStringLiteral(init.expression) &&
|
|
176
|
+
init.expression.text === "label"
|
|
177
|
+
) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const leaf = ts.isPropertyAccessExpression(tag)
|
|
182
|
+
? tag.name.text
|
|
183
|
+
: ts.isIdentifier(tag)
|
|
184
|
+
? tag.text
|
|
185
|
+
: "";
|
|
186
|
+
if (leaf.endsWith("Label")) return true;
|
|
187
|
+
return /^(Form(Item|Control|Field|Group)|Field)$/.test(leaf);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether an element is a NAME-INJECTING wrapper for its single child control: a
|
|
192
|
+
* design-system `<Tooltip>` carrying a `title` (or `aria-label`). MUI / antd /
|
|
193
|
+
* Mantine Tooltips clone their child and set the `title` as the child's
|
|
194
|
+
* `aria-label` by default (MUI: `describeChild=false` ⇒ "the title acts as an
|
|
195
|
+
* accessible label for the child"). So a nested icon-only `<IconButton>` /
|
|
196
|
+
* `<Button>` / `<Link>` is NAMED at runtime even though the call site shows no
|
|
197
|
+
* `aria-label` — the actionable-control analogue of an input under a `<label>`.
|
|
198
|
+
*
|
|
199
|
+
* Matched on the LEAF name `Tooltip` (so `Tooltip`, `MyTooltip`, `Tooltip.Root`
|
|
200
|
+
* all qualify) AND only when a `title`/`aria-label` is actually present — a
|
|
201
|
+
* Tooltip with no title injects no name, so it must not suppress. A bare
|
|
202
|
+
* `describeChild` Tooltip (title → description, not name) is the rare opposite;
|
|
203
|
+
* we accept the small false-negative risk there in exchange for killing the
|
|
204
|
+
* dominant, idiomatic titled-Tooltip false positive.
|
|
205
|
+
*/
|
|
206
|
+
export function isNameInjectingWrapper(
|
|
207
|
+
opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
|
|
208
|
+
sf: ts.SourceFile,
|
|
209
|
+
): boolean {
|
|
210
|
+
const tag = opening.tagName;
|
|
211
|
+
const leaf = ts.isPropertyAccessExpression(tag)
|
|
212
|
+
? tag.name.text
|
|
213
|
+
: ts.isIdentifier(tag)
|
|
214
|
+
? tag.text
|
|
215
|
+
: "";
|
|
216
|
+
if (!leaf.endsWith("Tooltip")) return false;
|
|
217
|
+
// The title must actually be there to inject a name (present or dynamic).
|
|
218
|
+
return attrState(opening, sf, "title") !== "missing" || anyNameAttr(opening, sf, LABEL_ATTRS);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The enclosing-ancestor suppressor state at one JSX element (see {@link walkAncestorSuppressors}). */
|
|
222
|
+
export interface AncestorFlags {
|
|
223
|
+
/** An enclosing `<label>` / form-field grouping (`isLabelContainer`) is an ancestor. */
|
|
224
|
+
readonly hasLabelAncestor: boolean;
|
|
225
|
+
/** An enclosing titled `<Tooltip>` (`isNameInjectingWrapper`) is an ancestor. */
|
|
226
|
+
readonly hasNameAncestor: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The per-element callback driven by {@link walkAncestorSuppressors}. */
|
|
230
|
+
export type AncestorVisitor = (args: {
|
|
231
|
+
readonly node: ts.JsxElement | ts.JsxSelfClosingElement;
|
|
232
|
+
readonly opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement;
|
|
233
|
+
/** 1-based opening-tag line — the same line a finding/abstention anchors to. */
|
|
234
|
+
readonly line: number;
|
|
235
|
+
/** Whether a label / name-injecting CONTAINER ENCLOSES this element (not itself). */
|
|
236
|
+
readonly flags: AncestorFlags;
|
|
237
|
+
}) => void;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The ONE ancestor-suppressor tree walk, shared by `enforceContent` and
|
|
241
|
+
* `buildSuppressorMap` so they cannot drift (the two used to keep divergent
|
|
242
|
+
* copies of this `labelDepth` / `nameAncestorDepth` descent). It pushes a depth
|
|
243
|
+
* counter as a label container (`isLabelContainer`) or a name-injecting wrapper
|
|
244
|
+
* (`isNameInjectingWrapper`) is entered and pops it on exit, so every descendant
|
|
245
|
+
* element learns it is UNDER one — the container element itself does NOT count as
|
|
246
|
+
* its own ancestor. For each JSX element it invokes `visitor` with the element,
|
|
247
|
+
* its opening, its 1-based line, and the enclosing-ancestor {@link AncestorFlags}.
|
|
248
|
+
*/
|
|
249
|
+
export function walkAncestorSuppressors(sf: ts.SourceFile, visitor: AncestorVisitor): void {
|
|
250
|
+
let labelDepth = 0;
|
|
251
|
+
let nameAncestorDepth = 0;
|
|
252
|
+
|
|
253
|
+
const visit = (node: ts.Node): void => {
|
|
254
|
+
let enteredLabel = false;
|
|
255
|
+
let enteredNameAncestor = false;
|
|
256
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
257
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
258
|
+
if (isLabelContainer(opening, sf)) {
|
|
259
|
+
labelDepth++;
|
|
260
|
+
enteredLabel = true;
|
|
261
|
+
}
|
|
262
|
+
if (isNameInjectingWrapper(opening, sf)) {
|
|
263
|
+
nameAncestorDepth++;
|
|
264
|
+
enteredNameAncestor = true;
|
|
265
|
+
}
|
|
266
|
+
const line = sf.getLineAndCharacterOfPosition(opening.getStart(sf)).line + 1;
|
|
267
|
+
// The element opening a container is not its OWN ancestor — only its
|
|
268
|
+
// descendants are — so subtract the entry just made for this element.
|
|
269
|
+
visitor({
|
|
270
|
+
node,
|
|
271
|
+
opening,
|
|
272
|
+
line,
|
|
273
|
+
flags: {
|
|
274
|
+
hasLabelAncestor: labelDepth - (enteredLabel ? 1 : 0) > 0,
|
|
275
|
+
hasNameAncestor: nameAncestorDepth - (enteredNameAncestor ? 1 : 0) > 0,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
ts.forEachChild(node, visit);
|
|
280
|
+
if (enteredLabel) labelDepth--;
|
|
281
|
+
if (enteredNameAncestor) nameAncestorDepth--;
|
|
282
|
+
};
|
|
283
|
+
visit(sf);
|
|
284
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tsconfig path-alias reader: tell whether an import specifier is the repo's
|
|
3
|
+
* OWN code reached through a `compilerOptions.paths` alias, rather than a
|
|
4
|
+
* third-party design system.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists. Design-system detection ranks the package that contributes
|
|
7
|
+
* the most host-resolving wrappers. A repo that imports its OWN UI through a
|
|
8
|
+
* project alias — Saleor `@dashboard/* -> src/*`, Cal.com `@coss/ui/* ->
|
|
9
|
+
* ../../packages/coss-ui/src/*` — looks like a published `@scope/pkg` to the
|
|
10
|
+
* naive `isOwnModule` check, so the alias wins the ranking over the real
|
|
11
|
+
* third-party library. Semantically an alias that maps INTO the repo's own
|
|
12
|
+
* source is not a design system; it is the team's own components.
|
|
13
|
+
*
|
|
14
|
+
* The signal is the alias TARGET: a target that is a relative path (`src/*`,
|
|
15
|
+
* `../../packages/.../src/*`) points at source the team controls. A target that
|
|
16
|
+
* is itself a bare package name (`@scope/pkg/*`, `node_modules/...`) is a
|
|
17
|
+
* re-pointer to an external dependency and is left alone. We resolve `extends`
|
|
18
|
+
* chains (bounded) and honor `baseUrl`, exactly as the customer's own build
|
|
19
|
+
* does — never a hardcoded per-repo list.
|
|
20
|
+
*
|
|
21
|
+
* Everything is best-effort + boundary-parsed: a missing/malformed tsconfig, an
|
|
22
|
+
* unresolvable `extends`, or a target we can't classify all degrade to "not an
|
|
23
|
+
* own-alias" — the caller then treats the import as a candidate design system,
|
|
24
|
+
* the pre-existing behavior.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { dirname, resolve, sep } from "node:path";
|
|
28
|
+
import ts from "typescript";
|
|
29
|
+
|
|
30
|
+
/** A compiled alias: the literal prefix of a `paths` key (before any `*`). */
|
|
31
|
+
interface AliasPrefix {
|
|
32
|
+
/** The key text up to the `*` (e.g. `@dashboard/`, `@coss/ui/`), or the whole key when no `*`. */
|
|
33
|
+
readonly prefix: string;
|
|
34
|
+
/** Whether the key had a trailing `*` (so it matches by prefix, not exactly). */
|
|
35
|
+
readonly wildcard: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A repo's own-alias matcher, derived once per tsconfig directory. */
|
|
39
|
+
export interface OwnAliasMatcher {
|
|
40
|
+
/** True iff `specifier` is reached through an alias that maps into own source. */
|
|
41
|
+
readonly isOwnAlias: (specifier: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NEVER: OwnAliasMatcher = { isOwnAlias: () => false };
|
|
45
|
+
|
|
46
|
+
const matcherCache = new Map<string, OwnAliasMatcher>();
|
|
47
|
+
|
|
48
|
+
/** Find the nearest `tsconfig.json` at or above `dir`, or `null`. */
|
|
49
|
+
function findTsconfig(dir: string): string | null {
|
|
50
|
+
return ts.findConfigFile(dir, ts.sys.fileExists, "tsconfig.json") ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The effective `{ baseUrl, paths }` for a tsconfig, with its `extends` chain
|
|
55
|
+
* already merged. We let TypeScript itself parse the file — it handles JSONC
|
|
56
|
+
* (comments, trailing commas), resolves `extends` (including shared-config
|
|
57
|
+
* packages under `node_modules`), and merges `paths`/`baseUrl` exactly as the
|
|
58
|
+
* customer's build does. A hand-rolled JSON stripper would diverge from real TS
|
|
59
|
+
* resolution; reusing the compiler API is the in-stack primitive. Returns
|
|
60
|
+
* `null` when the file can't be read/parsed at all.
|
|
61
|
+
*/
|
|
62
|
+
interface EffectivePaths {
|
|
63
|
+
/** Absolute dir the `paths` targets resolve against (baseUrl, else config dir). */
|
|
64
|
+
readonly baseDir: string;
|
|
65
|
+
/** The merged `paths` map (alias key -> target strings). */
|
|
66
|
+
readonly paths: Readonly<Record<string, readonly string[]>>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readEffectivePaths(configPath: string): EffectivePaths | null {
|
|
70
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
71
|
+
if (read.config === undefined || read.config === null) return null;
|
|
72
|
+
const configDir = dirname(configPath);
|
|
73
|
+
// parseJsonConfigFileContent follows `extends` and merges compilerOptions.
|
|
74
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, configDir);
|
|
75
|
+
const opts = parsed.options;
|
|
76
|
+
// baseUrl is resolved to an absolute path by the parser; `paths` keys/targets
|
|
77
|
+
// are relative to it (TS semantics). When no baseUrl is set TS still resolves
|
|
78
|
+
// `paths` against the config dir, so fall back to that.
|
|
79
|
+
const baseDir = typeof opts.baseUrl === "string" ? opts.baseUrl : configDir;
|
|
80
|
+
const paths: Record<string, readonly string[]> = {};
|
|
81
|
+
if (opts.paths !== undefined) {
|
|
82
|
+
for (const [key, targets] of Object.entries(opts.paths)) {
|
|
83
|
+
if (Array.isArray(targets)) {
|
|
84
|
+
paths[key] = targets.filter((t): t is string => typeof t === "string");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { baseDir, paths };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether an alias TARGET points into the repo's own source. A relative path
|
|
93
|
+
* (`src/*`, `../../packages/x/src/*`) is own source; a target that itself
|
|
94
|
+
* begins with a bare package name or a `node_modules/` segment is a re-pointer
|
|
95
|
+
* to an external dependency, not own code. The target is resolved against
|
|
96
|
+
* `baseDir` and rejected if it climbs into a `node_modules` directory.
|
|
97
|
+
*/
|
|
98
|
+
function targetIsOwnSource(target: string, baseDir: string): boolean {
|
|
99
|
+
// Strip the trailing `/*` (or `*`) wildcard for classification.
|
|
100
|
+
const cleaned = target.replace(/\*+$/, "").replace(/\/$/, "");
|
|
101
|
+
// A target that routes through node_modules is an external re-point.
|
|
102
|
+
if (cleaned.includes("node_modules")) return false;
|
|
103
|
+
const abs = resolve(baseDir, cleaned);
|
|
104
|
+
return !`${abs}${sep}`.includes(`${sep}node_modules${sep}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Compile a `paths` key into its literal prefix + wildcard flag. */
|
|
108
|
+
function compileAliasKey(key: string): AliasPrefix {
|
|
109
|
+
const star = key.indexOf("*");
|
|
110
|
+
if (star === -1) return { prefix: key, wildcard: false };
|
|
111
|
+
return { prefix: key.slice(0, star), wildcard: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the own-alias matcher for the tsconfig governing `fromDir`. Cached per
|
|
116
|
+
* directory so each repo's config is parsed once. Returns a matcher that always
|
|
117
|
+
* answers `false` when there is no usable tsconfig — the caller then keeps its
|
|
118
|
+
* pre-existing own-code heuristics.
|
|
119
|
+
*/
|
|
120
|
+
export function ownAliasMatcherFor(fromDir: string): OwnAliasMatcher {
|
|
121
|
+
const cached = matcherCache.get(fromDir);
|
|
122
|
+
if (cached !== undefined) return cached;
|
|
123
|
+
|
|
124
|
+
const configPath = findTsconfig(fromDir);
|
|
125
|
+
if (configPath === null) {
|
|
126
|
+
matcherCache.set(fromDir, NEVER);
|
|
127
|
+
return NEVER;
|
|
128
|
+
}
|
|
129
|
+
const effective = readEffectivePaths(configPath);
|
|
130
|
+
if (effective === null) {
|
|
131
|
+
matcherCache.set(fromDir, NEVER);
|
|
132
|
+
return NEVER;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Keep only the aliases whose target maps into own source — those are the
|
|
136
|
+
// ones that must be excluded from design-system ranking.
|
|
137
|
+
const ownPrefixes: AliasPrefix[] = [];
|
|
138
|
+
for (const [key, targets] of Object.entries(effective.paths)) {
|
|
139
|
+
if (targets.some((t) => targetIsOwnSource(t, effective.baseDir))) {
|
|
140
|
+
ownPrefixes.push(compileAliasKey(key));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const isOwnAlias = (specifier: string): boolean =>
|
|
145
|
+
ownPrefixes.some((p) => (p.wildcard ? specifier.startsWith(p.prefix) : specifier === p.prefix));
|
|
146
|
+
|
|
147
|
+
const matcher: OwnAliasMatcher = { isOwnAlias };
|
|
148
|
+
matcherCache.set(fromDir, matcher);
|
|
149
|
+
return matcher;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Reset the matcher cache — test-only, so fixtures don't bleed across cases. */
|
|
153
|
+
export function __resetAliasCacheForTest(): void {
|
|
154
|
+
matcherCache.clear();
|
|
155
|
+
}
|