feat(type-resolution): implement Milestone D — Phases A, B, C

Phase A — Fixpoint Completeness:
- Extract fixpoint loop into resolveFixpointBindings() with exhaustive switch guard
- Add classDefCache to memoize lookupFuzzy results during fixpoint iteration
- Post-fixpoint for-loop replay: bridge walk-time/fixpoint gap (ex-Phase 9B)
- Object destructuring via fieldAccess items (TS/JS object_pattern, Rust struct_pattern)
- PendingAssignmentExtractor now supports returning arrays for multi-binding patterns

Phase B — Inheritance & Receivers:
- BuildTypeEnvOptions object replaces positional params (future-proof API)
- Heritage pre-pass: thread parent class data from query matches into buildTypeEnv
- walkParentChain() helper: MRO-aware field/method resolution (depth 5, cycle-safe)
- this/self/$this/Me receiver substitution at extractPendingAssignment call site
- Go inc/dec write-access detection via tree-sitter queries

Phase C — Branch-Sensitive Narrowing:
- Rename PATTERN_BRANCH_TYPES → NARROWING_BRANCH_TYPES (semantic expansion)
- Null-check narrowing: != null / !== undefined strips nullable wrapper in truthy branch
- Position-indexed patternOverrides with extractor-provided narrowing ranges
- TS, Kotlin, C# null-check narrowing extractors with if-body range detection

All 3315 existing tests pass. 9 new null-check narrowing tests added.
This commit is contained in:
Gergo Magyar 2026-03-19 17:29:47 +00:00
parent 58f67d07f7
commit 7c72cefd8d
10 changed files with 823 additions and 113 deletions

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,38 @@ 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) {
const replayPendingItems: Array<{ scope: string } & PendingAssignment> = [];
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 });
}
// Collect any new pending items from replay-produced variables.
// Re-walk the for-loop bodies to pick up field/method chains on the now-typed loop vars.
// For simplicity, run a mini-fixpoint on any pending items that were already collected
// but couldn't resolve because they depended on the loop variable.
if (replayPendingItems.length > 0) {
resolveFixpointBindings(replayPendingItems, env, returnTypeLookup, symbolTable);
}
// Also 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,81 @@ 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') return undefined;
current = current.parent;
}
return undefined;
};
/** Check if a C# declaration type node represents a nullable type.
* Checks for nullable_type node or text containing '?' or 'null'. */
const isCSharpNullableDecl = (declTypeNode: SyntaxNode): boolean => {
if (declTypeNode.type === 'nullable_type') return true;
const text = declTypeNode.text;
return text.includes('?') || text.includes('null');
};
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 +366,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 +465,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

@ -660,27 +660,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: comparison_expression > simple_identifier, "!=", null_literal
if (node.type === 'comparison_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 === 'simple_identifier' && right.type === 'null_literal') {
varNode = left;
} else if (right.type === 'simple_identifier' && left.type === 'null_literal') {
varNode = right;
}
if (!varNode) 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', 'comparison_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 === '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

@ -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,164 @@ 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');
});
// TODO: Kotlin nullable parameter type capture needs tree-sitter grammar investigation
// The nullable_type node may not be captured via the current declarationTypeNodes path
it.skip('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');
});
});
});