Merge pull request #379 from abhigyanpatwari/feat/phase9-call-result-binding

feat: Phase 9 — Return-type-aware variable binding with unified fixpoint chain propagation
This commit is contained in:
Gergő Magyar 2026-03-19 12:43:45 +00:00 • committed by GitHub
commit a889ebb16d
66 changed files with 1696 additions and 427 deletions

View file

@ -2,7 +2,7 @@ import type { SyntaxNode } from './utils.js';
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext } from './type-extractors/types.js';
import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js';
import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
@ -364,6 +364,47 @@ const SKIP_SUBTREE_TYPES = new Set([
'regex', 'regex_pattern',
]);
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
/** Resolve a field's declared type given a receiver variable and field name.
* Uses SymbolTable to find the class nodeId for the receiver's type, then
* looks up the field via the eagerly-populated fieldByOwner index. */
const resolveFieldType = (
receiver: string, field: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const classDefs = symbolTable.lookupFuzzy(receiverType)
.filter(d => CLASS_LIKE_TYPES.has(d.type));
if (classDefs.length !== 1) return undefined;
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
if (!fieldDef?.declaredType) return undefined;
return extractReturnTypeName(fieldDef.declaredType);
};
/** Resolve a method's return type given a receiver variable and method name.
* Uses SymbolTable to find class nodeIds for the receiver's type, then
* looks up the method via lookupFuzzyCallable filtered by ownerId. */
const resolveMethodReturnType = (
receiver: string, method: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const classDefs = symbolTable.lookupFuzzy(receiverType)
.filter(d => CLASS_LIKE_TYPES.has(d.type));
if (classDefs.length === 0) return undefined;
const classNodeIds = new Set(classDefs.map(d => d.nodeId));
const methods = symbolTable.lookupFuzzyCallable(method)
.filter(d => d.ownerId && classNodeIds.has(d.ownerId));
if (methods.length !== 1) return undefined;
if (!methods[0].returnType) return undefined;
return extractReturnTypeName(methods[0].returnType);
};
export const buildTypeEnv = (
tree: { rootNode: SyntaxNode },
language: SupportedLanguages,
@ -403,12 +444,10 @@ export const buildTypeEnv = (
TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t));
config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t));
config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t));
// Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`)
const pendingCopies: Array<{ scope: string; lhs: string; rhs: string }> = [];
// NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' }
// from extractPendingAssignment. When one does, this array will bind variables to their
// function return types at TypeEnv build time. See PendingAssignment in types.ts.
const pendingCallResults: Array<{ scope: string; lhs: string; callee: string }> = [];
// Tier 2: unified fixpoint propagation — collects copy, callResult, fieldAccess, and
// methodCallResult items during walk(), then iterates until no new bindings are produced.
// Handles arbitrary-depth mixed chains: callResult → fieldAccess → methodCallResult → copy.
const pendingItems: Array<{ scope: string } & PendingAssignment> = [];
// Maps `scope\0varName` → the type annotation AST node from the original declaration.
// Allows pattern extractors to navigate back to the declaration's generic type arguments
// (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
@ -611,11 +650,7 @@ export const buildTypeEnv = (
if (scopeEnv) {
const pending = config.extractPendingAssignment(node, scopeEnv);
if (pending) {
if (pending.kind === 'copy') {
pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
} else {
pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
}
pendingItems.push({ scope, ...pending });
}
}
}
@ -641,28 +676,47 @@ export const buildTypeEnv = (
walk(tree.rootNode, FILE_SCOPE);
// Tier 2a: copy-propagation — `const b = a` where `a` has a known type from Tier 0/1.
// Multi-hop chains resolve when forward-declared (a→b→c in source order);
// reverse-order assignments are depth-1 only. No fixpoint iteration —
// this covers 95%+ of real-world patterns.
for (const { scope, lhs, rhs } of pendingCopies) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs);
if (rhsType) scopeEnv.set(lhs, rhsType);
}
// Unified fixpoint propagation: iterate over ALL pending items (copy, callResult,
// fieldAccess, methodCallResult) until no new bindings are produced.
// Handles arbitrary-depth mixed chains:
// const user = getUser(); // callResult → User
// const addr = user.address; // fieldAccess → Address (depends on user)
// const city = addr.getCity(); // methodCallResult → City (depends on addr)
// const alias = city; // copy → City (depends on city)
// Data flow: SymbolTable (immutable) + scopeEnv → resolve → scopeEnv.
// Termination: finite entries, each bound at most once (first-writer-wins), max 10 iterations.
const MAX_FIXPOINT_ITERATIONS = 10;
const resolved = new Set<number>();
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
let changed = false;
for (let i = 0; i < pendingItems.length; i++) {
if (resolved.has(i)) continue;
const item = pendingItems[i];
const scopeEnv = env.get(item.scope);
if (!scopeEnv || scopeEnv.has(item.lhs)) { resolved.add(i); continue; }
// Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type.
// Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable.
// Conservative: only binds when exactly one callable matches (avoids overload ambiguity).
// NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet.
// The loop is structurally complete and will activate when any language extractor starts
// returning { kind: 'callResult', lhs, callee } from extractPendingAssignment.
for (const { scope, lhs, callee } of pendingCallResults) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const typeName = returnTypeLookup.lookupReturnType(callee);
if (typeName) scopeEnv.set(lhs, typeName);
let typeName: string | undefined;
switch (item.kind) {
case 'callResult':
typeName = returnTypeLookup.lookupReturnType(item.callee);
break;
case 'copy':
typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs);
break;
case 'fieldAccess':
typeName = resolveFieldType(item.receiver, item.field, scopeEnv, symbolTable);
break;
case 'methodCallResult':
typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable);
break;
}
if (typeName) {
scopeEnv.set(item.lhs, typeName);
resolved.add(i);
changed = true;
}
}
if (!changed) break;
}
return {

View file

@ -171,7 +171,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const declarator = node.childForFieldName('declarator');
if (!declarator || declarator.type !== 'init_declarator') return undefined;
const value = declarator.childForFieldName('value');
if (!value || value.type !== 'identifier') return undefined;
if (!value) return undefined;
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return undefined;
const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
@ -179,7 +179,31 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!finalName) return undefined;
const lhs = extractVarName(finalName);
if (!lhs || scopeEnv.has(lhs)) return undefined;
return { kind: 'copy', lhs, rhs: value.text };
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
// field_expression RHS → fieldAccess (a.field)
if (value.type === 'field_expression') {
const obj = value.firstNamedChild;
const field = value.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call_expression RHS
if (value.type === 'call_expression') {
const funcNode = value.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call_expression → function: field_expression
if (funcNode?.type === 'field_expression') {
const obj = funcNode.firstNamedChild;
const field = funcNode.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: field.text };
}
}
}
return undefined;
};
// --- For-loop Tier 1c ---

View file

@ -327,6 +327,46 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) {
return { kind: 'copy', lhs, rhs: valueNode.text };
}
// member_access_expression RHS → fieldAccess (a.Field)
if (valueNode?.type === 'member_access_expression') {
const expr = valueNode.childForFieldName('expression');
const name = valueNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'fieldAccess', lhs, receiver: expr.text, field: name.text };
}
}
// invocation_expression RHS
if (valueNode?.type === 'invocation_expression') {
const funcNode = valueNode.firstNamedChild;
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver → methodCallResult: a.GetC()
if (funcNode?.type === 'member_access_expression') {
const expr = funcNode.childForFieldName('expression');
const name = funcNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text };
}
}
}
// await_expression → unwrap and check inner
if (valueNode?.type === 'await_expression') {
const inner = valueNode.firstNamedChild;
if (inner?.type === 'invocation_expression') {
const funcNode = inner.firstNamedChild;
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
if (funcNode?.type === 'member_access_expression') {
const expr = funcNode.childForFieldName('expression');
const name = funcNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text };
}
}
}
}
}
return undefined;
};

View file

@ -397,6 +397,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const lhs = lhsNode.text;
if (scopeEnv.has(lhs)) return undefined;
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
// selector_expression RHS → fieldAccess (a.field)
if (rhsNode.type === 'selector_expression') {
const operand = rhsNode.childForFieldName('operand');
const field = rhsNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text };
}
}
// call_expression RHS
if (rhsNode.type === 'call_expression') {
const funcNode = rhsNode.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call_expression → function: selector_expression
if (funcNode?.type === 'selector_expression') {
const operand = funcNode.childForFieldName('operand');
const field = funcNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text };
}
}
}
return undefined;
}
if (node.type === 'var_spec' || node.type === 'var_declaration') {
@ -422,6 +445,28 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
}
const rhsNode = exprList?.firstNamedChild;
if (rhsNode?.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
// selector_expression RHS → fieldAccess
if (rhsNode?.type === 'selector_expression') {
const operand = rhsNode.childForFieldName('operand');
const field = rhsNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text };
}
}
// call_expression RHS
if (rhsNode?.type === 'call_expression') {
const funcNode = rhsNode.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
if (funcNode?.type === 'selector_expression') {
const operand = funcNode.childForFieldName('operand');
const field = funcNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text };
}
}
}
}
}
return undefined;

