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