@binclusive/a11y 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,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
+ }