View file

@ -199,6 +199,31 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
// field_access RHS → fieldAccess (a.field)
if (valueNode.type === 'field_access') {
const obj = valueNode.childForFieldName('object');
const field = valueNode.childForFieldName('field');
if (obj?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// method_invocation RHS
if (valueNode.type === 'method_invocation') {
const objField = valueNode.childForFieldName('object');
if (!objField) {
// No receiver → callResult
const nameField = valueNode.childForFieldName('name');
if (nameField?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: nameField.text };
}
} else if (objField.type === 'identifier') {
// With receiver → methodCallResult
const nameField = valueNode.childForFieldName('name');
if (nameField?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: objField.text, method: nameField.text };
}
}
}
}
return undefined;
};
@ -541,7 +566,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Find the RHS: a simple_identifier sibling after the "=" token
// Find the RHS after the "=" token
let foundEq = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
@ -550,6 +575,31 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (foundEq && child.type === 'simple_identifier') {
return { kind: 'copy', lhs, rhs: child.text };
}
// navigation_expression RHS → fieldAccess (a.field)
if (foundEq && child.type === 'navigation_expression') {
const recv = child.firstNamedChild;
const suffix = child.lastNamedChild;
const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') {
return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text };
}
}
// call_expression RHS
if (foundEq && child.type === 'call_expression') {
const calleeNode = child.firstNamedChild;
if (calleeNode?.type === 'simple_identifier') {
return { kind: 'callResult', lhs, callee: calleeNode.text };
}
// navigation_expression callee → methodCallResult (a.method())
if (calleeNode?.type === 'navigation_expression') {
const recv = calleeNode.firstNamedChild;
const suffix = calleeNode.lastNamedChild;
const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') {
return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text };
}
}
}
}
return undefined;
}
@ -560,8 +610,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!nameNode) return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Look for RHS simple_identifier after "=" in the parent (property_declaration)
// variable_declaration itself doesn't contain "=" — it's in the parent
// Look for RHS after "=" in the parent (property_declaration)
const parent = node.parent;
if (!parent) return undefined;
let foundEq = false;
@ -572,6 +621,28 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (foundEq && child.type === 'simple_identifier') {
return { kind: 'copy', lhs, rhs: child.text };
}
if (foundEq && child.type === 'navigation_expression') {
const recv = child.firstNamedChild;
const suffix = child.lastNamedChild;
const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') {
return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text };
}
}
if (foundEq && child.type === 'call_expression') {
const calleeNode = child.firstNamedChild;
if (calleeNode?.type === 'simple_identifier') {
return { kind: 'callResult', lhs, callee: calleeNode.text };
}
if (calleeNode?.type === 'navigation_expression') {
const recv = calleeNode.firstNamedChild;
const suffix = calleeNode.lastNamedChild;
const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') {
return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text };
}
}
}
}
return undefined;
}

View file

@ -351,11 +351,37 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'variable_name' || right.type !== 'variable_name') return undefined;
if (left.type !== 'variable_name') return undefined;
const lhs = left.text;
const rhs = right.text;
if (!lhs || !rhs || scopeEnv.has(lhs)) return undefined;
return { kind: 'copy', lhs, rhs };
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (right.type === 'variable_name') {
const rhs = right.text;
if (rhs) return { kind: 'copy', lhs, rhs };
}
// member_access_expression RHS → fieldAccess ($a->field)
if (right.type === 'member_access_expression') {
const obj = right.childForFieldName('object');
const name = right.childForFieldName('name');
if (obj?.type === 'variable_name' && name) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: name.text };
}
}
// function_call_expression RHS → callResult (bare function calls only)
if (right.type === 'function_call_expression') {
const funcNode = right.childForFieldName('function');
if (funcNode?.type === 'name') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
}
// member_call_expression RHS → methodCallResult ($a->method())
if (right.type === 'member_call_expression') {
const obj = right.childForFieldName('object');
const name = right.childForFieldName('name');
if (obj?.type === 'variable_name' && name) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: name.text };
}
}
return undefined;
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([

View file

@ -358,6 +358,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const lhs = left.type === 'identifier' ? left.text : undefined;
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (right.type === 'identifier') return { kind: 'copy', lhs, rhs: right.text };
// attribute RHS → fieldAccess (a.field)
if (right.type === 'attribute') {
const obj = right.firstNamedChild;
const field = right.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'identifier' && obj !== field) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call RHS
if (right.type === 'call') {
const funcNode = right.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call → function: attribute
if (funcNode?.type === 'attribute') {
const obj = funcNode.firstNamedChild;
const method = funcNode.lastNamedChild;
if (obj?.type === 'identifier' && method?.type === 'identifier' && obj !== method) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: method.text };
}
}
}
return undefined;
};

View file

@ -389,8 +389,22 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const varName = lhsNode.text;
if (scopeEnv.has(varName)) return undefined;
const rhsNode = node.childForFieldName('right');
if (!rhsNode || rhsNode.type !== 'identifier') return undefined;
return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
if (!rhsNode) return undefined;
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
// call/method_call RHS — Ruby uses method calls for both field access and method calls
if (rhsNode.type === 'call' || rhsNode.type === 'method_call') {
const methodNode = rhsNode.childForFieldName('method');
const receiverNode = rhsNode.childForFieldName('receiver');
if (!receiverNode && methodNode?.type === 'identifier') {
// No receiver → callResult (bare function call)
return { kind: 'callResult', lhs: varName, callee: methodNode.text };
}
if (receiverNode?.type === 'identifier' && methodNode?.type === 'identifier') {
// With receiver → methodCallResult (a.method)
return { kind: 'methodCallResult', lhs: varName, receiver: receiverNode.text, method: methodNode.text };
}
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {

View file

@ -197,7 +197,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!pattern || !value) return undefined;
const lhs = extractVarName(pattern);
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
// Unwrap Rust .await: `let user = get_user().await` → call_expression
const unwrapped = unwrapAwait(value) ?? value;
if (unwrapped.type === 'identifier') return { kind: 'copy', lhs, rhs: unwrapped.text };
// field_expression RHS → fieldAccess (a.field)
if (unwrapped.type === 'field_expression') {
const obj = unwrapped.firstNamedChild;
const field = unwrapped.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call_expression RHS → callResult (simple calls only)
if (unwrapped.type === 'call_expression') {
const funcNode = unwrapped.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
}
// method_call_expression RHS → methodCallResult (receiver.method())
if (unwrapped.type === 'method_call_expression') {
const obj = unwrapped.firstNamedChild;
if (obj?.type === 'identifier') {
const methodNode = unwrapped.childForFieldName('name') ?? unwrapped.namedChild(1);
if (methodNode?.type === 'field_identifier') {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: methodNode.text };
}
}
}
return undefined;
};

View file

@ -53,15 +53,20 @@ export interface ForLoopExtractorContext {
export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) => void;
/** Discriminated union for pending Tier-2 propagation items.
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
* - `callResult` — `const b = foo()` (bind b to foo's declared return type) */
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
* - `callResult` — `const b = foo()` (bind b to foo's declared return type)
* - `fieldAccess` — `const b = a.field` (bind b to field's declaredType on a's type)
* - `methodCallResult` — `const b = a.method()` (bind b to method's returnType on a's type) */
export type PendingAssignment =
| { kind: 'copy'; lhs: string; rhs: string }
| { kind: 'callResult'; lhs: string; callee: string };
| { kind: 'callResult'; lhs: string; callee: string }
| { kind: 'fieldAccess'; lhs: string; receiver: string; field: string }
| { kind: 'methodCallResult'; lhs: string; receiver: string; method: string };
/** Extracts a pending assignment for Tier 2 propagation.
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`) or a
* call expression (`callResult`) and the LHS has no resolved type yet.
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`), a
* call expression (`callResult`), a field access (`fieldAccess`), or a
* method call with receiver (`methodCallResult`) and the LHS has no resolved type yet.
* Returns undefined if the node is not a matching assignment. */
export type PendingAssignmentExtractor = (
node: SyntaxNode,

View file

@ -440,6 +440,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
// member_expression RHS → fieldAccess (a.field, this.field)
if (valueNode.type === 'member_expression') {
const obj = valueNode.childForFieldName('object');
const prop = valueNode.childForFieldName('property');
if (obj && prop?.type === 'property_identifier' &&
(obj.type === 'identifier' || obj.type === 'this')) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: prop.text };
}
continue;
}
// Unwrap await: `const user = await fetchUser()` or `await a.getC()`
const callNode = unwrapAwait(valueNode);
if (!callNode || callNode.type !== 'call_expression') continue;
const funcNode = callNode.childForFieldName('function');
if (!funcNode) continue;
// Simple call → callResult: getUser()
if (funcNode.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// Method call with receiver → methodCallResult: a.getC()
if (funcNode.type === 'member_expression') {
const obj = funcNode.childForFieldName('object');
const prop = funcNode.childForFieldName('property');
if (obj && prop?.type === 'property_identifier' &&
(obj.type === 'identifier' || obj.type === 'this')) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: prop.text };
}
}
}
return undefined;
};

