gitnexus 1.4.6 → 1.4.7
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/dist/core/graph/types.d.ts +2 -2
- package/dist/core/ingestion/call-processor.d.ts +7 -1
- package/dist/core/ingestion/call-processor.js +308 -104
- package/dist/core/ingestion/call-routing.d.ts +17 -2
- package/dist/core/ingestion/call-routing.js +21 -0
- package/dist/core/ingestion/parsing-processor.d.ts +2 -1
- package/dist/core/ingestion/parsing-processor.js +32 -6
- package/dist/core/ingestion/pipeline.js +5 -1
- package/dist/core/ingestion/symbol-table.d.ts +13 -3
- package/dist/core/ingestion/symbol-table.js +23 -4
- package/dist/core/ingestion/tree-sitter-queries.d.ts +12 -12
- package/dist/core/ingestion/tree-sitter-queries.js +200 -0
- package/dist/core/ingestion/type-env.js +94 -38
- package/dist/core/ingestion/type-extractors/c-cpp.js +27 -2
- package/dist/core/ingestion/type-extractors/csharp.js +40 -0
- package/dist/core/ingestion/type-extractors/go.js +45 -0
- package/dist/core/ingestion/type-extractors/jvm.js +75 -3
- 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 +37 -3
- package/dist/core/ingestion/type-extractors/shared.d.ts +12 -0
- package/dist/core/ingestion/type-extractors/shared.js +110 -3
- package/dist/core/ingestion/type-extractors/types.d.ts +17 -4
- package/dist/core/ingestion/type-extractors/typescript.js +30 -0
- package/dist/core/ingestion/utils.d.ts +25 -0
- package/dist/core/ingestion/utils.js +160 -1
- package/dist/core/ingestion/workers/parse-worker.d.ts +23 -7
- package/dist/core/ingestion/workers/parse-worker.js +68 -26
- package/dist/core/lbug/lbug-adapter.d.ts +2 -0
- package/dist/core/lbug/lbug-adapter.js +2 -0
- package/dist/core/lbug/schema.d.ts +1 -1
- package/dist/core/lbug/schema.js +1 -1
- package/dist/mcp/core/lbug-adapter.d.ts +22 -0
- package/dist/mcp/core/lbug-adapter.js +167 -23
- package/dist/mcp/local/local-backend.js +3 -3
- package/dist/mcp/resources.js +11 -0
- package/dist/mcp/server.js +26 -4
- package/dist/mcp/tools.js +15 -5
- package/package.json +4 -4
|
@@ -25,7 +25,7 @@ export type NodeProperties = {
|
|
|
25
25
|
parameterCount?: number;
|
|
26
26
|
returnType?: string;
|
|
27
27
|
};
|
|
28
|
-
export type RelationshipType = 'CONTAINS' | 'CALLS' | 'INHERITS' | 'OVERRIDES' | 'IMPORTS' | 'USES' | 'DEFINES' | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' | 'HAS_METHOD' | 'MEMBER_OF' | 'STEP_IN_PROCESS';
|
|
28
|
+
export type RelationshipType = 'CONTAINS' | 'CALLS' | 'INHERITS' | 'OVERRIDES' | 'IMPORTS' | 'USES' | 'DEFINES' | 'DECORATES' | 'IMPLEMENTS' | 'EXTENDS' | 'HAS_METHOD' | 'HAS_PROPERTY' | 'ACCESSES' | 'MEMBER_OF' | 'STEP_IN_PROCESS';
|
|
29
29
|
export interface GraphNode {
|
|
30
30
|
id: string;
|
|
31
31
|
label: NodeLabel;
|
|
@@ -38,7 +38,7 @@ export interface GraphRelationship {
|
|
|
38
38
|
type: RelationshipType;
|
|
39
39
|
/** Confidence score 0-1 (1.0 = certain, lower = uncertain resolution) */
|
|
40
40
|
confidence: number;
|
|
41
|
-
/**
|
|
41
|
+
/** Semantics are edge-type-dependent: CALLS uses resolution tier, ACCESSES uses 'read'/'write', OVERRIDES uses MRO reason */
|
|
42
42
|
reason: string;
|
|
43
43
|
/** Step number for STEP_IN_PROCESS relationships (1-indexed) */
|
|
44
44
|
step?: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { KnowledgeGraph } from '../graph/types.js';
|
|
2
2
|
import { ASTCache } from './ast-cache.js';
|
|
3
3
|
import type { ResolutionContext } from './resolution-context.js';
|
|
4
|
-
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
|
4
|
+
import type { ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
|
|
5
5
|
export declare const processCalls: (graph: KnowledgeGraph, files: {
|
|
6
6
|
path: string;
|
|
7
7
|
content: string;
|
|
@@ -11,6 +11,12 @@ export declare const processCalls: (graph: KnowledgeGraph, files: {
|
|
|
11
11
|
* No AST parsing — workers already extracted calledName + sourceId.
|
|
12
12
|
*/
|
|
13
13
|
export declare const processCallsFromExtracted: (graph: KnowledgeGraph, extractedCalls: ExtractedCall[], ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, constructorBindings?: FileConstructorBindings[]) => Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
|
|
16
|
+
* Accepts optional constructorBindings for return-type-aware receiver inference,
|
|
17
|
+
* mirroring processCallsFromExtracted's verified binding lookup.
|
|
18
|
+
*/
|
|
19
|
+
export declare const processAssignmentsFromExtracted: (graph: KnowledgeGraph, assignments: ExtractedAssignment[], ctx: ResolutionContext, constructorBindings?: FileConstructorBindings[]) => void;
|
|
14
20
|
/**
|
|
15
21
|
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
|
16
22
|
*/
|
|
@@ -3,11 +3,20 @@ 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,
|
|
6
|
+
import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, FUNCTION_NODE_TYPES, extractFunctionName, isBuiltInOrNoise, countCallArguments, inferCallForm, extractReceiverName, extractReceiverNode, findEnclosingClassId, extractMixedChain, } from './utils.js';
|
|
7
7
|
import { buildTypeEnv } 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
|
+
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
|
|
12
|
+
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
|
|
13
|
+
// for type resolution — the current type passes through unchanged.
|
|
14
|
+
const TYPE_PRESERVING_METHODS = new Set([
|
|
15
|
+
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
|
|
16
|
+
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
|
|
17
|
+
'get', // Kotlin/Java Optional.get()
|
|
18
|
+
'orElseThrow', // Java Optional
|
|
19
|
+
]);
|
|
11
20
|
/**
|
|
12
21
|
* Walk up the AST from a node to find the enclosing function/method.
|
|
13
22
|
* Returns null if the call is at module/file level (top-level code).
|
|
@@ -81,6 +90,7 @@ const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
|
|
|
81
90
|
export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
82
91
|
const parser = await loadParser();
|
|
83
92
|
const collectedHeritage = [];
|
|
93
|
+
const pendingWrites = [];
|
|
84
94
|
const logSkipped = isVerboseIngestionEnabled();
|
|
85
95
|
const skippedByLang = logSkipped ? new Map() : null;
|
|
86
96
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -128,10 +138,47 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
128
138
|
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
|
129
139
|
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
|
130
140
|
: new Map();
|
|
141
|
+
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
|
|
131
142
|
ctx.enableCache(file.path);
|
|
132
143
|
matches.forEach(match => {
|
|
133
144
|
const captureMap = {};
|
|
134
145
|
match.captures.forEach(c => captureMap[c.name] = c.node);
|
|
146
|
+
// ── Write access: emit ACCESSES {reason: 'write'} for assignments to member fields ──
|
|
147
|
+
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
|
|
148
|
+
const receiverNode = captureMap['assignment.receiver'];
|
|
149
|
+
const propertyName = captureMap['assignment.property'].text;
|
|
150
|
+
// Resolve receiver type: simple identifier → TypeEnv lookup or class resolution
|
|
151
|
+
let receiverTypeName;
|
|
152
|
+
const receiverText = receiverNode.text;
|
|
153
|
+
if (receiverText && typeEnv) {
|
|
154
|
+
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']);
|
|
155
|
+
}
|
|
156
|
+
// Fall back to verified constructor bindings (mirrors CALLS resolution tier 2)
|
|
157
|
+
if (!receiverTypeName && receiverText && receiverIndex.size > 0) {
|
|
158
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
159
|
+
const funcName = enclosing ? extractFuncNameFromSourceId(enclosing) : '';
|
|
160
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverText);
|
|
161
|
+
}
|
|
162
|
+
if (!receiverTypeName && receiverText) {
|
|
163
|
+
const resolved = ctx.resolve(receiverText, file.path);
|
|
164
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
165
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
166
|
+
receiverTypeName = receiverText;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (receiverTypeName) {
|
|
170
|
+
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
|
|
171
|
+
const srcId = enclosing || generateId('File', file.path);
|
|
172
|
+
// Defer resolution: Ruby attr_accessor properties are registered during
|
|
173
|
+
// this same loop, so cross-file lookups fail if the declaring file hasn't
|
|
174
|
+
// been processed yet. Collect now, resolve after all files are done.
|
|
175
|
+
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
|
|
176
|
+
}
|
|
177
|
+
// Assignment-only capture (no @call sibling): skip the rest of this
|
|
178
|
+
// forEach iteration — this acts as a `continue` in the match loop.
|
|
179
|
+
if (!captureMap['call'])
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
135
182
|
if (!captureMap['call'])
|
|
136
183
|
return;
|
|
137
184
|
const nameNode = captureMap['call.name'];
|
|
@@ -169,7 +216,10 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
169
216
|
description: item.accessorType,
|
|
170
217
|
},
|
|
171
218
|
});
|
|
172
|
-
ctx.symbols.add(file.path, item.propName, nodeId, 'Property',
|
|
219
|
+
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
|
|
220
|
+
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
|
|
221
|
+
...(item.declaredType ? { declaredType: item.declaredType } : {}),
|
|
222
|
+
});
|
|
173
223
|
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
|
174
224
|
graph.addRelationship({
|
|
175
225
|
id: relId, sourceId: fileId, targetId: nodeId,
|
|
@@ -177,9 +227,9 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
177
227
|
});
|
|
178
228
|
if (propEnclosingClassId) {
|
|
179
229
|
graph.addRelationship({
|
|
180
|
-
id: generateId('
|
|
230
|
+
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
|
181
231
|
sourceId: propEnclosingClassId, targetId: nodeId,
|
|
182
|
-
type: '
|
|
232
|
+
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
|
|
183
233
|
});
|
|
184
234
|
}
|
|
185
235
|
}
|
|
@@ -196,10 +246,10 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
196
246
|
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
|
197
247
|
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
|
198
248
|
// Fall back to verified constructor bindings for return type inference
|
|
199
|
-
if (!receiverTypeName && receiverName &&
|
|
249
|
+
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
|
200
250
|
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
201
251
|
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
202
|
-
receiverTypeName = lookupReceiverType(
|
|
252
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
|
|
203
253
|
}
|
|
204
254
|
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
|
|
205
255
|
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
|
|
@@ -210,30 +260,33 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
210
260
|
receiverTypeName = receiverName;
|
|
211
261
|
}
|
|
212
262
|
}
|
|
213
|
-
//
|
|
214
|
-
|
|
263
|
+
// Hoist sourceId so it's available for ACCESSES edge emission during chain walk.
|
|
264
|
+
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
265
|
+
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
266
|
+
// Fall back to mixed chain resolution when the receiver is a complex expression
|
|
267
|
+
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
|
|
268
|
+
// svc.getUser().address.save()). Handles all cases with a single unified walk.
|
|
215
269
|
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
|
216
270
|
const receiverNode = extractReceiverNode(nameNode);
|
|
217
|
-
if (receiverNode
|
|
218
|
-
const extracted =
|
|
219
|
-
if (extracted) {
|
|
220
|
-
|
|
221
|
-
let baseType = extracted.baseReceiverName && typeEnv
|
|
271
|
+
if (receiverNode) {
|
|
272
|
+
const extracted = extractMixedChain(receiverNode);
|
|
273
|
+
if (extracted && extracted.chain.length > 0) {
|
|
274
|
+
let currentType = extracted.baseReceiverName && typeEnv
|
|
222
275
|
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
|
223
276
|
: undefined;
|
|
224
|
-
if (!
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
|
|
277
|
+
if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) {
|
|
278
|
+
const funcName = enclosingFuncId ? extractFuncNameFromSourceId(enclosingFuncId) : '';
|
|
279
|
+
currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName);
|
|
228
280
|
}
|
|
229
|
-
|
|
230
|
-
if (!baseType && extracted.baseReceiverName) {
|
|
281
|
+
if (!currentType && extracted.baseReceiverName) {
|
|
231
282
|
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
|
232
283
|
if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
233
|
-
|
|
284
|
+
currentType = extracted.baseReceiverName;
|
|
234
285
|
}
|
|
235
286
|
}
|
|
236
|
-
|
|
287
|
+
if (currentType) {
|
|
288
|
+
receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx, makeAccessEmitter(graph, sourceId));
|
|
289
|
+
}
|
|
237
290
|
}
|
|
238
291
|
}
|
|
239
292
|
}
|
|
@@ -245,8 +298,6 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
245
298
|
}, file.path, ctx);
|
|
246
299
|
if (!resolved)
|
|
247
300
|
return;
|
|
248
|
-
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
249
|
-
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
250
301
|
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
|
251
302
|
graph.addRelationship({
|
|
252
303
|
id: relId,
|
|
@@ -259,6 +310,21 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
259
310
|
});
|
|
260
311
|
ctx.clearCache();
|
|
261
312
|
}
|
|
313
|
+
// ── Resolve deferred write-access edges ──
|
|
314
|
+
// All properties (including Ruby attr_accessor) are now registered.
|
|
315
|
+
for (const pw of pendingWrites) {
|
|
316
|
+
const fieldOwner = resolveFieldOwnership(pw.receiverTypeName, pw.propertyName, pw.filePath, ctx);
|
|
317
|
+
if (fieldOwner) {
|
|
318
|
+
graph.addRelationship({
|
|
319
|
+
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
|
|
320
|
+
sourceId: pw.srcId,
|
|
321
|
+
targetId: fieldOwner.nodeId,
|
|
322
|
+
type: 'ACCESSES',
|
|
323
|
+
confidence: 1.0,
|
|
324
|
+
reason: 'write',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
262
328
|
if (skippedByLang && skippedByLang.size > 0) {
|
|
263
329
|
for (const [lang, count] of skippedByLang.entries()) {
|
|
264
330
|
console.warn(`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`);
|
|
@@ -302,38 +368,8 @@ const toResolveResult = (definition, tier) => ({
|
|
|
302
368
|
nodeId: definition.nodeId,
|
|
303
369
|
confidence: TIER_CONFIDENCE[tier],
|
|
304
370
|
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
|
371
|
+
returnType: definition.returnType,
|
|
305
372
|
});
|
|
306
|
-
/**
|
|
307
|
-
* Resolve a chain of intermediate method calls to find the receiver type for a
|
|
308
|
-
* final member call. Called when the receiver of a call is itself a call
|
|
309
|
-
* expression (e.g. `svc.getUser().save()`).
|
|
310
|
-
*
|
|
311
|
-
* @param chainNames Ordered list of method names from outermost to innermost
|
|
312
|
-
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
|
|
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.
|
|
319
|
-
*/
|
|
320
|
-
function resolveChainedReceiver(chainNames, baseReceiverTypeName, currentFile, ctx) {
|
|
321
|
-
let currentType = baseReceiverTypeName;
|
|
322
|
-
for (const name of chainNames) {
|
|
323
|
-
const resolved = resolveCallTarget({ calledName: name, callForm: 'member', receiverTypeName: currentType }, currentFile, ctx);
|
|
324
|
-
if (!resolved)
|
|
325
|
-
return undefined;
|
|
326
|
-
const candidates = ctx.symbols.lookupFuzzy(name);
|
|
327
|
-
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
|
|
328
|
-
if (!symDef?.returnType)
|
|
329
|
-
return undefined;
|
|
330
|
-
const returnTypeName = extractReturnTypeName(symDef.returnType);
|
|
331
|
-
if (!returnTypeName)
|
|
332
|
-
return undefined;
|
|
333
|
-
currentType = returnTypeName;
|
|
334
|
-
}
|
|
335
|
-
return currentType;
|
|
336
|
-
}
|
|
337
373
|
/**
|
|
338
374
|
* Resolve a function call to its target node ID using priority strategy:
|
|
339
375
|
* A. Narrow candidates by scope tier via ctx.resolve()
|
|
@@ -411,44 +447,159 @@ const extractFuncNameFromSourceId = (sourceId) => {
|
|
|
411
447
|
*/
|
|
412
448
|
const receiverKey = (scope, varName) => `${scope}\0${varName}`;
|
|
413
449
|
/**
|
|
414
|
-
*
|
|
415
|
-
* The map is keyed by `scope\0varName`
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
450
|
+
* Build a two-level secondary index from the verified receiver map.
|
|
451
|
+
* The verified map is keyed by `scope\0varName` where scope is either
|
|
452
|
+
* "funcName@startIndex" (inside a function) or "" (file level).
|
|
453
|
+
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
|
|
454
|
+
*/
|
|
455
|
+
const buildReceiverTypeIndex = (map) => {
|
|
456
|
+
const index = new Map();
|
|
457
|
+
for (const [key, typeName] of map) {
|
|
458
|
+
const nul = key.indexOf('\0');
|
|
459
|
+
if (nul < 0)
|
|
460
|
+
continue;
|
|
461
|
+
const scope = key.slice(0, nul);
|
|
462
|
+
const varName = key.slice(nul + 1);
|
|
463
|
+
if (!varName)
|
|
464
|
+
continue;
|
|
465
|
+
if (scope !== '' && !scope.includes('@'))
|
|
466
|
+
continue;
|
|
467
|
+
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
|
|
468
|
+
let varMap = index.get(funcName);
|
|
469
|
+
if (!varMap) {
|
|
470
|
+
varMap = new Map();
|
|
471
|
+
index.set(funcName, varMap);
|
|
472
|
+
}
|
|
473
|
+
const existing = varMap.get(varName);
|
|
474
|
+
if (existing === undefined) {
|
|
475
|
+
varMap.set(varName, { kind: 'resolved', value: typeName });
|
|
476
|
+
}
|
|
477
|
+
else if (existing.kind === 'resolved' && existing.value !== typeName) {
|
|
478
|
+
varMap.set(varName, { kind: 'ambiguous' });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return index;
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* O(1) receiver type lookup using the pre-built secondary index.
|
|
485
|
+
* Returns the unique type name if unambiguous. Falls back to file-level scope.
|
|
486
|
+
*/
|
|
487
|
+
const lookupReceiverType = (index, funcName, varName) => {
|
|
488
|
+
const funcBucket = index.get(funcName);
|
|
489
|
+
if (funcBucket) {
|
|
490
|
+
const entry = funcBucket.get(varName);
|
|
491
|
+
if (entry?.kind === 'resolved')
|
|
492
|
+
return entry.value;
|
|
493
|
+
if (entry?.kind === 'ambiguous') {
|
|
494
|
+
// Ambiguous in this function scope — try file-level fallback
|
|
495
|
+
const fileEntry = index.get('')?.get(varName);
|
|
496
|
+
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Fallback: file-level scope (funcName "")
|
|
500
|
+
if (funcName !== '') {
|
|
501
|
+
const fileEntry = index.get('')?.get(varName);
|
|
502
|
+
if (fileEntry?.kind === 'resolved')
|
|
503
|
+
return fileEntry.value;
|
|
504
|
+
}
|
|
505
|
+
return undefined;
|
|
506
|
+
};
|
|
507
|
+
/**
|
|
508
|
+
* Resolve the type that results from accessing `receiverName.fieldName`.
|
|
509
|
+
* Requires declaredType on the Property node (needed for chain walking continuation).
|
|
510
|
+
*/
|
|
511
|
+
const resolveFieldAccessType = (receiverName, fieldName, filePath, ctx) => {
|
|
512
|
+
const fieldDef = resolveFieldOwnership(receiverName, fieldName, filePath, ctx);
|
|
513
|
+
if (!fieldDef?.declaredType)
|
|
514
|
+
return undefined;
|
|
515
|
+
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
|
|
516
|
+
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
|
|
517
|
+
return {
|
|
518
|
+
typeName: stripNullable(fieldDef.declaredType),
|
|
519
|
+
fieldNodeId: fieldDef.nodeId,
|
|
520
|
+
};
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Resolve a field's Property node given a receiver type name and field name.
|
|
524
|
+
* Does NOT require declaredType — used by write-access tracking where only the
|
|
525
|
+
* fieldNodeId is needed (no chain continuation).
|
|
421
526
|
*/
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
527
|
+
const resolveFieldOwnership = (receiverName, fieldName, filePath, ctx) => {
|
|
528
|
+
const typeResolved = ctx.resolve(receiverName, filePath);
|
|
529
|
+
if (!typeResolved)
|
|
530
|
+
return undefined;
|
|
531
|
+
const classDef = typeResolved.candidates.find(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
532
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl');
|
|
533
|
+
if (!classDef)
|
|
534
|
+
return undefined;
|
|
535
|
+
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
|
|
536
|
+
};
|
|
537
|
+
/**
|
|
538
|
+
* Create a deduplicated ACCESSES edge emitter for a single source node.
|
|
539
|
+
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
|
|
540
|
+
*/
|
|
541
|
+
const makeAccessEmitter = (graph, sourceId) => {
|
|
542
|
+
const emitted = new Set();
|
|
543
|
+
return (fieldNodeId) => {
|
|
544
|
+
const key = `${sourceId}\0${fieldNodeId}`;
|
|
545
|
+
if (emitted.has(key))
|
|
546
|
+
return;
|
|
547
|
+
emitted.add(key);
|
|
548
|
+
graph.addRelationship({
|
|
549
|
+
id: generateId('ACCESSES', `${sourceId}:${fieldNodeId}:read`),
|
|
550
|
+
sourceId,
|
|
551
|
+
targetId: fieldNodeId,
|
|
552
|
+
type: 'ACCESSES',
|
|
553
|
+
confidence: 1.0,
|
|
554
|
+
reason: 'read',
|
|
555
|
+
});
|
|
556
|
+
};
|
|
557
|
+
};
|
|
558
|
+
const walkMixedChain = (chain, startType, filePath, ctx, onFieldResolved) => {
|
|
559
|
+
let currentType = startType;
|
|
560
|
+
for (const step of chain) {
|
|
561
|
+
if (!currentType)
|
|
562
|
+
break;
|
|
563
|
+
if (step.kind === 'field') {
|
|
564
|
+
const resolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
565
|
+
if (!resolved) {
|
|
566
|
+
currentType = undefined;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
onFieldResolved?.(resolved.fieldNodeId);
|
|
570
|
+
currentType = resolved.typeName;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
// Ruby/Python: property access is syntactically identical to method calls.
|
|
574
|
+
// Try field resolution first — if the name is a known property with declaredType,
|
|
575
|
+
// use that type directly. Otherwise fall back to method call resolution.
|
|
576
|
+
const fieldResolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
|
|
577
|
+
if (fieldResolved) {
|
|
578
|
+
onFieldResolved?.(fieldResolved.fieldNodeId);
|
|
579
|
+
currentType = fieldResolved.typeName;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const resolved = resolveCallTarget({ calledName: step.name, callForm: 'member', receiverTypeName: currentType }, filePath, ctx);
|
|
583
|
+
if (!resolved) {
|
|
584
|
+
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
|
|
585
|
+
if (TYPE_PRESERVING_METHODS.has(step.name))
|
|
586
|
+
continue;
|
|
587
|
+
currentType = undefined;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
if (!resolved.returnType) {
|
|
591
|
+
currentType = undefined;
|
|
592
|
+
break;
|
|
441
593
|
}
|
|
442
|
-
|
|
443
|
-
|
|
594
|
+
const retType = extractReturnTypeName(resolved.returnType);
|
|
595
|
+
if (!retType) {
|
|
596
|
+
currentType = undefined;
|
|
444
597
|
break;
|
|
445
598
|
}
|
|
599
|
+
currentType = retType;
|
|
446
600
|
}
|
|
447
601
|
}
|
|
448
|
-
|
|
449
|
-
return found;
|
|
450
|
-
// Fallback: file-level scope (bindings outside any function)
|
|
451
|
-
return map.get(fileLevelKey);
|
|
602
|
+
return currentType;
|
|
452
603
|
};
|
|
453
604
|
/**
|
|
454
605
|
* Fast path: resolve pre-extracted call sites from workers.
|
|
@@ -463,7 +614,7 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
463
614
|
for (const { filePath, bindings } of constructorBindings) {
|
|
464
615
|
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
465
616
|
if (verified.size > 0) {
|
|
466
|
-
fileReceiverTypes.set(filePath, verified);
|
|
617
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
467
618
|
}
|
|
468
619
|
}
|
|
469
620
|
}
|
|
@@ -503,24 +654,27 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
503
654
|
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
|
|
504
655
|
}
|
|
505
656
|
}
|
|
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) {
|
|
657
|
+
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
|
|
658
|
+
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
|
|
659
|
+
// type already; that type is used as the chain's starting point.
|
|
660
|
+
if (effectiveCall.receiverMixedChain?.length) {
|
|
661
|
+
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
|
|
662
|
+
let currentType = effectiveCall.receiverTypeName;
|
|
663
|
+
if (!currentType && effectiveCall.receiverName && receiverMap) {
|
|
518
664
|
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
|
519
|
-
|
|
665
|
+
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
|
520
666
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
667
|
+
if (!currentType && effectiveCall.receiverName) {
|
|
668
|
+
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
|
|
669
|
+
if (typeResolved?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
670
|
+
currentType = effectiveCall.receiverName;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (currentType) {
|
|
674
|
+
const walkedType = walkMixedChain(effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx, makeAccessEmitter(graph, effectiveCall.sourceId));
|
|
675
|
+
if (walkedType) {
|
|
676
|
+
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
|
|
677
|
+
}
|
|
524
678
|
}
|
|
525
679
|
}
|
|
526
680
|
const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
|
|
@@ -540,6 +694,56 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
540
694
|
}
|
|
541
695
|
onProgress?.(totalFiles, totalFiles);
|
|
542
696
|
};
|
|
697
|
+
/**
|
|
698
|
+
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
|
|
699
|
+
* Accepts optional constructorBindings for return-type-aware receiver inference,
|
|
700
|
+
* mirroring processCallsFromExtracted's verified binding lookup.
|
|
701
|
+
*/
|
|
702
|
+
export const processAssignmentsFromExtracted = (graph, assignments, ctx, constructorBindings) => {
|
|
703
|
+
// Build per-file receiver type indexes from verified constructor bindings
|
|
704
|
+
const fileReceiverTypes = new Map();
|
|
705
|
+
if (constructorBindings) {
|
|
706
|
+
for (const { filePath, bindings } of constructorBindings) {
|
|
707
|
+
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
708
|
+
if (verified.size > 0) {
|
|
709
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
for (const asn of assignments) {
|
|
714
|
+
// Resolve the receiver type
|
|
715
|
+
let receiverTypeName = asn.receiverTypeName;
|
|
716
|
+
// Tier 2: verified constructor bindings (return-type inference)
|
|
717
|
+
if (!receiverTypeName && fileReceiverTypes.size > 0) {
|
|
718
|
+
const receiverMap = fileReceiverTypes.get(asn.filePath);
|
|
719
|
+
if (receiverMap) {
|
|
720
|
+
const funcName = extractFuncNameFromSourceId(asn.sourceId);
|
|
721
|
+
receiverTypeName = lookupReceiverType(receiverMap, funcName, asn.receiverText);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Tier 3: static class-as-receiver fallback
|
|
725
|
+
if (!receiverTypeName) {
|
|
726
|
+
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
|
|
727
|
+
if (resolved?.candidates.some(d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|
|
728
|
+
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl')) {
|
|
729
|
+
receiverTypeName = asn.receiverText;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (!receiverTypeName)
|
|
733
|
+
continue;
|
|
734
|
+
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
|
|
735
|
+
if (!fieldOwner)
|
|
736
|
+
continue;
|
|
737
|
+
graph.addRelationship({
|
|
738
|
+
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
|
|
739
|
+
sourceId: asn.sourceId,
|
|
740
|
+
targetId: fieldOwner.nodeId,
|
|
741
|
+
type: 'ACCESSES',
|
|
742
|
+
confidence: 1.0,
|
|
743
|
+
reason: 'write',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
};
|
|
543
747
|
/**
|
|
544
748
|
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
|
545
749
|
*/
|
|
@@ -10,12 +10,25 @@
|
|
|
10
10
|
* two packages have separate build targets (Node native vs WASM/browser).
|
|
11
11
|
* Keep both copies in sync until a shared package is introduced.
|
|
12
12
|
*/
|
|
13
|
-
import { SupportedLanguages } from '../../config/supported-languages.js';
|
|
14
13
|
/** null = this call was not routed; fall through to default call handling */
|
|
15
14
|
export type CallRoutingResult = RubyCallRouting | null;
|
|
16
15
|
export type CallRouter = (calledName: string, callNode: any) => CallRoutingResult;
|
|
17
16
|
/** Per-language call routing. noRouting = no special routing (normal call processing) */
|
|
18
|
-
export declare const callRouters:
|
|
17
|
+
export declare const callRouters: {
|
|
18
|
+
javascript: CallRouter;
|
|
19
|
+
typescript: CallRouter;
|
|
20
|
+
python: CallRouter;
|
|
21
|
+
java: CallRouter;
|
|
22
|
+
kotlin: CallRouter;
|
|
23
|
+
go: CallRouter;
|
|
24
|
+
rust: CallRouter;
|
|
25
|
+
csharp: CallRouter;
|
|
26
|
+
php: CallRouter;
|
|
27
|
+
swift: CallRouter;
|
|
28
|
+
cpp: CallRouter;
|
|
29
|
+
c: CallRouter;
|
|
30
|
+
ruby: typeof routeRubyCall;
|
|
31
|
+
};
|
|
19
32
|
export type RubyCallRouting = {
|
|
20
33
|
kind: 'import';
|
|
21
34
|
importPath: string;
|
|
@@ -42,6 +55,8 @@ export interface RubyPropertyItem {
|
|
|
42
55
|
accessorType: RubyAccessorType;
|
|
43
56
|
startLine: number;
|
|
44
57
|
endLine: number;
|
|
58
|
+
/** YARD @return [Type] annotation preceding the attr_accessor call */
|
|
59
|
+
declaredType?: string;
|
|
45
60
|
}
|
|
46
61
|
/**
|
|
47
62
|
* Classify a Ruby call node and extract its semantic payload.
|