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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Models;
|
||||
|
||||
public class Repo
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public bool Save() { return true; }
|
||||
}
|
||||
+7
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user