diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 62cb79712..0babece3d 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -317,11 +317,12 @@ export const buildTypeEnv = ( const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map, 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}`)) { diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/Repo.cs new file mode 100644 index 000000000..ff84d1899 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/Repo.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class Repo +{ + public bool Save() { return false; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/User.cs new file mode 100644 index 000000000..13aff18b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Models/User.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class User +{ + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Program.cs new file mode 100644 index 000000000..9f0e1dc6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/Program.cs @@ -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; + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/SwitchPattern.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/SwitchPattern.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-switch-pattern/SwitchPattern.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/Repo.cs new file mode 100644 index 000000000..ff84d1899 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/Repo.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class Repo +{ + public bool Save() { return false; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/User.cs new file mode 100644 index 000000000..13aff18b3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Models/User.cs @@ -0,0 +1,6 @@ +namespace Models; + +public class User +{ + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Program.cs new file mode 100644 index 000000000..412c40013 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/Program.cs @@ -0,0 +1,29 @@ +using Models; +using System.Collections.Generic; + +namespace App; + +public class AppService +{ + public void ProcessUsers(List users) + { + foreach (var user in users) + { + user.Save(); + } + } + + public void ProcessRepos(List repos) + { + foreach (var repo in repos) + { + repo.Save(); + } + } + + public void Direct(User u, Repo r) + { + u.Save(); + r.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/VarForeach.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/VarForeach.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-var-foreach/VarForeach.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/go-map-range/main.go b/gitnexus/test/fixtures/lang-resolution/go-map-range/main.go new file mode 100644 index 000000000..344206dcd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-map-range/main.go @@ -0,0 +1,9 @@ +package main + +import "models" + +func processMap(userMap map[string]models.User) { + for _, user := range userMap { + user.Save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-map-range/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-map-range/models/repo.go new file mode 100644 index 000000000..ad306c835 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-map-range/models/repo.go @@ -0,0 +1,7 @@ +package models + +type Repo struct { + Path string +} + +func (r Repo) Save() {} diff --git a/gitnexus/test/fixtures/lang-resolution/go-map-range/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-map-range/models/user.go new file mode 100644 index 000000000..0c7df99e9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-map-range/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct { + Name string +} + +func (u User) Save() {} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/app.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/app.kt new file mode 100644 index 000000000..fd31c8bbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/app.kt @@ -0,0 +1,16 @@ +package app + +import models.User +import models.Repo + +fun processUsers(users: List) { + for (user in users) { + user.save() + } +} + +fun processRepos(repos: List) { + for (repo in repos) { + repo.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/Repo.kt new file mode 100644 index 000000000..8e31ab2a0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/User.kt new file mode 100644 index 000000000..494376e42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-var-foreach/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/main.rs new file mode 100644 index 000000000..b09908fe2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/main.rs @@ -0,0 +1,18 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process_users(users: Vec) { + for user in &users { + user.save(); + } +} + +fn process_repos(repos: Vec) { + for repo in &repos { + repo.save(); + } +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/repo.rs new file mode 100644 index 000000000..bc1ad22b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/user.rs new file mode 100644 index 000000000..706b24558 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-for-loop/src/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/main.rs new file mode 100644 index 000000000..7c40df44b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/main.rs @@ -0,0 +1,19 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process(opt: Option) { + match opt { + Some(user) => user.save(), + None => {}, + } +} + +fn check(res: Result) { + if let Ok(repo) = res { + repo.save(); + } +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/repo.rs new file mode 100644 index 000000000..bc1ad22b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/repo.rs @@ -0,0 +1,7 @@ +pub struct Repo { + pub name: String, +} + +impl Repo { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/user.rs new file mode 100644 index 000000000..706b24558 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-match-unwrap/src/user.rs @@ -0,0 +1,7 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/app.ts new file mode 100644 index 000000000..a7a0ea41a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/app.ts @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/repo.ts new file mode 100644 index 000000000..ed84b12ff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/user.ts new file mode 100644 index 000000000..a464054e0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-readonly-foreach/models/user.ts @@ -0,0 +1,3 @@ +export class User { + save() {} +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 6f2db2b24..6e9f79e5d 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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 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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 1b33977c3..b652815da 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 354234195..8fd6ee0ea 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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 +// --------------------------------------------------------------------------- + +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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 98ce66682..354e16361 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -1069,3 +1069,111 @@ describe('Rust chained method call resolution', () => { expect(repoSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Rust for-loop Tier 1c: for user in &users with Vec 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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 04e19b96c..7da8690d1 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -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(); + }); +});