spec-gen-cli 1.2.1 → 1.2.3

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 (132) hide show
  1. package/README.md +197 -11
  2. package/dist/api/generate.d.ts.map +1 -1
  3. package/dist/api/generate.js +5 -4
  4. package/dist/api/generate.js.map +1 -1
  5. package/dist/cli/commands/analyze.d.ts.map +1 -1
  6. package/dist/cli/commands/analyze.js +101 -41
  7. package/dist/cli/commands/analyze.js.map +1 -1
  8. package/dist/cli/commands/generate.d.ts.map +1 -1
  9. package/dist/cli/commands/generate.js +25 -21
  10. package/dist/cli/commands/generate.js.map +1 -1
  11. package/dist/cli/commands/mcp.d.ts +353 -10
  12. package/dist/cli/commands/mcp.d.ts.map +1 -1
  13. package/dist/cli/commands/mcp.js +236 -45
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/view.d.ts.map +1 -1
  16. package/dist/cli/commands/view.js +33 -4
  17. package/dist/cli/commands/view.js.map +1 -1
  18. package/dist/constants.d.ts +10 -0
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +10 -0
  21. package/dist/constants.js.map +1 -1
  22. package/dist/core/analyzer/ast-chunker.d.ts +24 -0
  23. package/dist/core/analyzer/ast-chunker.d.ts.map +1 -0
  24. package/dist/core/analyzer/ast-chunker.js +198 -0
  25. package/dist/core/analyzer/ast-chunker.js.map +1 -0
  26. package/dist/core/analyzer/call-graph.d.ts +51 -4
  27. package/dist/core/analyzer/call-graph.d.ts.map +1 -1
  28. package/dist/core/analyzer/call-graph.js +634 -44
  29. package/dist/core/analyzer/call-graph.js.map +1 -1
  30. package/dist/core/analyzer/code-shaper.d.ts.map +1 -1
  31. package/dist/core/analyzer/code-shaper.js +5 -0
  32. package/dist/core/analyzer/code-shaper.js.map +1 -1
  33. package/dist/core/analyzer/codebase-digest.d.ts +40 -0
  34. package/dist/core/analyzer/codebase-digest.d.ts.map +1 -0
  35. package/dist/core/analyzer/codebase-digest.js +194 -0
  36. package/dist/core/analyzer/codebase-digest.js.map +1 -0
  37. package/dist/core/analyzer/cpp-header-resolver.d.ts +30 -0
  38. package/dist/core/analyzer/cpp-header-resolver.d.ts.map +1 -0
  39. package/dist/core/analyzer/cpp-header-resolver.js +71 -0
  40. package/dist/core/analyzer/cpp-header-resolver.js.map +1 -0
  41. package/dist/core/analyzer/function-registry-trie.d.ts +21 -0
  42. package/dist/core/analyzer/function-registry-trie.d.ts.map +1 -0
  43. package/dist/core/analyzer/function-registry-trie.js +39 -0
  44. package/dist/core/analyzer/function-registry-trie.js.map +1 -0
  45. package/dist/core/analyzer/import-resolver-bridge.d.ts +25 -0
  46. package/dist/core/analyzer/import-resolver-bridge.d.ts.map +1 -0
  47. package/dist/core/analyzer/import-resolver-bridge.js +99 -0
  48. package/dist/core/analyzer/import-resolver-bridge.js.map +1 -0
  49. package/dist/core/analyzer/signature-extractor.d.ts.map +1 -1
  50. package/dist/core/analyzer/signature-extractor.js +72 -3
  51. package/dist/core/analyzer/signature-extractor.js.map +1 -1
  52. package/dist/core/analyzer/subgraph-extractor.d.ts +10 -2
  53. package/dist/core/analyzer/subgraph-extractor.d.ts.map +1 -1
  54. package/dist/core/analyzer/subgraph-extractor.js +25 -7
  55. package/dist/core/analyzer/subgraph-extractor.js.map +1 -1
  56. package/dist/core/analyzer/type-inference-engine.d.ts +23 -0
  57. package/dist/core/analyzer/type-inference-engine.d.ts.map +1 -0
  58. package/dist/core/analyzer/type-inference-engine.js +130 -0
  59. package/dist/core/analyzer/type-inference-engine.js.map +1 -0
  60. package/dist/core/analyzer/vector-index.d.ts +35 -6
  61. package/dist/core/analyzer/vector-index.d.ts.map +1 -1
  62. package/dist/core/analyzer/vector-index.js +308 -54
  63. package/dist/core/analyzer/vector-index.js.map +1 -1
  64. package/dist/core/generator/schemas.d.ts +365 -0
  65. package/dist/core/generator/schemas.d.ts.map +1 -0
  66. package/dist/core/generator/schemas.js +190 -0
  67. package/dist/core/generator/schemas.js.map +1 -0
  68. package/dist/core/generator/spec-pipeline.d.ts +31 -11
  69. package/dist/core/generator/spec-pipeline.d.ts.map +1 -1
  70. package/dist/core/generator/spec-pipeline.js +172 -40
  71. package/dist/core/generator/spec-pipeline.js.map +1 -1
  72. package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -1
  73. package/dist/core/generator/stages/stage2-entities.js +4 -2
  74. package/dist/core/generator/stages/stage2-entities.js.map +1 -1
  75. package/dist/core/generator/stages/stage3-services.d.ts.map +1 -1
  76. package/dist/core/generator/stages/stage3-services.js +4 -2
  77. package/dist/core/generator/stages/stage3-services.js.map +1 -1
  78. package/dist/core/generator/stages/stage4-api.d.ts.map +1 -1
  79. package/dist/core/generator/stages/stage4-api.js +4 -2
  80. package/dist/core/generator/stages/stage4-api.js.map +1 -1
  81. package/dist/core/generator/stages/stage5-architecture.d.ts +2 -1
  82. package/dist/core/generator/stages/stage5-architecture.d.ts.map +1 -1
  83. package/dist/core/generator/stages/stage5-architecture.js +15 -3
  84. package/dist/core/generator/stages/stage5-architecture.js.map +1 -1
  85. package/dist/core/generator/stages/stage6-adr.d.ts.map +1 -1
  86. package/dist/core/generator/stages/stage6-adr.js +2 -1
  87. package/dist/core/generator/stages/stage6-adr.js.map +1 -1
  88. package/dist/core/services/chat-agent.d.ts +5 -0
  89. package/dist/core/services/chat-agent.d.ts.map +1 -1
  90. package/dist/core/services/chat-agent.js +14 -0
  91. package/dist/core/services/chat-agent.js.map +1 -1
  92. package/dist/core/services/chat-tools.d.ts.map +1 -1
  93. package/dist/core/services/chat-tools.js +172 -50
  94. package/dist/core/services/chat-tools.js.map +1 -1
  95. package/dist/core/services/llm-service.d.ts +2 -0
  96. package/dist/core/services/llm-service.d.ts.map +1 -1
  97. package/dist/core/services/llm-service.js +47 -5
  98. package/dist/core/services/llm-service.js.map +1 -1
  99. package/dist/core/services/mcp-handlers/analysis.d.ts +12 -0
  100. package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -1
  101. package/dist/core/services/mcp-handlers/analysis.js +138 -2
  102. package/dist/core/services/mcp-handlers/analysis.js.map +1 -1
  103. package/dist/core/services/mcp-handlers/graph.d.ts +21 -1
  104. package/dist/core/services/mcp-handlers/graph.d.ts.map +1 -1
  105. package/dist/core/services/mcp-handlers/graph.js +142 -2
  106. package/dist/core/services/mcp-handlers/graph.js.map +1 -1
  107. package/dist/core/services/mcp-handlers/orient.d.ts +17 -0
  108. package/dist/core/services/mcp-handlers/orient.d.ts.map +1 -0
  109. package/dist/core/services/mcp-handlers/orient.js +200 -0
  110. package/dist/core/services/mcp-handlers/orient.js.map +1 -0
  111. package/dist/core/services/mcp-handlers/semantic.d.ts +18 -4
  112. package/dist/core/services/mcp-handlers/semantic.d.ts.map +1 -1
  113. package/dist/core/services/mcp-handlers/semantic.js +161 -17
  114. package/dist/core/services/mcp-handlers/semantic.js.map +1 -1
  115. package/dist/core/services/mcp-handlers/utils.d.ts +43 -0
  116. package/dist/core/services/mcp-handlers/utils.d.ts.map +1 -1
  117. package/dist/core/services/mcp-handlers/utils.js +66 -1
  118. package/dist/core/services/mcp-handlers/utils.js.map +1 -1
  119. package/dist/core/services/mcp-watcher.d.ts +41 -0
  120. package/dist/core/services/mcp-watcher.d.ts.map +1 -0
  121. package/dist/core/services/mcp-watcher.js +177 -0
  122. package/dist/core/services/mcp-watcher.js.map +1 -0
  123. package/dist/types/index.d.ts +1 -1
  124. package/dist/types/index.d.ts.map +1 -1
  125. package/dist/types/pipeline.d.ts +7 -0
  126. package/dist/types/pipeline.d.ts.map +1 -1
  127. package/package.json +3 -2
  128. package/src/viewer/InteractiveGraphViewer.jsx +33 -8
  129. package/src/viewer/components/ChatPanel.jsx +8 -5
  130. package/src/viewer/components/ClassGraph.jsx +699 -0
  131. package/src/viewer/utils/graph-helpers.js +1 -1
  132. package/src/viewer/utils/themes.js +36 -0
