diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 3f36ee65b..4df99d005 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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(); + for (const match of matches) { + const captureMap: Record = {}; + 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 = fileParentMap; + + const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null; const callRouter = callRouters[language]; const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0 diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 31945bef4..2dc6cb546 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index ce97f468c..345f6233f 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -61,17 +61,22 @@ interface PatternOverride { /** scope → varName → overrides (checked in order, first range match wins) */ type PatternOverrides = Map>; -/** 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>(); + 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 = ( + typeName: string, + parentMap: ReadonlyMap | undefined, + getClassDefs: (name: string) => Array<{ nodeId: string; type: string }>, + lookupOnClass: (nodeId: string) => T | undefined, +): T | undefined => { + if (!parentMap) return undefined; + const visited = new Set([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, symbolTable?: SymbolTable, + getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + parentMap?: ReadonlyMap, ): 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, symbolTable?: SymbolTable, + getClassDefs?: (typeName: string) => Array<{ nodeId: string; type: string }>, + parentMap?: ReadonlyMap, ): 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, +): void => { + if (pendingItems.length === 0) return; + const getClassDefs = createClassDefCache(symbolTable); + const resolved = new Set(); + 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; +} + 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(); @@ -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 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(); - 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 { diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index f6550dc26..2af9df5df 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index e0f3e9fc7..c05a918d9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 7059cc8bd..6bdcb4bad 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -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 = 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 diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index af3b8a17c..223fbcc70 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -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, -) => 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, declarationTypeNodes: ReadonlyMap, scope: string, -) => { varName: string; typeName: string } | undefined; +) => PatternBindingResult | undefined; /** Per-language type extraction configuration */ export interface LanguageTypeConfig { diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 56f4d15b8..778631875 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -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 = 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 = { diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 11c353713..3847b3bbf 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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(); + for (const match of matches) { + const captureMap: Record = {}; + 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 = 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 = {}; for (const c of match.captures) { diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 21b1810a1..ab2a351d4 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -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'); + }); + }); + });