@component-compass/reference-graph 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/builder.d.ts +10 -4
  2. package/dist/builder.js +13 -9
  3. package/dist/builder.js.map +1 -1
  4. package/dist/engine/assert-never.d.ts +1 -1
  5. package/dist/engine/assert-never.js +1 -1
  6. package/dist/engine/component-shape.d.ts +18 -7
  7. package/dist/engine/component-shape.js +48 -18
  8. package/dist/engine/component-shape.js.map +1 -1
  9. package/dist/engine/helper-callers.d.ts +24 -14
  10. package/dist/engine/helper-callers.js +43 -70
  11. package/dist/engine/helper-callers.js.map +1 -1
  12. package/dist/engine/host-element.d.ts +37 -0
  13. package/dist/engine/host-element.js +55 -0
  14. package/dist/engine/host-element.js.map +1 -0
  15. package/dist/engine/index.d.ts +4 -4
  16. package/dist/engine/index.js +181 -91
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/engine/library-stubs.js +1 -1
  19. package/dist/engine/member-identity.d.ts +9 -10
  20. package/dist/engine/member-identity.js +9 -10
  21. package/dist/engine/member-identity.js.map +1 -1
  22. package/dist/engine/re-export-chain.d.ts +1 -1
  23. package/dist/engine/re-export-chain.js +4 -3
  24. package/dist/engine/re-export-chain.js.map +1 -1
  25. package/dist/engine/registry.d.ts +43 -19
  26. package/dist/engine/registry.js +66 -50
  27. package/dist/engine/registry.js.map +1 -1
  28. package/dist/engine/resolve-reference.js +1 -1
  29. package/dist/engine/resolve-reference.js.map +1 -1
  30. package/dist/engine/resolve-type.d.ts +12 -0
  31. package/dist/engine/resolve-type.js +44 -52
  32. package/dist/engine/resolve-type.js.map +1 -1
  33. package/dist/engine/wrapper-folding.d.ts +22 -3
  34. package/dist/engine/wrapper-folding.js +146 -132
  35. package/dist/engine/wrapper-folding.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/types/file-graph.d.ts +29 -13
  40. package/dist/types/inferred-type.d.ts +2 -1
  41. package/package.json +2 -2
@@ -9,32 +9,38 @@ import { buildHelperCallers, findLocalDeclaration } from "./helper-callers.js";
9
9
  import { resolveOwnerChain } from "./owner-resolution.js";
10
10
  import { createCycleGuard } from "./cycle-detection.js";
11
11
  import { unparsedMemberDefinition, effectiveExportName, residualMemberChain, compoundExportName } from "./member-identity.js";
12
- import { buildComponentRegistry, excludeFoldedHolders, entryKey } from "./registry.js";
12
+ import { buildComponentRegistry, excludeFoldedHolders, excludeHostElementNames, entryKey } from "./registry.js";
13
+ import { hasOnlyHostElementNames, isHostElementName } from "./host-element.js";
14
+ import { firstDynamicImport } from "./component-shape.js";
13
15
  /**
14
16
  * JSX treats a bare, lowercase-initial element name as a host element
15
17
  * (`div`, `svg`, `web-button`) — it never names a binding, so it is not an
16
18
  * unresolved reference. A member chain (`motion.div`) or a capitalised name
17
- * is a component reference and IS reported when unbound (#518 D7).
19
+ * is a component reference and IS reported when unbound.
18
20
  */
19
21
  function isHostElementRoot(ref) {
20
- return ref.memberChain.length === 0 && /^[a-z]/.test(ref.symbol);
22
+ return ref.memberChain.length === 0 && isHostElementName(ref.symbol);
21
23
  }
