spec-gen-cli 1.2.2 → 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.
- package/README.md +197 -11
- package/dist/api/generate.d.ts.map +1 -1
- package/dist/api/generate.js +5 -4
- package/dist/api/generate.js.map +1 -1
- package/dist/cli/commands/analyze.d.ts.map +1 -1
- package/dist/cli/commands/analyze.js +101 -41
- package/dist/cli/commands/analyze.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +25 -21
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/mcp.d.ts +353 -10
- package/dist/cli/commands/mcp.d.ts.map +1 -1
- package/dist/cli/commands/mcp.js +236 -45
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/view.d.ts.map +1 -1
- package/dist/cli/commands/view.js +33 -4
- package/dist/cli/commands/view.js.map +1 -1
- package/dist/constants.d.ts +10 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +10 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/analyzer/ast-chunker.d.ts +24 -0
- package/dist/core/analyzer/ast-chunker.d.ts.map +1 -0
- package/dist/core/analyzer/ast-chunker.js +198 -0
- package/dist/core/analyzer/ast-chunker.js.map +1 -0
- package/dist/core/analyzer/call-graph.d.ts +51 -4
- package/dist/core/analyzer/call-graph.d.ts.map +1 -1
- package/dist/core/analyzer/call-graph.js +634 -44
- package/dist/core/analyzer/call-graph.js.map +1 -1
- package/dist/core/analyzer/code-shaper.d.ts.map +1 -1
- package/dist/core/analyzer/code-shaper.js +5 -0
- package/dist/core/analyzer/code-shaper.js.map +1 -1
- package/dist/core/analyzer/codebase-digest.d.ts +40 -0
- package/dist/core/analyzer/codebase-digest.d.ts.map +1 -0
- package/dist/core/analyzer/codebase-digest.js +194 -0
- package/dist/core/analyzer/codebase-digest.js.map +1 -0
- package/dist/core/analyzer/cpp-header-resolver.d.ts +30 -0
- package/dist/core/analyzer/cpp-header-resolver.d.ts.map +1 -0
- package/dist/core/analyzer/cpp-header-resolver.js +71 -0
- package/dist/core/analyzer/cpp-header-resolver.js.map +1 -0
- package/dist/core/analyzer/function-registry-trie.d.ts +21 -0
- package/dist/core/analyzer/function-registry-trie.d.ts.map +1 -0
- package/dist/core/analyzer/function-registry-trie.js +39 -0
- package/dist/core/analyzer/function-registry-trie.js.map +1 -0
- package/dist/core/analyzer/import-resolver-bridge.d.ts +25 -0
- package/dist/core/analyzer/import-resolver-bridge.d.ts.map +1 -0
- package/dist/core/analyzer/import-resolver-bridge.js +99 -0
- package/dist/core/analyzer/import-resolver-bridge.js.map +1 -0
- package/dist/core/analyzer/signature-extractor.d.ts.map +1 -1
- package/dist/core/analyzer/signature-extractor.js +72 -3
- package/dist/core/analyzer/signature-extractor.js.map +1 -1
- package/dist/core/analyzer/subgraph-extractor.d.ts +10 -2
- package/dist/core/analyzer/subgraph-extractor.d.ts.map +1 -1
- package/dist/core/analyzer/subgraph-extractor.js +25 -7
- package/dist/core/analyzer/subgraph-extractor.js.map +1 -1
- package/dist/core/analyzer/type-inference-engine.d.ts +23 -0
- package/dist/core/analyzer/type-inference-engine.d.ts.map +1 -0
- package/dist/core/analyzer/type-inference-engine.js +130 -0
- package/dist/core/analyzer/type-inference-engine.js.map +1 -0
- package/dist/core/analyzer/vector-index.d.ts +35 -6
- package/dist/core/analyzer/vector-index.d.ts.map +1 -1
- package/dist/core/analyzer/vector-index.js +308 -54
- package/dist/core/analyzer/vector-index.js.map +1 -1
- package/dist/core/generator/spec-pipeline.d.ts +31 -11
- package/dist/core/generator/spec-pipeline.d.ts.map +1 -1
- package/dist/core/generator/spec-pipeline.js +170 -39
- package/dist/core/generator/spec-pipeline.js.map +1 -1
- package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -1
- package/dist/core/generator/stages/stage2-entities.js +2 -1
- package/dist/core/generator/stages/stage2-entities.js.map +1 -1
- package/dist/core/generator/stages/stage3-services.d.ts.map +1 -1
- package/dist/core/generator/stages/stage3-services.js +2 -1
- package/dist/core/generator/stages/stage3-services.js.map +1 -1
- package/dist/core/generator/stages/stage4-api.d.ts.map +1 -1
- package/dist/core/generator/stages/stage4-api.js +2 -1
- package/dist/core/generator/stages/stage4-api.js.map +1 -1
- package/dist/core/generator/stages/stage5-architecture.d.ts +2 -1
- package/dist/core/generator/stages/stage5-architecture.d.ts.map +1 -1
- package/dist/core/generator/stages/stage5-architecture.js +15 -3
- package/dist/core/generator/stages/stage5-architecture.js.map +1 -1
- package/dist/core/services/chat-agent.d.ts +5 -0
- package/dist/core/services/chat-agent.d.ts.map +1 -1
- package/dist/core/services/chat-agent.js +14 -0
- package/dist/core/services/chat-agent.js.map +1 -1
- package/dist/core/services/chat-tools.d.ts.map +1 -1
- package/dist/core/services/chat-tools.js +172 -50
- package/dist/core/services/chat-tools.js.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.d.ts +12 -0
- package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/analysis.js +138 -2
- package/dist/core/services/mcp-handlers/analysis.js.map +1 -1
- package/dist/core/services/mcp-handlers/graph.d.ts +21 -1
- package/dist/core/services/mcp-handlers/graph.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/graph.js +142 -2
- package/dist/core/services/mcp-handlers/graph.js.map +1 -1
- package/dist/core/services/mcp-handlers/orient.d.ts +17 -0
- package/dist/core/services/mcp-handlers/orient.d.ts.map +1 -0
- package/dist/core/services/mcp-handlers/orient.js +200 -0
- package/dist/core/services/mcp-handlers/orient.js.map +1 -0
- package/dist/core/services/mcp-handlers/semantic.d.ts +18 -4
- package/dist/core/services/mcp-handlers/semantic.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/semantic.js +161 -17
- package/dist/core/services/mcp-handlers/semantic.js.map +1 -1
- package/dist/core/services/mcp-handlers/utils.d.ts +43 -0
- package/dist/core/services/mcp-handlers/utils.d.ts.map +1 -1
- package/dist/core/services/mcp-handlers/utils.js +66 -1
- package/dist/core/services/mcp-handlers/utils.js.map +1 -1
- package/dist/core/services/mcp-watcher.d.ts +41 -0
- package/dist/core/services/mcp-watcher.d.ts.map +1 -0
- package/dist/core/services/mcp-watcher.js +177 -0
- package/dist/core/services/mcp-watcher.js.map +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/pipeline.d.ts +7 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/viewer/InteractiveGraphViewer.jsx +33 -8
- package/src/viewer/components/ChatPanel.jsx +8 -5
- package/src/viewer/components/ClassGraph.jsx +699 -0
- package/src/viewer/utils/graph-helpers.js +1 -1
- 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 (
|
|
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 (
|
|
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() —
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
/**
|
|
712
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
|
795
|
-
* @param layers
|
|
796
|
-
*
|
|
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 —
|
|
842
|
-
const
|
|
843
|
-
for (const node of allNodes.values())
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
|
851
|
-
if (!
|
|
852
|
-
continue;
|
|
1346
|
+
const callerNode = allNodes.get(raw.callerId);
|
|
1347
|
+
if (!callerNode)
|
|
1348
|
+
continue;
|
|
853
1349
|
let calleeNode;
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
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,
|