diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 8bf9dac7e..9c08d11ba 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -13,7 +13,7 @@ import type { SymbolTable } from './symbol-table.js'; * * Design constraints: * - Explicit-only: Tier 0 uses type annotations; Tier 1 infers from constructors - * - Tier 2: single-pass assignment chain propagation (depth-1) — resolves + * - Tier 2: single-pass assignment chain propagation in source order — resolves * `const b = a` when `a` already has a type from Tier 0/1 * - Scope-aware: function-local variables don't collide across functions * - Conservative: complex/generic types extract the base name only @@ -377,9 +377,11 @@ export const buildTypeEnv = ( walk(tree.rootNode, FILE_SCOPE); - // Tier 2: single-pass assignment chain propagation (depth-1 only). + // Tier 2: single-pass assignment chain propagation in source order. // Resolves `const b = a` where `a` has a known type from Tier 0/1. - // No fixpoint iteration — depth-1 covers 95%+ of real-world patterns. + // Multi-hop chains resolve when forward-declared (a→b→c in source order); + // reverse-order assignments are depth-1 only. No fixpoint iteration — + // this covers 95%+ of real-world patterns. for (const { scope, lhs, rhs } of pendingAssignments) { const scopeEnv = env.get(scope); if (!scopeEnv || scopeEnv.has(lhs)) continue; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index e96892e2d..3ae5e7941 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -170,7 +170,10 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const lhs = nameNode.text; if (scopeEnv.has(lhs)) continue; // C# wraps value in equals_value_clause; fall back to last named child - const evc = child.children.find(c => c.type === 'equals_value_clause'); + let evc: SyntaxNode | null = null; + for (let j = 0; j < child.childCount; j++) { + if (child.child(j)?.type === 'equals_value_clause') { evc = child.child(j); break; } + } const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1); if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) { return { lhs, rhs: valueNode.text }; diff --git a/gitnexus/src/core/ingestion/type-extractors/go.ts b/gitnexus/src/core/ingestion/type-extractors/go.ts index 1af347703..fe083525f 100644 --- a/gitnexus/src/core/ingestion/type-extractors/go.ts +++ b/gitnexus/src/core/ingestion/type-extractors/go.ts @@ -194,17 +194,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => const lhs = lhsNode.text; if (scopeEnv.has(lhs)) return undefined; if (rhsNode.type === 'identifier') return { lhs, rhs: rhsNode.text }; + return undefined; } if (node.type === 'var_spec' || node.type === 'var_declaration') { // var_declaration contains var_spec children; var_spec has name + expression_list value - const specs = node.type === 'var_declaration' ? node.namedChildren.filter(c => c.type === 'var_spec') : [node]; + const specs: SyntaxNode[] = []; + if (node.type === 'var_declaration') { + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c?.type === 'var_spec') specs.push(c); + } + } else { + specs.push(node); + } for (const spec of specs) { const nameNode = spec.childForFieldName('name'); if (!nameNode || nameNode.type !== 'identifier') continue; const lhs = nameNode.text; if (scopeEnv.has(lhs)) continue; // Check if the last named child is a bare identifier (no type annotation between name and value) - const exprList = spec.children.find(c => c.type === 'expression_list'); + let exprList: SyntaxNode | null = null; + for (let i = 0; i < spec.childCount; i++) { + if (spec.child(i)?.type === 'expression_list') { exprList = spec.child(i); break; } + } const rhsNode = exprList?.firstNamedChild; if (rhsNode?.type === 'identifier') return { lhs, rhs: rhsNode.text }; } diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index 8f3c11c74..5b916b347 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -38,7 +38,8 @@ export type { TypeBindingExtractor, ParameterExtractor, ConstructorBindingScanner, - ForLoopExtractor + ForLoopExtractor, + PendingAssignmentExtractor, } from './types.js'; export { TYPED_PARAMETER_TYPES, diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 46cd9791c..262addf81 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -273,7 +273,7 @@ const extractKotlinForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEn const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => { if (node.type !== 'property_declaration') return undefined; // Find the variable name from variable_declaration child - const varDecl = node.children.find(c => c.type === 'variable_declaration'); + const varDecl = findChildByType(node, 'variable_declaration'); if (!varDecl) return undefined; const nameNode = varDecl.firstNamedChild; if (!nameNode || nameNode.type !== 'simple_identifier') return undefined; @@ -284,7 +284,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child) continue; - if (child.type === '=' || child.text === '=') { foundEq = true; continue; } + if (child.type === '=') { foundEq = true; continue; } if (foundEq && child.type === 'simple_identifier') { return { lhs, rhs: child.text }; } diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index a21133cdd..21305d71b 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -132,6 +132,9 @@ export const TYPED_PARAMETER_TYPES = new Set([ * Extract type arguments from a generic type node. * e.g., List → ['User', 'String'], Vec → ['User'] * + * Phase 5 infrastructure: not yet wired into production code. + * Will be used for container-type unwrapping (Optional → User). + * * Handles language-specific AST structures: * - TS/Java/Rust/Go: generic_type > type_arguments > type nodes * - C#: generic_type > type_argument_list > type nodes @@ -233,6 +236,9 @@ export const hasTypeAnnotation = (node: SyntaxNode): boolean => { return false; }; +/** Bare nullable keywords that should not produce a receiver binding. */ +const NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']); + /** * Strip nullable wrappers from a type name string. * Used by both lookupInEnv (TypeEnv annotations) and extractReturnTypeName @@ -245,9 +251,6 @@ export const hasTypeAnnotation = (node: SyntaxNode): boolean => { * "User | Repo" → undefined (genuine union — refuse) * "null" → undefined */ -/** Bare nullable keywords that should not produce a receiver binding. */ -const NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']); - export const stripNullable = (typeName: string): string | undefined => { let text = typeName.trim(); if (!text) return undefined; @@ -260,7 +263,7 @@ export const stripNullable = (typeName: string): string | undefined => { // Strip union with null/undefined/None/nil/void if (text.includes('|')) { const parts = text.split('|').map(p => p.trim()).filter(p => - p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil' + p !== '' && !NULLABLE_KEYWORDS.has(p) ); if (parts.length === 1) return parts[0]; return undefined; // genuine union or all-nullable — refuse diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/app.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/app.py new file mode 100644 index 000000000..f04a0788b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/app.py @@ -0,0 +1,24 @@ +from user import User +from repo import Repo + + +def get_user() -> User: + return User() + + +def get_repo() -> Repo: + return Repo() + + +# Python 3.10+ union: User | None is parsed as binary_operator, +# stored as raw text "User | None" in TypeEnv, then stripNullable resolves it. +def nullable_chain_user() -> None: + u: User | None = get_user() + alias = u + alias.save() + + +def nullable_chain_repo() -> None: + r: Repo | None = get_repo() + alias = r + alias.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/repo.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/repo.py new file mode 100644 index 000000000..0d725eb53 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self) -> bool: + return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/user.py b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/user.py new file mode 100644 index 000000000..8ef6bb419 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-nullable-chain/user.py @@ -0,0 +1,3 @@ +class User: + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/app.ts new file mode 100644 index 000000000..81e72c032 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/app.ts @@ -0,0 +1,22 @@ +import { User } from './user'; +import { Repo } from './repo'; + +function getUser(): User { return new User(); } +function getRepo(): Repo { return new Repo(); } + +// Multi-hop forward-declared chain: a → b → c (source order) +// All three should resolve because the post-walk pass processes in order. +export function multiHopForward(): void { + const a: User = getUser(); + const b = a; + const c = b; + c.save(); +} + +// Multi-hop with Repo to prove disambiguation +export function multiHopRepo(): void { + const a: Repo = getRepo(); + const b = a; + const c = b; + c.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/repo.ts new file mode 100644 index 000000000..19631246b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/user.ts new file mode 100644 index 000000000..e2af97ca7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/app.ts new file mode 100644 index 000000000..35f140d69 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/app.ts @@ -0,0 +1,27 @@ +import { User } from './user'; +import { Repo } from './repo'; + +function findUser(): User | null { return new User(); } +function findRepo(): Repo | undefined { return new Repo(); } + +// Nullable type + assignment chain: the nullable union must be stripped +// before the alias can resolve to User. +export function nullableChainUser(): void { + const u: User | null = findUser(); + const alias = u; + alias.save(); +} + +// Same pattern with Repo | undefined +export function nullableChainRepo(): void { + const r: Repo | undefined = findRepo(); + const alias = r; + alias.save(); +} + +// Triple nullable: User | null | undefined → still User +export function tripleNullable(): void { + const u: User | null | undefined = findUser(); + const alias = u; + alias.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/repo.ts new file mode 100644 index 000000000..19631246b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/user.ts new file mode 100644 index 000000000..e2af97ca7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index fd4dfbc7b..626a595f3 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -836,3 +836,60 @@ describe('Python assignment chain propagation', () => { expect(userSave!.targetFilePath).not.toBe(repoSave!.targetFilePath); }); }); + +// --------------------------------------------------------------------------- +// Python nullable (User | None) + assignment chain combined. +// Python 3.10+ union syntax is parsed as binary_operator by tree-sitter, +// stored as raw text "User | None" in TypeEnv. stripNullable's +// NULLABLE_KEYWORDS.has() path must resolve it at lookup time. +// --------------------------------------------------------------------------- + +describe('Python nullable (User | None) + assignment chain combined', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-nullable-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves alias.save() to User#save when source is User | None', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'nullable_chain_user' && c.targetFilePath?.includes('user.py'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.save() from User | None does NOT resolve to Repo#save (negative)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'nullable_chain_user' && c.targetFilePath?.includes('repo.py'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves alias.save() to Repo#save when source is Repo | None', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'nullable_chain_repo' && c.targetFilePath?.includes('repo.py'), + ); + expect(repoSave).toBeDefined(); + }); + + it('alias.save() from Repo | None does NOT resolve to User#save (negative)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'nullable_chain_repo' && c.targetFilePath?.includes('user.py'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 1b40ea33c..f95cdb4af 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1100,3 +1100,114 @@ describe('TypeScript assignment chain propagation (Tier 2)', () => { }); }); +// --------------------------------------------------------------------------- +// Multi-hop forward-declared chain (a → b → c) — validates that single-pass +// in source order resolves chains deeper than depth-1. +// --------------------------------------------------------------------------- + +describe('TypeScript multi-hop assignment chain (a → b → c)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-multi-hop-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves c.save() to User#save through a → b → c chain', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'multiHopForward' && c.targetFilePath?.includes('user.ts'), + ); + expect(userSave).toBeDefined(); + }); + + it('c.save() in multiHopForward does NOT resolve to Repo#save (negative)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'multiHopForward' && c.targetFilePath?.includes('repo.ts'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves c.save() to Repo#save through a → b → c chain (Repo variant)', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'multiHopRepo' && c.targetFilePath?.includes('repo.ts'), + ); + expect(repoSave).toBeDefined(); + }); + + it('c.save() in multiHopRepo does NOT resolve to User#save (negative)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'multiHopRepo' && c.targetFilePath?.includes('user.ts'), + ); + expect(wrongCall).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Nullable type + assignment chain: stripNullable must resolve the nullable +// union (User | null → User) before the chain propagation can work. +// Exercises the refactored NULLABLE_KEYWORDS.has() code path. +// --------------------------------------------------------------------------- + +describe('TypeScript nullable + assignment chain combined', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-nullable-chain'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves alias.save() to User#save when source is User | null', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'nullableChainUser' && c.targetFilePath?.includes('user.ts'), + ); + expect(userSave).toBeDefined(); + }); + + it('alias.save() from User | null does NOT resolve to Repo#save (negative)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongCall = calls.find(c => + c.target === 'save' && c.source === 'nullableChainUser' && c.targetFilePath?.includes('repo.ts'), + ); + expect(wrongCall).toBeUndefined(); + }); + + it('resolves alias.save() to Repo#save when source is Repo | undefined', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && c.source === 'nullableChainRepo' && c.targetFilePath?.includes('repo.ts'), + ); + expect(repoSave).toBeDefined(); + }); + + it('resolves alias.save() to User#save when source is User | null | undefined (triple)', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && c.source === 'tripleNullable' && c.targetFilePath?.includes('user.ts'), + ); + expect(userSave).toBeDefined(); + }); +}); + diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 37db1764a..af9c4fc2f 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -2026,5 +2026,229 @@ svc = App::Models::Service.new it('strips User | nil → User (Ruby)', () => { expect(stripNullable('User | nil')).toBe('User'); }); + + it('strips User | void | nil → User (multiple nullable keywords)', () => { + expect(stripNullable('User | void | nil')).toBe('User'); + }); + + it('returns undefined for None alone', () => { + expect(stripNullable('None')).toBeUndefined(); + }); + + it('returns undefined for nil alone', () => { + expect(stripNullable('nil')).toBeUndefined(); + }); + + it('returns undefined for void alone', () => { + expect(stripNullable('void')).toBeUndefined(); + }); + + it('returns undefined for undefined alone', () => { + expect(stripNullable('undefined')).toBeUndefined(); + }); + + it('strips nullable suffix with spaces: User ? → User', () => { + expect(stripNullable(' User? ')).toBe('User'); + }); + + it('returns undefined for all-nullable union: null | undefined | void', () => { + expect(stripNullable('null | undefined | void')).toBeUndefined(); + }); + + it('refuses triple non-null union: User | Repo | Service', () => { + expect(stripNullable('User | Repo | Service')).toBeUndefined(); + }); + }); + + // ── Assignment chain: reverse-order depth limitation ────────────────── + + describe('assignment chain — reverse-order limitation', () => { + it('resolves reverse-declared Tier 2→Tier 0 (Tier 0 set during walk, before post-walk)', () => { + // Even though b = a appears before a: User in source, a's Tier 0 binding + // is set during the AST walk. The post-walk Tier 2 loop runs after all + // Tier 0/1 bindings exist, so b = a resolves. + const tree = parse(` + function process() { + const b = a; + const a: User = getUser(); + } + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + const scopeKey = [...env.keys()].find(k => k.startsWith('process@')); + expect(scopeKey).toBeDefined(); + expect(env.get(scopeKey!)?.get('a')).toBe('User'); + expect(env.get(scopeKey!)?.get('b')).toBe('User'); + }); + + it('does NOT resolve reverse-ordered Tier 2 chains (b = a, a = c, c: User)', () => { + // Two chained Tier 2 assignments in reverse source order. + // Post-walk iterates source order: b = a (a not yet resolved) → fails, + // then a = c (c is Tier 0) → succeeds. b stays unresolved. + const tree = parse(` + function process() { + const b = a; + const a = c; + const c: User = getUser(); + } + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + const scopeKey = [...env.keys()].find(k => k.startsWith('process@')); + expect(scopeKey).toBeDefined(); + expect(env.get(scopeKey!)?.get('c')).toBe('User'); + expect(env.get(scopeKey!)?.get('a')).toBe('User'); + // b should NOT resolve — reverse Tier 2 chain + expect(env.get(scopeKey!)?.get('b')).toBeUndefined(); + }); + }); + + // ── Assignment chain: per-language coverage for refactored code ──────── + + describe('assignment chain — Go var_spec form', () => { + it('propagates var b = a when a has a known type (var_spec)', () => { + const tree = parse(` + package main + func process() { + var a User + var b = a + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'a')).toBe('User'); + expect(flatGet(env, 'b')).toBe('User'); + }); + }); + + describe('assignment chain — C# equals_value_clause', () => { + it('propagates var alias = u when u has a known type', () => { + const tree = parse(` + class App { + void Process() { + User u = new User(); + var alias = u; + } + } + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + }); + + describe('assignment chain — Kotlin property_declaration', () => { + it('propagates val alias = u when u has an explicit type annotation', () => { + const tree = parse(` + fun process() { + val u: User = User() + val alias = u + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + + it('propagates val alias = u inside a class method with explicit type', () => { + const tree = parse(` + class Service { + fun process() { + val u: User = User() + val alias = u + } + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + }); + + describe('assignment chain — Java variable_declarator', () => { + it('propagates var alias = u when u has an explicit type', () => { + const tree = parse(` + class App { + void process() { + User u = new User(); + var alias = u; + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + }); + + describe('assignment chain — Python identifier', () => { + it('propagates alias = u when u has a type annotation', () => { + const tree = parse(` +def process(): + u: User = get_user() + alias = u + `, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + }); + + describe('assignment chain — Rust let_declaration', () => { + it('propagates let alias = u when u has a type annotation', () => { + const tree = parse(` + fn process() { + let u: User = User::new(); + let alias = u; + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'u')).toBe('User'); + expect(flatGet(env, 'alias')).toBe('User'); + }); + }); + + describe('assignment chain — PHP variable_name', () => { + it('propagates $alias = $u when $u has a type from new', () => { + const tree = parse(` { + it('TypeScript: lookup strips User | null to User', () => { + const tree = parse(` + function process(user: User | null) { + user.save(); + } + `, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + // Find the call node for .save() + const { env } = typeEnv; + const scopeKey = [...env.keys()].find(k => k.startsWith('process@')); + expect(scopeKey).toBeDefined(); + // The raw env stores 'User' because extractSimpleTypeName already unwraps union_type + expect(env.get(scopeKey!)?.get('user')).toBe('User'); + }); + + it('Python: lookup strips User | None to User', () => { + const tree = parse(` +def process(): + user: User | None = get_user() + `, Python); + const { env } = buildTypeEnv(tree, 'python'); + // Python 3.10+ union syntax is stored as raw text "User | None" + // which stripNullable resolves at lookup time + const rawVal = flatGet(env, 'user'); + expect(rawVal).toBeDefined(); + // Either already unwrapped by AST, or stored as raw text for stripNullable + expect(stripNullable(rawVal!)).toBe('User'); + }); }); });