@component-compass/parser-react 0.0.2 → 0.0.4

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,111 +1,346 @@
1
- import { parse } from "@babel/parser";
1
+ import { relative, isAbsolute } from "node:path";
2
+ import { walk } from "oxc-walker";
2
3
  import { serialiseComponentId, externalPackageOf } from "@component-compass/plugin-core";
3
4
  import { asTagName, asWrapperId } from "@component-compass/plugin-core";
4
- import { traverse, posixPath, classifyImportOrigin } from "@component-compass/ast-utils";
5
- // `errorRecovery: true` only handles a narrow band of recoverable parse errors;
6
- // unrecoverable syntax errors still throw synchronously. Callers must wrap
7
- // invocations in try/catch and convert thrown errors into PARSE_FAILURE warnings.
8
- export function walkJsx(opts) {
9
- const ast = parse(opts.source, {
10
- sourceType: "module",
11
- plugins: ["typescript", "jsx", "decorators-legacy", "classProperties"],
12
- errorRecovery: true,
5
+ import { posixPath, classifyImportOrigin, positionAt } from "@component-compass/ast-utils";
6
+ // React-API factories whose return value is conventionally a component.
7
+ // Detected by callee identifier name OR `React.<name>` member-expression.
8
+ // Detection is callee-name only we don't try to verify the factory's
9
+ // return shape.
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"]);
13
+ /**
14
+ * Walk a JSXMemberExpression chain to its root identifier, returning the
15
+ * flat dotted name (e.g. "Tabs.Trigger.Inner") and the root identifier name.
16
+ * Returns null if the chain bottoms out at anything other than a JSXIdentifier.
17
+ */
18
+ function readMemberChain(node) {
19
+ const parts = [];
20
+ let current = node;
21
+ while (current.type === "JSXMemberExpression") {
22
+ parts.unshift(current.property.name);
23
+ current = current.object;
24
+ }
25
+ if (current.type !== "JSXIdentifier")
26
+ return null;
27
+ parts.unshift(current.name);
28
+ return { compoundName: parts.join("."), rootName: current.name };
29
+ }
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,
13
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;
14
171
  const occurrences = [];
15
172
  const wrappers = [];
16
173
  const imports = [];
17
174
  const warnings = [];
18
- const recovered = ast.errors ?? [];
19
- for (const err of recovered) {
20
- warnings.push({
21
- code: "PARSE_FAILURE",
22
- message: `Recovered parse error: ${err.message}`,
23
- file: opts.file,
24
- // line: 0 means "unknown" when Babel didn't supply a location.
25
- line: typeof err.loc?.line === "number" ? err.loc.line : 0,
26
- });
27
- }
28
175
  const localBindings = new Map();
29
- // Tracks imports that did NOT match a manifest entry: local → { importedName, specifier, leafPackage }.
30
- // `leafPackage` is non-null when the lazy resolver landed on a leaf package
31
- // but had no manifest entry for `importedName` in that case the third pass
32
- // attributes the opaque external occurrence to the leaf rather than re-running
33
- // 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.
34
184
  const unmappedImports = new Map();
35
- // First pass: collect imports and resolve via the lazy manifest resolver.
36
- traverse(ast, {
37
- ImportDeclaration(path) {
38
- const source = path.node.source.value;
39
- if (path.node.specifiers.length === 0) {
40
- // Side-effect import: `import "pkg"`. Prime the resolver's leaf cache
41
- // so later HTML / Lit-template tag references find the leaf's CEM
42
- // even though no JS-side identifier was ever bound. The empty
43
- // exportName won't match anything in matchInLeaf, but the call still
44
- // walks into the leaf and populates byTag for tag-only consumers.
45
- opts.resolveLazyManifest(opts.file, source, "");
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);
46
193
  return;
47
194
  }
48
- for (const spec of path.node.specifiers) {
49
- if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier")
50
- continue;
51
- const imported = spec.type === "ImportSpecifier" ? identName(spec.imported) : "default";
52
- const local = spec.local.name;
53
- const hit = opts.resolveLazyManifest(opts.file, source, imported);
54
- if (hit?.matched?.kind === "custom-element") {
55
- localBindings.set(local, {
56
- kind: "wc-wrapper",
57
- tagName: asTagName(hit.matched.tagName),
58
- package: hit.leafPackage,
59
- specifier: source,
60
- importName: imported,
61
- });
62
- }
63
- else if (hit?.matched?.kind === "react-component") {
64
- localBindings.set(local, {
65
- kind: "react-direct",
66
- package: hit.leafPackage,
67
- export: hit.matched.export,
68
- specifier: source,
69
- importName: imported,
70
- });
71
- }
72
- else if (hit) {
73
- // Resolver landed on a leaf but no manifest match → opaque external bound to leaf.
74
- unmappedImports.set(local, {
75
- importedName: imported,
76
- specifier: source,
77
- leafPackage: hit.leafPackage,
78
- });
79
- }
80
- else {
81
- // Specifier did not resolve (could be local relative or unresolvable).
82
- unmappedImports.set(local, {
83
- importedName: imported,
84
- specifier: source,
85
- leafPackage: null,
86
- });
87
- }
88
- imports.push({ file: posixPath(opts.file), imported: local, source, loc: locOf(spec, opts.file) });
195
+ if (node.type === "ImportDeclaration") {
196
+ collectImportBindings(node, opts, localBindings, unmappedImports, imports, source);
89
197
  }
90
198
  },
91
199
  });
92
- // Second pass: detect wrapper definitions.
93
- traverse(ast, {
94
- Function(path) {
95
- detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
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);
214
+ }
96
215
  },
97
- ClassDeclaration(path) {
98
- detectWrapper(path, opts.file, localBindings, opts.lookupByTag, wrappers);
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,
227
+ });
228
+ }
229
+ }
99
230
  },
