@component-compass/reference-graph 0.1.17 → 0.1.19

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 (42) hide show
  1. package/dist/builder.d.ts +7 -1
  2. package/dist/builder.js +9 -3
  3. package/dist/builder.js.map +1 -1
  4. package/dist/engine/argument-map.d.ts +4 -0
  5. package/dist/engine/argument-map.js +7 -4
  6. package/dist/engine/argument-map.js.map +1 -1
  7. package/dist/engine/assert-never.d.ts +7 -0
  8. package/dist/engine/assert-never.js +10 -0
  9. package/dist/engine/assert-never.js.map +1 -0
  10. package/dist/engine/component-shape.d.ts +38 -0
  11. package/dist/engine/component-shape.js +177 -0
  12. package/dist/engine/component-shape.js.map +1 -0
  13. package/dist/engine/helper-callers.d.ts +43 -28
  14. package/dist/engine/helper-callers.js +94 -96
  15. package/dist/engine/helper-callers.js.map +1 -1
  16. package/dist/engine/host-element.d.ts +37 -0
  17. package/dist/engine/host-element.js +55 -0
  18. package/dist/engine/host-element.js.map +1 -0
  19. package/dist/engine/index.d.ts +4 -0
  20. package/dist/engine/index.js +312 -99
  21. package/dist/engine/index.js.map +1 -1
  22. package/dist/engine/library-stubs.d.ts +2 -0
  23. package/dist/engine/library-stubs.js +42 -0
  24. package/dist/engine/library-stubs.js.map +1 -0
  25. package/dist/engine/member-identity.d.ts +24 -4
  26. package/dist/engine/member-identity.js +28 -4
  27. package/dist/engine/member-identity.js.map +1 -1
  28. package/dist/engine/registry.d.ts +100 -0
  29. package/dist/engine/registry.js +242 -0
  30. package/dist/engine/registry.js.map +1 -0
  31. package/dist/engine/resolve-reference.js +7 -0
  32. package/dist/engine/resolve-reference.js.map +1 -1
  33. package/dist/engine/resolve-type.js +54 -1
  34. package/dist/engine/resolve-type.js.map +1 -1
  35. package/dist/engine/wrapper-folding.d.ts +21 -3
  36. package/dist/engine/wrapper-folding.js +386 -91
  37. package/dist/engine/wrapper-folding.js.map +1 -1
  38. package/dist/index.d.ts +6 -1
  39. package/dist/index.js +4 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/types/file-graph.d.ts +21 -10
  42. package/package.json +2 -2
@@ -1,24 +1,71 @@
1
+ import { asTagName, computeOccurrenceId, serialiseComponentId, projectPropUsages, parseCompoundExport } from "@component-compass/plugin-core";
1
2
  import { MODULE_SCOPE } from "../index.js";
2
- import { asTagName, computeOccurrenceId, serialiseComponentId, projectPropUsages } from "@component-compass/plugin-core";
3
3
  import { createArgumentMap } from "./argument-map.js";
4
4
  import { resolveReference } from "./resolve-reference.js";
5
- import { walkWithFolding } from "./wrapper-folding.js";
5
+ import { walkWithFolding, argumentProvenance } from "./wrapper-folding.js";
6
6
  import { packageNameFromSpecifier, externalSubpath } from "./specifier.js";
7
7
  import { followReExportChain } from "./re-export-chain.js";
8
- import { buildHelperCallers } from "./helper-callers.js";
8
+ import { buildHelperCallers, findLocalDeclaration } from "./helper-callers.js";
9
9
  import { resolveOwnerChain } from "./owner-resolution.js";
10
10
  import { createCycleGuard } from "./cycle-detection.js";