@@ -12,6 +12,9 @@
12
12
  * - Layer violations — cross-layer calls in the wrong direction
13
13
  */
14
14
  import Parser from 'tree-sitter';
15
+ import { FunctionRegistryTrie } from './function-registry-trie.js';
16
+ import { inferTypesFromSource, resolveViaTypeInference } from './type-inference-engine.js';
17
+ import { extractAllHttpEdges } from './http-route-parser.js';
15
18
  // ============================================================================
16
19
  // CONSTANTS
17
20
  // ============================================================================
@@ -54,6 +57,15 @@ const IGNORED_CALLEES = new Set([
54
57
  'make_shared', 'make_unique', 'move', 'forward', 'swap',
55
58
  'static_cast', 'dynamic_cast', 'reinterpret_cast', 'const_cast',
56
59
  ]);
60
+ /** Returns true if the name should be skipped as a call target. */
61
+ function isIgnoredCallee(name) {
62
+ if (IGNORED_CALLEES.has(name))
63
+ return true;
64
+ // ALL_CAPS names (3+ chars) are almost certainly C/C++ macros, not functions
65
+ if (/^[A-Z][A-Z0-9_]{2,}$/.test(name))
66
+ return true;
67
+ return false;
68
+ }
57
69
  // ============================================================================
58
70
  // PARSER SINGLETONS (lazy init)
59
71
  // ============================================================================
@@ -156,6 +168,200 @@ function findEnclosingFunction(nodes, callPos) {
156
168
  return best;
157
169
  }
158
170
  // ============================================================================
