fix: code review fixes — DRY nullable keywords, avoid array allocations, clarify depth comment

Addresses findings from 6-agent code review on PR #310:

- Move stripNullable JSDoc to correct position (was orphaned above NULLABLE_KEYWORDS)
- DRY: reuse NULLABLE_KEYWORDS set in pipe-split filter instead of inline strings
- Replace node.children.find() with findChildByType/manual loops in jvm.ts,
  go.ts, csharp.ts to avoid unnecessary array allocations per tree-sitter call
- Clarify "depth-1" comment in type-env.ts: single-pass resolves multi-hop
  chains when forward-declared; reverse-order is depth-1 only
- Annotate extractGenericTypeArgs as Phase 5 infrastructure (zero production callers)
- Re-export PendingAssignmentExtractor from index.ts for API consistency
- Add explicit return undefined in Go extractPendingAssignment
- Remove redundant child.text === '=' check in Kotlin extractor

Test coverage:
- 20 new unit tests: stripNullable edge cases, per-language assignment chains,
  reverse-order depth limitation, nullable lookup resolution
- 15 new integration tests: multi-hop chains (a→b→c), nullable+chain combined
  (User|null + alias), Python User|None through stripNullable path
- 3 new fixtures: ts-multi-hop-chain, ts-nullable-chain, python-nullable-chain
This commit is contained in:
Gergo Magyar 2026-03-16 13:44:43 +00:00
parent e091c6489e
commit 98daed4414
18 changed files with 525 additions and 13 deletions

View file

@ -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;

View file

@ -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 };

View file

@ -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 };
}

View file

@ -38,7 +38,8 @@ export type {
TypeBindingExtractor,
ParameterExtractor,
ConstructorBindingScanner,
ForLoopExtractor
ForLoopExtractor,
PendingAssignmentExtractor,
} from './types.js';
export {
TYPED_PARAMETER_TYPES,

View file

@ -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 };
}

View file

@ -132,6 +132,9 @@ export const TYPED_PARAMETER_TYPES = new Set([
* Extract type arguments from a generic type node.
* e.g., List<User, String> ['User', 'String'], Vec<User> ['User']
*
* Phase 5 infrastructure: not yet wired into production code.
* Will be used for container-type unwrapping (Optional<User> 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

View file

@ -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()

View file

@ -0,0 +1,3 @@
class Repo:
def save(self) -> bool:
return False

View file

@ -0,0 +1,3 @@
class User:
def save(self) -> bool:
return True

View file

@ -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();
}

View file

@ -0,0 +1,5 @@
export class Repo {
save(): boolean {
return false;
}
}

View file

@ -0,0 +1,5 @@
export class User {
save(): boolean {
return true;
}
}

View file

@ -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();
}

View file

@ -0,0 +1,5 @@
export class Repo {
save(): boolean {
return false;
}
}

View file

@ -0,0 +1,5 @@
export class User {
save(): boolean {
return true;
}
}

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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(`<?php
function process() {
$u = new User();
$alias = $u;
}
`, PHP.php);
const { env } = buildTypeEnv(tree, 'php');
expect(flatGet(env, '$u')).toBe('User');
expect(flatGet(env, '$alias')).toBe('User');
});
});
// ── lookupInEnv with nullable stripping ───────────────────────────────
describe('lookup resolves through nullable stripping', () => {
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');
});
});
});