View file

@ -321,6 +321,13 @@ nodes:
additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Constructor, Template, Module (use backticks in queries: \`Struct\`, \`Enum\`, etc.)"
node_properties:
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
Constructor: "parameterCount (INT32)"
relationships:
- CONTAINS: File/Folder contains child
- DEFINES: File defines a symbol

View file

@ -95,7 +95,7 @@ EXAMPLES:
MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType
• Find all properties of a class:
MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.description
MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.declaredType
• Find all writers of a field:
MATCH (f:Function)-[r:CodeRelation {type: 'ACCESSES', reason: 'write'}]->(p:Property) WHERE p.name = "address" RETURN f.name, f.filePath
@ -132,7 +132,7 @@ AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/pro
Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.
NOTE: ACCESSES edges (field read/write tracking) are included in context results. Coverage: reads detected during call chain resolution (e.g., user.address.save() emits a read on 'address'). Standalone reads and writes require Phase 2.`,
NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`,
inputSchema: {
type: 'object',
properties: {

View file

@ -0,0 +1,6 @@
#include "user.h"
void processUser() {
auto user = getUser("alice");
user.save();
}

View file

@ -0,0 +1,14 @@
#pragma once
#include <string>
class User {
public:
User(const std::string& n) : name_(n) {}
bool save() { return true; }
private:
std::string name_;
};
User getUser(const std::string& name) {
return User(name);
}

View file

@ -0,0 +1,8 @@
#include "models.h"
void processChain() {
auto user = getUser();
auto addr = user.address;
auto city = addr.getCity();
city.save();
}

View file

@ -0,0 +1,26 @@
#pragma once
#include <string>
class City {
public:
std::string name;
City(const std::string& n) : name(n) {}
bool save() { return true; }
};
class Address {
public:
City city;
Address(const City& c) : city(c) {}
City getCity() { return city; }
};
class User {
public:
Address address;
User(const Address& a) : address(a) {}
};
User getUser() {
return User(Address(City("NYC")));
}

View file

@ -0,0 +1,22 @@
class User {
public string Name { get; set; }
public User(string name) {
Name = name;
}
public bool Save() {
return true;
}
}
class App {
static User GetUser(string name) {
return new User(name);
}
void ProcessUser() {
var user = GetUser("alice");
user.Save();
}
}

View file

@ -0,0 +1,29 @@
class City {
public string Name { get; set; }
public City(string name) { Name = name; }
public bool Save() { return true; }
}
class Address {
public City City { get; set; }
public Address(City city) { City = city; }
public City GetCity() { return City; }
}
class User {
public Address Address { get; set; }
public User(Address address) { Address = address; }
}
class App {
static User GetUser() {
return new User(new Address(new City("NYC")));
}
void ProcessChain() {
var user = GetUser();
var addr = user.Address;
var city = addr.GetCity();
city.Save();
}
}

View file

@ -0,0 +1,12 @@
package main
import "example.com/callresult/models"
func GetUser(name string) *models.User {
return &models.User{Name: name}
}
func processUser() {
user := GetUser("alice")
user.Save()
}

View file

@ -0,0 +1,3 @@
module example.com/callresult
go 1.21

View file

@ -0,0 +1,9 @@
package models
type User struct {
Name string
}
func (u *User) Save() bool {
return true
}

View file

@ -0,0 +1,14 @@
package main
import "example.com/methodchain/models"
func GetUser() *models.User {
return &models.User{}
}
func processChain() {
user := GetUser()
addr := user.Address
city := addr.GetCity()
city.Save()
}

View file

@ -0,0 +1,3 @@
module example.com/methodchain
go 1.21

View file

@ -0,0 +1,21 @@
package models
type City struct {
Name string
}
func (c *City) Save() bool {
return true
}
type Address struct {
City City
}
func (a *Address) GetCity() *City {
return &a.City
}
type User struct {
Address Address
}

View file

@ -0,0 +1,10 @@
public class App {
static User getUser(String name) {
return new User(name);
}
void processUser() {
var user = getUser("alice");
user.save();
}
}

View file

@ -0,0 +1,11 @@
public class User {
private String name;
public User(String name) {
this.name = name;
}
public boolean save() {
return true;
}
}

View file

@ -0,0 +1,12 @@
class App {
static User getUser() {
return new User(new Address(new City("NYC")));
}
void processChain() {
var user = getUser();
var addr = user.address;
var city = addr.getCity();
city.save();
}
}

View file

@ -0,0 +1,16 @@
class City {
String name;
City(String name) { this.name = name; }
boolean save() { return true; }
}
class Address {
City city;
Address(City city) { this.city = city; }
City getCity() { return city; }
}
class User {
Address address;
User(Address address) { this.address = address; }
}

View file

@ -0,0 +1,6 @@
const { getUser } = require('./service');
function processUser() {
const user = getUser('alice');
user.save();
}

View file

@ -0,0 +1,11 @@
class User {
constructor(name) {
this.name = name;
}
save() {
return true;
}
}
module.exports = { User };

View file

@ -0,0 +1,11 @@
const { User } = require('./models');
/**
* @param {string} name
* @returns {User}
*/
function getUser(name) {
return new User(name);
}
module.exports = { getUser };

View file

@ -0,0 +1,14 @@
class User(val name: String) {
fun save(): Boolean {
return true
}
}
fun getUser(name: String): User {
return User(name)
}
fun processUser() {
val user = getUser("alice")
user.save()
}

View file

@ -0,0 +1,18 @@
class City(val name: String) {
fun save(): Boolean = true
}
class Address(val city: City) {
fun getCity(): City = city
}
class User(val address: Address)
fun getUser(): User = User(Address(City("NYC")))
fun processChain() {
val user = getUser()
val addr = user.address
val city = addr.getCity()
city.save()
}

View file

@ -0,0 +1,22 @@
<?php
class User {
public string $name;
public function __construct(string $name) {
$this->name = $name;
}
public function save(): bool {
return true;
}
}
function getUser(string $name): User {
return new User($name);
}
function processUser(): void {
$user = getUser("alice");
$user->save();
}

View file

@ -0,0 +1,28 @@
<?php
class City {
public string $name;
public function __construct(string $name) { $this->name = $name; }
public function save(): bool { return true; }
}
class Address {
public City $city;
public function __construct(City $city) { $this->city = $city; }
public function getCity(): City { return $this->city; }
}
class User {
public Address $address;
public function __construct(Address $address) { $this->address = $address; }
}
function getUser(): User {
return new User(new Address(new City("NYC")));
}
function processChain(): void {
$user = getUser();
$city = $user->getCity();
$city->save();
}

View file

@ -0,0 +1,5 @@
from service import get_user
def process_user():
user = get_user("alice")
user.save()

View file

@ -0,0 +1,6 @@
class User:
def __init__(self, name: str):
self.name = name
def save(self) -> bool:
return True

View file

@ -0,0 +1,4 @@
from models import User
def get_user(name: str) -> User:
return User(name)

View file

@ -0,0 +1,9 @@
from models import User, Address, City
def get_user() -> User:
return User(Address(City("NYC")))
def process_chain():
user = get_user()
city = user.get_city()
city.save()

View file

@ -0,0 +1,21 @@
class City:
def __init__(self, name: str):
self.name = name
def save(self) -> bool:
return True
class Address:
city: City
def __init__(self, city: City):
self.city = city
def get_city(self) -> City:
return self.city
class User:
address: Address
def __init__(self, address: Address):
self.address = address

View file

@ -0,0 +1,15 @@
class User
def save
true
end
end
# @return [User]
def get_user(name)
User.new
end
def process_user
user = get_user("alice")
user.save
end

View file

@ -0,0 +1,31 @@
class City
def save
true
end
end
class Address
# @return [City]
def get_city
City.new
end
end
class User
# @return [Address]
def get_address
Address.new
end
end
# @return [User]
def get_user
User.new
end
def process_chain
user = get_user()
addr = user.get_address()
city = addr.get_city()
city.save
end

View file

@ -0,0 +1,7 @@
mod models;
use models::get_user;
fn process_user() {
let user = get_user("alice");
user.save();
}

View file

@ -0,0 +1,13 @@
pub struct User {
pub name: String,
}
impl User {
pub fn save(&self) -> bool {
true
}
}
pub fn get_user(name: &str) -> User {
User { name: name.to_string() }
}

View file

@ -0,0 +1,9 @@
mod models;
use models::get_user;
fn process_chain() {
let user = get_user();
let addr = user.address;
let city = addr.get_city();
city.save();
}

View file

@ -0,0 +1,23 @@
pub struct City {
pub name: String,
}
impl City {
pub fn save(&self) -> bool { true }
}
pub struct Address {
pub city: City,
}
impl Address {
pub fn get_city(&self) -> &City { &self.city }
}
pub struct User {
pub address: Address,
}
pub fn get_user() -> User {
User { address: Address { city: City { name: "NYC".to_string() } } }
}

View file

@ -0,0 +1,12 @@
import { getUser } from './service';
function processUser() {
const user = getUser('alice');
user.save();
}
function processAlias() {
const user = getUser('bob');
const alias = user;
alias.save();
}

View file

@ -0,0 +1,11 @@
export class User {
name: string;
constructor(name: string) {
this.name = name;
}
save(): boolean {
return true;
}
}

View file

@ -0,0 +1,5 @@
import { User } from './models';
export function getUser(name: string): User {
return new User(name);
}

View file

@ -0,0 +1,8 @@
import { getUser } from './service';
function processChain() {
const user = getUser();
const addr = user.address;
const city = addr.getCity();
city.save();
}

View file

@ -0,0 +1,31 @@
export class City {
name: string;
constructor(name: string) {
this.name = name;
}
save(): boolean {
return true;
}
}
export class Address {
city: City;
constructor(city: City) {
this.city = city;
}
getCity(): City {
return this.city;
}
}
export class User {
address: Address;
constructor(address: Address) {
this.address = address;
}
}

View file

@ -0,0 +1,5 @@
import { User, Address, City } from './models';
export function getUser(): User {
return new User(new Address(new City('NYC')));
}

View file

@ -969,3 +969,49 @@ describe('Write access tracking (C++)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): auto user = getUser(); user.save()
// ---------------------------------------------------------------------------
describe('C++ call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding with auto', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser'
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save()
// ---------------------------------------------------------------------------
describe('C++ method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain with auto', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processChain'
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1316,3 +1316,49 @@ describe('Write access tracking (C#)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): var user = GetUser(); user.Save()
// ---------------------------------------------------------------------------
describe('C# call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.Save() to User#Save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessUser' && c.targetFilePath.includes('App')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): GetUser() → .Address → .GetCity() → .Save()
// ---------------------------------------------------------------------------
describe('C# method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.Save() to City#Save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessChain' && c.targetFilePath.includes('App')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1099,3 +1099,49 @@ describe('Write access tracking (Go)', () => {
expect(addressWrite!.source).toBe('updateUser');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): user := GetUser(); user.Save()
// ---------------------------------------------------------------------------
describe('Go call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.Save() to User#Save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): GetUser() → .Address → .GetCity() → .Save()
// ---------------------------------------------------------------------------
describe('Go method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.Save() to City#Save via 3-step chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'processChain' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1232,3 +1232,49 @@ describe('Write access tracking (Java)', () => {
expect(addressWrite!.source).toBe('updateUser');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): var user = getUser(); user.save()
// ---------------------------------------------------------------------------
describe('Java call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save()
// ---------------------------------------------------------------------------
describe('Java method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('Models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1395,3 +1395,49 @@ describe('Write access tracking (Kotlin)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): val user = getUser(); user.save()
// ---------------------------------------------------------------------------
describe('Kotlin call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('User')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save()
// ---------------------------------------------------------------------------
describe('Kotlin method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('Models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1336,3 +1336,49 @@ describe('Write access tracking (PHP)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): $user = getUser(); $user->save()
// ---------------------------------------------------------------------------
describe('PHP call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-call-result-binding'),
() => {},
);
}, 60000);
it('resolves $user->save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('App')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): getUser() → ->getCity() → ->save()
// ---------------------------------------------------------------------------
describe('PHP method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves $city->save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('App')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1382,3 +1382,49 @@ describe('Write access tracking (Python)', () => {
expect(addressWrite!.source).toBe('update_user');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): user = get_user(); user.save()
// ---------------------------------------------------------------------------
describe('Python call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .get_city() → .save()
// ---------------------------------------------------------------------------
describe('Python method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -966,3 +966,49 @@ describe('Write access tracking (Ruby)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): user = get_user(); user.save
// ---------------------------------------------------------------------------
describe('Ruby call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .get_address() → .get_city() → .save
// ---------------------------------------------------------------------------
describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('app')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1429,3 +1429,49 @@ describe('Write access tracking (Rust)', () => {
expect(scoreWrite!.source).toBe('update_user');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): let user = get_user(); user.save()
// ---------------------------------------------------------------------------
describe('Rust call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .address → .get_city() → .save()
// ---------------------------------------------------------------------------
describe('Rust method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-method-chain-binding'),
() => {},
);
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1959,3 +1959,98 @@ describe('Write access tracking (TypeScript)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): const user = getUser(); user.save()
// Activates Tier 2b pendingCallResults — binds return type at TypeEnv build time.
// ---------------------------------------------------------------------------
describe('TypeScript call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-call-result-binding'),
() => {},
);
}, 60000);
it('detects User class with save method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
});
it('detects getUser function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
});
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
it('resolves alias.save() to User#save via call-result + copy chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processAlias' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// JavaScript call-result variable binding (Phase 9) via JSDoc @returns
// ---------------------------------------------------------------------------
describe('JavaScript call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'js-call-result-binding'),
() => {},
);
}, 60000);
it('resolves user.save() to User#save via call-result binding with JSDoc', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUser' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): getUser() → .address → .getCity() → .save()
// Unified fixpoint resolves field access + method-call-with-receiver at TypeEnv build time.
// ---------------------------------------------------------------------------
describe('TypeScript method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-method-chain-binding'),
() => {},
);
}, 60000);
it('detects User, Address, City classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('Address');
expect(classes).toContain('City');
});
it('resolves city.save() to City#save via 3-step chain (callResult → fieldAccess → methodCallResult)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processChain' && c.targetFilePath.includes('models')
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1602,6 +1602,8 @@ class RepoService {
const mockSymbolTable = {
lookupFuzzy: (name: string) =>
name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [],
lookupFuzzyCallable: () => [],
lookupFieldByOwner: () => undefined,
lookupExact: () => undefined,
lookupExactFull: () => undefined,
add: () => {},
@ -2241,10 +2243,11 @@ svc = App::Models::Service.new
expect(env.get(scopeKey!)?.get('b')).toBe('User');
});
it('does NOT resolve reverse-ordered Tier 2 chains (b = a, a = c, c: User)', () => {
it('resolves reverse-ordered Tier 2 chains via fixpoint (b = a, a = c, c: User)', () => {
// Two chained Tier 2 assignments in reverse source order.
// Post-walk iterates source order: b = a (a not yet resolved) → fails,
// then a = c (c is Tier 0) → succeeds. b stays unresolved.
// The unified fixpoint loop resolves this in 2 iterations:
// Iter 1: a = c (c is Tier 0 → a = User)
// Iter 2: b = a (a now resolved → b = User)
const tree = parse(`
function process() {
const b = a;
@ -2257,8 +2260,8 @@ svc = App::Models::Service.new
expect(scopeKey).toBeDefined();
expect(env.get(scopeKey!)?.get('c')).toBe('User');
expect(env.get(scopeKey!)?.get('a')).toBe('User');
// b should NOT resolve — reverse Tier 2 chain
expect(env.get(scopeKey!)?.get('b')).toBeUndefined();
// Fixpoint now resolves reverse-ordered chains
expect(env.get(scopeKey!)?.get('b')).toBe('User');
});
});