22
24
  export function resolve(graph, opts) {
23
25
  const occurrences = [];
24
26
  const argMap = createArgumentMap();
25
- const helperIndex = buildHelperCallers(graph);
27
+ // The registry is the one judge of component-ness: owner
28
+ // classification asks it (through the host-element narrowing, so a
29
+ // lowercase member is a helper for ownership while the base registry still
30
+ // gates the argument-site holder loop below).
26
31
  const registry = buildComponentRegistry(graph);
27
- // THE admission rule (spec D1/D12, ruling 14: shape-only). A local
32
+ const helperIndex = buildHelperCallers(graph, excludeHostElementNames(registry, graph));
33
+ // THE admission rule (shape-only). A local
28
34
  // react-component identity must be component-shaped; consumption is the
29
35
  // usage itself (a JSX usage that resolves to a local declaration IS that
30
36
  // declaration's consumption — the engine's own alias/union/dynamic-map/
31
37
  // re-export/wrapper-fold resolution already did that work, so admission
32
38
  // never re-derives it). Everything the registry does not type (externals,
33
39
  // custom elements, vue-dialect declarations, workspace-member definitions
34
- // outside the graph) is admitted as before — except a non-code file, which
35
- // is never a component.
40
+ // outside the graph) is admitted — except a non-code file, which is never a
41
+ // component.
36
42
  //
37
- // Ruling 17: a workspace-member path outside the graph (the bounded
43
+ // A workspace-member path outside the graph (the bounded
38
44
  // resolver pins it but never parses it — a lazy `import("./PhoneInput")`
39
45
  // whose target file wasn't walked) is rejected only when its path HAS an
40
46
  // extension that is provably not code (`hero.png`, `.SVG`). An
@@ -65,23 +71,23 @@ export function resolve(graph, opts) {
65
71
  // O(ownership) `.find()` — matters on large files (hundreds of usages).
66
72
  const composition = [];
67
73
  // Finalise the occurrences emitted by one usage (the slice since
68
- // `lengthBefore`): collapse #248 duplicates, then stamp composition info +
74
+ // `lengthBefore`): collapse duplicates, then stamp composition info +
69
75
  // a `depth` sourced from the ownership entry. Called after each per-usage
70
76
  // emit so helper-call fanout (multiple occurrences per usage) carries
71
77
  // identical composition metadata across the fan.
72
78
  //
73
- // #248 dedup: resolveType's `Function` case flatMaps every `return`, so a
79
+ // Dedup: resolveType's `Function` case flatMaps every `return`, so a
74
80
  // referenced component whose body has N `return <JSX>` branches yields N
75
81
  // folded terminals that all collapse to one identity — emitting N records
76
82
  // sharing an `occurrenceId` (componentId + file + line + column + owner).
77
83
  // Collapsing is scoped to THIS usage's slice, so genuinely-distinct
78
- // cross-usage repeats that share an occurrenceId (e.g. a #151 prop-forward
84
+ // cross-usage repeats that share an occurrenceId (e.g. a prop-forward
79
85
  // of one `const x = <Y/>` literal into two sibling call sites) are separate
80
86
  // usages and never merged. Within one usage, a shared occurrenceId means
81
87
  // same identity AND same owner — always a spurious duplicate. The
82
88
  // `composition` sidecar isn't populated for this slice until the stamp loop
83
89
  // below, so the compaction only touches `occurrences`.
84
- // THE per-slice compaction seam (admission + #248 dedup). Every emission
90
+ // THE per-slice compaction seam (admission + dedup). Every emission
85
91
  // path — a usage's slice via `stampComposition`, a holder's argument-site
86
92
  // slice below — routes through it; never re-derive admission or dedup at a
87
93
  // push site.
@@ -134,7 +140,7 @@ export function resolve(graph, opts) {
134
140
  if (!decl) {
135
141
  if (!isHostElementRoot(usage.ref)) {
136
142
  // No import record and no declaration in scope: the engine cannot
137
- // attribute this usage. Surface it (#518 D7 / #111) instead of
143
+ // attribute this usage. Surface it instead of
138
144
  // dropping it silently — `info`, because the usual cause is a
139
145
  // global or macro-provided binding the scan cannot see.
140
146
  opts?.collector?.emit({
@@ -156,9 +162,9 @@ export function resolve(graph, opts) {
156
162
  const packageName = packageNameFromSpecifier(matchedImport.specifier);
157
163
  // Resolve namespace-import members to their export name once, up front —
158
164
  // every export-name / re-export lookup below keys on this, not the raw
159
- // `imported` (#362). `via.import` keeps the raw `imported` (provenance).
165
+ // `imported`. `via.import` keeps the raw `imported` (provenance).
160
166
  const effectiveImported = effectiveExportName(matchedImport.imported, usage.ref.memberChain);
161
- // #518 D4: lookups below key on `effectiveImported` (the ROOT); the
167
+ // Lookups below key on `effectiveImported` (the ROOT); the
162
168
  // identity stamped on a fallback is the root plus the residual chain.
163
169
  // A member the walker resolves to its own declaration never reaches a
164
170
  // fallback, so this only names members with no declaration of their own.
@@ -169,7 +175,7 @@ export function resolve(graph, opts) {
169
175
  const resolvedAbsTarget = graph.moduleResolver(filePath, matchedImport.specifier);
170
176
  if (!resolvedAbsTarget) {
171
177
  // A JSX usage whose import failed to resolve is a dropped local
172
- // reference — surface it instead of vanishing (#369).
178
+ // reference — surface it instead of vanishing.
173
179
  opts?.collector?.emit({
174
180
  code: "unresolved-import",
175
181
  severity: "warning",
@@ -188,12 +194,14 @@ export function resolve(graph, opts) {
188
194
  const refType = resolveReference(graph, fileGraph, usage.ref);
189
195
  const folded = walkWithFolding(graph, fileGraph, refType, argMap);
190
196
  const jsxTerminals = folded.filter((t) => t.type.kind === "JSX");
191
- if (jsxTerminals.length === 0)
197
+ if (jsxTerminals.length === 0) {
198
+ emitLazyImportUnsupported(opts, filePath, usage.loc, refType);
192
199
  continue;
200
+ }
193
201
  // Determine the canonical source file + local export name. When the
194
202
  // target is a re-export barrel (`export { X } from './source'`),
195
203
  // follow the chain to the actual definition file so JSX edges
196
- // attribute to the source rather than the barrel (#101). Without
204
+ // attribute to the source rather than the barrel. Without
197
205
  // this, two consumers importing the same component through different
198
206
  // re-export paths fragment across phantom identities.
199
207
  const chained = followReExportChain(graph, resolvedTarget, effectiveImported);
@@ -228,7 +236,7 @@ export function resolve(graph, opts) {
228
236
  source: { type: "local", filePath: canonicalFile },
229
237
  };
230
238
  if (viaOverride !== undefined) {
231
- // #151 prop-forward: parser has pre-decided the owner — single-occurrence path.
239
+ // Prop-forward: parser has pre-decided the owner — single-occurrence path.
232
240
  const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, fileGraph.filePath);
233
241
  for (const ft of jsxTerminals) {
234
242
  const rawComponentId = componentIdFromIdentity(ft.identity, fallbackComponentId, fileGraph.dialect, {
@@ -297,7 +305,7 @@ export function resolve(graph, opts) {
297
305
  : resolvedAbsTarget;
298
306
  const targetInGraph = resolvedNormTarget !== null && graph.files.has(resolvedNormTarget);
299
307
  if (targetInGraph) {
300
- // Target file is in graph — workspace sibling (#94). Resolve to check
308
+ // Target file is in graph — workspace sibling. Resolve to check
301
309
  // JSX type, fan out per terminal. The fallback identity is local so
302
310
  // null-identity terminals still attribute to the sibling file rather
303
311
  // than a phantom external stub. Terminal identities from deriveIdentity
@@ -305,8 +313,10 @@ export function resolve(graph, opts) {
305
313
  const refType = resolveReference(graph, fileGraph, usage.ref);
306
314
  const foldedTerminals = walkWithFolding(graph, fileGraph, refType, argMap);
307
315
  const jsxTerminals = foldedTerminals.filter((t) => t.type.kind === "JSX");
308
- if (jsxTerminals.length === 0)
316
+ if (jsxTerminals.length === 0) {
317
+ emitLazyImportUnsupported(opts, filePath, usage.loc, refType);
309
318
  continue;
319
+ }
310
320
  const siblingFallback = workspaceSiblingFallback(graph, resolvedNormTarget, matchedImport, effectiveImported, fileGraph.dialect);
311
321
  const localFallback = siblingFallback !== undefined && siblingFallback.kind !== "custom-element"
312
322
  ? { ...siblingFallback, export: compoundExportName(siblingFallback.export, residual) }
@@ -316,7 +326,7 @@ export function resolve(graph, opts) {
316
326
  }
317
327
  }
318
328
  else {
319
- // Target not in graph. Workspace membership (spec Piece 2, #327): a
329
+ // Target not in graph. Workspace membership: a
320
330
  // resolved target owned by a workspace member is first-party — emit
321
331
  // LOCAL, pinned to the definition file (bounded re-export resolution)
322
332
  // so identity is scope-invariant between whole-repo and single-app
@@ -394,22 +404,22 @@ export function resolve(graph, opts) {
394
404
  stampComposition(tagLengthBefore, "tag", i, tagDepth, tagParentUsageIdx);
395
405
  }
396
406
  }
397
- // Argument-site seeding (spec §4, D4/D5; PR 2 rulings R1–R3). A module-
407
+ // Argument-site seeding. A module-
398
408
  // scope registry member whose value is a call — a wrapper product — is a
399
409
  // *holder*. Its own fold decides everything: when the fold reaches JSX and
400
410
  // every terminal carries neither an identity nor a `hoc-wrapper` hop, the
401
411
  // holder is its own identity
402
- // (D9 arm 2: the callee was visible and built a new function) and credited
412
+ // (the callee was visible and built a new function) and credited
403
413
  // none of its arguments, so each identifier argument of the OUTERMOST call
404
414
  // that resolves to a component gets one occurrence at the argument's own
405
415
  // position, owned by the holder. A fold that carried an identity credited
406
- // an argument already (arm 1 / arm 3) and seeds nothing — a site is never
416
+ // an argument already and seeds nothing — a site is never
407
417
  // counted twice. The registry gate keeps an unused, unexported holder from
408
418
  // owning anything (no phantom owner by construction). Runs after the usage
409
419
  // loops so a holder's slice never interleaves with a usage's; there is no
410
420
  // composition sidecar entry because an argument site has no lexical JSX
411
421
  // parent.
412
- // #539 D1/D2: the SAME fold answers membership. A holder whose every JSX
422
+ // The SAME fold answers membership. A holder whose every JSX
413
423
  // terminal carries an identity has credited another identity for every
414
424
  // render — it is the route to that identity, not a member. Collected here,
415
425
  // applied once through `excludeFoldedHolders` at the return below.
@@ -431,6 +441,43 @@ export function resolve(graph, opts) {
431
441
  compactSlice(lengthBefore);
432
442
  }
433
443
  }
444
+ // Hook argument seeding. A component passed to a hook call inside a
445
+ // top-level function body (`const [modal] = useModalTwoStatic(Modal)`) is
446
+ // used at that site: a hook's product is a value the caller interpolates,
447
+ // never a tag, so no render site will ever pay for the argument and the
448
+ // "fold credited it already" reasoning does not apply. Same argument rules
449
+ // and the same push seam as the holder loop; the owner is
450
+ // the enclosing declaration through `resolveOwnerChain`, so a helper hook
451
+ // fans the seed out to each caller with a `helper-call` hop. A hook is
452
+ // named by `HOOK_NAME`, React's own `isHookName` predicate (rules-of-hooks
453
+ // and the compiler) — there is no structural signal for "hook".
454
+ for (const [filePath, fileGraph] of graph.files) {
455
+ if (fileGraph.dialect === "vue")
456
+ continue;
457
+ for (const bodyCall of fileGraph.bodyCalls) {
458
+ if (!HOOK_NAME.test(bodyCall.callee.symbol) || bodyCall.args.length === 0)
459
+ continue;
460
+ const call = {
461
+ kind: "ReturnTypeOf",
462
+ callee: { kind: "TypeOf", ref: bodyCall.callee },
463
+ args: bodyCall.args,
464
+ };
465
+ const ownerRef = {
466
+ symbol: bodyCall.ownerSymbol,
467
+ scope: MODULE_SCOPE,
468
+ memberChain: [],
469
+ loc: { line: 0, column: 0 },
470
+ originFile: filePath,
471
+ };
472
+ const owners = () => resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard()).map((res) => ({
473
+ rawOwnerComponentId: ownerIdFromResolution(graph, res.ownerDecl),
474
+ viaPrefix: res.viaPrefix,
475
+ }));
476
+ const lengthBefore = occurrences.length;
477
+ pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph, fileGraph, argMap, opts);
478
+ compactSlice(lengthBefore);
479
+ }
480
+ }
434
481
  // Pass 2: resolve `parentRef` from per-file `parentUsageIdx` ownership data.
435
482
  // Pass 1 populated the `composition` sidecar with `(fileUsageIdx, kind)` per
436
483
  // emitted occurrence, which lets us build a `(filePath, kind, fileUsageIdx)
@@ -455,7 +502,7 @@ export function resolve(graph, opts) {
455
502
  if (parentIdx !== undefined)
456
503
  occ.parentRef = parentIdx;
457
504
  }
458
- return { occurrences, registry: excludeFoldedHolders(registry, folded) };
505
+ return { occurrences, registry: excludeHostElementNames(excludeFoldedHolders(registry, folded), graph) };
459
506
  }
460
507
  /**
461
508
  * Build a local-scope ComponentId for a symbol declared in `filePath`.
@@ -482,7 +529,7 @@ function dialectForFile(graph, filePath) {
482
529
  return graph.files.get(filePath)?.dialect ?? "react";
483
530
  }
484
531
  /**
485
- * Owner ComponentId for a parser-pre-decided owner reference (#151
532
+ * Owner ComponentId for a parser-pre-decided owner reference (the
486
533
  * prop-forward path). `fallbackFile` is the consuming file, used when the ref
487
534
  * carries no originFile.
488
535
  *
@@ -513,17 +560,14 @@ function ownerIdFromResolution(graph, ownerDecl) {
513
560
  * (e.g. `@shoelace/dist/components/button` → `modulePath: "dist/components/button"`)
514
561
  * so per-subpath components stay distinct. Relative imports canonicalise to
515
562
  * `identity.resolvedFile` (repo-relative POSIX from the moduleResolver) so
516
- * `'./page'` and `'../page'` from different callers collapse to one identity
517
- * (#109).
563
+ * `'./page'` and `'../page'` from different callers collapse to one identity.
518
564
  *
519
565
  * The component kind is derived from `fileDialect` via `componentKindFor` —
520
566
  * `"vue"` dialect files emit `vue-component`, all others emit `react-component`.
521
- * A future `manifestKindHint` parameter will let callers override the
522
- * dialect-derived default when a manifest entry resolves to a concrete kind.
523
567
  * Tag-usage paths emit `custom-element` directly and don't call this helper.
524
568
  *
525
569
  * When `ctx` is supplied, this is also the single seam that applies the
526
- * external-leaf relabel (#242) — see `relabelExternalLeaf`. The specifier fed
570
+ * external-leaf relabel — see `relabelExternalLeaf`. The specifier fed
527
571
  * to the relabel hook is the folded terminal's OWN specifier when `identity`
528
572
  * is `"imported"` (it may diverge from the consuming file's own import
529
573
  * specifier, e.g. an in-graph barrel re-wrap), falling back to
@@ -572,7 +616,7 @@ function buildComponentId(identity, fallback, fileDialect) {
572
616
  * Build the external `ComponentSource` for an import specifier. Stamps
573
617
  * `modulePath` with the subpath after the package name when one is present,
574
618
  * so subpath imports like `@shoelace/dist/components/button` stay distinct
575
- * from `/icon` (#109). Bare package specs (`react`, `@org/pkg`) omit
619
+ * from `/icon`. Bare package specs (`react`, `@org/pkg`) omit
576
620
  * modulePath. Subpath derivation (including module-extension normalisation, so
577
621
  * `react/button` and `react/button.js` collapse to one identity) lives in the
578
622
  * `externalSubpath` seam.
@@ -585,7 +629,7 @@ function externalSource(pkg, specifier) {
585
629
  }
586
630
  /**
587
631
  * Relabel an external react-component identity to the leaf package its
588
- * re-export chain terminates in (#242). No-op unless the hook is supplied,
632
+ * re-export chain terminates in. No-op unless the hook is supplied,
589
633
  * the identity is an external react-component, and the walk crossed a
590
634
  * package boundary. The relabeled modulePath routes through the
591
635
  * `externalSource`/`externalSubpath` seam so extension normalisation stays
@@ -597,7 +641,7 @@ function relabelExternalLeaf(rawComponentId, filePath, specifier, resolveExterna
597
641
  rawComponentId.source.type !== "external") {
598
642
  return rawComponentId;
599
643
  }
600
- // A compound export (#518) names a member of the package's ROOT export;
644
+ // A compound export names a member of the package's ROOT export;
601
645
  // the leaf walk resolves the root and the member chain is re-joined onto
602
646
  // whatever export the leaf calls it.
603
647
  const { root, path } = parseCompoundExport(rawComponentId.export);
@@ -611,7 +655,7 @@ function relabelExternalLeaf(rawComponentId, filePath, specifier, resolveExterna
611
655
  };
612
656
  }
613
657
  /**
614
- * Build the local fallback ComponentId for a workspace-sibling import (#94).
658
+ * Build the local fallback ComponentId for a workspace-sibling import.
615
659
  * When a bare-package import (`@org/foo`) resolves to a file in the parsed
616
660
  * graph, the package is a workspace sibling — JSX edges should land on the
617
661
  * sibling's actual file rather than a phantom external stub.
@@ -621,11 +665,11 @@ function relabelExternalLeaf(rawComponentId, filePath, specifier, resolveExterna
621
665
  * default external fallback.
622
666
  */
623
667
  function workspaceSiblingFallback(graph, resolvedTarget, matchedImport,
624
- /** Namespace-member-resolved export name (#362); see `effectiveExportName`. */
668
+ /** Namespace-member-resolved export name; see `effectiveExportName`. */
625
669
  effectiveImported, consumerDialect) {
626
670
  if (!resolvedTarget)
627
671
  return undefined;
628
- // Follow re-exports (#101) — workspace siblings can also use barrels.
672
+ // Follow re-exports — workspace siblings can also use barrels.
629
673
  const chained = followReExportChain(graph, resolvedTarget, effectiveImported);
630
674
  const canonicalFile = chained?.file ?? resolvedTarget;
631
675
  let exportName = chained?.localExport ?? effectiveImported;
@@ -670,8 +714,8 @@ function computeWraps(rawComponentId, wrapCallback) {
670
714
  }
671
715
  function pushOccurrence(occurrences, filePath, usage, matchedImport,
672
716
  /** Export name for the fallback identity — the root export resolved via
673
- * `effectiveExportName` (#362) joined with the residual member chain via
674
- * `compoundExportName` (#518). Distinct from `matchedImport.imported`,
717
+ * `effectiveExportName` joined with the residual member chain via
718
+ * `compoundExportName`. Distinct from `matchedImport.imported`,
675
719
  * which stays raw for `via` provenance. */
676
720
  effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, fallbackOverride, viaOverride) {
677
721
  const fallback = fallbackOverride ?? {
@@ -694,7 +738,7 @@ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opt
694
738
  import: matchedImport.imported,
695
739
  };
696
740
  if (viaOverride !== undefined) {
697
- // #151 prop-forward: parser has pre-decided the owner — single-occurrence path.
741
+ // Prop-forward: parser has pre-decided the owner — single-occurrence path.
698
742
  const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, filePath);
699
743
  const viaChain = [viaOverride];
700
744
  const provisionalId = serialiseComponentId(rawComponentId);
@@ -740,6 +784,27 @@ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opt
740
784
  });
741
785
  }
742
786
  }
787
+ /**
788
+ * A fold that reached no JSX while an import() sits in the walked value is a
789
+ * lazy shape the pipeline could not resolve to a component: a `.then` callback
790
+ * that does more than pick a property, or a target whose component shape could
791
+ * not be confirmed. Report it at the render site so the miss is visible, and
792
+ * name the file the `import()` is declared in when that is not the render file.
793
+ */
794
+ function emitLazyImportUnsupported(opts, filePath, loc, value) {
795
+ const di = opts?.collector ? firstDynamicImport(value) : null;
796
+ if (!di)
797
+ return;
798
+ opts?.collector?.emit({
799
+ code: "lazy-import-unsupported",
800
+ severity: "warning",
801
+ filePath,
802
+ line: loc.line,
803
+ column: loc.column,
804
+ specifier: di.specifier,
805
+ detail: `the import() target could not be resolved to a component${di.originFile === filePath ? "" : ` (declared in ${di.originFile})`}`,
806
+ });
807
+ }
743
808
  /**
744
809
  * Push local-scope occurrences for a symbol declared in the same file (no import).
745
810
  * Walks the decl's value via walkWithFolding and fans out per JSX terminal.
@@ -747,21 +812,22 @@ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opt
747
812
  function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGraph, argMap, ownerRef, helperIndex, opts, viaOverride) {
748
813
  // A member access on a same-file binding (`<LocalNS.Leaf/>`) walks the
749
814
  // MEMBER, not the whole object — `resolveReference` applies the usage's
750
- // member chain exactly as the import path already gets it (#520 shape 4,
751
- // the same static-MemberOf arm as #512). A bare reference is unchanged:
815
+ // member chain exactly as the import path already gets it (the same
816
+ // static-MemberOf arm). A bare reference is unchanged:
752
817
  // `decl.value` is what `resolveReference` returns for an empty chain.
753
818
  const value = usage.ref.memberChain.length > 0 ? resolveReference(graph, fileGraph, usage.ref) : decl.value;
754
819
  const folded = walkWithFolding(graph, fileGraph, value, argMap);
755
820
  const jsxTerminals = folded.filter((t) => t.type.kind === "JSX");
756
821
  // A reference that resolves to no JSX terminal — a function parameter
757
822
  // (`<Component/>` inside a factory body), a hook-return destructure, or an
758
- // unresolvable alias — emits nothing (spec D4/D7). The old dynamic-binding
759
- // phantom (`{ packageName: null, exportName: "Component" }`) is gone.
760
- if (jsxTerminals.length === 0)
823
+ // unresolvable alias — emits no occurrence.
824
+ if (jsxTerminals.length === 0) {
825
+ emitLazyImportUnsupported(opts, filePath, usage.loc, value);
761
826
  return;
827
+ }
762
828
  const outerVia = viaOverride ?? { kind: "local-component" };
763
829
  if (viaOverride !== undefined) {
764
- // #151 prop-forward: parser has pre-decided the owner — single-occurrence path.
830
+ // Prop-forward: parser has pre-decided the owner — single-occurrence path.
765
831
  const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, fileGraph.filePath);
766
832
  for (const ft of jsxTerminals) {
767
833
  const fallback = {
@@ -828,11 +894,11 @@ function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGrap
828
894
  }
829
895
  }
830
896
  /**
831
- * #539 D1: a holder is folded away iff its fold reaches JSX and EVERY JSX
897
+ * A holder is folded away iff its fold reaches JSX and EVERY JSX
832
898
  * terminal carries an identity — the fold credited someone else for every
833
899
  * render. Any identity-less terminal means the holder is its own identity
834
- * (spec D9 arm 2: `memo(createField(Base))`, `forwardRef((p, r) => <b/>)`).
835
- * D4: the target is reported only when all terminals name ONE local member of
900
+ * (`memo(createField(Base))`, `forwardRef((p, r) => <b/>)`).
901
+ * The target is reported only when all terminals name ONE local member of
836
902
  * the holder's own file; that member inherits `isDefault` in the seam.
837
903
  * Returns `null` when the holder is not folded.
838
904
  */
@@ -848,8 +914,8 @@ function foldedHolder(holderJsx, filePath) {
848
914
  return { target: sameFileSingle ? { filePath, export: first.export } : null };
849
915
  }
850
916
  /**
851
- * Argument-site seeding for one holder (spec §4; PR 2 rulings R1–R3) over the
852
- * fold the caller already ran (#539 D2) — the rule is stated at the call site
917
+ * Argument-site seeding for one holder over the fold the caller already ran
918
+ * — the rule is stated at the call site
853
919
  * in `resolve`. Identity goes through `componentIdFromIdentity` (the
854
920
  * external-leaf relabel seam) and the owner through `ownerIdFromRef`, exactly
855
921
  * as every other push path; the binding is named through `argumentProvenance`,
@@ -857,9 +923,9 @@ function foldedHolder(holderJsx, filePath) {
857
923
  * (admission + dedup).
858
924
  */
859
925
  function pushArgumentSiteOccurrences(occurrences, filePath, holder, call, holderJsx, graph, fileGraph, argMap, opts) {
860
- // R1: the holder is its own identity iff its fold reaches JSX and no
926
+ // The holder is its own identity iff its fold reaches JSX and no
861
927
  // terminal carries an identity of its own OR a `hoc-wrapper` hop — the hop
862
- // is the fold's own record that it credited an argument (arm 1 / arm 3),
928
+ // is the fold's own record that it credited an argument,
863
929
  // even when that argument was anonymous (`forwardRef((p, r) => <b/>, x)`),
864
930
  // so the holder is a wrapper of it and its other arguments are
865
931
  // configuration, not wrapped components.
@@ -874,15 +940,42 @@ function pushArgumentSiteOccurrences(occurrences, filePath, holder, call, holder
874
940
  loc: holder.loc,
875
941
  originFile: filePath,
876
942
  };
877
- const rawOwnerComponentId = ownerIdFromRef(graph, holderRef, filePath);
878
- const ownerKey = rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined;
879
- // R2: the outermost call's arguments only an inner call's arguments
880
- // configure the wrapper, they are not wrapped. R3: identifier arguments
881
- // only, each resolved through the walker so aliases, re-exports and
882
- // import-backed leaves take the same path a render site does; a null
883
- // identity is skipped, never invented.
943
+ // The holder still seeds (its argument is a real usage), but a
944
+ // declaration that can never be rendered under any of its names is not a
945
+ // component and cannot own the phantom-owner rule the registry gate
946
+ // above encodes, applied to the one owner this loop builds directly.
947
+ const rawOwnerComponentId = hasOnlyHostElementNames(holder.symbol, fileGraph)
948
+ ? undefined
949
+ : ownerIdFromRef(graph, holderRef, filePath);
950
+ pushCallArgumentOccurrences(occurrences, filePath, call, () => [{ rawOwnerComponentId, viaPrefix: [] }], graph, fileGraph, argMap, opts);
951
+ }
952
+ /** React's own hook predicate (`isHookName` in rules-of-hooks and the compiler). */
953
+ const HOOK_NAME = /^use[A-Z0-9]/;
954
+ /**
955
+ * THE argument-site push seam: one `passed-as-argument` occurrence per
956
+ * (identifier argument that resolves to a component) × (owner). The
957
+ * module-scope holder loop and the hook loop in `resolve` both call it;
958
+ * neither re-derives the argument rules or the push.
959
+ *
960
+ * The outermost call's arguments only — an inner call's arguments configure
961
+ * the wrapper, they are not wrapped. Identifier arguments only, each
962
+ * resolved through the walker so aliases, re-exports and import-backed
963
+ * leaves take the same path a render site does; a null identity is skipped,
964
+ * never invented. An identifier that is a host-element name (`useSelector(pick, shallowEqual)`) can never be
965
+ * rendered as a component, whatever it resolves to — an external leaf has
966
+ * no shape to check and would otherwise synthesise a component out of any
967
+ * import; the same predicate the held-reference seam and the roster naming
968
+ * gate apply.
969
+ *
970
+ * `owners` is resolved on the first push and reused: most hook calls
971
+ * (`useState(x)`, `useEffect(fn, deps)`) never reach a push, and resolving
972
+ * the owner chain for each of them measured as roughly half of the hook
973
+ * loop's scan-time cost on a large consumer repo.
974
+ */
975
+ function pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph, fileGraph, argMap, opts) {
976
+ let resolvedOwners;
884
977
  call.args.forEach((arg, index) => {
885
- if (arg.kind !== "TypeOf")
978
+ if (arg.kind !== "TypeOf" || isHostElementName(arg.ref.symbol))
886
979
  return;
887
980
  const outerVia = { kind: "passed-as-argument", index, ...argumentProvenance(call, arg, fileGraph) };
888
981
  const argDecl = findLocalDeclaration(fileGraph, arg.ref);
@@ -898,22 +991,26 @@ function pushArgumentSiteOccurrences(occurrences, filePath, holder, call, holder
898
991
  resolveExternalLeaf: opts?.resolveExternalLeaf,
899
992
  });
900
993
  const wraps = computeWraps(rawComponentId, opts?.wrapCallback);
901
- const occurrenceId = computeOccurrenceId(serialiseComponentId(rawComponentId), filePath, arg.ref.loc.line, arg.ref.loc.column, ownerKey);
902
- occurrences.push({
903
- rawComponentId,
904
- filePath,
905
- line: arg.ref.loc.line,
906
- column: arg.ref.loc.column,
907
- via: outerVia,
908
- viaChain: [outerVia, ...ft.viaTrail],
909
- props: {},
910
- events: [],
911
- occurrenceId,
912
- depth: 0,
913
- ...(argDecl !== null ? { definition: { line: argDecl.loc.line, column: argDecl.loc.column } } : {}),
914
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
915
- ...(wraps !== undefined ? { wraps } : {}),
916
- });
994
+ resolvedOwners ??= owners();
995
+ for (const { rawOwnerComponentId, viaPrefix } of resolvedOwners) {
996
+ const viaChain = [...viaPrefix, outerVia, ...ft.viaTrail];
997
+ const occurrenceId = computeOccurrenceId(serialiseComponentId(rawComponentId), filePath, arg.ref.loc.line, arg.ref.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
998
+ occurrences.push({
999
+ rawComponentId,
1000
+ filePath,
1001
+ line: arg.ref.loc.line,
1002
+ column: arg.ref.loc.column,
1003
+ via: viaChain[0] ?? outerVia,
1004
+ viaChain,
1005
+ props: {},
1006
+ events: [],
1007
+ occurrenceId,
1008
+ depth: 0,
1009
+ ...(argDecl !== null ? { definition: { line: argDecl.loc.line, column: argDecl.loc.column } } : {}),
1010
+ ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
1011
+ ...(wraps !== undefined ? { wraps } : {}),
1012
+ });
1013
+ }
917
1014
  }
918
1015
  });
919
1016
  }
@@ -963,18 +1060,11 @@ function pushTagOccurrence(occurrences, filePath, usage, rawComponentId, ownerRe
963
1060
  }
964
1061
  }
965
1062
  /**
966
- * Decide the `kind` for a JSX-emitted ComponentId.
967
- *
968
- * Resolution order:
969
- * 1. If a manifest entry returned a concrete kind hint, use it verbatim
970
- * (parser-vue's future CEM-hit path will thread this through; today no
971
- * caller supplies it).
972
- * 2. Otherwise fall back to the file's dialect — `"vue"` files emit
973
- * `"vue-component"`, all others emit `"react-component"`.
974
- *
975
- * `manifestKindHint` is wired through now so the future plumbing (parser-vue
976
- * + CEM lookups in A2) is a single-site change rather than another caller
977
- * sweep. Tag-usage paths (custom-element) don't call this helper.
1063
+ * Decide the `kind` for a JSX-emitted ComponentId. A concrete
1064
+ * `manifestKindHint` is used verbatim; otherwise the file's dialect decides —
1065
+ * `"vue"` files emit `"vue-component"`, all others `"react-component"`. No
1066
+ * caller supplies a hint today. Tag-usage paths (custom-element) don't call
1067
+ * this helper.
978
1068
  */
979
1069
  function componentKindFor(fileDialect, manifestKindHint) {
980
1070
  if (manifestKindHint !== undefined)