171
+ // DOCSTRING / SIGNATURE EXTRACTION HELPERS
172
+ // ============================================================================
173
+ /**
174
+ * Scan backward from `startIndex` in `source` to find the doc comment
175
+ * immediately preceding the function declaration. Skip blank lines.
176
+ *
177
+ * For Python, docstrings are INSIDE the function body — scan forward from
178
+ * `startIndex` past the `def name(...):` colon to find the triple-quoted string.
179
+ *
180
+ * Returns the first meaningful (non-empty, non-decorator) line of the comment.
181
+ */
182
+ function extractDocstringBefore(source, startIndex, language) {
183
+ // ── Python: scan forward past the colon into the function body ──────────
184
+ if (language === 'Python') {
185
+ // Find the colon that ends the `def` line
186
+ let i = startIndex;
187
+ while (i < source.length && source[i] !== ':')
188
+ i++;
189
+ // Skip past the colon
190
+ i++;
191
+ // Skip whitespace / newline
192
+ while (i < source.length && (source[i] === ' ' || source[i] === '\t' || source[i] === '\n' || source[i] === '\r'))
193
+ i++;
194
+ // Check for triple-quoted docstring
195
+ const tripleDouble = source.startsWith('"""', i);
196
+ const tripleSingle = source.startsWith("'''", i);
197
+ if (tripleDouble || tripleSingle) {
198
+ const quote = tripleDouble ? '"""' : "'''";
199
+ const bodyStart = i + 3;
200
+ const closeIdx = source.indexOf(quote, bodyStart);
201
+ if (closeIdx === -1)
202
+ return undefined;
203
+ const inner = source.slice(bodyStart, closeIdx);
204
+ const firstLine = inner.split('\n').map(l => l.trim()).find(l => l.length > 0);
205
+ return firstLine ?? undefined;
206
+ }
207
+ return undefined;
208
+ }
209
+ // ── All other languages: scan backward from startIndex ─────────────────
210
+ // Move to the character just before startIndex
211
+ let pos = startIndex - 1;
212
+ // Skip trailing whitespace / newlines before the declaration
213
+ while (pos >= 0 && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n' || source[pos] === '\r')) {
214
+ pos--;
215
+ }
216
+ if (pos < 0)
217
+ return undefined;
218
+ // ── TypeScript / JavaScript / Java / C++: JSDoc block /** ... */ ────────
219
+ if (language === 'TypeScript' || language === 'JavaScript' ||
220
+ language === 'Java' || language === 'C++') {
221
+ // Expect closing */ of a JSDoc block
222
+ if (source[pos] === '/' && pos > 0 && source[pos - 1] === '*') {
223
+ const closePos = pos - 1; // points at '*' of closing '*/'
224
+ // Find opening /**
225
+ const openIdx = source.lastIndexOf('/**', closePos);
226
+ if (openIdx === -1)
227
+ return undefined;
228
+ const inner = source.slice(openIdx + 3, closePos - 0);
229
+ // Remove leading * on each line, find first non-empty, non-@ line
230
+ const firstLine = inner
231
+ .split('\n')
232
+ .map(l => l.replace(/^\s*\*\s?/, '').trim())
233
+ .find(l => l.length > 0 && !l.startsWith('@'));
234
+ return firstLine ?? undefined;
235
+ }
236
+ return undefined;
237
+ }
238
+ // ── Go: // comment lines immediately before ──────────────────────────────
239
+ if (language === 'Go') {
240
+ const lines = [];
241
+ // Walk backward line by line
242
+ let lineEnd = pos;
243
+ while (lineEnd >= 0) {
244
+ // Find start of this line
245
+ let lineStart = lineEnd;
246
+ while (lineStart > 0 && source[lineStart - 1] !== '\n')
247
+ lineStart--;
248
+ const line = source.slice(lineStart, lineEnd + 1).trimEnd();
249
+ const trimmed = line.trim();
250
+ if (trimmed.startsWith('//')) {
251
+ lines.unshift(trimmed.slice(2).trim());
252
+ lineEnd = lineStart - 1;
253
+ // Skip over the newline
254
+ while (lineEnd >= 0 && (source[lineEnd] === '\n' || source[lineEnd] === '\r'))
255
+ lineEnd--;
256
+ }
257
+ else {
258
+ break;
259
+ }
260
+ }
261
+ return lines.find(l => l.length > 0) ?? undefined;
262
+ }
263
+ // ── Rust: /// doc comment lines immediately before ───────────────────────
264
+ if (language === 'Rust') {
265
+ const lines = [];
266
+ let lineEnd = pos;
267
+ while (lineEnd >= 0) {
268
+ let lineStart = lineEnd;
269
+ while (lineStart > 0 && source[lineStart - 1] !== '\n')
270
+ lineStart--;
271
+ const line = source.slice(lineStart, lineEnd + 1).trimEnd();
272
+ const trimmed = line.trim();
273
+ if (trimmed.startsWith('///')) {
274
+ lines.unshift(trimmed.slice(3).trim());
275
+ lineEnd = lineStart - 1;
276
+ while (lineEnd >= 0 && (source[lineEnd] === '\n' || source[lineEnd] === '\r'))
277
+ lineEnd--;
278
+ }
279
+ else {
280
+ break;
281
+ }
282
+ }
283
+ return lines.find(l => l.length > 0) ?? undefined;
284
+ }
285
+ // ── Ruby: # comment lines immediately before ─────────────────────────────
286
+ if (language === 'Ruby') {
287
+ const lines = [];
288
+ let lineEnd = pos;
289
+ while (lineEnd >= 0) {
290
+ let lineStart = lineEnd;
291
+ while (lineStart > 0 && source[lineStart - 1] !== '\n')
292
+ lineStart--;
293
+ const line = source.slice(lineStart, lineEnd + 1).trimEnd();
294
+ const trimmed = line.trim();
295
+ if (trimmed.startsWith('#')) {
296
+ lines.unshift(trimmed.slice(1).trim());
297
+ lineEnd = lineStart - 1;
298
+ while (lineEnd >= 0 && (source[lineEnd] === '\n' || source[lineEnd] === '\r'))
299
+ lineEnd--;
300
+ }
301
+ else {
302
+ break;
303
+ }
304
+ }
305
+ return lines.find(l => l.length > 0) ?? undefined;
306
+ }
307
+ return undefined;
308
+ }
309
+ /**
310
+ * Extract the function declaration (signature without body) from
311
+ * `source.slice(startIndex, endIndex)`.
312
+ *
313
+ * Strategy:
314
+ * - TS/JS/Java/C++/Go/Rust/Ruby: take everything up to the first `{` at depth 0
315
+ * - Python: take everything up to the first `:` that ends the `def` line
316
+ *
317
+ * Whitespace is normalized (multiple spaces/newlines → single space).
318
+ * Limited to 300 characters max.
319
+ */
320
+ function extractDeclaration(source, startIndex, endIndex, language) {
321
+ const slice = source.slice(startIndex, Math.min(endIndex, startIndex + 1500));
322
+ let decl;
323
+ if (language === 'Python') {
324
+ // Take up to (not including) the first `:` that ends the def line
325
+ // We scan for `:` while tracking parenthesis depth to avoid matching
326
+ // colons inside type annotations (e.g., def f(x: int) -> dict[str, int]:)
327
+ let depth = 0;
328
+ let end = -1;
329
+ for (let i = 0; i < slice.length; i++) {
330
+ const ch = slice[i];
331
+ if (ch === '(' || ch === '[' || ch === '{')
332
+ depth++;
333
+ else if (ch === ')' || ch === ']' || ch === '}')
334
+ depth--;
335
+ else if (ch === ':' && depth === 0) {
336
+ end = i;
337
+ break;
338
+ }
339
+ }
340
+ decl = end !== -1 ? slice.slice(0, end) : slice.slice(0, 300);
341
+ }
342
+ else {
343
+ // Find first `{` at brace depth 0
344
+ let depth = 0;
345
+ let end = -1;
346
+ for (let i = 0; i < slice.length; i++) {
347
+ const ch = slice[i];
348
+ if (ch === '{') {
349
+ if (depth === 0) {
350
+ end = i;
351
+ break;
352
+ }
353
+ depth++;
354
+ }
355
+ else if (ch === '}') {
356
+ depth--;
357
+ }
358
+ }
359
+ decl = end !== -1 ? slice.slice(0, end) : slice.slice(0, 300);
360
+ }
361
+ // Normalize whitespace
362
+ return decl.replace(/\s+/g, ' ').trim().slice(0, 300);
363
+ }
364
+ // ============================================================================
159
365
  // TYPESCRIPT EXTRACTOR
160
366
  // ============================================================================
161
367
  const TS_FN_QUERY = `
@@ -178,6 +384,7 @@ const TS_CALL_QUERY = `
178
384
  (call_expression
179
385
  function: [(identifier) @call.name
180
386
  (member_expression
387
+ object: (identifier) @call.object
181
388
  property: (property_identifier) @call.name)]) @call.node
