mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
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:
parent
58f67d07f7
commit
7c72cefd8d
10 changed files with 823 additions and 113 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue