gitnexus 1.4.6 → 1.4.8
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 +22 -1
- package/dist/cli/ai-context.d.ts +1 -1
- package/dist/cli/ai-context.js +1 -1
- package/dist/cli/analyze.d.ts +2 -0
- package/dist/cli/analyze.js +54 -21
- package/dist/cli/index.js +2 -1
- package/dist/cli/setup.js +78 -1
- package/dist/config/supported-languages.d.ts +30 -0
- package/dist/config/supported-languages.js +30 -0
- package/dist/core/embeddings/embedder.d.ts +6 -1
- package/dist/core/embeddings/embedder.js +65 -5
- package/dist/core/embeddings/embedding-pipeline.js +11 -9
- package/dist/core/embeddings/http-client.d.ts +31 -0
- package/dist/core/embeddings/http-client.js +179 -0
- package/dist/core/embeddings/index.d.ts +1 -0
- package/dist/core/embeddings/index.js +1 -0
- package/dist/core/embeddings/types.d.ts +1 -1
- package/dist/core/graph/types.d.ts +4 -3
- package/dist/core/ingestion/ast-helpers.d.ts +80 -0
- package/dist/core/ingestion/ast-helpers.js +738 -0
- package/dist/core/ingestion/call-analysis.d.ts +73 -0
- package/dist/core/ingestion/call-analysis.js +490 -0
- package/dist/core/ingestion/call-processor.d.ts +55 -2
- package/dist/core/ingestion/call-processor.js +673 -108
- package/dist/core/ingestion/call-routing.d.ts +23 -2
- package/dist/core/ingestion/call-routing.js +21 -0
- package/dist/core/ingestion/entry-point-scoring.js +36 -26
- package/dist/core/ingestion/framework-detection.d.ts +10 -2
- package/dist/core/ingestion/framework-detection.js +49 -12
- package/dist/core/ingestion/heritage-processor.js +47 -49
- package/dist/core/ingestion/import-processor.d.ts +1 -1
- package/dist/core/ingestion/import-processor.js +103 -194
- package/dist/core/ingestion/import-resolution.d.ts +101 -0
- package/dist/core/ingestion/import-resolution.js +251 -0
- package/dist/core/ingestion/language-config.d.ts +3 -0
- package/dist/core/ingestion/language-config.js +13 -0
- package/dist/core/ingestion/markdown-processor.d.ts +17 -0
- package/dist/core/ingestion/markdown-processor.js +124 -0
- package/dist/core/ingestion/mro-processor.js +8 -3
- package/dist/core/ingestion/named-binding-extraction.d.ts +9 -43
- package/dist/core/ingestion/named-binding-extraction.js +89 -79
- package/dist/core/ingestion/parsing-processor.d.ts +3 -2
- package/dist/core/ingestion/parsing-processor.js +27 -60
- package/dist/core/ingestion/pipeline.d.ts +10 -0
- package/dist/core/ingestion/pipeline.js +425 -4
- package/dist/core/ingestion/resolution-context.d.ts +5 -0
- package/dist/core/ingestion/resolution-context.js +7 -4
- package/dist/core/ingestion/resolvers/index.d.ts +1 -1
- package/dist/core/ingestion/resolvers/index.js +1 -1
- package/dist/core/ingestion/resolvers/jvm.d.ts +2 -1
- package/dist/core/ingestion/resolvers/jvm.js +25 -9
- package/dist/core/ingestion/resolvers/php.d.ts +14 -0
- package/dist/core/ingestion/resolvers/php.js +43 -3
- package/dist/core/ingestion/resolvers/utils.d.ts +5 -0
- package/dist/core/ingestion/resolvers/utils.js +16 -0
- package/dist/core/ingestion/symbol-table.d.ts +29 -3
- package/dist/core/ingestion/symbol-table.js +42 -9
- package/dist/core/ingestion/tree-sitter-queries.d.ts +12 -12
- package/dist/core/ingestion/tree-sitter-queries.js +243 -2
- package/dist/core/ingestion/type-env.d.ts +28 -1
- package/dist/core/ingestion/type-env.js +451 -72
- package/dist/core/ingestion/type-extractors/c-cpp.d.ts +5 -0
- package/dist/core/ingestion/type-extractors/c-cpp.js +146 -2
- package/dist/core/ingestion/type-extractors/csharp.js +189 -16
- package/dist/core/ingestion/type-extractors/go.js +45 -0
- package/dist/core/ingestion/type-extractors/index.d.ts +1 -1
- package/dist/core/ingestion/type-extractors/index.js +1 -1
- package/dist/core/ingestion/type-extractors/jvm.js +244 -69
- package/dist/core/ingestion/type-extractors/php.js +31 -4
- package/dist/core/ingestion/type-extractors/python.js +89 -17
- package/dist/core/ingestion/type-extractors/ruby.js +17 -2
- package/dist/core/ingestion/type-extractors/rust.js +72 -4
- package/dist/core/ingestion/type-extractors/shared.d.ts +12 -2
- package/dist/core/ingestion/type-extractors/shared.js +115 -13
- package/dist/core/ingestion/type-extractors/swift.js +7 -6
- package/dist/core/ingestion/type-extractors/types.d.ts +54 -11
- package/dist/core/ingestion/type-extractors/typescript.js +171 -9
- package/dist/core/ingestion/utils.d.ts +2 -95
- package/dist/core/ingestion/utils.js +3 -892
- package/dist/core/ingestion/workers/parse-worker.d.ts +36 -11
- package/dist/core/ingestion/workers/parse-worker.js +116 -95
- package/dist/core/lbug/csv-generator.js +18 -1
- package/dist/core/lbug/lbug-adapter.d.ts +12 -0
- package/dist/core/lbug/lbug-adapter.js +71 -4
- package/dist/core/lbug/schema.d.ts +6 -4
- package/dist/core/lbug/schema.js +27 -3
- package/dist/mcp/core/embedder.js +11 -3
- package/dist/mcp/core/lbug-adapter.d.ts +22 -0
- package/dist/mcp/core/lbug-adapter.js +178 -23
- package/dist/mcp/local/local-backend.d.ts +22 -0
- package/dist/mcp/local/local-backend.js +136 -32
- package/dist/mcp/resources.js +13 -0
- package/dist/mcp/server.js +26 -4
- package/dist/mcp/tools.js +17 -7
- package/dist/server/api.d.ts +19 -1
- package/dist/server/api.js +66 -6
- package/dist/storage/git.d.ts +12 -0
- package/dist/storage/git.js +21 -0
- package/package.json +12 -4
|
@@ -3,11 +3,146 @@ import { TIER_CONFIDENCE } from './resolution-context.js';
|
|
|
3
3
|
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
|
4
4
|
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
|
|
5
5
|
import { generateId } from '../../lib/utils.js';
|
|
6
|
-
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId,
|
|
7
|
-
import { buildTypeEnv } from './type-env.js';
|
|
6
|
+
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId, extractMixedChain, } from './utils.js';
|
|
7
|
+
import { buildTypeEnv, isSubclassOf } from './type-env.js';
|
|
8
8
|
import { getTreeSitterBufferSize } from './constants.js';
|
|
9
9
|
import { callRouters } from './call-routing.js';
|
|
10
|
-
import { extractReturnTypeName } from './type-extractors/shared.js';
|
|
10
|
+
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
|
11
|
+
import { typeConfigs } from './type-extractors/index.js';
|
|
12
|
+
const MAX_EXPORTS_PER_FILE = 500;
|
|
13
|
+
const MAX_TYPE_NAME_LENGTH = 256;
|
|
14
|
+
/** Build a map of imported callee names → return types for cross-file call-result binding.
|
|
15
|
+
* Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */
|
|
16
|
+
export function buildImportedReturnTypes(filePath, namedImportMap, symbolTable) {
|
|
17
|
+
const result = new Map();
|
|
18
|
+
const fileImports = namedImportMap.get(filePath);
|
|
19
|
+
if (!fileImports)
|
|
20
|
+
return result;
|
|
21
|
+
for (const [localName, binding] of fileImports) {
|
|
22
|
+
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
|
23
|
+
if (!def?.returnType)
|
|
24
|
+
continue;
|
|
25
|
+
const simpleReturn = extractReturnTypeName(def.returnType);
|
|
26
|
+
if (simpleReturn)
|
|
27
|
+
result.set(localName, simpleReturn);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
/** Build cross-file RAW return types for imported callables.
|
|
32
|
+
* Unlike buildImportedReturnTypes (which stores extractReturnTypeName output),
|
|
33
|
+
* this stores the raw declared return type string (e.g., 'User[]', 'List<User>').
|
|
34
|
+
* Used by lookupRawReturnType for for-loop element extraction via extractElementTypeFromString. */
|
|
35
|
+
export function buildImportedRawReturnTypes(filePath, namedImportMap, symbolTable) {
|
|
36
|
+
const result = new Map();
|
|
37
|
+
const fileImports = namedImportMap.get(filePath);
|
|
38
|
+
if (!fileImports)
|
|
39
|
+
return result;
|
|
40
|
+
for (const [localName, binding] of fileImports) {
|
|
41
|
+
const def = symbolTable.lookupExactFull(binding.sourcePath, binding.exportedName);
|
|
42
|
+
if (!def?.returnType)
|
|
43
|
+
continue;
|
|
44
|
+
result.set(localName, def.returnType);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
/** Collect resolved type bindings for exported file-scope symbols.
|
|
49
|
+
* Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */
|
|
50
|
+
function collectExportedBindings(typeEnv, filePath, symbolTable, graph) {
|
|
51
|
+
const fileScope = typeEnv.env.get('');
|
|
52
|
+
if (!fileScope || fileScope.size === 0)
|
|
53
|
+
return null;
|
|
54
|
+
const exported = new Map();
|
|
55
|
+
for (const [varName, typeName] of fileScope) {
|
|
56
|
+
if (exported.size >= MAX_EXPORTS_PER_FILE)
|
|
57
|
+
break;
|
|
58
|
+
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH)
|
|
59
|
+
continue;
|
|
60
|
+
const nodeId = symbolTable.lookupExact(filePath, varName);
|
|
61
|
+
if (!nodeId)
|
|
62
|
+
continue;
|
|
63
|
+
const node = graph.getNode(nodeId);
|
|
64
|
+
if (node?.properties?.isExported) {
|
|
65
|
+
exported.set(varName, typeName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return exported.size > 0 ? exported : null;
|
|
69
|
+
}
|
|
70
|
+
/** Build ExportedTypeMap from graph nodes — used for worker path where TypeEnv
|
|
71
|
+
* is not available in the main thread. Collects returnType/declaredType from
|
|
72
|
+
* exported symbols that have callables with known return types. */
|
|
73
|
+
export function buildExportedTypeMapFromGraph(graph, symbolTable) {
|
|
74
|
+
const result = new Map();
|
|
75
|
+
graph.forEachNode(node => {
|
|
76
|
+
if (!node.properties?.isExported)
|
|
77
|
+
return;
|
|
78
|
+
if (!node.properties?.filePath || !node.properties?.name)
|
|
79
|
+
return;
|
|
80
|
+
const filePath = node.properties.filePath;
|
|
81
|
+
const name = node.properties.name;
|
|
82
|
+
if (!name || name.length > MAX_TYPE_NAME_LENGTH)
|
|
83
|
+
return;
|
|
84
|
+
// For callable symbols, use returnType; for properties/variables, use declaredType
|
|
85
|
+
const def = symbolTable.lookupExactFull(filePath, name);
|
|
86
|
+
if (!def)
|
|
87
|
+
return;
|
|
88
|
+
const typeName = def.returnType ?? def.declaredType;
|
|
89
|
+
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH)
|
|
90
|
+
return;
|
|
91
|
+
// Extract simple type name (strip Promise<>, etc.) — reuse shared utility
|
|
92
|
+
const simpleType = extractReturnTypeName(typeName) ?? typeName;
|
|
93
|
+
if (!simpleType)
|
|
94
|
+
return;
|
|
95
|
+
let fileExports = result.get(filePath);
|
|
96
|
+
if (!fileExports) {
|
|
97
|
+
fileExports = new Map();
|
|
98
|
+
result.set(filePath, fileExports);
|
|
99
|
+
}
|
|
100
|
+
if (fileExports.size < MAX_EXPORTS_PER_FILE) {
|
|
101
|
+
fileExports.set(name, simpleType);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
/** Seed cross-file receiver types into pre-extracted call records.
|
|
107
|
+
* Fills missing receiverTypeName for single-hop imported variables
|
|
108
|
+
* using ExportedTypeMap + namedImportMap — zero disk I/O, zero AST re-parsing.
|
|
109
|
+
* Mutates calls in-place. Runs BEFORE processCallsFromExtracted. */
|
|
110
|
+
export function seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap) {
|
|
111
|
+
if (namedImportMap.size === 0 || exportedTypeMap.size === 0) {
|
|
112
|
+
return { enrichedCount: 0 };
|
|
113
|
+
}
|
|
114
|
+
let enrichedCount = 0;
|
|
115
|
+
for (const call of calls) {
|
|
116
|
+
if (call.receiverTypeName || !call.receiverName)
|
|
117
|
+
continue;
|
|
118
|
+
if (call.callForm !== 'member')
|
|
119
|
+
continue;
|
|
120
|
+
const fileImports = namedImportMap.get(call.filePath);
|
|
121
|
+
if (!fileImports)
|
|
122
|
+
continue;
|
|
123
|
+
const binding = fileImports.get(call.receiverName);
|
|
124
|
+
if (!binding)
|
|
125
|
+
continue;
|
|
126
|
+
const upstream = exportedTypeMap.get(binding.sourcePath);
|
|
127
|
+
if (!upstream)
|
|
128
|
+
continue;
|
|
129
|
+
const type = upstream.get(binding.exportedName);
|
|
130
|
+
if (type) {
|
|
131
|
+
call.receiverTypeName = type;
|
|
132
|
+
enrichedCount++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { enrichedCount };
|
|
136
|
+
}
|
|
137
|
+
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
|
138
|
+
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
|
139
|
+
// for type resolution — the current type passes through unchanged.
|
|
140
|
+
const TYPE_PRESERVING_METHODS = new Set([
|
|
141
|
+
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
|
|
142
|
+
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
|
|
143
|
+
'get', // Kotlin/Java Optional.get()
|
|
144
|
+
'orElseThrow', // Java Optional
|
|
145
|
+
]);
|
|
11
146
|
/**
|
|
12
147
|
* Walk up the AST from a node to find the enclosing function/method.
|
|
13
148
|
* Returns null if the call is at module/file level (top-level code).
|
|
@@ -78,9 +213,23 @@ const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
|
|
|
78
213
|
}
|
|
79
214
|
return verified;
|
|
80
215
|
};
|
|
81
|
-
export const processCalls = async (graph, files, astCache, ctx, onProgress
|
|
216
|
+
export const processCalls = async (graph, files, astCache, ctx, onProgress, exportedTypeMap,
|
|
217
|
+
/** Phase 14: pre-resolved cross-file bindings to seed into buildTypeEnv. Keyed by filePath → Map<localName, typeName>. */
|
|
218
|
+
importedBindingsMap,
|
|
219
|
+
/** Phase 14 E3: cross-file return types for imported callables. Keyed by filePath → Map<calleeName, returnType>.
|
|
220
|
+
* Consulted ONLY when SymbolTable has no unambiguous match (local-first principle). */
|
|
221
|
+
importedReturnTypesMap,
|
|
222
|
+
/** Phase 14 E3: cross-file RAW return types for for-loop element extraction. Keyed by filePath → Map<calleeName, rawReturnType>. */
|
|
223
|
+
importedRawReturnTypesMap) => {
|
|
82
224
|
const parser = await loadParser();
|
|
83
225
|
const collectedHeritage = [];
|
|
226
|
+
const pendingWrites = [];
|
|
227
|
+
// Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf.
|
|
228
|
+
// Used as a secondary check when per-file parentMap lacks the relationship — helps
|
|
229
|
+
// when the heritage-declaring file is processed before the call site file.
|
|
230
|
+
// For remaining cases (reverse file order), the SymbolTable class-type fallback applies.
|
|
231
|
+
const globalParentMap = new Map();
|
|
232
|
+
const globalParentSeen = new Map();
|
|
84
233
|
const logSkipped = isVerboseIngestionEnabled();
|
|
85
234
|
const skippedByLang = logSkipped ? new Map() : null;
|
|
86
235
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -123,15 +272,103 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
123
272
|
continue;
|
|
124
273
|
}
|
|
125
274
|
const lang = getLanguageFromFilename(file.path);
|
|
126
|
-
|
|
275
|
+
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
|
|
276
|
+
// Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs.
|
|
277
|
+
const fileParentMap = new Map();
|
|
278
|
+
for (const match of matches) {
|
|
279
|
+
const captureMap = {};
|
|
280
|
+
match.captures.forEach(c => captureMap[c.name] = c.node);
|
|
281
|
+
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
|
|
282
|
+
const className = captureMap['heritage.class'].text;
|
|
283
|
+
const parentName = captureMap['heritage.extends'].text;
|
|
284
|
+
const extendsNode = captureMap['heritage.extends'];
|
|
285
|
+
const fieldDecl = extendsNode.parent;
|
|
286
|
+
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'))
|
|
287
|
+
continue;
|
|
288
|
+
let parents = fileParentMap.get(className);
|
|
289
|
+
if (!parents) {
|
|
290
|
+
parents = [];
|
|
291
|
+
fileParentMap.set(className, parents);
|
|
292
|
+
}
|
|
293
|
+
if (!parents.includes(parentName))
|
|
294
|
+
parents.push(parentName);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const parentMap = fileParentMap;
|
|
298
|
+
// Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups.
|
|
299
|
+
// Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes().
|
|
300
|
+
for (const [cls, parents] of fileParentMap) {
|
|
301
|
+
let global = globalParentMap.get(cls);
|
|
302
|
+
let seen = globalParentSeen.get(cls);
|
|
303
|
+
if (!global) {
|
|
304
|
+
global = [];
|
|
305
|
+
globalParentMap.set(cls, global);
|
|
306
|
+
}
|
|
307
|
+
if (!seen) {
|
|
308
|
+
seen = new Set();
|
|
309
|
+
globalParentSeen.set(cls, seen);
|
|
310
|
+
}
|
|
311
|
+
for (const p of parents) {
|
|
312
|
+
if (!seen.has(p)) {
|
|
313
|
+
seen.add(p);
|
|
314
|
+
global.push(p);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const importedBindings = importedBindingsMap?.get(file.path);
|
|
319
|
+
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
|
|
320
|
+
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
|
|
321
|
+
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap, importedBindings, importedReturnTypes, importedRawReturnTypes }) : null;
|
|
322
|
+
if (typeEnv && exportedTypeMap) {
|
|
323
|
+
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
|
|
324
|
+
if (fileExports)
|
|
325
|
+
exportedTypeMap.set(file.path, fileExports);
|
|
326
|
+
}
|
|
127
327
|
const callRouter = callRouters[language];
|
|
128
328
|
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
|
129
329
|
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
|
130
330
|
: new Map();
|
|
331
|
+
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
|
|
131
332
|
ctx.enableCache(file.path);
|
|
132
333
|
matches.forEach(match => {
|
|
133
334
|
const captureMap = {};
|
|
134
335
|
match.captures.forEach(c => captureMap[c.name] = c.node);
|
|
336
|
+
// ── Write access: emit ACCESSES {reason: 'write'} for assignments to member fields ──
|
|
337
|
+
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
|
|
338
|
+
const receiverNode = captureMap['assignment.receiver'];
|
|
339
|
+
const propertyName = captureMap['assignment.property'].text;
|
|
340
|
+
// Resolve receiver type: simple identifier → TypeEnv lookup or class resolution
|
|
341
|
+
let receiverTypeName;
|
|
342
|
+
const receiverText = receiverNode.text;
|
|
343
|
+
if (receiverText && typeEnv) {
|
|
344
|
+
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']);
|
|
345
|
+
}
|
|
346
|
+
// Fall back to verified constructor bindings (mirrors CALLS resolution tier 2)
|
|
347
|
+
if (!receiverTypeName && receiverText && receiverIndex.size > 0) {
|
|
348
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
349
|
+
const funcName = enclosing ? extractFuncNameFromSourceId(enclosing) : '';
|
|
350
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverText);
|
|
351
|
+
}
|
|
352
|
+
if (!receiverTypeName && receiverText) {
|
|
353
|
+
const resolved = ctx.resolve(receiverText, file.path);
|
|
354
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
355
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
356
|
+
receiverTypeName = receiverText;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (receiverTypeName) {
|
|
360
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
361
|
+
const srcId = enclosing || generateId('File', file.path);
|
|
362
|
+
// Defer resolution: Ruby attr_accessor properties are registered during
|
|
363
|
+
// this same loop, so cross-file lookups fail if the declaring file hasn't
|
|
364
|
+
// been processed yet. Collect now, resolve after all files are done.
|
|
365
|
+
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
|
|
366
|
+
}
|
|
367
|
+
// Assignment-only capture (no @call sibling): skip the rest of this
|
|
368
|
+
// forEach iteration — this acts as a `continue` in the match loop.
|
|
369
|
+
if (!captureMap['call'])
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
135
372
|
if (!captureMap['call'])
|
|
136
373
|
return;
|
|
137
374
|
const nameNode = captureMap['call.name'];
|
|
@@ -169,7 +406,10 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
169
406
|
description: item.accessorType,
|
|
170
407
|
},
|
|
171
408
|
});
|
|
172
|
-
ctx.symbols.add(file.path, item.propName, nodeId, 'Property',
|
|
409
|
+
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
|
410
|
+
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
|
411
|
+
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
|
412
|
+
});
|
|
173
413
|
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
|
174
414
|
graph.addRelationship({
|
|
175
415
|
id: relId, sourceId: fileId, targetId: nodeId,
|
|
@@ -177,9 +417,9 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
177
417
|
});
|
|
178
418
|
if (propEnclosingClassId) {
|
|
179
419
|
graph.addRelationship({
|
|
180
|
-
id: generateId('
|
|
420
|
+
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
|
181
421
|
sourceId: propEnclosingClassId, targetId: nodeId,
|
|
182
|
-
type: '
|
|
422
|
+
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
|
|
183
423
|
});
|
|
184
424
|
}
|
|
185
425
|
}
|
|
@@ -195,11 +435,49 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
195
435
|
const callForm = inferCallForm(callNode, nameNode);
|
|
196
436
|
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
|
197
437
|
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
|
438
|
+
// Phase P: virtual dispatch override — when the declared type is a base class but
|
|
439
|
+
// the constructor created a known subclass, prefer the more specific type.
|
|
440
|
+
// Checks per-file parentMap first, then falls back to globalParentMap for
|
|
441
|
+
// cross-file heritage (e.g. Dog extends Animal declared in a different file).
|
|
442
|
+
// Reconstructs the exact scope key (funcName@startIndex\0varName) from the
|
|
443
|
+
// enclosing function AST node for a correct, O(1) map lookup.
|
|
444
|
+
if (receiverTypeName && receiverName && typeEnv && typeEnv.constructorTypeMap.size > 0) {
|
|
445
|
+
// Reconstruct scope key to match constructorTypeMap's scope\0varName format
|
|
446
|
+
let scope = '';
|
|
447
|
+
let p = callNode.parent;
|
|
448
|
+
while (p) {
|
|
449
|
+
if (FUNCTION_NODE_TYPES.has(p.type)) {
|
|
450
|
+
const { funcName } = extractFunctionName(p);
|
|
451
|
+
if (funcName) {
|
|
452
|
+
scope = `${funcName}@${p.startIndex}`;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
p = p.parent;
|
|
457
|
+
}
|
|
458
|
+
const ctorType = typeEnv.constructorTypeMap.get(`${scope}\0${receiverName}`);
|
|
459
|
+
if (ctorType && ctorType !== receiverTypeName) {
|
|
460
|
+
// Verify subclass relationship: per-file parentMap first, then cross-file
|
|
461
|
+
// globalParentMap, then fall back to SymbolTable class verification.
|
|
462
|
+
// The SymbolTable fallback handles cross-file cases where heritage is declared
|
|
463
|
+
// in a file not yet processed (e.g. Dog extends Animal in models/Dog.kt when
|
|
464
|
+
// processing services/App.kt). Since constructorTypeMap only records entries
|
|
465
|
+
// when a type annotation AND constructor are both present (val x: Base = Sub()),
|
|
466
|
+
// confirming both are class-like types is sufficient — the original code would
|
|
467
|
+
// not compile if Sub didn't extend Base.
|
|
468
|
+
if (isSubclassOf(ctorType, receiverTypeName, parentMap)
|
|
469
|
+
|| isSubclassOf(ctorType, receiverTypeName, globalParentMap)
|
|
470
|
+
|| (ctx.symbols.lookupFuzzy(ctorType).some(d => d.type === 'Class' || d.type === 'Struct')
|
|
471
|
+
&& ctx.symbols.lookupFuzzy(receiverTypeName).some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'))) {
|
|
472
|
+
receiverTypeName = ctorType;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
198
476
|
// Fall back to verified constructor bindings for return type inference
|
|
199
|
-
if (!receiverTypeName && receiverName &&
|
|
477
|
+
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
|
200
478
|
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
201
479
|
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
202
|
-
receiverTypeName = lookupReceiverType(
|
|
480
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
|
|
203
481
|
}
|
|
204
482
|
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
|
|
205
483
|
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
|
|
@@ -210,43 +488,51 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
210
488
|
receiverTypeName = receiverName;
|
|
211
489
|
}
|
|
212
490
|
}
|
|
213
|
-
//
|
|
214
|
-
|
|
491
|
+
// Hoist sourceId so it's available for ACCESSES edge emission during chain walk.
|
|
492
|
+
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
493
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
494
|
+
// Fall back to mixed chain resolution when the receiver is a complex expression
|
|
495
|
+
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
|
|
496
|
+
// svc.getUser().address.save()). Handles all cases with a single unified walk.
|
|
215
497
|
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
|
216
498
|
const receiverNode = extractReceiverNode(nameNode);
|
|
217
|
-
if (receiverNode
|
|
218
|
-
const extracted =
|
|
219
|
-
if (extracted) {
|
|
220
|
-
|
|
221
|
-
let baseType = extracted.baseReceiverName && typeEnv
|
|
499
|
+
if (receiverNode) {
|
|
500
|
+
const extracted = extractMixedChain(receiverNode);
|
|
501
|
+
if (extracted && extracted.chain.length > 0) {
|
|
502
|
+
let currentType = extracted.baseReceiverName && typeEnv
|
|
222
503
|
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
|
223
504
|
: undefined;
|
|
224
|
-
if (!
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
|
|
505
|
+
if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) {
|
|
506
|
+
const funcName = enclosingFuncId ? extractFuncNameFromSourceId(enclosingFuncId) : '';
|
|
507
|
+
currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName);
|
|
228
508
|
}
|
|
229
|
-
|
|
230
|
-
if (!baseType && extracted.baseReceiverName) {
|
|
509
|
+
if (!currentType && extracted.baseReceiverName) {
|
|
231
510
|
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
|
232
511
|
if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
233
|
-
|
|
512
|
+
currentType = extracted.baseReceiverName;
|
|
234
513
|
}
|
|
235
514
|
}
|
|
236
|
-
|
|
515
|
+
if (currentType) {
|
|
516
|
+
receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx, makeAccessEmitter(graph, sourceId));
|
|
517
|
+
}
|
|
237
518
|
}
|
|
238
519
|
}
|
|
239
520
|
}
|
|
521
|
+
// Build overload hints for languages with inferLiteralType (Java/Kotlin/C#/C++).
|
|
522
|
+
// Only used when multiple candidates survive arity filtering — ~1-3% of calls.
|
|
523
|
+
const langConfig = lang ? typeConfigs[lang] : undefined;
|
|
524
|
+
const hints = langConfig?.inferLiteralType
|
|
525
|
+
? { callNode, inferLiteralType: langConfig.inferLiteralType }
|
|
526
|
+
: undefined;
|
|
240
527
|
const resolved = resolveCallTarget({
|
|
241
528
|
calledName,
|
|
242
529
|
argCount: countCallArguments(callNode),
|
|
243
530
|
callForm,
|
|
244
531
|
receiverTypeName,
|
|
245
|
-
|
|
532
|
+
receiverName,
|
|
533
|
+
}, file.path, ctx, hints);
|
|
246
534
|
if (!resolved)
|
|
247
535
|
return;
|
|
248
|
-
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
249
|
-
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
250
536
|
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
|
251
537
|
graph.addRelationship({
|
|
252
538
|
id: relId,
|
|
@@ -259,6 +545,21 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
259
545
|
});
|
|
260
546
|
ctx.clearCache();
|
|
261
547
|
}
|
|
548
|
+
// ── Resolve deferred write-access edges ──
|
|
549
|
+
// All properties (including Ruby attr_accessor) are now registered.
|
|
550
|
+
for (const pw of pendingWrites) {
|
|
551
|
+
const fieldOwner = resolveFieldOwnership(pw.receiverTypeName, pw.propertyName, pw.filePath, ctx);
|
|
552
|
+
if (fieldOwner) {
|
|
553
|
+
graph.addRelationship({
|
|
554
|
+
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
|
|
555
|
+
sourceId: pw.srcId,
|
|
556
|
+
targetId: fieldOwner.nodeId,
|
|
557
|
+
type: 'ACCESSES',
|
|
558
|
+
confidence: 1.0,
|
|
559
|
+
reason: 'write',
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
262
563
|
if (skippedByLang && skippedByLang.size > 0) {
|
|
263
564
|
for (const [lang, count] of skippedByLang.entries()) {
|
|
264
565
|
console.warn(`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`);
|
|
@@ -296,58 +597,139 @@ const filterCallableCandidates = (candidates, argCount, callForm) => {
|
|
|
296
597
|
const hasParameterMetadata = kindFiltered.some(candidate => candidate.parameterCount !== undefined);
|
|
297
598
|
if (!hasParameterMetadata)
|
|
298
599
|
return kindFiltered;
|
|
299
|
-
return kindFiltered.filter(candidate => candidate.parameterCount === undefined
|
|
600
|
+
return kindFiltered.filter(candidate => candidate.parameterCount === undefined
|
|
601
|
+
|| (argCount >= (candidate.requiredParameterCount ?? candidate.parameterCount)
|
|
602
|
+
&& argCount <= candidate.parameterCount));
|
|
300
603
|
};
|
|
301
604
|
const toResolveResult = (definition, tier) => ({
|
|
302
605
|
nodeId: definition.nodeId,
|
|
303
606
|
confidence: TIER_CONFIDENCE[tier],
|
|
304
607
|
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
|
608
|
+
returnType: definition.returnType,
|
|
305
609
|
});
|
|
306
610
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
611
|
+
* Kotlin (and JVM in general) uses boxed type names in parameter declarations
|
|
612
|
+
* (e.g. `Int`, `Long`, `Boolean`) while inferJvmLiteralType returns unboxed
|
|
613
|
+
* primitives (`int`, `long`, `boolean`). Normalise both sides to lowercase so
|
|
614
|
+
* that the comparison `'Int' === 'int'` does not fail.
|
|
310
615
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* @param baseReceiverTypeName The already-resolved type of the base receiver
|
|
314
|
-
* (e.g. 'UserService' for `svc`), or undefined.
|
|
315
|
-
* @param currentFile The file path for resolution context.
|
|
316
|
-
* @param ctx The resolution context for symbol lookup.
|
|
317
|
-
* @returns The type name of the final intermediate call's return type, or undefined
|
|
318
|
-
* if resolution fails at any step.
|
|
616
|
+
* Only applied to single-word identifiers that look like a JVM primitive alias;
|
|
617
|
+
* multi-word or qualified names are left untouched.
|
|
319
618
|
*/
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
619
|
+
const KOTLIN_BOXED_TO_PRIMITIVE = {
|
|
620
|
+
Int: 'int',
|
|
621
|
+
Long: 'long',
|
|
622
|
+
Short: 'short',
|
|
623
|
+
Byte: 'byte',
|
|
624
|
+
Float: 'float',
|
|
625
|
+
Double: 'double',
|
|
626
|
+
Boolean: 'boolean',
|
|
627
|
+
Char: 'char',
|
|
628
|
+
};
|
|
629
|
+
const normalizeJvmTypeName = (name) => KOTLIN_BOXED_TO_PRIMITIVE[name] ?? name;
|
|
630
|
+
/**
|
|
631
|
+
* Try to disambiguate overloaded candidates using argument literal types.
|
|
632
|
+
* Only invoked when filteredCandidates.length > 1 and at least one has parameterTypes.
|
|
633
|
+
* Returns the single matching candidate, or null if ambiguous/inconclusive.
|
|
634
|
+
*/
|
|
635
|
+
const tryOverloadDisambiguation = (candidates, hints) => {
|
|
636
|
+
if (!candidates.some(c => c.parameterTypes))
|
|
637
|
+
return null;
|
|
638
|
+
// Find the argument list node in the call expression.
|
|
639
|
+
// Kotlin wraps value_arguments inside a call_suffix child, so we must also
|
|
640
|
+
// search one level deeper when a direct match is not found.
|
|
641
|
+
let argList = hints.callNode.childForFieldName?.('arguments')
|
|
642
|
+
?? hints.callNode.children.find((c) => c.type === 'arguments' || c.type === 'argument_list' || c.type === 'value_arguments');
|
|
643
|
+
if (!argList) {
|
|
644
|
+
// Kotlin: call_expression → call_suffix → value_arguments
|
|
645
|
+
const callSuffix = hints.callNode.children.find((c) => c.type === 'call_suffix');
|
|
646
|
+
if (callSuffix) {
|
|
647
|
+
argList = callSuffix.children.find((c) => c.type === 'value_arguments');
|
|
648
|
+
}
|
|
334
649
|
}
|
|
335
|
-
|
|
336
|
-
|
|
650
|
+
if (!argList)
|
|
651
|
+
return null;
|
|
652
|
+
const argTypes = [];
|
|
653
|
+
for (const arg of argList.namedChildren) {
|
|
654
|
+
if (arg.type === 'comment')
|
|
655
|
+
continue;
|
|
656
|
+
// Unwrap argument wrapper nodes before passing to inferLiteralType:
|
|
657
|
+
// - Kotlin value_argument: has 'value' field containing the literal
|
|
658
|
+
// - C# argument: has 'expression' field (handles named args like `name: "alice"`
|
|
659
|
+
// where firstNamedChild would return name_colon instead of the value)
|
|
660
|
+
// - Java/others: arg IS the literal directly (no unwrapping needed)
|
|
661
|
+
const valueNode = arg.childForFieldName?.('value')
|
|
662
|
+
?? arg.childForFieldName?.('expression')
|
|
663
|
+
?? (arg.type === 'argument' || arg.type === 'value_argument'
|
|
664
|
+
? arg.firstNamedChild ?? arg
|
|
665
|
+
: arg);
|
|
666
|
+
argTypes.push(hints.inferLiteralType(valueNode));
|
|
667
|
+
}
|
|
668
|
+
// If no literal types could be inferred, can't disambiguate
|
|
669
|
+
if (argTypes.every(t => t === undefined))
|
|
670
|
+
return null;
|
|
671
|
+
const matched = candidates.filter(c => {
|
|
672
|
+
// Keep candidates without type info — conservative: partially-annotated codebases
|
|
673
|
+
// (e.g. C++ with some missing declarations) may have mixed typed/untyped overloads.
|
|
674
|
+
// If one typed and one untyped both survive, matched.length > 1 → returns null (no edge).
|
|
675
|
+
if (!c.parameterTypes)
|
|
676
|
+
return true;
|
|
677
|
+
return c.parameterTypes.every((pType, i) => {
|
|
678
|
+
if (i >= argTypes.length || !argTypes[i])
|
|
679
|
+
return true;
|
|
680
|
+
// Normalise Kotlin boxed type names (Int→int, Boolean→boolean, etc.) so
|
|
681
|
+
// that the stored declaration type matches the inferred literal type.
|
|
682
|
+
return normalizeJvmTypeName(pType) === argTypes[i];
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
if (matched.length === 1)
|
|
686
|
+
return matched[0];
|
|
687
|
+
// Multiple survivors may share the same nodeId (e.g. TypeScript overload signatures +
|
|
688
|
+
// implementation body all collide via generateId). Deduplicate by nodeId — if all
|
|
689
|
+
// matched candidates resolve to the same graph node, disambiguation succeeded.
|
|
690
|
+
if (matched.length > 1) {
|
|
691
|
+
const uniqueIds = new Set(matched.map(c => c.nodeId));
|
|
692
|
+
if (uniqueIds.size === 1)
|
|
693
|
+
return matched[0];
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
};
|
|
337
697
|
/**
|
|
338
698
|
* Resolve a function call to its target node ID using priority strategy:
|
|
339
699
|
* A. Narrow candidates by scope tier via ctx.resolve()
|
|
340
700
|
* B. Filter to callable symbol kinds (constructor-aware when callForm is set)
|
|
341
701
|
* C. Apply arity filtering when parameter metadata is available
|
|
342
702
|
* D. Apply receiver-type filtering for member calls with typed receivers
|
|
703
|
+
* E. Apply overload disambiguation via argument literal types (when available)
|
|
343
704
|
*
|
|
344
705
|
* If filtering still leaves multiple candidates, refuse to emit a CALLS edge.
|
|
345
706
|
*/
|
|
346
|
-
const resolveCallTarget = (call, currentFile, ctx) => {
|
|
707
|
+
const resolveCallTarget = (call, currentFile, ctx, overloadHints) => {
|
|
347
708
|
const tiered = ctx.resolve(call.calledName, currentFile);
|
|
348
709
|
if (!tiered)
|
|
349
710
|
return null;
|
|
350
|
-
|
|
711
|
+
let filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
|
|
712
|
+
// Module-qualified constructor pattern: e.g. Python `import models; models.User()`.
|
|
713
|
+
// The attribute access gives callForm='member', but the callee may be a Class — a valid
|
|
714
|
+
// constructor target. Re-try with constructor-form filtering so that `module.ClassName()`
|
|
715
|
+
// emits a CALLS edge to the class node.
|
|
716
|
+
if (filteredCandidates.length === 0 && call.callForm === 'member') {
|
|
717
|
+
filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, 'constructor');
|
|
718
|
+
}
|
|
719
|
+
// Module-alias disambiguation: Python `import auth; auth.User()` — when both models.py and
|
|
720
|
+
// auth.py export User, receiverName='auth' selects auth.py via moduleAliasMap.
|
|
721
|
+
// Runs when multiple candidates survive filtering and the receiver is a known module alias.
|
|
722
|
+
if (filteredCandidates.length > 1 && call.callForm === 'member' && call.receiverName) {
|
|
723
|
+
const aliasMap = ctx.moduleAliasMap?.get(currentFile);
|
|
724
|
+
if (aliasMap) {
|
|
725
|
+
const moduleFile = aliasMap.get(call.receiverName);
|
|
726
|
+
if (moduleFile) {
|
|
727
|
+
const aliasFiltered = filteredCandidates.filter(c => c.filePath === moduleFile);
|
|
728
|
+
if (aliasFiltered.length > 0)
|
|
729
|
+
filteredCandidates = aliasFiltered;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
351
733
|
// D. Receiver-type filtering: for member calls with a known receiver type,
|
|
352
734
|
// resolve the type through the same tiered import infrastructure, then
|
|
353
735
|
// filter method candidates to the type's defining file. Fall back to
|
|
@@ -379,10 +761,25 @@ const resolveCallTarget = (call, currentFile, ctx) => {
|
|
|
379
761
|
if (ownerFiltered.length === 1) {
|
|
380
762
|
return toResolveResult(ownerFiltered[0], tiered.tier);
|
|
381
763
|
}
|
|
764
|
+
// E. Try overload disambiguation on the narrowed pool
|
|
765
|
+
if ((fileFiltered.length > 1 || ownerFiltered.length > 1) && overloadHints) {
|
|
766
|
+
const overloadPool = ownerFiltered.length > 1 ? ownerFiltered : fileFiltered;
|
|
767
|
+
const disambiguated = tryOverloadDisambiguation(overloadPool, overloadHints);
|
|
768
|
+
if (disambiguated)
|
|
769
|
+
return toResolveResult(disambiguated, tiered.tier);
|
|
770
|
+
}
|
|
382
771
|
if (fileFiltered.length > 1 || ownerFiltered.length > 1)
|
|
383
772
|
return null;
|
|
384
773
|
}
|
|
385
774
|
}
|
|
775
|
+
// E. Overload disambiguation: when multiple candidates survive arity + receiver filtering,
|
|
776
|
+
// try matching argument literal types against parameter types (Phase P).
|
|
777
|
+
// Only available on sequential path (has AST); worker path falls through gracefully.
|
|
778
|
+
if (filteredCandidates.length > 1 && overloadHints) {
|
|
779
|
+
const disambiguated = tryOverloadDisambiguation(filteredCandidates, overloadHints);
|
|
780
|
+
if (disambiguated)
|
|
781
|
+
return toResolveResult(disambiguated, tiered.tier);
|
|
782
|
+
}
|
|
386
783
|
if (filteredCandidates.length !== 1)
|
|
387
784
|
return null;
|
|
388
785
|
return toResolveResult(filteredCandidates[0], tiered.tier);
|
|
@@ -411,44 +808,159 @@ const extractFuncNameFromSourceId = (sourceId) => {
|
|
|
411
808
|
*/
|
|
412
809
|
const receiverKey = (scope, varName) => `${scope}\0${varName}`;
|
|
413
810
|
/**
|
|
414
|
-
*
|
|
415
|
-
* The map is keyed by `scope\0varName`
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* If exactly one unique type is found, return it. If multiple distinct types
|
|
419
|
-
* exist (true overload collision), return undefined (refuse to guess).
|
|
420
|
-
* Falls back to the file-level scope key `\0varName` (empty funcName).
|
|
811
|
+
* Build a two-level secondary index from the verified receiver map.
|
|
812
|
+
* The verified map is keyed by `scope\0varName` where scope is either
|
|
813
|
+
* "funcName@startIndex" (inside a function) or "" (file level).
|
|
814
|
+
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
|
|
421
815
|
*/
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
continue;
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
816
|
+
const buildReceiverTypeIndex = (map) => {
|
|
817
|
+
const index = new Map();
|
|
818
|
+
for (const [key, typeName] of map) {
|
|
819
|
+
const nul = key.indexOf('\0');
|
|
820
|
+
if (nul < 0)
|
|
821
|
+
continue;
|
|
822
|
+
const scope = key.slice(0, nul);
|
|
823
|
+
const varName = key.slice(nul + 1);
|
|
824
|
+
if (!varName)
|
|
825
|
+
continue;
|
|
826
|
+
if (scope !== '' && !scope.includes('@'))
|
|
827
|
+
continue;
|
|
828
|
+
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
|
|
829
|
+
let varMap = index.get(funcName);
|
|
830
|
+
if (!varMap) {
|
|
831
|
+
varMap = new Map();
|
|
832
|
+
index.set(funcName, varMap);
|
|
833
|
+
}
|
|
834
|
+
const existing = varMap.get(varName);
|
|
835
|
+
if (existing === undefined) {
|
|
836
|
+
varMap.set(varName, { kind: 'resolved', value: typeName });
|
|
837
|
+
}
|
|
838
|
+
else if (existing.kind === 'resolved' && existing.value !== typeName) {
|
|
839
|
+
varMap.set(varName, { kind: 'ambiguous' });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return index;
|
|
843
|
+
};
|
|
844
|
+
/**
|
|
845
|
+
* O(1) receiver type lookup using the pre-built secondary index.
|
|
846
|
+
* Returns the unique type name if unambiguous. Falls back to file-level scope.
|
|
847
|
+
*/
|
|
848
|
+
const lookupReceiverType = (index, funcName, varName) => {
|
|
849
|
+
const funcBucket = index.get(funcName);
|
|
850
|
+
if (funcBucket) {
|
|
851
|
+
const entry = funcBucket.get(varName);
|
|
852
|
+
if (entry?.kind === 'resolved')
|
|
853
|
+
return entry.value;
|
|
854
|
+
if (entry?.kind === 'ambiguous') {
|
|
855
|
+
// Ambiguous in this function scope — try file-level fallback
|
|
856
|
+
const fileEntry = index.get('')?.get(varName);
|
|
857
|
+
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Fallback: file-level scope (funcName "")
|
|
861
|
+
if (funcName !== '') {
|
|
862
|
+
const fileEntry = index.get('')?.get(varName);
|
|
863
|
+
if (fileEntry?.kind === 'resolved')
|
|
864
|
+
return fileEntry.value;
|
|
865
|
+
}
|
|
866
|
+
return undefined;
|
|
867
|
+
};
|
|
868
|
+
/**
|
|
869
|
+
* Resolve the type that results from accessing `receiverName.fieldName`.
|
|
870
|
+
* Requires declaredType on the Property node (needed for chain walking continuation).
|
|
871
|
+
*/
|
|
872
|
+
const resolveFieldAccessType = (receiverName, fieldName, filePath, ctx) => {
|
|
873
|
+
const fieldDef = resolveFieldOwnership(receiverName, fieldName, filePath, ctx);
|
|
874
|
+
if (!fieldDef?.declaredType)
|
|
875
|
+
return undefined;
|
|
876
|
+
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
|
|
877
|
+
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
|
|
878
|
+
return {
|
|
879
|
+
typeName: stripNullable(fieldDef.declaredType),
|
|
880
|
+
fieldNodeId: fieldDef.nodeId,
|
|
881
|
+
};
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Resolve a field's Property node given a receiver type name and field name.
|
|
885
|
+
* Does NOT require declaredType — used by write-access tracking where only the
|
|
886
|
+
* fieldNodeId is needed (no chain continuation).
|
|
887
|
+
*/
|
|
888
|
+
const resolveFieldOwnership = (receiverName, fieldName, filePath, ctx) => {
|
|
889
|
+
const typeResolved = ctx.resolve(receiverName, filePath);
|
|
890
|
+
if (!typeResolved)
|
|
891
|
+
return undefined;
|
|
892
|
+
const classDef = typeResolved.candidates.find(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
893
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl');
|
|
894
|
+
if (!classDef)
|
|
895
|
+
return undefined;
|
|
896
|
+
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
|
|
897
|
+
};
|
|
898
|
+
/**
|
|
899
|
+
* Create a deduplicated ACCESSES edge emitter for a single source node.
|
|
900
|
+
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
|
|
901
|
+
*/
|
|
902
|
+
const makeAccessEmitter = (graph, sourceId) => {
|
|
903
|
+
const emitted = new Set();
|
|
904
|
+
return (fieldNodeId) => {
|
|
905
|
+
const key = `${sourceId}\0${fieldNodeId}`;
|
|
906
|
+
if (emitted.has(key))
|
|
907
|
+
return;
|
|
908
|
+
emitted.add(key);
|
|
909
|
+
graph.addRelationship({
|
|
910
|
+
id: generateId('ACCESSES', `${sourceId}:${fieldNodeId}:read`),
|
|
911
|
+
sourceId,
|
|
912
|
+
targetId: fieldNodeId,
|
|
913
|
+
type: 'ACCESSES',
|
|
914
|
+
confidence: 1.0,
|
|
915
|
+
reason: 'read',
|
|
916
|
+
});
|
|
917
|
+
};
|
|
918
|
+
};
|
|
919
|
+
const walkMixedChain = (chain, startType, filePath, ctx, onFieldResolved) => {
|
|
920
|
+
let currentType = startType;
|
|
921
|
+
for (const step of chain) {
|
|
922
|
+
if (!currentType)
|
|
923
|
+
break;
|
|
924
|
+
if (step.kind === 'field') {
|
|
925
|
+
const resolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
926
|
+
if (!resolved) {
|
|
927
|
+
currentType = undefined;
|
|
444
928
|
break;
|
|
445
929
|
}
|
|
930
|
+
onFieldResolved?.(resolved.fieldNodeId);
|
|
931
|
+
currentType = resolved.typeName;
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
// Ruby/Python: property access is syntactically identical to method calls.
|
|
935
|
+
// Try field resolution first — if the name is a known property with declaredType,
|
|
936
|
+
// use that type directly. Otherwise fall back to method call resolution.
|
|
937
|
+
const fieldResolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
938
|
+
if (fieldResolved) {
|
|
939
|
+
onFieldResolved?.(fieldResolved.fieldNodeId);
|
|
940
|
+
currentType = fieldResolved.typeName;
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const resolved = resolveCallTarget({ calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, ctx);
|
|
944
|
+
if (!resolved) {
|
|
945
|
+
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
|
|
946
|
+
if (TYPE_PRESERVING_METHODS.has(step.name))
|
|
947
|
+
continue;
|
|
948
|
+
currentType = undefined;
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
if (!resolved.returnType) {
|
|
952
|
+
currentType = undefined;
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
const retType = extractReturnTypeName(resolved.returnType);
|
|
956
|
+
if (!retType) {
|
|
957
|
+
currentType = undefined;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
currentType = retType;
|
|
446
961
|
}
|
|
447
962
|
}
|
|
448
|
-
|
|
449
|
-
return found;
|
|
450
|
-
// Fallback: file-level scope (bindings outside any function)
|
|
451
|
-
return map.get(fileLevelKey);
|
|
963
|
+
return currentType;
|
|
452
964
|
};
|
|
453
965
|
/**
|
|
454
966
|
* Fast path: resolve pre-extracted call sites from workers.
|
|
@@ -463,7 +975,7 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
463
975
|
for (const { filePath, bindings } of constructorBindings) {
|
|
464
976
|
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
465
977
|
if (verified.size > 0) {
|
|
466
|
-
fileReceiverTypes.set(filePath, verified);
|
|
978
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
467
979
|
}
|
|
468
980
|
}
|
|
469
981
|
}
|
|
@@ -503,24 +1015,27 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
503
1015
|
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
|
|
504
1016
|
}
|
|
505
1017
|
}
|
|
506
|
-
// Step
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
|
|
514
|
-
// Use it as the starting point for chain resolution.
|
|
515
|
-
let baseType = effectiveCall.receiverTypeName;
|
|
516
|
-
// If Step 1 didn't resolve it, try the receiver map directly.
|
|
517
|
-
if (!baseType && effectiveCall.receiverName && receiverMap) {
|
|
1018
|
+
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
|
|
1019
|
+
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
|
|
1020
|
+
// type already; that type is used as the chain's starting point.
|
|
1021
|
+
if (effectiveCall.receiverMixedChain?.length) {
|
|
1022
|
+
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
|
|
1023
|
+
let currentType = effectiveCall.receiverTypeName;
|
|
1024
|
+
if (!currentType && effectiveCall.receiverName && receiverMap) {
|
|
518
1025
|
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
|
519
|
-
|
|
1026
|
+
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
|
1027
|
+
}
|
|
1028
|
+
if (!currentType && effectiveCall.receiverName) {
|
|
1029
|
+
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
|
1030
|
+
if (typeResolved?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
1031
|
+
currentType = effectiveCall.receiverName;
|
|
1032
|
+
}
|
|
520
1033
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1034
|
+
if (currentType) {
|
|
1035
|
+
const walkedType = walkMixedChain(effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx, makeAccessEmitter(graph, effectiveCall.sourceId));
|
|
1036
|
+
if (walkedType) {
|
|
1037
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
|
|
1038
|
+
}
|
|
524
1039
|
}
|
|
525
1040
|
}
|
|
526
1041
|
const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
|
|
@@ -540,6 +1055,56 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
540
1055
|
}
|
|
541
1056
|
onProgress?.(totalFiles, totalFiles);
|
|
542
1057
|
};
|
|
1058
|
+
/**
|
|
1059
|
+
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
|
|
1060
|
+
* Accepts optional constructorBindings for return-type-aware receiver inference,
|
|
1061
|
+
* mirroring processCallsFromExtracted's verified binding lookup.
|
|
1062
|
+
*/
|
|
1063
|
+
export const processAssignmentsFromExtracted = (graph, assignments, ctx, constructorBindings) => {
|
|
1064
|
+
// Build per-file receiver type indexes from verified constructor bindings
|
|
1065
|
+
const fileReceiverTypes = new Map();
|
|
1066
|
+
if (constructorBindings) {
|
|
1067
|
+
for (const { filePath, bindings } of constructorBindings) {
|
|
1068
|
+
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
1069
|
+
if (verified.size > 0) {
|
|
1070
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
for (const asn of assignments) {
|
|
1075
|
+
// Resolve the receiver type
|
|
1076
|
+
let receiverTypeName = asn.receiverTypeName;
|
|
1077
|
+
// Tier 2: verified constructor bindings (return-type inference)
|
|
1078
|
+
if (!receiverTypeName && fileReceiverTypes.size > 0) {
|
|
1079
|
+
const receiverMap = fileReceiverTypes.get(asn.filePath);
|
|
1080
|
+
if (receiverMap) {
|
|
1081
|
+
const funcName = extractFuncNameFromSourceId(asn.sourceId);
|
|
1082
|
+
receiverTypeName = lookupReceiverType(receiverMap, funcName, asn.receiverText);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// Tier 3: static class-as-receiver fallback
|
|
1086
|
+
if (!receiverTypeName) {
|
|
1087
|
+
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
|
|
1088
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
1089
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
1090
|
+
receiverTypeName = asn.receiverText;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!receiverTypeName)
|
|
1094
|
+
continue;
|
|
1095
|
+
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
|
|
1096
|
+
if (!fieldOwner)
|
|
1097
|
+
continue;
|
|
1098
|
+
graph.addRelationship({
|
|
1099
|
+
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
|
|
1100
|
+
sourceId: asn.sourceId,
|
|
1101
|
+
targetId: fieldOwner.nodeId,
|
|
1102
|
+
type: 'ACCESSES',
|
|
1103
|
+
confidence: 1.0,
|
|
1104
|
+
reason: 'write',
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
543
1108
|
/**
|
|
544
1109
|
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
|
545
1110
|
*/
|