@zuvia-software-solutions/code-mapper 1.4.0 → 2.0.0

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 (137) hide show
  1. package/dist/cli/ai-context.js +1 -1
  2. package/dist/cli/analyze.d.ts +1 -0
  3. package/dist/cli/analyze.js +73 -82
  4. package/dist/cli/augment.js +0 -2
  5. package/dist/cli/eval-server.d.ts +2 -2
  6. package/dist/cli/eval-server.js +6 -6
  7. package/dist/cli/index.js +6 -10
  8. package/dist/cli/mcp.d.ts +1 -3
  9. package/dist/cli/mcp.js +3 -3
  10. package/dist/cli/refresh.d.ts +2 -2
  11. package/dist/cli/refresh.js +24 -29
  12. package/dist/cli/status.js +4 -13
  13. package/dist/cli/tool.d.ts +5 -4
  14. package/dist/cli/tool.js +8 -10
  15. package/dist/config/ignore-service.js +14 -34
  16. package/dist/core/augmentation/engine.js +53 -83
  17. package/dist/core/db/adapter.d.ts +99 -0
  18. package/dist/core/db/adapter.js +402 -0
  19. package/dist/core/db/graph-loader.d.ts +27 -0
  20. package/dist/core/db/graph-loader.js +148 -0
  21. package/dist/core/db/queries.d.ts +160 -0
  22. package/dist/core/db/queries.js +441 -0
  23. package/dist/core/db/schema.d.ts +108 -0
  24. package/dist/core/db/schema.js +136 -0
  25. package/dist/core/embeddings/embedder.d.ts +21 -12
  26. package/dist/core/embeddings/embedder.js +104 -50
  27. package/dist/core/embeddings/embedding-pipeline.d.ts +48 -22
  28. package/dist/core/embeddings/embedding-pipeline.js +220 -262
  29. package/dist/core/embeddings/text-generator.js +4 -19
  30. package/dist/core/embeddings/types.d.ts +1 -1
  31. package/dist/core/graph/graph.d.ts +1 -1
  32. package/dist/core/graph/graph.js +1 -0
  33. package/dist/core/graph/types.d.ts +11 -9
  34. package/dist/core/graph/types.js +4 -1
  35. package/dist/core/incremental/refresh.d.ts +46 -0
  36. package/dist/core/incremental/refresh.js +464 -0
  37. package/dist/core/incremental/types.d.ts +2 -1
  38. package/dist/core/incremental/types.js +42 -44
  39. package/dist/core/ingestion/ast-cache.js +1 -0
  40. package/dist/core/ingestion/call-processor.d.ts +15 -3
  41. package/dist/core/ingestion/call-processor.js +448 -60
  42. package/dist/core/ingestion/cluster-enricher.d.ts +1 -1
  43. package/dist/core/ingestion/cluster-enricher.js +2 -0
  44. package/dist/core/ingestion/community-processor.d.ts +1 -1
  45. package/dist/core/ingestion/community-processor.js +8 -3
  46. package/dist/core/ingestion/export-detection.d.ts +1 -1
  47. package/dist/core/ingestion/export-detection.js +1 -1
  48. package/dist/core/ingestion/filesystem-walker.js +1 -1
  49. package/dist/core/ingestion/heritage-processor.d.ts +2 -2
  50. package/dist/core/ingestion/heritage-processor.js +22 -11
  51. package/dist/core/ingestion/import-processor.d.ts +2 -2
  52. package/dist/core/ingestion/import-processor.js +24 -9
  53. package/dist/core/ingestion/language-config.js +7 -4
  54. package/dist/core/ingestion/mro-processor.d.ts +1 -1
  55. package/dist/core/ingestion/mro-processor.js +23 -11
  56. package/dist/core/ingestion/named-binding-extraction.js +5 -5
  57. package/dist/core/ingestion/parsing-processor.d.ts +4 -4
  58. package/dist/core/ingestion/parsing-processor.js +26 -18
  59. package/dist/core/ingestion/pipeline.d.ts +4 -2
  60. package/dist/core/ingestion/pipeline.js +50 -20
  61. package/dist/core/ingestion/process-processor.d.ts +2 -2
  62. package/dist/core/ingestion/process-processor.js +28 -14
  63. package/dist/core/ingestion/resolution-context.d.ts +1 -1
  64. package/dist/core/ingestion/resolution-context.js +14 -4
  65. package/dist/core/ingestion/resolvers/csharp.js +4 -3
  66. package/dist/core/ingestion/resolvers/go.js +3 -1
  67. package/dist/core/ingestion/resolvers/jvm.js +13 -4
  68. package/dist/core/ingestion/resolvers/standard.js +2 -2
  69. package/dist/core/ingestion/resolvers/utils.js +6 -2
  70. package/dist/core/ingestion/route-stitcher.d.ts +15 -0
  71. package/dist/core/ingestion/route-stitcher.js +92 -0
  72. package/dist/core/ingestion/structure-processor.d.ts +1 -1
  73. package/dist/core/ingestion/structure-processor.js +3 -2
  74. package/dist/core/ingestion/symbol-table.d.ts +2 -0
  75. package/dist/core/ingestion/symbol-table.js +5 -1
  76. package/dist/core/ingestion/tree-sitter-queries.d.ts +2 -2
  77. package/dist/core/ingestion/tree-sitter-queries.js +177 -0
  78. package/dist/core/ingestion/type-env.js +20 -0
  79. package/dist/core/ingestion/type-extractors/csharp.js +4 -3
  80. package/dist/core/ingestion/type-extractors/go.js +23 -12
  81. package/dist/core/ingestion/type-extractors/php.js +18 -10
  82. package/dist/core/ingestion/type-extractors/ruby.js +15 -3
  83. package/dist/core/ingestion/type-extractors/rust.js +3 -2
  84. package/dist/core/ingestion/type-extractors/shared.js +3 -2
  85. package/dist/core/ingestion/type-extractors/typescript.js +11 -5
  86. package/dist/core/ingestion/utils.d.ts +27 -4
  87. package/dist/core/ingestion/utils.js +145 -100
  88. package/dist/core/ingestion/workers/parse-worker.d.ts +1 -0
  89. package/dist/core/ingestion/workers/parse-worker.js +97 -29
  90. package/dist/core/ingestion/workers/worker-pool.js +3 -0
  91. package/dist/core/search/bm25-index.d.ts +15 -8
  92. package/dist/core/search/bm25-index.js +48 -98
  93. package/dist/core/search/hybrid-search.d.ts +9 -3
  94. package/dist/core/search/hybrid-search.js +30 -25
  95. package/dist/core/search/reranker.js +9 -7
  96. package/dist/core/search/types.d.ts +0 -4
  97. package/dist/core/semantic/tsgo-service.d.ts +5 -1
  98. package/dist/core/semantic/tsgo-service.js +161 -66
  99. package/dist/lib/tsgo-test.d.ts +2 -0
  100. package/dist/lib/tsgo-test.js +6 -0
  101. package/dist/lib/type-utils.d.ts +25 -0
  102. package/dist/lib/type-utils.js +22 -0
  103. package/dist/lib/utils.d.ts +3 -2
  104. package/dist/lib/utils.js +3 -2
  105. package/dist/mcp/compatible-stdio-transport.js +1 -1
  106. package/dist/mcp/local/local-backend.d.ts +29 -56
  107. package/dist/mcp/local/local-backend.js +808 -1118
  108. package/dist/mcp/resources.js +35 -25
  109. package/dist/mcp/server.d.ts +1 -1
  110. package/dist/mcp/server.js +5 -5
  111. package/dist/mcp/tools.js +24 -25
  112. package/dist/storage/repo-manager.d.ts +2 -12
  113. package/dist/storage/repo-manager.js +1 -47
  114. package/dist/types/pipeline.d.ts +8 -5
  115. package/dist/types/pipeline.js +5 -0
  116. package/package.json +18 -11
  117. package/dist/cli/serve.d.ts +0 -5
  118. package/dist/cli/serve.js +0 -8
  119. package/dist/core/incremental/child-process.d.ts +0 -8
  120. package/dist/core/incremental/child-process.js +0 -649
  121. package/dist/core/incremental/refresh-coordinator.d.ts +0 -32
  122. package/dist/core/incremental/refresh-coordinator.js +0 -147
  123. package/dist/core/lbug/csv-generator.d.ts +0 -28
  124. package/dist/core/lbug/csv-generator.js +0 -355
  125. package/dist/core/lbug/lbug-adapter.d.ts +0 -96
  126. package/dist/core/lbug/lbug-adapter.js +0 -753
  127. package/dist/core/lbug/schema.d.ts +0 -46
  128. package/dist/core/lbug/schema.js +0 -402
  129. package/dist/mcp/core/embedder.d.ts +0 -24
  130. package/dist/mcp/core/embedder.js +0 -168
  131. package/dist/mcp/core/lbug-adapter.d.ts +0 -29
  132. package/dist/mcp/core/lbug-adapter.js +0 -330
  133. package/dist/server/api.d.ts +0 -5
  134. package/dist/server/api.js +0 -340
  135. package/dist/server/mcp-http.d.ts +0 -7
  136. package/dist/server/mcp-http.js +0 -95
  137. package/models/mlx-embedder.py +0 -185
