@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,814 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { resolveImportsSubpath } from "./imports-resolve";
|
|
4
|
+
import { lookupRegistry } from "./registry";
|
|
5
|
+
import { resolveWorkspaceImport } from "./workspace-resolve";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Source-tracing fallback: resolve a wrapper component to the single host
|
|
9
|
+
* primitive it renders, by reading its definition from source.
|
|
10
|
+
*
|
|
11
|
+
* This is what makes the checker work on codebases we have never seen. For any
|
|
12
|
+
* wrapper NOT in the registry, we resolve its import to a source file, parse
|
|
13
|
+
* it, find the component definition, and answer: (a) does it render exactly one
|
|
14
|
+
* host element, and (b) does it forward its props? If both hold, we infer the
|
|
15
|
+
* mapping. Everything ambiguous returns null -> the component is reported
|
|
16
|
+
* OPAQUE rather than mis-mapped. Honesty over coverage.
|
|
17
|
+
*
|
|
18
|
+
* Resolution uses the *containing file's own tsconfig* (paths / baseUrl /
|
|
19
|
+
* exports), i.e. exactly the resolution the customer's build uses — never a
|
|
20
|
+
* hardcoded package layout.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const HOST_TAG = /^[a-z]/; // lowercase JSX name = intrinsic host element
|
|
24
|
+
|
|
25
|
+
/** How many wrapper hops we follow before giving up (wrapper-of-wrapper). */
|
|
26
|
+
const MAX_DEPTH = 3;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The Radix module whose `Slot` is the polymorphic pass-through primitive. The
|
|
30
|
+
* shadcn convention `asChild ? <Slot {...props}/> : <button {...props}/>` makes
|
|
31
|
+
* `Slot` a TRANSPARENT branch: when `asChild` is set the wrapper renders AS its
|
|
32
|
+
* own child, so the only fixed accessible host it can be is the OTHER branch
|
|
33
|
+
* (`button`). We identify it by its import origin, never by the bare name
|
|
34
|
+
* `Slot` — a repo can name something else `Slot`, and only the Radix one has
|
|
35
|
+
* this pass-through semantics.
|
|
36
|
+
*/
|
|
37
|
+
const RADIX_SLOT_MODULE = "@radix-ui/react-slot";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* tsconfig-aware module resolver, cached per containing-directory so we parse
|
|
41
|
+
* each tsconfig once. Mirrors how the customer's own toolchain resolves
|
|
42
|
+
* imports.
|
|
43
|
+
*/
|
|
44
|
+
function makeResolver(): (specifier: string, fromFile: string) => string | null {
|
|
45
|
+
const optionsCache = new Map<string, ts.CompilerOptions>();
|
|
46
|
+
const baseOptions: ts.CompilerOptions = {
|
|
47
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
48
|
+
module: ts.ModuleKind.ESNext,
|
|
49
|
+
allowJs: true,
|
|
50
|
+
};
|
|
51
|
+
const host = ts.createCompilerHost(baseOptions);
|
|
52
|
+
|
|
53
|
+
function optionsFor(fromFile: string): ts.CompilerOptions {
|
|
54
|
+
const dir = dirname(fromFile);
|
|
55
|
+
const cfgPath = ts.findConfigFile(dir, ts.sys.fileExists, "tsconfig.json");
|
|
56
|
+
const cacheKey = cfgPath ?? "<none>";
|
|
57
|
+
const cached = optionsCache.get(cacheKey);
|
|
58
|
+
if (cached !== undefined) return cached;
|
|
59
|
+
|
|
60
|
+
let opts = baseOptions;
|
|
61
|
+
if (cfgPath !== undefined) {
|
|
62
|
+
const read = ts.readConfigFile(cfgPath, ts.sys.readFile);
|
|
63
|
+
if (read.config !== undefined) {
|
|
64
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(cfgPath));
|
|
65
|
+
// Keep the customer's `baseUrl` + `paths` (so `@/...` aliases resolve)
|
|
66
|
+
// but NEVER let their `moduleResolution` downgrade ours. Many real
|
|
67
|
+
// configs omit it or set a legacy value ("None"/"Classic"); honoring
|
|
68
|
+
// that would disable `node_modules` and `exports` resolution entirely,
|
|
69
|
+
// making every package import opaque. We always resolve with Bundler.
|
|
70
|
+
opts = {
|
|
71
|
+
...parsed.options,
|
|
72
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
73
|
+
module: ts.ModuleKind.ESNext,
|
|
74
|
+
allowJs: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
optionsCache.set(cacheKey, opts);
|
|
79
|
+
return opts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (specifier, fromFile) => {
|
|
83
|
+
const opts = optionsFor(fromFile);
|
|
84
|
+
const resolved = ts.resolveModuleName(specifier, fromFile, opts, host);
|
|
85
|
+
return resolved.resolvedModule?.resolvedFileName ?? null;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const resolve = makeResolver();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve an import specifier to a source file path, relative to `fromFile`.
|
|
93
|
+
*
|
|
94
|
+
* Three layers, in order:
|
|
95
|
+
* 1. tsconfig-aware TS resolution — handles relative imports, `@/...` path
|
|
96
|
+
* aliases, and packages present in an installed `node_modules`.
|
|
97
|
+
* 2. workspace resolution — follows a `@scope/pkg/subpath` import to the real
|
|
98
|
+
* source under `packages/*` via the monorepo's workspace config, for the
|
|
99
|
+
* common case where the design system is an un-built workspace package
|
|
100
|
+
* (and may not be symlinked into `node_modules` at all).
|
|
101
|
+
* 3. package.json `imports` subpaths — follows a `#`-prefixed internal import
|
|
102
|
+
* (`#app/components/button`) to own-code source via the nearest
|
|
103
|
+
* package.json's `imports` map. TS resolves a `#`-import only when it
|
|
104
|
+
* carries an explicit extension; the bare extensionless form (the common
|
|
105
|
+
* one) falls through to here, the third own-code alias source alongside
|
|
106
|
+
* tsconfig `paths`.
|
|
107
|
+
*
|
|
108
|
+
* Returns `null` only when no layer can reach real source (a bare external
|
|
109
|
+
* dependency with no types on disk, etc.) — the caller then keeps the wrapper
|
|
110
|
+
* opaque rather than guessing.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveRoute(specifier: string, fromFile: string): string | null {
|
|
113
|
+
const viaTs = resolve(specifier, fromFile);
|
|
114
|
+
if (viaTs !== null) return viaTs;
|
|
115
|
+
const viaWorkspace = resolveWorkspaceImport(specifier, fromFile);
|
|
116
|
+
if (viaWorkspace !== null) return viaWorkspace;
|
|
117
|
+
return resolveImportsSubpath(specifier, fromFile);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What a local JSX name was imported as. */
|
|
121
|
+
export interface ImportBinding {
|
|
122
|
+
/** Module specifier it came from. */
|
|
123
|
+
readonly module: string;
|
|
124
|
+
/**
|
|
125
|
+
* The exported name being referenced. For `import { Button }` -> "Button";
|
|
126
|
+
* for `import X` (default) -> "default"; for `import * as NS` the binding is
|
|
127
|
+
* the namespace and `member` carries the accessed member (`NS.Root`).
|
|
128
|
+
*/
|
|
129
|
+
readonly imported: string;
|
|
130
|
+
/** Namespace import flag; member access (`NS.Root`) is resolved by the caller. */
|
|
131
|
+
readonly isNamespace: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fileCache = new Map<string, ts.SourceFile | null>();
|
|
135
|
+
|
|
136
|
+
function readSource(filePath: string): ts.SourceFile | null {
|
|
137
|
+
const cached = fileCache.get(filePath);
|
|
138
|
+
if (cached !== undefined) return cached;
|
|
139
|
+
const text = ts.sys.readFile(filePath);
|
|
140
|
+
const sf =
|
|
141
|
+
text === undefined
|
|
142
|
+
? null
|
|
143
|
+
: ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
144
|
+
fileCache.set(filePath, sf);
|
|
145
|
+
return sf;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Collect every imported binding in a source file, keyed by the LOCAL name as
|
|
150
|
+
* used in JSX. Namespace imports key on the namespace identifier; the resolver
|
|
151
|
+
* pairs them with the accessed member.
|
|
152
|
+
*/
|
|
153
|
+
export function collectLocalImports(sf: ts.SourceFile): Map<string, ImportBinding> {
|
|
154
|
+
const out = new Map<string, ImportBinding>();
|
|
155
|
+
for (const stmt of sf.statements) {
|
|
156
|
+
if (!ts.isImportDeclaration(stmt)) continue;
|
|
157
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
158
|
+
const module = stmt.moduleSpecifier.text;
|
|
159
|
+
const clause = stmt.importClause;
|
|
160
|
+
if (clause === undefined) continue;
|
|
161
|
+
|
|
162
|
+
// Default import: `import X from "mod"`
|
|
163
|
+
if (clause.name !== undefined) {
|
|
164
|
+
out.set(clause.name.text, { module, imported: "default", isNamespace: false });
|
|
165
|
+
}
|
|
166
|
+
const bindings = clause.namedBindings;
|
|
167
|
+
if (bindings === undefined) continue;
|
|
168
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
169
|
+
// `import * as NS from "mod"`
|
|
170
|
+
out.set(bindings.name.text, { module, imported: "*", isNamespace: true });
|
|
171
|
+
} else if (ts.isNamedImports(bindings)) {
|
|
172
|
+
// `import { A, B as C } from "mod"`
|
|
173
|
+
for (const el of bindings.elements) {
|
|
174
|
+
const local = el.name.text;
|
|
175
|
+
const imported = el.propertyName?.text ?? local;
|
|
176
|
+
out.set(local, { module, imported, isNamespace: false });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Local JSX names that render AS their child (carry no fixed host), drawn from
|
|
185
|
+
* a file's import map. Today that is exactly the Radix `Slot`, identified by
|
|
186
|
+
* its import from {@link RADIX_SLOT_MODULE} — never by the bare name. Both the
|
|
187
|
+
* named (`import { Slot } from "@radix-ui/react-slot"`) and namespace
|
|
188
|
+
* (`import * as SlotPrimitive from "@radix-ui/react-slot"` → `SlotPrimitive.Root`)
|
|
189
|
+
* spellings reduce to the local identifier the JSX tag uses, so the tag set can
|
|
190
|
+
* test membership directly.
|
|
191
|
+
*/
|
|
192
|
+
function transparentLocalNames(imports: ReadonlyMap<string, ImportBinding>): ReadonlySet<string> {
|
|
193
|
+
const out = new Set<string>();
|
|
194
|
+
for (const [local, binding] of imports) {
|
|
195
|
+
if (binding.module === RADIX_SLOT_MODULE) out.add(local);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Result of tracing a wrapper: the host primitive plus how we found it, and the
|
|
202
|
+
* explicit ARIA `role` the host carries when one is statically set.
|
|
203
|
+
*
|
|
204
|
+
* `role` is the library's INTERNAL role on the rendered host — captured from a
|
|
205
|
+
* static `role="…"` string literal on the traced host JSX, or supplied by a
|
|
206
|
+
* registry rule for a known toggle primitive (Radix `Checkbox` → `button` with
|
|
207
|
+
* `role="checkbox"`). It exists so a traced toggle (host `button`/`input`, role
|
|
208
|
+
* `checkbox`/`switch`/`radio`) is not mistaken for a bare button/input
|
|
209
|
+
* downstream. Absent (a dynamic or missing role) ⇒ the host's implicit role;
|
|
210
|
+
* nothing changes. Only toggle roles are acted on by consumers.
|
|
211
|
+
*/
|
|
212
|
+
export interface TraceResult {
|
|
213
|
+
readonly host: string;
|
|
214
|
+
readonly via: "registry" | "trace";
|
|
215
|
+
readonly role?: string;
|
|
216
|
+
/**
|
|
217
|
+
* Whether the wrapper's own render body gives the host a STATIC accessible
|
|
218
|
+
* name — captured by scanning the returned JSX for a literal `aria-label`/
|
|
219
|
+
* `aria-labelledby` on the host, an `sr-only` / visually-hidden span with
|
|
220
|
+
* static text, or static (non-icon) text children. The name lives INSIDE the
|
|
221
|
+
* wrapper, invisible at the self-closing call site, so a host-strength control
|
|
222
|
+
* with no call-site name is NOT actually nameless — downstream skips the
|
|
223
|
+
* no-name check, exactly as a toggle role is skipped.
|
|
224
|
+
*
|
|
225
|
+
* Conservative: only a CLEARLY static name sets it true; anything uncertain
|
|
226
|
+
* (dynamic expression, icon-only child) leaves it false. A registry hit has no
|
|
227
|
+
* source to scan, so it is always false (registries map only leaf primitives).
|
|
228
|
+
* False-negative-safe — it can only ADD suppression, never a new finding.
|
|
229
|
+
*/
|
|
230
|
+
readonly rendersOwnName: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The JSX element a component ultimately renders, plus whether it spreads
|
|
235
|
+
* props. `tag` is the literal/identifier of the single returned root element.
|
|
236
|
+
* `role` is a STATIC `role="…"` string literal on the single host element, when
|
|
237
|
+
* one is set — threaded out so a homegrown `<button role="checkbox">` toggle is
|
|
238
|
+
* recognized as a toggle, not a bare button.
|
|
239
|
+
*/
|
|
240
|
+
interface RenderShape {
|
|
241
|
+
/** Distinct root element tag names returned across all return paths. */
|
|
242
|
+
readonly tags: ReadonlySet<string>;
|
|
243
|
+
readonly spreadsProps: boolean;
|
|
244
|
+
/** Static `role` literal on the (single) host element, or `null`. */
|
|
245
|
+
readonly role: string | null;
|
|
246
|
+
/**
|
|
247
|
+
* Whether the (single) host element is given a STATIC accessible name by the
|
|
248
|
+
* wrapper's own render body — a literal `aria-label`/`aria-labelledby`, an
|
|
249
|
+
* `sr-only`/visually-hidden span with static text, or static non-icon text
|
|
250
|
+
* children. Conservative: only a clear static name sets it true.
|
|
251
|
+
*/
|
|
252
|
+
readonly rendersOwnName: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The static `role` string literal on an opening element, or `null` for an
|
|
257
|
+
* absent, empty, or DYNAMIC role (`role={x}`). Only a literal counts — a dynamic
|
|
258
|
+
* role is unknowable, so per "uncertain → skip" it changes nothing.
|
|
259
|
+
*/
|
|
260
|
+
function staticRoleOf(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): string | null {
|
|
261
|
+
for (const attr of opening.attributes.properties) {
|
|
262
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
263
|
+
// `role` is a plain identifier attribute, never an `ns:name` form.
|
|
264
|
+
if (!ts.isIdentifier(attr.name) || attr.name.text !== "role") continue;
|
|
265
|
+
const init = attr.initializer;
|
|
266
|
+
if (init === undefined) return null;
|
|
267
|
+
if (ts.isStringLiteral(init)) return init.text.trim() === "" ? null : init.text;
|
|
268
|
+
if (
|
|
269
|
+
ts.isJsxExpression(init) &&
|
|
270
|
+
init.expression !== undefined &&
|
|
271
|
+
ts.isStringLiteral(init.expression)
|
|
272
|
+
) {
|
|
273
|
+
return init.expression.text.trim() === "" ? null : init.expression.text;
|
|
274
|
+
}
|
|
275
|
+
return null; // dynamic/computed role — unknowable
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Whether the opening element carries a STATIC, non-empty `aria-label` or
|
|
282
|
+
* `aria-labelledby` string literal — a directly-readable accessible name on the
|
|
283
|
+
* host. A dynamic value (`aria-label={x}`) is unknowable, so per "uncertain →
|
|
284
|
+
* skip" it does NOT count as a captured static name here.
|
|
285
|
+
*/
|
|
286
|
+
function hasStaticAriaName(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): boolean {
|
|
287
|
+
for (const attr of opening.attributes.properties) {
|
|
288
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
289
|
+
if (!ts.isIdentifier(attr.name)) continue;
|
|
290
|
+
if (attr.name.text !== "aria-label" && attr.name.text !== "aria-labelledby") continue;
|
|
291
|
+
const init = attr.initializer;
|
|
292
|
+
if (init === undefined) continue; // boolean-ish attr, no value to read
|
|
293
|
+
if (ts.isStringLiteral(init)) {
|
|
294
|
+
if (init.text.trim() !== "") return true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
ts.isJsxExpression(init) &&
|
|
299
|
+
init.expression !== undefined &&
|
|
300
|
+
ts.isStringLiteral(init.expression) &&
|
|
301
|
+
init.expression.text.trim() !== ""
|
|
302
|
+
) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
// dynamic / computed — unknowable, keep looking for another static name
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Visually-hidden utility class names that carry an accessible name in text. */
|
|
311
|
+
const VISUALLY_HIDDEN_CLASS = /(^|\s)(sr-only|visually-hidden|visuallyhidden|screen-reader-only)(\s|$)/;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Whether an element's `className` is a static string literal containing a
|
|
315
|
+
* visually-hidden utility class (`sr-only`, `visually-hidden`, …). Only a
|
|
316
|
+
* literal counts — a dynamic `className={cn(...)}` is unknowable.
|
|
317
|
+
*/
|
|
318
|
+
function hasVisuallyHiddenClass(opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement): boolean {
|
|
319
|
+
for (const attr of opening.attributes.properties) {
|
|
320
|
+
if (!ts.isJsxAttribute(attr)) continue;
|
|
321
|
+
if (!ts.isIdentifier(attr.name) || attr.name.text !== "className") continue;
|
|
322
|
+
const init = attr.initializer;
|
|
323
|
+
if (init === undefined) return false;
|
|
324
|
+
if (ts.isStringLiteral(init)) return VISUALLY_HIDDEN_CLASS.test(init.text);
|
|
325
|
+
if (
|
|
326
|
+
ts.isJsxExpression(init) &&
|
|
327
|
+
init.expression !== undefined &&
|
|
328
|
+
ts.isStringLiteral(init.expression)
|
|
329
|
+
) {
|
|
330
|
+
return VISUALLY_HIDDEN_CLASS.test(init.expression.text);
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Whether a JSX child is a static, non-empty text node. */
|
|
338
|
+
function isStaticTextChild(child: ts.JsxChild): boolean {
|
|
339
|
+
return ts.isJsxText(child) && child.text.trim() !== "";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Whether an element renders an internal STATIC accessible name. Scans the
|
|
344
|
+
* element's direct children for:
|
|
345
|
+
* - a nested element with a visually-hidden class (`<span className="sr-only">
|
|
346
|
+
* Previous slide</span>`) that carries static text, OR
|
|
347
|
+
* - a direct static text child (`<button>Save</button>`) that is real,
|
|
348
|
+
* visible label text.
|
|
349
|
+
* An icon-only child (`<ChevronLeft/>`) or a dynamic expression child carries no
|
|
350
|
+
* static name and is ignored. Conservative by construction: anything unreadable
|
|
351
|
+
* leaves the result false.
|
|
352
|
+
*/
|
|
353
|
+
export function rendersStaticNameInChildren(element: ts.JsxElement): boolean {
|
|
354
|
+
for (const child of element.children) {
|
|
355
|
+
if (isStaticTextChild(child)) return true;
|
|
356
|
+
if (ts.isJsxElement(child)) {
|
|
357
|
+
const opening = child.openingElement;
|
|
358
|
+
// A visually-hidden span whose text is static → that text is the name.
|
|
359
|
+
if (hasVisuallyHiddenClass(opening) && child.children.some(isStaticTextChild)) return true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether the host element (and its subtree) is given a clear STATIC accessible
|
|
367
|
+
* name by the wrapper: a literal `aria-label`/`aria-labelledby` on the host, or
|
|
368
|
+
* a static name rendered in its children (sr-only span / visible text). Used
|
|
369
|
+
* only for an actual JSX ELEMENT host; a self-closing host has no children and
|
|
370
|
+
* relies on the aria attributes alone.
|
|
371
|
+
*/
|
|
372
|
+
function rendersOwnNameOf(node: ts.JsxElement | ts.JsxSelfClosingElement): boolean {
|
|
373
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
374
|
+
if (hasStaticAriaName(opening)) return true;
|
|
375
|
+
if (ts.isJsxElement(node)) return rendersStaticNameInChildren(node);
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Find the JSX element name of a returned/rendered expression. */
|
|
380
|
+
function jsxTagOf(expr: ts.Expression): {
|
|
381
|
+
tag: string | null;
|
|
382
|
+
spreads: boolean;
|
|
383
|
+
role: string | null;
|
|
384
|
+
rendersOwnName: boolean;
|
|
385
|
+
} {
|
|
386
|
+
let node: ts.Node = expr;
|
|
387
|
+
// Unwrap parens.
|
|
388
|
+
while (ts.isParenthesizedExpression(node)) node = node.expression;
|
|
389
|
+
|
|
390
|
+
let opening: ts.JsxOpeningElement | ts.JsxSelfClosingElement | null = null;
|
|
391
|
+
let element: ts.JsxElement | ts.JsxSelfClosingElement | null = null;
|
|
392
|
+
if (ts.isJsxElement(node)) {
|
|
393
|
+
opening = node.openingElement;
|
|
394
|
+
element = node;
|
|
395
|
+
} else if (ts.isJsxSelfClosingElement(node)) {
|
|
396
|
+
opening = node;
|
|
397
|
+
element = node;
|
|
398
|
+
}
|
|
399
|
+
if (opening === null || element === null) {
|
|
400
|
+
return { tag: null, spreads: false, role: null, rendersOwnName: false };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const tagNode = opening.tagName;
|
|
404
|
+
const tag = ts.isIdentifier(tagNode)
|
|
405
|
+
? tagNode.text
|
|
406
|
+
: ts.isPropertyAccessExpression(tagNode) && ts.isIdentifier(tagNode.expression)
|
|
407
|
+
? // `LabelPrimitive.Root` -> keep the namespaced form so the tracer can
|
|
408
|
+
// resolve the namespace import AND know which member was rendered.
|
|
409
|
+
`${tagNode.expression.text}.${tagNode.name.text}`
|
|
410
|
+
: null;
|
|
411
|
+
|
|
412
|
+
const spreads = opening.attributes.properties.some((p) => ts.isJsxSpreadAttribute(p));
|
|
413
|
+
return { tag, spreads, role: staticRoleOf(opening), rendersOwnName: rendersOwnNameOf(element) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Walk a function/arrow body and collect the JSX root tags it returns and
|
|
418
|
+
* whether any of them spread props. Handles:
|
|
419
|
+
* - arrow with expression body: `(props) => <x {...props}/>`
|
|
420
|
+
* - block body with `return <x .../>`
|
|
421
|
+
* - `const Comp = cond ? "tag" : Other; return <Comp .../>` — resolves the
|
|
422
|
+
* variable to its string-literal host when one branch is a literal tag.
|
|
423
|
+
*
|
|
424
|
+
* `transparentTags` are local names that render AS their child and therefore
|
|
425
|
+
* carry no fixed host (the Radix `Slot`). They are dropped from the collected
|
|
426
|
+
* set, so the canonical shadcn `asChild ? <Slot/> : <button/>` collapses from
|
|
427
|
+
* `{Slot, button}` to `{button}` and resolves instead of going opaque. A
|
|
428
|
+
* GENUINELY composite component (`{div, span}` with no Slot) keeps both tags
|
|
429
|
+
* and stays opaque — only the pass-through primitive is removed.
|
|
430
|
+
*/
|
|
431
|
+
function renderShapeOf(
|
|
432
|
+
fn: ts.FunctionLikeDeclaration,
|
|
433
|
+
transparentTags: ReadonlySet<string>,
|
|
434
|
+
): RenderShape {
|
|
435
|
+
const tags = new Set<string>();
|
|
436
|
+
let spreadsProps = false;
|
|
437
|
+
// Static `role` literal on a recorded host element, when exactly one is seen.
|
|
438
|
+
// Stays meaningful only for a single-host shape (the conservative gate), which
|
|
439
|
+
// is the only shape the tracer resolves anyway.
|
|
440
|
+
let role: string | null = null;
|
|
441
|
+
// Whether the recorded host branch gives the host a static accessible name in
|
|
442
|
+
// the wrapper's own body. Like `role`, meaningful only for the single-host
|
|
443
|
+
// shape we resolve. Sticky-true once any host branch renders a name.
|
|
444
|
+
let rendersOwnName = false;
|
|
445
|
+
// Local `const Comp = ... "tag" ...` literal hosts, so `<Comp/>` resolves.
|
|
446
|
+
const localHostVars = new Map<string, string>();
|
|
447
|
+
|
|
448
|
+
const body = fn.body;
|
|
449
|
+
if (body === undefined) return { tags, spreadsProps, role, rendersOwnName };
|
|
450
|
+
|
|
451
|
+
function recordExpr(expr: ts.Expression): void {
|
|
452
|
+
const { tag, spreads, role: exprRole, rendersOwnName: exprName } = jsxTagOf(expr);
|
|
453
|
+
if (tag === null) return;
|
|
454
|
+
// Resolve a local polymorphic variable (`Comp`) to its literal host.
|
|
455
|
+
const resolved = localHostVars.get(tag) ?? tag;
|
|
456
|
+
// A transparent pass-through (Radix Slot) carries no host of its own —
|
|
457
|
+
// skip it so the sibling host branch is the wrapper's resolved host. The
|
|
458
|
+
// namespace form `<SlotPrimitive.Root/>` keys on the namespace local
|
|
459
|
+
// (before the dot), so test that too.
|
|
460
|
+
const localOfTag = resolved.includes(".") ? resolved.slice(0, resolved.indexOf(".")) : resolved;
|
|
461
|
+
if (transparentTags.has(resolved) || transparentTags.has(localOfTag)) {
|
|
462
|
+
if (spreads) spreadsProps = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
tags.add(resolved);
|
|
466
|
+
// Carry the role of the (host) branch. With a single host — the only shape
|
|
467
|
+
// we resolve — there is exactly one role to carry.
|
|
468
|
+
if (exprRole !== null) role = exprRole;
|
|
469
|
+
// Carry whether the (host) branch renders its own static accessible name.
|
|
470
|
+
if (exprName) rendersOwnName = true;
|
|
471
|
+
if (spreads) spreadsProps = true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Arrow with expression body.
|
|
475
|
+
if (!ts.isBlock(body)) {
|
|
476
|
+
recordExpr(body);
|
|
477
|
+
return { tags, spreadsProps, role, rendersOwnName };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Collect local host-variable assignments first (e.g. asChild ? Slot : "button").
|
|
481
|
+
for (const stmt of body.statements) {
|
|
482
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
483
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
484
|
+
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) continue;
|
|
485
|
+
const init = decl.initializer;
|
|
486
|
+
if (ts.isConditionalExpression(init)) {
|
|
487
|
+
for (const branch of [init.whenTrue, init.whenFalse]) {
|
|
488
|
+
if (ts.isStringLiteral(branch)) localHostVars.set(decl.name.text, branch.text);
|
|
489
|
+
}
|
|
490
|
+
} else if (ts.isStringLiteral(init)) {
|
|
491
|
+
localHostVars.set(decl.name.text, init.text);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Then collect returned JSX.
|
|
497
|
+
const visit = (node: ts.Node): void => {
|
|
498
|
+
if (ts.isReturnStatement(node) && node.expression !== undefined) {
|
|
499
|
+
recordExpr(node.expression);
|
|
500
|
+
}
|
|
501
|
+
// Do not descend into nested function bodies — their returns aren't ours.
|
|
502
|
+
if (ts.isFunctionLike(node) && node !== fn) return;
|
|
503
|
+
ts.forEachChild(node, visit);
|
|
504
|
+
};
|
|
505
|
+
for (const stmt of body.statements) visit(stmt);
|
|
506
|
+
|
|
507
|
+
return { tags, spreadsProps, role, rendersOwnName };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Extract the component function for an exported name from a source file. */
|
|
511
|
+
function findComponentFn(sf: ts.SourceFile, exportName: string): ts.FunctionLikeDeclaration | null {
|
|
512
|
+
let result: ts.FunctionLikeDeclaration | null = null;
|
|
513
|
+
|
|
514
|
+
const target = exportName === "default" ? null : exportName;
|
|
515
|
+
|
|
516
|
+
for (const stmt of sf.statements) {
|
|
517
|
+
// `export function Name() {}` or `function Name() {}`
|
|
518
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) {
|
|
519
|
+
if (target !== null && stmt.name.text === target) return stmt;
|
|
520
|
+
}
|
|
521
|
+
// `const Name = (...) => ...` or `const Name = forwardRef(...)`
|
|
522
|
+
if (ts.isVariableStatement(stmt)) {
|
|
523
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
524
|
+
if (!ts.isIdentifier(decl.name) || decl.initializer === undefined) continue;
|
|
525
|
+
if (target !== null && decl.name.text !== target) continue;
|
|
526
|
+
const fn = unwrapToFunction(decl.initializer);
|
|
527
|
+
if (fn !== null) {
|
|
528
|
+
if (target !== null) return fn;
|
|
529
|
+
result = fn;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Where an export name is RE-EXPORTED from, resolved from a barrel file. A
|
|
539
|
+
* monorepo design system commonly publishes a barrel
|
|
540
|
+
* (`@calcom/ui/components/button` -> `index.ts`) that only re-exports:
|
|
541
|
+
*
|
|
542
|
+
* export { Button } from "./Button"; // named, possibly `as`
|
|
543
|
+
* export * from "@calcom/ui-core"; // star (re-exports everything)
|
|
544
|
+
*
|
|
545
|
+
* The component DEFINITION lives one (or more) hops away, so a tracer that
|
|
546
|
+
* stops at the barrel reports the wrapper opaque. This finds the next hop.
|
|
547
|
+
*/
|
|
548
|
+
interface ReExportHop {
|
|
549
|
+
/** Module specifier to follow (relative to the barrel file). */
|
|
550
|
+
readonly module: string;
|
|
551
|
+
/** Export name to look up in that module (`default` / the original name). */
|
|
552
|
+
readonly exportName: string;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Find every place `exportName` could be re-exported FROM in a barrel file.
|
|
557
|
+
*
|
|
558
|
+
* - named re-export `export { Button } from "./x"` / `{ B as Button }` — the
|
|
559
|
+
* hop's `exportName` is the ORIGINAL name in `./x` (before the `as`).
|
|
560
|
+
* - star re-export `export * from "./y"` — `exportName` is unknown at the
|
|
561
|
+
* barrel, so every star target is a candidate carrying the SAME name.
|
|
562
|
+
*
|
|
563
|
+
* Named matches come first (deterministic); star targets are appended as
|
|
564
|
+
* fallbacks. An empty result means the name isn't re-exported here.
|
|
565
|
+
*/
|
|
566
|
+
function findReExports(sf: ts.SourceFile, exportName: string): ReExportHop[] {
|
|
567
|
+
const named: ReExportHop[] = [];
|
|
568
|
+
const stars: ReExportHop[] = [];
|
|
569
|
+
for (const stmt of sf.statements) {
|
|
570
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
571
|
+
const spec = stmt.moduleSpecifier;
|
|
572
|
+
if (spec === undefined || !ts.isStringLiteral(spec)) continue; // not a re-export
|
|
573
|
+
const module = spec.text;
|
|
574
|
+
const clause = stmt.exportClause;
|
|
575
|
+
if (clause === undefined) {
|
|
576
|
+
// `export * from "mod"` — the name (if exported) keeps its identity.
|
|
577
|
+
stars.push({ module, exportName });
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (ts.isNamedExports(clause)) {
|
|
581
|
+
for (const el of clause.elements) {
|
|
582
|
+
// `export { Orig as Public }` -> el.name = Public, el.propertyName = Orig.
|
|
583
|
+
const publicName = el.name.text;
|
|
584
|
+
if (publicName !== exportName) continue;
|
|
585
|
+
const original = el.propertyName?.text ?? publicName;
|
|
586
|
+
named.push({ module, exportName: original });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// `export * as NS from "mod"` (namespace re-export) doesn't expose a single
|
|
590
|
+
// component name we can trace — skip it.
|
|
591
|
+
}
|
|
592
|
+
return [...named, ...stars];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Unwrap a component initializer to its function-like node. Handles bare
|
|
597
|
+
* arrow/function expressions and `forwardRef(fn)` / `memo(fn)` wrappers.
|
|
598
|
+
*/
|
|
599
|
+
function unwrapToFunction(expr: ts.Expression): ts.FunctionLikeDeclaration | null {
|
|
600
|
+
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) return expr;
|
|
601
|
+
if (ts.isCallExpression(expr)) {
|
|
602
|
+
// forwardRef(fn) / memo(fn) / React.forwardRef(fn) — first fn arg is the component.
|
|
603
|
+
const fnArg = expr.arguments.find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a));
|
|
604
|
+
if (fnArg !== undefined && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) {
|
|
605
|
+
return fnArg;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Trace a wrapper component to its single host primitive.
|
|
613
|
+
*
|
|
614
|
+
* @param specifier module the wrapper is imported from (relative to `fromFile`)
|
|
615
|
+
* @param exportName the imported export name (`default`, `Button`, `Root`, ...)
|
|
616
|
+
* @param fromFile the file doing the importing (for resolution context)
|
|
617
|
+
*/
|
|
618
|
+
export function traceComponent(
|
|
619
|
+
specifier: string,
|
|
620
|
+
exportName: string,
|
|
621
|
+
fromFile: string,
|
|
622
|
+
depth = 0,
|
|
623
|
+
reExportDepth = 0,
|
|
624
|
+
): TraceResult | null {
|
|
625
|
+
// Registry first — deterministic, no source needed. Carry the registry's
|
|
626
|
+
// toggle `role` (Radix Checkbox/Switch, antd Switch) so the host isn't read
|
|
627
|
+
// as a bare button/input downstream.
|
|
628
|
+
const reg = lookupRegistry(specifier, exportName);
|
|
629
|
+
// A registry hit maps a leaf primitive and has no source body to scan, so it
|
|
630
|
+
// never carries an internally-rendered name.
|
|
631
|
+
if (reg !== null) return { host: reg.host, via: "registry", role: reg.role, rendersOwnName: false };
|
|
632
|
+
|
|
633
|
+
if (depth >= MAX_DEPTH) return null;
|
|
634
|
+
|
|
635
|
+
const defFile = resolveRoute(specifier, fromFile);
|
|
636
|
+
if (defFile === null) return null;
|
|
637
|
+
|
|
638
|
+
const sf = readSource(defFile);
|
|
639
|
+
if (sf === null) return null;
|
|
640
|
+
|
|
641
|
+
const fn = findComponentFn(sf, exportName);
|
|
642
|
+
if (fn === null) {
|
|
643
|
+
// No local definition here. The file may be a barrel that re-exports the
|
|
644
|
+
// component from elsewhere (`export { Button } from "./Button"` / `export *
|
|
645
|
+
// from "@scope/core"`). Follow each re-export hop to the real definition.
|
|
646
|
+
// Bounded by its own budget so a deep barrel chain can't loop forever and
|
|
647
|
+
// doesn't starve the wrapper-render hop budget.
|
|
648
|
+
if (reExportDepth >= MAX_DEPTH) return null;
|
|
649
|
+
for (const hop of findReExports(sf, exportName)) {
|
|
650
|
+
const traced = traceComponent(hop.module, hop.exportName, defFile, depth, reExportDepth + 1);
|
|
651
|
+
if (traced !== null) return traced;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const defImports = collectLocalImports(sf);
|
|
657
|
+
const transparentTags = transparentLocalNames(defImports);
|
|
658
|
+
const shape = renderShapeOf(fn, transparentTags);
|
|
659
|
+
|
|
660
|
+
// Conservative gate: exactly one distinct root tag, and props are forwarded.
|
|
661
|
+
if (shape.tags.size !== 1 || !shape.spreadsProps) return null;
|
|
662
|
+
const [tag] = [...shape.tags];
|
|
663
|
+
|
|
664
|
+
// Intrinsic host element -> done. Carry a static `role="…"` literal on it
|
|
665
|
+
// (e.g. a homegrown `<button role="checkbox">` toggle) so it isn't mistaken
|
|
666
|
+
// for a bare button/input downstream. `undefined` when no static role. Carry
|
|
667
|
+
// `rendersOwnName` too: a host given a static name in the wrapper's body
|
|
668
|
+
// (sr-only span / aria-label / text child) is named, not a nameless control.
|
|
669
|
+
if (HOST_TAG.test(tag)) {
|
|
670
|
+
return {
|
|
671
|
+
host: tag,
|
|
672
|
+
via: "trace",
|
|
673
|
+
role: shape.role ?? undefined,
|
|
674
|
+
rendersOwnName: shape.rendersOwnName,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// The wrapper renders ANOTHER component. Resolve its import within the
|
|
679
|
+
// definition file and recurse one hop. Two shapes:
|
|
680
|
+
// - plain identifier `<Other .../>` -> look up "Other"
|
|
681
|
+
// - namespace member `<NS.Member .../>` (Radix) -> look up "NS", trace "Member"
|
|
682
|
+
const dotIndex = tag.indexOf(".");
|
|
683
|
+
const localName = dotIndex === -1 ? tag : tag.slice(0, dotIndex);
|
|
684
|
+
const member = dotIndex === -1 ? null : tag.slice(dotIndex + 1);
|
|
685
|
+
|
|
686
|
+
const inner = defImports.get(localName);
|
|
687
|
+
if (inner === undefined) return null;
|
|
688
|
+
|
|
689
|
+
// For a namespace import the export we trace is the accessed member; for a
|
|
690
|
+
// named/default import it's whatever name it was imported under.
|
|
691
|
+
const innerExport = inner.isNamespace ? member : inner.imported;
|
|
692
|
+
if (innerExport === null) return null;
|
|
693
|
+
|
|
694
|
+
const innerResult = traceComponent(inner.module, innerExport, defFile, depth + 1);
|
|
695
|
+
if (innerResult === null) return null;
|
|
696
|
+
// The role comes from this wrapper's static `role="…"` on the inner element if
|
|
697
|
+
// it set one (`<CheckboxPrimitive.Root role="checkbox" …>`); otherwise inherit
|
|
698
|
+
// the inner component's role (the Radix-registry toggle role on the primitive).
|
|
699
|
+
const role = shape.role ?? innerResult.role;
|
|
700
|
+
// The name can live at EITHER hop: this wrapper may render the name itself
|
|
701
|
+
// (the shadcn carousel — `CarouselPrevious` puts the `sr-only` span around an
|
|
702
|
+
// inner `<Button>`), or the inner component may already render it. A name at
|
|
703
|
+
// either level means the resolved host is named, so OR them.
|
|
704
|
+
const rendersOwnName = shape.rendersOwnName || innerResult.rendersOwnName;
|
|
705
|
+
return { host: innerResult.host, via: innerResult.via, role, rendersOwnName };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* The local identifier a `const X = …` VALUE alias points at, when `X` is bound
|
|
710
|
+
* to a bare identifier or a member access — NOT a function. The shadcn barrel
|
|
711
|
+
* convention re-publishes a primitive as `const Dialog = DialogPrimitive.Root`
|
|
712
|
+
* (member) or `const Toaster = Sonner` (identifier); both are value aliases whose
|
|
713
|
+
* identity IS the thing on the right. Returns the LEFTMOST identifier (`DialogPrimitive`,
|
|
714
|
+
* `Sonner`) so the caller can resolve it through the file's import map. Returns
|
|
715
|
+
* `null` for a function/arrow/`forwardRef(…)` initializer — that is a real
|
|
716
|
+
* component definition, handled by {@link findComponentFn}, not an alias.
|
|
717
|
+
*/
|
|
718
|
+
function findValueAlias(sf: ts.SourceFile, exportName: string): string | null {
|
|
719
|
+
for (const stmt of sf.statements) {
|
|
720
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
721
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
722
|
+
if (!ts.isIdentifier(decl.name) || decl.name.text !== exportName) continue;
|
|
723
|
+
const init = decl.initializer;
|
|
724
|
+
if (init === undefined) continue;
|
|
725
|
+
// `const X = Other`
|
|
726
|
+
if (ts.isIdentifier(init)) return init.text;
|
|
727
|
+
// `const X = NS.Member` (or `NS.Member.Sub`) -> leftmost identifier `NS`.
|
|
728
|
+
if (ts.isPropertyAccessExpression(init)) {
|
|
729
|
+
let expr: ts.Expression = init;
|
|
730
|
+
while (ts.isPropertyAccessExpression(expr)) expr = expr.expression;
|
|
731
|
+
if (ts.isIdentifier(expr)) return expr.text;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The ORIGIN module a THIN own-code wrapper ultimately aliases — so the coverage
|
|
740
|
+
* classifier can bucket a local `@/components/ui/*` barrel by where its primitive
|
|
741
|
+
* REALLY comes from, not by the `@/…` import string it happens to wear.
|
|
742
|
+
*
|
|
743
|
+
* `traceComponent` answers "what host does this render?" and returns null for a
|
|
744
|
+
* host-LESS container primitive (`Dialog`, `Select` — context providers with no
|
|
745
|
+
* DOM element). Those land in `declare` even though they are guaranteed Radix
|
|
746
|
+
* underneath. This answers the narrower question that fixes that: "what module
|
|
747
|
+
* does this wrapper pass through to?", returning e.g. `@radix-ui/react-dialog`.
|
|
748
|
+
*
|
|
749
|
+
* THIN — and ONLY thin — resolves, so an app composite is NEVER wrongly vouched
|
|
750
|
+
* for:
|
|
751
|
+
* - value alias `const Dialog = DialogPrimitive.Root` -> the namespace's module;
|
|
752
|
+
* - single-tag, props-forwarding wrapper `forwardRef((p,r) => <X.Title {...p}/>)`
|
|
753
|
+
* (the same gate {@link traceComponent} uses) -> the inner element's module;
|
|
754
|
+
* - barrel re-export `export { Dialog } from "@scope/x"` -> that module.
|
|
755
|
+
*
|
|
756
|
+
* A multi-element render (Portal + Overlay + Content) is not thin -> `null`: it
|
|
757
|
+
* stays an opaque unknown, exactly where a genuine composite belongs. Bounded by
|
|
758
|
+
* {@link MAX_DEPTH} so an alias/wrapper chain can't loop.
|
|
759
|
+
*
|
|
760
|
+
* @param specifier module the wrapper is imported from (relative to `fromFile`)
|
|
761
|
+
* @param exportName the imported export name (`Dialog`, `Toaster`, …)
|
|
762
|
+
* @param fromFile the file doing the importing (resolution context)
|
|
763
|
+
*/
|
|
764
|
+
export function traceWrapperOrigin(
|
|
765
|
+
specifier: string,
|
|
766
|
+
exportName: string,
|
|
767
|
+
fromFile: string,
|
|
768
|
+
depth = 0,
|
|
769
|
+
): string | null {
|
|
770
|
+
if (depth >= MAX_DEPTH) return null;
|
|
771
|
+
|
|
772
|
+
const defFile = resolveRoute(specifier, fromFile);
|
|
773
|
+
if (defFile === null) return null;
|
|
774
|
+
const sf = readSource(defFile);
|
|
775
|
+
if (sf === null) return null;
|
|
776
|
+
|
|
777
|
+
const imports = collectLocalImports(sf);
|
|
778
|
+
|
|
779
|
+
// (a) value alias: `const X = NS.Member` / `const X = Other`. Its identity is
|
|
780
|
+
// whatever it points at — resolve that local to its import, else hop locally.
|
|
781
|
+
const aliasLocal = findValueAlias(sf, exportName);
|
|
782
|
+
if (aliasLocal !== null) {
|
|
783
|
+
const inner = imports.get(aliasLocal);
|
|
784
|
+
if (inner !== undefined) return inner.module;
|
|
785
|
+
return traceWrapperOrigin(specifier, aliasLocal, fromFile, depth + 1);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const fn = findComponentFn(sf, exportName);
|
|
789
|
+
if (fn === null) {
|
|
790
|
+
// (c) barrel re-export: `export { X } from "mod"`. An external `mod` IS the
|
|
791
|
+
// origin; an own-code `mod` is one more hop toward it.
|
|
792
|
+
for (const hop of findReExports(sf, exportName)) {
|
|
793
|
+
if (resolveRoute(hop.module, defFile) === null) return hop.module;
|
|
794
|
+
const traced = traceWrapperOrigin(hop.module, hop.exportName, defFile, depth + 1);
|
|
795
|
+
if (traced !== null) return traced;
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// (b) thin single-tag forwarding wrapper. Same conservative gate traceComponent
|
|
801
|
+
// applies — exactly one root tag, props forwarded — but here we want the inner
|
|
802
|
+
// element's MODULE (the host-less primitive traceComponent couldn't map).
|
|
803
|
+
const shape = renderShapeOf(fn, transparentLocalNames(imports));
|
|
804
|
+
if (shape.tags.size !== 1 || !shape.spreadsProps) return null;
|
|
805
|
+
const [tag] = [...shape.tags];
|
|
806
|
+
// An intrinsic host (`<button>`) is a real host traceComponent already maps to
|
|
807
|
+
// `checked`; it never reaches the opaque path, so it is not an origin alias.
|
|
808
|
+
if (HOST_TAG.test(tag)) return null;
|
|
809
|
+
const dot = tag.indexOf(".");
|
|
810
|
+
const local = dot === -1 ? tag : tag.slice(0, dot);
|
|
811
|
+
const inner = imports.get(local);
|
|
812
|
+
if (inner !== undefined) return inner.module;
|
|
813
|
+
return traceWrapperOrigin(specifier, local, fromFile, depth + 1);
|
|
814
|
+
}
|