100
231
  });
101
232
  /** Topmost element is the current tracked parent. node is kept for matched
102
233
  * pop on exit (depth-first traversal guarantees the topmost stack entry is
103
234
  * the current node when its exit handler fires). */
104
235
  const parentStack = [];
236
+ const renderPropStack = [];
237
+ const ownerStack = [];
105
238
  function resolveOpening(opening) {
106
239
  const nameNode = opening.name;
107
- if (nameNode.type !== "JSXIdentifier")
240
+ const start = opening.start;
241
+ const loc = typeof start === "number" ? positionAt(source, start) : { line: 1, column: 0 };
242
+ if (nameNode.type === "JSXMemberExpression") {
243
+ const chain = readMemberChain(nameNode);
244
+ if (!chain) {
245
+ opts.collector?.emit({
246
+ code: "jsx-shape-unsupported",
247
+ severity: "warning",
248
+ filePath: opts.file,
249
+ line: loc.line,
250
+ column: loc.column,
251
+ nameType: "JSXMemberExpression-malformed",
252
+ detail: "member chain did not bottom out at JSXIdentifier",
253
+ });
254
+ return undefined;
255
+ }
256
+ if (/^[a-z]/.test(chain.rootName)) {
257
+ opts.collector?.emit({
258
+ code: "jsx-shape-unsupported",
259
+ severity: "warning",
260
+ filePath: opts.file,
261
+ line: loc.line,
262
+ column: loc.column,
263
+ nameType: "JSXMemberExpression-lowercase-root",
264
+ detail: `lowercase root identifier "${chain.rootName}" cannot be a React component`,
265
+ });
266
+ return undefined;
267
+ }
268
+ const bound = localBindings.get(chain.rootName);
269
+ if (bound) {
270
+ // Compound subcomponents always emit as react-component, regardless of
271
+ // whether the root binding is react-direct or wc-wrapper. The WC-wrapper
272
+ // folding rule applies only to bare root usage (<Tabs />), never to
273
+ // compound subcomponents (<Tabs.Trigger />).
274
+ if (bound.kind === "local-context") {
275
+ // `<MyContext.Provider>` / `<MyContext.Consumer>` is access to a
276
+ // member of a React Context object, not a separate component
277
+ // definition. Skip emission so the artifact doesn't gain a spurious
278
+ // `MyContext.Provider` component identity without a real binding site.
279
+ return undefined;
280
+ }
281
+ if (bound.kind === "local-component") {
282
+ // Compound usage of a local var-bound component (e.g. forwardRef'd
283
+ // root with static subcomponents attached: `<Button.Header />`).
284
+ // Full compound name preserves the access pattern so subcomponents
285
+ // surface as distinct identities under the same local-source root.
286
+ return { kind: "local-component", export: chain.compoundName };
287
+ }
288
+ if (bound.kind === "react-namespace") {
289
+ // `import * as Foo from "pkg"` — strip the namespace alias and emit
290
+ // the tail as the package's actual export. `via.import` (set in the
291
+ // emission step) preserves the local namespace alias so consumers
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.
295
+ const tail = chain.compoundName.slice(chain.rootName.length + 1);
296
+ return {
297
+ kind: "react-direct",
298
+ package: bound.package,
299
+ export: tail,
300
+ specifier: bound.specifier,
301
+ importName: bound.importName,
302
+ modulePath: null,
303
+ };
304
+ }
305
+ return {
306
+ kind: "react-direct",
307
+ package: bound.package,
308
+ export: chain.compoundName,
309
+ specifier: bound.specifier,
310
+ importName: bound.importName,
311
+ modulePath: bound.modulePath,
312
+ };
313
+ }
314
+ // Root unbound. If the root matches an unmapped import, skip the
315
+ // diagnostic — the JSX-element opportunistic block will attempt to emit
316
+ // an opaque external (or local-source) occurrence using the unmapped
317
+ // binding. Symmetric with the single-identifier branch which is silent
318
+ // on unmapped misses.
319
+ if (!unmappedImports.has(chain.rootName)) {
320
+ opts.collector?.emit({
321
+ code: "jsx-shape-unsupported",
322
+ severity: "warning",
323
+ filePath: opts.file,
324
+ line: loc.line,
325
+ column: loc.column,
326
+ nameType: "JSXMemberExpression-unbound-root",
327
+ detail: `compound JSX with unbound root "${chain.rootName}"`,
328
+ });
329
+ }
330
+ return undefined;
331
+ }
332
+ if (nameNode.type !== "JSXIdentifier") {
333
+ opts.collector?.emit({
334
+ code: "jsx-shape-unsupported",
335
+ severity: "warning",
336
+ filePath: opts.file,
337
+ line: loc.line,
338
+ column: loc.column,
339
+ nameType: nameNode.type,
340
+ detail: `unhandled JSX element name type: ${nameNode.type}`,
341
+ });
108
342
  return undefined;
343
+ }
109
344
  const id = nameNode.name;
110
345
  if (/^[a-z]/.test(id) && id.includes("-")) {
111
346
  const indexed = opts.lookupByTag(asTagName(id));
@@ -131,128 +366,573 @@ export function walkJsx(opts) {
131
366
  export: bound.export,
132
367
  specifier: bound.specifier,
133
368
  importName: bound.importName,
369
+ modulePath: bound.modulePath,
134
370
  };
135
371
  }
372
+ if (bound?.kind === "local-component") {
373
+ return { kind: "local-component", export: id };
374
+ }
136
375
  return undefined;
137
376
  }
