@component-compass/parser-react 0.0.3 → 0.0.5

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/dist/walk-jsx.js CHANGED
@@ -1,30 +1,15 @@
1
- import { parse } from "@babel/parser";
2
- import { extname, relative, isAbsolute } from "node:path";
1
+ import { relative, isAbsolute } from "node:path";
2
+ import { walk } from "oxc-walker";
3
3
  import { serialiseComponentId, externalPackageOf } from "@component-compass/plugin-core";
4
4
  import { asTagName, asWrapperId } from "@component-compass/plugin-core";
5
- import { traverse, posixPath, classifyImportOrigin, findEnclosingComponentBinding } from "@component-compass/ast-utils";
6
- function resolveOwnerComponentId(path, filePath, localIndex, repoRoot) {
7
- // localIndex.byPath uses repo-root-relative POSIX keys; filePath is absolute.
8
- const relPath = isAbsolute(filePath) ? posixPath(relative(repoRoot, filePath)) : filePath;
9
- const defs = localIndex.byPath.get(relPath) ?? [];
10
- const knownExportNames = new Set(defs.map((d) => d.exportName));
11
- const binding = findEnclosingComponentBinding(path, (name) => knownExportNames.has(name));
12
- if (!binding)
13
- return undefined;
14
- return {
15
- kind: "react-component",
16
- export: binding.name,
17
- source: { type: "local", filePath: posixPath(filePath) },
18
- };
19
- }
20
- function withOwner(owner) {
21
- return owner === undefined ? {} : { ownerComponentId: owner };
22
- }
5
+ import { posixPath, classifyImportOrigin, positionAt } from "@component-compass/ast-utils";
23
6
  // React-API factories whose return value is conventionally a component.
24
7
  // Detected by callee identifier name OR `React.<name>` member-expression.
25
8
  // Detection is callee-name only — we don't try to verify the factory's
26
9
  // return shape.
27
10
  const LOCAL_COMPONENT_FACTORIES = new Set(["forwardRef", "memo", "lazy"]);
