gitnexus 1.4.5 → 1.4.6

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 (30) hide show
  1. package/dist/cli/eval-server.js +13 -5
  2. package/dist/cli/index.js +0 -0
  3. package/dist/cli/tool.d.ts +3 -2
  4. package/dist/cli/tool.js +48 -13
  5. package/dist/core/ingestion/call-processor.d.ts +0 -1
  6. package/dist/core/ingestion/call-processor.js +1 -132
  7. package/dist/core/ingestion/parsing-processor.js +5 -2
  8. package/dist/core/ingestion/symbol-table.d.ts +6 -0
  9. package/dist/core/ingestion/symbol-table.js +21 -1
  10. package/dist/core/ingestion/type-env.js +62 -10
  11. package/dist/core/ingestion/type-extractors/c-cpp.js +2 -2
  12. package/dist/core/ingestion/type-extractors/csharp.js +21 -7
  13. package/dist/core/ingestion/type-extractors/go.js +41 -10
  14. package/dist/core/ingestion/type-extractors/jvm.js +47 -20
  15. package/dist/core/ingestion/type-extractors/php.js +142 -4
  16. package/dist/core/ingestion/type-extractors/python.js +21 -7
  17. package/dist/core/ingestion/type-extractors/ruby.js +2 -2
  18. package/dist/core/ingestion/type-extractors/rust.js +25 -12
  19. package/dist/core/ingestion/type-extractors/shared.d.ts +1 -0
  20. package/dist/core/ingestion/type-extractors/shared.js +133 -1
  21. package/dist/core/ingestion/type-extractors/types.d.ts +44 -12
  22. package/dist/core/ingestion/type-extractors/typescript.js +22 -8
  23. package/dist/core/ingestion/workers/parse-worker.js +5 -2
  24. package/dist/mcp/local/local-backend.d.ts +1 -0
  25. package/dist/mcp/local/local-backend.js +23 -1
  26. package/hooks/claude/gitnexus-hook.cjs +0 -0
  27. package/hooks/claude/pre-tool-use.sh +0 -0
  28. package/hooks/claude/session-start.sh +0 -0
  29. package/package.json +3 -2
  30. package/scripts/patch-tree-sitter-swift.cjs +0 -0
@@ -24,6 +24,7 @@
24
24
  * POST /shutdown — Graceful shutdown.
25
25
  */
26
26
  import http from 'http';
27
+ import { writeSync } from 'node:fs';
27
28
  import { LocalBackend } from '../mcp/local/local-backend.js';
28
29
  // ─── Text Formatters ──────────────────────────────────────────────────
29
30
  // Convert structured JSON results into compact, LLM-friendly text.
@@ -121,8 +122,10 @@ export function formatContextResult(result) {
121
122
  return lines.join('\n').trim();
122
123
  }
