diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc8b321046..e50b7c8e07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -391,6 +391,7 @@ Single interface a language implements to plug into the pipeline. Contract fully | `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off | | `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | | `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | +| `resolveImplicitInheritanceBase?` | Fallback for a bare inheritance base the shared chain left unbound, bound by the implicit namespace rule (siblings exist only after the pre-pass); Java, Kotlin, C# opt in | | `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | | `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | | `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | @@ -504,7 +505,7 @@ Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`. ### Inheritance and MRO -Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy: +Inheritance is captured by the `@reference.inherits` tag and emitted by the scope-resolution phase: `preEmitInheritanceEdges` resolves each base in scope, then `emitHeritageEdges` writes the `EXTENDS`/`IMPLEMENTS` edges. A bare base the shared chain cannot bind falls back to `ScopeResolver.resolveImplicitInheritanceBase`, because same-namespace sibling bindings (`populateNamespaceSiblings`) are populated only after this pre-pass; without it a Java `class C extends B` with no import loses its edge whenever another package also declares a `B`. The phase then computes method resolution order via each `ScopeResolver`'s `buildMro` hook, feeding a `MethodDispatchIndex` used for owner-scoped lookups. Per-language strategy: - **`first-wins`** — Java, C#, C++, TS, Ruby, Go - **`c3`** — Python (C3 linearization) diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 29348da839..ae6e3a574a 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,19 @@ # Receiver-resolution baseline +## Same-namespace inheritance base fallback + +`c.greet().save()` in `java-grandparent-resolution/src/services/App.java:8` no +longer drops. Its `C extends B extends A` chain has no imports, so it binds only +by the same-package rule, and that sibling visibility is populated after the +inheritance pre-pass. The corpus's default-package `A`/`B` +(`java-unrelated-method-collision`) made the unique-name fallback decline, so +`C` had no EXTENDS edge and an empty MRO. The pre-pass now asks +`ScopeResolver.resolveImplicitInheritanceBase` (Java, Kotlin, C#) for a bare, +unclaimed base the shared chain left unbound. Call drops move from 128 to 127 +and all-kind drops from 192 to 191 (`.java` 49 to 48, `chain-call` 30 to 29, +`in-program` 55 to 54). The three new collision fixtures add no drop; on main +the C# one would add `leaf.Greet().Save()`. Every shape-arm state is unchanged. + ## Express inline-handler fixture (#3488) The new `express-inline-handlers` fixture reads `req.query.path` inside an diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index f320debea3..f03c1ade65 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -199,15 +199,15 @@ } }, "countArm": { - "callDrops": 128, - "totalDropsAllKinds": 192, + "callDrops": 127, + "totalDropsAllKinds": 191, "bySiteKind": { - "call": 128, + "call": 127, "read": 44, "write": 20 }, "callDropsByExtension": { - ".java": 49, + ".java": 48, ".py": 20, ".ts": 16, ".zig": 11, @@ -222,14 +222,14 @@ }, "callDropsByShape": { "chain-field": 57, - "chain-call": 30, + "chain-call": 29, "no-chain": 27, "<>": 11, "chain-mixed": 2, "chain-unwrap": 1 }, "callDropsByOrigin": { - "in-program": 55, + "in-program": 54, "external": 44, "unknown": 29 } diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index b05df10d2d..2ce0ec5df0 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -67,7 +67,7 @@ }, "csharp": { "_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.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", - "fingerprint": "af724813b5183ad9c98408a9c6944a44897b97d02e7f474ffa4ae637ca9ac1e3", + "fingerprint": "4c7a9195735a0b36de708786361c7675ffe2e4764a47fb414026100df1f00e99", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", @@ -77,10 +77,11 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.", "capture_groups_small": 4259, "capture_groups_large": 13609, - "capture_groups_fp": 2657, - "fixture_count": 178, + "capture_groups_fp": 2804, + "fixture_count": 185, "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec.", - "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 89 constructor-parameter type bindings are added, 46 field declarations become addressable properties, and 3 existing member reads gain receiver-chain tags. Reversing exactly these three changes restores the complete main capture set. Fixture entries 178; changed entries 47; capture groups 2657 -> 2746. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 89 constructor-parameter type bindings are added, 46 field declarations become addressable properties, and 3 existing member reads gain receiver-chain tags. Reversing exactly these three changes restores the complete main capture set. Fixture entries 178; changed entries 47; capture groups 2657 -> 2746. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_same_namespace_heritage_fixture": "Same-namespace heritage fixture: adds `csharp-same-namespace-base-collision` (7 files). FIXTURE ADDITION ONLY, no capture code changed: with that directory parked, --check passes against the prior fingerprint af724813b5183ad9c98408a9c6944a44897b97d02e7f474ffa4ae637ca9ac1e3 (measured fixture_count 178, capture_groups_fp 2746); with it, fixture_count 185, capture_groups_fp 2804. Synthetic counts and scaling unchanged." }, "rust": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", @@ -176,7 +177,7 @@ "_rebaselined_3633_native_extension_types": "#3633: Compared the prior grammar and emitter at 89602560d with native extension-type capture over the same 42 Dart fixture files. Only dart-extension-types/models.dart changes: 11 module/type scope and declaration matches retain the original header text, and Box's declaration gains type-parameter metadata. All method, receiver and heritage captures are unchanged. Total capture groups remain 895 across 43 fixture/synthetic entries; synthetic scale counts stay 5011/16011. Measured scaling 1.127 < 1.5; budget unchanged. Prior 557bdc505af40590b21e53298584f14227b7d79220806425f8e7da25d78f9712 -> 4690403d2be0d270e28a55791df82eaa627d26d28c52d81bfafa59761badcaf3." }, "java": { - "fingerprint": "41c4338f5813a73199dd5c2609313c11076812df21fd9c429039a833325870e0", + "fingerprint": "82a4b09708caed2350ab36d2b7a1ff00639a34bca73dd1536e37f231e800c2d2", "scaling_budget": 1.5, "_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.", "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", @@ -196,10 +197,11 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", "capture_groups_small": 6255, "capture_groups_large": 20005, - "capture_groups_fp": 3586, - "fixture_count": 209, + "capture_groups_fp": 3483, + "fixture_count": 218, "_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209.", - "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 39 constructor-parameter type bindings are added, 6 member reads gain receiver chains, and 196 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture; no qualified call disappears. Fixture entries 209; changed entries 89; capture groups 3586 -> 3429. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 39 constructor-parameter type bindings are added, 6 member reads gain receiver chains, and 196 duplicate qualified free-call matches are removed. Every removed free-call match retains a member-call match with the identical call anchor and name capture; no qualified call disappears. Fixture entries 209; changed entries 89; capture groups 3586 -> 3429. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_same_namespace_heritage_fixture": "Same-namespace heritage fixture: adds `java-same-package-extends-collision` (9 files). FIXTURE ADDITION ONLY, no capture code changed: with that directory parked, --check passes against the prior fingerprint 41c4338f5813a73199dd5c2609313c11076812df21fd9c429039a833325870e0 (measured fixture_count 209, capture_groups_fp 3429); with it, fixture_count 218, capture_groups_fp 3483. Synthetic counts and scaling unchanged." }, "java-local-types": { "fingerprint": "6ae163cd36c889454c8672b28944c765d3a3a5e8c49ea4db1cd6cc1787ede583", @@ -262,7 +264,7 @@ "_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3." }, "kotlin": { - "fingerprint": "026392f9a60d9808dd993fa6d26f3a2ca6240ed6ac63981d614058ae7acd29e2", + "fingerprint": "9b410bd5ad629cb8d6c5fef542ffae67cefa1b66782e24a68a26c49f3032f5ab", "scaling_budget": 1.5, "_rebaselined_3486_bodyless_jvm_accessors": "#3486 validation repair: synthetic JVM accessors have no lexical body; nested getter/setter scopes hid their class bindings. A/B over the same 142 benchmark entries removes exactly 98 standalone @scope.function groups across 27 entries (40 in the 20-entity DAO, 58 in fixtures), with zero additions and every declaration capture, text and range byte-identical. Actual fingerprint groups 2634 -> 2536; single-emission scaling-size counts 5753/18403 -> 5253/16803. Java, java-local-types and Swift fingerprints are unchanged. Existing count metadata is synchronized with the current corpus. Scaling budgets unchanged.", "_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.", @@ -281,13 +283,14 @@ "_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.", "capture_groups_small": 5253, "capture_groups_large": 16803, - "capture_groups_fp": 2536, - "fixture_count": 142, + "capture_groups_fp": 2581, + "fixture_count": 149, "_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.", "_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.", "_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132.", "_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2.", - "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 1 existing member-read match gains a receiver-chain tag; removing only that added tag restores the complete main capture set. No group is added or removed. Fixture entries 142; changed entries 1; capture groups 2634 -> 2634. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged." + "_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 1 existing member-read match gains a receiver-chain tag; removing only that added tag restores the complete main capture set. No group is added or removed. Fixture entries 142; changed entries 1; capture groups 2634 -> 2634. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.", + "_rebaselined_same_namespace_heritage_fixture": "Same-namespace heritage fixture: adds `kotlin-same-package-extends-collision` (7 files). FIXTURE ADDITION ONLY, no capture code changed: with that directory parked, --check passes against the prior fingerprint 026392f9a60d9808dd993fa6d26f3a2ca6240ed6ac63981d614058ae7acd29e2 (measured fixture_count 142, capture_groups_fp 2536); with it, fixture_count 149, capture_groups_fp 2581. Synthetic counts and scaling unchanged." }, "typescript-deep-chain": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index b767e8c3ff..d4c5f87e4c 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -9,7 +9,10 @@ import type { ParsedFile } from 'gitnexus-shared'; import { CLASS_KINDS, SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + populateClassOwnedMembers, + resolveSameNamespaceInheritanceBase, +} from '../../scope-resolution/scope/walkers.js'; import { populateCsharpNamespacePrefixes } from './qualified-type-names.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { @@ -119,6 +122,18 @@ const csharpScopeResolver: ScopeResolver = { // `namespace-siblings.ts` for the implementation. populateNamespaceSiblings: populateCsharpNamespaceSiblings, + // The inheritance pre-pass runs before those siblings exist, so a base + // declared in another file of the SAME named namespace binds here. Exact + // namespace only (the `namespacePrefix` sidecar from `populateOwners`): + // enclosing-namespace and global visibility stay with the shared chain. + resolveImplicitInheritanceBase: (baseName, derivingClass, scopes) => + resolveSameNamespaceInheritanceBase( + baseName, + derivingClass, + scopes, + (def) => def.namespacePrefix, + ), + // C# is statically typed — type information is reliable. Field- // fallback heuristic stays off (the type-binding layer already // produces precise owner types); return-type propagation on is fine diff --git a/gitnexus/src/core/ingestion/languages/java/package-siblings.ts b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts index 4116d03ad3..e9951dbc59 100644 --- a/gitnexus/src/core/ingestion/languages/java/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/java/package-siblings.ts @@ -10,3 +10,6 @@ export const populateJavaPackageSiblings = javaPackageSiblingVisibility.populate export const isJavaPackageSiblingVisibilityIncomplete = javaPackageSiblingVisibility.isVisibilityIncomplete; + +export const resolveJavaSamePackageBase = + javaPackageSiblingVisibility.resolveImplicitInheritanceBase; diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts index 3f23040a66..fc57578434 100644 --- a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts @@ -28,7 +28,7 @@ import { resolveJavaImportTarget, type JavaResolveContext, } from './index.js'; -import { populateJavaPackageSiblings } from './package-siblings.js'; +import { populateJavaPackageSiblings, resolveJavaSamePackageBase } from './package-siblings.js'; import { attachSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachJavaSpringAopMetadata } from './spring-aop.js'; import { attachJavaSpringConfigBindings } from './spring-config-bindings.js'; @@ -90,6 +90,7 @@ const javaScopeResolver: ScopeResolver = { freeCallsRequireInstanceOwnership: true, populateNamespaceSiblings: populateJavaPackageSiblings, + resolveImplicitInheritanceBase: resolveJavaSamePackageBase, populateRangeBindings: populateJavaCrossFileReturnTypes, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes, ctx) => { attachSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); diff --git a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts index b3e94b71ed..15186652a5 100644 --- a/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/jvm/package-siblings.ts @@ -1,7 +1,10 @@ -import type { BindingRef, ParsedFile, ScopeId, TypeRef } from 'gitnexus-shared'; +import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import { logger } from '../../../logger.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; -import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { + isClassLike, + resolveSameNamespaceInheritanceBase, +} from '../../scope-resolution/scope/walkers.js'; import type { JvmPackageFact } from './package-facts.js'; /** Packages larger than this get no implicit sibling visibility at all — every @@ -32,6 +35,14 @@ export interface JvmPackageSiblingVisibility { }, ) => void; readonly isVisibilityIncomplete: (filePath: string) => boolean; + /** `ScopeResolver.resolveImplicitInheritanceBase`: an unqualified base bound + * by the same-package rule, which the sibling bindings above only provide + * after the inheritance pre-pass has run. */ + readonly resolveImplicitInheritanceBase: ( + baseName: string, + derivingClass: SymbolDefinition, + scopes: ScopeResolutionIndexes, + ) => SymbolDefinition | undefined; } interface PackageBucket { @@ -225,9 +236,19 @@ export function createJvmPackageSiblingVisibility( } } + // A file whose package is unknown matches nothing, so it can neither claim a + // base nor be offered one. Top-level types only: `qualifiedNames` keys a + // nested type by its dotted `Outer.Inner` path, never by the bare name. + const packageOf = (def: SymbolDefinition): string | undefined => { + const fact = options.getPackageFact(def.filePath); + return fact?.status === 'known' ? fact.packageName : undefined; + }; + return { populateNamespaceSiblings, isVisibilityIncomplete: (filePath) => incompleteFiles.has(filePath), + resolveImplicitInheritanceBase: (baseName, derivingClass, scopes) => + resolveSameNamespaceInheritanceBase(baseName, derivingClass, scopes, packageOf), }; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/package-siblings.ts b/gitnexus/src/core/ingestion/languages/kotlin/package-siblings.ts index 4933c2c341..d36ab63b3e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/package-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/package-siblings.ts @@ -11,3 +11,6 @@ export const populateKotlinPackageSiblings = export const isKotlinPackageSiblingVisibilityIncomplete = kotlinPackageSiblingVisibility.isVisibilityIncomplete; + +export const resolveKotlinSamePackageBase = + kotlinPackageSiblingVisibility.resolveImplicitInheritanceBase; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 5e341ebe1a..1f014e5a3c 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -25,7 +25,7 @@ import { clearKotlinClassAnnotationFacts, } from './capture-side-channel.js'; import { isKotlinStaticOnly } from './owners.js'; -import { populateKotlinPackageSiblings } from './package-siblings.js'; +import { populateKotlinPackageSiblings, resolveKotlinSamePackageBase } from './package-siblings.js'; import { attachKotlinSpringBeanCandidateMetadata } from './spring-bean-metadata.js'; import { attachKotlinSpringAopMetadata } from './spring-aop.js'; import { clearKotlinPackageFacts } from './package-facts.js'; @@ -152,6 +152,7 @@ export const kotlinScopeResolver: ScopeResolver = { freeCallsRequireInstanceOwnership: true, postExtractSourceTextPolicy: 'uncached-files', populateNamespaceSiblings: populateKotlinPackageSiblings, + resolveImplicitInheritanceBase: resolveKotlinSamePackageBase, emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => { attachKotlinSpringBeanCandidateMetadata(graph, parsedFiles, nodeLookup, indexes); attachKotlinSpringAopMetadata(graph, parsedFiles, nodeLookup, indexes); 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 256b504652..6152c8056b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -1633,6 +1633,28 @@ export interface ScopeResolver { }, ) => void; + /** + * Optional fallback for an UNQUALIFIED inheritance base that the shared + * chain (`resolveInheritanceBaseInScope`) could not bind. Consulted only by + * the inheritance pre-pass, which runs before `populateNamespaceSiblings`: + * a base visible solely through that implicit rule (`class C extends B`, + * `B` declared in another file of the same namespace) otherwise reaches only + * the workspace-unique simple-name fallback, which any other namespace + * declaring a `B` defeats — no inheritance edge, so an empty MRO. + * + * The pre-pass skips the hook when the chain bound a base, when the base is + * written qualified, or when the site holds an explicit name claim (an + * import that shadows the implicit rule), so it can add an edge but never + * retarget one. Answer with `resolveSameNamespaceInheritanceBase` + * (`scope/walkers.ts`) given the provider's namespace fact, or `undefined` + * to decline. + */ + readonly resolveImplicitInheritanceBase?: ( + baseName: string, + derivingClass: SymbolDefinition, + scopes: ScopeResolutionIndexes, + ) => SymbolDefinition | undefined; + /** * Whether the compound-receiver resolver should walk up from a * class scope to ancestor (Module) scopes when looking up a diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 2c4168d449..60a89b68e7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -23,7 +23,13 @@ * Plan: `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`. */ -import type { ParsedFile, ReferenceSite, RegistryProviders, Scope } from 'gitnexus-shared'; +import type { + ParsedFile, + ReferenceSite, + RegistryProviders, + Scope, + SymbolDefinition, +} from 'gitnexus-shared'; import type { TypeRef } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; @@ -103,7 +109,13 @@ import { emitCallableValueFlow, } from '../passes/callable-value-flow.js'; import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js'; -import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; +import { + findEnclosingClassDef, + hasExplicitNameClaim, + lookupNameClaim, + resolveInheritanceBaseInScope, +} from '../scope/walkers.js'; +import { normalizeQualifiedName, stripTrailingTypeArguments } from '../../utils/qualified-name.js'; import { heritageTypeArgumentsKey, type HeritageTypeArgumentSink, @@ -207,6 +219,52 @@ function emitInheritanceEdgeDirect( }); } +/** + * Whether an `inherits` site's base was written as a bare name, with no + * qualifier (`a.b.Base`, `Ns::Base`). Captures that keep only the simple tail + * carry no `rawQualifiedName`, so the name capture must also start where the + * base expression does: `Base` and `Base(args)` lead with the name, + * `pkg.Base` does not. A site whose anchor is not the base expression at all + * fails the same test, which only costs it the implicit-namespace fallback. + */ +function isBareInheritanceBase(site: ReferenceSite): boolean { + if ( + site.rawQualifiedName !== undefined && + stripTrailingTypeArguments(normalizeQualifiedName(site.rawQualifiedName)).includes('.') + ) { + return false; + } + const name = site.nameRange; + return ( + name !== undefined && + name.startLine === site.atRange.startLine && + name.startCol === site.atRange.startCol + ); +} + +/** + * The provider's implicit same-namespace base for an `inherits` site the shared + * chain left unbound. That visibility (`populateNamespaceSiblings`) is only + * populated after the inheritance pre-pass, so without this a base it alone + * makes visible reaches nothing but the workspace-unique name fallback. Bare, + * unclaimed bases only: for a written qualifier or a shadowing import the + * chain has already decided, and its miss stands. + */ +function resolveImplicitInheritanceBaseAtSite( + site: ReferenceSite, + derivingClass: SymbolDefinition, + scopes: ScopeResolutionIndexes, + resolveImplicitBase: ScopeResolver['resolveImplicitInheritanceBase'], +): SymbolDefinition | undefined { + if (resolveImplicitBase === undefined || !isBareInheritanceBase(site)) return undefined; + const claim = lookupNameClaim(site.lookupScope ?? site.inScope, site.name, scopes, { + position: site.atRange, + purpose: site.lookupPurpose ?? 'type', + }); + if (hasExplicitNameClaim(claim, site.name)) return undefined; + return resolveImplicitBase(site.name, derivingClass, scopes); +} + /** * Resolve inheritance reference sites early and pre-emit their EXTENDS edges * before MRO construction. This lets template-base captures contribute to the @@ -231,6 +289,7 @@ function preEmitInheritanceEdges( nodeLookup: ReturnType, recordTypeArguments: HeritageTypeArgumentSink, unresolvedInheritanceByClass: Set, + resolveImplicitBase: ScopeResolver['resolveImplicitInheritanceBase'], recordSourceBinding?: SourceBindingRecorder, ): Set { const handledSites = new Set(); @@ -265,14 +324,15 @@ function preEmitInheritanceEdges( const callerClass = findEnclosingClassDef(site.inScope, scopes); if (callerClass === undefined) continue; - const targetDef = resolveInheritanceBaseInScope( - site.lookupScope ?? site.inScope, - site.name, - scopes, - site.rawQualifiedName, - callerClass, - { position: site.atRange, purpose: site.lookupPurpose ?? 'type' }, - ); + const targetDef = + resolveInheritanceBaseInScope( + site.lookupScope ?? site.inScope, + site.name, + scopes, + site.rawQualifiedName, + callerClass, + { position: site.atRange, purpose: site.lookupPurpose ?? 'type' }, + ) ?? resolveImplicitInheritanceBaseAtSite(site, callerClass, scopes, resolveImplicitBase); if (targetDef === undefined || targetDef.nodeId === callerClass.nodeId) { // Static lookup can mistake the current declaration for an earlier // same-named base. A self-edge is not inheritance evidence and would @@ -941,6 +1001,7 @@ export function runScopeResolution( nodeLookup, recordHeritageTypeArguments, unresolvedInheritanceByClass, + provider.resolveImplicitInheritanceBase, recordSourceBinding, ); // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 9b8fc5c875..a00b620c66 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -1722,6 +1722,56 @@ function dirnameOf(filePath: string): string { return idx === -1 ? '' : filePath.slice(0, idx); } +/** + * The class-like def named `baseName` that `derivingClass` sees through an + * implicit same-namespace rule — the shared body of + * `ScopeResolver.resolveImplicitInheritanceBase`. `namespaceOf` is the + * provider's namespace fact for a def (`undefined` = unknown, never matches). + * + * Candidates must share the deriving class's namespace. Several can when one + * namespace is declared under more than one source root (a multi-module + * build), so the nearest by shared leading path segments wins — the order the + * sibling hooks inject them in — and a tie at that distance refuses: a missing + * edge is recoverable, a wrong EXTENDS edge silently corrupts impact analysis. + */ +export function resolveSameNamespaceInheritanceBase( + baseName: string, + derivingClass: SymbolDefinition, + scopes: ScopeResolutionIndexes, + namespaceOf: (def: SymbolDefinition) => string | undefined, +): SymbolDefinition | undefined { + const namespace = namespaceOf(derivingClass); + if (namespace === undefined) return undefined; + const ownSegments = derivingClass.filePath.split('/'); + let nearest: SymbolDefinition | undefined; + let nearestShared = -1; + let tied = false; + for (const id of scopes.qualifiedNames.get(baseName)) { + const def = scopes.defs.get(id); + if (def === undefined || def.nodeId === derivingClass.nodeId || !isClassLike(def.type)) { + continue; + } + if (namespaceOf(def) !== namespace) continue; + const segments = def.filePath.split('/'); + let shared = 0; + while ( + shared < segments.length && + shared < ownSegments.length && + segments[shared] === ownSegments[shared] + ) { + shared++; + } + if (shared > nearestShared) { + nearest = def; + nearestShared = shared; + tied = false; + } else if (shared === nearestShared) { + tied = true; + } + } + return tied ? undefined : nearest; +} + /** * True when the caller can see `classDef` through import evidence, not * merely because the class name is workspace-unique. diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 02d621116d..e7fd15fca0 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -603,6 +603,34 @@ "captureGroups": 11, "digest": "daf34e132bd27b86fe012d64e6cc6fcf4f5e39dd168e9bd30dc1151644235d45" }, + "csharp-same-namespace-base-collision/App/App.cs": { + "captureGroups": 14, + "digest": "23a4e39b9e81bde699e0ea97801591b269565e22f6bd200641fd7e9d3119ec63" + }, + "csharp-same-namespace-base-collision/Core/A.cs": { + "captureGroups": 10, + "digest": "e3feae117230f7458726093097b396ca752737bf46d377a8416591553014fb52" + }, + "csharp-same-namespace-base-collision/Core/B.cs": { + "captureGroups": 6, + "digest": "51ec8184444d674f9dc646cac0cb3b88b0fc916edfb27d91f5e12a8cb1aa6803" + }, + "csharp-same-namespace-base-collision/Core/Leaf.cs": { + "captureGroups": 6, + "digest": "0cf391c146ada3f91172e0c484ad2857868ac686dcdcc6058a61a50e2be94204" + }, + "csharp-same-namespace-base-collision/Core/Stamp.cs": { + "captureGroups": 8, + "digest": "e8422aaf8c91bcaa8ed7bfac1c087f94dc5b912b7c5f86cb92a9d8547b0b94ef" + }, + "csharp-same-namespace-base-collision/Legacy/A.cs": { + "captureGroups": 8, + "digest": "57ff887da3157871ba8c549c5fa80a31dd72039da9841a4057ca1a5b9cc9a196" + }, + "csharp-same-namespace-base-collision/Legacy/B.cs": { + "captureGroups": 6, + "digest": "b20bc3cd08070a4242d3530e8fc6e55e5bf003989c56ac96f78c43cf9cdd6193" + }, "csharp-self-this-resolution/src/Models/Repo.cs": { "captureGroups": 8, "digest": "bc3ded7bee753993b7bb10a33f3fe4e4499cca0489fcfd58d8f80c811375a4a9" diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/App/App.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/App/App.cs new file mode 100644 index 0000000000..499cf94279 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/App/App.cs @@ -0,0 +1,13 @@ +using Acme.Core; + +namespace Acme.App +{ + public class App + { + public void Process() + { + var leaf = new Leaf(); + leaf.Greet().Save(); + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/A.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/A.cs new file mode 100644 index 0000000000..aeb772a785 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/A.cs @@ -0,0 +1,7 @@ +namespace Acme.Core +{ + public class A + { + public Stamp Greet() => new Stamp(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/B.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/B.cs new file mode 100644 index 0000000000..5a8b1245a9 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/B.cs @@ -0,0 +1,4 @@ +namespace Acme.Core +{ + public class B : A {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Leaf.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Leaf.cs new file mode 100644 index 0000000000..6319437590 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Leaf.cs @@ -0,0 +1,4 @@ +namespace Acme.Core +{ + public class Leaf : B {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Stamp.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Stamp.cs new file mode 100644 index 0000000000..034fca59ac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Core/Stamp.cs @@ -0,0 +1,7 @@ +namespace Acme.Core +{ + public class Stamp + { + public void Save() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/A.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/A.cs new file mode 100644 index 0000000000..23df9faf68 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/A.cs @@ -0,0 +1,7 @@ +namespace Acme.Legacy +{ + public class A + { + public void Greet() {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/B.cs b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/B.cs new file mode 100644 index 0000000000..9c1ea91eb8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-same-namespace-base-collision/Legacy/B.cs @@ -0,0 +1,4 @@ +namespace Acme.Legacy +{ + public class B : A {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/app/App.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/app/App.java new file mode 100644 index 0000000000..1b168c34d3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/app/App.java @@ -0,0 +1,10 @@ +package com.acme.app; + +import com.acme.core.C; + +public class App { + public void process() { + C c = new C(); + c.greet().save(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/A.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/A.java new file mode 100644 index 0000000000..158ad48aa4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/A.java @@ -0,0 +1,5 @@ +package com.acme.core; + +public class A { + public Stamp greet() { return new Stamp(); } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/B.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/B.java new file mode 100644 index 0000000000..9674c0cff2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/B.java @@ -0,0 +1,3 @@ +package com.acme.core; + +public class B extends A {} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/C.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/C.java new file mode 100644 index 0000000000..5fa700eb53 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/C.java @@ -0,0 +1,3 @@ +package com.acme.core; + +public class C extends B {} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Qualified.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Qualified.java new file mode 100644 index 0000000000..b1480ec922 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Qualified.java @@ -0,0 +1,3 @@ +package com.acme.core; + +public class Qualified extends com.acme.legacy.B {} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Shadowed.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Shadowed.java new file mode 100644 index 0000000000..9b18f25f13 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Shadowed.java @@ -0,0 +1,5 @@ +package com.acme.core; + +import com.acme.legacy.A; + +public class Shadowed extends A {} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Stamp.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Stamp.java new file mode 100644 index 0000000000..802401301a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/core/Stamp.java @@ -0,0 +1,5 @@ +package com.acme.core; + +public class Stamp { + public void save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/A.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/A.java new file mode 100644 index 0000000000..e2d8167773 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/A.java @@ -0,0 +1,5 @@ +package com.acme.legacy; + +public class A { + public void greet() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/B.java b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/B.java new file mode 100644 index 0000000000..dadba8a77c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-same-package-extends-collision/src/main/java/com/acme/legacy/B.java @@ -0,0 +1,3 @@ +package com.acme.legacy; + +public class B extends A {} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/app/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/app/App.kt new file mode 100644 index 0000000000..fc47e634a8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/app/App.kt @@ -0,0 +1,10 @@ +package com.acme.app + +import com.acme.core.C + +class App { + fun process() { + val c = C() + c.greet().save() + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/A.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/A.kt new file mode 100644 index 0000000000..c9adab7407 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/A.kt @@ -0,0 +1,5 @@ +package com.acme.core + +open class A { + fun greet(): Stamp = Stamp() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/B.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/B.kt new file mode 100644 index 0000000000..4d0ec00864 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/B.kt @@ -0,0 +1,3 @@ +package com.acme.core + +open class B : A() diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/C.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/C.kt new file mode 100644 index 0000000000..396bdf759c --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/C.kt @@ -0,0 +1,3 @@ +package com.acme.core + +class C : B() diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/Stamp.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/Stamp.kt new file mode 100644 index 0000000000..85c5fb33ef --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/core/Stamp.kt @@ -0,0 +1,5 @@ +package com.acme.core + +class Stamp { + fun save() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/A.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/A.kt new file mode 100644 index 0000000000..9c764cdd9b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/A.kt @@ -0,0 +1,5 @@ +package com.acme.legacy + +open class A { + fun greet() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/B.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/B.kt new file mode 100644 index 0000000000..bcdfcab124 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-same-package-extends-collision/src/main/kotlin/com/acme/legacy/B.kt @@ -0,0 +1,3 @@ +package com.acme.legacy + +class B : A() diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index 606eb485f2..eb46086a58 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -1595,6 +1595,46 @@ describe('C# grandparent method resolution via MRO (Phase B)', () => { }); }); +// --------------------------------------------------------------------------- +// Same-namespace base whose simple name another namespace also declares. +// Acme.Core's `B : A` / `Leaf : B` carry no `using` (same-namespace visibility), +// while Acme.Legacy declares its own A and B. The base must bind through the +// namespace, not through a workspace-unique simple name the legacy pair makes +// ambiguous. +// --------------------------------------------------------------------------- + +describe('C# same-namespace base with a colliding simple name', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'csharp-same-namespace-base-collision'), + () => {}, + ); + }, 60000); + + it('binds every base inside its own namespace', () => { + const extends_ = getRelationships(result, 'EXTENDS').map( + (e) => `${e.sourceFilePath}:${e.source} → ${e.targetFilePath}:${e.target}`, + ); + expect(extends_.sort()).toEqual([ + 'Core/B.cs:B → Core/A.cs:A', + 'Core/Leaf.cs:Leaf → Core/B.cs:B', + 'Legacy/B.cs:B → Legacy/A.cs:A', + ]); + }); + + it.each([ + ['Greet', 'Core/A.cs'], + ['Save', 'Core/Stamp.cs'], + ])('resolves App.Process → %s through the Leaf → B → A chain (%s)', (target, targetFile) => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'Process' && c.target === target, + ); + expect(calls.map((c) => c.targetFilePath)).toEqual([targetFile]); + }); +}); + // --------------------------------------------------------------------------- // Phase C: C# null-check narrowing — if (x != null) and if (x is not null) // Both patterns emit patternOverrides for the if-body position range diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 8c7897cf04..46bc5649d0 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -2101,6 +2101,57 @@ describe('Java grandparent method resolution via MRO (Phase B)', () => { }); }); +// --------------------------------------------------------------------------- +// Same-package `extends` whose base name another package also declares. +// com.acme.core's `B extends A` / `C extends B` carry no import (the Java +// same-package rule), while com.acme.legacy declares its own A and B. The base +// must bind through the package, not through a workspace-unique simple name +// that the legacy pair makes ambiguous. Two guards: `Shadowed` single-type +// imports legacy.A, which beats the same-package A; `Qualified` names +// `com.acme.legacy.B`, which the same-package B must never answer. +// --------------------------------------------------------------------------- + +describe('Java same-package extends with a colliding simple name', () => { + let result: PipelineResult; + const core = 'src/main/java/com/acme/core'; + const legacy = 'src/main/java/com/acme/legacy'; + const extendsEdges = (): string[] => + getRelationships(result, 'EXTENDS').map( + (e) => `${e.sourceFilePath}:${e.source} → ${e.targetFilePath}:${e.target}`, + ); + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'java-same-package-extends-collision'), + () => {}, + ); + }, 60000); + + it('binds unqualified bases by the same-package rule, a single-type import first', () => { + const unqualified = extendsEdges().filter((e) => !e.startsWith(`${core}/Qualified.java:`)); + expect(unqualified.sort()).toEqual([ + `${core}/B.java:B → ${core}/A.java:A`, + `${core}/C.java:C → ${core}/B.java:B`, + `${core}/Shadowed.java:Shadowed → ${legacy}/A.java:A`, + `${legacy}/B.java:B → ${legacy}/A.java:A`, + ]); + }); + + it('never binds a qualified base through the same-package simple name', () => { + expect(extendsEdges()).not.toContain(`${core}/Qualified.java:Qualified → ${core}/B.java:B`); + }); + + it.each([ + ['greet', `${core}/A.java`], + ['save', `${core}/Stamp.java`], + ])('resolves App.process → %s through the C → B → A chain (%s)', (target, targetFile) => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'process' && c.target === target, + ); + expect(calls.map((c) => c.targetFilePath)).toEqual([targetFile]); + }); +}); + // ── Phase P: Overload Disambiguation via Parameter Types ───────────────── describe('Java overload disambiguation by parameter types', () => { diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index b762339b52..cc4e233752 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -1637,6 +1637,48 @@ describe('Kotlin grandparent method resolution via MRO (Phase B)', () => { }); }); +// --------------------------------------------------------------------------- +// Same-package supertype whose simple name another package also declares. +// com.acme.core's `B : A()` / `C : B()` carry no import (same-package +// visibility), while com.acme.legacy declares its own A and B. The supertype +// must bind through the package, not through a workspace-unique simple name +// the legacy pair makes ambiguous. +// --------------------------------------------------------------------------- + +describe('Kotlin same-package supertype with a colliding simple name', () => { + let result: PipelineResult; + const core = 'src/main/kotlin/com/acme/core'; + const legacy = 'src/main/kotlin/com/acme/legacy'; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-same-package-extends-collision'), + () => {}, + ); + }, 60000); + + it('binds every supertype inside its own package', () => { + const extends_ = getRelationships(result, 'EXTENDS').map( + (e) => `${e.sourceFilePath}:${e.source} → ${e.targetFilePath}:${e.target}`, + ); + expect(extends_.sort()).toEqual([ + `${core}/B.kt:B → ${core}/A.kt:A`, + `${core}/C.kt:C → ${core}/B.kt:B`, + `${legacy}/B.kt:B → ${legacy}/A.kt:A`, + ]); + }); + + it.each([ + ['greet', `${core}/A.kt`], + ['save', `${core}/Stamp.kt`], + ])('resolves App.process → %s through the C → B → A chain (%s)', (target, targetFile) => { + const calls = getRelationships(result, 'CALLS').filter( + (c) => c.source === 'process' && c.target === target, + ); + expect(calls.map((c) => c.targetFilePath)).toEqual([targetFile]); + }); +}); + // --------------------------------------------------------------------------- // Phase C: Kotlin null-check narrowing — if (x != null) { x.save() } // NOTE: depends on nullable_type capture being fixed in jvm.ts diff --git a/gitnexus/test/unit/scope-resolution/resolve-same-namespace-inheritance-base.test.ts b/gitnexus/test/unit/scope-resolution/resolve-same-namespace-inheritance-base.test.ts new file mode 100644 index 0000000000..6616ee1892 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolve-same-namespace-inheritance-base.test.ts @@ -0,0 +1,97 @@ +/** + * Unit coverage for `resolveSameNamespaceInheritanceBase`, the shared body of + * `ScopeResolver.resolveImplicitInheritanceBase`. The inheritance pre-pass runs + * before same-namespace sibling bindings exist, so this is what binds + * `class C extends B` to the `B` of C's own namespace when another namespace + * also declares a `B`. Rules pinned here: + * + * - only candidates sharing the deriving class's namespace count + * - an unknown namespace (deriving class or candidate) never matches + * - several same-namespace candidates: nearest by shared leading path + * segments wins, a tie at that distance refuses + * - the deriving class itself and non-class-like defs are never answers + * + * Drives the function through a minimal cast `ScopeResolutionIndexes` (only + * `qualifiedNames` / `defs`, the accessors it reads). + */ +import { describe, it, expect } from 'vitest'; +import type { SymbolDefinition } from 'gitnexus-shared'; +import { resolveSameNamespaceInheritanceBase } from '../../../src/core/ingestion/scope-resolution/scope/walkers.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const BASE = 'B'; +const DERIVING_FILE = 'app/src/main/java/com/acme/core/C.java'; + +interface Candidate { + readonly nodeId: string; + readonly filePath: string; + readonly type?: string; +} + +/** `filePath -> namespace`; a path missing from the table has no known namespace. */ +const NAMESPACES: Readonly> = { + [DERIVING_FILE]: 'com.acme.core', + 'app/src/main/java/com/acme/core/B.java': 'com.acme.core', + 'app/src/test/java/com/acme/core/B.java': 'com.acme.core', + 'lib/src/main/java/com/acme/core/B.java': 'com.acme.core', + 'tools/src/main/java/com/acme/core/B.java': 'com.acme.core', + 'app/src/main/java/com/acme/legacy/B.java': 'com.acme.legacy', +}; + +function resolve( + candidates: readonly Candidate[], + derivingFile = DERIVING_FILE, +): string | undefined { + const defs = new Map(); + for (const c of candidates) { + defs.set(c.nodeId, { + nodeId: c.nodeId, + filePath: c.filePath, + type: c.type ?? 'Class', + } as SymbolDefinition); + } + const ids = candidates.map((c) => c.nodeId); + const scopes = { + qualifiedNames: { get: (name: string) => (name === BASE ? ids : []) }, + defs: { get: (id: string) => defs.get(id) }, + } as unknown as ScopeResolutionIndexes; + const deriving = { nodeId: 'd:self', filePath: derivingFile, type: 'Class' } as SymbolDefinition; + return resolveSameNamespaceInheritanceBase( + BASE, + deriving, + scopes, + (def) => NAMESPACES[def.filePath], + )?.nodeId; +} + +const CORE_B = { nodeId: 'd:core', filePath: 'app/src/main/java/com/acme/core/B.java' }; +const LEGACY_B = { nodeId: 'd:legacy', filePath: 'app/src/main/java/com/acme/legacy/B.java' }; +const TEST_ROOT_B = { nodeId: 'd:test', filePath: 'app/src/test/java/com/acme/core/B.java' }; +const LIB_B = { nodeId: 'd:lib', filePath: 'lib/src/main/java/com/acme/core/B.java' }; +const TOOLS_B = { nodeId: 'd:tools', filePath: 'tools/src/main/java/com/acme/core/B.java' }; + +describe('resolveSameNamespaceInheritanceBase', () => { + it.each<[string, readonly Candidate[], string | undefined]>([ + ['binds the one candidate in its own namespace', [LEGACY_B, CORE_B], 'd:core'], + ['declines when no candidate shares its namespace', [LEGACY_B], undefined], + ['prefers the nearest of several same-namespace candidates', [LIB_B, TEST_ROOT_B], 'd:test'], + ['refuses a tie at the nearest distance', [LIB_B, TOOLS_B], undefined], + [ + 'never answers with a candidate whose namespace is unknown', + [{ nodeId: 'd:unknown', filePath: 'gen/B.java' }], + undefined, + ], + [ + 'never answers with the deriving class itself', + [{ nodeId: 'd:self', filePath: DERIVING_FILE }], + undefined, + ], + ['ignores a non-class-like def', [{ ...CORE_B, type: 'Method' }], undefined], + ])('%s', (_title, candidates, expected) => { + expect(resolve(candidates)).toBe(expected); + }); + + it('declines when the deriving class has no known namespace', () => { + expect(resolve([CORE_B], 'gen/C.java')).toBeUndefined(); + }); +});