@component-compass/reference-graph 0.1.22 → 0.1.24

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.
@@ -10,8 +10,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
12
  import { buildComponentRegistry, excludeFoldedHolders, excludeHostElementNames, entryKey } from "./registry.js";
13
- import { hasOnlyHostElementNames, isHostElementName } from "./host-element.js";
13
+ import { hasOnlyHostElementNames, isHostElementName, HOOK_NAME } from "./host-element.js";
14
14
  import { firstDynamicImport } from "./component-shape.js";
15
+ import { creditedTerminals, renderOutcome } from "./denotation.js";
15
16
  /**
16
17
  * JSX treats a bare, lowercase-initial element name as a host element
17
18
  * (`div`, `svg`, `web-button`) — it never names a binding, so it is not an
@@ -118,238 +119,176 @@ export function resolve(graph, opts) {
118
119
  ? { fileUsageIdx, kind, parentUsageIdx }
119
120
  : { fileUsageIdx, kind };
120
121
  }
122
+ return occurrences.length - lengthBefore;
121
123
  };
122
- for (const [filePath, fileGraph] of graph.files) {
123
- for (let usageIdx = 0; usageIdx < fileGraph.jsxUsages.length; usageIdx++) {
124
- const usage = fileGraph.jsxUsages[usageIdx];
125
- if (!usage)
126
- continue;
127
- // Resolve owner symbol ref for this usage (for composition attribution).
128
- const ownerRef = fileGraph.ownership[usageIdx]?.ownerSymbolRef ?? null;
129
- const viaOverride = fileGraph.ownership[usageIdx]?.viaOverride;
130
- const jsxDepth = fileGraph.ownership[usageIdx]?.depth ?? 0;
131
- const jsxParentUsageIdx = fileGraph.ownership[usageIdx]?.parentUsageIdx;
132
- const jsxLengthBefore = occurrences.length;
133
- // Find the import record that introduced the root symbol at module scope.
134
- // We check usage.ref.symbol (the root binding name), not member-chain
135
- // segments, because ImportRecord.local binds the top-level name.
136
- const matchedImport = fileGraph.importsByLocal.get(usage.ref.symbol);
137
- if (!matchedImport) {
138
- // Symbol is locally declared (no import) — emit local identity.
139
- const decl = findLocalDeclaration(fileGraph, usage.ref);
140
- if (!decl) {
141
- if (!isHostElementRoot(usage.ref)) {
142
- // No import record and no declaration in scope: the engine cannot
143
- // attribute this usage. Surface it instead of
144
- // dropping it silently — `info`, because the usual cause is a
145
- // global or macro-provided binding the scan cannot see.
146
- opts?.collector?.emit({
147
- code: "unresolved-reference",
148
- severity: "info",
149
- filePath,
150
- line: usage.loc.line,
151
- column: usage.loc.column,
152
- symbol: usage.ref.symbol,
153
- memberChain: [...usage.ref.memberChain],
154
- });
155
- }
156
- continue;
157
- }
158
- pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGraph, argMap, ownerRef, helperIndex, opts, viaOverride);
159
- stampComposition(jsxLengthBefore, "jsx", usageIdx, jsxDepth, jsxParentUsageIdx);
160
- continue;
161
- }
162
- const packageName = packageNameFromSpecifier(matchedImport.specifier);
163
- // Resolve namespace-import members to their export name once, up front —
164
- // every export-name / re-export lookup below keys on this, not the raw
165
- // `imported`. `via.import` keeps the raw `imported` (provenance).
166
- const effectiveImported = effectiveExportName(matchedImport.imported, usage.ref.memberChain);
167
- // Lookups below key on `effectiveImported` (the ROOT); the
168
- // identity stamped on a fallback is the root plus the residual chain.
169
- // A member the walker resolves to its own declaration never reaches a
170
- // fallback, so this only names members with no declaration of their own.
171
- const residual = residualMemberChain(matchedImport.imported, usage.ref.memberChain);
172
- if (!packageName) {
173
- // Relative / absolute specifier — always a local file reference.
174
- // Resolve the target path; normalize absolute → graph-relative if possible.
175
- const resolvedAbsTarget = graph.moduleResolver(filePath, matchedImport.specifier);
176
- if (!resolvedAbsTarget) {
177
- // A JSX usage whose import failed to resolve is a dropped local
178
- // reference — surface it instead of vanishing.
179
- opts?.collector?.emit({
180
- code: "unresolved-import",
181
- severity: "warning",
182
- filePath,
183
- line: usage.loc.line,
184
- column: usage.loc.column,
185
- specifier: matchedImport.specifier,
186
- });
187
- continue;
188
- }
189
- const resolvedTarget = graph.resolveToGraphKey
190
- ? (graph.resolveToGraphKey(resolvedAbsTarget) ?? resolvedAbsTarget)
191
- : resolvedAbsTarget;
192
- // Resolve the type to confirm it's a JSX component. Cross-file resolution
193
- // works when resolveToGraphKey is present (graph keys are repo-relative).
194
- const refType = resolveReference(graph, fileGraph, usage.ref);
195
- const folded = walkWithFolding(graph, fileGraph, refType, argMap);
196
- const jsxTerminals = folded.filter((t) => t.type.kind === "JSX");
197
- if (jsxTerminals.length === 0) {
198
- emitLazyImportUnsupported(opts, filePath, usage.loc, refType);
199
- continue;
200
- }
201
- // Determine the canonical source file + local export name. When the
202
- // target is a re-export barrel (`export { X } from './source'`),
203
- // follow the chain to the actual definition file so JSX edges
204
- // attribute to the source rather than the barrel. Without
205
- // this, two consumers importing the same component through different
206
- // re-export paths fragment across phantom identities.
207
- const chained = followReExportChain(graph, resolvedTarget, effectiveImported);
208
- const canonicalFile = chained?.file ?? resolvedTarget;
209
- let exportName = chained?.localExport ?? effectiveImported;
210
- // When the re-export chain didn't follow (no chain present), still
211
- // look up the local export name at the resolved target to match the
212
- // local-index seed's hash convention.
213
- if (!chained) {
214
- const targetGraph = graph.files.get(resolvedTarget);
215
- if (targetGraph && effectiveImported === "default") {
216
- const defaultExp = targetGraph.exports.find((e) => e.kind === "default");
217
- if (defaultExp && "local" in defaultExp) {
218
- exportName = defaultExp.local;
219
- }
220
- }
221
- else if (targetGraph) {
222
- const namedExp = targetGraph.exports.find((e) => e.kind === "named" && "exportedAs" in e && e.exportedAs === effectiveImported && "local" in e);
223
- if (namedExp && "local" in namedExp) {
224
- exportName = namedExp.local;
225
- }
226
- }
227
- }
228
- const outerVia = viaOverride ?? {
229
- kind: "direct-import",
230
- specifier: matchedImport.specifier,
231
- import: matchedImport.imported,
232
- };
233
- const fallbackComponentId = {
234
- kind: componentKindFor(fileGraph.dialect),
235
- export: compoundExportName(exportName, residual),
236
- source: { type: "local", filePath: canonicalFile },
237
- };
238
- if (viaOverride !== undefined) {
239
- // Prop-forward: parser has pre-decided the owner — single-occurrence path.
240
- const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, fileGraph.filePath);
241
- for (const ft of jsxTerminals) {
242
- const rawComponentId = componentIdFromIdentity(ft.identity, fallbackComponentId, fileGraph.dialect, {
243
- filePath,
244
- resolveExternalLeaf: opts?.resolveExternalLeaf,
245
- });
246
- const viaChain = [viaOverride];
247
- const provisionalId = serialiseComponentId(rawComponentId);
248
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
249
- occurrences.push({
250
- rawComponentId,
251
- filePath,
252
- line: usage.loc.line,
253
- column: usage.loc.column,
254
- via: viaChain[0] ?? outerVia,
255
- viaChain,
256
- props: projectPropUsages(usage.props),
257
- events: usage.events ?? [],
258
- occurrenceId,
259
- depth: 0,
260
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
261
- });
262
- }
263
- }
264
- else {
265
- // Helper-call fanout: resolve owner chain and emit per (terminal × resolution).
266
- const ownerResolutions = resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard());
267
- for (const ft of jsxTerminals) {
268
- const rawComponentId = componentIdFromIdentity(ft.identity, fallbackComponentId, fileGraph.dialect, {
269
- filePath,
270
- resolveExternalLeaf: opts?.resolveExternalLeaf,
271
- });
272
- for (const res of ownerResolutions) {
273
- const rawOwnerComponentId = ownerIdFromResolution(graph, res.ownerDecl);
274
- const viaChain = [...res.viaPrefix, outerVia, ...ft.viaTrail];
275
- const provisionalId = serialiseComponentId(rawComponentId);
276
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
277
- occurrences.push({
278
- rawComponentId,
279
- filePath,
280
- line: usage.loc.line,
281
- column: usage.loc.column,
282
- via: viaChain[0] ?? outerVia,
283
- viaChain,
284
- props: projectPropUsages(usage.props),
285
- events: usage.events ?? [],
286
- occurrenceId,
287
- depth: 0,
288
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
289
- });
290
- }
291
- }
292
- }
293
- stampComposition(jsxLengthBefore, "jsx", usageIdx, jsxDepth, jsxParentUsageIdx);
294
- continue;
295
- }
296
- // Check whether the import resolves to a local file that IS in the graph.
297
- // If it does, resolve properly to avoid false positives (e.g. importing a
298
- // utility function that happens to be named with a capital letter from a
299
- // workspace sibling). For imports whose target is outside the graph
300
- // (external packages in node_modules), trust the JSX call site directly.
124
+ const pushJsxUsageOccurrences = (filePath, fileGraph, usage, ownership) => {
125
+ // Resolve owner symbol ref for this usage (for composition attribution).
126
+ const ownerRef = ownership?.ownerSymbolRef ?? null;
127
+ const viaOverride = ownership?.viaOverride;
128
+ // Find the import record that introduced the root symbol at module scope.
129
+ // We check usage.ref.symbol (the root binding name), not member-chain
130
+ // segments, because ImportRecord.local binds the top-level name.
131
+ const matchedImport = fileGraph.importsByLocal.get(usage.ref.symbol);
132
+ if (!matchedImport) {
133
+ // Symbol is locally declared (no import) — emit local identity.
134
+ const decl = findLocalDeclaration(fileGraph, usage.ref);
135
+ if (!decl)
136
+ return { kind: "unbound" };
137
+ return {
138
+ kind: "evaluated",
139
+ ...pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGraph, argMap, ownerRef, helperIndex, opts, viaOverride),
140
+ };
141
+ }
142
+ const packageName = packageNameFromSpecifier(matchedImport.specifier);
143
+ // Resolve namespace-import members to their export name once, up front —
144
+ // every export-name / re-export lookup below keys on this, not the raw
145
+ // `imported`. `via.import` keeps the raw `imported` (provenance).
146
+ const effectiveImported = effectiveExportName(matchedImport.imported, usage.ref.memberChain);
147
+ // Lookups below key on `effectiveImported` (the ROOT); the
148
+ // identity stamped on a fallback is the root plus the residual chain.
149
+ // A member the walker resolves to its own declaration never reaches a
150
+ // fallback, so this only names members with no declaration of their own.
151
+ const residual = residualMemberChain(matchedImport.imported, usage.ref.memberChain);
152
+ const holderDefinition = (file, exportName) => {
153
+ if (residual.length === 0)
154
+ return undefined;
155
+ const loc = graph.files.get(file)?.declarations.get(`${MODULE_SCOPE}::${exportName}`)?.loc;
156
+ return loc === undefined ? undefined : { line: loc.line, column: loc.column };
157
+ };
158
+ if (!packageName) {
159
+ // Relative / absolute specifier — always a local file reference.
160
+ // Resolve the target path; normalize absolute → graph-relative if possible.
301
161
  const resolvedAbsTarget = graph.moduleResolver(filePath, matchedImport.specifier);
302
- // Normalize absolute path → repo-relative graph key when possible.
303
- const resolvedNormTarget = resolvedAbsTarget !== null && graph.resolveToGraphKey
162
+ if (!resolvedAbsTarget)
163
+ return { kind: "import-failed", specifier: matchedImport.specifier };
164
+ const resolvedTarget = graph.resolveToGraphKey
304
165
  ? (graph.resolveToGraphKey(resolvedAbsTarget) ?? resolvedAbsTarget)
305
166
  : resolvedAbsTarget;
306
- const targetInGraph = resolvedNormTarget !== null && graph.files.has(resolvedNormTarget);
307
- if (targetInGraph) {
308
- // Target file is in graph — workspace sibling. Resolve to check
309
- // JSX type, fan out per terminal. The fallback identity is local so
310
- // null-identity terminals still attribute to the sibling file rather
311
- // than a phantom external stub. Terminal identities from deriveIdentity
312
- // are already local when applicable (workspaceSiblingIdentity fires).
313
- const refType = resolveReference(graph, fileGraph, usage.ref);
314
- const foldedTerminals = walkWithFolding(graph, fileGraph, refType, argMap);
315
- const jsxTerminals = foldedTerminals.filter((t) => t.type.kind === "JSX");
316
- if (jsxTerminals.length === 0) {
317
- emitLazyImportUnsupported(opts, filePath, usage.loc, refType);
318
- continue;
167
+ // Resolve the type to confirm it's a JSX component. Cross-file resolution
168
+ // works when resolveToGraphKey is present (graph keys are repo-relative).
169
+ const refType = resolveReference(graph, fileGraph, usage.ref);
170
+ const evaluation = walkWithFolding(graph, fileGraph, refType, argMap);
171
+ const jsxTerminals = creditedTerminals(evaluation);
172
+ if (jsxTerminals.length === 0)
173
+ return { kind: "evaluated", evaluation, value: refType };
174
+ // Determine the canonical source file + local export name. When the
175
+ // target is a re-export barrel (`export { X } from './source'`),
176
+ // follow the chain to the actual definition file so JSX edges
177
+ // attribute to the source rather than the barrel. Without
178
+ // this, two consumers importing the same component through different
179
+ // re-export paths fragment across phantom identities.
180
+ const chained = followReExportChain(graph, resolvedTarget, effectiveImported);
181
+ const canonicalFile = chained?.file ?? resolvedTarget;
182
+ let exportName = chained?.localExport ?? effectiveImported;
183
+ // When the re-export chain didn't follow (no chain present), still
184
+ // look up the local export name at the resolved target to match the
185
+ // local-index seed's hash convention.
186
+ if (!chained) {
187
+ const targetGraph = graph.files.get(resolvedTarget);
188
+ if (targetGraph && effectiveImported === "default") {
189
+ const defaultExp = targetGraph.exports.find((e) => e.kind === "default");
190
+ if (defaultExp && "local" in defaultExp) {
191
+ exportName = defaultExp.local;
192
+ }
319
193
  }
320
- const siblingFallback = workspaceSiblingFallback(graph, resolvedNormTarget, matchedImport, effectiveImported, fileGraph.dialect);
321
- const localFallback = siblingFallback !== undefined && siblingFallback.kind !== "custom-element"
322
- ? { ...siblingFallback, export: compoundExportName(siblingFallback.export, residual) }
323
- : siblingFallback;
324
- for (const ft of jsxTerminals) {
325
- pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, localFallback, viaOverride);
194
+ else if (targetGraph) {
195
+ const namedExp = targetGraph.exports.find((e) => e.kind === "named" && "exportedAs" in e && e.exportedAs === effectiveImported && "local" in e);
196
+ if (namedExp && "local" in namedExp) {
197
+ exportName = namedExp.local;
198
+ }
326
199
  }
327
200
  }
328
- else {
329
- // Target not in graph. Workspace membership: a
330
- // resolved target owned by a workspace member is first-party — emit
331
- // LOCAL, pinned to the definition file (bounded re-export resolution)
332
- // so identity is scope-invariant between whole-repo and single-app
333
- // scans. Otherwise: external, trusting the JSX call site (external
334
- // package or unresolvable specifier — identity null →
335
- // matchedImport-based componentId). See `unparsedMemberDefinition`.
336
- const member = resolvedAbsTarget !== null
337
- ? unparsedMemberDefinition(graph, resolvedAbsTarget, effectiveImported)
338
- : null;
339
- const sentinelFt = { type: { kind: "JSX" }, viaTrail: [], identity: null };
340
- if (member !== null) {
341
- const localFallback = {
342
- kind: componentKindFor(fileGraph.dialect),
343
- export: compoundExportName(member.exportName, residual),
344
- source: { type: "local", filePath: member.absFile },
345
- };
346
- pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, localFallback, viaOverride);
347
- }
348
- else {
349
- pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, undefined, viaOverride);
350
- }
201
+ const outerVia = viaOverride ?? {
202
+ kind: "direct-import",
203
+ specifier: matchedImport.specifier,
204
+ import: matchedImport.imported,
205
+ };
206
+ const fallbackComponentId = {
207
+ kind: componentKindFor(fileGraph.dialect),
208
+ export: compoundExportName(exportName, residual),
209
+ source: { type: "local", filePath: canonicalFile },
210
+ };
211
+ const site = usageSite(filePath, usage);
212
+ const owners = usageOwners(graph, helperIndex, ownerRef, viaOverride, fileGraph.filePath);
213
+ const definition = holderDefinition(canonicalFile, exportName);
214
+ for (const ft of jsxTerminals) {
215
+ const rawComponentId = componentIdFromIdentity(ft.identity, fallbackComponentId, fileGraph.dialect, {
216
+ filePath,
217
+ resolveExternalLeaf: opts?.resolveExternalLeaf,
218
+ });
219
+ emitOccurrence(occurrences, site, rawComponentId, owners, viaOverride !== undefined ? [viaOverride] : [outerVia, ...ft.viaTrail], ft.identity === null ? definition : undefined);
351
220
  }
352
- stampComposition(jsxLengthBefore, "jsx", usageIdx, jsxDepth, jsxParentUsageIdx);
221
+ return { kind: "evaluated", evaluation, value: refType };
222
+ }
223
+ // Check whether the import resolves to a local file that IS in the graph.
224
+ // If it does, resolve properly to avoid false positives (e.g. importing a
225
+ // utility function that happens to be named with a capital letter from a
226
+ // workspace sibling). For imports whose target is outside the graph
227
+ // (external packages in node_modules), trust the JSX call site directly.
228
+ const resolvedAbsTarget = graph.moduleResolver(filePath, matchedImport.specifier);
229
+ // Normalize absolute path → repo-relative graph key when possible.
230
+ const resolvedNormTarget = resolvedAbsTarget !== null && graph.resolveToGraphKey
231
+ ? (graph.resolveToGraphKey(resolvedAbsTarget) ?? resolvedAbsTarget)
232
+ : resolvedAbsTarget;
233
+ const targetInGraph = resolvedNormTarget !== null && graph.files.has(resolvedNormTarget);
234
+ if (targetInGraph) {
235
+ // Target file is in graph — workspace sibling. Resolve to check
236
+ // JSX type, fan out per terminal. The fallback identity is local so
237
+ // null-identity terminals still attribute to the sibling file rather
238
+ // than a phantom external stub. Terminal identities from deriveIdentity
239
+ // are already local when applicable (workspaceSiblingIdentity fires).
240
+ const refType = resolveReference(graph, fileGraph, usage.ref);
241
+ const evaluation = walkWithFolding(graph, fileGraph, refType, argMap);
242
+ const jsxTerminals = creditedTerminals(evaluation);
243
+ if (jsxTerminals.length === 0)
244
+ return { kind: "evaluated", evaluation, value: refType };
245
+ const siblingFallback = workspaceSiblingFallback(graph, resolvedNormTarget, matchedImport, effectiveImported, fileGraph.dialect);
246
+ const localFallback = siblingFallback !== undefined && siblingFallback.kind !== "custom-element"
247
+ ? { ...siblingFallback, export: compoundExportName(siblingFallback.export, residual) }
248
+ : siblingFallback;
249
+ const definition = siblingFallback !== undefined && siblingFallback.kind !== "custom-element" && siblingFallback.source.type === "local"
250
+ ? holderDefinition(siblingFallback.source.filePath, siblingFallback.export)
251
+ : undefined;
252
+ for (const ft of jsxTerminals) {
253
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, localFallback, viaOverride, ft.identity === null ? definition : undefined);
254
+ }
255
+ return { kind: "evaluated", evaluation, value: refType };
256
+ }
257
+ // Target not in graph. Workspace membership: a
258
+ // resolved target owned by a workspace member is first-party — emit
259
+ // LOCAL, pinned to the definition file (bounded re-export resolution)
260
+ // so identity is scope-invariant between whole-repo and single-app
261
+ // scans. Otherwise: external, trusting the JSX call site (external
262
+ // package or unresolvable specifier — identity null →
263
+ // matchedImport-based componentId). See `unparsedMemberDefinition`.
264
+ const member = resolvedAbsTarget !== null
265
+ ? unparsedMemberDefinition(graph, resolvedAbsTarget, effectiveImported)
266
+ : null;
267
+ const sentinelFt = { denotation: { kind: "foreign" }, viaTrail: [], identity: null };
268
+ if (member !== null) {
269
+ const localFallback = {
270
+ kind: componentKindFor(fileGraph.dialect),
271
+ export: compoundExportName(member.exportName, residual),
272
+ source: { type: "local", filePath: member.absFile },
273
+ };
274
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, localFallback, viaOverride);
275
+ }
276
+ else {
277
+ pushOccurrence(occurrences, filePath, usage, matchedImport, compoundExportName(effectiveImported, residual), packageName, graph, fileGraph, sentinelFt, ownerRef, helperIndex, opts, undefined, viaOverride);
278
+ }
279
+ return { kind: "evaluated", evaluation: [sentinelFt], value: null };
280
+ };
281
+ for (const [filePath, fileGraph] of graph.files) {
282
+ for (let usageIdx = 0; usageIdx < fileGraph.jsxUsages.length; usageIdx++) {
283
+ const usage = fileGraph.jsxUsages[usageIdx];
284
+ if (!usage)
285
+ continue;
286
+ const ownership = fileGraph.ownership[usageIdx];
287
+ const lengthBefore = occurrences.length;
288
+ const outcome = pushJsxUsageOccurrences(filePath, fileGraph, usage, ownership);
289
+ const admitted = stampComposition(lengthBefore, "jsx", usageIdx, ownership?.depth ?? 0, ownership?.parentUsageIdx);
290
+ if (admitted === 0)
291
+ reportDroppedRender(opts, filePath, fileGraph.dialect, usage, outcome);
353
292
  }
354
293
  // Tag-usage resolution (Vue auto-imports, Lit tag-only references, HTML).
355
294
  for (let i = 0; i < fileGraph.tagUsages.length; i++) {
@@ -433,7 +372,7 @@ export function resolve(graph, opts) {
433
372
  continue;
434
373
  if (!registry.hasLocal(filePath, decl.symbol))
435
374
  continue;
436
- const holderJsx = walkWithFolding(graph, fileGraph, decl.value, argMap).filter((t) => t.type.kind === "JSX");
375
+ const holderJsx = creditedTerminals(walkWithFolding(graph, fileGraph, decl.value, argMap));
437
376
  const foldedAway = foldedHolder(holderJsx, filePath);
438
377
  if (foldedAway !== null)
439
378
  folded.set(entryKey(filePath, decl.symbol), foldedAway);
@@ -470,10 +409,7 @@ export function resolve(graph, opts) {
470
409
  loc: { line: 0, column: 0 },
471
410
  originFile: filePath,
472
411
  };
473
- const owners = () => resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard()).map((res) => ({
474
- rawOwnerComponentId: ownerIdFromResolution(graph, res.ownerDecl),
475
- viaPrefix: res.viaPrefix,
476
- }));
412
+ const owners = () => chainOwners(graph, helperIndex, ownerRef);
477
413
  const lengthBefore = occurrences.length;
478
414
  pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph, fileGraph, argMap, opts);
479
415
  compactSlice(lengthBefore);
@@ -696,29 +632,65 @@ effectiveImported, consumerDialect) {
696
632
  };
697
633
  }
698
634
  /**
699
- * Derive the `wraps` cross-link for a given component identity by calling the
700
- * optional `wrapCallback`. Only fires for external react-component identities —
701
- * local components are user-written code, not wrappers. Returns undefined when
702
- * the callback is absent, returns null, or the identity is not an external
703
- * react-component.
635
+ * THE occurrence constructor: one occurrence per owner, `viaChain` being the
636
+ * owner's prefix followed by `chain`. Every emission path calls it; never
637
+ * build an `EngineOccurrence` literal anywhere else. The `occurrenceId` it
638
+ * computes is provisional, keyed on the serialised componentId; scan.ts
639
+ * re-derives it from the hashed componentId.
704
640
  */
