@zuvia-software-solutions/code-mapper 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -0
- package/dist/cli/ai-context.d.ts +19 -0
- package/dist/cli/ai-context.js +168 -0
- package/dist/cli/analyze.d.ts +7 -0
- package/dist/cli/analyze.js +325 -0
- package/dist/cli/augment.d.ts +7 -0
- package/dist/cli/augment.js +27 -0
- package/dist/cli/clean.d.ts +5 -0
- package/dist/cli/clean.js +56 -0
- package/dist/cli/eval-server.d.ts +25 -0
- package/dist/cli/eval-server.js +365 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.js +102 -0
- package/dist/cli/lazy-action.d.ts +6 -0
- package/dist/cli/lazy-action.js +19 -0
- package/dist/cli/list.d.ts +2 -0
- package/dist/cli/list.js +27 -0
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +35 -0
- package/dist/cli/refresh.d.ts +12 -0
- package/dist/cli/refresh.js +165 -0
- package/dist/cli/serve.d.ts +5 -0
- package/dist/cli/serve.js +8 -0
- package/dist/cli/setup.d.ts +6 -0
- package/dist/cli/setup.js +218 -0
- package/dist/cli/status.d.ts +2 -0
- package/dist/cli/status.js +33 -0
- package/dist/cli/tool.d.ts +28 -0
- package/dist/cli/tool.js +87 -0
- package/dist/config/ignore-service.d.ts +32 -0
- package/dist/config/ignore-service.js +282 -0
- package/dist/config/supported-languages.d.ts +23 -0
- package/dist/config/supported-languages.js +52 -0
- package/dist/core/augmentation/engine.d.ts +22 -0
- package/dist/core/augmentation/engine.js +232 -0
- package/dist/core/embeddings/embedder.d.ts +35 -0
- package/dist/core/embeddings/embedder.js +171 -0
- package/dist/core/embeddings/embedding-pipeline.d.ts +41 -0
- package/dist/core/embeddings/embedding-pipeline.js +402 -0
- package/dist/core/embeddings/index.d.ts +5 -0
- package/dist/core/embeddings/index.js +6 -0
- package/dist/core/embeddings/text-generator.d.ts +20 -0
- package/dist/core/embeddings/text-generator.js +159 -0
- package/dist/core/embeddings/types.d.ts +60 -0
- package/dist/core/embeddings/types.js +23 -0
- package/dist/core/graph/graph.d.ts +4 -0
- package/dist/core/graph/graph.js +65 -0
- package/dist/core/graph/types.d.ts +69 -0
- package/dist/core/graph/types.js +3 -0
- package/dist/core/incremental/child-process.d.ts +8 -0
- package/dist/core/incremental/child-process.js +649 -0
- package/dist/core/incremental/refresh-coordinator.d.ts +32 -0
- package/dist/core/incremental/refresh-coordinator.js +147 -0
- package/dist/core/incremental/types.d.ts +78 -0
- package/dist/core/incremental/types.js +153 -0
- package/dist/core/incremental/watcher.d.ts +63 -0
- package/dist/core/incremental/watcher.js +338 -0
- package/dist/core/ingestion/ast-cache.d.ts +12 -0
- package/dist/core/ingestion/ast-cache.js +34 -0
- package/dist/core/ingestion/call-processor.d.ts +34 -0
- package/dist/core/ingestion/call-processor.js +937 -0
- package/dist/core/ingestion/call-routing.d.ts +40 -0
- package/dist/core/ingestion/call-routing.js +97 -0
- package/dist/core/ingestion/cluster-enricher.d.ts +30 -0
- package/dist/core/ingestion/cluster-enricher.js +151 -0
- package/dist/core/ingestion/community-processor.d.ts +26 -0
- package/dist/core/ingestion/community-processor.js +272 -0
- package/dist/core/ingestion/constants.d.ts +5 -0
- package/dist/core/ingestion/constants.js +8 -0
- package/dist/core/ingestion/entry-point-scoring.d.ts +23 -0
- package/dist/core/ingestion/entry-point-scoring.js +317 -0
- package/dist/core/ingestion/export-detection.d.ts +11 -0
- package/dist/core/ingestion/export-detection.js +203 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +18 -0
- package/dist/core/ingestion/filesystem-walker.js +64 -0
- package/dist/core/ingestion/framework-detection.d.ts +42 -0
- package/dist/core/ingestion/framework-detection.js +405 -0
- package/dist/core/ingestion/heritage-processor.d.ts +15 -0
- package/dist/core/ingestion/heritage-processor.js +237 -0
- package/dist/core/ingestion/import-processor.d.ts +31 -0
- package/dist/core/ingestion/import-processor.js +416 -0
- package/dist/core/ingestion/language-config.d.ts +32 -0
- package/dist/core/ingestion/language-config.js +161 -0
- package/dist/core/ingestion/mro-processor.d.ts +32 -0
- package/dist/core/ingestion/mro-processor.js +343 -0
- package/dist/core/ingestion/named-binding-extraction.d.ts +51 -0
- package/dist/core/ingestion/named-binding-extraction.js +343 -0
- package/dist/core/ingestion/parsing-processor.d.ts +20 -0
- package/dist/core/ingestion/parsing-processor.js +282 -0
- package/dist/core/ingestion/pipeline.d.ts +3 -0
- package/dist/core/ingestion/pipeline.js +416 -0
- package/dist/core/ingestion/process-processor.d.ts +42 -0
- package/dist/core/ingestion/process-processor.js +357 -0
- package/dist/core/ingestion/resolution-context.d.ts +40 -0
- package/dist/core/ingestion/resolution-context.js +171 -0
- package/dist/core/ingestion/resolvers/csharp.d.ts +10 -0
- package/dist/core/ingestion/resolvers/csharp.js +101 -0
- package/dist/core/ingestion/resolvers/go.d.ts +8 -0
- package/dist/core/ingestion/resolvers/go.js +33 -0
- package/dist/core/ingestion/resolvers/index.d.ts +14 -0
- package/dist/core/ingestion/resolvers/index.js +10 -0
- package/dist/core/ingestion/resolvers/jvm.d.ts +9 -0
- package/dist/core/ingestion/resolvers/jvm.js +74 -0
- package/dist/core/ingestion/resolvers/php.d.ts +7 -0
- package/dist/core/ingestion/resolvers/php.js +30 -0
- package/dist/core/ingestion/resolvers/ruby.d.ts +9 -0
- package/dist/core/ingestion/resolvers/ruby.js +13 -0
- package/dist/core/ingestion/resolvers/rust.d.ts +5 -0
- package/dist/core/ingestion/resolvers/rust.js +62 -0
- package/dist/core/ingestion/resolvers/standard.d.ts +16 -0
- package/dist/core/ingestion/resolvers/standard.js +144 -0
- package/dist/core/ingestion/resolvers/utils.d.ts +18 -0
- package/dist/core/ingestion/resolvers/utils.js +113 -0
- package/dist/core/ingestion/structure-processor.d.ts +4 -0
- package/dist/core/ingestion/structure-processor.js +39 -0
- package/dist/core/ingestion/symbol-table.d.ts +34 -0
- package/dist/core/ingestion/symbol-table.js +48 -0
- package/dist/core/ingestion/tree-sitter-queries.d.ts +20 -0
- package/dist/core/ingestion/tree-sitter-queries.js +691 -0
- package/dist/core/ingestion/type-env.d.ts +52 -0
- package/dist/core/ingestion/type-env.js +349 -0
- package/dist/core/ingestion/type-extractors/c-cpp.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/c-cpp.js +214 -0
- package/dist/core/ingestion/type-extractors/csharp.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/csharp.js +224 -0
- package/dist/core/ingestion/type-extractors/go.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/go.js +261 -0
- package/dist/core/ingestion/type-extractors/index.d.ts +20 -0
- package/dist/core/ingestion/type-extractors/index.js +30 -0
- package/dist/core/ingestion/type-extractors/jvm.d.ts +5 -0
- package/dist/core/ingestion/type-extractors/jvm.js +386 -0
- package/dist/core/ingestion/type-extractors/php.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/php.js +280 -0
- package/dist/core/ingestion/type-extractors/python.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/python.js +175 -0
- package/dist/core/ingestion/type-extractors/ruby.d.ts +12 -0
- package/dist/core/ingestion/type-extractors/ruby.js +218 -0
- package/dist/core/ingestion/type-extractors/rust.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/rust.js +290 -0
- package/dist/core/ingestion/type-extractors/shared.d.ts +81 -0
- package/dist/core/ingestion/type-extractors/shared.js +322 -0
- package/dist/core/ingestion/type-extractors/swift.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/swift.js +140 -0
- package/dist/core/ingestion/type-extractors/types.d.ts +111 -0
- package/dist/core/ingestion/type-extractors/types.js +4 -0
- package/dist/core/ingestion/type-extractors/typescript.d.ts +4 -0
- package/dist/core/ingestion/type-extractors/typescript.js +227 -0
- package/dist/core/ingestion/utils.d.ts +73 -0
- package/dist/core/ingestion/utils.js +992 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +99 -0
- package/dist/core/ingestion/workers/parse-worker.js +1055 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
- package/dist/core/ingestion/workers/worker-pool.js +123 -0
- package/dist/core/lbug/csv-generator.d.ts +28 -0
- package/dist/core/lbug/csv-generator.js +355 -0
- package/dist/core/lbug/lbug-adapter.d.ts +96 -0
- package/dist/core/lbug/lbug-adapter.js +753 -0
- package/dist/core/lbug/schema.d.ts +46 -0
- package/dist/core/lbug/schema.js +402 -0
- package/dist/core/search/bm25-index.d.ts +20 -0
- package/dist/core/search/bm25-index.js +123 -0
- package/dist/core/search/hybrid-search.d.ts +32 -0
- package/dist/core/search/hybrid-search.js +131 -0
- package/dist/core/search/query-cache.d.ts +18 -0
- package/dist/core/search/query-cache.js +47 -0
- package/dist/core/search/query-expansion.d.ts +19 -0
- package/dist/core/search/query-expansion.js +75 -0
- package/dist/core/search/reranker.d.ts +29 -0
- package/dist/core/search/reranker.js +122 -0
- package/dist/core/search/types.d.ts +154 -0
- package/dist/core/search/types.js +51 -0
- package/dist/core/semantic/tsgo-service.d.ts +67 -0
- package/dist/core/semantic/tsgo-service.js +355 -0
- package/dist/core/tree-sitter/parser-loader.d.ts +12 -0
- package/dist/core/tree-sitter/parser-loader.js +71 -0
- package/dist/lib/memory-guard.d.ts +35 -0
- package/dist/lib/memory-guard.js +70 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.js +6 -0
- package/dist/mcp/compatible-stdio-transport.d.ts +32 -0
- package/dist/mcp/compatible-stdio-transport.js +209 -0
- package/dist/mcp/core/embedder.d.ts +24 -0
- package/dist/mcp/core/embedder.js +168 -0
- package/dist/mcp/core/lbug-adapter.d.ts +29 -0
- package/dist/mcp/core/lbug-adapter.js +330 -0
- package/dist/mcp/local/local-backend.d.ts +188 -0
- package/dist/mcp/local/local-backend.js +2759 -0
- package/dist/mcp/resources.d.ts +22 -0
- package/dist/mcp/resources.js +379 -0
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/server.js +217 -0
- package/dist/mcp/staleness.d.ts +10 -0
- package/dist/mcp/staleness.js +25 -0
- package/dist/mcp/tools.d.ts +21 -0
- package/dist/mcp/tools.js +202 -0
- package/dist/server/api.d.ts +5 -0
- package/dist/server/api.js +340 -0
- package/dist/server/mcp-http.d.ts +7 -0
- package/dist/server/mcp-http.js +95 -0
- package/dist/storage/git.d.ts +6 -0
- package/dist/storage/git.js +35 -0
- package/dist/storage/repo-manager.d.ts +87 -0
- package/dist/storage/repo-manager.js +249 -0
- package/dist/types/pipeline.d.ts +35 -0
- package/dist/types/pipeline.js +20 -0
- package/hooks/claude/code-mapper-hook.cjs +238 -0
- package/hooks/claude/pre-tool-use.sh +79 -0
- package/hooks/claude/session-start.sh +42 -0
- package/models/mlx-embedder.py +185 -0
- package/package.json +100 -0
- package/scripts/patch-tree-sitter-swift.cjs +74 -0
- package/vendor/leiden/index.cjs +355 -0
- package/vendor/leiden/utils.cjs +392 -0
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
// code-mapper/src/core/ingestion/call-processor.ts
|
|
2
|
+
/** @file call-processor.ts @description Resolves function/method call sites to target symbol nodes, supporting sequential AST-based and pre-extracted worker-based paths */
|
|
3
|
+
import Parser from 'tree-sitter';
|
|
4
|
+
import { TIER_CONFIDENCE } from './resolution-context.js';
|
|
5
|
+
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
|
6
|
+
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
|
7
|
+
import { generateId } from '../../lib/utils.js';
|
|
8
|
+
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId, CALL_EXPRESSION_TYPES, extractCallChain, } from './utils.js';
|
|
9
|
+
import { buildTypeEnv } from './type-env.js';
|
|
10
|
+
import { getTreeSitterBufferSize } from './constants.js';
|
|
11
|
+
import { callRouters } from './call-routing.js';
|
|
12
|
+
/** Walk up the AST to find the enclosing function/method, or null for top-level code */
|
|
13
|
+
const findEnclosingFunction = (node, filePath, ctx) => {
|
|
14
|
+
let current = node.parent;
|
|
15
|
+
while (current) {
|
|
16
|
+
if (FUNCTION_NODE_TYPES.has(current.type)) {
|
|
17
|
+
const { funcName, label } = extractFunctionName(current);
|
|
18
|
+
if (funcName) {
|
|
19
|
+
const resolved = ctx.resolve(funcName, filePath);
|
|
20
|
+
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
|
|
21
|
+
return resolved.candidates[0].nodeId;
|
|
22
|
+
}
|
|
23
|
+
return generateId(label, `${filePath}:${funcName}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
current = current.parent;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
/** Verify constructor bindings against SymbolTable and infer receiver types */
|
|
31
|
+
const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
|
|
32
|
+
const verified = new Map();
|
|
33
|
+
for (const { scope, varName, calleeName, receiverClassName } of bindings) {
|
|
34
|
+
const tiered = ctx.resolve(calleeName, filePath);
|
|
35
|
+
const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false;
|
|
36
|
+
if (isClass) {
|
|
37
|
+
verified.set(receiverKey(scope, varName), calleeName);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
let callableDefs = tiered?.candidates.filter(d => d.type === 'Function' || d.type === 'Method');
|
|
41
|
+
// When receiver class is known (e.g. $this->method() in PHP), narrow
|
|
42
|
+
// candidates to methods owned by that class
|
|
43
|
+
if (callableDefs && callableDefs.length > 1 && receiverClassName) {
|
|
44
|
+
if (graph) {
|
|
45
|
+
// Worker path: use graph.getNode
|
|
46
|
+
const narrowed = callableDefs.filter(d => {
|
|
47
|
+
if (!d.ownerId)
|
|
48
|
+
return false;
|
|
49
|
+
const owner = graph.getNode(d.ownerId);
|
|
50
|
+
return owner?.properties.name === receiverClassName;
|
|
51
|
+
});
|
|
52
|
+
if (narrowed.length > 0)
|
|
53
|
+
callableDefs = narrowed;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Sequential path: use ctx.resolve
|
|
57
|
+
const classResolved = ctx.resolve(receiverClassName, filePath);
|
|
58
|
+
if (classResolved && classResolved.candidates.length > 0) {
|
|
59
|
+
const classNodeIds = new Set(classResolved.candidates.map(c => c.nodeId));
|
|
60
|
+
const narrowed = callableDefs.filter(d => d.ownerId && classNodeIds.has(d.ownerId));
|
|
61
|
+
if (narrowed.length > 0)
|
|
62
|
+
callableDefs = narrowed;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) {
|
|
67
|
+
const typeName = extractReturnTypeName(callableDefs[0].returnType);
|
|
68
|
+
if (typeName) {
|
|
69
|
+
verified.set(receiverKey(scope, varName), typeName);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return verified;
|
|
75
|
+
};
|
|
76
|
+
/** Check if a node is inside a catch clause */
|
|
77
|
+
const isInsideCatch = (node) => {
|
|
78
|
+
let current = node.parent;
|
|
79
|
+
while (current) {
|
|
80
|
+
if (current.type === 'catch_clause')
|
|
81
|
+
return true;
|
|
82
|
+
// Stop at function boundary
|
|
83
|
+
if (FUNCTION_NODE_TYPES.has(current.type))
|
|
84
|
+
return false;
|
|
85
|
+
current = current.parent;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
/** Check if a node is inside an if-statement body (conditional call) */
|
|
90
|
+
const isInsideConditional = (node) => {
|
|
91
|
+
let current = node.parent;
|
|
92
|
+
while (current) {
|
|
93
|
+
if (current.type === 'if_statement' || current.type === 'conditional_expression' || current.type === 'ternary_expression')
|
|
94
|
+
return true;
|
|
95
|
+
if (FUNCTION_NODE_TYPES.has(current.type))
|
|
96
|
+
return false;
|
|
97
|
+
current = current.parent;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
};
|
|
101
|
+
export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
102
|
+
const parser = await loadParser();
|
|
103
|
+
const collectedHeritage = [];
|
|
104
|
+
const logSkipped = isVerboseIngestionEnabled();
|
|
105
|
+
const skippedByLang = logSkipped ? new Map() : null;
|
|
106
|
+
for (let i = 0; i < files.length; i++) {
|
|
107
|
+
const file = files[i];
|
|
108
|
+
onProgress?.(i + 1, files.length);
|
|
109
|
+
if (i % 20 === 0)
|
|
110
|
+
await yieldToEventLoop();
|
|
111
|
+
const language = getLanguageFromFilename(file.path);
|
|
112
|
+
if (!language)
|
|
113
|
+
continue;
|
|
114
|
+
if (!isLanguageAvailable(language)) {
|
|
115
|
+
if (skippedByLang) {
|
|
116
|
+
skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const queryStr = LANGUAGE_QUERIES[language];
|
|
121
|
+
if (!queryStr)
|
|
122
|
+
continue;
|
|
123
|
+
await loadLanguage(language, file.path);
|
|
124
|
+
let tree = astCache.get(file.path);
|
|
125
|
+
if (!tree) {
|
|
126
|
+
try {
|
|
127
|
+
tree = parser.parse(file.content, undefined, { bufferSize: getTreeSitterBufferSize(file.content.length) });
|
|
128
|
+
}
|
|
129
|
+
catch (parseError) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
astCache.set(file.path, tree);
|
|
133
|
+
}
|
|
134
|
+
let query;
|
|
135
|
+
let matches;
|
|
136
|
+
try {
|
|
137
|
+
const language = parser.getLanguage();
|
|
138
|
+
query = new Parser.Query(language, queryStr);
|
|
139
|
+
matches = query.matches(tree.rootNode);
|
|
140
|
+
}
|
|
141
|
+
catch (queryError) {
|
|
142
|
+
console.warn(`Query error for ${file.path}:`, queryError);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const lang = getLanguageFromFilename(file.path);
|
|
146
|
+
const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null;
|
|
147
|
+
const callRouter = callRouters[language];
|
|
148
|
+
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
|
149
|
+
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
|
150
|
+
: new Map();
|
|
151
|
+
ctx.enableCache(file.path);
|
|
152
|
+
matches.forEach(match => {
|
|
153
|
+
const captureMap = {};
|
|
154
|
+
match.captures.forEach(c => captureMap[c.name] = c.node);
|
|
155
|
+
if (!captureMap['call'])
|
|
156
|
+
return;
|
|
157
|
+
const nameNode = captureMap['call.name'];
|
|
158
|
+
if (!nameNode)
|
|
159
|
+
return;
|
|
160
|
+
const calledName = nameNode.text;
|
|
161
|
+
const routed = callRouter(calledName, captureMap['call']);
|
|
162
|
+
if (routed) {
|
|
163
|
+
switch (routed.kind) {
|
|
164
|
+
case 'skip':
|
|
165
|
+
case 'import':
|
|
166
|
+
return;
|
|
167
|
+
case 'heritage':
|
|
168
|
+
for (const item of routed.items) {
|
|
169
|
+
collectedHeritage.push({
|
|
170
|
+
filePath: file.path,
|
|
171
|
+
className: item.enclosingClass,
|
|
172
|
+
parentName: item.mixinName,
|
|
173
|
+
kind: item.heritageKind,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
case 'properties': {
|
|
178
|
+
const fileId = generateId('File', file.path);
|
|
179
|
+
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
|
|
180
|
+
for (const item of routed.items) {
|
|
181
|
+
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
|
182
|
+
graph.addNode({
|
|
183
|
+
id: nodeId,
|
|
184
|
+
label: 'Property', // TODO: add 'Property' to graph node label union
|
|
185
|
+
properties: {
|
|
186
|
+
name: item.propName, filePath: file.path,
|
|
187
|
+
startLine: item.startLine, endLine: item.endLine,
|
|
188
|
+
language, isExported: true,
|
|
189
|
+
description: item.accessorType,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
|
|
193
|
+
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
|
194
|
+
graph.addRelationship({
|
|
195
|
+
id: relId, sourceId: fileId, targetId: nodeId,
|
|
196
|
+
type: 'DEFINES', confidence: 1.0, reason: '',
|
|
197
|
+
});
|
|
198
|
+
if (propEnclosingClassId) {
|
|
199
|
+
graph.addRelationship({
|
|
200
|
+
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
|
|
201
|
+
sourceId: propEnclosingClassId, targetId: nodeId,
|
|
202
|
+
type: 'HAS_METHOD', confidence: 1.0, reason: '',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
case 'call':
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// B3: Handle dynamic imports — `const { foo } = await import('./module.js')`
|
|
213
|
+
// Extract destructured names and create CALLS edges to the resolved module's exports
|
|
214
|
+
if (calledName === 'import') {
|
|
215
|
+
const callNode = captureMap['call'];
|
|
216
|
+
// Get the import path from the argument
|
|
217
|
+
let importPath = null;
|
|
218
|
+
for (const arg of (callNode?.children || [])) {
|
|
219
|
+
if (arg.type === 'arguments') {
|
|
220
|
+
const strArg = arg.children?.find((c) => c.type === 'string' || c.type === 'template_string');
|
|
221
|
+
if (strArg) {
|
|
222
|
+
importPath = strArg.text?.replace(/['"``]/g, '') || null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (importPath) {
|
|
227
|
+
// Find the variable_declarator parent to get destructured names
|
|
228
|
+
let parent = callNode.parent;
|
|
229
|
+
while (parent && parent.type !== 'variable_declarator' && parent.type !== 'lexical_declaration') {
|
|
230
|
+
parent = parent.parent;
|
|
231
|
+
}
|
|
232
|
+
if (parent?.type === 'variable_declarator' || parent?.type === 'lexical_declaration') {
|
|
233
|
+
const declarator = parent.type === 'variable_declarator' ? parent : parent.children?.find((c) => c.type === 'variable_declarator');
|
|
234
|
+
const pattern = declarator?.childForFieldName?.('name') || declarator?.children?.find((c) => c.type === 'object_pattern');
|
|
235
|
+
if (pattern?.type === 'object_pattern') {
|
|
236
|
+
// Extract destructured names: { rerank, mergeWithRRF }
|
|
237
|
+
for (const child of (pattern.children || [])) {
|
|
238
|
+
if (child.type === 'shorthand_property_identifier_pattern' || child.type === 'shorthand_property_identifier') {
|
|
239
|
+
const importedName = child.text;
|
|
240
|
+
if (importedName && !isBuiltInOrNoise(importedName)) {
|
|
241
|
+
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
242
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
243
|
+
// Try module-scoped resolution first: extract the target filename
|
|
244
|
+
// from the import path and look up the name in that file
|
|
245
|
+
let targetId = null;
|
|
246
|
+
let confidence = 0.5;
|
|
247
|
+
let reason = 'global';
|
|
248
|
+
// Extract filename hint from import path (e.g. '../../core/search/hybrid-search.js' → 'hybrid-search')
|
|
249
|
+
const pathBase = importPath.split('/').pop()?.replace(/\.\w+$/, '');
|
|
250
|
+
if (pathBase) {
|
|
251
|
+
// Try exact lookup in files whose path contains the base name
|
|
252
|
+
const candidates = ctx.symbols.lookupFuzzy(importedName);
|
|
253
|
+
const moduleMatch = candidates.find(c => c.filePath.includes(pathBase) && (c.type === 'Function' || c.type === 'Class' || c.type === 'Interface' || c.type === 'Method'));
|
|
254
|
+
if (moduleMatch) {
|
|
255
|
+
targetId = moduleMatch.nodeId;
|
|
256
|
+
confidence = 0.9;
|
|
257
|
+
reason = 'dynamic-import-resolved';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Fall back to general resolution
|
|
261
|
+
if (!targetId) {
|
|
262
|
+
const resolved = ctx.resolve(importedName, file.path);
|
|
263
|
+
if (resolved && resolved.candidates.length > 0) {
|
|
264
|
+
targetId = resolved.candidates[0].nodeId;
|
|
265
|
+
confidence = Math.max(TIER_CONFIDENCE[resolved.tier] ?? 0.5, 0.8);
|
|
266
|
+
reason = `dynamic-import:${resolved.tier}`;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (targetId) {
|
|
270
|
+
graph.addRelationship({
|
|
271
|
+
id: generateId('CALLS', `${sourceId}:dyn_import:${importedName}->${targetId}`),
|
|
272
|
+
sourceId,
|
|
273
|
+
targetId,
|
|
274
|
+
type: 'CALLS',
|
|
275
|
+
confidence,
|
|
276
|
+
reason,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return; // don't process 'import' as a regular call
|
|
286
|
+
}
|
|
287
|
+
if (isBuiltInOrNoise(calledName))
|
|
288
|
+
return;
|
|
289
|
+
const callNode = captureMap['call'];
|
|
290
|
+
const callForm = inferCallForm(callNode, nameNode);
|
|
291
|
+
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
|
292
|
+
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
|
293
|
+
// Fall back to verified constructor bindings
|
|
294
|
+
if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) {
|
|
295
|
+
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
296
|
+
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
297
|
+
receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName);
|
|
298
|
+
}
|
|
299
|
+
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user())
|
|
300
|
+
// when receiver name resolves to a Class/Struct/Interface via tiered resolution
|
|
301
|
+
if (!receiverTypeName && receiverName && callForm === 'member') {
|
|
302
|
+
const typeResolved = ctx.resolve(receiverName, file.path);
|
|
303
|
+
if (typeResolved && typeResolved.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
304
|
+
receiverTypeName = receiverName;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Fall back to chained call resolution when receiver is a call expression
|
|
308
|
+
// (e.g. svc.getUser().save() -- receiver of save() is getUser())
|
|
309
|
+
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
|
310
|
+
const receiverNode = extractReceiverNode(nameNode);
|
|
311
|
+
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
|
|
312
|
+
const extracted = extractCallChain(receiverNode);
|
|
313
|
+
if (extracted) {
|
|
314
|
+
// Resolve the base receiver type
|
|
315
|
+
let baseType = extracted.baseReceiverName && typeEnv
|
|
316
|
+
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
|
317
|
+
: undefined;
|
|
318
|
+
if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
|
|
319
|
+
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
320
|
+
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
321
|
+
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
|
|
322
|
+
}
|
|
323
|
+
// Class-as-receiver for chain base
|
|
324
|
+
if (!baseType && extracted.baseReceiverName) {
|
|
325
|
+
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
|
326
|
+
if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
327
|
+
baseType = extracted.baseReceiverName;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Fix 2: Skip generic method names (has, get, set, etc.) when called on unknown receiver
|
|
335
|
+
// These almost always resolve to wrong targets (Map.has -> type-env.has) with low confidence
|
|
336
|
+
const GENERIC_MEMBER_METHODS = new Set(['has', 'get', 'set', 'add', 'remove', 'delete', 'close', 'stop', 'clear', 'reset', 'toString', 'valueOf', 'push', 'pop', 'entries', 'keys', 'values']);
|
|
337
|
+
if (callForm === 'member' && !receiverTypeName && GENERIC_MEMBER_METHODS.has(calledName))
|
|
338
|
+
return;
|
|
339
|
+
const resolved = resolveCallTarget({
|
|
340
|
+
calledName,
|
|
341
|
+
argCount: countCallArguments(callNode),
|
|
342
|
+
callForm,
|
|
343
|
+
receiverTypeName,
|
|
344
|
+
}, file.path, ctx);
|
|
345
|
+
if (!resolved)
|
|
346
|
+
return;
|
|
347
|
+
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
348
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
349
|
+
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
|
350
|
+
const callContext = isInsideCatch(callNode) ? 'error-handler' : isInsideConditional(callNode) ? 'conditional' : '';
|
|
351
|
+
graph.addRelationship({
|
|
352
|
+
id: relId,
|
|
353
|
+
sourceId,
|
|
354
|
+
targetId: resolved.nodeId,
|
|
355
|
+
type: 'CALLS',
|
|
356
|
+
confidence: resolved.confidence,
|
|
357
|
+
reason: callContext || resolved.reason,
|
|
358
|
+
callLine: callNode.startPosition?.row != null ? callNode.startPosition.row + 1 : undefined,
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
// Fix 9: Scan for dynamic imports that tree-sitter queries can't capture
|
|
362
|
+
// Pattern: const { foo, bar } = await import('./module.js')
|
|
363
|
+
// The tree-sitter query `(call_expression function: (import) @call.name) @call`
|
|
364
|
+
// doesn't reliably capture import() because (import) has no text as an identifier.
|
|
365
|
+
// This post-processing walks the AST directly to find these patterns.
|
|
366
|
+
try {
|
|
367
|
+
const visitNode = (node) => {
|
|
368
|
+
if (node.type === 'call_expression') {
|
|
369
|
+
const funcChild = node.childForFieldName?.('function');
|
|
370
|
+
if (funcChild?.type === 'import') {
|
|
371
|
+
// Found a dynamic import
|
|
372
|
+
const args = node.childForFieldName?.('arguments');
|
|
373
|
+
const strArg = args?.children?.find((c) => c.type === 'string' || c.type === 'template_string');
|
|
374
|
+
const importPath = strArg?.text?.replace(/['"``]/g, '') || null;
|
|
375
|
+
if (importPath) {
|
|
376
|
+
// Walk up to find variable_declarator with object_pattern
|
|
377
|
+
let parent = node.parent;
|
|
378
|
+
// Skip await_expression wrapper
|
|
379
|
+
if (parent?.type === 'await_expression')
|
|
380
|
+
parent = parent.parent;
|
|
381
|
+
// Should be in a variable_declarator
|
|
382
|
+
if (parent?.type === 'variable_declarator') {
|
|
383
|
+
const pattern = parent.childForFieldName?.('name');
|
|
384
|
+
if (pattern?.type === 'object_pattern') {
|
|
385
|
+
for (const child of (pattern.children || [])) {
|
|
386
|
+
if (child.type === 'shorthand_property_identifier_pattern' || child.type === 'shorthand_property_identifier') {
|
|
387
|
+
const importedName = child.text;
|
|
388
|
+
if (importedName && !isBuiltInOrNoise(importedName)) {
|
|
389
|
+
const enclosingFuncId = findEnclosingFunction(node, file.path, ctx);
|
|
390
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
391
|
+
const resolved = ctx.resolve(importedName, file.path);
|
|
392
|
+
if (resolved && resolved.candidates.length > 0) {
|
|
393
|
+
graph.addRelationship({
|
|
394
|
+
id: generateId('CALLS', `${sourceId}:dyn:${importedName}->${resolved.candidates[0].nodeId}`),
|
|
395
|
+
sourceId,
|
|
396
|
+
targetId: resolved.candidates[0].nodeId,
|
|
397
|
+
type: 'CALLS',
|
|
398
|
+
confidence: 0.85,
|
|
399
|
+
reason: 'dynamic-import',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
// Recurse into children
|
|
411
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
412
|
+
visitNode(node.child(i));
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
visitNode(tree.rootNode);
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
// Non-fatal — dynamic import scanning is best-effort
|
|
419
|
+
}
|
|
420
|
+
ctx.clearCache();
|
|
421
|
+
}
|
|
422
|
+
if (skippedByLang && skippedByLang.size > 0) {
|
|
423
|
+
for (const [lang, count] of skippedByLang.entries()) {
|
|
424
|
+
console.warn(`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return collectedHeritage;
|
|
428
|
+
};
|
|
429
|
+
const CALLABLE_SYMBOL_TYPES = new Set([
|
|
430
|
+
'Function',
|
|
431
|
+
'Method',
|
|
432
|
+
'Constructor',
|
|
433
|
+
'Macro',
|
|
434
|
+
'Delegate',
|
|
435
|
+
]);
|
|
436
|
+
const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']);
|
|
437
|
+
const filterCallableCandidates = (candidates, argCount, callForm) => {
|
|
438
|
+
let kindFiltered;
|
|
439
|
+
if (callForm === 'constructor') {
|
|
440
|
+
const constructors = candidates.filter(c => c.type === 'Constructor');
|
|
441
|
+
if (constructors.length > 0) {
|
|
442
|
+
kindFiltered = constructors;
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
const types = candidates.filter(c => CONSTRUCTOR_TARGET_TYPES.has(c.type));
|
|
446
|
+
kindFiltered = types.length > 0 ? types : candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
kindFiltered = candidates.filter(c => CALLABLE_SYMBOL_TYPES.has(c.type));
|
|
451
|
+
}
|
|
452
|
+
if (kindFiltered.length === 0)
|
|
453
|
+
return [];
|
|
454
|
+
if (argCount === undefined)
|
|
455
|
+
return kindFiltered;
|
|
456
|
+
const hasParameterMetadata = kindFiltered.some(candidate => candidate.parameterCount !== undefined);
|
|
457
|
+
if (!hasParameterMetadata)
|
|
458
|
+
return kindFiltered;
|
|
459
|
+
return kindFiltered.filter(candidate => candidate.parameterCount === undefined || candidate.parameterCount === argCount);
|
|
460
|
+
};
|
|
461
|
+
const toResolveResult = (definition, tier) => ({
|
|
462
|
+
nodeId: definition.nodeId,
|
|
463
|
+
confidence: TIER_CONFIDENCE[tier],
|
|
464
|
+
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
|
465
|
+
});
|
|
466
|
+
/**
|
|
467
|
+
* Resolve a chain of intermediate method calls to find the receiver type
|
|
468
|
+
* for a final member call (e.g. svc.getUser().save())
|
|
469
|
+
*
|
|
470
|
+
* @param chainNames - Ordered method names from outermost to innermost
|
|
471
|
+
* @param baseReceiverTypeName - Already-resolved type of the base receiver, or undefined
|
|
472
|
+
* @param currentFile - File path for resolution context
|
|
473
|
+
* @param ctx - Resolution context for symbol lookup
|
|
474
|
+
* @returns Final intermediate call's return type name, or undefined if resolution fails
|
|
475
|
+
*/
|
|
476
|
+
function resolveChainedReceiver(chainNames, baseReceiverTypeName, currentFile, ctx) {
|
|
477
|
+
let currentType = baseReceiverTypeName;
|
|
478
|
+
for (const name of chainNames) {
|
|
479
|
+
const resolved = resolveCallTarget({ calledName: name, callForm: 'member', receiverTypeName: currentType }, currentFile, ctx);
|
|
480
|
+
if (!resolved)
|
|
481
|
+
return undefined;
|
|
482
|
+
const candidates = ctx.symbols.lookupFuzzy(name);
|
|
483
|
+
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
|
|
484
|
+
if (!symDef?.returnType)
|
|
485
|
+
return undefined;
|
|
486
|
+
const returnTypeName = extractReturnTypeName(symDef.returnType);
|
|
487
|
+
if (!returnTypeName)
|
|
488
|
+
return undefined;
|
|
489
|
+
currentType = returnTypeName;
|
|
490
|
+
}
|
|
491
|
+
return currentType;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Resolve a function call to its target node ID
|
|
495
|
+
*
|
|
496
|
+
* Strategy: A) narrow by scope tier, B) filter callable kinds, C) arity filter,
|
|
497
|
+
* D) receiver-type filter. Refuses to emit CALLS edge if multiple candidates remain
|
|
498
|
+
*/
|
|
499
|
+
const resolveCallTarget = (call, currentFile, ctx) => {
|
|
500
|
+
const tiered = ctx.resolve(call.calledName, currentFile);
|
|
501
|
+
if (!tiered)
|
|
502
|
+
return null;
|
|
503
|
+
const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
|
504
|
+
// D. Receiver-type filtering: resolve type via tiered imports, filter method
|
|
505
|
+
// candidates to the type's defining file. Applied regardless of candidate count
|
|
506
|
+
// since the sole same-file candidate may belong to the wrong class
|
|
507
|
+
if (call.callForm === 'member' && call.receiverTypeName) {
|
|
508
|
+
// D1. Resolve receiver type
|
|
509
|
+
const typeResolved = ctx.resolve(call.receiverTypeName, currentFile);
|
|
510
|
+
if (typeResolved && typeResolved.candidates.length > 0) {
|
|
511
|
+
const typeNodeIds = new Set(typeResolved.candidates.map(d => d.nodeId));
|
|
512
|
+
const typeFiles = new Set(typeResolved.candidates.map(d => d.filePath));
|
|
513
|
+
// D2. Widen candidates: same-file tier may miss parent's method in another file
|
|
514
|
+
const methodPool = filteredCandidates.length <= 1
|
|
515
|
+
? filterCallableCandidates(ctx.symbols.lookupFuzzy(call.calledName), call.argCount, call.callForm)
|
|
516
|
+
: filteredCandidates;
|
|
517
|
+
// D3. File-based: prefer candidates in the resolved type's file
|
|
518
|
+
const fileFiltered = methodPool.filter(c => typeFiles.has(c.filePath));
|
|
519
|
+
if (fileFiltered.length === 1) {
|
|
520
|
+
return toResolveResult(fileFiltered[0], tiered.tier);
|
|
521
|
+
}
|
|
522
|
+
// D4. ownerId fallback: narrow by ownerId matching type's nodeId
|
|
523
|
+
const pool = fileFiltered.length > 0 ? fileFiltered : methodPool;
|
|
524
|
+
const ownerFiltered = pool.filter(c => c.ownerId && typeNodeIds.has(c.ownerId));
|
|
525
|
+
if (ownerFiltered.length === 1) {
|
|
526
|
+
return toResolveResult(ownerFiltered[0], tiered.tier);
|
|
527
|
+
}
|
|
528
|
+
if (fileFiltered.length > 1 || ownerFiltered.length > 1)
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (filteredCandidates.length === 0)
|
|
533
|
+
return null;
|
|
534
|
+
// Deduplicate by nodeId — the same symbol can appear multiple times from different resolution paths
|
|
535
|
+
if (filteredCandidates.length > 1) {
|
|
536
|
+
const uniqueById = new Map(filteredCandidates.map(c => [c.nodeId, c]));
|
|
537
|
+
if (uniqueById.size === 1) {
|
|
538
|
+
return toResolveResult(filteredCandidates[0], tiered.tier);
|
|
539
|
+
}
|
|
540
|
+
// Multiple distinct candidates — ambiguous, skip
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
return toResolveResult(filteredCandidates[0], tiered.tier);
|
|
544
|
+
};
|
|
545
|
+
// Return type text helpers
|
|
546
|
+
// Operates on raw return-type text in SymbolDefinition (e.g. "User", "Promise<User>",
|
|
547
|
+
// "User | null", "*User") to extract the base user-defined type name
|
|
548
|
+
// Primitive/built-in types that should NOT produce a receiver binding
|
|
549
|
+
const PRIMITIVE_TYPES = new Set([
|
|
550
|
+
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
|
|
551
|
+
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
|
|
552
|
+
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
|
|
553
|
+
'undefined', 'null', 'None', 'nil',
|
|
554
|
+
]);
|
|
555
|
+
/**
|
|
556
|
+
* Extract a simple type name from raw return-type text
|
|
557
|
+
*
|
|
558
|
+
* Handles: "User", "Promise<User>", "Option<User>", "Result<User, Error>",
|
|
559
|
+
* "User | null", "User?", "*User" (Go pointer), "&User" (Rust reference)
|
|
560
|
+
*
|
|
561
|
+
* @returns The base type name, or undefined for complex types or primitives
|
|
562
|
+
*/
|
|
563
|
+
// Wrapper generics that get unwrapped to their inner type argument
|
|
564
|
+
// Containers (List, Vec, Set, etc.) are excluded -- methods are called on the container
|
|
565
|
+
const WRAPPER_GENERICS = new Set([
|
|
566
|
+
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask',
|
|
567
|
+
'Option', 'Some', 'Optional', 'Maybe',
|
|
568
|
+
'Result', 'Either',
|
|
569
|
+
'Rc', 'Arc', 'Weak',
|
|
570
|
+
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard',
|
|
571
|
+
'Ref', 'RefMut',
|
|
572
|
+
'Cow',
|
|
573
|
+
]);
|
|
574
|
+
/** Extract the first type argument from a generic string, respecting nested angle brackets */
|
|
575
|
+
function extractFirstGenericArg(args) {
|
|
576
|
+
let depth = 0;
|
|
577
|
+
for (let i = 0; i < args.length; i++) {
|
|
578
|
+
if (args[i] === '<')
|
|
579
|
+
depth++;
|
|
580
|
+
else if (args[i] === '>')
|
|
581
|
+
depth--;
|
|
582
|
+
else if (args[i] === ',' && depth === 0)
|
|
583
|
+
return args.slice(0, i).trim();
|
|
584
|
+
}
|
|
585
|
+
return args.trim();
|
|
586
|
+
}
|
|
587
|
+
/** Extract the first non-lifetime type argument, skipping Rust lifetime params ('a, '_) */
|
|
588
|
+
function extractFirstTypeArg(args) {
|
|
589
|
+
let remaining = args;
|
|
590
|
+
while (remaining) {
|
|
591
|
+
const first = extractFirstGenericArg(remaining);
|
|
592
|
+
if (!first.startsWith("'"))
|
|
593
|
+
return first;
|
|
594
|
+
// Skip past this lifetime arg + comma separator
|
|
595
|
+
const commaIdx = remaining.indexOf(',', first.length);
|
|
596
|
+
if (commaIdx < 0)
|
|
597
|
+
return first; // only lifetimes, fall through
|
|
598
|
+
remaining = remaining.slice(commaIdx + 1).trim();
|
|
599
|
+
}
|
|
600
|
+
return args.trim();
|
|
601
|
+
}
|
|
602
|
+
export const extractReturnTypeName = (raw, depth = 0) => {
|
|
603
|
+
if (depth > 10)
|
|
604
|
+
return undefined;
|
|
605
|
+
let text = raw.trim();
|
|
606
|
+
if (!text)
|
|
607
|
+
return undefined;
|
|
608
|
+
// Strip pointer/reference prefixes (*User, &User, &mut User)
|
|
609
|
+
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
|
|
610
|
+
// Strip nullable suffix (User?)
|
|
611
|
+
text = text.replace(/\?$/, '');
|
|
612
|
+
// Handle union types ("User | null" -> "User")
|
|
613
|
+
if (text.includes('|')) {
|
|
614
|
+
const parts = text.split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil');
|
|
615
|
+
if (parts.length === 1)
|
|
616
|
+
text = parts[0];
|
|
617
|
+
else
|
|
618
|
+
return undefined; // genuine union, too complex
|
|
619
|
+
}
|
|
620
|
+
// Handle generics (Promise<User> -> unwrap if wrapper, else take base)
|
|
621
|
+
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
|
|
622
|
+
if (genericMatch) {
|
|
623
|
+
const [, base, args] = genericMatch;
|
|
624
|
+
if (WRAPPER_GENERICS.has(base)) {
|
|
625
|
+
// Take the first non-lifetime type argument using bracket-balanced splitting
|
|
626
|
+
const firstArg = extractFirstTypeArg(args);
|
|
627
|
+
return extractReturnTypeName(firstArg, depth + 1);
|
|
628
|
+
}
|
|
629
|
+
// Non-wrapper generic: return the base type (e.g. Map<K,V> -> Map)
|
|
630
|
+
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
|
|
631
|
+
}
|
|
632
|
+
// Bare wrapper type without generic argument is meaningless
|
|
633
|
+
if (WRAPPER_GENERICS.has(text))
|
|
634
|
+
return undefined;
|
|
635
|
+
// Handle qualified names (models.User -> User, Models::User -> User)
|
|
636
|
+
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
|
|
637
|
+
text = text.split(/::|[.\\]/).pop();
|
|
638
|
+
}
|
|
639
|
+
// Skip primitives
|
|
640
|
+
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase()))
|
|
641
|
+
return undefined;
|
|
642
|
+
// Must start with uppercase (class/type convention)
|
|
643
|
+
if (!/^[A-Z_]\w*$/.test(text))
|
|
644
|
+
return undefined;
|
|
645
|
+
return text;
|
|
646
|
+
};
|
|
647
|
+
// Scope key helpers
|
|
648
|
+
// Scope keys: "funcName@startIndex" (type-env.ts)
|
|
649
|
+
// Source IDs: "Label:filepath:funcName" (parse-worker.ts)
|
|
650
|
+
// NUL (\0) separates composite keys to prevent ambiguous concatenation
|
|
651
|
+
// receiverKey uses full scope to distinguish overloaded methods (e.g. User.save@100 vs Repo.save@200)
|
|
652
|
+
/** Extract function name from a scope key ("funcName@startIndex" -> "funcName") */
|
|
653
|
+
const extractFuncNameFromScope = (scope) => scope.slice(0, scope.indexOf('@'));
|
|
654
|
+
/** Extract trailing function name from a sourceId ("Function:filepath:funcName" -> "funcName") */
|
|
655
|
+
const extractFuncNameFromSourceId = (sourceId) => {
|
|
656
|
+
const lastColon = sourceId.lastIndexOf(':');
|
|
657
|
+
return lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
|
|
658
|
+
};
|
|
659
|
+
/** Build a composite key for receiver type storage using full scope string */
|
|
660
|
+
const receiverKey = (scope, varName) => `${scope}\0${varName}`;
|
|
661
|
+
/**
|
|
662
|
+
* Look up receiver type from a verified receiver map keyed by scope\0varName
|
|
663
|
+
*
|
|
664
|
+
* Scans entries matching funcName@ prefix with matching varName. Returns the
|
|
665
|
+
* type if exactly one unique match exists, undefined if ambiguous. Falls back
|
|
666
|
+
* to file-level scope (\0varName)
|
|
667
|
+
*/
|
|
668
|
+
const lookupReceiverType = (map, funcName, varName) => {
|
|
669
|
+
// Fast path: file-level scope (empty funcName, used as fallback)
|
|
670
|
+
const fileLevelKey = receiverKey('', varName);
|
|
671
|
+
const prefix = `${funcName}@`;
|
|
672
|
+
const suffix = `\0${varName}`;
|
|
673
|
+
let found;
|
|
674
|
+
let ambiguous = false;
|
|
675
|
+
for (const [key, value] of map) {
|
|
676
|
+
if (key === fileLevelKey)
|
|
677
|
+
continue; // handled separately below
|
|
678
|
+
if (key.startsWith(prefix) && key.endsWith(suffix)) {
|
|
679
|
+
// Verify key is "funcName@<startIndex>\0varName" with non-empty middle segment
|
|
680
|
+
const middle = key.slice(prefix.length, key.length - suffix.length);
|
|
681
|
+
if (middle.length === 0)
|
|
682
|
+
continue; // malformed key, skip
|
|
683
|
+
if (found === undefined) {
|
|
684
|
+
found = value;
|
|
685
|
+
}
|
|
686
|
+
else if (found !== value) {
|
|
687
|
+
ambiguous = true;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (!ambiguous && found !== undefined)
|
|
693
|
+
return found;
|
|
694
|
+
// Fallback: file-level scope
|
|
695
|
+
return map.get(fileLevelKey);
|
|
696
|
+
};
|
|
697
|
+
/** Resolve pre-extracted call sites from workers (no AST parsing needed) */
|
|
698
|
+
export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onProgress, constructorBindings) => {
|
|
699
|
+
// Scope-aware receiver types keyed by filePath -> scope\0varName -> typeName
|
|
700
|
+
const fileReceiverTypes = new Map();
|
|
701
|
+
if (constructorBindings) {
|
|
702
|
+
for (const { filePath, bindings } of constructorBindings) {
|
|
703
|
+
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
704
|
+
if (verified.size > 0) {
|
|
705
|
+
fileReceiverTypes.set(filePath, verified);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const byFile = new Map();
|
|
710
|
+
for (const call of extractedCalls) {
|
|
711
|
+
let list = byFile.get(call.filePath);
|
|
712
|
+
if (!list) {
|
|
713
|
+
list = [];
|
|
714
|
+
byFile.set(call.filePath, list);
|
|
715
|
+
}
|
|
716
|
+
list.push(call);
|
|
717
|
+
}
|
|
718
|
+
const totalFiles = byFile.size;
|
|
719
|
+
let filesProcessed = 0;
|
|
720
|
+
for (const [filePath, calls] of byFile) {
|
|
721
|
+
filesProcessed++;
|
|
722
|
+
if (filesProcessed % 100 === 0) {
|
|
723
|
+
onProgress?.(filesProcessed, totalFiles);
|
|
724
|
+
await yieldToEventLoop();
|
|
725
|
+
}
|
|
726
|
+
ctx.enableCache(filePath);
|
|
727
|
+
const receiverMap = fileReceiverTypes.get(filePath);
|
|
728
|
+
for (const call of calls) {
|
|
729
|
+
let effectiveCall = call;
|
|
730
|
+
// Step 1: resolve receiver type from constructor bindings
|
|
731
|
+
if (!call.receiverTypeName && call.receiverName && receiverMap) {
|
|
732
|
+
const callFuncName = extractFuncNameFromSourceId(call.sourceId);
|
|
733
|
+
const resolvedType = lookupReceiverType(receiverMap, callFuncName, call.receiverName);
|
|
734
|
+
if (resolvedType) {
|
|
735
|
+
effectiveCall = { ...call, receiverTypeName: resolvedType };
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// Step 1b: class-as-receiver for static method calls
|
|
739
|
+
if (!effectiveCall.receiverTypeName && effectiveCall.receiverName && effectiveCall.callForm === 'member') {
|
|
740
|
+
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
|
741
|
+
if (typeResolved && typeResolved.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
742
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// Step 2: resolve receiver call chain (e.g. svc.getUser().save())
|
|
746
|
+
// Runs even when Step 1 set a receiverTypeName -- that's the BASE receiver,
|
|
747
|
+
// the chain resolves the FINAL receiver type
|
|
748
|
+
if (effectiveCall.receiverCallChain?.length) {
|
|
749
|
+
// Use Step 1's base receiver type as starting point for chain resolution
|
|
750
|
+
let baseType = effectiveCall.receiverTypeName;
|
|
751
|
+
// If Step 1 didn't resolve it, try the receiver map directly
|
|
752
|
+
if (!baseType && effectiveCall.receiverName && receiverMap) {
|
|
753
|
+
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
|
754
|
+
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
|
755
|
+
}
|
|
756
|
+
const chainedType = resolveChainedReceiver(effectiveCall.receiverCallChain, baseType, effectiveCall.filePath, ctx);
|
|
757
|
+
if (chainedType) {
|
|
758
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
|
|
762
|
+
if (!resolved)
|
|
763
|
+
continue;
|
|
764
|
+
const relId = generateId('CALLS', `${effectiveCall.sourceId}:${effectiveCall.calledName}->${resolved.nodeId}`);
|
|
765
|
+
graph.addRelationship({
|
|
766
|
+
id: relId,
|
|
767
|
+
sourceId: effectiveCall.sourceId,
|
|
768
|
+
targetId: resolved.nodeId,
|
|
769
|
+
type: 'CALLS',
|
|
770
|
+
confidence: resolved.confidence,
|
|
771
|
+
reason: resolved.reason,
|
|
772
|
+
callLine: effectiveCall.callLine,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
ctx.clearCache();
|
|
776
|
+
}
|
|
777
|
+
onProgress?.(totalFiles, totalFiles);
|
|
778
|
+
};
|
|
779
|
+
/** Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods */
|
|
780
|
+
export const processRoutesFromExtracted = async (graph, extractedRoutes, ctx, onProgress) => {
|
|
781
|
+
for (let i = 0; i < extractedRoutes.length; i++) {
|
|
782
|
+
const route = extractedRoutes[i];
|
|
783
|
+
if (i % 50 === 0) {
|
|
784
|
+
onProgress?.(i, extractedRoutes.length);
|
|
785
|
+
await yieldToEventLoop();
|
|
786
|
+
}
|
|
787
|
+
if (!route.controllerName || !route.methodName)
|
|
788
|
+
continue;
|
|
789
|
+
const controllerResolved = ctx.resolve(route.controllerName, route.filePath);
|
|
790
|
+
if (!controllerResolved || controllerResolved.candidates.length === 0)
|
|
791
|
+
continue;
|
|
792
|
+
if (controllerResolved.tier === 'global' && controllerResolved.candidates.length > 1)
|
|
793
|
+
continue;
|
|
794
|
+
const controllerDef = controllerResolved.candidates[0];
|
|
795
|
+
const confidence = TIER_CONFIDENCE[controllerResolved.tier];
|
|
796
|
+
const methodResolved = ctx.resolve(route.methodName, controllerDef.filePath);
|
|
797
|
+
const methodId = methodResolved?.tier === 'same-file' ? methodResolved.candidates[0]?.nodeId : undefined;
|
|
798
|
+
const sourceId = generateId('File', route.filePath);
|
|
799
|
+
if (!methodId) {
|
|
800
|
+
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
|
|
801
|
+
const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
|
|
802
|
+
graph.addRelationship({
|
|
803
|
+
id: relId,
|
|
804
|
+
sourceId,
|
|
805
|
+
targetId: guessedId,
|
|
806
|
+
type: 'CALLS',
|
|
807
|
+
confidence: confidence * 0.8,
|
|
808
|
+
reason: 'laravel-route',
|
|
809
|
+
});
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
|
|
813
|
+
graph.addRelationship({
|
|
814
|
+
id: relId,
|
|
815
|
+
sourceId,
|
|
816
|
+
targetId: methodId,
|
|
817
|
+
type: 'CALLS',
|
|
818
|
+
confidence,
|
|
819
|
+
reason: 'laravel-route',
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
onProgress?.(extractedRoutes.length, extractedRoutes.length);
|
|
823
|
+
};
|
|
824
|
+
// ── DI Awareness: DEPENDS_ON + PROVIDES edge creation ───────────────────
|
|
825
|
+
/** Symbol types that represent injectable dependencies */
|
|
826
|
+
const INJECTABLE_TYPES = new Set(['Class', 'Interface', 'Trait', 'Struct']);
|
|
827
|
+
/** Method names that act as constructors across languages */
|
|
828
|
+
const CONSTRUCTOR_METHOD_NAMES = new Set(['__init__', 'initialize', 'constructor']);
|
|
829
|
+
/**
|
|
830
|
+
* Create DEPENDS_ON edges from constructor parameter types.
|
|
831
|
+
*
|
|
832
|
+
* For each Constructor (or constructor-like method like Python __init__),
|
|
833
|
+
* resolves its parameterTypes and creates DEPENDS_ON edges from the owning
|
|
834
|
+
* class to each resolved Class/Interface/Trait/Struct parameter type.
|
|
835
|
+
*
|
|
836
|
+
* This bridges DI: BookingService(IPaymentGateway) → DEPENDS_ON → IPaymentGateway
|
|
837
|
+
*/
|
|
838
|
+
export const createDependsOnEdges = async (graph, ctx) => {
|
|
839
|
+
let edgesCreated = 0;
|
|
840
|
+
const seen = new Set(); // deduplicate: "ownerClassId->targetId"
|
|
841
|
+
// Collect Constructor nodes + constructor-like methods (__init__, initialize)
|
|
842
|
+
const candidates = [];
|
|
843
|
+
graph.forEachNode(n => {
|
|
844
|
+
if (n.label === 'Constructor') {
|
|
845
|
+
candidates.push({ nodeId: n.id, filePath: n.properties.filePath, name: n.properties.name });
|
|
846
|
+
}
|
|
847
|
+
else if ((n.label === 'Method' || n.label === 'Function') && CONSTRUCTOR_METHOD_NAMES.has(n.properties.name)) {
|
|
848
|
+
candidates.push({ nodeId: n.id, filePath: n.properties.filePath, name: n.properties.name });
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
for (const ctor of candidates) {
|
|
852
|
+
const symDef = ctx.symbols.lookupExactFull(ctor.filePath, ctor.name);
|
|
853
|
+
if (!symDef?.parameterTypes?.length || !symDef.ownerId)
|
|
854
|
+
continue;
|
|
855
|
+
for (const paramType of symDef.parameterTypes) {
|
|
856
|
+
const resolved = ctx.resolve(paramType, ctor.filePath);
|
|
857
|
+
if (!resolved || resolved.candidates.length === 0)
|
|
858
|
+
continue;
|
|
859
|
+
// Find first injectable candidate
|
|
860
|
+
const target = resolved.candidates.find(c => INJECTABLE_TYPES.has(c.type));
|
|
861
|
+
if (!target)
|
|
862
|
+
continue;
|
|
863
|
+
// Skip self-referential edges
|
|
864
|
+
if (target.nodeId === symDef.ownerId)
|
|
865
|
+
continue;
|
|
866
|
+
const edgeKey = `${symDef.ownerId}->${target.nodeId}`;
|
|
867
|
+
if (seen.has(edgeKey))
|
|
868
|
+
continue;
|
|
869
|
+
seen.add(edgeKey);
|
|
870
|
+
graph.addRelationship({
|
|
871
|
+
id: generateId('DEPENDS_ON', edgeKey),
|
|
872
|
+
sourceId: symDef.ownerId,
|
|
873
|
+
targetId: target.nodeId,
|
|
874
|
+
type: 'DEPENDS_ON',
|
|
875
|
+
confidence: TIER_CONFIDENCE[resolved.tier],
|
|
876
|
+
reason: 'constructor-injection',
|
|
877
|
+
});
|
|
878
|
+
edgesCreated++;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return edgesCreated;
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Create PROVIDES edges from factory method return types.
|
|
885
|
+
*
|
|
886
|
+
* For methods/functions whose return type resolves to an Interface or Trait,
|
|
887
|
+
* creates a PROVIDES edge from the method to the interface. Heuristic filter:
|
|
888
|
+
* only short methods (< 20 lines) OR methods with DI-related framework annotations.
|
|
889
|
+
*
|
|
890
|
+
* This captures @Bean factories, provider methods, and factory functions.
|
|
891
|
+
*/
|
|
892
|
+
export const createProvidesEdges = async (graph, ctx) => {
|
|
893
|
+
const PROVIDER_TYPES = new Set(['Interface', 'Trait']);
|
|
894
|
+
let edgesCreated = 0;
|
|
895
|
+
const candidates = [];
|
|
896
|
+
graph.forEachNode(n => {
|
|
897
|
+
if ((n.label === 'Method' || n.label === 'Function') && n.properties.returnType) {
|
|
898
|
+
candidates.push({
|
|
899
|
+
id: n.id,
|
|
900
|
+
filePath: n.properties.filePath,
|
|
901
|
+
returnType: n.properties.returnType,
|
|
902
|
+
startLine: n.properties.startLine ?? 0,
|
|
903
|
+
endLine: n.properties.endLine ?? 0,
|
|
904
|
+
frameworkReason: n.properties.astFrameworkReason,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
for (const node of candidates) {
|
|
909
|
+
const returnTypeName = extractReturnTypeName(node.returnType);
|
|
910
|
+
if (!returnTypeName)
|
|
911
|
+
continue;
|
|
912
|
+
const resolved = ctx.resolve(returnTypeName, node.filePath);
|
|
913
|
+
if (!resolved || resolved.candidates.length === 0)
|
|
914
|
+
continue;
|
|
915
|
+
const interfaceTarget = resolved.candidates.find(c => PROVIDER_TYPES.has(c.type));
|
|
916
|
+
if (!interfaceTarget)
|
|
917
|
+
continue;
|
|
918
|
+
// Heuristic: only short methods (likely factories) or DI-annotated methods
|
|
919
|
+
const bodyLength = node.endLine - node.startLine;
|
|
920
|
+
const isDIAnnotated = node.frameworkReason && (node.frameworkReason.includes('bean') ||
|
|
921
|
+
node.frameworkReason.includes('provider') ||
|
|
922
|
+
node.frameworkReason.includes('provides') ||
|
|
923
|
+
node.frameworkReason.includes('di'));
|
|
924
|
+
if (bodyLength > 20 && !isDIAnnotated)
|
|
925
|
+
continue;
|
|
926
|
+
graph.addRelationship({
|
|
927
|
+
id: generateId('PROVIDES', `${node.id}->${interfaceTarget.nodeId}`),
|
|
928
|
+
sourceId: node.id,
|
|
929
|
+
targetId: interfaceTarget.nodeId,
|
|
930
|
+
type: 'PROVIDES',
|
|
931
|
+
confidence: TIER_CONFIDENCE[resolved.tier] * 0.9,
|
|
932
|
+
reason: 'factory-return-type',
|
|
933
|
+
});
|
|
934
|
+
edgesCreated++;
|
|
935
|
+
}
|
|
936
|
+
return edgesCreated;
|
|
937
|
+
};
|