test: integration tests for all Phase 6 language gaps + fix Rust param pattern field

Integration test fixtures and tests (30 new tests, all with exact match + negative):

Rust for-loop (5 tests):
- for user in &users with Vec<User> → User#save, negative Repo#save
- for repo in &repos with Vec<Repo> → Repo#save, negative User#save

Rust match arm (5 tests):
- match opt { Some(user) => user.save() } → User#save, negative Repo#save
- if let Ok(repo) = res → Repo#save, negative User#save

C# var foreach (5 tests):
- foreach (var user in users) with List<User> → User#Save, negative Repo#Save
- foreach (var repo in repos) with List<Repo> → Repo#Save

C# switch pattern (4 tests):
- is User user → User#Save, case Repo repo → Repo#Save

Kotlin unannotated for (4 tests):
- for (user in users) with List<User> → user.save, negative repo.save

Go map range (3 tests):
- for _, user := range userMap with map[string]User → User#Save, negative

TypeScript readonly (4 tests):
- for (const user of users) with readonly User[] → user.save, negative

Bug fix: type-env.ts parameter branch now falls back to childForFieldName('pattern')
for Rust parameters (Rust uses 'pattern' not 'name' for parameter names)
This commit is contained in:
Gergo Magyar 2026-03-16 22:52:11 +00:00
parent caa3310714
commit f1df9a12c1
29 changed files with 552 additions and 3 deletions

View file

@ -317,11 +317,12 @@ export const buildTypeEnv = (
const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map<string, string>, scope: string): void => {
// This guard eliminates 90%+ of calls before any language dispatch.
if (TYPED_PARAMETER_TYPES.has(node.type)) {
// Capture the raw type annotation BEFORE extractParameter — parameters
// consistently expose 'name' and 'type' fields across all languages.
// Capture the raw type annotation BEFORE extractParameter.
// Most languages use 'name' field; Rust uses 'pattern'; TS uses 'pattern' for some param types.
const typeNode = node.childForFieldName('type');
if (typeNode) {
const nameNode = node.childForFieldName('name');
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('pattern');
if (nameNode) {
const varName = extractVarName(nameNode);
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {

View file

@ -0,0 +1,6 @@
namespace Models;
public class Repo
{
public bool Save() { return false; }
}

View file

@ -0,0 +1,6 @@
namespace Models;
public class User
{
public bool Save() { return true; }
}

View file

@ -0,0 +1,21 @@
using Models;
namespace App;
public class AppService
{
public void Process(object obj)
{
if (obj is User user)
{
user.Save();
}
switch (obj)
{
case Repo repo:
repo.Save();
break;
}
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,6 @@
namespace Models;
public class Repo
{
public bool Save() { return false; }
}

View file

@ -0,0 +1,6 @@
namespace Models;
public class User
{
public bool Save() { return true; }
}

View file

@ -0,0 +1,29 @@
using Models;
using System.Collections.Generic;
namespace App;
public class AppService
{
public void ProcessUsers(List<User> users)
{
foreach (var user in users)
{
user.Save();
}
}
public void ProcessRepos(List<Repo> repos)
{
foreach (var repo in repos)
{
repo.Save();
}
}
public void Direct(User u, Repo r)
{
u.Save();
r.Save();
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,9 @@
package main
import "models"
func processMap(userMap map[string]models.User) {
for _, user := range userMap {
user.Save()
}
}

View file

@ -0,0 +1,7 @@
package models
type Repo struct {
Path string
}
func (r Repo) Save() {}

View file

@ -0,0 +1,7 @@
package models
type User struct {
Name string
}
func (u User) Save() {}

View file

@ -0,0 +1,16 @@
package app
import models.User
import models.Repo
fun processUsers(users: List<User>) {
for (user in users) {
user.save()
}
}
fun processRepos(repos: List<Repo>) {
for (repo in repos) {
repo.save()
}
}

View file

@ -0,0 +1,5 @@
package models
class Repo {
fun save() {}
}

View file

@ -0,0 +1,5 @@
package models
class User {
fun save() {}
}

View file

@ -0,0 +1,18 @@
mod user;
mod repo;
use crate::user::User;
use crate::repo::Repo;
fn process_users(users: Vec<User>) {
for user in &users {
user.save();
}
}
fn process_repos(repos: Vec<Repo>) {
for repo in &repos {
repo.save();
}
}
fn main() {}

View file

@ -0,0 +1,7 @@
pub struct Repo {
pub name: String,
}
impl Repo {
pub fn save(&self) {}
}

View file

@ -0,0 +1,7 @@
pub struct User {
pub name: String,
}
impl User {
pub fn save(&self) {}
}

View file

@ -0,0 +1,19 @@
mod user;
mod repo;
use crate::user::User;
use crate::repo::Repo;
fn process(opt: Option<User>) {
match opt {
Some(user) => user.save(),
None => {},
}
}
fn check(res: Result<Repo, String>) {
if let Ok(repo) = res {
repo.save();
}
}
fn main() {}

View file

@ -0,0 +1,7 @@
pub struct Repo {
pub name: String,
}
impl Repo {
pub fn save(&self) {}
}

View file

@ -0,0 +1,7 @@
pub struct User {
pub name: String,
}
impl User {
pub fn save(&self) {}
}

View file

@ -0,0 +1,14 @@
import { User } from './models/user';
import { Repo } from './models/repo';
function processUsers(users: readonly User[]) {
for (const user of users) {
user.save();
}
}
function processRepos(repos: readonly Repo[]) {
for (const repo of repos) {
repo.save();
}
}

View file

@ -0,0 +1,3 @@
export class Repo {
save() {}
}

View file

@ -0,0 +1,3 @@
export class User {
save() {}
}

View file

@ -982,3 +982,98 @@ describe('C# chained method call resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// C# var foreach Tier 1c: foreach (var user in users) with List<User> param
// ---------------------------------------------------------------------------
describe('C# var foreach type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-var-foreach'),
() => {},
);
}, 60000);
it('detects User and Repo classes, both with Save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('detects methods on both classes', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods.filter(m => m === 'Save').length).toBe(2);
expect(methods).toContain('ProcessUsers');
expect(methods).toContain('ProcessRepos');
});
it('resolves direct calls with explicit parameter types (u.Save, r.Save)', () => {
const calls = getRelationships(result, 'CALLS');
const directUserSave = calls.find(c =>
c.target === 'Save' && c.source === 'Direct' && c.targetFilePath?.includes('User.cs'),
);
const directRepoSave = calls.find(c =>
c.target === 'Save' && c.source === 'Direct' && c.targetFilePath?.includes('Repo.cs'),
);
expect(directUserSave).toBeDefined();
expect(directRepoSave).toBeDefined();
});
it('resolves user.Save() in var foreach to User#Save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessUsers' && c.targetFilePath?.includes('User.cs'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.Save() in var foreach to Repo#Save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessRepos' && c.targetFilePath?.includes('Repo.cs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-resolve user.Save() to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
const wrong = calls.find(c =>
c.target === 'Save' && c.source === 'ProcessUsers' && c.targetFilePath?.includes('Repo.cs'),
);
expect(wrong).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// C# switch pattern: switch (obj) { case User user: user.Save(); }
// ---------------------------------------------------------------------------
describe('C# switch pattern type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-switch-pattern'),
() => {},
);
}, 60000);
it('detects User and Repo classes', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves user.Save() via is-pattern to User#Save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'Models/User.cs');
expect(userSave).toBeDefined();
});
it('resolves repo.Save() via switch case pattern to Repo#Save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'Models/Repo.cs');
expect(repoSave).toBeDefined();
});
});

View file

@ -845,3 +845,42 @@ describe('Go chained method call resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Go map range: for _, user := range userMap where map[string]User
// ---------------------------------------------------------------------------
describe('Go map range type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-map-range'),
() => {},
);
}, 60000);
it('detects User and Repo structs with Save methods in separate files', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('Repo');
const methods = getNodesByLabel(result, 'Method');
expect(methods.filter(m => m === 'Save').length).toBe(2);
});
it('resolves user.Save() in map range to User#Save via map_type value', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'Save' && c.source === 'processMap' && c.targetFilePath?.includes('user.go'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.Save() to Repo#Save (negative disambiguation)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'Save' && c.source === 'processMap' && c.targetFilePath?.includes('repo.go'),
);
expect(wrongSave).toBeUndefined();
});
});