@@ -5,10 +5,12 @@ import { TIER_CONFIDENCE } from './resolution-context.js';
5
5
  import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
6
6
  import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
7
7
  import { generateId } from '../../lib/utils.js';
8
+ import { toNodeId, toEdgeId } from '../db/schema.js';
8
9
  import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId, CALL_EXPRESSION_TYPES, extractCallChain, } from './utils.js';
9
10
  import { buildTypeEnv } from './type-env.js';
10
11
  import { getTreeSitterBufferSize } from './constants.js';
11
12
  import { callRouters } from './call-routing.js';
13
+ import path from 'node:path';
12
14
  /** Walk up the AST to find the enclosing function/method, or null for top-level code */
13
15
  const findEnclosingFunction = (node, filePath, ctx) => {
14
16
  let current = node.parent;
@@ -18,7 +20,9 @@ const findEnclosingFunction = (node, filePath, ctx) => {
18
20
  if (funcName) {
19
21
  const resolved = ctx.resolve(funcName, filePath);
20
22
  if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
21
- return resolved.candidates[0].nodeId;
23
+ const first = resolved.candidates[0];
24
+ if (first)
25
+ return first.nodeId;
22
26
  }
23
27
  return generateId(label, `${filePath}:${funcName}`);
24
28
  }
@@ -46,7 +50,7 @@ const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
46
50
  const narrowed = callableDefs.filter(d => {
47
51
  if (!d.ownerId)
48
52
  return false;
49
- const owner = graph.getNode(d.ownerId);
53
+ const owner = graph.getNode(toNodeId(d.ownerId));
50
54
  return owner?.properties.name === receiverClassName;
51
55
  });
52
56
  if (narrowed.length > 0)
@@ -63,8 +67,9 @@ const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
63
67
  }
64
68
  }
65
69
  }
66
- if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) {
67
- const typeName = extractReturnTypeName(callableDefs[0].returnType);
70
+ const singleDef = callableDefs && callableDefs.length === 1 ? callableDefs[0] : undefined;
71
+ if (singleDef && singleDef.returnType) {
72
+ const typeName = extractReturnTypeName(singleDef.returnType);
68
73
  if (typeName) {
69
74
  verified.set(receiverKey(scope, varName), typeName);
70
75
  }
@@ -105,6 +110,8 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
105
110
  const skippedByLang = logSkipped ? new Map() : null;
106
111
  for (let i = 0; i < files.length; i++) {
107
112
  const file = files[i];
113
+ if (!file)
114
+ continue;
108
115
  onProgress?.(i + 1, files.length);
109
116
  if (i % 20 === 0)
110
117
  await yieldToEventLoop();
@@ -190,15 +197,16 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
190
197
  },
191
198
  });