705
- function computeWraps(rawComponentId, wrapCallback) {
706
- if (wrapCallback === undefined ||
707
- rawComponentId.kind !== "react-component" ||
708
- rawComponentId.source.type !== "external") {
709
- return undefined;
641
+ function emitOccurrence(occurrences, site, rawComponentId, owners, chain, definition) {
642
+ const provisionalId = serialiseComponentId(rawComponentId);
643
+ for (const { rawOwnerComponentId, viaPrefix } of owners) {
644
+ const viaChain = [...viaPrefix, ...chain];
645
+ occurrences.push({
646
+ rawComponentId,
647
+ filePath: site.filePath,
648
+ line: site.line,
649
+ column: site.column,
650
+ via: viaChain[0] ?? chain[0],
651
+ viaChain,
652
+ props: site.props,
653
+ events: site.events,
654
+ occurrenceId: computeOccurrenceId(provisionalId, site.filePath, site.line, site.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined),
655
+ depth: 0,
656
+ ...(definition !== undefined ? { definition } : {}),
657
+ ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
658
+ });
710
659
  }
711
- const hit = wrapCallback(rawComponentId.source.package, rawComponentId.export);
712
- if (!hit)
713
- return undefined;
714
- return { kind: "custom-element", tagName: hit.tagName, packageName: hit.packageName };
660
+ }
661
+ function usageSite(filePath, usage) {
662
+ return {
663
+ filePath,
664
+ line: usage.loc.line,
665
+ column: usage.loc.column,
666
+ props: projectPropUsages(usage.props),
667
+ events: usage.events ?? [],
668
+ };
669
+ }
670
+ /** The owner chain of `ownerRef`, one owner per resolution with its `helper-call` prefix. */
671
+ function chainOwners(graph, helperIndex, ownerRef) {
672
+ return resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard()).map((res) => ({
673
+ rawOwnerComponentId: ownerIdFromResolution(graph, res.ownerDecl),
674
+ viaPrefix: res.viaPrefix,
675
+ }));
676
+ }
677
+ /**
678
+ * Owners of a JSX usage. A `viaOverride` means the parser pre-decided the
679
+ * owner (prop-forward): one owner, no prefix. Otherwise the owner chain fans
680
+ * out, each resolution contributing its `helper-call` prefix.
681
+ */
682
+ function usageOwners(graph, helperIndex, ownerRef, viaOverride, fallbackFile) {
683
+ if (viaOverride !== undefined) {
684
+ return [{ rawOwnerComponentId: ownerIdFromRef(graph, ownerRef, fallbackFile), viaPrefix: [] }];
685
+ }
686
+ return chainOwners(graph, helperIndex, ownerRef);
715
687
  }
716
688
  function pushOccurrence(occurrences, filePath, usage, matchedImport,
717
689
  /** Export name for the fallback identity — the root export resolved via
718
690
  * `effectiveExportName` joined with the residual member chain via
719
691
  * `compoundExportName`. Distinct from `matchedImport.imported`,
720
692
  * which stays raw for `via` provenance. */
721
- effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, fallbackOverride, viaOverride) {
693
+ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opts, fallbackOverride, viaOverride, definition) {
722
694
  const fallback = fallbackOverride ?? {
723
695
  kind: componentKindFor(fileGraph.dialect),
724
696
  export: effectiveImported,
@@ -729,82 +701,63 @@ effectiveImported, packageName, graph, fileGraph, ft, ownerRef, helperIndex, opt
729
701
  fallbackSpecifier: matchedImport.specifier,
730
702
  resolveExternalLeaf: opts?.resolveExternalLeaf,
731
703
  });
732
- // Derive wraps cross-link for external react-component occurrences.
733
- // wrapCallback only fires for external packages — local react-components are
734
- // user-written, not wrappers. Does NOT affect rawComponentId or hashing.
735
- const wraps = computeWraps(rawComponentId, opts?.wrapCallback);
736
704
  const outerVia = viaOverride ?? {
737
705
  kind: "direct-import",
738
706
  specifier: matchedImport.specifier,
739
707
  import: matchedImport.imported,
740
708
  };
741
- if (viaOverride !== undefined) {
742
- // Prop-forward: parser has pre-decided the owner — single-occurrence path.
743
- const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, filePath);
744
- const viaChain = [viaOverride];
745
- const provisionalId = serialiseComponentId(rawComponentId);
746
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
747
- occurrences.push({
748
- rawComponentId,
749
- filePath,
750
- line: usage.loc.line,
751
- column: usage.loc.column,
752
- via: viaChain[0] ?? outerVia,
753
- viaChain,
754
- props: projectPropUsages(usage.props),
755
- events: usage.events ?? [],
756
- occurrenceId,
757
- depth: 0,
758
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
759
- ...(wraps !== undefined ? { wraps } : {}),
760
- });
709
+ emitOccurrence(occurrences, usageSite(filePath, usage), rawComponentId, usageOwners(graph, helperIndex, ownerRef, viaOverride, filePath), viaOverride !== undefined ? [viaOverride] : [outerVia, ...ft.viaTrail], definition);
710
+ }
711
+ /**
712
+ * The one reporter for a JSX usage that produced no occurrence. A host-element
713
+ * root reports nothing. A failed import is `unresolved-import`, an unbound root
714
+ * `unresolved-reference`. A walked value holding an `import()` is
715
+ * `lazy-import-unsupported`, naming the file the `import()` is declared in when
716
+ * that is not the render file; the `import()` in the walked value is checked
717
+ * before the evaluation. Everything the evaluation itself decides
718
+ * is `renderOutcome`'s: a value that is not a component reports nothing, a
719
+ * value supplied elsewhere reports `late-bound-render`, and anything else —
720
+ * including a credit whose occurrences admission rejected (#544: one occurrence
721
+ * or one diagnostic) — reports `unresolved-reference`. A non-react dialect
722
+ * reports only the import codes (#621).
723
+ */
724
+ function reportDroppedRender(opts, filePath, dialect, usage, outcome) {
725
+ const collector = opts?.collector;
726
+ if (collector === undefined)
727
+ return;
728
+ if (isHostElementRoot(usage.ref))
729
+ return;
730
+ const at = { filePath, line: usage.loc.line, column: usage.loc.column };
731
+ const positional = { ...at, symbol: usage.ref.symbol, memberChain: [...usage.ref.memberChain] };
732
+ if (outcome.kind === "import-failed") {
733
+ collector.emit({ code: "unresolved-import", severity: "warning", ...at, specifier: outcome.specifier });
761
734
  return;
762
735
  }
763
- // Helper-call fanout: resolve owner chain and emit per resolution.
764
- const ownerResolutions = resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard());
765
- for (const res of ownerResolutions) {
766
- const rawOwnerComponentId = ownerIdFromResolution(graph, res.ownerDecl);
767
- const viaChain = [...res.viaPrefix, outerVia, ...ft.viaTrail];
768
- // Provisional occurrenceId keyed on the serialised (human-readable) form.
769
- // Scan.ts re-derives the final occurrenceId using the hashed componentId.
770
- const provisionalId = serialiseComponentId(rawComponentId);
771
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
772
- occurrences.push({
773
- rawComponentId,
774
- filePath,
775
- line: usage.loc.line,
776
- column: usage.loc.column,
777
- via: viaChain[0] ?? outerVia,
778
- viaChain,
779
- props: projectPropUsages(usage.props),
780
- events: usage.events ?? [],
781
- occurrenceId,
782
- depth: 0,
783
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
784
- ...(wraps !== undefined ? { wraps } : {}),
736
+ if (outcome.kind === "unbound") {
737
+ collector.emit({ code: "unresolved-reference", severity: "info", ...positional });
738
+ return;
739
+ }
740
+ const di = outcome.value === null ? null : firstDynamicImport(outcome.value);
741
+ if (di) {
742
+ collector.emit({
743
+ code: "lazy-import-unsupported",
744
+ severity: "warning",
745
+ ...at,
746
+ specifier: di.specifier,
747
+ detail: `the import() target could not be resolved to a component${di.originFile === filePath ? "" : ` (declared in ${di.originFile})`}`,
785
748
  });
749
+ return;
786
750
  }
787
- }
788
- /**
789
- * A fold that reached no JSX while an import() sits in the walked value is a
790
- * lazy shape the pipeline could not resolve to a component: a `.then` callback
791
- * that does more than pick a property, or a target whose component shape could
792
- * not be confirmed. Report it at the render site so the miss is visible, and
793
- * name the file the `import()` is declared in when that is not the render file.
794
- */
795
- function emitLazyImportUnsupported(opts, filePath, loc, value) {
796
- const di = opts?.collector ? firstDynamicImport(value) : null;
797
- if (!di)
751
+ const decided = renderOutcome(outcome.evaluation);
752
+ if (decided.kind === "silent")
798
753
  return;
799
- opts?.collector?.emit({
800
- code: "lazy-import-unsupported",
801
- severity: "warning",
802
- filePath,
803
- line: loc.line,
804
- column: loc.column,
805
- specifier: di.specifier,
806
- detail: `the import() target could not be resolved to a component${di.originFile === filePath ? "" : ` (declared in ${di.originFile})`}`,
807
- });
754
+ if (dialect !== "react")
755
+ return;
756
+ if (decided.kind === "credit") {
757
+ collector.emit({ code: "unresolved-reference", severity: "info", ...positional });
758
+ return;
759
+ }
760
+ collector.emit({ code: decided.code, severity: "info", ...positional });
808
761
  }
809
762
  /**
810
763
  * Push local-scope occurrences for a symbol declared in the same file (no import).
@@ -817,82 +770,27 @@ function pushLocalOccurrence(occurrences, filePath, usage, decl, graph, fileGrap
817
770
  // static-MemberOf arm). A bare reference is unchanged:
818
771
  // `decl.value` is what `resolveReference` returns for an empty chain.
819
772
  const value = usage.ref.memberChain.length > 0 ? resolveReference(graph, fileGraph, usage.ref) : decl.value;
820
- const folded = walkWithFolding(graph, fileGraph, value, argMap);
821
- const jsxTerminals = folded.filter((t) => t.type.kind === "JSX");
822
- // A reference that resolves to no JSX terminal — a function parameter
823
- // (`<Component/>` inside a factory body), a hook-return destructure, or an
824
- // unresolvable alias — emits no occurrence.
825
- if (jsxTerminals.length === 0) {
826
- emitLazyImportUnsupported(opts, filePath, usage.loc, value);
827
- return;
828
- }
773
+ const evaluation = walkWithFolding(graph, fileGraph, value, argMap);
774
+ const jsxTerminals = creditedTerminals(evaluation);
775
+ if (jsxTerminals.length === 0)
776
+ return { evaluation, value };
829
777
  const outerVia = viaOverride ?? { kind: "local-component" };
830
- if (viaOverride !== undefined) {
831
- // Prop-forward: parser has pre-decided the owner — single-occurrence path.
832
- const rawOwnerComponentId = ownerIdFromRef(graph, ownerRef, fileGraph.filePath);
833
- for (const ft of jsxTerminals) {
834
- const fallback = {
835
- kind: componentKindFor(fileGraph.dialect),
836
- export: compoundExportName(usage.ref.symbol, usage.ref.memberChain),
837
- source: { type: "local", filePath },
838
- };
839
- const rawComponentId = componentIdFromIdentity(ft.identity, fallback, fileGraph.dialect, {
840
- filePath,
841
- resolveExternalLeaf: opts?.resolveExternalLeaf,
842
- });
843
- const viaChain = [viaOverride];
844
- const provisionalId = serialiseComponentId(rawComponentId);
845
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
846
- occurrences.push({
847
- rawComponentId,
848
- filePath,
849
- line: usage.loc.line,
850
- column: usage.loc.column,
851
- via: viaChain[0] ?? outerVia,
852
- viaChain,
853
- props: projectPropUsages(usage.props),
854
- events: usage.events ?? [],
855
- occurrenceId,
856
- depth: 0,
857
- definition: { line: decl.loc.line, column: decl.loc.column },
858
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
859
- });
860
- }
861
- return;
862
- }
863
- // Helper-call fanout: resolve owner chain and emit per (terminal × resolution).
864
- const ownerResolutions = resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard());
778
+ const site = usageSite(filePath, usage);
779
+ const owners = usageOwners(graph, helperIndex, ownerRef, viaOverride, fileGraph.filePath);
780
+ const fallback = {
781
+ kind: componentKindFor(fileGraph.dialect),
782
+ export: compoundExportName(usage.ref.symbol, usage.ref.memberChain),
783
+ source: { type: "local", filePath },
784
+ };
785
+ const definition = { line: decl.loc.line, column: decl.loc.column };
865
786
  for (const ft of jsxTerminals) {
866
- const fallback = {
867
- kind: componentKindFor(fileGraph.dialect),
868
- export: compoundExportName(usage.ref.symbol, usage.ref.memberChain),
869
- source: { type: "local", filePath },
870
- };
871
787
  const rawComponentId = componentIdFromIdentity(ft.identity, fallback, fileGraph.dialect, {
872
788
  filePath,
873
789
  resolveExternalLeaf: opts?.resolveExternalLeaf,
874
790
  });
875
- for (const res of ownerResolutions) {
876
- const rawOwnerComponentId = ownerIdFromResolution(graph, res.ownerDecl);
877
- const viaChain = [...res.viaPrefix, outerVia, ...ft.viaTrail];
878
- const provisionalId = serialiseComponentId(rawComponentId);
879
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
880
- occurrences.push({
881
- rawComponentId,
882
- filePath,
883
- line: usage.loc.line,
884
- column: usage.loc.column,
885
- via: viaChain[0] ?? outerVia,
886
- viaChain,
887
- props: projectPropUsages(usage.props),
888
- events: usage.events ?? [],
889
- occurrenceId,
890
- depth: 0,
891
- definition: { line: decl.loc.line, column: decl.loc.column },
892
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
893
- });
894
- }
791
+ emitOccurrence(occurrences, site, rawComponentId, owners, viaOverride !== undefined ? [viaOverride] : [outerVia, ...ft.viaTrail], ft.identity === null ? definition : undefined);
895
792
  }
793
+ return { evaluation, value };
896
794
  }
897
795
  /**
898
796
  * A holder is folded away iff its fold reaches JSX and EVERY JSX
@@ -939,7 +837,7 @@ function pushArgumentSiteOccurrences(occurrences, filePath, holder, call, holder
939
837
  const folded = foldedArgument(graph, fileGraph, call, holderJsx, argMap);
940
838
  if (folded?.arg.kind !== "ReturnTypeOf")
941
839
  return;
942
- const innerJsx = walkWithFolding(graph, fileGraph, folded.arg, argMap).filter((t) => t.type.kind === "JSX");
840
+ const innerJsx = creditedTerminals(walkWithFolding(graph, fileGraph, folded.arg, argMap));
943
841
  pushArgumentSiteOccurrences(occurrences, filePath, holder, folded.arg, innerJsx, graph, fileGraph, argMap, opts, depth + 1);
944
842
  return;
945
843
  }
@@ -978,8 +876,6 @@ function callFoldShape(jsx) {
978
876
  return "product";
979
877
  return credited === jsx.length ? "wrapper" : null;
980
878
  }
981
- /** React's own hook predicate (`isHookName` in rules-of-hooks and the compiler). */
982
- const HOOK_NAME = /^use[A-Z0-9]/;
983
879
  /**
984
880
  * THE argument-site push seam: one `passed-as-argument` occurrence per
985
881
  * (identifier argument that resolves to a component) × (owner). The
@@ -1021,34 +917,15 @@ function pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph,
1021
917
  // and `buildComponentId` returns the fallback only for a null one. The
1022
918
  // relabel seam's signature requires a fallback all the same.
1023
919
  const unusedFallback = makeLocalComponentId(filePath, arg.ref.symbol, fileGraph.dialect);
1024
- for (const ft of walkWithFolding(graph, fileGraph, arg, argMap)) {
1025
- if (ft.type.kind !== "JSX" || ft.identity === null)
920
+ for (const ft of creditedTerminals(walkWithFolding(graph, fileGraph, arg, argMap))) {
921
+ if (ft.identity === null)
1026
922
  continue;
1027
923
  const rawComponentId = componentIdFromIdentity(ft.identity, unusedFallback, fileGraph.dialect, {
1028
924
  filePath,
1029
925
  resolveExternalLeaf: opts?.resolveExternalLeaf,
1030
926
  });
1031
- const wraps = computeWraps(rawComponentId, opts?.wrapCallback);
1032
927
  resolvedOwners ??= owners();
1033
- for (const { rawOwnerComponentId, viaPrefix } of resolvedOwners) {
1034
- const viaChain = [...viaPrefix, outerVia, ...hops, ...ft.viaTrail];
1035
- const occurrenceId = computeOccurrenceId(serialiseComponentId(rawComponentId), filePath, arg.ref.loc.line, arg.ref.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
1036
- occurrences.push({
1037
- rawComponentId,
1038
- filePath,
1039
- line: arg.ref.loc.line,
1040
- column: arg.ref.loc.column,
1041
- via: viaChain[0] ?? outerVia,
1042
- viaChain,
1043
- props: {},
1044
- events: [],
1045
- occurrenceId,
1046
- depth: 0,
1047
- ...(argDecl !== null ? { definition: { line: argDecl.loc.line, column: argDecl.loc.column } } : {}),
1048
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
1049
- ...(wraps !== undefined ? { wraps } : {}),
1050
- });
1051
- }
928
+ emitOccurrence(occurrences, { filePath, line: arg.ref.loc.line, column: arg.ref.loc.column, props: {}, events: [] }, rawComponentId, resolvedOwners, [outerVia, ...hops, ...ft.viaTrail], argDecl !== null ? { line: argDecl.loc.line, column: argDecl.loc.column } : undefined);
1052
929
  }
1053
930
  };
1054
931
  const seedArguments = (site, siteDepth) => {
@@ -1061,7 +938,7 @@ function pushCallArgumentOccurrences(occurrences, filePath, call, owners, graph,
1061
938
  }
1062
939
  if (arg.kind !== "ReturnTypeOf" || callDepth >= MAX_ARGUMENT_CALL_DEPTH)
1063
940
  return;
1064
- const argJsx = walkWithFolding(graph, fileGraph, arg, argMap).filter((t) => t.type.kind === "JSX");
941
+ const argJsx = creditedTerminals(walkWithFolding(graph, fileGraph, arg, argMap));
1065
942
  const shape = callFoldShape(argJsx);
1066
943
  if (shape === "product") {
1067
944
  seedArguments(arg, callDepth + 1);
@@ -1098,27 +975,10 @@ function findTagOwnership(ownership, usageIdx) {
1098
975
  */
1099
976
  function pushTagOccurrence(occurrences, filePath, usage, rawComponentId, ownerRef, viaOverride, graph, helperIndex) {
1100
977
  const via = viaOverride ?? { kind: "html-tag" };
1101
- // Owner-chain fanout uses the same machinery as JsxUsage.
1102
- const ownerResolutions = resolveOwnerChain(ownerRef, graph, helperIndex, createCycleGuard());
1103
- for (const res of ownerResolutions) {
1104
- const rawOwnerComponentId = ownerIdFromResolution(graph, res.ownerDecl);
1105
- const viaChain = [...res.viaPrefix, via];
1106
- const provisionalId = serialiseComponentId(rawComponentId);
1107
- const occurrenceId = computeOccurrenceId(provisionalId, filePath, usage.loc.line, usage.loc.column, rawOwnerComponentId !== undefined ? serialiseComponentId(rawOwnerComponentId) : undefined);
1108
- occurrences.push({
1109
- rawComponentId,
1110
- filePath,
1111
- line: usage.loc.line,
1112
- column: usage.loc.column,
1113
- via: viaChain[0] ?? via,
1114
- viaChain,
1115
- props: projectPropUsages(usage.props),
1116
- events: usage.events ?? [],
1117
- occurrenceId,
1118
- depth: 0,
1119
- ...(rawOwnerComponentId !== undefined ? { rawOwnerComponentId } : {}),
1120
- });
1121
- }
978
+ // Owner-chain fanout uses the same machinery as JsxUsage, a `viaOverride`
979
+ // notwithstanding.
980
+ const owners = chainOwners(graph, helperIndex, ownerRef);
981
+ emitOccurrence(occurrences, usageSite(filePath, usage), rawComponentId, owners, [via]);
1122
982
  }
1123
983
  /**
1124
984
  * Decide the `kind` for a JSX-emitted ComponentId. A concrete