gitnexus 1.4.5 → 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/cli/eval-server.js +13 -5
- package/dist/cli/index.js +0 -0
- package/dist/cli/tool.d.ts +3 -2
- package/dist/cli/tool.js +48 -13
- package/dist/core/graph/types.d.ts +2 -2
- package/dist/core/ingestion/call-processor.d.ts +7 -2
- package/dist/core/ingestion/call-processor.js +308 -235
- 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 +37 -8
- package/dist/core/ingestion/pipeline.js +5 -1
- package/dist/core/ingestion/symbol-table.d.ts +19 -3
- package/dist/core/ingestion/symbol-table.js +41 -2
- 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 +126 -18
- package/dist/core/ingestion/type-extractors/c-cpp.js +28 -3
- package/dist/core/ingestion/type-extractors/csharp.js +61 -7
- package/dist/core/ingestion/type-extractors/go.js +86 -10
- package/dist/core/ingestion/type-extractors/jvm.js +122 -23
- package/dist/core/ingestion/type-extractors/php.js +172 -7
- package/dist/core/ingestion/type-extractors/python.js +107 -21
- package/dist/core/ingestion/type-extractors/ruby.js +18 -3
- package/dist/core/ingestion/type-extractors/rust.js +61 -14
- package/dist/core/ingestion/type-extractors/shared.d.ts +13 -0
- package/dist/core/ingestion/type-extractors/shared.js +243 -4
- package/dist/core/ingestion/type-extractors/types.d.ts +57 -12
- package/dist/core/ingestion/type-extractors/typescript.js +52 -8
- 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 +73 -28
- 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.d.ts +1 -0
- package/dist/mcp/local/local-backend.js +25 -3
- package/dist/mcp/resources.js +11 -0
- package/dist/mcp/server.js +26 -4
- package/dist/mcp/tools.js +15 -5
- package/hooks/claude/gitnexus-hook.cjs +0 -0
- package/hooks/claude/pre-tool-use.sh +0 -0
- package/hooks/claude/session-start.sh +0 -0
- package/package.json +6 -5
- package/scripts/patch-tree-sitter-swift.cjs +0 -0
|
@@ -3,10 +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, 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
|
+
]);
|
|
10
20
|
/**
|
|
11
21
|
* Walk up the AST from a node to find the enclosing function/method.
|
|
12
22
|
* Returns null if the call is at module/file level (top-level code).
|
|
@@ -80,6 +90,7 @@ const verifyConstructorBindings = (bindings, filePath, ctx, graph) => {
|
|
|
80
90
|
export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
81
91
|
const parser = await loadParser();
|
|
82
92
|
const collectedHeritage = [];
|
|
93
|
+
const pendingWrites = [];
|
|
83
94
|
const logSkipped = isVerboseIngestionEnabled();
|
|
84
95
|
const skippedByLang = logSkipped ? new Map() : null;
|
|
85
96
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -127,10 +138,47 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
127
138
|
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
|
|
128
139
|
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
|
|
129
140
|
: new Map();
|
|
141
|
+
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
|
|
130
142
|
ctx.enableCache(file.path);
|
|
131
143
|
matches.forEach(match => {
|
|
132
144
|
const captureMap = {};
|
|
133
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
|
+
}
|
|
134
182
|
if (!captureMap['call'])
|
|
135
183
|
return;
|
|
136
184
|
const nameNode = captureMap['call.name'];
|
|
@@ -168,7 +216,10 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
168
216
|
description: item.accessorType,
|
|
169
217
|
},
|
|
170
218
|
});
|
|
171
|
-
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
|
+
});
|
|
172
223
|
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
|
|
173
224
|
graph.addRelationship({
|
|
174
225
|
id: relId, sourceId: fileId, targetId: nodeId,
|
|
@@ -176,9 +227,9 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
176
227
|
});
|
|
177
228
|
if (propEnclosingClassId) {
|
|
178
229
|
graph.addRelationship({
|
|
179
|
-
id: generateId('
|
|
230
|
+
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
|
|
180
231
|
sourceId: propEnclosingClassId, targetId: nodeId,
|
|
181
|
-
type: '
|
|
232
|
+
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
|
|
182
233
|
});
|
|
183
234
|
}
|
|
184
235
|
}
|
|
@@ -195,10 +246,10 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
195
246
|
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
|
196
247
|
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
|
197
248
|
// Fall back to verified constructor bindings for return type inference
|
|
198
|
-
if (!receiverTypeName && receiverName &&
|
|
249
|
+
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
|
|
199
250
|
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
|
|
200
251
|
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
|
|
201
|
-
receiverTypeName = lookupReceiverType(
|
|
252
|
+
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
|
|
202
253
|
}
|
|
203
254
|
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
|
|
204
255
|
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
|
|
@@ -209,30 +260,33 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
209
260
|
receiverTypeName = receiverName;
|
|
210
261
|
}
|
|
211
262
|
}
|
|
212
|
-
//
|
|
213
|
-
|
|
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.
|
|
214
269
|
if (callForm === 'member' && !receiverTypeName && !receiverName) {
|
|
215
270
|
const receiverNode = extractReceiverNode(nameNode);
|
|
216
|
-
if (receiverNode
|
|
217
|
-
const extracted =
|
|
218
|
-
if (extracted) {
|
|
219
|
-
|
|
220
|
-
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
|
|
221
275
|
? typeEnv.lookup(extracted.baseReceiverName, callNode)
|
|
222
276
|
: undefined;
|
|
223
|
-
if (!
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
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);
|
|
227
280
|
}
|
|
228
|
-
|
|
229
|
-
if (!baseType && extracted.baseReceiverName) {
|
|
281
|
+
if (!currentType && extracted.baseReceiverName) {
|
|
230
282
|
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
|
|
231
283
|
if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum')) {
|
|
232
|
-
|
|
284
|
+
currentType = extracted.baseReceiverName;
|
|
233
285
|
}
|
|
234
286
|
}
|
|
235
|
-
|
|
287
|
+
if (currentType) {
|
|
288
|
+
receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx, makeAccessEmitter(graph, sourceId));
|
|
289
|
+
}
|
|
236
290
|
}
|
|
237
291
|
}
|
|
238
292
|
}
|
|
@@ -244,8 +298,6 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
244
298
|
}, file.path, ctx);
|
|
245
299
|
if (!resolved)
|
|
246
300
|
return;
|
|
247
|
-
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
|
|
248
|
-
const sourceId = enclosingFuncId || generateId('File', file.path);
|
|
249
301
|
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
|
|
250
302
|
graph.addRelationship({
|
|
251
303
|
id: relId,
|
|
@@ -258,6 +310,21 @@ export const processCalls = async (graph, files, astCache, ctx, onProgress) => {
|
|
|
258
310
|
});
|
|
259
311
|
ctx.clearCache();
|
|
260
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
|
+
}
|
|
261
328
|
if (skippedByLang && skippedByLang.size > 0) {
|
|
262
329
|
for (const [lang, count] of skippedByLang.entries()) {
|
|
263
330
|
console.warn(`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`);
|
|
@@ -301,38 +368,8 @@ const toResolveResult = (definition, tier) => ({
|
|
|
301
368
|
nodeId: definition.nodeId,
|
|
302
369
|
confidence: TIER_CONFIDENCE[tier],
|
|
303
370
|
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
|
|
371
|
+
returnType: definition.returnType,
|
|
304
372
|
});
|
|
305
|
-
/**
|
|
306
|
-
* Resolve a chain of intermediate method calls to find the receiver type for a
|
|
307
|
-
* final member call. Called when the receiver of a call is itself a call
|
|
308
|
-
* expression (e.g. `svc.getUser().save()`).
|
|
309
|
-
*
|
|
310
|
-
* @param chainNames Ordered list of method names from outermost to innermost
|
|
311
|
-
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
|
|
312
|
-
* @param baseReceiverTypeName The already-resolved type of the base receiver
|
|
313
|
-
* (e.g. 'UserService' for `svc`), or undefined.
|
|
314
|
-
* @param currentFile The file path for resolution context.
|
|
315
|
-
* @param ctx The resolution context for symbol lookup.
|
|
316
|
-
* @returns The type name of the final intermediate call's return type, or undefined
|
|
317
|
-
* if resolution fails at any step.
|
|
318
|
-
*/
|
|
319
|
-
function resolveChainedReceiver(chainNames, baseReceiverTypeName, currentFile, ctx) {
|
|
320
|
-
let currentType = baseReceiverTypeName;
|
|
321
|
-
for (const name of chainNames) {
|
|
322
|
-
const resolved = resolveCallTarget({ calledName: name, callForm: 'member', receiverTypeName: currentType }, currentFile, ctx);
|
|
323
|
-
if (!resolved)
|
|
324
|
-
return undefined;
|
|
325
|
-
const candidates = ctx.symbols.lookupFuzzy(name);
|
|
326
|
-
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
|
|
327
|
-
if (!symDef?.returnType)
|
|
328
|
-
return undefined;
|
|
329
|
-
const returnTypeName = extractReturnTypeName(symDef.returnType);
|
|
330
|
-
if (!returnTypeName)
|
|
331
|
-
return undefined;
|
|
332
|
-
currentType = returnTypeName;
|
|
333
|
-
}
|
|
334
|
-
return currentType;
|
|
335
|
-
}
|
|
336
373
|
/**
|
|
337
374
|
* Resolve a function call to its target node ID using priority strategy:
|
|
338
375
|
* A. Narrow candidates by scope tier via ctx.resolve()
|
|
@@ -386,138 +423,6 @@ const resolveCallTarget = (call, currentFile, ctx) => {
|
|
|
386
423
|
return null;
|
|
387
424
|
return toResolveResult(filteredCandidates[0], tiered.tier);
|
|
388
425
|
};
|
|
389
|
-
// ── Return type text helpers ─────────────────────────────────────────────
|
|
390
|
-
// extractSimpleTypeName works on AST nodes; this operates on raw return-type
|
|
391
|
-
// text already stored in SymbolDefinition (e.g. "User", "Promise<User>",
|
|
392
|
-
// "User | null", "*User"). Extracts the base user-defined type name.
|
|
393
|
-
/** Primitive / built-in types that should NOT produce a receiver binding. */
|
|
394
|
-
const PRIMITIVE_TYPES = new Set([
|
|
395
|
-
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
|
|
396
|
-
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
|
|
397
|
-
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
|
|
398
|
-
'undefined', 'null', 'None', 'nil',
|
|
399
|
-
]);
|
|
400
|
-
/**
|
|
401
|
-
* Extract a simple type name from raw return-type text.
|
|
402
|
-
* Handles common patterns:
|
|
403
|
-
* "User" → "User"
|
|
404
|
-
* "Promise<User>" → "User" (unwrap wrapper generics)
|
|
405
|
-
* "Option<User>" → "User"
|
|
406
|
-
* "Result<User, Error>" → "User" (first type arg)
|
|
407
|
-
* "User | null" → "User" (strip nullable union)
|
|
408
|
-
* "User?" → "User" (strip nullable suffix)
|
|
409
|
-
* "*User" → "User" (Go pointer)
|
|
410
|
-
* "&User" → "User" (Rust reference)
|
|
411
|
-
* Returns undefined for complex types or primitives.
|
|
412
|
-
*/
|
|
413
|
-
const WRAPPER_GENERICS = new Set([
|
|
414
|
-
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers
|
|
415
|
-
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
|
|
416
|
-
'Result', 'Either', // result wrappers
|
|
417
|
-
// Rust smart pointers (Deref to inner type)
|
|
418
|
-
'Rc', 'Arc', 'Weak', // pointer types
|
|
419
|
-
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types
|
|
420
|
-
'Ref', 'RefMut', // RefCell guards
|
|
421
|
-
'Cow', // copy-on-write
|
|
422
|
-
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
|
|
423
|
-
// methods are called on the container, not the element type.
|
|
424
|
-
// Non-wrapper generics return the base type (e.g., List) via the else branch.
|
|
425
|
-
]);
|
|
426
|
-
/**
|
|
427
|
-
* Extracts the first type argument from a comma-separated generic argument string,
|
|
428
|
-
* respecting nested angle brackets. For example:
|
|
429
|
-
* "Result<User, Error>" → "Result<User, Error>" (no top-level comma)
|
|
430
|
-
* "User, Error" → "User"
|
|
431
|
-
* "Map<K, V>, string" → "Map<K, V>"
|
|
432
|
-
*/
|
|
433
|
-
function extractFirstGenericArg(args) {
|
|
434
|
-
let depth = 0;
|
|
435
|
-
for (let i = 0; i < args.length; i++) {
|
|
436
|
-
if (args[i] === '<')
|
|
437
|
-
depth++;
|
|
438
|
-
else if (args[i] === '>')
|
|
439
|
-
depth--;
|
|
440
|
-
else if (args[i] === ',' && depth === 0)
|
|
441
|
-
return args.slice(0, i).trim();
|
|
442
|
-
}
|
|
443
|
-
return args.trim();
|
|
444
|
-
}
|
|
445
|
-
/**
|
|
446
|
-
* Extract the first non-lifetime type argument from a generic argument string.
|
|
447
|
-
* Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type.
|
|
448
|
-
* "'_, User" → "User"
|
|
449
|
-
* "'a, User" → "User"
|
|
450
|
-
* "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg)
|
|
451
|
-
*/
|
|
452
|
-
function extractFirstTypeArg(args) {
|
|
453
|
-
let remaining = args;
|
|
454
|
-
while (remaining) {
|
|
455
|
-
const first = extractFirstGenericArg(remaining);
|
|
456
|
-
if (!first.startsWith("'"))
|
|
457
|
-
return first;
|
|
458
|
-
// Skip past this lifetime arg + the comma separator
|
|
459
|
-
const commaIdx = remaining.indexOf(',', first.length);
|
|
460
|
-
if (commaIdx < 0)
|
|
461
|
-
return first; // only lifetimes — fall through
|
|
462
|
-
remaining = remaining.slice(commaIdx + 1).trim();
|
|
463
|
-
}
|
|
464
|
-
return args.trim();
|
|
465
|
-
}
|
|
466
|
-
const MAX_RETURN_TYPE_INPUT_LENGTH = 2048;
|
|
467
|
-
const MAX_RETURN_TYPE_LENGTH = 512;
|
|
468
|
-
export const extractReturnTypeName = (raw, depth = 0) => {
|
|
469
|
-
if (depth > 10)
|
|
470
|
-
return undefined;
|
|
471
|
-
if (raw.length > MAX_RETURN_TYPE_INPUT_LENGTH)
|
|
472
|
-
return undefined;
|
|
473
|
-
let text = raw.trim();
|
|
474
|
-
if (!text)
|
|
475
|
-
return undefined;
|
|
476
|
-
// Strip pointer/reference prefixes: *User, &User, &mut User
|
|
477
|
-
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
|
|
478
|
-
// Strip nullable suffix: User?
|
|
479
|
-
text = text.replace(/\?$/, '');
|
|
480
|
-
// Handle union types: "User | null" → "User"
|
|
481
|
-
if (text.includes('|')) {
|
|
482
|
-
const parts = text.split('|').map(p => p.trim()).filter(p => p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil');
|
|
483
|
-
if (parts.length === 1)
|
|
484
|
-
text = parts[0];
|
|
485
|
-
else
|
|
486
|
-
return undefined; // genuine union — too complex
|
|
487
|
-
}
|
|
488
|
-
// Handle generics: Promise<User> → unwrap if wrapper, else take base
|
|
489
|
-
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
|
|
490
|
-
if (genericMatch) {
|
|
491
|
-
const [, base, args] = genericMatch;
|
|
492
|
-
if (WRAPPER_GENERICS.has(base)) {
|
|
493
|
-
// Take the first non-lifetime type argument, using bracket-balanced splitting
|
|
494
|
-
// so that nested generics like Result<User, Error> are not split at the inner
|
|
495
|
-
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
|
|
496
|
-
const firstArg = extractFirstTypeArg(args);
|
|
497
|
-
return extractReturnTypeName(firstArg, depth + 1);
|
|
498
|
-
}
|
|
499
|
-
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
|
|
500
|
-
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
|
|
501
|
-
}
|
|
502
|
-
// Bare wrapper type without generic argument (e.g. Task, Promise, Option)
|
|
503
|
-
// should not produce a binding — these are meaningless without a type parameter
|
|
504
|
-
if (WRAPPER_GENERICS.has(text))
|
|
505
|
-
return undefined;
|
|
506
|
-
// Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User
|
|
507
|
-
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
|
|
508
|
-
text = text.split(/::|[.\\]/).pop();
|
|
509
|
-
}
|
|
510
|
-
// Final check: skip primitives
|
|
511
|
-
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase()))
|
|
512
|
-
return undefined;
|
|
513
|
-
// Must start with uppercase (class/type convention) or be a valid identifier
|
|
514
|
-
if (!/^[A-Z_]\w*$/.test(text))
|
|
515
|
-
return undefined;
|
|
516
|
-
// If the final extracted type name is too long, reject it
|
|
517
|
-
if (text.length > MAX_RETURN_TYPE_LENGTH)
|
|
518
|
-
return undefined;
|
|
519
|
-
return text;
|
|
520
|
-
};
|
|
521
426
|
// ── Scope key helpers ────────────────────────────────────────────────────
|
|
522
427
|
// Scope keys use the format "funcName@startIndex" (produced by type-env.ts).
|
|
523
428
|
// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
|
|
@@ -542,44 +447,159 @@ const extractFuncNameFromSourceId = (sourceId) => {
|
|
|
542
447
|
*/
|
|
543
448
|
const receiverKey = (scope, varName) => `${scope}\0${varName}`;
|
|
544
449
|
/**
|
|
545
|
-
*
|
|
546
|
-
* The map is keyed by `scope\0varName`
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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).
|
|
552
510
|
*/
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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).
|
|
526
|
+
*/
|
|
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;
|
|
572
589
|
}
|
|
573
|
-
|
|
574
|
-
|
|
590
|
+
if (!resolved.returnType) {
|
|
591
|
+
currentType = undefined;
|
|
575
592
|
break;
|
|
576
593
|
}
|
|
594
|
+
const retType = extractReturnTypeName(resolved.returnType);
|
|
595
|
+
if (!retType) {
|
|
596
|
+
currentType = undefined;
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
currentType = retType;
|
|
577
600
|
}
|
|
578
601
|
}
|
|
579
|
-
|
|
580
|
-
return found;
|
|
581
|
-
// Fallback: file-level scope (bindings outside any function)
|
|
582
|
-
return map.get(fileLevelKey);
|
|
602
|
+
return currentType;
|
|
583
603
|
};
|
|
584
604
|
/**
|
|
585
605
|
* Fast path: resolve pre-extracted call sites from workers.
|
|
@@ -594,7 +614,7 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
594
614
|
for (const { filePath, bindings } of constructorBindings) {
|
|
595
615
|
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
|
|
596
616
|
if (verified.size > 0) {
|
|
597
|
-
fileReceiverTypes.set(filePath, verified);
|
|
617
|
+
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
|
|
598
618
|
}
|
|
599
619
|
}
|
|
600
620
|
}
|
|
@@ -634,24 +654,27 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
634
654
|
effectiveCall = { ...effectiveCall, receiverTypeName: effectiveCall.receiverName };
|
|
635
655
|
}
|
|
636
656
|
}
|
|
637
|
-
// Step
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
|
|
645
|
-
// Use it as the starting point for chain resolution.
|
|
646
|
-
let baseType = effectiveCall.receiverTypeName;
|
|
647
|
-
// If Step 1 didn't resolve it, try the receiver map directly.
|
|
648
|
-
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) {
|
|
649
664
|
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
|
|
650
|
-
|
|
665
|
+
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
|
|
651
666
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
+
}
|
|
655
678
|
}
|
|
656
679
|
}
|
|
657
680
|
const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx);
|
|
@@ -671,6 +694,56 @@ export const processCallsFromExtracted = async (graph, extractedCalls, ctx, onPr
|
|
|
671
694
|
}
|
|
672
695
|
onProgress?.(totalFiles, totalFiles);
|
|
673
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
|
+
};
|
|
674
747
|
/**
|
|
675
748
|
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
|
|
676
749
|
*/
|