11
+ // HOC names recognised for owner-binding unwrap (mirrors find-owner.ts).
12
+ const HOC_NAMES = new Set(["memo", "forwardRef", "observer"]);
28
13
  /**
29
14
  * Walk a JSXMemberExpression chain to its root identifier, returning the
30
15
  * flat dotted name (e.g. "Tabs.Trigger.Inner") and the root identifier name.
@@ -42,167 +27,218 @@ function readMemberChain(node) {
42
27
  parts.unshift(current.name);
43
28
  return { compoundName: parts.join("."), rootName: current.name };
44
29
  }
45
- // `errorRecovery: true` only handles a narrow band of recoverable parse errors;
46
- // unrecoverable syntax errors still throw synchronously. Callers must wrap
47
- // invocations in try/catch and convert thrown errors into PARSE_FAILURE warnings.
48
- export function walkJsx(opts) {
49
- // `.ts` files cannot contain JSX; enabling the jsx plugin there causes
50
- // valid TypeScript like `return <const>{...}` to fail to parse.
51
- const plugins = ["typescript", "decorators-legacy", "classProperties"];
52
- if (extname(opts.file) !== ".ts")
53
- plugins.push("jsx");
54
- const ast = parse(opts.source, {
55
- sourceType: "module",
56
- plugins,
57
- errorRecovery: true,
30
+ /**
31
+ * Build an opportunistic Occurrence for an unmapped import — used when
32
+ * `resolveOpening` returns undefined but the root identifier matches an
33
+ * unmappedImports entry. `tail` is the compound-name suffix after the root
34
+ * (null for bare `<Foo/>`, "Bar" for `<Foo.Bar/>`, "Bar.Baz" for deeper).
35
+ *
36
+ * `unmapped.modulePath` is the package-relative resolved module path when the
37
+ * lazy resolver landed on a leaf (paired with non-null `leafPackage`); null
38
+ * otherwise (unresolvable specifier or bare-package fallback through
39
+ * classifyImportOrigin). The emission threads it onto the external identity
40
+ * so downstream consumers can disambiguate same-export entries served from
41
+ * different package subpaths (#63).
42
+ *
43
+ * Two terminal paths:
44
+ * - `unmapped.leafPackage` set → opaque external occurrence rooted at the
45
+ * leaf package; compound usages prefix the tail with the importedName
46
+ * (with the `default.` prefix stripped for default-import roots).
47
+ * - else → classifyImportOrigin → local-source match against the
48
+ * local-index by-path map.
49
+ */
50
+ function emitOpportunistic(unmapped, tail, opening, opts, source) {
51
+ const compoundExport = tail === null
52
+ ? unmapped.importedName
53
+ : unmapped.importedName === "default"
54
+ ? tail
55
+ : `${unmapped.importedName}.${tail}`;
56
+ const via = { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName };
57
+ const loc = locOf(opening, opts.file, source);
58
+ if (unmapped.leafPackage) {
59
+ const { props, events } = readJsxAttrs(opening, opts.captureValues);
60
+ return {
61
+ componentId: {
62
+ kind: "react-component",
63
+ export: compoundExport,
64
+ source: {
65
+ type: "external",
66
+ package: unmapped.leafPackage,
67
+ ...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
68
+ },
69
+ },
70
+ loc,
71
+ via,
72
+ props,
73
+ events,
74
+ };
75
+ }
76
+ // Fallback: classifyImportOrigin to determine whether the specifier is
77
+ // external or local. The lazy resolver returns null only for genuinely
78
+ // unresolvable specifiers or workspace siblings now (identity resolution
79
+ // always runs for external imports). Distinguish the two cases here:
80
+ // - external → emit an opaque occurrence keyed by package name; manifest
81
+ // stays null (manifest enrichment and identity resolution are separate)
82
+ // - local → match against the local index (original path below)
83
+ // - unresolved → nothing to emit
84
+ const origin = classifyImportOrigin(unmapped.specifier, {
85
+ repoRoot: opts.repoRoot,
86
+ fromFile: opts.file,
58
87
  });
88
+ if (origin.type === "external") {
89
+ const { props, events } = readJsxAttrs(opening, opts.captureValues);
90
+ return {
91
+ componentId: {
92
+ kind: "react-component",
93
+ export: compoundExport,
94
+ source: {
95
+ type: "external",
96
+ package: origin.package,
97
+ ...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
98
+ },
99
+ },
100
+ loc,
101
+ via,
102
+ props,
103
+ events,
104
+ };
105
+ }
106
+ // Bare-specifier fallback: classifyImportOrigin returns "unresolved" for
107
+ // workspace-symlinked packages (no node_modules/ segment in the resolved
108
+ // path, yet outside the fixture root). If the specifier looks like a bare
109
+ // npm package name, use it directly as the package name — identity is
110
+ // preserved; manifest stays null.
111
+ if (origin.type === "unresolved" && isBarePackageSpecifier(unmapped.specifier)) {
112
+ const pkgName = unmapped.specifier.startsWith("@")
113
+ ? unmapped.specifier.split("/").slice(0, 2).join("/")
114
+ : (unmapped.specifier.split("/")[0] ?? unmapped.specifier);
115
+ const { props, events } = readJsxAttrs(opening, opts.captureValues);
116
+ return {
117
+ componentId: {
118
+ kind: "react-component",
119
+ export: compoundExport,
120
+ source: {
121
+ type: "external",
122
+ package: pkgName,
123
+ ...(unmapped.modulePath !== null ? { modulePath: unmapped.modulePath } : {}),
124
+ },
125
+ },
126
+ loc,
127
+ via,
128
+ props,
129
+ events,
130
+ };
131
+ }
132
+ if (origin.type !== "local")
133
+ return undefined;
134
+ const localDefs = opts.localIndex.byPath.get(origin.filePath);
135
+ const matched = localDefs?.find((d) => unmapped.importedName === "default"
136
+ ? d.isDefault
137
+ : d.exportName === unmapped.importedName && !d.isDefault);
138
+ if (!matched || matched.componentId.kind !== "react-component")
139
+ return undefined;
140
+ if (tail !== null && matched.componentId.source.type !== "local")
141
+ return undefined;
142
+ const { props, events } = readJsxAttrs(opening, opts.captureValues);
143
+ // For bare usage (tail null): identity is the local def's componentId.
144
+ // For compound: rebuild with `matched.exportName.tail` (stripping
145
+ // `default.` for default-exported locals — mirrors the leafPackage path).
146
+ if (tail === null) {
147
+ return { componentId: matched.componentId, loc, via, props, events };
148
+ }
149
+ const localCompoundExport = matched.exportName === "default" ? tail : `${matched.exportName}.${tail}`;
150
+ return {
151
+ componentId: {
152
+ kind: "react-component",
153
+ export: localCompoundExport,
154
+ source: matched.componentId.source,
155
+ },
156
+ loc,
157
+ via,
158
+ props,
159
+ events,
160
+ };
161
+ }
162
+ // Pre-parsed oxc Program is supplied by scan.ts (single source of truth for
163
+ // parsing). Tasks 1-7 of Phase C swapped parsing to oxc-parser; this walker
164
+ // drives off the resulting Program node. Recovered parse errors land on the
165
+ // raw ParseResult.errors field upstream — oxc-parser carries them on the
166
+ // result, not the Program, so they are warned at parse time and not
167
+ // re-emitted here.
168
+ export function walkJsx(opts) {
169
+ const program = opts.ast;
170
+ const source = opts.source;
59
171
  const occurrences = [];
60
172
  const wrappers = [];
61
173
  const imports = [];
62
174
  const warnings = [];
63
- const recovered = ast.errors ?? [];
64
- for (const err of recovered) {
65
- warnings.push({
66
- code: "PARSE_FAILURE",
67
- message: `Recovered parse error: ${err.message}`,
68
- file: opts.file,
69
- // line: 0 means "unknown" when Babel didn't supply a location.
70
- line: typeof err.loc?.line === "number" ? err.loc.line : 0,
71
- });
72
- }
73
175
  const localBindings = new Map();
74
- // Tracks imports that did NOT match a manifest entry: local → { importedName, specifier, leafPackage }.
75
- // `leafPackage` is non-null when the lazy resolver landed on a leaf package
76
- // but had no manifest entry for `importedName` in that case the third pass
77
- // attributes the opaque external occurrence to the leaf rather than re-running
78
- // origin classification on the user-written aggregator specifier.
176
+ // Tracks imports that did NOT match a manifest entry: local → { importedName,
177
+ // specifier, leafPackage, modulePath }. `leafPackage` is non-null when the
178
+ // lazy resolver landed on a leaf package but had no manifest entry for
179
+ // `importedName` in that case the third pass attributes the opaque external
180
+ // occurrence to the leaf rather than re-running origin classification on the
181
+ // user-written aggregator specifier. `modulePath` is the package-relative
182
+ // resolved path (paired with `leafPackage`); null when the resolver returned
183
+ // no hit, threaded onto the external identity by the JSX-emission pass.
79
184
  const unmappedImports = new Map();
80
- // First pass: collect imports + local Context bindings, resolving imports
81
- // via the lazy manifest resolver. Both visitors share a single AST walk;
82
- // wrapper detection (second pass) and JSX emission (third pass) depend on
83
- // the fully-populated `localBindings` map.
84
- traverse(ast, {
85
- VariableDeclarator(path) {
86
- // Detect var bindings whose initializer is a known React-API call:
87
- // - `const X = createContext(...)` / `React.createContext(...)`
88
- // - `const X = forwardRef(...)` / `memo(...)` / `lazy(...)` (and React.* variants)
89
- // Scope-agnostic — any binding to a recognised callee gets tracked. The
90
- // identity downstream uses `source: { type: "local", filePath }`, so two
91
- // identically-named bindings in different files do not collide.
92
- if (path.node.init?.type !== "CallExpression")
93
- return;
94
- if (path.node.id.type !== "Identifier")
95
- return;
96
- const callee = path.node.init.callee;
97
- const localName = path.node.id.name;
98
- const isCreateContext = (callee.type === "Identifier" && callee.name === "createContext") ||
99
- (callee.type === "MemberExpression" &&
100
- !callee.computed &&
101
- callee.property.type === "Identifier" &&
102
- callee.property.name === "createContext");
103
- if (isCreateContext) {
104
- localBindings.set(localName, { kind: "local-context", localName });
185
+ // ── Pass 1: collect imports + local Context / factory bindings ──────────
186
+ // Single pre-pass over the program populates `localBindings` and
187
+ // `unmappedImports` so the wrapper-detection (pass 2) and JSX-emission
188
+ // (pass 3) passes have a complete view of what's bound.
189
+ walk(program, {
190
+ enter(node) {
191
+ if (node.type === "VariableDeclarator") {
192
+ collectLocalVarBinding(node, localBindings);
105
193
  return;
106
194
  }
107
- const factoryName = callee.type === "Identifier"
108
- ? callee.name
109
- : callee.type === "MemberExpression" &&
110
- !callee.computed &&
111
- callee.property.type === "Identifier"
112
- ? callee.property.name
113
- : null;
114
- if (factoryName && LOCAL_COMPONENT_FACTORIES.has(factoryName)) {
115
- localBindings.set(localName, { kind: "local-component", localName });
195
+ if (node.type === "ImportDeclaration") {
196
+ collectImportBindings(node, opts, localBindings, unmappedImports, imports, source);
116
197
  }
117
198
  },
118
- ImportDeclaration(path) {
119
- const source = path.node.source.value;
120
- if (path.node.specifiers.length === 0) {
121
- // Side-effect import: `import "pkg"`. Prime the resolver's leaf cache
122
- // so later HTML / Lit-template tag references find the leaf's CEM
123
- // even though no JS-side identifier was ever bound. The empty
124
- // exportName won't match anything in matchInLeaf, but the call still
125
- // walks into the leaf and populates byTag for tag-only consumers.
126
- opts.resolveLazyManifest(opts.file, source, "");
127
- return;
199
+ });
200
+ const wrapperStack = [];
201
+ const ancestorMap = new WeakMap();
202
+ const parentOf = (n) => ancestorMap.get(n) ?? null;
203
+ walk(program, {
204
+ enter(node, parent) {
205
+ if (parent)
206
+ ancestorMap.set(node, parent);
207
+ const candidate = wrapperCandidate(node, parent ?? null, program, parentOf);
208
+ if (candidate)
209
+ wrapperStack.push(candidate);
210
+ if (node.type === "JSXOpeningElement") {
211
+ const frame = wrapperStack[wrapperStack.length - 1];
212
+ if (frame)
213
+ collectWrapperDep(node, localBindings, opts.lookupByTag, frame);
128
214
  }
129
- for (const spec of path.node.specifiers) {
130
- if (spec.type === "ImportNamespaceSpecifier") {
131
- // Namespace imports skip the lazy resolver — there's no specific
132
- // exportName to resolve until a compound JSX usage picks one out.
133
- // Record the binding so the compound branch in `resolveOpening` can
134
- // emit the tail as the export name. `package` is the user-written
135
- // specifier; aggregator-following for namespace imports would
136
- // require a separate resolver pass and is deferred.
137
- const local = spec.local.name;
138
- localBindings.set(local, {
139
- kind: "react-namespace",
140
- package: source,
141
- specifier: source,
142
- importName: local,
143
- });
144
- imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
145
- continue;
146
- }
147
- if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier")
148
- continue;
149
- const imported = spec.type === "ImportSpecifier" ? identName(spec.imported) : "default";
150
- const local = spec.local.name;
151
- const hit = opts.resolveLazyManifest(opts.file, source, imported);
152
- if (hit?.matched?.kind === "custom-element") {
153
- localBindings.set(local, {
154
- kind: "wc-wrapper",
155
- tagName: asTagName(hit.matched.tagName),
156
- package: hit.leafPackage,
157
- specifier: source,
158
- importName: imported,
159
- });
160
- }
161
- else if (hit?.matched?.kind === "react-component") {
162
- localBindings.set(local, {
163
- kind: "react-direct",
164
- package: hit.leafPackage,
165
- export: hit.matched.export,
166
- specifier: source,
167
- importName: imported,
168
- });
169
- }
170
- else if (hit) {
171
- // Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
172
- unmappedImports.set(local, {
173
- importedName: imported,
174
- specifier: source,
175
- leafPackage: hit.leafPackage,
176
- });
177
- }
178
- else {
179
- // Specifier did not resolve (could be local relative or unresolvable).
180
- unmappedImports.set(local, {
181
- importedName: imported,
182
- specifier: source,
183
- leafPackage: null,
215
+ },
216
+ leave(node) {
217
+ const top = wrapperStack[wrapperStack.length - 1];
218
+ if (top && top.node === node) {
219
+ wrapperStack.pop();
220
+ if (top.deps.length > 0) {
221
+ wrappers.push({
222
+ id: wrapperId(top.name),
223
+ name: top.name,
224
+ file: posixPath(opts.file),
225
+ exportName: top.exportName,
226
+ componentDeps: top.deps,
184
227
  });
185
228
  }
186
- imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
187
229
  }
188
230
  },
189
231
  });
190
- // Second pass: detect wrapper definitions.
191
- traverse(ast, {
192
- Function(path) {
193
- detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
194
- },
195
- ClassDeclaration(path) {
196
- detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
197
- },
198
- });
199
232
  /** Topmost element is the current tracked parent. node is kept for matched
200
233
  * pop on exit (depth-first traversal guarantees the topmost stack entry is
201
234
  * the current node when its exit handler fires). */
202
235
  const parentStack = [];
236
+ const renderPropStack = [];
237
+ const ownerStack = [];
203
238
  function resolveOpening(opening) {
204
239
  const nameNode = opening.name;
205
- const loc = opening.loc?.start;
240
+ const start = opening.start;
241
+ const loc = typeof start === "number" ? positionAt(source, start) : { line: 1, column: 0 };
206
242
  if (nameNode.type === "JSXMemberExpression") {
207
243
  const chain = readMemberChain(nameNode);
208
244
  if (!chain) {
@@ -210,8 +246,8 @@ export function walkJsx(opts) {
210
246
  code: "jsx-shape-unsupported",
211
247
  severity: "warning",
212
248
  filePath: opts.file,
213
- ...(loc?.line !== undefined && { line: loc.line }),
214
- ...(loc?.column !== undefined && { column: loc.column }),
249
+ line: loc.line,
250
+ column: loc.column,
215
251
  nameType: "JSXMemberExpression-malformed",
216
252
  detail: "member chain did not bottom out at JSXIdentifier",
217
253
  });
@@ -222,8 +258,8 @@ export function walkJsx(opts) {
222
258
  code: "jsx-shape-unsupported",
223
259
  severity: "warning",
224
260
  filePath: opts.file,
225
- ...(loc?.line !== undefined && { line: loc.line }),
226
- ...(loc?.column !== undefined && { column: loc.column }),
261
+ line: loc.line,
262
+ column: loc.column,
227
263
  nameType: "JSXMemberExpression-lowercase-root",
228
264
  detail: `lowercase root identifier "${chain.rootName}" cannot be a React component`,
229
265
  });
@@ -254,6 +290,8 @@ export function walkJsx(opts) {
254
290
  // the tail as the package's actual export. `via.import` (set in the
255
291
  // emission step) preserves the local namespace alias so consumers
256
292
  // can still distinguish namespace-style access from named imports.
293
+ // Namespace imports don't trigger per-export resolver hits, so
294
+ // modulePath is unknown at this access point.
257
295
  const tail = chain.compoundName.slice(chain.rootName.length + 1);
258
296
  return {
259
297
  kind: "react-direct",
@@ -261,6 +299,7 @@ export function walkJsx(opts) {
261
299
  export: tail,
262
300
  specifier: bound.specifier,
263
301
  importName: bound.importName,
302
+ modulePath: null,
264
303
  };
265
304
  }
266
305
  return {
@@ -269,6 +308,7 @@ export function walkJsx(opts) {
269
308
  export: chain.compoundName,
270
309
  specifier: bound.specifier,
271
310
  importName: bound.importName,
311
+ modulePath: bound.modulePath,
272
312
  };
273
313
  }
274
314
  // Root unbound. If the root matches an unmapped import, skip the
@@ -281,8 +321,8 @@ export function walkJsx(opts) {
281
321
  code: "jsx-shape-unsupported",
282
322
  severity: "warning",
283
323
  filePath: opts.file,
284
- ...(loc?.line !== undefined && { line: loc.line }),
285
- ...(loc?.column !== undefined && { column: loc.column }),
324
+ line: loc.line,
325
+ column: loc.column,
286
326
  nameType: "JSXMemberExpression-unbound-root",
287
327
  detail: `compound JSX with unbound root "${chain.rootName}"`,
288
328
  });
@@ -294,8 +334,8 @@ export function walkJsx(opts) {
294
334
  code: "jsx-shape-unsupported",
295
335
  severity: "warning",
296
336
  filePath: opts.file,
297
- ...(loc?.line !== undefined && { line: loc.line }),
298
- ...(loc?.column !== undefined && { column: loc.column }),
337
+ line: loc.line,
338
+ column: loc.column,
299
339
  nameType: nameNode.type,
300
340
  detail: `unhandled JSX element name type: ${nameNode.type}`,
301
341
  });
@@ -326,6 +366,7 @@ export function walkJsx(opts) {
326
366
  export: bound.export,
327
367
  specifier: bound.specifier,
328
368
  importName: bound.importName,
369
+ modulePath: bound.modulePath,
329
370
  };
330
371
  }
331
372
  if (bound?.kind === "local-component") {
@@ -333,229 +374,565 @@ export function walkJsx(opts) {
333
374
  }
334
375
  return undefined;
335
376
  }
336
- traverse(ast, {
337
- JSXElement: {
338
- enter(path) {
339
- const opening = path.node.openingElement;
340
- const resolved = resolveOpening(opening);
341
- let occurrence;
342
- if (!resolved) {
343
- // Opportunistic emission for unmapped imports (preserves Phase 1 behaviour).
344
- const nameNode = opening.name;
345
- if (nameNode.type === "JSXIdentifier") {
346
- const id = nameNode.name;
347
- if (!/^[a-z]/.test(id) || !id.includes("-")) {
348
- const unmapped = unmappedImports.get(id);
349
- if (unmapped) {
350
- if (unmapped.leafPackage) {
351
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
352
- occurrence = {
353
- componentId: {
354
- kind: "react-component",
355
- export: unmapped.importedName,
356
- source: { type: "external", package: unmapped.leafPackage },
357
- },
358
- loc: locOf(opening, opts.file),
359
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
360
- props,
361
- events,
362
- };
363
- }
364
- else {
365
- const origin = classifyImportOrigin(unmapped.specifier, {
366
- repoRoot: opts.repoRoot,
367
- fromFile: opts.file,
368
- });
369
- if (origin.type === "local") {
370
- const localDefs = opts.localIndex.byPath.get(origin.filePath);
371
- const matched = localDefs?.find((d) => unmapped.importedName === "default"
372
- ? d.isDefault
373
- : d.exportName === unmapped.importedName && !d.isDefault);
374
- if (matched && matched.componentId.kind === "react-component") {
375
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
376
- occurrence = {
377
- componentId: matched.componentId,
378
- loc: locOf(opening, opts.file),
379
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
380
- props,
381
- events,
382
- };
383
- }
384
- }
385
- }
386
- }
387
- }
388
- }
389
- else if (nameNode.type === "JSXMemberExpression") {
390
- // Compound JSX rooted in an unmapped named import (e.g. Base UI primitives,
391
- // local workspace packages without a CCM, or aliased imports). Walks the
392
- // member chain, then mirrors the single-identifier path: leafPackage →
393
- // opaque external; else classifyImportOrigin → local-source match.
394
- const chain = readMemberChain(nameNode);
395
- if (chain && !/^[a-z]/.test(chain.rootName)) {
396
- const unmapped = unmappedImports.get(chain.rootName);
397
- if (unmapped) {
398
- // Tail = the chain after the root identifier ("Root" for
399
- // <ComboboxPrimitive.Root />, "Foo.Bar" for <Outer.Foo.Bar />).
400
- const tail = chain.compoundName.slice(chain.rootName.length + 1);
401
- // Package-side compound name: importedName + tail. For renamed
402
- // imports (`import { Combobox as ComboboxPrimitive }`), this
403
- // normalises away the local alias so two consumers using
404
- // different aliases roll up under the same identity.
405
- //
406
- // Default-import roots (`import React from "react"` then
407
- // `<React.Fragment>`) skip the prefix entirely — `default` is
408
- // the import-spec sentinel, not a meaningful name component.
409
- // The compound tail (`Fragment`) IS the package's named
410
- // export. Treats `<React.Fragment>` and `import { Fragment }`
411
- // as the same identity.
412
- const compoundExport = unmapped.importedName === "default"
413
- ? tail
414
- : `${unmapped.importedName}.${tail}`;
415
- if (unmapped.leafPackage) {
416
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
417
- occurrence = {
418
- componentId: {
419
- kind: "react-component",
420
- export: compoundExport,
421
- source: { type: "external", package: unmapped.leafPackage },
422
- },
423
- loc: locOf(opening, opts.file),
424
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
425
- props,
426
- events,
427
- };
428
- }
429
- else {
430
- const origin = classifyImportOrigin(unmapped.specifier, {
431
- repoRoot: opts.repoRoot,
432
- fromFile: opts.file,
433
- });
434
- if (origin.type === "local") {
435
- const localDefs = opts.localIndex.byPath.get(origin.filePath);
436
- const matched = localDefs?.find((d) => unmapped.importedName === "default"
437
- ? d.isDefault
438
- : d.exportName === unmapped.importedName && !d.isDefault);
439
- if (matched &&
440
- matched.componentId.kind === "react-component" &&
441
- matched.componentId.source.type === "local") {
442
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
443
- // Same default-prefix strip as the leafPackage path:
444
- // a default-exported local accessed as `<Foo.Bar />`
445
- // emits `Bar`, not `default.Bar`.
446
- const localCompoundExport = matched.exportName === "default"
447
- ? tail
448
- : `${matched.exportName}.${tail}`;
449
- occurrence = {
450
- componentId: {
451
- kind: "react-component",
452
- export: localCompoundExport,
453
- source: matched.componentId.source,
454
- },
455
- loc: locOf(opening, opts.file),
456
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
457
- props,
458
- events,
459
- };
460
- }
461
- }
462
- }
463
- }
464
- }
465
- }
377
+ // Render-prop guard: true iff the topmost render-prop frame was pushed
378
+ // AFTER its lexical parent JSX was entered. The parent JSX adds 1 to
379
+ // parentStack.length on enter; if the render-prop frame was pushed when
380
+ // parentStack.length was >= the current value, no JSX was emitted since
381
+ // meaning the render-prop scope opened inside the parent's subtree.
382
+ // (When called, `parentNode` is the topmost parentStack entry.)
383
+ const isInRenderPropScopeOxc = (parentNode) => {
384
+ if (!parentNode)
385
+ return false;
386
+ const top = renderPropStack[renderPropStack.length - 1];
387
+ return top !== undefined && top.depthAtPush >= parentStack.length;
388
+ };
389
+ walk(program, {
390
+ enter(node, parent) {
391
+ // ── Owner-attribution frame push ──────────────────────────────────
392
+ if (isOwnerCandidateNode(node)) {
393
+ const grand = parent ? parentOf(parent) : null;
394
+ ownerStack.push({ node, parent: parent ?? null, grand });
395
+ }
396
+ // ── Render-prop scope push ─────────────────────────────────────────
397
+ // Function bodies and JSXAttribute values open a render-prop boundary.
398
+ // Tracked at enter so parent-stack maintenance can read them at JSX time.
399
+ if (node.type === "ArrowFunctionExpression" ||
400
+ node.type === "FunctionExpression" ||
401
+ node.type === "FunctionDeclaration" ||
402
+ node.type === "JSXAttribute") {
403
+ renderPropStack.push({ node, depthAtPush: parentStack.length });
404
+ }
405
+ if (node.type !== "JSXElement")
406
+ return;
407
+ const jsxNode = node;
408
+ const opening = jsxNode.openingElement;
409
+ const resolved = resolveOpening(opening);
410
+ let occurrence;
411
+ if (!resolved) {
412
+ // Opportunistic emission for unmapped imports (preserves Phase 1
413
+ // behaviour). Determine the unmapped root + compound tail (if any),
414
+ // then delegate to emitOpportunistic — single-id and compound paths
415
+ // share leafPackage opaque-external and classifyImportOrigin →
416
+ // local-source resolution.
417
+ const nameNode = opening.name;
418
+ let rootId = null;
419
+ let tail = null;
420
+ if (nameNode.type === "JSXIdentifier") {
421
+ const id = nameNode.name;
422
+ if (!/^[a-z]/.test(id) || !id.includes("-"))
423
+ rootId = id;
466
424
  }
467
- else {
468
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
469
- const loc = locOf(opening, opts.file);
470
- if (resolved.kind === "wc-tag") {
471
- occurrence = {
472
- componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
473
- loc,
474
- via: { kind: "html-tag" },
475
- props,
476
- events,
477
- };
478
- }
479
- else if (resolved.kind === "wc-wrapper") {
480
- occurrence = {
481
- componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
482
- loc,
483
- via: { kind: "react-wrapper", specifier: resolved.specifier, import: resolved.importName },
484
- props,
485
- events,
486
- };
487
- }
488
- else if (resolved.kind === "local-context") {
489
- occurrence = {
490
- componentId: {
491
- kind: "react-component",
492
- export: resolved.export,
493
- source: { type: "local", filePath: posixPath(opts.file) },
494
- },
495
- loc,
496
- via: { kind: "context-provider" },
497
- props,
498
- events,
499
- };
500
- }
501
- else if (resolved.kind === "local-component") {
502
- occurrence = {
503
- componentId: {
504
- kind: "react-component",
505
- export: resolved.export,
506
- source: { type: "local", filePath: posixPath(opts.file) },
507
- },
508
- loc,
509
- via: { kind: "local-component" },
510
- props,
511
- events,
512
- };
425
+ else if (nameNode.type === "JSXMemberExpression") {
426
+ const chain = readMemberChain(nameNode);
427
+ if (chain && !/^[a-z]/.test(chain.rootName)) {
428
+ rootId = chain.rootName;
429
+ tail = chain.compoundName.slice(chain.rootName.length + 1);
513
430
  }
514
- else {
515
- occurrence = {
516
- componentId: { kind: "react-component", export: resolved.export, source: { type: "external", package: resolved.package } },
517
- loc,
518
- via: { kind: "direct-import", specifier: resolved.specifier, import: resolved.importName },
519
- props,
520
- events,
521
- };
431
+ }
432
+ if (rootId !== null) {
433
+ const unmapped = unmappedImports.get(rootId);
434
+ if (unmapped) {
435
+ occurrence = emitOpportunistic(unmapped, tail, opening, opts, source);
522
436
  }
523
437
  }
524
- if (!occurrence)
525
- return;
526
- const parentEntry = parentStack[parentStack.length - 1];
527
- if (parentEntry && !isInRenderPropScope(path, parentEntry.node)) {
528
- occurrence.parentRef = parentEntry.idx;
529
- occurrence.depth = parentStack.length;
438
+ }
439
+ else {
440
+ const { props, events } = readJsxAttrs(opening, opts.captureValues);
441
+ const loc = locOf(opening, opts.file, source);
442
+ if (resolved.kind === "wc-tag") {
443
+ occurrence = {
444
+ componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
445
+ loc,
446
+ via: { kind: "html-tag" },
447
+ props,
448
+ events,
449
+ };
450
+ }
451
+ else if (resolved.kind === "wc-wrapper") {
452
+ occurrence = {
453
+ componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
454
+ loc,
455
+ via: { kind: "react-wrapper", specifier: resolved.specifier, import: resolved.importName },
456
+ props,
457
+ events,
458
+ };
459
+ }
460
+ else if (resolved.kind === "local-context") {
461
+ occurrence = {
462
+ componentId: {
463
+ kind: "react-component",
464
+ export: resolved.export,
465
+ source: { type: "local", filePath: posixPath(opts.file) },
466
+ },
467
+ loc,
468
+ via: { kind: "context-provider" },
469
+ props,
470
+ events,
471
+ };
472
+ }
473
+ else if (resolved.kind === "local-component") {
474
+ occurrence = {
475
+ componentId: {
476
+ kind: "react-component",
477
+ export: resolved.export,
478
+ source: { type: "local", filePath: posixPath(opts.file) },
479
+ },
480
+ loc,
481
+ via: { kind: "local-component" },
482
+ props,
483
+ events,
484
+ };
530
485
  }
531
486
  else {
532
- // Render-prop scope OR no parent: treated as fresh root in this lexical scope.
533
- occurrence.depth = 0;
487
+ occurrence = {
488
+ componentId: {
489
+ kind: "react-component",
490
+ export: resolved.export,
491
+ source: {
492
+ type: "external",
493
+ package: resolved.package,
494
+ ...(resolved.modulePath !== null ? { modulePath: resolved.modulePath } : {}),
495
+ },
496
+ },
497
+ loc,
498
+ via: { kind: "direct-import", specifier: resolved.specifier, import: resolved.importName },
499
+ props,
500
+ events,
501
+ };
534
502
  }
535
- const owner = resolveOwnerComponentId(path, opts.file, opts.localIndex, opts.repoRoot);
536
- occurrence = { ...occurrence, ...withOwner(owner) };
537
- const idx = occurrences.length;
538
- occurrences.push(occurrence);
539
- parentStack.push({ idx, node: path.node });
540
- },
541
- exit(path) {
542
- const top = parentStack[parentStack.length - 1];
543
- if (top && top.node === path.node)
544
- parentStack.pop();
545
- },
503
+ }
504
+ if (!occurrence)
505
+ return;
506
+ const parentEntry = parentStack[parentStack.length - 1];
507
+ if (parentEntry && !isInRenderPropScopeOxc(parentEntry.node)) {
508
+ occurrence.parentRef = parentEntry.idx;
509
+ occurrence.depth = parentStack.length;
510
+ }
511
+ else {
512
+ // Render-prop scope OR no parent: treated as fresh root in this lexical scope.
513
+ occurrence.depth = 0;
514
+ }
515
+ const owner = resolveOwnerFromStack(ownerStack, opts.localIndex, opts.file, opts.repoRoot);
516
+ if (owner !== undefined) {
517
+ occurrence.ownerComponentId = owner;
518
+ }
519
+ const idx = occurrences.length;
520
+ occurrences.push(occurrence);
521
+ parentStack.push({ idx, node: jsxNode });
522
+ },
523
+ leave(node) {
524
+ // Pop owner frame first (it's a subset of structural frames).
525
+ const topOwner = ownerStack[ownerStack.length - 1];
526
+ if (topOwner && topOwner.node === node)
527
+ ownerStack.pop();
528
+ // Pop render-prop frame for function/attribute boundaries we entered.
529
+ if (node.type === "ArrowFunctionExpression" ||
530
+ node.type === "FunctionExpression" ||
531
+ node.type === "FunctionDeclaration" ||
532
+ node.type === "JSXAttribute") {
533
+ const topRp = renderPropStack[renderPropStack.length - 1];
534
+ if (topRp && topRp.node === node)
535
+ renderPropStack.pop();
536
+ }
537
+ if (node.type !== "JSXElement")
538
+ return;
539
+ const top = parentStack[parentStack.length - 1];
540
+ if (top && top.node === node)
541
+ parentStack.pop();
546
542
  },
547
543
  });
548
544
  return { occurrences, wrappers, imports, warnings };
549
545
  }
550
- function locOf(node, file) {
546
+ // ── Helpers ──────────────────────────────────────────────────────────────
547
+ function isOwnerCandidateNode(node) {
548
+ return (node.type === "FunctionDeclaration" ||
549
+ node.type === "FunctionExpression" ||
550
+ node.type === "ArrowFunctionExpression" ||
551
+ node.type === "ClassDeclaration" ||
552
+ node.type === "ClassExpression");
553
+ }
554
+ /**
555
+ * Resolve the nearest enclosing component-binding from the owner frame
556
+ * stack. Walks innermost-to-outermost; first match wins. Filters via the
557
+ * local-index so non-exported component-shaped functions don't receive
558
+ * owner attribution (matches the babel walker's predicate behaviour, where
559
+ * `resolveOwnerComponentId` consults the local index's by-path entries).
560
+ */
561
+ function resolveOwnerFromStack(ownerStack, localIndex, filePath, repoRoot) {
562
+ // localIndex.byPath uses repo-root-relative POSIX keys; filePath is absolute.
563
+ const relPath = isAbsolute(filePath) ? posixPath(relative(repoRoot, filePath)) : filePath;
564
+ const defs = localIndex.byPath.get(relPath) ?? [];
565
+ const knownExportNames = new Set(defs.map((d) => d.exportName));
566
+ const isKnown = (name) => knownExportNames.has(name);
567
+ for (let i = ownerStack.length - 1; i >= 0; i--) {
568
+ const frame = ownerStack[i];
569
+ if (!frame)
570
+ continue;
571
+ const binding = bindingForOwnerFrame(frame.node, frame.parent, frame.grand, isKnown);
572
+ if (binding) {
573
+ return {
574
+ kind: "react-component",
575
+ export: binding.name,
576
+ source: { type: "local", filePath: posixPath(filePath) },
577
+ };
578
+ }
579
+ }
580
+ return undefined;
581
+ }
582
+ function bindingForOwnerFrame(node, parent, grand, isKnown) {
583
+ // class Name { … }
584
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
585
+ const cls = node;
586
+ const id = cls.id;
587
+ if (id && isPascalCase(id.name)) {
588
+ if (isKnown(id.name)) {
589
+ return { name: id.name, isDefault: false };
590
+ }
591
+ }
592
+ if (parent?.type === "ExportDefaultDeclaration") {
593
+ if (isKnown("default")) {
594
+ return { name: "default", isDefault: true };
595
+ }
596
+ }
597
+ return null;
598
+ }
599
+ // function Name() { … }
600
+ if (node.type === "FunctionDeclaration") {
601
+ const fn = node;
602
+ const id = fn.id;
603
+ if (parent?.type === "ExportDefaultDeclaration") {
604
+ const name = id && isPascalCase(id.name) ? id.name : "default";
605
+ if (isKnown(name)) {
606
+ return { name, isDefault: true };
607
+ }
608
+ }
609
+ if (id && isPascalCase(id.name)) {
610
+ if (isKnown(id.name)) {
611
+ return { name: id.name, isDefault: false };
612
+ }
613
+ }
614
+ return null;
615
+ }
616
+ // (...) => … / function () { … } (anonymous), examined by its context.
617
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
618
+ return bindingFromAnonymousFunctionParent(parent, grand, isKnown);
619
+ }
620
+ return null;
621
+ }
622
+ function bindingFromAnonymousFunctionParent(parent, grand, isKnown) {
623
+ if (!parent)
624
+ return null;
625
+ if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") {
626
+ return bindingFromVariableId(parent.id.name, isKnown);
627
+ }
628
+ if (parent.type === "CallExpression" && isHocCallOxc(parent)) {
629
+ if (!grand)
630
+ return null;
631
+ if (grand.type === "VariableDeclarator" && grand.id.type === "Identifier") {
632
+ return bindingFromVariableId(grand.id.name, isKnown);
633
+ }
634
+ if (grand.type === "ExportDefaultDeclaration") {
635
+ if (isKnown("default")) {
636
+ return { name: "default", isDefault: true };
637
+ }
638
+ }
639
+ return null;
640
+ }
641
+ if (parent.type === "ExportDefaultDeclaration") {
642
+ if (isKnown("default")) {
643
+ return { name: "default", isDefault: true };
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+ function bindingFromVariableId(name, isKnown) {
649
+ if (!isPascalCase(name))
650
+ return null;
651
+ if (!isKnown(name))
652
+ return null;
653
+ return { name, isDefault: false };
654
+ }
655
+ function isHocCallOxc(call) {
656
+ if (call.type !== "CallExpression")
657
+ return false;
658
+ const callee = call.callee;
659
+ if (callee.type === "Identifier")
660
+ return HOC_NAMES.has(callee.name);
661
+ if (callee.type === "MemberExpression" &&
662
+ callee.object.type === "Identifier" &&
663
+ callee.object.name === "React" &&
664
+ callee.property.type === "Identifier") {
665
+ return HOC_NAMES.has(callee.property.name);
666
+ }
667
+ return false;
668
+ }
669
+ function isPascalCase(name) {
670
+ return /^[A-Z]/.test(name);
671
+ }
672
+ function collectLocalVarBinding(node, localBindings) {
673
+ // Detect var bindings whose initializer is a known React-API call:
674
+ // - `const X = createContext(...)` / `React.createContext(...)`
675
+ // - `const X = forwardRef(...)` / `memo(...)` / `lazy(...)` (and React.* variants)
676
+ // Scope-agnostic — any binding to a recognised callee gets tracked. The
677
+ // identity downstream uses `source: { type: "local", filePath }`, so two
678
+ // identically-named bindings in different files do not collide.
679
+ if (!node.init || node.init.type !== "CallExpression")
680
+ return;
681
+ if (node.id.type !== "Identifier")
682
+ return;
683
+ const call = node.init;
684
+ const callee = call.callee;
685
+ const localName = node.id.name;
686
+ const isCreateContext = (callee.type === "Identifier" && callee.name === "createContext") ||
687
+ (callee.type === "MemberExpression" &&
688
+ !callee.computed &&
689
+ callee.property.type === "Identifier" &&
690
+ callee.property.name === "createContext");
691
+ if (isCreateContext) {
692
+ localBindings.set(localName, { kind: "local-context", localName });
693
+ return;
694
+ }
695
+ const factoryName = callee.type === "Identifier"
696
+ ? callee.name
697
+ : callee.type === "MemberExpression" &&
698
+ !callee.computed &&
699
+ callee.property.type === "Identifier"
700
+ ? callee.property.name
701
+ : null;
702
+ if (factoryName && LOCAL_COMPONENT_FACTORIES.has(factoryName)) {
703
+ localBindings.set(localName, { kind: "local-component", localName });
704
+ }
705
+ }
706
+ function collectImportBindings(decl, opts, localBindings, unmappedImports, imports, source) {
707
+ const specifier = decl.source.value;
708
+ if (decl.specifiers.length === 0) {
709
+ // Side-effect import: `import "pkg"`. Prime the resolver's leaf cache
710
+ // so later HTML / Lit-template tag references find the leaf's CEM
711
+ // even though no JS-side identifier was ever bound. The empty
712
+ // exportName won't match anything in matchInLeaf, but the call still
713
+ // walks into the leaf and populates byTag for tag-only consumers.
714
+ opts.resolveLazyManifest(opts.file, specifier, "");
715
+ return;
716
+ }
717
+ for (const spec of decl.specifiers) {
718
+ if (spec.type === "ImportNamespaceSpecifier") {
719
+ // Namespace imports skip the lazy resolver — there's no specific
720
+ // exportName to resolve until a compound JSX usage picks one out.
721
+ // Record the binding so the compound branch in `resolveOpening` can
722
+ // emit the tail as the export name. `package` is the user-written
723
+ // specifier; aggregator-following for namespace imports would
724
+ // require a separate resolver pass and is deferred.
725
+ const nsSpec = spec;
726
+ const local = nsSpec.local.name;
727
+ localBindings.set(local, {
728
+ kind: "react-namespace",
729
+ package: specifier,
730
+ specifier,
731
+ importName: local,
732
+ });
733
+ imports.push({ file: posixPath(opts.file), imported: local, source: specifier, loc: locOf(spec, opts.file, source) });
734
+ continue;
735
+ }
736
+ if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier")
737
+ continue;
738
+ const importSpec = spec;
739
+ const imported = spec.type === "ImportSpecifier" ? importedName(spec.imported) : "default";
740
+ const local = importSpec.local.name;
741
+ const hit = opts.resolveLazyManifest(opts.file, specifier, imported);
742
+ if (hit?.matched?.kind === "custom-element") {
743
+ localBindings.set(local, {
744
+ kind: "wc-wrapper",
745
+ tagName: asTagName(hit.matched.tagName),
746
+ package: hit.leafPackage,
747
+ specifier,
748
+ importName: imported,
749
+ modulePath: hit.modulePath,
750
+ });
751
+ }
752
+ else if (hit?.matched?.kind === "react-component") {
753
+ localBindings.set(local, {
754
+ kind: "react-direct",
755
+ package: hit.leafPackage,
756
+ export: hit.matched.export,
757
+ specifier,
758
+ importName: imported,
759
+ modulePath: hit.modulePath,
760
+ });
761
+ }
762
+ else if (hit) {
763
+ // Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
764
+ unmappedImports.set(local, {
765
+ importedName: imported,
766
+ specifier,
767
+ leafPackage: hit.leafPackage,
768
+ modulePath: hit.modulePath,
769
+ });
770
+ }
771
+ else {
772
+ // Specifier did not resolve (could be local relative or unresolvable).
773
+ unmappedImports.set(local, {
774
+ importedName: imported,
775
+ specifier,
776
+ leafPackage: null,
777
+ modulePath: null,
778
+ });
779
+ }
780
+ imports.push({ file: posixPath(opts.file), imported: local, source: specifier, loc: locOf(spec, opts.file, source) });
781
+ }
782
+ }
783
+ function wrapperCandidate(node, parent, program, parentOf) {
784
+ // Function / class declarations with a PascalCase id, or an
785
+ // arrow/function-expr assigned to a PascalCase var. Anonymous bindings
786
+ // outside those patterns aren't tracked — they can't have an exportable
787
+ // wrapper name.
788
+ let name = null;
789
+ if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
790
+ const id = node.id;
791
+ if (id && isPascalCase(id.name))
792
+ name = id.name;
793
+ }
794
+ else if ((node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") &&
795
+ parent?.type === "VariableDeclarator" &&
796
+ parent.id.type === "Identifier" &&
797
+ isPascalCase(parent.id.name)) {
798
+ name = parent.id.name;
799
+ }
800
+ if (name === null)
801
+ return null;
802
+ return {
803
+ node,
804
+ name,
805
+ exportName: detectExportName(parent, program, parentOf, name),
806
+ deps: [],
807
+ seen: new Set(),
808
+ };
809
+ }
810
+ function detectExportName(parent, program, parentOf, declName) {
811
+ // Direct containment: walk up via the pre-built ancestor map. oxc does
812
+ // not auto-populate node.parent, so we thread parentOf through here.
813
+ for (let p = parent; p; p = parentOf(p)) {
814
+ if (p.type === "ExportDefaultDeclaration")
815
+ return "default";
816
+ if (p.type === "ExportNamedDeclaration")
817
+ return declName;
818
+ }
819
+ // Indirect: a separate `export default Foo;` or `export { Foo as Bar };`
820
+ // references this declaration by name. Scan the top-level program body.
821
+ for (const stmt of program.body) {
822
+ if (stmt.type === "ExportDefaultDeclaration" &&
823
+ stmt.declaration.type === "Identifier" &&
824
+ stmt.declaration.name === declName) {
825
+ return "default";
826
+ }
827
+ if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
828
+ for (const spec of stmt.specifiers) {
829
+ if (spec.type !== "ExportSpecifier")
830
+ continue;
831
+ if (spec.local.type !== "Identifier" || spec.local.name !== declName)
832
+ continue;
833
+ if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
834
+ return "default";
835
+ }
836
+ if (spec.exported.type === "Identifier")
837
+ return spec.exported.name;
838
+ return declName;
839
+ }
840
+ }
841
+ }
842
+ return declName;
843
+ }
844
+ function collectWrapperDep(opening, localBindings, lookupByTag, frame) {
845
+ const n = opening.name;
846
+ function addDep(cid) {
847
+ const key = serialiseComponentId(cid);
848
+ if (!frame.seen.has(key)) {
849
+ frame.seen.add(key);
850
+ frame.deps.push(cid);
851
+ }
852
+ }
853
+ if (n.type === "JSXMemberExpression") {
854
+ const chain = readMemberChain(n);
855
+ if (!chain)
856
+ return;
857
+ if (/^[a-z]/.test(chain.rootName))
858
+ return;
859
+ const bound = localBindings.get(chain.rootName);
860
+ if (!bound)
861
+ return;
862
+ // Locally-declared bindings (Context, var-bound components) are not
863
+ // external component deps the wrapper "wraps". Skip.
864
+ if (bound.kind === "local-context" || bound.kind === "local-component")
865
+ return;
866
+ // Namespace imports: tail is the package's actual export — strip the
867
+ // local namespace alias so the wrapper dep records the same export
868
+ // identity downstream consumers see for non-namespace access patterns.
869
+ // Namespace bindings have no per-export resolver hit, so modulePath stays
870
+ // unset on the wrapper-dep identity.
871
+ const exportName = bound.kind === "react-namespace"
872
+ ? chain.compoundName.slice(chain.rootName.length + 1)
873
+ : chain.compoundName;
874
+ const modulePath = bound.kind === "react-namespace" ? null : bound.modulePath;
875
+ addDep({
876
+ kind: "react-component",
877
+ export: exportName,
878
+ source: {
879
+ type: "external",
880
+ package: bound.package,
881
+ ...(modulePath !== null ? { modulePath } : {}),
882
+ },
883
+ });
884
+ return;
885
+ }
886
+ if (n.type !== "JSXIdentifier")
887
+ return;
888
+ const id = n.name;
889
+ if (/^[a-z]/.test(id) && id.includes("-")) {
890
+ const indexed = lookupByTag(asTagName(id));
891
+ if (indexed) {
892
+ addDep({
893
+ kind: "custom-element",
894
+ tagName: asTagName(id),
895
+ source: { type: "external", package: externalPackageOf(indexed.source) },
896
+ });
897
+ }
898
+ return;
899
+ }
900
+ const bound = localBindings.get(id);
901
+ // Only WC-wrapper bindings count as wrapper component-deps; React-direct
902
+ // bindings refer to other React components, which are not WC-wrappers.
903
+ if (bound && bound.kind === "wc-wrapper") {
904
+ addDep({
905
+ kind: "custom-element",
906
+ tagName: bound.tagName,
907
+ source: { type: "external", package: bound.package },
908
+ });
909
+ }
910
+ }
911
+ function wrapperId(name) {
912
+ const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
913
+ return asWrapperId(`wrap-${kebab}`);
914
+ }
915
+ // ── Position / attribute helpers ────────────────────────────────────────
916
+ function locOf(node, file, source) {
917
+ const start = node.start;
918
+ if (typeof start !== "number") {
919
+ return { file: posixPath(file), line: 1, column: 1 };
920
+ }
921
+ const pos = positionAt(source, start);
551
922
  return {
552
923
  file: posixPath(file),
553
- line: node.loc?.start?.line ?? 1,
554
- column: (node.loc?.start?.column ?? 0) + 1,
924
+ line: pos.line,
925
+ // babel emits 1-indexed columns at the call sites that read `loc.column`,
926
+ // matching the visible offset. positionAt returns 0-indexed columns; +1
927
+ // aligns with the historical babel output the artifact expects.
928
+ column: pos.column + 1,
555
929
  };
556
930
  }
557
- function identName(node) {
558
- return node.type === "Identifier" ? node.name : node.value;
931
+ function importedName(node) {
932
+ if (node.type === "Identifier")
933
+ return node.name ?? "";
934
+ // StringLiteral path for `import { "weird name" as X }`.
935
+ return node.value ?? "";
559
936
  }
560
937
  function readJsxAttrs(node, captureValues) {
561
938
  const props = [];
@@ -572,7 +949,9 @@ function readJsxAttrs(node, captureValues) {
572
949
  }
573
950
  if (attr.type !== "JSXAttribute")
574
951
  continue;
575
- const name = attr.name.type === "JSXIdentifier" ? attr.name.name : "";
952
+ const jsxAttr = attr;
953
+ const nameNode = jsxAttr.name;
954
+ const name = nameNode.type === "JSXIdentifier" ? nameNode.name : "";
576
955
  if (!name)
577
956
  continue;
578
957
  // React event handlers are conventionally `on<Event>` props on the JSX side.
@@ -580,7 +959,7 @@ function readJsxAttrs(node, captureValues) {
580
959
  events.push(name.slice(2).toLowerCase());
581
960
  continue;
582
961
  }
583
- if (!attr.value) {
962
+ if (!jsxAttr.value) {
584
963
  // Boolean shorthand `<Foo bar />` → literal `true`.
585
964
  const usage = { name, isDynamic: false };
586
965
  if (captureValues)
@@ -588,203 +967,75 @@ function readJsxAttrs(node, captureValues) {
588
967
  props.push(usage);
589
968
  continue;
590
969
  }
591
- if (attr.value.type === "StringLiteral") {
970
+ // oxc emits `Literal` for string/number/boolean (ESTree shape); babel
971
+ // emitted distinct `StringLiteral` etc. Discriminate by `value` typeof.
972
+ if (jsxAttr.value.type === "Literal" && typeof jsxAttr.value.value === "string") {
592
973
  const usage = { name, isDynamic: false };
593
974
  if (captureValues)
594
- usage.literalValue = attr.value.value;
975
+ usage.literalValue = jsxAttr.value.value;
595
976
  props.push(usage);
596
977
  continue;
597
978
  }
598
- if (attr.value.type === "JSXExpressionContainer") {
599
- const expr = attr.value.expression;
600
- if (expr.type === "StringLiteral") {
601
- const usage = { name, isDynamic: false };
602
- if (captureValues)
603
- usage.literalValue = expr.value;
604
- props.push(usage);
605
- }
606
- else if (expr.type === "NumericLiteral") {
607
- const usage = { name, isDynamic: false };
608
- if (captureValues)
609
- usage.literalValue = expr.value;
610
- props.push(usage);
611
- }
612
- else if (expr.type === "BooleanLiteral") {
613
- const usage = { name, isDynamic: false };
614
- if (captureValues)
615
- usage.literalValue = expr.value;
616
- props.push(usage);
979
+ if (jsxAttr.value.type === "JSXExpressionContainer") {
980
+ // JSXExpressionContainer.expression is JSXExpression = JSXEmptyExpression | Expression
981
+ // widen to Node for the unwrap step so the JSXEmptyExpression branch
982
+ // below is reachable (oxc's Expression union excludes JSXEmptyExpression).
983
+ const expr = unwrapParens(jsxAttr.value.expression);
984
+ // Empty expression containers (`{}`) are ignored.
985
+ if (expr.type === "JSXEmptyExpression")
986
+ continue;
987
+ if (expr.type === "Literal") {
988
+ const litVal = expr.value;
989
+ const kind = typeof litVal;
990
+ if (kind === "string" || kind === "number" || kind === "boolean") {
991
+ const usage = { name, isDynamic: false };
992
+ if (captureValues)
993
+ usage.literalValue = litVal;
994
+ props.push(usage);
995
+ continue;
996
+ }
997
+ // null / bigint / regex literals: treat as dynamic-expr.
998
+ props.push({ name, isDynamic: true, dynamicKind: "expr" });
999
+ continue;
617
1000
  }
618
- else if (expr.type === "Identifier") {
1001
+ if (expr.type === "Identifier") {
619
1002
  props.push({ name, isDynamic: true, dynamicKind: "identifier" });
1003
+ continue;
620
1004
  }
621
- else {
622
- props.push({ name, isDynamic: true, dynamicKind: "expr" });
623
- }
1005
+ props.push({ name, isDynamic: true, dynamicKind: "expr" });
624
1006
  }
625
1007
  }
626
1008
  return { props, events };
627
1009
  }
628
- function detectWrapper(path, file, localBindings, lookupByTag, wrappers) {
629
- const name = wrapperName(path);
630
- if (!name)
631
- return;
632
- if (!/^[A-Z]/.test(name))
633
- return;
634
- const seen = new Set();
635
- const deps = [];
636
- function addDep(cid) {
637
- const key = serialiseComponentId(cid);
638
- if (!seen.has(key)) {
639
- seen.add(key);
640
- deps.push(cid);
641
- }
642
- }
643
- path.traverse({
644
- JSXOpeningElement(inner) {
645
- const n = inner.node.name;
646
- if (n.type === "JSXMemberExpression") {
647
- const chain = readMemberChain(n);
648
- if (!chain)
649
- return;
650
- if (/^[a-z]/.test(chain.rootName))
651
- return;
652
- const bound = localBindings.get(chain.rootName);
653
- if (!bound)
654
- return;
655
- // Locally-declared bindings (Context, var-bound components) are not
656
- // external component deps the wrapper "wraps". Skip.
657
- if (bound.kind === "local-context" || bound.kind === "local-component")
658
- return;
659
- // Namespace imports: tail is the package's actual export — strip the
660
- // local namespace alias so the wrapper dep records the same export
661
- // identity downstream consumers see for non-namespace access patterns.
662
- const exportName = bound.kind === "react-namespace"
663
- ? chain.compoundName.slice(chain.rootName.length + 1)
664
- : chain.compoundName;
665
- addDep({
666
- kind: "react-component",
667
- export: exportName,
668
- source: { type: "external", package: bound.package },
669
- });
670
- return;
671
- }
672
- if (n.type !== "JSXIdentifier")
673
- return;
674
- const id = n.name;
675
- if (/^[a-z]/.test(id) && id.includes("-")) {
676
- const indexed = lookupByTag(asTagName(id));
677
- if (indexed) {
678
- addDep({
679
- kind: "custom-element",
680
- tagName: asTagName(id),
681
- source: { type: "external", package: externalPackageOf(indexed.source) },
682
- });
683
- }
684
- return;
685
- }
686
- const bound = localBindings.get(id);
687
- // Only WC-wrapper bindings count as wrapper component-deps; React-direct
688
- // bindings refer to other React components, which are not WC-wrappers.
689
- if (bound && bound.kind === "wc-wrapper") {
690
- addDep({
691
- kind: "custom-element",
692
- tagName: bound.tagName,
693
- source: { type: "external", package: bound.package },
694
- });
695
- }
696
- },
697
- });
698
- if (deps.length === 0)
699
- return;
700
- const id = wrapperId(name);
701
- wrappers.push({
702
- id,
703
- name,
704
- file: posixPath(file),
705
- exportName: detectExportName(path, name),
706
- componentDeps: deps,
707
- });
708
- }
709
- function wrapperName(path) {
710
- const node = path.node;
711
- if (node.type === "FunctionDeclaration" && node.id)
712
- return node.id.name;
713
- if (node.type === "ClassDeclaration" && node.id)
714
- return node.id.name;
715
- if (path.parent.type === "VariableDeclarator" && path.parent.id.type === "Identifier") {
716
- return path.parent.id.name;
717
- }
718
- return undefined;
719
- }
720
- function detectExportName(path, declName) {
721
- // Direct containment: declaration is itself part of an export statement.
722
- let p = path;
723
- while (p) {
724
- if (p.parent && p.parent.type === "ExportDefaultDeclaration")
725
- return "default";
726
- if (p.parent && p.parent.type === "ExportNamedDeclaration")
727
- return declName;
728
- p = p.parentPath;
729
- }
730
- // Indirect: a separate `export default Foo;` or `export { Foo as Bar };` references
731
- // this declaration by name. Scan the top-level program body.
732
- const program = path.findParent((parent) => parent.isProgram());
733
- if (program?.isProgram()) {
734
- for (const stmt of program.node.body) {
735
- if (stmt.type === "ExportDefaultDeclaration" &&
736
- stmt.declaration.type === "Identifier" &&
737
- stmt.declaration.name === declName) {
738
- return "default";
739
- }
740
- if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
741
- for (const spec of stmt.specifiers) {
742
- if (spec.type !== "ExportSpecifier")
743
- continue;
744
- if (spec.local.name !== declName)
745
- continue;
746
- if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
747
- return "default";
748
- }
749
- return declName;
750
- }
751
- }
752
- }
1010
+ /**
1011
+ * Returns true for specifiers that look like bare npm package names — neither
1012
+ * relative paths (`.`, `..`) nor absolute paths (`/`). Scoped packages
1013
+ * (`@scope/name`) are accepted only when the scope part is non-empty, to
1014
+ * exclude alias conventions like `@/foo` where `@` is not an npm scope.
1015
+ */
1016
+ function isBarePackageSpecifier(specifier) {
1017
+ if (specifier.startsWith(".") || specifier.startsWith("/"))
1018
+ return false;
1019
+ if (specifier.startsWith("@")) {
1020
+ // Scoped package: require a non-empty scope name before the first "/".
1021
+ const slashIdx = specifier.indexOf("/");
1022
+ // No "/" at all (bare "@foo") or scope part is empty ("@/foo") → not a
1023
+ // valid npm scoped package specifier.
1024
+ return slashIdx > 1; // slashIdx 1 means "@/" — scope is empty
753
1025
  }
754
- return declName;
755
- }
756
- function wrapperId(name) {
757
- const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
758
- return asWrapperId(`wrap-${kebab}`);
1026
+ return true;
759
1027
  }
760
1028
  /**
761
- * Render-prop guard: returns true when `path` (a JSXElement) is lexically
762
- * nested inside a function body or JSXAttribute that lives between it and
763
- * the topmost tracked parent (`parentNode`). React render-props pass children
764
- * opaquely through a function (`<DataLoader>{(d) => <Card />}</DataLoader>`,
765
- * `{items.map(i => <Card />)}`) or through a prop value (`<Modal title={<H/>}>`),
766
- * so descendants in that scope must NOT inherit the host as their parent.
767
- *
768
- * JSXExpressionContainer alone is transparent — `{cond && <Card />}` and
769
- * `{a ? <X /> : <Y />}` should keep lexical-parent attribution per
770
- * §Failure modes' "conditional rendering visible" rule. The function/attribute
771
- * boundary is what differentiates render-prop from a bare expression escape.
772
- *
773
- * (Vue scoped slots are lexical and handled differently in parser-vue.)
1029
+ * oxc preserves `ParenthesizedExpression` as a real AST node where babel
1030
+ * transparently strips them. Anywhere we inspect an expression's shape
1031
+ * (JSX-attribute values, return arguments, etc.) we must recurse through
1032
+ * these wrappers first.
774
1033
  */
775
- function isInRenderPropScope(path, parentNode) {
776
- if (!parentNode)
777
- return false;
778
- let p = path.parentPath;
779
- while (p && p.node !== parentNode) {
780
- if (p.isArrowFunctionExpression() ||
781
- p.isFunctionExpression() ||
782
- p.isFunctionDeclaration() ||
783
- p.isJSXAttribute()) {
784
- return true;
785
- }
786
- p = p.parentPath;
1034
+ function unwrapParens(node) {
1035
+ let cur = node;
1036
+ while (cur.type === "ParenthesizedExpression") {
1037
+ cur = cur.expression;
787
1038
  }
788
- return false;
1039
+ return cur;
789
1040
  }
790
1041
  //# sourceMappingURL=walk-jsx.js.map