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.
- 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/ingestion/call-processor.d.ts +0 -1
- package/dist/core/ingestion/call-processor.js +1 -132
- package/dist/core/ingestion/parsing-processor.js +5 -2
- package/dist/core/ingestion/symbol-table.d.ts +6 -0
- package/dist/core/ingestion/symbol-table.js +21 -1
- package/dist/core/ingestion/type-env.js +62 -10
- package/dist/core/ingestion/type-extractors/c-cpp.js +2 -2
- package/dist/core/ingestion/type-extractors/csharp.js +21 -7
- package/dist/core/ingestion/type-extractors/go.js +41 -10
- package/dist/core/ingestion/type-extractors/jvm.js +47 -20
- package/dist/core/ingestion/type-extractors/php.js +142 -4
- package/dist/core/ingestion/type-extractors/python.js +21 -7
- package/dist/core/ingestion/type-extractors/ruby.js +2 -2
- package/dist/core/ingestion/type-extractors/rust.js +25 -12
- package/dist/core/ingestion/type-extractors/shared.d.ts +1 -0
- package/dist/core/ingestion/type-extractors/shared.js +133 -1
- package/dist/core/ingestion/type-extractors/types.d.ts +44 -12
- package/dist/core/ingestion/type-extractors/typescript.js +22 -8
- package/dist/core/ingestion/workers/parse-worker.js +5 -2
- package/dist/mcp/local/local-backend.d.ts +1 -0
- package/dist/mcp/local/local-backend.js +23 -1
- 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 +3 -2
- package/scripts/patch-tree-sitter-swift.cjs +0 -0
package/dist/cli/eval-server.js
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
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
|
|
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
|
package/dist/cli/tool.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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;
|