mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
e091c6489e
commit
98daed4414
18 changed files with 525 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ export type {
|
|||
TypeBindingExtractor,
|
||||
ParameterExtractor,
|
||||
ConstructorBindingScanner,
|
||||
ForLoopExtractor
|
||||
ForLoopExtractor,
|
||||
PendingAssignmentExtractor,
|
||||
} from './types.js';
|
||||
export {
|
||||
TYPED_PARAMETER_TYPES,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/app.py
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/app.py
vendored
Normal 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()
|
||||
3
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/repo.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/repo.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class Repo:
|
||||
def save(self) -> bool:
|
||||
return False
|
||||
3
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/user.py
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/python-nullable-chain/user.py
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
class User:
|
||||
def save(self) -> bool:
|
||||
return True
|
||||
22
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/app.ts
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/app.ts
vendored
Normal 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();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/repo.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/repo.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Repo {
|
||||
save(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/user.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-multi-hop-chain/src/user.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class User {
|
||||
save(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
27
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/app.ts
vendored
Normal file
27
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/app.ts
vendored
Normal 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();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/repo.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/repo.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class Repo {
|
||||
save(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/user.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/ts-nullable-chain/src/user.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export class User {
|
||||
save(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue