Merge pull request #387 from abhigyanpatwari/feat/type-resolution-milestone-d

feat(type-resolution): Milestone D — Phases A, B, C + Kotlin fix + 11-language integration tests
This commit is contained in:
Gergő Magyar 2026-03-19 21:26:19 +00:00 • committed by GitHub
commit f05e84a4aa
88 changed files with 2098 additions and 245 deletions

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2169 symbols, 5213 relationships, 165 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -183,7 +183,27 @@ export const processCalls = async (
}
const lang = getLanguageFromFilename(file.path);
const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null;
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
// Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs.
const fileParentMap = new Map<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
match.captures.forEach(c => captureMap[c.name] = c.node);
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
const className: string = captureMap['heritage.class'].text;
const parentName: string = captureMap['heritage.extends'].text;
const extendsNode = captureMap['heritage.extends'];
const fieldDecl = extendsNode.parent;
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) continue;
let parents = fileParentMap.get(className);
if (!parents) { parents = []; fileParentMap.set(className, parents); }
if (!parents.includes(parentName)) parents.push(parentName);
}
}
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null;
const callRouter = callRouters[language];
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0

View file

@ -350,6 +350,16 @@ export const GO_QUERIES = `
operand: (_) @assignment.receiver
field: (field_identifier) @assignment.property))
right: (_)) @assignment
; Write access: obj.field++ / obj.field--
(inc_statement
(selector_expression
operand: (_) @assignment.receiver
field: (field_identifier) @assignment.property)) @assignment
(dec_statement
(selector_expression
operand: (_) @assignment.receiver
field: (field_identifier) @assignment.property)) @assignment
`;
// C++ queries - works with tree-sitter-cpp

View file