182
389
  `;
183
390
  async function extractTSGraph(filePath, content) {
@@ -224,6 +431,8 @@ async function extractTSGraph(filePath, content) {
224
431
  endIndex: fnNode.endIndex,
225
432
  fanIn: 0,
226
433
  fanOut: 0,
434
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'TypeScript'),
435
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'TypeScript'),
227
436
  });
228
437
  }
229
438
  // --- Extract calls ---
@@ -232,10 +441,11 @@ async function extractTSGraph(filePath, content) {
232
441
  for (const match of callMatches) {
233
442
  const nameCapture = match.captures.find(c => c.name === 'call.name');
234
443
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
444
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
235
445
  if (!nameCapture || !nodeCapture)
236
446
  continue;
237
447
  const calleeName = nameCapture.node.text;
238
- if (IGNORED_CALLEES.has(calleeName))
448
+ if (isIgnoredCallee(calleeName))
239
449
  continue;
240
450
  const callPos = nodeCapture.node.startIndex;
241
451
  const caller = findEnclosingFunction(nodes, callPos);
@@ -245,6 +455,7 @@ async function extractTSGraph(filePath, content) {
245
455
  callerId: caller.id,
246
456
  calleeName,
247
457
  line: nodeCapture.node.startPosition.row + 1,
458
+ calleeObject: objectCapture?.node.text,
248
459
  });
249
460
  }
250
461
  return { nodes, rawEdges };
@@ -331,6 +542,8 @@ async function extractPyGraph(filePath, content) {
331
542
  endIndex: fnNode.endIndex,
332
543
  fanIn: 0,
333
544
  fanOut: 0,
545
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'Python'),
546
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'Python'),
334
547
  });
335
548
  }
336
549
  // --- Extract calls ---
@@ -344,7 +557,7 @@ async function extractPyGraph(filePath, content) {
344
557
  if (!nameCapture || !nodeCapture)
345
558
  continue;
346
559
  const calleeName = nameCapture.node.text;
347
- if (IGNORED_CALLEES.has(calleeName))
560
+ if (isIgnoredCallee(calleeName))
348
561
  continue;
349
562
  const callPos = nodeCapture.node.startIndex;
350
563
  const caller = findEnclosingFunction(nodes, callPos);
@@ -356,20 +569,15 @@ async function extractPyGraph(filePath, content) {
356
569
  line: nodeCapture.node.startPosition.row + 1,
357
570
  });
358
571
  }
359
- // Method calls: obj.method() — only resolve self.* and cls.* (internal object methods)
572
+ // Method calls: obj.method() — capture receiver for type-inference-based resolution
360
573
  for (const match of methodCallQuery.matches(tree.rootNode)) {
361
574
  const objectCapture = match.captures.find(c => c.name === 'call.object');
362
575
  const nameCapture = match.captures.find(c => c.name === 'call.name');
363
576
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
364
577
  if (!objectCapture || !nameCapture || !nodeCapture)
365
578
  continue;
366
- const objectName = objectCapture.node.text;
367
- // Only track self.method() and cls.method() — external objects like
368
- // redis.get(), dict.get(), os.path.join() would create massive false positives
369
- if (objectName !== 'self' && objectName !== 'cls')
370
- continue;
371
579
  const calleeName = nameCapture.node.text;
372
- if (IGNORED_CALLEES.has(calleeName))
580
+ if (isIgnoredCallee(calleeName))
373
581
  continue;
374
582
  const callPos = nodeCapture.node.startIndex;
375
583
  const caller = findEnclosingFunction(nodes, callPos);
@@ -379,6 +587,7 @@ async function extractPyGraph(filePath, content) {
379
587
  callerId: caller.id,
380
588
  calleeName,
381
589
  line: nodeCapture.node.startPosition.row + 1,
590
+ calleeObject: objectCapture.node.text,
382
591
  });
383
592
  }
384
593
  return { nodes, rawEdges };
@@ -399,6 +608,7 @@ const GO_CALL_QUERY = `
399
608
 
400
609
  (call_expression
401
610
  function: (selector_expression
611
+ operand: (identifier) @call.object
402
612
  field: (field_identifier) @call.name)) @call.node
403
613
  `;
404
614
  async function extractGoGraph(filePath, content) {
@@ -434,21 +644,24 @@ async function extractGoGraph(filePath, content) {
434
644
  startIndex: fnNode.startIndex,
435
645
  endIndex: fnNode.endIndex,
436
646
  fanIn: 0, fanOut: 0,
647
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'Go'),
648
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'Go'),
437
649
  });
438
650
  }
439
651
  const rawEdges = [];
440
652
  for (const match of callQuery.matches(tree.rootNode)) {
441
653
  const nameCapture = match.captures.find(c => c.name === 'call.name');
442
654
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
655
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
443
656
  if (!nameCapture || !nodeCapture)
444
657
  continue;
445
658
  const calleeName = nameCapture.node.text;
446
- if (IGNORED_CALLEES.has(calleeName))
659
+ if (isIgnoredCallee(calleeName))
447
660
  continue;
448
661
  const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
449
662
  if (!caller)
450
663
  continue;
451
- rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1 });
664
+ rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1, calleeObject: objectCapture?.node.text });
452
665
  }
453
666
  return { nodes, rawEdges };
454
667
  }
@@ -465,6 +678,7 @@ const RUST_CALL_QUERY = `
465
678
 
466
679
  (call_expression
467
680
  function: (field_expression
681
+ value: (identifier) @call.object
468
682
  field: (field_identifier) @call.name)) @call.node
469
683
  `;
470
684
  async function extractRustGraph(filePath, content) {
@@ -502,21 +716,24 @@ async function extractRustGraph(filePath, content) {
502
716
  startIndex: fnNode.startIndex,
503
717
  endIndex: fnNode.endIndex,
504
718
  fanIn: 0, fanOut: 0,
719
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'Rust'),
720
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'Rust'),
505
721
  });
506
722
  }
507
723
  const rawEdges = [];
508
724
  for (const match of callQuery.matches(tree.rootNode)) {
509
725
  const nameCapture = match.captures.find(c => c.name === 'call.name');
510
726
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
727
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
511
728
  if (!nameCapture || !nodeCapture)
512
729
  continue;
513
730
  const calleeName = nameCapture.node.text;
514
- if (IGNORED_CALLEES.has(calleeName))
731
+ if (isIgnoredCallee(calleeName))
515
732
  continue;
516
733
  const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
517
734
  if (!caller)
518
735
  continue;
519
- rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1 });
736
+ rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1, calleeObject: objectCapture?.node.text });
520
737
  }
521
738
  return { nodes, rawEdges };
522
739
  }
@@ -532,6 +749,10 @@ const RUBY_FN_QUERY = `
532
749
  `;
533
750
  // Explicit calls: fetch(), obj.method()
534
751
  const RUBY_CALL_QUERY = `
752
+ (call
753
+ receiver: (identifier) @call.object
754
+ method: (identifier) @call.name) @call.node
755
+
535
756
  (call
536
757
  method: (identifier) @call.name) @call.node