11
- import { unparsedMemberDefinition, effectiveExportName } from "./member-identity.js";
11
+ import { unparsedMemberDefinition, effectiveExportName, residualMemberChain, compoundExportName } from "./member-identity.js";
12
+ import { buildComponentRegistry, excludeFoldedHolders, excludeHostElementNames, entryKey } from "./registry.js";
13
+ import { hasOnlyHostElementNames, isHostElementName } from "./host-element.js";
14
+ /**
15
+ * JSX treats a bare, lowercase-initial element name as a host element
16
+ * (`div`, `svg`, `web-button`) — it never names a binding, so it is not an
17
+ * unresolved reference. A member chain (`motion.div`) or a capitalised name
18
+ * is a component reference and IS reported when unbound (#518 D7).
19
+ */
20
+ function isHostElementRoot(ref) {
21
+ return ref.memberChain.length === 0 && isHostElementName(ref.symbol);
22
+ }
12
23
  export function resolve(graph, opts) {
13
24
  const occurrences = [];
14
25
  const argMap = createArgumentMap();
15
- const helperIndex = buildHelperCallers(graph);
26
+ // The registry is the one judge of component-ness (#559): owner
27
+ // classification asks it (through the host-element narrowing, so a
28
+ // lowercase member is a helper for ownership while the base registry still
29
+ // gates the argument-site holder loop below).
30
+ const registry = buildComponentRegistry(graph);
31
+ const helperIndex = buildHelperCallers(graph, excludeHostElementNames(registry, graph));
32
+ // THE admission rule (spec D1/D12, ruling 14: shape-only). A local
33
+ // react-component identity must be component-shaped; consumption is the
34
+ // usage itself (a JSX usage that resolves to a local declaration IS that
35
+ // declaration's consumption — the engine's own alias/union/dynamic-map/
36
+ // re-export/wrapper-fold resolution already did that work, so admission
37
+ // never re-derives it). Everything the registry does not type (externals,
38
+ // custom elements, vue-dialect declarations, workspace-member definitions
39
+ // outside the graph) is admitted as before — except a non-code file, which
40
+ // is never a component.
41
+ //
42
+ // Ruling 17: a workspace-member path outside the graph (the bounded
43
+ // resolver pins it but never parses it — a lazy `import("./PhoneInput")`
44
+ // whose target file wasn't walked) is rejected only when its path HAS an
45
+ // extension that is provably not code (`hero.png`, `.SVG`). An
46
+ // extensionless path (`./PhoneInput` resolved without a suffix — the
47
+ // resolver's own concern, not this rule's) is admitted: it's far more
48
+ // likely an unwalked source file than a non-code asset, and this rule has
49
+ // no way to tell without walking it.
50
+ const CODE_EXT = /\.(?:[cm]?[jt]sx?|vue)$/i;
51
+ const HAS_EXT = /\.[^./\\]+$/;
52
+ const admit = (id) => {
53
+ if (id.kind !== "react-component" || id.source.type !== "local")
54
+ return true;
55
+ const { filePath } = id.source;
56
+ const declaring = graph.files.get(filePath);
57
+ if (declaring === undefined)
58
+ return !HAS_EXT.test(filePath) || CODE_EXT.test(filePath);
59
+ if (declaring.dialect === "vue")
60
+ return true;
61
+ return registry.isComponentShaped(filePath, id.export);
62
+ };
16
63
  // Parallel sidecar holding the per-file usage index, kind, and (pre-resolved)
17
64
  // parent-usage index for each emitted occurrence. Used in pass 2 to resolve
18
65
  // `parentRef`. Held off-band (not on `EngineOccurrence`) so the public type
19
- // stays clean and scratch state can't leak into output. `null` slots
20
- // correspond to occurrences that have no composition source (defensive
21
- // every push path stamps a value today). Pre-capturing `parentUsageIdx` at
66
+ // stays clean and scratch state can't leak into output. `null` slots mean
67
+ // the occurrence has no lexical JSX parent the argument-site seeding pass
68
+ // stamps nothing for its occurrences. Pre-capturing `parentUsageIdx` at
22
69
  // stamp time lets pass 2 do an O(1) lookup per occurrence instead of an
23
70
  // O(ownership) `.find()` — matters on large files (hundreds of usages).
24
71
  const composition = [];
@@ -39,19 +86,28 @@ export function resolve(graph, opts) {
39
86
  // same identity AND same owner — always a spurious duplicate. The
40
87
  // `composition` sidecar isn't populated for this slice until the stamp loop
41
88
  // below, so the compaction only touches `occurrences`.
42
- const stampComposition = (lengthBefore, kind, fileUsageIdx, depth, parentUsageIdx) => {
89
+ // THE per-slice compaction seam (admission + #248 dedup). Every emission
90
+ // path — a usage's slice via `stampComposition`, a holder's argument-site
91
+ // slice below — routes through it; never re-derive admission or dedup at a
92
+ // push site.
93
+ const compactSlice = (lengthBefore) => {
43
94
  const seenOccurrenceIds = new Set();
44
95
  let write = lengthBefore;
45
96
  for (let read = lengthBefore; read < occurrences.length; read++) {
46
97
  const occ = occurrences[read];
47
98
  if (occ === undefined)
48
99
  continue;
100
+ if (!admit(occ.rawComponentId))
101
+ continue;
49
102
  if (seenOccurrenceIds.has(occ.occurrenceId))
50
103
  continue;
51
104
  seenOccurrenceIds.add(occ.occurrenceId);
52
105
  occurrences[write++] = occ;
53
106
  }
54
107
  occurrences.length = write;
108
+ };
109
+ const stampComposition = (lengthBefore, kind, fileUsageIdx, depth, parentUsageIdx) => {
110
+ compactSlice(lengthBefore);
55
111
  for (let i = lengthBefore; i < occurrences.length; i++) {
56
112
  const occ = occurrences[i];
57
113
  if (!occ)
@@ -80,8 +136,24 @@ export function resolve(graph, opts) {
80
136
  if (!matchedImport) {
81
137
  // Symbol is locally declared (no import) — emit local identity.
82
138
  const decl = findLocalDeclaration(fileGraph, usage.ref);
83
- if (!decl)
139
+ if (!decl) {
140
+ if (!isHostElementRoot(usage.ref)) {
141
+ // No import record and no declaration in scope: the engine cannot
142
+ // attribute this usage. Surface it (#518 D7 / #111) instead of
143
+ // dropping it silently — `info`, because the usual cause is a
144
+ // global or macro-provided binding the scan cannot see.
145
+ opts?.collector?.emit({
146
+ code: "unresolved-reference",
147
+ severity: "info",
148
+ filePath,
149
+ line: usage.loc.line,
150
+ column: usage.loc.column,
151
+ symbol: usage.ref.symbol,
152
+ memberChain: [...usage.ref.memberChain],
153
+ });
154
+ }
84
155
  continue;
156
+ }
85
157
  pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGraph, argMap, ownerRef, helperIndex, opts, viaOverride);
86
158
  stampComposition(jsxLengthBefore, "jsx", usageIdx, jsxDepth, jsxParentUsageIdx);
87
159
  continue;
@@ -91,6 +163,11 @@ export function resolve(graph, opts) {
91
163
  // every export-name / re-export lookup below keys on this, not the raw
92
164
  // `imported` (#362). `via.import` keeps the raw `imported` (provenance).
93
165
  const effectiveImported = effectiveExportName(matchedImport.imported, usage.ref.memberChain);
166
+ // #518 D4: lookups below key on `effectiveImported` (the ROOT); the
167
+ // identity stamped on a fallback is the root plus the residual chain.
168
+ // A member the walker resolves to its own declaration never reaches a
169
+ // fallback, so this only names members with no declaration of their own.
170
+ const residual = residualMemberChain(matchedImport.imported, usage.ref.memberChain);
94
171
  if (!packageName) {
95
172
  // Relative / absolute specifier — always a local file reference.
96
173
  // Resolve the target path; normalize absolute → graph-relative if possible.
@@ -152,7 +229,7 @@ export function resolve(graph, opts) {
152
229
  };
153
230
  const fallbackComponentId = {
154
231
  kind: componentKindFor(fileGraph.dialect),
155
- export: exportName,
232
+ export: compoundExportName(exportName, residual),
156
233
  source: { type: "local", filePath: canonicalFile },
157
234
  };
158
235
  if (viaOverride !== undefined) {
@@ -235,9 +312,12 @@ export function resolve(graph, opts) {
235
312
  const jsxTerminals = foldedTerminals.filter((t) => t.type.kind === "JSX");
236
313
  if (jsxTerminals.length === 0)
237
314
  continue;
238
- const localFallback = workspaceSiblingFallback(graph, resolvedNormTarget, matchedImport, effectiveImported, fileGraph.dialect);
315
+ const siblingFallback = workspaceSiblingFallback(graph, resolvedNormTarget, matchedImport, effectiveImported, fileGraph.dialect);
316
+ const localFallback = siblingFallback !== undefined && siblingFallback.kind !== "custom-element"
317
+ ? { ...siblingFallback, export: compoundExportName(siblingFallback.export, residual) }
318
+ : siblingFallback;
239
319
  for (const ft of jsxTerminals) {
240
- pushOccurrence(occurrences, filePath, usage, matchedImport, effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, localFallback, viaOverride);
320
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, localFallback, viaOverride);
241
321
  }
242
322
  }
243
323
  else {
@@ -255,13 +335,13 @@ export function resolve(graph, opts) {
255
335
  if (member !== null) {
256
336
  const localFallback = {
257
337
  kind: componentKindFor(fileGraph.dialect),
258
- export: member.exportName,
338
+ export: compoundExportName(member.exportName, residual),
259
339
  source: { type: "local", filePath: member.absFile },
260
340
  };
261
- pushOccurrence(occurrences, filePath, usage, matchedImport, effectiveImported, packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, localFallback, viaOverride);
341
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, localFallback, viaOverride);
262
342
  }
263
343
  else {
264
- pushOccurrence(occurrences, filePath, usage, matchedImport, effectiveImported, packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, undefined, viaOverride);
344
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, undefined, viaOverride);
265
345
  }
266
346
  }
267
347
  stampComposition(jsxLengthBefore, "jsx", usageIdx, jsxDepth, jsxParentUsageIdx);
@@ -319,6 +399,80 @@ export function resolve(graph, opts) {
319
399
  stampComposition(tagLengthBefore, "tag", i, tagDepth, tagParentUsageIdx);
320
400
  }
321
401
  }
402
+ // Argument-site seeding (spec §4, D4/D5; PR 2 rulings R1–R3). A module-
403
+ // scope registry member whose value is a call — a wrapper product — is a
404
+ // *holder*. Its own fold decides everything: when the fold reaches JSX and
405
+ // every terminal carries neither an identity nor a `hoc-wrapper` hop, the
406
+ // holder is its own identity
407
+ // (D9 arm 2: the callee was visible and built a new function) and credited
408
+ // none of its arguments, so each identifier argument of the OUTERMOST call
409
+ // that resolves to a component gets one occurrence at the argument's own
410
+ // position, owned by the holder. A fold that carried an identity credited
411
+ // an argument already (arm 1 / arm 3) and seeds nothing — a site is never
412
+ // counted twice. The registry gate keeps an unused, unexported holder from
413
+ // owning anything (no phantom owner by construction). Runs after the usage
414
+ // loops so a holder's slice never interleaves with a usage's; there is no
415
+ // composition sidecar entry because an argument site has no lexical JSX
416
+ // parent.
417
+ // #539 D1/D2: the SAME fold answers membership. A holder whose every JSX
418
+ // terminal carries an identity has credited another identity for every
419
+ // render — it is the route to that identity, not a member. Collected here,
420
+ // applied once through `excludeFoldedHolders` at the return below.
421
+ const folded = new Map();
422
+ for (const [filePath, fileGraph] of graph.files) {
423
+ if (fileGraph.dialect === "vue")
424
+ continue;
425
+ for (const decl of fileGraph.declarations.values()) {
426
+ if (decl.scope !== MODULE_SCOPE || decl.value.kind !== "ReturnTypeOf")
427
+ continue;
428
+ if (!registry.hasLocal(filePath, decl.symbol))
429
+ continue;
430
+ const holderJsx = walkWithFolding(graph, fileGraph, decl.value, argMap).filter((t) => t.type.kind === "JSX");
431
+ const foldedAway = foldedHolder(holderJsx, filePath);
432
+ if (foldedAway !== null)
433
+ folded.set(entryKey(filePath, decl.symbol), foldedAway);
434
+ const lengthBefore = occurrences.length;
435
+ pushArgumentSiteOccurrences(occurrences, filePath, decl, decl.value, holderJsx, graph, fileGraph, argMap, opts);
436
+ compactSlice(lengthBefore);
437
+ }
438
+ }
439
+ // Hook argument seeding (#561). A component passed to a hook call inside a
440
+ // top-level function body (`const [modal] = useModalTwoStatic(Modal)`) is
441
+ // used at that site: a hook's product is a value the caller interpolates,
442
+ // never a tag, so no render site will ever pay for the argument and R1's
443
+ // "the fold credited it already" reasoning does not apply. Same R2/R3
444
+ // argument rules and the same push seam as the holder loop; the owner is
445
+ // the enclosing declaration through `resolveOwnerChain`, so a helper hook
446
+ // fans the seed out to each caller with a `helper-call` hop. A hook is
447
+ // named by `HOOK_NAME`, React's own `isHookName` predicate (rules-of-hooks
448
+ // and the compiler) — there is no structural signal for "hook".
449
+ for (const [filePath, fileGraph] of graph.files) {
450
+ if (fileGraph.dialect === "vue")
451
+ continue;
452
+ for (const bodyCall of fileGraph.bodyCalls) {
453
+ if (!HOOK_NAME.test(bodyCall.callee.symbol) || bodyCall.args.length === 0)
454
+ continue;
455
+ const call = {
456
+ kind: "ReturnTypeOf",
457
+ callee: { kind: "TypeOf", ref: bodyCall.callee },
458
+ args: bodyCall.args,
459
+ };
460
+ const ownerRef = {
461
+ symbol: bodyCall.ownerSymbol,
462
+ scope: MODULE_SCOPE,
463
+ memberChain: [],
464
+ loc: { line: 0, column: 0 },
465
+ originFile: filePath,
466
+ };
467
+ const owners = () => resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard()).map((res) => ({
468
+ rawOwnerComponentId: ownerIdFromResolution(graph, res.ownerDecl),
469
+ viaPrefix: res.viaPrefix,
470
+ }));
471
+ const lengthBefore = occurrences.length;
472
+ pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph, fileGraph, argMap, opts);
473
+ compactSlice(lengthBefore);
474
+ }
475
+ }
322
476
  // Pass 2: resolve `parentRef` from per-file `parentUsageIdx` ownership data.
323
477
  // Pass 1 populated the `composition` sidecar with `(fileUsageIdx, kind)` per
324
478
  // emitted occurrence, which lets us build a `(filePath, kind, fileUsageIdx)
@@ -343,25 +497,7 @@ export function resolve(graph, opts) {
343
497
  if (parentIdx !== undefined)
344
498
  occ.parentRef = parentIdx;
345
499
  }
346
- return { occurrences };
347
- }
348
- /**
349
- * Walk the scope chain for `ref.symbol` in `fileGraph.declarations` and
350
- * return the first matching BindingDecl, or null if not found.
351
- */
352
- function findLocalDeclaration(fileGraph, ref) {
353
- let scope = ref.scope;
354
- while (true) {
355
- const decl = fileGraph.declarations.get(`${scope}::${ref.symbol}`);
356
- if (decl)
357
- return decl;
358
- if (scope === MODULE_SCOPE)
359
- return null;
360
- const parent = fileGraph.scopes.get(scope)?.parent;
361
- if (parent === null || parent === undefined)
362
- return null;
363
- scope = parent;
364
- }
500
+ return { occurrences, registry: excludeHostElementNames(excludeFoldedHolders(registry, folded), graph) };
365
501
  }
366
502
  /**
367
503
  * Build a local-scope ComponentId for a symbol declared in `filePath`.
@@ -389,8 +525,8 @@ function dialectForFile(graph, filePath) {
389
525
  }
390
526
  /**
391
527
  * Owner ComponentId for a parser-pre-decided owner reference (#151
392
- * prop-forward and dynamic-binding paths). `fallbackFile` is the consuming
393
- * file, used when the ref carries no originFile.
528
+ * prop-forward path). `fallbackFile` is the consuming file, used when the ref
529
+ * carries no originFile.
394
530
  *
395
531
  * This and `ownerIdFromResolution` are the ONLY places an occurrence's
396
532
  * `rawOwnerComponentId` may be built — the dialect must come from the
@@ -503,12 +639,16 @@ function relabelExternalLeaf(rawComponentId, filePath, specifier, resolveExterna
503
639
  rawComponentId.source.type !== "external") {
504
640
  return rawComponentId;
505
641
  }
506
- const hit = resolveExternalLeaf(filePath, specifier, rawComponentId.export);
642
+ // A compound export (#518) names a member of the package's ROOT export;
643
+ // the leaf walk resolves the root and the member chain is re-joined onto
644
+ // whatever export the leaf calls it.
645
+ const { root, path } = parseCompoundExport(rawComponentId.export);
646
+ const hit = resolveExternalLeaf(filePath, specifier, root);
507
647
  if (!hit || hit.leafPackage === rawComponentId.source.package)
508
648
  return rawComponentId;
509
649
  return {
510
650
  kind: rawComponentId.kind,
511
- export: hit.exportName,
651
+ export: compoundExportName(hit.exportName, path),
512
652
  source: externalSource(hit.leafPackage, `${hit.leafPackage}/${hit.modulePath}`),
513
653
  };
514
654
  }
@@ -571,9 +711,10 @@ function computeWraps(rawComponentId, wrapCallback) {
571
711
  return { kind: "custom-element", tagName: hit.tagName, packageName: hit.packageName };
572
712
  }
573
713
  function pushOccurrence(occurrences, filePath, usage, matchedImport,
574
- /** Export name for the fallback identity — namespace members already
575
- * resolved via `effectiveExportName` (#362). Distinct from
576
- * `matchedImport.imported`, which stays raw for `via` provenance. */
714
+ /** Export name for the fallback identity — the root export resolved via
715
+ * `effectiveExportName` (#362) joined with the residual member chain via
716
+ * `compoundExportName` (#518). Distinct from `matchedImport.imported`,
717
+ * which stays raw for `via` provenance. */
577
718
  effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, fallbackOverride, viaOverride) {
578
719
  const fallback = fallbackOverride ?? {
579
720
  kind: componentKindFor(fileGraph.dialect),
@@ -646,17 +787,20 @@ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opt
646
787
  * Walks the decl's value via walkWithFolding and fans out per JSX terminal.
647
788
  */
648
789
  function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGraph, argMap, ownerRef, helperIndex, opts, viaOverride) {
649
- const folded = walkWithFolding(graph, fileGraph, decl.value, argMap);
790
+ // A member access on a same-file binding (`<LocalNS.Leaf/>`) walks the
791
+ // MEMBER, not the whole object — `resolveReference` applies the usage's
792
+ // member chain exactly as the import path already gets it (#520 shape 4,
793
+ // the same static-MemberOf arm as #512). A bare reference is unchanged:
794
+ // `decl.value` is what `resolveReference` returns for an empty chain.
795
+ const value = usage.ref.memberChain.length > 0 ? resolveReference(graph, fileGraph, usage.ref) : decl.value;
796
+ const folded = walkWithFolding(graph, fileGraph, value, argMap);
650
797
  const jsxTerminals = folded.filter((t) => t.type.kind === "JSX");
651
- if (jsxTerminals.length === 0) {
652
- if (decl.dynamicSource) {
653
- // For dynamic-binding occurrences, derive a single owner (no fanout needed —
654
- // dynamic bindings are not the target of helper-call reclassification).
655
- const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, fileGraph.filePath);
656
- pushDynamicBindingOccurrence(occurrences, filePath, usage, decl.dynamicSource, fileGraph.dialect, rawOwnerComponentId);
657
- }
798
+ // A reference that resolves to no JSX terminal — a function parameter
799
+ // (`<Component/>` inside a factory body), a hook-return destructure, or an
800
+ // unresolvable alias emits nothing (spec D4/D7). The old dynamic-binding
801
+ // phantom (`{ packageName: null, exportName: "Component" }`) is gone.
802
+ if (jsxTerminals.length === 0)
658
803
  return;
659
- }
660
804
  const outerVia = viaOverride ?? { kind: "local-component" };
661
805
  if (viaOverride !== undefined) {
662
806
  // #151 prop-forward: parser has pre-decided the owner — single-occurrence path.
@@ -664,7 +808,7 @@ function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGrap
664
808
  for (const ft of jsxTerminals) {
665
809
  const fallback = {
666
810
  kind: componentKindFor(fileGraph.dialect),
667
- export: usage.ref.symbol,
811
+ export: compoundExportName(usage.ref.symbol, usage.ref.memberChain),
668
812
  source: { type: "local", filePath },
669
813
  };
670
814
  const rawComponentId = componentIdFromIdentity(ft.identity, fallback, fileGraph.dialect, {
@@ -696,7 +840,7 @@ function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGrap
696
840
  for (const ft of jsxTerminals) {
697
841
  const fallback = {
698
842
  kind: componentKindFor(fileGraph.dialect),
699
- export: usage.ref.symbol,
843
+ export: compoundExportName(usage.ref.symbol, usage.ref.memberChain),
700
844
  source: { type: "local", filePath },
701
845
  };
702
846
  const rawComponentId = componentIdFromIdentity(ft.identity, fallback, fileGraph.dialect, {
@@ -726,55 +870,124 @@ function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGrap
726
870
  }
727
871
  }
728
872
  /**
729
- * Emit a single occurrence for a JSX root that resolves to a dynamic binding
730
- * (function parameter or destructure of a CallExpression return). The runtime
731
- * identity is genuinely indeterminate, so:
732
- * - componentId.source = { type: "unknown" }
733
- * - componentId.export = the binding's local name
734
- * - via.kind = "dynamic-binding" with bindingName + bindingSource
873
+ * #539 D1: a holder is folded away iff its fold reaches JSX and EVERY JSX
874
+ * terminal carries an identity the fold credited someone else for every
875
+ * render. Any identity-less terminal means the holder is its own identity
876
+ * (spec D9 arm 2: `memo(createField(Base))`, `forwardRef((p, r) => <b/>)`).
877
+ * D4: the target is reported only when all terminals name ONE local member of
878
+ * the holder's own file; that member inherits `isDefault` in the seam.
879
+ * Returns `null` when the holder is not folded.
880
+ */
881
+ function foldedHolder(holderJsx, filePath) {
882
+ if (holderJsx.length === 0 || holderJsx.some((t) => t.identity === null))
883
+ return null;
884
+ const first = holderJsx[0]?.identity;
885
+ const sameFileSingle = first !== undefined &&
886
+ first !== null &&
887
+ first.kind === "local" &&
888
+ first.filePath === filePath &&
889
+ holderJsx.every((t) => t.identity !== null && t.identity.kind === "local" && t.identity.filePath === filePath && t.identity.export === first.export);
890
+ return { target: sameFileSingle ? { filePath, export: first.export } : null };
891
+ }
892
+ /**
893
+ * Argument-site seeding for one holder (spec §4; PR 2 rulings R1–R3) over the
894
+ * fold the caller already ran (#539 D2) — the rule is stated at the call site
895
+ * in `resolve`. Identity goes through `componentIdFromIdentity` (the
896
+ * external-leaf relabel seam) and the owner through `ownerIdFromRef`, exactly
897
+ * as every other push path; the binding is named through `argumentProvenance`,
898
+ * the same helper `hoc-wrapper` uses. The caller compacts the slice
899
+ * (admission + dedup).
900
+ */
901
+ function pushArgumentSiteOccurrences(occurrences, filePath, holder, call, holderJsx, graph, fileGraph, argMap, opts) {
902
+ // R1: the holder is its own identity iff its fold reaches JSX and no
903
+ // terminal carries an identity of its own OR a `hoc-wrapper` hop — the hop
904
+ // is the fold's own record that it credited an argument (arm 1 / arm 3),
905
+ // even when that argument was anonymous (`forwardRef((p, r) => <b/>, x)`),
906
+ // so the holder is a wrapper of it and its other arguments are
907
+ // configuration, not wrapped components.
908
+ if (holderJsx.length === 0 ||
909
+ holderJsx.some((t) => t.identity !== null || t.viaTrail.some((v) => v.kind === "hoc-wrapper"))) {
910
+ return;
911
+ }
912
+ const holderRef = {
913
+ symbol: holder.symbol,
914
+ scope: MODULE_SCOPE,
915
+ memberChain: [],
916
+ loc: holder.loc,
917
+ originFile: filePath,
918
+ };
919
+ // #540: the holder still seeds (its argument is a real usage), but a
920
+ // declaration that can never be rendered under any of its names is not a
921
+ // component and cannot own — the phantom-owner rule the registry gate
922
+ // above encodes, applied to the one owner this loop builds directly.
923
+ const rawOwnerComponentId = hasOnlyHostElementNames(holder.symbol, fileGraph)
924
+ ? undefined
925
+ : ownerIdFromRef(graph, holderRef, filePath);
926
+ pushCallArgumentOccurrences(occurrences, filePath, call, () => [{ rawOwnerComponentId, viaPrefix: [] }], graph, fileGraph, argMap, opts);
927
+ }
928
+ /** React's own hook predicate (`isHookName` in rules-of-hooks and the compiler). */
929
+ const HOOK_NAME = /^use[A-Z0-9]/;
930
+ /**
931
+ * THE argument-site push seam: one `passed-as-argument` occurrence per
932
+ * (identifier argument that resolves to a component) × (owner). The
933
+ * module-scope holder loop and the hook loop in `resolve` both call it;
934
+ * neither re-derives the argument rules or the push.
735
935
  *
736
- * memberChain on the usage is intentionally droppedclaiming `.Foo` of an
737
- * unknown adds noise without information.
936
+ * R2: the outermost call's arguments onlyan inner call's arguments
937
+ * configure the wrapper, they are not wrapped. R3: identifier arguments
938
+ * only, each resolved through the walker so aliases, re-exports and
939
+ * import-backed leaves take the same path a render site does; a null
940
+ * identity is skipped, never invented. R4 (#561): an identifier that is a
941
+ * host-element name (`useSelector(pick, shallowEqual)`) can never be
942
+ * rendered as a component, whatever it resolves to — an external leaf has
943
+ * no shape to check and would otherwise synthesise a component out of any
944
+ * import; the same predicate the held-reference seam and #540 apply.
738
945
  *
739
- * `definition` is intentionally absent: the declaration site of a parameter
740
- * is the function signature, which doesn't represent a component definition
741
- * in the local-component aggregation sense.
946
+ * `owners` is resolved on the first push and reused: most hook calls
947
+ * (`useState(x)`, `useEffect(fn, deps)`) never reach a push, and resolving
948
+ * the owner chain for each of them measured as roughly half of the hook
949
+ * loop's cost on WebClients (#561 PR body, scan time).
742
950
  */
743
- function pushDynamicBindingOccurrence(occurrences, filePath, usage, bindingSource, fileDialect, rawOwnerComponentId) {
744
- // Skip intrinsic HTML/SVG elements. React's convention: JSX whose root
745
- // identifier starts with a lowercase letter is always an intrinsic
746
- // (`<div>`, `<title>`, `<label>`, …), never a component. Without this
747
- // guard, auto-generated icon components like `function Icon({ title }) {
748
- // return <svg><title>{title}</title></svg>; }` produce a noisy
749
- // dynamic-binding occurrence for the `<title>` intrinsic — a false
750
- // positive that swamps the real signal.
751
- const first = usage.ref.symbol.charCodeAt(0);
752
- if (first >= 97 && first <= 122)
753
- return; // ASCII 'a'–'z'
754
- const rawComponentId = {
755
- kind: componentKindFor(fileDialect),
756
- export: usage.ref.symbol,
757
- source: { type: "unknown" },
758
- };
759
- const via = {
760
- kind: "dynamic-binding",
761
- bindingName: usage.ref.symbol,
762
- bindingSource,
763
- };
764
- const provisionalId = serialiseComponentId(rawComponentId);
765
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
766
- occurrences.push({
767
- rawComponentId,
768
- filePath,
769
- line: usage.loc.line,
770
- column: usage.loc.column,
771
- via,
772
- viaChain: [via],
773
- props: projectPropUsages(usage.props),
774
- events: usage.events ?? [],
775
- occurrenceId,
776
- depth: 0,
777
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
951
+ function pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph, fileGraph, argMap, opts) {
952
+ let resolvedOwners;
953
+ call.args.forEach((arg, index) => {
954
+ if (arg.kind !== "TypeOf" || isHostElementName(arg.ref.symbol))
955
+ return;
956
+ const outerVia = { kind: "passed-as-argument", index, ...argumentProvenance(call, arg, fileGraph) };
957
+ const argDecl = findLocalDeclaration(fileGraph, arg.ref);
958
+ // Never applied: every terminal used below carries a non-null identity,
959
+ // and `buildComponentId` returns the fallback only for a null one. The
960
+ // relabel seam's signature requires a fallback all the same.
961
+ const unusedFallback = makeLocalComponentId(filePath, arg.ref.symbol, fileGraph.dialect);
962
+ for (const ft of walkWithFolding(graph, fileGraph, arg, argMap)) {
963
+ if (ft.type.kind !== "JSX" || ft.identity === null)
964
+ continue;
965
+ const rawComponentId = componentIdFromIdentity(ft.identity, unusedFallback, fileGraph.dialect, {
966
+ filePath,
967
+ resolveExternalLeaf: opts?.resolveExternalLeaf,
968
+ });
969
+ const wraps = computeWraps(rawComponentId, opts?.wrapCallback);
970
+ resolvedOwners ??= owners();
971
+ for (const { rawOwnerComponentId, viaPrefix } of resolvedOwners) {
972
+ const viaChain = [...viaPrefix, outerVia, ...ft.viaTrail];
973
+ const occurrenceId = computeOccurrenceId(serialiseComponentId(rawComponentId), filePath, arg.ref.loc.line, arg.ref.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
974
+ occurrences.push({
975
+ rawComponentId,
976
+ filePath,
977
+ line: arg.ref.loc.line,
978
+ column: arg.ref.loc.column,
979
+ via: viaChain[0] ?? outerVia,
980
+ viaChain,
981
+ props: {},
982
+ events: [],
983
+ occurrenceId,
984
+ depth: 0,
985
+ ...(argDecl !== null ? { definition: { line: argDecl.loc.line, column: argDecl.loc.column } } : {}),
986
+ ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
987
+ ...(wraps !== undefined ? { wraps } : {}),
988
+ });
989
+ }
990
+ }
778
991
  });
779
992
  }
780
993
  /**