@ -61,17 +61,22 @@ interface PatternOverride {
/** scope → varName → overrides (checked in order, first range match wins) */
type PatternOverrides = Map<string, Map<string, PatternOverride[]>>;
/** AST node types that represent mutually exclusive branch containers for pattern bindings. */
const PATTERN_BRANCH_TYPES = new Set([
/** AST node types that represent mutually exclusive branch containers for pattern bindings.
* Includes both multi-arm pattern-match branches AND if-statement bodies for null-check narrowing. */
const NARROWING_BRANCH_TYPES = new Set([
'when_entry', // Kotlin when
'switch_block_label', // Java switch (enhanced)
'if_statement', // TS/JS, Java, C/C++
'if_expression', // Kotlin (if is an expression)
'statement_block', // TS/JS: { ... } body of if
'control_structure_body', // Kotlin: body of if
]);
/** Walk up the AST from a pattern node to find the enclosing branch container. */
const findPatternBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
const findNarrowingBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
let current = node.parent;
while (current) {
if (PATTERN_BRANCH_TYPES.has(current.type)) return current;
if (NARROWING_BRANCH_TYPES.has(current.type)) return current;
if (FUNCTION_NODE_TYPES.has(current.type)) return undefined;
current = current.parent;
}
@ -161,6 +166,23 @@ const findEnclosingClassName = (node: SyntaxNode): string | undefined => {
return undefined;
};
/** Keywords that refer to the current instance across languages. */
const THIS_RECEIVERS = new Set(['this', 'self', '$this', 'Me']);
/**
* If a pending assignment's receiver is this/self/$this/Me, substitute the
* enclosing class name. Returns the item unchanged for non-receiver kinds
* or when the receiver is not a this-keyword. Properties are readonly in the
* discriminated union, so a new object is returned when substitution occurs.
*/
const substituteThisReceiver = (item: PendingAssignment, node: SyntaxNode): PendingAssignment => {
if (item.kind !== 'fieldAccess' && item.kind !== 'methodCallResult') return item;
if (!THIS_RECEIVERS.has(item.receiver)) return item;
const className = findEnclosingClassName(node);
if (!className) return item;
return { ...item, receiver: className };
};
/**
* Walk up the AST to find the enclosing class, then extract its parent class name
* from the heritage/superclass AST node. Used to resolve `super`/`base`/`parent`.
@ -366,50 +388,208 @@ const SKIP_SUBTREE_TYPES = new Set([
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
/** Memoize class definition lookups during fixpoint iteration.
* SymbolTable is immutable during type resolution, so results never change.
* Eliminates redundant array allocations + filter scans across iterations. */
const createClassDefCache = (symbolTable?: SymbolTable) => {
const cache = new Map<string, Array<{ nodeId: string; type: string }>>();
return (typeName: string) => {
let result = cache.get(typeName);
if (result === undefined) {
result = symbolTable
? symbolTable.lookupFuzzy(typeName).filter(d => CLASS_LIKE_TYPES.has(d.type))
: [];
cache.set(typeName, result);
}
return result;
};
};
/** Max depth for MRO parent chain walking. Real-world inheritance rarely exceeds 3-4 levels. */
const MAX_MRO_DEPTH = 5;
/** Walk up the parent class chain to find a field or method on an ancestor.
* BFS-like traversal with depth limit and cycle detection. First match wins.
* Used by resolveFieldType and resolveMethodReturnType when direct lookup fails. */
const walkParentChain = <T>(
typeName: string,
parentMap: ReadonlyMap<string, readonly string[]> | undefined,
getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>,
lookupOnClass: (nodeId: string) => T | undefined,
): T | undefined => {
if (!parentMap) return undefined;
const visited = new Set<string>([typeName]);
let current = [typeName];
for (let depth = 0; depth < MAX_MRO_DEPTH && current.length > 0; depth++) {
const next: string[] = [];
for (const cls of current) {
const parents = parentMap.get(cls);
if (!parents) continue;
for (const parent of parents) {
if (visited.has(parent)) continue;
visited.add(parent);
const parentDefs = getClassDefs(parent);
if (parentDefs.length === 1) {
const result = lookupOnClass(parentDefs[0].nodeId);
if (result !== undefined) return result;
}
next.push(parent);
}
}
current = next;
}
return undefined;
};
/** 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. */
* looks up the field via the eagerly-populated fieldByOwner index.
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
const resolveFieldType = (
receiver: string, field: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
parentMap?: ReadonlyMap<string, readonly string[]>,
): 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));
const lookup = getClassDefs
?? ((name: string) => symbolTable.lookupFuzzy(name).filter(d => CLASS_LIKE_TYPES.has(d.type)));
const classDefs = lookup(receiverType);
if (classDefs.length !== 1) return undefined;
// Direct lookup first
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
if (!fieldDef?.declaredType) return undefined;
return extractReturnTypeName(fieldDef.declaredType);
if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType);
// MRO parent chain walking on miss
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const f = symbolTable.lookupFieldByOwner(nodeId, field);
return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined;
});
return inherited;
};
/** 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. */
* looks up the method via lookupFuzzyCallable filtered by ownerId.
* Falls back to MRO parent chain walking if direct lookup fails (Phase 11A). */
const resolveMethodReturnType = (
receiver: string, method: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>,
parentMap?: ReadonlyMap<string, readonly string[]>,
): 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));
const lookup = getClassDefs
?? ((name: string) => symbolTable.lookupFuzzy(name).filter(d => CLASS_LIKE_TYPES.has(d.type)));
const classDefs = lookup(receiverType);
if (classDefs.length === 0) return undefined;
// Direct lookup first
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);
if (methods.length === 1 && methods[0].returnType) {
return extractReturnTypeName(methods[0].returnType);
}
// MRO parent chain walking on miss
if (methods.length === 0) {
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const parentMethods = symbolTable.lookupFuzzyCallable(method)
.filter(d => d.ownerId === nodeId);
if (parentMethods.length !== 1 || !parentMethods[0].returnType) return undefined;
return extractReturnTypeName(parentMethods[0].returnType);
});
return inherited;
}
return undefined;
};
/**
* 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 resolveFixpointBindings = (
pendingItems: Array<{ scope: string } & PendingAssignment>,
env: TypeEnv,
returnTypeLookup: ReturnTypeLookup,
symbolTable?: SymbolTable,
parentMap?: ReadonlyMap<string, readonly string[]>,
): void => {
if (pendingItems.length === 0) return;
const getClassDefs = createClassDefCache(symbolTable);
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; }
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, getClassDefs, parentMap);
break;
case 'methodCallResult':
typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable, getClassDefs, parentMap);
break;
default: {
// Exhaustive check: TypeScript will error here if a new PendingAssignment
// kind is added without handling it in the switch.
const _exhaustive: never = item;
break;
}
}
if (typeName) {
scopeEnv.set(item.lhs, typeName);
resolved.add(i);
changed = true;
}
}
if (!changed) break;
if (iter === MAX_FIXPOINT_ITERATIONS - 1 && process.env.GITNEXUS_DEBUG) {
const unresolved = pendingItems.length - resolved.size;
if (unresolved > 0) {
console.warn(`[type-env] fixpoint hit iteration cap (${MAX_FIXPOINT_ITERATIONS}), ${unresolved} items unresolved`);
}
}
}
};
/**
* Options for buildTypeEnv.
* Uses an options object to allow future extensions without positional parameter sprawl.
*/
export interface BuildTypeEnvOptions {
symbolTable?: SymbolTable;
parentMap?: ReadonlyMap<string, readonly string[]>;
}
export const buildTypeEnv = (
tree: { rootNode: SyntaxNode },
language: SupportedLanguages,
symbolTable?: SymbolTable,
options?: BuildTypeEnvOptions,
): TypeEnvironment => {
const symbolTable = options?.symbolTable;
const parentMap = options?.parentMap;
const env: TypeEnv = new Map();
const patternOverrides: PatternOverrides = new Map();
const localClassNames = new Set<string>();
@ -448,6 +628,9 @@ export const buildTypeEnv = (
// 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> = [];
// For-loop nodes whose iterable was unresolved at walk-time. Replayed after the fixpoint
// resolves the iterable's type, bridging the walk-time/fixpoint gap (Phase 10 / ex-9B).
const pendingForLoops: Array<{ node: SyntaxNode; scope: string }> = [];
// 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`).
@ -498,7 +681,8 @@ export const buildTypeEnv = (
fallbackName = child;
}
if (!fallbackType && (child.type === 'user_type' || child.type === 'type_identifier'
|| child.type === 'generic_type' || child.type === 'parameterized_type')) {
|| child.type === 'generic_type' || child.type === 'parameterized_type'
|| child.type === 'nullable_type')) {
fallbackType = child;
}
}
@ -516,8 +700,14 @@ export const buildTypeEnv = (
// Checked before declarationNodeTypes — loop variables are not declarations.
if (config.forLoopNodeTypes?.has(node.type)) {
if (config.extractForLoopBinding) {
const sizeBefore = scopeEnv.size;
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
config.extractForLoopBinding(node, forLoopCtx);
// If no new binding was produced, the iterable's type may not yet be resolved.
// Store for post-fixpoint replay (Phase 10 / ex-9B loop-fixpoint bridge).
if (scopeEnv.size === sizeBefore) {
pendingForLoops.push({ node, scope });
}
}
return;
}
@ -605,7 +795,8 @@ export const buildTypeEnv = (
}
// Pattern binding extraction: handles constructs that introduce NEW typed variables
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`)
// or narrow existing variables within a branch (null-check narrowing).
// Runs after Tier 0/1 so scopeEnv already contains the source variable's type.
// Conservative: extractor returns undefined when source type is unknown.
if (config.extractPatternBinding && (!config.patternBindingNodeTypes || config.patternBindingNodeTypes.has(node.type))) {
@ -614,11 +805,22 @@ export const buildTypeEnv = (
const scopeEnv = env.get(scope)!;
const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope);
if (patternBinding) {
if (config.allowPatternBindingOverwrite) {
if (patternBinding.narrowingRange) {
// Explicit narrowing range (null-check narrowing): always store in patternOverrides
// using the extractor-provided range (typically the if-body block).
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
const varMap = patternOverrides.get(scope)!;
if (!varMap.has(patternBinding.varName)) varMap.set(patternBinding.varName, []);
varMap.get(patternBinding.varName)!.push({
rangeStart: patternBinding.narrowingRange.startIndex,
rangeEnd: patternBinding.narrowingRange.endIndex,
typeName: patternBinding.typeName,
});
} else if (config.allowPatternBindingOverwrite) {
// Position-indexed: store per-branch binding for smart-cast narrowing.
// Each when arm / switch case gets its own type for the variable,
// preventing cross-arm contamination (e.g., Kotlin when/is).
const branchNode = findPatternBranchScope(node);
const branchNode = findNarrowingBranchScope(node);
if (branchNode) {
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
const varMap = patternOverrides.get(scope)!;
@ -643,6 +845,7 @@ export const buildTypeEnv = (
// Delegates to per-language extractPendingAssignment — AST shapes differ widely
// (JS uses variable_declarator/name/value, Rust uses let_declaration/pattern/value,
// Python uses assignment/left/right, Go uses short_var_declaration/expression_list).
// May return a single item or an array (for destructuring: N fieldAccess items).
if (config.extractPendingAssignment && config.declarationNodeTypes.has(node.type)) {
// scopeEnv is guaranteed to exist here because declarationNodeTypes is a subset
// of interestingNodeTypes, so extractTypeBinding already created the scope map above.
@ -650,7 +853,12 @@ export const buildTypeEnv = (
if (scopeEnv) {
const pending = config.extractPendingAssignment(node, scopeEnv);
if (pending) {
pendingItems.push({ scope, ...pending });
const items = Array.isArray(pending) ? pending : [pending];
for (const item of items) {
// Substitute this/self/$this/Me receivers with enclosing class name
const resolved = substituteThisReceiver(item, node);
pendingItems.push({ scope, ...resolved });
}
}
}
}
@ -676,47 +884,30 @@ export const buildTypeEnv = (
walk(tree.rootNode, FILE_SCOPE);
// 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; }
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
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;
}
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
// For-loop nodes whose iterables were unresolved at walk-time may now be
// resolvable because the fixpoint bound the iterable's type.
// Example: `const users = getUsers(); for (const u of users) { u.save(); }`
// - walk-time: users untyped → u unresolved
// - fixpoint: users → User[]
// - replay: users now typed → u → User
if (pendingForLoops.length > 0 && config.extractForLoopBinding) {
for (const { node, scope } of pendingForLoops) {
if (!env.has(scope)) env.set(scope, new Map());
const scopeEnv = env.get(scope)!;
config.extractForLoopBinding(node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup });
}
// Re-run the main fixpoint to resolve items that depended on loop variables.
// Only needed if replay actually produced new bindings.
const unresolvedBefore = pendingItems.filter((item) => {
const scopeEnv = env.get(item.scope);
return scopeEnv && !scopeEnv.has(item.lhs);
});
if (unresolvedBefore.length > 0) {
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable);
}
if (!changed) break;
}
return {

View file

@ -278,18 +278,82 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
* declaration_pattern, or when the type/name cannot be extracted.
* No scopeEnv lookup is needed — the pattern explicitly declares the new variable's type.
*/
const extractPatternBinding: PatternBindingExtractor = (node) => {
/**
* Find the if-body (consequence) block for a C# null-check.
* Walks up from the expression to find the enclosing if_statement,
* then returns its first block child (the truthy branch body).
*/
const findCSharpIfConsequenceBlock = (expr: SyntaxNode): SyntaxNode | undefined => {
let current = expr.parent;
while (current) {
if (current.type === 'if_statement') {
// C# if_statement consequence is the 'consequence' field or first block child
const consequence = current.childForFieldName('consequence');
if (consequence) return consequence;
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'block') return child;
}
return undefined;
}
if (current.type === 'block' || current.type === 'method_declaration'
|| current.type === 'constructor_declaration' || current.type === 'local_function_statement'
|| current.type === 'lambda_expression') return undefined;
current = current.parent;
}
return undefined;
};
/** Check if a C# declaration type node represents a nullable type.
* Checks for nullable_type AST node or '?' in the type text (e.g., User?). */
const isCSharpNullableDecl = (declTypeNode: SyntaxNode): boolean => {
if (declTypeNode.type === 'nullable_type') return true;
return declTypeNode.text.includes('?');
};
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
// is_pattern_expression: `obj is User user` — has a declaration_pattern child
// Also handles `x is not null` for null-check narrowing
if (node.type === 'is_pattern_expression') {
const pattern = node.childForFieldName('pattern');
if (pattern?.type !== 'declaration_pattern' && pattern?.type !== 'recursive_pattern') return undefined;
const typeNode = pattern.childForFieldName('type');
const nameNode = pattern.childForFieldName('name');
if (!typeNode || !nameNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
if (!pattern) return undefined;
// Standard type pattern: `obj is User user`
if (pattern.type === 'declaration_pattern' || pattern.type === 'recursive_pattern') {
const typeNode = pattern.childForFieldName('type');
const nameNode = pattern.childForFieldName('name');
if (!typeNode || !nameNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
}
// Null-check: `x is not null` — negated_pattern > constant_pattern > null_literal
if (pattern.type === 'negated_pattern') {
const inner = pattern.firstNamedChild;
if (inner?.type === 'constant_pattern') {
const literal = inner.firstNamedChild ?? inner.firstChild;
if (literal?.type === 'null_literal' || literal?.text === 'null') {
const expr = node.childForFieldName('expression');
if (!expr || expr.type !== 'identifier') return undefined;
const varName = expr.text;
const resolvedType = scopeEnv.get(varName);
if (!resolvedType) return undefined;
// Verify the original declaration was nullable
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
if (!declTypeNode || !isCSharpNullableDecl(declTypeNode)) return undefined;
const ifBody = findCSharpIfConsequenceBlock(node);
if (!ifBody) return undefined;
return {
varName,
typeName: resolvedType,
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
};
}
}
}
return undefined;
}
// declaration_pattern / recursive_pattern: standalone in switch statements and switch expressions
// `case User u:` or `User u =>` or `User { Name: "Alice" } u =>`
@ -303,6 +367,34 @@ const extractPatternBinding: PatternBindingExtractor = (node) => {
if (!typeName || !varName) return undefined;
return { varName, typeName };
}
// Null-check: `x != null` — binary_expression with != operator
if (node.type === 'binary_expression') {
const op = node.children.find(c => !c.isNamed && c.text === '!=');
if (!op) return undefined;
const left = node.namedChild(0);
const right = node.namedChild(1);
if (!left || !right) return undefined;
let varNode: SyntaxNode | undefined;
if (left.type === 'identifier' && (right.type === 'null_literal' || right.text === 'null')) {
varNode = left;
} else if (right.type === 'identifier' && (left.type === 'null_literal' || left.text === 'null')) {
varNode = right;
}
if (!varNode) return undefined;
const varName = varNode.text;
const resolvedType = scopeEnv.get(varName);
if (!resolvedType) return undefined;
// Verify the original declaration was nullable
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
if (!declTypeNode || !isCSharpNullableDecl(declTypeNode)) return undefined;
const ifBody = findCSharpIfConsequenceBlock(node);
if (!ifBody) return undefined;
return {
varName,
typeName: resolvedType,
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
};
}
return undefined;
};
@ -374,7 +466,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern']),
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern', 'binary_expression']),
extractDeclaration,
extractParameter,
scanConstructorBinding,

View file

@ -295,7 +295,8 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
const varDecl = findChildByType(node, 'variable_declaration');
if (varDecl) {
const nameNode = findChildByType(varDecl, 'simple_identifier');
const typeNode = findChildByType(varDecl, 'user_type');
const typeNode = findChildByType(varDecl, 'user_type')
?? findChildByType(varDecl, 'nullable_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
@ -341,7 +342,8 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
if (!typeNode) typeNode = findChildByType(node, 'user_type');
if (!typeNode) typeNode = findChildByType(node, 'user_type')
?? findChildByType(node, 'nullable_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
@ -660,27 +662,76 @@ const findAncestorByType = (node: SyntaxNode, type: string): SyntaxNode | undefi
return undefined;
};
const extractKotlinPatternBinding: PatternBindingExtractor = (node) => {
if (node.type !== 'type_test') return undefined;
const typeNode = node.lastNamedChild;
if (!typeNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName) return undefined;
const whenExpr = findAncestorByType(node, 'when_expression');
if (!whenExpr) return undefined;
const whenSubject = whenExpr.namedChild(0);
const subject = whenSubject?.firstNamedChild ?? whenSubject;
if (!subject) return undefined;
const varName = extractVarName(subject);
if (!varName) return undefined;
return { varName, typeName };
const extractKotlinPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
// Kotlin when/is smart casts (existing behavior)
if (node.type === 'type_test') {
const typeNode = node.lastNamedChild;
if (!typeNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName) return undefined;
const whenExpr = findAncestorByType(node, 'when_expression');
if (!whenExpr) return undefined;
const whenSubject = whenExpr.namedChild(0);
const subject = whenSubject?.firstNamedChild ?? whenSubject;
if (!subject) return undefined;
const varName = extractVarName(subject);
if (!varName) return undefined;
return { varName, typeName };
}
// Null-check narrowing: if (x != null) { ... }
// Kotlin AST: equality_expression > simple_identifier, "!=" [anon], "null" [anon]
// Note: `null` is an anonymous node in tree-sitter-kotlin, not `null_literal`.
if (node.type === 'equality_expression') {
const op = node.children.find(c => !c.isNamed && c.text === '!=');
if (!op) return undefined;
// `null` is anonymous in Kotlin grammar — use positional child scan
let varNode: SyntaxNode | undefined;
let hasNull = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (c.type === 'simple_identifier') varNode = c;
if (!c.isNamed && c.text === 'null') hasNull = true;
}
if (!varNode || !hasNull) return undefined;
const varName = varNode.text;
const resolvedType = scopeEnv.get(varName);
if (!resolvedType) return undefined;
// Check if the original declaration type was nullable (ends with ?)
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
if (!declTypeNode) return undefined;
const declText = declTypeNode.text;
if (!declText.includes('?') && !declText.includes('null')) return undefined;
// Find the if-body: walk up to if_expression, then find control_structure_body
const ifExpr = findAncestorByType(node, 'if_expression');
if (!ifExpr) return undefined;
// The consequence is the first control_structure_body child
for (let i = 0; i < ifExpr.childCount; i++) {
const child = ifExpr.child(i);
if (child?.type === 'control_structure_body') {
return {
varName,
typeName: resolvedType,
narrowingRange: { startIndex: child.startIndex, endIndex: child.endIndex },
};
}
}
return undefined;
}
return undefined;
};
export const kotlinTypeConfig: LanguageTypeConfig = {
allowPatternBindingOverwrite: true,
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['type_test']),
patternBindingNodeTypes: new Set(['type_test', 'equality_expression']),
extractDeclaration: extractKotlinDeclaration,
extractParameter: extractKotlinParameter,
extractInitializer: extractKotlinInitializer,

View file

@ -1,5 +1,5 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PendingAssignment, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
@ -189,12 +189,45 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: patternNode.text, calleeName };
};
/** Rust: let alias = u; → let_declaration with pattern + value fields */
/** Rust: let alias = u; → let_declaration with pattern + value fields.
* Also handles struct destructuring: `let Point { x, y } = p` → N fieldAccess items. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type !== 'let_declaration') return undefined;
const pattern = node.childForFieldName('pattern');
const value = node.childForFieldName('value');
if (!pattern || !value) return undefined;
// Struct pattern destructuring: `let Point { x, y } = receiver`
// struct_pattern has a type child (struct name) and field_pattern children
if (pattern.type === 'struct_pattern' && value.type === 'identifier') {
const receiver = value.text;
const items: PendingAssignment[] = [];
for (let j = 0; j < pattern.namedChildCount; j++) {
const field = pattern.namedChild(j);
if (!field) continue;
if (field.type === 'field_pattern') {
// `Point { x: local_x }` → field_pattern with name + pattern children
const nameNode = field.childForFieldName('name');
const patNode = field.childForFieldName('pattern');
if (nameNode && patNode) {
const fieldName = nameNode.text;
const varName = extractVarName(patNode);
if (varName && !scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
}
} else if (nameNode) {
// Shorthand: `Point { x }` → field_pattern with only name (varName = fieldName)
const varName = nameNode.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
}
}
}
}
if (items.length > 0) return items;
return undefined;
}
const lhs = extractVarName(pattern);
if (!lhs || scopeEnv.has(lhs)) return undefined;
// Unwrap Rust .await: `let user = get_user().await` → call_expression

View file

@ -67,15 +67,29 @@ export type PendingAssignment =
* 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.
* May return an array of PendingAssignment items for destructuring patterns
* (e.g., `const { a, b } = obj` emits N fieldAccess items).
* Returns undefined if the node is not a matching assignment. */
export type PendingAssignmentExtractor = (
node: SyntaxNode,
scopeEnv: ReadonlyMap<string, string>,
) => PendingAssignment | undefined;
) => PendingAssignment | PendingAssignment[] | undefined;
/** Result of a pattern binding extraction. */
export interface PatternBindingResult {
varName: string;
typeName: string;
/** Optional: AST node whose position range should be used for the patternOverride.
* When present, the override uses this node's range instead of the auto-detected
* branch scope. Used by null-check narrowing to target the if-body specifically. */
narrowingRange?: { startIndex: number; endIndex: number };
}
/** Extracts a typed variable binding from a pattern-matching construct.
* Returns { varName, typeName } for patterns that introduce NEW variables.
* Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java).
* Returns { varName, typeName } for patterns that introduce NEW variables
* or narrow existing variables (null-check narrowing).
* Examples: `if let Some(user) = opt` (Rust), `x instanceof User user` (Java),
* `if (x != null)` (null-check narrowing in TS/Kotlin/C#).
* Conservative: returns undefined when the source variable's type is unknown.
*
* @param scopeEnv Read-only view of already-resolved type bindings in the current scope.
@ -88,7 +102,7 @@ export type PatternBindingExtractor = (
scopeEnv: ReadonlyMap<string, string>,
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
scope: string,
) => { varName: string; typeName: string } | undefined;
) => PatternBindingResult | undefined;
/** Per-language type extraction configuration */
export interface LanguageTypeConfig {

View file

@ -1,5 +1,5 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, PatternBindingExtractor } from './types.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, PendingAssignment, ForLoopExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
@ -429,7 +429,8 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
/** TS/JS: const alias = u → variable_declarator with name/value fields */
/** TS/JS: const alias = u → variable_declarator with name/value fields.
* Also handles destructuring: `const { a, b } = obj` → N fieldAccess items. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
@ -437,6 +438,38 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const nameNode = child.childForFieldName('name');
const valueNode = child.childForFieldName('value');
if (!nameNode || !valueNode) continue;
// Object destructuring: `const { address, name } = user`
// Emits N fieldAccess items — one per destructured binding.
if (nameNode.type === 'object_pattern' && valueNode.type === 'identifier') {
const receiver = valueNode.text;
const items: PendingAssignment[] = [];
for (let j = 0; j < nameNode.namedChildCount; j++) {
const prop = nameNode.namedChild(j);
if (!prop) continue;
if (prop.type === 'shorthand_property_identifier_pattern') {
// `const { name } = user` → shorthand: varName = fieldName
const varName = prop.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
}
} else if (prop.type === 'pair_pattern') {
// `const { address: addr } = user` → pair_pattern: key=field, value=varName
const keyNode = prop.childForFieldName('key');
const valNode = prop.childForFieldName('value');
if (keyNode && valNode) {
const fieldName = keyNode.text;
const varName = valNode.text;
if (!scopeEnv.has(varName)) {
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
}
}
}
}
if (items.length > 0) return items;
continue;
}
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
@ -472,19 +505,96 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
return undefined;
};
/** Null-check keywords that indicate a null-comparison in binary expressions. */
const NULL_CHECK_KEYWORDS = new Set(['null', 'undefined']);
/**
* Find the if-body (consequence) block for a null-check binary_expression.
* Walks up from the binary_expression through parenthesized_expression to if_statement,
* then returns the consequence block (statement_block).
*
* AST structure: if_statement > parenthesized_expression > binary_expression
* if_statement > statement_block (consequence)
*/
const findIfConsequenceBlock = (binaryExpr: SyntaxNode): SyntaxNode | undefined => {
// Walk up to find the if_statement (typically: binary_expression > parenthesized_expression > if_statement)
let current = binaryExpr.parent;
while (current) {
if (current.type === 'if_statement') {
// The consequence is the first statement_block child of if_statement
for (let i = 0; i < current.childCount; i++) {
const child = current.child(i);
if (child?.type === 'statement_block') return child;
}
return undefined;
}
// Stop climbing at function/block boundaries — don't cross scope
if (current.type === 'function_declaration' || current.type === 'function_expression'
|| current.type === 'arrow_function' || current.type === 'method_definition') return undefined;
current = current.parent;
}
return undefined;
};
/** TS instanceof narrowing: `x instanceof User` → bind x to User.
* Only works when x has no prior type binding (e.g. x: unknown, untyped params).
* Typed params (x: Animal) are blocked by the !scopeEnv.has() guard in buildTypeEnv.
* Uses first-writer-wins, same as Rust match arm bindings. */
const extractPatternBinding: PatternBindingExtractor = (node) => {
* Also handles null-check narrowing: `x !== null`, `x != undefined` etc.
* instanceof: first-writer-wins (no prior type binding).
* null-check: position-indexed narrowing via narrowingRange. */
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv, declarationTypeNodes, scope) => {
if (node.type !== 'binary_expression') return undefined;
const op = node.children.find(c => !c.isNamed && c.text === 'instanceof');
// Check for instanceof first (existing behavior)
const instanceofOp = node.children.find(c => !c.isNamed && c.text === 'instanceof');
if (instanceofOp) {
const left = node.namedChild(0);
const right = node.namedChild(1);
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
return { varName: left.text, typeName: right.text };
}
// Null-check narrowing: x !== null, x != null, x !== undefined, x != undefined
const op = node.children.find(c => !c.isNamed && (c.text === '!==' || c.text === '!='));
if (!op) return undefined;
// binary_expression children are positional — no left/right fields
const left = node.namedChild(0);
const right = node.namedChild(1);
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
return { varName: left.text, typeName: right.text };
if (!left || !right) return undefined;
// Determine which side is the variable and which is null/undefined
let varNode: SyntaxNode | undefined;
let isNullCheck = false;
if (left.type === 'identifier' && NULL_CHECK_KEYWORDS.has(right.text)) {
varNode = left;
isNullCheck = true;
} else if (right.type === 'identifier' && NULL_CHECK_KEYWORDS.has(left.text)) {
varNode = right;
isNullCheck = true;
}
if (!isNullCheck || !varNode) return undefined;
const varName = varNode.text;
// Look up the variable's resolved type (already stripped of nullable by extractSimpleTypeName)
const resolvedType = scopeEnv.get(varName);
if (!resolvedType) return undefined;
// Check if the original declaration type was nullable by looking at the raw AST type node.
// extractSimpleTypeName already strips nullable markers, so we need the original to know
// if narrowing is meaningful (i.e., the variable was declared as nullable).
const declTypeNode = declarationTypeNodes.get(`${scope}\0${varName}`);
if (!declTypeNode) return undefined;
const declText = declTypeNode.text;
// Only narrow if the original declaration was nullable
if (!declText.includes('null') && !declText.includes('undefined')) return undefined;
// Find the if-body block to scope the narrowing
const ifBody = findIfConsequenceBlock(node);
if (!ifBody) return undefined;
return {
varName,
typeName: resolvedType,
narrowingRange: { startIndex: ifBody.startIndex, endIndex: ifBody.endIndex },
};
};
export const typeConfig: LanguageTypeConfig = {

View file

@ -890,15 +890,6 @@ const processFileGroup = (
result.fileCount++;
onFileProcessed?.();
// Build per-file type environment + constructor bindings in a single AST walk.
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
const typeEnv = buildTypeEnv(tree, language);
const callRouter = callRouters[language];
if (typeEnv.constructorBindings.length > 0) {
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
}
let matches;
try {
matches = query.matches(tree.rootNode);
@ -907,6 +898,39 @@ const processFileGroup = (
continue;
}
// Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv.
// Heritage edges (EXTENDS/IMPLEMENTS) are created by heritage-processor which runs
// in PARALLEL with call-processor, so the graph edges don't exist when buildTypeEnv
// runs. This pre-pass makes parent class information available for type resolution.
const fileParentMap = new Map<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
for (const c of match.captures) {
captureMap[c.name] = c.node;
}
if (captureMap['heritage.class'] && captureMap['heritage.extends']) {
const className: string = captureMap['heritage.class'].text;
const parentName: string = captureMap['heritage.extends'].text;
// Skip Go named fields (only anonymous fields are struct embedding)
const extendsNode = captureMap['heritage.extends'];
const fieldDecl = extendsNode.parent;
if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) continue;
let parents = fileParentMap.get(className);
if (!parents) { parents = []; fileParentMap.set(className, parents); }
if (!parents.includes(parentName)) parents.push(parentName);
}
}
// Build per-file type environment + constructor bindings in a single AST walk.
// Constructor bindings are verified against the SymbolTable in processCallsFromExtracted.
const parentMap: ReadonlyMap<string, readonly string[]> = fileParentMap;
const typeEnv = buildTypeEnv(tree, language, { parentMap });
const callRouter = callRouters[language];
if (typeEnv.constructorBindings.length > 0) {
result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] });
}
for (const match of matches) {
const captureMap: Record<string, any> = {};
for (const c of match.captures) {

View file

@ -0,0 +1,7 @@
#pragma once
#include "Greeting.h"
class A {
public:
Greeting greet() { return Greeting(); }
};

View file

@ -0,0 +1,4 @@
#pragma once
#include "A.h"
class B : public A {};

View file

@ -0,0 +1,4 @@
#pragma once
#include "B.h"
class C : public B {};

View file

@ -0,0 +1,6 @@
#pragma once
class Greeting {
public:
void save() {}
};

View file

@ -0,0 +1,6 @@
#include "C.h"
void process() {
C c;
c.greet().save();
}

View file

@ -0,0 +1,7 @@
namespace Grandparent.Models
{
public class A
{
public Greeting Greet() => new Greeting();
}
}

View file

@ -0,0 +1,4 @@
namespace Grandparent.Models
{
public class B : A {}
}

View file

@ -0,0 +1,4 @@
namespace Grandparent.Models
{
public class C : B {}
}

View file

@ -0,0 +1,7 @@
namespace Grandparent.Models
{
public class Greeting
{
public void Save() {}
}
}

View file

@ -0,0 +1,13 @@
using Grandparent.Models;
namespace Grandparent.Services
{
public class App
{
public void Process()
{
var c = new C();
c.Greet().Save();
}
}
}

View file

@ -0,0 +1,7 @@
namespace NullCheck.Models
{
public class Repo
{
public void Save() {}
}
}

View file

@ -0,0 +1,7 @@
namespace NullCheck.Models
{
public class User
{
public void Save() {}
}
}

View file

@ -0,0 +1,44 @@
using NullCheck.Models;
using System;
namespace NullCheck.Services
{
public class App
{
public App(User? x)
{
if (x != null)
{
x.Save();
}
}
public void ProcessInequality(User x)
{
if (x != null)
{
x.Save();
}
}
public void ProcessIsNotNull(User x)
{
if (x is not null)
{
x.Save();
}
}
public void ProcessInLambda(User? x)
{
Action act = () =>
{
if (x != null)
{
x.Save();
}
};
act();
}
}
}

View file

@ -0,0 +1,15 @@
package main
type Counter struct {
Count int
Total int
}
func increment(c *Counter) {
c.Count++
c.Total++
}
func decrement(c *Counter) {
c.Count--
}

View file

@ -0,0 +1,5 @@
package models;
public class A {
public Greeting greet() { return new Greeting(); }
}

View file

@ -0,0 +1,3 @@
package models;
public class B extends A {}

View file

@ -0,0 +1,3 @@
package models;
public class C extends B {}

View file

@ -0,0 +1,5 @@
package models;
public class Greeting {
public void save() {}
}

View file

@ -0,0 +1,10 @@
package services;
import models.C;
public class App {
public void process() {
C c = new C();
c.greet().save();
}
}

View file

@ -0,0 +1,8 @@
const { getUsers } = require('./models');
function process() {
const users = getUsers();
for (const u of users) {
u.save();
}
}

View file

@ -0,0 +1,13 @@
/**
* @class
*/
class User {
save() {}
}
/**
* @returns {User[]}
*/
function getUsers() { return []; }
module.exports = { User, getUsers };

View file

@ -0,0 +1,7 @@
const { getUser } = require('./service');
function processDestructured() {
const user = getUser();
const { address } = user;
address.save();
}

View file

@ -0,0 +1,19 @@
/**
* @class
*/
class Address {
/** @returns {boolean} */
save() { return true; }
}
/**
* @class
*/
class User {
constructor() {
/** @type {Address} */
this.address = new Address();
}
}
module.exports = { Address, User };

View file

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

View file

@ -0,0 +1,5 @@
package models
open class A {
fun greet(): Greeting = Greeting()
}

View file

@ -0,0 +1,3 @@
package models
open class B : A()

View file

@ -0,0 +1,3 @@
package models
class C : B()

View file

@ -0,0 +1,5 @@
package models
class Greeting {
fun save() {}
}

View file

@ -0,0 +1,8 @@
package services
import models.C
fun process() {
val c = C()
c.greet().save()
}

View file

@ -0,0 +1,5 @@
package models
class Repo {
fun save() {}
}

View file

@ -0,0 +1,9 @@
package models
class User {
fun save() {}
}
fun findUser(): User? {
return null
}

View file

@ -0,0 +1,17 @@
package services
import models.User
import models.findUser
fun processNullable(x: User?) {
if (x != null) {
x.save()
}
}
fun processLocalNullable() {
val x: User? = findUser()
if (x != null) {
x.save()
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class A
{
public function greet(): Greeting { return new Greeting(); }
}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Models;
class B extends A {}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Models;
class C extends B {}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Models;
class Greeting
{
public function save(): void {}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Services;
use App\Models\C;
class App
{
public function process(): void
{
$c = new C();
$c->greet()->save();
}
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,5 @@
from models.c import C
def process():
c = C()
c.greet().save()

View file

@ -0,0 +1,5 @@
from .greeting import Greeting
class A:
def greet(self) -> Greeting:
return Greeting()

View file

@ -0,0 +1,4 @@
from .a import A
class B(A):
pass

View file

@ -0,0 +1,4 @@
from .b import B
class C(B):
pass

View file

@ -0,0 +1,3 @@
class Greeting:
def save(self) -> None:
pass

View file

@ -0,0 +1,6 @@
require_relative 'models/c'
def process
c = C.new
c.greet.save
end

View file

@ -0,0 +1,7 @@
require_relative 'greeting'
class A
def greet
Greeting.new
end
end

View file

@ -0,0 +1,4 @@
require_relative 'a'
class B < A
end

View file

@ -0,0 +1,4 @@
require_relative 'b'
class C < B
end

View file

@ -0,0 +1,5 @@
class Greeting
def save
true
end
end

View file

@ -0,0 +1,12 @@
mod point;
mod vec2;
use crate::point::Point;
fn process(p: Point) {
let Point { x, y } = p;
x.save();
y.save();
}
fn main() {}

View file

@ -0,0 +1,6 @@
use crate::vec2::Vec2;
pub struct Point {
pub x: Vec2,
pub y: Vec2,
}

View file

@ -0,0 +1,7 @@
pub struct Vec2 {
pub value: f32,
}
impl Vec2 {
pub fn save(&self) {}
}

View file

@ -0,0 +1,8 @@
import { getUsers } from './models';
function process() {
const users = getUsers();
for (const u of users) {
u.save();
}
}

View file

@ -0,0 +1,6 @@
export class User {
save(): void {}
}
export function getUsers(): User[] {
return [];
}

View file

@ -0,0 +1,6 @@
import { C } from './derived';
function process() {
const c = new C();
c.greet().save();
}

View file

@ -0,0 +1,5 @@
import { Greeting } from './greeting';
export class A {
greet(): Greeting { return new Greeting(); }
}

View file

@ -0,0 +1,2 @@
import { B } from './middle';
export class C extends B {}

View file

@ -0,0 +1,3 @@
export class Greeting {
save(): void {}
}

View file

@ -0,0 +1,2 @@
import { A } from './base';
export class B extends A {}

View file

@ -0,0 +1,25 @@
import { User } from './models';
function processStrict(x: User | null) {
if (x !== null) {
x.save();
}
}
function processLoose(x: User | null) {
if (x != null) {
x.save();
}
}
function processUndefined(x: User | undefined) {
if (x !== undefined) {
x.save();
}
}
const processFuncExpr = function(x: User | null) {
if (x !== null) {
x.save();
}
};

View file

@ -0,0 +1,7 @@
export class User {
save(): void {}
}
export class Repo {
save(): void {}
}

View file

@ -0,0 +1,13 @@
import { getUser } from './service';
function processDestructured() {
const user = getUser();
const { address } = user;
address.save();
}
function processMultiField() {
const user = getUser();
const { name, address } = user;
address.save();
}

View file

@ -0,0 +1,9 @@
export class Address {
city: string = '';
save(): boolean { return true; }
}
export class User {
name: string = '';
address: Address = new Address();
}

View file

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

View file

@ -1015,3 +1015,49 @@ describe('C++ method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('C++ grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('Greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('A.h'),
);
expect(greetCall).toBeDefined();
});
});

View file

@ -1362,3 +1362,110 @@ describe('C# method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// Greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('C# grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.Greet().Save() to Greeting#Save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.targetFilePath.includes('Greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.Greet() to A#Greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'Greet' && c.targetFilePath.includes('A.cs'),
);
expect(greetCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase C: C# null-check narrowing — if (x != null) and if (x is not null)
// Both patterns emit patternOverrides for the if-body position range
// ---------------------------------------------------------------------------
describe('C# null-check narrowing resolution (Phase C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-null-check-narrowing'),
() => {},
);
}, 60000);
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves x.Save() inside != null guard (ProcessInequality) to User#Save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessInequality' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('resolves x.Save() inside is not null guard (ProcessIsNotNull) to User#Save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessIsNotNull' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('does NOT cross-resolve to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(c =>
c.target === 'Save' && c.targetFilePath.includes('Repo'),
);
expect(wrongCall).toBeUndefined();
});
it('resolves x.Save() inside constructor via null-check narrowing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'App' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('resolves x.Save() inside lambda via null-check narrowing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessInLambda' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1145,3 +1145,40 @@ describe('Go method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Go inc_statement / dec_statement write access
// obj.Field++ and obj.Field-- emit ACCESSES write edges
// ---------------------------------------------------------------------------
describe('Go inc/dec write access tracking (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-inc-dec-write-access'),
() => {},
);
}, 60000);
it('emits ACCESSES write edge for Count++ in increment', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter(e => e.rel.reason === 'write');
const countInc = writes.find(e => e.target === 'Count' && e.source === 'increment');
expect(countInc).toBeDefined();
});
it('emits ACCESSES write edge for Total++ in increment', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter(e => e.rel.reason === 'write');
const totalInc = writes.find(e => e.target === 'Total' && e.source === 'increment');
expect(totalInc).toBeDefined();
});
it('emits ACCESSES write edge for Count-- in decrement', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter(e => e.rel.reason === 'write');
const countDec = writes.find(e => e.target === 'Count' && e.source === 'decrement');
expect(countDec).toBeDefined();
});
});

View file

@ -1278,3 +1278,49 @@ describe('Java method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('Java grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('Greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('A.java'),
);
expect(greetCall).toBeDefined();
});
});

View file

@ -302,3 +302,55 @@ describe('Write access tracking (JavaScript)', () => {
}
});
});
// ---------------------------------------------------------------------------
// Phase A: JS object destructuring — const { field } = receiver → fieldAccess PendingAssignment
// ---------------------------------------------------------------------------
describe('JavaScript object destructuring resolution (Phase A)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'js-object-destructuring'),
() => {},
);
}, 60000);
it('detects User, Address classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('Address');
});
it('resolves address.save() to Address#save via object destructuring', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase A: Post-fixpoint for-loop replay — iterable resolved via callResult fixpoint
// ---------------------------------------------------------------------------
describe('JavaScript post-fixpoint for-loop replay (Phase A ex-9B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'js-fixpoint-for-loop'),
() => {},
);
}, 60000);
it('resolves u.save() to User#save via post-fixpoint for-loop replay', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1441,3 +1441,94 @@ describe('Kotlin method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('Kotlin grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('Greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('A.kt'),
);
expect(greetCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase C: Kotlin null-check narrowing — if (x != null) { x.save() }
// NOTE: depends on nullable_type capture being fixed in jvm.ts
// ---------------------------------------------------------------------------
describe('Kotlin null-check narrowing resolution (Phase C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-null-check-narrowing'),
() => {},
);
}, 60000);
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves x.save() inside != null guard to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processNullable' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
it('does NOT resolve to Repo#save (no cross-contamination)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('Repo'),
);
expect(wrongCall).toBeUndefined();
});
it('resolves x.save() from local variable val x: User? via null-check narrowing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processLocalNullable' && c.targetFilePath.includes('User'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1382,3 +1382,49 @@ describe('PHP method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('PHP grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves $c->greet()->save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('Greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves $c->greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('A.php'),
);
expect(greetCall).toBeDefined();
});
});

View file

@ -1428,3 +1428,49 @@ describe('Python method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('Python grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'python-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('a.py'),
);
expect(greetCall).toBeDefined();
});
});

View file

@ -1012,3 +1012,49 @@ describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('Ruby grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects A, B, C, Greeting classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('a.rb'),
);
expect(greetCall).toBeDefined();
});
});

View file

@ -1475,3 +1475,40 @@ describe('Rust method chain binding via unified fixpoint (Phase 9C)', () => {
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase A: Rust struct_pattern destructuring — let Point { x, y } = p
// Each field emits a fieldAccess PendingAssignment; fixpoint resolves x/y → Vec2
// ---------------------------------------------------------------------------
describe('Rust struct_pattern destructuring resolution (Phase A)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-struct-destructuring'),
() => {},
);
}, 60000);
it('detects Point and Vec2 structs', () => {
const classes = getNodesByLabel(result, 'Struct');
expect(classes).toContain('Point');
expect(classes).toContain('Vec2');
});
it('resolves x.save() to Vec2#save via struct destructuring', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('vec2'),
);
expect(saveCall).toBeDefined();
});
it('resolves both x.save() and y.save() — emits at least 1 CALLS to Vec2#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'save' && c.targetFilePath.includes('vec2'));
// Both x and y are Vec2 — the same function, so calls may deduplicate to 1
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
});
});

View file

@ -2054,3 +2054,171 @@ describe('TypeScript method chain binding via unified fixpoint (Phase 9C)', () =
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase A: Object destructuring — const { field } = receiver → fieldAccess PendingAssignment
// ---------------------------------------------------------------------------
describe('TypeScript object destructuring resolution (Phase A)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-object-destructuring'),
() => {},
);
}, 60000);
it('detects User, Address classes', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('User');
expect(classes).toContain('Address');
});
it('resolves address.save() to Address#save via object destructuring', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('does NOT resolve save() to a wrong target', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(c => c.target === 'save');
for (const call of saveCalls) {
expect(call.targetFilePath).toContain('models');
}
});
});
// ---------------------------------------------------------------------------
// Phase A: Post-fixpoint for-loop replay — iterable resolved via callResult fixpoint
// Differs from ts-for-of-call-expression: iterable is an identifier, not inline call
// ---------------------------------------------------------------------------
describe('TypeScript post-fixpoint for-loop replay (Phase A ex-9B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-fixpoint-for-loop'),
() => {},
);
}, 60000);
it('resolves u.save() to User#save via post-fixpoint for-loop replay', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase B: Deep MRO — walkParentChain() at depth 2 (C→B→A)
// greet() is defined on A, accessed via C. Tests BFS depth-2 parent traversal.
// ---------------------------------------------------------------------------
describe('TypeScript grandparent method resolution via MRO (Phase B)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-grandparent-resolution'),
() => {},
);
}, 60000);
it('detects 3 classes in inheritance chain (A, B, C) plus Greeting', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('A');
expect(classes).toContain('B');
expect(classes).toContain('C');
expect(classes).toContain('Greeting');
});
it('emits EXTENDS edges: B→A, C→B', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(edgeSet(extends_)).toContain('B → A');
expect(edgeSet(extends_)).toContain('C → B');
});
it('resolves c.greet().save() to Greeting#save via depth-2 MRO lookup', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.targetFilePath.includes('greeting'),
);
expect(saveCall).toBeDefined();
});
it('resolves c.greet() to A#greet (method found via MRO walk)', () => {
const calls = getRelationships(result, 'CALLS');
const greetCall = calls.find(c =>
c.target === 'greet' && c.targetFilePath.includes('base'),
);
expect(greetCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase C: TS null-check narrowing — if (x !== null) { x.save() }
// patternOverrides stores narrowed type for the if-body position range
// ---------------------------------------------------------------------------
describe('TypeScript null-check narrowing resolution (Phase C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-null-check-narrowing'),
() => {},
);
}, 60000);
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves x.save() inside !== null guard to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processStrict' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('does NOT resolve to Repo#save (no cross-contamination)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find(c =>
c.target === 'save' && c.targetLabel === 'Repo',
);
expect(wrongCall).toBeUndefined();
});
it('resolves x.save() in loose != null check (processLoose)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processLoose' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves x.save() in !== undefined check (processUndefined)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processUndefined' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves x.save() inside function expression null-check (processFuncExpr)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' && c.source === 'processFuncExpr' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});

View file

@ -1589,7 +1589,7 @@ class RepoService {
getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }),
clear: () => {},
};
const { env } = buildTypeEnv(tree, 'kotlin', mockSymbolTable as any);
const { env } = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any });
expect(flatGet(env, 'user')).toBe('User');
});
@ -1610,7 +1610,7 @@ class RepoService {
getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }),
clear: () => {},
};
const { env } = buildTypeEnv(tree, 'kotlin', mockSymbolTable as any);
const { env } = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any });
expect(flatGet(env, 'result')).toBeUndefined();
});
@ -3694,4 +3694,162 @@ function calculate(service: Service) {
});
});
describe('null-check narrowing via patternOverrides (Phase C Task 7)', () => {
it('TS: if (x !== null) narrows User | null to User inside if-body', () => {
const code = `
function process(x: User | null) {
if (x !== null) {
x.save();
}
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
// Inside the if-body, x should resolve to User (nullable stripped)
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('TS: if (x !== undefined) narrows User | undefined to User inside if-body', () => {
const code = `
function process(x: User | undefined) {
if (x !== undefined) {
x.save();
}
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('TS: if (x != null) narrows with loose inequality', () => {
const code = `
function process(x: User | null) {
if (x != null) {
x.save();
}
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('TS: null-check narrowing does NOT leak to else branch', () => {
const code = `
function process(x: User | null) {
if (x !== null) {
x.save();
} else {
x.fallback();
}
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
// Inside else branch, x should retain original nullable type (User via fastStripNullable)
const fallbackCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.fallback'));
// The else branch is NOT in the narrowing range, so lookup falls through to
// the flat scopeEnv which has "User | null" — fastStripNullable strips it to User.
// This is expected: without negative narrowing (Phase 13A), else branches still get
// the base stripped type. The key invariant is that the narrowing override does NOT
// apply outside the if-body range.
expect(typeEnv.lookup('x', fallbackCall)).toBe('User');
});
it('TS: null-check narrowing does NOT apply outside the if block', () => {
const code = `
function process(x: User | null) {
if (x !== null) {
x.save();
}
x.other();
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
// After the if-block, x should use the flat scopeEnv (User | null → User via fastStripNullable)
const otherCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.other'));
expect(typeEnv.lookup('x', otherCall)).toBe('User');
});
it('TS: no narrowing when variable has no nullable type', () => {
const code = `
function process(x: User) {
if (x !== null) {
x.save();
}
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript');
// x is already non-nullable — no narrowing override is emitted, but lookup still works
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('TS: instanceof still works alongside null-check narrowing', () => {
const tree = parse(`
function process(x) {
if (x instanceof User) {
x.save();
}
}
`, TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript');
expect(flatGet(env, 'x')).toBe('User');
});
it('Kotlin: if (x != null) narrows nullable type inside if-body', () => {
const code = `
fun process(x: User?) {
if (x != null) {
x.save()
}
}`;
const tree = parse(code, Kotlin);
const typeEnv = buildTypeEnv(tree, 'kotlin');
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('Kotlin: when/is still works alongside null-check narrowing', () => {
const tree = parse(`
fun process(x: Any) {
when (x) {
is User -> x.name
}
}
`, Kotlin);
const { env } = buildTypeEnv(tree, 'kotlin');
expect(flatGet(env, 'x')).toBe('User');
});
it('C#: if (x != null) narrows nullable type inside if-body', () => {
const code = `
class App {
void Process(User? x) {
if (x != null) {
x.Save();
}
}
}`;
const tree = parse(code, CSharp);
const typeEnv = buildTypeEnv(tree, 'csharp');
const saveCall = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('x.Save'));
expect(typeEnv.lookup('x', saveCall)).toBe('User');
});
it('C#: is_pattern_expression type pattern still works alongside null-check', () => {
const tree = parse(`
class App {
void Process(object obj) {
if (obj is User user) {
user.Save();
}
}
}
`, CSharp);
const { env } = buildTypeEnv(tree, 'csharp');
expect(flatGet(env, 'user')).toBe('User');
});
});
});

View file

@ -51,122 +51,79 @@ The goal is not to build a compiler. The goal is to support high-value static an
- 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)
### Milestone D — Completeness ✅
**Shipped in** `feat/type-resolution-milestone-d` (PR #387). Consolidated original Phases 10–13 into 3 balanced phases.
#### Phase A: Fixpoint Completeness ✅
- Post-fixpoint for-loop replay (ex-9B): `pendingForLoops` collection + replay after fixpoint resolves iterable types
- Object destructuring via `fieldAccess` items (TS/JS `object_pattern`, Rust `struct_pattern`) — no new `destructure` PendingAssignment variant needed
- Extracted `resolveFixpointBindings()` helper with exhaustive switch + `classDefCache` memoization
#### Phase B: Inheritance & Receivers ✅
- `BuildTypeEnvOptions` interface replacing positional params for `buildTypeEnv`
- Heritage pre-pass constructing `parentMap` from tree-sitter query matches (not graph edges — heritage-processor runs in parallel)
- MRO-aware `walkParentChain()` (depth 5, cycle-safe BFS) for `resolveFieldType` and `resolveMethodReturnType`
- `this`/`self`/`$this`/`Me` receiver substitution via `substituteThisReceiver` hook
- Go `inc_statement`/`dec_statement` write-access queries
#### Phase C: Branch-Sensitive Narrowing ✅
- Null-check narrowing (`!= null`, `!== undefined`, `is not null`) via position-indexed `patternOverrides`
- Supported for TS, Kotlin, C# — renamed `PATTERN_BRANCH_TYPES` → `NARROWING_BRANCH_TYPES`
- Bug fix: Kotlin narrowing required 3 fixes in `jvm.ts` (AST node type `equality_expression`, anonymous `null` node, `nullable_type` parameter fallback)
#### Deferred from Milestone D
- **Type predicates (13A):** Cross-function analysis for niche TS `x is User` feature — deferred
- **Swift parity (11D):** tree-sitter-swift Node 22 issues — all Swift work consolidated to Phase S
- **Positional destructuring (12C):** Python/Kotlin/C#/C++ tuple-position-to-field mapping — deferred
- **Discriminated union narrowing (13C):** Needs tagged union metadata not in SymbolTable — deferred
#### Integration Test Coverage
17 fixture directories, 23 describe blocks, 705 lines of test code covering all 11 languages:
- Grandparent MRO (depth-2 C→B→A): TS, JS, Kotlin, C#, C++, Java, PHP, Python, Ruby
- Object destructuring: TS, JS
- Struct destructuring: Rust
- Post-fixpoint for-loop replay: TS, JS
- Go inc/dec write access
- Null-check narrowing: TS, C#, Kotlin
---
## Open Phases
### Phase 10: Loop-Fixpoint Bridge
### Phase P: Polymorphism & Overloading
**Supersedes Phase 9B.** For-loop element bindings run during `walk()` before the fixpoint. Variables typed by the fixpoint are invisible to for-loop extraction.
**Plan:** `docs/plans/2026-03-19-feat-polymorphism-overloading-type-resolution-plan.md`
**Problem:**
```typescript
const users = getUsers(); // fixpoint: users → User[]
for (const u of users) { // walk-time: users untyped → u unresolved
u.save(); // missed CALLS edge
}
```
Four incremental phases:
1. **Parameter type metadata** — extend `SymbolDefinition` with `parameterTypes: string[]` extracted during parsing
2. **Overload disambiguation** — filter overloaded methods by argument literal types at call sites
3. **Constructor-visible virtual dispatch** — `Base b = new Derived(); b.method()` resolves to `Derived#method` when constructor type is a known subclass
4. **Covariant return type awareness** — prefer child's return type over inherited definition
**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.
Languages benefiting: Java, Kotlin, C#, C++, TypeScript (overloading). All OOP languages (virtual dispatch).
**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)
**Risks:** AST node lifetime (safe — nodes valid within `buildTypeEnv` lifetime).
**Impact: High | Effort: Low-Medium**
**Impact: High | Effort: High**
---
### Phase 11: Inheritance & this/self
### Phase S: Swift Parity
Four items sharing infrastructure.
**Blocked on** tree-sitter-swift Node 22 compatibility.
#### 11A: MRO-aware field and method lookups
**Problem:** `lookupFieldByOwner` only finds direct fields. Inherited fields (`Admin extends User`, field on `User`) don't resolve.
**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).
Includes IMPLEMENTS edges so interface-declared fields/methods resolve (Java, Kotlin, C#).
#### 11B: this/self in fixpoint
**Problem:** `this.field` and `this.method()` emit pending items with `receiver: 'this'`, but `scopeEnv.get('this')` returns `undefined`.
**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.
#### 11C: Go inc/dec write access
**Problem:** `obj.field++`/`obj.field--` produce `inc_statement`/`dec_statement` — write-access tracking doesn't see them.
**Approach:** Add these node types to call-processor write detection (~5 lines).
#### 11D: Swift assignment chains
**Problem:** Swift has no `extractPendingAssignment` — copy/callResult/fieldAccess/methodCallResult don't work.
**Approach:** Implement `extractPendingAssignment` for Swift covering all 4 binding kinds.
**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).
**Impact: Medium-High | Effort: Medium**
---
### Phase 12: Destructuring
**Problem:** `const { address, name } = user` produces no bindings — LHS is a pattern node, not an identifier.
**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.
Works without Phase 11A (direct fields only). Phase 11A MRO enhances it to resolve inherited fields too.
**Phased delivery:**
| 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 |
Skip: computed properties, rest elements, nested destructuring (`{ address: { city } }` — deferred).
**Risks:** Nested destructuring requires recursive resolution (explicitly deferred).
- For-loop element binding (from Phase 10)
- Assignment chains: copy, callResult, fieldAccess, methodCallResult (from Phase 11D)
- `guard let` narrowing (from Phase 13B) — uses scopeEnv path, not `patternOverrides`
**Impact: Medium | Effort: Medium**
---
### 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.
@ -202,19 +159,14 @@ config.validate(); // missed
## Dependency Graph
```
Phase 10 (loops) ──────────────────────┐
│
Phase 11 (MRO + this + Go + Swift) ───┤
├──→ Phase 14 (cross-file)
Phase 12 (destructuring) ─────────────┤
│
Phase 13 (branch narrowing) ───────────┘
Milestone D (Phases A, B, C) ✅ ──┐
├──→ Phase 14 (cross-file)
Phase P (polymorphism) ───────────┤
│
Phase S (Swift parity) ───────────┘
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.
Phase P and Phase S are independent of each other and Phase 14.
Phase 14 benefits from Phase P (better per-file resolution = fewer cross-file gaps).
```
---
@ -222,19 +174,11 @@ Swift parity threaded through Phases 10–13 incrementally.
## Language-Specific Gaps (remaining)
### Swift
- For-loop element binding → Phase 10
- Assignment chains (copy, callResult, fieldAccess, methodCallResult) → Phase 11D
- Pattern binding → Phase 13B (`guard let`)
### Go
- `obj.field++`/`obj.field--` write ACCESSES → Phase 11C
### Rust
- Struct-pattern field destructuring → Phase 12B
- For-loop element binding → Phase S
- Assignment chains (copy, callResult, fieldAccess, methodCallResult) → Phase S
- `guard let` narrowing → Phase S
### All languages
- Inherited field/method resolution → Phase 11A
- `this`/`self` in fixpoint → Phase 11B
- Cross-file binding propagation → Phase 14
---
@ -253,14 +197,22 @@ Field/property maps, deep chains, mixed chains, stdlib passthroughs.
Unified fixpoint loop, call-result binding, field access binding, method-call-result binding, arbitrary-depth chain propagation.
### Milestone D — Completeness ← **next** (Phases 10–13)
### Milestone D — Completeness ✅ (Phases A, B, C)
Loop-fixpoint bridge, inheritance walking, `this`/`self` resolution, destructuring, branch narrowing, Swift parity.
Consolidated Phases 10–13 into 3 balanced phases. Loop-fixpoint bridge, MRO-aware inheritance walking, `this`/`self` resolution, object/struct destructuring, null-check narrowing. Kotlin null-check bug fix. Full 11-language integration test coverage.
### Milestone E — Cross-Boundary (Phase 14)
### Milestone E — Cross-Boundary ← **next** (Phase 14)
Export-type index, cross-file binding propagation.
### Milestone P — Polymorphism & Overloading (Phase P)
Parameter type metadata, overload disambiguation, constructor-visible virtual dispatch, covariant return types.
### Milestone S — Swift Parity (Phase S)
For-loop binding, assignment chains, `guard let` narrowing. Blocked on tree-sitter-swift Node 22.
---
## Open Design Questions
@ -294,8 +246,6 @@ That supports: better call graphs, more accurate impact analysis, stronger AI co
## Summary
**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.
**Complete:** Phases 7, 8, 9, 9C, Milestone D (A, B, C) — 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, MRO-aware inheritance walking, this/self resolution, object/struct destructuring, null-check narrowing — across 11 languages with full integration test coverage.
**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).
Each phase is independently deliverable (except Phase 14 which depends on 10–13 being stable). Swift parity is threaded incrementally through Phases 10–13.
**Next:** Phase 14 (cross-file binding propagation) — the architectural capstone. Phase S (Swift parity) is independent and unblocked once tree-sitter-swift Node 22 is resolved.