From 11a3d0515ca81b960e035dbf8a5cbe3f5fddd0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 18 Mar 2026 18:47:33 +0000 Subject: [PATCH] feat: Phase 8 field/property type resolution (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Phase 8 field/property type resolution — resolve chained member access Add field/property type extraction to the type resolution system so that chained member access like `user.address.save()` resolves the intermediate receiver type (`address → Address`) through Property symbols in SymbolTable. Key changes: - SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index, `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation, P2 exclude Properties from globalIndex to prevent namespace pollution - tree-sitter queries: add `definition.property` for TypeScript, Java, Go - parse-worker: extract declared types for Property nodes via `extractPropertyDeclaredType()`, capture field-access receiver info - call-processor: add `resolveFieldAccessType()` helper and field-access branch in both sequential and worker receiver resolution paths - Integration tests: new field-types test suite verifying end-to-end `user.address.save() → Address#save` resolution * fix: Go tree-sitter query captures field_declaration not field_declaration_list Post-review fix: the Go struct field query incorrectly put @definition.property on field_declaration_list (the list container) instead of field_declaration (the individual field). Also removed unused `language` parameter from extractPropertyDeclaredType. * 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 * refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving the graph schema proper semantic separation between methods and fields. - HAS_METHOD: Method, Constructor, Function (when inside a class) - HAS_PROPERTY: Property nodes (class fields, struct fields, attributes) MRO processor only reads HAS_METHOD — properties correctly excluded from method resolution order. Impact analysis accepts both edge types. Updated 12 files: graph types, schema, tools docs, parse-worker, parsing-processor, call-processor, and 6 test files. * fix(test): update security test to expect 7 VALID_RELATION_TYPES (added HAS_PROPERTY) * test: add unit tests for Phase 8 SymbolTable features (39 tests, up from 19) Cover all new branches: declaredType metadata, Property exclusion from globalIndex, conditional callableIndex invalidation, lookupFieldByOwner (happy path + edge cases), lookupFuzzyCallable filtering, and clear() with fieldByOwner. Fixes branch coverage threshold (21.8% → 23%+). * feat: Phase 8B mixed field+method chain resolution, C++/Rust chain fixes Unify field and method chain resolution into a single `extractMixedChain` walker that handles interleaved patterns like `svc.getUser().address.save()`. Fix C++ chain calls (tree-sitter-cpp `field_expression` uses `argument` not `object`), Rust unit struct instantiation (`let svc = TypeName;`), and add stdlib passthrough for `unwrap()`/`clone()`/`expect()` in chain loops. Key changes: - Replace `receiverCallChain` + `receiverFieldAccess` with unified `receiverMixedChain: MixedChainStep[]` on ExtractedCall - Add `extractMixedChain` in utils.ts (handles both call_expression and field_expression nodes, including C++ `argument` field) - Add `TYPE_PRESERVING_METHODS` set for stdlib identity operations - Add C++ inline method double-indexing guard in parsing-processor.ts and parse-worker.ts - Add Rust unit struct recognition in type-extractors/rust.ts - Split field-types.test.ts into per-language test files - Add ts-mixed-chain fixture and integration tests - Resolve rust.test.ts todo: Option.unwrap().save() now works - Update roadmap: Phases 7+8 complete, Phase 9 is next * fix: Python declaredType extraction and sequential-path property registration - Move @definition.property capture from expression_statement to assignment node in Python queries so Strategy 1 childForFieldName('type') succeeds - Pass item.declaredType through ctx.symbols.add in sequential call-processor path, matching worker path behavior (fixes Ruby YARD declaredType drop) - Add Python chain resolution integration test (user.address.save → Address#save) - Update Rust/Python status in roadmap and system docs to reflect actual coverage * fix: Python/Ruby field type disambiguation and Rust chain test Three fixes from PR #354 third review: 1. Python typed_parameter name extraction: tree-sitter-python's typed_parameter uses positional children for the name, not a named field. TypeEnv and extractParameter now fall back to firstNamedChild. 2. Ruby/Python call-step field resolution: Ruby's AST uses `call` nodes for both property access and method calls. The chain walker now tries resolveFieldAccessType before resolveCallTarget for call steps, so attr_accessor properties resolve via declaredType. 3. Rust chain resolution test: added missing integration test asserting user.address.save() resolves to Address#save. Also splits C/C++ and TS/JS columns in type-resolution-system.md language matrix with footnotes for accuracy. 1062 resolver integration tests passing, 0 failures. * refactor: Phase 8 code review cleanup — extract walkMixedChain, fix MCP agent gaps - Extract duplicated chain resolution loop into shared walkMixedChain() helper, eliminating ~60 lines of copy-pasted code between sequential and worker paths - Add returnType to ResolveResult, removing redundant lookupFuzzy+find per chain step - Fix context() tool to include HAS_METHOD, HAS_PROPERTY, OVERRIDES in queries so agents can discover class members - Fix p.declaredType Cypher example (column doesn't exist) → p.description - Add HAS_METHOD, HAS_PROPERTY, OVERRIDES to schema resource - Document HAS_METHOD/HAS_PROPERTY in impact tool description - Delete dead code extractMemberAccessParts (superseded by extractMixedChain) - Replace any with SyntaxNode on extractPropertyDeclaredType - Add Rust deep-field-chain test (5 tests), Java mixed-chain (4), Go mixed-chain (4) - All 1075 tests pass (13 new, 0 regressions) * refactor: type SymbolDefinition.type as NodeLabel, add O(1) receiver index - Change SymbolDefinition.type from string to NodeLabel union (35 members) across symbol-table.ts, parse-worker.ts, parsing-processor.ts — compiler now enforces correctness at all comparison/assignment sites - Replace O(N*M) linear scan in lookupReceiverType with pre-built ReceiverTypeIndex (Map>) for O(1) lookups with proper ambiguity handling and file-level fallback - All 1075 tests pass, 0 regressions * fix: capture C++ pointer/ref fields, Kotlin data class props, PHP constructor promotion Add tree-sitter query patterns for three previously missed property declaration forms: C++ pointer/reference member fields (Address* addr; Address& ref;), Kotlin primary constructor val/var parameters (data class User(val name: String)), and PHP 8.0+ constructor property promotion (public Address $address). Fix "10 languages" off-by-one in docs (Ruby is single-level only, not deep chain). Update Python feature matrix cell from No* to Yes* after 31b95f0 fix. 11 new integration tests with per-language fixtures verify property capture, HAS_PROPERTY edge emission, and field-access chain resolution. --- gitnexus/src/core/graph/types.ts | 1 + gitnexus/src/core/ingestion/call-processor.ts | 305 +++++++++++------- gitnexus/src/core/ingestion/call-routing.ts | 22 ++ .../src/core/ingestion/parsing-processor.ts | 39 ++- gitnexus/src/core/ingestion/symbol-table.ts | 55 +++- .../src/core/ingestion/tree-sitter-queries.ts | 68 ++++ gitnexus/src/core/ingestion/type-env.ts | 4 +- .../core/ingestion/type-extractors/python.ts | 4 + .../core/ingestion/type-extractors/rust.ts | 9 +- .../core/ingestion/type-extractors/shared.ts | 88 +++++ gitnexus/src/core/ingestion/utils.ts | 155 ++++++++- .../core/ingestion/workers/parse-worker.ts | 88 +++-- gitnexus/src/core/lbug/schema.ts | 2 +- gitnexus/src/mcp/local/local-backend.ts | 6 +- gitnexus/src/mcp/resources.ts | 3 + gitnexus/src/mcp/tools.ts | 13 +- .../lang-resolution/cpp-chain-call/app.cpp | 1 + .../cpp-deep-field-chain/models.h | 30 ++ .../cpp-deep-field-chain/service.cpp | 9 + .../lang-resolution/cpp-field-types/models.h | 20 ++ .../cpp-field-types/service.cpp | 6 + .../cpp-pointer-ref-fields/models.h | 21 ++ .../cpp-pointer-ref-fields/service.cpp | 6 + .../csharp-deep-field-chain/Models.cs | 33 ++ .../csharp-deep-field-chain/Service.cs | 13 + .../csharp-field-types/Models.cs | 22 ++ .../csharp-field-types/Service.cs | 10 + .../lang-resolution/field-types/models.ts | 24 ++ .../lang-resolution/field-types/service.ts | 11 + .../go-deep-field-chain/cmd/main.go | 11 + .../go-deep-field-chain/go.mod | 3 + .../go-deep-field-chain/models/models.go | 27 ++ .../go-field-types/cmd/main.go | 8 + .../lang-resolution/go-field-types/go.mod | 3 + .../go-field-types/models/models.go | 18 ++ .../go-mixed-chain/cmd/main.go | 11 + .../lang-resolution/go-mixed-chain/go.mod | 3 + .../go-mixed-chain/models/models.go | 32 ++ .../java-deep-field-chain/App.java | 11 + .../java-deep-field-chain/models/Address.java | 10 + .../java-deep-field-chain/models/City.java | 9 + .../java-deep-field-chain/models/User.java | 10 + .../lang-resolution/java-field-types/App.java | 8 + .../java-field-types/models/Address.java | 9 + .../java-field-types/models/User.java | 10 + .../lang-resolution/java-mixed-chain/App.java | 12 + .../java-mixed-chain/models/Address.java | 8 + .../java-mixed-chain/models/City.java | 7 + .../java-mixed-chain/models/User.java | 9 + .../services/UserService.java | 9 + .../lang-resolution/js-field-types/models.js | 26 ++ .../lang-resolution/js-field-types/service.js | 9 + .../kotlin-data-class-fields/Models.kt | 13 + .../kotlin-data-class-fields/Service.kt | 4 + .../kotlin-deep-field-chain/Models.kt | 25 ++ .../kotlin-deep-field-chain/Service.kt | 7 + .../kotlin-field-types/Models.kt | 16 + .../kotlin-field-types/Service.kt | 4 + .../Models.php | 20 ++ .../Service.php | 8 + .../php-deep-field-chain/Models.php | 34 ++ .../php-deep-field-chain/Service.php | 11 + .../php-field-types/Models.php | 22 ++ .../php-field-types/Service.php | 8 + .../python-field-type-disambig/address.py | 5 + .../python-field-type-disambig/service.py | 6 + .../python-field-type-disambig/user.py | 8 + .../python-field-types/models.py | 12 + .../python-field-types/service.py | 4 + .../ruby-field-type-disambig/address.rb | 8 + .../ruby-field-type-disambig/service.rb | 8 + .../ruby-field-type-disambig/user.rb | 13 + .../ruby-field-types/models.rb | 20 ++ .../ruby-field-types/service.rb | 7 + .../rust-deep-field-chain/models.rs | 25 ++ .../rust-deep-field-chain/service.rs | 6 + .../rust-field-types/models.rs | 20 ++ .../rust-field-types/service.rs | 5 + .../ts-deep-field-chain/models.ts | 25 ++ .../ts-deep-field-chain/service.ts | 9 + .../ts-field-type-disambig/address.ts | 7 + .../ts-field-type-disambig/service.ts | 7 + .../ts-field-type-disambig/user.ts | 10 + .../lang-resolution/ts-mixed-chain/models.ts | 27 ++ .../lang-resolution/ts-mixed-chain/service.ts | 11 + .../ts-param-property-fields/models.ts | 22 ++ .../ts-param-property-fields/service.ts | 5 + .../test/integration/resolvers/cpp.test.ts | 123 +++++++ .../test/integration/resolvers/csharp.test.ts | 90 ++++++ .../test/integration/resolvers/go.test.ts | 124 +++++++ .../test/integration/resolvers/java.test.ts | 129 ++++++++ .../integration/resolvers/javascript.test.ts | 36 ++- .../test/integration/resolvers/kotlin.test.ts | 132 ++++++++ .../test/integration/resolvers/php.test.ts | 125 +++++++ .../test/integration/resolvers/python.test.ts | 74 +++++ .../test/integration/resolvers/ruby.test.ts | 86 ++++- .../test/integration/resolvers/rust.test.ts | 108 ++++++- .../integration/resolvers/typescript.test.ts | 208 ++++++++++++ gitnexus/test/unit/mro-processor.test.ts | 20 +- gitnexus/test/unit/schema.test.ts | 4 +- gitnexus/test/unit/security.test.ts | 5 +- gitnexus/test/unit/symbol-table.test.ts | 175 +++++++++- type-resolution-roadmap.md | 181 +++++++---- type-resolution-system.md | 44 +-- 104 files changed, 3147 insertions(+), 290 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/models.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-field-types/models.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-field-types/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/models.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/service.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Models.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Service.cs 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/field-types/models.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/field-types/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/models/models.go 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/go-mixed-chain/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-mixed-chain/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-mixed-chain/models/models.go create mode 100644 gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/Address.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/City.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/User.java 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/java-mixed-chain/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/Address.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/City.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-mixed-chain/services/UserService.java create mode 100644 gitnexus/test/fixtures/lang-resolution/js-field-types/models.js create mode 100644 gitnexus/test/fixtures/lang-resolution/js-field-types/service.js create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Models.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Service.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Models.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Service.kt 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-constructor-promotion-fields/Models.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Service.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Models.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Service.php 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 create mode 100644 gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/address.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/service.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-field-types/models.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-field-types/service.py create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/address.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/service.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-field-types/models.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-field-types/service.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/service.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-field-types/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-field-types/service.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/models.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/address.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/models.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/models.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/service.ts diff --git a/gitnexus/src/core/graph/types.ts b/gitnexus/src/core/graph/types.ts index aa9b24538..475fd52b9 100644 --- a/gitnexus/src/core/graph/types.ts +++ b/gitnexus/src/core/graph/types.ts @@ -80,6 +80,7 @@ export type RelationshipType = | 'IMPLEMENTS' | 'EXTENDS' | 'HAS_METHOD' + | 'HAS_PROPERTY' | 'MEMBER_OF' | 'STEP_IN_PROCESS' diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 3e924d46a..471565d01 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -20,15 +20,25 @@ import { extractReceiverNode, findEnclosingClassId, CALL_EXPRESSION_TYPES, - MAX_CHAIN_DEPTH, - extractCallChain, + extractMixedChain, + type MixedChainStep, } from './utils.js'; import { buildTypeEnv } from './type-env.js'; import type { ConstructorBinding } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { callRouters } from './call-routing.js'; -import { extractReturnTypeName } from './type-extractors/shared.js'; +import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; + +// Stdlib methods that preserve the receiver's type identity. When TypeEnv already +// strips nullable wrappers (Option → User), these chain steps are no-ops +// for type resolution — the current type passes through unchanged. +const TYPE_PRESERVING_METHODS = new Set([ + 'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result + 'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow + 'get', // Kotlin/Java Optional.get() + 'orElseThrow', // Java Optional +]); /** * Walk up the AST from a node to find the enclosing function/method. @@ -178,6 +188,7 @@ export const processCalls = async ( const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0 ? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx) : new Map(); + const receiverIndex = buildReceiverTypeIndex(verifiedReceivers); ctx.enableCache(file.path); @@ -225,8 +236,10 @@ export const processCalls = async ( description: item.accessorType, }, }); - ctx.symbols.add(file.path, item.propName, nodeId, 'Property', - propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined); + ctx.symbols.add(file.path, item.propName, nodeId, 'Property', { + ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), + ...(item.declaredType ? { declaredType: item.declaredType } : {}), + }); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); graph.addRelationship({ id: relId, sourceId: fileId, targetId: nodeId, @@ -234,9 +247,9 @@ export const processCalls = async ( }); if (propEnclosingClassId) { graph.addRelationship({ - id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`), + id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), sourceId: propEnclosingClassId, targetId: nodeId, - type: 'HAS_METHOD', confidence: 1.0, reason: '', + type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); } } @@ -255,10 +268,10 @@ export const processCalls = async ( const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; // Fall back to verified constructor bindings for return type inference - if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) { + if (!receiverTypeName && receiverName && receiverIndex.size > 0) { const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx); const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; - receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName); + receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName); } // Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()). // When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface @@ -271,32 +284,33 @@ export const processCalls = async ( receiverTypeName = receiverName; } } - // Fall back to chained call resolution when the receiver is a call expression - // (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier). + // Fall back to mixed chain resolution when the receiver is a complex expression + // (field chain, call chain, or interleaved — e.g. user.address.city.save() or + // svc.getUser().address.save()). Handles all cases with a single unified walk. if (callForm === 'member' && !receiverTypeName && !receiverName) { const receiverNode = extractReceiverNode(nameNode); - if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) { - const extracted = extractCallChain(receiverNode); - if (extracted) { - // Resolve the base receiver type if possible - let baseType = extracted.baseReceiverName && typeEnv + if (receiverNode) { + const extracted = extractMixedChain(receiverNode); + if (extracted && extracted.chain.length > 0) { + let currentType = extracted.baseReceiverName && typeEnv ? typeEnv.lookup(extracted.baseReceiverName, callNode) : undefined; - if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) { + if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) { const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx); const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; - baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName); + currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName); } - // Class-as-receiver for chain base (e.g. UserService.find_user().save()) - if (!baseType && extracted.baseReceiverName) { + if (!currentType && extracted.baseReceiverName) { const cr = ctx.resolve(extracted.baseReceiverName, file.path); if (cr?.candidates.some(d => d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum', )) { - baseType = extracted.baseReceiverName; + currentType = extracted.baseReceiverName; } } - receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx); + if (currentType) { + receiverTypeName = walkMixedChain(extracted.chain, currentType, file.path, ctx); + } } } } @@ -345,6 +359,7 @@ interface ResolveResult { nodeId: string; confidence: number; reason: string; + returnType?: string; } const CALLABLE_SYMBOL_TYPES = new Set([ @@ -394,48 +409,9 @@ const toResolveResult = ( nodeId: definition.nodeId, confidence: TIER_CONFIDENCE[tier], reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global', + returnType: definition.returnType, }); -/** - * Resolve a chain of intermediate method calls to find the receiver type for a - * final member call. Called when the receiver of a call is itself a call - * expression (e.g. `svc.getUser().save()`). - * - * @param chainNames Ordered list of method names from outermost to innermost - * intermediate call (e.g. ['getUser'] for `svc.getUser().save()`). - * @param baseReceiverTypeName The already-resolved type of the base receiver - * (e.g. 'UserService' for `svc`), or undefined. - * @param currentFile The file path for resolution context. - * @param ctx The resolution context for symbol lookup. - * @returns The type name of the final intermediate call's return type, or undefined - * if resolution fails at any step. - */ -function resolveChainedReceiver( - chainNames: string[], - baseReceiverTypeName: string | undefined, - currentFile: string, - ctx: ResolutionContext, -): string | undefined { - let currentType = baseReceiverTypeName; - for (const name of chainNames) { - const resolved = resolveCallTarget( - { calledName: name, callForm: 'member', receiverTypeName: currentType }, - currentFile, - ctx, - ); - if (!resolved) return undefined; - - const candidates = ctx.symbols.lookupFuzzy(name); - const symDef = candidates.find(c => c.nodeId === resolved.nodeId); - if (!symDef?.returnType) return undefined; - - const returnTypeName = extractReturnTypeName(symDef.returnType); - if (!returnTypeName) return undefined; - - currentType = returnTypeName; - } - return currentType; -} /** * Resolve a function call to its target node ID using priority strategy: @@ -529,48 +505,139 @@ const receiverKey = (scope: string, varName: string): string => `${scope}\0${varName}`; /** - * Look up a receiver type from a verified receiver map. - * The map is keyed by `scope\0varName` (full scope with @startIndex). - * Since the lookup side only has `funcName` (no startIndex), we scan for - * all entries whose key starts with `funcName@` and has the matching varName. - * If exactly one unique type is found, return it. If multiple distinct types - * exist (true overload collision), return undefined (refuse to guess). - * Falls back to the file-level scope key `\0varName` (empty funcName). + * Pre-built secondary index for O(1) receiver type lookups. + * Built once per file from the verified receiver map, keyed by funcName → varName. + */ +type ReceiverTypeEntry = + | { readonly kind: 'resolved'; readonly value: string } + | { readonly kind: 'ambiguous' }; +type ReceiverTypeIndex = Map>; + +/** + * Build a two-level secondary index from the verified receiver map. + * The verified map is keyed by `scope\0varName` where scope is either + * "funcName@startIndex" (inside a function) or "" (file level). + * Index structure: Map> + */ +const buildReceiverTypeIndex = (map: Map): ReceiverTypeIndex => { + const index: ReceiverTypeIndex = new Map(); + for (const [key, typeName] of map) { + const nul = key.indexOf('\0'); + if (nul < 0) continue; + const scope = key.slice(0, nul); + const varName = key.slice(nul + 1); + if (!varName) continue; + if (scope !== '' && !scope.includes('@')) continue; + const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@')); + + let varMap = index.get(funcName); + if (!varMap) { varMap = new Map(); index.set(funcName, varMap); } + + const existing = varMap.get(varName); + if (existing === undefined) { + varMap.set(varName, { kind: 'resolved', value: typeName }); + } else if (existing.kind === 'resolved' && existing.value !== typeName) { + varMap.set(varName, { kind: 'ambiguous' }); + } + } + return index; +}; + +/** + * O(1) receiver type lookup using the pre-built secondary index. + * Returns the unique type name if unambiguous. Falls back to file-level scope. */ const lookupReceiverType = ( - map: Map, + index: ReceiverTypeIndex, funcName: string, varName: string, ): string | undefined => { - // Fast path: file-level scope (empty funcName — used as fallback) - const fileLevelKey = receiverKey('', varName); - - const prefix = `${funcName}@`; - const suffix = `\0${varName}`; - let found: string | undefined; - let ambiguous = false; - - for (const [key, value] of map) { - if (key === fileLevelKey) continue; // handled separately below - if (key.startsWith(prefix) && key.endsWith(suffix)) { - // Verify the key is exactly "funcName@\0varName" with no extra chars. - // The part between prefix and suffix should be the startIndex (digits only), - // but we accept any non-empty segment to be forward-compatible. - const middle = key.slice(prefix.length, key.length - suffix.length); - if (middle.length === 0) continue; // malformed key — skip - if (found === undefined) { - found = value; - } else if (found !== value) { - ambiguous = true; - break; - } + const funcBucket = index.get(funcName); + if (funcBucket) { + const entry = funcBucket.get(varName); + if (entry?.kind === 'resolved') return entry.value; + if (entry?.kind === 'ambiguous') { + // Ambiguous in this function scope — try file-level fallback + const fileEntry = index.get('')?.get(varName); + return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined; } } + // Fallback: file-level scope (funcName "") + if (funcName !== '') { + const fileEntry = index.get('')?.get(varName); + if (fileEntry?.kind === 'resolved') return fileEntry.value; + } + return undefined; +}; - if (!ambiguous && found !== undefined) return found; +const resolveFieldAccessType = ( + receiverName: string, + fieldName: string, + filePath: string, + ctx: ResolutionContext, +): string | undefined => { + // Resolve the receiver's type to a class/struct nodeId + const typeResolved = ctx.resolve(receiverName, filePath); + if (!typeResolved) return undefined; + const classDef = typeResolved.candidates.find( + d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface' + || d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl', + ); + if (!classDef) return undefined; - // Fallback: file-level scope (bindings outside any function) - return map.get(fileLevelKey); + const fieldDef = ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName); + if (!fieldDef?.declaredType) return undefined; + + // Use stripNullable (not extractReturnTypeName) — field types like List + // should be preserved as-is, not unwrapped to User. Only strip nullable wrappers. + return stripNullable(fieldDef.declaredType); +}; + +/** + * Walk a pre-built mixed chain of field/call steps, threading the current type + * through each step and returning the final resolved type. + * + * Returns `undefined` if any step cannot be resolved (chain is broken). + * The caller is responsible for seeding `startType` from its own context + * (TypeEnv, constructor bindings, or static-class fallback). + */ +const walkMixedChain = ( + chain: MixedChainStep[], + startType: string, + filePath: string, + ctx: ResolutionContext, +): string | undefined => { + let currentType: string | undefined = startType; + for (const step of chain) { + if (!currentType) break; + if (step.kind === 'field') { + currentType = resolveFieldAccessType(currentType, step.name, filePath, ctx); + } else { + // Ruby/Python: property access is syntactically identical to method calls. + // Try field resolution first — if the name is a known property with declaredType, + // use that type directly. Otherwise fall back to method call resolution. + const fieldType = resolveFieldAccessType(currentType, step.name, filePath, ctx); + if (fieldType) { + currentType = fieldType; + continue; + } + const resolved = resolveCallTarget( + { calledName: step.name, callForm: 'member', receiverTypeName: currentType }, + filePath, + ctx, + ); + if (!resolved) { + // Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type + if (TYPE_PRESERVING_METHODS.has(step.name)) continue; + currentType = undefined; break; + } + if (!resolved.returnType) { currentType = undefined; break; } + const retType = extractReturnTypeName(resolved.returnType); + if (!retType) { currentType = undefined; break; } + currentType = retType; + } + } + return currentType; }; /** @@ -587,12 +654,12 @@ export const processCallsFromExtracted = async ( // Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName. // The scope dimension prevents collisions when two functions in the same file // have same-named locals pointing to different constructor types. - const fileReceiverTypes = new Map>(); + const fileReceiverTypes = new Map(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); if (verified.size > 0) { - fileReceiverTypes.set(filePath, verified); + fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified)); } } } @@ -638,29 +705,31 @@ export const processCallsFromExtracted = async ( } } - // Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()), - // resolve the chain to determine the final receiver type. - // This runs whenever receiverCallChain is present — even when Step 1 set a - // receiverTypeName, that type is the BASE receiver (e.g. UserService for svc), - // and the chain must be walked to produce the FINAL receiver (e.g. User from - // getUser() : User). - if (effectiveCall.receiverCallChain?.length) { - // Step 1 may have resolved the base receiver type (e.g. svc → UserService). - // Use it as the starting point for chain resolution. - let baseType = effectiveCall.receiverTypeName; - // If Step 1 didn't resolve it, try the receiver map directly. - if (!baseType && effectiveCall.receiverName && receiverMap) { + // Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()). + // Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver + // type already; that type is used as the chain's starting point. + if (effectiveCall.receiverMixedChain?.length) { + // Use the already-resolved base type (from Steps 1/1b) or look it up now. + let currentType: string | undefined = effectiveCall.receiverTypeName; + if (!currentType && effectiveCall.receiverName && receiverMap) { const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId); - baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName); + currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName); } - const chainedType = resolveChainedReceiver( - effectiveCall.receiverCallChain, - baseType, - effectiveCall.filePath, - ctx, - ); - if (chainedType) { - effectiveCall = { ...effectiveCall, receiverTypeName: chainedType }; + if (!currentType && effectiveCall.receiverName) { + const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath); + if (typeResolved?.candidates.some(d => + d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum', + )) { + currentType = effectiveCall.receiverName; + } + } + if (currentType) { + const walkedType = walkMixedChain( + effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx, + ); + if (walkedType) { + effectiveCall = { ...effectiveCall, receiverTypeName: walkedType }; + } } } diff --git a/gitnexus/src/core/ingestion/call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts index 8916d2a11..c86f18074 100644 --- a/gitnexus/src/core/ingestion/call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -65,6 +65,8 @@ export interface RubyPropertyItem { accessorType: RubyAccessorType; startLine: number; endLine: number; + /** YARD @return [Type] annotation preceding the attr_accessor call */ + declaredType?: string; } // ── Pre-allocated singletons for common return values ──────────────────────── @@ -129,6 +131,25 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin // ── attr_accessor / attr_reader / attr_writer → property definitions ─── if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') { + // Extract YARD @return [Type] from preceding comment (e.g. `# @return [Address]`) + let yardType: string | undefined; + let sibling = callNode.previousSibling; + while (sibling) { + if (sibling.type === 'comment') { + const match = /@return\s+\[([^\]]+)\]/.exec(sibling.text); + if (match) { + const raw = match[1].trim(); + // Extract simple type name: "User", "Array" → "User" + const simple = raw.match(/^([A-Z]\w*)/); + if (simple) yardType = simple[1]; + break; + } + } else if (sibling.isNamed) { + break; // stop at non-comment named sibling + } + sibling = sibling.previousSibling; + } + const items: RubyPropertyItem[] = []; const argList = callNode.childForFieldName?.('arguments'); for (const arg of (argList?.children ?? [])) { @@ -138,6 +159,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin accessorType: calledName as RubyAccessorType, startLine: arg.startPosition.row, endLine: arg.endPosition.row, + ...(yardType ? { declaredType: yardType } : {}), }); } } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 3b709f8fc..a187f4882 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -1,14 +1,16 @@ -import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js'; +import { KnowledgeGraph, GraphNode, GraphRelationship, type NodeLabel } from '../graph/types.js'; import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; -import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js'; +import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js'; +import { extractPropertyDeclaredType } from './type-extractors/shared.js'; import { isNodeExported } from './export-detection.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { typeConfigs } from './type-extractors/index.js'; +import { SupportedLanguages } from '../../config/supported-languages.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; @@ -81,6 +83,7 @@ const processParsingWithWorkers = async ( symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, { parameterCount: sym.parameterCount, returnType: sym.returnType, + declaredType: sym.declaredType, ownerId: sym.ownerId, }); } @@ -198,9 +201,24 @@ const processParsingSequential = async ( if (!nameNode && !captureMap['definition.constructor']) return; const nodeName = nameNode ? nameNode.text : 'init'; - let nodeLabel = 'CodeElement'; + let nodeLabel: NodeLabel = 'CodeElement'; - if (captureMap['definition.function']) nodeLabel = 'Function'; + if (captureMap['definition.function']) { + // C/C++: @definition.function is broad and also matches inline class methods (inside + // a class/struct body). Those are already captured by @definition.method, so skip + // the duplicate Function entry to prevent double-indexing in globalIndex. + if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) { + let ancestor = captureMap['definition.function']?.parent; + while (ancestor) { + if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') { + break; + } + ancestor = ancestor.parent; + } + if (ancestor) return; // inside a class body — handled by @definition.method + } + nodeLabel = 'Function'; + } else if (captureMap['definition.class']) nodeLabel = 'Class'; else if (captureMap['definition.interface']) nodeLabel = 'Interface'; else if (captureMap['definition.method']) nodeLabel = 'Method'; @@ -275,9 +293,15 @@ const processParsingSequential = async ( const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null; + // Extract declared type for Property nodes (field/property type annotations) + const declaredType = (nodeLabel === 'Property' && definitionNode) + ? extractPropertyDeclaredType(definitionNode) + : undefined; + symbolTable.add(file.path, nodeName, nodeId, nodeLabel, { parameterCount: methodSig?.parameterCount, returnType: methodSig?.returnType, + declaredType, ownerId: enclosingClassId ?? undefined, }); @@ -296,13 +320,14 @@ const processParsingSequential = async ( graph.addRelationship(relationship); - // ── HAS_METHOD: link method/constructor/property to enclosing class ── + // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── if (enclosingClassId) { + const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; graph.addRelationship({ - id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`), + id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), sourceId: enclosingClassId, targetId: nodeId, - type: 'HAS_METHOD', + type: memberEdgeType, confidence: 1.0, reason: '', }); diff --git a/gitnexus/src/core/ingestion/symbol-table.ts b/gitnexus/src/core/ingestion/symbol-table.ts index e02f49c02..8718a9516 100644 --- a/gitnexus/src/core/ingestion/symbol-table.ts +++ b/gitnexus/src/core/ingestion/symbol-table.ts @@ -1,11 +1,15 @@ +import type { NodeLabel } from '../graph/types.js'; + export interface SymbolDefinition { nodeId: string; filePath: string; - type: string; // 'Function', 'Class', etc. + type: NodeLabel; parameterCount?: number; /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ returnType?: string; - /** Links Method/Constructor to owning Class/Struct/Trait nodeId */ + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } @@ -17,8 +21,8 @@ export interface SymbolTable { filePath: string, name: string, nodeId: string, - type: string, - metadata?: { parameterCount?: number; returnType?: string; ownerId?: string } + type: NodeLabel, + metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string } ) => void; /** @@ -45,7 +49,14 @@ export interface SymbolTable { * Used by ReturnTypeLookup to resolve callee → return type. */ lookupFuzzyCallable: (name: string) => SymbolDefinition[]; - + + /** + * Look up a field/property by its owning class nodeId and field name. + * O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`. + * Returns undefined when no matching property exists or the owner is ambiguous. + */ + lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined; + /** * Debugging: See how many symbols are tracked */ @@ -71,14 +82,18 @@ export const createSymbolTable = (): SymbolTable => { // Only Function, Method, Constructor symbols are indexed. let callableIndex: Map | null = null; + // 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName". + // Only Property symbols with ownerId and declaredType are indexed. + const fieldByOwner = new Map(); + const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']); const add = ( filePath: string, name: string, nodeId: string, - type: string, - metadata?: { parameterCount?: number; returnType?: string; ownerId?: string } + type: NodeLabel, + metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string } ) => { const def: SymbolDefinition = { nodeId, @@ -86,6 +101,7 @@ export const createSymbolTable = (): SymbolTable => { type, ...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}), ...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}), + ...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}), ...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}), }; @@ -95,14 +111,26 @@ export const createSymbolTable = (): SymbolTable => { } fileIndex.get(filePath)!.set(name, def); - // B. Add to Global Index (same object reference) + // B. Properties go to fieldByOwner index only — skip globalIndex to prevent + // namespace pollution for common names like 'id', 'name', 'type'. + if (type === 'Property' && metadata?.ownerId) { + if (metadata?.declaredType) { + fieldByOwner.set(`${metadata.ownerId}\0${name}`, def); + } + // Still add to fileIndex above (for lookupExact), but skip globalIndex + return; + } + + // C. Add to Global Index (same object reference) if (!globalIndex.has(name)) { globalIndex.set(name, []); } globalIndex.get(name)!.push(def); - // Invalidate the lazy callable index — it will be rebuilt on next use - callableIndex = null; + // D. Invalidate the lazy callable index only when adding callable types + if (CALLABLE_TYPES.has(type)) { + callableIndex = null; + } }; const lookupExact = (filePath: string, name: string): string | undefined => { @@ -129,6 +157,10 @@ export const createSymbolTable = (): SymbolTable => { return callableIndex.get(name) ?? []; }; + const lookupFieldByOwner = (ownerNodeId: string, fieldName: string): SymbolDefinition | undefined => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + }; + const getStats = () => ({ fileCount: fileIndex.size, globalSymbolCount: globalIndex.size @@ -138,7 +170,8 @@ export const createSymbolTable = (): SymbolTable => { fileIndex.clear(); globalIndex.clear(); callableIndex = null; + fieldByOwner.clear(); }; - return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear }; + return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear }; }; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index bedc50b2f..57fbf1de6 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -62,6 +62,19 @@ export const TYPESCRIPT_QUERIES = ` (new_expression constructor: (identifier) @call.name) @call +; Class properties — public_field_definition covers most TS class fields +(public_field_definition + name: (property_identifier) @name) @definition.property + +; Private class fields: #address: Address +(public_field_definition + name: (private_property_identifier) @name) @definition.property + +; Constructor parameter properties: constructor(public address: Address) +(required_parameter + (accessibility_modifier) + pattern: (identifier) @name) @definition.property + ; Heritage queries - class extends (class_declaration name: (type_identifier) @heritage.class @@ -128,6 +141,10 @@ export const JAVASCRIPT_QUERIES = ` (new_expression constructor: (identifier) @call.name) @call +; Class fields — field_definition captures JS class fields (class User { address = ... }) +(field_definition + property: (property_identifier) @name) @definition.property + ; Heritage queries - class extends (JavaScript uses different AST than TypeScript) ; In tree-sitter-javascript, class_heritage directly contains the parent identifier (class_declaration @@ -160,6 +177,14 @@ export const PYTHON_QUERIES = ` function: (attribute attribute: (identifier) @call.name)) @call +; Class attribute type annotations — PEP 526: address: Address or address: Address = Address() +; Both bare annotations (address: Address) and annotated assignments (name: str = "test") +; are parsed as (assignment left: ... type: ...) in tree-sitter-python. +(expression_statement + (assignment + left: (identifier) @name + type: (type)) @definition.property) + ; Heritage queries - Python class inheritance (class_definition name: (identifier) @heritage.class @@ -179,6 +204,11 @@ export const JAVA_QUERIES = ` (method_declaration name: (identifier) @name) @definition.method (constructor_declaration name: (identifier) @name) @definition.constructor +; Fields — typed field declarations inside class bodies +(field_declaration + declarator: (variable_declarator + name: (identifier) @name)) @definition.property + ; Imports - capture any import declaration child as source (import_declaration (_) @import.source) @import @@ -243,6 +273,11 @@ export const GO_QUERIES = ` (import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import (import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import +; Struct fields — named field declarations inside struct types +(field_declaration_list + (field_declaration + name: (field_identifier) @name) @definition.property) + ; Struct embedding (anonymous fields = inheritance) (type_declaration (type_spec @@ -299,6 +334,21 @@ export const CPP_QUERIES = ` (declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function (declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function +; Class/struct data member fields (Address address; int count;) +; Uses field_identifier to exclude method declarations (which use function_declarator) +(field_declaration + declarator: (field_identifier) @name) @definition.property + +; Pointer member fields (Address* address;) +(field_declaration + declarator: (pointer_declarator + declarator: (field_identifier) @name)) @definition.property + +; Reference member fields (Address& address;) +(field_declaration + declarator: (reference_declarator + (field_identifier) @name)) @definition.property + ; Inline class method declarations (inside class body, no body: void Foo();) (field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method @@ -414,6 +464,11 @@ export const RUST_QUERIES = ` ; Struct literal construction: User { name: value } (struct_expression name: (type_identifier) @call.name) @call +; Struct fields — named field declarations inside struct bodies +(field_declaration_list + (field_declaration + name: (field_identifier) @name) @definition.property) + ; Heritage (trait implementation) — all combinations of concrete/generic trait × concrete/generic type (impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage (impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage @@ -457,6 +512,13 @@ export const PHP_QUERIES = ` (variable_name (name) @name))) @definition.property +; Constructor property promotion (PHP 8.0+: public Address $address in __construct) +(method_declaration + parameters: (formal_parameters + (property_promotion_parameter + name: (variable_name + (name) @name)))) @definition.property + ; ── Imports: use statements ────────────────────────────────────────────────── ; Simple: use App\\Models\\User; (namespace_use_declaration @@ -582,6 +644,12 @@ export const KOTLIN_QUERIES = ` (variable_declaration (simple_identifier) @name)) @definition.property +; Primary constructor val/var parameters (data class, value class, regular class) +; binding_pattern_kind contains "val" or "var" — without it, the param is not a property +(class_parameter + (binding_pattern_kind) + (simple_identifier) @name) @definition.property + ; ── Enum entries ───────────────────────────────────────────────────────── (enum_entry (simple_identifier) @name) @definition.enum diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 6794afee5..0e4a2edaa 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -439,7 +439,9 @@ export const buildTypeEnv = ( let typeNode = node.childForFieldName('type'); if (typeNode) { const nameNode = node.childForFieldName('name') - ?? node.childForFieldName('pattern'); + ?? node.childForFieldName('pattern') + // Python typed_parameter: name is a positional child (identifier), not a named field + ?? (node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null); if (nameNode) { const varName = extractVarName(nameNode); if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) { diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index a202bdaf2..ca4192cff 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -62,6 +62,10 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map, _classNames: ClassNameLookup): void => { +const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { // Skip if there's an explicit type annotation — Tier 0 already handled it if (node.childForFieldName('type') !== null) return; const pattern = node.childForFieldName('pattern'); @@ -116,6 +116,13 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map { + if (!definitionNode) return undefined; + + // Strategy 1: Look for a `type` or `type_annotation` named field + const typeNode = definitionNode.childForFieldName?.('type'); + if (typeNode) { + const typeName = extractSimpleTypeName(typeNode); + if (typeName) return typeName; + // Fallback: use the raw text (for complex types like User[] or List) + const text = typeNode.text?.trim(); + if (text && text.length < 100) return text; + } + + // Strategy 2: Walk children looking for type_annotation (TypeScript pattern) + for (let i = 0; i < definitionNode.childCount; i++) { + const child = definitionNode.child(i); + if (!child) continue; + if (child.type === 'type_annotation') { + // Type annotation has the actual type as a child + for (let j = 0; j < child.childCount; j++) { + const typeChild = child.child(j); + if (typeChild && typeChild.type !== ':') { + const typeName = extractSimpleTypeName(typeChild); + if (typeName) return typeName; + const text = typeChild.text?.trim(); + if (text && text.length < 100) return text; + } + } + } + } + + // Strategy 3: For Java field_declaration, the type is a sibling of variable_declarator + // AST: (field_declaration type: (type_identifier) declarator: (variable_declarator ...)) + const parentDecl = definitionNode.parent; + if (parentDecl) { + const parentType = parentDecl.childForFieldName?.('type'); + if (parentType) { + const typeName = extractSimpleTypeName(parentType); + if (typeName) return typeName; + } + } + + // Strategy 4: Kotlin property_declaration — type is nested inside variable_declaration child + // AST: (property_declaration (variable_declaration name: ... type: (user_type ...))) + for (let i = 0; i < definitionNode.childCount; i++) { + const child = definitionNode.child(i); + if (child?.type === 'variable_declaration') { + const varType = child.childForFieldName?.('type'); + if (varType) { + const typeName = extractSimpleTypeName(varType); + if (typeName) return typeName; + const text = varType.text?.trim(); + if (text && text.length < 100) return text; + } + } + } + + // Strategy 5: PHP @var PHPDoc — look for preceding comment with @var Type + // Handles pre-PHP-7.4 code: /** @var Address */ public $address; + const prevSibling = definitionNode.previousNamedSibling ?? definitionNode.parent?.previousNamedSibling; + if (prevSibling?.type === 'comment') { + const commentText = prevSibling.text; + const varMatch = commentText?.match(/@var\s+([A-Z][\w\\]*)/); + if (varMatch) { + // Strip namespace prefix: \App\Models\User → User + const raw = varMatch[1]; + const base = raw.includes('\\') ? raw.split('\\').pop()! : raw; + if (base && /^[A-Z]\w*$/.test(base)) return base; + } + } + + return undefined; +}; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 53e2333ed..7cbb0e6a2 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -264,7 +264,7 @@ export const CLASS_CONTAINER_TYPES = new Set([ 'class_declaration', 'abstract_class_declaration', 'interface_declaration', 'struct_declaration', 'record_declaration', 'class_specifier', 'struct_specifier', - 'impl_item', 'trait_item', + 'impl_item', 'trait_item', 'struct_item', 'enum_item', 'class_definition', 'trait_declaration', 'protocol_declaration', @@ -286,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { class_definition: 'Class', impl_item: 'Impl', trait_item: 'Trait', + struct_item: 'Struct', + enum_item: 'Enum', trait_declaration: 'Trait', record_declaration: 'Record', protocol_declaration: 'Interface', @@ -318,6 +320,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') { @@ -1156,6 +1173,142 @@ export function extractCallChain( return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; } +/** Node types representing member/field access across languages. */ +const FIELD_ACCESS_NODE_TYPES = new Set([ + 'member_expression', // TS/JS + 'member_access_expression', // C# + 'selector_expression', // Go + 'field_expression', // Rust/C++ + 'attribute', // Python + 'navigation_expression', // Kotlin/Swift + 'member_binding_expression', // C# null-conditional (user?.Address) +]); +/** One step in a mixed receiver chain. */ +export type MixedChainStep = { kind: 'field' | 'call'; name: string }; +/** + * Walk a receiver AST node that may interleave field accesses and method calls, + * building a unified chain of steps up to MAX_CHAIN_DEPTH. + * + * For `svc.getUser().address.save()`, called with the receiver of `save` + * (`svc.getUser().address`, a field access node): + * returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }], + * baseReceiverName: 'svc' } + * + * For `user.getAddress().city.getName()`, called with receiver of `getName` + * (`user.getAddress().city`): + * returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }], + * baseReceiverName: 'user' } + * + * Pure field chains and pure call chains are special cases (all steps same kind). + */ +export function extractMixedChain( + receiverNode: SyntaxNode, +): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined { + const chain: MixedChainStep[] = []; + let current: SyntaxNode = receiverNode; + while (chain.length < MAX_CHAIN_DEPTH) { + if (CALL_EXPRESSION_TYPES.has(current.type)) { + // ── Call expression: extract method name + inner receiver ──────────── + const funcNode = current.childForFieldName?.('function') + ?? current.childForFieldName?.('name') + ?? current.childForFieldName?.('method'); + let methodName: string | undefined; + let innerReceiver: SyntaxNode | null = null; + + if (funcNode) { + methodName = funcNode.lastNamedChild?.text ?? funcNode.text; + } + // Kotlin/Swift: call_expression → navigation_expression + if (!funcNode && current.type === 'call_expression') { + const callee = current.firstNamedChild; + if (callee?.type === 'navigation_expression') { + const suffix = callee.lastNamedChild; + if (suffix?.type === 'navigation_suffix') { + methodName = suffix.lastNamedChild?.text; + for (let i = 0; i < callee.namedChildCount; i++) { + const child = callee.namedChild(i); + if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; } + } + } + } + } + if (!methodName) break; + chain.unshift({ kind: 'call', name: methodName }); + + if (!innerReceiver && funcNode) { + innerReceiver = funcNode.childForFieldName?.('object') + ?? funcNode.childForFieldName?.('value') + ?? funcNode.childForFieldName?.('operand') + ?? funcNode.childForFieldName?.('argument') // C/C++ field_expression + ?? funcNode.childForFieldName?.('expression') + ?? null; + } + if (!innerReceiver && current.type === 'method_invocation') { + innerReceiver = current.childForFieldName?.('object') ?? null; + } + if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) { + innerReceiver = current.childForFieldName?.('object') ?? null; + } + if (!innerReceiver && current.type === 'call') { + innerReceiver = current.childForFieldName?.('receiver') ?? null; + } + if (!innerReceiver) break; + + if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) { + current = innerReceiver; + } else { + return { chain, baseReceiverName: innerReceiver.text || undefined }; + } + } else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) { + // ── Field/member access: extract property name + inner object ───────── + let propertyName: string | undefined; + let innerObject: SyntaxNode | null = null; + + if (current.type === 'navigation_expression') { + for (const child of current.children ?? []) { + if (child.type === 'navigation_suffix') { + for (const sc of child.children ?? []) { + if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; } + } + } else if (child.isNamed && !innerObject) { + innerObject = child; + } + } + } else if (current.type === 'attribute') { + innerObject = current.childForFieldName?.('object') ?? null; + propertyName = current.childForFieldName?.('attribute')?.text; + } else { + innerObject = current.childForFieldName?.('object') + ?? current.childForFieldName?.('value') + ?? current.childForFieldName?.('operand') + ?? current.childForFieldName?.('argument') // C/C++ field_expression + ?? current.childForFieldName?.('expression') + ?? null; + propertyName = (current.childForFieldName?.('property') + ?? current.childForFieldName?.('field') + ?? current.childForFieldName?.('name'))?.text; + } + + if (!propertyName) break; + chain.unshift({ kind: 'field', name: propertyName }); + + if (!innerObject) break; + + if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) { + current = innerObject; + } else { + return { chain, baseReceiverName: innerObject.text || undefined }; + } + } else { + // Simple identifier — this is the base receiver + return chain.length > 0 + ? { chain, baseReceiverName: current.text || undefined } + : undefined; + } + } + + return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 0c90d8cd8..a56fe6e17 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -36,8 +36,8 @@ import { inferCallForm, extractReceiverName, extractReceiverNode, - CALL_EXPRESSION_TYPES, - extractCallChain, + extractMixedChain, + type MixedChainStep, } from '../utils.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; @@ -48,6 +48,8 @@ import { generateId } from '../../../lib/utils.js'; import { extractNamedBindings } from '../named-binding-extraction.js'; import { appendKotlinWildcard } from '../resolvers/index.js'; import { callRouters } from '../call-routing.js'; +import { extractPropertyDeclaredType } from '../type-extractors/shared.js'; +import type { NodeLabel } from '../../graph/types.js'; // ============================================================================ // Types for serializable results @@ -75,7 +77,7 @@ interface ParsedRelationship { id: string; sourceId: string; targetId: string; - type: 'DEFINES' | 'HAS_METHOD'; + type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY'; confidence: number; reason: string; } @@ -84,9 +86,10 @@ interface ParsedSymbol { filePath: string; name: string; nodeId: string; - type: string; + type: NodeLabel; parameterCount?: number; returnType?: string; + declaredType?: string; ownerId?: string; } @@ -111,13 +114,14 @@ export interface ExtractedCall { /** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */ receiverTypeName?: string; /** - * Chained call names when the receiver is itself a call expression. - * For `svc.getUser().save()`, the `save` ExtractedCall gets receiverCallChain = ['getUser'] - * with receiverName = 'svc'. The chain is ordered outermost-last, e.g.: - * `a.b().c().d()` → calledName='d', receiverCallChain=['b','c'], receiverName='a' + * Unified mixed chain when the receiver is a chain of field accesses and/or method calls. + * Steps are ordered base-first (innermost to outermost). Examples: + * `svc.getUser().save()` → chain=[{kind:'call',name:'getUser'}], receiverName='svc' + * `user.address.save()` → chain=[{kind:'field',name:'address'}], receiverName='user' + * `svc.getUser().address.save()` → chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}] * Length is capped at MAX_CHAIN_DEPTH (3). */ - receiverCallChain?: string[]; + receiverMixedChain?: MixedChainStep[]; } export interface ExtractedHeritage { @@ -233,7 +237,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null => // Label detection from capture map // ============================================================================ -const getLabelFromCaptures = (captureMap: Record): string | null => { +const getLabelFromCaptures = (captureMap: Record): NodeLabel | null => { // Skip imports (handled separately) and calls if (captureMap['import'] || captureMap['call']) return null; if (!captureMap['name']) return null; @@ -965,6 +969,7 @@ const processFileGroup = ( nodeId, type: 'Property', ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), + ...(item.declaredType ? { declaredType: item.declaredType } : {}), }); const fileId = generateId('File', file.path); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); @@ -978,10 +983,10 @@ const processFileGroup = ( }); if (propEnclosingClassId) { result.relationships.push({ - id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`), + id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), sourceId: propEnclosingClassId, targetId: nodeId, - type: 'HAS_METHOD', + type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); @@ -1000,27 +1005,20 @@ const processFileGroup = ( const callForm = inferCallForm(callNode, callNameNode); let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined; - let receiverCallChain: string[] | undefined; + let receiverMixedChain: MixedChainStep[] | undefined; - // When the receiver is a call_expression (e.g. svc.getUser().save()), - // extractReceiverName returns undefined because it refuses complex expressions. - // Instead, walk the receiver node to build a call chain for deferred resolution. - // We capture the base receiver name so processCallsFromExtracted can look it up - // from constructor bindings. receiverTypeName is intentionally left unset here — - // the chain resolver in processCallsFromExtracted needs the base type as input and - // produces the final receiver type as output. + // When the receiver is a complex expression (call chain, field chain, or mixed), + // extractReceiverName returns undefined. Walk the receiver node to build a unified + // mixed chain for deferred resolution in processCallsFromExtracted. if (callForm === 'member' && receiverName === undefined && !receiverTypeName) { const receiverNode = extractReceiverNode(callNameNode); - if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) { - const extracted = extractCallChain(receiverNode); - if (extracted) { - receiverCallChain = extracted.chain; - // Set receiverName to the base object so Step 1 in processCallsFromExtracted - // can resolve it via constructor bindings to a base type for the chain. + if (receiverNode) { + const extracted = extractMixedChain(receiverNode); + if (extracted && extracted.chain.length > 0) { + receiverMixedChain = extracted.chain; receiverName = extracted.baseReceiverName; - // Also try the type environment immediately (covers explicitly-typed locals - // and annotated parameters like `fn process(svc: &UserService)`). - // This sets a base type that chain resolution (Step 2) will use as input. + // Try the type environment immediately for the base receiver + // (covers explicitly-typed locals and annotated parameters). if (receiverName) { receiverTypeName = typeEnv.lookup(receiverName, callNode); } @@ -1036,7 +1034,7 @@ const processFileGroup = ( ...(callForm !== undefined ? { callForm } : {}), ...(receiverName !== undefined ? { receiverName } : {}), ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - ...(receiverCallChain !== undefined ? { receiverCallChain } : {}), + ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), }); } } @@ -1086,6 +1084,23 @@ const processFileGroup = ( const nodeLabel = getLabelFromCaptures(captureMap); if (!nodeLabel) continue; + // C/C++: @definition.function is broad and also matches inline class methods (inside + // a class/struct body). Those are already captured by @definition.method, so skip + // the duplicate Function entry to prevent double-indexing in globalIndex. + if ( + (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) && + nodeLabel === 'Function' + ) { + let ancestor = captureMap['definition.function']?.parent; + while (ancestor) { + if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') { + break; // inside a class body — duplicate of @definition.method + } + ancestor = ancestor.parent; + } + if (ancestor) continue; // found a class/struct ancestor → skip + } + const nameNode = captureMap['name']; // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor') continue; @@ -1109,6 +1124,7 @@ const processFileGroup = ( let parameterCount: number | undefined; let returnType: string | undefined; + let declaredType: string | undefined; if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') { const sig = extractMethodSignature(definitionNode); parameterCount = sig.parameterCount; @@ -1123,6 +1139,10 @@ const processFileGroup = ( if (docReturn) returnType = docReturn; } } + } else if (nodeLabel === 'Property' && definitionNode) { + // Extract the declared type for property/field nodes. + // Walk the definition node for type annotation children. + declaredType = extractPropertyDeclaredType(definitionNode); } result.nodes.push({ @@ -1157,6 +1177,7 @@ const processFileGroup = ( type: nodeLabel, ...(parameterCount !== undefined ? { parameterCount } : {}), ...(returnType !== undefined ? { returnType } : {}), + ...(declaredType !== undefined ? { declaredType } : {}), ...(enclosingClassId ? { ownerId: enclosingClassId } : {}), }); @@ -1171,13 +1192,14 @@ const processFileGroup = ( reason: '', }); - // ── HAS_METHOD: link method/constructor/property to enclosing class ── + // ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ── if (enclosingClassId) { + const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD'; result.relationships.push({ - id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`), + id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`), sourceId: enclosingClassId, targetId: nodeId, - type: 'HAS_METHOD', + type: memberEdgeType, confidence: 1.0, reason: '', }); diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 11f55ae7b..c55e86524 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number]; export const REL_TABLE_NAME = 'CodeRelation'; // Valid relation types -export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const; +export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const; export type RelType = typeof REL_TYPES[number]; // ============================================================================ diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 67a9109ad..dc28ded0d 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([ ]); /** Valid relation types for impact analysis filtering */ -export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']); +export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES']); /** Regex to detect write operations in user-supplied Cypher queries */ export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i; @@ -898,7 +898,7 @@ export class LocalBackend { // Categorized incoming refs const incomingRows = await executeParameterized(repo.id, ` MATCH (caller)-[r:CodeRelation]->(n {id: $symId}) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES'] RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind LIMIT 30 `, { symId }); @@ -906,7 +906,7 @@ export class LocalBackend { // Categorized outgoing refs const outgoingRows = await executeParameterized(repo.id, ` MATCH (n {id: $symId})-[r:CodeRelation]->(target) - WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'] + WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES'] RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind LIMIT 30 `, { symId }); diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 95c7884dc..2d7f3683e 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -328,6 +328,9 @@ relationships: - IMPORTS: Module imports - EXTENDS: Class inheritance - IMPLEMENTS: Interface implementation + - HAS_METHOD: Class/Struct/Interface owns a Method + - HAS_PROPERTY: Class/Struct/Interface owns a Property (field) + - OVERRIDES: Method overrides another Method (MRO) - MEMBER_OF: Symbol belongs to community - STEP_IN_PROCESS: Symbol is step N in process diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 8565c8bc0..87add7f46 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -78,7 +78,7 @@ SCHEMA: - Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process - Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc. - All edges via single CodeRelation table with 'type' property -- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS +- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS - Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32) EXAMPLES: @@ -94,6 +94,9 @@ EXAMPLES: • Find all methods of a class: MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType +• Find all properties of a class: + MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.description + • Find method overrides (MRO resolution): MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason @@ -119,7 +122,7 @@ TIPS: { name: 'context', description: `360-degree view of a single code symbol. -Shows categorized incoming/outgoing references (calls, imports, extends, implements), process participation, and file location. +Shows categorized incoming/outgoing references (calls, imports, extends, implements, methods, properties, overrides), process participation, and file location. WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in. AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace. @@ -200,7 +203,9 @@ Depth groups: - d=2: LIKELY AFFECTED (indirect) - d=3: MAY NEED TESTING (transitive) -EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES +TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. + +EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES Confidence: 1.0 = certain, <0.8 = fuzzy match`, inputSchema: { type: 'object', @@ -208,7 +213,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`, target: { type: 'string', description: 'Name of function, class, or file to analyze' }, direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' }, maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 }, - relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' }, + relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES (default: usage-based)' }, includeTests: { type: 'boolean', description: 'Include test files (default: false)' }, minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' }, repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' }, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-chain-call/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-chain-call/app.cpp index 8493229fa..af1cd1bc3 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-chain-call/app.cpp +++ b/gitnexus/test/fixtures/lang-resolution/cpp-chain-call/app.cpp @@ -1,4 +1,5 @@ #include "service.h" +#include "user.h" #include "repo.h" void processUser() { diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/models.h new file mode 100644 index 000000000..6c0553b82 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/models.h @@ -0,0 +1,30 @@ +#pragma once + +class City { +public: + std::string zipCode; + + std::string getName() { + return "city"; + } +}; + +class Address { +public: + City city; + std::string street; + + void save() { + // persist address + } +}; + +class User { +public: + std::string name; + Address address; + + std::string greet() { + return name; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/service.cpp new file mode 100644 index 000000000..bfa5a904c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-deep-field-chain/service.cpp @@ -0,0 +1,9 @@ +#include "models.h" + +void processUser(User user) { + // 2-level chain: user.address → Address, then .save() → Address#save + user.address.save(); + + // 3-level chain: user.address → Address, .city → City, .getName() → City#getName + user.address.city.getName(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-field-types/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-field-types/models.h new file mode 100644 index 000000000..0b91ffd26 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-field-types/models.h @@ -0,0 +1,20 @@ +#pragma once + +class Address { +public: + std::string city; + + void save() { + // persist address + } +}; + +class User { +public: + std::string name; + Address address; + + std::string greet() { + return name; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-field-types/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-field-types/service.cpp new file mode 100644 index 000000000..4b90c656c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-field-types/service.cpp @@ -0,0 +1,6 @@ +#include "models.h" + +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/cpp-pointer-ref-fields/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/models.h new file mode 100644 index 000000000..51443bc8a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/models.h @@ -0,0 +1,21 @@ +#pragma once + +class Address { +public: + std::string city; + + void save() { + // persist address + } +}; + +class User { +public: + Address* address; // raw pointer member field + Address& ref_address; // reference member field + std::string name; + + std::string greet() { + return name; + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/service.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/service.cpp new file mode 100644 index 000000000..d7ffbfd77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-pointer-ref-fields/service.cpp @@ -0,0 +1,6 @@ +#include "models.h" + +void processUser(User user) { + // Pointer member field access: user.address->save() + user.address->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Models.cs b/gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Models.cs new file mode 100644 index 000000000..7e3b1ae00 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Models.cs @@ -0,0 +1,33 @@ +namespace DeepFieldChain; + +public class City +{ + public string ZipCode { get; set; } + + public string GetName() + { + return "city"; + } +} + +public class Address +{ + public City City { get; set; } + public string Street { 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-deep-field-chain/Service.cs b/gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Service.cs new file mode 100644 index 000000000..52e8594bc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-deep-field-chain/Service.cs @@ -0,0 +1,13 @@ +namespace DeepFieldChain; + +public class Service +{ + public static void ProcessUser(User user) + { + // 2-level chain: user.Address → Address, then .Save() → Address#Save + user.Address.Save(); + + // 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName + user.Address.City.GetName(); + } +} 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/field-types/models.ts b/gitnexus/test/fixtures/lang-resolution/field-types/models.ts new file mode 100644 index 000000000..4ac2d2109 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/field-types/models.ts @@ -0,0 +1,24 @@ +export class Address { + city: string; + + save(): void { + // persist address + } +} + +export class User { + name: string; + address: Address; + + greet(): string { + return this.name; + } +} + +export class Config { + static DEFAULT: Config = new Config(); + + validate(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/field-types/service.ts b/gitnexus/test/fixtures/lang-resolution/field-types/service.ts new file mode 100644 index 000000000..ecb00b374 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/field-types/service.ts @@ -0,0 +1,11 @@ +import { User, Config } from './models'; + +function processUser(user: User) { + // Field-access chain: user.address resolves to Address, then .save() resolves to Address#save + user.address.save(); +} + +function validateConfig() { + // Static field access: Config.DEFAULT resolves to Config, then .validate() resolves to Config#validate + Config.DEFAULT.validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/cmd/main.go new file mode 100644 index 000000000..c848c9368 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/cmd/main.go @@ -0,0 +1,11 @@ +package main + +import "example.com/go-deep-field-chain/models" + +func processUser(user models.User) { + // 2-level chain: user.Address → Address, then .Save() → Address#Save + user.Address.Save() + + // 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName + user.Address.City.GetName() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/go.mod b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/go.mod new file mode 100644 index 000000000..f9f8e3511 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/go.mod @@ -0,0 +1,3 @@ +module example.com/go-deep-field-chain + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/models/models.go b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/models/models.go new file mode 100644 index 000000000..ac615e385 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-deep-field-chain/models/models.go @@ -0,0 +1,27 @@ +package models + +type City struct { + ZipCode string +} + +func (c *City) GetName() string { + return "city" +} + +type Address struct { + City City + Street 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/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/go-mixed-chain/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/cmd/main.go new file mode 100644 index 000000000..5269dd6cf --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/cmd/main.go @@ -0,0 +1,11 @@ +package main + +import "example.com/go-mixed-chain/models" + +func processWithService(svc *models.UserService) { + svc.GetUser().Address.Save() +} + +func processWithUser(user *models.User) { + user.GetAddress().City.GetName() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/go.mod b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/go.mod new file mode 100644 index 000000000..e0b17c82b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/go.mod @@ -0,0 +1,3 @@ +module example.com/go-mixed-chain + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/models/models.go b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/models/models.go new file mode 100644 index 000000000..7e19915c7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-mixed-chain/models/models.go @@ -0,0 +1,32 @@ +package models + +type City struct { + Name string +} + +func (c *City) GetName() string { + return c.Name +} + +type Address struct { + City City + Street string +} + +func (a *Address) Save() { +} + +type User struct { + Name string + Address Address +} + +func (u *User) GetAddress() *Address { + return &u.Address +} + +type UserService struct{} + +func (s *UserService) GetUser() *User { + return &User{} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/App.java b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/App.java new file mode 100644 index 000000000..a158e6168 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/App.java @@ -0,0 +1,11 @@ +import models.User; + +public class App { + public static void processUser(User user) { + // 2-level chain: user.address → Address, then .save() → Address#save + user.address.save(); + + // 3-level chain: user.address → Address, .city → City, .getName() → City#getName + user.address.city.getName(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/Address.java b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/Address.java new file mode 100644 index 000000000..c89d8132c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/Address.java @@ -0,0 +1,10 @@ +package models; + +public class Address { + public City city; + public String street; + + public void save() { + // persist address + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/City.java b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/City.java new file mode 100644 index 000000000..c843c5ccc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/City.java @@ -0,0 +1,9 @@ +package models; + +public class City { + public String zipCode; + + public String getName() { + return "city"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/models/User.java new file mode 100644 index 000000000..dc3b7e76a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-deep-field-chain/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/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/java-mixed-chain/App.java b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/App.java new file mode 100644 index 000000000..c3d0eeff4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/App.java @@ -0,0 +1,12 @@ +import services.UserService; +import models.User; + +public class App { + public static void processWithService(UserService svc) { + svc.getUser().address.save(); + } + + public static void processWithUser(User user) { + user.getAddress().city.getName(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/Address.java b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/Address.java new file mode 100644 index 000000000..b37a7f78d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/Address.java @@ -0,0 +1,8 @@ +package models; + +public class Address { + public City city; + + public void save() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/City.java b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/City.java new file mode 100644 index 000000000..c75cf7e2e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/City.java @@ -0,0 +1,7 @@ +package models; + +public class City { + public String getName() { + return "city"; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/User.java new file mode 100644 index 000000000..3a9355ded --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/models/User.java @@ -0,0 +1,9 @@ +package models; + +public class User { + public Address address; + + public Address getAddress() { + return this.address; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/services/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/services/UserService.java new file mode 100644 index 000000000..fe18a97b0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-mixed-chain/services/UserService.java @@ -0,0 +1,9 @@ +package services; + +import models.User; + +public class UserService { + public User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/js-field-types/models.js b/gitnexus/test/fixtures/lang-resolution/js-field-types/models.js new file mode 100644 index 000000000..4d9d45fa4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-field-types/models.js @@ -0,0 +1,26 @@ +class Address { + city = ''; + + save() { + // persist address + } +} + +class User { + name = ''; + address = new Address(); + + greet() { + return this.name; + } +} + +class Config { + static DEFAULT = new Config(); + + validate() { + return true; + } +} + +module.exports = { Address, User, Config }; diff --git a/gitnexus/test/fixtures/lang-resolution/js-field-types/service.js b/gitnexus/test/fixtures/lang-resolution/js-field-types/service.js new file mode 100644 index 000000000..9f9494621 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/js-field-types/service.js @@ -0,0 +1,9 @@ +const { User, Config } = require('./models'); + +function processUser(user) { + user.address.save(); +} + +function validateConfig() { + Config.DEFAULT.validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Models.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Models.kt new file mode 100644 index 000000000..837c23633 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Models.kt @@ -0,0 +1,13 @@ +class Address { + var city: String = "" + + fun save() { + // persist address + } +} + +data class User( + val name: String, + val address: Address, + val age: Int +) diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Service.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/Service.kt new file mode 100644 index 000000000..706294126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-data-class-fields/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/kotlin-deep-field-chain/Models.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Models.kt new file mode 100644 index 000000000..44e658a3b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Models.kt @@ -0,0 +1,25 @@ +class City { + var zipCode: String = "" + + fun getName(): String { + return "city" + } +} + +class Address { + var city: City = City() + var street: 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-deep-field-chain/Service.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Service.kt new file mode 100644 index 000000000..b07cdc865 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-deep-field-chain/Service.kt @@ -0,0 +1,7 @@ +fun processUser(user: User) { + // 2-level chain: user.address → Address, then .save() → Address#save + user.address.save() + + // 3-level chain: user.address → Address, .city → City, .getName() → City#getName + user.address.city.getName() +} 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-constructor-promotion-fields/Models.php b/gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Models.php new file mode 100644 index 000000000..03eebc7b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Models.php @@ -0,0 +1,20 @@ +name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Service.php b/gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Service.php new file mode 100644 index 000000000..5744b4da7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-constructor-promotion-fields/Service.php @@ -0,0 +1,8 @@ +address → Address, then ->save() → Address#save + $user->address->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Models.php b/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Models.php new file mode 100644 index 000000000..1a91ced7a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Models.php @@ -0,0 +1,34 @@ +name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Service.php b/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Service.php new file mode 100644 index 000000000..05cd4b017 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-deep-field-chain/Service.php @@ -0,0 +1,11 @@ +address → Address, then ->save() → Address#save + $user->address->save(); + + // 3-level chain: $user->address → Address, ->city → City, ->getName() → City#getName + $user->address->city->getName(); + } +} 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/fixtures/lang-resolution/python-field-type-disambig/address.py b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/address.py new file mode 100644 index 000000000..83754b8df --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/address.py @@ -0,0 +1,5 @@ +class Address: + city: str + + def save(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/service.py b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/service.py new file mode 100644 index 000000000..443bce2bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/service.py @@ -0,0 +1,6 @@ +from user import User + +def process_user(user: User): + # Field-access chain: user.address → Address, then .save() must resolve + # to Address#save (NOT User#save) — only lookupFieldByOwner can disambiguate. + user.address.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/user.py b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/user.py new file mode 100644 index 000000000..3681631d5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-field-type-disambig/user.py @@ -0,0 +1,8 @@ +from address import Address + +class User: + name: str + address: Address + + def save(self): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-field-types/models.py b/gitnexus/test/fixtures/lang-resolution/python-field-types/models.py new file mode 100644 index 000000000..fccf42ae2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-field-types/models.py @@ -0,0 +1,12 @@ +class Address: + city: str + + def save(self): + pass + +class User: + name: str + address: Address + + def greet(self) -> str: + return self.name diff --git a/gitnexus/test/fixtures/lang-resolution/python-field-types/service.py b/gitnexus/test/fixtures/lang-resolution/python-field-types/service.py new file mode 100644 index 000000000..f7c2a8d74 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-field-types/service.py @@ -0,0 +1,4 @@ +from models import User + +def process_user(user: User): + user.address.save() diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/address.rb b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/address.rb new file mode 100644 index 000000000..6d96cbb10 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/address.rb @@ -0,0 +1,8 @@ +class Address + # @return [String] + attr_accessor :city + + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/service.rb new file mode 100644 index 000000000..30c7ffd2b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/service.rb @@ -0,0 +1,8 @@ +require_relative 'user' + +# @param user [User] +def process_user(user) + # Field-access chain: user.address → Address, then .save → Address#save + # Both User and Address have save — only lookupFieldByOwner can disambiguate. + user.address.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/user.rb new file mode 100644 index 000000000..242e358d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-field-type-disambig/user.rb @@ -0,0 +1,13 @@ +require_relative 'address' + +class User + # @return [String] + attr_accessor :name + + # @return [Address] + attr_accessor :address + + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-field-types/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-field-types/models.rb new file mode 100644 index 000000000..a4bd432a7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-field-types/models.rb @@ -0,0 +1,20 @@ +class Address + # @return [String] + attr_accessor :city + + def save + true + end +end + +class User + # @return [String] + attr_accessor :name + + # @return [Address] + attr_accessor :address + + def greet + name + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-field-types/service.rb b/gitnexus/test/fixtures/lang-resolution/ruby-field-types/service.rb new file mode 100644 index 000000000..a7da3e692 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-field-types/service.rb @@ -0,0 +1,7 @@ +require_relative 'models' + +# @param user [User] +def process_user(user) + # Field-access chain: user.address → Address, then .save → Address#save + user.address.save +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/models.rs new file mode 100644 index 000000000..03f26eda1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/models.rs @@ -0,0 +1,25 @@ +pub struct City { + pub zip_code: String, +} + +impl City { + pub fn get_name(&self) -> &str { + "city" + } +} + +pub struct Address { + pub city: City, + pub street: String, +} + +impl Address { + pub fn save(&self) { + // persist address + } +} + +pub struct User { + pub name: String, + pub address: Address, +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/service.rs b/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/service.rs new file mode 100644 index 000000000..005e90695 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-deep-field-chain/service.rs @@ -0,0 +1,6 @@ +use crate::models::{User, Address, City}; + +fn process_user(user: &User) { + user.address.save(); + user.address.city.get_name(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-field-types/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-field-types/models.rs new file mode 100644 index 000000000..3d9f571ff --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-field-types/models.rs @@ -0,0 +1,20 @@ +pub struct Address { + pub city: String, +} + +impl Address { + pub fn save(&self) { + // persist address + } +} + +pub struct User { + pub name: String, + pub address: Address, +} + +impl User { + pub fn greet(&self) -> &str { + &self.name + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-field-types/service.rs b/gitnexus/test/fixtures/lang-resolution/rust-field-types/service.rs new file mode 100644 index 000000000..be2ae66f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-field-types/service.rs @@ -0,0 +1,5 @@ +use crate::models::{User, Address}; + +fn process_user(user: &User) { + user.address.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/models.ts new file mode 100644 index 000000000..b28b0a6ee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/models.ts @@ -0,0 +1,25 @@ +export class City { + zipCode: string; + + getName(): string { + return 'city'; + } +} + +export class Address { + city: City; + street: string; + + save(): void { + // persist address + } +} + +export class User { + name: string; + address: Address; + + greet(): string { + return this.name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/service.ts new file mode 100644 index 000000000..bf561827d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-deep-field-chain/service.ts @@ -0,0 +1,9 @@ +import { User } from './models'; + +function processUser(user: User) { + // 2-level chain: user.address → Address, then .save() → Address#save + user.address.save(); + + // 3-level chain: user.address → Address, .city → City, .getName() → City#getName + user.address.city.getName(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/address.ts b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/address.ts new file mode 100644 index 000000000..68559457c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/address.ts @@ -0,0 +1,7 @@ +export class Address { + city: string; + + save(): void { + // persist address + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/service.ts new file mode 100644 index 000000000..4c78a3fa2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/service.ts @@ -0,0 +1,7 @@ +import { User } from './user'; + +function processUser(user: User) { + // Field-access chain: user.address resolves to Address, then .save() must resolve + // to Address#save (NOT User#save) — only lookupFieldByOwner can disambiguate. + user.address.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/user.ts new file mode 100644 index 000000000..6386ecbf7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-field-type-disambig/user.ts @@ -0,0 +1,10 @@ +import { Address } from './address'; + +export class User { + name: string; + address: Address; + + save(): void { + // persist user + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/models.ts new file mode 100644 index 000000000..baeb43deb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/models.ts @@ -0,0 +1,27 @@ +export class City { + getName(): string { + return 'city'; + } +} + +export class Address { + city: City; + + save(): void { + // persist address + } +} + +export class User { + address: Address; + + getAddress(): Address { + return this.address; + } +} + +export class UserService { + getUser(): User { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/service.ts new file mode 100644 index 000000000..227d87fbb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-mixed-chain/service.ts @@ -0,0 +1,11 @@ +import { User, UserService } from './models'; + +function processWithService(svc: UserService) { + // call → field → call: svc.getUser().address.save() + svc.getUser().address.save(); +} + +function processWithUser(user: User) { + // field → call → call: user.getAddress().city.getName() + user.getAddress().city.getName(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/models.ts b/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/models.ts new file mode 100644 index 000000000..513af1e9f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/models.ts @@ -0,0 +1,22 @@ +export class Address { + city: string; + + save(): void { + // persist address + } +} + +export class User { + #secret: string; + + constructor( + public name: string, + public address: Address, + ) { + this.#secret = 'hidden'; + } + + greet(): string { + return this.name; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/service.ts b/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/service.ts new file mode 100644 index 000000000..e11269c12 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-param-property-fields/service.ts @@ -0,0 +1,5 @@ +import { User } from './models'; + +function processUser(user: User) { + user.address.save(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 23aca2322..55810aad5 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -812,3 +812,126 @@ describe('C++ pointer dereference in range-for', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +describe('Field type resolution (C++)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-field-types'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for C++ data member fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking fields to classes', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(propEdges.length).toBeGreaterThanOrEqual(2); + expect(edgeSet(propEdges)).toContain('User → address'); + 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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (C++)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']); + }); + + it('detects Property nodes for all typed fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('Address → city'); + expect(edgeSet(propEdges)).toContain('City → zipCode'); + }); + + it('resolves 2-level chain: user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.address.city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models')); + expect(cityGetName).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Pointer and reference member fields (Address* address; Address& ref_address;) +// --------------------------------------------------------------------------- + +describe('C++ pointer/reference member field capture', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-pointer-ref-fields'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for pointer and reference member fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('ref_address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges for pointer/reference fields', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('User → ref_address'); + expect(edgeSet(propEdges)).toContain('User → name'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 2275e908c..1fbabd089 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1193,3 +1193,93 @@ describe('C# nested member access foreach (this.data.Values)', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +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'); + expect(properties).toContain('Address'); + expect(properties).toContain('Name'); + expect(properties).toContain('City'); + }); + + it('emits HAS_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (C#)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, Service, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'Service', 'User']); + }); + + it('detects Property nodes for C# properties', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('Address'); + expect(properties).toContain('City'); + expect(properties).toContain('ZipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → Address'); + expect(edgeSet(propEdges)).toContain('Address → City'); + expect(edgeSet(propEdges)).toContain('City → ZipCode'); + }); + + it('resolves 2-level chain: user.Address.Save() → Address#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'Save' && e.source === 'ProcessUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.Address.City.GetName() → City#GetName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'GetName' && e.source === 'ProcessUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models')); + expect(cityGetName).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 737235666..22955a65c 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -940,3 +940,127 @@ describe('Go for-loop call_expression iterable resolution (Phase 7.3)', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +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_PROPERTY edges linking struct fields to structs', () => { + const propEdges = getRelationships(result, 'HAS_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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (Go)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects structs: Address, City, User', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User']); + }); + + it('detects Property nodes for Go struct fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('Address'); + expect(properties).toContain('City'); + expect(properties).toContain('ZipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(propEdges.length).toBeGreaterThanOrEqual(3); + }); + + it('resolves 2-level chain: user.Address.Save() → Address#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'Save' && e.source === 'processUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.Address.City.GetName() → City#GetName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'GetName' && e.source === 'processUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models')); + expect(cityGetName).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Mixed field+call chain resolution (Go) +// --------------------------------------------------------------------------- + +describe('Mixed field+call chain resolution (Go)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-mixed-chain'), + () => {}, + ); + }, 60000); + + it('detects structs: Address, City, User, UserService', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User', 'UserService']); + }); + + it('detects Property nodes for mixed-chain fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('City'); + expect(properties).toContain('Address'); + }); + + it('resolves call→field chain: svc.GetUser().Address.Save() → Address#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'Save' && e.source === 'processWithService'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('models'); + }); + + it('resolves field→call chain: user.GetAddress().City.GetName() → City#GetName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'GetName' && e.source === 'processWithUser'); + expect(getNameCalls.length).toBe(1); + expect(getNameCalls[0].targetFilePath).toContain('models'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index a65102084..096732d23 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1068,3 +1068,132 @@ describe('Java foreach call_expression iterable resolution (Phase 7.3)', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +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_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (Java)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, App, City, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User']); + }); + + it('detects Property nodes for Java fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('Address → city'); + expect(edgeSet(propEdges)).toContain('City → zipCode'); + }); + + it('resolves 2-level chain: user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('Address')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.address.city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('City')); + expect(cityGetName).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Mixed field+call chain resolution (Java) +// --------------------------------------------------------------------------- + +describe('Mixed field+call chain resolution (Java)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-mixed-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, App, City, User, UserService', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'App', 'City', 'User', 'UserService']); + }); + + it('detects Property nodes for mixed-chain fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('city'); + expect(properties).toContain('address'); + }); + + it('resolves call→field chain: svc.getUser().address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processWithService'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('Address'); + }); + + it('resolves field→call chain: user.getAddress().city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processWithUser'); + expect(getNameCalls.length).toBe(1); + expect(getNameCalls[0].targetFilePath).toContain('City'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 58f69e8bd..ee6bd8d8e 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import path from 'path'; import { - FIXTURES, getRelationships, getNodesByLabel, + FIXTURES, getRelationships, getNodesByLabel, edgeSet, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -234,3 +234,37 @@ describe('JavaScript chained method call resolution', () => { expect(repoSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution — class field_definition capture +// --------------------------------------------------------------------------- + +describe('Field type resolution (JavaScript)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'js-field-types'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, Config, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Config', 'User']); + }); + + it('detects Property nodes for JS class fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking fields to classes', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(propEdges.length).toBeGreaterThanOrEqual(3); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('User → name'); + expect(edgeSet(propEdges)).toContain('Address → city'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 171872c95..4a4396f44 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1218,3 +1218,135 @@ describe('Kotlin for-loop call_expression iterable resolution (Phase 7.3)', () = expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +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'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (Kotlin)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']); + }); + + it('detects Property nodes for Kotlin properties', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('Address → city'); + expect(edgeSet(propEdges)).toContain('City → zipCode'); + }); + + it('resolves 2-level chain: user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.address.city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models')); + expect(cityGetName).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Kotlin data class primary constructor val/var properties +// --------------------------------------------------------------------------- + +describe('Kotlin data class primary constructor property capture', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-data-class-fields'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for data class val parameters', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('name'); + expect(properties).toContain('address'); + expect(properties).toContain('age'); + }); + + it('emits HAS_PROPERTY edges for primary constructor properties', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → name'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('User → age'); + }); + + it('resolves user.address.save() → Address#save via data class 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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index a8cc100f7..f01e60de9 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -1161,3 +1161,128 @@ describe('PHP foreach call_expression iterable resolution (Phase 7.3)', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +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'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (PHP)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, Service, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'Service', 'User']); + }); + + it('detects Property nodes for PHP properties', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(propEdges.length).toBeGreaterThanOrEqual(3); + }); + + it('resolves 2-level chain: $user->address->save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('Models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: $user->address->city->getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('Models')); + expect(cityGetName).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PHP 8.0+ constructor promotion as property declarations +// --------------------------------------------------------------------------- + +describe('PHP constructor promotion property capture', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-constructor-promotion-fields'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, Service, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Service', 'User']); + }); + + it('detects Property nodes for promoted constructor parameters', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('name'); + expect(properties).toContain('address'); + }); + + it('emits HAS_PROPERTY edges for promoted parameters', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → name'); + expect(edgeSet(propEdges)).toContain('User → address'); + }); + + it('resolves $user->address->save() → Address#save via promoted 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(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index c31531124..61de82bcf 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -1281,3 +1281,77 @@ describe('Python enumerate() for-loop resolution', () => { expect(userSave).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution — annotated attribute capture +// --------------------------------------------------------------------------- + +describe('Field type resolution (Python)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-field-types'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for Python annotated attributes', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking attributes to classes', () => { + const propEdges = getRelationships(result, 'HAS_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 === 'process_user' && e.targetFilePath.includes('models'), + ); + expect(addressSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8: Field type disambiguation — both User and Address have save() +// --------------------------------------------------------------------------- + +describe('Field type disambiguation (Python)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-field-type-disambig'), + () => {}, + ); + }, 60000); + + it('detects both User#save and Address#save', () => { + const methods = getNodesByLabel(result, 'Function'); + const saveMethods = methods.filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.address.save() → Address#save (not User#save)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter( + e => e.target === 'save' && e.source === 'process_user', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('address'); + expect(saveCalls[0].targetFilePath).not.toContain('user'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index f153dedf0..752efaf5d 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -124,15 +124,15 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(props).toContain('email'); }); - it('emits HAS_METHOD from User to attr_reader :name', () => { - const hasMethod = getRelationships(result, 'HAS_METHOD'); - const edge = hasMethod.find(e => e.source === 'User' && e.target === 'name'); + it('emits HAS_PROPERTY from User to attr_reader :name', () => { + const hasProperty = getRelationships(result, 'HAS_PROPERTY'); + const edge = hasProperty.find(e => e.source === 'User' && e.target === 'name'); expect(edge).toBeDefined(); }); - it('emits HAS_METHOD from BaseModel to attr_accessor :id', () => { - const hasMethod = getRelationships(result, 'HAS_METHOD'); - const edge = hasMethod.find(e => e.source === 'BaseModel' && e.target === 'id'); + it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { + const hasProperty = getRelationships(result, 'HAS_PROPERTY'); + const edge = hasProperty.find(e => e.source === 'BaseModel' && e.target === 'id'); expect(edge).toBeDefined(); }); @@ -846,3 +846,77 @@ describe('Ruby for-in loop resolution', () => { expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution via YARD @return annotations +// --------------------------------------------------------------------------- + +describe('Field type resolution (Ruby)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-field-types'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for attr_accessor fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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 YARD @return [Address]', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save'); + const addressSave = saveCalls.find( + e => e.source === 'process_user' && e.targetFilePath.includes('models'), + ); + expect(addressSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8: Field type disambiguation — both User and Address have save() +// --------------------------------------------------------------------------- + +describe('Field type disambiguation (Ruby)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-field-type-disambig'), + () => {}, + ); + }, 60000); + + it('detects both User#save and Address#save', () => { + const methods = getNodesByLabel(result, 'Method'); + const saveMethods = methods.filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.address.save → Address#save (not User#save)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter( + e => e.target === 'save' && e.source === 'process_user', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('address'); + expect(saveCalls[0].targetFilePath).not.toContain('user'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index f44a076c3..c2bed8049 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -832,10 +832,25 @@ describe('Rust nullable receiver resolution (Option)', () => { expect(saveFns.length).toBe(2); }); - // Known limitation: user.unwrap().save() chains two method calls. unwrap() - // returns User but TypeEnv doesn't track intermediate return values in chains. - // Disambiguating through .unwrap() requires chained return type inference (Phase 5). - it.todo('resolves user.unwrap().save() to User.save (requires chained call inference)'); + it('resolves user.unwrap().save() to User#save via Option unwrapping', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => + c.target === 'save' && + c.source === 'process_entities' && + c.targetFilePath?.includes('user'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.unwrap().save() to Repo#save via Option unwrapping', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => + c.target === 'save' && + c.source === 'process_entities' && + c.targetFilePath?.includes('repo'), + ); + expect(repoSave).toBeDefined(); + }); }); // --------------------------------------------------------------------------- @@ -1279,3 +1294,88 @@ describe('Rust for-loop direct call_expression iterable resolution (Phase 7.3)', expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution — struct field capture +// --------------------------------------------------------------------------- + +describe('Field type resolution (Rust)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-field-types'), + () => {}, + ); + }, 60000); + + it('detects structs: Address, User', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']); + }); + + it('detects Property nodes for Rust struct fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking fields to structs', () => { + const propEdges = getRelationships(result, 'HAS_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' && e.source === 'process_user', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('models'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8B: Deep field chain resolution (3-level) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (Rust)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects structs: Address, City, User', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User']); + }); + + it('detects Property nodes for Rust struct fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zip_code'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(propEdges.length).toBeGreaterThanOrEqual(3); + }); + + it('resolves 2-level chain: user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'process_user'); + const addressSave = saveCalls.find(e => e.targetFilePath.includes('models')); + expect(addressSave).toBeDefined(); + }); + + it('resolves 3-level chain: user.address.city.get_name() → City#get_name', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'get_name' && e.source === 'process_user'); + const cityGetName = getNameCalls.find(e => e.targetFilePath.includes('models')); + expect(cityGetName).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 0dae92fd0..04fc472bc 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -64,6 +64,12 @@ describe('TypeScript heritage resolution', () => { ]); }); + it('emits HAS_PROPERTY edge for class fields', () => { + const hasProperty = getRelationships(result, 'HAS_PROPERTY'); + expect(hasProperty.length).toBe(1); + expect(edgeSet(hasProperty)).toEqual(['BaseService → name']); + }); + it('no OVERRIDES edges target Property nodes', () => { const overrides = getRelationships(result, 'OVERRIDES'); for (const edge of overrides) { @@ -1692,3 +1698,205 @@ describe('TypeScript for-of call_expression iterable resolution (Phase 7.3)', () expect(wrongSave).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Phase 8: Field/property type resolution (1-level) +// --------------------------------------------------------------------------- + +describe('Field type resolution (TypeScript)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'field-types'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, Config, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'Config', 'User']); + }); + + it('detects Property nodes for typed fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('name'); + expect(properties).toContain('city'); + }); + + it('emits HAS_PROPERTY edges linking properties to classes', () => { + const propEdges = getRelationships(result, 'HAS_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.targetFilePath.includes('models')); + expect(addressSave).toBeDefined(); + expect(addressSave!.source).toBe('processUser'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8: Field type disambiguation — both User and Address have save() +// --------------------------------------------------------------------------- + +describe('Field type disambiguation (TypeScript)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-field-type-disambig'), + () => {}, + ); + }, 60000); + + it('detects both User#save and Address#save', () => { + const methods = getNodesByLabel(result, 'Method'); + const saveMethods = methods.filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.address.save() → Address#save (not User#save)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter( + e => e.target === 'save' && e.source === 'processUser', + ); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('address'); + expect(saveCalls[0].targetFilePath).not.toContain('user'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8: Parameter properties and #private fields +// --------------------------------------------------------------------------- + +describe('Field type resolution (TS parameter properties)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-param-property-fields'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); + }); + + it('captures constructor parameter properties as Property nodes', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('name'); + expect(properties).toContain('address'); + }); + + it('captures #private fields as Property nodes', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('#secret'); + }); + + it('emits HAS_PROPERTY edges for parameter properties', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → name'); + expect(edgeSet(propEdges)).toContain('User → address'); + }); + + it('resolves user.address.save() via parameter property type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('models'); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 8A: Deep field chain resolution (3-level: user.address.city.getName()) +// --------------------------------------------------------------------------- + +describe('Deep field chain resolution (TypeScript)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-deep-field-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, User', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User']); + }); + + it('detects Property nodes for all typed fields', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('address'); + expect(properties).toContain('city'); + expect(properties).toContain('zipCode'); + }); + + it('emits HAS_PROPERTY edges for nested type chain', () => { + const propEdges = getRelationships(result, 'HAS_PROPERTY'); + expect(edgeSet(propEdges)).toContain('User → address'); + expect(edgeSet(propEdges)).toContain('Address → city'); + expect(edgeSet(propEdges)).toContain('City → zipCode'); + }); + + it('resolves 2-level chain: user.address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processUser'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('models'); + }); + + it('resolves 3-level chain: user.address.city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processUser'); + expect(getNameCalls.length).toBe(1); + expect(getNameCalls[0].targetFilePath).toContain('models'); + }); +}); + +// --------------------------------------------------------------------------- +// Mixed chain resolution (field ↔ call interleaved) +// --------------------------------------------------------------------------- + +describe('Mixed field+call chain resolution (TypeScript)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-mixed-chain'), + () => {}, + ); + }, 60000); + + it('detects classes: Address, City, User, UserService', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'City', 'User', 'UserService']); + }); + + it('detects Property node for Address.city field', () => { + const properties = getNodesByLabel(result, 'Property'); + expect(properties).toContain('city'); + expect(properties).toContain('address'); + }); + + it('resolves call→field chain: svc.getUser().address.save() → Address#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(e => e.target === 'save' && e.source === 'processWithService'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toContain('models'); + }); + + it('resolves field→call chain: user.getAddress().city.getName() → City#getName', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCalls = calls.filter(e => e.target === 'getName' && e.source === 'processWithUser'); + expect(getNameCalls.length).toBe(1); + expect(getNameCalls[0].targetFilePath).toContain('models'); + }); +}); diff --git a/gitnexus/test/unit/mro-processor.test.ts b/gitnexus/test/unit/mro-processor.test.ts index 78ca0bf14..2e4d04a59 100644 --- a/gitnexus/test/unit/mro-processor.test.ts +++ b/gitnexus/test/unit/mro-processor.test.ts @@ -292,19 +292,19 @@ describe('computeMRO', () => { addExtends(graph, 'Child', 'ParentA'); addExtends(graph, 'Child', 'ParentB'); - // Add Property nodes (same name 'name') to both parents via HAS_METHOD + // Add Property nodes (same name 'name') to both parents via HAS_PROPERTY const propA = generateId('Property', 'ParentA.name'); graph.addNode({ id: propA, label: 'Property', properties: { name: 'name', filePath: 'src/ParentA.ts' } }); graph.addRelationship({ - id: generateId('HAS_METHOD', `${parentA}->${propA}`), - sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '', + id: generateId('HAS_PROPERTY', `${parentA}->${propA}`), + sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); const propB = generateId('Property', 'ParentB.name'); graph.addNode({ id: propB, label: 'Property', properties: { name: 'name', filePath: 'src/ParentB.ts' } }); graph.addRelationship({ - id: generateId('HAS_METHOD', `${parentB}->${propB}`), - sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '', + id: generateId('HAS_PROPERTY', `${parentB}->${propB}`), + sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); const result = computeMRO(graph); @@ -328,19 +328,19 @@ describe('computeMRO', () => { const methodA = addMethod(graph, 'PA', 'doWork'); addMethod(graph, 'PB', 'doWork'); - // Property collision (should NOT trigger OVERRIDES) + // Property collision (should NOT trigger OVERRIDES — properties use HAS_PROPERTY, not HAS_METHOD) const propA = generateId('Property', 'PA.id'); graph.addNode({ id: propA, label: 'Property', properties: { name: 'id', filePath: 'src/PA.ts' } }); graph.addRelationship({ - id: generateId('HAS_METHOD', `${parentA}->${propA}`), - sourceId: parentA, targetId: propA, type: 'HAS_METHOD', confidence: 1.0, reason: '', + id: generateId('HAS_PROPERTY', `${parentA}->${propA}`), + sourceId: parentA, targetId: propA, type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); const propB = generateId('Property', 'PB.id'); graph.addNode({ id: propB, label: 'Property', properties: { name: 'id', filePath: 'src/PB.ts' } }); graph.addRelationship({ - id: generateId('HAS_METHOD', `${parentB}->${propB}`), - sourceId: parentB, targetId: propB, type: 'HAS_METHOD', confidence: 1.0, reason: '', + id: generateId('HAS_PROPERTY', `${parentB}->${propB}`), + sourceId: parentB, targetId: propB, type: 'HAS_PROPERTY', confidence: 1.0, reason: '', }); const result = computeMRO(graph); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 15f2a9832..87e369652 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -122,11 +122,11 @@ describe('LadybugDB Schema', () => { it('has all FROM/TO pairs needed for HAS_METHOD edges', () => { // HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record - // HAS_METHOD targets: Method, Constructor, Property + // HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY) const sources = ['Class', 'Interface']; const backtickSources = ['Struct', 'Trait', 'Impl', 'Record']; const targets = ['Method']; - const backtickTargets = ['Constructor', 'Property']; + const backtickTargets = ['Constructor']; // Non-backtick source → non-backtick target for (const src of sources) { diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index f4344c37d..7a3ffd1ab 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -96,13 +96,14 @@ describe('isWriteQuery', () => { // ─── Relation type allowlist ────────────────────────────────────────── describe('VALID_RELATION_TYPES', () => { - it('contains exactly the expected 6 types', () => { - expect(VALID_RELATION_TYPES.size).toBe(6); + it('contains exactly the expected 7 types', () => { + expect(VALID_RELATION_TYPES.size).toBe(7); expect(VALID_RELATION_TYPES.has('CALLS')).toBe(true); expect(VALID_RELATION_TYPES.has('IMPORTS')).toBe(true); expect(VALID_RELATION_TYPES.has('EXTENDS')).toBe(true); expect(VALID_RELATION_TYPES.has('IMPLEMENTS')).toBe(true); expect(VALID_RELATION_TYPES.has('HAS_METHOD')).toBe(true); + expect(VALID_RELATION_TYPES.has('HAS_PROPERTY')).toBe(true); expect(VALID_RELATION_TYPES.has('OVERRIDES')).toBe(true); }); diff --git a/gitnexus/test/unit/symbol-table.test.ts b/gitnexus/test/unit/symbol-table.test.ts index acf204ada..8d52d28bb 100644 --- a/gitnexus/test/unit/symbol-table.test.ts +++ b/gitnexus/test/unit/symbol-table.test.ts @@ -137,14 +137,187 @@ describe('SymbolTable', () => { }); }); + describe('declaredType metadata', () => { + it('stores declaredType in SymbolDefinition', () => { + table.add('src/models.ts', 'address', 'prop:address', 'Property', { + declaredType: 'Address', + ownerId: 'class:User', + }); + const def = table.lookupExactFull('src/models.ts', 'address'); + expect(def).toBeDefined(); + expect(def!.declaredType).toBe('Address'); + }); + + it('omits declaredType when not provided', () => { + table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); + const def = table.lookupExactFull('src/models.ts', 'name'); + expect(def).toBeDefined(); + expect(def!.declaredType).toBeUndefined(); + }); + }); + + describe('Property exclusion from globalIndex', () => { + it('Property with ownerId is NOT added to globalIndex', () => { + table.add('src/models.ts', 'name', 'prop:name', 'Property', { + declaredType: 'string', + ownerId: 'class:User', + }); + // Should not appear in fuzzy lookup + expect(table.lookupFuzzy('name')).toEqual([]); + // But should still be in fileIndex + expect(table.lookupExact('src/models.ts', 'name')).toBe('prop:name'); + }); + + it('Property without ownerId IS added to globalIndex', () => { + table.add('src/models.ts', 'name', 'prop:name', 'Property'); + expect(table.lookupFuzzy('name')).toHaveLength(1); + }); + + it('Property without declaredType is still added to fieldByOwner index only (not globalIndex)', () => { + table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); + // No declaredType → not in fieldByOwner, but still excluded from globalIndex + expect(table.lookupFuzzy('name')).toEqual([]); + expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('non-Property types are always added to globalIndex', () => { + table.add('src/models.ts', 'save', 'method:save', 'Method', { ownerId: 'class:User' }); + expect(table.lookupFuzzy('save')).toHaveLength(1); + }); + }); + + describe('conditional callableIndex invalidation', () => { + it('adding a Function invalidates callableIndex', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function', { returnType: 'void' }); + // First call builds the index + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Add another callable — should invalidate and rebuild + table.add('src/a.ts', 'bar', 'func:bar', 'Method'); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + }); + + it('adding a Property does NOT invalidate callableIndex', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function'); + // Build callable index + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + // Add a Property — callable index should still be valid (foo still found) + table.add('src/models.ts', 'name', 'prop:name', 'Property', { + declaredType: 'string', + ownerId: 'class:User', + }); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + }); + + it('adding a Class does NOT invalidate callableIndex', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function'); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + table.add('src/models.ts', 'User', 'class:User', 'Class'); + // Class is not callable, should not trigger rebuild + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + }); + }); + + describe('lookupFieldByOwner', () => { + it('finds a Property by ownerNodeId and fieldName', () => { + table.add('src/models.ts', 'address', 'prop:address', 'Property', { + declaredType: 'Address', + ownerId: 'class:User', + }); + const def = table.lookupFieldByOwner('class:User', 'address'); + expect(def).toBeDefined(); + expect(def!.declaredType).toBe('Address'); + expect(def!.nodeId).toBe('prop:address'); + }); + + it('returns undefined for unknown owner', () => { + table.add('src/models.ts', 'address', 'prop:address', 'Property', { + declaredType: 'Address', + ownerId: 'class:User', + }); + expect(table.lookupFieldByOwner('class:Unknown', 'address')).toBeUndefined(); + }); + + it('returns undefined for unknown field name', () => { + table.add('src/models.ts', 'address', 'prop:address', 'Property', { + declaredType: 'Address', + ownerId: 'class:User', + }); + expect(table.lookupFieldByOwner('class:User', 'email')).toBeUndefined(); + }); + + it('returns undefined for empty table', () => { + expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('does not index Property without declaredType', () => { + table.add('src/models.ts', 'name', 'prop:name', 'Property', { ownerId: 'class:User' }); + expect(table.lookupFieldByOwner('class:User', 'name')).toBeUndefined(); + }); + + it('distinguishes fields by owner', () => { + table.add('src/models.ts', 'name', 'prop:user:name', 'Property', { + declaredType: 'string', + ownerId: 'class:User', + }); + table.add('src/models.ts', 'name', 'prop:repo:name', 'Property', { + declaredType: 'RepoName', + ownerId: 'class:Repo', + }); + expect(table.lookupFieldByOwner('class:User', 'name')!.declaredType).toBe('string'); + expect(table.lookupFieldByOwner('class:Repo', 'name')!.declaredType).toBe('RepoName'); + }); + }); + + describe('lookupFuzzyCallable', () => { + it('returns only callable types (Function, Method, Constructor)', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function'); + table.add('src/a.ts', 'bar', 'method:bar', 'Method'); + table.add('src/a.ts', 'Baz', 'ctor:Baz', 'Constructor'); + table.add('src/a.ts', 'User', 'class:User', 'Class'); + table.add('src/a.ts', 'IUser', 'iface:IUser', 'Interface'); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + expect(table.lookupFuzzyCallable('Baz')).toHaveLength(1); + expect(table.lookupFuzzyCallable('User')).toEqual([]); + expect(table.lookupFuzzyCallable('IUser')).toEqual([]); + }); + + it('returns empty array for unknown name', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function'); + expect(table.lookupFuzzyCallable('unknown')).toEqual([]); + }); + + it('rebuilds index after adding new callable', () => { + table.add('src/a.ts', 'foo', 'func:foo', 'Function'); + expect(table.lookupFuzzyCallable('foo')).toHaveLength(1); + expect(table.lookupFuzzyCallable('bar')).toEqual([]); + table.add('src/a.ts', 'bar', 'func:bar', 'Function'); + expect(table.lookupFuzzyCallable('bar')).toHaveLength(1); + }); + + it('filters non-callable types from mixed name entries', () => { + table.add('src/a.ts', 'save', 'func:save', 'Function'); + table.add('src/b.ts', 'save', 'class:save', 'Class'); + const callables = table.lookupFuzzyCallable('save'); + expect(callables).toHaveLength(1); + expect(callables[0].type).toBe('Function'); + }); + }); + describe('clear', () => { - it('resets all state', () => { + it('resets all state including fieldByOwner', () => { table.add('src/a.ts', 'foo', 'func:foo', 'Function'); table.add('src/b.ts', 'bar', 'func:bar', 'Function'); + table.add('src/models.ts', 'address', 'prop:address', 'Property', { + declaredType: 'Address', + ownerId: 'class:User', + }); table.clear(); expect(table.getStats()).toEqual({ fileCount: 0, globalSymbolCount: 0 }); expect(table.lookupExact('src/a.ts', 'foo')).toBeUndefined(); expect(table.lookupFuzzy('foo')).toEqual([]); + expect(table.lookupFieldByOwner('class:User', 'address')).toBeUndefined(); + expect(table.lookupFuzzyCallable('foo')).toEqual([]); }); it('allows re-adding after clear', () => { diff --git a/type-resolution-roadmap.md b/type-resolution-roadmap.md index 2da9077b0..e7bbcf26b 100644 --- a/type-resolution-roadmap.md +++ b/type-resolution-roadmap.md @@ -107,32 +107,76 @@ The interface change touched all extractors but remained additive — no existin --- -## Phase 8: Field and Property Type Resolution +## Phase 8: Field and Property Type Resolution *(delivered)* ### Goal Model class / struct fields so chained member access can be resolved more accurately. +### Status + +**Delivered.** One-level, deep, and mixed field+method chain resolution is implemented across 9 languages. Pattern destructuring (8C) remains open. + +#### What shipped + +- **SymbolTable `fieldByOwner` index** — O(1) lookup via `ownerNodeId\0fieldName` key. Properties excluded from `globalIndex` to prevent namespace pollution. *(Q1 resolved)* +- **`HAS_PROPERTY` edge type** — split from `HAS_METHOD` to distinguish property linkage +- **`declaredType` field** on Property symbols — semantic split from `returnType` (methods) +- **`resolveFieldAccessType`** in call-processor — resolves field access chains at call sites +- **`extractPropertyDeclaredType`** in shared utils — 5-strategy cross-language type extraction +- **Per-language `@definition.property` captures** — see coverage table below +- **`extractMixedChain`** in utils — unified recursive AST walker that handles both `call_expression` and `field_expression` nodes interchangeably, building `MixedChainStep[]` capped at `MAX_CHAIN_DEPTH` (3). Replaces the earlier separate `extractFieldChain` / `extractCallChain` functions. +- **`receiverMixedChain`** on `ExtractedCall` — unified chain representation replacing the old `receiverCallChain` + `receiverFieldAccess` split +- **Unified chain resolution** in call-processor — a single loop in both `processCalls` (sequential) and `processCallsFromExtracted` (worker) walks `MixedChainStep[]`, dispatching `kind: 'field'` to `resolveFieldAccessType` and `kind: 'call'` to `resolveCallTarget` + return type extraction +- **Type-preserving stdlib passthrough** — `unwrap()`, `expect()`, `clone()`, `as_ref()`, and similar stdlib methods that don't change the receiver type are recognized as identity operations in the chain loop, allowing chains like `user.unwrap().save()` to resolve correctly when TypeEnv has already stripped the nullable wrapper +- **C++ `field_declaration`** property capture via `field_identifier` declarator +- **C++ `field_expression` support** — tree-sitter-cpp uses `argument` (not `object`) for the receiver of `field_expression`; `extractMixedChain` handles this +- **C++ inline method double-indexing guard** — prevents `@definition.function` from creating duplicate symbol entries for methods already captured by `@definition.method` inside class/struct bodies (applied in both `parsing-processor.ts` and `parse-worker.ts`) +- **Rust unit struct instantiation** — `let svc = UserService;` (bare identifier assignment) now recognized by type-env when the RHS matches a known class/struct name +- **Ruby YARD `@return [Type]`** extraction for `attr_accessor` properties, enabling field-type resolution in dynamically typed Ruby + +#### Language coverage + +| Language | Property capture | `declaredType` extraction | Deep chain | Notes | +|----------|-----------------|--------------------------|:----------:|-------| +| TypeScript | ✅ `public_field_definition`, `private_property_identifier`, `required_parameter` | ✅ Strategy 2 (type_annotation) | ✅ | Parameter properties added | +| JavaScript | ✅ `field_definition` | ⚠️ No type annotations in JS | — | Capture added; declaredType requires JSDoc | +| Java | ✅ `field_declaration` | ✅ Strategy 3 (parent type) | ✅ | | +| C# | ✅ `property_declaration` | ✅ Strategy 1 (type field) | ✅ | | +| Go | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | | +| Kotlin | ✅ `property_declaration` | ✅ Strategy 4 (variable_declaration) | ✅ | New strategy added | +| PHP | ✅ `property_declaration` | ✅ Strategy 1 + PHPDoc @var fallback | ✅ | Strategy 5 for pre-7.4 | +| Rust | ✅ `field_declaration` | ✅ Strategy 1 (type field) | ✅ | `extractMemberAccessParts` handles `field_expression` via `value`/`field` | +| Python | ✅ `assignment` with `type` | ✅ Class-level annotations | ✅ | `self.x` instance pattern not yet supported | +| Ruby | ✅ `attr_*` via call routing | ✅ YARD `@return [Type]` | — | YARD fallback for dynamically typed properties | +| C++ | ✅ `field_declaration` via `field_identifier` | ✅ Strategy 1 (type field) | ✅ | | +| Swift | ✅ `property_declaration` | ⚠️ Untested | — | | + +#### What remains open + +- **8C. Pattern destructuring** dependent on field knowledge +- Python `self.x` instance attribute pattern + ### Problems this phase addresses -#### 8A. Deep property chains +#### 8A. Deep property chains *(delivered)* ```typescript -user.address.city +user.address.city.getName() ``` -Today the system may resolve `user -> User`, but it cannot generally resolve: +✅ `extractFieldChain` recursively walks nested member_expression nodes at parse time, building a `fieldChain: string[]`. At resolution time, the chain is walked step-by-step: `user → User`, `address → Address`, `city → City`, `getName() → City#getName`. Supported across TS, Java, C#, Go, Kotlin, PHP, C++. -- `address -> Address` -- `city -> City` or scalar type - -#### 8B. Chained method targets through field access +#### 8B. Mixed field+method chain resolution *(delivered)* ```typescript -user.address.save() +svc.getUser().address.save() // call → field → call +user.getAddress().city.getName() // call → field → call +user.address.getCity().save() // field → call → call +user.unwrap().save() // stdlib passthrough → call ``` -Without field typing, the resolver cannot reliably identify the receiver type of `address`. +✅ `extractMixedChain` walks both call-expression and field-expression nodes in a single unified pass, producing `MixedChainStep[]`. The resolver walks steps left-to-right: `kind: 'field'` resolves via `resolveFieldAccessType`, `kind: 'call'` resolves via `resolveCallTarget` + return type extraction. Stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) are recognized as type-preserving identity operations. #### 8C. Pattern destructuring that depends on field knowledge @@ -142,32 +186,42 @@ This is especially relevant for: - PHP chained property access - richer TypeScript or Python object-based destructuring in future work -### Engineering direction +### Engineering direction (as implemented) -- parse field / property declarations per class or struct -- build a field-type map keyed by owning type -- teach lookup and chain-resolution logic to walk member segments +- ~~parse field / property declarations per class or struct~~ ✅ +- ~~build a field-type map keyed by owning type~~ ✅ (`fieldByOwner` index) +- ~~teach lookup and chain-resolution logic to walk member segments (deep chains)~~ ✅ (`extractMixedChain` + unified chain-walking loop) +- ~~unify field chains and call chains into a single representation~~ ✅ (`MixedChainStep[]` replaces separate `receiverCallChain` / `receiverFieldAccess`) +- ~~C++ struct member field capture~~ ✅ (`field_declaration` via `field_identifier`) +- ~~C++ `field_expression` receiver extraction~~ ✅ (`argument` field support in `extractMixedChain`) +- ~~Rust unit struct instantiation~~ ✅ (`let svc = TypeName;` recognized by type-env) +- ~~Ruby YARD `@return` for `attr_accessor`~~ ✅ (comment-walking in `call-routing.ts`) +- ~~stdlib passthrough methods~~ ✅ (`TYPE_PRESERVING_METHODS` set in call-processor) - keep this separate from the base variable-binding layer where possible -### Expected impact +### Delivered impact This is the biggest unlock for richer static analysis because it allows the graph to model more than just top-level receivers. -It would materially improve: +It materially improved: -- chained property resolution -- member-based call disambiguation +- chained property resolution (up to 3 levels deep) +- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`) +- member-based call disambiguation across 9 languages - deeper context extraction for downstream tooling +- C++ struct/class field visibility in the knowledge graph +- C++ chained method call resolution (previously blocked by missing `argument` field support) +- Rust nullable receiver chains (`user.unwrap().save()`) +- Ruby field-type resolution via YARD documentation ### Risk level -**High** +**High** (delivered — risk was managed through incremental delivery across 8, 8A, 8B) -This is the first phase that pushes the system from variable typing into structural object modelling. It will likely require: +This phase pushed the system from variable typing into structural object modelling. Remaining work: -- schema expansion or new internal maps - careful handling of inheritance / embedding / language-specific member semantics -- broader test coverage than earlier phases +- pattern destructuring dependent on field knowledge (8C) --- @@ -282,44 +336,33 @@ Key remaining gap: Shared missing capabilities: -- field / property type resolution -- generalised return-type-aware binding in `TypeEnv` +- ~~field / property type resolution~~ ✓ shipped in Phase 8 + 8A (10 languages) +- ~~mixed field+method chain resolution~~ ✓ shipped in Phase 8B (unified `MixedChainStep[]`) +- generalised return-type-aware binding in `TypeEnv` (Phase 9) -**Priority:** Very High -**Reason:** These are the biggest remaining blockers to deeper static analysis. +**Priority:** High +**Reason:** Return-type propagation is the biggest remaining blocker to deeper static analysis. --- ## Recommended Delivery Order -### 1. Generalise existing return and loop inference +### ~~1. Generalise existing return and loop inference~~ ✅ Phase 7 -This is the best cost-to-value step. +Delivered. Iterable call-expression support, `ReturnTypeLookup`, file-scope binding, PHP Strategy C. -Deliverables: +### ~~2. Add field / property type maps~~ ✅ Phase 8 + 8A + 8B -- iterable call-expression support -- wider access to return-type maps -- file-scope binding visibility where needed +Delivered. Per-type field metadata, deep chain resolution (up to 3 levels), mixed field+method chains, type-preserving stdlib passthrough, C++ and Rust fixes. -### 2. Add field / property type maps - -This unlocks the next class of analysis depth. - -Deliverables: - -- per-type field metadata -- chained property resolution -- better destructuring support - -### 3. Promote return types into first-class `TypeEnv` inputs +### 3. Promote return types into first-class `TypeEnv` inputs ← **next** This converts existing downstream validation into a broader inference capability. Deliverables: -- call-result variable binding -- loop inference from call results +- call-result variable binding (`var x = f()` propagation) +- loop inference from call results (already done for direct iterables, pending for assigned results) - broader chain propagation ### 4. Broaden branch-sensitive narrowing where low-risk @@ -352,23 +395,25 @@ That would be sufficient for: ## Suggested Milestone Definitions -### Milestone A — Inference Expansion +### Milestone A — Inference Expansion ✅ -Success looks like: +Delivered in Phase 7. -- loop inference works for identifier iterables and common call-expression iterables -- simple call-result assignments benefit from return types more broadly -- no major regression in ambiguity handling +- loop inference works for identifier iterables and common call-expression iterables across 7 languages +- `ReturnTypeLookup` threads return-type knowledge into TypeEnv +- PHP class-level `@var` property typing for `$this->property` foreach -### Milestone B — Structural Member Typing +### Milestone B — Structural Member Typing ✅ -Success looks like: +Delivered in Phase 8 + 8A + 8B. -- field/property maps exist for class-like types -- chained access can resolve at least one segment beyond the base receiver -- field-aware member-call resolution works in the most important languages +- field/property maps exist for class-like types across 9 languages +- deep chains resolve up to 3 levels (`user.address.city.getName()`) +- mixed field+method chains resolve interleaved patterns (`svc.getUser().address.save()`) +- stdlib passthroughs (`unwrap`, `clone`, etc.) are type-preserving in chains +- C++ and Rust chain call resolution fixed (field_expression argument, unit struct) -### Milestone C — Static-Analysis Foundation +### Milestone C — Static-Analysis Foundation ← **next** Success looks like: @@ -382,8 +427,8 @@ Success looks like: These should be resolved before or during implementation of the later phases. -1. **Where should field-type metadata live?** - In `TypeEnv`, in `SymbolTable`, or in a dedicated side structure? +1. **Where should field-type metadata live?** + ✅ Resolved: in `SymbolTable` via the `fieldByOwner` index, keyed by `ownerNodeId\0fieldName`. Properties live alongside other symbols but are excluded from `globalIndex` to prevent namespace pollution. 2. **How should ambiguity be represented?** Is `undefined` sufficient, or do later phases need a richer "known ambiguous" state? @@ -394,19 +439,23 @@ These should be resolved before or during implementation of the later phases. 4. **How much branch sensitivity is worth the complexity?** Some narrowing gives clear value; full control-flow typing likely does not. -5. **Should field typing and chain typing be one phase or two?** - Keeping them separate may reduce risk and make regressions easier to isolate. +5. **Should field typing and chain typing be one phase or two?** + ✅ Resolved: delivered as Phase 8 (single-level) + Phase 8A (deep chains) in the same branch, with separate test suites per language. Incremental delivery within one phase worked well. --- ## Summary -The next stage of the type system should focus on **generalising what already works** before attempting compiler-like sophistication. +Phases 7 and 8 (including 8A and 8B) are **complete**. The type system now handles: -The most important path is: +- ✅ explicit type annotations and parameters across 13 languages +- ✅ initializer/constructor inference with SymbolTable validation +- ✅ loop element inference including call-expression iterables (7 languages) +- ✅ field/property type resolution with deep chains (up to 3 levels, 10 languages) +- ✅ mixed field+method chains (`svc.getUser().address.save()`) +- ✅ type-preserving stdlib passthroughs (`unwrap`, `clone`, `expect`, etc.) +- ✅ comment-based types (JSDoc, PHPDoc, YARD) -1. extend return-type and iterable inference -2. add field/property type knowledge -3. promote return-type-aware inference into `TypeEnv` +**The next step is Phase 9**: promote return-type-aware inference into `TypeEnv` as a first-class input, enabling `var x = f()` variable binding and broader chain propagation. The `pendingCallResults` infrastructure is already in place (Tier 2b loop + `PendingAssignment` union) — it just needs extractors to emit `{ kind: 'callResult' }` entries. -That path preserves the current strengths of the system while moving GitNexus materially closer to a robust, production-grade static-analysis foundation. +That path preserves the current strengths of the system while moving GitNexus the final step toward a robust, production-grade static-analysis foundation. diff --git a/type-resolution-system.md b/type-resolution-system.md index ab221155c..32e39a0ed 100644 --- a/type-resolution-system.md +++ b/type-resolution-system.md @@ -122,7 +122,6 @@ It does not: - perform full semantic type checking - run fixpoint inference - propagate inferred bindings across files as ordinary environment entries -- model deep field/property chains such as `user.address.city` - guarantee resolution for every ambiguous construct --- @@ -366,31 +365,41 @@ So return-type-aware receiver inference already exists in a constrained downstre ## Language Feature Matrix -| Feature | TS/JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C/C++ | -|---------|:-----:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:-----:| -| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | -| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | No | -| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | -| Comment-based types | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | -| Return type extraction | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | +| Feature | TS | JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C++ | C | +|---------|:--:|:--:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:---:|:-:| +| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | +| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | No | No | No | +| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | +| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | +| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | +| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | + +\* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported. + +† JS field topology is captured (`field_definition` → `HAS_PROPERTY` edges) but `declaredType` is never set — JS has no AST type annotations. Disambiguation via `lookupFieldByOwner` requires `declaredType`. JSDoc `@type` support is a Phase 9 candidate. + +‡ C has no `@definition.property` query pattern. Struct member fields are not captured. C++ captures class/struct member fields via `field_declaration`. --- ## Current Strengths -The current system already provides strong value for call resolution because it combines: +The current system provides strong value for call resolution because it combines: -- explicit annotation extraction -- generic-aware loop element typing -- initializer-based inference +- explicit annotation extraction across 13 languages +- generic-aware loop element typing (including call-expression iterables) +- initializer-based inference with SymbolTable validation - selected pattern-based narrowing - scope-aware lookups -- comment-based fallbacks for dynamic ecosystems +- comment-based fallbacks for dynamic ecosystems (JSDoc, PHPDoc, YARD) - constrained return-type-aware receiver inference in call processing +- deep field/property chains up to 3 levels across 9 languages +- mixed field+method chain resolution (e.g. `svc.getUser().address.save()`) +- type-preserving stdlib passthrough for `unwrap()`, `clone()`, `expect()`, etc. This is enough to materially improve call-edge precision even without implementing a full static type system. @@ -400,7 +409,6 @@ This is enough to materially improve call-edge precision even without implementi Important gaps still remain: -- no field / property type map for deep chains such as `user.address.city` - no general cross-file propagation of inferred bindings - no fixpoint inference - limited branch-sensitive narrowing outside selected pattern constructs