537
758
  `;
@@ -576,6 +797,8 @@ async function extractRubyGraph(filePath, content) {
576
797
  startIndex: fnNode.startIndex,
577
798
  endIndex: fnNode.endIndex,
578
799
  fanIn: 0, fanOut: 0,
800
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'Ruby'),
801
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'Ruby'),
579
802
  });
580
803
  }
581
804
  const rawEdges = [];
@@ -583,15 +806,16 @@ async function extractRubyGraph(filePath, content) {
583
806
  for (const match of callQuery.matches(tree.rootNode)) {
584
807
  const nameCapture = match.captures.find(c => c.name === 'call.name');
585
808
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
809
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
586
810
  if (!nameCapture || !nodeCapture)
587
811
  continue;
588
812
  const calleeName = nameCapture.node.text;
589
- if (IGNORED_CALLEES.has(calleeName))
813
+ if (isIgnoredCallee(calleeName))
590
814
  continue;
591
815
  const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
592
816
  if (!caller)
593
817
  continue;
594
- rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1 });
818
+ rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1, calleeObject: objectCapture?.node.text });
595
819
  }
596
820
  // Bareword calls: fetch (no parens) — identifier at statement level
597
821
  for (const match of barewordQuery.matches(tree.rootNode)) {
@@ -599,7 +823,7 @@ async function extractRubyGraph(filePath, content) {
599
823
  if (!nameCapture)
600
824
  continue;
601
825
  const calleeName = nameCapture.node.text;
602
- if (IGNORED_CALLEES.has(calleeName))
826
+ if (isIgnoredCallee(calleeName))
603
827
  continue;
604
828
  const caller = findEnclosingFunction(nodes, nameCapture.node.startIndex);
605
829
  if (!caller)
@@ -619,6 +843,10 @@ const JAVA_FN_QUERY = `
619
843
  name: (identifier) @fn.name) @fn.node
620
844
  `;
621
845
  const JAVA_CALL_QUERY = `
846
+ (method_invocation
847
+ object: (identifier) @call.object
848
+ name: (identifier) @call.name) @call.node
849
+
622
850
  (method_invocation
623
851
  name: (identifier) @call.name) @call.node
624
852
  `;
@@ -656,21 +884,24 @@ async function extractJavaGraph(filePath, content) {
656
884
  startIndex: fnNode.startIndex,
657
885
  endIndex: fnNode.endIndex,
658
886
  fanIn: 0, fanOut: 0,
887
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'Java'),
888
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'Java'),
659
889
  });
660
890
  }
661
891
  const rawEdges = [];
662
892
  for (const match of callQuery.matches(tree.rootNode)) {
663
893
  const nameCapture = match.captures.find(c => c.name === 'call.name');
664
894
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
895
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
665
896
  if (!nameCapture || !nodeCapture)
666
897
  continue;
667
898
  const calleeName = nameCapture.node.text;
668
- if (IGNORED_CALLEES.has(calleeName))
899
+ if (isIgnoredCallee(calleeName))
669
900
  continue;
670
901
  const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
671
902
  if (!caller)
672
903
  continue;
673
- rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1 });
904
+ rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1, calleeObject: objectCapture?.node.text });
674
905
  }
675
906
  return { nodes, rawEdges };
676
907
  }
@@ -708,13 +939,16 @@ const CPP_FN_QUALIFIED_QUERY = `
708
939
  declarator: (qualified_identifier
709
940
  name: (identifier) @fn.name))) @fn.node
710
941
  `;
711
- /** Function calls: foo() and obj.method() / ptr->method() */
712
- const CPP_CALL_QUERY = `
942
+ /** Plain function calls: foo() */
943
+ const CPP_CALL_DIRECT_QUERY = `
713
944
  (call_expression
714
945
  function: (identifier) @call.name) @call.node
715
-
946
+ `;
947
+ /** Member calls: obj.method() and ptr->method() — captures receiver */
948
+ const CPP_CALL_MEMBER_QUERY = `
716
949
  (call_expression
717
950
  function: (field_expression
951
+ argument: (identifier) @call.object
718
952
  field: (field_identifier) @call.name)) @call.node
719
953
  `;
720
954
  async function extractCppGraph(filePath, content) {
@@ -732,6 +966,9 @@ async function extractCppGraph(filePath, content) {
732
966
  continue;
733
967
  seen.add(nameCapture.node.startIndex);
734
968
  const name = nameCapture.node.text;
969
+ // Skip ALL_CAPS names — these are almost certainly macros, not functions
970
+ if (/^[A-Z][A-Z0-9_]{2,}$/.test(name))
971
+ continue;
735
972
  const fnNode = nodeCapture.node;
736
973
  // Find enclosing class (inline method defined inside class body)
737
974
  let className;
@@ -765,37 +1002,295 @@ async function extractCppGraph(filePath, content) {
765
1002
  startIndex: fnNode.startIndex,
766
1003
  endIndex: fnNode.endIndex,
767
1004
  fanIn: 0, fanOut: 0,
1005
+ docstring: extractDocstringBefore(content, fnNode.startIndex, 'C++'),
1006
+ signature: extractDeclaration(content, fnNode.startIndex, fnNode.endIndex, 'C++'),
768
1007
  });
769
1008
  }
770
1009
  }
771
1010
  const rawEdges = [];
772
- for (const match of safeQuery(lang, CPP_CALL_QUERY, tree.rootNode)) {
1011
+ // Plain calls: foo()
1012
+ for (const match of safeQuery(lang, CPP_CALL_DIRECT_QUERY, tree.rootNode)) {
773
1013
  const nameCapture = match.captures.find(c => c.name === 'call.name');
774
1014
  const nodeCapture = match.captures.find(c => c.name === 'call.node');
775
1015
  if (!nameCapture || !nodeCapture)
776
1016
  continue;
777
1017
  const calleeName = nameCapture.node.text;
778
- if (IGNORED_CALLEES.has(calleeName))
1018
+ if (isIgnoredCallee(calleeName))
779
1019
  continue;
780
1020
  const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
781
1021
  if (!caller)
782
1022
  continue;
783
1023
  rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1 });
784
1024
  }
1025
+ // Member calls: obj.method() / ptr->method()
1026
+ for (const match of safeQuery(lang, CPP_CALL_MEMBER_QUERY, tree.rootNode)) {
1027
+ const nameCapture = match.captures.find(c => c.name === 'call.name');
1028
+ const nodeCapture = match.captures.find(c => c.name === 'call.node');
1029
+ const objectCapture = match.captures.find(c => c.name === 'call.object');
1030
+ if (!nameCapture || !nodeCapture)
1031
+ continue;
1032
+ const calleeName = nameCapture.node.text;
1033
+ if (isIgnoredCallee(calleeName))
1034
+ continue;
1035
+ const caller = findEnclosingFunction(nodes, nodeCapture.node.startIndex);
1036
+ if (!caller)
1037
+ continue;
1038
+ rawEdges.push({ callerId: caller.id, calleeName, line: nodeCapture.node.startPosition.row + 1, calleeObject: objectCapture?.node.text });
1039
+ }
785
1040
  return { nodes, rawEdges };
