From 216a8b5db89722a8094c6236a27330c593a10f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 14 Mar 2026 19:05:49 +0000 Subject: [PATCH] feat: TypeEnvironment API with constructor inference, self/this/super resolution (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(type-env): constructor-call type inference for TypeEnv (Phase 1) Add extractInitializer as a Tier 1 fallback in buildTypeEnv: when a declaration node has no explicit type annotation, infer the type from constructor-call patterns (new X(), X::new(), X::default(), $x = new X()). Languages covered: TypeScript/JS, Java (var), Rust, PHP, C++ (auto). Python/Kotlin/Swift deferred — need symbol-table access to distinguish class constructors from function calls. Adds 20 new unit tests covering constructor inference, annotation precedence, and known limitations across all supported languages. * fix(type-env): class-aware constructor resolution, multi-declarator fix - Add collectClassNames pre-scan: walks AST to build Set of class/struct names defined in the file - C++ extractInitializer uses classNames.has() to verify identifier is a known class before inferring (auto x = User() resolves, auto x = getUser() does not — no false positives) - Add InitializerExtractor type that receives classNames parameter - Fix env.size gating: always call extractInitializer when available, so mixed declarators like const a: A = x, b = new B() resolve both - Add env.has() guard in Java extractInitializer to skip already-bound vars - Document Rust new/default whitelist rationale - Pin all test assertions, add mixed multi-declarator test case * fix(type-env): resolve Self/self/static/parent to actual type names - Rust: Self::new()/Self::default() resolves to enclosing impl type - PHP: new self()/static() resolves to enclosing class, parent() to superclass - Rust: Tier 0 annotation guard prevents overwrite by constructor inference - Rust: mut_pattern handling in extractVarName for let mut bindings - TS: fix misleading comment in extractInitializer - 58 tests passing (3 new Self/self resolution tests) * perf(type-env): single-pass AST walk with closure-scoped state Refactors buildTypeEnv to use closures instead of passing mutable state as parameters. classNames, env, and config are captured by the inner walk and extractTypeBinding functions — no parameter mutation. - Eliminates separate collectClassNames pre-scan (O(2n) → O(n)) - config looked up once per file instead of per-node - 29 fewer lines * feat(type-env): constructor-inferred type resolution for all languages Add cross-file constructor type inference to the ingestion pipeline, enabling receiver-type disambiguation for member calls like `user.save()` when the variable is assigned from a constructor without explicit type annotations. Pipeline changes: - Add extractInitializer to Python and Swift type extractors - Add CONSTRUCTOR_BINDING_SCANNERS for Python, Swift, C/C++ in type-env - Wire constructorBindings through parse-worker → parsing-processor → pipeline → processCallsFromExtracted - Rewrite resolveCallTarget receiver-type filtering (step D) to use tiered import resolution (same-file → import-scoped → global) before falling back to fuzzy ownerId matching - Use collectTieredCandidates for constructor binding verification instead of raw lookupFuzzy Bug fixes: - Fix C++ inline method query: @definition.method was captured on field_declaration_list instead of function_definition, causing wrong parameterCount for all inline class methods - Fix parse-worker accumulated/flush results missing constructorBindings CI changes: - Add swift.test.ts to ci-integration pipeline group and coverage job - Update ci-report to fetch base branch (main) coverage for delta reporting instead of showing config thresholds - Add per-suite timing breakdown table (unit/integration/total) - Add expandable skipped test details section Tests: 288 passed, 4 skipped (swift — macOS only) across 10 languages - 36 new constructor-inferred integration tests (4 per language) - 10 fixture directories with cross-file constructor patterns - TypeScript, JavaScript, Java, Kotlin, Python, PHP, Rust, Go, C++, Swift * fix(type-extractors): add type assertion for LanguageTypeConfig * feat(ruby): constructor-inferred type resolution and self-receiver mapping Add Ruby User.new constructor binding scanner to type-env, enabling receiver-type disambiguation for member calls like user.save vs repo.save. Add self/this → enclosing class resolution in lookupTypeEnv so self.method() calls resolve to the correct class even when the method name is ambiguous. * docs: update README with constructor inference and self/this resolution details * refactor(ingestion): unified ResolutionContext replaces fragmented map passing Introduce createResolutionContext() as the single resolution API for all processors. Eliminates duplicated tier-selection logic, fixes heritage namedImportMap bug, and adds per-file resolution caching. - NEW resolution-context.ts: closure-factory with resolve(), per-file cache, TIER_CONFIDENCE constant, and shared ResolutionTier type - DELETE symbol-resolver.ts: zero production importers, logic now in resolution-context.ts - call-processor: all functions take ctx instead of 6 separate maps, collectTieredCandidates removed (ctx.resolve replaces it), D4 redundant re-resolve eliminated - heritage-processor: takes ctx, resolveHeritageId helper extracts repeated 14-line fallback pattern, namedImportMap now included - import-processor: takes ctx, dead createImportMap/createPackageMap/ createNamedImportMap factories removed - pipeline: creates single ctx, wires onProgress to all processors, logs cache hit rate in dev mode - Tier renamed: unique-global → global (honest about returning all candidates) - Tests migrated: 1178 unit + 84 integration passing * feat(type-env): self/this/super resolution, TypeEnvironment API, and review fixes Add cross-language receiver keyword resolution: - self/this/$this → enclosing class name via AST walk - super/base/parent → parent class name via heritage AST extraction (8 grammar variants: TS/JS, Java, Python, Ruby, C#, PHP, Kotlin, C++, Swift) - D-phase widening in resolveCallTarget for super→parent method dispatch Introduce TypeEnvironment API replacing loose TypeEnvResult + lookupTypeEnv: - buildTypeEnv() returns TypeEnvironment with .lookup() method - Single-pass AST walk merges constructor binding scan (was separate traversal) - ClassNameLookup type replaces over-broad ReadonlySet facade - Memoized class name lookups to avoid redundant SymbolTable scans Code review fixes (6 agents, 11 findings): - Replace ctx.resolve(name, '') hack with direct symbols.lookupFuzzy() - Extract scope key helpers (extractFuncNameFromScope, receiverKey) - Simplify D-phase from 5 steps to 4 with deduped typeNodeIds - Remove C from CONSTRUCTOR_BINDING_SCANNERS (YAGNI — C has no constructors) - Cache Map reuse in ResolutionContext to reduce GC pressure - Remove unused TieredCandidates import Integration tests for self/this, parent, and super resolution across all 12 supported languages with per-language fixture directories. * fix(type-env): generic parent resolution, TS cast inference, C++ brace-init Fix generic parent class breaking super resolution: - extractParentClassFromNode now uses extractSimpleTypeName to strip generic params (Base → Base) and qualified names (models.Model → Model) - Affects TS, Java, Python, C# heritage extraction Fix TypeScript new X() as T / new X()! missed inference: - Unwrap as_expression and non_null_expression before checking for new_expression in extractInitializer Fix C++ brace-init User{} missed inference: - Handle compound_literal_expression with type_identifier child in extractInitializer Clean up deprecated lookupTypeEnv: - Remove standalone lookupTypeEnv export, migrate all callers to TypeEnvironment.lookup() method - Update all 80+ test assertions to use the new API Integration test fixtures added: - typescript-cast-constructor-inference (new X() as T, new X()!) - typescript/java/csharp/kotlin-generic-parent-resolution - cpp-brace-init-inference (auto x = User{}) * fix(type-extractors): Go &User{}, TS double-cast, Swift .init inference Fix Go pointer-to-struct literal not inferred: - Unwrap unary_expression (address-of &) before composite_literal check - user := &User{} now correctly infers type User Fix TypeScript double-cast only unwrapping one level: - Change if to while loop for nested as_expression/non_null_expression - new User() as unknown as Admin now correctly infers type User Fix Swift User.init(name:) explicit init call missed: - Handle navigation_expression callee with .init suffix in extractInitializer Integration test fixtures: - go-pointer-constructor-inference (&User{}, &Repo{}) - typescript-double-cast-inference (as unknown as T) * feat: Rust struct literal, Python qualified ctor, Go new(), Swift .init scanner - Rust: handle struct_expression in extractInitializer (User { name: "alice" }) - Python: support attribute nodes in extractInitializer (models.User("alice")) and the cross-file scanner — extractSimpleTypeName handles qualified names - Go: handle new(User) built-in in extractGoShortVarDeclaration - Swift: extend CONSTRUCTOR_BINDING_SCANNERS to handle navigation_expression callee for User.init(name:) cross-file resolution Unit tests: 87 → 96 (Rust struct literal, Go new(), Python qualified ctor, Python scanner qualified, plus edge cases) Integration tests: 4 new describe blocks with fixtures * fix: Rust Self{} resolution, C++ scoped brace-init, PHP promotion params, Ruby constants - Rust: resolve Self {} struct literal to enclosing impl type (was stored as "Self") - C++: replace type_identifier guard with extractSimpleTypeName for compound_literal_expression, enabling ns::User{} scoped brace-init (closes previously deferred gap) - PHP: add property_promotion_parameter to TYPED_PARAMETER_TYPES for PHP 8.0+ constructor property promotion (__construct(private Foo $x)) - Ruby: extend extractRubyConstructorBinding to accept constant left-hand side (REPO = Repo.new) Unit tests: 96 → 101 (+5: Rust Self{} ×2, C++ ns::User{} ×1, PHP promotion ×1, Ruby constant ×1) Integration tests: 4 new describe blocks with fixtures * feat: Phase 1 type resolution gaps — walrus, PHP properties, nullable, Go make/assert Phase 1 quick wins from the type resolution gap analysis: 1. Python walrus operator := (named_expression) — extractInitializer + scanner 2. PHP 7.4+ typed class properties — property_declaration in extractDeclaration 3. Nullable union unwrapping — User | null → User in extractSimpleTypeName 4. Go make() builtin — slice/map element type extraction 5. Go type assertions — iface.(User) type extraction Also: PHP primitive_type handling in extractSimpleTypeName (string, int, etc.) Unit tests: 101 → 114 (+13) Integration tests: 8 new describe blocks with fixtures * feat: Phase 2 type resolution gaps — C++ range-for, Rust if-let, C# pattern matching, Python class annotations Phase 2 medium-effort improvements: 1. C++ range-for with explicit type — for (User& u : vec) binds u: User 2. Rust if-let/while-let captured_pattern — user @ User { .. } binds user: User 3. C# is-pattern matching — if (obj is User user) binds user: User 4. Python class-level annotations — confirmed already working, added tests Unit tests: 114 → 127 (+13) Integration tests: 11 new test cases with fixtures --- .github/workflows/ci-integration.yml | 2 + .github/workflows/ci-report.yml | 186 ++- AGENTS.md | 54 +- CLAUDE.md | 54 +- README.md | 23 +- gitnexus/.claude/settings.local.json | 7 + gitnexus/Dockerfile.test | 9 + gitnexus/README.md | 32 +- ...4-feat-type-resolution-gap-closure-plan.md | 259 ++++ gitnexus/src/core/ingestion/call-processor.ts | 320 ++-- .../src/core/ingestion/heritage-processor.ts | 113 +- .../src/core/ingestion/import-processor.ts | 31 +- .../src/core/ingestion/parsing-processor.ts | 9 +- gitnexus/src/core/ingestion/pipeline.ts | 94 +- .../src/core/ingestion/resolution-context.ts | 192 +++ .../src/core/ingestion/symbol-resolver.ts | 123 -- .../src/core/ingestion/tree-sitter-queries.ts | 2 +- gitnexus/src/core/ingestion/type-env.ts | 505 ++++++- .../core/ingestion/type-extractors/c-cpp.ts | 73 +- .../core/ingestion/type-extractors/csharp.ts | 21 +- .../src/core/ingestion/type-extractors/go.ts | 46 +- .../core/ingestion/type-extractors/index.ts | 2 +- .../src/core/ingestion/type-extractors/jvm.ts | 56 +- .../src/core/ingestion/type-extractors/php.ts | 94 +- .../core/ingestion/type-extractors/python.ts | 41 +- .../core/ingestion/type-extractors/rust.ts | 122 +- .../core/ingestion/type-extractors/shared.ts | 29 + .../core/ingestion/type-extractors/swift.ts | 41 +- .../core/ingestion/type-extractors/types.ts | 12 + .../ingestion/type-extractors/typescript.ts | 27 +- gitnexus/src/core/ingestion/utils.ts | 28 +- .../core/ingestion/workers/parse-worker.ts | 25 +- .../cpp-brace-init-inference/models/Repo.h | 4 + .../cpp-brace-init-inference/models/User.h | 4 + .../cpp-brace-init-inference/services/App.cpp | 10 + .../models/Repo.h | 10 + .../models/User.h | 10 + .../services/App.cpp | 9 + .../cpp-parent-resolution/src/BaseModel.h | 6 + .../cpp-parent-resolution/src/User.h | 7 + .../lang-resolution/cpp-range-for/app.cpp | 8 + .../lang-resolution/cpp-range-for/user.h | 6 + .../cpp-scoped-brace-init/main.cpp | 7 + .../cpp-scoped-brace-init/models.h | 7 + .../cpp-self-this-resolution/src/Repo.cpp | 4 + .../cpp-self-this-resolution/src/User.cpp | 7 + .../src/Models/BaseModel.cs | 5 + .../src/Models/Repo.cs | 5 + .../src/Models/User.cs | 8 + .../src/Models/BaseModel.cs | 5 + .../src/Models/ISerializable.cs | 5 + .../src/Models/User.cs | 5 + .../csharp-pattern-matching/Models/Animal.cs | 20 + .../PatternMatchProj.csproj | 5 + .../Services/AnimalService.cs | 14 + .../src/Models/Repo.cs | 5 + .../src/Models/User.cs | 8 + .../src/Models/BaseModel.cs | 5 + .../src/Models/Repo.cs | 5 + .../src/Models/User.cs | 8 + .../go-constructor-type-inference/cmd/main.go | 10 + .../go-constructor-type-inference/go.mod | 3 + .../models/repo.go | 7 + .../models/user.go | 7 + .../lang-resolution/go-make-builtin/main.go | 9 + .../lang-resolution/go-make-builtin/models.go | 10 + .../lang-resolution/go-new-builtin/main.go | 7 + .../lang-resolution/go-new-builtin/models.go | 11 + .../go-parent-resolution/go.mod | 3 + .../go-parent-resolution/models/base.go | 7 + .../go-parent-resolution/models/user.go | 9 + .../cmd/main.go | 11 + .../go-pointer-constructor-inference/go.mod | 3 + .../models/repo.go | 9 + .../models/user.go | 9 + .../lang-resolution/go-type-assertion/main.go | 7 + .../go-type-assertion/models.go | 14 + .../java-constructor-type-inference/App.java | 11 + .../models/Repo.java | 7 + .../models/User.java | 7 + .../src/models/BaseModel.java | 5 + .../src/models/Repo.java | 5 + .../src/models/User.java | 8 + .../src/interfaces/Serializable.java | 5 + .../src/models/BaseModel.java | 5 + .../src/models/User.java | 7 + .../src/models/Repo.java | 5 + .../src/models/User.java | 8 + .../src/models/BaseModel.java | 5 + .../src/models/Repo.java | 5 + .../src/models/User.java | 8 + .../src/app.js | 11 + .../src/repo.js | 7 + .../src/user.js | 7 + .../src/models/Base.js | 4 + .../src/models/User.js | 6 + .../src/models/Repo.js | 4 + .../src/models/User.js | 7 + .../src/models/Base.js | 4 + .../src/models/Repo.js | 4 + .../src/models/User.js | 9 + .../models/Repo.kt | 5 + .../models/User.kt | 5 + .../services/App.kt | 11 + .../models/BaseModel.kt | 5 + .../models/Repo.kt | 5 + .../models/User.kt | 8 + .../models/BaseModel.kt | 5 + .../models/Serializable.kt | 5 + .../kotlin-parent-resolution/models/User.kt | 5 + .../models/AppConfig.kt | 8 + .../models/Repo.kt | 5 + .../models/User.kt | 8 + .../models/BaseModel.kt | 5 + .../kotlin-super-resolution/models/Repo.kt | 5 + .../kotlin-super-resolution/models/User.kt | 8 + .../app/Models/Repo.php | 11 + .../app/Models/User.php | 11 + .../app/Services/AppService.php | 17 + .../composer.json | 7 + .../app/Models/BaseModel.php | 8 + .../app/Models/Serializable.php | 8 + .../php-parent-resolution/app/Models/User.php | 8 + .../php-parent-resolution/composer.json | 5 + .../php-property-promotion/UserService.php | 15 + .../app/Models/Repo.php | 8 + .../app/Models/User.php | 12 + .../php-self-this-resolution/composer.json | 5 + .../app/Models/BaseModel.php | 8 + .../php-super-resolution/app/Models/Repo.php | 8 + .../php-super-resolution/app/Models/User.php | 12 + .../php-super-resolution/composer.json | 5 + .../app/Models/UserRepo.php | 9 + .../app/Services/UserService.php | 15 + .../php-typed-properties/composer.json | 7 + .../python-class-annotations/repo.py | 3 + .../python-class-annotations/service.py | 10 + .../python-class-annotations/user.py | 3 + .../models/repo.py | 6 + .../models/user.py | 6 + .../services/app.py | 9 + .../models/__init__.py | 0 .../python-parent-resolution/models/base.py | 3 + .../python-parent-resolution/models/user.py | 5 + .../python-qualified-constructor/main.py | 6 + .../python-qualified-constructor/models.py | 9 + .../models/repo.py | 3 + .../models/user.py | 5 + .../models/__init__.py | 0 .../python-super-resolution/models/base.py | 3 + .../python-super-resolution/models/repo.py | 3 + .../python-super-resolution/models/user.py | 6 + .../python-walrus-operator/main.py | 6 + .../python-walrus-operator/models.py | 9 + .../ruby-constant-constructor/app.rb | 5 + .../ruby-constant-constructor/models.rb | 7 + .../models/repo.rb | 9 + .../models/user.rb | 9 + .../services/app.rb | 20 + .../lib/models/base_model.rb | 5 + .../lib/models/serializable.rb | 5 + .../ruby-parent-resolution/lib/models/user.rb | 6 + .../lib/models/repo.rb | 5 + .../lib/models/user.rb | 8 + .../lib/models/base_model.rb | 5 + .../ruby-super-resolution/lib/models/repo.rb | 5 + .../ruby-super-resolution/lib/models/user.rb | 8 + .../src/main.rs | 13 + .../src/repo.rs | 11 + .../src/user.rs | 11 + .../lang-resolution/rust-if-let/main.rs | 24 + .../lang-resolution/rust-if-let/models.rs | 18 + .../rust-parent-resolution/src/lib.rs | 2 + .../src/serializable.rs | 3 + .../rust-parent-resolution/src/user.rs | 9 + .../rust-self-struct-literal/main.rs | 7 + .../rust-self-struct-literal/models.rs | 19 + .../rust-self-this-resolution/src/repo.rs | 5 + .../rust-self-this-resolution/src/user.rs | 8 + .../rust-struct-literal-inference/main.rs | 10 + .../rust-struct-literal-inference/models.rs | 21 + .../Models/Repo.swift | 11 + .../Models/User.swift | 11 + .../Services/App.swift | 8 + .../swift-init-cross-file/User.swift | 12 + .../swift-init-cross-file/main.swift | 5 + .../Sources/Models/BaseModel.swift | 3 + .../Sources/Models/Serializable.swift | 3 + .../Sources/Models/User.swift | 3 + .../Sources/Models/Repo.swift | 3 + .../Sources/Models/User.swift | 6 + .../ts-nullable-receiver/src/app.ts | 12 + .../ts-nullable-receiver/src/repo.ts | 5 + .../ts-nullable-receiver/src/user.ts | 9 + .../src/app.ts | 10 + .../src/repo.ts | 3 + .../src/user.ts | 3 + .../src/app.ts | 9 + .../src/repo.ts | 5 + .../src/user.ts | 5 + .../src/app.ts | 10 + .../src/repo.ts | 3 + .../src/user.ts | 3 + .../src/models/Base.ts | 3 + .../src/models/Repo.ts | 3 + .../src/models/User.ts | 8 + .../src/models/Base.ts | 7 + .../src/models/User.ts | 5 + .../src/models/Repo.ts | 3 + .../src/models/User.ts | 6 + .../src/models/Base.ts | 3 + .../src/models/Repo.ts | 3 + .../src/models/User.ts | 8 + .../test/integration/resolvers/cpp.test.ts | 193 +++ .../test/integration/resolvers/csharp.test.ts | 174 ++- .../test/integration/resolvers/go.test.ts | 198 +++ .../test/integration/resolvers/java.test.ts | 168 +++ .../integration/resolvers/javascript.test.ts | 107 ++ .../test/integration/resolvers/kotlin.test.ts | 175 +++ .../test/integration/resolvers/php.test.ts | 207 +++ .../test/integration/resolvers/python.test.ts | 235 +++ .../test/integration/resolvers/ruby.test.ts | 190 +++ .../test/integration/resolvers/rust.test.ts | 193 +++ .../test/integration/resolvers/swift.test.ts | 147 ++ .../integration/resolvers/typescript.test.ts | 346 +++++ gitnexus/test/unit/call-form.test.ts | 36 + gitnexus/test/unit/call-processor.test.ts | 187 ++- gitnexus/test/unit/heritage-processor.test.ts | 76 +- gitnexus/test/unit/import-processor.test.ts | 9 +- .../sequential-language-availability.test.ts | 18 +- gitnexus/test/unit/symbol-resolver.test.ts | 329 +++-- gitnexus/test/unit/type-env.test.ts | 1298 ++++++++++++++++- 232 files changed, 7302 insertions(+), 924 deletions(-) create mode 100644 gitnexus/.claude/settings.local.json create mode 100644 gitnexus/Dockerfile.test create mode 100644 gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md create mode 100644 gitnexus/src/core/ingestion/resolution-context.ts delete mode 100644 gitnexus/src/core/ingestion/symbol-resolver.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/Repo.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/User.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/services/App.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/Repo.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/User.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/services/App.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/BaseModel.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/User.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/main.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/models.h create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/Repo.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/User.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/BaseModel.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/BaseModel.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/ISerializable.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Models/Animal.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/PatternMatchProj.csproj create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Services/AnimalService.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/BaseModel.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-make-builtin/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-make-builtin/models.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-new-builtin/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-new-builtin/models.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-parent-resolution/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/base.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/cmd/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/repo.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/user.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-type-assertion/main.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-type-assertion/models.go create mode 100644 gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/App.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/BaseModel.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/interfaces/Serializable.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/BaseModel.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/BaseModel.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/Repo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/User.java create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/repo.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/user.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/Base.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/User.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/Repo.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/User.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Base.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Repo.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/User.js create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/services/App.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/BaseModel.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/BaseModel.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/Serializable.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/AppConfig.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/BaseModel.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/Repo.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/User.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Models/Repo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Services/AppService.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/BaseModel.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/Serializable.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-parent-resolution/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/php-property-promotion/UserService.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/app/Models/Repo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/BaseModel.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/Repo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/User.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-super-resolution/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/php-typed-properties/app/Models/UserRepo.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-typed-properties/app/Services/UserService.php create mode 100644 gitnexus/test/fixtures/lang-resolution/php-typed-properties/composer.json create mode 100644 gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-class-annotations/service.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/services/app.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/base.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/main.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/models.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/base.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/repo.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/user.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-walrus-operator/main.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-walrus-operator/models.py create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/models.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/repo.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/services/app.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/base_model.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/serializable.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/repo.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/base_model.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/repo.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/user.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-if-let/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-if-let/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/lib.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/serializable.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/repo.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/user.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/models.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/Repo.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/User.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Services/App.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/User.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/main.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/BaseModel.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/Serializable.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/User.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/Repo.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/User.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/app.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/user.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Base.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/User.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/Base.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/User.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/Repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/User.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Base.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Repo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/User.ts create mode 100644 gitnexus/test/integration/resolvers/javascript.test.ts create mode 100644 gitnexus/test/integration/resolvers/swift.test.ts diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 2cdb89d3e0..f15b788b37 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -52,6 +52,7 @@ jobs: test/integration/resolvers/kotlin.test.ts test/integration/resolvers/php.test.ts test/integration/resolvers/ruby.test.ts + test/integration/resolvers/swift.test.ts - test-group: e2e test-glob: >- test/integration/cli-e2e.test.ts @@ -161,6 +162,7 @@ jobs: test/integration/resolvers/kotlin.test.ts test/integration/resolvers/php.test.ts test/integration/resolvers/ruby.test.ts + test/integration/resolvers/swift.test.ts - name: Upload integration coverage if: always() diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index bcd933f989..106035930b 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -119,6 +119,66 @@ jobs: sparse-checkout: gitnexus/vitest.config.ts sparse-checkout-cone-mode: false + # ── Fetch base branch coverage for delta reporting ─────────── + - name: Fetch base branch coverage + if: steps.meta.outputs.skip != 'true' + id: base-coverage + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + // Find the latest successful CI run on main + const runs = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'ci.yml', + branch: 'main', + status: 'success', + per_page: 1, + }); + + if (runs.data.workflow_runs.length === 0) { + core.setOutput('found', 'false'); + core.info('No successful main branch CI runs found'); + return; + } + + const mainRunId = runs.data.workflow_runs[0].id; + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: mainRunId, + }); + + const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports'); + if (!testReports) { + core.setOutput('found', 'false'); + core.info('No test-reports artifact on main branch'); + return; + } + + const zip = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: testReports.id, + archive_format: 'zip', + }); + + const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage'); + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data)); + core.setOutput('found', 'true'); + core.setOutput('dir', dest); + + - name: Extract base coverage + if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true' + shell: bash + run: | + cd "${{ steps.base-coverage.outputs.dir }}" + unzip -o base.zip -d base + # ── Merge coverage from unit + integration ───────────────────── - name: Setup Node.js if: steps.meta.outputs.skip != 'true' @@ -183,13 +243,14 @@ jobs: UNIT: ${{ steps.meta.outputs.unit }} INTEG: ${{ steps.meta.outputs.integration }} HAS_MERGED: ${{ steps.coverage.outputs.has_merged }} + BASE_FOUND: ${{ steps.base-coverage.outputs.found }} + BASE_DIR: ${{ steps.base-coverage.outputs.dir }} RUN_URL: ${{ github.event.workflow_run.html_url }} run: | DIR="$RUNNER_TEMP/artifacts" MERGED_DIR="$RUNNER_TEMP/merged-coverage" # ── Helper: read coverage summary into prefixed vars ── - # Uses printf -v for safe variable assignment (no eval). read_cov() { local prefix=$1 file=$2 if [ -n "$file" ] && [ -f "$file" ]; then @@ -224,7 +285,7 @@ jobs: fi } - # ── Read all three coverage reports ── + # ── Read all coverage reports ── UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1) INTEG_SUMMARY=$(find "$DIR/integration-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1) MERGED_SUMMARY="$MERGED_DIR/coverage-summary.json" @@ -235,7 +296,14 @@ jobs: HAS_INTEG=$? read_cov "M" "$MERGED_SUMMARY" - # ── Locate test results (unit) ── + # ── Read base branch coverage (main) ── + BASE_SUMMARY="" + if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then + BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1) + fi + read_cov "B" "$BASE_SUMMARY" + + # ── Locate test results ── RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1) INTEG_RESULTS=$(find "$DIR/integration-reports" -name "integration-results.json" -type f 2>/dev/null | head -1) @@ -267,17 +335,6 @@ jobs: FAILED=$((U_FAILED + I_FAILED)) SKIPPED=$((U_SKIPPED + I_SKIPPED)) SUITES=$((U_SUITES + I_SUITES)) - DURATION=$((U_DURATION + I_DURATION)) - - # ── Coverage thresholds (read from vitest.config.ts) ── - if [ -f gitnexus/vitest.config.ts ]; then - THRESH_STMTS=$(grep -oP 'statements:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0) - THRESH_BRANCH=$(grep -oP 'branches:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0) - THRESH_FUNCS=$(grep -oP 'functions:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0) - THRESH_LINES=$(grep -oP 'lines:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0) - else - THRESH_STMTS=0; THRESH_BRANCH=0; THRESH_FUNCS=0; THRESH_LINES=0 - fi # ── Status helpers ── status_icon() { @@ -289,8 +346,22 @@ jobs: esac } + cov_delta() { + local pct=$1 base=$2 + if [ "$pct" = "N/A" ] || [ "$base" = "N/A" ]; then echo "—"; return; fi + local diff + diff=$(awk "BEGIN { printf \"%.1f\", $pct - $base }") + if [ "$(awk "BEGIN { print ($pct > $base) ? 1 : 0 }")" = "1" ]; then + echo "📈 +${diff}" + elif [ "$(awk "BEGIN { print ($pct < $base) ? 1 : 0 }")" = "1" ]; then + echo "📉 ${diff}" + else + echo "= ${diff}" + fi + } + cov_bar() { - local pct=$1 thresh=$2 + local pct=$1 base=$2 if [ "$pct" = "N/A" ]; then echo "—"; return; fi local filled filled=$(awk "BEGIN { printf \"%d\", $pct / 5 }") @@ -300,7 +371,8 @@ jobs: local bar="" for ((i=0; i= $thresh) ? 1 : 0 }")" = "1" ]; then + # Green if >= base (or base unavailable), red if dropped + if [ "$base" = "N/A" ] || [ "$(awk "BEGIN { print ($pct >= $base) ? 1 : 0 }")" = "1" ]; then echo "🟢 ${bar}" else echo "🔴 ${bar}" @@ -333,18 +405,50 @@ jobs: if [ "$TOTAL" -gt 0 ] 2>/dev/null; then echo "### Test Results" echo "" + echo "| Suite | Tests | Passed | Failed | Skipped | Duration |" + echo "|-------|-------|--------|--------|---------|----------|" + if [ "$U_TOTAL" -gt 0 ] 2>/dev/null; then + echo "| Unit | ${U_TOTAL} | ${U_PASSED} | ${U_FAILED} | ${U_SKIPPED} | ${U_DURATION}s |" + fi + if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then + echo "| Integration | ${I_TOTAL} | ${I_PASSED} | ${I_FAILED} | ${I_SKIPPED} | ${I_DURATION}s |" + fi + echo "| **Total** | **${TOTAL}** | **${PASSED}** | **${FAILED}** | **${SKIPPED}** | **$((U_DURATION + I_DURATION))s** |" + echo "" + if [ "$FAILED" = "0" ]; then - echo "✅ **${PASSED}** passed" + echo "✅ All **${PASSED}** tests passed" else echo "❌ **${FAILED}** failed / **${PASSED}** passed" fi if [ "$SKIPPED" != "0" ]; then - echo " · ${SKIPPED} skipped" - fi - echo " · ${SUITES} suites · ${TOTAL} total" - echo " · ⏱️ ${DURATION}s" - if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then - echo " · 📊 ${U_TOTAL} unit + ${I_TOTAL} integration" + echo "" + echo "
" + echo "${SKIPPED} test(s) skipped — expand for details" + echo "" + # Extract skipped test names from integration results + if [ -n "$INTEG_RESULTS" ] && [ "$I_SKIPPED" -gt 0 ] 2>/dev/null; then + echo "**Integration:**" + jq -r ' + .testResults[] + | .assertionResults[]? + | select(.status == "pending" or .status == "skipped") + | "- \(.ancestorTitles | join(" > ")) > \(.title)" + ' "$INTEG_RESULTS" 2>/dev/null || echo "- _(unable to parse skipped test details)_" + fi + # Extract skipped test names from unit results + if [ -n "$RESULTS_FILE" ] && [ "$U_SKIPPED" -gt 0 ] 2>/dev/null; then + echo "" + echo "**Unit:**" + jq -r ' + .testResults[] + | .assertionResults[]? + | select(.status == "pending" or .status == "skipped") + | "- \(.ancestorTitles | join(" > ")) > \(.title)" + ' "$RESULTS_FILE" 2>/dev/null || echo "- _(unable to parse skipped test details)_" + fi + echo "" + echo "
" fi echo "" fi @@ -353,15 +457,15 @@ jobs: cov_table() { local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9 shift 9 - local ts=$1 tb=$2 tf=$3 tl=$4 + local bs=$1 bb=$2 bf=$3 bl=$4 echo "#### ${label}" echo "" - echo "| Metric | Coverage | Covered | Threshold | Status |" - echo "|--------|----------|---------|-----------|--------|" - echo "| Statements | **${s}%** | ${sc} | ${ts}% | $(cov_bar "$s" "$ts") |" - echo "| Branches | **${b}%** | ${bc} | ${tb}% | $(cov_bar "$b" "$tb") |" - echo "| Functions | **${f}%** | ${fc} | ${tf}% | $(cov_bar "$f" "$tf") |" - echo "| Lines | **${l}%** | ${lc} | ${tl}% | $(cov_bar "$l" "$tl") |" + echo "| Metric | Coverage | Covered | Base | Delta | Status |" + echo "|--------|----------|---------|------|-------|--------|" + echo "| Statements | **${s}%** | ${sc} | ${bs}% | $(cov_delta "$s" "$bs") | $(cov_bar "$s" "$bs") |" + echo "| Branches | **${b}%** | ${bc} | ${bb}% | $(cov_delta "$b" "$bb") | $(cov_bar "$b" "$bb") |" + echo "| Functions | **${f}%** | ${fc} | ${bf}% | $(cov_delta "$f" "$bf") | $(cov_bar "$f" "$bf") |" + echo "| Lines | **${l}%** | ${lc} | ${bl}% | $(cov_delta "$l" "$bl") | $(cov_bar "$l" "$bl") |" echo "" } @@ -371,7 +475,7 @@ jobs: cov_table "Combined (Unit + Integration)" \ "$M_STMTS" "$M_BRANCH" "$M_FUNCS" "$M_LINES" \ "$M_STMTS_COV" "$M_BRANCH_COV" "$M_FUNCS_COV" "$M_LINES_COV" \ - "$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES" + "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" echo "
" echo "Coverage breakdown by test suite" @@ -380,37 +484,23 @@ jobs: cov_table "Unit Tests" \ "$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \ "$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \ - "$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES" + "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" fi if [ "$I_STMTS" != "N/A" ]; then cov_table "Integration Tests" \ "$I_STMTS" "$I_BRANCH" "$I_FUNCS" "$I_LINES" \ "$I_STMTS_COV" "$I_BRANCH_COV" "$I_FUNCS_COV" "$I_LINES_COV" \ - "$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES" + "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" fi echo "
" echo "" - echo "
" - echo "Coverage thresholds are auto-ratcheted — they only go up" - echo "" - echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it." - echo "CI enforces the current thresholds; developers commit the ratcheted values." - echo "
" - echo "" elif [ "$U_STMTS" != "N/A" ]; then echo "### Code Coverage (Unit only)" echo "" cov_table "Unit Tests" \ "$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \ "$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \ - "$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES" - echo "
" - echo "Coverage thresholds are auto-ratcheted — they only go up" - echo "" - echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it." - echo "CI enforces the current thresholds; developers commit the ratcheted values." - echo "
" - echo "" + "$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES" else echo "### Code Coverage" echo "" diff --git a/AGENTS.md b/AGENTS.md index 6a9f026a40..f09ad8c1ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1927 symbols, 4372 relationships, 143 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -69,6 +69,24 @@ Before completing any code modification task, verify: 3. `gitnexus_detect_changes()` confirms changes match expected scope 4. All d=1 (WILL BREAK) dependents were updated +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + ## CLI | Task | Read this skill file | @@ -79,25 +97,25 @@ Before completing any code modification task, verify: | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | -| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` | -| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` | -| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` | -| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` | -| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` | +| Work in the Ingestion area (184 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | +| Work in the Cli area (71 symbols) | `.claude/skills/generated/cli/SKILL.md` | +| Work in the Workers area (58 symbols) | `.claude/skills/generated/workers/SKILL.md` | +| Work in the Wiki area (50 symbols) | `.claude/skills/generated/wiki/SKILL.md` | +| Work in the Kuzu area (45 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | +| Work in the Components area (40 symbols) | `.claude/skills/generated/components/SKILL.md` | +| Work in the Embeddings area (32 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | +| Work in the Local area (31 symbols) | `.claude/skills/generated/local/SKILL.md` | +| Work in the Mcp area (31 symbols) | `.claude/skills/generated/mcp/SKILL.md` | +| Work in the Services area (25 symbols) | `.claude/skills/generated/services/SKILL.md` | +| Work in the Resolvers area (15 symbols) | `.claude/skills/generated/resolvers/SKILL.md` | +| Work in the Eval area (15 symbols) | `.claude/skills/generated/eval/SKILL.md` | +| Work in the Llm area (14 symbols) | `.claude/skills/generated/llm/SKILL.md` | | Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | -| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` | +| Work in the Bridge area (13 symbols) | `.claude/skills/generated/bridge/SKILL.md` | | Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | | Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | -| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` | -| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` | +| Work in the Server area (9 symbols) | `.claude/skills/generated/server/SKILL.md` | +| Work in the Type-extractors area (8 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | +| Work in the Unit area (7 symbols) | `.claude/skills/generated/unit/SKILL.md` | diff --git a/CLAUDE.md b/CLAUDE.md index 6a9f026a40..f09ad8c1ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (1927 symbols, 4372 relationships, 143 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -69,6 +69,24 @@ Before completing any code modification task, verify: 3. `gitnexus_detect_changes()` confirms changes match expected scope 4. All d=1 (WILL BREAK) dependents were updated +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + ## CLI | Task | Read this skill file | @@ -79,25 +97,25 @@ Before completing any code modification task, verify: | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | -| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` | -| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` | -| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | -| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` | -| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | -| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` | -| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` | -| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` | -| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` | -| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` | -| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` | -| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` | -| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` | +| Work in the Ingestion area (184 symbols) | `.claude/skills/generated/ingestion/SKILL.md` | +| Work in the Cli area (71 symbols) | `.claude/skills/generated/cli/SKILL.md` | +| Work in the Workers area (58 symbols) | `.claude/skills/generated/workers/SKILL.md` | +| Work in the Wiki area (50 symbols) | `.claude/skills/generated/wiki/SKILL.md` | +| Work in the Kuzu area (45 symbols) | `.claude/skills/generated/kuzu/SKILL.md` | +| Work in the Components area (40 symbols) | `.claude/skills/generated/components/SKILL.md` | +| Work in the Embeddings area (32 symbols) | `.claude/skills/generated/embeddings/SKILL.md` | +| Work in the Local area (31 symbols) | `.claude/skills/generated/local/SKILL.md` | +| Work in the Mcp area (31 symbols) | `.claude/skills/generated/mcp/SKILL.md` | +| Work in the Services area (25 symbols) | `.claude/skills/generated/services/SKILL.md` | +| Work in the Resolvers area (15 symbols) | `.claude/skills/generated/resolvers/SKILL.md` | +| Work in the Eval area (15 symbols) | `.claude/skills/generated/eval/SKILL.md` | +| Work in the Llm area (14 symbols) | `.claude/skills/generated/llm/SKILL.md` | | Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` | -| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` | +| Work in the Bridge area (13 symbols) | `.claude/skills/generated/bridge/SKILL.md` | | Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` | | Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` | -| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` | -| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` | +| Work in the Server area (9 symbols) | `.claude/skills/generated/server/SKILL.md` | +| Work in the Type-extractors area (8 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` | +| Work in the Unit area (7 symbols) | `.claude/skills/generated/unit/SKILL.md` | diff --git a/README.md b/README.md index 3061e1958f..58fc0f5146 100644 --- a/README.md +++ b/README.md @@ -320,14 +320,30 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas 1. **Structure** — Walks the file tree and maps folder/file relationships 2. **Parsing** — Extracts functions, classes, methods, and interfaces using Tree-sitter ASTs -3. **Resolution** — Resolves imports and function calls across files with language-aware logic +3. **Resolution** — Resolves imports, function calls, heritage, constructor inference, and `self`/`this` receiver types across files with language-aware logic 4. **Clustering** — Groups related symbols into functional communities 5. **Processes** — Traces execution flows from entry points through call chains 6. **Search** — Builds hybrid search indexes for fast retrieval ### Supported Languages -TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Ruby, Rust, PHP, Swift +| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points | +|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------| +| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | +| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | +| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | + +**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics --- @@ -487,9 +503,10 @@ The wiki generator reads the indexed graph structure, groups files into modules ### Recently Completed +- [X] Constructor-Inferred Type Resolution, `self`/`this` Receiver Mapping - [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis - [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks -- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support +- [X] Multi-Repo MCP, Zero-Config Setup, 13 Language Support - [X] Community Detection, Process Detection, Confidence Scoring - [X] Hybrid Search, Vector Index diff --git a/gitnexus/.claude/settings.local.json b/gitnexus/.claude/settings.local.json new file mode 100644 index 0000000000..5ce0c2d701 --- /dev/null +++ b/gitnexus/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "mcp__plugin_claude-mem_mcp-search__get_observations" + ] + } +} diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test new file mode 100644 index 0000000000..9d68cf2d47 --- /dev/null +++ b/gitnexus/Dockerfile.test @@ -0,0 +1,9 @@ +FROM node:22-bookworm +WORKDIR /app +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY . . +RUN npm ci --ignore-scripts \ + && node scripts/patch-tree-sitter-swift.cjs \ + && (npm rebuild 2>&1 || true) \ + && cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1 +CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"] diff --git a/gitnexus/README.md b/gitnexus/README.md index b637735f6a..46aca00b68 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -161,23 +161,23 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, ### Language Feature Matrix -| Language | Imports | Types | Exports | Named Bindings | Config | Frameworks | Entry Points | Heritage | -|----------|---------|-------|---------|----------------|--------|------------|-------------|----------| -| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| JavaScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Java | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | -| Kotlin | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | -| Go | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | -| Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | -| PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | -| Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | -| Ruby | ✓ | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | -| C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | -| C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ | +| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points | +|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------| +| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | +| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | +| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | +| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ | +| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | -**Imports** — cross-file import resolution · **Types** — type annotation extraction · **Exports** — public/exported symbol detection · **Named Bindings** — `import { X }` tracking · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics · **Heritage** — class inheritance / interface implementation +**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics ## Agent Skills diff --git a/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md b/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md new file mode 100644 index 0000000000..68c73729ef --- /dev/null +++ b/gitnexus/docs/plans/2026-03-14-feat-type-resolution-gap-closure-plan.md @@ -0,0 +1,259 @@ +--- +title: "feat: Close type resolution gaps across all 12 languages" +type: feat +status: active +date: 2026-03-14 +--- + +# Close Type Resolution Gaps Across All 12 Languages + +## Overview + +The type resolution system (TypeEnv + call-processor) has been hardened through 6 rounds of review on `feat/type-resolution-constructor-inference`. This plan closes the remaining gaps identified by the comprehensive 4-agent gap analysis covering all 12 supported languages. + +Every task MUST include per-language integration tests following the established pattern: +- Fixture directory: `test/fixtures/lang-resolution/-/` +- Integration test: `test/integration/resolvers/.test.ts` +- Unit tests: `test/unit/type-env.test.ts` where applicable + +## Problem Statement + +The current TypeEnv extracts types from explicit annotations (Tier 0) and constructor calls (Tier 1). Many common patterns across all languages produce no type binding, causing receiver-type resolution to fail silently. The gap analysis identified 14 categories of missing patterns affecting call resolution accuracy. + +## Implementation Phases + +### Phase 1: Quick Wins (Independent — can run in parallel) + +Each task is self-contained. No dependencies between them. + +#### Task 1.1: Python walrus operator `:=` +- **File**: `src/core/ingestion/type-extractors/python.ts` +- **Change**: Add `named_expression` to `DECLARATION_NODE_TYPES` +- **Why**: `if (user := get_user()):` is common Python 3.8+ pattern; `user` gets no type binding +- **Pattern**: `named_expression` has `name` (identifier) and `value` (call) children +- **Tests**: + - Unit: `type-env.test.ts` — walrus with call, walrus with annotated type + - Integration fixture: `test/fixtures/lang-resolution/python-walrus-operator/` + - Integration test: `python.test.ts` — `user := User(); user.save()` resolves + +#### Task 1.2: PHP typed class properties +- **File**: `src/core/ingestion/type-extractors/php.ts` +- **Change**: Add `property_declaration` to `DECLARATION_NODE_TYPES`, implement `extractDeclaration` for it +- **Why**: `private User $repo;` is the primary PHP 7.4+ property declaration pattern +- **Pattern**: `property_declaration` has `type` (named_type) and child `property_element` with `name` (variable_name) +- **Tests**: + - Unit: `type-env.test.ts` — typed property extraction + - Integration fixture: `test/fixtures/lang-resolution/php-typed-properties/` + - Integration test: `php.test.ts` — `private UserRepo $repo; $repo->save()` resolves + +#### Task 1.3: Nullable receiver unwrapping +- **File**: `src/core/ingestion/utils.ts` — `extractReceiverName` function (~line 800) +- **Change**: Before returning `undefined` for non-simple receivers, check if the receiver node wraps a simple identifier with an optional chain operator +- **Patterns to handle**: + - TS/JS: `optional_chain_expression` wrapping `member_expression` + - Kotlin: `safe_navigation_expression` + - C#: `conditional_access_expression` + - Swift: `optional_chaining_expression` +- **Approach**: Add the wrapped expression types to `SIMPLE_RECEIVER_TYPES` or unwrap one level before checking +- **Tests**: + - Integration fixtures per language: `ts-nullable-receiver/`, `kotlin-nullable-receiver/`, etc. + - Integration tests: `user?.save()` resolves same as `user.save()` + +#### Task 1.4: Go `make()` builtin +- **File**: `src/core/ingestion/type-extractors/go.ts` +- **Change**: In `extractGoShortVarDeclaration`, add a `call_expression` branch for `make` (similar to existing `new` branch) +- **Pattern**: `make([]User, 0)` — first arg is `slice_type`/`map_type` with element type +- **Approach**: Extract element type from the first argument's type node +- **Tests**: + - Unit: `type-env.test.ts` — `make([]User, 0)`, `make(map[string]User)` + - Integration fixture: `test/fixtures/lang-resolution/go-make-builtin/` + - Integration test: `go.test.ts` + +#### Task 1.5: Go type assertions +- **File**: `src/core/ingestion/type-extractors/go.ts` +- **Change**: Handle `type_assertion_expression` in short var declarations +- **Pattern**: `user, ok := iface.(User)` — `type_assertion_expression` has a `type` field +- **Tests**: + - Unit: `type-env.test.ts` — type assertion single and comma-ok forms + - Integration fixture: `test/fixtures/lang-resolution/go-type-assertion/` + - Integration test: `go.test.ts` + +--- + +### Phase 2: Medium Effort (Some dependencies noted) + +#### Task 2.1: C++ range-for loop variables +- **File**: `src/core/ingestion/type-extractors/c-cpp.ts` +- **Change**: Add `for_range_loop` to `DECLARATION_NODE_TYPES`, extract the type from the declaration part +- **Pattern**: `for (auto& user : users)` — the `for_range_loop` has a `type` and `declarator` child +- **Note**: When type is `auto`, would need collection element type inference (deferred to Phase 3). For explicit types `for (User& u : users)` this works now. +- **Tests**: + - Unit: explicit type in range-for + - Integration fixture: `test/fixtures/lang-resolution/cpp-range-for/` + - Integration test: `cpp.test.ts` + +#### Task 2.2: Rust `if let` / `while let` bindings +- **File**: `src/core/ingestion/type-extractors/rust.ts` +- **Change**: Add `if_let_expression` and `while_let_expression` to `DECLARATION_NODE_TYPES` or handle in `extractDeclaration` +- **Pattern**: `if let Some(user) = opt { user.save() }` — pattern binding inside conditional +- **Approach**: Extract the pattern variable and the matched type from the source expression +- **Note**: Full generic unwrapping (Some → User) is Phase 3. Initial version extracts the binding variable with no type — still useful for scope tracking. +- **Tests**: + - Unit: if-let with annotated type, while-let + - Integration fixture: `test/fixtures/lang-resolution/rust-if-let/` + - Integration test: `rust.test.ts` + +#### Task 2.3: Swift `guard let` / `if let` bindings +- **File**: `src/core/ingestion/type-extractors/swift.ts` +- **Change**: Add `guard_statement` and `if_statement` with `optional_binding_condition` to declaration handling +- **Pattern**: `guard let user = fetchUser() else { return }` — `optional_binding_condition` has `pattern` and `value` +- **Note**: Swift parser availability varies (Node 22 issue). Tests must use `describe.skipIf(!swiftAvailable)`. +- **Tests**: + - Integration fixture: `test/fixtures/lang-resolution/swift-guard-let/` + - Integration test: `swift.test.ts` with skipIf guard + +#### Task 2.4: C# pattern matching `is Type variable` +- **File**: `src/core/ingestion/type-extractors/csharp.ts` +- **Change**: Handle `is_pattern_expression` or `declaration_pattern` in the type extractor +- **Pattern**: `if (obj is User user) { user.Save(); }` — `declaration_pattern` has `type` and `name` +- **Tests**: + - Unit: `type-env.test.ts` — is-pattern with type + - Integration fixture: `test/fixtures/lang-resolution/csharp-pattern-matching/` + - Integration test: `csharp.test.ts` + +#### Task 2.5: Python class-level type annotations +- **File**: `src/core/ingestion/type-extractors/python.ts` +- **Change**: Ensure `assignment` declarationNodeTypes also captures class body assignments with type annotations +- **Pattern**: `class User: name: str = "default"` — tree-sitter may use `expression_statement` > `assignment` inside class body +- **Note**: Check if this already works via existing `assignment` handling — may just need scope key fix +- **Tests**: + - Unit: class-level annotation + - Integration fixture: `test/fixtures/lang-resolution/python-class-annotations/` + +--- + +### Phase 3: Architecture Changes (Sequential — requires design decisions) + +#### Task 3.1: Return type inference +- **Files**: `src/core/ingestion/type-env.ts`, `src/core/ingestion/call-processor.ts` +- **Change**: When processing calls, look up the callee's return type from SymbolTable and bind the assignment target +- **Approach**: + 1. `extractMethodSignature` already stores `returnType` in symbol metadata + 2. In `buildTypeEnv` or as a post-processing step, for assignments like `let x = foo()`, look up `foo` in SymbolTable + 3. If `foo.returnType` exists, add binding `x → returnType` + 4. Must strip nullable wrappers and generic containers to get base type +- **Risk**: Circular dependencies if two functions return each other's types. Mitigate with depth limit. +- **Tests**: Per-language integration tests for return-type-inferred receiver resolution + +#### Task 3.2: Chained property access resolution +- **Files**: `src/core/ingestion/utils.ts` (`extractReceiverName`), `src/core/ingestion/call-processor.ts` +- **Change**: When receiver is `this.repo`, resolve `this` → class type, then look up `repo` property type on that class +- **Approach**: + 1. Extend `extractReceiverName` to return structured data: `{ chain: ['this', 'repo'] }` for multi-level access + 2. In call-processor, resolve chain iteratively: lookup first element, find property type, continue + 3. Requires property-type tracking: store class property types in TypeEnv or SymbolTable +- **Risk**: Performance impact from recursive lookups. Limit chain depth to 3. +- **Tests**: Per-language `this.repo.save()` / `self.db.query()` patterns + +#### Task 3.3: For-loop variable typing +- **Files**: Per-language type extractors + `src/core/ingestion/type-env.ts` +- **Change**: Extract loop variable type from explicit annotations or inferred from collection type +- **Patterns per language**: + - TS/JS: `for (const x of collection)` — `for_in_statement` with `left` and `right` + - Java/C#: `for (Type x : collection)` — explicit type already works + - Go: `for _, v := range slice` — `range_clause` with identifier + - Python: `for x in collection` — `for_statement` with `left` + - Rust: `for x in iter` — `for_expression` with `pattern` +- **Depends on**: Task 3.1 (return type inference) for inferring collection element types +- **Tests**: Per-language integration tests + +#### Task 3.4: Generic type parameter extraction +- **File**: `src/core/ingestion/type-extractors/shared.ts` — `extractSimpleTypeName` +- **Change**: Add optional `extractGenericArgs` mode that returns type parameters alongside base type +- **Current**: `List` → `List` (base only) +- **New**: `List` → `{ base: 'List', args: ['User'] }` when requested +- **Use case**: For-loop element type inference, collection method resolution +- **Tests**: Unit tests for each language's generic syntax + +#### Task 3.5: Block-level type narrowing +- **File**: `src/core/ingestion/type-env.ts` — scope handling +- **Change**: Add sub-function scope support for if/match/guard blocks +- **Current**: Scope keys are `funcName@startIndex` or `''` (file) +- **New**: Add block scope keys like `funcName@startIndex#if@lineN` +- **Risk**: Scope lookup complexity increases. Need to walk up scope chain on miss. +- **Tests**: Per-language pattern matching, instanceof, type guards + +#### Task 3.6: Ruby dedicated type extractor +- **File**: New `src/core/ingestion/type-extractors/ruby.ts` +- **Change**: Replace the stub in `index.ts` with a real type extractor +- **Features**: + - YARD annotation parsing (`@param name [Type]`, `@return [Type]`) + - Instance variable type inference from constructor assignments + - Block parameter typing heuristics +- **Tests**: Integration fixture: `test/fixtures/lang-resolution/ruby-typed-methods/` + +--- + +## Acceptance Criteria + +### Functional Requirements +- [ ] Each Phase 1 task has unit tests AND per-language integration tests with fixtures +- [ ] Each Phase 2 task has per-language integration tests with fixtures +- [ ] Each Phase 3 task has cross-language integration tests +- [ ] All existing 101 unit tests continue to pass +- [ ] All existing integration tests continue to pass +- [ ] No regressions in existing call resolution + +### Quality Gates +- [ ] `npx vitest run test/unit/type-env.test.ts` — all pass +- [ ] `npx vitest run test/integration/resolvers/ --no-file-parallelism` — all pass +- [ ] No new TypeScript compilation errors + +### Testing Pattern (MANDATORY for every task) +``` +1. Create fixture: test/fixtures/lang-resolution/-/ + - Source file with the pattern (e.g., main.py, models.py) + - Class/struct definitions with methods to resolve against +2. Add describe block: test/integration/resolvers/.test.ts + - beforeAll: runPipelineFromRepo(fixture_path) + - Tests: verify CALLS edges resolve to correct file/method +3. Add unit tests: test/unit/type-env.test.ts (where applicable) + - Parse code snippet, verify TypeEnv bindings +``` + +## Task Dependency Graph + +``` +Phase 1 (all parallel, no dependencies): + 1.1 Python walrus ─┐ + 1.2 PHP properties ├─ All independent + 1.3 Nullable unwrap ├─ Can run as parallel swarm agents + 1.4 Go make() │ + 1.5 Go type assert ─┘ + +Phase 2 (mostly parallel): + 2.1 C++ range-for ─┐ + 2.2 Rust if-let ├─ Independent of each other + 2.3 Swift guard-let ├─ Can run as parallel swarm agents + 2.4 C# pattern match │ + 2.5 Python class ann ─┘ + +Phase 3 (sequential dependencies): + 3.1 Return type inference ──→ 3.3 For-loop typing + ↑ + 3.4 Generic extraction ────────────┘ + + 3.2 Chained property access (independent) + 3.5 Block-level scoping (independent) + 3.6 Ruby type extractor (independent) +``` + +## Sources & References + +- PR #274: `feat/type-resolution-constructor-inference` — 6 rounds of review fixes +- Existing patterns: `test/fixtures/lang-resolution/` (150 fixture dirs) +- Integration tests: `test/integration/resolvers/*.test.ts` (12 languages) +- Type extractors: `src/core/ingestion/type-extractors/*.ts` (9 files + index) +- TypeEnv: `src/core/ingestion/type-env.ts` +- Call processor: `src/core/ingestion/call-processor.ts` +- Receiver extraction: `src/core/ingestion/utils.ts:extractReceiverName` diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 76ec631862..12077f6b93 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,14 +1,12 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition, SymbolTable } from './symbol-table.js'; -import { ImportMap, PackageMap, NamedImportMap, isFileInPackageDir } from './import-processor.js'; -import { resolveSymbolInternal } from './symbol-resolver.js'; -import { walkBindingChain } from './named-binding-extraction.js'; +import type { SymbolDefinition } from './symbol-table.js'; import Parser from 'tree-sitter'; +import type { ResolutionContext } from './resolution-context.js'; +import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; -import { SupportedLanguages } from '../../config/supported-languages.js'; import { getLanguageFromFilename, isVerboseIngestionEnabled, @@ -21,9 +19,9 @@ import { extractReceiverName, findEnclosingClassId, } from './utils.js'; -import { buildTypeEnv, lookupTypeEnv } from './type-env.js'; +import { buildTypeEnv } from './type-env.js'; import { getTreeSitterBufferSize } from './constants.js'; -import type { ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js'; +import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { callRouters } from './call-routing.js'; /** @@ -33,7 +31,7 @@ import { callRouters } from './call-routing.js'; const findEnclosingFunction = ( node: any, filePath: string, - symbolTable: SymbolTable + ctx: ResolutionContext ): string | null => { let current = node.parent; @@ -42,8 +40,10 @@ const findEnclosingFunction = ( const { funcName, label } = extractFunctionName(current); if (funcName) { - const nodeId = symbolTable.lookupExact(filePath, funcName); - if (nodeId) return nodeId; + const resolved = ctx.resolve(funcName, filePath); + if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) { + return resolved.candidates[0].nodeId; + } return generateId(label, `${filePath}:${funcName}`); } @@ -58,11 +58,8 @@ export const processCalls = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], astCache: ASTCache, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, + ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, - namedImportMap?: NamedImportMap, ): Promise => { const parser = await loadParser(); const collectedHeritage: ExtractedHeritage[] = []; @@ -74,7 +71,6 @@ export const processCalls = async ( onProgress?.(i + 1, files.length); if (i % 20 === 0) await yieldToEventLoop(); - // 1. Check language support first const language = getLanguageFromFilename(file.path); if (!language) continue; if (!isLanguageAvailable(language)) { @@ -87,24 +83,15 @@ export const processCalls = async ( const queryStr = LANGUAGE_QUERIES[language]; if (!queryStr) continue; - // 2. ALWAYS load the language before querying (parser is stateful) await loadLanguage(language, file.path); - // 3. Get AST (Try Cache First) let tree = astCache.get(file.path); - let wasReparsed = false; - if (!tree) { - // Cache Miss: Re-parse - // Use larger bufferSize for files > 32KB try { tree = parser.parse(file.content, undefined, { bufferSize: getTreeSitterBufferSize(file.content.length) }); } catch (parseError) { - // Skip files that can't be parsed continue; } - wasReparsed = true; - // Cache re-parsed tree so heritage phase gets hits astCache.set(file.path, tree); } @@ -119,17 +106,16 @@ export const processCalls = async ( continue; } - // Build per-file TypeEnv for receiver resolution const lang = getLanguageFromFilename(file.path); - const typeEnv = lang ? buildTypeEnv(tree, lang) : new Map(); + const typeEnv = lang ? buildTypeEnv(tree, lang, ctx.symbols) : null; const callRouter = callRouters[language]; - // 3. Process each call match + ctx.enableCache(file.path); + matches.forEach(match => { const captureMap: Record = {}; match.captures.forEach(c => captureMap[c.name] = c.node); - // Only process @call captures if (!captureMap['call']) return; const nameNode = captureMap['call.name']; @@ -137,12 +123,11 @@ export const processCalls = async ( const calledName = nameNode.text; - // Dispatch: route language-specific calls (heritage, properties, imports) const routed = callRouter(calledName, captureMap['call']); if (routed) { switch (routed.kind) { case 'skip': - case 'import': // handled by import-processor + case 'import': return; case 'heritage': @@ -171,7 +156,7 @@ export const processCalls = async ( description: item.accessorType, }, }); - symbolTable.add(file.path, item.propName, nodeId, 'Property', + ctx.symbols.add(file.path, item.propName, nodeId, 'Property', propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); graph.addRelationship({ @@ -190,34 +175,28 @@ export const processCalls = async ( } case 'call': - break; // fall through to normal call processing below + break; } } - // Skip common built-ins and noise if (isBuiltInOrNoise(calledName)) return; const callNode = captureMap['call']; const callForm = inferCallForm(callNode, nameNode); const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; - const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined; + const receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; - // 4. Resolve the target using priority strategy (returns confidence) const resolved = resolveCallTarget({ calledName, argCount: countCallArguments(callNode), callForm, receiverTypeName, - }, file.path, symbolTable, importMap, packageMap, namedImportMap); + }, file.path, ctx); if (!resolved) return; - // 5. Find the enclosing function (caller) - const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable); - - // Use enclosing function as source, fallback to file for top-level calls + const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx); const sourceId = enclosingFuncId || generateId('File', file.path); - const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`); graph.addRelationship({ @@ -230,7 +209,7 @@ export const processCalls = async ( }); }); - // Tree is now owned by the LRU cache — no manual delete needed + ctx.clearCache(); } if (skippedByLang && skippedByLang.size > 0) { @@ -249,15 +228,8 @@ export const processCalls = async ( */ interface ResolveResult { nodeId: string; - confidence: number; // 0-1: how sure are we? - reason: string; // 'import-resolved' | 'same-file' | 'unique-global' -} - -type ResolutionTier = 'same-file' | 'import-scoped' | 'unique-global'; - -interface TieredCandidates { - candidates: SymbolDefinition[]; - tier: ResolutionTier; + confidence: number; + reason: string; } const CALLABLE_SYMBOL_TYPES = new Set([ @@ -268,73 +240,16 @@ const CALLABLE_SYMBOL_TYPES = new Set([ 'Delegate', ]); -const collectTieredCandidates = ( - calledName: string, - currentFile: string, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, -): TieredCandidates | null => { - const allDefs = symbolTable.lookupFuzzy(calledName); - - // Tier 1: Same-file — highest priority, prevents imports from shadowing local defs - // (matches resolveSymbolInternal which checks lookupExactFull before named bindings) - const localDefs = allDefs.filter(def => def.filePath === currentFile); - if (localDefs.length > 0) { - return { candidates: localDefs, tier: 'same-file' }; - } - - // Tier 2a-named: Check named bindings with re-export chain following. - // Aliased imports (import { User as U }) mean lookupFuzzy('U') returns - // empty but we can resolve via the exported name. - // Re-exports (export { User } from './base') are followed up to 5 hops. - if (namedImportMap) { - const chainResult = resolveNamedBindingChainForCandidates( - calledName, currentFile, symbolTable, namedImportMap, allDefs, - ); - if (chainResult) return chainResult; - } - - if (allDefs.length === 0) return null; - - const importedFiles = importMap.get(currentFile); - if (importedFiles) { - const importedDefs = allDefs.filter(def => importedFiles.has(def.filePath)); - if (importedDefs.length > 0) { - return { candidates: importedDefs, tier: 'import-scoped' }; - } - } - - const importedPackages = packageMap?.get(currentFile); - if (importedPackages) { - const packageDefs = allDefs.filter(def => { - for (const dirSuffix of importedPackages) { - if (isFileInPackageDir(def.filePath, dirSuffix)) return true; - } - return false; - }); - if (packageDefs.length > 0) { - return { candidates: packageDefs, tier: 'import-scoped' }; - } - } - - // Tier 3: Global — pass all candidates through; filterCallableCandidates - // will narrow by kind/arity and resolveCallTarget only emits when exactly 1 remains. - return { candidates: allDefs, tier: 'unique-global' }; -}; - const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']); const filterCallableCandidates = ( - candidates: SymbolDefinition[], + candidates: readonly SymbolDefinition[], argCount?: number, callForm?: 'free' | 'member' | 'constructor', ): SymbolDefinition[] => { let kindFiltered: SymbolDefinition[]; if (callForm === 'constructor') { - // For constructor calls, prefer Constructor > Class/Struct/Record > callable fallback const constructors = candidates.filter(c => c.type === 'Constructor'); if (constructors.length > 0) { kindFiltered = constructors; @@ -360,19 +275,15 @@ const filterCallableCandidates = ( const toResolveResult = ( definition: SymbolDefinition, tier: ResolutionTier, -): ResolveResult => { - if (tier === 'same-file') { - return { nodeId: definition.nodeId, confidence: 0.95, reason: 'same-file' }; - } - if (tier === 'import-scoped') { - return { nodeId: definition.nodeId, confidence: 0.9, reason: 'import-resolved' }; - } - return { nodeId: definition.nodeId, confidence: 0.5, reason: 'unique-global' }; -}; +): ResolveResult => ({ + nodeId: definition.nodeId, + confidence: TIER_CONFIDENCE[tier], + reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global', +}); /** * Resolve a function call to its target node ID using priority strategy: - * A. Narrow candidates by scope tier (same-file, import-scoped, unique-global) + * A. Narrow candidates by scope tier via ctx.resolve() * B. Filter to callable symbol kinds (constructor-aware when callForm is set) * C. Apply arity filtering when parameter metadata is available * D. Apply receiver-type filtering for member calls with typed receivers @@ -382,29 +293,48 @@ const toResolveResult = ( const resolveCallTarget = ( call: Pick, currentFile: string, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, + ctx: ResolutionContext, ): ResolveResult | null => { - const tiered = collectTieredCandidates(call.calledName, currentFile, symbolTable, importMap, packageMap, namedImportMap); + const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; const filteredCandidates = filterCallableCandidates(tiered.candidates, call.argCount, call.callForm); // D. Receiver-type filtering: for member calls with a known receiver type, - // filter candidates by ownerId matching the resolved type's nodeId - if (call.callForm === 'member' && call.receiverTypeName && filteredCandidates.length > 1) { - const typeDefs = symbolTable.lookupFuzzy(call.receiverTypeName); - if (typeDefs.length > 0) { - const typeNodeIds = new Set(typeDefs.map(d => d.nodeId)); - const ownerFiltered = filteredCandidates.filter(c => c.ownerId && typeNodeIds.has(c.ownerId)); + // resolve the type through the same tiered import infrastructure, then + // filter method candidates to the type's defining file. Fall back to + // fuzzy ownerId matching only when file-based narrowing is inconclusive. + // + // Applied regardless of candidate count — the sole same-file candidate may + // belong to the wrong class (e.g. super.save() should hit the parent's save, + // not the child's own save method in the same file). + if (call.callForm === 'member' && call.receiverTypeName) { + // D1. Resolve the receiver type + const typeResolved = ctx.resolve(call.receiverTypeName, currentFile); + if (typeResolved && typeResolved.candidates.length > 0) { + const typeNodeIds = new Set(typeResolved.candidates.map(d => d.nodeId)); + const typeFiles = new Set(typeResolved.candidates.map(d => d.filePath)); + + // D2. Widen candidates: same-file tier may miss the parent's method when + // it lives in another file. Query the symbol table directly for all + // global methods with this name, then apply arity/kind filtering. + const methodPool = filteredCandidates.length <= 1 + ? filterCallableCandidates(ctx.symbols.lookupFuzzy(call.calledName), call.argCount, call.callForm) + : filteredCandidates; + + // D3. File-based: prefer candidates whose filePath matches the resolved type's file + const fileFiltered = methodPool.filter(c => typeFiles.has(c.filePath)); + if (fileFiltered.length === 1) { + return toResolveResult(fileFiltered[0], tiered.tier); + } + + // D4. ownerId fallback: narrow by ownerId matching the type's nodeId + const pool = fileFiltered.length > 0 ? fileFiltered : methodPool; + const ownerFiltered = pool.filter(c => c.ownerId && typeNodeIds.has(c.ownerId)); if (ownerFiltered.length === 1) { return toResolveResult(ownerFiltered[0], tiered.tier); } - // If receiver filtering narrows to 0, fall through to name-only resolution - // If still 2+, refuse (don't guess) - if (ownerFiltered.length > 1) return null; + if (fileFiltered.length > 1 || ownerFiltered.length > 1) return null; } } @@ -413,62 +343,100 @@ const resolveCallTarget = ( return toResolveResult(filteredCandidates[0], tiered.tier); }; +// ── Scope key helpers ──────────────────────────────────────────────────── +// Scope keys use the format "funcName@startIndex" (produced by type-env.ts). +// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts). +// NUL (\0) is used as a composite-key separator because it cannot appear +// in source-code identifiers, preventing ambiguous concatenation. + +/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */ +const extractFuncNameFromScope = (scope: string): string => + scope.slice(0, scope.indexOf('@')); + +/** Extract the trailing function name from a sourceId ("Function:filepath:funcName" → "funcName"). */ +const extractFuncNameFromSourceId = (sourceId: string): string => { + const lastColon = sourceId.lastIndexOf(':'); + return lastColon >= 0 ? sourceId.slice(lastColon + 1) : ''; +}; + +/** Build a scope-aware composite key for receiver type lookup. */ +const receiverKey = (funcName: string, varName: string): string => + `${funcName}\0${varName}`; + /** * Fast path: resolve pre-extracted call sites from workers. * No AST parsing — workers already extracted calledName + sourceId. - * This function only does symbol table lookups + graph mutations. */ export const processCallsFromExtracted = async ( graph: KnowledgeGraph, extractedCalls: ExtractedCall[], - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, + ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, - namedImportMap?: NamedImportMap, + constructorBindings?: FileConstructorBindings[], ) => { - // Group by file for progress reporting + // 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>(); + if (constructorBindings) { + for (const { filePath, bindings } of constructorBindings) { + for (const { scope, varName, calleeName } of bindings) { + const tiered = ctx.resolve(calleeName, filePath); + const isClass = tiered?.candidates.some(def => def.type === 'Class') ?? false; + if (isClass) { + if (!fileReceiverTypes.has(filePath)) fileReceiverTypes.set(filePath, new Map()); + fileReceiverTypes.get(filePath)!.set(receiverKey(extractFuncNameFromScope(scope), varName), calleeName); + } + } + } + } + const byFile = new Map(); for (const call of extractedCalls) { let list = byFile.get(call.filePath); - if (!list) { - list = []; - byFile.set(call.filePath, list); - } + if (!list) { list = []; byFile.set(call.filePath, list); } list.push(call); } const totalFiles = byFile.size; let filesProcessed = 0; - for (const [_filePath, calls] of byFile) { + for (const [filePath, calls] of byFile) { filesProcessed++; if (filesProcessed % 100 === 0) { onProgress?.(filesProcessed, totalFiles); await yieldToEventLoop(); } + ctx.enableCache(filePath); + const receiverMap = fileReceiverTypes.get(filePath); + for (const call of calls) { - const resolved = resolveCallTarget( - call, - call.filePath, - symbolTable, - importMap, - packageMap, - namedImportMap, - ); + let effectiveCall = call; + if (!call.receiverTypeName && call.receiverName && receiverMap) { + const callFuncName = extractFuncNameFromSourceId(call.sourceId); + const resolvedType = receiverMap.get(receiverKey(callFuncName, call.receiverName)) + ?? receiverMap.get(receiverKey('', call.receiverName)); // fall back to file-level scope + if (resolvedType) { + effectiveCall = { ...call, receiverTypeName: resolvedType }; + } + } + + const resolved = resolveCallTarget(effectiveCall, effectiveCall.filePath, ctx); if (!resolved) continue; - const relId = generateId('CALLS', `${call.sourceId}:${call.calledName}->${resolved.nodeId}`); + const relId = generateId('CALLS', `${effectiveCall.sourceId}:${effectiveCall.calledName}->${resolved.nodeId}`); graph.addRelationship({ id: relId, - sourceId: call.sourceId, + sourceId: effectiveCall.sourceId, targetId: resolved.nodeId, type: 'CALLS', confidence: resolved.confidence, reason: resolved.reason, }); } + + ctx.clearCache(); } onProgress?.(totalFiles, totalFiles); @@ -480,10 +448,8 @@ export const processCallsFromExtracted = async ( export const processRoutesFromExtracted = async ( graph: KnowledgeGraph, extractedRoutes: ExtractedRoute[], - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - onProgress?: (current: number, total: number) => void + ctx: ResolutionContext, + onProgress?: (current: number, total: number) => void, ) => { for (let i = 0; i < extractedRoutes.length; i++) { const route = extractedRoutes[i]; @@ -494,23 +460,18 @@ export const processRoutesFromExtracted = async ( if (!route.controllerName || !route.methodName) continue; - // Resolve controller class using shared resolver (Tier 1: same file, - // Tier 2: import-scoped, Tier 3: unique global). - const resolution = resolveSymbolInternal(route.controllerName, route.filePath, symbolTable, importMap, packageMap); - if (!resolution) continue; + const controllerResolved = ctx.resolve(route.controllerName, route.filePath); + if (!controllerResolved || controllerResolved.candidates.length === 0) continue; + if (controllerResolved.tier === 'global' && controllerResolved.candidates.length > 1) continue; - const controllerDef = resolution.definition; - // Derive confidence from the resolution tier - const confidence = resolution.tier === 'same-file' ? 0.95 - : resolution.tier === 'import-scoped' ? 0.9 - : 0.7; + const controllerDef = controllerResolved.candidates[0]; + const confidence = TIER_CONFIDENCE[controllerResolved.tier]; - // Find the method on the controller - const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName); + const methodResolved = ctx.resolve(route.methodName, controllerDef.filePath); + const methodId = methodResolved?.tier === 'same-file' ? methodResolved.candidates[0]?.nodeId : undefined; const sourceId = generateId('File', route.filePath); if (!methodId) { - // Construct method ID manually const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`); const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`); graph.addRelationship({ @@ -537,22 +498,3 @@ export const processRoutesFromExtracted = async ( onProgress?.(extractedRoutes.length, extractedRoutes.length); }; - -/** - * Follow re-export chains through NamedImportMap for call candidate collection. - * Delegates chain-walking to the shared walkBindingChain utility, then - * applies call-processor semantics: any number of matches accepted. - */ -const resolveNamedBindingChainForCandidates = ( - calledName: string, - currentFile: string, - symbolTable: SymbolTable, - namedImportMap: NamedImportMap, - allDefs: SymbolDefinition[], -): TieredCandidates | null => { - const defs = walkBindingChain(calledName, currentFile, symbolTable, namedImportMap, allDefs); - if (defs && defs.length > 0) { - return { candidates: defs, tier: 'import-scoped' }; - } - return null; -}; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 40eb298572..cbbff44305 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -16,7 +16,6 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import { SymbolTable, SymbolDefinition } from './symbol-table.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; @@ -25,8 +24,7 @@ import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop } import { SupportedLanguages } from '../../config/supported-languages.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { ExtractedHeritage } from './workers/parse-worker.js'; -import { resolveSymbol } from './symbol-resolver.js'; -import type { ImportMap, PackageMap } from './import-processor.js'; +import type { ResolutionContext } from './resolution-context.js'; /** C#/Java convention: interfaces start with I followed by an uppercase letter */ const INTERFACE_NAME_RE = /^I[A-Z]/; @@ -42,14 +40,12 @@ const INTERFACE_NAME_RE = /^I[A-Z]/; const resolveExtendsType = ( parentName: string, currentFilePath: string, - symbolTable: SymbolTable, - importMap: ImportMap, + ctx: ResolutionContext, language: SupportedLanguages, - packageMap?: PackageMap, ): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => { - const resolved = resolveSymbol(parentName, currentFilePath, symbolTable, importMap, packageMap); - if (resolved) { - const isInterface = resolved.type === 'Interface'; + const resolved = ctx.resolve(parentName, currentFilePath); + if (resolved && resolved.candidates.length > 0) { + const isInterface = resolved.candidates[0].type === 'Interface'; return isInterface ? { type: 'IMPLEMENTS', idPrefix: 'Interface' } : { type: 'EXTENDS', idPrefix: 'Class' }; @@ -66,14 +62,34 @@ const resolveExtendsType = ( return { type: 'EXTENDS', idPrefix: 'Class' }; }; +/** + * Resolve a symbol ID for heritage, with fallback to generated ID. + * Uses ctx.resolve() → pick first candidate's nodeId → generate synthetic ID. + */ +const resolveHeritageId = ( + name: string, + filePath: string, + ctx: ResolutionContext, + fallbackLabel: string, + fallbackKey?: string, +): string => { + const resolved = ctx.resolve(name, filePath); + if (resolved && resolved.candidates.length > 0) { + // For global with multiple candidates, refuse (a wrong edge is worse than no edge) + if (resolved.tier === 'global' && resolved.candidates.length > 1) { + return generateId(fallbackLabel, fallbackKey ?? name); + } + return resolved.candidates[0].nodeId; + } + return generateId(fallbackLabel, fallbackKey ?? name); +}; + export const processHeritage = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], astCache: ASTCache, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - onProgress?: (current: number, total: number) => void + ctx: ResolutionContext, + onProgress?: (current: number, total: number) => void, ) => { const parser = await loadParser(); const logSkipped = isVerboseIngestionEnabled(); @@ -102,8 +118,6 @@ export const processHeritage = async ( // 3. Get AST let tree = astCache.get(file.path); - let wasReparsed = false; - if (!tree) { // Use larger bufferSize for files > 32KB try { @@ -112,7 +126,6 @@ export const processHeritage = async ( // Skip files that can't be parsed continue; } - wasReparsed = true; // Cache re-parsed tree for potential future use astCache.set(file.path, tree); } @@ -148,14 +161,10 @@ export const processHeritage = async ( const className = captureMap['heritage.class'].text; const parentClassName = captureMap['heritage.extends'].text; - const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, symbolTable, importMap, language, packageMap); + const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, ctx, language); - const childId = symbolTable.lookupExact(file.path, className) || - resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId('Class', `${file.path}:${className}`); - - const parentId = resolveSymbol(parentClassName, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId(idPrefix, `${parentClassName}`); + const childId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`); + const parentId = resolveHeritageId(parentClassName, file.path, ctx, idPrefix); if (childId && parentId && childId !== parentId) { graph.addRelationship({ @@ -174,19 +183,12 @@ export const processHeritage = async ( const className = captureMap['heritage.class'].text; const interfaceName = captureMap['heritage.implements'].text; - // Resolve class and interface IDs - const classId = symbolTable.lookupExact(file.path, className) || - resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId('Class', `${file.path}:${className}`); - - const interfaceId = resolveSymbol(interfaceName, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId('Interface', `${interfaceName}`); + const classId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`); + const interfaceId = resolveHeritageId(interfaceName, file.path, ctx, 'Interface'); if (classId && interfaceId) { - const relId = generateId('IMPLEMENTS', `${classId}->${interfaceId}`); - graph.addRelationship({ - id: relId, + id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`), sourceId: classId, targetId: interfaceId, type: 'IMPLEMENTS', @@ -201,19 +203,12 @@ export const processHeritage = async ( const structName = captureMap['heritage.class'].text; const traitName = captureMap['heritage.trait'].text; - // Resolve struct and trait IDs - const structId = symbolTable.lookupExact(file.path, structName) || - resolveSymbol(structName, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId('Struct', `${file.path}:${structName}`); - - const traitId = resolveSymbol(traitName, file.path, symbolTable, importMap, packageMap)?.nodeId || - generateId('Trait', `${traitName}`); + const structId = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`); + const traitId = resolveHeritageId(traitName, file.path, ctx, 'Trait'); if (structId && traitId) { - const relId = generateId('IMPLEMENTS', `${structId}->${traitId}`); - graph.addRelationship({ - id: relId, + id: generateId('IMPLEMENTS', `${structId}->${traitId}`), sourceId: structId, targetId: traitId, type: 'IMPLEMENTS', @@ -243,10 +238,8 @@ export const processHeritage = async ( export const processHeritageFromExtracted = async ( graph: KnowledgeGraph, extractedHeritage: ExtractedHeritage[], - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - onProgress?: (current: number, total: number) => void + ctx: ResolutionContext, + onProgress?: (current: number, total: number) => void, ) => { const total = extractedHeritage.length; @@ -261,14 +254,10 @@ export const processHeritageFromExtracted = async ( if (h.kind === 'extends') { const fileLanguage = getLanguageFromFilename(h.filePath); if (!fileLanguage) continue; - const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, symbolTable, importMap, fileLanguage, packageMap); + const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, ctx, fileLanguage); - const childId = symbolTable.lookupExact(h.filePath, h.className) || - resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId('Class', `${h.filePath}:${h.className}`); - - const parentId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId(idPrefix, `${h.parentName}`); + const childId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`); + const parentId = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix); if (childId && parentId && childId !== parentId) { graph.addRelationship({ @@ -281,12 +270,8 @@ export const processHeritageFromExtracted = async ( }); } } else if (h.kind === 'implements') { - const classId = symbolTable.lookupExact(h.filePath, h.className) || - resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId('Class', `${h.filePath}:${h.className}`); - - const interfaceId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId('Interface', `${h.parentName}`); + const classId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`); + const interfaceId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface'); if (classId && interfaceId) { graph.addRelationship({ @@ -299,12 +284,8 @@ export const processHeritageFromExtracted = async ( }); } } else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') { - const structId = symbolTable.lookupExact(h.filePath, h.className) || - resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId('Struct', `${h.filePath}:${h.className}`); - - const traitId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId || - generateId('Trait', `${h.parentName}`); + const structId = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`); + const traitId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait'); if (structId && traitId) { graph.addRelationship({ diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 868baaf6e7..cbe5923d3f 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -33,6 +33,7 @@ import { resolveRubyImport, } from './resolvers/index.js'; import { callRouters } from './call-routing.js'; +import type { ResolutionContext } from './resolution-context.js'; import type { SuffixIndex, TsconfigPaths, @@ -56,15 +57,11 @@ const isDev = process.env.NODE_ENV === 'development'; // Stores all files that a given file imports from export type ImportMap = Map>; -export const createImportMap = (): ImportMap => new Map(); - // Type: Map> // Stores Go package directory suffixes imported by a file (e.g., "/internal/auth/"). // Avoids expanding every Go package import into N individual ImportMap edges. export type PackageMap = Map>; -export const createPackageMap = (): PackageMap => new Map(); - // Type: Map> // Tracks which specific names a file imports from which sources (TS/Python only). // Used to tighten Tier 2a resolution: `import { User } from './models'` @@ -74,8 +71,6 @@ export const createPackageMap = (): PackageMap => new Map(); export interface NamedImportBinding { sourcePath: string; exportedName: string } export type NamedImportMap = Map>; -export const createNamedImportMap = (): NamedImportMap => new Map(); - /** * Check if a file path is directly inside a package directory identified by its suffix. * Used by the symbol resolver for Go and C# directory-level import matching. @@ -309,13 +304,14 @@ export const processImports = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], astCache: ASTCache, - importMap: ImportMap, + ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, repoRoot?: string, allPaths?: string[], - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, ) => { + const importMap = ctx.importMap; + const packageMap = ctx.packageMap; + const namedImportMap = ctx.namedImportMap; // Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files const allFileList = allPaths ?? files.map(f => f.path); const allFilePaths = new Set(allFileList); @@ -341,7 +337,7 @@ export const processImports = async ( swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot), csharpConfigs: await loadCSharpProjectConfig(effectiveRoot), }; - const ctx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; + const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache }; // Helper: add an IMPORTS edge to the graph only (no ImportMap update) const addImportGraphEdge = (filePath: string, resolvedPath: string) => { @@ -448,7 +444,7 @@ export const processImports = async ( : sourceNode.text.replace(/['"<>]/g, ''); totalImportsFound++; - const result = resolveLanguageImport(file.path, rawImportPath, language, configs, ctx); + const result = resolveLanguageImport(file.path, rawImportPath, language, configs, resolveCtx); const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined; applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap); } @@ -461,7 +457,7 @@ export const processImports = async ( const routed = callRouter(callNameNode.text, captureMap['call']); if (routed && routed.kind === 'import') { totalImportsFound++; - const result = resolveLanguageImport(file.path, routed.importPath, language, configs, ctx); + const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx); applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge); } } @@ -492,15 +488,16 @@ export const processImportsFromExtracted = async ( graph: KnowledgeGraph, files: { path: string }[], extractedImports: ExtractedImport[], - importMap: ImportMap, + ctx: ResolutionContext, onProgress?: (current: number, total: number) => void, repoRoot?: string, prebuiltCtx?: ImportResolutionContext, - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, ) => { - const ctx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path)); - const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = ctx; + const importMap = ctx.importMap; + const packageMap = ctx.packageMap; + const namedImportMap = ctx.namedImportMap; + const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path)); + const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = importCtx; let totalImportsFound = 0; let totalImportsResolved = 0; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 26d5a6d512..557105e57f 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -9,7 +9,7 @@ import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, get import { isNodeExported } from './export-detection.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { WorkerPool } from './workers/worker-pool.js'; -import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js'; +import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -19,6 +19,7 @@ export interface WorkerExtractedData { calls: ExtractedCall[]; heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; + constructorBindings: FileConstructorBindings[]; } // isNodeExported imported from ./export-detection.js (shared module) @@ -44,7 +45,7 @@ const processParsingWithWorkers = async ( if (lang) parseableFiles.push({ path: file.path, content: file.content }); } - if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] }; + if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [], constructorBindings: [] }; const total = files.length; @@ -61,6 +62,7 @@ const processParsingWithWorkers = async ( const allCalls: ExtractedCall[] = []; const allHeritage: ExtractedHeritage[] = []; const allRoutes: ExtractedRoute[] = []; + const allConstructorBindings: FileConstructorBindings[] = []; for (const result of chunkResults) { for (const node of result.nodes) { graph.addNode({ @@ -85,11 +87,12 @@ const processParsingWithWorkers = async ( allCalls.push(...result.calls); allHeritage.push(...result.heritage); allRoutes.push(...result.routes); + allConstructorBindings.push(...result.constructorBindings); } // Final progress onFileProgress?.(total, total, 'done'); - return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes }; + return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings }; }; // ============================================================================ diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e3cacd78ce..862c699a0d 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -4,9 +4,6 @@ import { processParsing } from './parsing-processor.js'; import { processImports, processImportsFromExtracted, - createImportMap, - createPackageMap, - createNamedImportMap, buildImportResolutionContext } from './import-processor.js'; import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js'; @@ -14,7 +11,7 @@ import { processHeritage, processHeritageFromExtracted } from './heritage-proces import { computeMRO } from './mro-processor.js'; import { processCommunities } from './community-processor.js'; import { processProcesses } from './process-processor.js'; -import { createSymbolTable } from './symbol-table.js'; +import { createResolutionContext } from './resolution-context.js'; import { createASTCache } from './ast-cache.js'; import { PipelineProgress, PipelineResult } from '../../types/pipeline.js'; import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js'; @@ -41,15 +38,13 @@ export const runPipelineFromRepo = async ( onProgress: (progress: PipelineProgress) => void ): Promise => { const graph = createKnowledgeGraph(); - const symbolTable = createSymbolTable(); + const ctx = createResolutionContext(); + const symbolTable = ctx.symbols; let astCache = createASTCache(AST_CACHE_CAP); - const importMap = createImportMap(); - const packageMap = createPackageMap(); - const namedImportMap = createNamedImportMap(); const cleanup = () => { astCache.clear(); - symbolTable.clear(); + ctx.clear(); }; try { @@ -221,38 +216,69 @@ export const runPipelineFromRepo = async ( workerPool, ); + const chunkBasePercent = 20 + ((filesParsedSoFar / totalParseable) * 62); + if (chunkWorkerData) { // Imports - await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, importMap, undefined, repoPath, importCtx, packageMap, namedImportMap); + await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, ctx, (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} files`, + stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + }, repoPath, importCtx); // Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them) // This is safe because each writes disjoint relationship types into idempotent id-keyed Maps, // and the single-threaded event loop prevents races between synchronous addRelationship calls. await Promise.all([ processCallsFromExtracted( - graph, - chunkWorkerData.calls, - symbolTable, importMap, - packageMap, - undefined, - namedImportMap + graph, + chunkWorkerData.calls, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving calls (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} files`, + stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + }, + chunkWorkerData.constructorBindings, ), processHeritageFromExtracted( - graph, - chunkWorkerData.heritage, - symbolTable, - importMap, - packageMap + graph, + chunkWorkerData.heritage, + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} records`, + stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + }, ), processRoutesFromExtracted( - graph, - chunkWorkerData.routes ?? [], - symbolTable, - importMap, - packageMap + graph, + chunkWorkerData.routes ?? [], + ctx, + (current, total) => { + onProgress({ + phase: 'parsing', + percent: Math.round(chunkBasePercent), + message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`, + detail: `${current}/${total} routes`, + stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount }, + }); + }, ), ]); } else { - await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths, packageMap, namedImportMap); + await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths); sequentialChunkPaths.push(chunkPaths); } @@ -273,14 +299,22 @@ export const runPipelineFromRepo = async ( .filter(p => chunkContents.has(p)) .map(p => ({ path: p, content: chunkContents.get(p)! })); astCache = createASTCache(chunkFiles.length); - const rubyHeritage = await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap); - await processHeritage(graph, chunkFiles, astCache, symbolTable, importMap, packageMap); + const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx); + await processHeritage(graph, chunkFiles, astCache, ctx); if (rubyHeritage.length > 0) { - await processHeritageFromExtracted(graph, rubyHeritage, symbolTable, importMap, packageMap); + await processHeritageFromExtracted(graph, rubyHeritage, ctx); } astCache.clear(); } + // Log resolution cache stats + if (isDev) { + const rcStats = ctx.getStats(); + const total = rcStats.cacheHits + rcStats.cacheMisses; + const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0'; + console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`); + } + // Free import resolution context — suffix index + resolve cache no longer needed // (allPathObjects and importCtx hold ~94MB+ for large repos) allPathObjects.length = 0; diff --git a/gitnexus/src/core/ingestion/resolution-context.ts b/gitnexus/src/core/ingestion/resolution-context.ts new file mode 100644 index 0000000000..bac5bfb947 --- /dev/null +++ b/gitnexus/src/core/ingestion/resolution-context.ts @@ -0,0 +1,192 @@ +/** + * Resolution Context + * + * Single implementation of tiered name resolution. Replaces the duplicated + * tier-selection logic previously split between symbol-resolver.ts and + * call-processor.ts. + * + * Resolution tiers (highest confidence first): + * 1. Same file (lookupExactFull — authoritative) + * 2a-named. Named binding chain (walkBindingChain via NamedImportMap) + * 2a. Import-scoped (lookupFuzzy filtered by ImportMap) + * 2b. Package-scoped (lookupFuzzy filtered by PackageMap) + * 3. Global (all candidates — consumers must check candidate count) + */ + +import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; +import { createSymbolTable } from './symbol-table.js'; +import type { NamedImportBinding } from './import-processor.js'; +import { isFileInPackageDir } from './import-processor.js'; +import { walkBindingChain } from './named-binding-extraction.js'; + +/** Resolution tier for tracking, logging, and test assertions. */ +export type ResolutionTier = 'same-file' | 'import-scoped' | 'global'; + +/** Tier-selected candidates with metadata. */ +export interface TieredCandidates { + readonly candidates: readonly SymbolDefinition[]; + readonly tier: ResolutionTier; +} + +/** Confidence scores per resolution tier. */ +export const TIER_CONFIDENCE: Record = { + 'same-file': 0.95, + 'import-scoped': 0.9, + 'global': 0.5, +}; + +// --- Map types --- +export type ImportMap = Map>; +export type PackageMap = Map>; +export type NamedImportMap = Map>; + +export interface ResolutionContext { + /** + * The only resolution API. Returns all candidates at the winning tier. + * + * Tier 3 ('global') returns ALL candidates regardless of count — + * consumers must check candidates.length and refuse ambiguous matches. + */ + resolve(name: string, fromFile: string): TieredCandidates | null; + + // --- Data access (for pipeline wiring, not resolution) --- + /** Symbol table — used by parsing-processor to populate symbols. */ + readonly symbols: SymbolTable; + /** Raw maps — used by import-processor to populate import data. */ + readonly importMap: ImportMap; + readonly packageMap: PackageMap; + readonly namedImportMap: NamedImportMap; + + // --- Per-file cache lifecycle --- + enableCache(filePath: string): void; + clearCache(): void; + + // --- Operational --- + getStats(): { fileCount: number; globalSymbolCount: number; cacheHits: number; cacheMisses: number }; + clear(): void; +} + +export const createResolutionContext = (): ResolutionContext => { + const symbols = createSymbolTable(); + const importMap: ImportMap = new Map(); + const packageMap: PackageMap = new Map(); + const namedImportMap: NamedImportMap = new Map(); + + // Per-file cache state + let cacheFile: string | null = null; + let cache: Map | null = null; + let cacheHits = 0; + let cacheMisses = 0; + + // --- Core resolution (single implementation of tier logic) --- + + const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => { + // Tier 1: Same file — authoritative match + const localDef = symbols.lookupExactFull(fromFile, name); + if (localDef) { + return { candidates: [localDef], tier: 'same-file' }; + } + + // Get all global definitions for subsequent tiers + const allDefs = symbols.lookupFuzzy(name); + + // Tier 2a-named: Check named bindings BEFORE empty-allDefs early return + // because aliased imports mean lookupFuzzy('U') returns empty but we + // can resolve via the exported name. + const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap, allDefs); + if (chainResult && chainResult.length > 0) { + return { candidates: chainResult, tier: 'import-scoped' }; + } + + if (allDefs.length === 0) return null; + + // Tier 2a: Import-scoped — definition in a file imported by fromFile + const importedFiles = importMap.get(fromFile); + if (importedFiles) { + const importedDefs = allDefs.filter(def => importedFiles.has(def.filePath)); + if (importedDefs.length > 0) { + return { candidates: importedDefs, tier: 'import-scoped' }; + } + } + + // Tier 2b: Package-scoped — definition in a package dir imported by fromFile + const importedPackages = packageMap.get(fromFile); + if (importedPackages) { + const packageDefs = allDefs.filter(def => { + for (const dirSuffix of importedPackages) { + if (isFileInPackageDir(def.filePath, dirSuffix)) return true; + } + return false; + }); + if (packageDefs.length > 0) { + return { candidates: packageDefs, tier: 'import-scoped' }; + } + } + + // Tier 3: Global — pass all candidates through. + // Consumers must check candidate count and refuse ambiguous matches. + return { candidates: allDefs, tier: 'global' }; + }; + + const resolve = (name: string, fromFile: string): TieredCandidates | null => { + // Check cache (only when enabled AND fromFile matches cached file) + if (cache && cacheFile === fromFile) { + if (cache.has(name)) { + cacheHits++; + return cache.get(name)!; + } + cacheMisses++; + } + + const result = resolveUncached(name, fromFile); + + // Store in cache if active and file matches + if (cache && cacheFile === fromFile) { + cache.set(name, result); + } + + return result; + }; + + // --- Cache lifecycle --- + + const enableCache = (filePath: string): void => { + cacheFile = filePath; + if (!cache) cache = new Map(); + else cache.clear(); + }; + + const clearCache = (): void => { + cacheFile = null; + // Reuse the Map instance — just clear entries to reduce GC pressure at scale. + cache?.clear(); + }; + + const getStats = () => ({ + ...symbols.getStats(), + cacheHits, + cacheMisses, + }); + + const clear = (): void => { + symbols.clear(); + importMap.clear(); + packageMap.clear(); + namedImportMap.clear(); + clearCache(); + cacheHits = 0; + cacheMisses = 0; + }; + + return { + resolve, + symbols, + importMap, + packageMap, + namedImportMap, + enableCache, + clearCache, + getStats, + clear, + }; +}; diff --git a/gitnexus/src/core/ingestion/symbol-resolver.ts b/gitnexus/src/core/ingestion/symbol-resolver.ts deleted file mode 100644 index 2763b8eee7..0000000000 --- a/gitnexus/src/core/ingestion/symbol-resolver.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Symbol Resolver - * - * Import-filtered candidate narrowing for bare identifier resolution. - * NOT FQN resolution — does not parse qualifiers (ns::Bar, com.foo.Bar). - * - * Shared between heritage-processor.ts and call-processor.ts. - */ - -import type { SymbolTable, SymbolDefinition } from './symbol-table.js'; -import type { ImportMap, PackageMap, NamedImportMap } from './import-processor.js'; -import { isFileInPackageDir } from './import-processor.js'; -import { walkBindingChain } from './named-binding-extraction.js'; - -/** Resolution tier for internal tracking, logging, and test assertions. */ -export type ResolutionTier = 'same-file' | 'import-scoped' | 'unique-global'; - -/** Internal resolution result preserving tier metadata. */ -export interface InternalResolution { - definition: SymbolDefinition; - tier: ResolutionTier; - candidateCount: number; -} - -/** - * Resolve a bare identifier to its best-matching definition using import context. - * - * Resolution tiers (highest confidence first): - * 1. Same file (lookupExactFull — authoritative) - * 2. Import-scoped (lookupFuzzy filtered by importMap — acceptable) - * 3. Unique global (lookupFuzzy with exactly 1 match — acceptable fallback) - * - * If multiple global candidates remain after filtering, returns null. - * A wrong edge is worse than no edge. - */ -export const resolveSymbol = ( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, -): SymbolDefinition | null => { - return resolveSymbolInternal(name, currentFilePath, symbolTable, importMap, packageMap, namedImportMap)?.definition ?? null; -}; - -/** Internal resolver preserving tier metadata for logging and test assertions. */ -export const resolveSymbolInternal = ( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - importMap: ImportMap, - packageMap?: PackageMap, - namedImportMap?: NamedImportMap, -): InternalResolution | null => { - // Tier 1: Same file — authoritative match - const localDef = symbolTable.lookupExactFull(currentFilePath, name); - if (localDef) return { definition: localDef, tier: 'same-file', candidateCount: 1 }; - - // Get all global definitions for subsequent tiers - const allDefs = symbolTable.lookupFuzzy(name); - - // Tier 2a-named: Check named bindings BEFORE the empty-allDefs early return, - // because aliased imports (import { User as U }) mean lookupFuzzy('U') returns - // empty but we can resolve via the exported name. - if (namedImportMap) { - const result = resolveNamedBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs); - if (result) return result; - } - - if (allDefs.length === 0) return null; - - // Tier 2a: Import-scoped — check if any definition is in a file imported by currentFile - const importedFiles = importMap.get(currentFilePath); - if (importedFiles) { - for (const def of allDefs) { - if (importedFiles.has(def.filePath)) { - return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length }; - } - } - } - - // Tier 2b: Package-scoped — check if any definition is in a package/namespace dir imported by currentFile - // Used for Go packages and C# namespace imports to avoid ImportMap expansion bloat - const importedPackages = packageMap?.get(currentFilePath); - if (importedPackages) { - for (const def of allDefs) { - for (const dirSuffix of importedPackages) { - if (isFileInPackageDir(def.filePath, dirSuffix)) { - return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length }; - } - } - } - } - - // Tier 3: Unique global — ONLY if exactly one candidate exists - // Ambiguous global matches are refused. A wrong edge is worse than no edge. - if (allDefs.length === 1) { - return { definition: allDefs[0], tier: 'unique-global', candidateCount: 1 }; - } - - // Ambiguous: multiple global candidates, no import or same-file match → refuse - return null; -}; - -/** - * Follow re-export chains through NamedImportMap. - * Delegates chain-walking to the shared walkBindingChain utility, then - * applies symbol-resolver semantics: exactly one match required. - */ -const resolveNamedBindingChain = ( - name: string, - currentFilePath: string, - symbolTable: SymbolTable, - namedImportMap: NamedImportMap, - allDefs: SymbolDefinition[], -): InternalResolution | null => { - const defs = walkBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs); - if (defs?.length === 1) { - return { definition: defs[0], tier: 'import-scoped', candidateCount: defs.length }; - } - return null; -}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d2a1c2ad3e..d48d780d93 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -306,7 +306,7 @@ export const CPP_QUERIES = ` (field_declaration_list (function_definition declarator: (function_declarator - declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name))) @definition.method + declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name)) @definition.method) ; Templates (template_declaration (class_specifier name: (type_identifier) @name)) @definition.template diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 9e37646d84..c1b4cf944e 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -1,7 +1,10 @@ import type { SyntaxNode } from './utils.js'; -import { FUNCTION_NODE_TYPES, extractFunctionName } from './utils.js'; +import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES } from './utils.js'; import { SupportedLanguages } from '../../config/supported-languages.js'; import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js'; +import type { ClassNameLookup } from './type-extractors/types.js'; +import { extractSimpleTypeName } from './type-extractors/shared.js'; +import type { SymbolTable } from './symbol-table.js'; /** * Per-file scoped type environment: maps (scope, variableName) → typeName. @@ -19,15 +22,47 @@ export type TypeEnv = Map>; /** File-level scope key */ const FILE_SCOPE = ''; +/** Fallback for languages where class names aren't in a 'name' field (e.g. Kotlin uses type_identifier). */ +const findTypeIdentifierChild = (node: SyntaxNode): SyntaxNode | null => { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === 'type_identifier') return child; + } + return null; +}; + /** - * Look up a variable's type in the TypeEnv, trying the call's enclosing - * function scope first, then falling back to file-level scope. + * Per-file type environment with receiver resolution. + * Built once per file via `buildTypeEnv`, used for receiver-type filtering, + * then discarded. Encapsulates scope-aware type lookup and self/this/super + * AST resolution behind a single `.lookup()` method. */ -export const lookupTypeEnv = ( +export interface TypeEnvironment { + /** Look up a variable's resolved type, with self/this/super AST resolution. */ + lookup(varName: string, callNode: SyntaxNode): string | undefined; + /** Unverified cross-file constructor bindings for SymbolTable verification. */ + readonly constructorBindings: readonly ConstructorBinding[]; + /** Raw per-scope type bindings — for testing and debugging. */ + readonly env: TypeEnv; +} + +/** Implementation of the lookup logic — shared between TypeEnvironment and the legacy export. */ +const lookupInEnv = ( env: TypeEnv, varName: string, callNode: SyntaxNode, ): string | undefined => { + // Self/this receiver: resolve to enclosing class name via AST walk + if (varName === 'self' || varName === 'this' || varName === '$this') { + return findEnclosingClassName(callNode); + } + + // Super/base/parent receiver: resolve to the parent class name via AST walk. + // Walks up to the enclosing class, then extracts the superclass from its heritage node. + if (varName === 'super' || varName === 'base' || varName === 'parent') { + return findEnclosingParentClassName(callNode); + } + // Determine the enclosing function scope for the call const scopeKey = findEnclosingScopeKey(callNode); @@ -45,6 +80,154 @@ export const lookupTypeEnv = ( return fileEnv?.get(varName); }; + +/** + * Walk up the AST from a node to find the enclosing class/module name. + * Used to resolve `self`/`this` receivers to their containing type. + */ +const findEnclosingClassName = (node: SyntaxNode): string | undefined => { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) { + const nameNode = current.childForFieldName('name') + ?? findTypeIdentifierChild(current); + if (nameNode) return nameNode.text; + } + current = current.parent; + } + return undefined; +}; + +/** + * Walk up the AST to find the enclosing class, then extract its parent class name + * from the heritage/superclass AST node. Used to resolve `super`/`base`/`parent`. + * + * Supported patterns per tree-sitter grammar: + * - Java/Ruby: `superclass` field → type_identifier/constant + * - Python: `superclasses` field → argument_list → first identifier + * - TypeScript/JS: unnamed `class_heritage` child → `extends_clause` → identifier + * - C#: unnamed `base_list` child → first identifier + * - PHP: unnamed `base_clause` child → name + * - Kotlin: unnamed `delegation_specifier` child → constructor_invocation → user_type → type_identifier + * - C++: unnamed `base_class_clause` child → type_identifier + * - Swift: unnamed `inheritance_specifier` child → user_type → type_identifier + */ +const findEnclosingParentClassName = (node: SyntaxNode): string | undefined => { + let current = node.parent; + while (current) { + if (CLASS_CONTAINER_TYPES.has(current.type)) { + return extractParentClassFromNode(current); + } + current = current.parent; + } + return undefined; +}; + +/** Extract the parent/superclass name from a class declaration AST node. */ +const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined => { + // 1. Named fields: Java (superclass), Ruby (superclass), Python (superclasses) + const superclassNode = classNode.childForFieldName('superclass'); + if (superclassNode) { + // Java: superclass > type_identifier or generic_type, Ruby: superclass > constant + const inner = superclassNode.childForFieldName('type') + ?? superclassNode.firstNamedChild + ?? superclassNode; + return extractSimpleTypeName(inner) ?? inner.text; + } + + const superclassesNode = classNode.childForFieldName('superclasses'); + if (superclassesNode) { + // Python: argument_list with identifiers or attribute nodes (e.g. models.Model) + const first = superclassesNode.firstNamedChild; + if (first) return extractSimpleTypeName(first) ?? first.text; + } + + // 2. Unnamed children: walk class node's children looking for heritage nodes + for (let i = 0; i < classNode.childCount; i++) { + const child = classNode.child(i); + if (!child) continue; + + switch (child.type) { + // TypeScript: class_heritage > extends_clause > type_identifier + // JavaScript: class_heritage > identifier (no extends_clause wrapper) + case 'class_heritage': { + for (let j = 0; j < child.childCount; j++) { + const clause = child.child(j); + if (clause?.type === 'extends_clause') { + const typeNode = clause.firstNamedChild; + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text; + } + // JS: direct identifier child (no extends_clause wrapper) + if (clause?.type === 'identifier' || clause?.type === 'type_identifier') { + return clause.text; + } + } + break; + } + + // C#: base_list > identifier or generic_name > identifier + case 'base_list': { + const first = child.firstNamedChild; + if (first) { + // generic_name wraps the identifier: BaseClass + if (first.type === 'generic_name') { + const inner = first.childForFieldName('name') ?? first.firstNamedChild; + if (inner) return inner.text; + } + return first.text; + } + break; + } + + // PHP: base_clause > name + case 'base_clause': { + const name = child.firstNamedChild; + if (name) return name.text; + break; + } + + // C++: base_class_clause > type_identifier (with optional access_specifier before it) + case 'base_class_clause': { + for (let j = 0; j < child.childCount; j++) { + const inner = child.child(j); + if (inner?.type === 'type_identifier') return inner.text; + } + break; + } + + // Kotlin: delegation_specifier > constructor_invocation > user_type > type_identifier + case 'delegation_specifier': { + const delegate = child.firstNamedChild; + if (delegate?.type === 'constructor_invocation') { + const userType = delegate.firstNamedChild; + if (userType?.type === 'user_type') { + const typeId = userType.firstNamedChild; + if (typeId) return typeId.text; + } + } + // Also handle plain user_type (interface conformance without parentheses) + if (delegate?.type === 'user_type') { + const typeId = delegate.firstNamedChild; + if (typeId) return typeId.text; + } + break; + } + + // Swift: inheritance_specifier > user_type > type_identifier + case 'inheritance_specifier': { + const userType = child.childForFieldName('inherits_from') ?? child.firstNamedChild; + if (userType?.type === 'user_type') { + const typeId = userType.firstNamedChild; + if (typeId) return typeId.text; + } + break; + } + } + } + + return undefined; +}; + /** Find the enclosing function name for scope lookup. */ const findEnclosingScopeKey = (node: SyntaxNode): string | undefined => { let current = node.parent; @@ -58,67 +241,289 @@ const findEnclosingScopeKey = (node: SyntaxNode): string | undefined => { return undefined; }; +/** + * Create a lookup that checks both local AST class names AND the SymbolTable's + * global index. This allows extractInitializer functions to distinguish + * constructor calls from function calls (e.g. Kotlin `User()` vs `getUser()`) + * using cross-file type information when available. + * + * Only `.has()` is exposed — the SymbolTable doesn't support iteration. + * Results are memoized to avoid redundant lookupFuzzy scans across declarations. + */ +const createClassNameLookup = ( + localNames: Set, + symbolTable?: SymbolTable, +): ClassNameLookup => { + if (!symbolTable) return localNames; + + const memo = new Map(); + return { + has(name: string): boolean { + if (localNames.has(name)) return true; + const cached = memo.get(name); + if (cached !== undefined) return cached; + const result = symbolTable.lookupFuzzy(name).some(def => def.type === 'Class'); + memo.set(name, result); + return result; + }, + }; +}; + /** * Build a scoped TypeEnv from a tree-sitter AST for a given language. - * Walks the tree tracking enclosing function scopes, so that variables - * inside different functions don't collide. + * Single-pass: collects class/struct names AND type bindings in one walk. + * Class names are accumulated incrementally — this is safe because no + * language allows constructing a class before its definition. + * + * When a symbolTable is provided (call-processor path), class names from across + * the project are available for constructor inference in languages like Kotlin + * where constructors are syntactically identical to function calls. + */ +/** + * Build a TypeEnvironment from a tree-sitter AST for a given language. + * Single-pass: collects class/struct names, type bindings, AND constructor + * bindings that couldn't be resolved locally — all in one AST walk. */ export const buildTypeEnv = ( tree: { rootNode: SyntaxNode }, language: SupportedLanguages, -): TypeEnv => { + symbolTable?: SymbolTable, +): TypeEnvironment => { const env: TypeEnv = new Map(); - walkForTypes(tree.rootNode, language, env, FILE_SCOPE); - return env; -}; + const localClassNames = new Set(); + const classNames = createClassNameLookup(localClassNames, symbolTable); + const config = typeConfigs[language]; + const scanner = CONSTRUCTOR_BINDING_SCANNERS[language]; + const bindings: ConstructorBinding[] = []; -const walkForTypes = ( - node: SyntaxNode, - language: SupportedLanguages, - env: TypeEnv, - currentScope: string, -): void => { - // Detect scope boundaries (function/method definitions) - let scope = currentScope; - if (FUNCTION_NODE_TYPES.has(node.type)) { - const { funcName } = extractFunctionName(node); - if (funcName) scope = `${funcName}@${node.startIndex}`; - } + /** + * Try to extract a (variableName → typeName) binding from a single AST node. + * + * Resolution tiers (first match wins): + * - Tier 0: explicit type annotations via extractDeclaration + * - Tier 1: constructor-call inference via extractInitializer (fallback) + */ + const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map): void => { + // This guard eliminates 90%+ of calls before any language dispatch. + if (TYPED_PARAMETER_TYPES.has(node.type)) { + config.extractParameter(node, scopeEnv); + return; + } + if (config.declarationNodeTypes.has(node.type)) { + config.extractDeclaration(node, scopeEnv); + // Tier 1: constructor-call inference as fallback. + // Always called when available — each language's extractInitializer + // internally skips declarators that already have explicit annotations, + // so this handles mixed cases like `const a: A = x, b = new B()`. + if (config.extractInitializer) { + config.extractInitializer(node, scopeEnv, classNames); + } + } + }; - // Get or create the sub-map for this scope - if (!env.has(scope)) env.set(scope, new Map()); - const scopeEnv = env.get(scope)!; + const walk = (node: SyntaxNode, currentScope: string): void => { + // Collect class/struct names as we encounter them (used by extractInitializer + // to distinguish constructor calls from function calls, e.g. C++ `User()` vs `getUser()`) + // Currently only C++ uses this locally; other languages rely on the SymbolTable path. + if (CLASS_CONTAINER_TYPES.has(node.type)) { + // Most languages use 'name' field; Kotlin uses a type_identifier child instead + const nameNode = node.childForFieldName('name') + ?? findTypeIdentifierChild(node); + if (nameNode) localClassNames.add(nameNode.text); + } - // Check if this node provides type information - extractTypeBinding(node, language, scopeEnv); + // Detect scope boundaries (function/method definitions) + let scope = currentScope; + if (FUNCTION_NODE_TYPES.has(node.type)) { + const { funcName } = extractFunctionName(node); + if (funcName) scope = `${funcName}@${node.startIndex}`; + } - // Recurse into children - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (child) walkForTypes(child, language, env, scope); - } + // Get or create the sub-map for this scope + if (!env.has(scope)) env.set(scope, new Map()); + const scopeEnv = env.get(scope)!; + + extractTypeBinding(node, scopeEnv); + + // Scan for constructor bindings that couldn't be resolved locally. + // Only collect if TypeEnv didn't already resolve this binding. + if (scanner) { + const result = scanner(node); + if (result && !scopeEnv.has(result.varName)) { + bindings.push({ scope, ...result }); + } + } + + // Recurse into children + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child) walk(child, scope); + } + }; + + walk(tree.rootNode, FILE_SCOPE); + return { + lookup: (varName, callNode) => lookupInEnv(env, varName, callNode), + constructorBindings: bindings, + env, + }; }; /** - * Try to extract a (variableName → typeName) binding from a single AST node. - * Delegates to per-language type configurations. + * Unverified constructor binding: a `val x = Callee()` pattern where we + * couldn't confirm the callee is a class (because it's defined in another file). + * The caller must verify `calleeName` against the SymbolTable before trusting. */ -const extractTypeBinding = ( - node: SyntaxNode, - language: SupportedLanguages, - env: Map, -): void => { - // === PARAMETERS (most languages) === - // This guard eliminates 90%+ of calls before any language dispatch. - if (TYPED_PARAMETER_TYPES.has(node.type)) { - const config = typeConfigs[language]; - config.extractParameter(node, env); - return; - } +export interface ConstructorBinding { + /** Function scope key (matches TypeEnv scope keys) */ + scope: string; + /** Variable name that received the constructor result */ + varName: string; + /** Name of the callee (potential class constructor) */ + calleeName: string; +} - // === Per-language declaration extraction === - const config = typeConfigs[language]; - if (config.declarationNodeTypes.has(node.type)) { - config.extractDeclaration(node, env); +/** C/C++: auto x = User() where function is an identifier (not type_identifier) */ +const extractCppConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => { + if (node.type !== 'declaration') return undefined; + const typeNode = node.childForFieldName('type'); + if (!typeNode) return undefined; + const typeText = typeNode.text; + if (typeText !== 'auto' && typeText !== 'decltype(auto)' && typeNode.type !== 'placeholder_type_specifier') return undefined; + const declarator = node.childForFieldName('declarator'); + if (!declarator || declarator.type !== 'init_declarator') return undefined; + const value = declarator.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + const func = value.childForFieldName('function'); + // Match plain identifiers (type_identifier is already resolved by extractInitializer) + // and qualified/scoped identifiers for namespaced calls like ns::HttpClient() + if (!func) return undefined; + if (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') { + // ns::HttpClient → extract "HttpClient" (last segment) + const last = func.lastNamedChild; + if (!last) return undefined; + const nameNode = declarator.childForFieldName('declarator'); + if (!nameNode) return undefined; + const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild : nameNode; + if (!finalName) return undefined; + return { varName: finalName.text, calleeName: last.text }; } + if (func.type !== 'identifier') return undefined; + const nameNode = declarator.childForFieldName('declarator'); + if (!nameNode) return undefined; + const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild : nameNode; + if (!finalName) return undefined; + const varName = finalName.text; + if (!varName) return undefined; + return { varName, calleeName: func.text }; }; + +/** Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant */ +const extractRubyConstructorBinding = (node: SyntaxNode): { varName: string; calleeName: string } | undefined => { + if (node.type !== 'assignment') return undefined; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return undefined; + // Support both local variables (identifier) and constants (USER = User.new) + if (left.type !== 'identifier' && left.type !== 'constant') return undefined; + if (right.type !== 'call') return undefined; + const method = right.childForFieldName('method'); + if (!method || method.text !== 'new') return undefined; + const receiver = right.childForFieldName('receiver'); + if (!receiver || receiver.type !== 'constant') return undefined; + return { varName: left.text, calleeName: receiver.text }; +}; + +/** Language-specific constructor-binding scanners. */ +const CONSTRUCTOR_BINDING_SCANNERS: Partial { varName: string; calleeName: string } | undefined>> = { + // Kotlin: val x = User(...) — property_declaration with call_expression + [SupportedLanguages.Kotlin]: (node) => { + if (node.type !== 'property_declaration') return undefined; + const varDecl = node.namedChildren.find(c => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + if (varDecl.namedChildren.some(c => c.type === 'user_type')) return undefined; + const callExpr = node.namedChildren.find(c => c.type === 'call_expression'); + if (!callExpr) return undefined; + const callee = callExpr.firstNamedChild; + if (!callee || callee.type !== 'simple_identifier') return undefined; + const nameNode = varDecl.namedChildren.find(c => c.type === 'simple_identifier'); + if (!nameNode) return undefined; + return { varName: nameNode.text, calleeName: callee.text }; + }, + + // Python: user = User("alice") — assignment with call + // Also handles walrus operator: (user := User("alice")) + [SupportedLanguages.Python]: (node) => { + let left: SyntaxNode | null; + let right: SyntaxNode | null; + + if (node.type === 'named_expression') { + // Walrus operator: (user := User("alice")) + left = node.childForFieldName('name'); + right = node.childForFieldName('value'); + } else if (node.type === 'assignment') { + left = node.childForFieldName('left'); + right = node.childForFieldName('right'); + // Skip annotated assignments — extractDeclaration handles those + if (node.childForFieldName('type')) return undefined; + } else { + return undefined; + } + + if (!left || !right) return undefined; + if (left.type !== 'identifier') return undefined; + if (right.type !== 'call') return undefined; + const func = right.childForFieldName('function'); + if (!func) return undefined; + // Support both direct calls (User()) and qualified calls (models.User()) + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return undefined; + return { varName: left.text, calleeName }; + }, + + // Swift: let user = User(name: "alice") — property_declaration with call_expression + [SupportedLanguages.Swift]: (node) => { + if (node.type !== 'property_declaration') return undefined; + // Skip if has type annotation + if (node.childForFieldName('type')) return undefined; + for (let i = 0; i < node.namedChildCount; i++) { + if (node.namedChild(i)?.type === 'type_annotation') return undefined; + } + const pattern = node.childForFieldName('pattern'); + if (!pattern) return undefined; + const varName = pattern.text; + if (!varName) return undefined; + // Find call_expression child + let callExpr: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'call_expression') { callExpr = child; break; } + } + if (!callExpr) return undefined; + const callee = callExpr.firstNamedChild; + if (!callee) return undefined; + // Direct call: User(name: "alice") — simple_identifier callee + if (callee.type === 'simple_identifier') { + return { varName, calleeName: callee.text }; + } + // Explicit init: User.init(name: "alice") — navigation_expression with .init suffix + if (callee.type === 'navigation_expression') { + const receiver = callee.firstNamedChild; + const suffix = callee.lastNamedChild; + if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') { + return { varName, calleeName: receiver.text }; + } + } + return undefined; + }, + + // C++: auto x = User() where User is parsed as identifier (cross-file) + // Note: C is excluded — C has no constructors and `auto` is a storage-class specifier, not type inference. + [SupportedLanguages.CPlusPlus]: extractCppConstructorBinding, + + // Ruby: user = User.new — assignment with call where method is 'new' and receiver is a constant + [SupportedLanguages.Ruby]: extractRubyConstructorBinding, +}; + diff --git a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts index db47248721..ede01b280b 100644 --- a/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/c-cpp.ts @@ -1,9 +1,10 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'declaration', + 'for_range_loop', ]); /** C++: Type x = ...; Type* x; Type& x; */ @@ -32,6 +33,75 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { + const typeNode = node.childForFieldName('type'); + if (!typeNode) return; + + // Only handle auto/placeholder — typed declarations are handled by extractDeclaration + const typeText = typeNode.text; + if ( + typeText !== 'auto' && + typeText !== 'decltype(auto)' && + typeNode.type !== 'placeholder_type_specifier' + ) return; + + const declarator = node.childForFieldName('declarator'); + if (!declarator) return; + + // Must be an init_declarator (i.e., has an initializer value) + if (declarator.type !== 'init_declarator') return; + + const value = declarator.childForFieldName('value'); + if (!value) return; + + // Resolve the variable name, unwrapping pointer/reference declarators + const nameNode = declarator.childForFieldName('declarator'); + if (!nameNode) return; + const finalName = + nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator' + ? nameNode.firstNamedChild + : nameNode; + if (!finalName) return; + const varName = extractVarName(finalName); + if (!varName) return; + + // auto x = new User() — new_expression + if (value.type === 'new_expression') { + const ctorType = value.childForFieldName('type'); + if (ctorType) { + const typeName = extractSimpleTypeName(ctorType); + if (typeName) env.set(varName, typeName); + } + return; + } + + // auto x = User() — call_expression where function is a type name + // tree-sitter-cpp may parse the constructor name as type_identifier or identifier. + // For plain identifiers, verify against known class names from the file's AST + // to distinguish constructor calls (User()) from function calls (getUser()). + if (value.type === 'call_expression') { + const func = value.childForFieldName('function'); + if (!func) return; + if (func.type === 'type_identifier') { + const typeName = func.text; + if (typeName) env.set(varName, typeName); + } else if (func.type === 'identifier') { + const text = func.text; + if (text && classNames.has(text)) env.set(varName, text); + } + return; + } + + // auto x = User{} — compound_literal_expression (brace initialization) + // AST: compound_literal_expression > type_identifier + initializer_list + if (value.type === 'compound_literal_expression') { + const typeId = value.firstNamedChild; + const typeName = typeId ? extractSimpleTypeName(typeId) : undefined; + if (typeName) env.set(varName, typeName); + } +}; + /** C/C++: parameter_declaration → type declarator */ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { let nameNode: SyntaxNode | null = null; @@ -60,4 +130,5 @@ export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + extractInitializer, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/csharp.ts b/gitnexus/src/core/ingestion/type-extractors/csharp.ts index 9840326a44..af5f093453 100644 --- a/gitnexus/src/core/ingestion/type-extractors/csharp.ts +++ b/gitnexus/src/core/ingestion/type-extractors/csharp.ts @@ -6,10 +6,26 @@ const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'local_declaration_statement', 'variable_declaration', 'field_declaration', + 'is_pattern_expression', ]); -/** C#: Type x = ...; var x = new Type(); */ +/** C#: Type x = ...; var x = new Type(); obj is Type x */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + // C# pattern matching: `obj is User user` → is_pattern_expression > declaration_pattern + if (node.type === 'is_pattern_expression') { + const pattern = node.childForFieldName('pattern'); + if (pattern?.type === 'declaration_pattern') { + const typeNode = pattern.childForFieldName('type'); + const nameNode = pattern.childForFieldName('name'); + if (typeNode && nameNode) { + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(nameNode); + if (typeName && varName) env.set(varName, typeName); + } + } + return; + } + // C# tree-sitter: local_declaration_statement > variable_declaration > ... // Recursively descend through wrapper nodes for (let i = 0; i < node.namedChildCount; i++) { @@ -44,7 +60,8 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map // Pair each LHS name with its corresponding RHS value const count = Math.min(lhsNodes.length, rhsNodes.length); for (let i = 0; i < count; i++) { - const valueNode = rhsNodes[i]; + let valueNode = rhsNodes[i]; + // Unwrap &User{} — unary_expression (address-of) wrapping composite_literal + if (valueNode.type === 'unary_expression' && valueNode.firstNamedChild?.type === 'composite_literal') { + valueNode = valueNode.firstNamedChild; + } + // Go built-in new(User) — call_expression with 'new' callee and type argument + // Go built-in make([]User, 0) / make(map[string]User) — extract element/value type + if (valueNode.type === 'call_expression') { + const funcNode = valueNode.childForFieldName('function'); + if (funcNode?.text === 'new') { + const args = valueNode.childForFieldName('arguments'); + if (args?.firstNamedChild) { + const typeName = extractSimpleTypeName(args.firstNamedChild); + const varName = extractVarName(lhsNodes[i]); + if (varName && typeName) env.set(varName, typeName); + } + } else if (funcNode?.text === 'make') { + const args = valueNode.childForFieldName('arguments'); + const firstArg = args?.firstNamedChild; + if (firstArg) { + let innerType: SyntaxNode | null = null; + if (firstArg.type === 'slice_type') { + innerType = firstArg.childForFieldName('element'); + } else if (firstArg.type === 'map_type') { + innerType = firstArg.childForFieldName('value'); + } + if (innerType) { + const typeName = extractSimpleTypeName(innerType); + const varName = extractVarName(lhsNodes[i]); + if (varName && typeName) env.set(varName, typeName); + } + } + } + continue; + } + // Go type assertion: user := iface.(User) — type_assertion_expression with 'type' field + if (valueNode.type === 'type_assertion_expression') { + const typeNode = valueNode.childForFieldName('type'); + if (typeNode) { + const typeName = extractSimpleTypeName(typeNode); + const varName = extractVarName(lhsNodes[i]); + if (varName && typeName) env.set(varName, typeName); + } + continue; + } if (valueNode.type !== 'composite_literal') continue; const typeNode = valueNode.childForFieldName('type'); if (!typeNode) continue; diff --git a/gitnexus/src/core/ingestion/type-extractors/index.ts b/gitnexus/src/core/ingestion/type-extractors/index.ts index 50e35a7e74..7fb30b0bc8 100644 --- a/gitnexus/src/core/ingestion/type-extractors/index.ts +++ b/gitnexus/src/core/ingestion/type-extractors/index.ts @@ -33,7 +33,7 @@ export const typeConfigs = { declarationNodeTypes: new Set(), extractDeclaration: () => {}, extractParameter: () => {}, - }, + } as LanguageTypeConfig, } satisfies Record; export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js'; diff --git a/gitnexus/src/core/ingestion/type-extractors/jvm.ts b/gitnexus/src/core/ingestion/type-extractors/jvm.ts index 150cf9e66b..bc8281ca67 100644 --- a/gitnexus/src/core/ingestion/type-extractors/jvm.ts +++ b/gitnexus/src/core/ingestion/type-extractors/jvm.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; // ── Java ────────────────────────────────────────────────────────────────── @@ -14,7 +14,7 @@ const extractJavaDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map const typeNode = node.childForFieldName('type'); if (!typeNode) return; const typeName = extractSimpleTypeName(typeNode); - if (!typeName) return; + if (!typeName || typeName === 'var') return; // skip Java 10 var — handled by extractInitializer // Find variable_declarator children for (let i = 0; i < node.namedChildCount; i++) { @@ -28,6 +28,25 @@ const extractJavaDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map } }; +/** Java 10+: var x = new User() — infer type from object_creation_expression */ +const extractJavaInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, _classNames: ClassNameLookup): void => { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type !== 'variable_declarator') continue; + const nameNode = child.childForFieldName('name'); + const valueNode = child.childForFieldName('value'); + if (!nameNode || !valueNode) continue; + // Skip declarators that already have a binding from extractDeclaration + const varName = extractVarName(nameNode); + if (!varName || env.has(varName)) continue; + if (valueNode.type !== 'object_creation_expression') continue; + const ctorType = valueNode.childForFieldName('type'); + if (!ctorType) continue; + const typeName = extractSimpleTypeName(ctorType); + if (typeName) env.set(varName, typeName); + } +}; + /** Java: formal_parameter → type name */ const extractJavaParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { let nameNode: SyntaxNode | null = null; @@ -52,6 +71,7 @@ export const javaTypeConfig: LanguageTypeConfig = { declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES, extractDeclaration: extractJavaDeclaration, extractParameter: extractJavaParameter, + extractInitializer: extractJavaInitializer, }; // ── Kotlin ──────────────────────────────────────────────────────────────── @@ -115,8 +135,40 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { + if (node.type !== 'property_declaration') return; + // Skip if there's an explicit type annotation — Tier 0 already handled it + const varDecl = findChildByType(node, 'variable_declaration'); + if (varDecl && findChildByType(varDecl, 'user_type')) return; + + // Get the initializer value — the call_expression after '=' + const value = node.childForFieldName('value') + ?? findChildByType(node, 'call_expression'); + if (!value || value.type !== 'call_expression') return; + + // The callee is the first child of call_expression (simple_identifier for direct calls) + const callee = value.firstNamedChild; + if (!callee || callee.type !== 'simple_identifier') return; + + const calleeName = callee.text; + if (!calleeName || !classNames.has(calleeName)) return; + + // Extract the variable name from the variable_declaration inside property_declaration + const nameNode = varDecl + ? findChildByType(varDecl, 'simple_identifier') + : findChildByType(node, 'simple_identifier'); + if (!nameNode) return; + + const varName = extractVarName(nameNode); + if (varName) env.set(varName, calleeName); +}; + export const kotlinTypeConfig: LanguageTypeConfig = { declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES, extractDeclaration: extractKotlinDeclaration, extractParameter: extractKotlinParameter, + extractInitializer: extractKotlinInitializer, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/php.ts b/gitnexus/src/core/ingestion/type-extractors/php.ts index 8448cb8472..d07ce1706f 100644 --- a/gitnexus/src/core/ingestion/type-extractors/php.ts +++ b/gitnexus/src/core/ingestion/type-extractors/php.ts @@ -1,13 +1,94 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; -// PHP has no local variable type annotations; only params carry types -const DECLARATION_NODE_TYPES: ReadonlySet = new Set(); +const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ + 'assignment_expression', // For constructor inference: $x = new User() + 'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo; +]); -/** PHP: no typed local variable declarations */ -const extractDeclaration: TypeBindingExtractor = (_node: SyntaxNode, _env: Map): void => { - // PHP has no local variable type annotations +/** Walk up the AST to find the enclosing class declaration. */ +const findEnclosingClass = (node: SyntaxNode): SyntaxNode | null => { + let current = node.parent; + while (current) { + if (current.type === 'class_declaration') return current; + current = current.parent; + } + return null; +}; + +/** + * Resolve PHP self/static/parent to the actual class name. + * - self/static → enclosing class name + * - parent → superclass from base_clause + */ +const resolvePhpKeyword = (keyword: string, node: SyntaxNode): string | undefined => { + if (keyword === 'self' || keyword === 'static') { + const cls = findEnclosingClass(node); + if (!cls) return undefined; + const nameNode = cls.childForFieldName('name'); + return nameNode?.text; + } + if (keyword === 'parent') { + const cls = findEnclosingClass(node); + if (!cls) return undefined; + // base_clause contains the parent class name + for (let i = 0; i < cls.namedChildCount; i++) { + const child = cls.namedChild(i); + if (child?.type === 'base_clause') { + const parentName = child.firstNamedChild; + if (parentName) return extractSimpleTypeName(parentName); + } + } + return undefined; + } + return undefined; +}; + +/** PHP: typed class properties (PHP 7.4+): private UserRepo $repo; */ +const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + if (node.type !== 'property_declaration') return; + + const typeNode = node.childForFieldName('type'); + if (!typeNode) return; + + const typeName = extractSimpleTypeName(typeNode); + if (!typeName) return; + + // The variable name is inside property_element > variable_name + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'property_element') { + const varNameNode = child.firstNamedChild; // variable_name + if (varNameNode) { + const varName = extractVarName(varNameNode); + if (varName) env.set(varName, typeName); + } + break; + } + } +}; + +/** PHP: $x = new User() — infer type from object_creation_expression */ +const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map, _classNames: ClassNameLookup): void => { + if (node.type !== 'assignment_expression') return; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right) return; + if (right.type !== 'object_creation_expression') return; + // The class name is the first named child of object_creation_expression + // (tree-sitter-php uses 'name' or 'qualified_name' nodes here) + const ctorType = right.firstNamedChild; + if (!ctorType) return; + const typeName = extractSimpleTypeName(ctorType); + if (!typeName) return; + // Resolve PHP self/static/parent to actual class names + const resolvedType = (typeName === 'self' || typeName === 'static' || typeName === 'parent') + ? resolvePhpKeyword(typeName, node) + : typeName; + if (!resolvedType) return; + const varName = extractVarName(left); + if (varName) env.set(varName, resolvedType); }; /** PHP: simple_parameter → type $name */ @@ -33,4 +114,5 @@ export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + extractInitializer, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/python.ts b/gitnexus/src/core/ingestion/type-extractors/python.ts index 9e8a6557b9..55e399bf48 100644 --- a/gitnexus/src/core/ingestion/type-extractors/python.ts +++ b/gitnexus/src/core/ingestion/type-extractors/python.ts @@ -1,9 +1,10 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'assignment', + 'named_expression', ]); /** Python: x: Foo = ... (PEP 484 annotations) */ @@ -37,8 +38,46 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { + let left: SyntaxNode | null; + let right: SyntaxNode | null; + + if (node.type === 'named_expression') { + // Walrus operator: (user := User("alice")) + // tree-sitter-python: named_expression has 'name' and 'value' fields + left = node.childForFieldName('name'); + right = node.childForFieldName('value'); + } else if (node.type === 'assignment') { + left = node.childForFieldName('left'); + right = node.childForFieldName('right'); + // Skip if already has type annotation — extractDeclaration handled it + if (node.childForFieldName('type')) return; + } else { + return; + } + + if (!left || !right) return; + const varName = extractVarName(left); + if (!varName || env.has(varName)) return; + if (right.type !== 'call') return; + const func = right.childForFieldName('function'); + if (!func) return; + // Support both direct calls (User()) and qualified calls (models.User()) + // tree-sitter-python: direct → identifier, qualified → attribute + const calleeName = extractSimpleTypeName(func); + if (!calleeName) return; + if (classNames.has(calleeName)) { + env.set(varName, calleeName); + } +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + extractInitializer, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/rust.ts b/gitnexus/src/core/ingestion/type-extractors/rust.ts index 64b9213007..7380037a77 100644 --- a/gitnexus/src/core/ingestion/type-extractors/rust.ts +++ b/gitnexus/src/core/ingestion/type-extractors/rust.ts @@ -1,13 +1,90 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ 'let_declaration', + 'let_condition', ]); -/** Rust: let x: Foo = ... */ +/** Walk up the AST to find the enclosing impl block and extract the implementing type name. */ +const findEnclosingImplType = (node: SyntaxNode): string | undefined => { + let current = node.parent; + while (current) { + if (current.type === 'impl_item') { + // The 'type' field holds the implementing type (e.g., `impl User { ... }`) + const typeNode = current.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode); + } + current = current.parent; + } + return undefined; +}; + +/** + * Extract the type name from a struct_pattern's 'type' field. + * Handles both simple `User { .. }` and scoped `Message::Data { .. }`. + */ +const extractStructPatternType = (structPattern: SyntaxNode): string | undefined => { + const typeNode = structPattern.childForFieldName('type'); + if (!typeNode) return undefined; + return extractSimpleTypeName(typeNode); +}; + +/** + * Recursively scan a pattern tree for captured_pattern nodes (x @ StructType { .. }) + * and extract variable → type bindings from them. + */ +const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map): void => { + if (pattern.type === 'captured_pattern') { + // captured_pattern: identifier @ inner_pattern + // The first named child is the identifier, followed by the inner pattern. + const nameNode = pattern.firstNamedChild; + if (!nameNode || nameNode.type !== 'identifier') return; + // Find the struct_pattern child — that gives us the type + for (let i = 0; i < pattern.namedChildCount; i++) { + const child = pattern.namedChild(i); + if (child?.type === 'struct_pattern') { + const typeName = extractStructPatternType(child); + if (typeName) env.set(nameNode.text, typeName); + return; + } + } + return; + } + // Recurse into tuple_struct_pattern children to find nested captured_patterns + // e.g., Some(user @ User { .. }) + if (pattern.type === 'tuple_struct_pattern') { + for (let i = 0; i < pattern.namedChildCount; i++) { + const child = pattern.namedChild(i); + if (child) extractCapturedPatternBindings(child, env); + } + } +}; + +/** Rust: let x: Foo = ... | if let / while let pattern bindings */ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map): void => { + if (node.type === 'let_condition') { + // if let / while let: extract type bindings from pattern matching. + // + // Supported patterns: + // - captured_pattern: `if let user @ User { .. } = expr` → user: User + // - tuple_struct_pattern with nested captured_pattern: + // `if let Some(user @ User { .. }) = expr` → user: User + // + // NOT supported (requires generic unwrapping — Phase 3): + // - `if let Some(x) = opt` where opt: Option → x: T + // + // struct_pattern without capture (`if let User { name } = expr`) + // destructures fields — individual field types are unknown without + // field-type resolution, so no bindings are extracted. + const pattern = node.childForFieldName('pattern'); + if (!pattern) return; + extractCapturedPatternBindings(pattern, env); + return; + } + + // Standard let_declaration: let x: Foo = ... const pattern = node.childForFieldName('pattern'); const typeNode = node.childForFieldName('type'); if (!pattern || !typeNode) return; @@ -16,6 +93,46 @@ const extractDeclaration: TypeBindingExtractor = (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'); + const value = node.childForFieldName('value'); + if (!pattern || !value) return; + + // Rust struct literal: let user = User { name: "alice", age: 30 } + // tree-sitter-rust: struct_expression with 'name' field holding the type + if (value.type === 'struct_expression') { + const typeNode = value.childForFieldName('name'); + if (!typeNode) return; + const rawType = extractSimpleTypeName(typeNode); + if (!rawType) return; + // Resolve Self to the actual struct/enum name from the enclosing impl block + const typeName = rawType === 'Self' ? findEnclosingImplType(node) : rawType; + const varName = extractVarName(pattern); + if (varName && typeName) env.set(varName, typeName); + return; + } + + if (value.type !== 'call_expression') return; + const func = value.childForFieldName('function'); + if (!func || func.type !== 'scoped_identifier') return; + const nameField = func.childForFieldName('name'); + // Only match ::new() and ::default() — the two idiomatic Rust constructors. + // Deliberately excludes ::from(), ::with_capacity(), etc. to avoid false positives + // (e.g. String::from("x") is not necessarily the "String" type we want for method resolution). + if (!nameField || (nameField.text !== 'new' && nameField.text !== 'default')) return; + const pathField = func.childForFieldName('path'); + if (!pathField) return; + const rawType = extractSimpleTypeName(pathField); + if (!rawType) return; + // Resolve Self to the actual struct/enum name from the enclosing impl block + const typeName = rawType === 'Self' ? findEnclosingImplType(node) : rawType; + const varName = extractVarName(pattern); + if (varName && typeName) env.set(varName, typeName); +}; + /** Rust: parameter → pattern: type */ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map): void => { let nameNode: SyntaxNode | null = null; @@ -38,5 +155,6 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map { const nameChild = node.childForFieldName('name'); if (nameChild) return extractVarName(nameChild); } + // Rust: let mut x = ... — mut_pattern wraps an identifier + if (node.type === 'mut_pattern') { + const inner = node.firstNamedChild; + if (inner) return extractVarName(inner); + } return undefined; }; @@ -91,6 +119,7 @@ export const TYPED_PARAMETER_TYPES = new Set([ 'parameter', // C#/Rust/Go/Python/Swift 'parameter_declaration', // C/C++ void f(Type name) 'simple_parameter', // PHP function(Foo $x) + 'property_promotion_parameter', // PHP 8.0+ constructor promotion: __construct(private Foo $x) ]); /** Find the first named child with the given node type */ diff --git a/gitnexus/src/core/ingestion/type-extractors/swift.ts b/gitnexus/src/core/ingestion/type-extractors/swift.ts index 374c8ebbda..41f2f78184 100644 --- a/gitnexus/src/core/ingestion/type-extractors/swift.ts +++ b/gitnexus/src/core/ingestion/type-extractors/swift.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -39,8 +39,47 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup): void => { + if (node.type !== 'property_declaration') return; + // Skip if has type annotation — extractDeclaration handled it + if (node.childForFieldName('type') || findChildByType(node, 'type_annotation')) return; + // Find pattern (variable name) + const pattern = node.childForFieldName('pattern') ?? findChildByType(node, 'pattern'); + if (!pattern) return; + const varName = extractVarName(pattern) ?? pattern.text; + if (!varName || env.has(varName)) return; + // Find call_expression in the value + const callExpr = findChildByType(node, 'call_expression'); + if (!callExpr) return; + const callee = callExpr.firstNamedChild; + if (!callee) return; + // Direct call: User(name: "alice") + if (callee.type === 'simple_identifier') { + const calleeName = callee.text; + if (calleeName && classNames.has(calleeName)) { + env.set(varName, calleeName); + } + return; + } + // Explicit init: User.init(name: "alice") — navigation_expression with .init suffix + if (callee.type === 'navigation_expression') { + const receiver = callee.firstNamedChild; + const suffix = callee.lastNamedChild; + if (receiver?.type === 'simple_identifier' && suffix?.text === 'init') { + const calleeName = receiver.text; + if (calleeName && classNames.has(calleeName)) { + env.set(varName, calleeName); + } + } + } +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + extractInitializer, }; diff --git a/gitnexus/src/core/ingestion/type-extractors/types.ts b/gitnexus/src/core/ingestion/type-extractors/types.ts index d3bf830e81..e874292cf9 100644 --- a/gitnexus/src/core/ingestion/type-extractors/types.ts +++ b/gitnexus/src/core/ingestion/type-extractors/types.ts @@ -6,6 +6,13 @@ export type TypeBindingExtractor = (node: SyntaxNode, env: Map) /** Extracts type bindings from a parameter node into the env map */ export type ParameterExtractor = (node: SyntaxNode, env: Map) => void; +/** Minimal interface for checking whether a name is a known class/struct. + * Narrower than ReadonlySet — only `.has()` is used by extractors. */ +export type ClassNameLookup = { has(name: string): boolean }; + +/** Extracts type bindings from a constructor-call initializer, with access to known class names */ +export type InitializerExtractor = (node: SyntaxNode, env: Map, classNames: ClassNameLookup) => void; + /** Per-language type extraction configuration */ export interface LanguageTypeConfig { /** Node types that represent typed declarations for this language */ @@ -14,4 +21,9 @@ export interface LanguageTypeConfig { extractDeclaration: TypeBindingExtractor; /** Extract a (varName → typeName) binding from a parameter node */ extractParameter: ParameterExtractor; + /** Extract a (varName → typeName) binding from a constructor-call initializer. + * Called as fallback when extractDeclaration produces no binding for a declaration node. + * Only for languages with syntactic constructor markers (new, composite_literal, ::new). + * Receives classNames — the set of class/struct names visible in the current file's AST. */ + extractInitializer?: InitializerExtractor; } diff --git a/gitnexus/src/core/ingestion/type-extractors/typescript.ts b/gitnexus/src/core/ingestion/type-extractors/typescript.ts index 2d77d8ee23..49e2f97234 100644 --- a/gitnexus/src/core/ingestion/type-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/type-extractors/typescript.ts @@ -1,5 +1,5 @@ import type { SyntaxNode } from '../utils.js'; -import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js'; +import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup } from './types.js'; import { extractSimpleTypeName, extractVarName } from './shared.js'; const DECLARATION_NODE_TYPES: ReadonlySet = new Set([ @@ -41,8 +41,33 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map, _classNames: ClassNameLookup): void => { + for (let i = 0; i < node.namedChildCount; i++) { + const declarator = node.namedChild(i); + if (declarator?.type !== 'variable_declarator') continue; + // Only activate when there is no explicit type annotation — extractDeclaration already + // handles the annotated case and this function is called as a fallback. + if (declarator.childForFieldName('type') !== null) continue; + let valueNode = declarator.childForFieldName('value'); + // Unwrap `new User() as T`, `new User()!`, and double-cast `new User() as unknown as T` + while (valueNode?.type === 'as_expression' || valueNode?.type === 'non_null_expression') { + valueNode = valueNode.firstNamedChild; + } + if (valueNode?.type !== 'new_expression') continue; + const constructorNode = valueNode.childForFieldName('constructor'); + if (!constructorNode) continue; + const nameNode = declarator.childForFieldName('name'); + if (!nameNode) continue; + const varName = extractVarName(nameNode); + const typeName = extractSimpleTypeName(constructorNode); + if (varName && typeName) env.set(varName, typeName); + } +}; + export const typeConfig: LanguageTypeConfig = { declarationNodeTypes: DECLARATION_NODE_TYPES, extractDeclaration, extractParameter, + extractInitializer, }; diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index 3d87a109c5..039024e18e 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -122,7 +122,7 @@ export const BUILT_IN_NAMES = new Set([ 'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'append', 'extend', 'update', // NOTE: 'open', 'read', 'write', 'close' removed — these are real C POSIX syscalls - 'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', + 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', 'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs', // Kotlin stdlib 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', @@ -271,6 +271,9 @@ export const CLASS_CONTAINER_TYPES = new Set([ // Ruby 'class', 'module', + // Kotlin + 'object_declaration', + 'companion_object', ]); export const CONTAINER_TYPE_TO_LABEL: Record = { @@ -288,6 +291,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { protocol_declaration: 'Interface', class: 'Class', module: 'Module', + object_declaration: 'Class', + companion_object: 'Class', }; /** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null. @@ -787,6 +792,10 @@ const SIMPLE_RECEIVER_TYPES = new Set([ 'name', // PHP name node 'this', // TS/JS/Java/C# this.method() 'self', // Rust/Python self.method() + 'super', // TS/JS/Java/Kotlin/Ruby super.method() + 'super_expression', // Kotlin wraps super in super_expression + 'base', // C# base.Method() + 'parent', // PHP parent::method() ]); export const extractReceiverName = ( @@ -824,6 +833,17 @@ export const extractReceiverName = ( receiver = parent.childForFieldName('receiver'); } + // PHP scoped_call_expression (parent::method(), self::method()): + // nameNode's direct parent IS the scoped_call_expression (name is a direct child) + if (!receiver && (parent.type === 'scoped_call_expression' || callNode.type === 'scoped_call_expression')) { + const scopedCall = parent.type === 'scoped_call_expression' ? parent : callNode; + receiver = scopedCall.childForFieldName('scope'); + // relative_scope wraps 'parent'/'self'/'static' — unwrap to get the keyword + if (receiver?.type === 'relative_scope') { + receiver = receiver.firstChild; + } + } + // Kotlin/Swift: navigation_expression target is the first child if (!receiver && parent.type === 'navigation_suffix') { const navExpr = parent.parent; @@ -845,6 +865,12 @@ export const extractReceiverName = ( return receiver.text; } + // Python super().method(): receiver is a call node `super()` — extract the function name + if (receiver.type === 'call') { + const func = receiver.childForFieldName('function'); + if (func?.text === 'super') return 'super'; + } + return undefined; }; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index c3bc275f08..768fdc9910 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -33,7 +33,8 @@ import { inferCallForm, extractReceiverName } from '../utils.js'; -import { buildTypeEnv, lookupTypeEnv } from '../type-env.js'; +import { buildTypeEnv } from '../type-env.js'; +import type { ConstructorBinding } from '../type-env.js'; import { isNodeExported } from '../export-detection.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; @@ -122,6 +123,12 @@ export interface ExtractedRoute { lineNumber: number; } +/** Constructor bindings keyed by filePath for cross-file type resolution */ +export interface FileConstructorBindings { + filePath: string; + bindings: ConstructorBinding[]; +} + export interface ParseWorkerResult { nodes: ParsedNode[]; relationships: ParsedRelationship[]; @@ -130,6 +137,7 @@ export interface ParseWorkerResult { calls: ExtractedCall[]; heritage: ExtractedHeritage[]; routes: ExtractedRoute[]; + constructorBindings: FileConstructorBindings[]; fileCount: number; } @@ -241,6 +249,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n calls: [], heritage: [], routes: [], + constructorBindings: [], fileCount: 0, }; @@ -824,10 +833,15 @@ const processFileGroup = ( result.fileCount++; onFileProcessed?.(); - // Build per-file TypeEnv from explicit type annotations (for receiver resolution) + // Build per-file type environment + constructor bindings in a single AST walk. + // Constructor bindings are verified against the SymbolTable in processCallsFromExtracted. const typeEnv = buildTypeEnv(tree, language); const callRouter = callRouters[language]; + if (typeEnv.constructorBindings.length > 0) { + result.constructorBindings.push({ filePath: file.path, bindings: [...typeEnv.constructorBindings] }); + } + let matches; try { matches = query.matches(tree.rootNode); @@ -946,7 +960,7 @@ const processFileGroup = ( || generateId('File', file.path); const callForm = inferCallForm(callNode, callNameNode); const receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; - const receiverTypeName = receiverName ? lookupTypeEnv(typeEnv, receiverName, callNode) : undefined; + const receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined; result.calls.push({ filePath: file.path, calledName, @@ -1106,7 +1120,7 @@ const processFileGroup = ( /** Accumulated result across sub-batches */ let accumulated: ParseWorkerResult = { nodes: [], relationships: [], symbols: [], - imports: [], calls: [], heritage: [], routes: [], fileCount: 0, + imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], fileCount: 0, }; let cumulativeProcessed = 0; @@ -1118,6 +1132,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => { target.calls.push(...src.calls); target.heritage.push(...src.heritage); target.routes.push(...src.routes); + target.constructorBindings.push(...src.constructorBindings); target.fileCount += src.fileCount; }; @@ -1139,7 +1154,7 @@ parentPort!.on('message', (msg: any) => { if (msg && msg.type === 'flush') { parentPort!.postMessage({ type: 'result', data: accumulated }); // Reset for potential reuse - accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 0 }; + accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], fileCount: 0 }; cumulativeProcessed = 0; return; } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/Repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/Repo.h new file mode 100644 index 0000000000..09e52e1f6c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/Repo.h @@ -0,0 +1,4 @@ +class Repo { +public: + void save() {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/User.h b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/User.h new file mode 100644 index 0000000000..866e725667 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/models/User.h @@ -0,0 +1,4 @@ +class User { +public: + void save() {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/services/App.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/services/App.cpp new file mode 100644 index 0000000000..e09ec4a61c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-brace-init-inference/services/App.cpp @@ -0,0 +1,10 @@ +#include "../models/User.h" +#include "../models/Repo.h" + +void process() { + auto user = User{}; + user.save(); + + auto repo = Repo{}; + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/Repo.h b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/Repo.h new file mode 100644 index 0000000000..8e02df5058 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/Repo.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class Repo { +public: + Repo(const std::string& dbName) : dbName_(dbName) {} + bool save() { return false; } +private: + std::string dbName_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/User.h b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/User.h new file mode 100644 index 0000000000..44b48cd6d1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/models/User.h @@ -0,0 +1,10 @@ +#pragma once +#include + +class User { +public: + User(const std::string& name) : name_(name) {} + bool save() { return true; } +private: + std::string name_; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/services/App.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/services/App.cpp new file mode 100644 index 0000000000..ed954e03a1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-constructor-type-inference/services/App.cpp @@ -0,0 +1,9 @@ +#include "models/User.h" +#include "models/Repo.h" + +void processEntities() { + auto user = User("alice"); + auto repo = Repo("maindb"); + user.save(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/BaseModel.h b/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/BaseModel.h new file mode 100644 index 0000000000..b53d615dbc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/BaseModel.h @@ -0,0 +1,6 @@ +#pragma once + +class BaseModel { +public: + bool save() { return true; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/User.h b/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/User.h new file mode 100644 index 0000000000..48f9273964 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-parent-resolution/src/User.h @@ -0,0 +1,7 @@ +#pragma once +#include "BaseModel.h" + +class User : public BaseModel { +public: + const char* serialize() { return ""; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp new file mode 100644 index 0000000000..e37a6e9326 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/app.cpp @@ -0,0 +1,8 @@ +#include "user.h" +#include + +void processUsers(std::vector users) { + for (User& user : users) { + user.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h new file mode 100644 index 0000000000..489f99f923 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-range-for/user.h @@ -0,0 +1,6 @@ +#pragma once + +class User { +public: + bool save() { return true; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/main.cpp new file mode 100644 index 0000000000..a23f5c0da3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/main.cpp @@ -0,0 +1,7 @@ +#include "models.h" + +void run() { + auto client = ns::HttpClient{}; + client.connect(); + client.send(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/models.h new file mode 100644 index 0000000000..d216dae9e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-scoped-brace-init/models.h @@ -0,0 +1,7 @@ +namespace ns { + class HttpClient { + public: + void connect() {} + void send() {} + }; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/Repo.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/Repo.cpp new file mode 100644 index 0000000000..e95ca225b6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/Repo.cpp @@ -0,0 +1,4 @@ +class Repo { +public: + bool save() { return true; } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/User.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/User.cpp new file mode 100644 index 0000000000..7892d4784b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-self-this-resolution/src/User.cpp @@ -0,0 +1,7 @@ +class User { +public: + bool save() { return true; } + void process() { + this->save(); + } +}; diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/BaseModel.cs b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/BaseModel.cs new file mode 100644 index 0000000000..9e4891e43c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/BaseModel.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class BaseModel { + public virtual bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/Repo.cs new file mode 100644 index 0000000000..60c1b2a079 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/Repo.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class Repo { + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/User.cs new file mode 100644 index 0000000000..a9f7dd3188 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-generic-parent-resolution/src/Models/User.cs @@ -0,0 +1,8 @@ +namespace Models; + +public class User : BaseModel { + public override bool Save() { + base.Save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/BaseModel.cs b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/BaseModel.cs new file mode 100644 index 0000000000..a706110452 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/BaseModel.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class BaseModel { + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/ISerializable.cs b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/ISerializable.cs new file mode 100644 index 0000000000..4f90720964 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/ISerializable.cs @@ -0,0 +1,5 @@ +namespace Models; + +public interface ISerializable { + string Serialize(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/User.cs new file mode 100644 index 0000000000..0041bda76d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-parent-resolution/src/Models/User.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class User : BaseModel, ISerializable { + public string Serialize() { return ""; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Models/Animal.cs b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Models/Animal.cs new file mode 100644 index 0000000000..a940ba041c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Models/Animal.cs @@ -0,0 +1,20 @@ +namespace Models; + +public class Animal +{ + public string Name { get; set; } +} + +public class Dog : Animal +{ + public void Bark() + { + } +} + +public class Cat : Animal +{ + public void Meow() + { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/PatternMatchProj.csproj b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/PatternMatchProj.csproj new file mode 100644 index 0000000000..ec2cce1432 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/PatternMatchProj.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Services/AnimalService.cs b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Services/AnimalService.cs new file mode 100644 index 0000000000..8a66f73228 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-pattern-matching/Services/AnimalService.cs @@ -0,0 +1,14 @@ +using Models; + +namespace Services; + +public class AnimalService +{ + public void HandleAnimal(Animal animal) + { + if (animal is Dog dog) + { + dog.Bark(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/Repo.cs new file mode 100644 index 0000000000..60c1b2a079 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/Repo.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class Repo { + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/User.cs new file mode 100644 index 0000000000..84c36c1523 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-self-this-resolution/src/Models/User.cs @@ -0,0 +1,8 @@ +namespace Models; + +public class User { + public bool Save() { return true; } + public void Process() { + this.Save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/BaseModel.cs b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/BaseModel.cs new file mode 100644 index 0000000000..f1b69c38fb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/BaseModel.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class BaseModel { + public virtual bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/Repo.cs new file mode 100644 index 0000000000..60c1b2a079 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/Repo.cs @@ -0,0 +1,5 @@ +namespace Models; + +public class Repo { + public bool Save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/User.cs new file mode 100644 index 0000000000..5773d64269 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-super-resolution/src/Models/User.cs @@ -0,0 +1,8 @@ +namespace Models; + +public class User : BaseModel { + public override bool Save() { + base.Save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/cmd/main.go new file mode 100644 index 0000000000..2b587af0de --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/cmd/main.go @@ -0,0 +1,10 @@ +package main + +import "example.com/go-constructor-type-inference/models" + +func processEntities() { + user := models.User{} + repo := models.Repo{} + user.Save() + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/go.mod b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/go.mod new file mode 100644 index 0000000000..4c2acbf0fa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/go.mod @@ -0,0 +1,3 @@ +module example.com/go-constructor-type-inference + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/repo.go new file mode 100644 index 0000000000..25e42eaf55 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/repo.go @@ -0,0 +1,7 @@ +package models + +type Repo struct{} + +func (r *Repo) Save() bool { + return false +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/user.go new file mode 100644 index 0000000000..7307a10d6a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-constructor-type-inference/models/user.go @@ -0,0 +1,7 @@ +package models + +type User struct{} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-make-builtin/main.go b/gitnexus/test/fixtures/lang-resolution/go-make-builtin/main.go new file mode 100644 index 0000000000..d6a1976da8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-make-builtin/main.go @@ -0,0 +1,9 @@ +package main + +func main() { + sl := make([]User, 0) + sl[0].Save() + + m := make(map[string]User) + m["key"].Greet() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-make-builtin/models.go b/gitnexus/test/fixtures/lang-resolution/go-make-builtin/models.go new file mode 100644 index 0000000000..cedc8307d8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-make-builtin/models.go @@ -0,0 +1,10 @@ +package main + +type User struct { + Name string +} + +func (u *User) Save() {} +func (u *User) Greet() string { + return "Hello, " + u.Name +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-new-builtin/main.go b/gitnexus/test/fixtures/lang-resolution/go-new-builtin/main.go new file mode 100644 index 0000000000..6da0937341 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-new-builtin/main.go @@ -0,0 +1,7 @@ +package main + +func main() { + user := new(User) + user.Save() + user.Greet() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-new-builtin/models.go b/gitnexus/test/fixtures/lang-resolution/go-new-builtin/models.go new file mode 100644 index 0000000000..f83199ae02 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-new-builtin/models.go @@ -0,0 +1,11 @@ +package main + +type User struct { + Name string + Age int +} + +func (u *User) Save() {} +func (u *User) Greet() string { + return "Hello, " + u.Name +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/go.mod b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/go.mod new file mode 100644 index 0000000000..192e075e86 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/go.mod @@ -0,0 +1,3 @@ +module example.com/app + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/base.go b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/base.go new file mode 100644 index 0000000000..f402c29da6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/base.go @@ -0,0 +1,7 @@ +package models + +type BaseModel struct{} + +func (b *BaseModel) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/user.go new file mode 100644 index 0000000000..bf962a773d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-parent-resolution/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + BaseModel +} + +func (u *User) Serialize() string { + return "" +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/cmd/main.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/cmd/main.go new file mode 100644 index 0000000000..21349e23d5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/cmd/main.go @@ -0,0 +1,11 @@ +package main + +import "example.com/pointer-test/models" + +func process() { + user := &models.User{Name: "alice"} + user.Save() + + repo := &models.Repo{Name: "test"} + repo.Save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/go.mod b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/go.mod new file mode 100644 index 0000000000..bba0365249 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/go.mod @@ -0,0 +1,3 @@ +module example.com/pointer-test + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/repo.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/repo.go new file mode 100644 index 0000000000..5abb2e7d82 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/repo.go @@ -0,0 +1,9 @@ +package models + +type Repo struct { + Name string +} + +func (r *Repo) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/user.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/user.go new file mode 100644 index 0000000000..0e78a30a83 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-constructor-inference/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct { + Name string +} + +func (u *User) Save() bool { + return true +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-type-assertion/main.go b/gitnexus/test/fixtures/lang-resolution/go-type-assertion/main.go new file mode 100644 index 0000000000..016562c7cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-type-assertion/main.go @@ -0,0 +1,7 @@ +package main + +func process(s Saver) { + user := s.(User) + user.Save() + user.Greet() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-type-assertion/models.go b/gitnexus/test/fixtures/lang-resolution/go-type-assertion/models.go new file mode 100644 index 0000000000..b5cb732f38 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-type-assertion/models.go @@ -0,0 +1,14 @@ +package main + +type Saver interface { + Save() +} + +type User struct { + Name string +} + +func (u *User) Save() {} +func (u *User) Greet() string { + return "Hello, " + u.Name +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/App.java b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/App.java new file mode 100644 index 0000000000..c39f5a99dc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/App.java @@ -0,0 +1,11 @@ +import models.User; +import models.Repo; + +public class App { + public static void processEntities() { + var user = new User(); + var repo = new Repo(); + user.save(); + repo.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/Repo.java new file mode 100644 index 0000000000..cf3712bbca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/Repo.java @@ -0,0 +1,7 @@ +package models; + +public class Repo { + public boolean save() { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/User.java new file mode 100644 index 0000000000..e8dacc1360 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-constructor-type-inference/models/User.java @@ -0,0 +1,7 @@ +package models; + +public class User { + public boolean save() { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/BaseModel.java b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/BaseModel.java new file mode 100644 index 0000000000..601bee4762 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/BaseModel.java @@ -0,0 +1,5 @@ +package models; + +public class BaseModel { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/Repo.java new file mode 100644 index 0000000000..4abcdfce8c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/Repo.java @@ -0,0 +1,5 @@ +package models; + +public class Repo { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/User.java new file mode 100644 index 0000000000..ce96757356 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-parent-resolution/src/models/User.java @@ -0,0 +1,8 @@ +package models; + +public class User extends BaseModel { + public boolean save() { + super.save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/interfaces/Serializable.java b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/interfaces/Serializable.java new file mode 100644 index 0000000000..fec500b419 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/interfaces/Serializable.java @@ -0,0 +1,5 @@ +package interfaces; + +public interface Serializable { + String serialize(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/BaseModel.java b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/BaseModel.java new file mode 100644 index 0000000000..804771b3b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/BaseModel.java @@ -0,0 +1,5 @@ +package models; + +public class BaseModel { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/User.java new file mode 100644 index 0000000000..4952c8b860 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-parent-resolution/src/models/User.java @@ -0,0 +1,7 @@ +package models; + +import interfaces.Serializable; + +public class User extends BaseModel implements Serializable { + public String serialize() { return ""; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/Repo.java new file mode 100644 index 0000000000..4abcdfce8c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/Repo.java @@ -0,0 +1,5 @@ +package models; + +public class Repo { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/User.java new file mode 100644 index 0000000000..42bad2849b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-self-this-resolution/src/models/User.java @@ -0,0 +1,8 @@ +package models; + +public class User { + public boolean save() { return true; } + public void process() { + this.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/BaseModel.java b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/BaseModel.java new file mode 100644 index 0000000000..804771b3b2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/BaseModel.java @@ -0,0 +1,5 @@ +package models; + +public class BaseModel { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/Repo.java b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/Repo.java new file mode 100644 index 0000000000..4abcdfce8c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/Repo.java @@ -0,0 +1,5 @@ +package models; + +public class Repo { + public boolean save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/User.java b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/User.java new file mode 100644 index 0000000000..01ab6241ca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-super-resolution/src/models/User.java @@ -0,0 +1,8 @@ +package models; + +public class User extends BaseModel { + public boolean save() { + super.save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/app.js b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/app.js new file mode 100644 index 0000000000..ec2609dd6d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/app.js @@ -0,0 +1,11 @@ +const { User } = require('./user'); +const { Repo } = require('./repo'); + +function processEntities() { + const user = new User(); + const repo = new Repo(); + user.save(); + repo.save(); +} + +module.exports = { processEntities }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/repo.js b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/repo.js new file mode 100644 index 0000000000..b224a3a1cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/repo.js @@ -0,0 +1,7 @@ +class Repo { + save() { + return false; + } +} + +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/user.js b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/user.js new file mode 100644 index 0000000000..62f995fae8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-constructor-type-inference/src/user.js @@ -0,0 +1,7 @@ +class User { + save() { + return true; + } +} + +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/Base.js b/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/Base.js new file mode 100644 index 0000000000..6a7bf92de9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/Base.js @@ -0,0 +1,4 @@ +class BaseModel { + save() { return true; } +} +module.exports = { BaseModel }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/User.js b/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/User.js new file mode 100644 index 0000000000..cb2644d5f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-parent-resolution/src/models/User.js @@ -0,0 +1,6 @@ +const { BaseModel } = require('./Base'); + +class User extends BaseModel { + serialize() { return ''; } +} +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/Repo.js b/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/Repo.js new file mode 100644 index 0000000000..41ab63ddd6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/Repo.js @@ -0,0 +1,4 @@ +class Repo { + save() { return true; } +} +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/User.js b/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/User.js new file mode 100644 index 0000000000..48f4944d68 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-self-this-resolution/src/models/User.js @@ -0,0 +1,7 @@ +class User { + save() { return true; } + process() { + this.save(); + } +} +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Base.js b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Base.js new file mode 100644 index 0000000000..6a7bf92de9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Base.js @@ -0,0 +1,4 @@ +class BaseModel { + save() { return true; } +} +module.exports = { BaseModel }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Repo.js b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Repo.js new file mode 100644 index 0000000000..41ab63ddd6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/Repo.js @@ -0,0 +1,4 @@ +class Repo { + save() { return true; } +} +module.exports = { Repo }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/User.js b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/User.js new file mode 100644 index 0000000000..79efca509b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-super-resolution/src/models/User.js @@ -0,0 +1,9 @@ +const { BaseModel } = require('./Base'); + +class User extends BaseModel { + save() { + super.save(); + return true; + } +} +module.exports = { User }; diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/Repo.kt new file mode 100644 index 0000000000..c337e368c1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo(val dbName: String) { + fun save(): Boolean = false +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/User.kt new file mode 100644 index 0000000000..7b3c396104 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User(val name: String) { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/services/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/services/App.kt new file mode 100644 index 0000000000..532650adfb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-constructor-type-inference/services/App.kt @@ -0,0 +1,11 @@ +package services + +import models.User +import models.Repo + +fun processEntities() { + val user = User("alice") + val repo = Repo("maindb") + user.save() + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/BaseModel.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/BaseModel.kt new file mode 100644 index 0000000000..7c372216c3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/BaseModel.kt @@ -0,0 +1,5 @@ +package models + +open class BaseModel { + open fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/Repo.kt new file mode 100644 index 0000000000..97c8fdeb5d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/User.kt new file mode 100644 index 0000000000..44327de3c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-generic-parent-resolution/models/User.kt @@ -0,0 +1,8 @@ +package models + +class User : BaseModel() { + override fun save(): Boolean { + super.save() + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/BaseModel.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/BaseModel.kt new file mode 100644 index 0000000000..14ae906145 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/BaseModel.kt @@ -0,0 +1,5 @@ +package models + +open class BaseModel { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/Serializable.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/Serializable.kt new file mode 100644 index 0000000000..1cc1406e61 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/Serializable.kt @@ -0,0 +1,5 @@ +package models + +interface Serializable { + fun serialize(): String +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/User.kt new file mode 100644 index 0000000000..4955fef800 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-parent-resolution/models/User.kt @@ -0,0 +1,5 @@ +package models + +class User : BaseModel(), Serializable { + override fun serialize(): String = "" +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/AppConfig.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/AppConfig.kt new file mode 100644 index 0000000000..ac40d9ded4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/AppConfig.kt @@ -0,0 +1,8 @@ +package models + +object AppConfig { + fun init(): Boolean = true + fun setup() { + this.init() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/Repo.kt new file mode 100644 index 0000000000..97c8fdeb5d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/User.kt new file mode 100644 index 0000000000..0e1176a69d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-self-this-resolution/models/User.kt @@ -0,0 +1,8 @@ +package models + +class User { + fun save(): Boolean = true + fun process() { + this.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/BaseModel.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/BaseModel.kt new file mode 100644 index 0000000000..9e71d30437 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/BaseModel.kt @@ -0,0 +1,5 @@ +package models + +open class BaseModel { + open fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/Repo.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/Repo.kt new file mode 100644 index 0000000000..97c8fdeb5d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/Repo.kt @@ -0,0 +1,5 @@ +package models + +class Repo { + fun save(): Boolean = true +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/User.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/User.kt new file mode 100644 index 0000000000..aba049296d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-super-resolution/models/User.kt @@ -0,0 +1,8 @@ +package models + +class User : BaseModel() { + override fun save(): Boolean { + super.save() + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Models/Repo.php new file mode 100644 index 0000000000..d3670e2f51 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/app/Models/Repo.php @@ -0,0 +1,11 @@ +save(); + $repo->save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/composer.json b/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-constructor-type-inference/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/BaseModel.php b/gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/BaseModel.php new file mode 100644 index 0000000000..38ecfbf1d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-parent-resolution/app/Models/BaseModel.php @@ -0,0 +1,8 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/app/Models/Repo.php b/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/app/Models/Repo.php new file mode 100644 index 0000000000..83a5311bc9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/app/Models/Repo.php @@ -0,0 +1,8 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/composer.json b/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/composer.json new file mode 100644 index 0000000000..60ede80e5e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-self-this-resolution/composer.json @@ -0,0 +1,5 @@ +{ + "autoload": { + "psr-4": { "App\\": "app/" } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/BaseModel.php b/gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/BaseModel.php new file mode 100644 index 0000000000..38ecfbf1d9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-super-resolution/app/Models/BaseModel.php @@ -0,0 +1,8 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/php-typed-properties/composer.json b/gitnexus/test/fixtures/lang-resolution/php-typed-properties/composer.json new file mode 100644 index 0000000000..386b0bd2d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/php-typed-properties/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py new file mode 100644 index 0000000000..18ce75c496 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self): + return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/service.py b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/service.py new file mode 100644 index 0000000000..ce0a999e7b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/service.py @@ -0,0 +1,10 @@ +from user import User +from repo import Repo + +# File-level class annotations (no default) +active_user: User +active_repo: Repo + +def process(): + active_user.save() + active_repo.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py new file mode 100644 index 0000000000..a9220e7448 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-class-annotations/user.py @@ -0,0 +1,3 @@ +class User: + def save(self): + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/repo.py b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/repo.py new file mode 100644 index 0000000000..d17e71270b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/repo.py @@ -0,0 +1,6 @@ +class Repo: + def __init__(self, db_name: str): + self.db_name = db_name + + def save(self) -> bool: + return False diff --git a/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/user.py b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/user.py new file mode 100644 index 0000000000..c0ddac39f8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/models/user.py @@ -0,0 +1,6 @@ +class User: + def __init__(self, name: str): + self.name = name + + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/services/app.py b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/services/app.py new file mode 100644 index 0000000000..9a5162c9f7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-constructor-type-inference/services/app.py @@ -0,0 +1,9 @@ +from models.user import User +from models.repo import Repo + + +def process_entities(): + user = User("alice") + repo = Repo("maindb") + user.save() + repo.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/base.py b/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/base.py new file mode 100644 index 0000000000..d105e82e1d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/base.py @@ -0,0 +1,3 @@ +class BaseModel: + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/user.py b/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/user.py new file mode 100644 index 0000000000..5b73530b91 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-parent-resolution/models/user.py @@ -0,0 +1,5 @@ +from .base import BaseModel + +class User(BaseModel): + def serialize(self) -> str: + return '' diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/main.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/main.py new file mode 100644 index 0000000000..40132cbccb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/main.py @@ -0,0 +1,6 @@ +import models + +def main(): + user = models.User("alice") + user.save() + user.greet() diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/models.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/models.py new file mode 100644 index 0000000000..69133bb963 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-qualified-constructor/models.py @@ -0,0 +1,9 @@ +class User: + def __init__(self, name): + self.name = name + + def save(self): + pass + + def greet(self): + return f"Hello, {self.name}" diff --git a/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/repo.py b/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/repo.py new file mode 100644 index 0000000000..ec93e0e2f4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/user.py b/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/user.py new file mode 100644 index 0000000000..15d710a56f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-self-this-resolution/models/user.py @@ -0,0 +1,5 @@ +class User: + def save(self) -> bool: + return True + def process(self) -> None: + self.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/base.py b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/base.py new file mode 100644 index 0000000000..d105e82e1d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/base.py @@ -0,0 +1,3 @@ +class BaseModel: + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/repo.py b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/repo.py new file mode 100644 index 0000000000..ec93e0e2f4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/repo.py @@ -0,0 +1,3 @@ +class Repo: + def save(self) -> bool: + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/user.py b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/user.py new file mode 100644 index 0000000000..b7a51e51c1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-super-resolution/models/user.py @@ -0,0 +1,6 @@ +from .base import BaseModel + +class User(BaseModel): + def save(self) -> bool: + super().save() + return True diff --git a/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/main.py b/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/main.py new file mode 100644 index 0000000000..b24b822e06 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/main.py @@ -0,0 +1,6 @@ +from models import User + + +def process(): + if (user := User("alice")): + user.save() diff --git a/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/models.py b/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/models.py new file mode 100644 index 0000000000..a2b31a8a8d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-walrus-operator/models.py @@ -0,0 +1,9 @@ +class User: + def __init__(self, name: str): + self.name = name + + def save(self) -> bool: + return True + + def greet(self) -> str: + return f"Hello, {self.name}" diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/app.rb new file mode 100644 index 0000000000..64f5159019 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/app.rb @@ -0,0 +1,5 @@ +require_relative 'models' + +SERVICE = UserService.new +SERVICE.process +SERVICE.validate diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/models.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/models.rb new file mode 100644 index 0000000000..1abcfa66f1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constant-constructor/models.rb @@ -0,0 +1,7 @@ +class UserService + def process + end + + def validate + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/repo.rb new file mode 100644 index 0000000000..a5660b4f2a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/repo.rb @@ -0,0 +1,9 @@ +class Repo + def save + true + end + + def cleanup + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/user.rb new file mode 100644 index 0000000000..215b1c6924 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/models/user.rb @@ -0,0 +1,9 @@ +class User + def save + true + end + + def cleanup + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/services/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/services/app.rb new file mode 100644 index 0000000000..e82e7d2fc9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-constructor-type-inference/services/app.rb @@ -0,0 +1,20 @@ +require_relative '../models/user' +require_relative '../models/repo' + +class AppService + def process_entities + user = User.new + repo = Repo.new + user.save + repo.save + end + + def cleanup + true + end + + def greet + self.process_entities + self.cleanup + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/base_model.rb b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/base_model.rb new file mode 100644 index 0000000000..d024eda4c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/base_model.rb @@ -0,0 +1,5 @@ +class BaseModel + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/serializable.rb b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/serializable.rb new file mode 100644 index 0000000000..9675515e11 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/serializable.rb @@ -0,0 +1,5 @@ +module Serializable + def serialize + '' + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/user.rb new file mode 100644 index 0000000000..49170a1b3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-parent-resolution/lib/models/user.rb @@ -0,0 +1,6 @@ +require_relative 'base_model' +require_relative 'serializable' + +class User < BaseModel + include Serializable +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/repo.rb new file mode 100644 index 0000000000..3e78d628c6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/repo.rb @@ -0,0 +1,5 @@ +class Repo + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/user.rb new file mode 100644 index 0000000000..b9518e9c25 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-self-this-resolution/lib/models/user.rb @@ -0,0 +1,8 @@ +class User + def save + true + end + def process + self.save + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/base_model.rb b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/base_model.rb new file mode 100644 index 0000000000..d024eda4c9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/base_model.rb @@ -0,0 +1,5 @@ +class BaseModel + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/repo.rb b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/repo.rb new file mode 100644 index 0000000000..3e78d628c6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/repo.rb @@ -0,0 +1,5 @@ +class Repo + def save + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/user.rb b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/user.rb new file mode 100644 index 0000000000..a82ea78b3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-super-resolution/lib/models/user.rb @@ -0,0 +1,8 @@ +require_relative 'base_model' + +class User < BaseModel + def save + super + true + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/main.rs new file mode 100644 index 0000000000..c32a3724c3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/main.rs @@ -0,0 +1,13 @@ +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process_entities() { + let user = User::new(); + let repo = Repo::new(); + user.save(); + repo.save(); +} + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/repo.rs new file mode 100644 index 0000000000..da880dd959 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/repo.rs @@ -0,0 +1,11 @@ +pub struct Repo; + +impl Repo { + pub fn new() -> Self { + Repo + } + + pub fn save(&self) -> bool { + false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/user.rs new file mode 100644 index 0000000000..e4203f981d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-constructor-type-inference/src/user.rs @@ -0,0 +1,11 @@ +pub struct User; + +impl User { + pub fn new() -> Self { + User + } + + pub fn save(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let/main.rs new file mode 100644 index 0000000000..73c49f715f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let/main.rs @@ -0,0 +1,24 @@ +mod models; +use models::{User, Config}; + +fn get_user() -> User { + User { name: "alice".to_string(), age: 30 } +} + +fn process_if_let() { + // captured_pattern: user @ User { .. } — binds 'user' with type 'User' + if let user @ User { .. } = get_user() { + user.save(); + } +} + +fn process_while_let() { + // captured_pattern inside while-let + while let cfg @ Config { .. } = get_config() { + cfg.validate(); + } +} + +fn get_config() -> Config { + Config { debug: true } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-if-let/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-if-let/models.rs new file mode 100644 index 0000000000..449554d1b9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-if-let/models.rs @@ -0,0 +1,18 @@ +pub struct User { + pub name: String, + pub age: u32, +} + +impl User { + pub fn save(&self) {} +} + +pub struct Config { + pub debug: bool, +} + +impl Config { + pub fn validate(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/lib.rs new file mode 100644 index 0000000000..09298bff66 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/lib.rs @@ -0,0 +1,2 @@ +pub mod serializable; +pub mod user; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/serializable.rs b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/serializable.rs new file mode 100644 index 0000000000..ddf412263c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/serializable.rs @@ -0,0 +1,3 @@ +pub trait Serializable { + fn serialize(&self) -> String; +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/user.rs new file mode 100644 index 0000000000..3b41fe70d5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-parent-resolution/src/user.rs @@ -0,0 +1,9 @@ +use crate::serializable::Serializable; + +pub struct User; + +impl Serializable for User { + fn serialize(&self) -> String { + String::new() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/main.rs new file mode 100644 index 0000000000..5cae674943 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/main.rs @@ -0,0 +1,7 @@ +mod models; +use models::User; + +fn main() { + let user = User::blank(); + user.greet(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/models.rs new file mode 100644 index 0000000000..aa76c1e493 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-self-struct-literal/models.rs @@ -0,0 +1,19 @@ +pub struct User { + pub name: String, +} + +impl User { + pub fn blank() -> Self { + let fresh = Self { name: String::new() }; + fresh.validate(); + fresh + } + + pub fn validate(&self) -> bool { + !self.name.is_empty() + } + + pub fn greet(&self) -> String { + format!("Hello, {}", self.name) + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/repo.rs b/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/repo.rs new file mode 100644 index 0000000000..318a2605d3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/repo.rs @@ -0,0 +1,5 @@ +pub struct Repo; + +impl Repo { + pub fn save(&self) -> bool { true } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/user.rs b/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/user.rs new file mode 100644 index 0000000000..117be7c67d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-self-this-resolution/src/user.rs @@ -0,0 +1,8 @@ +pub struct User; + +impl User { + pub fn save(&self) -> bool { true } + pub fn process(&self) { + self.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/main.rs new file mode 100644 index 0000000000..5086cb560d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/main.rs @@ -0,0 +1,10 @@ +mod models; +use models::{User, Config}; + +fn main() { + let user = User { name: "alice".to_string(), age: 30 }; + user.save(); + + let config = Config { debug: true }; + config.validate(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/models.rs b/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/models.rs new file mode 100644 index 0000000000..378ef8ff80 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-struct-literal-inference/models.rs @@ -0,0 +1,21 @@ +pub struct User { + pub name: String, + pub age: u32, +} + +impl User { + pub fn save(&self) {} + pub fn greet(&self) -> String { + format!("Hello, {}", self.name) + } +} + +pub struct Config { + pub debug: bool, +} + +impl Config { + pub fn validate(&self) -> bool { + true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/Repo.swift b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/Repo.swift new file mode 100644 index 0000000000..ebbdce0040 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/Repo.swift @@ -0,0 +1,11 @@ +class Repo { + let dbName: String + + init(dbName: String) { + self.dbName = dbName + } + + func save() -> Bool { + return false + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/User.swift b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/User.swift new file mode 100644 index 0000000000..bdc4ec0ef4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Models/User.swift @@ -0,0 +1,11 @@ +class User { + let name: String + + init(name: String) { + self.name = name + } + + func save() -> Bool { + return true + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Services/App.swift b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Services/App.swift new file mode 100644 index 0000000000..040c3a973c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-constructor-type-inference/Services/App.swift @@ -0,0 +1,8 @@ +import Models + +func processEntities() { + let user = User(name: "alice") + let repo = Repo(dbName: "maindb") + user.save() + repo.save() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/User.swift b/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/User.swift new file mode 100644 index 0000000000..ba74a89d8a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/User.swift @@ -0,0 +1,12 @@ +class User { + var name: String + + init(name: String) { + self.name = name + } + + func save() {} + func greet() -> String { + return "Hello, \(name)" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/main.swift b/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/main.swift new file mode 100644 index 0000000000..79675258cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-init-cross-file/main.swift @@ -0,0 +1,5 @@ +func main() { + let user = User.init(name: "alice") + user.save() + user.greet() +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/BaseModel.swift b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/BaseModel.swift new file mode 100644 index 0000000000..c91513dffc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/BaseModel.swift @@ -0,0 +1,3 @@ +class BaseModel { + func save() -> Bool { return true } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/Serializable.swift b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/Serializable.swift new file mode 100644 index 0000000000..fd8c83b067 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/Serializable.swift @@ -0,0 +1,3 @@ +protocol Serializable { + func serialize() -> String +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/User.swift b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/User.swift new file mode 100644 index 0000000000..c4616473ce --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-parent-resolution/Sources/Models/User.swift @@ -0,0 +1,3 @@ +class User: BaseModel, Serializable { + func serialize() -> String { return "" } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/Repo.swift b/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/Repo.swift new file mode 100644 index 0000000000..3d6901a654 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/Repo.swift @@ -0,0 +1,3 @@ +class Repo { + func save() -> Bool { return true } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/User.swift b/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/User.swift new file mode 100644 index 0000000000..e30c21f083 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-self-this-resolution/Sources/Models/User.swift @@ -0,0 +1,6 @@ +class User { + func save() -> Bool { return true } + func process() { + self.save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/app.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/app.ts new file mode 100644 index 0000000000..a6d0f80285 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/app.ts @@ -0,0 +1,12 @@ +import { User } from './user'; +import { Repo } from './repo'; + +export function processEntities(): void { + const user: User | null = new User(); + const repo: Repo | undefined = new Repo(); + + // Optional chain calls — receiver should still resolve + user?.save(); + user?.greet('hello'); + repo?.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/repo.ts new file mode 100644 index 0000000000..19631246b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/user.ts b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/user.ts new file mode 100644 index 0000000000..f9c5caa77a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ts-nullable-receiver/src/user.ts @@ -0,0 +1,9 @@ +export class User { + save(): boolean { + return true; + } + + greet(msg: string): void { + console.log(msg); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/app.ts new file mode 100644 index 0000000000..70c3274b6e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/app.ts @@ -0,0 +1,10 @@ +import { User } from './user'; +import { Repo } from './repo'; + +function process() { + const user = new User() as any; + user.save(); + + const repo = new Repo()!; + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/repo.ts new file mode 100644 index 0000000000..5ff21c34a6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/user.ts new file mode 100644 index 0000000000..e79beaad6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-cast-constructor-inference/src/user.ts @@ -0,0 +1,3 @@ +export class User { + save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts new file mode 100644 index 0000000000..33433b7773 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/app.ts @@ -0,0 +1,9 @@ +import { User } from './user'; +import { Repo } from './repo'; + +export function processEntities(): void { + const user = new User(); + const repo = new Repo(); + user.save(); + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts new file mode 100644 index 0000000000..19631246b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/repo.ts @@ -0,0 +1,5 @@ +export class Repo { + save(): boolean { + return false; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts new file mode 100644 index 0000000000..e2af97ca77 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-constructor-type-inference/src/user.ts @@ -0,0 +1,5 @@ +export class User { + save(): boolean { + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/app.ts new file mode 100644 index 0000000000..6938f08b98 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/app.ts @@ -0,0 +1,10 @@ +import { User } from './user'; +import { Repo } from './repo'; + +function process() { + const user = new User() as unknown as any; + user.save(); + + const repo = new Repo() as unknown as object; + repo.save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/repo.ts new file mode 100644 index 0000000000..5ff21c34a6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/user.ts b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/user.ts new file mode 100644 index 0000000000..e79beaad6f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-double-cast-inference/src/user.ts @@ -0,0 +1,3 @@ +export class User { + save() { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Base.ts new file mode 100644 index 0000000000..a560b7b985 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Base.ts @@ -0,0 +1,3 @@ +export class BaseModel { + save(): boolean { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Repo.ts new file mode 100644 index 0000000000..939092cce3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): boolean { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/User.ts new file mode 100644 index 0000000000..ecf4abee07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-parent-resolution/src/models/User.ts @@ -0,0 +1,8 @@ +import { BaseModel } from './Base'; + +export class User extends BaseModel { + save(): boolean { + super.save(); + return true; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/Base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/Base.ts new file mode 100644 index 0000000000..5717e68455 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/Base.ts @@ -0,0 +1,7 @@ +export class BaseModel { + save(): boolean { return true; } +} + +export interface Serializable { + serialize(): string; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/User.ts new file mode 100644 index 0000000000..76d5c63bc8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-parent-resolution/src/models/User.ts @@ -0,0 +1,5 @@ +import { BaseModel, Serializable } from './Base'; + +export class User extends BaseModel implements Serializable { + serialize(): string { return ''; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/Repo.ts new file mode 100644 index 0000000000..939092cce3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): boolean { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/User.ts new file mode 100644 index 0000000000..4c8e33f09f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-self-this-resolution/src/models/User.ts @@ -0,0 +1,6 @@ +export class User { + save(): boolean { return true; } + process(): void { + this.save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Base.ts new file mode 100644 index 0000000000..4ac02f5965 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Base.ts @@ -0,0 +1,3 @@ +export class BaseModel { + save(): boolean { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Repo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Repo.ts new file mode 100644 index 0000000000..939092cce3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/Repo.ts @@ -0,0 +1,3 @@ +export class Repo { + save(): boolean { return true; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/User.ts b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/User.ts new file mode 100644 index 0000000000..0a2b2bc24a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-super-resolution/src/models/User.ts @@ -0,0 +1,8 @@ +import { BaseModel } from './Base'; + +export class User extends BaseModel { + save(): boolean { + super.save(); + return true; + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 863fc84427..41eaa5c5e6 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -233,6 +233,49 @@ describe('C++ receiver-constrained resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: auto user = User(); user.save() → User.save +// Cross-file SymbolTable verification (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('C++ constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to models/User.h via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.h'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to models/Repo.h via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.h'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // Variadic resolution: C-style variadic (...) doesn't get filtered by arity // --------------------------------------------------------------------------- @@ -284,3 +327,153 @@ describe('C++ local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// this->save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('C++ this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves this->save() to User::save in the same file (not Repo::save)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/User.cpp'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS via base_class_clause +// --------------------------------------------------------------------------- + +describe('C++ parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('BaseModel'); + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('emits EXTENDS edge: User → BaseModel (base_class_clause)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); +}); + +// --------------------------------------------------------------------------- +// Brace-init constructor inference: auto x = User{}; x.save() → User.save +// --------------------------------------------------------------------------- + +describe('C++ brace-init constructor inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-brace-init-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to User.save via brace-init', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.h'); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() to Repo.save via brace-init', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.h'); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// C++ scoped brace-init: auto x = ns::HttpClient{} +// --------------------------------------------------------------------------- + +describe('C++ scoped brace-init resolution (ns::Type{})', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-scoped-brace-init'), + () => {}, + ); + }, 60000); + + it('resolves client.connect() via ns::HttpClient{} scoped brace-init', () => { + const calls = getRelationships(result, 'CALLS'); + const connectCall = calls.find(c => c.target === 'connect' && c.targetFilePath === 'models.h'); + expect(connectCall).toBeDefined(); + expect(connectCall!.source).toBe('run'); + }); + + it('resolves client.send() via ns::HttpClient{} scoped brace-init', () => { + const calls = getRelationships(result, 'CALLS'); + const sendCall = calls.find(c => c.target === 'send' && c.targetFilePath === 'models.h'); + expect(sendCall).toBeDefined(); + expect(sendCall!.source).toBe('run'); + }); +}); + +// --------------------------------------------------------------------------- +// Range-for with explicit type: for (User& user : users) { user.save(); } +// --------------------------------------------------------------------------- + +describe('C++ range-for explicit type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-range-for'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() inside range-for to User.save via explicit type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'user.h'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('processUsers'); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find(e => e.source === 'User' && e.target === 'save'); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index efe38749bb..db6345cc6f 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -58,7 +58,7 @@ describe('C# heritage resolution', () => { // Calls resolve via directory-based PackageMap (import-resolved) when ambiguous, // or via unique-global when the symbol name is globally unique. for (const call of calls) { - expect(['import-resolved', 'unique-global']).toContain(call.rel.reason); + expect(['import-resolved', 'global']).toContain(call.rel.reason); } }); @@ -386,3 +386,175 @@ describe('C# local definition shadows import', () => { expect(saveToUtils).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// this.Save() resolves to enclosing class's own Save method +// --------------------------------------------------------------------------- + +describe('C# this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a Save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves this.Save() inside User.Process to User.Save, not Repo.Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.source === 'Process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/Models/User.cs'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + IMPLEMENTS via base_list +// --------------------------------------------------------------------------- + +describe('C# parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus ISerializable interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['ISerializable']); + }); + + it('emits EXTENDS edge: User → BaseModel (from base_list)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User → ISerializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('ISerializable'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// base.Save() resolves to parent class's Save method +// --------------------------------------------------------------------------- + +describe('C# base resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves base.Save() inside User to BaseModel.Save, not Repo.Save', () => { + const calls = getRelationships(result, 'CALLS'); + const baseSave = calls.find(c => c.source === 'Save' && c.target === 'Save' + && c.targetFilePath === 'src/Models/BaseModel.cs'); + expect(baseSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'src/Models/Repo.cs'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// base.Save() resolves to generic parent class's Save method +// --------------------------------------------------------------------------- + +describe('C# generic parent base resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-generic-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves base.Save() inside User to BaseModel.Save, not Repo.Save', () => { + const calls = getRelationships(result, 'CALLS'); + const baseSave = calls.find(c => c.source === 'Save' && c.target === 'Save' + && c.targetFilePath === 'src/Models/BaseModel.cs'); + expect(baseSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'src/Models/Repo.cs'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Pattern matching: `if (animal is Dog dog)` binds `dog` as type `Dog` +// --------------------------------------------------------------------------- + +describe('C# is pattern matching resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-pattern-matching'), + () => {}, + ); + }, 60000); + + it('detects Animal, Dog, and Cat classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Animal'); + expect(classes).toContain('Dog'); + expect(classes).toContain('Cat'); + }); + + it('detects Bark and Meow methods', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('Bark'); + expect(methods).toContain('Meow'); + }); + + it('resolves dog.Bark() to Dog.Bark via is-pattern type binding', () => { + const calls = getRelationships(result, 'CALLS'); + const barkCall = calls.find(c => c.target === 'Bark'); + expect(barkCall).toBeDefined(); + expect(barkCall!.source).toBe('HandleAnimal'); + expect(barkCall!.targetFilePath).toBe('Models/Animal.cs'); + }); + + it('emits EXTENDS edges for Dog and Cat', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const dogExtends = extends_.find(e => e.source === 'Dog'); + const catExtends = extends_.find(e => e.source === 'Cat'); + expect(dogExtends).toBeDefined(); + expect(dogExtends!.target).toBe('Animal'); + expect(catExtends).toBeDefined(); + expect(catExtends!.target).toBe('Animal'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 3bb51dbb9a..f90f420d83 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -343,3 +343,201 @@ describe('Go local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: user := models.User{}; user.Save() +// Go composite literal constructor pattern (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('Go constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with Save methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() to models/user.go via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.Save() to models/repo.go via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Pointer-constructor-inferred type resolution: user := &models.User{...}; user.Save() +// Go address-of composite literal constructor pattern (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('Go pointer-constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-pointer-constructor-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with Save methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'Save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.Save() to models/user.go via &User{} pointer-constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/user.go'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process'); + }); + + it('resolves repo.Save() to models/repo.go via &Repo{} pointer-constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models/repo.go'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process'); + }); + + it('emits exactly 2 Save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'Save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Parent resolution: struct embedding emits EXTENDS +// --------------------------------------------------------------------------- + +describe('Go parent resolution (struct embedding)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User structs', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['BaseModel', 'User']); + }); + + it('emits EXTENDS edge: User → BaseModel (struct embedding)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); +}); + +// --------------------------------------------------------------------------- +// Go new() builtin type inference: user := new(User); user.Save() +// --------------------------------------------------------------------------- + +describe('Go new() builtin type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-new-builtin'), + () => {}, + ); + }, 60000); + + it('resolves user.Save() via new(User) inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + }); + + it('resolves user.Greet() via new(User) inference', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// Go make() builtin type inference: sl := make([]User, 0); sl[0].Save() +// --------------------------------------------------------------------------- + +describe('Go make() builtin type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-make-builtin'), + () => {}, + ); + }, 60000); + + it('resolves sl[0].Save() via make([]User, 0) slice inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + }); + + it('resolves m["key"].Greet() via make(map[string]User) map inference', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// Go type assertion inference: user := s.(User); user.Save() +// --------------------------------------------------------------------------- + +describe('Go type assertion type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'go-type-assertion'), + () => {}, + ); + }, 60000); + + it('resolves user.Save() via type assertion s.(User)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'Save' && c.targetFilePath === 'models.go'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process'); + }); + + it('resolves user.Greet() via type assertion s.(User)', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'Greet' && c.targetFilePath === 'models.go'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('process'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index d181b85ad7..371382c0e6 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -358,3 +358,171 @@ describe('Java local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: var user = new User(); user.save() +// Java 10+ local variable type inference (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('Java constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to models/User.java via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.java'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to models/Repo.java via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.java'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// this.save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('Java this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves this.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/models/User.java'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('Java parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User → Serializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('Serializable'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('Java super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'src/models/BaseModel.java'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/models/Repo.java'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to generic parent class's save method +// --------------------------------------------------------------------------- + +describe('Java generic parent super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-generic-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'src/models/BaseModel.java'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/models/Repo.java'); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts new file mode 100644 index 0000000000..41be8a8412 --- /dev/null +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -0,0 +1,107 @@ +/** + * JavaScript: self/this resolution, parent resolution, super resolution + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// this.save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('JavaScript this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves this.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/models/User.js'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS edge +// --------------------------------------------------------------------------- + +describe('JavaScript parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('EXTENDS edge points to real graph node', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const target = result.graph.getNode(extends_[0].rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe('BaseModel'); + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('JavaScript super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(3); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'src/models/Base.js'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/models/Repo.js'); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 7173c2dd6d..4ee89971b7 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -380,3 +380,178 @@ describe('Kotlin local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: val user = User() without annotation +// disambiguates user.save() vs repo.save() via TypeEnv constructor inference +// --------------------------------------------------------------------------- + +describe('Kotlin constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save functions', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + +it('resolves user.save() to models/User.kt via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/User.kt'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to models/Repo.kt via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.kt'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// this.save() resolves to enclosing class's / object's own method +// --------------------------------------------------------------------------- + +describe('Kotlin this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User, Repo classes and AppConfig object', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + expect(getNodesByLabel(result, 'Class')).toContain('AppConfig'); + }); + + it('resolves this.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('models/User.kt'); + }); + + it('resolves this.init() inside AppConfig.setup to AppConfig.init (object_declaration)', () => { + const calls = getRelationships(result, 'CALLS'); + const initCall = calls.find(c => c.target === 'init' && c.source === 'setup'); + expect(initCall).toBeDefined(); + expect(initCall!.targetFilePath).toBe('models/AppConfig.kt'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('Kotlin parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User → Serializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('Serializable'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('Kotlin super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'models/BaseModel.kt'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.kt'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to generic parent class's save method +// --------------------------------------------------------------------------- + +describe('Kotlin generic parent super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-generic-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'models/BaseModel.kt'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/Repo.kt'); + expect(repoSave).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/php.test.ts b/gitnexus/test/integration/resolvers/php.test.ts index aaddb12dba..2803f8d143 100644 --- a/gitnexus/test/integration/resolvers/php.test.ts +++ b/gitnexus/test/integration/resolvers/php.test.ts @@ -488,3 +488,210 @@ describe('PHP local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: $user = new User(); $user->save() +// PHP object_creation_expression (no typed local variable annotations) +// --------------------------------------------------------------------------- + +describe('PHP constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves $user->save() to app/Models/User.php via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'app/Models/User.php'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves $repo->save() to app/Models/Repo.php via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'app/Models/Repo.php'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// $this->save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('PHP $this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves $this->save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('app/Models/User.php'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('PHP parent class resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User → Serializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('Serializable'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// parent::save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('PHP parent:: resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves parent::save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const parentSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'app/Models/BaseModel.php'); + expect(parentSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'app/Models/Repo.php'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// PHP 8.0+ constructor property promotion: __construct(private UserRepo $repo) +// --------------------------------------------------------------------------- + +describe('PHP constructor property promotion resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-property-promotion'), + () => {}, + ); + }, 60000); + + it('detects UserRepo and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('resolves $repo->save() inside constructor via promoted parameter type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === '__construct'); + expect(saveCall).toBeDefined(); + }); + + // NOTE: $this->repo->save() in other methods requires multi-step receiver resolution + // (chained property access), which is a cross-language architectural feature not yet + // implemented. The promoted parameter type IS extracted into the TypeEnv — it just + // can't be accessed via $this->property chains yet. +}); + +// --------------------------------------------------------------------------- +// PHP 7.4+ typed class property resolution: private UserRepo $repo; +// --------------------------------------------------------------------------- + +describe('PHP typed class property resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'php-typed-properties'), + () => {}, + ); + }, 60000); + + it('detects UserRepo and UserService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + }); + + it('detects typed property $repo on UserService', () => { + expect(getNodesByLabel(result, 'Property')).toContain('repo'); + }); + + it('detects find and save methods on UserRepo', () => { + expect(getNodesByLabel(result, 'Method')).toContain('find'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves $repo->save() to UserRepo.php via parameter type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('app/Models/UserRepo.php'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 2c57db9c71..c366700704 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -342,6 +342,53 @@ describe('Python local definition shadows import', () => { // Constructor-call resolution: User("alice") resolves to User class // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: user = User(); user.save() → User.save +// Cross-file SymbolTable verification (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('Python constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to models/user.py via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/user.py'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + }); + + it('resolves repo.save() to models/repo.py via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/repo.py'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process_entities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor-call resolution: User("alice") resolves to User class +// --------------------------------------------------------------------------- + describe('Python constructor-call resolution', () => { let result: PipelineResult; @@ -381,3 +428,191 @@ describe('Python constructor-call resolution', () => { expect(saveCall!.targetFilePath).toBe('models.py'); }); }); + +// --------------------------------------------------------------------------- +// self.save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('Python self resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save function', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves self.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('models/user.py'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS edge +// --------------------------------------------------------------------------- + +describe('Python parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('EXTENDS edge points to real graph node in base.py', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const target = result.graph.getNode(extends_[0].rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.filePath).toBe('models/base.py'); + }); +}); + +// --------------------------------------------------------------------------- +// super().save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('Python super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('resolves super().save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'models/base.py'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/repo.py'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Python qualified constructor: user = models.User("alice"); user.save() +// --------------------------------------------------------------------------- + +describe('Python qualified constructor inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-qualified-constructor'), + () => {}, + ); + }, 60000); + + it('resolves user.save() via qualified constructor (models.User)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'models.py'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + }); + + it('resolves user.greet() via qualified constructor (models.User)', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'greet' && c.targetFilePath === 'models.py'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// Walrus operator: if (user := User("alice")): user.save() +// --------------------------------------------------------------------------- + +describe('Python walrus operator type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-walrus-operator'), + () => {}, + ); + }, 60000); + + it('detects User class with save and greet methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('greet'); + }); + + it('resolves user.save() via walrus operator constructor inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'models.py'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('process'); + }); +}); + +// --------------------------------------------------------------------------- +// Class-level annotations: file-scope `user: User` disambiguates method calls +// --------------------------------------------------------------------------- + +describe('Python class-level annotation resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'python-class-annotations'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves active_user.save() to User.save via file-level annotation', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'user.py'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process'); + }); + + it('resolves active_repo.save() to Repo.save via file-level annotation', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'repo.py'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 9a44900b2d..360289bd87 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -315,3 +315,193 @@ describe('Ruby local definition shadows import', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: user = User.new; user.save → User.save +// --------------------------------------------------------------------------- + +describe('Ruby constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User, Repo, and AppService classes', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + expect(getNodesByLabel(result, 'Class')).toContain('AppService'); + }); + + it('detects save on User and Repo, cleanup on all three', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods.filter(m => m === 'save').length).toBe(2); + expect(methods.filter(m => m === 'cleanup').length).toBe(3); + }); + + it('resolves user.save to models/user.rb via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/user.rb'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + }); + + it('resolves repo.save to models/repo.rb via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'models/repo.rb'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process_entities'); + }); + + it('emits exactly 2 save CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); + + it('resolves self.process_entities to services/app.rb (unique method)', () => { + const calls = getRelationships(result, 'CALLS'); + const selfCall = calls.find(c => + c.source === 'greet' && c.target === 'process_entities' + ); + expect(selfCall).toBeDefined(); + expect(selfCall!.targetFilePath).toContain('app.rb'); + }); + + it('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { + const calls = getRelationships(result, 'CALLS'); + const selfCleanup = calls.find(c => + c.source === 'greet' && c.target === 'cleanup' + ); + expect(selfCleanup).toBeDefined(); + expect(selfCleanup!.targetFilePath).toContain('app.rb'); + }); +}); + +// --------------------------------------------------------------------------- +// self.save resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('Ruby self resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves self.save inside User#process to User#save, not Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('lib/models/user.rb'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: < BaseModel + include Module +// --------------------------------------------------------------------------- + +describe('Ruby parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable module', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Module')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User < BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User includes Serializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const includeEdge = implements_.find(e => e.source === 'User' && e.target === 'Serializable'); + expect(includeEdge).toBeDefined(); + expect(includeEdge!.rel.reason).toBe('include'); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby super: standalone keyword calls same-named method on parent +// --------------------------------------------------------------------------- + +describe('Ruby super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + }); + + it('emits EXTENDS edge: User < BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('detects save methods on all three classes', () => { + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby constant constructor: SERVICE = UserService.new; SERVICE.process +// --------------------------------------------------------------------------- + +describe('Ruby constant constructor binding resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-constant-constructor'), + () => {}, + ); + }, 60000); + + it('detects UserService class with process and validate methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('UserService'); + expect(getNodesByLabel(result, 'Method')).toContain('process'); + expect(getNodesByLabel(result, 'Method')).toContain('validate'); + }); + + it('resolves SERVICE.process() via constant constructor binding', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find(c => c.target === 'process' && c.targetFilePath === 'models.rb'); + expect(processCall).toBeDefined(); + }); + + it('resolves SERVICE.validate() via constant constructor binding', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => c.target === 'validate' && c.targetFilePath === 'models.rb'); + expect(validateCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 9e5c8501f4..c14adcc1ac 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -407,3 +407,196 @@ describe('Rust grouped import resolution', () => { expect(imports[0].targetFilePath).toBe('src/helpers/mod.rs'); }); }); + +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: let user = User::new(); user.save() +// Rust scoped_identifier constructor pattern (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('Rust constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, both with save methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to src/user.rs via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.rs'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('process_entities'); + }); + + it('resolves repo.save() to src/repo.rs via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.rs'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('process_entities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// self.save() resolves to enclosing impl's own save method +// --------------------------------------------------------------------------- + +describe('Rust self resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo structs, each with a save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves self.save() inside User::process to User::save, not Repo::save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/user.rs'); + }); +}); + +// --------------------------------------------------------------------------- +// Trait impl emits IMPLEMENTS edge +// --------------------------------------------------------------------------- + +describe('Rust parent resolution (trait impl)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects User struct and Serializable trait', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Trait')).toContain('Serializable'); + }); + + it('emits IMPLEMENTS edge: User → Serializable (trait impl)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('Serializable'); + expect(implements_[0].rel.reason).toBe('trait-impl'); + }); + + it('no EXTENDS edges (Rust has no class inheritance)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Struct literal inference: let user = User { ... }; user.save() +// --------------------------------------------------------------------------- + +describe('Rust struct literal type inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-struct-literal-inference'), + () => {}, + ); + }, 60000); + + it('resolves user.save() via struct literal inference (User { ... })', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'models.rs'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + }); + + it('resolves config.validate() via struct literal inference (Config { ... })', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => c.target === 'validate' && c.targetFilePath === 'models.rs'); + expect(validateCall).toBeDefined(); + expect(validateCall!.source).toBe('main'); + }); +}); + +// --------------------------------------------------------------------------- +// Rust Self {} struct literal: Self resolves to enclosing impl type +// --------------------------------------------------------------------------- + +describe('Rust Self {} struct literal resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-self-struct-literal'), + () => {}, + ); + }, 60000); + + it('resolves fresh.validate() inside impl User via Self {} inference', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => c.target === 'validate' && c.source === 'blank'); + expect(validateCall).toBeDefined(); + expect(validateCall!.targetFilePath).toBe('models.rs'); + }); +}); + +// --------------------------------------------------------------------------- +// if let / while let: captured_pattern type extraction +// Extracts type from `user @ User { .. }` patterns in if-let/while-let +// --------------------------------------------------------------------------- + +describe('Rust if-let captured_pattern type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-if-let'), + () => {}, + ); + }, 60000); + + it('detects User and Config structs with their methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Struct')).toContain('Config'); + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('validate'); + }); + + it('resolves user.save() inside if-let via captured_pattern binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process_if_let'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('models.rs'); + }); + + it('resolves cfg.validate() inside while-let via captured_pattern binding', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find(c => c.target === 'validate' && c.source === 'process_while_let'); + expect(validateCall).toBeDefined(); + expect(validateCall!.targetFilePath).toBe('models.rs'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts new file mode 100644 index 0000000000..cca1a3459e --- /dev/null +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -0,0 +1,147 @@ +/** + * Swift: constructor-inferred type resolution for member calls. + * Verifies that `let user = User(name: "alice"); user.save()` resolves to User.save + * without explicit type annotations, using SymbolTable verification. + * + * NOTE: tree-sitter-swift has build issues on Node 22 — these tests skip gracefully + * when the Swift parser is not available. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { + FIXTURES, getRelationships, getNodesByLabel, + runPipelineFromRepo, type PipelineResult, +} from './helpers.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +describe.skipIf(!swiftAvailable)('Swift constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves user.save() to Models/User.swift via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'Models/User.swift'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to Models/Repo.swift via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'Models/Repo.swift'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// self.save() resolves to enclosing class's own save method +// Known issue: tree-sitter-swift has build issues on Node 22 +// --------------------------------------------------------------------------- + +describe.skip('Swift self resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save function', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveFns = getNodesByLabel(result, 'Function').filter(m => m === 'save'); + expect(saveFns.length).toBe(2); + }); + + it('resolves self.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('Sources/Models/User.swift'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + protocol conformance +// Known issue: tree-sitter-swift has build issues on Node 22 +// --------------------------------------------------------------------------- + +describe.skip('Swift parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable protocol', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const extendsEdge = extends_.find(e => e.source === 'User' && e.target === 'BaseModel'); + expect(extendsEdge).toBeDefined(); + }); + + it('emits IMPLEMENTS edge: User → Serializable (protocol conformance)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const implEdge = implements_.find(e => e.source === 'User' && e.target === 'Serializable'); + expect(implEdge).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Swift cross-file User.init() type inference +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift cross-file User.init() inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-init-cross-file'), + () => {}, + ); + }, 60000); + + it('resolves user.save() via User.init(name:) inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.targetFilePath === 'User.swift'); + expect(saveCall).toBeDefined(); + expect(saveCall!.source).toBe('main'); + }); + + it('resolves user.greet() via User.init(name:) inference', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'greet' && c.targetFilePath === 'User.swift'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('main'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 2e85eca82f..7d14488db3 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -506,3 +506,349 @@ describe('TypeScript variadic call resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Constructor-inferred type resolution: const user = new User(); user.save() +// Cross-file SymbolTable verification (no explicit type annotations) +// --------------------------------------------------------------------------- + +describe('TypeScript constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to src/user.ts via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.ts'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to src/repo.ts via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.ts'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript constructor-inferred type resolution: const user = new User() +// --------------------------------------------------------------------------- + +describe('JavaScript constructor-inferred type resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-constructor-type-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to src/user.js via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.js'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves repo.save() to src/repo.js via constructor-inferred type', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.js'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits exactly 2 save() CALLS edges (one per receiver type)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter(c => c.target === 'save'); + expect(saveCalls.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// this.save() resolves to enclosing class's own save method +// --------------------------------------------------------------------------- + +describe('TypeScript this resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-self-this-resolution'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves this.save() inside User.process to User.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find(c => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + expect(saveCall!.targetFilePath).toBe('src/models/User.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// Parent class resolution: EXTENDS + IMPLEMENTS edges +// --------------------------------------------------------------------------- + +describe('TypeScript parent resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel and User classes plus Serializable interface', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); + expect(getNodesByLabel(result, 'Interface')).toEqual(['Serializable']); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('emits IMPLEMENTS edge: User → Serializable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(1); + expect(implements_[0].source).toBe('User'); + expect(implements_[0].target).toBe('Serializable'); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of [...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS')]) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to parent class's save method +// --------------------------------------------------------------------------- + +describe('TypeScript super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-super-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(3); + }); + + it('emits EXTENDS edge: User → BaseModel', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'src/models/Base.ts'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/models/Repo.ts'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// super.save() resolves to generic parent class's save method +// --------------------------------------------------------------------------- + +describe('TypeScript generic parent super resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-generic-parent-resolution'), + () => {}, + ); + }, 60000); + + it('detects BaseModel, User, and Repo classes, each with a save method', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(3); + }); + + it('emits EXTENDS edge: User → BaseModel (not BaseModel)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_.length).toBe(1); + expect(extends_[0].source).toBe('User'); + expect(extends_[0].target).toBe('BaseModel'); + }); + + it('resolves super.save() inside User to BaseModel.save, not Repo.save', () => { + const calls = getRelationships(result, 'CALLS'); + const superSave = calls.find(c => c.source === 'save' && c.target === 'save' + && c.targetFilePath === 'src/models/Base.ts'); + expect(superSave).toBeDefined(); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/models/Repo.ts'); + expect(repoSave).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Cast/non-null constructor inference: new X() as T, new X()! +// --------------------------------------------------------------------------- + +describe('TypeScript cast/non-null constructor inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-cast-constructor-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to User.save via new User() as any', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.ts'); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() to Repo.save via new Repo()!', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.ts'); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Double-cast constructor inference: new X() as unknown as T +// --------------------------------------------------------------------------- + +describe('TypeScript double-cast constructor inference', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-double-cast-inference'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes, both with save methods', () => { + expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); + const saveMethods = getNodesByLabel(result, 'Method').filter(m => m === 'save'); + expect(saveMethods.length).toBe(2); + }); + + it('resolves user.save() to User.save via new User() as unknown as any', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.ts'); + expect(userSave).toBeDefined(); + }); + + it('resolves repo.save() to Repo.save via new Repo() as unknown as object', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.ts'); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Nullable/optional receiver unwrapping: user?.save() resolves through ?. +// --------------------------------------------------------------------------- + +describe('TypeScript nullable receiver resolution (optional chaining)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ts-nullable-receiver'), + () => {}, + ); + }, 60000); + + it('detects User and Repo classes with their methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Repo'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('greet'); + }); + + it('resolves user?.save() to User.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/user.ts'); + expect(userSave).toBeDefined(); + expect(userSave!.source).toBe('processEntities'); + }); + + it('resolves user?.greet() to User.greet via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find(c => c.target === 'greet' && c.targetFilePath === 'src/user.ts'); + expect(greetCall).toBeDefined(); + expect(greetCall!.source).toBe('processEntities'); + }); + + it('resolves repo?.save() to Repo.save via receiver typing', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find(c => c.target === 'save' && c.targetFilePath === 'src/repo.ts'); + expect(repoSave).toBeDefined(); + expect(repoSave!.source).toBe('processEntities'); + }); + + it('emits constructor CALLS edges for both User and Repo', () => { + const calls = getRelationships(result, 'CALLS'); + const userCtor = calls.find(c => c.target === 'User' && c.targetLabel === 'Class'); + const repoCtor = calls.find(c => c.target === 'Repo' && c.targetLabel === 'Class'); + expect(userCtor).toBeDefined(); + expect(repoCtor).toBeDefined(); + }); +}); + diff --git a/gitnexus/test/unit/call-form.test.ts b/gitnexus/test/unit/call-form.test.ts index 37d59530bb..7467c1d7c8 100644 --- a/gitnexus/test/unit/call-form.test.ts +++ b/gitnexus/test/unit/call-form.test.ts @@ -307,6 +307,23 @@ describe('extractReceiverName', () => { expect(match).toBeDefined(); expect(extractReceiverName(match!.nameNode)).toBeUndefined(); }); + + it('extracts receiver from optional chain call user?.save()', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'user?.save()', SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); + + it('extracts "this" from optional chain call this?.save()', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Foo { run() { this?.save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.TypeScript); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('this'); + }); }); describe('Python', () => { @@ -369,6 +386,16 @@ describe('extractReceiverName', () => { expect(match).toBeDefined(); expect(extractReceiverName(match!.nameNode)).toBe('user'); }); + + it('does not capture null-conditional user?.Save() with current queries', () => { + parser.setLanguage(CSharp); + const code = `class Foo { void Run() { user?.Save(); } }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp); + const match = captures.find(c => c.calledName === 'Save'); + // C# conditional_access_expression uses member_binding_expression, not member_access_expression + // The tree-sitter query doesn't match — this documents the gap for future work + expect(match).toBeUndefined(); + }); }); describe('Kotlin', () => { @@ -380,6 +407,15 @@ describe('extractReceiverName', () => { expect(match).toBeDefined(); expect(extractReceiverName(match!.nameNode)).toBe('user'); }); + + it('extracts receiver from safe navigation user?.save()', () => { + parser.setLanguage(Kotlin); + const code = `fun main() { user?.save() }`; + const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin); + const match = captures.find(c => c.calledName === 'save'); + expect(match).toBeDefined(); + expect(extractReceiverName(match!.nameNode)).toBe('user'); + }); }); }); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 5d5cf17512..db11d471c1 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -1,23 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; -import { createImportMap, type ImportMap } from '../../src/core/ingestion/import-processor.js'; +import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js'; +import type { ExtractedCall, FileConstructorBindings } from '../../src/core/ingestion/workers/parse-worker.js'; describe('processCallsFromExtracted', () => { let graph: ReturnType; - let symbolTable: ReturnType; - let importMap: ImportMap; + let ctx: ResolutionContext; beforeEach(() => { graph = createKnowledgeGraph(); - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); it('creates CALLS relationship for same-file resolution', async () => { - symbolTable.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); + ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -25,7 +22,7 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -36,8 +33,8 @@ describe('processCallsFromExtracted', () => { }); it('creates CALLS relationship for import-resolved resolution', async () => { - symbolTable.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); - importMap.set('src/index.ts', new Set(['src/utils.ts'])); + ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function'); + ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -45,7 +42,7 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -54,7 +51,7 @@ describe('processCallsFromExtracted', () => { }); it('resolves unique global symbol with moderate confidence', async () => { - symbolTable.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); + ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -62,17 +59,17 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].confidence).toBe(0.5); - expect(rels[0].reason).toBe('unique-global'); + expect(rels[0].reason).toBe('global'); }); it('refuses ambiguous global symbols — no CALLS edge created', async () => { - symbolTable.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); - symbolTable.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); + ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function'); + ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -80,9 +77,8 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); - // Ambiguous matches are refused — a wrong edge is worse than no edge const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); @@ -94,12 +90,12 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationshipCount).toBe(0); }); it('refuses non-callable symbols even when the name resolves', async () => { - symbolTable.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); + ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class'); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -107,13 +103,13 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationshipCount).toBe(0); }); it('refuses CALLS edges to Interface symbols', async () => { - symbolTable.add('src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', 'Interface'); - importMap.set('src/index.ts', new Set(['src/types.ts'])); + ctx.symbols.add('src/types.ts', 'Serializable', 'Interface:src/types.ts:Serializable', 'Interface'); + ctx.importMap.set('src/index.ts', new Set(['src/types.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -121,13 +117,13 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('refuses CALLS edges to Enum symbols', async () => { - symbolTable.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); - importMap.set('src/index.ts', new Set(['src/status.ts'])); + ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum'); + ctx.importMap.set('src/index.ts', new Set(['src/status.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -135,15 +131,14 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('prefers same-file over import-resolved', async () => { - // Symbol exists both locally and in imported file - symbolTable.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); - symbolTable.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); - importMap.set('src/index.ts', new Set(['src/utils.ts'])); + ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function'); + ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function'); + ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -151,32 +146,31 @@ describe('processCallsFromExtracted', () => { sourceId: 'Function:src/index.ts:main', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); - // Same-file resolution takes priority expect(rels[0].targetId).toBe('Function:src/index.ts:render'); expect(rels[0].reason).toBe('same-file'); }); it('handles multiple calls from the same file', async () => { - symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); - symbolTable.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); + ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, { filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' }, ]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2); }); it('uses arity to disambiguate import-scoped callable candidates', async () => { - symbolTable.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0 }); - symbolTable.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); - importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); + ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 0 }); + ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); + ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -185,7 +179,7 @@ describe('processCallsFromExtracted', () => { argCount: 1, }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -194,9 +188,9 @@ describe('processCallsFromExtracted', () => { }); it('refuses ambiguous call targets when arity does not produce a unique match', async () => { - symbolTable.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1 }); - symbolTable.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); - importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); + ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', { parameterCount: 1 }); + ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', { parameterCount: 1 }); + ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -205,34 +199,33 @@ describe('processCallsFromExtracted', () => { argCount: 1, }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(0); }); it('calls progress callback', async () => { - symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); + ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function'); const calls: ExtractedCall[] = [ { filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' }, ]; const onProgress = vi.fn(); - await processCallsFromExtracted(graph, calls, symbolTable, importMap, undefined, onProgress); + await processCallsFromExtracted(graph, calls, ctx, onProgress); - // Final progress call expect(onProgress).toHaveBeenCalledWith(1, 1); }); it('handles empty calls array', async () => { - await processCallsFromExtracted(graph, [], symbolTable, importMap); + await processCallsFromExtracted(graph, [], ctx); expect(graph.relationshipCount).toBe(0); }); // ---- Constructor-aware resolution (Phase 2) ---- it('resolves constructor call to Class when no Constructor node exists', async () => { - symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - importMap.set('src/index.ts', new Set(['src/models.ts'])); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -241,7 +234,7 @@ describe('processCallsFromExtracted', () => { callForm: 'constructor', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -250,9 +243,9 @@ describe('processCallsFromExtracted', () => { }); it('resolves constructor call to Constructor node over Class node', async () => { - symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { parameterCount: 1 }); - importMap.set('src/index.ts', new Set(['src/models.ts'])); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', { parameterCount: 1 }); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -262,7 +255,7 @@ describe('processCallsFromExtracted', () => { callForm: 'constructor', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -270,27 +263,24 @@ describe('processCallsFromExtracted', () => { }); it('refuses Class target without callForm=constructor (existing behavior)', async () => { - symbolTable.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); - importMap.set('src/index.ts', new Set(['src/models.ts'])); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', calledName: 'User', sourceId: 'Function:src/index.ts:main', - // no callForm — treated as regular call }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); - // Without constructor callForm, Class is not in CALLABLE_SYMBOL_TYPES → refused const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(0); }); it('constructor call falls back to callable types when no Constructor/Class found', async () => { - // Edge case: calledName matches a Function, not a Class/Constructor - symbolTable.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); - importMap.set('src/index.ts', new Set(['src/utils.ts'])); + ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function'); + ctx.importMap.set('src/index.ts', new Set(['src/utils.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -299,18 +289,17 @@ describe('processCallsFromExtracted', () => { callForm: 'constructor', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); - // Falls back to callable filtering — Function is callable const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); expect(rels[0].targetId).toBe('Function:src/utils.ts:Widget'); }); it('constructor arity filtering narrows overloaded constructors', async () => { - symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { parameterCount: 0 }); - symbolTable.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { parameterCount: 2 }); - importMap.set('src/index.ts', new Set(['src/models.ts'])); + ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', { parameterCount: 0 }); + ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', { parameterCount: 2 }); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -320,7 +309,7 @@ describe('processCallsFromExtracted', () => { callForm: 'constructor', }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); expect(rels).toHaveLength(1); @@ -328,12 +317,9 @@ describe('processCallsFromExtracted', () => { }); it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => { - // Java: save(User u) vs save(Repo r) — both have parameterCount: 1 - // The system counts arguments, not their types, so both candidates match equally. - // With parameter type capture, receiver-typed calls could be discriminated. - symbolTable.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1 }); - symbolTable.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1 }); - importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); + ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', { parameterCount: 1 }); + ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', { parameterCount: 1 }); + ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts'])); const calls: ExtractedCall[] = [{ filePath: 'src/index.ts', @@ -342,11 +328,52 @@ describe('processCallsFromExtracted', () => { argCount: 1, }]; - await processCallsFromExtracted(graph, calls, symbolTable, importMap); + await processCallsFromExtracted(graph, calls, ctx); const rels = graph.relationships.filter(r => r.type === 'CALLS'); - - // Both candidates match (same name, same arity) — ambiguous → no edge emitted - // Discriminating by parameter type would require capturing type annotations at call sites expect(rels).toHaveLength(0); }); + + // ---- Scope-aware constructor bindings (Phase 3) ---- + + it('scope-aware bindings: same varName in different functions resolves to correct type', async () => { + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function'); + ctx.importMap.set('src/index.ts', new Set(['src/models.ts'])); + + const constructorBindings: FileConstructorBindings[] = [{ + filePath: 'src/index.ts', + bindings: [ + { scope: 'processUser@12', varName: 'obj', calleeName: 'User' }, + { scope: 'processRepo@89', varName: 'obj', calleeName: 'Repo' }, + ], + }]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:processUser', + receiverName: 'obj', + callForm: 'member', + }, + { + filePath: 'src/index.ts', + calledName: 'save', + sourceId: 'Function:src/index.ts:processRepo', + receiverName: 'obj', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings); + + const rels = graph.relationships.filter(r => r.type === 'CALLS'); + expect(rels).toHaveLength(2); + // Both calls should resolve, each with the correct receiver type from their scope + // (the important thing is they don't collide — without scope awareness, + // last-write-wins would give both calls the same receiver type) + expect(rels[0].sourceId).toBe('Function:src/index.ts:processUser'); + expect(rels[1].sourceId).toBe('Function:src/index.ts:processRepo'); + }); }); diff --git a/gitnexus/test/unit/heritage-processor.test.ts b/gitnexus/test/unit/heritage-processor.test.ts index 20af0c4959..8a2a4f56d6 100644 --- a/gitnexus/test/unit/heritage-processor.test.ts +++ b/gitnexus/test/unit/heritage-processor.test.ts @@ -1,26 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processHeritageFromExtracted } from '../../src/core/ingestion/heritage-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; -import { createImportMap } from '../../src/core/ingestion/import-processor.js'; -import type { ImportMap } from '../../src/core/ingestion/import-processor.js'; +import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js'; describe('processHeritageFromExtracted', () => { let graph: ReturnType; - let symbolTable: ReturnType; - let importMap: ImportMap; + let ctx: ResolutionContext; beforeEach(() => { graph = createKnowledgeGraph(); - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); describe('extends', () => { it('creates EXTENDS relationship between classes', async () => { - symbolTable.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); - symbolTable.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); + ctx.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class'); + ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/admin.ts', @@ -29,7 +25,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const rels = graph.relationships.filter(r => r.type === 'EXTENDS'); expect(rels).toHaveLength(1); @@ -46,7 +42,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const rels = graph.relationships.filter(r => r.type === 'EXTENDS'); expect(rels).toHaveLength(1); @@ -55,7 +51,7 @@ describe('processHeritageFromExtracted', () => { }); it('skips self-inheritance', async () => { - symbolTable.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/a.ts', @@ -64,15 +60,15 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); expect(graph.relationshipCount).toBe(0); }); }); describe('implements', () => { it('creates IMPLEMENTS relationship', async () => { - symbolTable.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); - symbolTable.add('src/interfaces.ts', 'IService', 'Interface:src/interfaces.ts:IService', 'Interface'); + ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class'); + ctx.symbols.add('src/interfaces.ts', 'IService', 'Interface:src/interfaces.ts:IService', 'Interface'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/service.ts', @@ -81,7 +77,7 @@ describe('processHeritageFromExtracted', () => { kind: 'implements', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const rels = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); expect(rels).toHaveLength(1); @@ -91,8 +87,8 @@ describe('processHeritageFromExtracted', () => { describe('trait-impl (Rust)', () => { it('creates IMPLEMENTS relationship for trait impl', async () => { - symbolTable.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); - symbolTable.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); + ctx.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct'); + ctx.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/point.rs', @@ -101,7 +97,7 @@ describe('processHeritageFromExtracted', () => { kind: 'trait-impl', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const rels = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); expect(rels).toHaveLength(1); @@ -111,8 +107,8 @@ describe('processHeritageFromExtracted', () => { describe('C# interface resolution from extends captures', () => { it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => { - symbolTable.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); - symbolTable.add('src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', 'Interface'); + ctx.symbols.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class'); + ctx.symbols.add('src/IService.cs', 'IService', 'Interface:src/IService.cs:IService', 'Interface'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/Service.cs', @@ -121,7 +117,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', // C# base_list always sends extends }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); @@ -132,8 +128,8 @@ describe('processHeritageFromExtracted', () => { }); it('emits EXTENDS when parent is a Class in symbol table', async () => { - symbolTable.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); - symbolTable.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); + ctx.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class'); + ctx.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/Admin.cs', @@ -142,7 +138,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); @@ -159,7 +155,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); expect(impls).toHaveLength(1); @@ -175,7 +171,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); @@ -191,7 +187,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); @@ -207,7 +203,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); // "Id" starts with I but second char is lowercase — should be EXTENDS const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); @@ -215,10 +211,10 @@ describe('processHeritageFromExtracted', () => { }); it('handles mixed class + interface base_list from C#', async () => { - symbolTable.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); - symbolTable.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); - symbolTable.add('src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface'); - symbolTable.add('src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', 'Interface'); + ctx.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class'); + ctx.symbols.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class'); + ctx.symbols.add('src/IRepo.cs', 'IRepository', 'Interface:src/IRepo.cs:IRepository', 'Interface'); + ctx.symbols.add('src/IDisp.cs', 'IDisposable', 'Interface:src/IDisp.cs:IDisposable', 'Interface'); const heritage: ExtractedHeritage[] = [ { filePath: 'src/Repo.cs', className: 'UserRepo', parentName: 'BaseRepository', kind: 'extends' }, @@ -226,7 +222,7 @@ describe('processHeritageFromExtracted', () => { { filePath: 'src/Repo.cs', className: 'UserRepo', parentName: 'IDisposable', kind: 'extends' }, ]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); @@ -245,7 +241,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); @@ -256,7 +252,7 @@ describe('processHeritageFromExtracted', () => { it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => { // When the parent is in the symbol table as a Class, EXTENDS wins even in Swift - symbolTable.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); + ctx.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class'); const heritage: ExtractedHeritage[] = [{ filePath: 'src/Dog.swift', @@ -265,7 +261,7 @@ describe('processHeritageFromExtracted', () => { kind: 'extends', }]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); const exts = graph.relationships.filter(r => r.type === 'EXTENDS'); const impls = graph.relationships.filter(r => r.type === 'IMPLEMENTS'); @@ -281,7 +277,7 @@ describe('processHeritageFromExtracted', () => { { filePath: 'src/e.rs', className: 'E', parentName: 'F', kind: 'trait-impl' }, ]; - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap); + await processHeritageFromExtracted(graph, heritage, ctx); expect(graph.relationships.filter(r => r.type === 'EXTENDS')).toHaveLength(1); expect(graph.relationships.filter(r => r.type === 'IMPLEMENTS')).toHaveLength(2); }); @@ -292,12 +288,12 @@ describe('processHeritageFromExtracted', () => { ]; const onProgress = vi.fn(); - await processHeritageFromExtracted(graph, heritage, symbolTable, importMap, undefined, onProgress); + await processHeritageFromExtracted(graph, heritage, ctx, onProgress); expect(onProgress).toHaveBeenCalledWith(1, 1); }); it('handles empty heritage array', async () => { - await processHeritageFromExtracted(graph, [], symbolTable, importMap); + await processHeritageFromExtracted(graph, [], ctx); expect(graph.relationshipCount).toBe(0); }); }); diff --git a/gitnexus/test/unit/import-processor.test.ts b/gitnexus/test/unit/import-processor.test.ts index dd19f684e3..e41a516889 100644 --- a/gitnexus/test/unit/import-processor.test.ts +++ b/gitnexus/test/unit/import-processor.test.ts @@ -1,15 +1,16 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { createImportMap, buildImportResolutionContext, type ImportMap, type ImportResolutionContext } from '../../src/core/ingestion/import-processor.js'; +import { buildImportResolutionContext, type ImportResolutionContext } from '../../src/core/ingestion/import-processor.js'; +import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; -describe('createImportMap', () => { +describe('ResolutionContext.importMap', () => { it('creates an empty Map', () => { - const map = createImportMap(); + const map = createResolutionContext().importMap; expect(map).toBeInstanceOf(Map); expect(map.size).toBe(0); }); it('can be used to store import relationships', () => { - const map = createImportMap(); + const map = createResolutionContext().importMap; map.set('src/index.ts', new Set(['src/utils.ts', 'src/types.ts'])); expect(map.get('src/index.ts')!.size).toBe(2); expect(map.get('src/index.ts')!.has('src/utils.ts')).toBe(true); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 8a0d855532..0ab01c3f07 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -11,10 +11,10 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({ import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { createASTCache } from '../../src/core/ingestion/ast-cache.js'; -import { createImportMap, processImports } from '../../src/core/ingestion/import-processor.js'; +import { processImports } from '../../src/core/ingestion/import-processor.js'; import { processCalls } from '../../src/core/ingestion/call-processor.js'; import { processHeritage } from '../../src/core/ingestion/heritage-processor.js'; -import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; +import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js'; @@ -30,7 +30,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'import Foundation' }], createASTCache(), - createImportMap(), + createResolutionContext(), undefined, '/tmp/repo', ['App.swift'], @@ -49,7 +49,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'import Foundation' }], createASTCache(), - createImportMap(), + createResolutionContext(), undefined, '/tmp/repo', ['App.swift'], @@ -74,8 +74,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'func demo() {}' }], createASTCache(), - createSymbolTable(), - createImportMap(), + createResolutionContext(), )).resolves.toEqual([]); expect(parserLoader.loadLanguage).not.toHaveBeenCalled(); @@ -91,8 +90,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'func demo() {}' }], createASTCache(), - createSymbolTable(), - createImportMap(), + createResolutionContext(), ); expect(warnSpy).toHaveBeenCalledWith( @@ -114,7 +112,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }], createASTCache(), - createSymbolTable(), + createResolutionContext(), )).resolves.toBeUndefined(); expect(parserLoader.loadLanguage).not.toHaveBeenCalled(); @@ -130,7 +128,7 @@ describe('sequential native parser availability', () => { createKnowledgeGraph(), [{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }], createASTCache(), - createSymbolTable(), + createResolutionContext(), ); expect(warnSpy).toHaveBeenCalledWith( diff --git a/gitnexus/test/unit/symbol-resolver.test.ts b/gitnexus/test/unit/symbol-resolver.test.ts index a9707de096..377349a202 100644 --- a/gitnexus/test/unit/symbol-resolver.test.ts +++ b/gitnexus/test/unit/symbol-resolver.test.ts @@ -1,23 +1,36 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { resolveSymbol, resolveSymbolInternal } from '../../src/core/ingestion/symbol-resolver.js'; +import { createResolutionContext, type ResolutionContext } from '../../src/core/ingestion/resolution-context.js'; import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js'; -import { createImportMap, createPackageMap, isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; -import type { ImportMap, PackageMap } from '../../src/core/ingestion/import-processor.js'; +import { isFileInPackageDir } from '../../src/core/ingestion/import-processor.js'; -describe('resolveSymbol', () => { - let symbolTable: ReturnType; - let importMap: ImportMap; +/** Helper: resolve to single best definition (refuses ambiguous global) */ +const resolveOne = (ctx: ResolutionContext, name: string, fromFile: string) => { + const tiered = ctx.resolve(name, fromFile); + if (!tiered) return null; + if (tiered.tier === 'global' && tiered.candidates.length !== 1) return null; + return tiered.candidates[0]; +}; + +/** Helper: resolve with tier metadata (refuses ambiguous global) */ +const resolveInternal = (ctx: ResolutionContext, name: string, fromFile: string) => { + const tiered = ctx.resolve(name, fromFile); + if (!tiered) return null; + if (tiered.tier === 'global' && tiered.candidates.length !== 1) return null; + return { definition: tiered.candidates[0], tier: tiered.tier, candidateCount: tiered.candidates.length }; +}; + +describe('ResolutionContext.resolve — resolveSymbol compatibility', () => { + let ctx: ResolutionContext; beforeEach(() => { - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); describe('Tier 1: Same-file resolution', () => { it('resolves symbol defined in the same file', () => { - symbolTable.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); + ctx.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class'); - const result = resolveSymbol('User', 'src/models/user.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'User', 'src/models/user.ts'); expect(result).not.toBeNull(); expect(result!.nodeId).toBe('Class:src/models/user.ts:User'); @@ -26,11 +39,11 @@ describe('resolveSymbol', () => { }); it('prefers same-file over imported definition', () => { - symbolTable.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); - symbolTable.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); - importMap.set('src/local.ts', new Set(['src/shared.ts'])); + ctx.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class'); + ctx.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class'); + ctx.importMap.set('src/local.ts', new Set(['src/shared.ts'])); - const result = resolveSymbol('Config', 'src/local.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Config', 'src/local.ts'); expect(result!.nodeId).toBe('Class:src/local.ts:Config'); expect(result!.filePath).toBe('src/local.ts'); @@ -39,10 +52,10 @@ describe('resolveSymbol', () => { describe('Tier 2: Import-scoped resolution', () => { it('resolves symbol from an imported file', () => { - symbolTable.add('src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', 'Class'); - importMap.set('src/controllers/login.ts', new Set(['src/services/auth.ts'])); + ctx.symbols.add('src/services/auth.ts', 'AuthService', 'Class:src/services/auth.ts:AuthService', 'Class'); + ctx.importMap.set('src/controllers/login.ts', new Set(['src/services/auth.ts'])); - const result = resolveSymbol('AuthService', 'src/controllers/login.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'AuthService', 'src/controllers/login.ts'); expect(result).not.toBeNull(); expect(result!.nodeId).toBe('Class:src/services/auth.ts:AuthService'); @@ -50,76 +63,85 @@ describe('resolveSymbol', () => { }); it('prefers imported definition over non-imported with same name', () => { - symbolTable.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); - symbolTable.add('src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', 'Class'); - importMap.set('src/app.ts', new Set(['src/services/logger.ts'])); + ctx.symbols.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); + ctx.symbols.add('src/testing/mock-logger.ts', 'Logger', 'Class:src/testing/mock-logger.ts:Logger', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/services/logger.ts'])); - const result = resolveSymbol('Logger', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Logger', 'src/app.ts'); expect(result!.nodeId).toBe('Class:src/services/logger.ts:Logger'); expect(result!.filePath).toBe('src/services/logger.ts'); }); it('handles file with no imports — unique global falls through', () => { - symbolTable.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); + ctx.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class'); - const result = resolveSymbol('Helper', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Helper', 'src/app.ts'); - // Falls through to Tier 3 (unique global) expect(result).not.toBeNull(); expect(result!.nodeId).toBe('Class:src/utils.ts:Helper'); }); }); - describe('Tier 3: Unique global resolution', () => { + describe('Tier 3: Global resolution', () => { it('resolves unique global when not in imports', () => { - symbolTable.add('src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', 'Class'); - importMap.set('src/app.ts', new Set(['src/other.ts'])); + ctx.symbols.add('src/external/base.ts', 'BaseModel', 'Class:src/external/base.ts:BaseModel', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/other.ts'])); - const result = resolveSymbol('BaseModel', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'BaseModel', 'src/app.ts'); expect(result).not.toBeNull(); expect(result!.nodeId).toBe('Class:src/external/base.ts:BaseModel'); }); it('refuses ambiguous global — returns null when multiple candidates exist', () => { - symbolTable.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - symbolTable.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); - const result = resolveSymbol('Config', 'src/other.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Config', 'src/other.ts'); - // A wrong edge is worse than no edge expect(result).toBeNull(); }); + + it('ctx.resolve returns all candidates at global tier (consumers decide)', () => { + ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + + const tiered = ctx.resolve('Config', 'src/other.ts'); + + expect(tiered).not.toBeNull(); + expect(tiered!.tier).toBe('global'); + expect(tiered!.candidates.length).toBe(2); + }); }); describe('null cases', () => { it('returns null for unknown symbol', () => { - const result = resolveSymbol('NonExistent', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'NonExistent', 'src/app.ts'); expect(result).toBeNull(); }); it('returns null when symbol table is empty', () => { - const result = resolveSymbol('Anything', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Anything', 'src/app.ts'); expect(result).toBeNull(); }); }); describe('type preservation', () => { it('preserves Interface type for heritage resolution', () => { - symbolTable.add('src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', 'Interface'); - importMap.set('src/app.ts', new Set(['src/interfaces.ts'])); + ctx.symbols.add('src/interfaces.ts', 'ILogger', 'Interface:src/interfaces.ts:ILogger', 'Interface'); + ctx.importMap.set('src/app.ts', new Set(['src/interfaces.ts'])); - const result = resolveSymbol('ILogger', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'ILogger', 'src/app.ts'); expect(result!.type).toBe('Interface'); }); it('preserves Class type for heritage resolution', () => { - symbolTable.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); - importMap.set('src/app.ts', new Set(['src/base.ts'])); + ctx.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/base.ts'])); - const result = resolveSymbol('BaseService', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'BaseService', 'src/app.ts'); expect(result!.type).toBe('Class'); }); @@ -127,42 +149,39 @@ describe('resolveSymbol', () => { describe('heritage-specific scenarios', () => { it('resolves C# interface vs class ambiguity via imports', () => { - // ILogger exists as Interface in one file and Class in another - symbolTable.add('src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface'); - symbolTable.add('src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', 'Class'); - importMap.set('src/services/auth.cs', new Set(['src/logging/ilogger.cs'])); + ctx.symbols.add('src/logging/ilogger.cs', 'ILogger', 'Interface:src/logging/ilogger.cs:ILogger', 'Interface'); + ctx.symbols.add('src/testing/ilogger.cs', 'ILogger', 'Class:src/testing/ilogger.cs:ILogger', 'Class'); + ctx.importMap.set('src/services/auth.cs', new Set(['src/logging/ilogger.cs'])); - const result = resolveSymbol('ILogger', 'src/services/auth.cs', symbolTable, importMap); + const result = resolveOne(ctx, 'ILogger', 'src/services/auth.cs'); expect(result!.type).toBe('Interface'); expect(result!.filePath).toBe('src/logging/ilogger.cs'); }); it('resolves parent class from imported file for extends', () => { - symbolTable.add('src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class'); - symbolTable.add('src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', 'Class'); - importMap.set('src/api/controller.ts', new Set(['src/base/controller.ts'])); + ctx.symbols.add('src/api/controller.ts', 'UserController', 'Class:src/api/controller.ts:UserController', 'Class'); + ctx.symbols.add('src/base/controller.ts', 'BaseController', 'Class:src/base/controller.ts:BaseController', 'Class'); + ctx.importMap.set('src/api/controller.ts', new Set(['src/base/controller.ts'])); - const result = resolveSymbol('BaseController', 'src/api/controller.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'BaseController', 'src/api/controller.ts'); expect(result!.nodeId).toBe('Class:src/base/controller.ts:BaseController'); }); }); }); -describe('resolveSymbolInternal — tier metadata', () => { - let symbolTable: ReturnType; - let importMap: ImportMap; +describe('ResolutionContext.resolve — tier metadata', () => { + let ctx: ResolutionContext; beforeEach(() => { - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); it('returns same-file tier for Tier 1 match', () => { - symbolTable.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); - const result = resolveSymbolInternal('Foo', 'src/a.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Foo', 'src/a.ts'); expect(result).not.toBeNull(); expect(result!.tier).toBe('same-file'); @@ -171,48 +190,46 @@ describe('resolveSymbolInternal — tier metadata', () => { }); it('returns import-scoped tier for Tier 2 match', () => { - symbolTable.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); - symbolTable.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); - importMap.set('src/app.ts', new Set(['src/logger.ts'])); + ctx.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class'); + ctx.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class'); + ctx.importMap.set('src/app.ts', new Set(['src/logger.ts'])); - const result = resolveSymbolInternal('Logger', 'src/app.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Logger', 'src/app.ts'); expect(result).not.toBeNull(); expect(result!.tier).toBe('import-scoped'); - expect(result!.candidateCount).toBe(2); - expect(result!.definition.filePath).toBe('src/logger.ts'); }); - it('returns unique-global tier for Tier 3 match', () => { - symbolTable.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); + it('returns global tier for Tier 3 match', () => { + ctx.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class'); - const result = resolveSymbolInternal('Singleton', 'src/other.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Singleton', 'src/other.ts'); expect(result).not.toBeNull(); - expect(result!.tier).toBe('unique-global'); + expect(result!.tier).toBe('global'); expect(result!.candidateCount).toBe(1); }); it('returns null for ambiguous global — refuses to guess', () => { - symbolTable.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); - symbolTable.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); + ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class'); + ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class'); - const result = resolveSymbolInternal('Config', 'src/other.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Config', 'src/other.ts'); expect(result).toBeNull(); }); it('returns null for unknown symbol', () => { - const result = resolveSymbolInternal('Ghost', 'src/any.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Ghost', 'src/any.ts'); expect(result).toBeNull(); }); it('Tier 1 wins over Tier 2 — same-file takes priority', () => { - symbolTable.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); - symbolTable.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); - importMap.set('src/app.ts', new Set(['src/lib.ts'])); + ctx.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function'); + ctx.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function'); + ctx.importMap.set('src/app.ts', new Set(['src/lib.ts'])); - const result = resolveSymbolInternal('Util', 'src/app.ts', symbolTable, importMap); + const result = resolveInternal(ctx, 'Util', 'src/app.ts'); expect(result!.tier).toBe('same-file'); expect(result!.definition.filePath).toBe('src/app.ts'); @@ -220,71 +237,62 @@ describe('resolveSymbolInternal — tier metadata', () => { }); describe('negative tests — ambiguous refusal per language family', () => { - let symbolTable: ReturnType; - let importMap: ImportMap; + let ctx: ResolutionContext; beforeEach(() => { - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); it('TS/JS: two Logger definitions with no import → returns null', () => { - symbolTable.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); - symbolTable.add('src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', 'Class'); + ctx.symbols.add('src/services/logger.ts', 'Logger', 'Class:src/services/logger.ts:Logger', 'Class'); + ctx.symbols.add('src/testing/logger.ts', 'Logger', 'Class:src/testing/logger.ts:Logger', 'Class'); - const result = resolveSymbol('Logger', 'src/app.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'Logger', 'src/app.ts'); expect(result).toBeNull(); }); it('Java: same-named class in different packages, no import → returns null', () => { - symbolTable.add('com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class'); - symbolTable.add('com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', 'Class'); + ctx.symbols.add('com/example/models/User.java', 'User', 'Class:com/example/models/User.java:User', 'Class'); + ctx.symbols.add('com/example/dto/User.java', 'User', 'Class:com/example/dto/User.java:User', 'Class'); - const result = resolveSymbol('User', 'com/example/services/UserService.java', symbolTable, importMap); + const result = resolveOne(ctx, 'User', 'com/example/services/UserService.java'); expect(result).toBeNull(); }); it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => { - // a.c includes b.h (direct), b.h includes c.h (transitive — not in ImportMap) - symbolTable.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); - symbolTable.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); - importMap.set('src/a.c', new Set(['src/b.h'])); + ctx.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct'); + ctx.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct'); + ctx.importMap.set('src/a.c', new Set(['src/b.h'])); - const result = resolveSymbol('Widget', 'src/a.c', symbolTable, importMap); - // Neither c.h nor d.h is directly imported → ambiguous global → null + const result = resolveOne(ctx, 'Widget', 'src/a.c'); expect(result).toBeNull(); }); it('C#: two IService interfaces in different namespaces, no import → returns null', () => { - symbolTable.add('src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface'); - symbolTable.add('src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', 'Interface'); + ctx.symbols.add('src/Services/IService.cs', 'IService', 'Interface:src/Services/IService.cs:IService', 'Interface'); + ctx.symbols.add('src/Testing/IService.cs', 'IService', 'Interface:src/Testing/IService.cs:IService', 'Interface'); - const result = resolveSymbol('IService', 'src/App.cs', symbolTable, importMap); + const result = resolveOne(ctx, 'IService', 'src/App.cs'); expect(result).toBeNull(); }); }); describe('heritage false-positive guard', () => { - let symbolTable: ReturnType; - let importMap: ImportMap; + let ctx: ResolutionContext; beforeEach(() => { - symbolTable = createSymbolTable(); - importMap = createImportMap(); + ctx = createResolutionContext(); }); - it('null from resolveSymbol prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { - // Two BaseController in different files — ambiguous - symbolTable.add('src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class'); - symbolTable.add('src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', 'Class'); + it('null from resolve prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => { + ctx.symbols.add('src/api/base.ts', 'BaseController', 'Class:src/api/base.ts:BaseController', 'Class'); + ctx.symbols.add('src/testing/base.ts', 'BaseController', 'Class:src/testing/base.ts:BaseController', 'Class'); - // resolveSymbol returns null — heritage-processor would use generateId fallback - const result = resolveSymbol('BaseController', 'src/routes/admin.ts', symbolTable, importMap); + const result = resolveOne(ctx, 'BaseController', 'src/routes/admin.ts'); expect(result).toBeNull(); - // Verify: with import, it resolves correctly to the right one - importMap.set('src/routes/admin.ts', new Set(['src/api/base.ts'])); - const resolved = resolveSymbol('BaseController', 'src/routes/admin.ts', symbolTable, importMap); + ctx.importMap.set('src/routes/admin.ts', new Set(['src/api/base.ts'])); + const resolved = resolveOne(ctx, 'BaseController', 'src/routes/admin.ts'); expect(resolved).not.toBeNull(); expect(resolved!.filePath).toBe('src/api/base.ts'); }); @@ -324,7 +332,6 @@ describe('lookupExactFull', () => { const fromExact = symbolTable.lookupExactFull('src/x.ts', 'Bar'); const fromFuzzy = symbolTable.lookupFuzzy('Bar')[0]; - // Same object reference — zero additional memory expect(fromExact).toBe(fromFuzzy); }); @@ -377,73 +384,113 @@ describe('isFileInPackageDir', () => { }); describe('Tier 2b: PackageMap resolution (Go)', () => { - let symbolTable: ReturnType; - let importMap: ImportMap; - let packageMap: PackageMap; + let ctx: ResolutionContext; beforeEach(() => { - symbolTable = createSymbolTable(); - importMap = createImportMap(); - packageMap = createPackageMap(); + ctx = createResolutionContext(); }); it('resolves symbol via PackageMap when not in ImportMap', () => { - symbolTable.add('internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', 'Function'); - // No ImportMap entry — but PackageMap has the package directory - packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + ctx.symbols.add('internal/auth/handler.go', 'HandleLogin', 'Function:internal/auth/handler.go:HandleLogin', 'Function'); + ctx.packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); - const result = resolveSymbolInternal('HandleLogin', 'cmd/server/main.go', symbolTable, importMap, packageMap); + const result = ctx.resolve('HandleLogin', 'cmd/server/main.go'); expect(result).not.toBeNull(); expect(result!.tier).toBe('import-scoped'); - expect(result!.definition.filePath).toBe('internal/auth/handler.go'); + expect(result!.candidates[0].filePath).toBe('internal/auth/handler.go'); }); it('does not resolve symbol from wrong package', () => { - symbolTable.add('internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', 'Function'); - packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + ctx.symbols.add('internal/db/connection.go', 'Connect', 'Function:internal/db/connection.go:Connect', 'Function'); + ctx.packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); - const result = resolveSymbolInternal('Connect', 'cmd/server/main.go', symbolTable, importMap, packageMap); + const result = ctx.resolve('Connect', 'cmd/server/main.go'); - // Not in imported package, and not unique global (only 1 def) → unique-global + // Not in imported package, single global def → global tier expect(result).not.toBeNull(); - expect(result!.tier).toBe('unique-global'); + expect(result!.tier).toBe('global'); }); it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => { - symbolTable.add('internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function'); - symbolTable.add('internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', 'Function'); + ctx.symbols.add('internal/auth/handler.go', 'Validate', 'Function:internal/auth/handler.go:Validate', 'Function'); + ctx.symbols.add('internal/db/validator.go', 'Validate', 'Function:internal/db/validator.go:Validate', 'Function'); - // ImportMap points to db, PackageMap points to auth - importMap.set('cmd/server/main.go', new Set(['internal/db/validator.go'])); - packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); + ctx.importMap.set('cmd/server/main.go', new Set(['internal/db/validator.go'])); + ctx.packageMap.set('cmd/server/main.go', new Set(['/internal/auth/'])); - const result = resolveSymbolInternal('Validate', 'cmd/server/main.go', symbolTable, importMap, packageMap); + const result = ctx.resolve('Validate', 'cmd/server/main.go'); expect(result).not.toBeNull(); expect(result!.tier).toBe('import-scoped'); - expect(result!.definition.filePath).toBe('internal/db/validator.go'); + expect(result!.candidates[0].filePath).toBe('internal/db/validator.go'); }); - it('resolves both symbols in same imported package (first match wins)', () => { - symbolTable.add('internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function'); - symbolTable.add('internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', 'Function'); - packageMap.set('cmd/main.go', new Set(['/internal/auth/'])); + it('resolves both symbols in same imported package', () => { + ctx.symbols.add('internal/auth/handler.go', 'Run', 'Function:internal/auth/handler.go:Run', 'Function'); + ctx.symbols.add('internal/auth/worker.go', 'Run', 'Function:internal/auth/worker.go:Run', 'Function'); + ctx.packageMap.set('cmd/main.go', new Set(['/internal/auth/'])); - const result = resolveSymbolInternal('Run', 'cmd/main.go', symbolTable, importMap, packageMap); + const result = ctx.resolve('Run', 'cmd/main.go'); - // Both match the package — returns first match as import-scoped expect(result).not.toBeNull(); expect(result!.tier).toBe('import-scoped'); + expect(result!.candidates.length).toBe(2); }); - it('returns null without packageMap (backward compat)', () => { - symbolTable.add('internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function'); - symbolTable.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); + it('returns global without packageMap when ambiguous', () => { + ctx.symbols.add('internal/auth/handler.go', 'X', 'Function:internal/auth/handler.go:X', 'Function'); + ctx.symbols.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function'); - // No importMap entry, no packageMap → ambiguous - const result = resolveSymbolInternal('X', 'cmd/main.go', symbolTable, importMap); + const result = resolveInternal(ctx, 'X', 'cmd/main.go'); + // No import or package match, 2 candidates → ambiguous → null expect(result).toBeNull(); }); }); + +describe('per-file cache', () => { + let ctx: ResolutionContext; + + beforeEach(() => { + ctx = createResolutionContext(); + }); + + it('caches results per file', () => { + ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + + ctx.enableCache('src/a.ts'); + const r1 = ctx.resolve('Foo', 'src/a.ts'); + const r2 = ctx.resolve('Foo', 'src/a.ts'); + ctx.clearCache(); + + // Same object reference from cache + expect(r1).toBe(r2); + expect(ctx.getStats().cacheHits).toBe(1); + expect(ctx.getStats().cacheMisses).toBe(1); + }); + + it('resolve works without cache enabled', () => { + ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + + const result = ctx.resolve('Foo', 'src/a.ts'); + + expect(result).not.toBeNull(); + expect(result!.candidates[0].nodeId).toBe('Class:src/a.ts:Foo'); + expect(ctx.getStats().cacheHits).toBe(0); + }); + + it('cache does not leak across files', () => { + ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class'); + + ctx.enableCache('src/a.ts'); + ctx.resolve('Foo', 'src/a.ts'); // cached for a.ts + + // Resolve from different file — should NOT use cache + const r = ctx.resolve('Foo', 'src/b.ts'); + ctx.clearCache(); + + // Foo is not in src/b.ts, so same-file fails. Falls to global with 1 candidate. + expect(r!.tier).toBe('global'); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 78f6523bea..8e563af620 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildTypeEnv, lookupTypeEnv, type TypeEnv } from '../../src/core/ingestion/type-env.js'; +import { buildTypeEnv, type TypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js'; import Parser from 'tree-sitter'; import TypeScript from 'tree-sitter-typescript'; import Java from 'tree-sitter-java'; @@ -10,6 +10,7 @@ import Python from 'tree-sitter-python'; import CPP from 'tree-sitter-cpp'; import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; +import Ruby from 'tree-sitter-ruby'; const parser = new Parser(); @@ -38,34 +39,58 @@ describe('buildTypeEnv', () => { describe('TypeScript', () => { it('extracts type from const declaration', () => { const tree = parse('const user: User = getUser();', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatGet(env, 'user')).toBe('User'); }); it('extracts type from let declaration', () => { const tree = parse('let repo: Repository;', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatGet(env, 'repo')).toBe('Repository'); }); it('extracts type from function parameters', () => { const tree = parse('function save(user: User, repo: Repository) {}', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); it('extracts type from arrow function parameters', () => { const tree = parse('const fn = (user: User) => user.save();', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatGet(env, 'user')).toBe('User'); }); it('ignores variables without type annotations', () => { const tree = parse('const x = 5; let y = "hello";', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatSize(env)).toBe(0); }); + + it('extracts type from nullable union User | null', () => { + const tree = parse('const user: User | null = getUser();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from optional union User | undefined', () => { + const tree = parse('let user: User | undefined;', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from triple nullable union User | null | undefined', () => { + const tree = parse('const user: User | null | undefined = getUser();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('ignores non-nullable unions like User | Repo', () => { + const tree = parse('const entity: User | Repo = getEntity();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'entity')).toBeUndefined(); + }); }); describe('Java', () => { @@ -78,7 +103,7 @@ describe('buildTypeEnv', () => { } } `, Java); - const env = buildTypeEnv(tree, 'java'); + const { env } = buildTypeEnv(tree, 'java'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); @@ -89,7 +114,7 @@ describe('buildTypeEnv', () => { void process(User user, Repository repo) {} } `, Java); - const env = buildTypeEnv(tree, 'java'); + const { env } = buildTypeEnv(tree, 'java'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); @@ -100,7 +125,7 @@ describe('buildTypeEnv', () => { private User user; } `, Java); - const env = buildTypeEnv(tree, 'java'); + const { env } = buildTypeEnv(tree, 'java'); expect(flatGet(env, 'user')).toBe('User'); }); }); @@ -114,7 +139,7 @@ describe('buildTypeEnv', () => { } } `, CSharp); - const env = buildTypeEnv(tree, 'csharp'); + const { env } = buildTypeEnv(tree, 'csharp'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -126,7 +151,7 @@ describe('buildTypeEnv', () => { } } `, CSharp); - const env = buildTypeEnv(tree, 'csharp'); + const { env } = buildTypeEnv(tree, 'csharp'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -136,10 +161,25 @@ describe('buildTypeEnv', () => { void Process(User user, Repository repo) {} } `, CSharp); - const env = buildTypeEnv(tree, 'csharp'); + const { env } = buildTypeEnv(tree, 'csharp'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); + + it('extracts type from is pattern matching (obj is User user)', () => { + const tree = parse(` + class User { public void Save() {} } + class App { + void Process(object obj) { + if (obj is User user) { + user.Save(); + } + } + } + `, CSharp); + const { env } = buildTypeEnv(tree, 'csharp'); + expect(flatGet(env, 'user')).toBe('User'); + }); }); describe('Go', () => { @@ -150,7 +190,7 @@ describe('buildTypeEnv', () => { var user User } `, Go); - const env = buildTypeEnv(tree, 'go'); + const { env } = buildTypeEnv(tree, 'go'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -161,7 +201,98 @@ describe('buildTypeEnv', () => { user := User{Name: "Alice"} } `, Go); - const env = buildTypeEnv(tree, 'go'); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from address-of composite literal (&User{})', () => { + const tree = parse(` + package main + func main() { + user := &User{Name: "Alice"} + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from address-of in multi-assignment', () => { + const tree = parse(` + package main + func main() { + user, repo := &User{}, &Repo{} + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + expect(flatGet(env, 'repo')).toBe('Repo'); + }); + + it('infers type from new(User) built-in', () => { + const tree = parse(` + package main + func main() { + user := new(User) + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does not infer from non-new function calls', () => { + const tree = parse(` + package main + func main() { + user := getUser() + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBeUndefined(); + }); + + it('infers element type from make([]User, 0) slice builtin', () => { + const tree = parse(` + package main + func main() { + sl := make([]User, 0) + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'sl')).toBe('User'); + }); + + it('infers value type from make(map[string]User) map builtin', () => { + const tree = parse(` + package main + func main() { + m := make(map[string]User) + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'm')).toBe('User'); + }); + + it('infers type from type assertion: user := iface.(User)', () => { + const tree = parse(` + package main + type Saver interface { Save() } + func process(s Saver) { + user := s.(User) + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from type assertion in multi-assignment: user, ok := iface.(User)', () => { + const tree = parse(` + package main + type Saver interface { Save() } + func process(s Saver) { + user, ok := s.(User) + } + `, Go); + const { env } = buildTypeEnv(tree, 'go'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -170,7 +301,7 @@ describe('buildTypeEnv', () => { package main func process(user User, repo Repository) {} `, Go); - const env = buildTypeEnv(tree, 'go'); + const { env } = buildTypeEnv(tree, 'go'); // Go parameter extraction depends on tree-sitter grammar structure // Parameters may or may not have 'name'/'type' fields }); @@ -183,7 +314,7 @@ describe('buildTypeEnv', () => { let user: User = User::new(); } `, Rust); - const env = buildTypeEnv(tree, 'rust'); + const { env } = buildTypeEnv(tree, 'rust'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -191,7 +322,7 @@ describe('buildTypeEnv', () => { const tree = parse(` fn process(user: User, repo: Repository) {} `, Rust); - const env = buildTypeEnv(tree, 'rust'); + const { env } = buildTypeEnv(tree, 'rust'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); @@ -202,7 +333,7 @@ describe('buildTypeEnv', () => { let user: &User = &get_user(); } `, Rust); - const env = buildTypeEnv(tree, 'rust'); + const { env } = buildTypeEnv(tree, 'rust'); expect(flatGet(env, 'user')).toBe('User'); }); }); @@ -210,15 +341,48 @@ describe('buildTypeEnv', () => { describe('Python', () => { it('extracts type from annotated assignment (PEP 484)', () => { const tree = parse('user: User = get_user()', Python); - const env = buildTypeEnv(tree, 'python'); + const { env } = buildTypeEnv(tree, 'python'); expect(flatGet(env, 'user')).toBe('User'); }); it('extracts type from function parameters', () => { const tree = parse('def process(user: User, repo: Repository): pass', Python); - const env = buildTypeEnv(tree, 'python'); + const { env } = buildTypeEnv(tree, 'python'); // Python uses typed_parameter nodes, check if they match }); + + it('extracts type from class-level annotation with default value', () => { + const tree = parse(`class User: + name: str = "default" + age: int = 0 +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'name')).toBe('str'); + expect(flatGet(env, 'age')).toBe('int'); + }); + + it('extracts type from class-level annotation without default value', () => { + const tree = parse(`class User: + repo: UserRepo +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'repo')).toBe('UserRepo'); + }); + + it('extracts types from mixed class-level annotations and methods', () => { + const tree = parse(`class User: + name: str = "default" + age: int = 0 + repo: UserRepo + + def save(self): + pass +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'name')).toBe('str'); + expect(flatGet(env, 'age')).toBe('int'); + expect(flatGet(env, 'repo')).toBe('UserRepo'); + }); }); describe('C++', () => { @@ -228,7 +392,7 @@ describe('buildTypeEnv', () => { User user; } `, CPP); - const env = buildTypeEnv(tree, 'cpp'); + const { env } = buildTypeEnv(tree, 'cpp'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -238,7 +402,7 @@ describe('buildTypeEnv', () => { User user = getUser(); } `, CPP); - const env = buildTypeEnv(tree, 'cpp'); + const { env } = buildTypeEnv(tree, 'cpp'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -248,7 +412,7 @@ describe('buildTypeEnv', () => { User* user = new User(); } `, CPP); - const env = buildTypeEnv(tree, 'cpp'); + const { env } = buildTypeEnv(tree, 'cpp'); expect(flatGet(env, 'user')).toBe('User'); }); @@ -256,10 +420,36 @@ describe('buildTypeEnv', () => { const tree = parse(` void process(User user, Repository& repo) {} `, CPP); - const env = buildTypeEnv(tree, 'cpp'); + const { env } = buildTypeEnv(tree, 'cpp'); expect(flatGet(env, 'user')).toBe('User'); expect(flatGet(env, 'repo')).toBe('Repository'); }); + + it('extracts type from range-for with explicit type', () => { + const tree = parse(` + void run() { + std::vector users; + for (User& user : users) { + user.save(); + } + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from range-for with const ref', () => { + const tree = parse(` + void run() { + std::vector users; + for (const User& user : users) { + user.save(); + } + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); }); describe('PHP', () => { @@ -267,11 +457,191 @@ describe('buildTypeEnv', () => { const tree = parse(` { + const code = `save(); + } +}`; + const tree = parse(code, PHP.php); + const typeEnv = buildTypeEnv(tree, 'php'); + + // Find the call node ($this->save()) + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'member_call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + expect(calls.length).toBeGreaterThanOrEqual(1); + // $this should resolve to enclosing class 'UserService' + expect(typeEnv.lookup('$this', calls[0])).toBe('UserService'); + }); + + it('extracts type from constructor property promotion (PHP 8.0+)', () => { + const tree = parse(` { + const tree = parse(` { + const tree = parse(` { + it('resolves super to parent class name (TypeScript)', () => { + const code = ` +class BaseModel { + save(): boolean { return true; } +} +class User extends BaseModel { + save(): boolean { + super.save(); + return true; + } +}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + // Find the super.save() call (inside User class) + const superCall = calls.find((c: any) => { + const text = c.text; + return text.includes('super'); + }); + expect(superCall).toBeDefined(); + expect(typeEnv.lookup('super', superCall)).toBe('BaseModel'); + }); + + it('resolves super to parent class name (Java)', () => { + const code = ` +class BaseModel { + boolean save() { return true; } +} +class User extends BaseModel { + boolean save() { + super.save(); + return true; + } +}`; + const tree = parse(code, Java); + const typeEnv = buildTypeEnv(tree, 'java'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'method_invocation') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + const superCall = calls.find((c: any) => c.text.includes('super')); + expect(superCall).toBeDefined(); + expect(typeEnv.lookup('super', superCall)).toBe('BaseModel'); + }); + + it('resolves super to parent class name (Python)', () => { + const code = ` +class BaseModel: + def save(self) -> bool: + return True + +class User(BaseModel): + def save(self) -> bool: + super().save() + return True +`; + const tree = parse(code, Python); + const typeEnv = buildTypeEnv(tree, 'python'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + // Find a call inside the User class + const superCall = calls.find((c: any) => c.text.includes('super')); + expect(superCall).toBeDefined(); + expect(typeEnv.lookup('super', superCall)).toBe('BaseModel'); + }); + + it('returns undefined when class has no parent', () => { + const code = ` +class Standalone { + save(): boolean { + return true; + } +}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + + // No calls in this code — test the resolution function directly + // by using the class body as the context node + const classNode = tree.rootNode.firstNamedChild; + expect(typeEnv.lookup('super', classNode!)).toBeUndefined(); + }); + }); + + describe('Kotlin object_declaration this resolution', () => { + it('resolves this inside object declaration', () => { + const code = ` +object AppConfig { + fun setup() { + this.init() + } +}`; + const tree = parse(code, Kotlin); + const typeEnv = buildTypeEnv(tree, 'kotlin'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + expect(calls.length).toBeGreaterThanOrEqual(1); + expect(typeEnv.lookup('this', calls[0])).toBe('AppConfig'); + }); }); describe('scope awareness', () => { @@ -284,7 +654,7 @@ describe('buildTypeEnv', () => { user.save(); } `, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); // Each function has its own scope for 'user' (keyed by funcName@startIndex) // Find the scope keys that start with handleUser/handleRepo @@ -297,7 +667,7 @@ describe('buildTypeEnv', () => { expect(env.get(handleRepoKey!)?.get('user')).toBe('Repo'); }); - it('lookupTypeEnv resolves from enclosing function scope', () => { + it('lookup resolves from enclosing function scope', () => { const code = ` function handleUser(user: User) { user.save(); @@ -306,7 +676,7 @@ function handleRepo(user: Repo) { user.save(); }`; const tree = parse(code, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, 'typescript'); // Find the call nodes inside each function const calls: any[] = []; @@ -320,9 +690,9 @@ function handleRepo(user: Repo) { expect(calls.length).toBe(2); // First call is inside handleUser → user should be User - expect(lookupTypeEnv(env, 'user', calls[0])).toBe('User'); + expect(typeEnv.lookup('user', calls[0])).toBe('User'); // Second call is inside handleRepo → user should be Repo - expect(lookupTypeEnv(env, 'user', calls[1])).toBe('Repo'); + expect(typeEnv.lookup('user', calls[1])).toBe('Repo'); }); it('separates same-named methods in different classes via startIndex', () => { @@ -338,7 +708,7 @@ class RepoService { } }`; const tree = parse(code, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, 'typescript'); // Find the call nodes inside each process method const calls: any[] = []; @@ -352,9 +722,9 @@ class RepoService { expect(calls.length).toBe(2); // First call inside UserService.process → user should be User - expect(lookupTypeEnv(env, 'user', calls[0])).toBe('User'); + expect(typeEnv.lookup('user', calls[0])).toBe('User'); // Second call inside RepoService.process → repo should be Repo - expect(lookupTypeEnv(env, 'repo', calls[1])).toBe('Repo'); + expect(typeEnv.lookup('repo', calls[1])).toBe('Repo'); }); it('file-level variables are accessible from all scopes', () => { @@ -365,10 +735,10 @@ class RepoService { user.save(); } `, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const typeEnv = buildTypeEnv(tree, 'typescript'); // config is at file-level scope - const fileScope = env.get(''); + const fileScope = typeEnv.env.get(''); expect(fileScope?.get('config')).toBe('Config'); // user is in process scope (key includes startIndex) @@ -380,10 +750,9 @@ class RepoService { } findCalls(tree.rootNode); // calls[0] = getConfig() at file level, calls[1] = config.validate(), calls[2] = user.save() - // Use a call inside the function to test scope resolution - expect(lookupTypeEnv(env, 'user', calls[2])).toBe('User'); + expect(typeEnv.lookup('user', calls[2])).toBe('User'); // config is file-level, accessible from any scope - expect(lookupTypeEnv(env, 'config', calls[1])).toBe('Config'); + expect(typeEnv.lookup('config', calls[1])).toBe('Config'); }); }); @@ -393,7 +762,7 @@ class RepoService { const user: User = getUser(); const { name, email } = user; `, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); // The typed variable is captured expect(flatGet(env, 'user')).toBe('User'); // Destructured bindings (name, email) would need type inference to resolve @@ -408,16 +777,625 @@ class RepoService { const tree = parse(` const { name }: { name: string } = getUser(); `, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); // Complex type annotation (object type) — extractSimpleTypeName returns undefined expect(flatSize(env)).toBe(0); }); }); + describe('constructor inference (Tier 1 fallback)', () => { + describe('TypeScript', () => { + it('infers type from new expression when no annotation', () => { + const tree = parse('const user = new User();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('prefers explicit annotation over constructor inference', () => { + const tree = parse('const user: BaseUser = new User();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'user')).toBe('BaseUser'); + }); + + it('does not infer from namespaced constructor (known limitation)', () => { + // extractSimpleTypeName only handles simple identifiers, not member expressions + const tree = parse('const svc = new ns.Service();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // member_expression as constructor → extractSimpleTypeName returns undefined + expect(flatGet(env, 'svc')).toBeUndefined(); + }); + + it('infers type from new expression with as cast', () => { + const tree = parse('const x = new User() as BaseUser;', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // Unwraps as_expression to find the inner new_expression → User + expect(flatGet(env, 'x')).toBe('User'); + }); + + it('infers type from new expression with non-null assertion', () => { + const tree = parse('const x = new User()!;', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // Unwraps non_null_expression to find the inner new_expression → User + expect(flatGet(env, 'x')).toBe('User'); + }); + + it('infers type from double-cast (new X() as unknown as T)', () => { + const tree = parse('const x = new User() as unknown as Admin;', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + // Unwraps nested as_expression to find inner new_expression → User + expect(flatGet(env, 'x')).toBe('User'); + }); + + it('ignores non-new assignments', () => { + const tree = parse('const x = getUser();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatSize(env)).toBe(0); + }); + + it('handles mixed annotated + unannotated declarators', () => { + const tree = parse('const a: A = getA(), b = new B();', TypeScript.typescript); + const { env } = buildTypeEnv(tree, 'typescript'); + expect(flatGet(env, 'a')).toBe('A'); + expect(flatGet(env, 'b')).toBe('B'); + }); + }); + + describe('Java', () => { + it('infers type from var with new expression (Java 10+)', () => { + const tree = parse(` + class App { + void run() { + var user = new User(); + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('prefers explicit type over constructor inference', () => { + const tree = parse(` + class App { + void run() { + User user = new User(); + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does not infer from var without new expression', () => { + const tree = parse(` + class App { + void run() { + var x = getUser(); + } + } + `, Java); + const { env } = buildTypeEnv(tree, 'java'); + expect(flatGet(env, 'x')).toBeUndefined(); + }); + }); + + describe('Rust', () => { + it('infers type from Type::new()', () => { + const tree = parse(` + fn main() { + let user = User::new(); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from Type::default()', () => { + const tree = parse(` + fn main() { + let config = Config::default(); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'config')).toBe('Config'); + }); + + it('prefers explicit annotation over constructor inference', () => { + // Uses DIFFERENT types to catch Tier 0 overwrite bugs + const tree = parse(` + fn main() { + let user: BaseUser = Admin::new(); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('BaseUser'); + }); + + it('infers type from let mut with ::new()', () => { + const tree = parse(` + fn main() { + let mut user = User::new(); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('resolves Self::new() to enclosing impl type', () => { + const tree = parse(` + struct User {} + impl User { + fn create() -> Self { + let instance = Self::new(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'instance')).toBe('User'); + }); + + it('resolves Self::default() to enclosing impl type', () => { + const tree = parse(` + struct Config {} + impl Config { + fn make() -> Self { + let cfg = Self::default(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'cfg')).toBe('Config'); + }); + + it('skips Self::new() outside impl block', () => { + const tree = parse(` + fn main() { + let x = Self::new(); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'x')).toBeUndefined(); + }); + + it('does not infer from Type::other_method()', () => { + const tree = parse(` + fn main() { + let user = User::from_str("alice"); + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBeUndefined(); + }); + + it('infers type from struct literal (User { ... })', () => { + const tree = parse(` + fn main() { + let user = User { name: "alice", age: 30 }; + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from empty struct literal (Config {})', () => { + const tree = parse(` + fn main() { + let config = Config {}; + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'config')).toBe('Config'); + }); + + it('prefers explicit annotation over struct literal inference', () => { + const tree = parse(` + fn main() { + let user: BaseUser = Admin { name: "alice" }; + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('BaseUser'); + }); + + it('resolves Self {} struct literal to enclosing impl type', () => { + const tree = parse(` + struct User { name: String } + impl User { + fn reset(&self) -> Self { + let fresh = Self { name: String::new() }; + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'fresh')).toBe('User'); + }); + + it('skips Self {} outside impl block', () => { + const tree = parse(` + fn main() { + let x = Self { name: String::new() }; + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'x')).toBeUndefined(); + }); + }); + + describe('Rust if-let / while-let pattern bindings', () => { + it('extracts type from captured_pattern in if let (user @ User { .. })', () => { + const tree = parse(` + fn process() { + if let user @ User { .. } = get_user() { + user.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from nested captured_pattern in if let Some(user @ User { .. })', () => { + const tree = parse(` + fn process(opt: Option) { + if let Some(user @ User { .. }) = opt { + user.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('extracts type from captured_pattern in while let', () => { + const tree = parse(` + fn process() { + while let item @ Config { .. } = iter.next() { + item.validate(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'item')).toBe('Config'); + }); + + it('does NOT extract binding from if let Some(x) = opt (requires generic unwrapping)', () => { + const tree = parse(` + fn process(opt: Option) { + if let Some(user) = opt { + user.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // user's type is Option's inner type — requires generic unwrapping (Phase 3) + expect(flatGet(env, 'user')).toBeUndefined(); + }); + + it('does NOT extract field bindings from struct pattern destructuring', () => { + const tree = parse(` + fn process(val: User) { + if let User { name } = val { + name.len(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // 'name' is a field of User — we don't know its type without field-type resolution + expect(flatGet(env, 'name')).toBeUndefined(); + // 'val' should still be extracted from the parameter annotation + expect(flatGet(env, 'val')).toBe('User'); + }); + + it('extracts type from scoped struct pattern (Message::Data)', () => { + const tree = parse(` + fn process() { + if let msg @ Message::Data { .. } = get_msg() { + msg.process(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + // scoped_type_identifier: Message::Data — extractSimpleTypeName returns "Data" + expect(flatGet(env, 'msg')).toBe('Data'); + }); + + it('still extracts parameter types alongside if-let bindings', () => { + const tree = parse(` + fn process(opt: Option) { + if let user @ User { .. } = get_user() { + user.save(); + } + } + `, Rust); + const { env } = buildTypeEnv(tree, 'rust'); + expect(flatGet(env, 'opt')).toBe('Option'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('PHP', () => { + it('infers type from new expression', () => { + const tree = parse(` { + const tree = parse(` { + const tree = parse(` { + const tree = parse(` { + const tree = parse(` { + it('infers type from auto with new expression', () => { + const tree = parse(` + void run() { + auto user = new User(); + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from auto with direct construction when class is defined', () => { + const tree = parse(` + class User {}; + void run() { + auto user = User(); + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('prefers explicit type over auto inference', () => { + const tree = parse(` + void run() { + User* user = new User(); + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does not infer from auto with function call (not a known class)', () => { + const tree = parse(` + class User {}; + User getUser() { return User(); } + void run() { + auto x = getUser(); + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + // getUser is an identifier but NOT a known class — no inference + expect(flatGet(env, 'x')).toBeUndefined(); + }); + + it('infers type from brace initialization (User{})', () => { + const tree = parse(` + class User {}; + void run() { + auto user = User{}; + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from brace initialization with args (User{1,2})', () => { + const tree = parse(` + class Config {}; + void run() { + auto cfg = Config{1, 2}; + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'cfg')).toBe('Config'); + }); + + it('infers type from namespaced brace-init (ns::User{})', () => { + const tree = parse(` + namespace ns { class User {}; } + void run() { + auto user = ns::User{}; + } + `, CPP); + const { env } = buildTypeEnv(tree, 'cpp'); + expect(flatGet(env, 'user')).toBe('User'); + }); + }); + + describe('Kotlin constructor inference', () => { + it('still extracts explicit type annotations', () => { + const tree = parse(` + fun main() { + val user: User = User() + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from constructor call when class is in same file', () => { + const tree = parse(` + class User(val name: String) + fun main() { + val user = User("Alice") + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does NOT infer type from plain function call', () => { + const tree = parse(` + fun getUser(): User = User("Alice") + fun main() { + val user = getUser() + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + // getUser is not a class name — should NOT produce a binding + expect(flatGet(env, 'user')).toBeUndefined(); + }); + + it('infers type from constructor when class defined via SymbolTable', () => { + const tree = parse(` + fun main() { + val user = User("Alice") + } + `, Kotlin); + // User is NOT defined in this file, but SymbolTable knows it's a Class + const mockSymbolTable = { + lookupFuzzy: (name: string) => + name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, + add: () => {}, + getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + clear: () => {}, + }; + const { env } = buildTypeEnv(tree, 'kotlin', mockSymbolTable as any); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does NOT infer when SymbolTable says callee is a Function', () => { + const tree = parse(` + fun main() { + val result = doStuff() + } + `, Kotlin); + const mockSymbolTable = { + lookupFuzzy: (name: string) => + name === 'doStuff' ? [{ nodeId: 'n1', filePath: 'utils.kt', type: 'Function' }] : [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, + add: () => {}, + getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }), + clear: () => {}, + }; + const { env } = buildTypeEnv(tree, 'kotlin', mockSymbolTable as any); + expect(flatGet(env, 'result')).toBeUndefined(); + }); + + it('prefers explicit annotation over constructor inference', () => { + const tree = parse(` + class User(val name: String) + fun main() { + val user: BaseEntity = User("Alice") + } + `, Kotlin); + const { env } = buildTypeEnv(tree, 'kotlin'); + // Tier 0 (explicit annotation) wins over Tier 1 (constructor inference) + expect(flatGet(env, 'user')).toBe('BaseEntity'); + }); + }); + + describe('Python constructor inference', () => { + it('infers type from direct constructor call when class is known', () => { + const tree = parse(` +class User: + pass + +def main(): + user = User("alice") +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('infers type from qualified constructor call (models.User)', () => { + const tree = parse(` +class User: + pass + +def main(): + user = models.User("alice") +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + // extractSimpleTypeName extracts "User" from attribute node "models.User" + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does not infer from plain function call', () => { + const tree = parse(` +def main(): + user = get_user() +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBeUndefined(); + }); + }); + + describe('Python walrus operator type inference', () => { + it('infers type from walrus operator with constructor call', () => { + const tree = parse(` +class User: + pass + +def main(): + if (user := User("alice")): + pass +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBe('User'); + }); + + it('does not infer type from walrus operator without known class', () => { + const tree = parse(` +def main(): + if (data := get_data()): + pass +`, Python); + const { env } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'data')).toBeUndefined(); + }); + }); + }); + describe('edge cases', () => { it('returns empty map for code without type annotations', () => { const tree = parse('const x = 5;', TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); expect(flatSize(env)).toBe(0); }); @@ -426,9 +1404,245 @@ class RepoService { let x: User = getUser(); let x: Admin = getAdmin(); `, TypeScript.typescript); - const env = buildTypeEnv(tree, 'typescript'); + const { env } = buildTypeEnv(tree, 'typescript'); // Both declarations are at file level; last one wins expect(flatGet(env, 'x')).toBeDefined(); }); }); + + describe('generic parent class resolution', () => { + it('resolves super through generic parent (TypeScript)', () => { + const code = ` +class BaseModel { + save(): T { return {} as T; } +} +class User extends BaseModel { + save(): string { + super.save(); + return "ok"; + } +}`; + const tree = parse(code, TypeScript.typescript); + const typeEnv = buildTypeEnv(tree, 'typescript'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + const superCall = calls.find((c: any) => c.text.includes('super')); + expect(superCall).toBeDefined(); + // Should resolve to "BaseModel", not "BaseModel" + expect(typeEnv.lookup('super', superCall)).toBe('BaseModel'); + }); + + it('resolves super through generic parent (Java)', () => { + const code = ` +class BaseModel { + T save() { return null; } +} +class User extends BaseModel { + String save() { + super.save(); + return "ok"; + } +}`; + const tree = parse(code, Java); + const typeEnv = buildTypeEnv(tree, 'java'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'method_invocation') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + const superCall = calls.find((c: any) => c.text.includes('super')); + expect(superCall).toBeDefined(); + // Should resolve to "BaseModel", not "BaseModel" + expect(typeEnv.lookup('super', superCall)).toBe('BaseModel'); + }); + + it('resolves super through qualified parent (Python models.Model)', () => { + const code = ` +class Model: + def save(self): + pass + +class User(Model): + def save(self): + super().save() +`; + const tree = parse(code, Python); + const typeEnv = buildTypeEnv(tree, 'python'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'call') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + const superCall = calls.find((c: any) => c.text.includes('super')); + expect(superCall).toBeDefined(); + expect(typeEnv.lookup('super', superCall)).toBe('Model'); + }); + + it('resolves super through generic parent (C#)', () => { + const code = ` +class BaseModel { + public T Save() { return default; } +} +class User : BaseModel { + public string Save() { + base.Save(); + return "ok"; + } +}`; + const tree = parse(code, CSharp); + const typeEnv = buildTypeEnv(tree, 'csharp'); + + const calls: any[] = []; + function findCalls(node: any) { + if (node.type === 'invocation_expression') calls.push(node); + for (let i = 0; i < node.childCount; i++) findCalls(node.child(i)); + } + findCalls(tree.rootNode); + + const baseCall = calls.find((c: any) => c.text.includes('base')); + expect(baseCall).toBeDefined(); + // Should resolve to "BaseModel", not "BaseModel" + expect(typeEnv.lookup('base', baseCall)).toBe('BaseModel'); + }); + }); + + describe('C++ namespaced constructor binding', () => { + it('infers type from auto with namespaced constructor (ns::User)', () => { + const tree = parse(` + namespace ns { + class HttpClient {}; + } + void run() { + auto client = ns::HttpClient(); + } + `, CPP); + const { constructorBindings } = buildTypeEnv(tree, 'cpp'); + // Should extract "HttpClient" from the scoped_identifier ns::HttpClient + const binding = constructorBindings.find(b => b.varName === 'client'); + expect(binding).toBeDefined(); + expect(binding!.calleeName).toBe('HttpClient'); + }); + + it('does not extract from non-namespaced plain identifier (existing behavior)', () => { + const tree = parse(` + class User {}; + void run() { + auto user = User(); + } + `, CPP); + const { env, constructorBindings } = buildTypeEnv(tree, 'cpp'); + // User() with known class resolves via extractInitializer, not constructor bindings + expect(flatGet(env, 'user')).toBe('User'); + // No unresolved bindings since User is locally known + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); + }); + + describe('constructorBindings merged into buildTypeEnv', () => { + it('returns constructor bindings for Kotlin val x = UnknownClass()', () => { + const tree = parse(` + fun main() { + val user = UnknownClass() + } + `, Kotlin); + const { env, constructorBindings } = buildTypeEnv(tree, 'kotlin'); + // UnknownClass is not defined locally — should appear as unverified binding + expect(flatGet(env, 'user')).toBeUndefined(); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('UnknownClass'); + }); + + it('does NOT emit constructor binding when TypeEnv already resolved', () => { + const tree = parse(` + fun main() { + val user: User = User() + } + `, Kotlin); + const { env, constructorBindings } = buildTypeEnv(tree, 'kotlin'); + // Explicit annotation resolves it — no unverified binding needed + expect(flatGet(env, 'user')).toBe('User'); + expect(constructorBindings.find(b => b.varName === 'user')).toBeUndefined(); + }); + + it('returns constructor bindings for Python x = UnknownClass()', () => { + const tree = parse(` +def main(): + user = SomeClass() +`, Python); + const { env, constructorBindings } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBeUndefined(); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('SomeClass'); + }); + + it('returns constructor bindings for Python qualified call (models.User)', () => { + const tree = parse(` +def main(): + user = models.User("alice") +`, Python); + const { constructorBindings } = buildTypeEnv(tree, 'python'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('User'); + }); + + it('returns constructor bindings for Python walrus operator (user := SomeClass())', () => { + const tree = parse(` +def main(): + if (user := SomeClass()): + pass +`, Python); + const { env, constructorBindings } = buildTypeEnv(tree, 'python'); + expect(flatGet(env, 'user')).toBeUndefined(); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('user'); + expect(constructorBindings[0].calleeName).toBe('SomeClass'); + }); + + it('returns empty bindings for language without scanner (Go)', () => { + const tree = parse(` + package main + func main() { + var x int = 5 + } + `, Go); + const { constructorBindings } = buildTypeEnv(tree, 'go'); + expect(constructorBindings).toEqual([]); + }); + + it('returns constructor bindings for Ruby constant assignment (REPO = Repo.new)', () => { + const tree = parse(` +REPO = Repo.new +`, Ruby); + const { constructorBindings } = buildTypeEnv(tree, 'ruby'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].varName).toBe('REPO'); + expect(constructorBindings[0].calleeName).toBe('Repo'); + }); + + it('includes scope key in constructor bindings', () => { + const tree = parse(` + fun process() { + val user = RemoteUser() + } + `, Kotlin); + const { constructorBindings } = buildTypeEnv(tree, 'kotlin'); + expect(constructorBindings.length).toBe(1); + expect(constructorBindings[0].scope).toMatch(/^process@\d+$/); + }); + }); });