@component-compass/reference-graph 0.1.15 → 0.1.18

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 (36) hide show
  1. package/dist/engine/argument-map.d.ts +4 -0
  2. package/dist/engine/argument-map.js +7 -4
  3. package/dist/engine/argument-map.js.map +1 -1
  4. package/dist/engine/assert-never.d.ts +7 -0
  5. package/dist/engine/assert-never.js +10 -0
  6. package/dist/engine/assert-never.js.map +1 -0
  7. package/dist/engine/component-shape.d.ts +38 -0
  8. package/dist/engine/component-shape.js +177 -0
  9. package/dist/engine/component-shape.js.map +1 -0
  10. package/dist/engine/helper-callers.d.ts +31 -29
  11. package/dist/engine/helper-callers.js +98 -78
  12. package/dist/engine/helper-callers.js.map +1 -1
  13. package/dist/engine/index.d.ts +4 -0
  14. package/dist/engine/index.js +239 -99
  15. package/dist/engine/index.js.map +1 -1
  16. package/dist/engine/library-stubs.d.ts +2 -0
  17. package/dist/engine/library-stubs.js +42 -0
  18. package/dist/engine/library-stubs.js.map +1 -0
  19. package/dist/engine/member-identity.d.ts +24 -4
  20. package/dist/engine/member-identity.js +28 -4
  21. package/dist/engine/member-identity.js.map +1 -1
  22. package/dist/engine/registry.d.ts +74 -0
  23. package/dist/engine/registry.js +226 -0
  24. package/dist/engine/registry.js.map +1 -0
  25. package/dist/engine/resolve-reference.js +7 -0
  26. package/dist/engine/resolve-reference.js.map +1 -1
  27. package/dist/engine/resolve-type.js +54 -1
  28. package/dist/engine/resolve-type.js.map +1 -1
  29. package/dist/engine/wrapper-folding.d.ts +21 -3
  30. package/dist/engine/wrapper-folding.js +386 -91
  31. package/dist/engine/wrapper-folding.js.map +1 -1
  32. package/dist/index.d.ts +5 -1
  33. package/dist/index.js +3 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/types/file-graph.d.ts +0 -10
  36. package/package.json +2 -2
@@ -1,9 +1,21 @@
1
1
  import { resolveType, resolveToFunctions, DYNAMIC_MEMBER_KEY } from "./resolve-type.js";
2
+ import { argumentRefKey } from "./argument-map.js";
2
3
  import { createCycleGuard } from "./cycle-detection.js";
3
- import { fileGraphForRef } from "./resolve-reference.js";
4
+ import { fileGraphForRef, resolveReference } from "./resolve-reference.js";
4
5
  import { packageNameFromSpecifier } from "./specifier.js";
5
6
  import { followReExportChain } from "./re-export-chain.js";
