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.
Files changed (99) hide show
  1. package/README.md +22 -1
  2. package/dist/cli/ai-context.d.ts +1 -1
  3. package/dist/cli/ai-context.js +1 -1
  4. package/dist/cli/analyze.d.ts +2 -0
  5. package/dist/cli/analyze.js +54 -21
  6. package/dist/cli/index.js +2 -1
  7. package/dist/cli/setup.js +78 -1
  8. package/dist/config/supported-languages.d.ts +30 -0
  9. package/dist/config/supported-languages.js +30 -0
  10. package/dist/core/embeddings/embedder.d.ts +6 -1
  11. package/dist/core/embeddings/embedder.js +65 -5
  12. package/dist/core/embeddings/embedding-pipeline.js +11 -9
  13. package/dist/core/embeddings/http-client.d.ts +31 -0
  14. package/dist/core/embeddings/http-client.js +179 -0
  15. package/dist/core/embeddings/index.d.ts +1 -0
  16. package/dist/core/embeddings/index.js +1 -0
  17. package/dist/core/embeddings/types.d.ts +1 -1
  18. package/dist/core/graph/types.d.ts +4 -3
  19. package/dist/core/ingestion/ast-helpers.d.ts +80 -0
  20. package/dist/core/ingestion/ast-helpers.js +738 -0
  21. package/dist/core/ingestion/call-analysis.d.ts +73 -0
  22. package/dist/core/ingestion/call-analysis.js +490 -0
  23. package/dist/core/ingestion/call-processor.d.ts +55 -2
  24. package/dist/core/ingestion/call-processor.js +673 -108
  25. package/dist/core/ingestion/call-routing.d.ts +23 -2
  26. package/dist/core/ingestion/call-routing.js +21 -0
  27. package/dist/core/ingestion/entry-point-scoring.js +36 -26
  28. package/dist/core/ingestion/framework-detection.d.ts +10 -2
  29. package/dist/core/ingestion/framework-detection.js +49 -12
  30. package/dist/core/ingestion/heritage-processor.js +47 -49
  31. package/dist/core/ingestion/import-processor.d.ts +1 -1
  32. package/dist/core/ingestion/import-processor.js +103 -194
  33. package/dist/core/ingestion/import-resolution.d.ts +101 -0
  34. package/dist/core/ingestion/import-resolution.js +251 -0
  35. package/dist/core/ingestion/language-config.d.ts +3 -0
  36. package/dist/core/ingestion/language-config.js +13 -0
  37. package/dist/core/ingestion/markdown-processor.d.ts +17 -0
  38. package/dist/core/ingestion/markdown-processor.js +124 -0
  39. package/dist/core/ingestion/mro-processor.js +8 -3
  40. package/dist/core/ingestion/named-binding-extraction.d.ts +9 -43
  41. package/dist/core/ingestion/named-binding-extraction.js +89 -79
  42. package/dist/core/ingestion/parsing-processor.d.ts +3 -2
  43. package/dist/core/ingestion/parsing-processor.js +27 -60
  44. package/dist/core/ingestion/pipeline.d.ts +10 -0
  45. package/dist/core/ingestion/pipeline.js +425 -4
  46. package/dist/core/ingestion/resolution-context.d.ts +5 -0
  47. package/dist/core/ingestion/resolution-context.js +7 -4
  48. package/dist/core/ingestion/resolvers/index.d.ts +1 -1
  49. package/dist/core/ingestion/resolvers/index.js +1 -1
  50. package/dist/core/ingestion/resolvers/jvm.d.ts +2 -1
  51. package/dist/core/ingestion/resolvers/jvm.js +25 -9
  52. package/dist/core/ingestion/resolvers/php.d.ts +14 -0
  53. package/dist/core/ingestion/resolvers/php.js +43 -3
  54. package/dist/core/ingestion/resolvers/utils.d.ts +5 -0
  55. package/dist/core/ingestion/resolvers/utils.js +16 -0
  56. package/dist/core/ingestion/symbol-table.d.ts +29 -3
  57. package/dist/core/ingestion/symbol-table.js +42 -9
  58. package/dist/core/ingestion/tree-sitter-queries.d.ts +12 -12
  59. package/dist/core/ingestion/tree-sitter-queries.js +243 -2
  60. package/dist/core/ingestion/type-env.d.ts +28 -1
  61. package/dist/core/ingestion/type-env.js +451 -72
  62. package/dist/core/ingestion/type-extractors/c-cpp.d.ts +5 -0
  63. package/dist/core/ingestion/type-extractors/c-cpp.js +146 -2
  64. package/dist/core/ingestion/type-extractors/csharp.js +189 -16
  65. package/dist/core/ingestion/type-extractors/go.js +45 -0
  66. package/dist/core/ingestion/type-extractors/index.d.ts +1 -1
  67. package/dist/core/ingestion/type-extractors/index.js +1 -1
  68. package/dist/core/ingestion/type-extractors/jvm.js +244 -69
  69. package/dist/core/ingestion/type-extractors/php.js +31 -4
  70. package/dist/core/ingestion/type-extractors/python.js +89 -17
  71. package/dist/core/ingestion/type-extractors/ruby.js +17 -2
  72. package/dist/core/ingestion/type-extractors/rust.js +72 -4
  73. package/dist/core/ingestion/type-extractors/shared.d.ts +12 -2
  74. package/dist/core/ingestion/type-extractors/shared.js +115 -13
  75. package/dist/core/ingestion/type-extractors/swift.js +7 -6
  76. package/dist/core/ingestion/type-extractors/types.d.ts +54 -11
  77. package/dist/core/ingestion/type-extractors/typescript.js +171 -9
  78. package/dist/core/ingestion/utils.d.ts +2 -95
  79. package/dist/core/ingestion/utils.js +3 -892
  80. package/dist/core/ingestion/workers/parse-worker.d.ts +36 -11
  81. package/dist/core/ingestion/workers/parse-worker.js +116 -95
  82. package/dist/core/lbug/csv-generator.js +18 -1
  83. package/dist/core/lbug/lbug-adapter.d.ts +12 -0
  84. package/dist/core/lbug/lbug-adapter.js +71 -4
  85. package/dist/core/lbug/schema.d.ts +6 -4
  86. package/dist/core/lbug/schema.js +27 -3
  87. package/dist/mcp/core/embedder.js +11 -3
  88. package/dist/mcp/core/lbug-adapter.d.ts +22 -0
  89. package/dist/mcp/core/lbug-adapter.js +178 -23
  90. package/dist/mcp/local/local-backend.d.ts +22 -0
  91. package/dist/mcp/local/local-backend.js +136 -32
  92. package/dist/mcp/resources.js +13 -0
  93. package/dist/mcp/server.js +26 -4
  94. package/dist/mcp/tools.js +17 -7
  95. package/dist/server/api.d.ts +19 -1
  96. package/dist/server/api.js +66 -6
  97. package/dist/storage/git.d.ts +12 -0
  98. package/dist/storage/git.js +21 -0
  99. 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, CALL_EXPRESSION_TYPES, extractCallChain, } from './utils.js';
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
- const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null;
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', propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
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('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
420
+ id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
181
421
  sourceId: propEnclosingClassId, targetId: nodeId,
182
- type: 'HAS_METHOD', confidence: 1.0, reason: '',
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 && verifiedReceivers.size > 0) {
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(verifiedReceivers, funcName, receiverName);
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
- // Fall back to chained call resolution when the receiver is a call expression
214
- // (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier).
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 && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
218
- const extracted = extractCallChain(receiverNode);
219
- if (extracted) {
220
- // Resolve the base receiver type if possible
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 (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
225
- const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
226
- const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
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
- // Class-as-receiver for chain base (e.g. UserService.find_user().save())
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
- baseType = extracted.baseReceiverName;
512
+ currentType = extracted.baseReceiverName;
234
513
  }
235
514
  }
236
- receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
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
- }, file.path, ctx);
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 || candidate.parameterCount === argCount);
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
- * 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()`).
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
- * @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.
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
- 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;
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
- return currentType;
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
- const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm);
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
- * Look up a receiver type from a verified receiver map.
415
- * The map is keyed by `scope\0varName` (full scope with @startIndex).
416
- * Since the lookup side only has `funcName` (no startIndex), we scan for
417
- * all entries whose key starts with `funcName@` and has the matching varName.
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 lookupReceiverType = (map, funcName, varName) => {
423
- // Fast path: file-level scope (empty funcName — used as fallback)
424
- const fileLevelKey = receiverKey('', varName);
425
- const prefix = `${funcName}@`;
426
- const suffix = `\0${varName}`;
427
- let found;
428
- let ambiguous = false;
429
- for (const [key, value] of map) {
430
- if (key === fileLevelKey)
431
- continue; // handled separately below
432
- if (key.startsWith(prefix) && key.endsWith(suffix)) {
433
- // Verify the key is exactly "funcName@<digits>\0varName" with no extra chars.
434
- // The part between prefix and suffix should be the startIndex (digits only),
435
- // but we accept any non-empty segment to be forward-compatible.
436
- const middle = key.slice(prefix.length, key.length - suffix.length);
437
- if (middle.length === 0)
438
- continue; // malformed key — skip
439
- if (found === undefined) {
440
- found = value;
441
- }
442
- else if (found !== value) {
443
- ambiguous = true;
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
- if (!ambiguous && found !== undefined)
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 2: if the call has a receiver call chain (e.g. svc.getUser().save()),
507
- // resolve the chain to determine the final receiver type.
508
- // This runs whenever receiverCallChain is present even when Step 1 set a
509
- // receiverTypeName, that type is the BASE receiver (e.g. UserService for svc),
510
- // and the chain must be walked to produce the FINAL receiver (e.g. User from
511
- // getUser() : User).
512
- if (effectiveCall.receiverCallChain?.length) {
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
- baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
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
- const chainedType = resolveChainedReceiver(effectiveCall.receiverCallChain, baseType, effectiveCall.filePath, ctx);
522
- if (chainedType) {
523
- effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
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
  */