138
- traverse(ast, {
139
- JSXElement: {
140
- enter(path) {
141
- const opening = path.node.openingElement;
142
- const resolved = resolveOpening(opening);
143
- let occurrence;
144
- if (!resolved) {
145
- // Opportunistic emission for unmapped imports (preserves Phase 1 behaviour).
146
- const nameNode = opening.name;
147
- if (nameNode.type === "JSXIdentifier") {
148
- const id = nameNode.name;
149
- if (!/^[a-z]/.test(id) || !id.includes("-")) {
150
- const unmapped = unmappedImports.get(id);
151
- if (unmapped) {
152
- if (unmapped.leafPackage) {
153
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
154
- occurrence = {
155
- componentId: {
156
- kind: "react-component",
157
- export: unmapped.importedName,
158
- source: { type: "external", package: unmapped.leafPackage },
159
- },
160
- loc: locOf(opening, opts.file),
161
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
162
- props,
163
- events,
164
- };
165
- }
166
- else {
167
- const origin = classifyImportOrigin(unmapped.specifier, {
168
- repoRoot: opts.repoRoot,
169
- fromFile: opts.file,
170
- });
171
- if (origin.type === "local") {
172
- const localDefs = opts.localIndex.byPath.get(origin.filePath);
173
- const matched = localDefs?.find((d) => unmapped.importedName === "default"
174
- ? d.isDefault
175
- : d.exportName === unmapped.importedName && !d.isDefault);
176
- if (matched && matched.componentId.kind === "react-component") {
177
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
178
- occurrence = {
179
- componentId: matched.componentId,
180
- loc: locOf(opening, opts.file),
181
- via: { kind: "direct-import", specifier: unmapped.specifier, import: unmapped.importedName },
182
- props,
183
- events,
184
- };
185
- }
186
- }
187
- }
188
- }
189
- }
190
- }
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;
191
424
  }
192
- else {
193
- const { props, events } = readJsxAttrs(opening, opts.captureValues);
194
- const loc = locOf(opening, opts.file);
195
- if (resolved.kind === "wc-tag") {
196
- occurrence = {
197
- componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
198
- loc,
199
- via: { kind: "html-tag" },
200
- props,
201
- events,
202
- };
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);
203
430
  }
204
- else if (resolved.kind === "wc-wrapper") {
205
- occurrence = {
206
- componentId: { kind: "custom-element", tagName: resolved.tagName, source: { type: "external", package: resolved.package } },
207
- loc,
208
- via: { kind: "react-wrapper", specifier: resolved.specifier, import: resolved.importName },
209
- props,
210
- events,
211
- };
212
- }
213
- else {
214
- occurrence = {
215
- componentId: { kind: "react-component", export: resolved.export, source: { type: "external", package: resolved.package } },
216
- loc,
217
- via: { kind: "direct-import", specifier: resolved.specifier, import: resolved.importName },
218
- props,
219
- events,
220
- };
431
+ }
432
+ if (rootId !== null) {
433
+ const unmapped = unmappedImports.get(rootId);
434
+ if (unmapped) {
435
+ occurrence = emitOpportunistic(unmapped, tail, opening, opts, source);
221
436
  }
222
437
  }
223
- if (!occurrence)
224
- return;
225
- const parentEntry = parentStack[parentStack.length - 1];
226
- if (parentEntry && !isInRenderPropScope(path, parentEntry.node)) {
227
- occurrence.parentRef = parentEntry.idx;
228
- 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
+ };
229
485
  }
230
486
  else {
231
- // Render-prop scope OR no parent: treated as fresh root in this lexical scope.
232
- 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
+ };
233
502
  }
234
- const idx = occurrences.length;
235
- occurrences.push(occurrence);
236
- parentStack.push({ idx, node: path.node });
237
- },
238
- exit(path) {
239
- const top = parentStack[parentStack.length - 1];
240
- if (top && top.node === path.node)
241
- parentStack.pop();
242
- },
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();
243
542
  },