6
- import { unparsedMemberDefinition, effectiveExportName } from "./member-identity.js";
7
+ import { unparsedMemberDefinition, effectiveExportName, residualMemberChain, compoundExportName } from "./member-identity.js";
8
+ import { evalKind, isImportBackedLeaf } from "./component-shape.js";
9
+ import { assertNever } from "./assert-never.js";
10
+ /** Ruling 19: true when a `ReturnTypeOf`'s callee is a data-method render
11
+ * call (`items.map(render)`) rather than a wrapper taking a component as a
12
+ * prop — the exact structural condition `component-shape.ts`'s rule (ii)
13
+ * uses to answer `"jsx"` instead of `"component"`. Shared, not re-derived:
14
+ * a `MemberOf` callee that is NOT an import-backed leaf (`Sentry.x` stays a
15
+ * wrapper; `items.map`/a hook result/a parameter does not). */
16
+ function isDataMethodRenderCall(type, graph, fileGraph, guard) {
17
+ return type.callee.kind === "MemberOf" && !isImportBackedLeaf(type.callee, graph, fileGraph, guard);
18
+ }
7
19
  /**
8
20
  * THE single seam for unreducible-leaf identity synthesis (#135 / #274).
9
21
  *
@@ -13,7 +25,8 @@ import { unparsedMemberDefinition, effectiveExportName } from "./member-identity
13
25
  * synthesize a JSX terminal carrying the identity derived FROM THE TERMINAL
14
26
  * ITSELF: the reference is real even though its target can't be parsed
15
27
  * (external package, CJS, unresolvable specifier). Every other terminal
16
- * derives identity from `source ?? fallback`, exactly as before.
28
+ * derives identity from `source`, falling back to `fallback` only when the
29
+ * branch itself carries none.
17
30
  *
18
31
  * Do NOT re-derive this salvage logic at individual fold sites — call this
19
32
  * seam. Named in docs/CONTEXT.md ("unreducible-leaf identity synthesis").
@@ -26,27 +39,69 @@ function foldTerminals(graph, fileGraph, results, fallback) {
26
39
  return { type: { kind: "JSX" }, viaTrail: [], identity };
27
40
  }
28
41
  }
42
+ // A fan-out branch (Union / dynamic-map) is stamped as `source`. Derive
43
+ // from it FIRST — a branch that has its own binding must win (#131) —
44
+ // and only fall back to the resolved binding when the branch carries no
45
+ // identity of its own (e.g. a bare `{kind:"JSX"}` branch from a lowered
46
+ // ternary/logical render body). Committing to `source ?? fallback` before
47
+ // deriving discarded the fallback entirely once any fanout occurred, even
48
+ // when the branch itself resolved to nothing.
49
+ const identity = (source && deriveIdentity(source, fileGraph, graph)) ?? deriveIdentity(fallback, fileGraph, graph);
29
50
  return {
30
51
  type: t,
31
52
  viaTrail: [],
32
- identity: deriveIdentity(source ?? fallback, fileGraph, graph),
53
+ identity,
33
54
  };
34
55
  });
35
56
  }
36
57
  /**
37
- * Build the `hoc-wrapper` via for a ReturnTypeOf wrapper call. The via.import
38
- * names the wrapped last arg (imported binding when there is one, else the
39
- * anonymous-arg resolution); hocCallee names the wrapper. Shared by the
40
- * pass-through recovery (step 3b) and the opaque HOC fallback (step 4) so both
41
- * paths attribute composition identically.
58
+ * Fold a wrapper call to its wrapped argument's terminals. Shared by the
59
+ * structural pass-through step (2b) and the opaque-HOC fallback (step 4) so
60
+ * a seen-through wrapper and an opaque one attribute identically. Inner
61
+ * terminals pass through with the wrapper hop prepended. When the inner walk
62
+ * bottomed out at Unknown but carried an identity hint (imported, or
63
+ * local-promoted via #101), synthesise a JSX terminal from it — without this
64
+ * rescue, HOC-wrapped occurrences drop silently (~128 in example-web's
65
+ * connect/flow/withTranslators patterns, diagnosed on PR #141). Truly
66
+ * nothing → a single Unknown terminal, as before.
42
67
  */
