From 0fc0211d266e6784664eca9032a69fc39f019de7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 1 Jun 2026 17:04:27 +0100 Subject: [PATCH] fix(ingestion): migrate all languages' inheritance to scope-resolution on the worker path (#1951) (#1956) --- .../python-scope/baseline-fingerprint.txt | 2 +- gitnexus/bench/python-scope/measure.mjs | 6 +- gitnexus/bench/scope-capture/baselines.json | 65 +++- gitnexus/bench/scope-capture/measure.mjs | 141 ++++++++- .../src/core/ingestion/heritage-processor.ts | 5 + .../core/ingestion/languages/c/captures.ts | 47 ++- .../core/ingestion/languages/cpp/captures.ts | 119 ++++--- .../ingestion/languages/csharp/captures.ts | 90 +++++- .../core/ingestion/languages/go/captures.ts | 138 +++++++- .../core/ingestion/languages/java/captures.ts | 173 ++++++++-- .../languages/javascript/captures.ts | 208 +++++++++++- .../ingestion/languages/kotlin/captures.ts | 141 +++++++-- .../core/ingestion/languages/php/captures.ts | 131 +++++++- .../ingestion/languages/python/captures.ts | 102 +++++- .../core/ingestion/languages/ruby/captures.ts | 92 ++++++ .../core/ingestion/languages/rust/captures.ts | 78 +++++ .../languages/rust/receiver-binding.ts | 9 + .../languages/rust/scope-resolver.ts | 93 +++++- .../ingestion/languages/swift/base-type.ts | 28 ++ .../ingestion/languages/swift/captures.ts | 63 ++++ .../languages/swift/receiver-binding.ts | 8 +- .../languages/typescript/captures.ts | 148 ++++++++- .../contract/scope-resolver.ts | 20 +- .../scope-resolution/graph-bridge/edges.ts | 3 + .../scope-resolution/pipeline/run.ts | 96 ++++-- .../scope-resolution/scope/walkers.ts | 88 ++++++ .../src/core/ingestion/utils/ast-helpers.ts | 22 ++ .../expected-captures.json | 112 ++++--- .../go-captures-golden/expected-captures.json | 28 +- .../src/BaseEntity.cs | 7 + .../csharp-primary-ctor-heritage/src/IFoo.cs | 7 + .../csharp-primary-ctor-heritage/src/Repo.cs | 7 + .../src/Service.cs | 7 + .../csharp-primary-ctor-heritage/src/User.cs | 8 + .../csharp-qualified-base/src/Domain.cs | 20 ++ .../csharp-qualified-base/src/Shapes.cs | 47 +++ .../go-qualified-base/base/base.go | 26 ++ .../go-qualified-base/consumers/local.go | 30 ++ .../go-qualified-base/consumers/qualified.go | 30 ++ .../lang-resolution/go-qualified-base/go.mod | 3 + .../java-generic-base/src/app/Box.java | 5 + .../java-generic-base/src/app/IFoo.java | 5 + .../java-generic-base/src/app/Service.java | 5 + .../java-iface-extends/src/app/IA.java | 13 + .../java-iface-extends/src/app/IB.java | 5 + .../java-iface-extends/src/app/IC.java | 5 + .../java-qualified-base/src/app/Plain.java | 10 + .../java-qualified-base/src/app/Service.java | 8 + .../java-qualified-base/src/app/Two.java | 9 + .../src/app/base/Base.java | 5 + .../java-qualified-base/src/app/base/Box.java | 5 + .../src/app/base/IBar.java | 5 + .../src/app/base/IFoo.java | 5 + .../javascript-qualified-base/src/Service.js | 21 ++ .../javascript-qualified-base/src/base.js | 5 + .../kotlin-qualified-base/src/Base.kt | 5 + .../kotlin-qualified-base/src/F.kt | 12 + .../kotlin-qualified-base/src/G.kt | 9 + .../kotlin-qualified-base/src/Iface.kt | 5 + .../python-qualified-base/a/__init__.py | 0 .../python-qualified-base/a/b.py | 3 + .../python-qualified-base/base_mod.py | 8 + .../python-qualified-base/service.py | 29 ++ .../ruby-qualified-base/lib/derived.rb | 24 ++ .../ruby-qualified-base/lib/outer.rb | 24 ++ .../rust-cross-module-collision/src/a.rs | 9 + .../rust-cross-module-collision/src/b.rs | 9 + .../rust-cross-module-collision/src/main.rs | 5 + .../rust-cross-module-collision/src/traits.rs | 3 + .../rust-qualified-trait/src/main.rs | 4 + .../rust-qualified-trait/src/traits.rs | 7 + .../rust-qualified-trait/src/widget.rs | 27 ++ .../Sources/Derived.swift | 2 + .../swift-qualified-base/Sources/Outer.swift | 7 + .../typescript-generic-base/src/Box.ts | 5 + .../typescript-generic-base/src/IFoo.ts | 3 + .../typescript-generic-base/src/Service.ts | 6 + .../typescript-qualified-base/src/Service.ts | 15 + .../typescript-qualified-base/src/base.ts | 17 + .../expected-captures.json | 64 ++-- .../expected-captures.json | 80 +++-- .../expected-captures.json | 40 ++- .../expected-captures.json | 56 +++- .../expected-captures.json | 28 +- .../integration/heritage-worker-path.test.ts | 297 ++++++++++++++++++ .../test/integration/resolvers/csharp.test.ts | 100 ++++-- .../test/integration/resolvers/go.test.ts | 33 ++ .../test/integration/resolvers/helpers.ts | 10 + .../test/integration/resolvers/java.test.ts | 102 ++++++ .../integration/resolvers/javascript.test.ts | 25 ++ .../test/integration/resolvers/kotlin.test.ts | 41 +++ .../test/integration/resolvers/python.test.ts | 45 +++ .../test/integration/resolvers/ruby.test.ts | 31 ++ .../test/integration/resolvers/rust.test.ts | 92 ++++++ .../integration/resolvers/typescript.test.ts | 54 ++++ .../javascript/javascript-captures.test.ts | 40 +++ ...resolve-ambiguous-inheritance-base.test.ts | 142 +++++++++ .../swift-qualified-base-captures.test.ts | 41 +++ .../typescript-captures-anchor.test.ts | 22 ++ .../sequential-language-availability.test.ts | 46 ++- 100 files changed, 3788 insertions(+), 398 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/swift/base-type.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/BaseEntity.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/IFoo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Repo.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Service.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/User.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Domain.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Shapes.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/go-qualified-base/base/base.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/local.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/qualified.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-qualified-base/go.mod create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Box.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/IFoo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Service.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IA.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IB.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IC.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Plain.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Service.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Two.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Base.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Box.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IBar.java create mode 100644 gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IFoo.java create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/Service.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/base.js create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Base.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/F.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/G.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Iface.kt create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/__init__.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/b.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-base/base_mod.py create mode 100644 gitnexus/test/fixtures/lang-resolution/python-qualified-base/service.py create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/derived.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/outer.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/a.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/b.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/traits.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/main.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/traits.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/widget.rs create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Derived.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Outer.swift create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Box.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/IFoo.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/Service.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/base.ts create mode 100644 gitnexus/test/integration/heritage-worker-path.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/resolve-ambiguous-inheritance-base.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/swift/swift-qualified-base-captures.test.ts diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index fdf5b2835..4969a17a9 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -f2b4376f30dab76f3befc9cbd3d7cc2bf1afbd7329a5e953439083e005de4a7c +9803b81f0c3738ecd276aba187436482be47b5f5f62e5e85a983524129713b7d diff --git a/gitnexus/bench/python-scope/measure.mjs b/gitnexus/bench/python-scope/measure.mjs index fa4c72a35..1ecd43b16 100644 --- a/gitnexus/bench/python-scope/measure.mjs +++ b/gitnexus/bench/python-scope/measure.mjs @@ -89,10 +89,14 @@ function generatePyDao(entityCount) { lines.push(`import top.level.module${i}`); } lines.push(''); + // Shared base + mixin so every Entity is heritage-bearing — exercises the + // @reference.inherits synth (#1951) at scale (single + multiple inheritance), + // not just the base capture loop. + lines.push('class Base:', ' pass', '', 'class Mixin:', ' pass', ''); for (let i = 0; i < entityCount; i++) { const n = String(i).padStart(4, '0'); lines.push( - `class Entity${n}:`, + `class Entity${n}(Base, Mixin):`, ` def __init__(self, id: int, name: str):`, ` self.id = id`, ` self.name = name`, diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 7802e0447..d28a998b8 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,31 +1,70 @@ { - "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. All six languages now thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp this PR).", + "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "faca3555c61ed6980d2b739bf6b1cac7f4ad4644968a27e4687532d9835cd4c7", - "scaling_budget": 1.5 + "fingerprint": "976bfd17cee048db11e06a27298e48919b7d45d5277d11923faeb61b138737dd", + "scaling_budget": 1.5, + "_rebaselined": "#1956 synth-widening: + go-qualified-base fixture; synthesizeGoInheritanceReferences now emits embeds for qualified_type (pkg.Base), generic_type (Box[T]), pointer, AND interface_type embeds (matching the #1940 legacy leg), reduced to bare names at parity. go-ambiguous gains an embed inherits capture. Linear (~1.01). (Earlier #1956: heritage-bearing scale source so the synth is gated at scale.)" }, "cobol": { "fingerprint": "575016f329c0be29eb90db974f750d02a21b4a12515f7029bda312df713b27b0", - "scaling_budget": 1.5 + "scaling_budget": 1.5, + "_note": "COBOL has no inheritance construct, so its scale source stays flat; unchanged." + }, + "c": { + "fingerprint": "0de009bdbfe095f530fa87eb32bce6ab83092c904f26b3c8fe8d8ab587cf6dc9", + "scaling_budget": 1.5, + "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96." + }, + "cpp": { + "fingerprint": "a571d260559fa48994d12970965b4f9df93efd087541ca31dc7818ac4cd2a2a6", + "scaling_budget": 1.5, + "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12." }, "csharp": { - "fingerprint": "bdc7803046011876b2d21ae38e9cb8c97ca1e01769f93ca8affe9317585427bf", + "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)", + "fingerprint": "68ef32c126d5c6de5d8184c6ad0a6104043036daf9805947db8b21741b883f43", "scaling_budget": 1.5 }, "rust": { - "fingerprint": "025f5b6d4cf1d8cc42033f1f6b592f8d5428e571939c7f61df4d34b4bbe14be3", - "scaling_budget": 1.5 + "fingerprint": "2ffad4ba7b1d2eb1ac407cb6d75d0eb98cbc1878260dbdfe982c0fc925b2d00c", + "scaling_budget": 1.5, + "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04)." }, "php": { - "fingerprint": "00fe6e83cebd67c5f346fedb4234ebedf192995f9f171a424551cb792a0b91a9", - "scaling_budget": 1.5 + "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", + "scaling_budget": 1.5, + "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "0f44b0d153b4534866589db93c582928651238b319cb606f2c6396362770cc18", - "scaling_budget": 1.5 + "fingerprint": "bdc7dbfbe5ce7b1e98292f88b404071b4a4b5566f6e756cd637e36a2214967e1", + "scaling_budget": 1.5, + "_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)" }, "swift": { - "fingerprint": "e6870c409c1005944c51dffd6e485005bb206f7afcbc2ef078e0c2f781c2b2ad", - "scaling_budget": 1.5 + "fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0", + "scaling_budget": 1.5, + "_rebaselined": "#1956: swift-qualified-base fixture + heritage-bearing scale source (class: Base, Serviceable — extends + protocol conformance); linear (~1.03)." + }, + "java": { + "fingerprint": "b63f9be458f7ece854e7b007159d7bf65b4b66a86e83a6c0656fc93ebd5d83da", + "scaling_budget": 1.5, + "_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.)" + }, + "typescript": { + "fingerprint": "7087f62dbab5fff0d8a9c39f7bc305842ee73a7ba20d7b44677f6511c92e5b92", + "scaling_budget": 1.5, + "_rebaselined": "#1956 tri-review U2: + typescript-qualified-base fixture AND terminalTsTypeNameNode now treats a member_expression tail (property_identifier) as a leaf name, so qualified `extends ns.Base` synthesizes an edge (was dropped). Linear (~1.03)." + }, + "javascript": { + "fingerprint": "a8ddfb15620ae55e50651fc21ab14c4a1f874d9b19e208cc6cbf0a8daac8ec5b", + "scaling_budget": 1.5, + "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", + "_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05)." + }, + "kotlin": { + "fingerprint": "5121a11855cd9cc44a357ae3ff50953de80cdd743f00e8924c31503b132bcd84", + "scaling_budget": 1.5, + "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", + "_rebaselined": "#1956 synth-widening: + kotlin-qualified-base fixture; synthesizeKotlinInheritanceReferences now handles the explicit_delegation form (class F : Iface by d -> Iface), matching the #1940 legacy leg, at parity. Linear (~0.87)." } } diff --git a/gitnexus/bench/scope-capture/measure.mjs b/gitnexus/bench/scope-capture/measure.mjs index 40e5d14d6..613b6c24d 100644 --- a/gitnexus/bench/scope-capture/measure.mjs +++ b/gitnexus/bench/scope-capture/measure.mjs @@ -34,6 +34,12 @@ import { emitPhpScopeCaptures } from '../../src/core/ingestion/languages/php/ind import { emitRubyScopeCaptures } from '../../src/core/ingestion/languages/ruby/index.ts'; import { emitCobolScopeCaptures } from '../../src/core/ingestion/languages/cobol/index.ts'; import { emitSwiftScopeCaptures } from '../../src/core/ingestion/languages/swift/index.ts'; +import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts'; +import { emitJsScopeCaptures } from '../../src/core/ingestion/languages/javascript/index.ts'; +import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/index.ts'; +import { emitJavaScopeCaptures } from '../../src/core/ingestion/languages/java/index.ts'; +import { emitCScopeCaptures } from '../../src/core/ingestion/languages/c/index.ts'; +import { emitCppScopeCaptures } from '../../src/core/ingestion/languages/cpp/index.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution'); @@ -93,9 +99,12 @@ const LANGS = [ fixturePrefix: 'go', exts: ['.go'], file: 'bench.go', - header: 'package generated\n\n', + // Heritage-bearing: each Entity embeds Base (Go inheritance = struct + // embedding) so the @reference.inherits synth (#1951) is driven at scale. + header: + 'package generated\n\ntype Base struct{}\n\nfunc (b *Base) BaseMethod() string { return "base" }\n\n', unit: (n) => - `type Entity${n} struct {\n\tid int64\n\tname string\n}\n\n` + + `type Entity${n} struct {\n\tBase\n\tid int64\n\tname string\n}\n\n` + `func (e *Entity${n}) GetID() int64 { return e.id }\n` + `func (e *Entity${n}) SetName(v string) { e.name = v }\n\n`, }, @@ -105,9 +114,12 @@ const LANGS = [ fixturePrefix: 'csharp', exts: ['.cs'], file: 'bench.cs', - header: 'namespace Generated;\n\n', + // Heritage-bearing: extends Base + implements IEntity (both forms) so the + // @reference.inherits synth (#1951) is driven at scale, not just the base loop. + header: + 'namespace Generated;\n\npublic class Base { }\n\npublic interface IEntity {\n long GetId();\n}\n\n', unit: (n) => - `public class Entity${n} {\n` + + `public class Entity${n} : Base, IEntity {\n` + ` public long Id;\n public string Name;\n` + ` public long GetId() { return Id; }\n` + ` public void SetName(string v) { Name = v; }\n}\n\n`, @@ -118,12 +130,15 @@ const LANGS = [ fixturePrefix: 'rust', exts: ['.rs'], file: 'bench.rs', - header: '', + // Heritage-bearing: `impl Shape for Entity_n` (Rust inheritance lives on + // impl_item) so the @reference.inherits trait-impl synth (#1951) is driven + // at scale. The two methods move into the trait impl to keep unit size flat. + header: 'trait Shape {\n fn area(&self) -> i64;\n fn name(&self) -> String;\n}\n\n', unit: (n) => `struct Entity${n} {\n id: i64,\n name: String,\n}\n\n` + - `impl Entity${n} {\n` + - ` fn get_id(&self) -> i64 { self.id }\n` + - ` fn set_name(&mut self, v: String) { self.name = v; }\n}\n\n`, + `impl Shape for Entity${n} {\n` + + ` fn area(&self) -> i64 { self.id }\n` + + ` fn name(&self) -> String { self.name.clone() }\n}\n\n`, }, { name: 'php', @@ -131,9 +146,13 @@ const LANGS = [ fixturePrefix: 'php', exts: ['.php'], file: 'bench.php', - header: ' - `class Entity${n} {\n` + + `class Entity${n} extends Base {\n` + + ` use Auditable;\n` + ` public $id;\n public $name;\n` + ` function getId() { return $this->id; }\n` + ` function setName($v) { $this->name = $v; }\n}\n\n`, @@ -144,9 +163,13 @@ const LANGS = [ fixturePrefix: 'ruby', exts: ['.rb'], file: 'bench.rb', - header: '', + // Heritage-bearing: `< Base` superclass + `include Trackable` mixin (both + // forms) so the @reference.inherits synth (#1951) is driven at scale. + header: + 'class Base\n def base_id\n @id\n end\nend\n\nmodule Trackable\n def track\n @tracked = true\n end\nend\n\n', unit: (n) => - `class Entity${n}\n` + + `class Entity${n} < Base\n` + + ` include Trackable\n` + ` def get_id\n @id\n end\n` + ` def set_name(v)\n @name = v\n end\nend\n\n`, }, @@ -162,18 +185,106 @@ const LANGS = [ ' PROCEDURE DIVISION.\n', unit: (n) => ` PARA-${String(n).padStart(5, '0')}.\n DISPLAY "P${n}".\n`, }, + { + name: 'c', + emit: emitCScopeCaptures, + fixturePrefix: 'c', + exts: ['.c', '.h'], + file: 'bench.c', + // C has no inheritance construct — flat scale source. Added (was unbenched); + // adding it exposed + fixed the same O(n²) findNodeAtRange root-walk (#1956). + header: '#include \n#include \n\ntypedef int64_t id_t;\n\n', + unit: (n) => + `typedef struct Entity${n} {\n id_t id;\n const char *name;\n} Entity${n};\n\n` + + `id_t entity_${n}_get_id(Entity${n} *e) { return e->id; }\n` + + `void entity_${n}_set_name(Entity${n} *e, const char *v) { e->name = v; }\n\n`, + }, + { + name: 'cpp', + emit: emitCppScopeCaptures, + fixturePrefix: 'cpp', + exts: ['.cpp', '.cc', '.cxx', '.hpp', '.h'], + file: 'bench.cpp', + // Heritage-bearing: `: public Base, public Mixin` (single + multiple + // inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added + // (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956). + header: + '#include \n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n', + unit: (n) => + `class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` + + ` long getId() const { return id; }\n` + + ` void setName(std::string v) { name = v; }\n};\n\n`, + }, { name: 'swift', emit: emitSwiftScopeCaptures, fixturePrefix: 'swift', exts: ['.swift'], file: 'bench.swift', - header: '', + // Heritage-bearing: inherits Base + conforms to Serviceable (both forms) so + // the @reference.inherits synth (#1951) is driven at scale. + header: + 'class Base {\n func ping() -> String { return "base" }\n}\n\nprotocol Serviceable {\n func serve() -> String\n}\n\n', unit: (n) => - `class Entity${n} {\n` + + `class Entity${n}: Base, Serviceable {\n` + ` var id: Int64 = 0\n var name: String = ""\n` + ` func getId() -> Int64 { return self.id }\n` + - ` func setName(_ v: String) { self.name = v }\n}\n\n`, + ` func serve() -> String { return self.name }\n}\n\n`, + }, + { + name: 'java', + emit: emitJavaScopeCaptures, + fixturePrefix: 'java', + exts: ['.java'], + file: 'bench.java', + // Java was previously unbenched. Heritage-bearing: extends Base + implements + // Marker (both forms) so the @reference.inherits synth (#1951) is driven at scale. + header: 'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\n', + unit: (n) => + `class Entity${n} extends Base implements Marker {\n` + + ` long id = 0L;\n String name = "";\n` + + ` public long getId() { return this.id; }\n` + + ` public void setName(String v) { this.name = v; }\n}\n\n`, + }, + { + name: 'typescript', + emit: emitTsScopeCaptures, + fixturePrefix: 'typescript', + exts: ['.ts', '.tsx'], + file: 'bench.ts', + // Inheritance-bearing units so the @reference.inherits synth pass (#1951) + // is exercised at scale, not just the base capture loop. + header: 'class Base {}\n\n', + unit: (n) => + `class Entity${n} extends Base {\n` + + ` id: number = 0;\n name: string = '';\n` + + ` getId(): number { return this.id; }\n` + + ` setName(v: string): void { this.name = v; }\n}\n\n`, + }, + { + name: 'javascript', + emit: emitJsScopeCaptures, + fixturePrefix: 'javascript', + exts: ['.js', '.jsx', '.mjs', '.cjs'], + file: 'bench.js', + header: 'class Base {}\n\n', + unit: (n) => + `class Entity${n} extends Base {\n` + + ` getId() { return this.id; }\n` + + ` setName(v) { this.name = v; }\n}\n\n`, + }, + { + name: 'kotlin', + emit: emitKotlinScopeCaptures, + fixturePrefix: 'kotlin', + exts: ['.kt', '.kts'], + file: 'bench.kt', + header: 'open class Base\n\n', + unit: (n) => + `class Entity${n} : Base() {\n` + + ` var id: Long = 0\n var name: String = ""\n` + + ` fun getId(): Long { return id }\n` + + ` fun setName(v: String) { name = v }\n}\n\n`, }, ]; diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index c223bd286..576e15afc 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -20,6 +20,7 @@ import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared'; +import { isRegistryPrimary } from './registry-primary-flag.js'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { parseSourceSafe } from '../tree-sitter/safe-parse.js'; @@ -202,6 +203,10 @@ export const processHeritage = async ( // 1. Check language support const language = getLanguageFromFilename(file.path); if (!language) continue; + // Registry-primary gate: the scope-based phase owns inheritance (EXTENDS/ + // IMPLEMENTS) for this language, so the legacy `@heritage` pass skips it — + // mirrors `call-processor`/`import-processor` (#1951). + if (isRegistryPrimary(language)) continue; if (!isLanguageAvailable(language)) { if (skippedByLang) { skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index d075dbfbb..85dfe6118 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -1,6 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { - findNodeAtRange, + nodeIfType, nodeToCapture, syntheticCapture, type SyntaxNode, @@ -33,17 +33,30 @@ export function emitCScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The tree-sitter query already + // hands us each matched node as `c.node`, so anchors resolve via a + // type-guarded lookup (`nodeIfType`) instead of re-deriving them with + // `findNodeAtRange(tree.rootNode, ...)` per match — the + // O(matches × rootChildren) root-walk fixed for go #1848 / python #1918 / + // rust/csharp #1915 / java #1951, mirrored here for C. Every C scope-query + // anchor below captures directly ON the node the old root-walk re-derived + // (verified against C_SCOPE_QUERY in query.ts: @import.statement on + // preproc_include, @declaration.function on function_definition/declaration, + // @reference.call.free/.member on call_expression), so the type check is + // exact. C has no inheritance construct, so there is no heritage synthesis. + const nodeMap: Record = {}; for (const c of m.captures) { const tag = '@' + c.name; if (tag.startsWith('@_')) continue; grouped[tag] = nodeToCapture(tag, c.node); + nodeMap[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; - // Handle #include statements + // Handle #include statements. `@import.statement` is captured directly on + // the `preproc_include` node. if (grouped['@import.statement'] !== undefined) { - const anchor = grouped['@import.statement']!; - const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + const includeNode = nodeIfType(nodeMap['@import.statement'], 'preproc_include'); if (includeNode !== null) { const split = splitCInclude(includeNode); if (split !== null) { @@ -71,12 +84,16 @@ export function emitCScopeCaptures( if (concreteTypedefRanges.has(key)) continue; } - // Enrich function declarations with arity metadata and detect static linkage - const declAnchor = grouped['@declaration.function']; - if (declAnchor !== undefined) { - const fnNode = - findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? - findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration'); + // Enrich function declarations with arity metadata and detect static linkage. + // `@declaration.function` is captured directly on the `function_definition` + // node (definitions) or the `declaration` node (prototypes) — the captured + // node IS what the old findNodeAtRange re-derived. + if (grouped['@declaration.function'] !== undefined) { + const fnNode = nodeIfType( + nodeMap['@declaration.function'], + 'function_definition', + 'declaration', + ); if (fnNode !== null) { const arity = computeCDeclarationArity(fnNode); if (arity.parameterCount !== undefined) { @@ -111,10 +128,12 @@ export function emitCScopeCaptures( } } - // Enrich call references with arity - const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member']; - if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression'); + // Enrich call references with arity. @reference.call.free / .member are both + // captured directly on the `call_expression` node — the captured node IS + // what the old findNodeAtRange re-derived. + const callAnchorNode = nodeMap['@reference.call.free'] ?? nodeMap['@reference.call.member']; + if (callAnchorNode !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = nodeIfType(callAnchorNode, 'call_expression'); if (callNode !== null) { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 29c091ce4..de52fee8a 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -1,6 +1,6 @@ import type { Capture, CaptureMatch, ParameterTypeClass } from 'gitnexus-shared'; import { - findNodeAtRange, + nodeIfType, nodeToCapture, syntheticCapture, type SyntaxNode, @@ -41,17 +41,28 @@ export function emitCppScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The tree-sitter query already + // hands us each matched node as `c.node`, so anchors resolve via a + // type-guarded lookup (`nodeIfType`) instead of re-deriving them with + // `findNodeAtRange(tree.rootNode, ...)` per match — the + // O(matches × rootChildren) root-walk fixed for go #1848 / python #1918 / + // rust/csharp #1915 / java, mirrored here for C++ (#1951). Each C++ + // scope-query anchor used below captures directly ON the node the old + // root-walk re-derived (verified against CPP_SCOPE_QUERY in query.ts and a + // real-parse AST probe), so the type check is exact. + const nodeMap: Record = {}; for (const c of m.captures) { const tag = '@' + c.name; if (tag.startsWith('@_')) continue; grouped[tag] = nodeToCapture(tag, c.node); + nodeMap[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; // ── Handle #include statements ────────────────────────────────── + // `@import.statement` is captured directly on the `preproc_include` node. if (grouped['@import.statement'] !== undefined) { - const anchor = grouped['@import.statement']!; - const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include'); + const includeNode = nodeIfType(nodeMap['@import.statement'], 'preproc_include'); if (includeNode !== null) { const split = splitCppInclude(includeNode); if (split !== null) { @@ -62,9 +73,9 @@ export function emitCppScopeCaptures( } // ── Handle using declarations (using namespace / using name) ──── + // `@import.using-decl` is captured directly on the `using_declaration` node. if (grouped['@import.using-decl'] !== undefined) { - const anchor = grouped['@import.using-decl']!; - const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration'); + const usingNode = nodeIfType(nodeMap['@import.using-decl'], 'using_declaration'); if (usingNode !== null) { const split = splitCppUsingDecl(usingNode); if (split !== null) { @@ -93,12 +104,18 @@ export function emitCppScopeCaptures( } // ── Enrich function/method declarations with arity metadata ───── - const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method']; - if (declAnchor !== undefined) { - const fnNode = - findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ?? - findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ?? - findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration'); + // `@declaration.function` / `@declaration.method` capture directly on the + // `function_definition` (definitions/templates), `declaration` (free/ + // constructor prototypes), or `field_declaration` (class-body method + // prototypes) node — the node the old findNodeAtRange re-derived. + const declAnchorNode = nodeMap['@declaration.function'] ?? nodeMap['@declaration.method']; + if (declAnchorNode !== undefined) { + const fnNode = nodeIfType( + declAnchorNode, + 'function_definition', + 'declaration', + 'field_declaration', + ); if (fnNode !== null) { const arity = computeCppDeclarationArity(fnNode); if (arity.parameterCount !== undefined) { @@ -182,9 +199,9 @@ export function emitCppScopeCaptures( } // ── Detect static variables (file-local linkage) ──────────────── - const varDeclAnchor = grouped['@declaration.variable']; - if (varDeclAnchor !== undefined) { - const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration'); + // `@declaration.variable` is captured directly on the `declaration` node. + if (grouped['@declaration.variable'] !== undefined) { + const varNode = nodeIfType(nodeMap['@declaration.variable'], 'declaration'); if (varNode !== null) { if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) { const nameText = grouped['@declaration.name']?.text; @@ -196,22 +213,30 @@ export function emitCppScopeCaptures( } // ── Enrich call references with arity ─────────────────────────── + // `@reference.call.free` / `.member` capture on the `call_expression` (plain + // / member / template calls) or on the `binary_expression` (the operator-call + // patterns: `a + b`, `lhs << rhs`); `@reference.call.qualified` always on the + // `call_expression`. The captured node IS the node the old findNodeAtRange + // re-derived (verified against CPP_SCOPE_QUERY + a real-parse probe). const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member'] ?? grouped['@reference.call.qualified']; + const callAnchorNode = + nodeMap['@reference.call.free'] ?? + nodeMap['@reference.call.member'] ?? + nodeMap['@reference.call.qualified']; const operatorAnchor = grouped['@reference.operator']; if (operatorAnchor !== undefined) { + // When `@reference.operator` fires, the co-captured call anchor is the + // enclosing `binary_expression` itself, so a type guard reproduces the + // old findNodeAtRange(callAnchor.range, 'binary_expression'). const operatorNode = - callAnchor !== undefined - ? findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression') - : null; + callAnchorNode !== undefined ? nodeIfType(callAnchorNode, 'binary_expression') : null; if (operatorNode !== null && isPrimitiveOnlyBinaryOperator(operatorNode)) continue; } - if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = - findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? - findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression'); + if (callAnchorNode !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = nodeIfType(callAnchorNode, 'call_expression', 'binary_expression'); if (callNode?.type === 'call_expression') { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', @@ -228,17 +253,25 @@ export function emitCppScopeCaptures( } if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) { + // The old code did `findNodeAtRange(tree.rootNode, operatorAnchor.range, + // operatorAnchor.text)`, searching for a node of type `+` / `<<` at the + // operator-token range. That token is an UNNAMED grammar node, and + // findNodeAtRange only descends `namedChild`ren, so the search NEVER hit + // and ALWAYS fell back to `tree.rootNode`. Use `tree.rootNode` directly to + // preserve the exact synthetic-capture range while dropping the root-walk. grouped['@reference.name'] = syntheticCapture( '@reference.name', - findNodeAtRange(tree.rootNode, operatorAnchor.range, operatorAnchor.text) ?? tree.rootNode, + tree.rootNode, `operator${operatorAnchor.text}`, ); } // ── Enrich constructor calls (new Foo()) with arity ───────────── + // `@reference.call.constructor` is captured directly on the `new_expression`. const ctorCallAnchor = grouped['@reference.call.constructor']; + const ctorCallAnchorNode = nodeMap['@reference.call.constructor']; if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) { - const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression'); + const newNode = nodeIfType(ctorCallAnchorNode, 'new_expression'); if (newNode !== null) { grouped['@reference.arity'] = syntheticCapture( '@reference.arity', @@ -249,12 +282,18 @@ export function emitCppScopeCaptures( } // ── Synthesize argument types for overload narrowing ──────────── + // The any-call anchor is either the call/operator anchor (`call_expression` + // / `binary_expression`) or the constructor anchor (`new_expression`); the + // captured node IS what the old findNodeAtRange re-derived. const anyCallAnchor = callAnchor ?? ctorCallAnchor; + const anyCallAnchorNode = callAnchorNode ?? ctorCallAnchorNode; if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) { - const cNode = - findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ?? - findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression') ?? - findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'binary_expression'); + const cNode = nodeIfType( + anyCallAnchorNode, + 'call_expression', + 'new_expression', + 'binary_expression', + ); if (cNode !== null) { const argTypes = cNode.type === 'binary_expression' @@ -293,13 +332,12 @@ export function emitCppScopeCaptures( // `@declaration.namespace` fires only for NAMED namespaces (the query // requires a `name: (namespace_identifier)` child). Use the unconditional // `@scope.namespace` capture so the anonymous-namespace branch also runs. - const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace']; - if (namespaceScopeAnchor !== undefined) { - const nsNode = findNodeAtRange( - tree.rootNode, - namespaceScopeAnchor.range, - 'namespace_definition', - ); + // `@declaration.namespace` and `@scope.namespace` both capture directly on + // the `namespace_definition` node. + const namespaceScopeAnchorNode = + nodeMap['@declaration.namespace'] ?? nodeMap['@scope.namespace']; + if (namespaceScopeAnchorNode !== undefined) { + const nsNode = nodeIfType(namespaceScopeAnchorNode, 'namespace_definition'); if (nsNode !== null) { // Range coords stored in the shared Range shape use 1-based // line numbers (see `ast-helpers.ts` rangeForNode where @@ -329,11 +367,11 @@ export function emitCppScopeCaptures( // qualified `Ns::f(s)` and member `obj.f(s)` calls bypass the // free-call fallback entirely (handled by receiver-bound-calls). if (grouped['@reference.call.free'] !== undefined) { - const freeCallNode = findNodeAtRange( - tree.rootNode, - grouped['@reference.call.free']!.range, - 'call_expression', - ); + // `@reference.call.free` captures on a `call_expression` (plain/template + // free call) or a `binary_expression` (the `lhs << rhs` operator-call + // pattern). The old findNodeAtRange filtered to `call_expression`, so the + // `binary_expression` case yields null here — `nodeIfType` matches exactly. + const freeCallNode = nodeIfType(nodeMap['@reference.call.free'], 'call_expression'); if (freeCallNode !== null) { const adlAnchorRange = grouped['@reference.call.free']!.range; if (isParenthesizedFunctionCall(freeCallNode)) { @@ -358,7 +396,8 @@ export function emitCppScopeCaptures( grouped['@type-binding.type']?.text === 'auto' ) { const anchor = grouped['@type-binding.assignment']!; - const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration'); + // `@type-binding.assignment` is captured directly on the `declaration` node. + const declNode = nodeIfType(nodeMap['@type-binding.assignment'], 'declaration'); if (declNode !== null) { const declarator = declNode.childForFieldName('declarator'); if (declarator?.type === 'init_declarator') { diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 2dba41803..d052ed080 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -17,7 +17,12 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { + nodeIfType, + nodeToCapture, + syntheticCapture, + walkNamedTree, +} from '../../utils/ast-helpers.js'; import { splitUsingDirective } from './import-decomposer.js'; import { computeCsharpArityMetadata } from './arity-metadata.js'; import { synthesizeCsharpReceiverBinding } from './receiver-binding.js'; @@ -257,15 +262,65 @@ export function emitCsharpScopeCaptures( } out.push(...synthesizeGenericTypeArgumentReferences(tree.rootNode)); + out.push(...synthesizeCsharpInheritanceReferences(tree.rootNode)); return out; } +/** + * Synthesize `@reference.inherits` captures from C# base lists so the + * registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges + * (mirrors C++ `emitCppInheritanceCaptures`). Without this, C# inheritance + * edges came only from the legacy `@heritage.*` path, which is dropped for + * registry-primary languages in the worker pipeline (issue #1951). + * + * Scope covers every `base_list`-bearing declaration the legacy `@heritage` + * leg matches: `class_declaration`, `interface_declaration`, + * `record_declaration`, and `struct_declaration`. Records and structs were + * dropped before (#1951): a `record R(...) : Base(args), IFoo` or + * `struct S : IFoo, ns.IBar` produced no registry-primary inheritance edge + * even though the legacy heritage query covered them. The + * EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved target's + * symbol kind (`preEmitInheritanceEdges`), so all bases are emitted with the + * same `inherits` kind here; the base lookup name is normalized to its bare + * simple identifier (`IRepository` → `IRepository`, `A.B.IFace` → `IFace`, + * `Base(args)` primary-ctor base → `Base`, `MyAlias::Foo` → `Foo`) to match + * the V1 simple-name `findClassBindingInScope` contract — exactly the bare + * text `normalizeSupertypeName` (supertype-alternation.ts) reduces each shape + * to on the legacy leg. + */ +function synthesizeCsharpInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if ( + node.type !== 'class_declaration' && + node.type !== 'interface_declaration' && + node.type !== 'record_declaration' && + node.type !== 'struct_declaration' + ) { + return; + } + const baseList = findNamedChild(node, 'base_list'); + if (baseList === null) return; + for (const base of baseList.namedChildren) { + if (base === null) continue; + const nameNode = terminalTypeNameNode(base); + if (nameNode === null) continue; + if (BUILTIN_TYPE_NAMES.has(nameNode.text)) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + }); + return out; +} + function synthesizeGenericTypeArgumentReferences(root: SyntaxNode): CaptureMatch[] { const out: CaptureMatch[] = []; // Treat all generic type arguments as static type references, including // declaration signatures and call-site generic instantiations. - visit(root, (node) => { + walkNamedTree(root, (node) => { if (node.type !== 'generic_name') return; const args = findNamedChild(node, 'type_argument_list'); if (args === null) return; @@ -290,8 +345,28 @@ function terminalTypeNameNode(node: SyntaxNode): SyntaxNode | null { return node; case 'nullable_type': return node.firstNamedChild === null ? null : terminalTypeNameNode(node.firstNamedChild); - case 'qualified_name': - return node.lastNamedChild; + case 'qualified_name': { + // `A.B.Base` -> tail identifier `Base`; `A.B.Base` -> the tail is a + // `generic_name`, so recurse to drop the type arguments and reach the + // bare base identifier (#1951). + const tail = node.lastNamedChild; + return tail === null ? null : terminalTypeNameNode(tail); + } + case 'alias_qualified_name': { + // `MyAlias::Foo` / `global::IDisposable` -> the `name` field is the bare + // identifier (the `alias` is the qualifier). Mirrors + // normalizeSupertypeName's `name`-field reduction for this shape (#1951). + const name = node.childForFieldName('name'); + return name === null ? null : terminalTypeNameNode(name); + } + case 'primary_constructor_base_type': { + // record base with a constructor call: `Base(args)` / `pkg.Base(id)` / + // `Box(id)`. The `type` field holds the supertype (identifier / + // qualified_name / generic_name); the trailing argument_list is dropped. + // Mirrors normalizeSupertypeName's `type`-field reduction (#1951). + const type = node.childForFieldName('type'); + return type === null ? null : terminalTypeNameNode(type); + } case 'generic_name': // generic_name has no `name` field (verified by real parse, #1920); the // base identifier is the first named child. @@ -308,13 +383,6 @@ function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { return null; } -function visit(node: SyntaxNode, cb: (node: SyntaxNode) => void): void { - cb(node); - for (const child of node.namedChildren) { - if (child !== null) visit(child, cb); - } -} - /** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`. * The parameters are a bare `parameter_list` named child of the type * declaration (no `constructor_declaration` node). Emit a synthetic diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index 5bc38b73e..f835bc79a 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -1,5 +1,10 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { + nodeToCapture, + syntheticCapture, + walkNamedTree, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; import { getGoParser, getGoScopeQuery } from './query.js'; import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js'; import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js'; @@ -159,9 +164,140 @@ export function emitGoScopeCaptures( }); } + out.push(...synthesizeGoInheritanceReferences(tree.rootNode)); + return out; } +/** + * Synthesize `@reference.inherits` captures for Go struct embedding so the + * registry-primary scope-resolution path emits inheritance edges (mirrors C# + * `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`). + * Without this, Go embedding edges came only from the legacy `@heritage.*` + * path, which is dropped for registry-primary languages in the worker pipeline + * (issue #1951). + * + * Scope EXACTLY matches the legacy Go heritage query + its `shouldSkipExtends` + * hook (`heritage-extractors/configs/go.ts`), whose supertype alternation is + * `[(type_identifier) (qualified_type) (generic_type)]` (see `goHeritageShapes`) + * matched against BOTH struct embedding and interface-in-interface embedding: + * + * struct: (struct_type (field_declaration_list + * (field_declaration type: ))) — anonymous (no `name`) field + * interface: (interface_type (type_elem )) — single-element type_elem + * + * i.e. an embedded (anonymous) field inside a struct, or an embedded type inside + * an interface. Named struct fields (`Breed string`) are skipped because their + * `field_declaration` carries a `name` field; multi-operand interface type-sets + * (`int | float64`) are skipped because their `type_elem` has >1 named child — + * both matching the legacy `shouldSkipExtends` filter. + * + * The base shapes covered (issue #1951 — these were previously DROPPED by the + * registry-primary synth, so production silently omitted their edges even though + * the legacy `@heritage` leg, config-driven since #1940, captured them): + * - bare `type_identifier` (`Base`) → the node itself + * - `qualified_type` (`pkg.Base`) → `name:` tail + * - `generic_type` (`Box[T]`) → `type:` base + * - pointer embeds (`*Base`, `*pkg.Base`, …) → the `*` is an + * unnamed token, so `field_declaration.type` already points at the inner + * shape above; no `pointer_type` unwrap is needed in this grammar version. + * + * The captured `@reference.name` is reduced to its bare simple identifier so the + * V1 simple-name `findClassBindingInScope` contract keeps holding — `pkg.Base` + * → `Base`, `Box[T]` → `Box`. {@link goEmbedBaseNameNode} returns the node whose + * `.text` EQUALS `normalizeSupertypeName(base)` for every shape (verified by + * real-parse), so this synth stays at parity with the legacy leg's reduction. + * For a bare `type_identifier` it returns the same node, keeping the simple-base + * path byte-identical. + * + * The EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved + * target's symbol kind (`preEmitInheritanceEdges`): an embedded struct resolves + * to EXTENDS, an embedded interface to IMPLEMENTS. + */ +function synthesizeGoInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'type_declaration') return; + for (const spec of node.namedChildren) { + if (spec.type !== 'type_spec') continue; + const typeNode = spec.childForFieldName('type'); + if (typeNode === null) continue; + if (typeNode.type === 'struct_type') { + const fieldList = findNamedChildOfType(typeNode, 'field_declaration_list'); + if (fieldList === null) continue; + for (const field of fieldList.namedChildren) { + if (field.type !== 'field_declaration') continue; + // Embedded (anonymous) field: no `name` field. Named fields are + // skipped (legacy `shouldSkipExtends`). + if (field.childForFieldName('name') !== null) continue; + // `field.type` is the embedded base — bare/qualified/generic, with any + // `*` pointer marker as an unnamed sibling token (already unwrapped). + emitGoEmbedInheritance(field.childForFieldName('type'), out); + } + } else if (typeNode.type === 'interface_type') { + for (const elem of typeNode.namedChildren) { + // Only `type_elem` (an embedded type); `method_elem` is a method, not + // an embed. Multi-operand type-sets (`int | float64`) parse as a + // `type_elem` with >1 named child — skip them (legacy + // `shouldSkipExtends`); a single-element `type_elem` is the embed. + if (elem.type !== 'type_elem' || elem.namedChildCount !== 1) continue; + emitGoEmbedInheritance(elem.namedChild(0), out); + } + } + } + }); + return out; +} + +/** + * Emit one `@reference.inherits` / `@reference.name` match for a Go embed base + * node, reducing the name to its bare simple identifier. No-ops when `baseNode` + * is null or not one of the embed shapes. + */ +function emitGoEmbedInheritance(baseNode: SyntaxNode | null, out: CaptureMatch[]): void { + if (baseNode === null) return; + const nameNode = goEmbedBaseNameNode(baseNode); + if (nameNode === null) return; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', baseNode), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); +} + +/** + * Reduce a Go embed base node to its trailing bare `type_identifier`, matching + * the node shapes the legacy `@heritage` query accepts (`goHeritageShapes`) and + * the reduction `normalizeSupertypeName` performs (verified by real-parse to + * yield an identical `.text` for each shape): + * - `type_identifier` → the node itself (`Base`) + * - `qualified_type` name: (type_identifier) → the trailing `name:` id + * (`pkg.Base` → `Base`) + * - `generic_type` type: → recurse into `type:` + * (`Box[T]` → `Box`) + * Any other node type returns null (no edge), keeping this emitter at parity + * with the legacy leg. + */ +function goEmbedBaseNameNode(node: SyntaxNode): SyntaxNode | null { + if (node.type === 'type_identifier') return node; + if (node.type === 'qualified_type') { + const name = node.childForFieldName('name'); + return name !== null ? goEmbedBaseNameNode(name) : null; + } + if (node.type === 'generic_type') { + const inner = node.childForFieldName('type'); + return inner !== null ? goEmbedBaseNameNode(inner) : null; + } + return null; +} + +/** First named child of `node` matching `type`, else null. */ +function findNamedChildOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child.type === type) return child; + } + return null; +} + /** * Resolve the node passed to `splitGoImportStatement` for an @import.statement * match. The capture is on the `import_spec`; the original preferred an diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index f92227631..e00d0c2d0 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -15,7 +15,7 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; import { splitImportDeclaration } from './import-decomposer.js'; import { computeJavaArityMetadata } from './arity-metadata.js'; import { synthesizeJavaReceiverBinding } from './receiver-binding.js'; @@ -65,16 +65,26 @@ export function emitJavaScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The tree-sitter query already + // hands us each matched node as `c.node`, so anchors resolve via a + // type-guarded lookup (`nodeIfType`) instead of re-deriving them with + // `findNodeAtRange(tree.rootNode, ...)` per match — the + // O(matches × rootChildren) root-walk fixed for go #1848 / python #1918 / + // rust/csharp #1915, mirrored here for java (#1951). Every Java scope-query + // anchor below captures directly ON the node the old root-walk re-derived + // (verified against JAVA_SCOPE_QUERY in query.ts), so the type check is exact. + const nodeMap: Record = {}; for (const c of m.captures) { const tag = '@' + c.name; grouped[tag] = nodeToCapture(tag, c.node); + nodeMap[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; - // Decompose each `import_declaration`. + // Decompose each `import_declaration`. `@import.statement` is captured + // directly on the `import_declaration` node. if (grouped['@import.statement'] !== undefined) { - const stmtCapture = grouped['@import.statement']; - const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration'); + const stmtNode = nodeIfType(nodeMap['@import.statement'], 'import_declaration'); if (stmtNode !== null) { const decomposed = splitImportDeclaration(stmtNode); if (decomposed !== null) { @@ -101,9 +111,9 @@ export function emitJavaScopeCaptures( } // Filter read.member when it's a child of method_invocation or assignment. + // `@reference.read.member` is captured directly on the `field_access` node. if (grouped['@reference.read.member'] !== undefined) { - const anchor = grouped['@reference.read.member']; - const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access'); + const memberNode = nodeIfType(nodeMap['@reference.read.member'], 'field_access'); if (memberNode === null || !shouldEmitReadMember(memberNode)) { continue; } @@ -113,8 +123,8 @@ export function emitJavaScopeCaptures( // instance method-like. if (grouped['@scope.function'] !== undefined) { out.push(grouped); - const anchor = grouped['@scope.function']!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + // `@scope.function` is captured directly on the method/constructor node. + const fnNode = findFunctionNode(nodeMap['@scope.function']); if (fnNode !== null) { for (const synth of synthesizeJavaReceiverBinding(fnNode)) { out.push(synth); @@ -126,8 +136,9 @@ export function emitJavaScopeCaptures( // Synthesize arity metadata on function-like declarations. const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); if (declTag !== undefined) { - const anchor = grouped[declTag]!; - const fnNode = findFunctionNode(tree.rootNode, anchor.range); + // FUNCTION_DECL_TAGS (@declaration.method/.constructor) are captured + // directly on the method/constructor node. + const fnNode = findFunctionNode(nodeMap[declTag]); if (fnNode !== null) { const arity = computeJavaArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -159,10 +170,14 @@ export function emitJavaScopeCaptures( ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const ).find((t) => grouped[t] !== undefined); if (callTag !== undefined && grouped['@reference.arity'] === undefined) { - const anchor = grouped[callTag]!; - const callNode = - findNodeAtRange(tree.rootNode, anchor.range, 'method_invocation') ?? - findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression'); + // @reference.call.free/.member are captured on the `method_invocation`; + // @reference.call.constructor on the `object_creation_expression`. The + // captured node IS the call node the old findNodeAtRange re-derived. + const callNode = nodeIfType( + nodeMap[callTag], + 'method_invocation', + 'object_creation_expression', + ); if (callNode !== null) { const argList = callNode.childForFieldName('arguments'); // Exclude interleaved comments — tree-sitter-java emits `block_comment` / @@ -201,7 +216,116 @@ export function emitJavaScopeCaptures( out.push(grouped); } - return resolveVarTypeBindings(out); + return [...resolveVarTypeBindings(out), ...synthesizeJavaInheritanceReferences(tree.rootNode)]; +} + +/** + * Synthesize `@reference.inherits` captures from Java class heritage so the + * registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges + * (mirrors C++ `emitCppInheritanceCaptures`). Without this, Java inheritance + * edges came only from the legacy `@heritage.*` path, which is dropped for + * registry-primary languages in the worker pipeline (issue #1951). + * + * Scope covers `class_declaration` (`superclass` extends + `interfaces` + * implements clauses) AND `interface_declaration` (`extends_interfaces` → + * interface-to-interface EXTENDS), matching the legacy Java heritage query + * (tree-sitter-queries.ts), which has a dedicated `interface_declaration + * (extends_interfaces (type_list …))` arm. Without the interface arm the + * registry-primary synth silently dropped every `interface IA extends IB` + * edge while the legacy leg emitted it — the exact =0/=N parity break #1951 + * targets. Enum/record heritage stays unemitted (no legacy arm). Generic + * bases (`extends Box`, `implements IFoo`) ARE emitted here: the legacy + * `@heritage` query was widened to capture the inner `type_identifier` of a + * `generic_type` (tree-sitter-queries.ts), so both paths now agree on SIMPLE + * (unqualified) generic bases — the more-correct behavior, consistent with + * C#/Rust (#1951). Qualified bases (`a.b.Base`, `a.b.Box`, `a.b.IFoo`) are + * ALSO now at parity (#1956 tri-review U2): the synth resolves them by their + * `scoped_type_identifier` tail, and the legacy `@heritage` query was widened + * with matching `scoped_type_identifier` arms (plain + generic-wrapped). The + * EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved target's + * symbol kind (`preEmitInheritanceEdges`): a superclass resolves to a class + * (EXTENDS), an implemented interface resolves to an interface (IMPLEMENTS). + * An `interface IA extends IB` base resolves to an Interface too, so it is + * emitted as IMPLEMENTS — matching the legacy `interface_declaration` arm, + * which tags the bases `@heritage.impl` (`kind: 'implements'`) and likewise + * resolves them as interfaces. The synth therefore does not need to know the + * declaration's own kind; it only emits inherits sites and lets the resolved + * target decide the edge type. + * Base names are normalized to their bare simple identifier (`Box` → `Box`, + * `java.io.Serializable` → `Serializable`) to match the V1 simple-name + * `findClassBindingInScope` contract. + */ +function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'class_declaration') { + const superclass = node.childForFieldName('superclass'); + if (superclass !== null) { + for (const base of superclass.namedChildren) emitJavaInheritanceBase(out, base); + } + const interfaces = node.childForFieldName('interfaces'); + if (interfaces !== null) { + for (const typeList of interfaces.namedChildren) { + if (typeList === null || typeList.type !== 'type_list') continue; + for (const base of typeList.namedChildren) emitJavaInheritanceBase(out, base); + } + } + } else if (node.type === 'interface_declaration') { + // `interface IA extends IB, IC` — the `extends_interfaces` clause is + // NOT exposed via a tree-sitter field (unlike a class's `superclass` / + // `interfaces`), so scan named children for it. It wraps a `type_list` + // whose bases reuse `javaBaseLookupNameNode` (handles type_identifier / + // generic_type / scoped_type_identifier). These resolve to Interface + // targets, so `preEmitInheritanceEdges` emits them as IMPLEMENTS, at + // parity with the legacy `interface_declaration` @heritage.impl arm. + for (let i = 0; i < node.namedChildCount; i++) { + const extendsInterfaces = node.namedChild(i); + if (extendsInterfaces === null || extendsInterfaces.type !== 'extends_interfaces') continue; + for (const typeList of extendsInterfaces.namedChildren) { + if (typeList === null || typeList.type !== 'type_list') continue; + for (const base of typeList.namedChildren) emitJavaInheritanceBase(out, base); + } + } + } + // Named children only: every type/heritage node we care about is named, + // so skipping unnamed punctuation tokens keeps the walk single-pass and + // lighter on large files. + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) stack.push(child); + } + } + return out; +} + +function emitJavaInheritanceBase(out: CaptureMatch[], base: SyntaxNode | null): void { + if (base === null) return; + const nameNode = javaBaseLookupNameNode(base); + if (nameNode === null) return; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); +} + +/** Resolve a Java base-type node to its bare simple-name identifier node. */ +function javaBaseLookupNameNode(node: SyntaxNode): SyntaxNode | null { + switch (node.type) { + case 'type_identifier': + return node; + case 'scoped_type_identifier': + // `java.io.Serializable` → trailing `type_identifier` (`Serializable`). + return node.lastNamedChild; + case 'generic_type': { + // `Box` → recurse into the base type (`Box`). + const first = node.firstNamedChild; + return first === null ? null : javaBaseLookupNameNode(first); + } + default: + return null; + } } function resolveVarTypeBindings(matches: CaptureMatch[]): CaptureMatch[] { @@ -350,11 +474,16 @@ function inferArgType(argNode: SyntaxNode): string { } } -/** Find the first Java function-like node at the given range. */ -function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { - for (const nodeType of FUNCTION_NODE_TYPES) { - const n = findNodeAtRange(rootNode, range, nodeType); - if (n !== null) return n as SyntaxNode; - } - return null; +/** + * Resolve a Java function-like node from a query-captured node. + * + * The `@scope.function` / `@declaration.method` / `@declaration.constructor` + * anchors all capture directly on the `method_declaration` / + * `constructor_declaration` node (per JAVA_SCOPE_QUERY), so this is a type + * guard against `FUNCTION_NODE_TYPES` — the threaded-node equivalent of the + * old `findNodeAtRange(tree.rootNode, range, type)` root-walk, minus the + * O(matches × rootChildren) traversal. + */ +function findFunctionNode(node: SyntaxNode | undefined): SyntaxNode | null { + return nodeIfType(node, ...FUNCTION_NODE_TYPES); } diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index cc136d394..356ffb4c7 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -75,6 +75,45 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu return undefined; } +function pickFirstNode( + groupedNodes: Record, + tags: readonly string[], +): SyntaxNode | undefined { + for (const tag of tags) { + const node = groupedNodes[tag]; + if (node !== undefined) return node; + } + return undefined; +} + +/** Walks the parent chain from `node` (inclusive), returning the first node + * whose type matches, or null. Faster than `findNodeAtRange` when the caller + * already holds the anchor node — avoids re-scanning the tree from the root. */ +function findSelfOrAncestorOfType(node: SyntaxNode | undefined, type: string): SyntaxNode | null { + if (node === undefined) return null; + let current: SyntaxNode | null = node; + while (current !== null) { + if (current.type === type) return current; + current = current.parent; + } + return null; +} + +/** Walks the parent chain from `node` (inclusive), returning the first node + * whose type is in the set, or null. Plural form of {@link findSelfOrAncestorOfType}. */ +function findSelfOrAncestorOfTypes( + node: SyntaxNode | undefined, + types: readonly string[], +): SyntaxNode | null { + if (node === undefined) return null; + let current: SyntaxNode | null = node; + while (current !== null) { + if (types.includes(current.type)) return current; + current = current.parent; + } + return null; +} + /** Filter `@reference.read.member` in non-read contexts (same logic as TS). */ function shouldEmitReadMember(memberNode: SyntaxNode): boolean { const parent = memberNode.parent; @@ -95,8 +134,17 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean { } } -/** Find the first JS function-like node at the given range. */ -function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { +/** Find the first JS function-like node at the given range. + * Prefers the threaded anchor node (walk up its parent chain) so the common + * case avoids a root re-scan; falls back to a range scan from root only when + * the anchor isn't a function-like (or isn't supplied). */ +function findFunctionNode( + rootNode: SyntaxNode, + range: Capture['range'], + anchorNode?: SyntaxNode, +): SyntaxNode | null { + const fromAnchor = findSelfOrAncestorOfTypes(anchorNode, FUNCTION_NODE_TYPES); + if (fromAnchor !== null) return fromAnchor; for (const nodeType of FUNCTION_NODE_TYPES) { const n = findNodeAtRange(rootNode, range, nodeType); if (n !== null) return n; @@ -585,6 +633,100 @@ function synthesizeConstructorFieldBindings(root: SyntaxNode, out: CaptureMatch[ } } +// ─── Inheritance references (EXTENDS) ──────────────────────────────────── + +/** + * Synthesize `@reference.inherits` captures from JavaScript class heritage so + * the registry-primary scope-resolution path emits EXTENDS edges (mirrors C# + * `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`). + * Without this, JS inheritance edges came only from the legacy `@heritage.*` + * path, which the worker pipeline drops for registry-primary languages, + * yielding 0 inheritance edges in worker mode (issue #1951). + * + * Scope is intentionally limited to a `class_declaration`'s `class_heritage` + * base, matching the legacy JavaScript `@heritage` query's class scope and its + * supertype shape descriptor (`javascriptHeritageShapes`: + * `['identifier', 'member_expression']`). JavaScript classes have a single + * `extends` base and no `implements`, so every emission is an EXTENDS (decided + * downstream from the resolved target's symbol kind in + * `preEmitInheritanceEdges`). + * + * Bases handled (at parity with the legacy `@heritage` leg, #1951): + * - `(identifier)` base (`extends Base`) — bare simple name. + * - `(member_expression)` base (`extends ns.Base`, `extends a.b.Base`) — + * qualified; reduced to its trailing `property_identifier` (`Base`) so the + * V1 `findClassBindingInScope` simple-name contract holds. This mirrors the + * TypeScript `terminalTsTypeNameNode` member_expression arm. + * + * Deliberately NOT emitted (preserving parity with the legacy query, incl. the + * #1943 HOC behavior): + * - `class` EXPRESSION nodes (legacy captures `class_declaration` only). + * - `call_expression` / HOC bases (`extends withFoo(Bar)`) — not a legacy + * heritage shape; left to the normal call-resolution path. + * + * The `@reference.name` bare-name text emitted for each base equals + * `normalizeSupertypeName(base)` (the legacy leg's reduction): `Base` → `Base`, + * `ns.Base` → `Base`, `a.b.Base` → `Base` — keeping the two legs at parity. + */ +function synthesizeJsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + for (;;) { + const node = stack.pop(); + if (node === undefined) break; + for (const child of node.namedChildren) { + if (child !== null) stack.push(child); + } + + if (node.type !== 'class_declaration') continue; + + // Find the `class_heritage` child (holds the single `extends` base). + let heritage: SyntaxNode | null = null; + for (const child of node.namedChildren) { + if (child !== null && child.type === 'class_heritage') { + heritage = child; + break; + } + } + if (heritage === null) continue; + + // Emit for `(identifier)` and `(member_expression)` bases — matching the + // legacy heritage shape descriptor (`call_expression` HOC bases excluded). + for (const base of heritage.namedChildren) { + if (base === null) continue; + const nameNode = terminalJsHeritageNameNode(base); + if (nameNode === null) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + } +} + +/** Resolve a JavaScript heritage base node to its bare simple-identifier node. + * `Base` (identifier) → `Base`, `ns.Base` / `a.b.Base` (member_expression) → + * the trailing `property_identifier` `Base`. Mirrors the TypeScript + * `terminalTsTypeNameNode` member_expression arm. Returns null for any other + * shape (e.g. `call_expression` HOC bases), which is then skipped — keeping + * parity with the legacy `javascriptHeritageShapes` descriptor and + * `normalizeSupertypeName`'s reduction of each shape. */ +function terminalJsHeritageNameNode(node: SyntaxNode): SyntaxNode | null { + switch (node.type) { + case 'identifier': + // `extends ns.Base` parses as a member_expression whose tail is a + // `property_identifier` (not an identifier) — treat it as a leaf name. + case 'property_identifier': + return node; + case 'member_expression': { + // Qualified `ns.Base` / `a.b.Base` → tail identifier `Base`. + const tail = node.lastNamedChild; + return tail === null ? null : terminalJsHeritageNameNode(tail); + } + default: + return null; + } +} + // ─── Main emitter ────────────────────────────────────────────────────────── export function emitJsScopeCaptures( @@ -607,9 +749,17 @@ export function emitJsScopeCaptures( for (const m of rawMatches) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The query hands us each matched + // node as c.node, so anchors resolve by walking up from the captured node + // (findSelfOrAncestorOfType[s]) instead of re-deriving them with + // findNodeAtRange(tree.rootNode, ...) per match — the O(matches x N) + // root-walk fixed for go #1915 / python #1918 / csharp, mirrored here + // (mirrors typescript/captures.ts groupedNodes). + const groupedNodes: Record = {}; for (const c of m.captures) { const tag = '@' + c.name; grouped[tag] = nodeToCapture(tag, c.node); + groupedNodes[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; @@ -617,6 +767,10 @@ export function emitJsScopeCaptures( if (grouped['@import.statement'] !== undefined) { const stmtCapture = grouped['@import.statement']; const stmtNode = + findSelfOrAncestorOfTypes(groupedNodes['@import.statement'], [ + 'import_statement', + 'export_statement', + ]) ?? findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement') ?? findNodeAtRange(tree.rootNode, stmtCapture.range, 'export_statement'); if (stmtNode !== null) { @@ -629,7 +783,9 @@ export function emitJsScopeCaptures( // Decompose dynamic import() calls. if (grouped['@import.dynamic'] !== undefined) { const dynCapture = grouped['@import.dynamic']; - const callNode = findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression'); + const callNode = + findSelfOrAncestorOfType(groupedNodes['@import.dynamic'], 'call_expression') ?? + findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression'); if (callNode !== null) { const decomposed = splitImportStatement(callNode); for (const d of decomposed) out.push(d); @@ -640,7 +796,9 @@ export function emitJsScopeCaptures( // Filter @reference.read.member false-positives. if (grouped['@reference.read.member'] !== undefined) { const anchor = grouped['@reference.read.member']; - const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_expression'); + const memberNode = + findSelfOrAncestorOfType(groupedNodes['@reference.read.member'], 'member_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'member_expression'); if (memberNode === null || !shouldEmitReadMember(memberNode)) { continue; } @@ -655,7 +813,11 @@ export function emitJsScopeCaptures( // scope instead of a phantom Function. const fnDeclAnchor = grouped['@declaration.function']; if (fnDeclAnchor !== undefined) { - const arrowNode = findFunctionNode(tree.rootNode, fnDeclAnchor.range); + const arrowNode = findFunctionNode( + tree.rootNode, + fnDeclAnchor.range, + groupedNodes['@declaration.function'], + ); if (arrowNode !== null && isArrayMethodCallbackArrow(arrowNode)) { continue; } @@ -665,7 +827,11 @@ export function emitJsScopeCaptures( } if (fnDeclAnchor !== undefined) { - const fnNode = findFunctionNode(tree.rootNode, fnDeclAnchor.range); + const fnNode = findFunctionNode( + tree.rootNode, + fnDeclAnchor.range, + groupedNodes['@declaration.function'], + ); if (fnNode !== null && isDefaultExportHocFunctionNode(fnNode)) { grouped['@declaration.name'] = syntheticCapture( '@declaration.name', @@ -677,8 +843,9 @@ export function emitJsScopeCaptures( // Synthesize arity metadata on function-like declarations. const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS); + const declAnchorNode = pickFirstNode(groupedNodes, FUNCTION_DECL_TAGS); if (declAnchor !== undefined) { - const fnNode = findFunctionNode(tree.rootNode, declAnchor.range); + const fnNode = findFunctionNode(tree.rootNode, declAnchor.range, declAnchorNode); if (fnNode !== null) { const arity = computeTsArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -705,10 +872,26 @@ export function emitJsScopeCaptures( } } - // Synthesize @reference.arity on callsites. + // Synthesize @reference.arity on callsites. Skip JSX element anchors: a JSX + // component used as a call argument (e.g. `render()`) is itself a + // @reference.call.* anchor, and the ascent below would climb into the + // enclosing call_expression and mis-attribute that call's arity to the + // component. A JSX component reference has no call arity here — this restores + // the pre-#1951 range-based behavior (no call_expression at the JSX range). + // The guard lives at this call site, not inside findSelfOrAncestorOfTypes, + // which is also used by the import-statement and function-scope ascents. const callAnchor = pickFirstDefined(grouped, CALL_TAGS); - if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callAnchorNode = pickFirstNode(groupedNodes, CALL_TAGS); + const anchorIsJsxElement = + callAnchorNode?.type === 'jsx_self_closing_element' || + callAnchorNode?.type === 'jsx_opening_element'; + if ( + callAnchor !== undefined && + grouped['@reference.arity'] === undefined && + !anchorIsJsxElement + ) { const callNode = + findSelfOrAncestorOfTypes(callAnchorNode, ['call_expression', 'new_expression']) ?? findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression'); if (callNode !== null) { @@ -737,7 +920,11 @@ export function emitJsScopeCaptures( // Synthesize `this` receiver type-bindings on class member functions. const scopeFnAnchor = grouped['@scope.function']; if (scopeFnAnchor !== undefined) { - const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range); + const fnNode = findFunctionNode( + tree.rootNode, + scopeFnAnchor.range, + groupedNodes['@scope.function'], + ); if (fnNode !== null) { const synth = synthesizeTsReceiverBinding(fnNode); if (synth !== null) out.push(synth); @@ -752,6 +939,7 @@ export function emitJsScopeCaptures( synthesizeDestructuringBindings(tree.rootNode, out); synthesizeForOfMapTupleBindings(tree.rootNode, out); synthesizeInstanceofNarrowings(tree.rootNode, out); + synthesizeJsInheritanceReferences(tree.rootNode, out); return out; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 2fd8fac8e..6b6cca104 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -1,6 +1,6 @@ -import { makeScopeId, type Capture, type CaptureMatch, type Range } from 'gitnexus-shared'; +import { makeScopeId, type Capture, type CaptureMatch } from 'gitnexus-shared'; import { - findNodeAtRange, + nodeIfType, nodeToCapture, syntheticCapture, type SyntaxNode, @@ -38,12 +38,20 @@ export function emitKotlinScopeCaptures( out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes)); out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode)); out.push(...synthesizeKotlinLambdaBindings(tree.rootNode, returnTypes)); + out.push(...synthesizeKotlinInheritanceReferences(tree.rootNode)); for (const match of getKotlinScopeQuery().matches(tree.rootNode)) { const grouped: Record = {}; + // Parallel tag -> captured SyntaxNode map. The query hands us each matched + // node as capture.node, so anchors resolve via a type-guarded lookup + // (nodeIfType) instead of re-deriving them with + // findNodeAtRange(tree.rootNode, ...) per match — the O(matches x N) + // root-walk fixed for go #1915 / python #1918 / csharp, mirrored here. + const groupedNodes: Record = {}; for (const capture of match.captures) { const tag = '@' + capture.name; grouped[tag] = nodeToCapture(tag, capture.node); + groupedNodes[tag] = capture.node; } if (Object.keys(grouped).length === 0) continue; @@ -69,11 +77,7 @@ export function emitKotlinScopeCaptures( } if (grouped['@import.statement'] !== undefined) { - const importNode = findNodeAtRange( - tree.rootNode, - grouped['@import.statement']!.range, - 'import_header', - ); + const importNode = nodeIfType(groupedNodes['@import.statement'], 'import_header'); if (importNode !== null) { const decomposed = splitKotlinImportHeader(importNode); if (decomposed !== null) { @@ -91,8 +95,7 @@ export function emitKotlinScopeCaptures( } if (grouped['@reference.read.member'] !== undefined) { - const anchor = grouped['@reference.read.member']!; - const navNode = findNodeAtRange(tree.rootNode, anchor.range, 'navigation_expression'); + const navNode = nodeIfType(groupedNodes['@reference.read.member'], 'navigation_expression'); if (navNode === null || !shouldEmitReadMember(navNode)) continue; } @@ -114,19 +117,15 @@ export function emitKotlinScopeCaptures( grouped['@type-binding.name'] !== undefined && grouped['@type-binding.type'] !== undefined ) { - const annotation = grouped['@type-binding.annotation']!; - if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) { + const propNode = nodeIfType(groupedNodes['@type-binding.annotation'], 'property_declaration'); + if (propNode !== null && propertyDeclHasConstructorValue(propNode)) { continue; } } if (grouped['@scope.function'] !== undefined) { out.push(grouped); - const fnNode = findNodeAtRange( - tree.rootNode, - grouped['@scope.function']!.range, - 'function_declaration', - ); + const fnNode = nodeIfType(groupedNodes['@scope.function'], 'function_declaration'); if (fnNode !== null) { out.push(...synthesizeKotlinReceiverBinding(fnNode)); } @@ -135,11 +134,7 @@ export function emitKotlinScopeCaptures( const declTag = FUNCTION_DECL_TAGS.find((tag) => grouped[tag] !== undefined); if (declTag !== undefined) { - const fnNode = findNodeAtRange( - tree.rootNode, - grouped[declTag]!.range, - 'function_declaration', - ); + const fnNode = nodeIfType(groupedNodes[declTag], 'function_declaration'); if (fnNode !== null) { const arity = computeKotlinArityMetadata(fnNode); if (arity.parameterCount !== undefined) { @@ -170,7 +165,7 @@ export function emitKotlinScopeCaptures( ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const ).find((tag) => grouped[tag] !== undefined); if (callTag !== undefined && grouped['@reference.arity'] === undefined) { - const callNode = findNodeAtRange(tree.rootNode, grouped[callTag]!.range, 'call_expression'); + const callNode = nodeIfType(groupedNodes[callTag], 'call_expression'); if (callNode !== null) { const args = callArguments(callNode); grouped['@reference.arity'] = syntheticCapture( @@ -188,13 +183,90 @@ export function emitKotlinScopeCaptures( out.push(grouped); - const extensionFallback = extensionFreeCallFallback(grouped, tree.rootNode); + const extensionFallback = extensionFreeCallFallback(grouped, groupedNodes); if (extensionFallback !== null) out.push(extensionFallback); } return out; } +/** + * Synthesize `@reference.inherits` captures from Kotlin `class_declaration` + * delegation specifiers so the registry-primary scope-resolution path emits + * EXTENDS / IMPLEMENTS edges (mirrors C# `synthesizeCsharpInheritanceReferences` + * and C++ `emitCppInheritanceCaptures`). Without this, Kotlin inheritance edges + * came only from the legacy `@heritage.*` path, which the worker pipeline drops + * for registry-primary languages → 0 inheritance edges in worker mode (#1951). + * + * Scope mirrors the legacy KOTLIN_QUERIES `@heritage.extends` patterns exactly + * (the config-driven `kotlinHeritageShapes`: `user_type`, + * `constructor_invocation`, `explicit_delegation`). Each `delegation_specifier` + * child of a `class_declaration`, in one of three forms — + * - bare interface/superclass: `class Foo : Bar` + * `(delegation_specifier (user_type (type_identifier)))` + * - constructor-call superclass: `class Foo : Bar()` + * `(delegation_specifier (constructor_invocation (user_type (type_identifier))))` + * - interface delegation: `class Foo : Bar by delegate` + * `(delegation_specifier (explicit_delegation (user_type (type_identifier)) …))` + * — the delegated interface is the LEADING `user_type`; the trailing + * delegate expression (`by delegate`) is NOT a supertype (#1951). This is + * the dropped shape the registry-primary synth previously skipped, leaving + * `class F : Iface by d` with no IMPLEMENTS edge in worker mode. + * + * Kotlin uses `:` for BOTH superclass and interfaces — the EXTENDS-vs-IMPLEMENTS + * split is decided downstream from the resolved target's symbol kind + * (`preEmitInheritanceEdges`), so every base is emitted with the same `inherits` + * kind here. The bare lookup name is normalized to the simple identifier + * (`Base()` → `Base`, `Base` → `Base`, `pkg.Base` → `Base`, + * `Iface by d` → `Iface`) so V1's simple-name `findClassBindingInScope` + * resolves it. The extracted bare name agrees with the legacy leg's + * `normalizeSupertypeName` for every shape (verified by real-parse). + */ +function synthesizeKotlinInheritanceReferences(rootNode: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + for (const classNode of descendantsOfType(rootNode, 'class_declaration')) { + for (const child of classNode.namedChildren) { + if (child.type !== 'delegation_specifier') continue; + // Three wrappers, all resolving to a leading `user_type` → + // `type_identifier`: + // - `(delegation_specifier (constructor_invocation (user_type …)))` for `Base()` + // - `(delegation_specifier (explicit_delegation (user_type …) ))` + // for `Iface by d` — the supertype is the FIRST `user_type`; the + // delegate expression that trails `by` is ignored. + // - `(delegation_specifier (user_type …))` for a bare interface/superclass. + const ctor = child.namedChildren.find((n) => n.type === 'constructor_invocation'); + const delegation = child.namedChildren.find((n) => n.type === 'explicit_delegation'); + const userType = + ctor?.namedChildren.find((n) => n.type === 'user_type') ?? + delegation?.namedChildren.find((n) => n.type === 'user_type') ?? + child.namedChildren.find((n) => n.type === 'user_type'); + if (userType === undefined) continue; + const nameNode = kotlinUserTypeNameNode(userType); + if (nameNode === null) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', child), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + } + return out; +} + +/** + * The bare simple-name `type_identifier` of a `user_type`. Strips generic + * type arguments (`Base` → `Base`) and qualifier tails (`pkg.Base` → `Base`) + * by taking the LAST direct `type_identifier` child, matching the legacy + * `(user_type (type_identifier) @heritage.extends)` capture and V1's + * simple-name `findClassBindingInScope` contract. + */ +function kotlinUserTypeNameNode(userType: SyntaxNode): SyntaxNode | null { + let nameNode: SyntaxNode | null = null; + for (const child of userType.namedChildren) { + if (child.type === 'type_identifier') nameNode = child; + } + return nameNode; +} + function synthesizeKotlinLoopBindings( rootNode: SyntaxNode, returnTypes: ReadonlyMap, @@ -1045,13 +1117,11 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean { return true; } -/** True when the property_declaration anchored at `range` has a - * `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to - * suppress the explicit-annotation type-binding capture so the - * constructor-inferred binding wins (#1762). */ -function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean { - const propNode = findNodeAtRange(rootNode, range, 'property_declaration'); - if (propNode === null) return false; +/** True when the given `property_declaration` has a `call_expression` + * value sibling (i.e. `val x: T = Foo()`). Used to suppress the + * explicit-annotation type-binding capture so the constructor-inferred + * binding wins (#1762). */ +function propertyDeclHasConstructorValue(propNode: SyntaxNode): boolean { const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration'); if (variable === undefined) return false; const value = propNode.namedChildren.find( @@ -1097,17 +1167,20 @@ function inferArgType(argNode: SyntaxNode): string { function extensionFreeCallFallback( grouped: Record, - rootNode: SyntaxNode, + groupedNodes: Record, ): CaptureMatch | null { const member = grouped['@reference.call.member']; const receiver = grouped['@reference.receiver']; const name = grouped['@reference.name']; if (member === undefined || receiver === undefined || name === undefined) return null; - const callNode = findNodeAtRange(rootNode, member.range, 'call_expression'); + // The `@reference.call.member` anchor IS the `call_expression`, and the + // `@reference.receiver` anchor IS the receiver node — both threaded from the + // query match (no per-match root walk). + const callNode = nodeIfType(groupedNodes['@reference.call.member'], 'call_expression'); if (callNode === null) return null; - const receiverNode = findNodeAtRange(rootNode, receiver.range); - if (receiverNode === null || !isLiteralReceiver(receiverNode)) return null; + const receiverNode = groupedNodes['@reference.receiver']; + if (receiverNode === undefined || !isLiteralReceiver(receiverNode)) return null; const out: Record = { '@reference.call.free': syntheticCapture('@reference.call.free', callNode, callNode.text), diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts index 42e10aa2e..4ab3e3d2b 100644 --- a/gitnexus/src/core/ingestion/languages/php/captures.ts +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -32,7 +32,12 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeIfType, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { + nodeIfType, + nodeToCapture, + syntheticCapture, + walkNamedTree, +} from '../../utils/ast-helpers.js'; import { splitNamespaceUseDeclaration } from './import-decomposer.js'; import { computePhpArityMetadata } from './arity-metadata.js'; import { synthesizePhpReceiverBinding } from './receiver-binding.js'; @@ -291,9 +296,133 @@ export function emitPhpScopeCaptures( out.push(grouped); } + out.push(...synthesizePhpInheritanceReferences(tree.rootNode)); + return out; } +// ─── PHP inheritance synthesis ─────────────────────────────────────────────── + +/** + * Synthesize `@reference.inherits` captures from PHP class/trait heritage so + * the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges + * (mirrors C# `synthesizeCsharpInheritanceReferences` / C++ + * `emitCppInheritanceCaptures`). Without this, PHP inheritance edges came only + * from the legacy `@heritage.*` path, which the worker pipeline drops for + * registry-primary languages (issue #1951). + * + * Scope matches the legacy PHP heritage query (tree-sitter-queries.ts + * PHP_QUERIES @heritage.extends / @heritage.implements / @heritage.trait): + * + * 1. `class_declaration` > `base_clause` > [(name) (qualified_name)] — extends + * 2. `class_declaration` > `class_interface_clause` > [(name) (qualified_name)] — implements + * 3. `class_declaration` body `use_declaration` > [(name) (qualified_name)] — trait use + * 4. `trait_declaration` body `use_declaration` > [(name) (qualified_name)] — trait use + * + * The EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved + * target's symbol kind (`preEmitInheritanceEdges`: `Interface` → IMPLEMENTS, + * else EXTENDS), so all bases emit the same `inherits` kind here. The base + * lookup name is normalized to its bare simple identifier (`Foo\Bar\Base` → + * `Base`) to match the V1 simple-name `findClassBindingInScope` contract. + * + * NOTE (#1951 trait-use parity): legacy emits trait-use as an IMPLEMENTS edge + * (`heritage.trait` → `trait-impl` → IMPLEMENTS in heritage-processor.ts), and + * the central pass matches it — `preEmitInheritanceEdges` (run.ts) maps a + * resolved `Interface` OR `Trait` target to IMPLEMENTS (`type === 'Interface' + * || type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'`), so `use Trait` resolves to + * IMPLEMENTS on both the legacy and registry-primary paths. + */ +function synthesizePhpInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type === 'class_declaration') { + // extends: single base_clause child carrying one base name. + const baseClause = findNamedChild(node, 'base_clause'); + if (baseClause !== null) emitPhpBaseNames(baseClause, out); + // implements: class_interface_clause may list several interfaces. + const ifaceClause = findNamedChild(node, 'class_interface_clause'); + if (ifaceClause !== null) emitPhpBaseNames(ifaceClause, out); + // trait use: `use TraitName;` inside the class body. + emitPhpTraitUses(node, out); + } else if (node.type === 'trait_declaration') { + // trait-uses-trait: `use OtherTrait;` inside a trait body. + emitPhpTraitUses(node, out); + } + }); + return out; +} + +/** + * Emit `@reference.inherits` for every `use_declaration` (trait use) in the + * declaration body of `node` (a class_declaration or trait_declaration). + * Class-body `use_declaration` is the trait-use node (distinct from the + * top-level `namespace_use_declaration` import node). + */ +function emitPhpTraitUses(node: SyntaxNode, out: CaptureMatch[]): void { + const body = node.childForFieldName('body'); + if (body === null || body.type !== 'declaration_list') return; + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child !== null && child.type === 'use_declaration') { + emitPhpBaseNames(child, out); + } + } +} + +/** + * Walk the named children of a heritage clause (`base_clause`, + * `class_interface_clause`, or `use_declaration`) and emit one + * `@reference.inherits` match per `name` / `qualified_name` base. The lookup + * name is the bare tail identifier so `findClassBindingInScope` resolves it. + */ +function emitPhpBaseNames(clause: SyntaxNode, out: CaptureMatch[]): void { + for (let i = 0; i < clause.namedChildCount; i++) { + const base = clause.namedChild(i); + if (base === null) continue; + if (base.type !== 'name' && base.type !== 'qualified_name') continue; + const bareName = phpBareBaseName(base); + if (bareName === '') continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': syntheticCapture('@reference.name', base, bareName), + }); + } +} + +/** + * Normalize a PHP base node to its bare simple identifier: + * `Base` (name) → `Base` + * `Foo\Bar\Base` (qualified_name)→ `Base` (last `name` child) + * `\Foo\Base` (qualified_name)→ `Base` + * Mirrors C#'s `terminalTypeNameNode`: strip the qualifier tail so the V1 + * simple-name scope-chain lookup resolves the target def. + */ +function phpBareBaseName(base: SyntaxNode): string { + if (base.type === 'name') return base.text; + if (base.type === 'qualified_name') { + // qualified_name holds one or more `name` children (plus `\` separators); + // the bare class is the last `name` child. + for (let i = base.namedChildCount - 1; i >= 0; i--) { + const child = base.namedChild(i); + if (child !== null && child.type === 'name') return child.text; + } + // Fallback: split the raw text on the namespace separator. + const segs = base.text.split('\\').filter((s) => s.length > 0); + return segs.length > 0 ? segs[segs.length - 1]! : ''; + } + return ''; +} + +/** Find the first named child of `node` with the given type. */ +function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null && child.type === type) return child; + } + return null; +} + +/** Pre-order walk over named children, invoking `cb` on each node. */ // ─── PHP receiver normalization ────────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 8a15669d2..a4b162ccc 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -17,7 +17,12 @@ */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { + nodeToCapture, + syntheticCapture, + walkNamedTree, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; import { splitImportStatement } from './import-decomposer.js'; import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; @@ -162,9 +167,104 @@ export function emitPythonScopeCaptures( out.push(grouped); } + out.push(...synthesizePythonInheritanceReferences(tree.rootNode)); + return out; } +/** + * Synthesize `@reference.inherits` captures from Python class superclass + * lists so the registry-primary scope-resolution path emits EXTENDS edges + * (mirrors C#'s `synthesizeCsharpInheritanceReferences` / C++'s + * `emitCppInheritanceCaptures` / TypeScript's `synthesizeTsInheritanceReferences`). + * Without this, Python inheritance edges came only from the legacy + * `@heritage.*` path, which is dropped for registry-primary languages in the + * worker pipeline (issue #1951). + * + * Scope matches the legacy Python heritage leg (config-driven since #1940): + * every direct base in the `superclasses` `argument_list`, resolved to its bare + * simple name. Three base shapes that the previous synth DROPPED — and so + * silently omitted in production while the legacy `@heritage` leg captured them + * — are now handled (#1951): + * + * - `class C(pkg.Base)` → `attribute` (trailing `.attribute` id → `Base`) + * - `class C(pkg.sub.Base)` → nested `attribute` (recurse → `Base`) + * - `class C(Generic[T])` → `subscript` (`.value` id → `Generic`) + * + * The bare-name text MUST agree with `normalizeSupertypeName` (the legacy leg's + * reduction in heritage-extractors/supertype-alternation.ts) so both legs emit + * the same edge under the CI scope-parity gate: `pkg.Base` → `Base`, + * `Generic[T]` → `Generic`, `pkg.Container[str]` → `Container`. Verified by a + * real tree-sitter-python parse. The simple `identifier` base keeps its exact + * prior capture (the base node itself). + * + * Tuple/multi bases (`class C(A, pkg.B, Gen[T])`) already iterate here — each + * `argument_list` named child is one base. Python has no interfaces, so every + * base resolves to a Class and the central `preEmitInheritanceEdges` pass emits + * EXTENDS; the EXTENDS-vs-IMPLEMENTS split is decided downstream from the + * resolved target's symbol kind, so all bases are emitted with the same + * `inherits` kind here. + */ +function synthesizePythonInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'class_definition') return; + const superclasses = node.childForFieldName('superclasses'); + if (superclasses === null || superclasses.type !== 'argument_list') return; + for (let i = 0; i < superclasses.namedChildCount; i++) { + const base = superclasses.namedChild(i); + if (base === null) continue; + const nameNode = pythonBaseLookupNameNode(base); + if (nameNode === null) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + }); + return out; +} + +/** + * Reduce a Python superclass base node to the bare simple-identifier node whose + * `.text` is the lookup name `findClassBindingInScope` resolves. Mirrors the + * TypeScript `terminalTsTypeNameNode` / C++ `extractBaseLookupName` reference + * patterns, and its returned node's `.text` is contractually equal to + * `normalizeSupertypeName(base)` for every shape (real-parse verified): + * + * - `identifier` (`Base`) → the node itself + * - `attribute` (`pkg.Base`, + * `pkg.sub.Base`) → trailing `attribute:` identifier → `Base` + * - `subscript` (`Generic[T]`, + * `pkg.Container[T]`)→ `value:` (recurse, strips `[...]` and + * any qualifier) → `Generic` / `Container` + * + * Returns null for any other shape (no leaf identifier reachable), so it never + * emits a spurious edge. + */ +function pythonBaseLookupNameNode(base: SyntaxNode): SyntaxNode | null { + switch (base.type) { + case 'identifier': + return base; + case 'attribute': { + // `pkg.Base` / `pkg.sub.Base`: the `attribute:` field is the trailing + // simple-identifier segment (`Base`); recurse so chained dotted paths + // still resolve to the final identifier. + const attr = base.childForFieldName('attribute'); + return attr === null ? null : pythonBaseLookupNameNode(attr); + } + case 'subscript': { + // `Generic[T]` / `pkg.Container[str]`: the `value:` field is the + // subscripted base (identifier or attribute); recurse to strip the + // `[...]` slice and any qualifier, reaching the bare base name. + const value = base.childForFieldName('value'); + return value === null ? null : pythonBaseLookupNameNode(value); + } + default: + return null; + } +} + function scopeExtractionError(stage: string, filePath: string, err: unknown): Error { const reason = err instanceof Error ? err.message : String(err); return new Error( diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 63d9bf053..376ebc546 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -1,8 +1,10 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findChild, nodeIfType, nodeToCapture, syntheticCapture, + walkNamedTree, type SyntaxNode, } from '../../utils/ast-helpers.js'; import { getRubyParser, getRubyScopeQuery } from './query.js'; @@ -430,9 +432,99 @@ export function emitRubyScopeCaptures( } } + // Fifth pass: superclass inheritance (`class Foo < Bar`). + // Emit `@reference.inherits` captures so the registry-primary scope- + // resolution path produces EXTENDS edges (issue #1951). This mirrors the + // C#/C++ inheritance synthesis: Ruby's superclass edges previously came + // only from the legacy `@heritage.extends` query, which the worker + // pipeline drops for registry-primary languages → 0 inheritance edges in + // worker mode. Mixins (include/extend/prepend) are NOT touched here — they + // flow through `emitHeritageEdges` (the `__heritage__:` import path above), + // an independent lane that stays intact when legacy @heritage is gated off. + out.push(...synthesizeRubySuperclassReferences(tree.rootNode)); + return out; } +/** + * Synthesize `@reference.inherits` captures from Ruby `class Foo < Bar` + * superclass declarations so the shared `preEmitInheritanceEdges` pass can + * resolve the base to a Class def and emit an EXTENDS edge. + * + * Scope is `class` nodes whose `superclass` field holds either a bare + * `constant` base (`class D < Super`) or a qualified/scoped + * `scope_resolution` base (`class C < Outer::Super`, `class E < A::B::C`) — + * exactly the two shapes the config-driven legacy `@heritage.extends` + * alternation now captures (heritage-extractors/configs/ruby.ts + * `rubyHeritageShapes: ['constant', 'scope_resolution']`): + * + * (class + * name: (constant) @heritage.class + * superclass: (superclass + * [(constant) (scope_resolution)] @heritage.extends)) @heritage + * + * Previously this pass emitted only for a direct `(constant)` child, so the + * production registry-primary path silently dropped `Outer::Super` + * superclasses while the legacy @heritage leg captured them — the exact + * EXTENDS/IMPLEMENTS-drop bug of #1951. + * + * THE PARITY CONTRACT: the `@reference.name` bare text must equal the legacy + * leg's `normalizeSupertypeName(baseNode)` reduction. For a `scope_resolution` + * (`Outer::Super`, `A::B::C`) the normalizer recurses into the `name:` field + * and returns the trailing `constant` (`Super` / `C`); this synth mirrors that + * by reading the same `name:` tail. A bare `constant` is unchanged + * (byte-identical to the prior emission). `module` nodes are excluded (no + * superclass field). Mixins (include/extend/prepend) are untouched — they flow + * through the `__heritage__:` import lane above. + * + * Edge type (EXTENDS vs IMPLEMENTS) is decided downstream from the resolved + * target's symbol kind — this pass only emits `@reference.inherits`. + */ +function synthesizeRubySuperclassReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'class') return; + const superclass = node.childForFieldName('superclass'); + if (superclass === null) return; + const baseNode = extractRubySuperclassBaseNode(superclass); + if (baseNode === null) return; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', baseNode), + '@reference.name': nodeToCapture('@reference.name', baseNode), + }); + }); + return out; +} + +/** + * Reduce a Ruby `superclass` node to the bare `constant` the resolver should + * look up, at parity with the legacy heritage leg's + * `normalizeSupertypeName(baseNode)`: + * + * - direct `(constant)` child (`class D < Super`) → that constant + * (unchanged from the original emission — kept byte-identical) + * - `(scope_resolution)` child (`class C < Outer::Super`, + * `class E < A::B::C`) → the trailing + * `name:` constant (`Super` / `C`) + * + * A `scope_resolution` nests qualifier-first, name-last + * (`scope: (...) name: (constant)`), so the `name:` field is always the + * trailing simple identifier — the same tail `normalizeSupertypeName` reaches + * by recursing through its `name` field. Any other shape returns null (no + * edge), keeping this emitter at parity with the legacy alternation + * (`['constant', 'scope_resolution']`). + */ +function extractRubySuperclassBaseNode(superclass: SyntaxNode): SyntaxNode | null { + const directConstant = findChild(superclass, 'constant'); + if (directConstant !== null) return directConstant; + const scoped = findChild(superclass, 'scope_resolution'); + if (scoped !== null) { + const tail = scoped.childForFieldName('name'); + if (tail !== null && tail.type === 'constant') return tail; + } + return null; +} + function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null { const methodNode = callNode.childForFieldName('method'); if (methodNode === null) return null; diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index 1bad116c2..00f7759d4 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -3,6 +3,7 @@ import { nodeIfType, nodeToCapture, syntheticCapture, + walkNamedTree, type SyntaxNode, } from '../../utils/ast-helpers.js'; import { getRustParser, getRustScopeQuery } from './query.js'; @@ -161,9 +162,86 @@ export function emitRustScopeCaptures( out.push(grouped); } + out.push(...synthesizeRustInheritanceReferences(tree.rootNode)); + return out; } +/** + * Synthesize `@reference.inherits` captures from Rust trait `impl` blocks so + * the registry-primary scope-resolution path can emit the IMPLEMENTS edge for + * `impl Trait for Struct` (mirrors the legacy `@heritage.trait`/`@heritage.class` + * path, which the worker pipeline drops for registry-primary languages — #1951). + * + * Rust inheritance is structurally unlike a base list on a type declaration: + * the relationship lives on `impl_item { trait: T, type: S }`, meaning + * `S IMPLEMENTS T`. The shared `preEmitInheritanceEdges` derives an edge's + * SOURCE from the enclosing *class* def of the `@reference.inherits` site, but + * an `impl_item` scope owns no class-like def (the struct `S` is declared + * elsewhere as a `struct_item`), so `findEnclosingClassDef` returns undefined + * and that pass emits nothing for these sites (it still marks them handled, + * suppressing the generic reference bridge). The real IMPLEMENTS edge is + * therefore emitted by `rustScopeResolver.emitHeritageEdges`, which reads these + * sites back from `parsedFiles[*].referenceSites`. + * + * To carry both ends of the relationship through a single reference site we + * encode: `@reference.name` = the trait `T` (becomes `site.name`, the IMPLEMENTS + * target) and `@reference.receiver` = the struct `S` (becomes + * `site.explicitReceiver.name`, the IMPLEMENTS source). + * + * Parity is intentionally pinned to the legacy heritage query's `impl_item` + * patterns: both `trait:` and `type:` normalize to the base's trailing bare + * `type_identifier` — directly, via a `scoped_type_identifier`'s `name:` tail + * (`crate::traits::Drawable` → `Drawable`; KTD-1 tail resolution), or through a + * `generic_type`'s `type:` field (which may itself be either). Inherent impls + * (`impl S {}`, no `trait:` field) still emit nothing. + */ +function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'impl_item') return; + const traitField = node.childForFieldName('trait'); + const typeField = node.childForFieldName('type'); + if (traitField === null || typeField === null) return; + const traitName = bareTypeIdentifier(traitField); + const structName = bareTypeIdentifier(typeField); + if (traitName === null || structName === null) return; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', traitName), + '@reference.name': nodeToCapture('@reference.name', traitName), + '@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text), + }); + }); + return out; +} + +/** + * Normalize a `trait:` / `type:` impl_item field to the base's trailing bare + * `type_identifier`, matching exactly the node shapes the legacy `@heritage` + * query accepts (kept at parity — see the `impl_item` heritage arm in + * tree-sitter-queries.ts): + * - `type_identifier` → the node itself + * - `scoped_type_identifier name: (type_identifier)` → the trailing `name:` id + * (`crate::traits::Drawable` → `Drawable`; KTD-1 tail resolution — the + * simple name then resolves scope-aware via `emitRustTraitImplEdges`) + * - `generic_type type: ` → recurse into `type:` + * (covers `Box` and `m::Wrapped`) + * Any other node type returns null (no edge), keeping this emitter at parity + * with the legacy query. + */ +function bareTypeIdentifier(node: SyntaxNode): SyntaxNode | null { + if (node.type === 'type_identifier') return node; + if (node.type === 'scoped_type_identifier') { + const tail = node.childForFieldName('name'); + return tail !== null && tail.type === 'type_identifier' ? tail : null; + } + if (node.type === 'generic_type') { + const inner = node.childForFieldName('type'); + return inner !== null ? bareTypeIdentifier(inner) : null; + } + return null; +} + function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null { let current: SyntaxNode | null = node.parent; while (current !== null) { diff --git a/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts index bafeb8896..0f16d28f3 100644 --- a/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts @@ -129,6 +129,15 @@ export function getImplTraitName(implNode: SyntaxNode): string | null { return null; } +// NOTE: this strips reference/pointer sigils and generic arguments but NOT a +// path qualifier, so `crate::traits::Drawable` stays qualified here — whereas +// the inheritance synth (rust/captures.ts `bareTypeIdentifier`) resolves scoped +// bases by their trailing simple name (`Drawable`). The two intentionally +// diverge for scoped paths. This is inert today (`getImplTraitName` has no +// ingestion consumer and Rust's `isSuperReceiver` is false, so nothing keys an +// edge on this name); the synth is the single source of truth for the +// inheritance edge. A future change that wires `getImplTraitName` into +// resolution must reconcile this with the synth's tail-only normalization. function normalizeRustTypeName(text: string): string { let t = text.trim(); while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index fb06fd248..7815924b9 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -6,8 +6,96 @@ import { rustProvider } from '../rust.js'; import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js'; import { populateRustOwners } from './method-owners.js'; import { populateRustRangeBindings } from './range-binding.js'; -import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { + isClassLike, + findClassBindingInScope, + resolveAmbiguousInheritanceBaseViaImports, +} from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; + +/** + * Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations. + * + * Rust inheritance is not a base list on the type declaration — it lives on + * `impl_item { trait: T, type: S }`. The shared `preEmitInheritanceEdges` pass + * derives an `@reference.inherits` site's edge SOURCE from the enclosing class + * def, but an `impl_item` scope owns no class-like def, so that pass cannot + * produce these edges (it only marks the sites handled). The `@reference.inherits` + * sites synthesized in `captures.ts` carry the trait `T` as `site.name` (target) + * and the struct `S` as `site.explicitReceiver.name` (source); this hook reads + * them back and emits the IMPLEMENTS edge with source `S`, target `T`, and the + * legacy `'trait-impl'` reason — matching the legacy `@heritage` DAG (#1951). + * + * Resolution is scope-aware and import-aware, mirroring the shared + * `preEmitInheritanceEdges` pass: both `S` and `T` resolve from the `impl` + * block's own scope via `findClassBindingInScope` (scope-chain + single-match + * fallbacks), then `resolveAmbiguousInheritanceBaseViaImports` for a name that + * several modules declare (disambiguated by the referencing file's `use` + * imports). A trait `T` is commonly declared in a different file (e.g. the + * `rust-traits` fixture imports `Drawable`/`Clickable` from a sibling module); + * the scope chain reaches it through those `use` bindings. When a name does + * not resolve to exactly one class-like def — unresolved (e.g. a std trait + * like `Default`) OR ambiguous across modules (two same-named `struct`s / + * traits) — NO edge is emitted, restoring the legacy file-scoped path's + * "a wrong edge is worse than no edge" invariant. (The prior global + * simple-name index used last-write-wins and could source an `impl` edge from + * the wrong same-named def across modules.) Idempotent: pre-seeds the dedup + * set from existing IMPLEMENTS edges so a worker-mode legacy emission (or a + * re-resolution) is not duplicated. + */ +function emitRustTraitImplEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + scopes: ScopeResolutionIndexes | undefined, +): void { + if (scopes === undefined) return; + + const emitted = new Set(); + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + emitted.add(`${rel.sourceId}->${rel.targetId}`); + } + + for (const parsed of parsedFiles) { + for (const site of parsed.referenceSites) { + if (site.kind !== 'inherits') continue; + const structName = site.explicitReceiver?.name; + const traitName = site.name; + if (structName === undefined || structName === '' || traitName === '') continue; + + // Scope-aware (+ import-aware) resolution from the impl block's scope. + // Refuse when either end is unresolved or ambiguous. + const structDef = + findClassBindingInScope(site.inScope, structName, scopes) ?? + resolveAmbiguousInheritanceBaseViaImports(site.inScope, structName, scopes); + const traitDef = + findClassBindingInScope(site.inScope, traitName, scopes) ?? + resolveAmbiguousInheritanceBaseViaImports(site.inScope, traitName, scopes); + if (structDef === undefined || traitDef === undefined) continue; + + const structGraphId = resolveDefGraphId(structDef.filePath, structDef, nodeLookup); + const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup); + if (structGraphId === undefined || traitGraphId === undefined) continue; + + const edgeKey = `${structGraphId}->${traitGraphId}`; + if (emitted.has(edgeKey)) continue; + emitted.add(edgeKey); + + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${edgeKey}:trait-impl`), + sourceId: structGraphId, + targetId: traitGraphId, + type: 'IMPLEMENTS', + confidence: 0.85, + reason: 'trait-impl', + }); + } + } +} function buildRustMro( graph: Parameters[0], @@ -66,6 +154,9 @@ export const rustScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => + emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes), + populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), isSuperReceiver: () => false, diff --git a/gitnexus/src/core/ingestion/languages/swift/base-type.ts b/gitnexus/src/core/ingestion/languages/swift/base-type.ts new file mode 100644 index 000000000..0e8b110bc --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/base-type.ts @@ -0,0 +1,28 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Trailing simple-name `type_identifier` of a Swift `user_type` base. A + * qualified `Outer.Inner` parses flat as + * `(user_type (type_identifier "Outer") (type_identifier "Inner"))`, and the + * actual base type is the TRAILING segment `Inner` (mirrors Java + * `scoped_type_identifier` → `lastNamedChild` and TS `nested_type_identifier` + * → tail). Generic arguments live in a sibling `type_arguments` node — never a + * `type_identifier` — so they are skipped: `Box` → `Box`, + * `Outer.Inner` → `Inner`. Returns null when the `user_type` has no + * `type_identifier` child. + * + * Shared by `swiftBaseTypeIdentifier` (captures.ts — returns the node for an + * `@reference.inherits` site) and `firstInheritedType` (receiver-binding.ts — + * reads `.text` for `super` receiver binding). It lives in this leaf module + * rather than being exported from captures.ts because captures.ts already + * imports receiver-binding.ts, so a captures.ts export would create a + * bidirectional import cycle (#1956 tri-review U7). + */ +export function swiftQualifiedBaseTail(userType: SyntaxNode): SyntaxNode | null { + let last: SyntaxNode | null = null; + for (let i = 0; i < userType.namedChildCount; i++) { + const child = userType.namedChild(i); + if (child !== null && child.type === 'type_identifier') last = child; + } + return last; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index 51a0910db..067bbecad 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -37,9 +37,11 @@ import { nodeIfType, nodeToCapture, syntheticCapture, + walkNamedTree, type SyntaxNode, } from '../../utils/ast-helpers.js'; import { splitSwiftImport } from './import-decomposer.js'; +import { swiftQualifiedBaseTail } from './base-type.js'; import { computeSwiftArityMetadata } from './arity-metadata.js'; import { synthesizeSwiftReceiverBinding } from './receiver-binding.js'; import { synthesizeSwiftSignatureBindings } from './signature-bindings.js'; @@ -282,9 +284,70 @@ export function emitSwiftScopeCaptures( out.push(grouped); } + // ── Emit inheritance references for scope-resolution EXTENDS / IMPLEMENTS ── + // Walk every class/struct/enum/actor/extension and protocol declaration's + // inheritance specifiers and synthesize `@reference.inherits` captures so + // the registry-primary path emits EXTENDS / IMPLEMENTS (mirrors C++ / + // C# / Java). Without this, Swift inheritance edges came only from the + // legacy `@heritage.*` path, which the worker pipeline drops for + // registry-primary languages (issue #1951). + out.push(...synthesizeSwiftInheritanceReferences(tree.rootNode)); + return out; } +/** + * Synthesize `@reference.inherits` captures from Swift inheritance + * specifiers so the registry-primary scope-resolution path emits + * EXTENDS / IMPLEMENTS edges (mirrors `synthesizeCsharpInheritanceReferences` + * / `emitCppInheritanceCaptures`). Without this, Swift inheritance edges came + * only from the legacy `@heritage.*` path, dropped for registry-primary + * languages in the worker pipeline (issue #1951). + * + * Scope matches the legacy SWIFT_QUERIES `@heritage` blocks exactly: a + * `class_declaration` (class / struct / enum / actor / extension all share + * this node) or a `protocol_declaration`, each with an + * `(inheritance_specifier inherits_from: (user_type (type_identifier)))`. + * The EXTENDS-vs-IMPLEMENTS split is decided downstream from the resolved + * target's symbol kind (`preEmitInheritanceEdges` → Interface = IMPLEMENTS, + * else EXTENDS), so every base is emitted with the same `inherits` kind here. + * The base lookup name is normalized to its bare simple identifier + * (`SomeProtocol` → `SomeProtocol`, `Outer.Inner` → `Inner`) to match the + * V1 simple-name `findClassBindingInScope` contract. + */ +function synthesizeSwiftInheritanceReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + walkNamedTree(root, (node) => { + if (node.type !== 'class_declaration' && node.type !== 'protocol_declaration') return; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child === null || child.type !== 'inheritance_specifier') continue; + const inheritsFrom = child.childForFieldName('inherits_from') ?? child.firstNamedChild; + if (inheritsFrom === null) continue; + const nameNode = swiftBaseTypeIdentifier(inheritsFrom); + if (nameNode === null) continue; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', child), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + }); + return out; +} + +/** Normalize an `inherits_from` node to its bare simple identifier node. + * Only a `user_type`-shaped base contributes an edge; its trailing + * `type_identifier` (the actual base — see `swiftQualifiedBaseTail`) is + * returned. Returns null for any other base shape (e.g. a tuple / + * function-type conformance), so no edge is synthesized — matching the legacy + * query's `user_type` gate. */ +function swiftBaseTypeIdentifier(inheritsFrom: SyntaxNode): SyntaxNode | null { + if (inheritsFrom.type === 'type_identifier') return inheritsFrom; + if (inheritsFrom.type !== 'user_type') return null; + return swiftQualifiedBaseTail(inheritsFrom); +} + +/** Pre-order walk over named children (mirrors C#'s `visit`). */ /** Synthesize a `@type-binding.constructor` for EACH clause of an * if-let / guard-let optional binding: * `if let u = getUser()` → one binding `u: getUser` diff --git a/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts index d7e9c2202..92ac3232c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/swift/receiver-binding.ts @@ -26,6 +26,7 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +import { swiftQualifiedBaseTail } from './base-type.js'; import { swiftMethodConfig } from '../../method-extractors/configs/swift.js'; const TYPE_DECL_NODE_TYPES = new Set(['class_declaration', 'protocol_declaration']); @@ -83,7 +84,10 @@ function isClassKeyword(typeNode: SyntaxNode): boolean { /** First inherited type (superclass or first protocol) as raw text, or * null. For a class the first `inheritance_specifier` is conventionally - * the superclass — `super.x()` only compiles when that is true. */ + * the superclass — `super.x()` only compiles when that is true. For a + * `user_type` base the name is its trailing `type_identifier` segment (see + * `swiftQualifiedBaseTail`), falling back to the raw node text when there is + * no `type_identifier` child. */ function firstInheritedType(typeNode: SyntaxNode): string | null { for (let i = 0; i < typeNode.namedChildCount; i++) { const child = typeNode.namedChild(i); @@ -91,7 +95,7 @@ function firstInheritedType(typeNode: SyntaxNode): string | null { const inheritsFrom = child.childForFieldName('inherits_from') ?? child.firstNamedChild; if (inheritsFrom === null) return null; if (inheritsFrom.type === 'user_type') { - return inheritsFrom.firstNamedChild?.text ?? inheritsFrom.text; + return swiftQualifiedBaseTail(inheritsFrom)?.text ?? inheritsFrom.text; } return inheritsFrom.text; } diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 9f432fbe3..90a649e47 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -336,21 +336,29 @@ export function emitTsScopeCaptures( // calls use `new_expression`; regular calls use `call_expression`. // // JSX call anchors (`jsx_self_closing_element` / `jsx_opening_element` - // captured by the TSX-only suffix in `query.ts`) intentionally do - // NOT carry arity metadata. The lookup below would resolve `callNode` - // to `null` for a JSX anchor (the anchor is neither a call_expression - // nor a new_expression), so the synthesis branch silently no-ops and - // the JSX call enters the registry with name-only resolution. This - // is acceptable for React: components are virtually never - // overloaded in the current GitNexus graph model, so name-only - // dispatch matches the single component definition. If a future - // codebase introduces overloaded React components AND needs JSX - // calls to disambiguate by props-arity, a JSX-aware arity - // synthesizer would need to count `jsx_attribute` children of the - // opening tag instead of `arguments`. + // captured by the TSX-only suffix in `query.ts`) intentionally do NOT carry + // arity metadata. A JSX component used as a call argument (e.g. + // `render()`) is itself a @reference.call.* anchor; without a guard + // the ascent below would climb from it into the enclosing call_expression and + // mis-attribute that call's arity to the component. The early guard skips + // arity synthesis for JSX anchors — restoring the pre-#1951 range-based + // behavior (the old findNodeAtRange found no call_expression at the JSX + // element's range). The guard lives here, not inside findSelfOrAncestorOfTypes + // (shared with the import-statement and function-scope ascents). This is + // acceptable for React: components are virtually never overloaded in the + // current GitNexus graph model, so name-only dispatch matches the single + // component definition. A future props-arity-aware synthesizer would count + // `jsx_attribute` children of the opening tag instead of `arguments`. const callAnchor = pickFirstCapture(grouped, CALL_TAGS); const callAnchorNode = pickFirstNode(groupedNodes, CALL_TAGS); - if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const anchorIsJsxElement = + callAnchorNode?.type === 'jsx_self_closing_element' || + callAnchorNode?.type === 'jsx_opening_element'; + if ( + callAnchor !== undefined && + grouped['@reference.arity'] === undefined && + !anchorIsJsxElement + ) { const callNode = findSelfOrAncestorOfTypes(callAnchorNode, ['call_expression', 'new_expression']) ?? findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? @@ -411,10 +419,124 @@ export function emitTsScopeCaptures( synthesizeDestructuringBindings(tree.rootNode, out); synthesizeForOfMapTupleBindings(tree.rootNode, out); synthesizeInstanceofNarrowings(tree.rootNode, out); + synthesizeTsInheritanceReferences(tree.rootNode, out); return out; } +/** + * Synthesize `@reference.inherits` captures from TypeScript class heritage so + * the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges + * (mirrors C# `synthesizeCsharpInheritanceReferences` / JS + * `synthesizeJsInheritanceReferences`). Without this, TS inheritance edges came + * only from the legacy `@heritage.*` path, which the worker pipeline drops for + * registry-primary languages — yielding 0 inheritance edges in worker mode + * (issue #1951). + * + * Scope is intentionally limited to a `class_declaration`'s `class_heritage` + * `extends_clause` value + `implements_clause` types, matching the legacy + * TypeScript `@heritage` query's class scope (TYPESCRIPT_QUERIES). Generic + * bases agree across both paths: `extends Base` is captured by the legacy + * `extends_clause value: (identifier)` already (the `type_arguments` are a + * sibling field), and `implements IFoo` is captured by a legacy clause + * widened to read the `generic_type`'s `name:` identifier — so the registry + * path keeps parity on SIMPLE (unqualified) generic bases too (#1951). + * Qualified bases (`ns.Base`, `ns.Base`, `ns.IFoo`) are ALSO now at parity + * (#1956 tri-review U2): the synth resolves them by their member_expression / + * nested_type_identifier tail, and the legacy `@heritage` query was widened with + * matching arms (member_expression for extends, nested_type_identifier plain + + * generic-wrapped for implements). + * + * `interface_declaration` / `abstract_class_declaration` heritage is NOT emitted + * — the legacy query captures neither, so the registry path keeps parity with + * the legacy DAG under the CI scope-parity gate (REGISTRY_PRIMARY_TYPESCRIPT=0 + * vs =1). The EXTENDS-vs-IMPLEMENTS split is decided downstream from the + * resolved target's symbol kind in `preEmitInheritanceEdges` (class-extends → + * EXTENDS, implements-interface / interface-target → IMPLEMENTS), so all bases + * are emitted with the same `inherits` kind here. The base lookup name is + * normalized to its bare simple identifier (`BaseModel` → `BaseModel`, + * `models.Base` → `Base`) so `findClassBindingInScope` resolves it. + */ +function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + for (;;) { + const node = stack.pop(); + if (node === undefined) break; + for (const child of node.namedChildren) { + if (child !== null) stack.push(child); + } + + if (node.type !== 'class_declaration') continue; + + // Find the `class_heritage` child (holds extends / implements clauses). + let heritage: SyntaxNode | null = null; + for (const child of node.namedChildren) { + if (child !== null && child.type === 'class_heritage') { + heritage = child; + break; + } + } + if (heritage === null) continue; + + for (const clause of heritage.namedChildren) { + if (clause === null) continue; + if (clause.type === 'extends_clause') { + // `extends Foo` / `extends Foo` — the base is the `value:` field + // (an identifier; generics live in a sibling `type_arguments`). + const value = clause.childForFieldName('value') ?? clause.firstNamedChild; + emitTsInheritanceBase(value, out); + } else if (clause.type === 'implements_clause') { + // `implements IFoo, IBar` — each base type is a direct named child. + for (const base of clause.namedChildren) { + emitTsInheritanceBase(base, out); + } + } + } + } +} + +/** Emit one `@reference.inherits` match for a TS heritage base, normalizing + * the lookup name to its bare simple identifier. No-ops on null / non-type + * nodes or when the bare name can't be derived. */ +function emitTsInheritanceBase(base: SyntaxNode | null, out: CaptureMatch[]): void { + if (base === null) return; + const nameNode = terminalTsTypeNameNode(base); + if (nameNode === null) return; + out.push({ + '@reference.inherits': nodeToCapture('@reference.inherits', base), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); +} + +/** Resolve a TypeScript heritage base node to its bare simple-identifier node. + * `Foo` → `Foo`, `Foo` (generic_type) → `Foo`, `models.Base` + * (nested_type_identifier / member_expression) → `Base`. Mirrors C#'s + * `terminalTypeNameNode`; returns null when no leaf identifier is reachable. */ +function terminalTsTypeNameNode(node: SyntaxNode): SyntaxNode | null { + switch (node.type) { + case 'identifier': + case 'type_identifier': + // `extends ns.Base` parses as a member_expression whose tail is a + // `property_identifier` (not a type_identifier) — treat it as a leaf name. + case 'property_identifier': + return node; + case 'generic_type': { + // generic_type has a `name:` field (type_identifier / nested_type_identifier); + // recurse to strip the type_arguments and reach the bare base identifier. + const name = node.childForFieldName('name') ?? node.firstNamedChild; + return name === null ? null : terminalTsTypeNameNode(name); + } + case 'nested_type_identifier': + case 'member_expression': { + // Qualified `A.B.Base` → tail identifier `Base`. + const tail = node.lastNamedChild; + return tail === null ? null : terminalTsTypeNameNode(tail); + } + default: + return null; + } +} + /** * Walk the AST and synthesize type-binding captures for object * destructuring of the form `const { field } = rhs` or diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 73cc28701..35ce18c6e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -459,9 +459,22 @@ export interface ScopeResolver { * `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges * from parsed import or reference data. * - * Receives the graph (writable), parsedFiles, and nodeLookup — same - * surface as `buildMro`. Must be idempotent (the orchestrator may call - * it more than once during re-resolution). + * Receives the graph (writable), parsedFiles, nodeLookup, and the finalized + * `ScopeResolutionIndexes` — the same scope/import/def model + * `preEmitInheritanceEdges` resolves against, and already a first-class part + * of this contract (the structure/binding hooks below take it too), so the + * trailing `scopes` parameter is not a new type dependency here. It is + * appended and optional so implementations that don't need scope-aware + * resolution keep their narrower signature. + * + * `scopes` has exactly ONE consumer: the Rust resolver — see + * `emitRustTraitImplEdges` in languages/rust/scope-resolver.ts — which + * resolves `impl T for S` trait/struct names through the scope chain + + * import-aware disambiguation (refusing ambiguous matches) instead of a + * global last-write-wins simple-name index (#1951). Other implementations + * (e.g. Ruby `include`/`extend`/`prepend`) ignore it and keep the 3-arg + * shape. Must be idempotent (the orchestrator may call it more than once + * during re-resolution). * * Default: undefined (no extra heritage edges needed). */ @@ -469,6 +482,7 @@ export interface ScopeResolver { graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + scopes?: ScopeResolutionIndexes, ) => void; /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts index 2562868e4..19b6bf0f1 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/edges.ts @@ -70,6 +70,9 @@ export function tryEmitEdge( confidence = 0.85, collapseByCallerTarget = false, ): boolean { + // Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which + // owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this + // generic bridge derives caller + edge type purely from the site. const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 8076a87b1..2a2b7edf7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -35,19 +35,59 @@ import { resolveReferenceSites, type ResolveStats } from '../../resolve-referenc import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js'; -import { tryEmitEdge } from '../graph-bridge/edges.js'; import { propagateImportedReturnTypes } from '../passes/imported-return-types.js'; import { emitReceiverBoundCalls } from '../passes/receiver-bound-calls.js'; import { emitFreeCallFallback } from '../passes/free-call-fallback.js'; import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js'; import { emitImportEdges } from '../graph-bridge/imports-to-edges.js'; import type { ScopeResolver } from '../contract/scope-resolver.js'; -import { findClassBindingInScope, findEnclosingClassDef } from '../scope/walkers.js'; +import { + findClassBindingInScope, + findEnclosingClassDef, + resolveAmbiguousInheritanceBaseViaImports, +} from '../scope/walkers.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logger } from '../../../logger.js'; +/** + * Emit one class-owned inheritance edge directly (the inheritance pre-pass is + * the authoritative emitter — see `preEmitInheritanceEdges`). Encapsulates the + * dual dedup contract so the two sets' joint semantics live in one place: + * - `existing` — coarse per-`(caller, target, type)` gate, seeded from the + * graph (so this pass is a no-op when the legacy path already emitted it). + * - `seen` — per-site key shared with the generic edge bridge so the two + * passes never double-emit the same resolution. + * The `dedupKey` and `rel:` id shape match `tryEmitEdge` exactly, so graph + * output stays byte-identical. The caller is the enclosing class (NOT the + * method/constructor `resolveCallerGraphId` would prefer — that broke MRO for + * C# 12 primary constructors, #1951); the edge type is pre-discriminated. + */ +function emitInheritanceEdgeDirect( + graph: KnowledgeGraph, + seen: Set, + existing: Set, + callerGraphId: string, + targetGraphId: string, + edgeType: 'EXTENDS' | 'IMPLEMENTS', + site: { readonly atRange: { startLine: number; startCol: number } }, +): void { + const edgeKey = `${edgeType}:${callerGraphId}->${targetGraphId}`; + const dedupKey = `${edgeKey}:${site.atRange.startLine}:${site.atRange.startCol}`; + if (existing.has(edgeKey) || seen.has(dedupKey)) return; + seen.add(dedupKey); + existing.add(edgeKey); + graph.addRelationship({ + id: `rel:${dedupKey}`, + sourceId: callerGraphId, + targetId: targetGraphId, + type: edgeType, + confidence: 0.85, + reason: 'scope-resolution: inherits', + }); +} + /** * Resolve inheritance reference sites early and pre-emit their EXTENDS edges * before MRO construction. This lets template-base captures contribute to the @@ -63,9 +103,16 @@ function preEmitInheritanceEdges( ): Set { const handledSites = new Set(); const seen = new Set(); + // Seed the dedup set with both inheritance edge types already in the graph + // (e.g. emitted by the legacy heritage path in sequential mode). Keying by + // edge type lets us add IMPLEMENTS without colliding with EXTENDS and keeps + // this pass a no-op when the legacy path already produced the same edge. const existing = new Set(); for (const rel of graph.iterRelationshipsByType('EXTENDS')) { - existing.add(`${rel.sourceId}->${rel.targetId}`); + existing.add(`EXTENDS:${rel.sourceId}->${rel.targetId}`); + } + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + existing.add(`IMPLEMENTS:${rel.sourceId}->${rel.targetId}`); } for (const site of scopes.referenceSites) { @@ -81,12 +128,20 @@ function preEmitInheritanceEdges( // edge. The shared bridge resolves the source via // `resolveCallerGraphId`, which can degrade class-heritage sites into // method-owned EXTENDS edges once methods exist on the class. This - // pre-pass is the authoritative inheritance emitter, so broad + // pre-pass is the authoritative inheritance emitter and pins the source + // to the enclosing class (via the `callerGraphId` override below), so // suppression keeps `buildMro` and the final graph class-owned. handledSites.add(siteKey); } - const targetDef = findClassBindingInScope(site.inScope, site.name, scopes); + const targetDef = + findClassBindingInScope(site.inScope, site.name, scopes) ?? + // Import-aware disambiguation fallback (#1951). Only engages when the + // scope-chain + single-match lookups above returned undefined because + // the simple name is ambiguous (multiple same-named class-like defs). + // Picks the candidate whose defining file is imported/included by the + // referencing file. Never changes behavior for single-match cases. + resolveAmbiguousInheritanceBaseViaImports(site.inScope, site.name, scopes); if (targetDef === undefined) continue; const callerClass = findEnclosingClassDef(site.inScope, scopes); @@ -94,23 +149,18 @@ function preEmitInheritanceEdges( const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); if (callerGraphId === undefined || targetGraphId === undefined) continue; - const edgeKey = `${callerGraphId}->${targetGraphId}`; - if (existing.has(edgeKey)) continue; - - if ( - tryEmitEdge( - graph, - scopes, - nodeLookup, - site, - targetDef, - 'scope-resolution: inherits', - seen, - 0.85, - ) - ) { - existing.add(edgeKey); - } + // Discriminate EXTENDS vs IMPLEMENTS by the resolved target's symbol kind: + // conforming to an interface OR mixing in a trait/protocol is IMPLEMENTS, + // deriving from a class-like is EXTENDS. This matches the legacy heritage + // emitters (`resolveExtendsType` maps Interface→IMPLEMENTS; the trait-impl + // branch of `resolveAndAddHeritageEdge` maps trait use → IMPLEMENTS), so the + // registry-primary path matches the legacy DAG. The discriminator is purely + // symbol-kind-driven (no language is named here, per AGENTS.md): a base that + // resolves to neither an Interface nor a Trait symbol always takes the + // EXTENDS branch, so such languages are unchanged. + const edgeType: 'EXTENDS' | 'IMPLEMENTS' = + targetDef.type === 'Interface' || targetDef.type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'; + emitInheritanceEdgeDirect(graph, seen, existing, callerGraphId, targetGraphId, edgeType, site); } return handledSites; @@ -313,7 +363,7 @@ export function runScopeResolution( // the heritage declarations are syntactic method calls, not grammar-level // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees // the freshly-emitted IMPLEMENTS edges. - provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup); + provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup, finalized); // Implicit IMPORTS-edge hook — for languages whose files have compiler- // implicit cross-file visibility (no syntactic import statement). The // finalized-ImportEdge pipeline (`emitImportEdges`) cannot produce these diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 68936b5e6..e02a59552 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -300,6 +300,94 @@ export function findClassBindingInScope( return undefined; } +/** + * Import/include-aware disambiguation for an *ambiguous* class-like base + * name. Engages ONLY as a fallback after `findClassBindingInScope` has + * already returned `undefined` — i.e. the scope-chain walk and the + * single-match `qualifiedNames` fast paths could not pick a winner because + * several same-named class-like defs exist (e.g. two `class Handler`s in + * different headers/namespaces). + * + * Disambiguates by the referencing file's import graph: the enclosing + * module scope's finalized `ImportEdge[]` (C++ `#include`, C# `using`, etc.) + * each carry the exporting file in `targetFile`. A candidate whose defining + * file is brought in by one of those edges is preferred. Resolution is + * tiered, strictest first, and only commits when EXACTLY ONE candidate + * survives a tier — so a still-ambiguous name keeps the historical + * "return undefined" refusal: + * + * 1. Exact file match — candidate.filePath === an import's `targetFile` + * (covers C++ `#include "handler_a.h"` → that header's class). + * 2. Same-directory match — candidate.filePath sits in the same directory + * as some import target file (covers C# `using MyApp.Models;`, where the + * namespace import resolves to ONE representative file in the namespace's + * directory, not necessarily the file declaring the referenced type). + * + * Language-neutral: keyed only on the finalized import edges and the + * candidate defs' `filePath`. Returns `undefined` (preserving refusal) when + * the name is single-match-resolvable already (never reached — caller gates + * on `findClassBindingInScope` miss), when no import disambiguates, or when + * a tier leaves more than one survivor. + */ +export function resolveAmbiguousInheritanceBaseViaImports( + startScope: ScopeId, + baseName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + // Gather the class-like candidates that share this simple name. Defs are + // indexed by their `qualifiedName` in `qualifiedNames`; for languages whose + // class qualifiedName IS the simple name (C++, C#, etc.) this is the full + // candidate set. A single candidate is not "ambiguous" — leave it to the + // existing single-match fast path (this fallback shouldn't have been called). + const candidateIds = scopes.qualifiedNames.get(baseName); + if (candidateIds.length < 2) return undefined; + const candidates: SymbolDefinition[] = []; + for (const id of candidateIds) { + const def = scopes.defs.get(id); + if (def !== undefined && isClassLike(def.type)) candidates.push(def); + } + if (candidates.length < 2) return undefined; + + // Collect the exporting files imported by the referencing file's enclosing + // module scope (the chain may carry function-local imports too, but the + // module scope is where `#include` / `using` land). + const moduleScopeId = moduleScopeIdOf(startScope, scopes); + if (moduleScopeId === null) return undefined; + const importEdges = scopes.imports.get(moduleScopeId); + if (importEdges === undefined || importEdges.length === 0) return undefined; + const importedFiles = new Set(); + const importedDirs = new Set(); + for (const edge of importEdges) { + if (edge.targetFile === null) continue; + importedFiles.add(edge.targetFile); + importedDirs.add(dirnameOf(edge.targetFile)); + } + if (importedFiles.size === 0) return undefined; + + // Tier 1 — exact file match (C++ `#include "handler_a.h"`). + const exact = candidates.filter((c) => importedFiles.has(c.filePath)); + if (exact.length === 1) return exact[0]; + if (exact.length > 1) return undefined; // still ambiguous → refuse + + // Tier 2 — same-directory match (C# namespace `using`, where the namespace + // import resolves to one representative file in the namespace's directory). + const sameDir = candidates.filter((c) => importedDirs.has(dirnameOf(c.filePath))); + if (sameDir.length === 1) return sameDir[0]; + + return undefined; +} + +/** + * Directory portion of a forward-slash workspace-relative path. Returns `''` + * for a bare filename (no directory). Workspace paths are always normalized to + * `/` separators upstream, so a simple `lastIndexOf('/')` is sufficient and + * keeps this dependency-free. + */ +function dirnameOf(filePath: string): string { + const idx = filePath.lastIndexOf('/'); + return idx === -1 ? '' : filePath.slice(0, idx); +} + /** * Predicate for value-receiver bridge: the labels for which * `reconcileOwnership` registers methods/fields under the def's diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index ef8ed187f..3289bb8d3 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -216,6 +216,28 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { companion_object: 'Class', }; +/** + * Pre-order walk over a node and all its named descendants, invoking `cb` on + * each. Replaces the per-language `visit`/`visitGo`/`visitRust`/`visitSwift` + * clones that every language's capture-synthesis walker re-implemented (#1956 + * tri-review U6). + * + * Iterates by index with a null guard: `node.namedChild(i)` is typed + * `SyntaxNode | null`, and most callers already guarded it. The Go and C# + * callers previously iterated `node.namedChildren`; the Go one had no null + * guard, so this standardizes them onto the guarded indexed form — a deliberate, + * strictly-safer behavior addition (the traversal *sequence* is identical, so + * capture output stays byte-identical on well-formed trees; the guard only + * matters for a null named child, which the fixture corpus never produces). + */ +export function walkNamedTree(node: SyntaxNode, cb: (node: SyntaxNode) => void): void { + cb(node); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) walkNamedTree(child, cb); + } +} + /** Return the first matching ancestor unless a boundary ancestor is reached first. */ export function findAncestorBeforeBoundary( node: SyntaxNode, diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 6a8dead8c..9de082980 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -28,8 +28,8 @@ "digest": "ceae1be5aae89de4eec68c5a64195728a7ae434a9b2cab2eaab617b912c3433d" }, "csharp-ambiguous/Services/UserHandler.cs": { - "captureGroups": 9, - "digest": "f4e4371e9d23c48e9c1a89cc9475bddadffdd3d0bab129690b4b142a4dea94cd" + "captureGroups": 11, + "digest": "77fbdaffa5e1f6ab94b14d339af380c83466183162a57ea1b34f5d9264c2ed4a" }, "csharp-assignment-chain/Models/Repo.cs": { "captureGroups": 7, @@ -100,8 +100,8 @@ "digest": "817c2f4caf130d8af6ca4bbbc44d25c3186b59e1bddf6cb06b93cd99b98abd0c" }, "csharp-child-extends-parent/src/Child.cs": { - "captureGroups": 4, - "digest": "18a42204289b0c126ff5eb78497c631c51361b030626639b60e8005d0e4e69c2" + "captureGroups": 5, + "digest": "06860696436195a2e4bdac8954307bd97d28e8fb9ef27e1adbb31aa0f4939012" }, "csharp-child-extends-parent/src/Parent.cs": { "captureGroups": 7, @@ -176,24 +176,24 @@ "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" }, "csharp-generic-parent-resolution/src/Models/User.cs": { - "captureGroups": 9, - "digest": "62f80db9064341b9ff5f5761113672ec4ceb369af962a94e3b8b4b34c4e26e9c" + "captureGroups": 10, + "digest": "fddcbe19d2a797949e35f8a9f04546c9f483f92c6055685651b96e4dd6e87577" }, "csharp-generic-type-refs/Program.cs": { - "captureGroups": 21, - "digest": "833526f5f33d9b648fa22f696a3c2fff9ebfd9e733f567fa13c9607d40faba06" + "captureGroups": 22, + "digest": "d4181ce42bba3ee454f4a3b9870ea88944a0df9cd5c9147d42d930dac63b8b33" }, "csharp-grandparent-resolution/Models/A.cs": { "captureGroups": 9, "digest": "2687b8e8f0b869d1ea8bdb9cf04dba3845f25ec1699b518114c57c4851b3528d" }, "csharp-grandparent-resolution/Models/B.cs": { - "captureGroups": 4, - "digest": "0031e28abc076739af04291521c7c5272201c68c60ce5cef68f259a2c10429d4" + "captureGroups": 5, + "digest": "7b5140fffae831750f64f832cb19449d88cf421089ddbeb8790d58386a821b8b" }, "csharp-grandparent-resolution/Models/C.cs": { - "captureGroups": 4, - "digest": "8f100ee67304e05401c5021167d36fa7a1fbce9188fd8f1d2fdf9ac0e7a6eb7d" + "captureGroups": 5, + "digest": "e18b37eacce974faa20912b3ecf0588e5c3130b14624e672973bfff718aa0db9" }, "csharp-grandparent-resolution/Models/Greeting.cs": { "captureGroups": 7, @@ -212,8 +212,8 @@ "digest": "3f64ed5bfd0eb0bc62d73b908eebac7025f9e32639aeebc8db9160f3523b521a" }, "csharp-interface-default-method/User.cs": { - "captureGroups": 10, - "digest": "747fe9fed63700c0ce233b76d2544ed4cae16d68beb4ea29095d24cf68ff8bbe" + "captureGroups": 11, + "digest": "40a8824497cc733891ef51ea960b3e44bb4ed23db9934884f78bb36d4dde78be" }, "csharp-interface-default-method/Validator.cs": { "captureGroups": 7, @@ -228,12 +228,12 @@ "digest": "14743965ad586b13254a9618686d601b0dd36f3be97531c9fb0605139e83809d" }, "csharp-interface-dispatch/SqlRepository.cs": { - "captureGroups": 11, - "digest": "57cf94afb053e43647c295d3bd5ba9864a56cc75895d5e37194d2df18f1e744a" + "captureGroups": 12, + "digest": "65bd6fd311969af60313ea33345188894fa236ca26020b3e681fc8c3144898fb" }, "csharp-interface-heritage/src/IAuditableService.cs": { - "captureGroups": 5, - "digest": "eca6d49bcd2d8316ced662b168c4cd379f6f0ce08e591d6fd1ff513e28372a56" + "captureGroups": 7, + "digest": "bc413b880556ec2798ec501b020d993e7a53d1d9be1e0e0bee4d885990330fad" }, "csharp-interface-heritage/src/IBarService.cs": { "captureGroups": 6, @@ -244,12 +244,12 @@ "digest": "cfbc24950b74dd10ddb59c25f8aa4d4b83c5e72502c4227f3f6e311e9e06401f" }, "csharp-interface-heritage/src/IFooService.cs": { - "captureGroups": 6, - "digest": "19bef355b4992f96f06331ed11d8652b86d25ac1fb5e9cbc2d5d5c0ef85b123d" + "captureGroups": 7, + "digest": "0d00dcf860e772b8ec539064ed97941aadb64c99fc91783d3ff657c93d00b67e" }, "csharp-interface-heritage/src/MyService.cs": { - "captureGroups": 18, - "digest": "c565e684ad7e04915bce9fc3124e51db6b41669a20f29b4bc117317506bd7985" + "captureGroups": 19, + "digest": "b39d4f1b8860dc16be70431b86e5bfaf0a6fb317a1e586d7e08bbdfd6682aee2" }, "csharp-interface-receiver-static/src/ILogger.cs": { "captureGroups": 6, @@ -304,8 +304,8 @@ "digest": "09e0bed66a03cddb567b863b8d916b5b2f4210547caf2f8682c266d36102125e" }, "csharp-method-enrichment/Animal.cs": { - "captureGroups": 16, - "digest": "a2200a76108b6ef05340718bf7485d762f8004db76191992552cba370eb3999c" + "captureGroups": 17, + "digest": "b21133dbc6ecaaf9ba13de8d1b70c11c6a89d02f89696a137c8d79ae9e5e287a" }, "csharp-method-enrichment/App.cs": { "captureGroups": 14, @@ -388,8 +388,8 @@ "digest": "5211301516540daf9c54fb3c8ca3b43bddcce4f81a14034056f2bc897ae925a4" }, "csharp-overload-dispatch/SqlRepository.cs": { - "captureGroups": 16, - "digest": "7052be187bc4039e4e94c29446c4dae0aed9b288d95c067905a15a33f3d1625d" + "captureGroups": 17, + "digest": "8b67299c96e0331a44bfd56edf13bc71f957e39bfa95888108d3180f454050a3" }, "csharp-overload-interface/App/Caller.cs": { "captureGroups": 13, @@ -400,12 +400,12 @@ "digest": "1f78db08c2a7a8d3402cf3b0f092ec6aa5b6aa9fedc790d088eaffce8e4bd713" }, "csharp-overload-interface/Greeting/EnGreeter.cs": { - "captureGroups": 8, - "digest": "f719d5044c243225989e8cbf675d01adeb5cc2023338f320d422ccdab94964ac" + "captureGroups": 9, + "digest": "fa1429dc0e98f268ad3360d9ed55534dd39aa49b933771dd628a7b517f36fef6" }, "csharp-overload-interface/Greeting/FrGreeter.cs": { - "captureGroups": 8, - "digest": "4e18d58d5a54a87262e42cd84043dc31cbf6d31609eb5a8b3d61c0e692f3ea08" + "captureGroups": 9, + "digest": "3a3ec96334e17fa0c48e41d7766238eca3ae4d55d206b21b0f89c1d67d9c4f93" }, "csharp-overload-interface/Greeting/IGreeter.cs": { "captureGroups": 6, @@ -424,17 +424,37 @@ "digest": "93f2d7c639083aa077ba3c9d2dea678d0d6a2aa914e8078307c98f4c68530226" }, "csharp-parent-resolution/src/Models/User.cs": { - "captureGroups": 8, - "digest": "ad73f273c29be9536b40c663f6d1864f78b2bca667483d73cc3de4c4900c79ed" + "captureGroups": 10, + "digest": "848b4ec5fc727efb001134164da1158db495776bf006300fd58391040f6f906f" }, "csharp-pattern-matching/Models/Animal.cs": { - "captureGroups": 17, - "digest": "fda2322e993f129e139d11bffe48e2742459792e90b99b8eb0c9a10da15b5139" + "captureGroups": 19, + "digest": "40f80b8588591728ed99f3a3566dcefc14df511f8350cdc6843401e0f943c8ee" }, "csharp-pattern-matching/Services/AnimalService.cs": { "captureGroups": 11, "digest": "5418fbf243683914fea8eeed02f2ef856b02ba6c7e361e001da4662eb1332359" }, + "csharp-primary-ctor-heritage/src/BaseEntity.cs": { + "captureGroups": 5, + "digest": "b1ed570e315646104e03429cee0ea982f8563bce337a31293b3718f63679c2b2" + }, + "csharp-primary-ctor-heritage/src/IFoo.cs": { + "captureGroups": 6, + "digest": "b19bf122c62247868382dd45b258757b653e526fd3fda8cabe080f7f83510891" + }, + "csharp-primary-ctor-heritage/src/Repo.cs": { + "captureGroups": 6, + "digest": "3b339a7ef549da1554aa28d83a2d0402d79c1a7c483538e0266f283ac6175307" + }, + "csharp-primary-ctor-heritage/src/Service.cs": { + "captureGroups": 5, + "digest": "9514716e0d445012f9ff62e9b53d8f48e633d1f74b05c9cf90d85f8393c2015f" + }, + "csharp-primary-ctor-heritage/src/User.cs": { + "captureGroups": 11, + "digest": "36d4762bfb9ff4083315384560ae6716d12edd0f2d13550506d55721573fd214" + }, "csharp-primary-ctors/App.cs": { "captureGroups": 14, "digest": "4c2e190ed675ac78244e0a4230d79e9e4c74f2ef794dd9b8126f6b57a4242f4c" @@ -456,13 +476,21 @@ "digest": "633570540177423d352b44b0f5230d7f440f101789d1b92192c0fc360e6c26d3" }, "csharp-proj/Models/User.cs": { - "captureGroups": 18, - "digest": "a9ef60d8f0ad5109369df107c4ce20ac472e5a0d018bc7df2350c2ae66607372" + "captureGroups": 20, + "digest": "1bc7f070357cf54c14c4c1c7967ceb4caeb2a4a1aba0fa32e1e6c6c672a97ea4" }, "csharp-proj/Services/UserService.cs": { "captureGroups": 19, "digest": "42b79c274955abcd548ca842077e05b0b6b46e324dd29f7a297882ac790ce11f" }, + "csharp-qualified-base/src/Domain.cs": { + "captureGroups": 15, + "digest": "76cdfe6e0bb9ace81fda8e4c3e1b5bea536b3d906170ee92c68a8b4d5b918fbc" + }, + "csharp-qualified-base/src/Shapes.cs": { + "captureGroups": 38, + "digest": "d0023367c412f37f030860022c859a58a249aff2d17ec92b109f47a7efe77cf3" + }, "csharp-qualified-types/Data/User.cs": { "captureGroups": 7, "digest": "16b056de69cc44d953e4cc702f22986b61d935193c4d6bd9cd9a1adcbe2b3e6c" @@ -488,8 +516,8 @@ "digest": "e7da2e190dad718eeaa22dad011740fd2086eb2f86331d887e47cd9c5092003c" }, "csharp-record-base/src/Models/UserRecord.cs": { - "captureGroups": 9, - "digest": "4b7092ef2ded4e37d2fe1591610259e18b9bd3d92ebe32c90eef6683d9b9a0f6" + "captureGroups": 10, + "digest": "e6276cd40438125f468adb2f193786a6e888032a3980d0d7ac586d355c40534c" }, "csharp-recursive-pattern/Models/Repo.cs": { "captureGroups": 8, @@ -520,8 +548,8 @@ "digest": "3b66f87310d7dae98a79a6983996abca827e8b78d37e5a7234e26e9520634c5b" }, "csharp-same-arity-cross-file/DbLookup.cs": { - "captureGroups": 11, - "digest": "77916f4d60865d277c624d1a7ecde55fdb10e6354e5dc1d516c80677628dee3a" + "captureGroups": 12, + "digest": "870869bc7dec677c4f037cd1ef6da71ff6a180b40b76a313a4e6f8e6a2030ffa" }, "csharp-same-arity-cross-file/Formatter.cs": { "captureGroups": 11, @@ -576,8 +604,8 @@ "digest": "03009e02efb0bb1fb33dae6cd15b99c46f2e11a30da79fb8696ec3066c203db0" }, "csharp-super-resolution/src/Models/User.cs": { - "captureGroups": 9, - "digest": "231c0dabbb2e5d105ee19ae3d043f576315f4ec8764baaa310dda95770175827" + "captureGroups": 10, + "digest": "7884f0582c5b433845bfc9a8e331e8939638ee4e2308e0d8d8767f8332209a4b" }, "csharp-switch-pattern/Models/Repo.cs": { "captureGroups": 7, diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 24a0fc8d1..223edbde6 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -16,8 +16,8 @@ "digest": "08a61721581c4f17741ef0c4ee1c8945235ee7f2aca7d88d2fe122071cd62e6f" }, "go-ambiguous/internal/services/user.go": { - "captureGroups": 8, - "digest": "802b81a07c64c01f2381cf33d41cdb1a58f535c0007b5ae151c38cda87f28321" + "captureGroups": 9, + "digest": "642da0df644ebd5a789e2de2d473ced2c9a77e5f6f1b4a1b1d53cf04845b820c" }, "go-assignment-chain/cmd/main.go": { "captureGroups": 50, @@ -64,8 +64,8 @@ "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" }, "go-child-extends-parent/models/child.go": { - "captureGroups": 3, - "digest": "6fd9fe7b82066f82a93bf5e5024ddf89382091ec04e55648845c8295f13bd412" + "captureGroups": 4, + "digest": "61dafb01f7616ab6d965bcf17197de315f57a606c7b39cb25a0f83b84508d86d" }, "go-child-extends-parent/models/parent.go": { "captureGroups": 8, @@ -232,8 +232,8 @@ "digest": "2afaeb50d544a55fe437ef20e2c0de92152d2ba62f2693c329255787bb3d0a02" }, "go-parent-resolution/models/user.go": { - "captureGroups": 8, - "digest": "c76ba16343dd94024fdaac10fa7640536966e530d675c0084434f42edb5b5f15" + "captureGroups": 9, + "digest": "bc39ef8b54dcfcf975155ff69721bf2075ca170fb81bb081a1052dda72f64ecd" }, "go-pkg/cmd/main.go": { "captureGroups": 14, @@ -244,8 +244,8 @@ "digest": "2204643b50f486423ee7a5877b2bab7d6334cbe62b14b4435fe4ba8a6465ce92" }, "go-pkg/internal/models/admin.go": { - "captureGroups": 13, - "digest": "1a5ec9fd5e752adcfec91cd03b3c2a67c124852228a527002237ec51cd4b39b7" + "captureGroups": 14, + "digest": "85b2e848f29885082671f00d015bcce3b14ebd372a88acbc225e6ade1ee5449c" }, "go-pkg/internal/models/repository.go": { "captureGroups": 3, @@ -267,6 +267,18 @@ "captureGroups": 10, "digest": "5c31199e148340ee32dd48e32a00003e094be963b1287b9b40c6b4effca12175" }, + "go-qualified-base/base/base.go": { + "captureGroups": 16, + "digest": "4c50e40140094556e8bfc540f0ea6627c55823a1e4fcc91ca5d74912df67d499" + }, + "go-qualified-base/consumers/local.go": { + "captureGroups": 19, + "digest": "9097247f77ab93b742c715ff9d656038fc8ea0558e3fe7e4df64e6b1c6fcb64c" + }, + "go-qualified-base/consumers/qualified.go": { + "captureGroups": 14, + "digest": "01fe99cadcdf3ce6f00cafce8db0474ad152983f766c8dee9b9da42c9256ae2f" + }, "go-receiver-method-free-call/example.go": { "captureGroups": 8, "digest": "2a3c26672d3b997bdc39644361c550f8cf0749489f0945d210e2fb7f3bca9383" diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/BaseEntity.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/BaseEntity.cs new file mode 100644 index 000000000..c3d9783fd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/BaseEntity.cs @@ -0,0 +1,7 @@ +namespace App +{ + public class BaseEntity + { + public int Id { get; set; } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/IFoo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/IFoo.cs new file mode 100644 index 000000000..c256a243f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/IFoo.cs @@ -0,0 +1,7 @@ +namespace App +{ + public interface IFoo + { + void Foo(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Repo.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Repo.cs new file mode 100644 index 000000000..f51dcd957 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Repo.cs @@ -0,0 +1,7 @@ +namespace App +{ + public class Repo + { + public T Value { get; set; } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Service.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Service.cs new file mode 100644 index 000000000..be87c42ca --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/Service.cs @@ -0,0 +1,7 @@ +namespace App +{ + // Fully-qualified GENERIC base — exercises qualified+generic name normalization. + public class Service : App.Repo + { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/User.cs b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/User.cs new file mode 100644 index 000000000..7c66c1f44 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-primary-ctor-heritage/src/User.cs @@ -0,0 +1,8 @@ +namespace App +{ + // C# 12 primary constructor + base list (the #1951 worker-mode regression). + public class User(int id) : BaseEntity, IFoo + { + public void Foo() { } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Domain.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Domain.cs new file mode 100644 index 000000000..3674a7089 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Domain.cs @@ -0,0 +1,20 @@ +namespace App.Domain +{ + // Sibling-namespace base types. `Base` resolves to EXTENDS (Class kind); + // `IFoo` / `IBar` resolve to IMPLEMENTS (Interface kind). Single definition + // per name keeps the registry-primary base lookup unambiguous. + public class Base + { + public virtual void Run() { } + } + + public interface IFoo + { + void Foo(); + } + + public interface IBar + { + void Bar(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Shapes.cs b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Shapes.cs new file mode 100644 index 000000000..f1a75cd59 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-qualified-base/src/Shapes.cs @@ -0,0 +1,47 @@ +using App.Domain; +using DomainAlias = App.Domain; + +namespace App +{ + // Each declaration below exercises a base-list shape the registry-primary + // inheritance synth DROPPED before #1951. The legacy @heritage leg already + // covered them (tree-sitter-queries.ts record/struct base_list arms), so + // both resolver legs must now agree. + + // record_declaration base_list, plain identifier bases (record traversal + // was skipped — synth only walked class/interface declarations). + public record R(int x) : Base, IFoo + { + public void Foo() { } + } + + // record_declaration with a primary_constructor_base_type (`Base(id)`): the + // base-name extractor had no case for primary_constructor_base_type and + // returned null, dropping the EXTENDS edge. Its `type` field is the + // supertype; the trailing argument_list is normalized away → `Base`. + public record P(int id) : Base(id), IBar + { + public void Bar() { } + } + + // struct_declaration base_list with a qualified_name base (`App.Domain.IBar` + // → `IBar`). Struct traversal was skipped before #1951. + public struct S : IFoo, App.Domain.IBar + { + public void Foo() { } + public void Bar() { } + } + + // qualified_name base on a class — already handled; pinned as a regression + // guard so the simple/qualified path stays byte-identical. + public class A : App.Domain.Base + { + } + + // alias_qualified_name base (`DomainAlias::Base` → `Base`): the extractor + // had no case for alias_qualified_name and returned null. Its `name` field + // is the bare identifier; `normalizeSupertypeName` reduces it the same way. + public class B : DomainAlias::Base + { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-qualified-base/base/base.go b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/base/base.go new file mode 100644 index 000000000..12b3530d0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/base/base.go @@ -0,0 +1,26 @@ +package base + +// Base types embedded cross-package by the consumers package. Struct bases +// produce EXTENDS; the interface base produces IMPLEMENTS (the split is decided +// downstream from the resolved target's symbol kind). + +type Base struct { + ID int +} + +func (b *Base) Describe() string { + return "base" +} + +// Box is a generic struct embedded as `base.Box[int]` (generic_type wrapping a +// qualified_type) — the previously DROPPED qualified-generic embed shape. +type Box[T any] struct { + value T +} + +// Reader is embedded into a consumer interface as `base.Reader` (qualified +// interface embed) — previously DROPPED because the synth never walked +// interface_type bodies. +type Reader interface { + Read() (int, error) +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/local.go b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/local.go new file mode 100644 index 000000000..3b0fc3c0d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/local.go @@ -0,0 +1,30 @@ +package consumers + +// Same-package bases for the bare-name embed forms. These exercise the +// byte-identical simple-base path (bare type_identifier) alongside the newly +// handled bare interface embed. + +type Local struct { + Tag string +} + +func (l *Local) Tag2() string { + return l.Tag +} + +type LocalIface interface { + Local2() string +} + +// Bare struct embed `Local` (type_identifier) — the long-supported simple-base +// path, unchanged by this fix. → EXTENDS T → Local. +type T struct { + Local +} + +// Bare interface embed `LocalIface` inside an interface body +// (interface_type → type_elem → type_identifier) — previously DROPPED because +// the synth never walked interface bodies. → IMPLEMENTS RLocal → LocalIface. +type RLocal interface { + LocalIface +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/qualified.go b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/qualified.go new file mode 100644 index 000000000..65dc0a2e1 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/consumers/qualified.go @@ -0,0 +1,30 @@ +package consumers + +import "example.com/app/base" + +// Qualified struct embed `pkg.Base` (qualified_type) — previously DROPPED by the +// registry-primary synth (it rejected anything but a bare type_identifier). +// Resolves to the struct base.Base → EXTENDS S → Base. +type S struct { + base.Base +} + +// Pointer-qualified struct embed `*pkg.Base`. The `*` is an unnamed token, so +// field_declaration.type is already the qualified_type — same shape as S. +// → EXTENDS P → Base. +type P struct { + *base.Base +} + +// Qualified-generic struct embed `pkg.Box[T]` (generic_type wrapping a +// qualified_type). Reduces to the bare base name `Box`. → EXTENDS G → Box. +type G struct { + base.Box[int] +} + +// Qualified interface embed `pkg.Reader` inside an interface body +// (interface_type → type_elem). The synth now walks interface bodies. The +// target base.Reader is an interface → IMPLEMENTS R → Reader. +type R interface { + base.Reader +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-qualified-base/go.mod b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/go.mod new file mode 100644 index 000000000..192e075e8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-qualified-base/go.mod @@ -0,0 +1,3 @@ +module example.com/app + +go 1.21 diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Box.java b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Box.java new file mode 100644 index 000000000..f34be125e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Box.java @@ -0,0 +1,5 @@ +package app; + +public class Box { + public T get() { return null; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/IFoo.java b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/IFoo.java new file mode 100644 index 000000000..3822944e7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/IFoo.java @@ -0,0 +1,5 @@ +package app; + +public interface IFoo { + void foo(T t); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Service.java b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Service.java new file mode 100644 index 000000000..7c9f4c16b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-generic-base/src/app/Service.java @@ -0,0 +1,5 @@ +package app; + +public class Service extends Box implements IFoo { + public void foo(String t) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IA.java b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IA.java new file mode 100644 index 000000000..00d4c668a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IA.java @@ -0,0 +1,13 @@ +package app; + +// Interface-to-interface EXTENDS (#1951). `interface IA extends IB, IC` +// lives under `interface_declaration > extends_interfaces > type_list`, which +// the registry-primary synth previously NEVER walked (it visited +// class_declaration only) — so production silently dropped these edges while the +// legacy @heritage `interface_declaration` arm emitted them. Both bases resolve +// to Interface symbols, so the edges are emitted as IMPLEMENTS at both legs. +// IC exercises the generic-base reduction (IC -> IC), matching +// normalizeSupertypeName. +public interface IA extends IB, IC { + void a(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IB.java b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IB.java new file mode 100644 index 000000000..d562bad37 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IB.java @@ -0,0 +1,5 @@ +package app; + +public interface IB { + void b(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IC.java b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IC.java new file mode 100644 index 000000000..4af35fe09 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-iface-extends/src/app/IC.java @@ -0,0 +1,5 @@ +package app; + +public interface IC { + void c(T t); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Plain.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Plain.java new file mode 100644 index 000000000..9071cb107 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Plain.java @@ -0,0 +1,10 @@ +package app; + +// 2-SEGMENT qualified non-generic bases (Outer.Inner shape): `extends base.Base` +// and `implements base.IBar`. Both segments parse as direct type_identifier +// children of the scoped_type_identifier (no nested prefix), so the legacy +// @heritage query MUST end-anchor to the trailing segment or it double-matches +// and emits a spurious prefix edge. Regression guard for the U2 anchor fix. +public class Plain extends base.Base implements base.IBar { + public void bar() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Service.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Service.java new file mode 100644 index 000000000..5a18f0cce --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Service.java @@ -0,0 +1,8 @@ +package app; + +// Qualified-generic bases: `extends app.base.Box` (generic_type wrapping +// a scoped_type_identifier) and `implements app.base.IFoo` (in a +// type_list). Both resolve by their trailing simple name (Box / IFoo). +public class Service extends app.base.Box implements app.base.IFoo { + public void foo(String t) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Two.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Two.java new file mode 100644 index 000000000..7b0969118 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/Two.java @@ -0,0 +1,9 @@ +package app; + +// 2-SEGMENT qualified-GENERIC bases: `extends base.Box` and `implements +// base.IFoo`. Exercises the generic_type-wrapped scoped arms at two +// segments (the shape that double-matched before the end-anchor fix). Pairs with +// Plain (2-segment plain) and Service (3-segment generic) for full arm coverage. +public class Two extends base.Box implements base.IFoo { + public void foo(String t) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Base.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Base.java new file mode 100644 index 000000000..5995784bb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Base.java @@ -0,0 +1,5 @@ +package app.base; + +public class Base { + public void base() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Box.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Box.java new file mode 100644 index 000000000..11518971f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/Box.java @@ -0,0 +1,5 @@ +package app.base; + +public class Box { + public T get() { return null; } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IBar.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IBar.java new file mode 100644 index 000000000..8d093d899 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IBar.java @@ -0,0 +1,5 @@ +package app.base; + +public interface IBar { + void bar(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IFoo.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IFoo.java new file mode 100644 index 000000000..32658c6fb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-base/src/app/base/IFoo.java @@ -0,0 +1,5 @@ +package app.base; + +public interface IFoo { + void foo(T t); +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/Service.js b/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/Service.js new file mode 100644 index 000000000..9a75ff2e4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/Service.js @@ -0,0 +1,21 @@ +import * as ns from './base.js'; +import { Base } from './base.js'; + +// Qualified base: `extends ns.Base` parses as a class_heritage holding a +// member_expression (object: identifier `ns`, property: property_identifier +// `Base`). The registry-primary synth resolves it by its trailing +// property_identifier (`Base`), matching the legacy @heritage leg's +// normalizeSupertypeName reduction (member_expression -> `Base`). +export class Service extends ns.Base { + base() { + return 'service'; + } +} + +// Bare control: `extends Base` (direct identifier) — its handling is unchanged +// (byte-identical to the pre-fix simple-base path). +export class Plain extends Base { + base() { + return 'plain'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/base.js b/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/base.js new file mode 100644 index 000000000..5acb69346 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-qualified-base/src/base.js @@ -0,0 +1,5 @@ +export class Base { + base() { + return 'base'; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Base.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Base.kt new file mode 100644 index 000000000..f3f67f137 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Base.kt @@ -0,0 +1,5 @@ +package models + +open class Base { + fun base() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/F.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/F.kt new file mode 100644 index 000000000..783c8fe79 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/F.kt @@ -0,0 +1,12 @@ +package models + +// Interface-delegation base: `: Iface by d` parses as +// `(delegation_specifier (explicit_delegation (user_type (type_identifier)) ))`. +// The supertype is the LEADING `user_type` (Iface); the trailing delegate +// expression (`by d`) is NOT a supertype. The registry-primary synth previously +// DROPPED this shape, so production emitted no IMPLEMENTS edge here (#1951). +// Resolves by its simple name `Iface`, matching the legacy @heritage leg's +// normalizeSupertypeName(explicit_delegation) reduction. +class F(d: Iface) : Iface by d { + fun extra() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/G.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/G.kt new file mode 100644 index 000000000..c70fd1494 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/G.kt @@ -0,0 +1,9 @@ +package models + +// Bare control: constructor-call superclass `: Base()` parses as +// `(delegation_specifier (constructor_invocation (user_type (type_identifier))))`. +// This shape was already handled; it stays byte-identical and is the regression +// guard that the simple-base path is unchanged by the explicit_delegation widening. +class G : Base() { + fun other() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Iface.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Iface.kt new file mode 100644 index 000000000..0072575f9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-qualified-base/src/Iface.kt @@ -0,0 +1,5 @@ +package models + +interface Iface { + fun handle(): String +} diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/__init__.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/b.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/b.py new file mode 100644 index 000000000..cca16fb09 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/a/b.py @@ -0,0 +1,3 @@ +class Base: + def base(self) -> None: + pass diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-base/base_mod.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/base_mod.py new file mode 100644 index 000000000..bfaa1a947 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/base_mod.py @@ -0,0 +1,8 @@ +class Model: + def save(self) -> None: + pass + + +class Container: + def get(self): + return None diff --git a/gitnexus/test/fixtures/lang-resolution/python-qualified-base/service.py b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/service.py new file mode 100644 index 000000000..a9fa101fb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/python-qualified-base/service.py @@ -0,0 +1,29 @@ +import base_mod +import a.b +from base_mod import Container + + +# Qualified attribute base: `class Service(base_mod.Model)` parses the base as +# an `attribute` node (object `base_mod`, attribute `Model`). The synth resolves +# it by its trailing `.attribute` identifier -> `Model` (#1951). +class Service(base_mod.Model): + pass + + +# Nested attribute base: `class Nested(a.b.Base)` parses as a nested `attribute` +# (object `a.b`, attribute `Base`). Recurse to the final identifier -> `Base`. +class Nested(a.b.Base): + pass + + +# Generic subscript base: `class Gen(Container[str])` parses the base as a +# `subscript` node (`value:` `Container`, slice `str`). The synth strips the +# `[...]` via the `value:` field -> `Container`. +class Gen(Container[str]): + pass + + +# Bare control: `class Plain(Container)` keeps the existing simple-identifier +# capture byte-identical. +class Plain(Container): + pass diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/derived.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/derived.rb new file mode 100644 index 000000000..7f8b05665 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/derived.rb @@ -0,0 +1,24 @@ +require_relative 'outer' + +# SCOPED superclass `class C < Outer::Super`: the superclass field holds a +# `scope_resolution` (Outer::Super), not a direct `constant`. The registry- +# primary synth previously dropped this (findChild(superclass,'constant') was +# null) so production silently omitted the EXTENDS edge while the legacy +# @heritage leg captured it (#1951). It must resolve to `Super` by the trailing +# `name:` constant, at parity with normalizeSupertypeName. `include Mixin` flows +# through the independent mixin lane (IMPLEMENTS, unchanged). +class C < Outer::Super + include Mixin + + def run + base + end +end + +# BARE superclass control `class D < Base` (direct `constant`): the original +# path, kept byte-identical. EXTENDS D -> Base. +class D < Base + def run + base + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/outer.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/outer.rb new file mode 100644 index 000000000..ba89f78a4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-base/lib/outer.rb @@ -0,0 +1,24 @@ +# Module-nested superclass + a top-level bare base + a mixin module. +# - `Super` is defined inside `Outer`, so a scoped superclass +# `< Outer::Super` must resolve to it by its trailing bare name (Super). +# - `Base` is a top-level class used as the bare-superclass control. +# - `Mixin` is included by C to exercise the (unchanged) mixin lane. +module Outer + class Super + def base + "super" + end + end +end + +class Base + def base + "base" + end +end + +module Mixin + def mixed + "mixed" + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/a.rs b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/a.rs new file mode 100644 index 000000000..5e06c0445 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/a.rs @@ -0,0 +1,9 @@ +use crate::traits::Drawable; + +pub struct User { + pub id: u32, +} + +impl Drawable for User { + fn draw(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/b.rs b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/b.rs new file mode 100644 index 000000000..8da20764a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/b.rs @@ -0,0 +1,9 @@ +use crate::traits::Drawable; + +pub struct User { + pub name: String, +} + +impl Drawable for User { + fn draw(&self) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/main.rs new file mode 100644 index 000000000..193f4cb4b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/main.rs @@ -0,0 +1,5 @@ +mod traits; +mod a; +mod b; + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/traits.rs b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/traits.rs new file mode 100644 index 000000000..aaff8510b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-cross-module-collision/src/traits.rs @@ -0,0 +1,3 @@ +pub trait Drawable { + fn draw(&self); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/main.rs new file mode 100644 index 000000000..babe720b7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/main.rs @@ -0,0 +1,4 @@ +mod traits; +mod widget; + +fn main() {} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/traits.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/traits.rs new file mode 100644 index 000000000..2b3ef0ad3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/traits.rs @@ -0,0 +1,7 @@ +pub trait Drawable { + fn draw(&self); +} + +pub trait Wrapped { + fn wrap(&self) -> T; +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/widget.rs b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/widget.rs new file mode 100644 index 000000000..99ca22d3d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-qualified-trait/src/widget.rs @@ -0,0 +1,27 @@ +pub struct Widget { + label: String, +} + +pub struct Gadget { + id: u32, +} + +// Qualified trait path with NO `use` — the base is a `scoped_type_identifier` +// that resolves by its trailing name `Drawable` (KTD-1). The trait is unique +// and lives in a sibling module, so it resolves via the single-match fast path. +// This doubles as the lone-cross-module-match characterization: tail-only +// resolution is no worse than the bare-name path here, and distinguishing +// same-named traits across modules is deferred (qualifier-preserving resolution). +impl crate::traits::Drawable for Widget { + fn draw(&self) { + println!("{}", self.label); + } +} + +// Qualified-generic trait path — `crate::traits::Wrapped` normalizes to the +// trailing `Wrapped` through the generic_type -> scoped_type_identifier tail. +impl crate::traits::Wrapped for Gadget { + fn wrap(&self) -> u32 { + self.id + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Derived.swift b/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Derived.swift new file mode 100644 index 000000000..a7d8e5f73 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Derived.swift @@ -0,0 +1,2 @@ +class Derived: Outer.Inner { +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Outer.swift b/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Outer.swift new file mode 100644 index 000000000..b56986400 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-qualified-base/Sources/Outer.swift @@ -0,0 +1,7 @@ +class Outer { + class Inner { + func ping() -> String { + return "inner" + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Box.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Box.ts new file mode 100644 index 000000000..d4d579e35 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Box.ts @@ -0,0 +1,5 @@ +export class Box { + get(): T { + return undefined as unknown as T; + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/IFoo.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/IFoo.ts new file mode 100644 index 000000000..235ebf5de --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/IFoo.ts @@ -0,0 +1,3 @@ +export interface IFoo { + foo(t: T): void; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Service.ts new file mode 100644 index 000000000..2b1dacfb5 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-generic-base/src/Service.ts @@ -0,0 +1,6 @@ +import { Box } from './Box'; +import { IFoo } from './IFoo'; + +export class Service extends Box implements IFoo { + foo(t: string): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/Service.ts b/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/Service.ts new file mode 100644 index 000000000..24a423b57 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/Service.ts @@ -0,0 +1,15 @@ +import * as ns from './base'; + +// Qualified-generic bases: `extends ns.Box` (extends_clause value is a +// member_expression, type_arguments a sibling) and `implements ns.IFoo` +// (implements_clause -> generic_type wrapping a nested_type_identifier). Both +// resolve by their trailing simple name (Box / IFoo). +export class Service extends ns.Box implements ns.IFoo { + foo(t: string): void {} +} + +// Qualified non-generic bases: `extends ns.Base` (member_expression) and +// `implements ns.IBar` (nested_type_identifier). +export class Plain extends ns.Base implements ns.IBar { + bar(): void {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/base.ts b/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/base.ts new file mode 100644 index 000000000..d9ff1321f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-qualified-base/src/base.ts @@ -0,0 +1,17 @@ +export class Base { + base(): void {} +} + +export class Box { + get(): T { + return undefined as unknown as T; + } +} + +export interface IFoo { + foo(t: T): void; +} + +export interface IBar { + bar(): void; +} diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index 04158eeb5..f657d68be 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -4,8 +4,8 @@ "digest": "3459af9360d51aaaba72963fc49bb79edc188e97f9831421d6504c7919b61dc5" }, "php-abstract-dispatch/src/Repositories/SqlRepository.php": { - "captureGroups": 13, - "digest": "01093dcbb7c4482e93572c59d091c6ece2332d46badd7d7a5b44fd2f7cad5507" + "captureGroups": 14, + "digest": "5905bb450b29d4e186d74b50f70f07a54c8e0d4b8c3748c998c1579e72283215" }, "php-abstract-dispatch/src/app.php": { "captureGroups": 10, @@ -40,8 +40,8 @@ "digest": "f6ad05a50da70b32744792da353d520d63722bca7c0802e603af5eb13a223c5c" }, "php-ambiguous/app/Services/UserHandler.php": { - "captureGroups": 10, - "digest": "90054792db28ad77054994b483305ebf82d8a8959a0350a721d4a76438aea0a0" + "captureGroups": 12, + "digest": "1c47e7020939a6cd6bb18f3732b51a16f4c75ded0fe9883a87d6706a2c624d1f" }, "php-app/app/Contracts/Loggable.php": { "captureGroups": 7, @@ -56,16 +56,16 @@ "digest": "aadf56b87d67191b13f4e3f9fae7e8097f4c8a81d13135645ceb8c5be1063a9b" }, "php-app/app/Models/BaseModel.php": { - "captureGroups": 16, - "digest": "3a768a8443ad599d8501e9aa1e56535cb32ba8b13779b837a68781df3442edff" + "captureGroups": 18, + "digest": "be7ca5be7e28417afdef2e45c8cfed9e04594b341b0d676e7a00fdd84c94e42a" }, "php-app/app/Models/User.php": { - "captureGroups": 25, - "digest": "641b8f77fc29b102d2c4d4f04cfea8f5a64f769c0d53da9282c0d9505e1e7dd9" + "captureGroups": 27, + "digest": "b6b08be1af66cbd757cfd715389725952c0e2a8e5e910ed715594c0b07570a37" }, "php-app/app/Services/UserService.php": { - "captureGroups": 37, - "digest": "4c20f536c2df20a9fc1cac33aa48a06535898dcf4f46c62067c8ffd9deab5dbd" + "captureGroups": 38, + "digest": "80b8557e183052f25222cfda8879e08b1613752001f58670ce7b3dbf4b8bee28" }, "php-app/app/Traits/HasTimestamps.php": { "captureGroups": 11, @@ -108,8 +108,8 @@ "digest": "0c8a7c7edaa20009b71f22fef98230119a4738d2a21c8b88d64047c3c932fd36" }, "php-child-extends-parent/src/Child.php": { - "captureGroups": 4, - "digest": "4943bc0abc98546bb82d98e17260d686805a6aa644aabf2b86ad75dc8334b73b" + "captureGroups": 5, + "digest": "07f4216630a17fddb627eff217aa81f089b5e1fc6a9f82d727a6ae91693f8e38" }, "php-child-extends-parent/src/Parent.php": { "captureGroups": 7, @@ -240,12 +240,12 @@ "digest": "47af1c30a957f15a2a5ebbddd80c0214a4f68c15f08699fc81cdbfaefa4a70b5" }, "php-grandparent-resolution/app/Models/B.php": { - "captureGroups": 4, - "digest": "d6326a8eb65bfa8da20d9fb0e5ce07c61eeabd5d53cc94faaebbc3dda0274359" + "captureGroups": 5, + "digest": "eea22a8993a483f264d7bed26999be9d4d0cb9ad48a30ef85bc09b5049b274c8" }, "php-grandparent-resolution/app/Models/C.php": { - "captureGroups": 4, - "digest": "92fb1dabd5e6dc6eb4115d26303c96ba3d5e34ef9d0ccd965f531b5cf34b7390" + "captureGroups": 5, + "digest": "497307bd7e5c75da828297baabfc1dc2a36865ba358583a4bc39b605ddaacc16" }, "php-grandparent-resolution/app/Models/Greeting.php": { "captureGroups": 7, @@ -292,16 +292,16 @@ "digest": "f32de8a30558f2678e73c68d7db9c6eab827fd8d5f7655b906a76066c8421bef" }, "php-method-enrichment/src/Models/Dog.php": { - "captureGroups": 8, - "digest": "ffe9909633e018ae077b685a83d18dde30d30b818068b7ef6674cbd184ebdf33" + "captureGroups": 9, + "digest": "04aabc879f65c14e581ab782acfedf74335ba2905402a5fbfcf15bf5308f57d5" }, "php-method-enrichment/src/app.php": { "captureGroups": 11, "digest": "52791f6945c8c4ae083b816bd3af239bce44f0e97cbf80cdc119f4f366015138" }, "php-mro-arity-mismatch/app/Models/ChildModel.php": { - "captureGroups": 15, - "digest": "e007097393563883393a2469befbbe76568fa25dce87d15cc808a877edc0177c" + "captureGroups": 16, + "digest": "eabbdb94047d99d03b7e5cafcde4b0f89666695ca4c3b97ea7e68148999c1c00" }, "php-mro-arity-mismatch/app/Models/Orphan.php": { "captureGroups": 9, @@ -364,8 +364,8 @@ "digest": "e047de645ae2b8029a4bb31a61bbc556f58da066d346a2e57101d22cb8dcbb6a" }, "php-parent-resolution/app/Models/User.php": { - "captureGroups": 8, - "digest": "b34ddf79f02bc1f8a233c91e0d7ad7471cc50912897d619b03c9c60e111de2e6" + "captureGroups": 10, + "digest": "9e8891046e8311bcc07fd1f726e8f28dac37c2c2a6e28ed08b780ff475dbe60a" }, "php-parent-vs-trait/app/Auditable.php": { "captureGroups": 7, @@ -376,8 +376,8 @@ "digest": "d7f05cb3f8cf09740fd7063932cc4fccb5b6fff553093b64a341e2503ec42f2d" }, "php-parent-vs-trait/app/Child.php": { - "captureGroups": 14, - "digest": "8b0149c1d5d54d5d2cb743bbf43d424bc19cf758f4dde04105084c0903117825" + "captureGroups": 16, + "digest": "e3e9273efaf6c06ed241dc88d0754b167a64197731a4ccc18be43672fab07547" }, "php-phpdoc-attribute-return-type/Models.php": { "captureGroups": 11, @@ -452,8 +452,8 @@ "digest": "70b05bbb0ad6e8c1ba62d3355dbc1ffa6ac01c9e85ac22e43a07e387eddf401e" }, "php-super-resolution/app/Models/User.php": { - "captureGroups": 9, - "digest": "e62c49645b1ef609bb915c355b06379d996c030c3eaacbe40ca49d0d0b323e64" + "captureGroups": 10, + "digest": "56d657425e80ae4e58ffe139f90afab5a8527e191bc4b76662f5ce4cbbca4cbb" }, "php-this-receiver-disambiguation/AdminService.php": { "captureGroups": 15, @@ -468,16 +468,16 @@ "digest": "147924d3638edc63eef1a009943fae66a87ac85d02ae533428646c644e81c2bb" }, "php-transitive-traits/app/Models/Consumer.php": { - "captureGroups": 17, - "digest": "a447ed654cc0e074c2f0d93b0d371953e823c9a77da559e841741f5b73b97058" + "captureGroups": 18, + "digest": "c4524f4fa18f6e7f0fd76a11920a6a65ab547ab4cf0fa5799b42b7678e14db08" }, "php-transitive-traits/app/Traits/TraitA.php": { - "captureGroups": 7, - "digest": "53ca5a3b0407c54a2d6ce80a83eee592c32443795a01186bc9c4cac680e050b7" + "captureGroups": 8, + "digest": "485735b607fbe569527952fe697b33723626de3e974807e0bd9d84f63b789f65" }, "php-transitive-traits/app/Traits/TraitB.php": { - "captureGroups": 7, - "digest": "abafc915f22428f94e82ca1450902b6ef0599557e05aea345f388392b8097bf2" + "captureGroups": 8, + "digest": "ad387364baf5195af56688cf53956d40b4da193bad28a37b148963fbea68cbea" }, "php-transitive-traits/app/Traits/TraitC.php": { "captureGroups": 7, diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 350e8f8cb..3f1189813 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -4,12 +4,12 @@ "digest": "8662c17b0f21fcfa650065abd62f0c9b7e1c65bf8a1f7dce6f2a16bba9df759f" }, "python-abstract-dispatch/base.py": { - "captureGroups": 15, - "digest": "9c2891a6143c10cc3d81603c8623e54b6914cd8aaa668e84b59c1602492c0e4b" + "captureGroups": 16, + "digest": "2d25dcc17cb5b31cea26c15776d3a8cae290790d92a7adc6d6155be534fdd75b" }, "python-abstract-dispatch/impl.py": { - "captureGroups": 14, - "digest": "2a7ec28b431cb4010829bca1f0cadf3b11ef7a67abe16f322fc4329a5b619223" + "captureGroups": 15, + "digest": "6c27015f13d32024ce06c1515ab29ca0436df7a4864bbb62188d5f79d685cc31" }, "python-alias-imports/app.py": { "captureGroups": 13, @@ -40,8 +40,8 @@ "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-ambiguous/services/user_handler.py": { - "captureGroups": 7, - "digest": "2a441d1b24e272fdf5c214401d59211b6dff18b56bdaa06148f633ee3a5105e2" + "captureGroups": 8, + "digest": "5a4bb82d0e6a6a53fe012f197e42c572ac1169ff38e84db9c6281399f6739021" }, "python-ancestor-import/a/b/c/deep.py": { "captureGroups": 5, @@ -128,8 +128,8 @@ "digest": "0f60d5cd521b0073524b0993e82d5291f86badd5cbefb986cefdf7b0bed64157" }, "python-child-extends-parent/child.py": { - "captureGroups": 4, - "digest": "c85d867b0b206cd3e13e8dd94c83b49f13e0f2ddde7acd61785b9755f41d3d14" + "captureGroups": 5, + "digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61" }, "python-child-extends-parent/parent.py": { "captureGroups": 7, @@ -208,16 +208,16 @@ "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" }, "python-django-app-imports/accounts/apps.py": { - "captureGroups": 5, - "digest": "6fc1529373f9e3183ccd1c9a7013fa2762ef30c36fea7a7afb4ba418aafab9b0" + "captureGroups": 6, + "digest": "784cba903ad9534337ed820b085c8ecc352964e797bde1d4dda9e700960366a0" }, "python-django-app-imports/accounts/migrations/__init__.py": { "captureGroups": 0, "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-django-app-imports/accounts/models.py": { - "captureGroups": 7, - "digest": "35025fe0635eb78a60be6ec7a808aa1db586f49a878f16263307b3a04452b4d5" + "captureGroups": 8, + "digest": "b240f4ea2135824ee47fd5f2d9a4788ff0374cafb5070b295fc710a523f19873" }, "python-django-app-imports/accounts/tests.py": { "captureGroups": 2, @@ -236,16 +236,16 @@ "digest": "392b15be747e2b5cbd3ac5a9e61a7677ffa6ba52e49d3631681e43c557373b5f" }, "python-django-app-imports/billing/apps.py": { - "captureGroups": 5, - "digest": "b93de8fe8c56a8ef878b665667fc5fcfa73b526d095228ed4c972aeff9c1ad44" + "captureGroups": 6, + "digest": "0ff487476397cc85c2ce5ec0afe59eb82d83f97bcb3d4518b5040f52790e1833" }, "python-django-app-imports/billing/migrations/__init__.py": { "captureGroups": 0, "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, "python-django-app-imports/billing/models.py": { - "captureGroups": 11, - "digest": "9e7f426103b6c5f53630bddf7c50253bee22c0c018ef85437fdefe0117998053" + "captureGroups": 12, + "digest": "eedb2e1992f85a6743555947118784db9c78f3c113d2d598e53dd54cb80a0629" }, "python-django-app-imports/billing/tests.py": { "captureGroups": 2, @@ -348,12 +348,12 @@ "digest": "5879a42d6655248623c9aee193fedcae51daa1887d8e597fdd944ad93af053a4" }, "python-grandparent-resolution/models/b.py": { - "captureGroups": 4, - "digest": "55d7ad9e04c57cffa63b0bbcd71bfe18277b6369e7f4facb0faf802eea94db91" + "captureGroups": 5, + "digest": "be5c28ebfb06cd90c7cd453d612f0cacdffdac3e57d7ce794c0a353a9b057597" }, "python-grandparent-resolution/models/c.py": { - "captureGroups": 4, - "digest": "1fe0d62c3f332a954e7a7b18dd3c1a6d8d051ecf007d36cb6ecb32d3f7592c38" + "captureGroups": 5, + "digest": "2ad9124422ca018e59855c55a054d5c37d15b448a90027842fc56dd6f61e4593" }, "python-grandparent-resolution/models/greeting.py": { "captureGroups": 7, @@ -472,8 +472,8 @@ "digest": "88dfd417951b8083184f83da8c1e0c2b19700cfbf1f1ff203a904ebc00f50b30" }, "python-method-enrichment/models.py": { - "captureGroups": 23, - "digest": "c177fc004563a84c1ccb5ed9cac366f184a7b5d0badcbfe7b3c204caf23bd533" + "captureGroups": 25, + "digest": "14c45aebe0fc6ad1a9328bda3da8cdf7eaa6b9d84641cd2d63c1a105aa18cd42" }, "python-module-export-vs-method-collision/app.py": { "captureGroups": 14, @@ -500,16 +500,16 @@ "digest": "98bcec072e85a50303be141212b835322f5f9e53f7fe5d77b23d8ee524623e84" }, "python-multi-level-mro/child.py": { - "captureGroups": 4, - "digest": "c85d867b0b206cd3e13e8dd94c83b49f13e0f2ddde7acd61785b9755f41d3d14" + "captureGroups": 5, + "digest": "d118691eb76c9432841743efee8556f1e7a1d136e9b403a12fd512f91d73ca61" }, "python-multi-level-mro/grandparent.py": { "captureGroups": 7, "digest": "f9f81d3a37c55b3e23bec3774405920afa29c5793c46860a98a06c5d0c7f0980" }, "python-multi-level-mro/parent.py": { - "captureGroups": 4, - "digest": "05380e5d4d88a93546a3185c0f6598259cc5a1c271ec08a130cf72cf9107023d" + "captureGroups": 5, + "digest": "b68bfb8fdedb8f725c609264e604ccb674a5a775c0d87c008a9990234c70bde3" }, "python-multi-segment-ancestor-import/backend/auth_utils.py": { "captureGroups": 6, @@ -592,16 +592,16 @@ "digest": "f0384bd6ecb7d1a9ad2306358917b7295c71ea8f1bcea818fd39c56d2c28c7e9" }, "python-parent-resolution/models/user.py": { - "captureGroups": 8, - "digest": "0dbcf175be44961f7a7c02e21167f10063772297633173236ce49f970bb395a4" + "captureGroups": 9, + "digest": "b06a66a108097eec9427a028dec38bbab284918ac39344e86e58bba89533c530" }, "python-pkg/models/base.py": { "captureGroups": 9, "digest": "4984ee01b7a9fefe622195f0e4925823c0e62a1714ca2dda5cd8250e5e45fa7c" }, "python-pkg/models/user.py": { - "captureGroups": 7, - "digest": "8c340fe8e46b5adf03c82e736822c501983c087b85dc72bdf6d12b75faa843ab" + "captureGroups": 8, + "digest": "9ee707b36f42a635fdb867ce20359b5f51ac4e13550cde801359dc8314e01a77" }, "python-pkg/services/auth.py": { "captureGroups": 9, @@ -623,6 +623,22 @@ "captureGroups": 11, "digest": "abf5fcdf7cc473efa5a149319b0fc3d14b3ae91a2e9a14d621243c5ed7fafafa" }, + "python-qualified-base/a/__init__.py": { + "captureGroups": 0, + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "python-qualified-base/a/b.py": { + "captureGroups": 7, + "digest": "725adf1074b272c8d2ce664549106b07286a92cfb08ab4d605068102317f8baa" + }, + "python-qualified-base/base_mod.py": { + "captureGroups": 12, + "digest": "7fc34dae23f54cdee030a2d4a1d80c0bf226ea3b37335ecf48551c4707d32e20" + }, + "python-qualified-base/service.py": { + "captureGroups": 16, + "digest": "adecbd613fe97656cd5797c4dbef9f3c32bcd1b9faa5e765227af5d2001da427" + }, "python-qualified-constructor/main.py": { "captureGroups": 9, "digest": "9e4d51c06d75721e3c07898124ef85139d52d9f20d6df6bae557aff98a3ed207" @@ -712,8 +728,8 @@ "digest": "1d6eb1cdc661f2463d8e1a499eaa5bfcd9367324f6090c44fe4d59ed02151215" }, "python-super-resolution/models/user.py": { - "captureGroups": 10, - "digest": "2ab193907b46f0fd20f18e0b3b019986ae34f6252bff38145ebb40922bf7eb00" + "captureGroups": 11, + "digest": "8a66f8962fcf960b66c1106d1d67da21f7f6bac323b961e5fc0623b3c93dbc38" }, "python-variadic-resolution/app.py": { "captureGroups": 5, diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 235436ea5..dc2a4d1b2 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -1,7 +1,7 @@ { "ruby-ambiguous/lib/user_handler.rb": { - "captureGroups": 9, - "digest": "282d98829c29708081e96207dc0a6d82142400444a1345e162287e6b2c23d019" + "captureGroups": 10, + "digest": "5c5588f1417a793f5b5d80ac09d6aa7fbff94dacb743b1da5395f72a91607ebf" }, "ruby-ambiguous/models/handler.rb": { "captureGroups": 6, @@ -32,8 +32,8 @@ "digest": "49bd37b2138dc17820351c126a7a75622cb564e8dd15a9b5f79fcc2d0844474d" }, "ruby-app/lib/user.rb": { - "captureGroups": 23, - "digest": "38b79d3ef8e804c56e3819804129a6fc9c6bbb8d93d2a96413909f4884e99d94" + "captureGroups": 24, + "digest": "6567c827276575ef3a087757b5725e381fbedb421ffecfe6c5904d7d62f6bcd4" }, "ruby-call-result-binding/app.rb": { "captureGroups": 17, @@ -72,8 +72,8 @@ "digest": "8a75c4468b2b1f033620e7d233071340c129a06db4b8ca85a54293efdc5b989f" }, "ruby-child-extends-parent/lib/child.rb": { - "captureGroups": 5, - "digest": "6b0b88e4101367fb23107124b5ccea5c54632cc39ec272c5816fc6119b680910" + "captureGroups": 6, + "digest": "7742919ebf9abc5c07d42b7889af78f4cb1c5b54a432b1cd42da68b69c02c052" }, "ruby-child-extends-parent/lib/parent.rb": { "captureGroups": 6, @@ -156,12 +156,12 @@ "digest": "83bfdb09dd8a1a0bda06c84092041e257502ca37ae393c57ec2ff43b67d699ae" }, "ruby-grandparent-resolution/lib/models/b.rb": { - "captureGroups": 5, - "digest": "fc9e9541d4c3e85be94cc8be6559d6ee25b9d7178aafa7b50848ab00409446dc" + "captureGroups": 6, + "digest": "e904d2c4238558168579e0c3afc2ca8bf3eed71e94861942858089d30d5dc7bf" }, "ruby-grandparent-resolution/lib/models/c.rb": { - "captureGroups": 5, - "digest": "d507a997c8d022705c15d55c3bbc6a6134353c1114a2de4629f4b4b4f9080ca0" + "captureGroups": 6, + "digest": "fd1bf5ad5b3a1eb4fbad3473e7eac7a156b01e6d77f6662d768bca1a2b97fb18" }, "ruby-grandparent-resolution/lib/models/greeting.rb": { "captureGroups": 6, @@ -188,8 +188,8 @@ "digest": "54fc82a9a0a67ccff1d5ca3055c93044e79e6cd2972d943f9c2e90f1f2198716" }, "ruby-method-enrichment/lib/animal.rb": { - "captureGroups": 27, - "digest": "5feb199009fcc4923665fcd4a8f3173388be58ca6ae53f19de6795cd2521d397" + "captureGroups": 28, + "digest": "f694a63268d14ab07db08332809823351abc680a7dc53f4797651f0e7b167384" }, "ruby-method-enrichment/lib/app.rb": { "captureGroups": 14, @@ -220,8 +220,16 @@ "digest": "4b415b1bcb31b0290a01279f86305459f53e43efb388e96d6e7b4bf804bd12d8" }, "ruby-parent-resolution/lib/models/user.rb": { - "captureGroups": 8, - "digest": "6bb1c81445d4a3f41b6a23e6a20ee4602a261ee5405c3f463dd81c6741bc8e00" + "captureGroups": 9, + "digest": "0e229692965ca5fd6dc5493aa69f056585912f2e4f4acf5af7915a4aee26cb09" + }, + "ruby-qualified-base/lib/derived.rb": { + "captureGroups": 18, + "digest": "8825a54a774c8c77f96315413f632fda626f35d705d8fe697cd362f7acf77a8a" + }, + "ruby-qualified-base/lib/outer.rb": { + "captureGroups": 18, + "digest": "f81f06be06d013a08a5c9b730a79494251f102f48ca4c25bf0bbd4a2cdcd889e" }, "ruby-qualified-types/lib/admin/user.rb": { "captureGroups": 8, @@ -280,8 +288,8 @@ "digest": "9effd68932555f44968ec89ea1fb0bff4719185c777e856ae167a2289fa72239" }, "ruby-super-resolution/lib/models/user.rb": { - "captureGroups": 8, - "digest": "5122d102ff7e2e0b9f1396ee5b6ddab33a866aa3c29af10301dbf673ffaa19af" + "captureGroups": 9, + "digest": "73c8b1725670e841d01fefa807b6148017e181e6bb34d8f5110d970f4292eaff" }, "ruby-write-access/models.rb": { "captureGroups": 13, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 25690226b..187ade0a7 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -1,7 +1,7 @@ { "rust-abstract-dispatch/src/lib.rs": { - "captureGroups": 26, - "digest": "8723f4d9b1c4824be8ec22c28b203113b781fc05cffcd80a6b15efe60a0f927d" + "captureGroups": 27, + "digest": "e7a8f5bca32037a547093095eae32d68560b9ef2bbb29c51bef9f2e7b3e57614" }, "rust-abstract-dispatch/src/main.rs": { "captureGroups": 19, @@ -100,8 +100,8 @@ "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" }, "rust-child-extends-parent/src/child.rs": { - "captureGroups": 12, - "digest": "b357cb50ed1d09f5d218261ed640f73c393f05be36c959e001987d8423ab4194" + "captureGroups": 13, + "digest": "0d60bdf7a88460ee3eebdd48debee9ebb163cfe3f7a22f53323dcef535b6c6b7" }, "rust-child-extends-parent/src/main.rs": { "captureGroups": 17, @@ -123,6 +123,22 @@ "captureGroups": 15, "digest": "043cd8b9341ab299750f8d07f1d1ca34b714c4ecf69acba80ec827e93e3852c0" }, + "rust-cross-module-collision/src/a.rs": { + "captureGroups": 11, + "digest": "27e5b3770416d99fa69fcd492adf565c296780181e11fd5ac6bd26f3c57a9ab0" + }, + "rust-cross-module-collision/src/b.rs": { + "captureGroups": 11, + "digest": "45fbd21cf6ed58ca9501d26be034a4969388a730dfc6d36e3982fbc3aa56dcd2" + }, + "rust-cross-module-collision/src/main.rs": { + "captureGroups": 7, + "digest": "e0120e3f215282e68d83b4f8f5d8918945e0b3e7ce4e0128c6afd2aa43caa1c0" + }, + "rust-cross-module-collision/src/traits.rs": { + "captureGroups": 3, + "digest": "88eef9d92ea6e370bd8ef7fbf64c42ec622ec933fb53c56b32bc67db87fa8e03" + }, "rust-deep-field-chain/models.rs": { "captureGroups": 24, "digest": "fe28a5861492fc4b20d911e44abc246ec33e4f3305fe4bd7e95c952972f17564" @@ -136,12 +152,12 @@ "digest": "6516c6b21d2cca74b18ee443ca0049228b4832efc551ca23e5cf3098c95d34f4" }, "rust-default-constructor/src/repo.rs": { - "captureGroups": 21, - "digest": "968aaf37e492e999c3699fb6eb8f3ee3d021bc112fa205198469e48fd65aeef8" + "captureGroups": 22, + "digest": "8abe9352adc39d7b197e7e042fbb17f5bdad89946bae7b5e8fa11c897102a7f5" }, "rust-default-constructor/src/user.rs": { - "captureGroups": 21, - "digest": "fe375f3a12ea8c05743816c7d218d50fc1898968603a00906194ef1833e247cd" + "captureGroups": 22, + "digest": "c53db401a81fde2ffd5665393acb9cd605a62ec51c015c3aafb3f41c0897471f" }, "rust-err-unwrap/src/error.rs": { "captureGroups": 9, @@ -280,8 +296,8 @@ "digest": "cd836a2a9c15ab240961d2e15f192f7e33d65eb5ebf2e1a8af2f620a47fe66ae" }, "rust-method-enrichment/src/lib.rs": { - "captureGroups": 37, - "digest": "a257cb6d2bf8f4ecc00d3c2880c5103d3d17ce9444f154be2fa283f5ce0a3e47" + "captureGroups": 38, + "digest": "014c09ab82a5a348c2a6225e07da0773981dd6f282492b4517e33071873dcda6" }, "rust-method-enrichment/src/main.rs": { "captureGroups": 18, @@ -320,8 +336,20 @@ "digest": "f35d44f44d81e3a0be40f68ba9dbd4bde6f01659fa15b6db34a458ad460f904e" }, "rust-parent-resolution/src/user.rs": { - "captureGroups": 12, - "digest": "7c87a84a30e4de06e3bdc6f3adc8310aef09300bb035c77c0a3e6cf08c14c6ac" + "captureGroups": 13, + "digest": "00d29171a1c471087eb3f06cca66c275611e626e6e8f82b8f8fa9b2f6dfb6125" + }, + "rust-qualified-trait/src/main.rs": { + "captureGroups": 6, + "digest": "bc8946d31db81b85d780633608fdaa7565258cd788285fa00cd6dcb0de3dd16c" + }, + "rust-qualified-trait/src/traits.rs": { + "captureGroups": 5, + "digest": "15be069f28f1400e4beb0b0860acb59979f78549960486f36a92f56578f05a06" + }, + "rust-qualified-trait/src/widget.rs": { + "captureGroups": 22, + "digest": "3e1d4c6167338e410d9d93bf5f80f289ffb2f05611813e59c7a53402bf6a101d" }, "rust-receiver-resolution/src/main.rs": { "captureGroups": 21, @@ -424,8 +452,8 @@ "digest": "60fc4ac44f58ae67d462e243655b0571392a18de85b9e200e9391b50c941a6c9" }, "rust-traits/src/impls/button.rs": { - "captureGroups": 30, - "digest": "2b4523d8013330f59ce4bd44c7e8c4d8e185e691c5bef08fa8af20a86eed25de" + "captureGroups": 32, + "digest": "ba93629d0e5a008a5ea84b6a93ea93b1ae4901cc24d8c4c7ee685f51e2712f12" }, "rust-traits/src/main.rs": { "captureGroups": 11, diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index c47db1afa..aabf63ddd 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -4,8 +4,8 @@ "digest": "56b82214cca3ed89312d84d6b04a48ee13cbe748ea6978e9bffa20ce46ac0930" }, "swift-abstract-dispatch/Sources/Repository.swift": { - "captureGroups": 23, - "digest": "c97501a445d79f137705c040e57437f6488d4c87ee80f003ec547eb45e8fc35b" + "captureGroups": 24, + "digest": "3a3fa6017b6937de81e5f70bcab727910edb00053c7e5bd30da67ed1fb84c71f" }, "swift-await-try/App.swift": { "captureGroups": 13, @@ -28,8 +28,8 @@ "digest": "19b16f002f2e10b661ada767769723ed92c6101fbf8dd895a9077c563c06946d" }, "swift-child-extends-parent/Sources/Child.swift": { - "captureGroups": 3, - "digest": "4af236b587337ef59887abed10892d2f179b1ad502664086d85139ee840b51fd" + "captureGroups": 4, + "digest": "6a2c35357f3efaef456f9acabaf5a068b6ac9906f83bee3afaaa005271afcbb8" }, "swift-child-extends-parent/Sources/Parent.swift": { "captureGroups": 7, @@ -128,8 +128,8 @@ "digest": "e830e3dca7d6c6260181b78d4af5bc61e031bdce3a451588989f17223a5eac1c" }, "swift-method-enrichment/Sources/Animal.swift": { - "captureGroups": 22, - "digest": "4b7aa00484047437a9e6af3faf12cd6a4e9575bf4dd7d6454e29f05459cf8795" + "captureGroups": 23, + "digest": "999c14cd327ad7e1cc90bae25e9c7e79a8ade5dd274d834d87a61846c4c91259" }, "swift-method-enrichment/Sources/App.swift": { "captureGroups": 10, @@ -184,8 +184,8 @@ "digest": "b5ed80965325806c715d1c3a65163b014e0bbba5ca7e2df9d7c81b084fada15e" }, "swift-overload-dispatch/SqlRepository.swift": { - "captureGroups": 22, - "digest": "0f90013bd74e3c8ee8d040f433144c2e470bd7af391cda22171b36df90d8db86" + "captureGroups": 23, + "digest": "e5abcf7249afe3fb58fad54ec17da29195a95a05e6f1275375b9dc395030d9dc" }, "swift-parent-resolution/Sources/Models/BaseModel.swift": { "captureGroups": 7, @@ -196,8 +196,16 @@ "digest": "684be5a2e9d7c03c4a9ce209fe5719a776ad4fe0ed12a7a79ba64eed6f34a40e" }, "swift-parent-resolution/Sources/Models/User.swift": { - "captureGroups": 8, - "digest": "035816ff924424620539717e70c5a9c3f771ed4b5e7e167d94ccda9bd8abad7b" + "captureGroups": 10, + "digest": "bd01b5adcd523ceee95772cc74ded0dbc1d70449fc9e9d17e975299dcbac783f" + }, + "swift-qualified-base/Sources/Derived.swift": { + "captureGroups": 4, + "digest": "39e6ba35775ce624d1fb982204d2e2f0fcbbaf3046e317242602d9f245eb5a9d" + }, + "swift-qualified-base/Sources/Outer.swift": { + "captureGroups": 9, + "digest": "8674f64c110ed91d7e63add63a90612c56d7b827e1b0ade54841677e82c56d80" }, "swift-return-type-inference/App.swift": { "captureGroups": 21, diff --git a/gitnexus/test/integration/heritage-worker-path.test.ts b/gitnexus/test/integration/heritage-worker-path.test.ts new file mode 100644 index 000000000..22ee32a8e --- /dev/null +++ b/gitnexus/test/integration/heritage-worker-path.test.ts @@ -0,0 +1,297 @@ +/** + * Worker-path inheritance edges for the registry-primary languages (issue #1951). + * + * Diagrams showed classes and interfaces with no EXTENDS / IMPLEMENTS edges + * between them. Root cause: registry-primary languages have their legacy + * `@heritage.*` edges dropped by the worker pipeline's `shouldAccumulate` gate + * (parse-impl.ts) — while the scope-resolution path that DOES run in worker + * mode emitted nothing for them (unlike C++, they synthesized no + * `@reference.inherits` captures). Small fixtures stayed under the worker + * threshold and ran sequentially (legacy heritage intact), so the bug hid. + * + * The migration routed every language's inheritance through scope-resolution. + * These tests force the worker pool on small fixtures (production threshold is + * 15 files / 512 KB) and assert the edges are present. They FAIL before the + * fix (0 EXTENDS / 0 IMPLEMENTS in worker mode) and pass once each language + * emits inheritance through scope-resolution. The `usedWorkerPool === true` + * guard is mandatory: without the compiled worker (built by + * `pretest:integration`) the pipeline silently falls back to sequential, which + * would hide the regression. + * + * The C#/Java blocks below are the original (#1951) coverage; the + * table-driven block at the end extends worker-forced coverage to the other + * migrated languages (go, python, php, rust, kotlin, ruby, typescript, + * javascript, swift) so a worker-only capture regression in ANY of them fails + * here rather than slipping every sequential gate. + * + * Run under the default (registry-primary) flags — the bug only exists on the + * registry-primary path, so we must NOT force REGISTRY_PRIMARY_*=0 here. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import { + runPipelineFromRepo, + getRelationships, + edgeSet, + type PipelineResult, +} from './resolvers/helpers.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +const FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'lang-resolution'); + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +const runWorker = (fixture: string): Promise => + runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, { + skipGraphPhases: true, + // Force the worker-pool gate low so a 4-5 file fixture engages the pool. + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + +// Sequential counterpart (no worker pool): the legacy heritage path runs and +// scope-resolution dedups against it. Used to pin worker/sequential parity. +const runSequential = (fixture: string): Promise => + runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, { + skipGraphPhases: true, + skipWorkers: true, + }); + +describe('C# inheritance edges on the worker path (#1951)', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runWorker('csharp-proj'); + }, 120_000); + + it('genuinely used the worker pool (guards against silent sequential fallback)', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('emits class-extends-class EXTENDS: User → BaseEntity (class-owned, via scope-resolution)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['User → BaseEntity']); + // The edge must originate from scope-resolution (the worker-safe channel), + // and be owned by the Class node — not a method/constructor. + expect(extends_[0]?.sourceLabel).toBe('Class'); + expect(extends_[0]?.rel.reason).toBe('scope-resolution: inherits'); + }); + + it('emits class-implements-interface IMPLEMENTS: User → IRepository (class-owned, via scope-resolution)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['User → IRepository']); + expect(implements_[0]?.sourceLabel).toBe('Class'); + expect(implements_[0]?.rel.reason).toBe('scope-resolution: inherits'); + }); +}); + +describe('C# interface heritage on the worker path (#1951)', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runWorker('csharp-interface-heritage'); + }, 120_000); + + it('genuinely used the worker pool', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('models interface-extends-interface and multi-interface heritage as IMPLEMENTS', () => { + // C# semantics (matching the legacy DAG): conforming to an interface is + // IMPLEMENTS regardless of whether the child is a class or interface. + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual([ + 'IAuditableService → IBarService', + 'IAuditableService → IFooService', + 'IFooService → IBaseInterface', + 'MyService → IAuditableService', + ]); + }); + + it('emits no EXTENDS edges for pure interface heritage', () => { + expect(getRelationships(result, 'EXTENDS').length).toBe(0); + }); +}); + +describe('Java inheritance edges on the worker path (#1951)', () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runWorker('java-heritage'); + }, 120_000); + + it('genuinely used the worker pool', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('emits class-extends-class EXTENDS: User → BaseModel (and none to interfaces)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['User → BaseModel']); + }); + + it('emits multi-interface IMPLEMENTS: User → Serializable, User → Validatable', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['User → Serializable', 'User → Validatable']); + }); +}); + +describe('C# primary-constructor + qualified-generic base on the worker path (#1951 regression)', () => { + // A C# 12 primary constructor is synthesized into the class scope, so the + // shared `resolveCallerGraphId` would degrade the inheritance edge source to + // the constructor (breaking MRO). The edge must stay owned by the Class. + // Also covers fully-qualified generic base-name normalization (App.Repo). + let result: PipelineResult; + beforeAll(async () => { + result = await runWorker('csharp-primary-ctor-heritage'); + }, 120_000); + + it('genuinely used the worker pool', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('emits EXTENDS owned by the Class, not the primary constructor', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + // User(int id) : BaseEntity and Service : App.Repo + expect(edgeSet(extends_)).toEqual(['Service → Repo', 'User → BaseEntity']); + // The regression: every inheritance edge source is the Class node. Before + // the fix, User's source degraded to Constructor:User. + expect(extends_.every((e) => e.sourceLabel === 'Class')).toBe(true); + }); + + it('emits IMPLEMENTS owned by the Class for a primary-constructor class', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['User → IFoo']); + expect(implements_.every((e) => e.sourceLabel === 'Class')).toBe(true); + }); +}); + +describe('Worker/sequential inheritance-edge parity (#1951)', () => { + // The dedup-key change (type-prefixed, graph-seeded) must keep the worker + // path (scope-resolution emits) and the sequential path (legacy heritage + // emits, scope-resolution dedups) producing the SAME single edges — no + // double-emission, no dropped IMPLEMENTS. + let worker: PipelineResult; + let sequential: PipelineResult; + beforeAll(async () => { + worker = await runWorker('csharp-proj'); + sequential = await runSequential('csharp-proj'); + }, 120_000); + + it('worker mode used the pool; sequential did not', () => { + expect(worker.usedWorkerPool).toBe(true); + expect(sequential.usedWorkerPool).toBe(false); + }); + + it('produces identical EXTENDS and IMPLEMENTS edge sets in both modes', () => { + expect(edgeSet(getRelationships(worker, 'EXTENDS'))).toEqual( + edgeSet(getRelationships(sequential, 'EXTENDS')), + ); + expect(edgeSet(getRelationships(worker, 'IMPLEMENTS'))).toEqual( + edgeSet(getRelationships(sequential, 'IMPLEMENTS')), + ); + }); + + it('emits exactly one EXTENDS and one IMPLEMENTS in each mode (no double-emission)', () => { + expect(getRelationships(worker, 'EXTENDS').length).toBe(1); + expect(getRelationships(worker, 'IMPLEMENTS').length).toBe(1); + expect(getRelationships(sequential, 'EXTENDS').length).toBe(1); + expect(getRelationships(sequential, 'IMPLEMENTS').length).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Worker-forced coverage for the remaining migrated languages (#1951 review). +// Each language's inheritance now flows ONLY through its scope-resolution synth +// in worker mode (legacy heritage gated off). Edge sets are sorted (edgeSet +// sorts), so the expectations below are in sorted order. +// --------------------------------------------------------------------------- + +interface WorkerHeritageCase { + readonly lang: string; + readonly fixture: string; + readonly extends: readonly string[]; + readonly implements: readonly string[]; + /** Optional gate for grammars that may not be installed (Swift). */ + readonly available?: boolean; +} + +const WORKER_HERITAGE_CASES: readonly WorkerHeritageCase[] = [ + // Go struct embedding → EXTENDS. + { lang: 'Go', fixture: 'go-child-extends-parent', extends: ['Child → Parent'], implements: [] }, + // Python single inheritance → EXTENDS. + { + lang: 'Python', + fixture: 'python-child-extends-parent', + extends: ['Child → Parent'], + implements: [], + }, + // PHP class extends + trait use → EXTENDS (Base) + IMPLEMENTS (trait Auditable). + { + lang: 'PHP', + fixture: 'php-parent-vs-trait', + extends: ['Child → Base'], + implements: ['Child → Auditable'], + }, + // Rust `impl T for S` → IMPLEMENTS (resolved scope-aware after #1951 review). + { + lang: 'Rust', + fixture: 'rust-traits', + extends: [], + implements: ['Button → Clickable', 'Button → Drawable'], + }, + // Kotlin class + interfaces → EXTENDS (BaseModel) + IMPLEMENTS (2 interfaces). + { + lang: 'Kotlin', + fixture: 'kotlin-heritage', + extends: ['User → BaseModel'], + implements: ['User → Serializable', 'User → Validatable'], + }, + // Ruby `class Child < Parent` → EXTENDS. + { + lang: 'Ruby', + fixture: 'ruby-child-extends-parent', + extends: ['Child → Parent'], + implements: [], + }, + // TypeScript generic base + generic interface → EXTENDS (Box) + IMPLEMENTS (IFoo). + { + lang: 'TypeScript', + fixture: 'typescript-generic-base', + extends: ['Service → Box'], + implements: ['Service → IFoo'], + }, + // JavaScript `class Child extends Parent` → EXTENDS. + { + lang: 'JavaScript', + fixture: 'javascript-child-extends-parent', + extends: ['Child → Parent'], + implements: [], + }, + // Swift class inheritance → EXTENDS (grammar is an optional dependency). + { + lang: 'Swift', + fixture: 'swift-child-extends-parent', + extends: ['Child → Parent'], + implements: [], + available: swiftAvailable, + }, +]; + +for (const c of WORKER_HERITAGE_CASES) { + describe.skipIf(c.available === false)( + `${c.lang} inheritance edges on the worker path (#1951)`, + () => { + let result: PipelineResult; + beforeAll(async () => { + result = await runWorker(c.fixture); + }, 120_000); + + it('genuinely used the worker pool (guards against silent sequential fallback)', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('emits the expected EXTENDS / IMPLEMENTS edge set via scope-resolution', () => { + expect(edgeSet(getRelationships(result, 'EXTENDS'))).toEqual([...c.extends]); + expect(edgeSet(getRelationships(result, 'IMPLEMENTS'))).toEqual([...c.implements]); + }); + }, + ); +} diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index db65f8d66..5ffdf4240 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -116,7 +116,7 @@ describe('C# ambiguous symbol resolution', () => { expect(ifaces.filter((n) => n === 'IProcessor').length).toBe(2); }); - it('heritage targets are synthetic (correct refusal for ambiguous namespace import)', () => { + it('resolves both ambiguous bases to the imported Models namespace via import-aware disambiguation', () => { const extends_ = getRelationships(result, 'EXTENDS'); const implements_ = getRelationships(result, 'IMPLEMENTS'); @@ -125,13 +125,18 @@ describe('C# ambiguous symbol resolution', () => { expect(implements_.length).toBe(1); expect(implements_[0].source).toBe('UserHandler'); - // The key invariant: no edge points to Other/ - if (extends_[0].targetFilePath) { - expect(extends_[0].targetFilePath).not.toContain('Other/'); - } - if (implements_[0].targetFilePath) { - expect(implements_[0].targetFilePath).not.toContain('Other/'); - } + // `using MyApp.Models;` emits the file-level import edge, so import-aware + // resolution (#1951) disambiguates both same-named bases to the Models/ + // definitions (NOT Other/) — pinned exactly (the prior `if (targetFilePath)` + // guard was vacuous). This asserts the correct registry-primary model; the + // legacy DAG does not emit the C# namespace using-import edge and so refuses + // to disambiguate, which is why this test is listed in + // LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES (helpers.ts) — a scope-resolver- + // only correctness win, not branched with conditional logic here. + expect(extends_[0].target).toBe('Handler'); + expect(extends_[0].targetFilePath).toBe('Models/Handler.cs'); + expect(implements_[0].target).toBe('IProcessor'); + expect(implements_[0].targetFilePath).toBe('Models/IProcessor.cs'); }); }); @@ -2267,13 +2272,18 @@ describe('C# record base resolution (record inheritance + base.Save)', () => { expect(all).toContain('UserRecord'); }); - it('does not emit a spurious self-EXTENDS (record heritage not emitted by C# heritage queries)', () => { - // NOTE: C# tree-sitter heritage queries cover class/interface - // declarations but not `record_declaration`, so records don't - // emit an EXTENDS edge today. The record-base linkage is still - // visible via `base.Save()` resolution (next test). This - // assertion pins the negative invariant so a future heritage - // extension for records can flip both tests at once. + it('emits no spurious self-EXTENDS for a record (record→record same-namespace EXTENDS is a known registry gap)', () => { + // Since #1956 the registry-primary synth walks `record_declaration` base_lists + // (matching the legacy @heritage leg), so record→class and record→interface + // bases now resolve to EXTENDS/IMPLEMENTS edges — see the qualified/record/ + // struct block below (record R : Base, record P : Base(id), …). The + // record→RECORD case in the SAME namespace (`record UserRecord : BaseEntity`, + // both in `Models`) is a separate, pre-existing registry resolution gap: the + // synth emits the @reference.inherits capture, but the same-namespace + // record-target binding is not resolved on the registry leg, so no + // UserRecord→BaseEntity EXTENDS edge appears there (the legacy leg does emit + // it). It is NOT asserted here — doing so would diverge between legs — and is + // tracked as a follow-up. The self-edge invariant must hold on both legs. const extends_ = getRelationships(result, 'EXTENDS'); const selfExtend = extends_.find((e) => e.source === 'UserRecord' && e.target === 'UserRecord'); expect(selfExtend).toBeUndefined(); @@ -2288,13 +2298,12 @@ describe('C# record base resolution (record inheritance + base.Save)', () => { c.targetFilePath === 'src/Models/BaseEntity.cs', ); expect(baseSave).toBeDefined(); - // NOTE: no `rel.reason` assertion here. Records don't emit EXTENDS - // edges today (see the negative-invariant test above), so the - // super-branch MRO lookup returns no ancestor and the edge is - // produced by the downstream reference-index fallback instead of - // the canonical super path. The `csharp-super-resolution` and + // NOTE: no `rel.reason` assertion here. The base.Save() linkage is + // exercised independently of which path produces it (super-branch MRO + // now that records emit EXTENDS since #1951, or the downstream + // reference-index fallback). The `csharp-super-resolution` and // `csharp-generic-parent` suites pin the super-branch reason on - // paths that do go through MRO. + // paths that go through MRO. const selfSave = calls.find( (c) => c.source === 'Save' && @@ -2305,6 +2314,55 @@ describe('C# record base resolution (record inheritance + base.Save)', () => { }); }); +// --------------------------------------------------------------------------- +// C# qualified / record / struct / alias-qualified base heritage (#1951) +// +// The registry-primary synth previously walked only class/interface base +// lists, so `record R(...) : Base, IFoo`, `record P(...) : Base(id), IBar` +// (primary_constructor_base_type), `struct S : IFoo, ns.IBar`, and the +// `alias_qualified_name` base `B : DomainAlias::Base` produced NO inheritance +// edges in worker mode — even though the legacy @heritage leg covered them. +// This block runs on BOTH legs (createResolverParityIt) and asserts the now- +// emitted edge sets, exactly mirroring the bare names normalizeSupertypeName +// reduces each shape to (Base / IFoo / IBar). +// --------------------------------------------------------------------------- + +describe('C# qualified/record/struct/alias base heritage (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-qualified-base'), () => {}); + }, 60000); + + it('emits EXTENDS for every class-like base, tail/type/alias-resolved', () => { + // R, P (record bases incl. primary_constructor_base_type `Base(id)`), and + // A (qualified_name) / B (alias_qualified_name) all derive from the Class + // `Base`, so each takes the EXTENDS branch (target kind = Class). + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['A → Base', 'B → Base', 'P → Base', 'R → Base']); + }); + + it('emits IMPLEMENTS for interface bases on records and structs', () => { + // R → IFoo (record identifier base), P → IBar (record identifier base + // alongside the primary_constructor_base_type), S → IFoo + S → IBar + // (struct base_list: identifier + qualified_name). Interface targets take + // the IMPLEMENTS branch. + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['P → IBar', 'R → IFoo', 'S → IBar', 'S → IFoo']); + }); + + 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); + } + }); +}); + // --------------------------------------------------------------------------- // Finding 4: struct overload dispatch exercises the extracted // narrowOverloadCandidates utility via implicit-this free calls. diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 2a9b637ca..8119800f3 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -92,6 +92,39 @@ describe('Go package import & call resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Qualified / generic / pointer / interface embeds (#1951) +// +// The registry-primary inheritance synth (languages/go/captures.ts) used to +// emit edges ONLY for a bare `type_identifier` struct embed, silently DROPPING +// the qualified (`pkg.Base`), pointer (`*pkg.Base`), qualified-generic +// (`pkg.Box[T]`) struct embeds and ALL interface embeds — even though the +// legacy `@heritage` leg (config-driven since #1940) captured them. This +// fixture widens the synth to parity: every base reduces to its bare simple +// name, struct bases resolve to EXTENDS and interface bases to IMPLEMENTS. The +// bare-name struct embed (T → Local) is the byte-identical simple-base path +// (unchanged), kept here as a regression guard. Runs under BOTH legs +// (createResolverParityIt), so a regression on either leg fails. +// --------------------------------------------------------------------------- + +describe('Go qualified-base embed resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'go-qualified-base'), () => {}); + }, 60000); + + it('emits EXTENDS for qualified / pointer / generic / bare struct embeds (tail-resolved)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['G → Box', 'P → Base', 'S → Base', 'T → Local']); + }); + + it('emits IMPLEMENTS for qualified and bare interface embeds (tail-resolved)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['R → Reader', 'RLocal → LocalIface']); + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler struct in two packages, package import disambiguates // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index f35fe1239..3e9e5d25d 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -26,6 +26,16 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { }); }); +// --------------------------------------------------------------------------- +// Generic-base heritage (#1951): extends Box + implements IFoo. The +// legacy @heritage query was type_identifier-only and matched 0 generic bases, +// while the registry-primary synth emitted 1 — a latent =0/=1 parity break. +// Widening the legacy query closes it. This block runs under BOTH legs via +// createResolverParityIt, so it fails on the legacy leg if widening regresses. +// --------------------------------------------------------------------------- + +describe('Java generic-base heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-generic-base'), () => {}); + }, 60000); + + it('emits EXTENDS Service → Box for a generic superclass (extends Box)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['Service → Box']); + }); + + it('emits IMPLEMENTS Service → IFoo for a generic interface (implements IFoo)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['Service → IFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Qualified (namespaced) bases (#1956 tri-review U2). Three shapes: +// - Service: 3-segment generic (extends app.base.Box, implements app.base.IFoo) +// - Plain: 2-segment plain (extends base.Base, implements base.IBar) +// - Two: 2-segment generic (extends base.Box, implements base.IFoo) +// The registry-primary synth resolves each by its scoped-name tail; the legacy +// @heritage query was widened with end-anchored scoped_type_identifier arms to +// match. The 2-segment cases are the regression guard: an un-anchored arm +// double-matches a 2-segment base (both segments are direct type_identifier +// children) and emits a spurious prefix edge, breaking the =1/=1 parity this +// runs under BOTH legs (createResolverParityIt) to assert. +// --------------------------------------------------------------------------- + +describe('Java qualified-base heritage resolution (#1956 U2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-base'), () => {}); + }, 60000); + + it('emits exactly one EXTENDS per class, tail-resolved (no spurious prefix edge)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['Plain → Base', 'Service → Box', 'Two → Box']); + }); + + it('emits exactly one IMPLEMENTS per class, tail-resolved (no spurious prefix edge)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['Plain → IBar', 'Service → IFoo', 'Two → IFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Interface-to-interface EXTENDS (#1951): `interface IA extends IB, IC`. +// The registry-primary synth walked class_declaration ONLY, so it NEVER emitted +// interface-to-interface heritage — production silently dropped these edges +// while the legacy @heritage `interface_declaration (extends_interfaces …)` arm +// emitted them: a latent =N/=0 parity break. Widening the synth's traversal to +// also walk interface_declaration > extends_interfaces > type_list closes it. +// Both bases resolve to Interface symbols, so preEmitInheritanceEdges emits them +// as IMPLEMENTS (matching the legacy arm's @heritage.impl / kind:'implements'). +// IC exercises the generic-base reduction (IC -> IC). Runs under +// BOTH legs via createResolverParityIt, so it fails on the legacy leg if the +// synth and legacy query disagree. +// --------------------------------------------------------------------------- + +describe('Java interface-extends-interface heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-iface-extends'), () => {}); + }, 60000); + + it('detects 3 interfaces and no classes', () => { + expect(getNodesByLabel(result, 'Interface')).toEqual(['IA', 'IB', 'IC']); + expect(getNodesByLabel(result, 'Class')).toEqual([]); + }); + + it('emits IMPLEMENTS IA → IB and IA → IC for interface-to-interface extends', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['IA → IB', 'IA → IC']); + }); + + it('emits no EXTENDS edges (interface bases resolve to Interface → IMPLEMENTS)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(extends_).toEqual([]); + }); + + it('all interface-heritage edges point to real Interface graph nodes', () => { + for (const edge of getRelationships(result, 'IMPLEMENTS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler + Processor in two packages, imports disambiguate // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index 25316ac6d..ab6b1376a 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -22,6 +22,31 @@ import { // requires this for the issue #1358 singleton describes below. const it = createResolverParityIt('javascript'); +// --------------------------------------------------------------------------- +// Qualified (namespaced) base (#1951): `extends ns.Base` parses as a +// class_heritage holding a member_expression (object: `ns`, property: `Base`). +// The registry-primary synth (synthesizeJsInheritanceReferences) previously +// dropped member_expression bases, emitting only for a direct identifier base, +// so production silently omitted this EXTENDS edge. It is now resolved by the +// base's trailing property_identifier (`Base`), matching the legacy @heritage +// leg's normalizeSupertypeName reduction. `Plain extends Base` is the bare +// control (its simple-base handling is unchanged). Runs under BOTH legs via +// createResolverParityIt. +// --------------------------------------------------------------------------- + +describe('JavaScript qualified-base heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'javascript-qualified-base'), () => {}); + }, 60000); + + it('emits EXTENDS for the qualified base (ns.Base) and the bare control (Base)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['Plain → Base', 'Service → Base']); + }); +}); + // --------------------------------------------------------------------------- // skipGraphPhases: verify pipeline works correctly when graph phases are skipped // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index f2d241950..69ef91c7d 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -113,6 +113,47 @@ describe('Kotlin heritage resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Interface-delegation heritage (#1951): `class F : Iface by d`. The base is an +// `explicit_delegation` (`(user_type) by `); the registry-primary +// synth previously DROPPED this shape (only `user_type` / `constructor_invocation` +// were handled), so production emitted NO IMPLEMENTS edge for the delegated +// interface in worker mode — while the legacy @heritage leg (config-driven +// `kotlinHeritageShapes` + normalizeSupertypeName) captured it. Widening the +// synth to descend into `explicit_delegation`'s leading `user_type` closes the +// parity break. G : Base() is the bare control proving the simple-base path is +// unchanged. This block runs under BOTH legs via createResolverParityIt. +// --------------------------------------------------------------------------- + +describe('Kotlin interface-delegation heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-qualified-base'), () => {}); + }, 60000); + + it('emits IMPLEMENTS F → Iface for an interface-delegation base (: Iface by d)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['F → Iface']); + }); + + it('emits EXTENDS G → Base for the bare constructor-call control (: Base())', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['G → Base']); + }); + + 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); + } + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler + Runnable in two packages, explicit imports disambiguate // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/python.test.ts b/gitnexus/test/integration/resolvers/python.test.ts index 450ba46fc..1e6e741e8 100644 --- a/gitnexus/test/integration/resolvers/python.test.ts +++ b/gitnexus/test/integration/resolvers/python.test.ts @@ -89,6 +89,51 @@ describe('Python relative import & heritage resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Qualified / generic bases (#1951). The registry-primary synth previously +// DROPPED these shapes — only bare `identifier` bases emitted, so production +// silently omitted their inheritance edges while the legacy @heritage leg +// captured them. service.py exercises the three now-handled shapes plus a bare +// control, each base defined in a sibling module: +// - Service: `base_mod.Model` (attribute base, trailing id -> Model) +// - Nested: `a.b.Base` (nested attribute base, recurse -> Base) +// - Gen: `Container[str]` (subscript base, value: field -> Container) +// - Plain: `Container` (bare control, byte-identical capture) +// Runs under BOTH legs (createResolverParityIt) so the synth's bare-name text +// is asserted equal to the legacy normalizeSupertypeName reduction. +// --------------------------------------------------------------------------- + +describe('Python qualified-base heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'python-qualified-base'), () => {}); + }, 60000); + + it('emits EXTENDS edges for attribute / nested-attribute / subscript / bare bases', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual([ + 'Gen → Container', + 'Nested → Base', + 'Plain → Container', + 'Service → Model', + ]); + }); + + it('emits no IMPLEMENTS edges (Python has no interfaces)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(0); + }); + + it('all heritage edges point to real graph nodes', () => { + for (const edge of getRelationships(result, 'EXTENDS')) { + const target = result.graph.getNode(edge.rel.targetId); + expect(target).toBeDefined(); + expect(target!.properties.name).toBe(edge.target); + } + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler in two packages, relative import disambiguates // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index c60fe25b1..6f7c4fac0 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -279,6 +279,37 @@ describe('Ruby qualified class names', () => { }); }); +// --------------------------------------------------------------------------- +// Qualified-base heritage: `class C < Outer::Super` (scope_resolution super- +// class) must emit EXTENDS at parity with the legacy @heritage leg (#1951). +// The bare control `class D < Base` keeps the original path byte-identical, and +// `include Mixin` flows through the unchanged mixin → IMPLEMENTS lane. +// --------------------------------------------------------------------------- + +describe('Ruby qualified-base heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-base'), () => {}); + }, 60000); + + pit('emits EXTENDS for scoped (C < Outer::Super) and bare (D < Base) bases', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edges = edgeSet(extends_); + // Scoped superclass resolves by its trailing bare name (Outer::Super → Super). + expect(edges).toContain('C → Super'); + // Bare control resolves unchanged. + expect(edges).toContain('D → Base'); + }); + + pit('emits IMPLEMENTS for the include Mixin (unchanged mixin lane): C → Mixin', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + const edge = implements_.find((e) => e.source === 'C' && e.target === 'Mixin'); + expect(edge).toBeDefined(); + expect(edge!.rel.reason).toBe('include'); + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler in two dirs, require_relative disambiguates // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 5e03da39b..b2f7de90c 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -75,6 +75,98 @@ describe('Rust trait implementation resolution', () => { }); }); +// --------------------------------------------------------------------------- +// Cross-module collision (#1951 review): two `struct User` in separate modules, +// each `impl Drawable`. The legacy global last-write-wins simple-name index +// collapsed both impl sites onto ONE `User`, sourcing one (or both) edges from +// the wrong module's struct. Scope-aware resolution sources each edge from the +// `User` defined in that impl's own module, so BOTH edges are present and +// correctly sourced. +// --------------------------------------------------------------------------- + +describe('Rust cross-module trait-impl collision resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-cross-module-collision'), + () => {}, + ); + }, 60000); + + it('detects 2 User structs in separate modules and 1 Drawable trait', () => { + const structs: string[] = []; + result.graph.forEachNode((n) => { + if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`); + }); + const users = structs.filter((s) => s.startsWith('User@')).sort(); + expect(users).toEqual(['User@src/a.rs', 'User@src/b.rs']); + expect(getNodesByLabel(result, 'Trait')).toEqual(['Drawable']); + }); + + it('emits one IMPLEMENTS edge per module, each sourced from its OWN User', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(implements_.length).toBe(2); + expect(edgeSet(implements_)).toEqual(['User → Drawable', 'User → Drawable']); + // The fix: each edge sources from the User in its own module — not a single + // last-write-wins struct. Before the fix, both edges collapsed onto one file. + const sourceFiles = implements_.map((e) => e.sourceFilePath).sort(); + expect(sourceFiles).toEqual(['src/a.rs', 'src/b.rs']); + for (const edge of implements_) { + expect(edge.rel.reason).toBe('trait-impl'); + expect(edge.targetFilePath).toBe('src/traits.rs'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Qualified/scoped trait paths (#1956 tri-review U1): `impl crate::traits::Foo +// for S` and `impl crate::traits::Wrapped for S`. The base is a +// `scoped_type_identifier` (or a generic_type wrapping one). Both the synth +// (registry leg, rust/captures.ts `bareTypeIdentifier`) and the legacy +// `@heritage` query now resolve it by its trailing bare name (KTD-1). The traits +// are unique, so both legs resolve identically — parity-tested. (Ambiguous +// scoped bases reuse the same refuse-on-ambiguity path as bare names, already +// covered by rust-cross-module-collision / rust-ambiguous; that path diverges +// across legs by design and is intentionally not added to this parity fixture.) +// --------------------------------------------------------------------------- + +describe('Rust qualified/scoped trait-impl resolution (#1956 U1)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-qualified-trait'), () => {}); + }, 60000); + + it('detects the structs and traits', () => { + expect(getNodesByLabel(result, 'Struct')).toEqual(['Gadget', 'Widget']); + expect(getNodesByLabel(result, 'Trait')).toEqual(['Drawable', 'Wrapped']); + }); + + it('emits IMPLEMENTS edges for qualified and qualified-generic trait paths', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + // `impl crate::traits::Drawable for Widget` (scoped) and + // `impl crate::traits::Wrapped for Gadget` (generic-of-scoped) both + // resolve by their trailing bare name. + expect(edgeSet(implements_)).toEqual(['Gadget → Wrapped', 'Widget → Drawable']); + for (const edge of implements_) { + expect(edge.rel.reason).toBe('trait-impl'); + } + }); + + it('sources each edge from its struct file and targets the trait module', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + for (const edge of implements_) { + expect(edge.sourceFilePath).toBe('src/widget.rs'); + expect(edge.targetFilePath).toBe('src/traits.rs'); + } + }); + + it('does not emit EXTENDS edges (Rust trait impls are IMPLEMENTS)', () => { + expect(getRelationships(result, 'EXTENDS').length).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // Ambiguous: Handler struct in two modules, crate:: import disambiguates // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 88dceda83..7c6012e93 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -31,6 +31,60 @@ function writeFixtureRepo(root: string, files: Record): void { } } +// --------------------------------------------------------------------------- +// Generic-base heritage (#1951): extends Box already worked (value: identifier +// captures Base; type_args are a sibling), but `implements IFoo` matched 0 in +// the legacy @heritage query while the registry synth emitted 1 — a latent =0/=1 +// parity break. Widening the legacy implements clause closes it. Runs under BOTH +// legs via createResolverParityIt, so it fails on the legacy leg if it regresses. +// --------------------------------------------------------------------------- + +describe('TypeScript generic-base heritage resolution (#1951)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-generic-base'), () => {}); + }, 60000); + + it('emits EXTENDS Service → Box for a generic superclass (extends Box)', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['Service → Box']); + }); + + it('emits IMPLEMENTS Service → IFoo for a generic interface (implements IFoo)', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['Service → IFoo']); + }); +}); + +// --------------------------------------------------------------------------- +// Qualified (namespaced) bases (#1956 tri-review U2): `extends ns.Box` +// + `implements ns.IFoo` (qualified-generic, on Service) and `extends +// ns.Base` + `implements ns.IBar` (qualified non-generic, on Plain). extends +// uses a member_expression value; implements uses a nested_type_identifier +// (plain) or a generic_type wrapping one. The registry-primary synth resolves +// these by their tail; the legacy @heritage query was widened to match. Runs +// under BOTH legs via createResolverParityIt. +// --------------------------------------------------------------------------- + +describe('TypeScript qualified-base heritage resolution (#1956 U2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-qualified-base'), () => {}); + }, 60000); + + it('emits EXTENDS for qualified and qualified-generic superclasses', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toEqual(['Plain → Base', 'Service → Box']); + }); + + it('emits IMPLEMENTS for qualified and qualified-generic interfaces', () => { + const implements_ = getRelationships(result, 'IMPLEMENTS'); + expect(edgeSet(implements_)).toEqual(['Plain → IBar', 'Service → IFoo']); + }); +}); + // --------------------------------------------------------------------------- // Heritage: class extends + implements interface // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts b/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts index c8a819cd5..cab9a4ef7 100644 --- a/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/javascript/javascript-captures.test.ts @@ -146,3 +146,43 @@ describe('emitJsScopeCaptures — #1876 array-method-callback narrowing', () => ).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// JSX-element-as-call-argument arity (#1956 tri-review U3): a JSX component used +// as a call argument, e.g. `render()`, must NOT inherit the +// enclosing call's arity. The JSX element is itself a `@reference.call.*` anchor; +// the call-arity walk-up would ascend from it into the enclosing call_expression +// and mis-attribute that call's arity. An early guard skips arity synthesis when +// the call anchor is a JSX element (restoring the pre-#1951 range-based behavior). +// --------------------------------------------------------------------------- + +/** Arity text for the call-site match whose callee `@reference.name` is `name`; + * `'NONE'` when no such call-site match exists, `undefined` when it exists with + * no `@reference.arity`. */ +function callArity(src: string, name: string, file = 'test.jsx'): string | undefined | 'NONE' { + const matches = emitJsScopeCaptures(src, file).filter( + (m) => + Object.keys(m).some((k) => k.startsWith('@reference.call')) && + m['@reference.name']?.text === name, + ); + if (matches.length === 0) return 'NONE'; + return matches[0]['@reference.arity']?.text; +} + +describe('emitJsScopeCaptures — JSX-as-call-arg arity (#1956 U3)', () => { + it('does not attribute the enclosing call arity to a JSX component reference', () => { + const src = 'render();'; + // The Foo JSX component ref must carry NO arity (was wrongly 1 before the fix). + expect(callArity(src, 'Foo')).toBeUndefined(); + // The enclosing render() call keeps its real arity (1 argument: the element). + expect(callArity(src, 'render')).toBe('1'); + }); + + it('keeps arity on a plain (non-JSX) call (regression guard)', () => { + expect(callArity('foo(1, 2);', 'foo', 'test.js')).toBe('2'); + }); + + it('emits no arity for a standalone JSX element not used as a call argument', () => { + expect(callArity('const x = ;', 'Foo')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/resolve-ambiguous-inheritance-base.test.ts b/gitnexus/test/unit/scope-resolution/resolve-ambiguous-inheritance-base.test.ts new file mode 100644 index 000000000..3bdb5965b --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolve-ambiguous-inheritance-base.test.ts @@ -0,0 +1,142 @@ +/** + * Unit coverage for `resolveAmbiguousInheritanceBaseViaImports` (#1956 tri-review + * U8). The import-aware disambiguation fallback for an ambiguous (≥2 same-named + * class-like) inheritance base. It only commits when EXACTLY ONE candidate + * survives a tier, otherwise preserves the historical "return undefined" refusal: + * + * - guard: fewer than 2 class-like candidates → undefined (not this fallback's job) + * - guard: no import edges on the module scope → undefined (refuse) + * - Tier 1: exactly one candidate file is imported exactly → resolve + * - Tier 1: more than one imported exactly → undefined (refuse) + * - Tier 2: exactly one candidate shares a dir with an import target → resolve + * - Tier 2: more than one shares a dir → undefined (refuse) + * + * Drives the function directly through a minimal cast `ScopeResolutionIndexes` + * (only the accessors it reads — qualifiedNames/defs/scopeTree/imports), so the + * branch behavior is pinned independent of the full finalize pipeline. + */ +import { describe, it, expect } from 'vitest'; +import { resolveAmbiguousInheritanceBaseViaImports } from '../../../src/core/ingestion/scope-resolution/scope/walkers.js'; +import type { ImportEdge, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const MODULE = 'scope:module' as ScopeId; +const BASE = 'Handler'; + +interface Candidate { + nodeId: string; + filePath: string; + type?: string; // class-like; defaults to 'Class' +} + +/** Build a minimal indexes object whose module scope imports `importTargetFiles` + * and whose `qualifiedNames` maps BASE → the given class-like candidates. */ +function buildIndexes( + candidates: Candidate[], + importTargetFiles: string[], +): ScopeResolutionIndexes { + const defsMap = new Map(); + const ids: string[] = []; + for (const c of candidates) { + defsMap.set(c.nodeId, { + nodeId: c.nodeId, + filePath: c.filePath, + type: c.type ?? 'Class', + } as SymbolDefinition); + ids.push(c.nodeId); + } + const moduleScope = { + id: MODULE, + kind: 'Module', + parent: null, + filePath: 'ref.ts', + } as unknown as Scope; + const importEdges = importTargetFiles.map((f) => ({ targetFile: f }) as unknown as ImportEdge); + return { + qualifiedNames: { get: (n: string) => (n === BASE ? ids : []) }, + defs: { get: (id: string) => defsMap.get(id) }, + scopeTree: { getScope: (id: ScopeId) => (id === MODULE ? moduleScope : undefined) }, + imports: new Map([[MODULE, importEdges]]), + } as unknown as ScopeResolutionIndexes; +} + +function resolve(candidates: Candidate[], importTargetFiles: string[]): string | undefined { + const def = resolveAmbiguousInheritanceBaseViaImports( + MODULE, + BASE, + buildIndexes(candidates, importTargetFiles), + ); + return def?.nodeId; +} + +describe('resolveAmbiguousInheritanceBaseViaImports (#1956 U8)', () => { + it('refuses (undefined) when there is only a single candidate (not ambiguous)', () => { + expect( + resolve([{ nodeId: 'd:models', filePath: 'Models/Handler.ts' }], ['Models/Handler.ts']), + ).toBeUndefined(); + }); + + it('refuses (undefined) when the module scope has no import edges', () => { + expect( + resolve( + [ + { nodeId: 'd:models', filePath: 'Models/Handler.ts' }, + { nodeId: 'd:other', filePath: 'Other/Handler.ts' }, + ], + [], + ), + ).toBeUndefined(); + }); + + it('Tier 1: resolves to the single candidate whose file is imported exactly', () => { + expect( + resolve( + [ + { nodeId: 'd:models', filePath: 'Models/Handler.ts' }, + { nodeId: 'd:other', filePath: 'Other/Handler.ts' }, + ], + ['Models/Handler.ts'], + ), + ).toBe('d:models'); + }); + + it('Tier 1: refuses when more than one candidate file is imported exactly', () => { + expect( + resolve( + [ + { nodeId: 'd:models', filePath: 'Models/Handler.ts' }, + { nodeId: 'd:other', filePath: 'Other/Handler.ts' }, + ], + ['Models/Handler.ts', 'Other/Handler.ts'], + ), + ).toBeUndefined(); + }); + + it('Tier 2: resolves to the single candidate sharing a directory with an import target', () => { + // No exact file match (import target is a different file in Models/), so it + // falls to the same-directory tier — only Models/Handler.ts shares a dir. + expect( + resolve( + [ + { nodeId: 'd:models', filePath: 'Models/Handler.ts' }, + { nodeId: 'd:other', filePath: 'Other/Handler.ts' }, + ], + ['Models/IProcessor.ts'], + ), + ).toBe('d:models'); + }); + + it('Tier 2: refuses when more than one candidate shares a directory with an import target', () => { + // Two same-named candidates in the same directory; the import target is a + // third file in that directory (no exact match) — still ambiguous, refuse. + expect( + resolve( + [ + { nodeId: 'd:a', filePath: 'Models/HandlerA.ts' }, + { nodeId: 'd:b', filePath: 'Models/HandlerB.ts' }, + ], + ['Models/Registry.ts'], + ), + ).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/swift-qualified-base-captures.test.ts b/gitnexus/test/unit/scope-resolution/swift/swift-qualified-base-captures.test.ts new file mode 100644 index 000000000..8dc495d97 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/swift-qualified-base-captures.test.ts @@ -0,0 +1,41 @@ +/** + * Focused capture-synthesis test for the Swift qualified-base fix (#1951 review). + * + * `class Derived: Outer.Inner` inherits from the NESTED base `Inner`, not the + * qualifier `Outer`. `swiftBaseTypeIdentifier` previously returned the FIRST + * `type_identifier` of the flat `user_type` (`Outer`); it now returns the LAST + * (`Inner`). This asserts the synthesized `@reference.inherits` site carries the + * trailing segment, directly at the changed path — independent of downstream + * resolution (a bare nested-type name does not resolve to an edge in the current + * model, so the integration resolver test cannot observe it). + */ +import { describe, it, expect } from 'vitest'; +import { emitSwiftScopeCaptures } from '../../../../src/core/ingestion/languages/swift/index.js'; +import { isLanguageAvailable } from '../../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../../src/config/supported-languages.js'; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +function inheritedBaseNames(src: string): string[] { + return emitSwiftScopeCaptures(src, 'Probe.swift') + .filter((m) => m['@reference.inherits'] !== undefined) + .map((m) => m['@reference.name']?.text ?? ''); +} + +describe.skipIf(!swiftAvailable)('Swift qualified-base capture synthesis (#1951)', () => { + it('extracts the trailing segment Inner from a qualified base Outer.Inner', () => { + expect(inheritedBaseNames('class Derived: Outer.Inner {}\n')).toEqual(['Inner']); + }); + + it('extracts the trailing segment from a qualified generic base Outer.Inner', () => { + expect(inheritedBaseNames('class Derived: Outer.Inner {}\n')).toEqual(['Inner']); + }); + + it('leaves a non-qualified base unchanged (no regression)', () => { + expect(inheritedBaseNames('class Child: Parent {}\n')).toEqual(['Parent']); + }); + + it('leaves a non-qualified generic base unchanged (Box -> Box)', () => { + expect(inheritedBaseNames('class Boxed: Box {}\n')).toEqual(['Box']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts index d7821d78f..1488be05f 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-captures-anchor.test.ts @@ -89,6 +89,28 @@ describe('captures.ts ancestor-walk rewrite (U8 / B5)', () => { expect('@declaration.parameter-count' in jsxCalls[0]).toBe(false); }); + it('JSX as a call argument does not inherit the enclosing call arity (#1956 U3)', () => { + // `render()`: the JSX element is itself a + // @reference.call.free anchor nested INSIDE the render() call_expression. + // The arity walk-up (findSelfOrAncestorOfTypes) would climb from the JSX + // element into render() and stamp arity 1 onto the Foo component ref. The + // early JSX-anchor guard prevents that; the enclosing render() call still + // gets its real arity (1 argument: the element). + const matches = emitTsScopeCaptures( + 'function App() { return render(); }', + 'test.tsx', + ); + const fooJsx = matches.find( + (m) => '@reference.call.free' in m && m['@reference.name']?.text === 'Foo', + ); + const renderCall = matches.find( + (m) => '@reference.call.free' in m && m['@reference.name']?.text === 'render', + ); + expect(fooJsx).toBeDefined(); + expect('@reference.arity' in fooJsx!).toBe(false); + expect(renderCall?.['@reference.arity']?.text).toBe('1'); + }); + it('constructor call `new Foo(1, 2)` emits exactly one @reference.call.constructor capture', () => { // new_expression anchor → self in ancestor walk. const count = countMatches( diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index 091ae4d0a..f05702b07 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -89,7 +89,13 @@ describe('sequential native parser availability', () => { } }); - it('skips Swift files in processCalls when the native parser is unavailable', async () => { + it('skips Swift files in processCalls (registry-primary: scope-resolution owns call resolution)', async () => { + // Swift is registry-primary, so processCalls skips it via the + // isRegistryPrimary gate (call-processor.ts) BEFORE the parser-availability + // check — the registry-primary scope-resolution path owns its call edges + // (#1951). The unavailable-parser mock is therefore moot: the file is skipped + // (no loadLanguage) regardless. The legacy availability-skip path itself is + // exercised by the Dart verbose test below (Dart is not registry-primary). vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); await expect( @@ -107,19 +113,18 @@ describe('sequential native parser availability', () => { it('warns when processCalls skips files in verbose mode', async () => { cap = _captureLogger(); const previous = process.env.GITNEXUS_VERBOSE; - // Swift is now registry-primary (MIGRATED_LANGUAGES), and - // call-processor gates registry-primary languages before the skip - // counter — so force the legacy path off here to exercise the - // skip/warn branch. (We do NOT edit the processor.) - const previousFlag = process.env.REGISTRY_PRIMARY_SWIFT; process.env.GITNEXUS_VERBOSE = '1'; - process.env.REGISTRY_PRIMARY_SWIFT = '0'; try { vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); + // Use Dart, a non-registry-primary language. call-processor gates + // registry-primary languages (Swift, etc.) via the isRegistryPrimary + // gate before the parser-availability skip counter, so a Dart file + // exercises the skip/warn branch without forcing any language out of + // registry-primary mode (Swift must stay scope-based). await processCalls( createKnowledgeGraph(), - [{ path: 'App.swift', content: 'func demo() {}' }], + [{ path: 'App.dart', content: 'void demo() {}' }], createASTCache(), createResolutionContext(), ); @@ -130,7 +135,7 @@ describe('sequential native parser availability', () => { .some( (r) => r.msg === - '[ingestion] Skipped 1 swift file(s) in call processing — swift parser not available.', + '[ingestion] Skipped 1 dart file(s) in call processing — dart parser not available.', ), ).toBe(true); } finally { @@ -139,15 +144,16 @@ describe('sequential native parser availability', () => { } else { process.env.GITNEXUS_VERBOSE = previous; } - if (previousFlag === undefined) { - delete process.env.REGISTRY_PRIMARY_SWIFT; - } else { - process.env.REGISTRY_PRIMARY_SWIFT = previousFlag; - } } }); - it('skips Swift files in processHeritage when the native parser is unavailable', async () => { + it('skips Swift files in processHeritage (registry-primary: scope-resolution owns heritage)', async () => { + // Swift is registry-primary, so processHeritage skips it via the + // isRegistryPrimary gate (heritage-processor.ts) BEFORE the parser-availability + // check — scope-resolution (#1951) owns its EXTENDS/IMPLEMENTS edges. The + // unavailable-parser mock is therefore moot: the file is skipped (no + // loadLanguage) regardless. The legacy availability-skip path itself is + // exercised by the Dart verbose test below (Dart is not registry-primary). vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); await expect( @@ -169,9 +175,15 @@ describe('sequential native parser availability', () => { try { vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); + // Use Dart, a non-registry-primary language. processHeritage skips + // registry-primary languages (Swift, etc.) via the isRegistryPrimary gate + // — scope-based resolution owns their inheritance (#1951) — BEFORE the + // legacy parser-availability skip this test exercises. Dart still flows + // through the legacy heritage path, so the skip/warn branch fires without + // forcing any language out of registry-primary mode. await processHeritage( createKnowledgeGraph(), - [{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }], + [{ path: 'App.dart', content: 'class Widget extends StatelessWidget {}' }], createASTCache(), createResolutionContext(), ); @@ -182,7 +194,7 @@ describe('sequential native parser availability', () => { .some( (r) => r.msg === - '[ingestion] Skipped 1 swift file(s) in heritage processing — swift parser not available.', + '[ingestion] Skipped 1 dart file(s) in heritage processing — dart parser not available.', ), ).toBe(true); } finally {