244
543
  });
245
544
  return { occurrences, wrappers, imports, warnings };
246
545
  }
247
- 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);
248
922
  return {
249
923
  file: posixPath(file),
250
- line: node.loc?.start?.line ?? 1,
251
- 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,
252
929
  };
253
930
  }
254
- function identName(node) {
255
- 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 ?? "";
256
936
  }
257
937
  function readJsxAttrs(node, captureValues) {
258
938
  const props = [];
@@ -269,7 +949,9 @@ function readJsxAttrs(node, captureValues) {
269
949
  }
270
950
  if (attr.type !== "JSXAttribute")
271
951
  continue;
272
- 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 : "";
273
955
  if (!name)
274
956
  continue;
275
957
  // React event handlers are conventionally `on<Event>` props on the JSX side.
@@ -277,7 +959,7 @@ function readJsxAttrs(node, captureValues) {
277
959
  events.push(name.slice(2).toLowerCase());
278
960
  continue;
279
961
  }
280
- if (!attr.value) {
962
+ if (!jsxAttr.value) {
281
963
  // Boolean shorthand `<Foo bar />` → literal `true`.
282
964
  const usage = { name, isDynamic: false };
283
965
  if (captureValues)
@@ -285,177 +967,75 @@ function readJsxAttrs(node, captureValues) {
285
967
  props.push(usage);
286
968
  continue;
287
969
  }
288
- 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") {
289
973
  const usage = { name, isDynamic: false };
290
974
  if (captureValues)
291
- usage.literalValue = attr.value.value;
975
+ usage.literalValue = jsxAttr.value.value;
292
976
  props.push(usage);
293
977
  continue;
294
978
  }
295
- if (attr.value.type === "JSXExpressionContainer") {
296
- const expr = attr.value.expression;
297
- if (expr.type === "StringLiteral") {
298
- const usage = { name, isDynamic: false };
299
- if (captureValues)
300
- usage.literalValue = expr.value;
301
- props.push(usage);
302
- }
303
- else if (expr.type === "NumericLiteral") {
304
- const usage = { name, isDynamic: false };
305
- if (captureValues)
306
- usage.literalValue = expr.value;
307
- props.push(usage);
308
- }
309
- else if (expr.type === "BooleanLiteral") {
310
- const usage = { name, isDynamic: false };
311
- if (captureValues)
312
- usage.literalValue = expr.value;
313
- 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;
314
1000
  }
315
- else if (expr.type === "Identifier") {
1001
+ if (expr.type === "Identifier") {
316
1002
  props.push({ name, isDynamic: true, dynamicKind: "identifier" });
1003
+ continue;
317
1004
  }
318
- else {
319
- props.push({ name, isDynamic: true, dynamicKind: "expr" });
320
- }
1005
+ props.push({ name, isDynamic: true, dynamicKind: "expr" });
321
1006
  }
322
1007
  }
323
1008
  return { props, events };
324
1009
  }
325
- function detectWrapper(path, file, localBindings, lookupByTag, wrappers) {
326
- const name = wrapperName(path);
327
- if (!name)
328
- return;
329
- if (!/^[A-Z]/.test(name))
330
- return;
331
- const seen = new Set();
332
- const deps = [];
333
- function addDep(cid) {
334
- const key = serialiseComponentId(cid);
335
- if (!seen.has(key)) {
336
- seen.add(key);
337
- deps.push(cid);
338
- }
339
- }
340
- path.traverse({
341
- JSXOpeningElement(inner) {
342
- const n = inner.node.name;
343
- if (n.type !== "JSXIdentifier")
344
- return;
345
- const id = n.name;
346
- if (/^[a-z]/.test(id) && id.includes("-")) {
347
- const indexed = lookupByTag(asTagName(id));
348
- if (indexed) {
349
- addDep({
350
- kind: "custom-element",
351
- tagName: asTagName(id),
352
- source: { type: "external", package: externalPackageOf(indexed.source) },
353
- });
354
- }
355
- return;
356
- }
357
- const bound = localBindings.get(id);
358
- // Only WC-wrapper bindings count as wrapper component-deps; React-direct
359
- // bindings refer to other React components, which are not WC-wrappers.
360
- if (bound && bound.kind === "wc-wrapper") {
361
- addDep({
362
- kind: "custom-element",
363
- tagName: bound.tagName,
364
- source: { type: "external", package: bound.package },
365
- });
366
- }
367
- },
368
- });
369
- if (deps.length === 0)
370
- return;
371
- const id = wrapperId(name);
372
- wrappers.push({
373
- id,
374
- name,
375
- file: posixPath(file),
376
- exportName: detectExportName(path, name),
377
- componentDeps: deps,
378
- });
379
- }
380
- function wrapperName(path) {
381
- const node = path.node;
382
- if (node.type === "FunctionDeclaration" && node.id)
383
- return node.id.name;
384
- if (node.type === "ClassDeclaration" && node.id)
385
- return node.id.name;
386
- if (path.parent.type === "VariableDeclarator" && path.parent.id.type === "Identifier") {
387
- return path.parent.id.name;
388
- }
389
- return undefined;
390
- }
391
- function detectExportName(path, declName) {
392
- // Direct containment: declaration is itself part of an export statement.
393
- let p = path;
394
- while (p) {
395
- if (p.parent && p.parent.type === "ExportDefaultDeclaration")
396
- return "default";
397
- if (p.parent && p.parent.type === "ExportNamedDeclaration")
398
- return declName;
399
- p = p.parentPath;
400
- }
401
- // Indirect: a separate `export default Foo;` or `export { Foo as Bar };` references
402
- // this declaration by name. Scan the top-level program body.
403
- const program = path.findParent((parent) => parent.isProgram());
404
- if (program?.isProgram()) {
405
- for (const stmt of program.node.body) {
406
- if (stmt.type === "ExportDefaultDeclaration" &&
407
- stmt.declaration.type === "Identifier" &&
408
- stmt.declaration.name === declName) {
409
- return "default";
410
- }
411
- if (stmt.type === "ExportNamedDeclaration" && !stmt.declaration) {
412
- for (const spec of stmt.specifiers) {
413
- if (spec.type !== "ExportSpecifier")
414
- continue;
415
- if (spec.local.name !== declName)
416
- continue;
417
- if (spec.exported.type === "Identifier" && spec.exported.name === "default") {
418
- return "default";
419
- }
420
- return declName;
421
- }
422
- }
423
- }
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
424
1025
  }
425
- return declName;
426
- }
427
- function wrapperId(name) {
428
- const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
429
- return asWrapperId(`wrap-${kebab}`);
1026
+ return true;
430
1027
  }
431
1028
  /**
432
- * Render-prop guard: returns true when `path` (a JSXElement) is lexically
433
- * nested inside a function body or JSXAttribute that lives between it and
434
- * the topmost tracked parent (`parentNode`). React render-props pass children
435
- * opaquely through a function (`<DataLoader>{(d) => <Card />}</DataLoader>`,
436
- * `{items.map(i => <Card />)}`) or through a prop value (`<Modal title={<H/>}>`),
437
- * so descendants in that scope must NOT inherit the host as their parent.
438
- *
439
- * JSXExpressionContainer alone is transparent — `{cond && <Card />}` and
440
- * `{a ? <X /> : <Y />}` should keep lexical-parent attribution per
441
- * §Failure modes' "conditional rendering visible" rule. The function/attribute
442
- * boundary is what differentiates render-prop from a bare expression escape.
443
- *
444
- * (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.
445
1033
  */
446
- function isInRenderPropScope(path, parentNode) {
447
- if (!parentNode)
448
- return false;
449
- let p = path.parentPath;
450
- while (p && p.node !== parentNode) {
451
- if (p.isArrowFunctionExpression() ||
452
- p.isFunctionExpression() ||
453
- p.isFunctionDeclaration() ||
454
- p.isJSXAttribute()) {
455
- return true;
456
- }
457
- p = p.parentPath;
1034
+ function unwrapParens(node) {
1035
+ let cur = node;
1036
+ while (cur.type === "ParenthesizedExpression") {
1037
+ cur = cur.expression;
458
1038
  }
459
- return false;
1039
+ return cur;
460
1040
  }
461
1041
  //# sourceMappingURL=walk-jsx.js.map