43
- function buildHocVia(type, lastArg, fileGraph) {
44
- const hocCallee = deriveHocCallee(type.callee);
45
- const importRecord = findImportForLastArg(lastArg, fileGraph);
68
+ function foldToWrappedArg(inner, via) {
69
+ const innerHasNonUnknown = inner.some((t) => t.type.kind !== "Unknown");
70
+ if (innerHasNonUnknown) {
71
+ return inner.map((t) => ({ type: t.type, viaTrail: [via, ...t.viaTrail], identity: t.identity }));
72
+ }
73
+ const synthesisIdentity = inner.find((t) => t.identity !== null)?.identity ?? null;
74
+ if (synthesisIdentity) {
75
+ return [{ type: { kind: "JSX" }, viaTrail: [via], identity: synthesisIdentity }];
76
+ }
77
+ return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
78
+ }
79
+ /**
80
+ * Provenance of ONE argument of a wrapper call: the wrapper's `callee` name
81
+ * and the `{ specifier, import }` pair naming the argument's own binding —
82
+ * its import record when it is import-backed, else the anonymous-argument
83
+ * resolution. THE single helper for both via kinds that describe a wrapped
84
+ * argument (`hoc-wrapper` when the wrapper folds to the argument,
85
+ * `passed-as-argument` when the holder is its own identity and the argument
86
+ * is seeded at its site — spec §4), so the two name a binding identically.
87
+ * Never re-derive `{ specifier, import }` for an argument elsewhere.
88
+ */
89
+ export function argumentProvenance(type, arg, fileGraph) {
90
+ const importRecord = findImportForLastArg(arg, fileGraph);
46
91
  const resolved = importRecord
47
92
  ? { specifier: importRecord.specifier, import: importRecord.imported }
48
- : resolveAnonymousLastArg(lastArg, fileGraph);
49
- return { kind: "hoc-wrapper", hocCallee, specifier: resolved.specifier, import: resolved.import };
93
+ : resolveAnonymousLastArg(arg, fileGraph);
94
+ return { callee: deriveHocCallee(type.callee), specifier: resolved.specifier, import: resolved.import };
95
+ }
96
+ /**
97
+ * Build the `hoc-wrapper` via for a ReturnTypeOf wrapper call. Shared by the
98
+ * structural pass-through (step 2b), the pass-through recovery (step 3b) and
99
+ * the opaque HOC fallback (step 4) so all three paths attribute composition
100
+ * identically; the binding is named through `argumentProvenance`.
101
+ */
102
+ function buildHocVia(type, lastArg, fileGraph) {
103
+ const { callee, specifier, import: imported } = argumentProvenance(type, lastArg, fileGraph);
104
+ return { kind: "hoc-wrapper", hocCallee: callee, specifier, import: imported };
50
105
  }
51
106
  /**
52
107
  * Stable comparison key for a TerminalIdentity. Mirrors the collapse rule in
@@ -71,9 +126,13 @@ function identityKey(id) {
71
126
  * returns a DIFFERENT component fails this and keeps its richer algebra result.
72
127
  */
73
128
  function isPassThroughWrapper(algResult, inner) {
74
- const innerKeys = new Set(inner.filter((t) => t.type.kind !== "Unknown").map((t) => identityKey(t.identity)));
129
+ const innerKeys = new Set(inner.filter((t) => t.type.kind !== "Unknown" && t.identity !== null).map((t) => identityKey(t.identity)));
75
130
  const algKeys = algResult.filter((t) => t.type.kind !== "Unknown");
76
- return algKeys.length > 0 && algKeys.every((t) => innerKeys.has(identityKey(t.identity)));
131
+ // A null identity on either side is "no information", never a match — the
132
+ // old null-equals-null comparison folded factory calls onto their
133
+ // rehydrate/config argument once the callee unwrap stopped inventing an
134
+ // identity for the algebra side.
135
+ return algKeys.length > 0 && algKeys.every((t) => t.identity !== null && innerKeys.has(identityKey(t.identity)));
77
136
  }
78
137
  /**
79
138
  * Walker layered above `resolveType`. Applies three folding rules
@@ -87,7 +146,21 @@ function isPassThroughWrapper(algResult, inner) {
87
146
  *
88
147
  * See spec: docs/superpowers/specs/2026-05-19-wrapper-folding-engine-extension-design.md §4.3
89
148
  */
90
- export function walkWithFolding(graph, fileGraph, type, argMap, guard = createCycleGuard()) {
149
+ export function walkWithFolding(graph, fileGraph, type, argMap, guard = createCycleGuard(),
150
+ // Ruling 20: the identity `foldTerminals` falls back to when a terminal
151
+ // carries no identity of its own (no `source`, no unreducible TypeOf/
152
+ // DynamicImport leaf). Defaults to `type` (the walked node itself) —
153
+ // unchanged behaviour for every caller that doesn't pass this. The
154
+ // `Function` arm below is the ONE place that overrides it: a component's
155
+ // own return (`return priceElement;`, `return contactModals;` — a bare
156
+ // JSX-value alias or a hook-return leaf) must keep deriving identity as
157
+ // "nothing", so the engine's own fallback (the rendered declaration, e.g.
158
+ // `OfferPrice`) wins — never a same-file local alias like `priceElement`
159
+ // that happens to sit in `foldTerminals`' fallback slot. Per-branch
160
+ // identity from a fanout `source` (#131 — Union([TypeOf A, TypeOf B])) is
161
+ // untouched: `source` is checked BEFORE this fallback in `foldTerminals`.
162
+ identityFallback) {
163
+ const fallback = identityFallback ?? type;
91
164
  switch (type.kind) {
92
165
  case "ReturnTypeOf":
93
166
  return walkReturnTypeOf(graph, fileGraph, type, argMap, guard);
@@ -95,13 +168,165 @@ export function walkWithFolding(graph, fileGraph, type, argMap, guard = createCy
95
168
  if (type.member === DYNAMIC_MEMBER_KEY) {
96
169
  return walkDynamicMember(graph, fileGraph, type, argMap, guard);
97
170
  }
98
- // Static member access fall through to default handling.
99
- {
100
- return foldTerminals(graph, fileGraph, resolveType(graph, fileGraph, type, argMap, guard), type);
101
- }
102
- default: {
103
- return foldTerminals(graph, fileGraph, resolveType(graph, fileGraph, type, argMap, guard), type);
171
+ return walkStaticMember(graph, fileGraph, type, argMap, guard, fallback);
172
+ case "Function":
173
+ // Ruling 19: a component's own returns can contain a nested
174
+ // ReturnTypeOf/MemberOf (`List = ({items}) => items.map(render)`) that
175
+ // needs wrapper-folding's steps (the opaque-callee / dynamic-map
176
+ // handling below), not `resolveType`'s plain algebra `resolveType`'s
177
+ // own "Function" case recurses via plain `resolveType`, which never
178
+ // re-enters this walk, so a JSX usage resolving to such a Function
179
+ // (e.g. `<List/>`) silently produced zero occurrences before this.
180
+ // Mirrors `resolveType`'s "Function" case, one level up. Ruling 20:
181
+ // pass THIS Function as the identity fallback for every return — a
182
+ // Function is never TypeOf/DynamicImport, so `deriveIdentity` on it
183
+ // is always null, matching the pre-ruling-19 semantics this restores.
184
+ return type.returns.flatMap((r) => walkWithFolding(graph, fileGraph, r, argMap, guard, type));
185
+ case "TypeOf":
186
+ return walkTypeOf(graph, fileGraph, type, argMap, guard, fallback);
187
+ case "Union":
188
+ // Structural re-entry (D1/D3): each branch walks through the walker so
189
+ // a TypeOf branch reaches `walkTypeOf` and decides its own identity
190
+ // (per-branch attribution, #131) while a bare JSX branch — a lowered
191
+ // ternary/logical render body — derives from the outer fallback exactly
192
+ // as the algebra's `source ?? fallback` did.
193
+ return type.types.flatMap((branch) => walkWithFolding(graph, fileGraph, branch, argMap, guard, fallback));
194
+ case "ParameterOf":
195
+ case "DynamicImport":
196
+ case "Object":
197
+ case "Array":
198
+ case "JSX":
199
+ case "Str":
200
+ case "Unknown":
201
+ return foldLeaf(graph, fileGraph, type, argMap, guard, fallback);
202
+ default:
203
+ return assertNever(type);
204
+ }
205
+ }
206
+ /**
207
+ * The leaf fold: pure algebra, then identity synthesis through the ONE
208
+ * unreducible-leaf seam (`foldTerminals`). This is the only place the walker
209
+ * hands a subtree to `resolveType`; every structural kind (TypeOf, Union,
210
+ * MemberOf, Function, ReturnTypeOf) has its own arm above and re-enters
211
+ * `walkWithFolding` instead.
212
+ */
213
+ function foldLeaf(graph, fileGraph, type, argMap, guard, fallback) {
214
+ return foldTerminals(graph, fileGraph, resolveType(graph, fileGraph, type, argMap, guard), fallback);
215
+ }
216
+ /**
217
+ * THE seam for "walk a Function in its authoring file" (spec D4). A Function
218
+ * declared in another module must walk in THAT module's graph, because the
219
+ * walker's file-scoped lookups inside it — a dynamic map's binding
220
+ * (`findMapBinding`), an import table — only resolve where the code was
221
+ * written. Originally `walkDispatcherReturns`' rationale (#136): a
222
+ * cross-module `function getMapped(k) { return MAP[k]; }` walked in the
223
+ * caller's fileGraph misses `MAP` entirely and loses the `dynamic-map` hop.
224
+ * `walkTypeOf` needs the identical switch, so it is one function, not two.
225
+ * Falls back to the caller's graph when the file is absent or unindexed.
226
+ */
227
+ function fileGraphForFunction(graph, fileGraph, fn) {
228
+ return fn.enclosingBinding?.file && fn.enclosingBinding.file !== fileGraph.filePath
229
+ ? (graph.files.get(fn.enclosingBinding.file) ?? fileGraph)
230
+ : fileGraph;
231
+ }
232
+ /**
233
+ * Structural re-entry for a reference (spec D1–D4, D6; #521). Resolves ONE
234
+ * hop and walks the resolved value through `walkWithFolding`, so every walker
235
+ * rule — wrapper hops, dynamic maps, Ruling 19's `.map()` bodies — applies
236
+ * through an alias exactly as at a direct render site. Before this arm the
237
+ * default fold handed the whole subtree to `resolveType`, which never comes
238
+ * back, so a map-bodied `List` reached via `MAP[k]` died as Unknown.
239
+ *
240
+ * Identity (D2): a terminal that comes back with no identity of its own is
241
+ * stamped with THIS reference's identity only when the reference names a
242
+ * component-shaped value — `evalKind === "component"`, the one component-
243
+ * shape seam, called not re-derived. A reference to a JSX value
244
+ * (`return priceElement`) or to a parameter binding stays null so the outer
245
+ * fallback wins: Ruling 20 by construction rather than by special case.
246
+ *
247
+ * An import the graph cannot see (`resolved` is Unknown) is NOT re-entered
248
+ * (D6): it is the identity-bearing leaf `foldLeaf` synthesises from
249
+ * (#135/#274), and `foldToWrappedArg`'s rescue relies on that shape.
250
+ *
251
+ * Cross-file (D4): a Function authored elsewhere walks in ITS file graph
252
+ * through the `fileGraphForFunction` seam — the same switch
253
+ * `walkDispatcherReturns` makes — so file-scoped lookups inside it (a dynamic
254
+ * map's binding) resolve where they were written.
255
+ *
256
+ * Cycle guard: mirrors `resolveType`'s node guard. `const A = B; const B = A`
257
+ * is legal JS and would otherwise re-enter forever.
258
+ */
259
+ function walkTypeOf(graph, fileGraph, type, argMap, guard, fallback) {
260
+ if (guard.pushNode(type) === "cycle") {
261
+ return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
262
+ }
263
+ try {
264
+ const resolved = resolveReference(graph, fileGraph, type.ref, guard);
265
+ if (resolved.kind === "Unknown") {
266
+ // Pop before delegating: foldLeaf re-enters `resolveType`, which pushes
267
+ // this SAME node onto the guard's path again — leaving it pushed here
268
+ // would self-collide as a spurious cycle (guard tracks by object
269
+ // identity) on every import-backed leaf, not just real cycles. The
270
+ // `finally` below still runs (a no-op double pop) once this returns.
271
+ guard.popNode(type);
272
+ return foldLeaf(graph, fileGraph, type, argMap, guard, fallback);
273
+ }
274
+ const innerGraph = resolved.kind === "Function" ? fileGraphForFunction(graph, fileGraph, resolved) : fileGraph;
275
+ const inner = walkWithFolding(graph, innerGraph, resolved, argMap, guard, fallback);
276
+ if (inner.every((t) => t.identity !== null))
277
+ return inner;
278
+ const self = resolved.kind !== "ParameterOf" && evalKind(resolved, graph, innerGraph, argMap) === "component"
279
+ ? deriveIdentity(type, fileGraph, graph)
280
+ : null;
281
+ const stamp = self ?? deriveIdentity(fallback, fileGraph, graph);
282
+ return inner.map((t) => (t.identity === null ? { type: t.type, viaTrail: t.viaTrail, identity: stamp } : t));
283
+ }
284
+ finally {
285
+ guard.popNode(type);
286
+ }
287
+ }
288
+ /**
289
+ * Static member access (spec D5; #512). Mirrors `walkDynamicMember` for one
290
+ * named prop and without the `dynamic-map` hop: resolve the object, take the
291
+ * member, walk the member's VALUE through `walkWithFolding` — so a wrapper
292
+ * product stored in a map (`MAP.memo` → `memo(Button)`) reaches
293
+ * `walkReturnTypeOf` and keeps its `hoc-wrapper` hop. Anything that is not an
294
+ * in-graph Object (an Array, an import-backed namespace the graph cannot see)
295
+ * takes the leaf fold exactly as before.
296
+ *
297
+ * Cycle guard: mirrors `walkTypeOf`. `const A = { Item: () => B.Item };
298
+ * const B = { Item: () => A.Item }` is legal, loadable JS and the walk
299
+ * re-enters this arm forever without it — `resolveType` pops every node it
300
+ * pushes, so nothing on such a cycle stays on the guard's path otherwise.
301
+ */
302
+ function walkStaticMember(graph, fileGraph, type, argMap, guard, fallback) {
303
+ if (guard.pushNode(type) === "cycle") {
304
+ return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
305
+ }
306
+ try {
307
+ const objects = resolveType(graph, fileGraph, type.obj, argMap, guard)
308
+ .map((r) => r.type)
309
+ .filter((t) => t.kind === "Object");
310
+ if (objects.length === 0) {
311
+ // Pop before delegating: foldLeaf re-enters `resolveType`, which pushes
312
+ // this SAME node onto the guard's path again — leaving it pushed here
313
+ // would self-collide as a spurious cycle (guard tracks by object
314
+ // identity), not just on real cycles. The `finally` below still runs
315
+ // (a no-op double pop) once this returns.
316
+ guard.popNode(type);
317
+ return foldLeaf(graph, fileGraph, type, argMap, guard, fallback);
104
318
  }
319
+ const out = [];
320
+ for (const obj of objects) {
321
+ const value = obj.props[type.member];
322
+ if (value === undefined)
323
+ continue;
324
+ out.push(...walkWithFolding(graph, fileGraph, value, argMap, guard, fallback));
325
+ }
326
+ return out.length > 0 ? out : [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
327
+ }
328
+ finally {
329
+ guard.popNode(type);
105
330
  }
106
331
  }
107
332
  function walkReturnTypeOf(graph, fileGraph, type, argMap, guard) {
@@ -172,6 +397,23 @@ function walkReturnTypeOf(graph, fileGraph, type, argMap, guard) {
172
397
  const dispatcherResult = walkDispatcherReturns(graph, fileGraph, type, argMap, guard);
173
398
  if (dispatcherResult)
174
399
  return dispatcherResult;
400
+ // (2b) Structural pass-through (spec D9 arm 1; #328). When every Function
401
+ // the callee resolves to returns its own parameter (a bare `ParameterOf`, or a `TypeOf` that resolves to one) bound by THIS call,
402
+ // the wrapper is identity-preserving by construction — `c => c`; React's
403
+ // memo/forwardRef reach this arm through a library stub table, regardless
404
+ // of import spelling (named, default, or namespace). Fold straight to that argument and record the wrapper as a
405
+ // hoc-wrapper hop. Decided from the callee's shape, never from whether the
406
+ // algebra happened to succeed, so a local wrapped component and an external
407
+ // one attribute identically. Folding goes through `foldToWrappedArg`, the
408
+ // same tail step 4 uses, so both arms synthesise identity identically.
409
+ const passThroughIndex = passThroughParameterIndex(graph, fileGraph, type, argMap, guard);
410
+ if (passThroughIndex !== null) {
411
+ const wrapped = type.args[passThroughIndex];
412
+ if (wrapped !== undefined) {
413
+ const inner = walkWithFolding(graph, fileGraph, wrapped, argMap, guard);
414
+ return foldToWrappedArg(inner, buildHocVia(type, wrapped, fileGraph));
415
+ }
416
+ }
175
417
  // (3) Pure algebra walk. Per-leaf identity uses each terminal's source
176
418
  // (the leaf at the innermost fanout fork that produced it) so dynamic-map /
177
419
  // Union / Array fanouts attribute each branch to its own binding instead
@@ -209,30 +451,57 @@ function walkReturnTypeOf(graph, fileGraph, type, argMap, guard) {
209
451
  return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
210
452
  }
211
453
  const inner = walkWithFolding(graph, fileGraph, lastArg, argMap, guard);
212
- const innerHasNonUnknown = inner.some((t) => t.type.kind !== "Unknown");
213
- const via = buildHocVia(type, lastArg, fileGraph);
214
- if (innerHasNonUnknown) {
215
- // Inner resolved to JSX terminal(s): inherit identity from inner.
216
- return inner.map((t) => ({
217
- type: t.type,
218
- viaTrail: [via, ...t.viaTrail],
219
- identity: t.identity,
220
- }));
221
- }
222
- // Wrapped target with a known identity: inner couldn't resolve to JSX
223
- // (algebra failed common for opaque HOCs), but if we have an identity
224
- // hint (imported OR local-promoted via #101), synthesize a JSX terminal.
225
- // Matches engine/index.ts's sentinel pattern for non-graph external imports
226
- // AND covers the in-graph case where #101 promoted the inner identity from
227
- // `imported` to `local` (without this, ~128 HOC-wrapped occurrences in
228
- // example-web's connect/flow/withTranslators patterns drop silently —
229
- // diagnosed via location-set diff on PR #141).
230
- const synthesisIdentity = inner.find((t) => t.identity !== null)?.identity ?? null;
231
- if (synthesisIdentity) {
232
- return [{ type: { kind: "JSX" }, viaTrail: [via], identity: synthesisIdentity }];
454
+ // Ruling 19: a data-method render call (`items.map(render)`) is not a
455
+ // wrapper its result IS the callback's rendered JSX, not a component
456
+ // received as a prop. Return the callback's terminals AS-IS: no
457
+ // `hoc-wrapper` hop, and identity stays null so the engine's own fallback
458
+ // (the declaration being walked, e.g. `List`) wins rather than whatever
459
+ // the callback itself happened to resolve to. Import-backed MemberOf
460
+ // callees (`Sentry.withProfiler`) fall through to the existing fold+hop
461
+ // below — arm 3 (D9) stands for those.
462
+ if (isDataMethodRenderCall(type, graph, fileGraph, guard)) {
463
+ const nonUnknown = inner.filter((t) => t.type.kind !== "Unknown");
464
+ if (nonUnknown.length > 0) {
465
+ return nonUnknown.map((t) => ({ type: t.type, viaTrail: t.viaTrail, identity: null }));
466
+ }
467
+ return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
233
468
  }
234
- // Truly nothing preserve the original "no useful identity" behaviour.
235
- return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
469
+ return foldToWrappedArg(inner, buildHocVia(type, lastArg, fileGraph));
470
+ }
471
+ /**
472
+ * Index of the argument an identity-preserving wrapper returns, or null when
473
+ * the callee is not structurally pass-through. Pass-through means: every
474
+ * Function the callee resolves to has ≥1 return, every return reduces (bare, or
475
+ * a `TypeOf` resolved one hop) to a `ParameterOf` whose `fn` is the ref this
476
+ * call binds args to (`bindingRef`), and every return names the same index.
477
+ * Unions, JSX, nested Functions or an unresolvable callee all yield null so the
478
+ * existing algebra / opaque-HOC paths handle them exactly as before.
479
+ */
480
+ function passThroughParameterIndex(graph, fileGraph, type, argMap, guard) {
481
+ const calleeFns = resolveToFunctions(graph, fileGraph, type.callee, argMap, guard);
482
+ if (calleeFns.length === 0)
483
+ return null;
484
+ let index = null;
485
+ for (const { fn, bindingRef } of calleeFns) {
486
+ if (fn.kind !== "Function" || bindingRef === null || fn.returns.length === 0)
487
+ return null;
488
+ const boundKey = argumentRefKey(bindingRef);
489
+ for (const ret of fn.returns) {
490
+ // The parser lowers `(C) => C` to a TypeOf return whose ref names the
491
+ // parameter binding; the ParameterOf lives on that binding's declaration
492
+ // in the function's body scope. Resolve one hop so real parser output
493
+ // reaches this arm, not only hand-built graphs.
494
+ const leaf = ret.kind === "TypeOf" ? resolveReference(graph, fileGraph, ret.ref, guard) : ret;
495
+ if (leaf.kind !== "ParameterOf")
496
+ return null;
497
+ if (argumentRefKey(leaf.fn) !== boundKey)
498
+ return null;
499
+ if (index !== null && index !== leaf.index)
500
+ return null;
501
+ index = leaf.index;
502
+ }
503
+ }
504
+ return index;
236
505
  }
237
506
  /**
238
507
  * Cross-module dispatcher walk (#136). When the callee of a ReturnTypeOf
@@ -257,9 +526,7 @@ function walkDispatcherReturns(graph, fileGraph, type, argMap, guard) {
257
526
  for (const { fn, bindingRef } of calleeFns) {
258
527
  if (fn.kind !== "Function")
259
528
  continue;
260
- const fnFileGraph = fn.enclosingBinding?.file && fn.enclosingBinding.file !== fileGraph.filePath
261
- ? (graph.files.get(fn.enclosingBinding.file) ?? fileGraph)
262
- : fileGraph;
529
+ const fnFileGraph = fileGraphForFunction(graph, fileGraph, fn);
263
530
  if (bindingRef)
264
531
  argMap.bind(bindingRef, type.args);
265
532
  try {
@@ -326,36 +593,49 @@ function findFirstDynamicImport(t, fileGraph, depthBudget) {
326
593
  }
327
594
  return null;
328
595
  }
596
+ /**
597
+ * Cycle guard: mirrors `walkStaticMember`. A mutually-recursive object map
598
+ * reached through `MAP[k]` re-enters this arm forever without it. No
599
+ * `foldLeaf` path here, so no early pop is needed.
600
+ */
329
601
  function walkDynamicMember(graph, fileGraph, type, argMap, guard) {
330
- const objResults = resolveType(graph, fileGraph, type.obj, argMap, guard);
331
- const out = [];
332
- for (const { type: obj } of objResults) {
333
- if (obj.kind !== "Object")
334
- continue;
335
- const mapBinding = findMapBinding(type.obj, fileGraph);
336
- const mapName = mapBinding?.symbol ?? "unknown";
337
- const mapLoc = {
338
- file: fileGraph.filePath,
339
- line: mapBinding?.loc.line ?? 0,
340
- column: mapBinding?.loc.column ?? 0,
341
- };
342
- for (const propValue of Object.values(obj.props)) {
343
- // Recurse via walkWithFolding so identity is derived from each branch's
344
- // leaf TypeOf (Foo/Bar resolve to their respective bindings).
345
- const inner = walkWithFolding(graph, fileGraph, propValue, argMap, guard);
346
- const via = { kind: "dynamic-map", mapName, mapLoc };
347
- for (const t of inner) {
348
- if (t.type.kind === "Unknown")
349
- continue;
350
- out.push({
351
- type: t.type,
352
- viaTrail: [via, ...t.viaTrail],
353
- identity: t.identity, // per-branch identity, distinct per map value
354
- });
602
+ if (guard.pushNode(type) === "cycle") {
603
+ return [{ type: { kind: "Unknown" }, viaTrail: [], identity: null }];
604
+ }
605
+ try {
606
+ const objResults = resolveType(graph, fileGraph, type.obj, argMap, guard);
607
+ const out = [];
608
+ for (const { type: obj } of objResults) {
609
+ if (obj.kind !== "Object")
610
+ continue;
611
+ const mapBinding = findMapBinding(type.obj, fileGraph);
612
+ const mapName = mapBinding?.symbol ?? "unknown";
613
+ const mapLoc = {
614
+ file: fileGraph.filePath,
615
+ line: mapBinding?.loc.line ?? 0,
616
+ column: mapBinding?.loc.column ?? 0,
617
+ };
618
+ for (const propValue of Object.values(obj.props)) {
619
+ // Recurse via walkWithFolding so identity is derived from each branch's
620
+ // leaf TypeOf (Foo/Bar resolve to their respective bindings).
621
+ const inner = walkWithFolding(graph, fileGraph, propValue, argMap, guard);
622
+ const via = { kind: "dynamic-map", mapName, mapLoc };
623
+ for (const t of inner) {
624
+ if (t.type.kind === "Unknown")
625
+ continue;
626
+ out.push({
627
+ type: t.type,
628
+ viaTrail: [via, ...t.viaTrail],
629
+ identity: t.identity, // per-branch identity, distinct per map value
630
+ });
631
+ }
355
632
  }
356
633
  }
634
+ return out;
635
+ }
636
+ finally {
637
+ guard.popNode(type);
357
638
  }
358
- return out;
359
639
  }
360
640
  function findMapBinding(obj, fileGraph) {
361
641
  if (obj.kind !== "TypeOf")
@@ -577,9 +857,18 @@ function memberLocalIdentity(graph, fileGraph, specifier, imported) {
577
857
  return null;
578
858
  return { kind: "local", filePath: member.absFile, export: member.exportName };
579
859
  }
860
+ /** Append a residual member chain (#518) to a derived identity's export name. */
861
+ function withResidual(identity, residual) {
862
+ if (identity === null || residual.length === 0)
863
+ return identity;
864
+ return identity.kind === "local"
865
+ ? { ...identity, export: compoundExportName(identity.export, residual) }
866
+ : { ...identity, imported: compoundExportName(identity.imported, residual) };
867
+ }
580
868
  /**
581
- * Walk the input type to find the leaf TypeOf ref (or DynamicImport), then resolve
582
- * that ref to either an imported or local identity.
869
+ * Resolve the input type's leaf TypeOf ref (or DynamicImport) to either an
870
+ * imported or a local identity. Any other shape — a `ReturnTypeOf` included —
871
+ * has no identity of its own and yields null.
583
872
  *
584
873
  * Switches FileGraph context via `ref.originFile` when set, so refs that have
585
874
  * travelled across module boundaries (e.g. cross-module function returns) are
@@ -587,11 +876,11 @@ function memberLocalIdentity(graph, fileGraph, specifier, imported) {
587
876
  * that match the ref actually live.
588
877
  */
589
878
  export function deriveIdentity(type, fileGraph, graph) {
590
- // Unwrap nested ReturnTypeOf chains to find the wrapped argument (used by
591
- // HOC fallback callers passing the last arg here).
592
- let cur = type;
593
- while (cur.kind === "ReturnTypeOf")
594
- cur = cur.callee;
879
+ // A ReturnTypeOf never derives identity from its callee (spec D9 arm 2):
880
+ // a resolvable factory call's identity is the declaration holding the call,
881
+ // which every engine push site already supplies as its fallback. Returning
882
+ // null here is what makes that fallback win. (#271, #328)
883
+ const cur = type;
595
884
  if (cur.kind === "DynamicImport") {
596
885
  const importedName = cur.projection[0] ?? "default";
597
886
  const resolvedFile = graph ? resolveImportedFile(graph, fileGraph, cur.specifier, importedName) : null;
@@ -622,33 +911,39 @@ export function deriveIdentity(type, fileGraph, graph) {
622
911
  if (imp) {
623
912
  // Namespace-import members canonicalise to the member name (#394),
624
913
  // matching the direct-import path's `effectiveImported` (engine/index.ts,
625
- // #362) so both paths mint the same identity.
914
+ // #362) so both paths mint the same identity. The residual chain (#518)
915
+ // is appended AFTER every root lookup, so `Dialog.Popup` on an unparsed
916
+ // package and `NS.Inline` on a workspace member both mirror the direct
917
+ // path.
626
918
  const imported = effectiveExportName(imp.imported, cur.ref.memberChain);
919
+ const residual = residualMemberChain(imp.imported, cur.ref.memberChain);
627
920
  const resolvedFile = graph ? resolveImportedFile(graph, lookup, imp.specifier, imported) : null;
628
921
  const workspaceLocal = workspaceSiblingIdentity(graph, imp.specifier, imported, resolvedFile);
629
922
  if (workspaceLocal)
630
- return workspaceLocal;
923
+ return withResidual(workspaceLocal, residual);
631
924
  const relativeLocal = relativeChainLocalIdentity(graph, lookup, imp.specifier, imported);
632
925
  if (relativeLocal)
633
- return relativeLocal;
926
+ return withResidual(relativeLocal, residual);
634
927
  const memberLocal = memberLocalIdentity(graph, lookup, imp.specifier, imported);
635
928
  if (memberLocal)
636
- return memberLocal;
637
- return {
929
+ return withResidual(memberLocal, residual);
930
+ return withResidual({
638
931
  kind: "imported",
639
932
  specifier: imp.specifier,
640
933
  imported,
641
934
  resolvedFile,
642
- };
935
+ }, residual);
643
936
  }
644
- // Check local declarations.
937
+ // Check local declarations. A same-file symbol is never a namespace
938
+ // import, so the whole member chain is residual (mirror of
939
+ // `pushLocalOccurrence`'s fallback, #518) — an empty chain is a no-op.
645
940
  for (const [, decl] of lookup.declarations) {
646
941
  if (decl && decl.symbol === symbol) {
647
- return {
942
+ return withResidual({
648
943
  kind: "local",
649
944
  filePath: lookup.filePath,
650
945
  export: decl.exportedAs ?? symbol,
651
- };
946
+ }, cur.ref.memberChain);
652
947
  }
653
948
  }
654
949
  }