View file

@ -1,25 +1,12 @@
# Type Resolution Roadmap
This roadmap describes the next major capabilities needed to evolve GitNexus's type-resolution layer from a strong receiver-disambiguation aid into a broader static-analysis foundation.
The roadmap assumes the current system already provides:
- explicit type extraction from declarations and parameters
- initializer / constructor inference
- loop element inference for many languages
- selected pattern binding and narrowing
- comment-based fallbacks in JS/TS, PHP, and Ruby
- constrained return-type-aware receiver inference during call processing
The remaining work is about **generalisation**, **deeper structure modelling**, and **better propagation**.
This roadmap describes the evolution of GitNexus's type-resolution layer from a receiver-disambiguation aid into a production-grade static-analysis foundation.
---
## Principles for Future Work
## Principles
The type system should continue to preserve the qualities that make it practical today:
- **stay conservative**
- **stay conservative** — prefer missing a binding over introducing a misleading one
- **prefer explainable inference over clever but brittle inference**
- **limit performance overhead during ingestion**
- **keep per-language extractors explicit rather than over-generic**
@ -29,435 +16,286 @@ The goal is not to build a compiler. The goal is to support high-value static an
---
## Near-Term Priority: Generalise Existing Inference
## Delivered Phases
The next biggest gain is not inventing a new type system layer. It is expanding the inference the system already performs so more constructs can benefit from it.
### Phase 7: Cross-Scope and Return-Aware Propagation ✅
### Why this is the right next step
**Shipped in** `feat/phase7-type-resolution`.
Today, return-type-aware inference already exists in constrained form inside `call-processor.ts`, and loop element inference already handles many identifier-based iterables.
- `ReturnTypeLookup` interface threading return-type knowledge into TypeEnv
- Iterable call-expression support across 7 languages (Go, TS, Python, Rust, Java, Kotlin, C#)
- PHP class-level `@var` property typing for `$this->property` foreach (Strategy C)
- `pendingCallResults` infrastructure (Tier 2b loop + `PendingAssignment` union) — activated by Phase 9
The most valuable next move is to let those signals participate in more places, especially:
### Phase 8: Field and Property Type Resolution ✅
- iterable expressions rather than only iterable identifiers
- assignment propagation from call results
- doc-comment-derived file-scope bindings where local scope is insufficient
**Shipped in** `feat/phase8-field-property-type-resolution`.
- SymbolTable `fieldByOwner` index — O(1) field lookup by `ownerNodeId\0fieldName`
- `HAS_PROPERTY` edge type + `declaredType` on Property symbols
- Deep chain resolution up to 3 levels (`user.address.city.getName()`) across 10 languages
- Mixed field+method chains via unified `MixedChainStep[]` (`svc.getUser().address.save()`)
- Type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.)
- `ACCESSES` edge type — read/write field access tracking across 12 languages
- C++ `field_declaration` capture, `field_expression` receiver support
- Rust unit struct instantiation, Ruby YARD `@return` for `attr_accessor`
### Phase 9 + 9C: Return-Type-Aware Variable Binding ✅
**Shipped in** `feat/phase9-call-result-binding` (PR #379).
- Simple call-result binding: `const user = getUser(); user.save()` across 11 languages
- Unified fixpoint loop replacing sequential Tier 2b/2a — handles 4 binding kinds (`callResult`, `copy`, `fieldAccess`, `methodCallResult`) at arbitrary depth
- Field access binding: `const addr = user.address` resolves via `lookupFieldByOwner` + `declaredType`
- Method-call-result binding: `const city = addr.getCity()` resolves via `lookupFuzzyCallable` filtered by `ownerId`
- Fixpoint iterates until stable (max 10 iterations), enabling chains like `getUser() → .address → .getCity() → city.save()`
- Reverse-order copy chains now resolve (`const b = a; const a: User = x` → both resolve)
---
## Phase 7: Cross-Scope and Return-Aware Propagation
## Open Phases
> **Status: COMPLETE** — shipped in `feat/phase7-type-resolution` (commits `ed767e3`, `ca4c6c1`, `d79237e`).
### Phase 10: Loop-Fixpoint Bridge
### Goal
**Supersedes Phase 9B.** For-loop element bindings run during `walk()` before the fixpoint. Variables typed by the fixpoint are invisible to for-loop extraction.
Allow loop inference and assignment inference to see more than the current function-local environment.
### Problems this phase addresses
#### 7A. Iterable expressions in Go and similar cases (shipped as Phase 7.3)
```go
for _, user := range getUsers() {
user.Save()
**Problem:**
```typescript
const users = getUsers(); // fixpoint: users → User[]
for (const u of users) { // walk-time: users untyped → u unresolved
u.save(); // missed CALLS edge
}
```
The iterable is a call expression, not an identifier with a local binding.
**Approach:** Post-fixpoint for-loop replay. During `walk()`, store for-loop AST nodes whose iterable is unresolved. After fixpoint completes, replay `extractForLoopBinding` on those nodes using the now-resolved iterable types.
Resolved: `ReturnTypeLookup` introduced in Phase 7.1 exposes `lookupRawReturnType`. All seven typed-iteration languages (Go, TypeScript, Python, Rust, Java, Kotlin, C#) now unwrap the raw container type string to extract the element type when the iterable is a direct function call.
**Scope:**
- Infrastructure: `pendingForLoops` collection + replay (~20 lines in `type-env.ts`)
- No extractor changes — reuses existing `extractForLoopBinding`
- Swift: add for-loop element binding (currently missing)
- Nested for-loops with field-dependent iterables are NOT replayed (handled by call-processor chain resolution)
#### 7B. File-scope or class-scope iterable typing in PHP (shipped as Phase 7.4)
**Risks:** AST node lifetime (safe — nodes valid within `buildTypeEnv` lifetime).
```php
foreach ($this->users as $user) {
$user->save();
}
```
If `$this->users` is typed through a class property annotation or file/class-scope doc-comment information, the current local-scope-only path may not be enough.
Resolved: Strategy C in the PHP `extractForLoopBinding` walks up the AST to the enclosing `class_declaration`, scans the `declaration_list` for a matching `property_declaration`, and extracts the element type from the `@var` PHPDoc comment (or PHP 7.4+ native type field). The `@param` workaround previously required in the fixture is gone.
#### 7C. Broader use of already-known return types (shipped as Phase 7.1 + 7.2)
The system can already infer receiver types from uniquely resolved call results in `call-processor.ts`. That needs to be generalised so `TypeEnv` can benefit from it too.
Resolved: `ReturnTypeLookup` (Phase 7.1) encapsulates `lookupReturnType` / `lookupRawReturnType` and is threaded through `ForLoopExtractorContext` (Phase 7.2) to all for-loop extractors. Phase 7.2 also added the `pendingCallResults` infrastructure (the `PendingAssignment` discriminated union in `types.ts` and the Tier 2b processing loop in `type-env.ts`), but no extractor populates it yet — `var x = f()` propagation is Phase 9 work.
### Engineering direction (as implemented)
- introduced `ReturnTypeLookup` interface and `buildReturnTypeLookup` factory in `type-env.ts`
- replaced per-extractor `(node, env)` signature with `ForLoopExtractorContext` context object for extensibility
- added `extractElementTypeFromString` to `shared.ts` as the canonical raw-string container unwrapper
- added PHP Strategy C helper (`findClassPropertyElementType`) scoped to the PHP extractor
- kept all changes backwards-compatible — explicit-type paths are untouched
### Delivered impact
- loop inference now works for direct function call iterables in all 7 typed-iteration languages
- PHP `$this->property` foreach is resolved from class-level `@var` without requiring `@param` workarounds
- `pendingCallResults` infrastructure is in place (Tier 2b loop + `PendingAssignment` union) — dormant until an extractor emits `{ kind: 'callResult' }` (Phase 9)
### Risk level
**Medium** (as predicted)
The interface change touched all extractors but remained additive — no existing paths were changed.
**Impact: High | Effort: Low-Medium**
---
## Phase 8: Field and Property Type Resolution *(delivered)*
### Phase 11: Inheritance & this/self
### Goal
Four items sharing infrastructure.
Model class / struct fields so chained member access can be resolved more accurately.
#### 11A: MRO-aware field and method lookups
### Status
**Problem:** `lookupFieldByOwner` only finds direct fields. Inherited fields (`Admin extends User`, field on `User`) don't resolve.
**Delivered.** One-level, deep, and mixed field+method chain resolution is implemented across 9 languages. Pattern destructuring (8C) remains open.
**Approach:** Pre-compute `parentMap: Map<nodeId, nodeId[]>` from EXTENDS + IMPLEMENTS edges. Pass to `buildTypeEnv`. Update `resolveFieldType` and `resolveMethodReturnType` to walk parent chain on miss (max depth 5, cycle-safe, first-match-wins for diamond inheritance).
#### What shipped
Includes IMPLEMENTS edges so interface-declared fields/methods resolve (Java, Kotlin, C#).
- **SymbolTable `fieldByOwner` index** — O(1) lookup via `ownerNodeId\0fieldName` key. Properties excluded from `globalIndex` to prevent namespace pollution. *(Q1 resolved)*
- **`HAS_PROPERTY` edge type** — split from `HAS_METHOD` to distinguish property linkage
- **`declaredType` field** on Property symbols — semantic split from `returnType` (methods)
- **`resolveFieldAccessType`** in call-processor — resolves field access chains at call sites
- **`extractPropertyDeclaredType`** in shared utils — 5-strategy cross-language type extraction
- **Per-language `@definition.property` captures** — see coverage table below
- **`extractMixedChain`** in utils — unified recursive AST walker that handles both `call_expression` and `field_expression` nodes interchangeably, building `MixedChainStep[]` capped at `MAX_CHAIN_DEPTH` (3). Replaces the earlier separate `extractFieldChain` / `extractCallChain` functions.
- **`receiverMixedChain`** on `ExtractedCall` — unified chain representation replacing the old `receiverCallChain` + `receiverFieldAccess` split
- **`ACCESSES` edge type** — read and write field/property access tracking. Read edges emitted via `walkMixedChain` chain resolution; write edges emitted via tree-sitter `@assignment` capture patterns across 12 languages (C excluded). PHP includes static property writes (`ClassName::$field`). Ruby compound assignment (`operator_assignment`) tracked.
- **Unified chain resolution** in call-processor — a single loop in both `processCalls` (sequential) and `processCallsFromExtracted` (worker) walks `MixedChainStep[]`, dispatching `kind: 'field'` to `resolveFieldAccessType` and `kind: 'call'` to `resolveCallTarget` + return type extraction
- **Type-preserving stdlib passthrough** — `unwrap()`, `expect()`, `clone()`, `as_ref()`, and similar stdlib methods that don't change the receiver type are recognized as identity operations in the chain loop, allowing chains like `user.unwrap().save()` to resolve correctly when TypeEnv has already stripped the nullable wrapper
- **C++ `field_declaration`** property capture via `field_identifier` declarator
- **C++ `field_expression` support** — tree-sitter-cpp uses `argument` (not `object`) for the receiver of `field_expression`; `extractMixedChain` handles this
- **C++ inline method double-indexing guard** — prevents `@definition.function` from creating duplicate symbol entries for methods already captured by `@definition.method` inside class/struct bodies (applied in both `parsing-processor.ts` and `parse-worker.ts`)
- **Rust unit struct instantiation** — `let svc = UserService;` (bare identifier assignment) now recognized by type-env when the RHS matches a known class/struct name
- **Ruby YARD `@return [Type]`** extraction for `attr_accessor` properties, enabling field-type resolution in dynamically typed Ruby
#### 11B: this/self in fixpoint
#### Language coverage
**Problem:** `this.field` and `this.method()` emit pending items with `receiver: 'this'`, but `scopeEnv.get('this')` returns `undefined`.
| Language | Property capture | `declaredType` extraction | Deep chain | Notes |
|----------|-----------------|--------------------------|:----------:|-------|
| TypeScript | ✅ `public_field_definition`, `private_property_identifier`, `required_parameter` | ✅ Strategy 2 (type_annotation) | ✅ | Parameter properties added |
| JavaScript | ✅ `field_definition` | ⚠️ No type annotations in JS | — | Capture added; declaredType requires JSDoc |
| Java | ✅ `field_declaration` | ✅ Strategy 3 (parent type) | ✅ | |
| C# | ✅ `property_declaration` | ✅ Strategy 1 (type field) | ✅ | |
| Go | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | |
| Kotlin | ✅ `property_declaration` | ✅ Strategy 4 (variable_declaration) | ✅ | New strategy added |
| PHP | ✅ `property_declaration` | ✅ Strategy 1 + PHPDoc @var fallback | ✅ | Strategy 5 for pre-7.4 |
| Rust | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | `extractMemberAccessParts` handles `field_expression` via `value`/`field` |
| Python | ✅ `assignment` with `type` | ✅ Class-level annotations | ✅ | `self.x` instance pattern not yet supported |
| Ruby | ✅ `attr_*` via call routing | ✅ YARD `@return [Type]` | — | YARD fallback for dynamically typed properties |
| C++ | ✅ `field_declaration` via `field_identifier` | ✅ Strategy 1 (type field) | ✅ | |
| Swift | ✅ `property_declaration` | ⚠️ Untested | — | |
**Approach:** At collection time during `walk()`, resolve the enclosing class name immediately via `findEnclosingClassName()` and substitute it as the receiver. Covers both `fieldAccess` and `methodCallResult`. No fixpoint changes needed. 5-10 lines per extractor.
#### What remains open
#### 11C: Go inc/dec write access
- **8C. Pattern destructuring** dependent on field knowledge
- Python `self.x` instance attribute pattern
**Problem:** `obj.field++`/`obj.field--` produce `inc_statement`/`dec_statement` — write-access tracking doesn't see them.
### Problems this phase addresses
**Approach:** Add these node types to call-processor write detection (~5 lines).
#### 8A. Deep property chains *(delivered)*
#### 11D: Swift assignment chains
```typescript
user.address.city.getName()
```
**Problem:** Swift has no `extractPendingAssignment` — copy/callResult/fieldAccess/methodCallResult don't work.
✅ `extractFieldChain` recursively walks nested member_expression nodes at parse time, building a `fieldChain: string[]`. At resolution time, the chain is walked step-by-step: `user → User`, `address → Address`, `city → City`, `getName() → City#getName`. Supported across TS, Java, C#, Go, Kotlin, PHP, C++.
**Approach:** Implement `extractPendingAssignment` for Swift covering all 4 binding kinds.
#### 8B. Mixed field+method chain resolution *(delivered)*
**Risks:** Performance of parent chain walking in fixpoint (bounded by `n_pending × depth × iterations`). Interface method ambiguity (mitigated by checking direct class first, parents on miss).
```typescript
svc.getUser().address.save() // call → field → call
user.getAddress().city.getName() // call → field → call
user.address.getCity().save() // field → call → call
user.unwrap().save() // stdlib passthrough → call
```
✅ `extractMixedChain` walks both call-expression and field-expression nodes in a single unified pass, producing `MixedChainStep[]`. The resolver walks steps left-to-right: `kind: 'field'` resolves via `resolveFieldAccessType`, `kind: 'call'` resolves via `resolveCallTarget` + return type extraction. Stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) are recognized as type-preserving identity operations.
#### 8C. Pattern destructuring that depends on field knowledge
This is especially relevant for:
- Rust struct-pattern destructuring
- PHP chained property access
- richer TypeScript or Python object-based destructuring in future work
### Engineering direction (as implemented)
- ~~parse field / property declarations per class or struct~~ ✅
- ~~build a field-type map keyed by owning type~~ ✅ (`fieldByOwner` index)
- ~~teach lookup and chain-resolution logic to walk member segments (deep chains)~~ ✅ (`extractMixedChain` + unified chain-walking loop)
- ~~unify field chains and call chains into a single representation~~ ✅ (`MixedChainStep[]` replaces separate `receiverCallChain` / `receiverFieldAccess`)
- ~~C++ struct member field capture~~ ✅ (`field_declaration` via `field_identifier`)
- ~~C++ `field_expression` receiver extraction~~ ✅ (`argument` field support in `extractMixedChain`)
- ~~Rust unit struct instantiation~~ ✅ (`let svc = TypeName;` recognized by type-env)
- ~~Ruby YARD `@return` for `attr_accessor`~~ ✅ (comment-walking in `call-routing.ts`)
- ~~stdlib passthrough methods~~ ✅ (`TYPE_PRESERVING_METHODS` set in call-processor)
- keep this separate from the base variable-binding layer where possible
### Delivered impact
This is the biggest unlock for richer static analysis because it allows the graph to model more than just top-level receivers.
It materially improved:
- chained property resolution (up to 3 levels deep)
- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`)
- member-based call disambiguation across 9 languages
- deeper context extraction for downstream tooling
- C++ struct/class field visibility in the knowledge graph
- C++ chained method call resolution (previously blocked by missing `argument` field support)
- Rust nullable receiver chains (`user.unwrap().save()`)
- Ruby field-type resolution via YARD documentation
### Risk level
**High** (delivered — risk was managed through incremental delivery across 8, 8A, 8B)
This phase pushed the system from variable typing into structural object modelling. Remaining work:
- careful handling of inheritance / embedding / language-specific member semantics
- pattern destructuring dependent on field knowledge (8C)
**Impact: Medium-High | Effort: Medium**
---
## Phase 9: Full Return-Type-Aware Variable Binding
### Phase 12: Destructuring
### Goal
**Problem:** `const { address, name } = user` produces no bindings — LHS is a pattern node, not an identifier.
Make return-type-driven inference a first-class input to `TypeEnv`, not just a downstream verification path.
**Approach:** Add `{ kind: 'destructure', source, bindings: [{ varName, fieldName }] }` to `PendingAssignment`. In the fixpoint, when source's type resolves, look up each field via `lookupFieldByOwner` and bind each variable.
### Problems this phase addresses
Works without Phase 11A (direct fields only). Phase 11A MRO enhances it to resolve inherited fields too.
#### 9A. Binding variables from call results
**Phased delivery:**
```typescript
const users = repo.getUsers()
```
| Sub-phase | Scope | Languages |
|-----------|-------|-----------|
| **12A** | Object destructuring | TS/JS (`object_pattern`) |
| **12B** | Struct pattern destructuring | Rust (`struct_pattern`) |
| **12C** (deferred) | Positional destructuring | Python, Kotlin, C#, C++ — needs tuple-position-to-field mapping |
Desired binding:
Skip: computed properties, rest elements, nested destructuring (`{ address: { city } }` — deferred).
- `users -> List<User>`
**Risks:** Nested destructuring requires recursive resolution (explicitly deferred).
#### 9B. Looping directly over call results
```typescript
for (const user of getUsers()) {
user.save()
}
```
Desired binding:
- `user -> User`
#### 9C. Broader method-chain inference
```typescript
repo.getUsers().first()
```
If return types can propagate more systematically, later chain stages become much more resolvable.
### Engineering direction
- expose return types as reusable inference inputs inside `TypeEnv`
- distinguish raw textual return types from normalized receiver-usable types
- make method-call return inference receiver-aware where necessary
- avoid over-eager propagation when multiple call targets remain ambiguous
### Expected impact
This phase would make the type system feel much closer to a static-analysis substrate rather than a set of local heuristics.
It will especially improve codebases that rely heavily on:
- service-returned collections
- builder APIs
- repository methods
- chain-heavy fluent interfaces
### Risk level
**Medium to High**
The conceptual basis already exists, but generalising it without introducing false bindings requires careful ambiguity rules.
**Impact: Medium | Effort: Medium**
---
## Language-Specific Gaps
### Phase 13: Branch-Sensitive Narrowing
**Design principle:** Targeted narrowing, not general control-flow analysis. Skip anything that requires a control-flow graph.
**Phased delivery:**
#### 13A: Type predicate functions (TS only)
`function isUser(x: unknown): x is User` — detect `type_predicate` return type. When called in an `if` condition, emit pattern binding for the narrowed parameter.
#### 13B: Nullability narrowing (TS/Kotlin/C#/Swift)
`if (x != null)` → strip nullable wrapper in truthy branch. Uses existing `patternOverrides` mechanism (position-indexed, scope-aware). Swift `guard let` uses standard scopeEnv (narrowing persists for rest of function). Swift work requires Phase 11D (assignment chains) first.
#### 13C: Discriminated union narrowing (deferred)
`if (shape.kind === 'circle')` → needs tagged union metadata not in SymbolTable. Defer.
**What we skip entirely:** Full control-flow graph, arbitrary conditional narrowing, `typeof` guards, exhaustiveness checking.
**Risks:** Scope leakage (mitigated by `patternOverrides` position indexing). Swift `guard let` needs scopeEnv path (different from `patternOverrides`).
**Impact: Medium | Effort: Medium-High**
---
### Phase 14: Cross-File Binding Propagation
**Problem:** `buildTypeEnv` is per-file. Inferred types don't cross file boundaries.
```typescript
// file-a.ts — fixpoint resolves: config → Config
export const config = getConfig();
// file-b.ts — config has no type
import { config } from './file-a';
config.validate(); // missed
```
**Approach: Export-type index.** After each file's fixpoint, export resolved bindings for exported symbols into `ExportedBindings: Map<filePath, Map<symbolName, typeName>>`. Subsequent files seed scopeEnv from this index for imported symbols.
**Details:**
- Process files in topological import order (import-processor already builds the dependency graph)
- Re-exports: follow import chain transitively in `ExportedBindings`
- Barrel files (`index.ts`): chain of re-exports — same mechanism
- Default exports: keyed as `"default"` in the map, mapped to local name at import site
- Dynamic imports (`import()`, conditional `require()`): excluded — runtime-only edges
- Circular imports: files in a cycle processed in arbitrary order within the cycle; cross-cycle bindings don't propagate (conservative)
- Parallelism preserved within topological levels
**Why this is last:** Every earlier phase makes the per-file fixpoint stronger, reducing cases where cross-file propagation is needed. This is also the highest-risk architectural change.
**Risks:** Topological ordering correctness (mitigated by reusing import-processor's existing graph). Re-export chain depth (bounded by import depth, typically 2-3). Memory for `ExportedBindings` (~100K entries for 10K-file monorepo — negligible).
**Impact: High | Effort: High**
---
## Dependency Graph
```
Phase 10 (loops) ──────────────────────┐
│
Phase 11 (MRO + this + Go + Swift) ───┤
├──→ Phase 14 (cross-file)
Phase 12 (destructuring) ─────────────┤
│
Phase 13 (branch narrowing) ───────────┘
Phases 10–13 are independent of each other.
Exception: Phase 13B Swift (guard let) requires Phase 11D (Swift assignment chains).
Exception: Phase 12 benefits from Phase 11A (MRO) but works without it.
Phase 14 depends on all of 10–13 being stable.
Swift parity threaded through Phases 10–13 incrementally.
```
---
## Language-Specific Gaps (remaining)
### Swift
Current support remains relatively minimal.
Missing or weak areas include:
- for-loop element binding
- pattern binding
- assignment-chain propagation
- broader expression-based inference
**Priority:** Medium
**Reason:** It matters for parity, but the biggest global analysis gains are elsewhere.
- For-loop element binding → Phase 10
- Assignment chains (copy, callResult, fieldAccess, methodCallResult) → Phase 11D
- Pattern binding → Phase 13B (`guard let`)
### Go
Key remaining gaps:
- ~~iterable call expressions in range loops~~ ✓ shipped in Phase 7.3
- `obj.field++` / `obj.field--` produce `inc_statement`/`dec_statement` nodes (not `assignment_statement`), so write ACCESSES edges are not emitted for increment/decrement on struct fields
**Priority:** Medium (chained property access remains for Phase 8)
### PHP
Key remaining gaps:
- ~~file/class-scope iterable propagation~~ ✓ shipped in Phase 7.4 (Strategy C)
- chained property access
**Priority:** High
**Reason:** PHP heavily benefits from doc-comment-aware field and property modelling.
- `obj.field++`/`obj.field--` write ACCESSES → Phase 11C
### Rust
Key remaining gap:
- struct-pattern field destructuring
**Priority:** Medium
**Reason:** Important for completeness, but field-type infrastructure is the real prerequisite.
- Struct-pattern field destructuring → Phase 12B
### All languages
Shared missing capabilities:
- ~~field / property type resolution~~ ✓ shipped in Phase 8 + 8A (10 languages)
- ~~mixed field+method chain resolution~~ ✓ shipped in Phase 8B (unified `MixedChainStep[]`)
- generalised return-type-aware binding in `TypeEnv` (Phase 9)
**Priority:** High
**Reason:** Return-type propagation is the biggest remaining blocker to deeper static analysis.
- Inherited field/method resolution → Phase 11A
- `this`/`self` in fixpoint → Phase 11B
- Cross-file binding propagation → Phase 14
---
## Recommended Delivery Order
## Milestones
### ~~1. Generalise existing return and loop inference~~ ✅ Phase 7
### Milestone A — Inference Expansion ✅ (Phase 7)
Delivered. Iterable call-expression support, `ReturnTypeLookup`, file-scope binding, PHP Strategy C.
Loop inference, `ReturnTypeLookup`, PHP Strategy C.
### ~~2. Add field / property type maps~~ ✅ Phase 8 + 8A + 8B
### Milestone B — Structural Member Typing ✅ (Phase 8)
Delivered. Per-type field metadata, deep chain resolution (up to 3 levels), mixed field+method chains, type-preserving stdlib passthrough, C++ and Rust fixes.
Field/property maps, deep chains, mixed chains, stdlib passthroughs.
### 3. Promote return types into first-class `TypeEnv` inputs ← **next**
### Milestone C — Static-Analysis Foundation ✅ (Phase 9 + 9C)
This converts existing downstream validation into a broader inference capability.
Unified fixpoint loop, call-result binding, field access binding, method-call-result binding, arbitrary-depth chain propagation.
Deliverables:
### Milestone D — Completeness ← **next** (Phases 10–13)
- call-result variable binding (`var x = f()` propagation)
- loop inference from call results (already done for direct iterables, pending for assigned results)
- broader chain propagation
Loop-fixpoint bridge, inheritance walking, `this`/`self` resolution, destructuring, branch narrowing, Swift parity.
### 4. Broaden branch-sensitive narrowing where low-risk
### Milestone E — Cross-Boundary (Phase 14)
After the structural work lands, selective branch refinement becomes more valuable and easier to reason about.
Export-type index, cross-file binding propagation.
---
## What “Production-Grade Static Analysis” Means Here
## Open Design Questions
For GitNexus, production-grade does **not** mean replacing a language compiler.
A realistic target is:
- strong receiver-constrained call resolution across common language idioms
- reliable handling of typed loops, constructor-like initializers, and common patterns
- useful return-type propagation for service/repository style code
- enough field/property knowledge to support chained-member analysis
- conservative behavior under ambiguity
- predictable performance during indexing
That would be sufficient for:
- better call graphs
- more accurate impact analysis
- stronger context assembly for AI workflows
- more trustworthy graph traversal features
| # | Question | Status |
|---|----------|--------|
| 1 | Where should field-type metadata live? | ✅ Resolved: `fieldByOwner` index in SymbolTable |
| 2 | How should ambiguity be represented? | ✅ Resolved: keep `undefined`. Conservative approach proven through 9 phases. |
| 3 | How much receiver context for return types? | ✅ Resolved: Phase 9C `resolveMethodReturnType` filters by `ownerId`. |
| 4 | How much branch sensitivity? | ✅ Resolved: type predicates + null checks only. No control-flow graph. (Phase 13) |
| 5 | Field typing and chain typing — one phase or two? | ✅ Resolved: incremental delivery within phases (Phase 8/8A precedent). |
| 6 | Phase 9B vs Phase 10? | ✅ Resolved: Phase 10 supersedes 9B via post-fixpoint replay. |
---
## Suggested Milestone Definitions
## What "Production-Grade" Means Here
### Milestone A — Inference Expansion ✅
For GitNexus, production-grade does **not** mean replacing a language compiler. The target:
Delivered in Phase 7.
- Strong receiver-constrained call resolution across common language idioms
- Reliable handling of typed loops, constructors, and common patterns
- Return-type propagation for service/repository code
- Field/property knowledge for chained-member analysis
- Inheritance-aware lookups
- Conservative behavior under ambiguity
- Predictable performance during indexing
- loop inference works for identifier iterables and common call-expression iterables across 7 languages
- `ReturnTypeLookup` threads return-type knowledge into TypeEnv
- PHP class-level `@var` property typing for `$this->property` foreach
### Milestone B — Structural Member Typing ✅
Delivered in Phase 8 + 8A + 8B.
- field/property maps exist for class-like types across 9 languages
- deep chains resolve up to 3 levels (`user.address.city.getName()`)
- mixed field+method chains resolve interleaved patterns (`svc.getUser().address.save()`)
- stdlib passthroughs (`unwrap`, `clone`, etc.) are type-preserving in chains
- C++ and Rust chain call resolution fixed (field_expression argument, unit struct)
### Milestone C — Static-Analysis Foundation ← **next**
Success looks like:
- return-type-aware variable binding is a first-class part of environment construction
- chains, loops, and assignments share a coherent propagation model
- downstream graph features can rely on more than local receiver heuristics
---
## Open Questions for Future Design
These should be resolved before or during implementation of the later phases.
1. **Where should field-type metadata live?**
✅ Resolved: in `SymbolTable` via the `fieldByOwner` index, keyed by `ownerNodeId\0fieldName`. Properties live alongside other symbols but are excluded from `globalIndex` to prevent namespace pollution.
2. **How should ambiguity be represented?**
Is `undefined` sufficient, or do later phases need a richer "known ambiguous" state?
3. **How much receiver context should return-type inference require?**
Some methods only become meaningful once the receiver type is already partially known.
4. **How much branch sensitivity is worth the complexity?**
Some narrowing gives clear value; full control-flow typing likely does not.
5. **Should field typing and chain typing be one phase or two?**
✅ Resolved: delivered as Phase 8 (single-level) + Phase 8A (deep chains) in the same branch, with separate test suites per language. Incremental delivery within one phase worked well.
That supports: better call graphs, more accurate impact analysis, stronger AI context assembly, more trustworthy graph traversal.
---
## Summary
Phases 7 and 8 (including 8A and 8B) are **complete**. The type system now handles:
**Complete:** Phases 7, 8, 9, 9C — explicit types, constructor inference, loop inference, field/property resolution, deep chains, mixed chains, stdlib passthroughs, comment-based types, unified fixpoint with 4 binding kinds, arbitrary-depth chain propagation across 11 languages.
- ✅ explicit type annotations and parameters across 13 languages
- ✅ initializer/constructor inference with SymbolTable validation
- ✅ loop element inference including call-expression iterables (7 languages)
- ✅ field/property type resolution with deep chains (up to 3 levels, 10 languages)
- ✅ mixed field+method chains (`svc.getUser().address.save()`)
- ✅ type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.)
- ✅ comment-based types (JSDoc, PHPDoc, YARD)
**Next:** Phase 10 (loop-fixpoint bridge) → Phase 11 (MRO + this/self + Go + Swift) → Phase 12 (destructuring) → Phase 13 (branch narrowing) → Phase 14 (cross-file propagation).
**The next step is Phase 9**: promote return-type-aware inference into `TypeEnv` as a first-class input, enabling `var x = f()` variable binding and broader chain propagation. The `pendingCallResults` infrastructure is already in place (Tier 2b loop + `PendingAssignment` union) — it just needs extractors to emit `{ kind: 'callResult' }` entries.
That path preserves the current strengths of the system while moving GitNexus the final step toward a robust, production-grade static-analysis foundation.
Each phase is independently deliverable (except Phase 14 which depends on 10–13 being stable). Swift parity is threaded incrementally through Phases 10–13.

View file

@ -7,7 +7,7 @@ When the code contains a call such as `user.save()`, the resolver tries to deter
This system is designed to be:
- **Conservative** — it prefers missing a binding over introducing a misleading one
- **Single-pass** — bindings are collected during a single AST walk, with a limited post-pass for assignment propagation
- **Walk + fixpoint** — bindings are collected during a single AST walk, then a unified fixpoint loop iterates over pending assignments (copy, callResult, fieldAccess, methodCallResult) until no new bindings are produced
- **Scope-aware** — function-local bindings are isolated from file-level bindings
- **Per-file** — the environment is built for one file at a time, though it may consult the global `SymbolTable` for validation in specific cases
@ -292,16 +292,20 @@ const alias = user
const other = alias
```
This is handled after the main walk through a single pass over pending assignments.
This supports simple forward propagation, but there is no iterative fixpoint step. For example:
This is handled after the main walk through a unified fixpoint loop over all pending assignments (copy, callResult, fieldAccess, methodCallResult). The loop iterates until no new bindings are produced (max 10 iterations), enabling arbitrary-depth mixed chains and reverse-order resolution:
```typescript
const b = a
const a: User = getUser()
const b = a // iteration 2: b → User (a now resolved)
const a: User = getUser() // iteration 1: a → User
```
will not resolve `b`.
Both `a` and `b` resolve correctly. The fixpoint also handles chains mixing field access and method calls:
```typescript
const user = getUser() // callResult → User
const addr = user.address // fieldAccess → Address
const city = addr.getCity() // methodCallResult → City
```
---
@ -359,7 +363,7 @@ A key detail is that some initializer bindings are not fully resolved inside `Ty
- validated class / struct constructor candidates
- uniquely resolved function or method calls that expose a usable return type
So return-type-aware receiver inference already exists in a constrained downstream form today. Phase 7.3 extended this by threading `ReturnTypeLookup` into `TypeEnv` via `ForLoopExtractorContext`, enabling for-loop call-expression iterables (e.g., `for (const u of getUsers())`) to resolve element types in 7 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP). General assignment propagation (`var x = f()` binding the return type of `f` into the scope env) remains pending — the `pendingCallResults` infrastructure exists but is dormant until Phase 9.
So return-type-aware receiver inference already exists in a constrained downstream form today. Phase 7.3 extended this by threading `ReturnTypeLookup` into `TypeEnv` via `ForLoopExtractorContext`, enabling for-loop call-expression iterables (e.g., `for (const u of getUsers())`) to resolve element types in 7 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP). Phase 9 activated simple call-result binding (`var x = f()`) across all 11 supported languages (Swift excluded). Phase 9C replaced the sequential Tier 2b/2a with a unified fixpoint loop that handles four binding kinds — `callResult`, `copy`, `fieldAccess`, and `methodCallResult` — iterating until no new bindings are produced. This enables arbitrary-depth mixed chains like `const user = getUser(); const addr = user.address; const city = addr.getCity(); city.save()`.
---
@ -377,6 +381,9 @@ So return-type-aware receiver inference already exists in a constrained downstre
| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ |
| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No |
| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No |
| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | No | Yes | No |
| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | No | Yes | No |
| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | No | Yes | No |
| Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No |
\* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported.
@ -385,6 +392,12 @@ So return-type-aware receiver inference already exists in a constrained downstre
‡ C has no `@definition.property` query pattern. Struct member fields are not captured. C++ captures class/struct member fields via `field_declaration`.
¶ Ruby call-result and method-call-result binding work via `call`/`method_call` nodes. Ruby uses method calls for both field access and method calls — there is no separate field access node type.
‖ Python class-level annotated attributes (`address: Address`) have `declaredType`, but `self.x` instance attributes do not. Field access binding only works for class-level annotated fields.
**Note on `this`/`self`/`$this` receivers:** Field access and method-call-result binding with `this`/`self`/`$this` as the receiver do not resolve in the fixpoint loop because these keywords are not stored in `scopeEnv`. They are resolved on-demand at call sites via `findEnclosingClassName()` AST walk. This is consistent across all languages and not a regression.
§ PHP write access covers instance property writes (`$obj->field = value`) and static property writes (`ClassName::$field = value`). Nullsafe writes (`$obj?->field = value`) are not tracked because this is invalid PHP syntax — null-safe member access on the left-hand side of assignment is a parse error.
---
@ -414,11 +427,12 @@ This is enough to materially improve call-edge precision even without implementi
Important gaps still remain:
- no general cross-file propagation of inferred bindings
- no fixpoint inference
- `this`/`self`/`$this` receivers are not resolved in the fixpoint loop (resolved on-demand at call sites via AST walk instead)
- limited branch-sensitive narrowing outside selected pattern constructs
- limited Swift support compared with other languages
- no complete destructuring-based field typing
- no broad expression-level return-type propagation inside `TypeEnv` (for-loop call-expression iterables are resolved in 7 languages via `ReturnTypeLookup`, but general `var x = f()` assignment propagation is pending)
- no MRO/inheritance walking for field lookups (`lookupFieldByOwner` is direct-only)
- for-loop variables bound at walk time cannot see fixpoint-resolved types (Phase 9B gap)
---