192
199
  ctx.symbols.add(file.path, item.propName, nodeId, 'Property', propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
193
- const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
200
+ const relId = toEdgeId(`DEFINES:${fileId}->${nodeId}`);
194
201
  graph.addRelationship({
195
202
  id: relId, sourceId: fileId, targetId: nodeId,
196
203
  type: 'DEFINES', confidence: 1.0, reason: '',
197
204
  });
198
205
  if (propEnclosingClassId) {
206
+ const ownerNodeId = toNodeId(propEnclosingClassId);
199
207
  graph.addRelationship({
200
- id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
201
- sourceId: propEnclosingClassId, targetId: nodeId,
208
+ id: toEdgeId(`HAS_METHOD:${propEnclosingClassId}->${nodeId}`),
209
+ sourceId: ownerNodeId, targetId: nodeId,
202
210
  type: 'HAS_METHOD', confidence: 1.0, reason: '',
203
211
  });
204
212
  }
@@ -239,7 +247,7 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
239
247
  const importedName = child.text;
240
248
  if (importedName && !isBuiltInOrNoise(importedName)) {
241
249
  const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
242
- const sourceId = enclosingFuncId || generateId('File', file.path);
250
+ const sourceId = enclosingFuncId ? toNodeId(enclosingFuncId) : generateId('File', file.path);
243
251
  // Try module-scoped resolution first: extract the target filename
244
252
  // from the import path and look up the name in that file
245
253
  let targetId = null;
@@ -261,16 +269,19 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
261
269
  if (!targetId) {
262
270
  const resolved = ctx.resolve(importedName, file.path);
263
271
  if (resolved && resolved.candidates.length > 0) {
264
- targetId = resolved.candidates[0].nodeId;
265
- confidence = Math.max(TIER_CONFIDENCE[resolved.tier] ?? 0.5, 0.8);
266
- reason = `dynamic-import:${resolved.tier}`;
272
+ const firstCandidate = resolved.candidates[0];
273
+ if (firstCandidate) {
274
+ targetId = firstCandidate.nodeId;
275
+ confidence = Math.max(TIER_CONFIDENCE[resolved.tier] ?? 0.5, 0.8);
276
+ reason = `dynamic-import:${resolved.tier}`;
277
+ }
267
278
  }
268
279
  }
269
280
  if (targetId) {
270
281
  graph.addRelationship({
271
- id: generateId('CALLS', `${sourceId}:dyn_import:${importedName}->${targetId}`),
282
+ id: toEdgeId(`CALLS:${sourceId}:dyn_import:${importedName}->${targetId}`),
272
283
  sourceId,
273
- targetId,
284
+ targetId: toNodeId(targetId),
274
285
  type: 'CALLS',
275
286
  confidence,
276
287
  reason,
@@ -336,22 +347,28 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
336
347
  const GENERIC_MEMBER_METHODS = new Set(['has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset', 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values']);
337
348
  if (callForm === 'member' && !receiverTypeName && GENERIC_MEMBER_METHODS.has(calledName))
338
349
  return;
339
- const resolved = resolveCallTarget({
340
- calledName,
341
- argCount: countCallArguments(callNode),
342
- callForm,
343
- receiverTypeName,
344
- }, file.path, ctx);
350
+ const argCount = countCallArguments(callNode);
351
+ const callInfo = { calledName };
352
+ if (argCount !== undefined)
353
+ callInfo.argCount = argCount;
354
+ if (callForm !== undefined)
355
+ callInfo.callForm = callForm;
356
+ if (receiverTypeName !== undefined)
357
+ callInfo.receiverTypeName = receiverTypeName;
358
+ const resolved = resolveCallTarget(callInfo, file.path, ctx);
345
359
  if (!resolved)
346
360
  return;
347
361
  const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
348
- const sourceId = enclosingFuncId || generateId('File', file.path);
349
- const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
362
+ const sourceId = enclosingFuncId ? toNodeId(enclosingFuncId) : generateId('File', file.path);
363
+ // Suppress self-referencing edges from unresolved method chains
364
+ if (sourceId === toNodeId(resolved.nodeId) && callForm === 'member' && !receiverTypeName)
365
+ return;
366
+ const relId = toEdgeId(`CALLS:${sourceId}:${calledName}->${resolved.nodeId}`);
350
367
  const callContext = isInsideCatch(callNode) ? 'error-handler' : isInsideConditional(callNode) ? 'conditional' : '';
351
368
  graph.addRelationship({
352
369
  id: relId,
353
370
  sourceId,
354
- targetId: resolved.nodeId,
371
+ targetId: toNodeId(resolved.nodeId),
355
372
  type: 'CALLS',
356
373
  confidence: resolved.confidence,
357
374
  reason: callContext || resolved.reason,
@@ -387,13 +404,15 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
387
404
  const importedName = child.text;
388
405
  if (importedName && !isBuiltInOrNoise(importedName)) {
389
406
  const enclosingFuncId = findEnclosingFunction(node, file.path, ctx);
390
- const sourceId = enclosingFuncId || generateId('File', file.path);
407
+ const sourceId = enclosingFuncId ? toNodeId(enclosingFuncId) : generateId('File', file.path);
391
408
  const resolved = ctx.resolve(importedName, file.path);
392
- if (resolved && resolved.candidates.length > 0) {
409
+ const firstResolved = resolved && resolved.candidates.length > 0 ? resolved.candidates[0] : undefined;
410
+ if (firstResolved) {
411
+ const targetNodeId = toNodeId(firstResolved.nodeId);
393
412
  graph.addRelationship({
394
- id: generateId('CALLS', `${sourceId}:dyn:${importedName}->${resolved.candidates[0].nodeId}`),
413
+ id: toEdgeId(`CALLS:${sourceId}:dyn:${importedName}->${targetNodeId}`),
395
414
  sourceId,
396
- targetId: resolved.candidates[0].nodeId,
415
+ targetId: targetNodeId,
397
416
  type: 'CALLS',
398
417
  confidence: 0.85,
399
418
  reason: 'dynamic-import',
@@ -476,7 +495,10 @@ const toResolveResult = (definition, tier) => ({
476
495
  function resolveChainedReceiver(chainNames, baseReceiverTypeName, currentFile, ctx) {
477
496
  let currentType = baseReceiverTypeName;
478
497
  for (const name of chainNames) {
479
- const resolved = resolveCallTarget({ calledName: name, callForm: 'member', receiverTypeName: currentType }, currentFile, ctx);
498
+ const chainCallInfo = { calledName: name, callForm: 'member' };
499
+ if (currentType !== undefined)
500
+ chainCallInfo.receiverTypeName = currentType;
501
+ const resolved = resolveCallTarget(chainCallInfo, currentFile, ctx);
480
502
  if (!resolved)
481
503
  return undefined;
482
504
  const candidates = ctx.symbols.lookupFuzzy(name);
@@ -516,14 +538,16 @@ const resolveCallTarget = (call, currentFile, ctx) => {
516
538
  : filteredCandidates;
517
539
  // D3. File-based: prefer candidates in the resolved type's file
518
540
  const fileFiltered = methodPool.filter(c => typeFiles.has(c.filePath));
519
- if (fileFiltered.length === 1) {
520
- return toResolveResult(fileFiltered[0], tiered.tier);
541
+ const firstFileFiltered = fileFiltered.length === 1 ? fileFiltered[0] : undefined;
542
+ if (firstFileFiltered) {
543
+ return toResolveResult(firstFileFiltered, tiered.tier);
521
544
  }
522
545
  // D4. ownerId fallback: narrow by ownerId matching type's nodeId
523
546
  const pool = fileFiltered.length > 0 ? fileFiltered : methodPool;
524
547
  const ownerFiltered = pool.filter(c => c.ownerId && typeNodeIds.has(c.ownerId));
525
- if (ownerFiltered.length === 1) {
526
- return toResolveResult(ownerFiltered[0], tiered.tier);
548
+ const firstOwnerFiltered = ownerFiltered.length === 1 ? ownerFiltered[0] : undefined;
549
+ if (firstOwnerFiltered) {
550
+ return toResolveResult(firstOwnerFiltered, tiered.tier);
527
551
  }
528
552
  if (fileFiltered.length > 1 || ownerFiltered.length > 1)
529
553
  return null;
@@ -534,13 +558,17 @@ const resolveCallTarget = (call, currentFile, ctx) => {
534
558
  // Deduplicate by nodeId — the same symbol can appear multiple times from different resolution paths
535
559
  if (filteredCandidates.length > 1) {
536
560
  const uniqueById = new Map(filteredCandidates.map(c => [c.nodeId, c]));
537
- if (uniqueById.size === 1) {
538
- return toResolveResult(filteredCandidates[0], tiered.tier);
561
+ const firstUnique = uniqueById.size === 1 ? filteredCandidates[0] : undefined;
562
+ if (firstUnique) {
563
+ return toResolveResult(firstUnique, tiered.tier);
539
564
  }
540
565
  // Multiple distinct candidates — ambiguous, skip
541
566
  return null;
542
567
  }
543
- return toResolveResult(filteredCandidates[0], tiered.tier);
568
+ const firstCandidate = filteredCandidates[0];
569
+ if (!firstCandidate)
570
+ return null;
571
+ return toResolveResult(firstCandidate, tiered.tier);
544
572
  };
545
573
  // Return type text helpers
546
574
  // Operates on raw return-type text in SymbolDefinition (e.g. "User", "Promise<User>",
@@ -612,15 +640,19 @@ export const extractReturnTypeName = (raw, depth = 0) => {
612
640
  // Handle union types ("User | null" -> "User")
613
641
  if (text.includes('|')) {
614
642
  const parts = text.split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil');
615
- if (parts.length === 1)
616
- text = parts[0];
643
+ const singlePart = parts.length === 1 ? parts[0] : undefined;
644
+ if (singlePart)
645
+ text = singlePart;
617
646
  else
618
647
  return undefined; // genuine union, too complex
619
648
  }
620
649
  // Handle generics (Promise<User> -> unwrap if wrapper, else take base)
621
650
  const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
622
651
  if (genericMatch) {
623
- const [, base, args] = genericMatch;
652
+ const base = genericMatch[1];
653
+ const args = genericMatch[2];
654
+ if (!base || !args)
655
+ return undefined;
624
656
  if (WRAPPER_GENERICS.has(base)) {
625
657
  // Take the first non-lifetime type argument using bracket-balanced splitting
626
658
  const firstArg = extractFirstTypeArg(args);
@@ -634,7 +666,10 @@ export const extractReturnTypeName = (raw, depth = 0) => {
634
666
  return undefined;
635
667
  // Handle qualified names (models.User -> User, Models::User -> User)
636
668
  if (text.includes('::') || text.includes('.') || text.includes('\\')) {
637
- text = text.split(/::|[.\\]/).pop();
669
+ const lastSegment = text.split(/::|[.\\]/).pop();
670
+ if (!lastSegment)
671
+ return undefined;
672
+ text = lastSegment;
638
673
  }
639
674
  // Skip primitives
640
675
  if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase()))
@@ -649,8 +684,6 @@ export const extractReturnTypeName = (raw, depth = 0) => {
649
684
  // Source IDs: "Label:filepath:funcName" (parse-worker.ts)
650
685
  // NUL (\0) separates composite keys to prevent ambiguous concatenation
651
686
  // receiverKey uses full scope to distinguish overloaded methods (e.g. User.save@100 vs Repo.save@200)
652
- /** Extract function name from a scope key ("funcName@startIndex" -> "funcName") */
653
- const extractFuncNameFromScope = (scope) => scope.slice(0, scope.indexOf('@'));
654
687
  /** Extract trailing function name from a sourceId ("Function:filepath:funcName" -> "funcName") */
655
688
  const extractFuncNameFromSourceId = (sourceId) => {
656
689
  const lastColon = sourceId.lastIndexOf(':');
@@ -694,8 +727,128 @@ const lookupReceiverType = (map, funcName, varName) => {
694
727
  // Fallback: file-level scope
695
728
  return map.get(fileLevelKey);
696
729
  };
730
+ /** Check if a file is TypeScript or JavaScript (extensions tsgo can handle) */
731
+ function isTypeScriptOrJavaScript(filePath) {
732
+ return /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/.test(filePath);
733
+ }
734
+ /**
735
+ * Batch-resolve call sites via tsgo LSP before heuristic resolution.
736
+ *
737
+ * For each TS/JS call with line+column info, asks tsgo for go-to-definition.
738
+ * Returns a Map from callKey -> ResolveResult for calls that tsgo resolved.
739
+ *
740
+ * Call key format: "sourceId\0calledName\0callLine" — unique per call site.
741
+ */
742
+ async function batchResolveTsgo(tsgoService, extractedCalls, ctx, graph, repoPath) {
743
+ const results = new Map();
744
+ // Collect eligible calls (TS/JS files with line+column info)
745
+ const eligible = [];
746
+ for (const call of extractedCalls) {
747
+ if (isTypeScriptOrJavaScript(call.filePath) &&
748
+ call.callLine !== undefined &&
749
+ call.callColumn !== undefined) {
750
+ eligible.push(call);
751
+ }
752
+ }
753
+ if (eligible.length === 0)
754
+ return results;
755
+ // Group calls by file — process one file at a time so tsgo only needs
756
+ // one file hot in memory. LSP is sequential over stdio, so concurrent
757
+ // requests just create a queue that causes timeouts.
758
+ const byFile = new Map();
759
+ for (const call of eligible) {
760
+ let list = byFile.get(call.filePath);
761
+ if (!list) {
762
+ list = [];
763
+ byFile.set(call.filePath, list);
764
+ }
765
+ list.push(call);
766
+ }
767
+ let resolved = 0;
768
+ let failed = 0;
769
+ const t0 = Date.now();
770
+ console.error(`Code Mapper: tsgo resolving ${eligible.length} calls across ${byFile.size} files...`);
771
+ for (const [filePath, calls] of byFile) {
772
+ const absFilePath = path.resolve(repoPath, filePath);
773
+ // Resolve all calls in this file sequentially
774
+ for (const call of calls) {
775
+ try {
776
+ const def = await tsgoService.resolveDefinition(absFilePath, call.callLine - 1, call.callColumn);
777
+ if (!def) {
778
+ failed++;
779
+ continue;
780
+ }
781
+ const targetSymbols = ctx.symbols.lookupAllInFile(def.filePath);
782
+ if (targetSymbols.length === 0) {
783
+ failed++;
784
+ continue;
785
+ }
786
+ // Match by exact startLine, then by range containment
787
+ let bestMatch;
788
+ for (const sym of targetSymbols) {
789
+ const node = graph.getNode(toNodeId(sym.nodeId));
790
+ if (node && node.properties.startLine === def.line) {
791
+ bestMatch = sym;
792
+ break;
793
+ }
794
+ }
795
+ if (!bestMatch) {
796
+ for (const sym of targetSymbols) {
797
+ const node = graph.getNode(toNodeId(sym.nodeId));
798
+ if (node) {
799
+ const sl = node.properties.startLine;
800
+ const el = node.properties.endLine;
801
+ if (sl !== undefined && el !== undefined && def.line >= sl && def.line <= el) {
802
+ bestMatch = sym;
803
+ break;
804
+ }
805
+ }
806
+ }
807
+ }
808
+ if (bestMatch) {
809
+ // Drop self-referencing tsgo edges: these come from property access
810
+ // on parameters (req.params, res.json) that tsgo resolves back to
811
+ // the enclosing function's definition. Legitimate recursion is captured
812
+ // by the heuristic path (free-form calls to the function's own name).
813
+ if (bestMatch.nodeId === call.sourceId) {
814
+ failed++;
815
+ continue;
816
+ }
817
+ const callKey = `${call.sourceId}\0${call.calledName}\0${call.callLine}`;
818
+ results.set(callKey, {
819
+ nodeId: bestMatch.nodeId,
820
+ confidence: TIER_CONFIDENCE['tsgo-resolved'],
821
+ reason: 'tsgo-lsp',
822
+ });
823
+ resolved++;
824
+ }
825
+ else {
826
+ failed++;
827
+ }
828
+ }
829
+ catch {
830
+ failed++;
831
+ }
832
+ }
833
+ }
834
+ const elapsed = Date.now() - t0;
835
+ console.error(`Code Mapper: tsgo resolved ${resolved}/${eligible.length} calls in ${elapsed}ms (${failed} unresolvable)`);
836
+ return results;
837
+ }
838
+ /** Generic method names that produce false edges when receiver type is unknown (worker-extracted path) */
839
+ const GENERIC_MEMBER_METHODS_WORKER = new Set([
840
+ 'has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset',
841
+ 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values',
842
+ 'all', 'any', 'race', 'resolve', 'reject', // Promise methods
843
+ 'map', 'filter', 'reduce', 'forEach', 'find', 'some', 'every', 'includes', // Array methods
844
+ 'then', 'catch', 'finally', // Promise chain
845
+ 'next', 'return', 'throw', // Iterator
846
+ 'emit', 'on', 'off', 'once', 'subscribe', 'unsubscribe', // EventEmitter
847
+ 'log', 'warn', 'error', 'info', 'debug', // Console
848
+ 'json', 'text', 'blob', 'status', 'send', 'end', // HTTP response
849
+ ]);
697
850
  /** Resolve pre-extracted call sites from workers (no AST parsing needed) */
698
- export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onProgress, constructorBindings) => {
851
+ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onProgress, constructorBindings, tsgoService, repoPath) => {
699
852
  // Scope-aware receiver types keyed by filePath -> scope\0varName -> typeName
700
853
  const fileReceiverTypes = new Map();
701
854
  if (constructorBindings) {
@@ -715,6 +868,11 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
715
868
  }
716
869
  list.push(call);
717
870
  }
871
+ // Batch pre-resolve via tsgo LSP (highest confidence, TS/JS only)
872
+ let tsgoResolved;
873
+ if (tsgoService?.isReady() && repoPath) {
874
+ tsgoResolved = await batchResolveTsgo(tsgoService, extractedCalls, ctx, graph, repoPath);
875
+ }
718
876
  const totalFiles = byFile.size;
719
877
  let filesProcessed = 0;
720
878
  for (const [filePath, calls] of byFile) {
@@ -758,19 +916,172 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
758
916
  effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
759
917
  }
760
918
  }
761
- const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
919
+ // Skip generic method names on unknown receivers (matches sequential path filter)
920
+ // Prevents false edges like Promise.all() → bookingKeys.all()
921
+ if (effectiveCall.callForm === 'member' && !effectiveCall.receiverTypeName && GENERIC_MEMBER_METHODS_WORKER.has(effectiveCall.calledName)) {
922
+ continue;
923
+ }
924
+ // Check tsgo pre-resolved map first (highest confidence)
925
+ let resolved;
926
+ if (tsgoResolved && effectiveCall.callLine !== undefined) {
927
+ const callKey = `${effectiveCall.sourceId}\0${effectiveCall.calledName}\0${effectiveCall.callLine}`;
928
+ resolved = tsgoResolved.get(callKey);
929
+ }
930
+ // Fall through to heuristic resolution if tsgo didn't resolve
931
+ if (!resolved) {
932
+ resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
933
+ }
934
+ // RC-I: Interface dispatch — when receiver type is an Interface, find implementations.
935
+ // Strategy 1: Classes with IMPLEMENTS edges to the interface.
936
+ // Strategy 2: Factory functions whose return type matches the interface name.
937
+ // Covers both class-based and closure-based (factory pattern) implementations.
938
+ if (!resolved && effectiveCall.callForm === 'member' && effectiveCall.receiverTypeName) {
939
+ const receiverResolved = ctx.resolve(effectiveCall.receiverTypeName, effectiveCall.filePath);
940
+ if (receiverResolved) {
941
+ const interfaceDefs = receiverResolved.candidates.filter(d => d.type === 'Interface' || d.type === 'Trait');
942
+ if (interfaceDefs.length > 0) {
943
+ for (const ifaceDef of interfaceDefs) {
944
+ // Strategy 1: Class-based — find IMPLEMENTS edges
945
+ const implRows = graph.relationships.filter(r => r.type === 'IMPLEMENTS' && r.targetId === toNodeId(ifaceDef.nodeId));
946
+ for (const implRel of implRows) {
947
+ const methodEdges = graph.relationships.filter(r => r.type === 'HAS_METHOD' && r.sourceId === implRel.sourceId);
948
+ for (const methodEdge of methodEdges) {
949
+ const methodNode = graph.getNode(methodEdge.targetId);
950
+ if (methodNode && methodNode.properties.name === effectiveCall.calledName) {
951
+ resolved = { nodeId: methodEdge.targetId, confidence: 0.85, reason: 'interface-dispatch' };
952
+ break;
953
+ }
954
+ }
955
+ if (resolved)
956
+ break;
957
+ }
958
+ if (resolved)
959
+ break;
960
+ // Strategy 2: Factory/closure pattern — find functions returning this interface
961
+ // e.g. createEventBus(): EventBus → the closure has emit/subscribe as inner functions
962
+ // Look for the method name in files that import the interface's file
963
+ if (!resolved) {
964
+ const methodName = effectiveCall.calledName;
965
+ const ifaceFile = ifaceDef.filePath;
966
+ // Check: which files import the interface's file?
967
+ const importingFiles = [];
968
+ for (const [file, importedFiles] of ctx.importMap) {
969
+ if (importedFiles.has(ifaceFile)) {
970
+ importingFiles.push(file);
971
+ }
972
+ }
973
+ // Also check the interface's own file
974
+ importingFiles.push(ifaceFile);
975
+ for (const file of importingFiles) {
976
+ const method = ctx.symbols.lookupExactFull(file, methodName);
977
+ if (method && (method.type === 'Function' || method.type === 'Method')) {
978
+ resolved = { nodeId: method.nodeId, confidence: 0.75, reason: 'interface-factory-dispatch' };
979
+ break;
980
+ }
981
+ }
982
+ }
983
+ if (resolved)
984
+ break;
985
+ }
986
+ }
987
+ }
988
+ }
989
+ // RC-G: ORM chain recognition — for member chains like prisma.booking.findMany(),
990
+ // match the model name against entity classes (e.g. "Booking", "BookingEntity").
991
+ // Requires: (1) terminal method is a known ORM operation, (2) base receiver is a known ORM client name.
992
+ // Both gates prevent false positives from generic chains like filePath.includes().
993
+ if (!resolved && effectiveCall.callForm === 'member' && effectiveCall.receiverCallChain?.length) {
994
+ const ORM_METHODS = new Set([
995
+ 'findMany', 'findFirst', 'findUnique', 'findUniqueOrThrow', 'findFirstOrThrow',
996
+ 'create', 'createMany', 'update', 'updateMany', 'upsert', 'deleteMany',
997
+ 'count', 'aggregate', 'groupBy', // Prisma
998
+ 'findOne', 'findOneBy', 'findAndCount', 'save', 'softDelete',
999
+ 'createQueryBuilder', // TypeORM
1000
+ '$queryRaw', '$executeRaw', // Prisma raw
1001
+ 'bulkCreate', 'bulkUpdate', 'findAll', 'findByPk', // Sequelize
1002
+ ]);
1003
+ // Known ORM client variable names — the base of the chain must match
1004
+ const ORM_CLIENTS = new Set([
1005
+ 'prisma', 'db', 'knex', 'sequelize', 'typeorm', 'orm',
1006
+ 'repo', 'repository', 'em', 'entityManager', 'dataSource',
1007
+ 'connection', 'pool', 'client',
1008
+ ]);
1009
+ // Extract the base receiver from the chain (the outermost object)
1010
+ // For prisma.booking.findMany: receiverCallChain=['booking'], receiverName might be undefined
1011
+ // but the chain's base should be 'prisma'
1012
+ const chainBase = effectiveCall.receiverCallChain[0];
1013
+ const baseReceiver = effectiveCall.receiverName?.toLowerCase();
1014
+ const isOrmChain = (baseReceiver && ORM_CLIENTS.has(baseReceiver))
1015
+ || (chainBase && ORM_CLIENTS.has(chainBase.toLowerCase()));
1016
+ if (isOrmChain && ORM_METHODS.has(effectiveCall.calledName)) {
1017
+ // The model name is the intermediate chain element (e.g. "booking" from prisma.booking.findMany)
1018
+ const modelName = effectiveCall.receiverCallChain.length > 0
1019
+ ? effectiveCall.receiverCallChain[effectiveCall.receiverCallChain.length - 1]
1020
+ : effectiveCall.receiverName;
1021
+ if (modelName && modelName.length > 2) {
1022
+ const capitalized = modelName.charAt(0).toUpperCase() + modelName.slice(1);
1023
+ const allEntityCandidates = ctx.symbols.lookupFuzzy(capitalized)
1024
+ .filter(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct');
1025
+ // Prefer entities sharing the closest common ancestor directory
1026
+ // e.g. cross-boundary/server/src/services/ calling → cross-boundary/server/src/models/ (same project)
1027
+ // but NOT → lang-resolution/ruby-calls/ (different project)
1028
+ const callerParts = effectiveCall.filePath.split('/');
1029
+ const entityCandidates = allEntityCandidates.filter(d => {
1030
+ const entityParts = d.filePath.split('/');
1031
+ // Require at least 4 shared path segments — no fallback to global.
1032
+ // A missing match is better than a false cross-project match.
1033
+ let shared = 0;
1034
+ for (let i = 0; i < Math.min(callerParts.length, entityParts.length); i++) {
1035
+ if (callerParts[i] === entityParts[i])
1036
+ shared++;
1037
+ else
1038
+ break;
1039
+ }
1040
+ return shared >= 4;
1041
+ });
1042
+ if (entityCandidates.length === 1) {
1043
+ resolved = {
1044
+ nodeId: entityCandidates[0].nodeId,
1045
+ confidence: 0.7,
1046
+ reason: 'orm-entity-match',
1047
+ };
1048
+ }
1049
+ else if (entityCandidates.length === 0) {
1050
+ const entitySuffix = ctx.symbols.lookupFuzzy(capitalized + 'Entity')
1051
+ .filter(d => d.type === 'Class');
1052
+ if (entitySuffix.length === 1) {
1053
+ resolved = {
1054
+ nodeId: entitySuffix[0].nodeId,
1055
+ confidence: 0.7,
1056
+ reason: 'orm-entity-match',
1057
+ };
1058
+ }
1059
+ }
1060
+ }
1061
+ }
1062
+ }
762
1063
  if (!resolved)
763
1064
  continue;
764
- const relId = generateId('CALLS', `${effectiveCall.sourceId}:${effectiveCall.calledName}->${resolved.nodeId}`);
765
- graph.addRelationship({
1065
+ // Suppress self-referencing edges from unresolved method chains
1066
+ // (e.g. db('table').select().where() falsely resolving chain methods to the enclosing function)
1067
+ // Preserve legitimate recursion: only suppress member calls with no resolved receiver type
1068
+ const sourceNodeId = toNodeId(effectiveCall.sourceId);
1069
+ const targetNodeId = toNodeId(resolved.nodeId);
1070
+ if (sourceNodeId === targetNodeId && effectiveCall.callForm === 'member' && !effectiveCall.receiverTypeName) {
1071
+ continue;
1072
+ }
1073
+ const relId = toEdgeId(`CALLS:${effectiveCall.sourceId}:${effectiveCall.calledName}->${resolved.nodeId}`);
1074
+ const rel = {
766
1075
  id: relId,
767
- sourceId: effectiveCall.sourceId,
768
- targetId: resolved.nodeId,
1076
+ sourceId: sourceNodeId,
1077
+ targetId: targetNodeId,
769
1078
  type: 'CALLS',
770
1079
  confidence: resolved.confidence,
771
1080
  reason: resolved.reason,
772
- callLine: effectiveCall.callLine,
773
- });
1081
+ };
1082
+ if (effectiveCall.callLine !== undefined)
1083
+ rel.callLine = effectiveCall.callLine;
1084
+ graph.addRelationship(rel);
774
1085
  }
775
1086
  ctx.clearCache();
776
1087
  }
@@ -780,6 +1091,8 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
780
1091
  export const processRoutesFromExtracted = async (graph, extractedRoutes, ctx, onProgress) => {
781
1092
  for (let i = 0; i < extractedRoutes.length; i++) {
782
1093
  const route = extractedRoutes[i];
1094
+ if (!route)
1095
+ continue;
783
1096
  if (i % 50 === 0) {
784
1097
  onProgress?.(i, extractedRoutes.length);
785
1098
  await yieldToEventLoop();
@@ -792,13 +1105,16 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, ctx, on
792
1105
  if (controllerResolved.tier === 'global' && controllerResolved.candidates.length > 1)
793
1106
  continue;
794
1107
  const controllerDef = controllerResolved.candidates[0];
1108
+ if (!controllerDef)
1109
+ continue;
795
1110
  const confidence = TIER_CONFIDENCE[controllerResolved.tier];
796
1111
  const methodResolved = ctx.resolve(route.methodName, controllerDef.filePath);
797
- const methodId = methodResolved?.tier === 'same-file' ? methodResolved.candidates[0]?.nodeId : undefined;
1112
+ const firstMethodCandidate = methodResolved?.tier === 'same-file' ? methodResolved.candidates[0] : undefined;
1113
+ const methodId = firstMethodCandidate?.nodeId;
798
1114
  const sourceId = generateId('File', route.filePath);
799
1115
  if (!methodId) {
800
1116
  const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
801
- const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
1117
+ const relId = toEdgeId(`CALLS:${sourceId}:route->${guessedId}`);
802
1118
  graph.addRelationship({
803
1119
  id: relId,
804
1120
  sourceId,
@@ -809,11 +1125,11 @@ export const processRoutesFromExtracted = async (graph, extractedRoutes, ctx, on
809
1125
  });
810
1126
  continue;
811
1127
  }
812
- const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
1128
+ const relId = toEdgeId(`CALLS:${sourceId}:route->${methodId}`);
813
1129
  graph.addRelationship({
814
1130
  id: relId,
815
1131
  sourceId,
816
- targetId: methodId,
1132
+ targetId: toNodeId(methodId),
817
1133
  type: 'CALLS',
818
1134
  confidence,
819
1135
  reason: 'laravel-route',
@@ -868,9 +1184,9 @@ export const createDependsOnEdges = async (graph, ctx) => {
868
1184
  continue;
869
1185
  seen.add(edgeKey);
870
1186
  graph.addRelationship({
871
- id: generateId('DEPENDS_ON', edgeKey),
872
- sourceId: symDef.ownerId,
873
- targetId: target.nodeId,
1187
+ id: toEdgeId(`DEPENDS_ON:${edgeKey}`),
1188
+ sourceId: toNodeId(symDef.ownerId),
1189
+ targetId: toNodeId(target.nodeId),
874
1190
  type: 'DEPENDS_ON',
875
1191
  confidence: TIER_CONFIDENCE[resolved.tier],
876
1192
  reason: 'constructor-injection',
@@ -895,14 +1211,16 @@ export const createProvidesEdges = async (graph, ctx) => {
895
1211
  const candidates = [];
896
1212
  graph.forEachNode(n => {
897
1213
  if ((n.label === 'Method' || n.label === 'Function') && n.properties.returnType) {
898
- candidates.push({
1214
+ const entry = {
899
1215
  id: n.id,
900
1216
  filePath: n.properties.filePath,
901
1217
  returnType: n.properties.returnType,
902
1218
  startLine: n.properties.startLine ?? 0,
903
1219
  endLine: n.properties.endLine ?? 0,
904
- frameworkReason: n.properties.astFrameworkReason,
905
- });
1220
+ };
1221
+ if (n.properties.astFrameworkReason !== undefined)
1222
+ entry.frameworkReason = n.properties.astFrameworkReason;
1223
+ candidates.push(entry);
906
1224
  }
907
1225
  });
908
1226
  for (const node of candidates) {
@@ -924,9 +1242,9 @@ export const createProvidesEdges = async (graph, ctx) => {
924
1242
  if (bodyLength > 20 && !isDIAnnotated)
925
1243
  continue;
926
1244
  graph.addRelationship({
927
- id: generateId('PROVIDES', `${node.id}->${interfaceTarget.nodeId}`),
1245
+ id: toEdgeId(`PROVIDES:${node.id}->${interfaceTarget.nodeId}`),
928
1246
  sourceId: node.id,
929
- targetId: interfaceTarget.nodeId,
1247
+ targetId: toNodeId(interfaceTarget.nodeId),
930
1248
  type: 'PROVIDES',
931
1249
  confidence: TIER_CONFIDENCE[resolved.tier] * 0.9,
932
1250
  reason: 'factory-return-type',
@@ -935,3 +1253,73 @@ export const createProvidesEdges = async (graph, ctx) => {
935
1253
  }
936
1254
  return edgesCreated;
937
1255
  };
1256
+ /**
1257
+ * Post-pass: connect interface method DECLARATIONS to their IMPLEMENTATIONS.
1258
+ *
1259
+ * For each interface Method node (e.g. EventBus.emit), find the matching
1260
+ * implementation Function in files that import the interface definition.
1261
+ * Creates CALLS edges: InterfaceMethod → ImplementationFunction.
1262
+ *
1263
+ * This makes the call chain traversable:
1264
+ * register() →[tsgo]→ EventBus.emit (Method) →[dispatch]→ emit (Function in event-bus.ts)
1265
+ */
1266
+ export const resolveInterfaceDispatches = async (graph, ctx) => {
1267
+ let edgesCreated = 0;
1268
+ const seen = new Set();
1269
+ // Step 1: Find all interface/trait Method nodes (via HAS_METHOD edges)
1270
+ const ifaceMethods = [];
1271
+ graph.forEachRelationship(rel => {
1272
+ if (rel.type !== 'HAS_METHOD')
1273
+ return;
1274
+ const parentNode = graph.getNode(rel.sourceId);
1275
+ if (!parentNode || (parentNode.label !== 'Interface' && parentNode.label !== 'Trait'))
1276
+ return;
1277
+ const methodNode = graph.getNode(rel.targetId);
1278
+ if (!methodNode)
1279
+ return;
1280
+ ifaceMethods.push({
1281
+ methodId: rel.targetId,
1282
+ methodName: methodNode.properties.name,
1283
+ ifaceFile: parentNode.properties.filePath,
1284
+ });
1285
+ });
1286
+ // Step 2: For each interface method, find implementation in files that import the interface
1287
+ for (const ifaceMethod of ifaceMethods) {
1288
+ const implFiles = [];
1289
+ const ifaceFileBase = ifaceMethod.ifaceFile.replace(/\.[^.]+$/, '');
1290
+ for (const [file, importedFiles] of ctx.importMap) {
1291
+ // Check exact match OR suffix match (import paths may differ in extension)
1292
+ let found = importedFiles.has(ifaceMethod.ifaceFile);
1293
+ if (!found) {
1294
+ for (const imp of importedFiles) {
1295
+ if (imp.replace(/\.[^.]+$/, '') === ifaceFileBase || imp.endsWith(ifaceFileBase) || ifaceFileBase.endsWith(imp.replace(/\.[^.]+$/, ''))) {
1296
+ found = true;
1297
+ break;
1298
+ }
1299
+ }
1300
+ }
1301
+ if (found)
1302
+ implFiles.push(file);
1303
+ }
1304
+ for (const implFile of implFiles) {
1305
+ // Look for a Function with the same name in the implementation file
1306
+ const implSym = ctx.symbols.lookupExactFull(implFile, ifaceMethod.methodName);
1307
+ if (!implSym || (implSym.type !== 'Function' && implSym.type !== 'Method'))
1308
+ continue;
1309
+ const edgeKey = `${ifaceMethod.methodId}->${implSym.nodeId}`;
1310
+ if (seen.has(edgeKey))
1311
+ continue;
1312
+ seen.add(edgeKey);
1313
+ graph.addRelationship({
1314
+ id: toEdgeId(`CALLS:iface-dispatch:${edgeKey}`),
1315
+ sourceId: toNodeId(ifaceMethod.methodId),
1316
+ targetId: toNodeId(implSym.nodeId),
1317
+ type: 'CALLS',
1318
+ confidence: 0.80,
1319
+ reason: 'interface-dispatch',
1320
+ });
1321
+ edgesCreated++;
1322
+ }
1323
+ }
1324
+ return edgesCreated;
1325
+ };