786
1041
  }
787
1042
  // ============================================================================
1043
+ // CLASS HIERARCHY EXTRACTION
1044
+ // ============================================================================
1045
+ /**
1046
+ * Extract parent class / interface relationships from source files using
1047
+ * tree-sitter. Returns a map from `filePath::ClassName` → relationship info.
1048
+ * Uses safeQuery so any query that doesn't match a grammar version is silently
1049
+ * skipped rather than crashing.
1050
+ */
1051
+ async function extractClassRelationships(files) {
1052
+ const out = new Map();
1053
+ // Helper to merge into map keyed by `filePath::ClassName`
1054
+ function merge(filePath, className, parents, ifaces) {
1055
+ const key = `${filePath}::${className}`;
1056
+ const existing = out.get(key) ?? { parentClasses: [], interfaces: [] };
1057
+ for (const p of parents)
1058
+ if (!existing.parentClasses.includes(p))
1059
+ existing.parentClasses.push(p);
1060
+ for (const i of ifaces)
1061
+ if (!existing.interfaces.includes(i))
1062
+ existing.interfaces.push(i);
1063
+ out.set(key, existing);
1064
+ }
1065
+ for (const file of files) {
1066
+ try {
1067
+ if (file.language === 'TypeScript' || file.language === 'JavaScript') {
1068
+ const { parser, lang } = await getTSParser();
1069
+ const tree = parser.parse(file.content);
1070
+ // class Foo extends Bar implements Baz, Qux
1071
+ const EXTENDS_Q = `
1072
+ (class_declaration
1073
+ name: (type_identifier) @cls
1074
+ (class_heritage (extends_clause value: (identifier) @parent)))`;
1075
+ const IMPLEMENTS_Q = `
1076
+ (class_declaration
1077
+ name: (type_identifier) @cls
1078
+ (class_heritage (implements_clause (type_identifier) @iface)))`;
1079
+ for (const m of safeQuery(lang, EXTENDS_Q, tree.rootNode)) {
1080
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1081
+ const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1082
+ if (cls && parent)
1083
+ merge(file.path, cls, [parent], []);
1084
+ }
1085
+ for (const m of safeQuery(lang, IMPLEMENTS_Q, tree.rootNode)) {
1086
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1087
+ const iface = m.captures.find(c => c.name === 'iface')?.node.text;
1088
+ if (cls && iface)
1089
+ merge(file.path, cls, [], [iface]);
1090
+ }
1091
+ }
1092
+ else if (file.language === 'Python') {
1093
+ const { parser, lang } = await getPyParser();
1094
+ const tree = parser.parse(file.content);
1095
+ // class Foo(Bar, Baz):
1096
+ const Q = `
1097
+ (class_definition
1098
+ name: (identifier) @cls
1099
+ superclasses: (argument_list (identifier) @parent))`;
1100
+ for (const m of safeQuery(lang, Q, tree.rootNode)) {
1101
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1102
+ const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1103
+ if (cls && parent && parent !== 'object')
1104
+ merge(file.path, cls, [parent], []);
1105
+ }
1106
+ }
1107
+ else if (file.language === 'Java') {
1108
+ const { parser, lang } = await getJavaParser();
1109
+ const tree = parser.parse(file.content);
1110
+ const EXTENDS_Q = `
1111
+ (class_declaration
1112
+ name: (identifier) @cls
1113
+ (superclass (type_identifier) @parent))`;
1114
+ const IMPLEMENTS_Q = `
1115
+ (class_declaration
1116
+ name: (identifier) @cls
1117
+ (super_interfaces (type_list (type_identifier) @iface)))`;
1118
+ for (const m of safeQuery(lang, EXTENDS_Q, tree.rootNode)) {
1119
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1120
+ const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1121
+ if (cls && parent)
1122
+ merge(file.path, cls, [parent], []);
1123
+ }
1124
+ for (const m of safeQuery(lang, IMPLEMENTS_Q, tree.rootNode)) {
1125
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1126
+ const iface = m.captures.find(c => c.name === 'iface')?.node.text;
1127
+ if (cls && iface)
1128
+ merge(file.path, cls, [], [iface]);
1129
+ }
1130
+ }
1131
+ else if (file.language === 'C++') {
1132
+ const { parser, lang } = await getCppParser();
1133
+ const tree = parser.parse(file.content);
1134
+ // class Foo : public Bar
1135
+ const Q = `
1136
+ (class_specifier
1137
+ name: (type_identifier) @cls
1138
+ (base_class_clause (type_identifier) @parent))`;
1139
+ for (const m of safeQuery(lang, Q, tree.rootNode)) {
1140
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1141
+ const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1142
+ if (cls && parent)
1143
+ merge(file.path, cls, [parent], []);
1144
+ }
1145
+ }
1146
+ else if (file.language === 'Ruby') {
1147
+ const { parser, lang } = await getRubyParser();
1148
+ const tree = parser.parse(file.content);
1149
+ // class Foo < Bar
1150
+ const Q = `
1151
+ (class
1152
+ name: (constant) @cls
1153
+ superclass: (superclass (constant) @parent))`;
1154
+ for (const m of safeQuery(lang, Q, tree.rootNode)) {
1155
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1156
+ const parent = m.captures.find(c => c.name === 'parent')?.node.text;
1157
+ if (cls && parent)
1158
+ merge(file.path, cls, [parent], []);
1159
+ }
1160
+ }
1161
+ else if (file.language === 'Go') {
1162
+ // Go has no inheritance but has struct embedding; treat as 'embeds' edges
1163
+ const { parser, lang } = await getGoParser();
1164
+ const tree = parser.parse(file.content);
1165
+ // Anonymous (embedded) field in a struct: type Foo struct { Bar }
1166
+ const Q = `
1167
+ (type_declaration
1168
+ (type_spec
1169
+ name: (type_identifier) @cls
1170
+ type: (struct_type
1171
+ (field_declaration_list
1172
+ (field_declaration
1173
+ type: (type_identifier) @embedded)))))`;
1174
+ for (const m of safeQuery(lang, Q, tree.rootNode)) {
1175
+ const cls = m.captures.find(c => c.name === 'cls')?.node.text;
1176
+ const embedded = m.captures.find(c => c.name === 'embedded')?.node.text;
1177
+ if (cls && embedded) {
1178
+ const key = `${file.path}::${cls}`;
1179
+ const existing = out.get(key) ?? { parentClasses: [], interfaces: [] };
1180
+ // Store Go embeds as parentClasses (will be tagged as 'embeds' when building edges)
1181
+ if (!existing.parentClasses.includes(embedded))
1182
+ existing.parentClasses.push(embedded);
1183
+ out.set(key, existing);
1184
+ }
1185
+ }
1186
+ }
1187
+ // Rust: trait impls are structural but less like OOP inheritance; skip for now
1188
+ }
1189
+ catch {
1190
+ // Best-effort; skip unparseable files
1191
+ }
1192
+ }
1193
+ return out;
1194
+ }
1195
+ /**
1196
+ * Build ClassNode[] from the set of extracted FunctionNodes (which carry
1197
+ * `className`), enriched with inheritance data from `extractClassRelationships`.
1198
+ *
1199
+ * Functions without a className are grouped by file into synthetic module nodes
1200
+ * (e.g. `[call-graph]`) so every function appears in the class graph, not just
1201
+ * class methods. This is essential for codebases that use mostly module-level
1202
+ * exports rather than OOP classes.
1203
+ */
1204
+ function buildClassNodes(allNodes, relationships) {
1205
+ // Group FunctionNodes by (filePath, className).
1206
+ // Free functions use a synthetic "[basename]" module name keyed by filePath alone.
1207
+ const groups = new Map();
1208
+ for (const fn of allNodes.values()) {
1209
+ let key;
1210
+ let name;
1211
+ let isModule;
1212
+ if (fn.className) {
1213
+ key = `${fn.filePath}::${fn.className}`;
1214
+ name = fn.className;
1215
+ isModule = false;
1216
+ }
1217
+ else {
1218
+ // Synthetic module node — one per file
1219
+ key = fn.filePath;
1220
+ const base = fn.filePath.split('/').pop() ?? fn.filePath;
1221
+ name = '[' + base.replace(/\.[^.]+$/, '') + ']';
1222
+ isModule = true;
1223
+ }
1224
+ if (!groups.has(key)) {
1225
+ groups.set(key, { name, filePath: fn.filePath, language: fn.language, isModule, methods: [] });
1226
+ }
1227
+ groups.get(key).methods.push(fn);
1228
+ }
1229
+ // Build ClassNode[]
1230
+ const classMap = new Map();
1231
+ for (const [id, g] of groups) {
1232
+ const rel = relationships.get(id) ?? { parentClasses: [], interfaces: [] };
1233
+ const cls = {
1234
+ id,
1235
+ name: g.name,
1236
+ filePath: g.filePath,
1237
+ language: g.language,
1238
+ parentClasses: rel.parentClasses,
1239
+ interfaces: rel.interfaces,
1240
+ methodIds: g.methods.map(m => m.id),
1241
+ fanIn: g.methods.reduce((s, m) => s + m.fanIn, 0),
1242
+ fanOut: g.methods.reduce((s, m) => s + m.fanOut, 0),
1243
+ isModule: g.isModule,
1244
+ };
1245
+ classMap.set(id, cls);
1246
+ }
1247
+ // Build InheritanceEdge[] — only when both parent and child are in our graph
1248
+ // Parent lookup: match by class name across all ClassNodes (first match wins)
1249
+ const byName = new Map();
1250
+ for (const cls of classMap.values()) {
1251
+ if (!byName.has(cls.name))
1252
+ byName.set(cls.name, cls);
1253
+ }
1254
+ const inheritanceEdges = [];
1255
+ const seenEdges = new Set();
1256
+ for (const cls of classMap.values()) {
1257
+ for (const parentName of cls.parentClasses) {
1258
+ const parent = byName.get(parentName);
1259
+ if (!parent)
1260
+ continue;
1261
+ const edgeId = `${parent.id}->${cls.id}`;
1262
+ if (seenEdges.has(edgeId))
1263
+ continue;
1264
+ seenEdges.add(edgeId);
1265
+ // Go embedding vs OOP inheritance
1266
+ const kind = cls.language === 'Go' ? 'embeds' : 'extends';
1267
+ inheritanceEdges.push({ id: edgeId, parentId: parent.id, childId: cls.id, kind });
1268
+ }
1269
+ for (const ifaceName of cls.interfaces) {
1270
+ const parent = byName.get(ifaceName);
1271
+ if (!parent)
1272
+ continue;
1273
+ const edgeId = `${parent.id}->${cls.id}`;
1274
+ if (seenEdges.has(edgeId))
1275
+ continue;
1276
+ seenEdges.add(edgeId);
1277
+ inheritanceEdges.push({ id: edgeId, parentId: parent.id, childId: cls.id, kind: 'implements' });
1278
+ }
1279
+ }
1280
+ return { classes: Array.from(classMap.values()), inheritanceEdges };
1281
+ }
1282
+ // ============================================================================
788
1283
  // CALL GRAPH BUILDER