123
124
  export function formatImpactResult(result) {
124
- if (result.error)
125
- return `Error: ${result.error}`;
125
+ if (result.error) {
126
+ const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
127
+ return `Error: ${result.error}${suggestion}`;
128
+ }
126
129
  const target = result.target;
127
130
  const direction = result.direction;
128
131
  const byDepth = result.byDepth || {};
@@ -132,7 +135,11 @@ export function formatImpactResult(result) {
132
135
  }
133
136
  const lines = [];
134
137
  const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
135
- lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}\n`);
138
+ lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`);
139
+ if (result.partial) {
140
+ lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
141
+ }
142
+ lines.push('');
136
143
  const depthLabels = {
137
144
  1: 'WILL BREAK (direct)',
138
145
  2: 'LIKELY AFFECTED (indirect)',
@@ -346,10 +353,11 @@ export async function evalServerCommand(options) {
346
353
  console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
347
354
  }
348
355
  try {
349
- process.stdout.write(`GITNEXUS_EVAL_SERVER_READY:${port}\n`);
356
+ // Use fd 1 directly — LadybugDB captures process.stdout (#324)
357
+ writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${port}\n`);
350
358
  }
351
359
  catch {
352
- // stdout may not be available
360
+ // stdout may not be available (e.g., broken pipe)
353
361
  }
354
362
  });
355
363
  resetIdleTimer();
package/dist/cli/index.js CHANGED
File without changes
@@ -10,8 +10,9 @@
10
10
  * gitnexus impact --target "AuthService" --direction upstream
11
11
  * gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
12
12
  *
13
- * Note: Output goes to stderr because LadybugDB's native module captures stdout
14
- * at the OS level during init. This is consistent with augment.ts.
13
+ * Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
14
+ * native module which captures the Node.js process.stdout stream during init.
15
+ * See the output() function for details (#324).
15
16
  */
16
17
  export declare function queryCommand(queryText: string, options?: {
17
18
  repo?: string;
package/dist/cli/tool.js CHANGED
@@ -10,9 +10,11 @@
10
10
  * gitnexus impact --target "AuthService" --direction upstream
11
11
  * gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
12
12
  *
13
- * Note: Output goes to stderr because LadybugDB's native module captures stdout
14
- * at the OS level during init. This is consistent with augment.ts.
13
+ * Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
14
+ * native module which captures the Node.js process.stdout stream during init.
15
+ * See the output() function for details (#324).
15
16
  */
17
+ import { writeSync } from 'node:fs';
16
18
  import { LocalBackend } from '../mcp/local/local-backend.js';
17
19
  let _backend = null;
18
20
  async function getBackend() {
@@ -26,10 +28,30 @@ async function getBackend() {
26
28
  }
27
29
  return _backend;
28
30
  }
31
+ /**
32
+ * Write tool output to stdout using low-level fd write.
33
+ *
34
+ * LadybugDB's native module captures Node.js process.stdout during init,
35
+ * but the underlying OS file descriptor 1 (stdout) remains intact.
36
+ * By using fs.writeSync(1, ...) we bypass the Node.js stream layer
37
+ * and write directly to the real stdout fd (#324).
38
+ *
39
+ * Falls back to stderr if the fd write fails (e.g., broken pipe).
40
+ */
29
41
  function output(data) {
30
42
  const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
31
- // stderr because LadybugDB captures stdout at OS level
32
- process.stderr.write(text + '\n');
43
+ try {
44
+ writeSync(1, text + '\n');
45
+ }
46
+ catch (err) {
47
+ if (err?.code === 'EPIPE') {
48
+ // Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
49
+ // Exit cleanly per Unix convention
50
+ process.exit(0);
51
+ }
52
+ // Fallback: stderr (previous behavior, works on all platforms)
53
+ process.stderr.write(text + '\n');
54
+ }
33
55
  }
34
56
  export async function queryCommand(queryText, options) {
35
57
  if (!queryText?.trim()) {
@@ -67,15 +89,28 @@ export async function impactCommand(target, options) {
67
89
  console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
68
90
  process.exit(1);
69
91
  }
70
- const backend = await getBackend();
71
- const result = await backend.callTool('impact', {
72
- target,
73
- direction: options?.direction || 'upstream',
74
- maxDepth: options?.depth ? parseInt(options.depth) : undefined,
75
- includeTests: options?.includeTests ?? false,
76
- repo: options?.repo,
77
- });
78
- output(result);
92
+ try {
93
+ const backend = await getBackend();
94
+ const result = await backend.callTool('impact', {
95
+ target,
96
+ direction: options?.direction || 'upstream',
97
+ maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
98
+ includeTests: options?.includeTests ?? false,
99
+ repo: options?.repo,
100
+ });
101
+ output(result);
102
+ }
103
+ catch (err) {
104
+ // Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
105
+ // The backend's impact() already returns structured errors for graph query failures
106
+ output({
107
+ error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
108
+ target: { name: target },
109
+ direction: options?.direction || 'upstream',
110
+ suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
111
+ });
112
+ process.exit(1);
113
+ }
79
114
  }
80
115
  export async function cypherCommand(query, options) {
81
116
  if (!query?.trim()) {
@@ -6,7 +6,6 @@ export declare const processCalls: (graph: KnowledgeGraph, files: {
6
6
  path: string;
7
7
  content: string;
8
8
  }[], astCache: ASTCache, ctx: ResolutionContext, onProgress?: (current: number, total: number) => void) => Promise<ExtractedHeritage[]>;
9
- export declare const extractReturnTypeName: (raw: string, depth?: number) => string | undefined;
10
9
  /**
11
10
  * Fast path: resolve pre-extracted call sites from workers.
12
11
  * No AST parsing — workers already extracted calledName + sourceId.
@@ -7,6 +7,7 @@ import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop, F
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
11
  /**
11
12
  * Walk up the AST from a node to find the enclosing function/method.
12
13
  * Returns null if the call is at module/file level (top-level code).
@@ -386,138 +387,6 @@ const resolveCallTarget = (call, currentFile, ctx) => {
386
387
  return null;
387
388
  return toResolveResult(filteredCandidates[0], tiered.tier);
388
389
  };
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
390
  // ── Scope key helpers ────────────────────────────────────────────────────
522
391
  // Scope keys use the format "funcName@startIndex" (produced by type-env.ts).
523
392
  // Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
@@ -201,10 +201,13 @@ const processParsingSequential = async (graph, files, symbolTable, astCache, onF
201
201
  ? extractMethodSignature(definitionNode)
202
202
  : undefined;
203
203
  // Language-specific return type fallback (e.g. Ruby YARD @return [Type])
204
- if (methodSig && !methodSig.returnType && definitionNode) {
204
+ // Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
205
+ if (methodSig && (!methodSig.returnType || methodSig.returnType === 'array' || methodSig.returnType === 'iterable') && definitionNode) {
205
206
  const tc = typeConfigs[language];
206
207
  if (tc?.extractReturnType) {
207
- methodSig.returnType = tc.extractReturnType(definitionNode);
208
+ const docReturn = tc.extractReturnType(definitionNode);
209
+ if (docReturn)
210
+ methodSig.returnType = docReturn;
208
211
  }
209
212
  }
210
213
  const node = {
@@ -32,6 +32,12 @@ export interface SymbolTable {
32
32
  * Used when imports are missing or for framework magic
33
33
  */
34
34
  lookupFuzzy: (name: string) => SymbolDefinition[];
35
+ /**
36
+ * Low Confidence: Look for callable symbols (Function/Method/Constructor) by name.
37
+ * Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index.
38
+ * Used by ReturnTypeLookup to resolve callee → return type.
39
+ */
40
+ lookupFuzzyCallable: (name: string) => SymbolDefinition[];
35
41
  /**
36
42
  * Debugging: See how many symbols are tracked
37
43
  */
@@ -5,6 +5,11 @@ export const createSymbolTable = () => {
5
5
  // 2. Global Reverse Index (The "Backup")
6
6
  // Structure: SymbolName -> [List of Definitions]
7
7
  const globalIndex = new Map();
8
+ // 3. Lazy Callable Index — populated on first lookupFuzzyCallable call.
9
+ // Structure: SymbolName -> [Callable Definitions]
10
+ // Only Function, Method, Constructor symbols are indexed.
11
+ let callableIndex = null;
12
+ const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
8
13
  const add = (filePath, name, nodeId, type, metadata) => {
9
14
  const def = {
10
15
  nodeId,
@@ -24,6 +29,8 @@ export const createSymbolTable = () => {
24
29
  globalIndex.set(name, []);
25
30
  }
26
31
  globalIndex.get(name).push(def);
32
+ // Invalidate the lazy callable index — it will be rebuilt on next use
33
+ callableIndex = null;
27
34
  };
28
35
  const lookupExact = (filePath, name) => {
29
36
  return fileIndex.get(filePath)?.get(name)?.nodeId;
@@ -34,6 +41,18 @@ export const createSymbolTable = () => {
34
41
  const lookupFuzzy = (name) => {
35
42
  return globalIndex.get(name) || [];
36
43
  };
44
+ const lookupFuzzyCallable = (name) => {
45
+ if (!callableIndex) {
46
+ // Build the callable index lazily on first use
47
+ callableIndex = new Map();
48
+ for (const [symName, defs] of globalIndex) {
49
+ const callables = defs.filter(d => CALLABLE_TYPES.has(d.type));
50
+ if (callables.length > 0)
51
+ callableIndex.set(symName, callables);
52
+ }
53
+ }
54
+ return callableIndex.get(name) ?? [];
55
+ };
37
56
  const getStats = () => ({
38
57
  fileCount: fileIndex.size,
39
58
  globalSymbolCount: globalIndex.size
@@ -41,6 +60,7 @@ export const createSymbolTable = () => {
41
60
  const clear = () => {
42
61
  fileIndex.clear();
43
62
  globalIndex.clear();
63
+ callableIndex = null;
44
64
  };
45
- return { add, lookupExact, lookupExactFull, lookupFuzzy, getStats, clear };
65
+ return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
46
66
  };
@@ -1,6 +1,6 @@
1
- import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES } from './utils.js';
1
+ import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js';
2
2
  import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
3
- import { extractSimpleTypeName, extractVarName, stripNullable } from './type-extractors/shared.js';
3
+ import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js';
4
4
  /** File-level scope key */
5
5
  const FILE_SCOPE = '';
6
6
  /** Fallback for languages where class names aren't in a 'name' field (e.g. Kotlin uses type_identifier). */
@@ -303,13 +303,45 @@ export const buildTypeEnv = (tree, language, symbolTable) => {
303
303
  const classNames = createClassNameLookup(localClassNames, symbolTable);
304
304
  const config = typeConfigs[language];
305
305
  const bindings = [];
306
+ // Build ReturnTypeLookup from optional SymbolTable.
307
+ // Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
308
+ const returnTypeLookup = {
309
+ lookupReturnType(callee) {
310
+ if (!symbolTable)
311
+ return undefined;
312
+ if (isBuiltInOrNoise(callee))
313
+ return undefined;
314
+ const callables = symbolTable.lookupFuzzyCallable(callee);
315
+ if (callables.length !== 1)
316
+ return undefined;
317
+ const rawReturn = callables[0].returnType;
318
+ if (!rawReturn)
319
+ return undefined;
320
+ return extractReturnTypeName(rawReturn);
321
+ },
322
+ lookupRawReturnType(callee) {
323
+ if (!symbolTable)
324
+ return undefined;
325
+ if (isBuiltInOrNoise(callee))
326
+ return undefined;
327
+ const callables = symbolTable.lookupFuzzyCallable(callee);
328
+ if (callables.length !== 1)
329
+ return undefined;
330
+ return callables[0].returnType;
331
+ }
332
+ };
306
333
  // Pre-compute combined set of node types that need extractTypeBinding.
307
334
  // Single Set.has() replaces 3 separate checks per node in walk().
308
335
  const interestingNodeTypes = new Set();
309
336
  TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t));
310
337
  config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t));
311
338
  config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t));
312
- const pendingAssignments = [];
339
+ // Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`)
340
+ const pendingCopies = [];
341
+ // NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' }
342
+ // from extractPendingAssignment. When one does, this array will bind variables to their
343
+ // function return types at TypeEnv build time. See PendingAssignment in types.ts.
344
+ const pendingCallResults = [];
313
345
  // Maps `scope\0varName` → the type annotation AST node from the original declaration.
314
346
  // Allows pattern extractors to navigate back to the declaration's generic type arguments
315
347
  // (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
@@ -376,7 +408,10 @@ export const buildTypeEnv = (tree, language, symbolTable) => {
376
408
  // For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST.
377
409
  // Checked before declarationNodeTypes — loop variables are not declarations.
378
410
  if (config.forLoopNodeTypes?.has(node.type)) {
379
- config.extractForLoopBinding?.(node, scopeEnv, declarationTypeNodes, scope);
411
+ if (config.extractForLoopBinding) {
412
+ const forLoopCtx = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
413
+ config.extractForLoopBinding(node, forLoopCtx);
414
+ }
380
415
  return;
381
416
  }
382
417
  if (config.declarationNodeTypes.has(node.type)) {
@@ -514,7 +549,12 @@ export const buildTypeEnv = (tree, language, symbolTable) => {
514
549
  if (scopeEnv) {
515
550
  const pending = config.extractPendingAssignment(node, scopeEnv);
516
551
  if (pending) {
517
- pendingAssignments.push({ scope, ...pending });
552
+ if (pending.kind === 'copy') {
553
+ pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
554
+ }
555
+ else {
556
+ pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
557
+ }
518
558
  }
519
559
  }
520
560
  }
@@ -537,19 +577,31 @@ export const buildTypeEnv = (tree, language, symbolTable) => {
537
577
  }
538
578
  };
539
579
  walk(tree.rootNode, FILE_SCOPE);
540
- // Tier 2: single-pass assignment chain propagation in source order.
541
- // Resolves `const b = a` where `a` has a known type from Tier 0/1.
580
+ // Tier 2a: copy-propagation `const b = a` where `a` has a known type from Tier 0/1.
542
581
  // Multi-hop chains resolve when forward-declared (a→b→c in source order);
543
582
  // reverse-order assignments are depth-1 only. No fixpoint iteration —
544
583
  // this covers 95%+ of real-world patterns.
545
- for (const { scope, lhs, rhs } of pendingAssignments) {
584
+ for (const { scope, lhs, rhs } of pendingCopies) {
546
585
  const scopeEnv = env.get(scope);
547
586
  if (!scopeEnv || scopeEnv.has(lhs))
548
587
  continue;
549
588
  const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs);
550
- if (rhsType) {
589
+ if (rhsType)
551
590
  scopeEnv.set(lhs, rhsType);
552
- }
591
+ }
592
+ // Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type.
593
+ // Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable.
594
+ // Conservative: only binds when exactly one callable matches (avoids overload ambiguity).
595
+ // NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet.
596
+ // The loop is structurally complete and will activate when any language extractor starts
597
+ // returning { kind: 'callResult', lhs, callee } from extractPendingAssignment.
598
+ for (const { scope, lhs, callee } of pendingCallResults) {
599
+ const scopeEnv = env.get(scope);
600
+ if (!scopeEnv || scopeEnv.has(lhs))
601
+ continue;
602
+ const typeName = returnTypeLookup.lookupReturnType(callee);
603
+ if (typeName)
604
+ scopeEnv.set(lhs, typeName);
553
605
  }
554
606
  return {
555
607
  lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides),
@@ -198,7 +198,7 @@ const extractPendingAssignment = (node, scopeEnv) => {
198
198
  const lhs = extractVarName(finalName);
199
199
  if (!lhs || scopeEnv.has(lhs))
200
200
  return undefined;
201
- return { lhs, rhs: value.text };
201
+ return { kind: 'copy', lhs, rhs: value.text };
202
202
  };
203
203
  // --- For-loop Tier 1c ---
204
204
  const FOR_LOOP_NODE_TYPES = new Set(['for_range_loop']);
@@ -291,7 +291,7 @@ const findCppParamElementType = (iterableName, startNode, pos = 'last') => {
291
291
  /** C++: for (auto& user : users) — extract loop variable binding.
292
292
  * Handles explicit types (for (User& user : users)) and auto (for (auto& user : users)).
293
293
  * For auto, resolves element type from the iterable's container type. */
294
- const extractForLoopBinding = (node, scopeEnv, declarationTypeNodes, scope) => {
294
+ const extractForLoopBinding = (node, { scopeEnv, declarationTypeNodes, scope }) => {
295
295
  if (node.type !== 'for_range_loop')
296
296
  return;
297
297
  const typeNode = node.childForFieldName('type');
@@ -1,4 +1,4 @@
1
- import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition } from './shared.js';
1
+ import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString } from './shared.js';
2
2
  /** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
3
3
  const KNOWN_CONTAINER_PROPS = new Set(['Keys', 'Values']);
4
4
  const DECLARATION_NODE_TYPES = new Set([
@@ -214,7 +214,7 @@ const findCSharpParamElementType = (iterableName, startNode, pos = 'last') => {
214
214
  };
215
215
  /** C#: foreach (User user in users) — extract loop variable binding.
216
216
  * Tier 1c: for `foreach (var user in users)`, resolves element type from iterable. */
217
- const extractForLoopBinding = (node, scopeEnv, declarationTypeNodes, scope) => {
217
+ const extractForLoopBinding = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }) => {
218
218
  const typeNode = node.childForFieldName('type');
219
219
  const nameNode = node.childForFieldName('left');
220
220
  if (!typeNode || !nameNode)
@@ -233,6 +233,7 @@ const extractForLoopBinding = (node, scopeEnv, declarationTypeNodes, scope) => {
233
233
  const rightNode = node.childForFieldName('right');
234
234
  let iterableName;
235
235
  let methodName;
236
+ let callExprElementType;
236
237
  if (rightNode?.type === 'identifier') {
237
238
  iterableName = rightNode.text;
238
239
  }
@@ -261,6 +262,7 @@ const extractForLoopBinding = (node, scopeEnv, declarationTypeNodes, scope) => {
261
262
  }
262
263
  else if (rightNode?.type === 'invocation_expression') {
263
264
  // C# method call: data.Select(...) → invocation_expression > member_access_expression
265
+ // Direct function call: GetUsers() → invocation_expression > identifier
264
266
  const fn = rightNode.firstNamedChild;
265
267
  if (fn?.type === 'member_access_expression') {
266
268
  const obj = fn.childForFieldName('expression');
@@ -270,12 +272,24 @@ const extractForLoopBinding = (node, scopeEnv, declarationTypeNodes, scope) => {
270
272
  if (prop?.type === 'identifier')
271
273
  methodName = prop.text;
272
274
  }
275
+ else if (fn?.type === 'identifier') {
276
+ // Direct function call: foreach (var u in GetUsers())
277
+ const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
278
+ if (rawReturn)
279
+ callExprElementType = extractElementTypeFromString(rawReturn);
280
+ }
273
281
  }
274
- if (!iterableName)
282
+ if (!iterableName && !callExprElementType)
275
283
  return;
276
- const containerTypeName = scopeEnv.get(iterableName);
277
- const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
278
- const elementType = resolveIterableElementType(iterableName, node, scopeEnv, declarationTypeNodes, scope, extractCSharpElementTypeFromTypeNode, findCSharpParamElementType, typeArgPos);
284
+ let elementType;
285
+ if (callExprElementType) {
286
+ elementType = callExprElementType;
287
+ }
288
+ else {
289
+ const containerTypeName = scopeEnv.get(iterableName);
290
+ const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
291
+ elementType = resolveIterableElementType(iterableName, node, scopeEnv, declarationTypeNodes, scope, extractCSharpElementTypeFromTypeNode, findCSharpParamElementType, typeArgPos);
292
+ }
279
293
  if (elementType)
280
294
  scopeEnv.set(varName, elementType);
281
295
  };
@@ -351,7 +365,7 @@ const extractPendingAssignment = (node, scopeEnv) => {
351
365
  }
352
366
  const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1);
353
367
  if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) {
354
- return { lhs, rhs: valueNode.text };
368
+ return { kind: 'copy', lhs, rhs: valueNode.text };
355
369
  }
356
370
  }
357
371
  return undefined;