View file

@ -869,3 +869,47 @@ describe('Kotlin chained method call resolution (Phase 5 review fix)', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Kotlin unannotated for-loop Tier 1c: for (user in users) with List<User>
// ---------------------------------------------------------------------------
describe('Kotlin unannotated for-loop type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-var-foreach'),
() => {},
);
}, 60000);
it('detects User and Repo classes with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves user.save() in unannotated for to User#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('User.kt'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() in unannotated for to Repo#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('Repo.kt'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-resolve user.save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const wrong = calls.find(c =>
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('Repo.kt'),
);
expect(wrong).toBeUndefined();
});
});

View file

@ -1069,3 +1069,111 @@ describe('Rust chained method call resolution', () => {
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Rust for-loop Tier 1c: for user in &users with Vec<User> parameter
// ---------------------------------------------------------------------------
describe('Rust for-loop type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-for-loop'),
() => {},
);
}, 60000);
it('detects User and Repo structs with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() in for-loop to User#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process_users' && c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves repo.save() in for-loop to Repo#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve repo.save() to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process_repos' && c.targetFilePath?.includes('user.rs'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Rust match arm: match opt { Some(user) => user.save() }
// ---------------------------------------------------------------------------
describe('Rust match arm type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-match-unwrap'),
() => {},
);
}, 60000);
it('detects User and Repo structs with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter(f => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() inside match Some(user) to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() in match to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves repo.save() inside if-let Ok(repo) to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' && c.source === 'check' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve repo.save() in if-let to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(c =>
c.target === 'save' && c.source === 'check' && c.targetFilePath?.includes('user.rs'),
);
expect(wrongSave).toBeUndefined();
});
});

View file

@ -1427,3 +1427,47 @@ describe('TypeScript static class-name chain call resolution (Phase 5 review fix
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// TS readonly User[] for-loop: for (const user of users) with readonly User[]
// ---------------------------------------------------------------------------
describe('TypeScript readonly array for-loop resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ts-readonly-foreach'),
() => {},
);
}, 60000);
it('detects User and Repo classes with save methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Class')).toContain('Repo');
});
it('resolves user.save() in readonly array for-of to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(c =>
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('user'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() in readonly array for-of to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(c =>
c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('repo'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-resolve user.save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const wrong = calls.find(c =>
c.target === 'save' && c.source === 'processUsers' && c.targetFilePath?.includes('repo'),
);
expect(wrong).toBeUndefined();
});
});