feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression

- Add integration test fixtures for Java, C#, Go, Kotlin, PHP (alongside existing TS)
- Fix Go: add type_declaration handling in findEnclosingClassId for struct fields
  (field_declaration → field_declaration_list → struct_type → type_spec → type_declaration)
- Fix Kotlin: add navigation_expression handling in field-access resolution
  (Kotlin uses navigation_expression + navigation_suffix, not member_expression)
- Add extractMemberAccessParts helper in call-processor for cross-language member access
- All 24 field-type tests pass across 6 languages, 181 Go+Kotlin tests pass with no regressions
This commit is contained in:
Gergo Magyar 2026-03-18 09:51:24 +00:00
parent 69a611e0f5
commit 9681fa20da
16 changed files with 472 additions and 29 deletions

View file

@ -276,26 +276,17 @@ export const processCalls = async (
if (callForm === 'member' && !receiverTypeName && !receiverName) {
const receiverNode = extractReceiverNode(nameNode);
if (receiverNode && !CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
// receiverNode is a member_expression — extract object.property
const objectNode = receiverNode.childForFieldName?.('object')
?? receiverNode.childForFieldName?.('value')
?? receiverNode.childForFieldName?.('operand')
?? receiverNode.childForFieldName?.('expression');
const propertyNode = receiverNode.childForFieldName?.('property')
?? receiverNode.childForFieldName?.('field')
?? receiverNode.childForFieldName?.('name');
if (objectNode && propertyNode) {
const objectName = objectNode.text;
const propertyName = propertyNode.text;
const parts = extractMemberAccessParts(receiverNode);
if (parts) {
// Resolve the object's type from TypeEnv
let objectType = typeEnv ? typeEnv.lookup(objectName, callNode) : undefined;
let objectType = typeEnv ? typeEnv.lookup(parts.objectName, callNode) : undefined;
if (!objectType && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
objectType = lookupReceiverType(verifiedReceivers, funcName, objectName);
objectType = lookupReceiverType(verifiedReceivers, funcName, parts.objectName);
}
if (objectType) {
receiverTypeName = resolveFieldAccessType(objectType, propertyName, file.path, ctx);
receiverTypeName = resolveFieldAccessType(objectType, parts.propertyName, file.path, ctx);
}
}
}
@ -612,6 +603,66 @@ const lookupReceiverType = (
*
* @returns The resolved type of the deepest field access, or undefined if resolution fails.
*/
/**
* Extract object and property names from a member-access AST node.
* Handles cross-language AST variations:
* - TS/JS: member_expression with `object`/`property` fields
* - C#: member_access_expression with `expression`/`name` fields
* - Go: selector_expression with `operand`/`field` fields
* - Rust/C++: field_expression with `value`/`field` fields
* - Kotlin: navigation_expression with first child as object and navigation_suffix child containing property
* - Python: attribute with `object`/`attribute` fields
*/
const extractMemberAccessParts = (
node: any,
): { objectName: string; propertyName: string } | undefined => {
// Kotlin/Swift: navigation_expression — object is first child, property is inside navigation_suffix
if (node.type === 'navigation_expression') {
let objectNode: any = null;
let propertyNode: any = null;
for (const child of node.children ?? []) {
if (child.type === 'navigation_suffix') {
// The property identifier is inside the suffix
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') {
propertyNode = sc;
break;
}
}
} else if (child.isNamed && !objectNode) {
objectNode = child;
}
}
if (objectNode && propertyNode) {
return { objectName: objectNode.text, propertyName: propertyNode.text };
}
return undefined;
}
// Python: attribute node — `object` and `attribute` fields
if (node.type === 'attribute') {
const objectNode = node.childForFieldName?.('object');
const attrNode = node.childForFieldName?.('attribute');
if (objectNode && attrNode) {
return { objectName: objectNode.text, propertyName: attrNode.text };
}
return undefined;
}
// General: try standard field names used across grammars
const objectNode = node.childForFieldName?.('object')
?? node.childForFieldName?.('value')
?? node.childForFieldName?.('operand')
?? node.childForFieldName?.('expression');
const propertyNode = node.childForFieldName?.('property')
?? node.childForFieldName?.('field')
?? node.childForFieldName?.('name');
if (objectNode && propertyNode) {
return { objectName: objectNode.text, propertyName: propertyNode.text };
}
return undefined;
};
const resolveFieldAccessType = (
receiverName: string,
fieldName: string,

View file

@ -318,6 +318,21 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
if (typeSpec) {
const typeBody = typeSpec.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
if (current.type === 'impl_item') {

View file

@ -1097,16 +1097,34 @@ const processFileGroup = (
} else if (receiverNode) {
// Receiver is a member_expression (field access like user.address.save()).
// Extract object and property so processCallsFromExtracted can resolve the field type.
const objectNode = receiverNode.childForFieldName?.('object')
?? receiverNode.childForFieldName?.('value')
?? receiverNode.childForFieldName?.('operand')
?? receiverNode.childForFieldName?.('expression');
const propertyNode = receiverNode.childForFieldName?.('property')
?? receiverNode.childForFieldName?.('field')
?? receiverNode.childForFieldName?.('name');
if (objectNode && propertyNode) {
const objectName = objectNode.text;
const fieldName = propertyNode.text;
let objectName: string | undefined;
let fieldName: string | undefined;
// Kotlin/Swift: navigation_expression — object is first child, property inside navigation_suffix
if (receiverNode.type === 'navigation_expression') {
for (const child of receiverNode.children ?? []) {
if (child.type === 'navigation_suffix') {
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') { fieldName = sc.text; break; }
}
} else if (child.isNamed && !objectName) {
objectName = child.text;
}
}
} else {
// General: try standard field names used across grammars
const objectNode = receiverNode.childForFieldName?.('object')
?? receiverNode.childForFieldName?.('value')
?? receiverNode.childForFieldName?.('operand')
?? receiverNode.childForFieldName?.('expression');
const propertyNode = receiverNode.childForFieldName?.('property')
?? receiverNode.childForFieldName?.('field')
?? receiverNode.childForFieldName?.('name');
if (objectNode) objectName = objectNode.text;
if (propertyNode) fieldName = propertyNode.text;
}
if (objectName && fieldName) {
receiverFieldAccess = { objectName, fieldName };
// Try resolving the object's type immediately from TypeEnv
const objectType = typeEnv.lookup(objectName, callNode);

View file

@ -0,0 +1,22 @@
namespace FieldTypes;
public class Address
{
public string City { get; set; }
public void Save()
{
// persist address
}
}
public class User
{
public string Name { get; set; }
public Address Address { get; set; }
public string Greet()
{
return Name;
}
}

View file

@ -0,0 +1,10 @@
namespace FieldTypes;
public class Service
{
public static void ProcessUser(User user)
{
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save();
}
}

View file

@ -0,0 +1,8 @@
package main
import "example.com/go-field-types/models"
func processUser(user models.User) {
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save()
}

View file

@ -0,0 +1,3 @@
module example.com/go-field-types
go 1.21

View file

@ -0,0 +1,18 @@
package models
type Address struct {
City string
}
func (a *Address) Save() bool {
return true
}
type User struct {
Name string
Address Address
}
func (u *User) Greet() string {
return u.Name
}

View file

@ -0,0 +1,8 @@
import models.User;
public class App {
public static void processUser(User user) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save();
}
}

View file

@ -0,0 +1,9 @@
package models;
public class Address {
public String city;
public void save() {
// persist address
}
}

View file

@ -0,0 +1,10 @@
package models;
public class User {
public String name;
public Address address;
public String greet() {
return this.name;
}
}

View file

@ -0,0 +1,16 @@
class Address {
var city: String = ""
fun save() {
// persist address
}
}
class User {
var name: String = ""
var address: Address = Address()
fun greet(): String {
return name
}
}

View file

@ -0,0 +1,4 @@
fun processUser(user: User) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save()
}

View file

@ -0,0 +1,22 @@
<?php
class Address {
/** @var string */
public string $city;
public function save(): void {
// persist address
}
}
class User {
/** @var string */
public string $name;
/** @var Address */
public Address $address;
public function greet(): string {
return $this->name;
}
}

View file

@ -0,0 +1,8 @@
<?php
class Service {
public function processUser(User $user): void {
// Field-access chain: $user->address → Address, then ->save() → Address#save
$user->address->save();
}
}

View file

@ -1,6 +1,11 @@
/**
* Phase 8: Field/property type resolution — verifies that chained member access
* through typed fields resolves correctly (e.g. user.address.save() → Address#save).
*
* Per-language fixtures test:
* 1. Property nodes are extracted with correct ownerId linkage
* 2. HAS_METHOD edges link properties to their owning classes
* 3. Field-access chain resolution resolves user.address.save() → Address#save
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
@ -9,6 +14,10 @@ import {
runPipelineFromRepo, type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// TypeScript
// ---------------------------------------------------------------------------
describe('Field type resolution (TypeScript)', () => {
let result: PipelineResult;
@ -25,8 +34,6 @@ describe('Field type resolution (TypeScript)', () => {
it('detects Property nodes for typed fields', () => {
const properties = getNodesByLabel(result, 'Property');
// Should capture: address, city, name (from User + Address classes)
// DEFAULT is static and may or may not be captured depending on tree-sitter query
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
@ -35,7 +42,6 @@ describe('Field type resolution (TypeScript)', () => {
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
// At minimum: User.address, User.name, Address.city
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
@ -44,11 +50,226 @@ describe('Field type resolution (TypeScript)', () => {
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
// processUser should call save() with receiver type Address
const saveCalls = calls.filter(e => e.target === 'save');
// The save method belongs to Address, so the target should be Address's save
const addressSave = saveCalls.find(e => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
expect(addressSave!.source).toBe('processUser');
});
});
// ---------------------------------------------------------------------------
// Java
// ---------------------------------------------------------------------------
describe('Field type resolution (Java)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'java-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, App, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'User']);
});
it('detects Property nodes for Java fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('Address → city');
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Address'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// C#
// ---------------------------------------------------------------------------
describe('Field type resolution (C#)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'csharp-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for C# properties', () => {
const properties = getNodesByLabel(result, 'Property');
// C# property_declaration already captured before Phase 8
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → Address');
expect(edgeSet(propEdges)).toContain('User → Name');
expect(edgeSet(propEdges)).toContain('Address → City');
});
it('resolves user.Address.Save() → Address#Save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'ProcessUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Go
// ---------------------------------------------------------------------------
describe('Field type resolution (Go)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-field-types'),
() => {},
);
}, 60000);
it('detects structs: Address, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Go struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('Address');
expect(properties).toContain('Name');
expect(properties).toContain('City');
});
it('emits HAS_METHOD edges linking struct fields to structs', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
expect(propEdges.length).toBeGreaterThanOrEqual(2);
});
it('resolves user.Address.Save() → Address#Save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'Save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Kotlin
// ---------------------------------------------------------------------------
describe('Field type resolution (Kotlin)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'kotlin-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Kotlin properties', () => {
const properties = getNodesByLabel(result, 'Property');
// Kotlin property_declaration was already captured pre-Phase 8
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('Address → city');
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// PHP
// ---------------------------------------------------------------------------
describe('Field type resolution (PHP)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'php-field-types'),
() => {},
);
}, 60000);
it('detects classes: Address, Service, User', () => {
expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']);
});
it('detects Property nodes for PHP properties', () => {
const properties = getNodesByLabel(result, 'Property');
// PHP property_declaration was already captured pre-Phase 8
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_METHOD edges linking properties to classes', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const propEdges = hasMethod.filter(e => e.targetLabel === 'Property');
expect(propEdges.length).toBeGreaterThanOrEqual(3);
});
it('resolves $user->address->save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter(e => e.target === 'save');
const addressSave = saveCalls.find(
e => e.source === 'processUser' && e.targetFilePath.includes('Models'),
);
expect(addressSave).toBeDefined();
});
});