From f092b33e40df1e6feed36e256b18afe0dbf33453 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 17 Mar 2026 12:25:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206.1=20type=20resolution=20gap?= =?UTF-8?q?=20closure=20=E2=80=94=20container=20descriptors,=20recursive?= =?UTF-8?q?=5Fpattern,=20class=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 13 missing container type descriptors (Collection, MutableMap, Stream, SortedSet, etc.) to CONTAINER_DESCRIPTORS for correct element type extraction across C#, Kotlin, and Java. Extend C# pattern binding to handle recursive_pattern (obj is User { Name: "Alice" } u) in both is-expression and switch expression contexts. Add TypeScript class field declaration support (public_field_definition) so for-loop iteration over this.fieldName resolves element types from class field type annotations. Includes file-scope fallback in resolveIterableElementType and nested member_expression handling for this.field.method() patterns. --- .../core/ingestion/type-extractors/csharp.ts | 11 +- .../core/ingestion/type-extractors/shared.ts | 19 +- .../ingestion/type-extractors/typescript.ts | 23 ++- .../csharp-recursive-pattern/Models/Repo.cs | 7 + .../csharp-recursive-pattern/Models/User.cs | 7 + .../csharp-recursive-pattern/Program.cs | 20 +++ .../RecursivePatternProj.csproj | 5 + .../ts-class-field-foreach/app.ts | 19 ++ .../ts-class-field-foreach/models/repo.ts | 3 + .../ts-class-field-foreach/models/user.ts | 3 + .../test/integration/resolvers/csharp.test.ts | 46 +++++ .../integration/resolvers/typescript.test.ts | 53 ++++++ gitnexus/test/unit/type-env.test.ts | 163 ++++++++++++++++++ 13 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Program.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/RecursivePatternProj.csproj create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/user.ts diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index fb777ecb3..9b9e87a84 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -270,7 +270,7 @@ const extractPatternBinding: PatternBindingExtractor = (node) => { // is_pattern_expression: `obj is User user` — has a declaration_pattern child if (node.type === 'is_pattern_expression') { const pattern = node.childForFieldName('pattern'); - if (pattern?.type !== 'declaration_pattern') return undefined; + if (pattern?.type !== 'declaration_pattern' && pattern?.type !== 'recursive_pattern') return undefined; const typeNode = pattern.childForFieldName('type'); const nameNode = pattern.childForFieldName('name'); if (!typeNode || !nameNode) return undefined; @@ -279,9 +279,10 @@ const extractPatternBinding: PatternBindingExtractor = (node) => { if (!typeName || !varName) return undefined; return { varName, typeName }; } - // declaration_pattern: standalone in switch statements and switch expressions - // `case User u:` or `User u =>` — the declaration_pattern is a direct child - if (node.type === 'declaration_pattern') { + // declaration_pattern / recursive_pattern: standalone in switch statements and switch expressions + // `case User u:` or `User u =>` or `User { Name: "Alice" } u =>` + // Both use the same 'type' and 'name' fields. + if (node.type === 'declaration_pattern' || node.type === 'recursive_pattern') { const typeNode = node.childForFieldName('type'); const nameNode = node.childForFieldName('name'); if (!typeNode || !nameNode) return undefined; @@ -321,7 +322,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, forLoopNodeTypes: FOR_LOOP_NODE_TYPES, - patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern']), + patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern']), extractDeclaration, extractParameter, scanConstructorBinding, diff --git a/gitnexus/src/core/ingestion/type-extractors/shared.ts b/gitnexus/src/core/ingestion/type-extractors/shared.ts index 277eb39dc..e79b10b83 100644 --- a/gitnexus/src/core/ingestion/type-extractors/shared.ts +++ b/gitnexus/src/core/ingestion/type-extractors/shared.ts @@ -75,6 +75,18 @@ const CONTAINER_DESCRIPTORS: ReadonlyMap = new Map( ['Iterator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], ['IEnumerable', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], ['IList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['ICollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['Collection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['ObservableCollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['IEnumerator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['SortedSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['Stream', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['MutableList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['MutableSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['LinkedHashSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['ArrayDeque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['PriorityQueue', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], + ['MutableMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }], ['list', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], ['set', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], ['tuple', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }], @@ -136,8 +148,9 @@ export function resolveIterableElementType( findParamElementType?: (name: string, startNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined, typeArgPos: TypeArgPosition = 'last', ): string | undefined { - // Strategy 1: declarationTypeNodes AST node - const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`); + // Strategy 1: declarationTypeNodes AST node (check current scope, then file scope) + const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`) + ?? (scope !== '' ? declarationTypeNodes.get(`\0${iterableName}`) : undefined); if (typeNode) { const t = extractFromTypeNode(typeNode, typeArgPos); if (t) return t; @@ -274,7 +287,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string | export const extractVarName = (node: SyntaxNode): string | undefined => { if (node.type === 'identifier' || node.type === 'simple_identifier' || node.type === 'variable_name' || node.type === 'name' - || node.type === 'constant') { + || node.type === 'constant' || node.type === 'property_identifier') { return node.text; } // variable_declarator (Java/C#): has a 'name' field diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 83c797b04..f7ebde477 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -7,6 +7,7 @@ const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'variable_declaration', 'function_declaration', // JSDoc @param on function declarations 'method_definition', // JSDoc @param on class methods + 'public_field_definition', // class field: private users: User[] ]); const normalizeJsDocType = (raw: string): string | undefined => { @@ -80,6 +81,19 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map function: member_expression > object + property + // this.repos.values() → nested member_expression: extract property from inner member const fn = rightNode.childForFieldName('function'); if (fn?.type === 'member_expression') { const obj = fn.childForFieldName('object'); const prop = fn.childForFieldName('property'); - if (obj?.type === 'identifier') iterableName = obj.text; + if (obj?.type === 'identifier') { + iterableName = obj.text; + } else if (obj?.type === 'member_expression') { + // this.repos.values() → obj = this.repos → extract 'repos' + const innerProp = obj.childForFieldName('property'); + if (innerProp) iterableName = innerProp.text; + } if (prop?.type === 'property_identifier') methodName = prop.text; } } diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/Repo.cs new file mode 100644 index 000000000..aa05916b1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/Repo.cs @@ -0,0 +1,7 @@ +namespace Models; + +public class Repo +{ + public string Name { get; set; } = ""; + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/User.cs new file mode 100644 index 000000000..77ec47364 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Models/User.cs @@ -0,0 +1,7 @@ +namespace Models; + +public class User +{ + public string Name { get; set; } = ""; + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Program.cs b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Program.cs new file mode 100644 index 000000000..429418670 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/Program.cs @@ -0,0 +1,20 @@ +using Models; + +namespace App; + +public class AppService +{ + public void ProcessWithRecursivePattern(object obj) + { + if (obj is User { Name: "Alice" } u) + { + u.Save(); + } + + var result = obj switch + { + Repo { Name: "main" } r => r.Save(), + _ => false + }; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/RecursivePatternProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/RecursivePatternProj.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-recursive-pattern/RecursivePatternProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/app.ts new file mode 100644 index 000000000..c1d029e2c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/app.ts @@ -0,0 +1,19 @@ +import { User } from './models/user'; +import { Repo } from './models/repo'; + +class UserService { + private users: User[] = []; + private repos: Map = new Map(); + + processUsers() { + for (const user of this.users) { + user.save(); + } + } + + processRepos() { + for (const repo of this.repos.values()) { + repo.save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/repo.ts new file mode 100644 index 000000000..ed84b12ff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-class-field-foreach/models/user.ts new file mode 100644 index 000000000..a464054e0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-class-field-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 2a837873d..59a93ca99 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1112,3 +1112,49 @@ describe('C# Dictionary .Values foreach resolution', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// C# recursive_pattern: obj is User { Name: "Alice" } u — Phase 6.1 +// --------------------------------------------------------------------------- + +describe('C# recursive_pattern type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-recursive-pattern'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with Save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + }); + + it('resolves u.Save() via recursive_pattern is-expression to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'Save' && c.targetFilePath?.includes('User'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves r.Save() via recursive_pattern switch expression to Repo#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'Save' && c.targetFilePath?.includes('Repo'), + ); + expect(repoSave).toBeDefined(); + }); + + it('resolves exactly one Save call per target class (no cross-resolution)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save' && c.source === 'ProcessWithRecursivePattern'); + const toUser = saveCalls.filter(c => c.targetFilePath?.includes('User')); + const toRepo = saveCalls.filter(c => c.targetFilePath?.includes('Repo')); + // u.Save() → User#Save only, r.Save() → Repo#Save only + expect(toUser.length).toBe(1); + expect(toRepo.length).toBe(1); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index a638dfe6e..3fc49fbcb 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -1586,3 +1586,56 @@ describe('TypeScript member access iterable for-loop', () => { expect(repoSave).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// TypeScript class field foreach: for (const user of this.users) with class field User[] +// --------------------------------------------------------------------------- + +describe('TypeScript class field foreach resolution (Phase 6.1)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-class-field-foreach'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() via class field User[] 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('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(); + }); + + it('resolves repo.save() via class field Map.values() 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 repo.save() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const wrong = calls.find(c => + c.target === 'save' && c.source === 'processRepos' && c.targetFilePath?.includes('user'), + ); + expect(wrong).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 0201e4792..030534057 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -3327,6 +3327,169 @@ class App { }); }); + describe('new container descriptors (Phase 6.1)', () => { + it('Collection resolves element type via descriptor (arity 1)', () => { + const tree = parse(` +using System.Collections.ObjectModel; +public class App { + public void Process(Collection users) { + foreach (var user in users) { + user.Save(); + } + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('MutableMap.values() resolves to User via descriptor (arity 2)', () => { + const tree = parse(` +fun process(data: MutableMap) { + for (user in data.values()) { + user.save() + } +} + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('MutableList resolves element type via descriptor', () => { + const tree = parse(` +fun process(users: MutableList) { + for (user in users) { + user.save() + } +} + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('SortedSet resolves element type via descriptor (C#)', () => { + const tree = parse(` +using System.Collections.Generic; +public class App { + public void Process(SortedSet users) { + foreach (var user in users) { + user.Save(); + } + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('Stream resolves element type via descriptor (Java)', () => { + const tree = parse(` +class App { + void process(Stream users) { + for (User user : users.toList()) { + user.save(); + } + } +} + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('C# recursive_pattern binding (Phase 6.1)', () => { + it('obj is User { Name: "Alice" } u — binds u to User', () => { + const tree = parse(` +public class App { + public void Process(object obj) { + if (obj is User { Name: "Alice" } u) { + u.Save(); + } + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'u')).toBe('User'); + }); + + it('switch expression with recursive_pattern — binds r to Repo', () => { + const tree = parse(` +public class App { + public void Process(object obj) { + var result = obj switch { + Repo { Name: "main" } r => r.Save(), + _ => false + }; + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'r')).toBe('Repo'); + }); + + it('recursive_pattern without designation — no pattern binding produced', () => { + const tree = parse(` +public class App { + public void Process(object obj) { + if (obj is User { Name: "Alice" }) { + } + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + // obj → object from the parameter, but no pattern binding + expect(flatGet(env, 'obj')).toBe('object'); + expect(flatSize(env)).toBe(1); // only the parameter binding + }); + }); + + describe('C# await foreach (Phase 6.1)', () => { + it('await foreach (var user in users) — same node type as foreach, resolves element type', () => { + const tree = parse(` +using System.Collections.Generic; +public class App { + public async Task Process(IAsyncEnumerable users) { + await foreach (var user in users) { + user.Save(); + } + } +} + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('TypeScript class field declaration (Phase 6.1)', () => { + it('class field with array type — for-loop resolves element type via declarationTypeNodes', () => { + const tree = parse(` +class UserService { + private users: User[] = []; + processUsers() { + for (const user of this.users) { + user.save(); + } + } +} + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // User[] is an array_type — extractSimpleTypeName returns undefined (no simple base name). + // But declarationTypeNodes captures the raw AST node, so for-loop resolution + // uses Strategy 1 (extractTsElementTypeFromAnnotation) to resolve the element type. + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('class field with generic type annotation — binds field name to base type', () => { + const tree = parse(` +class RepoService { + repos: Map = new Map(); +} + `, TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'repos')).toBe('Map'); + }); + }); + describe('match arm scoping — first-writer-wins regression', () => { it('Rust: first match arm binding wins, later arms do not overwrite', () => { const tree = parse(`