feat: Phase 6.1 type resolution gap closure — container descriptors, recursive_pattern, class fields

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.
This commit is contained in:
Gergo Magyar
2026-03-17 12:25:36 +00:00
parent 3c5a62982d
commit f092b33e40
13 changed files with 370 additions and 9 deletions
@@ -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,
@@ -75,6 +75,18 @@ const CONTAINER_DESCRIPTORS: ReadonlyMap<string, ContainerDescriptor> = 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
@@ -7,6 +7,7 @@ const DECLARATION_NODE_TYPES: ReadonlySet<string> = 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<str
return;
}
// Class field: `private users: User[]` — public_field_definition has name + type fields directly
// The name child is a property_identifier (not handled by extractVarName), so we read .text directly.
if (node.type === 'public_field_definition') {
const nameNode = node.childForFieldName('name');
const typeAnnotation = node.childForFieldName('type');
if (!nameNode || !typeAnnotation) return;
const varName = nameNode.text;
if (!varName) return;
const typeName = extractSimpleTypeName(typeAnnotation);
if (typeName) env.set(varName, typeName);
return;
}
for (let i = 0; i < node.namedChildCount; i++) {
const declarator = node.namedChild(i);
if (declarator?.type !== 'variable_declarator') continue;
@@ -350,11 +364,18 @@ const extractForLoopBinding: ForLoopExtractor = (
if (prop) iterableName = prop.text;
} else if (rightNode?.type === 'call_expression') {
// entries.values() → call_expression > 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;
}
}
@@ -0,0 +1,7 @@
namespace Models;
public class Repo
{
public string Name { get; set; } = "";
public bool Save() { return true; }
}
@@ -0,0 +1,7 @@
namespace Models;
public class User
{
public string Name { get; set; } = "";
public bool Save() { return true; }
}
@@ -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
};
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,19 @@
import { User } from './models/user';
import { Repo } from './models/repo';
class UserService {
private users: User[] = [];
private repos: Map<string, Repo> = new Map();
processUsers() {
for (const user of this.users) {
user.save();
}
}
processRepos() {
for (const repo of this.repos.values()) {
repo.save();
}
}
}
@@ -0,0 +1,3 @@
export class Repo {
save() {}
}
@@ -0,0 +1,3 @@
export class User {
save() {}
}
@@ -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);
});
});
@@ -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<string, Repo>.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();
});
});
+163
View File
@@ -3327,6 +3327,169 @@ class App {
});
});
describe('new container descriptors (Phase 6.1)', () => {
it('Collection<User> resolves element type via descriptor (arity 1)', () => {
const tree = parse(`
using System.Collections.ObjectModel;
public class App {
public void Process(Collection<User> users) {
foreach (var user in users) {
user.Save();
}
}
}
`, CSharp);
const { env } = buildTypeEnv(tree, 'csharp');
expect(flatGet(env, 'user')).toBe('User');
});
it('MutableMap<String, User>.values() resolves to User via descriptor (arity 2)', () => {
const tree = parse(`
fun process(data: MutableMap<String, User>) {
for (user in data.values()) {
user.save()
}
}
`, Kotlin);
const { env } = buildTypeEnv(tree, 'kotlin');
expect(flatGet(env, 'user')).toBe('User');
});
it('MutableList<User> resolves element type via descriptor', () => {
const tree = parse(`
fun process(users: MutableList<User>) {
for (user in users) {
user.save()
}
}
`, Kotlin);
const { env } = buildTypeEnv(tree, 'kotlin');
expect(flatGet(env, 'user')).toBe('User');
});
it('SortedSet<User> resolves element type via descriptor (C#)', () => {
const tree = parse(`
using System.Collections.Generic;
public class App {
public void Process(SortedSet<User> users) {
foreach (var user in users) {
user.Save();
}
}
}
`, CSharp);
const { env } = buildTypeEnv(tree, 'csharp');
expect(flatGet(env, 'user')).toBe('User');
});
it('Stream<User> resolves element type via descriptor (Java)', () => {
const tree = parse(`
class App {
void process(Stream<User> 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<User> 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<string, Repo> = 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(`