789
1284
  // ============================================================================
790
1285
  export class CallGraphBuilder {
791
1286
  /**
792
1287
  * Build a call graph from a list of source files.
793
1288
  *
794
- * @param files Source files with path, content, and language
795
- * @param layers Optional layer map { layerName: [path prefix, ...] }
796
- * e.g. { api: ['routes/', 'controllers/'], storage: ['models/'] }
1289
+ * @param files Source files with path, content, and language
1290
+ * @param layers Optional layer map { layerName: [path prefix, ...] }
1291
+ * @param importMap Optional per-file import map (from ImportResolverBridge)
797
1292
  */
798
- async build(files, layers) {
1293
+ async build(files, layers, importMap) {
799
1294
  const allNodes = new Map();
800
1295
  const allRawEdges = [];
801
1296
  // Pass 1: Extract nodes and raw edges from each file
@@ -838,35 +1333,121 @@ export class CallGraphBuilder {
838
1333
  }
839
1334
  }
840
1335
  }
841
- // Pass 2: Resolve raw edges — find callee FunctionNode by name
842
- const nodesByName = new Map();
843
- for (const node of allNodes.values()) {
844
- const list = nodesByName.get(node.name) ?? [];
845
- list.push(node);
846
- nodesByName.set(node.name, list);
847
- }
1336
+ // Pass 2: Resolve raw edges — multi-strategy resolution
1337
+ const trie = new FunctionRegistryTrie();
1338
+ for (const node of allNodes.values())
1339
+ trie.insert(node);
1340
+ // Build per-function-body content slices for type inference (keyed by functionId)
1341
+ const fileContents = new Map();
1342
+ for (const file of files)
1343
+ fileContents.set(file.path, file.content);
848
1344
  const edges = [];
849
1345
  for (const raw of allRawEdges) {
850
- const candidates = nodesByName.get(raw.calleeName);
851
- if (!candidates || candidates.length === 0)
852
- continue; // external call
1346
+ const callerNode = allNodes.get(raw.callerId);
1347
+ if (!callerNode)
1348
+ continue;
853
1349
  let calleeNode;
854
- if (candidates.length === 1) {
855
- calleeNode = candidates[0];
1350
+ let confidence = 'name_only';
1351
+ // Strategy 1 — self/cls intra-class (Python self.*, cls.* or same-class method)
1352
+ if (raw.calleeObject === 'self' || raw.calleeObject === 'cls') {
1353
+ if (callerNode.className) {
1354
+ const candidates = trie.findByQualifiedName(callerNode.className, raw.calleeName);
1355
+ if (candidates.length > 0) {
1356
+ calleeNode = candidates[0];
1357
+ confidence = 'self_cls';
1358
+ }
1359
+ }
856
1360
  }
857
- else {
858
- // Prefer same file as caller
859
- const callerNode = allNodes.get(raw.callerId);
860
- const sameFile = candidates.find(c => c.filePath === callerNode?.filePath);
861
- calleeNode = sameFile ?? candidates[0];
1361
+ // Strategy 2 — type inference on receiver variable
1362
+ if (!calleeNode && raw.calleeObject) {
1363
+ const fileContent = fileContents.get(callerNode.filePath);
1364
+ if (fileContent) {
1365
+ const bodySlice = fileContent.slice(callerNode.startIndex, callerNode.endIndex);
1366
+ const inferredTypes = inferTypesFromSource(bodySlice, callerNode.language);
1367
+ const resolved = resolveViaTypeInference(raw.calleeObject, raw.calleeName, inferredTypes, trie);
1368
+ if (resolved) {
1369
+ calleeNode = resolved;
1370
+ confidence = 'type_inference';
1371
+ }
1372
+ }
1373
+ }
1374
+ // Strategy 3 — import resolution (TS/JS/Python/Go/Rust/Ruby/Java)
1375
+ if (!calleeNode && importMap) {
1376
+ const importedFile = importMap.get(callerNode.filePath)?.get(raw.calleeName)
1377
+ ?? (raw.calleeObject ? importMap.get(callerNode.filePath)?.get(raw.calleeObject) : undefined);
1378
+ if (importedFile) {
1379
+ const candidates = trie.findBySimpleName(raw.calleeName).filter(n => n.filePath.startsWith(importedFile));
1380
+ if (candidates.length > 0) {
1381
+ calleeNode = candidates[0];
1382
+ confidence = 'import';
1383
+ }
1384
+ }
1385
+ }
1386
+ // Strategy 4 — same-file preference (only for calls without a typed receiver)
1387
+ // When a receiver is explicitly present but unresolvable (e.g. redis_client.get()),
1388
+ // skip name_only fallback to avoid false-positive edges.
1389
+ if (!calleeNode && !raw.calleeObject) {
1390
+ const candidates = trie.findBySimpleName(raw.calleeName);
1391
+ if (candidates.length === 0)
1392
+ continue; // external call, skip
1393
+ const sameFile = candidates.find(c => c.filePath === callerNode.filePath);
1394
+ if (sameFile) {
1395
+ calleeNode = sameFile;
1396
+ confidence = 'same_file';
1397
+ }
1398
+ else {
1399
+ calleeNode = candidates[0];
1400
+ confidence = 'name_only';
1401
+ }
862
1402
  }
1403
+ if (!calleeNode)
1404
+ continue;
863
1405
  edges.push({
864
1406
  callerId: raw.callerId,
865
1407
  calleeId: calleeNode.id,
866
1408
  calleeName: raw.calleeName,
867
1409
  line: raw.line,
1410
+ confidence,
868
1411
  });
869
1412
  }
1413
+ // Pass 2b: HTTP cross-language edges (JS/TS caller → Python handler)
1414
+ try {
1415
+ const filePaths = files.map(f => f.path);
1416
+ const { edges: httpEdges } = await extractAllHttpEdges(filePaths);
1417
+ for (const he of httpEdges) {
1418
+ // Find callee: handler function by name in handlerFile
1419
+ const calleeNode = trie.findBySimpleName(he.route.handlerName)
1420
+ .find(n => n.filePath === he.handlerFile);
1421
+ if (!calleeNode)
1422
+ continue;
1423
+ // Find caller: any function in callerFile that encloses the HTTP call's line
1424
+ const callerContent = fileContents.get(he.callerFile);
1425
+ const callerNode = callerContent
1426
+ ? (() => {
1427
+ let offset = 0;
1428
+ const lines = callerContent.split('\n');
1429
+ for (let i = 0; i < he.call.line - 1 && i < lines.length; i++) {
1430
+ offset += lines[i].length + 1;
1431
+ }
1432
+ const candidates = Array.from(allNodes.values())
1433
+ .filter(n => n.filePath === he.callerFile);
1434
+ return findEnclosingFunction(candidates, offset);
1435
+ })()
1436
+ : undefined;
1437
+ if (!callerNode)
1438
+ continue;
1439
+ edges.push({
1440
+ callerId: callerNode.id,
1441
+ calleeId: calleeNode.id,
1442
+ calleeName: he.route.handlerName,
1443
+ line: he.call.line,
1444
+ confidence: 'http_endpoint',
1445
+ });
1446
+ }
1447
+ }
1448
+ catch {
1449
+ // HTTP edge extraction is best-effort; don't fail the whole build
1450
+ }
870
1451
  // Pass 3: Calculate fanIn / fanOut (count unique caller→callee pairs, not call sites)
871
1452
  const seenPairs = new Set();
872
1453
  for (const edge of edges) {
@@ -895,9 +1476,14 @@ export class CallGraphBuilder {
895
1476
  : [];
896
1477
  const totalFanIn = nodes.reduce((s, n) => s + n.fanIn, 0);
897
1478
  const totalFanOut = nodes.reduce((s, n) => s + n.fanOut, 0);
1479
+ // Pass 5: Build class hierarchy (inheritance + grouping)
1480
+ const relationships = await extractClassRelationships(files);
1481
+ const { classes, inheritanceEdges } = buildClassNodes(allNodes, relationships);
898
1482
  return {
899
1483
  nodes: allNodes,
900
1484
  edges,
1485
+ classes,
1486
+ inheritanceEdges,
901
1487
  hubFunctions,
902
1488
  entryPoints,
903
1489
  layerViolations,
@@ -931,6 +1517,8 @@ export class CallGraphBuilder {
931
1517
  continue;
932
1518
  const callerIdx = layerOrder.indexOf(callerLayer);
933
1519
  const calleeIdx = layerOrder.indexOf(calleeLayer);
1520
+ if (callerIdx === -1 || calleeIdx === -1)
1521
+ continue;
934
1522
  if (callerIdx > calleeIdx) {
935
1523
  // Lower layer calling upper layer — violation
936
1524
  violations.push({
@@ -952,6 +1540,8 @@ export function serializeCallGraph(result) {
952
1540
  return {
953
1541
  nodes: Array.from(result.nodes.values()),
954
1542
  edges: result.edges,
1543
+ classes: result.classes,
1544
+ inheritanceEdges: result.inheritanceEdges,
955
1545
  hubFunctions: result.hubFunctions,
956
1546
  entryPoints: result.entryPoints,
957
1547
  layerViolations: result.layerViolations,