From 9681fa20da5e97441188db9563db5a4a4445fe8a Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 18 Mar 2026 09:51:24 +0000 Subject: [PATCH] feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- gitnexus/src/core/ingestion/call-processor.ts | 79 ++++-- gitnexus/src/core/ingestion/utils.ts | 15 ++ .../core/ingestion/workers/parse-worker.ts | 38 ++- .../csharp-field-types/Models.cs | 22 ++ .../csharp-field-types/Service.cs | 10 + .../go-field-types/cmd/main.go | 8 + .../lang-resolution/go-field-types/go.mod | 3 + .../go-field-types/models/models.go | 18 ++ .../lang-resolution/java-field-types/App.java | 8 + .../java-field-types/models/Address.java | 9 + .../java-field-types/models/User.java | 10 + .../kotlin-field-types/Models.kt | 16 ++ .../kotlin-field-types/Service.kt | 4 + .../php-field-types/Models.php | 22 ++ .../php-field-types/Service.php | 8 + .../integration/resolvers/field-types.test.ts | 231 +++++++++++++++++- 16 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-field-types/Models.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-field-types/Service.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/go-field-types/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-field-types/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-field-types/models/models.go create mode 100644 gitnexus/test/fixtures/lang-resolution/java-field-types/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-field-types/models/Address.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-field-types/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Models.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Service.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/php-field-types/Models.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-field-types/Service.php diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index e306c25cc..11830b233 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 53e2333ed..b1762d532 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -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') { diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ab0a9799e..9100ccec4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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); diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Models.cs b/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Models.cs new file mode 100644 index 000000000..191c7624a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Models.cs @@ -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; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Service.cs b/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Service.cs new file mode 100644 index 000000000..48e01d710 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-field-types/Service.cs @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-field-types/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-field-types/cmd/main.go new file mode 100644 index 000000000..50b64f816 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-field-types/cmd/main.go @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-field-types/go.mod b/gitnexus/test/fixtures/lang-resolution/go-field-types/go.mod new file mode 100644 index 000000000..86923ee6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-field-types/go.mod @@ -0,0 +1,3 @@ +module example.com/go-field-types + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-field-types/models/models.go b/gitnexus/test/fixtures/lang-resolution/go-field-types/models/models.go new file mode 100644 index 000000000..e769f2bbf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-field-types/models/models.go @@ -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 +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-field-types/App.java b/gitnexus/test/fixtures/lang-resolution/java-field-types/App.java new file mode 100644 index 000000000..1ba64e00e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-field-types/App.java @@ -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(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-field-types/models/Address.java b/gitnexus/test/fixtures/lang-resolution/java-field-types/models/Address.java new file mode 100644 index 000000000..40107bbf1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-field-types/models/Address.java @@ -0,0 +1,9 @@ +package models; + +public class Address { + public String city; + + public void save() { + // persist address + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-field-types/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-field-types/models/User.java new file mode 100644 index 000000000..dc3b7e76a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-field-types/models/User.java @@ -0,0 +1,10 @@ +package models; + +public class User { + public String name; + public Address address; + + public String greet() { + return this.name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Models.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Models.kt new file mode 100644 index 000000000..de1190425 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Models.kt @@ -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 + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Service.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Service.kt new file mode 100644 index 000000000..706294126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-field-types/Service.kt @@ -0,0 +1,4 @@ +fun processUser(user: User) { + // Field-access chain: user.address → Address, then .save() → Address#save + user.address.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-field-types/Models.php b/gitnexus/test/fixtures/lang-resolution/php-field-types/Models.php new file mode 100644 index 000000000..cec490ea1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-field-types/Models.php @@ -0,0 +1,22 @@ +name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-field-types/Service.php b/gitnexus/test/fixtures/lang-resolution/php-field-types/Service.php new file mode 100644 index 000000000..5744b4da7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-field-types/Service.php @@ -0,0 +1,8 @@ +address → Address, then ->save() → Address#save + $user->address->save(); + } +} diff --git a/gitnexus/test/integration/resolvers/field-types.test.ts b/gitnexus/test/integration/resolvers/field-types.test.ts index 8e1e20ebb..0217796d1 100644 --- a/gitnexus/test/integration/resolvers/field-types.test.ts +++ b/gitnexus/test/integration/resolvers/field-types.test.ts @@ -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(); + }); +});