diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03e3730b3..138530d24 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,6 +277,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`. +Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator` must not reach an implementor of `IValidator`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest. + +The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper : IValidator` stays reachable from every instantiation while `class IntValidator : IValidator` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned. + +The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run(IValidator v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place. + Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning. ### Receiver chains and the drop census (#2766) diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 6629dacd3..b559d32e3 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -82,6 +82,28 @@ export interface ReferenceSite { * otherwise, in which case resolution is unchanged. */ readonly rawQualifiedName?: string; + /** + * Top-level generic/template arguments the source wrote ON this reference — + * `class UserValidator : IValidator` yields `['string']` on the + * `inherits` site whose `name` is `IValidator`. + * + * `name` is the BASE name and stays that way: every lookup in resolution is + * keyed by it, and one declaration answers for every instantiation of itself. + * This records what the erasure threw away, so a consumer that needs the + * INSTANTIATION — receiver-bound interface dispatch, which must not fan a + * `IValidator` receiver out to an `IValidator` implementor + * (#2912) — can ask for it without re-parsing the source. + * + * Derived generically from the anchor capture's own text (see + * `collectReferenceSites`), so no language query change is needed: an emitter + * whose `@reference.inherits` anchor spans the whole base gets this for free, + * and one whose anchor is the bare name simply leaves it absent. + * + * ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable + * here, and only the first is safe to act on. Consumers must fail OPEN on + * absence (keep the target), matching `SymbolDefinition.typeParameters`. + */ + readonly typeArguments?: readonly string[]; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 896b0dc04..e90b0be85 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -107,6 +107,10 @@ export interface SymbolDefinition { * Unavailable callables still participate in overload selection, but a * selected unavailable target must suppress edge emission. */ isDeleted?: boolean; + /** True when the declaration identity was synthesized rather than written in + * source (for example an anonymous class). Consumers may use this only as a + * conservative priority hint; it does not change graph-node identity. */ + isSynthetic?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index b39351123..a877b7be0 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -30,12 +30,12 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.28.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -55,7 +55,7 @@ "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.8.23", "@vitejs/plugin-react": "^6.0.5", @@ -2589,9 +2589,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5715,9 +5715,9 @@ } }, "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -7393,24 +7393,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-i18next": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 1b4c721fc..493546aee 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -40,12 +40,12 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.28.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -65,7 +65,7 @@ "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.8.23", "@vitejs/plugin-react": "^6.0.5", diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 724a6a29a..b010fa30a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -72,8 +72,9 @@ "fixture_count": 178 }, "rust": { - "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", + "fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", @@ -123,8 +124,9 @@ "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { - "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", + "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field — the type arguments the clause was written with (`implements Validator`) — so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.", "_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.", "_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.", @@ -133,8 +135,10 @@ "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0." }, "java": { - "fingerprint": "36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5", + "fingerprint": "79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a", "scaling_budget": 1.5, + "_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.", + "_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).", "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", @@ -148,17 +152,20 @@ "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", "_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|…` instead of `1|…`. 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": 5755, - "capture_groups_large": 18405, - "capture_groups_fp": 3512, + "capture_groups_small": 6255, + "capture_groups_large": 20005, + "capture_groups_fp": 3560, "fixture_count": 206 }, "java-local-types": { - "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", + "fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e", "scaling_budget": 1.5, + "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", + "_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_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|…` instead of `1|…`. 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 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_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|…` instead of `1|…`. 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 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.", + "capture_groups_fp": 680 }, "typescript": { "fingerprint": "f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f", diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 790dbc010..4f5fa40ad 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -79,7 +79,7 @@ "version": "1.0.0", "dev": true, "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" } }, "node_modules/@babel/code-frame": { @@ -5430,9 +5430,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.11", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", - "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index fdfc75839..109a2ea94 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -214,6 +214,20 @@ interface LanguageProviderConfig { * Default: undefined (standard label assignment). */ readonly labelOverride?: (functionNode: SyntaxNode, defaultLabel: NodeLabel) => NodeLabel | null; + /** + * Suppress a definition query match after its default label is known. + * Languages use this for syntax that represents an implicit declaration + * unless an explicit declaration with the same semantics is present. + * + * `defaultLabel` is supplied so an implementation can scope itself to one + * kind of definition; implementations whose capture map alone decides the + * question may ignore it. + */ + readonly shouldSkipDefinitionCapture?: ( + captureMap: CaptureMap, + defaultLabel: NodeLabel, + ) => boolean; + // ── MRO ─────────────────────────────────────────────────────────── /** MRO strategy for multiple inheritance resolution. * Default: 'first-wins'. */ diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 615da3c21..18c37ba2b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -93,8 +93,14 @@ const CSHARP_SCOPE_QUERY = ` name: (identifier) @declaration.name) @declaration.enum ;; Declarations — methods / constructors / properties +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \`void Run(IValidator v)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor of \`IValidator\` from the call's fan-out. (method_declaration - name: (identifier) @declaration.name) @declaration.method + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.method (constructor_declaration name: (identifier) @declaration.name) @declaration.constructor diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index eb1d3b10d..4b50efc67 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -102,6 +102,82 @@ const csharpScopeResolver: ScopeResolver = { // files. The compound-receiver walker needs to walk up from the // class scope to find them; see the contract field for rationale. hoistTypeBindingsToModule: true, + + // `IValidator` and `IValidator` are one instantiation, so the + // dispatch fan-out must not read them as two (#2912). See the alias table. + normalizeTypeArgument: normalizeCsharpTypeArgument, }; +/** + * C# predefined type aliases — the 15 keywords the language defines as exact + * synonyms for `System` types (`string` ≡ `System.String`), plus `nint`/`nuint`. + * A codebase mixing the spellings is common enough that StyleCop ships a rule + * about it (SA1121), so the two forms genuinely meet across files. + * + * Keyword → BCL simple name; anything else is returned unchanged, including the + * BCL names themselves (already canonical) and any qualified spelling, which is + * compared as written. + * + * A workspace may legally declare its OWN type named `String`, which shadows the + * BCL simple name; this table then reads `IValidator` as the `string` + * instantiation and KEEPS that implementor in the fan-out. Deliberate, and the + * safe direction: the alternative is pruning on the belief that two spellings + * differ, which is the missing-edge failure `generic-instantiation.ts` is built + * to avoid. Resolving instead of normalizing cannot settle it either — the + * identity comparison needs a `definitionId` from BOTH sides, and a built-in + * name has none, so "built-in versus workspace-declared" would be a new prune + * with no positive evidence behind it. The result is one surplus edge in a + * shape that is rare on its own terms, i.e. exactly the pre-#2912 fan-out for + * that pair and no worse. + */ +const CSHARP_PREDEFINED_TYPE_ALIASES: ReadonlyMap = new Map([ + ['bool', 'Boolean'], + ['byte', 'Byte'], + ['sbyte', 'SByte'], + ['char', 'Char'], + ['decimal', 'Decimal'], + ['double', 'Double'], + ['float', 'Single'], + ['int', 'Int32'], + ['uint', 'UInt32'], + ['long', 'Int64'], + ['ulong', 'UInt64'], + ['short', 'Int16'], + ['ushort', 'UInt16'], + ['nint', 'IntPtr'], + ['nuint', 'UIntPtr'], + ['object', 'Object'], + ['string', 'String'], +]); + +/** The BCL simple names the keywords alias. A spelling that reduces to one of + * these IS the predefined type; anything else that merely happens to sit in + * `System` is an ordinary type and keeps its qualifier. */ +const CSHARP_PREDEFINED_TYPE_NAMES: ReadonlySet = new Set( + CSHARP_PREDEFINED_TYPE_ALIASES.values(), +); + +const CSHARP_SYSTEM_QUALIFIER = /^(?:global::)?System\./; + +function normalizeCsharpTypeArgument(name: string): string { + const named = name.trim(); + // A keyword answers immediately: `string` → `String`. + const aliased = CSHARP_PREDEFINED_TYPE_ALIASES.get(named); + if (aliased !== undefined) return aliased; + // Otherwise the `System.` qualifier is dropped so the fully-qualified + // spelling of a predefined type meets that keyword: `System.String` → + // `String` ≡ `string` → `String`. The optional `global::` alias qualifier goes + // with it — `import-decomposer` already unwraps that spelling elsewhere, and + // leaving it on would make `global::System.String` unequal to `string` and + // prune a live implementor. + // + // ONLY when what remains is a predefined type. `System.Custom` is an ordinary + // type that happens to live in `System`, and answering `Custom` for it would + // equate it with an unrelated `Custom` elsewhere in the workspace. Returned as + // written instead, which sends it to the identity comparison — the step that + // can actually tell two declarations apart. + const bare = named.replace(CSHARP_SYSTEM_QUALIFIER, ''); + return bare !== named && CSHARP_PREDEFINED_TYPE_NAMES.has(bare) ? bare : named; +} + export { csharpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 351eaee7d..a6c5ef773 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -1069,9 +1069,15 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void { for (let i = 0; i < superclass.namedChildCount; i++) { const c = superclass.namedChild(i); if (c !== null && c.type === 'type_identifier') { + // `extends Base` spells the arguments in a SIBLING node, so the + // anchor's own text cannot carry them; the sub-tag does (#2912). + const args = typeArgumentsAfter(superclass, i); out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', c), '@reference.name': nodeToCapture('@reference.name', c), + ...(args === null + ? {} + : { '@reference.type-arguments': nodeToCapture('@reference.type-arguments', args) }), }); break; } @@ -1144,7 +1150,26 @@ function emitHeritageMarkers( for (let i = 0; i < container.namedChildCount; i++) { const c = container.namedChild(i); if (c === null || c.type !== 'type_identifier') continue; - const payload = encodeMarker('heritage', [kind, c.text, className]); + // `implements Validator` / `with M`: the arguments ride the + // marker payload, because this heritage never becomes a reference SITE — + // `emitDartHeritageEdges` reads the marker and emits the edge (#2912). + // Dropped rather than encoded when the spelling contains the marker's own + // ':' delimiter, which `encodeMarker` rejects outright; absence is the + // fail-open value everywhere this is read. + const args = typeArgumentsAfter(container, i)?.text; + const fields = + args === undefined || args.includes(':') + ? [kind, c.text, className] + : [kind, c.text, className, args]; + const payload = encodeMarker('heritage', fields); out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) }); } } + +/** The `type_arguments` node written immediately after `container`'s named + * child at `index` — the arguments of the type that child names — or `null` + * when that type was written without any. */ +function typeArgumentsAfter(container: SyntaxNode, index: number): SyntaxNode | null { + const next = container.namedChild(index + 1); + return next !== null && next.type === 'type_arguments' ? next : null; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index 38496f2ec..fb93f5fb8 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -42,7 +42,15 @@ const DART_SCOPE_QUERY = ` (enum_declaration) @scope.class ; ── Declarations — types ───────────────────────────────────────────────────── -(class_definition name: (identifier) @declaration.name) @declaration.class +; The type-parameter list is matched as an UNNAMED optional child: the Dart +; grammar hangs \`type_parameters\` off \`class_definition\` without a field name. +; Recording it is what lets instantiation-aware interface dispatch tell a type +; VARIABLE (\`class Box implements Validator\`) from a concrete argument +; (\`class V implements Validator\`) — see #2912; absent parameters are +; indistinguishable from a language that captures none, and read as unknown. +(class_definition + name: (identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.class (mixin_declaration (identifier) @declaration.name) @declaration.trait (extension_declaration name: (identifier) @declaration.name) @declaration.class (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 22e1171d1..76bec5d74 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -38,6 +38,8 @@ import { generateId } from '../../../../lib/utils.js'; import { dartProvider } from '../dart.js'; import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { typeApplicationArguments } from '../../utils/template-arguments.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; interface ClassDefRef { @@ -77,6 +79,7 @@ function emitDartHeritageEdges( graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { const defsByName = new Map(); for (const parsed of parsedFiles) { @@ -110,10 +113,19 @@ function emitDartHeritageEdges( if (decoded?.kind !== 'heritage') continue; const parts = decoded.fields; if (parts.length < 3) continue; - const [kind, baseName, childName] = parts; + const [kind, baseName, childName, rawTypeArguments] = parts; const childId = pickClassByName(childName!, parsed.filePath, defsByName); const baseId = pickClassByName(baseName!, parsed.filePath, defsByName); if (childId === undefined || baseId === undefined || childId === baseId) continue; + // The instantiation this clause was written with — `implements + // Validator` (#2912). Recorded before the dedup below, since the + // FIRST writer wins on both sides and an edge deduped here still needs + // its arguments. A marker from a pre-#2912 cache has no fourth field, + // which reads as unknown. + if (rawTypeArguments !== undefined) { + const typeArguments = typeApplicationArguments(rawTypeArguments); + if (typeArguments !== undefined) recordTypeArguments?.(childId, baseId, typeArguments); + } const key = `${childId}->${baseId}:${kind}`; if (emitted.has(key)) continue; emitted.add(key); @@ -211,8 +223,8 @@ export const dartScopeResolver: ScopeResolver = { // `implements` / `with` IMPLEMENTS edges (extends rides the generic // inherits pre-pass; these need an explicit, kind-independent edge type). - emitHeritageEdges: (graph, parsedFiles, nodeLookup) => - emitDartHeritageEdges(graph, parsedFiles, nodeLookup), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, _scopes, recordTypeArguments) => + emitDartHeritageEdges(graph, parsedFiles, nodeLookup, recordTypeArguments), // Dart is statically typed — the field-fallback heuristic over-connects. fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 047a87791..317a853ac 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -23,14 +23,16 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { javaCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; -import { createMethodExtractor } from '../method-extractors/generic.js'; -import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createJavaCfgVisitor } from '../cfg/visitors/java.js'; import { assertCloneable } from '../workers/clone-safety.js'; import { collectJavaCaptureSideChannel } from './java/capture-side-channel.js'; import type { SymbolDefinition } from 'gitnexus-shared'; +import { + javaRecordMethodExtractor, + shouldSkipJavaRecordComponentDefinition, +} from './java/record-components.js'; import { emitJavaScopeCaptures, interpretJavaImport, @@ -186,7 +188,8 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), - methodExtractor: createMethodExtractor(javaMethodConfig), + methodExtractor: javaRecordMethodExtractor, + shouldSkipDefinitionCapture: shouldSkipJavaRecordComponentDefinition, variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index b85616602..73969670d 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -17,3 +17,17 @@ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { (filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath), ), }; + +/** Durable completeness contract for implicit Java record-component accessors. */ +export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.record-component-accessors', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), +}; + +/** Durable completeness contract for Java heritage captures. */ +export const JAVA_ENUM_INTERFACE_HERITAGE_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.heritage-captures', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), +}; diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index b71775534..47c694b37 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -50,6 +50,7 @@ import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, } from './spring-conditionals.js'; +import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -397,6 +398,7 @@ export function emitJavaScopeCaptures( ...synthesizeJavaInheritanceReferences(tree.rootNode), ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), ...synthesizeJavaAnonymousClassDeclarations(tree.rootNode), + ...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode), ...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS), ]; } @@ -424,6 +426,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', body), '@declaration.name': syntheticCapture('@declaration.name', body, identity.name), + '@declaration.is-synthetic': syntheticCapture('@declaration.is-synthetic', body, 'true'), }); // Inheritance: the anonymous class extends/implements its constructed @@ -485,6 +488,11 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', bodyNode), '@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name), + '@declaration.is-synthetic': syntheticCapture( + '@declaration.is-synthetic', + bodyNode, + 'true', + ), }); if (hostEnum !== undefined) { out.push({ @@ -636,12 +644,14 @@ function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { * `emitCppInheritanceCaptures`). * * Scope covers `class_declaration` (`superclass` extends + `interfaces` - * implements clauses), `record_declaration` (`interfaces` implements clauses), - * and `interface_declaration` (`extends_interfaces` clauses). Interface + * implements clauses), `record_declaration` and `enum_declaration` + * (`interfaces` implements clauses), and `interface_declaration` + * (`extends_interfaces` clauses). Interface * inheritance was restored for registry-primary resolution in #1951. Record * graph nodes became canonical link targets in #2801 / PR #2871, so their - * `implements` clauses must participate for interface dispatch (#2900). Java - * enum interface heritage remains a separately tracked gap (#2918). + * `implements` clauses must participate for interface dispatch (#2900). + * Enums use the same tree-sitter `interfaces` field and participate as + * class-like `Enum` graph nodes (#2918). * * Generic bases (`extends Box`, `implements IFoo`) and qualified bases * (`a.b.Base`, `a.b.Box`, `a.b.IFoo`) are normalized to their simple @@ -664,9 +674,13 @@ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { for (const base of superclass.namedChildren) emitJavaInheritanceBase(out, base); } } - if (node.type === 'class_declaration' || node.type === 'record_declaration') { - // Records cannot declare a superclass; they share only the class - // `interfaces` arm. + if ( + node.type === 'class_declaration' || + node.type === 'record_declaration' || + node.type === 'enum_declaration' + ) { + // Records and enums cannot declare a superclass; all three declarations + // expose implemented interfaces through the same tree-sitter field. const interfaces = node.childForFieldName('interfaces'); if (interfaces !== null) { for (const typeList of interfaces.namedChildren) { @@ -724,15 +738,22 @@ function javaBaseSimpleNameOf(typeNode: SyntaxNode): string | undefined { function javaBaseLookupNameNode(node: SyntaxNode): SyntaxNode | null { switch (node.type) { case 'type_identifier': - return node; - case 'scoped_type_identifier': + return node.isMissing || node.text.length === 0 ? null : node; + case 'scoped_type_identifier': { // `java.io.Serializable` → trailing `type_identifier` (`Serializable`). - return node.lastNamedChild; + const tail = node.lastNamedChild; + return tail === null ? null : javaBaseLookupNameNode(tail); + } case 'generic_type': { // `Box` → recurse into the base type (`Box`). const first = node.firstNamedChild; return first === null ? null : javaBaseLookupNameNode(first); } + case 'annotated_type': { + // The final named child is the base type; preceding children are annotations. + const type = node.lastNamedChild; + return type === null ? null : javaBaseLookupNameNode(type); + } default: return null; } diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 99c72dc09..507d3ce79 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -89,7 +89,13 @@ const JAVA_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — methods / constructors +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \` boolean runAny(Validator v)\` writes a receiver +;; whose argument is a type VARIABLE, and a pass that cannot tell that from a +;; concrete type prunes every implementor from the call's dispatch fan-out. (method_declaration + type_parameters: (type_parameters)? @declaration.type-parameters name: (identifier) @declaration.name) @declaration.method (constructor_declaration diff --git a/gitnexus/src/core/ingestion/languages/java/record-components.ts b/gitnexus/src/core/ingestion/languages/java/record-components.ts new file mode 100644 index 000000000..51053510c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/record-components.ts @@ -0,0 +1,233 @@ +import { SupportedLanguages, type CaptureMatch } from 'gitnexus-shared'; +import type { CaptureMap } from '../../language-provider.js'; +import { createMethodExtractor } from '../../method-extractors/generic.js'; +import { javaMethodConfig } from '../../method-extractors/configs/jvm.js'; +import { extractAnnotations } from '../../field-extractors/configs/helpers.js'; +import type { + ExtractedMethods, + MethodExtractor, + MethodExtractorContext, + MethodInfo, +} from '../../method-types.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const javaExplicitMethodExtractor = createMethodExtractor(javaMethodConfig); + +function recordComponents(recordNode: SyntaxNode): SyntaxNode[] { + const parameters = recordNode.childForFieldName('parameters'); + if (parameters === null) return []; + return parameters.namedChildren.filter( + (node): node is SyntaxNode => + node !== null && (node.type === 'formal_parameter' || node.type === 'spread_parameter'), + ); +} + +/** + * A record component is named by a real `identifier` and nothing else. + * + * Two node shapes reach this that are not one, and both would mint a graph node + * for source that does not compile: + * + * - `record M(int x, y) {}` — a dropped type. tree-sitter recovers by + * synthesizing `name: (MISSING identifier)`, a zero-width node whose text is + * `''`. It still satisfies the query's `name: (identifier)`, so testing the + * node TYPE alone does not reject it. + * - `record R(int _) {}` — the grammar declares both `formal_parameter.name` + * and `variable_declarator.name` as `identifier | underscore_pattern`, and + * `_` parses with no error at all. `_` is illegal as a component name, and + * admitting it here while the query rejects it is what let the structure and + * scope paths disagree. + * + * Same degenerate-node shape as `javaBaseLookupNameNode` in captures.ts (#2935). + */ +function isRecordComponentName(node: SyntaxNode | null | undefined): node is SyntaxNode { + return ( + node !== null && + node !== undefined && + node.type === 'identifier' && + !node.isMissing && + node.text.length > 0 + ); +} + +function recordComponentNameNode(component: SyntaxNode): SyntaxNode | null { + const name = + component.type === 'formal_parameter' + ? component.childForFieldName('name') + : (component.namedChildren + .find((node) => node?.type === 'variable_declarator') + ?.childForFieldName('name') ?? null); + return isRecordComponentName(name) ? name : null; +} + +/** + * Memoised per record node. `shouldSkipJavaRecordComponentDefinition` is called + * once per component capture, so recomputing this would rescan the whole record + * body per component — O(components x body members) for a single record. The + * scope-capture path hoists the call out of its own loop instead; this cache is + * what gives the structure path the same cost. Keyed weakly on the AST node, so + * it drops with the tree at the end of the file's parse. + */ +const explicitZeroArgAccessorNamesCache = new WeakMap>(); + +function explicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const memoized = explicitZeroArgAccessorNamesCache.get(recordNode); + if (memoized !== undefined) return memoized; + const names = computeExplicitZeroArgAccessorNames(recordNode); + explicitZeroArgAccessorNamesCache.set(recordNode, names); + return names; +} + +function computeExplicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const names = new Set(); + const body = recordNode.childForFieldName('body'); + if (body === null) return names; + + for (const node of body.namedChildren) { + if (node === null || node.type !== 'method_declaration') continue; + const name = node.childForFieldName('name')?.text; + const parameters = node.childForFieldName('parameters'); + const parameterCount = + parameters?.namedChildren.filter( + (parameter) => + parameter !== null && + (parameter.type === 'formal_parameter' || parameter.type === 'spread_parameter'), + ).length ?? 0; + if (name !== undefined && parameterCount === 0) names.add(name); + } + return names; +} + +function recordComponentReturnType(component: SyntaxNode): string | null { + const typeNode = + component.childForFieldName('type') ?? + (component.type === 'spread_parameter' + ? component.namedChildren.find( + (node) => node?.type !== 'modifiers' && node?.type !== 'variable_declarator', + ) + : undefined); + const type = typeNode?.text; + if (type === undefined) return null; + return component.type === 'spread_parameter' ? `${type}[]` : type; +} + +function implicitAccessorInfo( + component: SyntaxNode, + context: MethodExtractorContext, +): MethodInfo | null { + const name = recordComponentNameNode(component)?.text; + if (name === undefined) return null; + + return { + name, + receiverType: null, + returnType: recordComponentReturnType(component), + parameters: [], + visibility: 'public', + isStatic: false, + isAbstract: false, + isFinal: false, + // JLS 8.10.3 / 9.7.4: a component annotation reaches the generated accessor + // when its @Target admits METHOD (or TYPE_USE, in the return-type position). + // ponytail: over-approximate — we propagate every component annotation, + // because @Target lives in another file and parsing is per-file, so the + // target set is not knowable here. Nothing reads Method annotations today: + // `annotations` is not a column in METHOD_SCHEMA/FUNCTION_SCHEMA + // (src/core/lbug/schema.ts), so it lives only in the in-memory graph for one + // analyze run, and the sole in-memory reader (springDiFieldMatcher) is gated + // to `Property` nodes. If that column is ever added, revisit this: the set + // would then become an agent-visible claim that may over-state the target. + annotations: extractAnnotations(component, 'modifiers'), + sourceFile: context.filePath, + line: component.startPosition.row + 1, + column: component.startPosition.column, + }; +} + +/** Java records synthesize one public, zero-argument accessor per component. */ +export const javaRecordMethodExtractor: MethodExtractor = { + ...javaExplicitMethodExtractor, + language: SupportedLanguages.Java, + extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null { + const extracted = javaExplicitMethodExtractor.extract(node, context); + if (extracted === null || node.type !== 'record_declaration') return extracted; + + const explicitAccessors = explicitZeroArgAccessorNames(node); + const implicitAccessors = recordComponents(node) + .filter((component) => { + const name = recordComponentNameNode(component)?.text; + return name !== undefined && !explicitAccessors.has(name); + }) + .map((component) => implicitAccessorInfo(component, context)) + .filter((method): method is MethodInfo => method !== null); + + return { ...extracted, methods: [...extracted.methods, ...implicitAccessors] }; + }, +}; + +/** Scope declarations matching the structure-phase synthetic accessor nodes. */ +export function synthesizeJavaRecordComponentAccessorCaptures( + rootNode: SyntaxNode, +): CaptureMatch[] { + const captures: CaptureMatch[] = []; + for (const recordNode of rootNode.descendantsOfType('record_declaration')) { + const explicitAccessors = explicitZeroArgAccessorNames(recordNode); + for (const component of recordComponents(recordNode)) { + const nameNode = recordComponentNameNode(component); + const returnType = recordComponentReturnType(component); + if (nameNode === null || returnType === null || explicitAccessors.has(nameNode.text)) + continue; + + captures.push({ + '@scope.function': nodeToCapture('@scope.function', component), + }); + captures.push({ + '@declaration.method': nodeToCapture('@declaration.method', component), + '@declaration.name': nodeToCapture('@declaration.name', nameNode), + '@declaration.parameter-count': syntheticCapture( + '@declaration.parameter-count', + component, + '0', + ), + '@declaration.required-parameter-count': syntheticCapture( + '@declaration.required-parameter-count', + component, + '0', + ), + '@declaration.return-type': syntheticCapture( + '@declaration.return-type', + component, + returnType, + ), + }); + } + } + return captures; +} + +/** + * The structure query sees every record component. Suppress that synthetic + * definition when the record body provides the canonical zero-argument + * accessor explicitly, leaving the explicit method as the single authority. + */ +export function shouldSkipJavaRecordComponentDefinition(captureMap: CaptureMap): boolean { + const component = captureMap['definition.method']; + if (component?.type !== 'formal_parameter' && component?.type !== 'spread_parameter') { + return false; + } + + const parameters = component.parent; + const recordNode = parameters?.parent; + if (parameters?.type !== 'formal_parameters' || recordNode?.type !== 'record_declaration') { + return false; + } + + // Same predicate the scope path applies, so the two can never disagree about + // which components have an accessor. The query's `name: (identifier)` is + // satisfied by tree-sitter's zero-width MISSING recovery token, so the + // structure path has to re-check what the query cannot express. + const nameNode = captureMap['name']; + if (!isRecordComponentName(nameNode)) return true; + + return explicitZeroArgAccessorNames(recordNode).has(nameNode.text); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index f442a2b37..94dadc59e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -121,7 +121,13 @@ const KOTLIN_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — functions / methods / properties +;; +;; A generic FUNCTION's parameters are read for the same reason a generic type's +;; are (#2912 review): \`fun runAny(v: Validator)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor from the call's dispatch fan-out. (function_declaration + (type_parameters)? @declaration.type-parameters (simple_identifier) @declaration.name) @declaration.function ;; Lambda bound to a val/var: val handler = { x: Int -> target(x) } diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index ae15c99e2..d6685dde3 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -1,5 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findChild, nodeIfType, nodeToCapture, syntheticCapture, @@ -252,10 +253,22 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const traitName = bareTypeIdentifier(traitField); const structName = bareTypeIdentifier(typeField); if (traitName === null || structName === null) return; + // The trait's generic ARGUMENTS (`impl Validator for V`), so + // interface dispatch can tell one instantiation of a trait from another + // (#2912). Emitted as a sub-tag rather than by widening the anchor: the + // anchor is the bare `type_identifier` inside the `generic_type`, and its + // range is part of the inheritance edge's id. + const traitArguments = + traitField.type === 'generic_type' ? findChild(traitField, 'type_arguments') : null; out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', traitName), '@reference.name': nodeToCapture('@reference.name', traitName), '@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text), + ...(traitArguments === null + ? {} + : { + '@reference.type-arguments': nodeToCapture('@reference.type-arguments', traitArguments), + }), }); }); return out; diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 5fd0f1570..bea53d9fd 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -16,6 +16,7 @@ import { 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 { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; @@ -54,6 +55,7 @@ function emitRustTraitImplEdges( parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes: ScopeResolutionIndexes | undefined, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { if (scopes === undefined) return; @@ -83,6 +85,14 @@ function emitRustTraitImplEdges( const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup); if (structGraphId === undefined || traitGraphId === undefined) continue; + // The instantiation the impl was written with — `impl Validator + // for V` (#2912). Recorded against THIS edge's ids, not the pre-pass's: + // the pre-pass sources its edge from the enclosing def, and interface + // dispatch crosses the corrected one emitted here. + if (site.typeArguments !== undefined) { + recordTypeArguments?.(structGraphId, traitGraphId, site.typeArguments); + } + const edgeKey = `${structGraphId}->${traitGraphId}`; if (emitted.has(edgeKey)) continue; emitted.add(edgeKey); @@ -159,8 +169,8 @@ export const rustScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), - emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => - emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) => + emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes, recordTypeArguments), populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts index 4e87ddd89..c6550050f 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts @@ -316,6 +316,7 @@ export const csharpMethodConfig: MethodExtractionConfig = { annotations: [], // C# has no syntax for attributes on primary constructors sourceFile: context.filePath, line: paramList.startPosition.row + 1, + column: paramList.startPosition.column, }; }, }; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index ecb25d939..e1a10f825 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -256,5 +256,6 @@ function buildMethod( annotations: config.extractAnnotations?.(node) ?? [], sourceFile: context.filePath, line: node.startPosition.row + 1, + column: node.startPosition.column, }; } diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index d263fd5db..05659db02 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -37,6 +37,22 @@ export interface MethodInfo { annotations: string[]; sourceFile: string; line: number; + /** + * 0-based `startPosition.column` of the node `line` was derived from. + * + * `line` alone does NOT identify a callable. A callable that is SYNTHESIZED + * at a position that is not its own declaration shares its owner's line: a + * Java record's implicit component accessor is minted at the COMPONENT, and a + * C# 12 primary constructor at the owner's `parameter_list`. So both + * `record P(int x, int y) { int x(int s) {…} }` and + * `class Point(int x, int y) { public Point(int x) : this(x, 0) {} }` give two + * different callables the same (name, line) (#2936). + * + * Required, not optional: the per-class map in parse-worker keys on it, and + * an absent column would key an entry no lookup could ever reach — a silent, + * whole-language loss of method enrichment rather than a compile error. + */ + column: number; } export interface MethodExtractorContext { diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index fb9f07697..39b8667ae 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -80,6 +80,7 @@ import type { CallableFlowOperand, CallableFlowPassingMode, CallableFlowSite, + Capture, CaptureMatch, ImportEdge, ParameterTypeClass, @@ -97,7 +98,11 @@ import type { import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; import { isValidReceiverChain } from './utils/receiver-chain-codec.js'; -import { extractTemplateArguments } from './utils/template-arguments.js'; +import { + extractTemplateArguments, + stripTrailingCallSuffix, + typeApplicationArguments, +} from './utils/template-arguments.js'; import { parseTypeParameterList } from './utils/type-parameters.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -701,6 +706,7 @@ function buildDefFromDeclarationMatch( const typeParameters = parseTypeParameterList(match['@declaration.type-parameters']?.text ?? ''); const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); + const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -718,6 +724,7 @@ function buildDefFromDeclarationMatch( ...(templateConstraints !== undefined ? { templateConstraints } : {}), ...(isExplicit === true ? { isExplicit: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}), + ...(isSynthetic === true ? { isSynthetic: true } : {}), }; } @@ -1253,6 +1260,12 @@ function pass5CollectReferences( // sibling via the full-path QualifiedNameIndex before the simple-tail walk // (#1982). Absent for unqualified references — resolution stays unchanged. const qualifiedCap = match['@reference.qualified-name']; + // Generic ARGUMENTS written on a heritage reference (`: IValidator`); + // `inherits` only, because a call/read/write anchor spans the whole call + // expression, whose `<…>` would be an argument list, a comparison, or + // nothing at all — widening the kind would mint confident nonsense (#2912). + const typeArguments = + kind === 'inherits' ? heritageTypeArguments(match, anchor, nameCap) : undefined; const inScopeId = positionIndex.atPosition( filePath, anchor.range.startLine, @@ -1304,6 +1317,7 @@ function pass5CollectReferences( ...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0 ? { rawQualifiedName: qualifiedCap.text } : {}), + ...(typeArguments !== undefined ? { typeArguments } : {}), ...(propertyKeyCap?.text !== undefined && propertyKeyCap.text.length > 0 ? { propertyKey: propertyKeyCap.text } : {}), @@ -1320,6 +1334,60 @@ function pass5CollectReferences( } } +/** + * The generic arguments a heritage reference was written with, by whichever of + * the two routes this emitter uses (#2912). + * + * `@reference.type-arguments` is the explicit route, for an emitter whose anchor + * is the bare NAME node (Rust's `impl Trait for S` anchors on the trait + * identifier inside a `generic_type`). It wins where present: moving such an + * anchor to cover the arguments would change the site's range, and that range is + * part of every inheritance EDGE ID — a spelling detail must not renumber the + * graph. Every other emitter already anchors on the whole base, so its spelling + * is read directly and no query changed. + */ +function heritageTypeArguments( + match: CaptureMatch, + anchor: Capture, + nameCap: Capture, +): readonly string[] | undefined { + const explicit = match['@reference.type-arguments']?.text; + return explicit !== undefined + ? typeApplicationArguments(explicit) + : referenceTypeArguments(anchor.text, nameCap.text); +} + +/** + * Type arguments written on a heritage reference, read from the anchor's own + * spelling — `IValidator` → `['string']` (#2912). + * + * Two shapes are handled before the spelling is read as an application: + * + * - A trailing CONSTRUCTOR INVOCATION is dropped. `record R : Base(x)` + * and Kotlin `class C : Bar()` write a call in the heritage position; + * the call is not part of the type, and leaving it attached would make the + * list fail to close at the end and lose the arguments entirely. + * - The application's base must BE the referenced name (`Other::Inner` + * ends with `Inner`). An anchor that spans more than the base type is not + * read at all rather than read wrongly. + * + * `undefined` for a non-generic base and for every spelling that is not exactly + * one balanced argument list — absence is the "unknown" value that consumers + * fail open on, so declining is always safe here. + */ +function referenceTypeArguments( + anchorText: string, + baseName: string, +): readonly string[] | undefined { + const text = stripTrailingCallSuffix(anchorText.trim()); + const opener = text.search(OPENING_BRACKET); + if (opener === -1) return undefined; + if (!text.slice(0, opener).trimEnd().endsWith(baseName)) return undefined; + return typeApplicationArguments(text); +} + +const OPENING_BRACKET = /[<[]/; + function referenceKindFromAnchor(name: string): ReferenceKind | undefined { const suffix = name.slice('@reference.'.length); // Strip sub-tag after the kind (`@reference.call.member` → `call`). @@ -1718,6 +1786,10 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.qualified-name', + // The generic arguments a heritage base was written with, when the emitter's + // anchor is the bare name and cannot carry them (#2912). A sub-tag for the + // usual reason: it spans a sibling node of the anchor, never the site itself. + '@reference.type-arguments', '@reference.property-key', '@reference.callee-position', '@reference.embedded-pointer', 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 1e871cc8e..016aa1a29 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -297,6 +297,7 @@ import { LanguageProvider } from '../../language-provider.js'; import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ConversionRankFn } from '../passes/overload-narrowing.js'; +import type { HeritageTypeArgumentSink } from '../utils/generic-instantiation.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; /** A LinearizeStrategy receives the full ancestor map so C3-style @@ -586,6 +587,16 @@ export interface ScopeResolver { * shape. Must be idempotent (the orchestrator may call it more than once * during re-resolution). * + * `recordTypeArguments` is the same sink `preEmitInheritanceEdges` writes to: + * the generic INSTANTIATION a heritage clause was written with, so + * interface-dispatch fan-out can refuse an implementor of an incompatible one + * (#2912). An implementation that emits an edge for a generic base + * (`impl Validator for V`, `class V implements Validator`) + * should call it with the same (source, target) graph ids it just used; + * anything not recorded reads as "unknown" and keeps the pre-#2912 fan-out. + * Ignoring it entirely is correct for a language whose heritage carries no + * type arguments (Ruby `include`). + * * Default: undefined (no extra heritage edges needed). */ readonly emitHeritageEdges?: ( @@ -593,6 +604,7 @@ export interface ScopeResolver { parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes?: ScopeResolutionIndexes, + recordTypeArguments?: HeritageTypeArgumentSink, ) => void; /** @@ -1006,6 +1018,25 @@ export interface ScopeResolver { */ readonly isStaticOnly?: (def: SymbolDefinition) => boolean; + /** + * Optional canonicalizer for a written GENERIC TYPE ARGUMENT, so two + * spellings of one type compare equal during interface-dispatch + * instantiation matching (#2912). + * + * The case it exists for is a language with predefined ALIASES: C# `string` + * and `String` are the same type, so `IValidator` must still fan out + * to `class V : IValidator`. Without the hook the two spellings look + * like two instantiations and the implementor is pruned — a missing edge, + * which is the failure direction #2912 is most concerned to avoid. + * + * Called ONLY on the two sides of one argument comparison, never on a name + * used for lookup, so it may map to whatever canonical form the language + * prefers (`string` → `String`, or the reverse) as long as it is consistent. + * Languages whose types have one spelling each leave it undefined and the + * comparison stays exact. + */ + readonly normalizeTypeArgument?: (name: string) => string; + /** * Optional predicate to gate free-call fallback emission by caller-side * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 5a82ec455..66453dc3e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -24,7 +24,11 @@ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; -import { erasedTypeApplication, stripTemplateArguments } from '../../utils/template-arguments.js'; +import { + erasedTypeApplication, + matchingOpenParen, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecorationStripper } from '../scope/walkers.js'; @@ -75,7 +79,22 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } return { tupleIdx: Number(idxStr), rhs }; } +/** + * Notified with the spelling a receiver position was typed from and the class + * it resolved to — see {@link noteReceiverType}. Pure side channel: this file + * never reads it back, and resolution is identical whether or not it is set. + */ +type ReceiverTypeRecorder = (spelling: string, defId: string) => void; + interface ResolveCompoundReceiverOptions { + /** + * Optional sink for the DECLARED TYPE SPELLINGS this fold typed receiver + * positions from (#2912). The fold returns a class, and a class has lost the + * generic arguments that decide which implementations an interface-typed + * receiver can dispatch to; the caller keeps the last report whose def id + * matches the returned class and reads the arguments off that spelling. + */ + readonly recordReceiverType?: ReceiverTypeRecorder; /** When true (default), if method lookup fails on the receiver's * class, walk its fields and try the lookup on each field's class. * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ @@ -348,17 +367,65 @@ function classOfDeclaredType( typeRef: TypeRef, scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, + recordReceiverType?: ReceiverTypeRecorder, ): SymbolDefinition | undefined { // `declaredAtScope`, never a scope the caller chose: all five sites passed // exactly this `TypeRef`'s own anchor, and taking it as a parameter is what // would let a sixth quietly not — which is the hole this helper exists to // close, one level up. - return resolveClassBindingForName( + const spelling = erasedTypeApplication(typeRef) ?? typeRef.rawName; + const def = resolveClassBindingForName( typeRef.declaredAtScope, - erasedTypeApplication(typeRef) ?? typeRef.rawName, + spelling, scopes, stripDecoration, ); + return noteReceiverType(recordReceiverType, spelling, def); +} + +/** + * Report the SPELLING a receiver position was typed from, alongside the class + * it resolved to (#2912). + * + * The fold answers "which class", which is all dispatch needed until generic + * instantiation mattered: `IValidator` and `IValidator` fold to + * the same declaration. The spelling is the only place the arguments survive, + * and it exists at every one of these lookups already — reporting it costs a + * function call and changes no resolution. + * + * Pairing it with the def id is what makes it usable: the caller keeps the LAST + * report and uses it only if it names the class the fold ultimately returned, + * so a route that typed an intermediate position, or a later route that + * answered differently, cannot lend its arguments to another class. + */ +function noteReceiverType( + record: ReceiverTypeRecorder | undefined, + spelling: string, + def: SymbolDefinition | undefined, +): SymbolDefinition | undefined { + if (def !== undefined) record?.(spelling, def.nodeId); + return def; +} + +/** + * The class a CALL's return type names, reported to the receiver-type side + * channel — the return-type twin of {@link classOfDeclaredType}. + * + * The pairing it exists to keep in one place: the lookup goes through + * `rawName`, while the SPELLING reported alongside it is the erased type + * application, so an `IValidator` return is reported with its + * arguments intact. The spelling is built only once the lookup has actually + * found a class, because it is discarded otherwise — and every fold hop + * through a call reaches this, generic or not. + */ +function classOfReturnType( + retType: TypeRef, + scopes: ScopeResolutionIndexes, + record: ReceiverTypeRecorder | undefined, +): SymbolDefinition | undefined { + const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + if (def === undefined || record === undefined) return def; + return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); } function typeOfMemberOnClass( @@ -374,7 +441,12 @@ function typeOfMemberOnClass( const classScope = classScopeByDefId.get(ownerId); const memberType = classScope?.typeBindings.get(memberName); if (memberType !== undefined) { - const def = classOfDeclaredType(memberType, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + memberType, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // The declared type is reported even when it resolved to no class: // `Promise` and `[]Repo` name nothing in the workspace, and an // await or index step unwrapping them is exactly how they become @@ -404,7 +476,12 @@ function typeOfMemberOnClass( // Same stripper the primary branch above passes. Omitting it here // meant a decorated declared type (`*Host`) resolved on one branch and // not the other, for the same member of the same class. - const def = classOfDeclaredType(hoisted, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + hoisted, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // Identical to the primary branch: a declared type that named no // class is still a usable position when the next step unwraps it. // Returning `undefined` here made `svc.getMap()['k'].run()` decline @@ -569,9 +646,68 @@ export function foldReceiverChain( } // A chain that ended without a class returns undefined naturally — no // separate guard, because `def` IS the signal. + // + // The receiver-type report is made HERE, from the final `FoldState`, because + // that record pairs the class with the spelling that produced it BY + // CONSTRUCTION — same step, same lookup. The individual `classOfDeclaredType` + // calls inside the fold also report, including from steps that were later + // folded past, so the last of those is not reliably about the class the fold + // returns. Reporting the final state last makes it the one that stands. + if (current.def !== undefined && current.declaredType !== undefined) { + options.recordReceiverType?.(current.declaredType, current.def.nodeId); + } return current.def; } +/** A resolved compound receiver, together with the declared spelling that typed + * the position it came from — see {@link resolveCompoundReceiverTyped}. */ +export interface TypedCompoundReceiver { + readonly def: SymbolDefinition; + /** + * The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the route that answered had no declared type to report — a + * construction expression, a namespace target, a static class receiver. The + * fan-out reads its generic arguments off this and restores the unfiltered + * behaviour when it is absent, so declining is always safe (#2912). + */ + readonly declaredSpelling: string | undefined; +} + +/** + * {@link resolveCompoundReceiverClass}, paired with the spelling that typed the + * position (#2912). + * + * The sink is created and read HERE, per call, which is the whole point: a + * recorder that outlives one resolution has to be reset by hand before every + * call, and the retry shapes in this pass make two calls in a row — a reset + * missed at one of them silently attributes the previous receiver's spelling to + * this one. A local cannot be forgotten. + * + * The def-id guard is the second half. Lookups that lost — an MRO walk that + * moved on, a fold step later folded past — report too, so a report counts only + * when it names the class actually returned. `foldReceiverChain` reports its + * final state last for exactly this reason, so the structural route wins. + */ +export function resolveCompoundReceiverTyped( + receiverText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, + options: ResolveCompoundReceiverOptions = {}, +): TypedCompoundReceiver | undefined { + let spelling: string | undefined; + let spellingDefId: string | undefined; + const def = resolveCompoundReceiverClass(receiverText, inScope, scopes, index, { + ...options, + recordReceiverType: (reported, defId) => { + spelling = reported; + spellingDefId = defId; + }, + }); + if (def === undefined) return undefined; + return { def, declaredSpelling: spellingDefId === def.nodeId ? spelling : undefined }; +} + export function resolveCompoundReceiverClass( receiverText: string, inScope: ScopeId, @@ -676,7 +812,12 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } - const viaTb = classOfDeclaredType(tb, scopes, options.stripTypePreservingDecoration); + const viaTb = classOfDeclaredType( + tb, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); if (viaTb !== undefined) return viaTb; // Member-alias / call-result shapes store the RHS path on rawName @@ -769,7 +910,7 @@ export function resolveCompoundReceiverClass( const viaReturn = retType === undefined ? undefined - : findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + : classOfReturnType(retType, scopes, options.recordReceiverType); if (viaReturn !== undefined) return viaReturn; } // Inline construction — `Service(db).m()` / `new Service(db).m()`. @@ -891,7 +1032,7 @@ export function resolveCompoundReceiverClass( } if (retType === undefined) return undefined; - return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + return classOfReturnType(retType, scopes, options.recordReceiverType); } // Mixed dotted + call chain: `obj.field.method().field.method()…`. @@ -967,7 +1108,7 @@ export function resolveCompoundReceiverClass( // two had a fixture. See `classOfDeclaredType` for why this cannot change a // `TypeRef` that was never reduced. let currentClass: SymbolDefinition | undefined = headType - ? classOfDeclaredType(headType, scopes) + ? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType) : findClassBindingInScope(inScope, headMemberName, scopes); // Whether the walk currently sits on the CLASS ITSELF rather than on a // value of that class. Seeded true only when the head resolved straight to @@ -1097,7 +1238,7 @@ export function resolveCompoundReceiverClass( // grounds fell through here — a declined fold is documented as "no answer", // never a veto — and this walk re-minted `other.py:Mapped` from the // workspace index. Same rule, same lookup, so the two routes now agree. - let nextClass = classOfDeclaredType(memberType, scopes); + let nextClass = classOfDeclaredType(memberType, scopes, undefined, options.recordReceiverType); if (nextClass === undefined) { const fromMap = unwrapMapValueToClass(memberType, scopes); if (fromMap !== undefined) nextClass = fromMap; @@ -1167,22 +1308,6 @@ function isInitializerContext(startScope: ScopeId, scopes: ScopeResolutionIndexe return false; } -/** Find the index of the `(` that matches the trailing `)` of a - * call-expression text. Returns -1 if unbalanced. */ -function matchingOpenParen(text: string): number { - if (!text.endsWith(')')) return -1; - let depth = 0; - for (let i = text.length - 1; i >= 0; i--) { - const ch = text[i]; - if (ch === ')') depth++; - else if (ch === '(') { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - /** Max peel iterations for `stripCastWrappers`. Real cast nesting — * including decompiler output like `((Target)((Object)expr))` — * is a handful of levels, and each cast level costs at most two diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index d6ea81b3c..0000b2451 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -68,6 +68,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { collectNamespaceTargets } from '../scope/namespace-targets.js'; import { + bindsTypeParameter, findClassBindingInScope, findEnclosingClassDef, isReceiverOwnedButUnbound, @@ -86,8 +87,17 @@ import { type CalleeIdCaptureCtx, } from '../graph-bridge/edges.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; -import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; -import { erasedTypeApplication } from '../../utils/template-arguments.js'; +import { + resolveCompoundReceiverClass, + resolveCompoundReceiverTyped, +} from '../passes/compound-receiver.js'; +import { erasedTypeApplication, typeApplicationArguments } from '../../utils/template-arguments.js'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type GroundedTypeArgument, + type HeritageTypeArguments, +} from '../utils/generic-instantiation.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, @@ -124,6 +134,7 @@ type ReceiverBoundProviderSubset = Pick< | 'conversionOnlyArgTypePrefixes' | 'constraintCompatibility' | 'isStaticOnly' + | 'normalizeTypeArgument' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -298,6 +309,14 @@ export function emitReceiverBoundCalls( * degrades a drop's label to `unknown` (the safe direction) and changes no * edge. */ readonly isBuiltInName?: (name: string) => boolean; + /** The generic arguments each heritage clause instantiated its base with, + * from the passes that emitted those heritage edges — the inheritance + * pre-pass, and the language resolvers that emit their own (Rust `impl T + * for S`, Dart `implements` / `with`) (#2912). Read + * ONLY by the interface-dispatch fan-out, to refuse an implementor of an + * incompatible instantiation. Absent ⇒ every heritage instantiation reads + * as unknown ⇒ the pre-#2912 fan-out, unchanged. */ + readonly heritageTypeArguments?: HeritageTypeArguments; } = {}, ): ReceiverBoundResult { let emitted = 0; @@ -331,6 +350,26 @@ export function emitReceiverBoundCalls( // DefIds, and `pickOverload` keys member lookup by those DefIds. Preserving // every part makes dispatch independent of declaration order. const graphIdToClassDefs = new Map(); + // The same correspondence read the other way, so the dispatch walk can name a + // heritage EDGE (which is keyed by graph ids) from the two DEFS it holds. + const classGraphIdByDefId = new Map(); + /** + * Does THIS language record generic type parameters (#2912)? + * + * `SymbolDefinition.typeParameters` is absent both for a non-generic + * declaration and for every declaration in a language whose captures do not + * emit `@declaration.type-parameters`, and instantiation filtering needs the + * two told apart: in the second case a heritage argument `T` is a type + * VARIABLE that would otherwise read as a concrete type named "T", and + * `class Box : IValidator` would be pruned out of every instantiation. + * + * Evidence rather than a declared capability, because the evidence is exactly + * as good and costs nothing: one run resolves one language (`phase.ts` loops + * per language), so a single generic declaration anywhere in it proves the + * captures record parameters. A run where none exists cannot be harmed by the + * answer — with no generic declaration there is no type variable to mistake. + */ + let languageCapturesTypeParameters = false; for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (!isClassLike(def.type)) continue; @@ -342,6 +381,10 @@ export function emitReceiverBoundCalls( graphIdToClassDefs.set(graphId, defs); } defs.push(def); + classGraphIdByDefId.set(def.nodeId, graphId); + if (def.typeParameters !== undefined && def.typeParameters.length > 0) { + languageCapturesTypeParameters = true; + } } } // Direct subtypes of a type, keyed by the SUPERtype's def id. @@ -433,6 +476,34 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isStatic === true; }; + /** + * What does this written type argument NAME, as seen from `scopeId` (#2912)? + * + * The scope is load-bearing: a heritage argument is resolved from the + * declaring class's own scope and a receiver argument from the call site's, + * because a name means what it means where it was WRITTEN. Resolving both + * makes `Models.User` and an imported `User` one type, which a string + * comparison could only get wrong. + * + * Neither answer is an error: a name that binds nothing and is not built in + * comes back ungrounded, which the matcher reads as "unknown" and keeps. + * + * A TYPE PARAMETER is reported as such rather than left to the ungrounded + * path, because `resolveClassBindingForName` answers a bounded one with its + * BOUND's declaration — grounded, and the wrong thing to compare. + */ + const groundTypeArgument = (name: string, scopeId: string | undefined): GroundedTypeArgument => { + const def = + scopeId === undefined ? undefined : resolveClassBindingForName(scopeId, name, scopes); + return { + ...(def !== undefined ? { definitionId: def.nodeId } : {}), + builtIn: options.isBuiltInName?.(name) === true, + ...(scopeId !== undefined && bindsTypeParameter(scopeId, name, scopes) + ? { typeVariable: true } + : {}), + }; + }; + /** * Emit secondary CALLS edges with reason='interface-dispatch' when the primary * receiver-typed edge targeted an Interface's method. @@ -454,6 +525,17 @@ export function emitReceiverBoundCalls( * override further down is an equally real runtime target — dispatch is an * over-approximation by design, and stopping early would silently prefer the * base. + * + * The closure is walked carrying the receiver's generic INSTANTIATION (#2912). + * `IValidator` and `IValidator` are one declaration and therefore + * one subtype list, so without the substitution a `IValidator` call + * reaches `IntValidator.Check(int)` — a target no dispatch can produce. Each + * hop unifies the arguments the subtype wrote against the ones the supertype + * is known to hold; an incompatible hop is skipped BEFORE the visit is + * recorded, so a type reachable by a second, compatible path still gets its + * edge, and skipped WITHOUT descending, because its own subtypes inherit the + * mismatch. + * Every uncertainty keeps the target — see `generic-instantiation.ts`. */ const emitInterfaceDispatchFor = ( ownerDef: SymbolDefinition, @@ -462,40 +544,159 @@ export function emitReceiverBoundCalls( site: ParsedFile['referenceSites'][number], confidence: number, calleeCapture: CalleeIdCaptureCtx | undefined, + /** The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the case could not recover it — which restores the + * unfiltered fan-out for that site rather than guessing. + * + * The SPELLING rather than the parsed arguments, so the parse happens after + * the two gates below rather than at every resolved receiver site: all five + * cases call this unconditionally, and the overwhelming majority of + * receivers are concrete classes that return at the first line. */ + receiverTypeSpelling: string | undefined, ): number => { if (ownerDef.type !== 'Interface') return 0; if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; + const receiverTypeArguments = + receiverTypeSpelling === undefined + ? undefined + : typeApplicationArguments(receiverTypeSpelling); + // Captures only `site`, so it is built once per SITE rather than once per + // subtype visited. Its partner below cannot be: it is keyed by the subtype. + const resolveSupertypeArgument = (name: string): GroundedTypeArgument => + groundTypeArgument(name, site.inScope); // Collect concrete targets across the closure first, so the cap below counts - // real dispatch targets rather than types visited. - const targets: SymbolDefinition[] = []; - const seenTypes = new Set([ownerDef.nodeId]); - const queue: string[] = [ownerDef.nodeId]; - while (queue.length > 0) { - const superId = queue.shift() as string; - for (const subDef of subtypesBySupertypeDefId.get(superId) ?? []) { - if (seenTypes.has(subDef.nodeId)) continue; - seenTypes.add(subDef.nodeId); - queue.push(subDef.nodeId); - const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + // real dispatch targets rather than types visited. Source-written owners + // rank ahead of synthesized owners so a large anonymous implementation + // family cannot consume the whole budget. Within each group, the priority + // counts concrete implementations already encountered on the path: the + // first implementation under an abstract branch ranks ahead of deeper + // overrides. Carrying that count through this existing walk avoids a reverse + // traversal per target at every call site. + type DispatchTarget = { + readonly member: SymbolDefinition; + readonly syntheticOwnerPriority: number; + readonly ancestorImplementationCount: number; + readonly discoveryOrder: number; + }; + type DispatchTraversal = { + readonly typeId: string; + readonly ancestorImplementationCount: number; + /** The instantiation this type is known to hold ON THIS PATH (#2912), or + * `undefined` where it is not known — which restores the unfiltered + * fan-out for the subtree below it rather than guessing. */ + readonly typeArguments: readonly string[] | undefined; + }; + const targetByMemberId = new Map(); + const bestIncomingCount = new Map([[ownerDef.nodeId, 0]]); + const queue: DispatchTraversal[] = [ + { + typeId: ownerDef.nodeId, + ancestorImplementationCount: 0, + typeArguments: receiverTypeArguments, + }, + ]; + let head = 0; + let discoveryOrder = 0; + while (head < queue.length) { + const current = queue[head++]!; + // The whole instantiation apparatus hangs off ONE question: is the + // supertype's own instantiation known? It is not for a non-generic + // receiver, nor for any language that captures no heritage arguments, so + // those walks skip every lookup below and emit exactly what they did + // before #2912. + const superGraphId = + current.typeArguments === undefined ? undefined : classGraphIdByDefId.get(current.typeId); + for (const subDef of subtypesBySupertypeDefId.get(current.typeId) ?? []) { + const previousIncomingCount = bestIncomingCount.get(subDef.nodeId); if ( - implMember === undefined || - implMember === OVERLOAD_AMBIGUOUS || - implMember.isDeleted === true + previousIncomingCount !== undefined && + previousIncomingCount <= current.ancestorImplementationCount ) { continue; } - if (implMember.nodeId === primaryMemberDef.nodeId) continue; - // A re-declared interface method or an `abstract` override is not an - // implementation — keep descending past it rather than emitting to it. - if (isDeclarationOnly(implMember)) continue; - // Nor is a static member: no instance-typed receiver can reach one, so - // an edge to it is a target dispatch cannot produce (#2842 review). - if (isUnreachableByInstanceDispatch(implMember)) continue; - targets.push(implMember); + + // What THIS heritage clause instantiated its base with. `superGraphId` + // already answers "is the supertype's instantiation known?", so it gates + // the whole lookup once instead of being re-asked at each step below. + let subtypeArguments: readonly string[] | undefined; + if (superGraphId !== undefined) { + const subGraphId = classGraphIdByDefId.get(subDef.nodeId); + const heritageArguments = + subGraphId === undefined + ? undefined + : options.heritageTypeArguments?.get( + heritageTypeArgumentsKey(subGraphId, superGraphId), + ); + if (heritageArguments !== undefined) { + const subtypeScopeId = index.classScopeByDefId.get(subDef.nodeId)?.id; + const step = stepHeritageInstantiation({ + supertypeArguments: current.typeArguments, + heritageArguments, + subtypeParameters: subDef.typeParameters, + // The "this subtype declares parameters" disjunct an earlier + // revision carried here could never decide: `subDef` comes out of + // the same loop that sets this flag, from exactly these defs, so a + // subtype with parameters has already set it. + subtypeParametersComplete: languageCapturesTypeParameters, + resolveSupertypeArgument, + resolveHeritageArgument: (name) => groundTypeArgument(name, subtypeScopeId), + normalize: provider.normalizeTypeArgument, + }); + // Skipped BEFORE the visit is recorded, so a type reachable by a + // second, compatible path still gets its edge; and without + // descending, because its own subtypes inherit the mismatch. + if (!step.compatible) continue; + subtypeArguments = step.subtypeArguments; + } + } + + bestIncomingCount.set(subDef.nodeId, current.ancestorImplementationCount); + + const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + let descendantImplementationCount = current.ancestorImplementationCount; + if ( + implMember !== undefined && + implMember !== OVERLOAD_AMBIGUOUS && + implMember.isDeleted !== true && + implMember.nodeId !== primaryMemberDef.nodeId && + !isDeclarationOnly(implMember) && + !isUnreachableByInstanceDispatch(implMember) + ) { + const existing = targetByMemberId.get(implMember.nodeId); + const syntheticOwnerPriority = subDef.isSynthetic === true ? 1 : 0; + if ( + existing === undefined || + syntheticOwnerPriority < existing.syntheticOwnerPriority || + (syntheticOwnerPriority === existing.syntheticOwnerPriority && + current.ancestorImplementationCount < existing.ancestorImplementationCount) + ) { + targetByMemberId.set(implMember.nodeId, { + member: implMember, + syntheticOwnerPriority, + ancestorImplementationCount: current.ancestorImplementationCount, + discoveryOrder: existing?.discoveryOrder ?? discoveryOrder++, + }); + } + descendantImplementationCount++; + } + queue.push({ + typeId: subDef.nodeId, + ancestorImplementationCount: descendantImplementationCount, + typeArguments: subtypeArguments, + }); } } + const targets = [...targetByMemberId.values()] + .sort( + (left, right) => + left.syntheticOwnerPriority - right.syntheticOwnerPriority || + left.ancestorImplementationCount - right.ancestorImplementationCount || + left.discoveryOrder - right.discoveryOrder, + ) + .map((target) => target.member); + // Bounded, and NEVER silently (#2829). An interface with a very large // implementor set multiplies edges by every call site — Go, TypeScript and // Kotlin do not set `collapseMemberCallsByCallerTarget`, so the product is @@ -505,8 +706,13 @@ export function emitReceiverBoundCalls( if (targets.length > MAX_INTERFACE_DISPATCH_FANOUT) { dispatchFanoutSkipped += targets.length - MAX_INTERFACE_DISPATCH_FANOUT; if (dispatchFanoutSkippedNames.length < MAX_REPORTED_SKIPPED_INTERFACES) { + const dropped = targets + .slice(MAX_INTERFACE_DISPATCH_FANOUT, MAX_INTERFACE_DISPATCH_FANOUT + 5) + .map((target) => target.qualifiedName ?? target.nodeId); + const omitted = targets.length - MAX_INTERFACE_DISPATCH_FANOUT - dropped.length; dispatchFanoutSkippedNames.push( - `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets)`, + `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets; ` + + `dropped: ${dropped.join(', ')}${omitted > 0 ? `, +${omitted} more` : ''})`, ); } targets.length = MAX_INTERFACE_DISPATCH_FANOUT; @@ -734,7 +940,7 @@ export function emitReceiverBoundCalls( receiverName.includes('(') || site.receiverChain !== undefined ) { - const currentClass = resolveCompoundReceiverClass( + const resolved = resolveCompoundReceiverTyped( receiverName, site.inScope, scopes, @@ -743,8 +949,9 @@ export function emitReceiverBoundCalls( // captured chain describes it and the structural fold applies. { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); + const currentClass = resolved?.def; compoundReceiverUnresolved = currentClass === undefined; - if (currentClass !== undefined) { + if (resolved !== undefined && currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; let ambiguousOwnerId: string | undefined; @@ -847,6 +1054,10 @@ export function emitReceiverBoundCalls( // Deliberately not "fixed" here: changing Case 0's primary // confidence is a separate behavioural change affecting every // language, and is out of scope for #2813. + // + // The instantiation the FOLD typed this receiver from — the + // declared spelling of `this.repo` / `svc.get().repo`, which the + // folded class alone no longer carries (#2912). emitted += emitInterfaceDispatchFor( currentClass, memberName, @@ -854,6 +1065,7 @@ export function emitReceiverBoundCalls( site, 0.85, calleeCapture, + resolved.declaredSpelling, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1324,15 +1536,18 @@ export function emitReceiverBoundCalls( // already contain `()` (Ruby member-call-return captures), // pass through directly — the compound resolver handles the // full expression including the call syntax. - let ownerDef = resolveCompoundReceiverClass( + // Each attempt carries its OWN spelling: the retry below used to reuse a + // recorder reset once, before the first call, so a spelling reported by + // the attempt that FAILED could be read as the retry's. + let resolved = resolveCompoundReceiverTyped( typeRef.rawName, typeRef.declaredAtScope, scopes, index, fileCompoundOpts, ); - if (ownerDef === undefined && !typeRef.rawName.includes('(')) { - ownerDef = resolveCompoundReceiverClass( + if (resolved === undefined && !typeRef.rawName.includes('(')) { + resolved = resolveCompoundReceiverTyped( typeRef.rawName + '()', typeRef.declaredAtScope, scopes, @@ -1340,7 +1555,8 @@ export function emitReceiverBoundCalls( fileCompoundOpts, ); } - if (ownerDef !== undefined) { + const ownerDef = resolved?.def; + if (resolved !== undefined && ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; let ambiguousOwnerId: string | undefined; @@ -1441,6 +1657,7 @@ export function emitReceiverBoundCalls( // value instead because ITS primary varies that way; Case 3b's // primary, like Case 0's, does not, so there is no 1.0 arm here to // mirror. + // Same fold, same recovered spelling as Case 0. emitted += emitInterfaceDispatchFor( ownerDef, memberName, @@ -1448,6 +1665,7 @@ export function emitReceiverBoundCalls( site, 0.85, calleeCapture, + resolved.declaredSpelling, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1721,6 +1939,12 @@ export function emitReceiverBoundCalls( // Interface dispatch: when the primary owner is an // Interface, emit secondary CALLS edges to every // implementing class's same-named method. + // + // This case is the one that KNOWS the instantiation: the receiver + // has a declared type, and `typeApplication` is that type restored + // to its written `Base` spelling (`rawName` is the erasure). + // A language whose `rawName` was never erased carries the arguments + // itself, so both spellings are read (#2912). emitted += emitInterfaceDispatchFor( ownerDef, memberName, @@ -1728,6 +1952,7 @@ export function emitReceiverBoundCalls( site, confidence, calleeCapture, + typeApplication ?? typeRef.rawName, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1999,6 +2224,8 @@ export function emitReceiverBoundCalls( // way. Omitting it would make the static spelling emit fewer // targets than the identical instance field, which is the very // spelling-dependence #2829/#2842 closed elsewhere. + // The field's DECLARED type is the spelling the source wrote, so + // its arguments are available here exactly as in Case 4 (#2912). emitted += emitInterfaceDispatchFor( receiverClass, memberName, @@ -2006,6 +2233,7 @@ export function emitReceiverBoundCalls( site, confidence, calleeCapture, + fieldDeclaredType, ); handledSites.add(siteKey); continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 78da450fc..fcc456d66 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -95,6 +95,10 @@ import { } from '../passes/callable-value-flow.js'; import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js'; import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; +import { + heritageTypeArgumentsKey, + type HeritageTypeArgumentSink, +} from '../utils/generic-instantiation.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; @@ -149,11 +153,22 @@ function emitInheritanceEdgeDirect( * reference-edge bridge from re-emitting the same sites later. * * @returns Site keys to seed the downstream handled-site skip set. + * + * The generic INSTANTIATION each heritage edge was written with (#2912) goes to + * `recordTypeArguments` rather than out through the return, because the caller + * shares that sink with the language heritage hook — see + * `HeritageTypeArguments` in `utils/generic-instantiation.ts`. This pass is + * where the pairing exists at all: the site carries the arguments and this is + * the only code that resolves the site to a (subtype, supertype) pair, so + * recording it here costs one map write per generic heritage edge, while + * recovering it downstream would mean redoing the resolution against a graph + * edge that no longer carries the spelling. */ function preEmitInheritanceEdges( graph: KnowledgeGraph, scopes: ReturnType, nodeLookup: ReturnType, + recordTypeArguments: HeritageTypeArgumentSink, ): Set { const handledSites = new Set(); const seen = new Set(); @@ -207,6 +222,12 @@ function preEmitInheritanceEdges( const edgeType: 'EXTENDS' | 'IMPLEMENTS' = targetDef.type === 'Interface' || targetDef.type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'; emitInheritanceEdgeDirect(graph, seen, existing, callerGraphId, targetGraphId, edgeType, site); + // The instantiation this heritage clause wrote (`: IValidator`), + // keyed by the same graph-id pair the edge itself carries. Only generic + // bases produce an entry; the sink owns the first-writer-wins rule. + if (site.typeArguments !== undefined) { + recordTypeArguments(callerGraphId, targetGraphId, site.typeArguments); + } } return handledSites; @@ -704,16 +725,38 @@ export function runScopeResolution( }, }); logHeapProbe('sr-post-finalize', `lang=${provider.language}`); + // One store and ONE writer rule for heritage instantiations (#2912), shared by + // the pre-pass below and by the language hook further down — a heritage shape + // the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records + // through the same sink. FIRST writer wins: a repeated (sub, super) pair is a + // partial declaration or a re-listed base, and letting a later entry overwrite + // the first would make dispatch depend on file order. + const heritageTypeArguments = new Map(); + const recordHeritageTypeArguments: HeritageTypeArgumentSink = ( + subtypeGraphId, + supertypeGraphId, + typeArguments, + ) => { + if (typeArguments.length === 0) return; + const key = heritageTypeArgumentsKey(subtypeGraphId, supertypeGraphId); + if (!heritageTypeArguments.has(key)) heritageTypeArguments.set(key, typeArguments); + }; const preEmittedInheritanceSites = callableFlowOnly ? new Set() - : preEmitInheritanceEdges(graph, finalized, nodeLookup); + : preEmitInheritanceEdges(graph, finalized, nodeLookup, recordHeritageTypeArguments); // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because // 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. if (!callableFlowOnly) { - provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup, finalized); + provider.emitHeritageEdges?.( + graph, + parsedFiles, + nodeLookup, + finalized, + recordHeritageTypeArguments, + ); } // Implicit IMPORTS-edge hook — for languages whose files have compiler- // implicit cross-file visibility (no syntactic import statement). The @@ -980,6 +1023,10 @@ export function runScopeResolution( // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as // the `emitFreeCallFallback` wiring below. isBuiltInName: provider.languageProvider.isBuiltInName, + // What each heritage clause instantiated its base with, so the + // interface-dispatch fan-out can refuse an incompatible instantiation + // (#2912). Empty under `callableFlowOnly`, which emits no dispatch. + heritageTypeArguments, }, ); const receiverExtras = receiverBound.emitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts new file mode 100644 index 000000000..b7dce0a72 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts @@ -0,0 +1,345 @@ +/** + * Generic-instantiation compatibility for interface-dispatch fan-out (#2912). + * + * ── THE PROBLEM ────────────────────────────────────────────────────────────── + * + * Heritage edges are stored between DECLARATIONS, and a declaration answers for + * every instantiation of itself: `class UserValidator : IValidator` and + * `class IntValidator : IValidator` both land in `IValidator`'s subtype + * list, indistinguishable once the arguments are erased. A call through an + * `IValidator` receiver then fans out to `IntValidator.Check(int)` — a + * target no runtime dispatch can produce, because the two instantiations are + * unrelated types. + * + * ── THE MODEL ──────────────────────────────────────────────────────────────── + * + * The subtype closure is walked carrying a SUBSTITUTION, exactly as a type + * checker would. Each hop takes the arguments the supertype is currently known + * to be instantiated with and the arguments the subtype WROTE on that supertype, + * and unifies them positionally: + * + * receiver `IValidator` → super args ['string'] + * `UserValidator : IValidator` ['string'] ≡ ['string'] → keep + * `IntValidator : IValidator` ['int'] ✗ → prune + * `Wrapper : IValidator` ['T'] binds T = string → keep, + * and the next hop sees `Wrapper` instantiated with ['string'], so + * `IntWrapper : Wrapper` prunes and `StrWrapper : Wrapper` + * survives. + * + * ── WHY EVERY UNCERTAINTY FAILS OPEN ───────────────────────────────────────── + * + * Dispatch fan-out is an over-approximation by design: a missing edge is a + * silently wrong answer to "what can this call reach", while a surplus edge is + * the pre-existing, documented imprecision. So this only ever prunes on POSITIVE + * evidence that two instantiations differ, and returns `compatible` for every + * shape it cannot decide — unknown arguments on either side, an arity it cannot + * line up, or an argument that might be a type variable this pipeline did not + * capture. `SymbolDefinition.typeParameters` and `ReferenceSite.typeArguments` + * are both absent for languages whose captures do not populate them, and absence + * means "unknown", never "not generic"; a language that captures neither is + * therefore left with exactly the pre-#2912 fan-out. + * + * That is also why the arguments are RESOLVED rather than string-compared. Two + * spellings that differ are only certainly different types when both bind to + * something this pipeline can see — an imported `User` and a `Models.User` are + * one type, and a lone `T` may be a type variable the capture layer never + * recorded. The caller supplies the evidence (scope lookup + built-in names); + * anything it cannot ground keeps the target. + */ + +import type { TypeParameter } from 'gitnexus-shared'; + +/** + * What a written type argument turned out to name, as far as the pipeline can + * tell from where it was written. + * + * A spelling is GROUNDED when either field answers: it bound to a declaration, + * or the language calls the name built in. Two grounded arguments that are not + * the same type are the only evidence that licenses a prune. An ungrounded + * spelling is `unknown` — it may be an external type, but it may equally be a + * TYPE VARIABLE in a language whose captures do not record type parameters, and + * pruning on that would delete `class Box : IValidator` from every + * instantiation's fan-out. + */ +export interface GroundedTypeArgument { + /** Identity of the declaration this spelling bound to, when it bound to one. + * Comparing identities rather than spellings is what makes `Models.User` and + * an imported `User` one type. */ + readonly definitionId?: string; + /** The language declares this name built in (`string`, `int`). */ + readonly builtIn: boolean; + /** + * The name is a TYPE PARAMETER of a declaration enclosing where it was + * written — the `T` of `void Run(IValidator v)` at the call site, or of + * an outer class around a nested one's heritage clause. + * + * It stands for a different type at every instantiation, so it cannot be + * compared with anything, and it must be recognised SEPARATELY from + * ungrounded: a bounded `T extends User` grounds to its bound's declaration, + * and comparing that bound against a concrete argument would prune every + * implementor of a call written through `IValidator`. + */ + readonly typeVariable?: boolean; +} + +/** Resolve a written type argument from the scope it was written in. */ +type TypeArgumentResolver = (name: string) => GroundedTypeArgument; + +/** + * Generic arguments written on a heritage clause, keyed by the GRAPH-ID pair of + * the edge they were written on — see {@link heritageTypeArgumentsKey}. + * + * Graph ids rather than def ids because that is the identity the heritage edge + * itself carries, and because same-file partial declarations share one node: a + * base listed on any part is the base of the whole type. Absent for every + * non-generic base, for every language whose captures do not record arguments, + * and for heritage that never passes through the inheritance pre-pass (Ruby's + * `include`, Go's structural implements) — all of which read as "unknown". + */ +export type HeritageTypeArguments = ReadonlyMap; + +/** + * Records one heritage edge's instantiation, from whichever pass emitted that + * edge — the generic inheritance pre-pass, or a language's own + * `ScopeResolver.emitHeritageEdges` for heritage the pre-pass cannot express + * (Rust `impl Trait for S`, Dart's `implements` markers). + * + * The ids MUST be the same pair the emitted edge carries, because the dispatch + * walk looks the instantiation up by the edge it is crossing. Recording nothing + * is always safe: absence reads as "unknown" and keeps every target. + */ +export type HeritageTypeArgumentSink = ( + subtypeGraphId: string, + supertypeGraphId: string, + typeArguments: readonly string[], +) => void; + +/** Key for {@link HeritageTypeArguments}. NUL-separated because a graph id + * embeds a file path, and a path may legally contain every other separator a + * reader would reach for first — `:`, `|`, even a space. */ +export function heritageTypeArgumentsKey(subtypeGraphId: string, supertypeGraphId: string): string { + return `${subtypeGraphId}\u0000${supertypeGraphId}`; +} + +/** One hop of the subtype closure, expressed as a substitution problem. */ +export interface HeritageInstantiationStep { + /** + * Arguments the SUPERTYPE is currently known to be instantiated with, in + * declaration order — `['string']` for a receiver typed `IValidator`. + * `undefined` when the instantiation is unknown, which keeps every subtype. + */ + readonly supertypeArguments: readonly string[] | undefined; + /** + * Arguments the SUBTYPE wrote on the supertype in its own heritage clause — + * `['string']` for `: IValidator`, `['T']` for `: IValidator`. + * `undefined` when the subtype named the supertype without arguments, or when + * the language's captures did not record them. + */ + readonly heritageArguments: readonly string[] | undefined; + /** The SUBTYPE's own declared type parameters, in declaration order. */ + readonly subtypeParameters: readonly TypeParameter[] | undefined; + /** + * Does an EMPTY `subtypeParameters` mean "this declaration is not generic"? + * + * The distinction decides whether an unresolvable argument may be pruned on. + * `SymbolDefinition.typeParameters` is absent both for a plain `class C : + * IValidator` and for every declaration in a language whose captures + * record no parameters at all — and the two demand opposite answers, because + * in the second case the `T` of `class Box : IValidator` is also absent + * and would be read as a concrete type named "T". + * + * True when the caller has evidence the parameters ARE recorded: this + * declaration itself lists some, or some declaration in the same language run + * does. False leaves an unresolvable argument unusable as evidence, which is + * the pre-#2912 fan-out for that language. + */ + readonly subtypeParametersComplete: boolean; + /** Ground a supertype argument — resolved from the RECEIVER's scope. */ + readonly resolveSupertypeArgument: TypeArgumentResolver; + /** Ground a heritage argument — resolved from where the HERITAGE was written, + * a different scope from the call site and usually a different file. */ + readonly resolveHeritageArgument: TypeArgumentResolver; + /** Optional language normalization applied to both sides before they are + * compared, for aliases that denote one type (C# `string` / `String`). */ + readonly normalize?: (name: string) => string; +} + +interface HeritageInstantiationResult { + /** False ONLY when the two instantiations are provably different types. */ + readonly compatible: boolean; + /** + * What the SUBTYPE is instantiated with, for the next hop of the walk: + * its own type parameters resolved through this step's bindings. `undefined` + * whenever any parameter stayed unbound — a partially known list would have to + * be tracked per slot, and the whole-list unknown is the fail-open reading. + */ + readonly subtypeArguments: readonly string[] | undefined; +} + +const UNKNOWN: HeritageInstantiationResult = { compatible: true, subtypeArguments: undefined }; + +/** Stand-in for a language that declares no `normalizeTypeArgument`. Module + * level so the 15 that do not are not charged a closure per hop. */ +const identity = (name: string): string => name; + +/** A resolved declaration, or a name the language calls built in. Anything else + * might be a type variable nobody captured. */ +function grounded(type: GroundedTypeArgument): boolean { + return type.definitionId !== undefined || type.builtIn; +} + +/** + * Does this spelling name a SET of types rather than one — a Java wildcard + * (`?`, `? extends User`, `? super User`), a Kotlin star projection (`*`) or + * use-site variance (`out User`, `in User`)? + * + * Nullable decoration (`User?`, `string?`) matches the `?` test too. Keeping it + * in is deliberate: an argument that may or may not be null is still the same + * type for dispatch purposes, so the only cost is declining to prune a position + * that could have been pruned — the direction every other uncertainty here + * takes. + */ +function isWildcard(name: string): boolean { + return WILDCARD_MARK.test(name) || USE_SITE_VARIANCE.test(name); +} + +const WILDCARD_MARK = /[?*]/; +/** Leading whitespace is matched rather than trimmed off, so a spelling that + * carries none — the overwhelming majority — costs no allocation. */ +const USE_SITE_VARIANCE = /^\s*(?:out|in)\s/; + +/** Drop insignificant whitespace so two spellings of one instantiation compare + * equal: `Map` and `Map` are the same type, and + * which one a capture produced depends on how the source was written. */ +function compact(name: string): string { + return name.replace(INSIGNIFICANT_WHITESPACE, ''); +} + +const INSIGNIFICANT_WHITESPACE = /\s+/g; + +/** Last segment of a qualified spelling: `java.lang.String` → `String`, + * `System::Text::Encoding` → `Encoding`. Used only when a name did not + * resolve, so the qualifier is exactly the part nothing can check. */ +function simpleName(name: string): string { + const cut = Math.max(name.lastIndexOf('.'), name.lastIndexOf(':')); + return cut === -1 ? name : name.slice(cut + 1); +} + +/** + * Unify one heritage hop and carry the substitution to the subtype. + * + * Pure and total: no lookups of its own, no throwing, and every branch it cannot + * decide answers {@link UNKNOWN} — compatible, with an unknown instantiation. + */ +export function stepHeritageInstantiation( + step: HeritageInstantiationStep, +): HeritageInstantiationResult { + const { supertypeArguments, heritageArguments, subtypeParameters } = step; + if (supertypeArguments === undefined || heritageArguments === undefined) return UNKNOWN; + // An arity that does not line up means one of the two lists is not what this + // code thinks it is (a spelling the argument splitter read differently, a + // partial specialization, a variadic parameter pack). Nothing positive can be + // concluded from a mismatched pairing, so nothing is. + if (supertypeArguments.length !== heritageArguments.length) return UNKNOWN; + + const normalize = step.normalize ?? identity; + const bindings = new Map(); + for (let i = 0; i < heritageArguments.length; i++) { + const written = heritageArguments[i] as string; + const actual = supertypeArguments[i] as string; + // A type VARIABLE of the subtype binds rather than compares: `Wrapper : + // IValidator` under an `IValidator` receiver means T = string. + if (subtypeParameters?.some((p) => p.name === written) === true) { + const previous = bindings.get(written); + if (previous !== undefined) { + // The SAME variable in a second position must receive the same type: + // `class C : Pair` cannot be a `Pair`, and + // overwriting the first binding would both accept that and hand the + // next hop a substitution the subtype never had. Unify instead — but + // prune only on the evidence the concrete path below demands, since two + // spellings that differ are not yet two types. + const first = step.resolveSupertypeArgument(previous); + const second = step.resolveSupertypeArgument(actual); + if ( + isWildcard(previous) || + isWildcard(actual) || + first.typeVariable === true || + second.typeVariable === true + ) { + return UNKNOWN; + } + if (compact(normalize(previous)) === compact(normalize(actual))) continue; + if (first.definitionId !== undefined && second.definitionId !== undefined) { + if (first.definitionId === second.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + if (grounded(first) && grounded(second)) { + return { compatible: false, subtypeArguments: undefined }; + } + // One side names something this pipeline cannot see. The position is + // undecided, and so is the binding it would have carried onward. + return UNKNOWN; + } + bindings.set(written, actual); + continue; + } + // A WILDCARD names a set of types, not one: `Repo` holds a + // `Repo` perfectly well, and Kotlin's `Repo<*>` or `Repo` + // say the same thing in their own spelling. Comparing one against a + // concrete argument answers a question neither spelling asked, so the + // position is simply unknown. Nullable decoration (`User?`, `string?`) trips + // the same test, which costs a little precision in the safe direction. + if (isWildcard(written) || isWildcard(actual)) continue; + // Normalized once and reused by the simple-name compare below, so both + // comparisons are visibly made on the same normalization. + const writtenKey = compact(normalize(written)); + const actualKey = compact(normalize(actual)); + if (writtenKey === actualKey) continue; + // Differing spellings, which is not yet a difference of TYPE. Resolve both + // where each was written and compare what they bound to: an imported `User` + // and a `Models.User` are one declaration, and a declaration is what the + // instantiation is actually about. + const heritageType = step.resolveHeritageArgument(written); + const supertypeType = step.resolveSupertypeArgument(actual); + // A type PARAMETER in scope where it was written stands for a different type + // at every instantiation, so it is not comparable with anything — and + // `subtypeParametersComplete` says nothing about it, because that flag is + // evidence about the SUBTYPE's parameter list while this `T` belongs to the + // enclosing generic method or class at the other end. Without this branch a + // call written `void Run(IValidator v) { v.Check(x); }` prunes every + // implementor: `T` is unbounded, so it grounds to nothing, and a bounded one + // grounds to its BOUND and compares unequal to the concrete argument. + if (heritageType.typeVariable === true || supertypeType.typeVariable === true) return UNKNOWN; + if (heritageType.definitionId !== undefined && supertypeType.definitionId !== undefined) { + if (heritageType.definitionId === supertypeType.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + // At least one side names something outside this workspace — `String`, + // `HttpClient`, a generated type. That is the COMMON case for a generic + // argument, so refusing to decide here would make the whole filter inert; + // what is compared instead is the simple name, which cannot tell + // `a.User` from `b.User` (kept, the over-approximating direction) but does + // tell `String` from `Integer`. + if (simpleName(writtenKey) === simpleName(actualKey)) continue; + // The one thing a spelling difference must not be read as: a TYPE VARIABLE + // this pipeline never captured. Where the subtype's parameter list is not + // known to be complete, only a pair of grounded names — resolved or built + // in — is safe to prune on. A variable that IS captured never reaches here: + // the subtype's own bind above, and any other declaration's through the + // `typeVariable` test, which is why that test has to be reliable — see the + // type-parameter captures on generic METHODS. + if (!step.subtypeParametersComplete && !(grounded(heritageType) && grounded(supertypeType))) { + return UNKNOWN; + } + return { compatible: false, subtypeArguments: undefined }; + } + + if (subtypeParameters === undefined || subtypeParameters.length === 0) return UNKNOWN; + const subtypeArguments: string[] = []; + for (const parameter of subtypeParameters) { + const bound = bindings.get(parameter.name); + if (bound === undefined) return UNKNOWN; + subtypeArguments.push(bound); + } + return { compatible: true, subtypeArguments }; +} diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 0e56f7c7b..bd0ae6700 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1205,6 +1205,17 @@ export const JAVA_QUERIES = ` (record_declaration name: (identifier) @name) @definition.record (annotation_type_declaration name: (identifier) @name) @definition.annotation +; Canonical record-component accessors are implicit public zero-argument methods. +(record_declaration + parameters: (formal_parameters + (formal_parameter + name: (identifier) @name) @definition.method)) +(record_declaration + parameters: (formal_parameters + (spread_parameter + (variable_declarator + name: (identifier) @name)) @definition.method)) + ; Anonymous class bodies: new Runnable() { ... } — no @name capture; the ; class extractor synthesizes the javac-style Worker$N name (#2550) (object_creation_expression (class_body)) @definition.class diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts index 5f8fa1a6b..4ade74c8a 100644 --- a/gitnexus/src/core/ingestion/utils/method-props.ts +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -17,11 +17,31 @@ export function arityForIdFromInfo(info: MethodInfo): number | undefined { } /** - * Compute a type-based discriminator suffix for same-arity overloads. - * Returns `~type1,type2` when the current method collides with another method - * in the same class that has the same name and arity but different parameter types. - * Returns `''` when there is no collision or types are unavailable. + * Key for the per-class method map built by `getMethodInfo` (parse-worker). + * + * `name:line` is NOT unique. Two callables can start on the same line with the + * same name whenever one of them is SYNTHESIZED at a position that is not its + * own declaration: a Java record's implicit component accessor is minted at the + * component (`record P(int x, int y) { int x(int s) {…} }`), and a C# 12 primary + * constructor at the owner's `parameter_list` + * (`class Point(int x, int y) { public Point(int x) : this(x, 0) {} }`). Both are + * appended LAST by their extractor, so under a `name:line` key the synthesized + * entry silently destroyed the source-written method's MethodInfo and the two + * ids collapsed onto one node (#2936). + * + * `line` is 1-based and `column` is 0-based — deliberately, because this is a + * join key rather than a displayed location and both sides derive it from the + * same node. Do not "normalize" one half; the join is the only contract. + * + * The KEY is never parsed by any consumer — `buildCollisionGroups`, + * `typeTagForId` and `constTagForId` all iterate `.values()`. Keep it that way, + * and never insert one MethodInfo under two keys: those consumers would then + * count it twice and turn every singleton into a false collision group. */ +export function methodInfoKey(name: string, line: number, column: number): string { + return `${name}:${line}:${column}`; +} + /** * Build collision groups from a method map — groups methods by `name#arity`. * Call once per class, then pass to typeTagForId/constTagForId to avoid O(N²) scans. @@ -43,6 +63,12 @@ export function buildCollisionGroups( return groups; } +/** + * Compute a type-based discriminator suffix for same-arity overloads. + * Returns `~type1,type2` when the current method collides with another method + * in the same class that has the same name and arity but different parameter types. + * Returns `''` when there is no collision or types are unavailable. + */ export function typeTagForId( methodMap: Map, methodName: string, diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts index 9d1f25e8f..6ed710eb7 100644 --- a/gitnexus/src/core/ingestion/utils/template-arguments.ts +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -47,6 +47,129 @@ export function extractTemplateArguments(text: string): string[] | undefined { return args.length > 0 ? args : undefined; } +/** + * The type ARGUMENTS a reference applies to its base, read from the reference's + * own source spelling: `IValidator` → `['string']`, `Base[User]` → + * `['User']`, `Repository` → `undefined`. + * + * The inverse direction of {@link erasedTypeApplication}, which rebuilds the + * `Base` SPELLING so a lookup can stay grounded; this returns the + * ARGUMENTS so a consumer that has already resolved the base can ask which + * instantiation it was (#2912). + * + * Both bracket families count, because both spell type application in a + * heritage position — `class C : IValidator` and Go's `struct { Base[int] }` + * / Python's `class C(Base[User])`. What is NOT accepted is anything that fails + * to be exactly one balanced, non-empty list closing at the very end: + * + * - `Base(args)` — a C# primary-constructor base, not an application. + * - `Foo[]` — an empty list is an array spelling, not arguments. + * - `(Int) -> Unit` — a Kotlin function type, whose `>` closes nothing. + * + * Declining is the safe outcome for all of them: absence reads as "unknown" + * and every consumer of this fails open on it. + */ +export function typeApplicationArguments(spelling: string): string[] | undefined { + const text = spelling.trim(); + const inner = balancedTailList(text, text.search(OPENING_BRACKET)); + if (inner === undefined) return undefined; + const args = splitTopLevelArguments(inner); + return args.length > 0 ? args : undefined; +} + +const OPENING_BRACKET = /[<[]/; + +/** + * The contents of the ONE balanced bracket list that opens at `start` and closes + * on the LAST character of `text` — `Repo` from index 4 yields `User`. + * + * `undefined` for everything else, which is what both callers need: a list that + * closes early (`User[][]`, `Repo?`), one that never closes + * (`Map Unit>`), an empty one (`User[]`), one whose brackets + * cross families (`Foo`), or no bracket at all (`start === -1`). Shared + * because the rule is one rule — `erasedTypeApplication` rebuilds the spelling + * from it and `typeApplicationArguments` splits it, and two copies of a scan + * this fiddly would be free to disagree about `User[][]`. + */ +function balancedTailList(text: string, start: number): string | undefined { + const opener = text[start]; + if (opener !== '<' && opener !== '[') return undefined; + // A STACK of expected closers rather than one counter for one family: a + // counter scanning `Foo` never sees the `]`, reaches the final `>` at + // depth zero and reports `Bar]` as a balanced argument list. Every closer must + // now match the opener it actually closes, so a crossed pair declines — which + // is what the contract above says and what both callers read as "unknown". + const expected: string[] = []; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '<' || ch === '[') { + expected.push(ch === '<' ? '>' : ']'); + continue; + } + if (ch !== '>' && ch !== ']') continue; + if (expected.pop() !== ch) return undefined; + if (expected.length === 0) { + return i === text.length - 1 && i > start + 1 ? text.slice(start + 1, i) : undefined; + } + } + return undefined; +} + +/** Split `string, Map` on the commas that are not inside a nested + * list. Tracks BOTH bracket families so a mixed spelling (`List`) + * does not split inside the inner one. */ +function splitTopLevelArguments(inner: string): string[] { + const args: string[] = []; + let depth = 0; + let tokenStart = 0; + const push = (end: number): void => { + const token = inner.slice(tokenStart, end).trim(); + if (token.length > 0) args.push(token); + }; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '<' || ch === '[') depth++; + else if (ch === '>' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + push(i); + tokenStart = i + 1; + } + } + push(inner.length); + return args; +} + +/** + * Index of the `(` that matches the trailing `)` of `text`, or -1 when the text + * does not end in a balanced call suffix. + * + * Shared for the same reason as {@link balancedTailList}: this scan is fiddly + * enough that two copies would be free to disagree, and it has two unrelated + * readers — splitting a receiver chain at its call, and stripping a base's + * constructor invocation off a heritage spelling. + */ +export function matchingOpenParen(text: string): number { + if (!text.endsWith(')')) return -1; + let depth = 0; + for (let i = text.length - 1; i >= 0; i--) { + const ch = text[i]; + if (ch === ')') depth++; + else if (ch === '(') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Drop a balanced `(...)` that ENDS the text — the argument list of a base's + * constructor invocation, as in `record R : Base(x)` or Kotlin + * `class C : Bar()`. Anything else is returned unchanged. */ +export function stripTrailingCallSuffix(text: string): string { + const open = matchingOpenParen(text); + return open === -1 ? text : text.slice(0, open).trimEnd(); +} + export function stripTemplateArguments(text: string): string { const start = text.indexOf('<'); if (start === -1) return text; @@ -151,21 +274,8 @@ export function erasedTypeApplication(typeRef: TypeRef): string | undefined { if (spelling === undefined) return undefined; const base = typeRef.rawName.trim(); if (base.length === 0 || !spelling.startsWith(base)) return undefined; - const rest = spelling.slice(base.length).trimStart(); - const opener = rest[0]; - if (opener !== '<' && opener !== '[') return undefined; - const closer = opener === '<' ? '>' : ']'; - let depth = 0; - for (let i = 0; i < rest.length; i++) { - if (rest[i] === opener) depth++; - else if (rest[i] === closer) { - depth--; - // The list the spelling opened must close on the LAST character, and must - // have held something: `Repo[User]` yes, `User[]` no, `User[][]` no. - if (depth === 0) { - return i === rest.length - 1 && i > 1 ? `${base}<${rest.slice(1, i)}>` : undefined; - } - } - } - return undefined; + // The list must open immediately after the base and close on the LAST + // character, holding something: `Repo[User]` yes, `User[]` no, `User[][]` no. + const inner = balancedTailList(spelling.slice(base.length).trimStart(), 0); + return inner === undefined ? undefined : `${base}<${inner}>`; } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f139c2be1..d9fb0fd67 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -133,6 +133,7 @@ import { constTagForId, buildCollisionGroups, parameterShapeIdTag, + methodInfoKey, } from '../utils/method-props.js'; import { extractTemplateArguments, @@ -739,9 +740,12 @@ const methodInfoCache = new Map>(); /** * Get (or extract and cache) method info for a class node. - * Returns a "name:line" → MethodInfo map, or undefined if the provider has no method extractor - * or the class yielded no methods. - * Keyed by name:line (not name alone) to support overloaded methods in Java/Kotlin. + * Returns a "name:line:column" → MethodInfo map, or undefined if the provider has no method + * extractor or the class yielded no methods. + * Keyed by name:line:column (not name, and not name:line) to support overloaded methods in + * Java/Kotlin AND to keep a callable SYNTHESIZED at another node's position from evicting the + * source-written one that starts on the same line (#2936). Every lookup site MUST pass the + * column of the SAME node it takes the line from — see `methodInfoKey`. */ function getMethodInfo( classNode: SyntaxNode, @@ -759,7 +763,7 @@ function getMethodInfo( cached = new Map(); for (const method of result.methods) { - cached.set(`${method.name}:${method.line}`, method); + cached.set(methodInfoKey(method.name, method.line, method.column), method); } methodInfoCache.set(cacheKey, cached); return cached; @@ -996,7 +1000,9 @@ const findEnclosingFunctionId = ( language: encLang, }); const defLine = current.startPosition.row + 1; - const info = methodMap?.get(`${funcName}:${defLine}`); + const info = methodMap?.get( + methodInfoKey(funcName, defLine, current.startPosition.column), + ); if (info) { arity = info.parameters.some((p) => p.isVariadic) ? undefined @@ -1062,7 +1068,9 @@ const findEnclosingFunctionId = ( language: encLang2, }); const defLine2 = sigNode.startPosition.row + 1; - const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`); + const info2 = methodMap2?.get( + methodInfoKey(customResult.funcName, defLine2, sigNode.startPosition.column), + ); if (info2) { arity2 = info2.parameters.some((p) => p.isVariadic) ? undefined @@ -2112,6 +2120,7 @@ const processFileGroup = ( const definitionNode = getDefinitionNodeFromCaptures(captureMap); const defaultNodeLabel = getLabelFromCaptures(captureMap, provider); if (!defaultNodeLabel) continue; + if (provider.shouldSkipDefinitionCapture?.(captureMap, defaultNodeLabel) === true) continue; const nameNode = captureMap['name']; const extractedClassSymbol = @@ -2562,7 +2571,9 @@ const processFileGroup = ( language, }); const defLine = definitionNode.startPosition.row + 1; - const info = methodMap?.get(`${nodeName}:${defLine}`); + const info = methodMap?.get( + methodInfoKey(nodeName, defLine, definitionNode.startPosition.column), + ); if (info) { enrichedByMethodExtractor = true; arityForId = arityForIdFromInfo(info); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index aae283798..d60c731fd 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -172,7 +172,11 @@ import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from './ingestion/languages/java/analysis-features.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, @@ -225,6 +229,8 @@ const ANALYSIS_FEATURES = [ SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, ] as const; interface PersistedFrameworkAnnotationRow { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index f0df5a881..270e2c0ad 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -467,7 +467,6 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // // Still open at this commit: #2891 also claims 59, which main now holds. That is // a live exact clash for #2891 to renumber, not for this branch. -// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 60 -> 62 for the two optional `ParsedImport` fields the cycle-checker fix // adds: `typeOnly` (TS `import type`) and `runsOnlyWhenCalled` (an import @@ -503,10 +502,38 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // one, and the accessor definitions it materializes are not in this branch's // ParsedFile shape at all. // -// Note for whoever merges next: #2935 and #2840 BOTH claim 61, independently of -// this branch. That clash is still live and is theirs to resolve. +// 63 -> 64 for Java enum heritage plus annotated class, record, interface, +// enum, and explicit-super base names emitting corrected captures (#2918). +// Warm v63 ParsedFiles lack those captures and must be re-extracted. +// 64 -> 66 adds the synthetic-declaration sidecar used to keep anonymous class +// implementations from evicting ordinary implementors at the dispatch cap. +// That PR published a v64 head first, so 66 kept all the shapes in flight at the +// time distinct. (It also named a v65 claim from this branch; that claim was +// superseded before either landed — see the 66 -> 67 entry below. Nothing holds +// 65 now.) +// +// 66 -> 67 for #2917's implicit Java record-component accessor definitions and +// scope declarations. A warm cache would otherwise replay ParsedFiles without +// the synthesized accessors. This branch staged 65 before #2918's 66 landed on +// main; 67 is the next free value above every in-flight claim (main 66, #2939's +// 64), which is the ledger rule above — re-check against the claims, not just +// against main. +// +// 67 -> 68 for #2912's `ReferenceSite.typeArguments`: the generic arguments a +// heritage reference was written with (`: IValidator`), derived at +// EXTRACTION time from the anchor's spelling. A warm cache replays `inherits` +// sites with the field absent, absence is the fail-open "unknown", and +// generic-instantiation filtering therefore degrades to the pre-fix fan-out on +// exactly the unchanged files — silent, and passing every cold-run test. +// +// This branch staged 64 when main held 60 and #2935/#2936/#2934 claimed 61/62/63. +// All three have since landed and cascaded main to 67, burying 64 inside main's +// own ledger — the EIGHTH time the re-check moved a number, and the reason the +// re-check is a merge step rather than a one-time choice. 68 is the next free +// value above every in-flight claim at this merge (main 67, #2891's 59, #1616's +// stale 2), which is the rule above: above every claim, not above origin/main. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 63; +const SCHEMA_BUMP = 68; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index ed093716c..86dc818ce 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -41,7 +41,7 @@ }, "csharp-assignment-chain/Program.cs": { "captureGroups": 32, - "digest": "7698bdabe97661a2a2539a13cf9d886cd7efb2f4924b17a15597ce63cec5126a" + "digest": "6b8eddb5525ef0358276dc18fba264ee0443a8adf5036a3300891507b99c36ab" }, "csharp-async-binding/Order.cs": { "captureGroups": 9, @@ -49,11 +49,11 @@ }, "csharp-async-binding/OrderService.cs": { "captureGroups": 14, - "digest": "712fb5f3a791581ab56c37df58d8245a17a674a6d2f7bd25b2bc8d1632f751c3" + "digest": "492b2ceffaeae03b6a9d673f961dc5386bf08496ef4a8ae70dae68a604801c2f" }, "csharp-async-binding/Program.cs": { "captureGroups": 37, - "digest": "73735c3910ed4db423302d9575cec86156d420e9961c592c34e0436301cac7ce" + "digest": "fc6bb5b9887e5193c90c687248d890873f5eb40f6a8e5b597729311dd3cc9f0a" }, "csharp-async-binding/User.cs": { "captureGroups": 9, @@ -61,11 +61,11 @@ }, "csharp-async-binding/UserService.cs": { "captureGroups": 14, - "digest": "a4e6b093fa23a86313bc468f8b6a89a96d5c90b1d16f166417745bd36dfbd10f" + "digest": "e68423fbb601a61a100d01ef070e0d37f817be4a4c6555500b56fa2ed290adce" }, "csharp-call-result-binding/App.cs": { "captureGroups": 27, - "digest": "3d8c7dc0b7f5bd60c74d6a595bd4b49bdb13b62522fb7f0c1434495e10e1171b" + "digest": "7439cd5fada77ae186fb76594404a8650e21b4892ff268dfbcad6c3ec741c478" }, "csharp-calls/Services/UserService.cs": { "captureGroups": 11, @@ -93,7 +93,7 @@ }, "csharp-chain-call/Services/UserService.cs": { "captureGroups": 11, - "digest": "9833795eeb79a08ef9c8afb66a58b789ab314e48c1ae3193fe87fec279c55374" + "digest": "36a822100dccd931041f95be155b426d87f8e3d1dd1e2268f152b6f8fbffc6d5" }, "csharp-child-extends-parent/src/App.cs": { "captureGroups": 13, @@ -125,11 +125,11 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14" + "digest": "daeb42918323c3f79de9b72ffc72d2c61bb085a65ffb1f67ca3a0d9a78ec06e8" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, - "digest": "ee8eb9c569b71d7050f292bc7fbf89b68cdbb60b4bcd557f2523c86831891543" + "digest": "1f6dfccf8eef881d22795dc6aa80bd267f0ee6f12538c7cabf5cac71db6a7f57" }, "csharp-dictionary-keys-values/Repo.cs": { "captureGroups": 7, @@ -153,7 +153,7 @@ }, "csharp-field-types/Service.cs": { "captureGroups": 11, - "digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51" + "digest": "4cb300e33d2dcc08f869b6752c4655a34b67aebeb4baa44f37411117486d5f1d" }, "csharp-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -165,7 +165,7 @@ }, "csharp-foreach/Program.cs": { "captureGroups": 20, - "digest": "810f0f65e956343cf6817918dc5b28ce7bc1f1f755f89e008a6e8b05864ff469" + "digest": "0f71f4a62926fb335d32b456d5778812519c0eb2bf85528e2e9073db1b976749" }, "csharp-frozen-binding-collision/App/Program.cs": { "captureGroups": 18, @@ -189,11 +189,11 @@ }, "csharp-generic-type-refs/Program.cs": { "captureGroups": 25, - "digest": "e0cd6ea7dc08f66b651027f964f7a36fd3c4efb7a4584df5f14935b93faace5a" + "digest": "28246dd1c88ecfe07fcee84ba314dbd9e39ccd0c30f20b8fb4633ec71e48e375" }, "csharp-grandparent-resolution/Models/A.cs": { "captureGroups": 10, - "digest": "3cd545b2cbec5fee82e9e3d09f2d2ff7ff940e3bf4b597d7c9080fcd8b526675" + "digest": "159a52b959e021b1a34cc0dfbf1ba1a0748a0f29c84634948e2e85afc75db003" }, "csharp-grandparent-resolution/Models/B.cs": { "captureGroups": 6, @@ -221,7 +221,7 @@ }, "csharp-inline-constructor-receiver/src/Svc.cs": { "captureGroups": 19, - "digest": "c2ec7f452c244d7fda931e32c16e46cc7c59e15f404a4413d18f522ed6c492bb" + "digest": "cfbc783ca38a1149913b71f5dead7fd7a7048ca313cfaa7806c68ac9f1867e1f" }, "csharp-interface-default-method/App.cs": { "captureGroups": 12, @@ -321,7 +321,7 @@ }, "csharp-method-chain-binding/App.cs": { "captureGroups": 60, - "digest": "2b4761e1dfe2d48ac25cfda7ccce95175607926b47db43726f38ad2a16acc6f1" + "digest": "4cdccde81efbe41cac6bc82e33b365ccd79481a440e65012f78cb543421c9873" }, "csharp-method-enrichment/Animal.cs": { "captureGroups": 18, @@ -393,7 +393,7 @@ }, "csharp-null-check-narrowing/Services/App.cs": { "captureGroups": 36, - "digest": "5d840c524610b6a84a2f09981c15327e9f7ea5c2c4b11b09e959d880ac6b9bc9" + "digest": "6c7fa1daf5d12a60403a63d5d29c1b42b99370d12f4be53c8e56bb31e5b09d8a" }, "csharp-null-conditional/App.cs": { "captureGroups": 17, @@ -425,7 +425,7 @@ }, "csharp-overload-interface/App/Caller.cs": { "captureGroups": 15, - "digest": "f1ea2e564c46dab5fb93fb19e81ab3411f458b172820b5dc0bdc57f49ffa88e0" + "digest": "5769b1eda360cd588192335e47035683dae751b9f67e0528cccc066b1e4ed887" }, "csharp-overload-interface/App/Logger.cs": { "captureGroups": 14, @@ -445,7 +445,7 @@ }, "csharp-overload-param-types/Models/UserService.cs": { "captureGroups": 30, - "digest": "178e1a7dd5b07ba3361e1eb28ce73ca6a6075fa8ecb8f2ca40e8e87a410de552" + "digest": "ba08bc90619c578582465cb9f640fd0bf0640a5a6a84fdfed0be987802086bb3" }, "csharp-parent-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, @@ -465,7 +465,7 @@ }, "csharp-pattern-matching/Services/AnimalService.cs": { "captureGroups": 13, - "digest": "2623dcd94520473dc3dd830cfc21675349b6981185b779db3f5a47200b2f44a3" + "digest": "6f308b4411f9ad397e2789d7f85eaaaed78f7879dd16f4d7b8d8e7639335d302" }, "csharp-primary-ctor-heritage/src/BaseEntity.cs": { "captureGroups": 6, @@ -581,7 +581,7 @@ }, "csharp-return-type/Models/User.cs": { "captureGroups": 23, - "digest": "6681e6830c71c25908e50273e4babb41611bc229395d1dbab70ac9f219b68ca8" + "digest": "8ca5e28d14a29fb1f29ca6f19300b26fd99d20bc99bd9f537787a41cd9fe6196" }, "csharp-return-type/Services/App.cs": { "captureGroups": 16, @@ -621,7 +621,7 @@ }, "csharp-spurious-edges-no-csproj/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-spurious-edges/Legacy/Tasks.cs": { "captureGroups": 8, @@ -633,7 +633,7 @@ }, "csharp-spurious-edges/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-struct-overloads/src/Calc.cs": { "captureGroups": 19, @@ -689,7 +689,7 @@ }, "csharp-var-foreach/Program.cs": { "captureGroups": 32, - "digest": "a9c7bf1f2425cece1ecb698abc0c6fa5e7a3bb24e3cc7d2dba50ef7d0651badc" + "digest": "58052d12af8b6e4f34924d59bd965773ce6083cb557adebe28eb1d3edcd41b7a" }, "csharp-variadic-resolution/Services/App.cs": { "captureGroups": 10, @@ -705,7 +705,7 @@ }, "csharp-write-access/Service.cs": { "captureGroups": 11, - "digest": "aa6d8a61ac39db413df10a6bc8b9bad3305327dcdce09e01cf01eff31f945537" + "digest": "f97be4b109be6bdaa583e2e7b3268b2fb90ac1ce3cfaf0291a7873f09103a2f9" }, "synthetic:dao-20": { "captureGroups": 263, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 1505032d6..793451b4a 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -661,7 +661,7 @@ }, "rust-qualified-trait/src/widget.rs": { "captureGroups": 23, - "digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac" + "digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5" }, "rust-receiver-resolution/src/main.rs": { "captureGroups": 25, diff --git a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts index d3473d034..8b4da76d9 100644 --- a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts @@ -1332,7 +1332,13 @@ class PyMultiSvc: rows: [ { caller: 'runTsNested', - targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + // No `UserRepo.save`, and that is the #2912 filter doing its job rather + // than the receiver failing to resolve: `UserRepo implements Repo` + // is an implementor of a DIFFERENT instantiation from this receiver's + // `Repo>`, so no dispatch through it can reach `UserRepo`. + // The primary edge to the interface's own declaration is unaffected, + // which is what still proves the receiver typed correctly here. + targets: ['Method:a.ts:Repo.save#1'], note: 'DISCRIMINATING nested generic: TypeScript reaches the shared lookup, unlike the Java/Kotlin/Rust spelling rows above', }, { diff --git a/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts new file mode 100644 index 000000000..845f5aed6 --- /dev/null +++ b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts @@ -0,0 +1,492 @@ +/** + * Interface-dispatch fan-out is generic-instantiation aware (#2912). + * + * `IValidator` and `IValidator` are one DECLARATION and therefore + * one subtype list, so an erased fan-out reaches implementors of instantiations + * the receiver can never hold. Each language here declares two incompatible + * instantiations of one interface with the SAME method name — the shape the + * issue was filed with — plus the cases the filter must not break: a generic + * pass-through implementor, a non-generic interface, and (C#) the predefined + * alias spellings of one type. + * + * Both ways a receiver gets its type are covered, because they reach the + * instantiation by different routes: a DECLARED receiver (`Validator v`) + * carries it on the type binding, while a FOLDED one (`this._validator`, + * `this._holder.Validator`) is typed by the compound fold, which answers with a + * class and reports the spelling separately. + * + * Every implementor lives in its own file so a dispatch target can be named by + * `targetFilePath`: the two `Check` methods are otherwise indistinguishable by + * node name alone. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; + +/** Files a dispatch edge out of `caller` landed in, deduped and sorted. */ +function dispatchTargetFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter( + (edge) => + edge.source === caller && + edge.target === member && + edge.rel.reason === 'interface-dispatch', + ) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +/** Files ANY resolved call out of `caller` landed in — primary edges included. */ +function calledFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter((edge) => edge.source === caller && edge.target === member) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +describe('C# generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-generic-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public record UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public record IntValidator : IValidator { public bool Check(int item) => true; }`, + 'AliasValidator.cs': `namespace Probe; + public class AliasValidator : IValidator { public bool Check(String item) => true; }`, + 'GlobalAliasValidator.cs': `namespace Probe; + public class GlobalAliasValidator : IValidator { public bool Check(String item) => true; }`, + 'Wrapper.cs': `namespace Probe; + public class Wrapper : IValidator { public bool Check(T item) => true; }`, + 'Runner.cs': `namespace Probe; + public class Runner { + public bool Run(IValidator v) => v.Check("x"); + public bool RunInt(IValidator v) => v.Check(1); + public bool RunAny(IValidator v, TItem item) => v.Check(item); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('does not fan a string-instantiated receiver out to the int implementor', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).not.toContain('IntValidator.cs'); + }); + + it('still reaches the implementor of the matching instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('UserValidator.cs'); + }); + + it('mirrors the filter for the other instantiation', () => { + const intTargets = dispatchTargetFiles(result, 'RunInt', 'Check'); + expect(intTargets).toContain('IntValidator.cs'); + expect(intTargets).not.toContain('UserValidator.cs'); + }); + + it('keeps a generic pass-through implementor for BOTH instantiations', () => { + // `Wrapper : IValidator` is an implementor of every instantiation — + // T binds to the receiver's argument rather than clashing with it. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('Wrapper.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).toContain('Wrapper.cs'); + }); + + it('treats the predefined alias spelling as the same instantiation', () => { + // `IValidator` ≡ `IValidator`: C# defines the keyword as an + // alias, so pruning on the spelling would delete a real dispatch target. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('AliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('AliasValidator.cs'); + }); + + it('treats the `global::`-qualified spelling as that same instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('GlobalAliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('GlobalAliasValidator.cs'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + // `RunAny(IValidator v)` knows no instantiation, so the filter + // has nothing to prune on and must restore the unfiltered fan-out. `TItem` + // is a type parameter of the calling METHOD, which the subtype's own + // parameter-list evidence says nothing about. + const targets = dispatchTargetFiles(result, 'RunAny', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).toContain('IntValidator.cs'); + }); + + it('still emits the primary edge to the interface declaration', () => { + expect(calledFiles(result, 'Run', 'Check')).toContain('IValidator.cs'); + }); +}); + +describe('C# generic dispatch through a FOLDED receiver (#2912)', () => { + // The dependency-injection shape: the receiver is a field reached through a + // dot, so it is typed by the compound fold rather than by a type binding. + // The fold answers with a CLASS, which no longer carries the instantiation — + // the spelling it typed the position from is what does. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-folded-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public class UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public class IntValidator : IValidator { public bool Check(int item) => true; }`, + 'Service.cs': `namespace Probe; + public class Service { + private readonly IValidator _validator; + public Service(IValidator validator) { _validator = validator; } + public bool Run() => this._validator.Check("x"); + }`, + 'Holder.cs': `namespace Probe; + public class Holder { + public IValidator Validator { get; set; } + }`, + 'ChainRunner.cs': `namespace Probe; + public class ChainRunner { + private readonly Holder _holder; + public ChainRunner(Holder holder) { _holder = holder; } + public bool RunChain() => this._holder.Validator.Check(1); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('filters a field-typed receiver by its own instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).not.toContain('IntValidator.cs'); + }); + + it("filters a two-hop chain by the LAST hop's instantiation", () => { + // `this._holder.Validator` — the fold walks two members, and it is the + // second one's declared spelling that types the receiver. + const targets = dispatchTargetFiles(result, 'RunChain', 'Check'); + expect(targets).toContain('IntValidator.cs'); + expect(targets).not.toContain('UserValidator.cs'); + }); +}); + +describe('C# non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-plain-dispatch-')); + writeFixtureRepo(root, { + 'IGreeter.cs': `namespace Probe; + public interface IGreeter { string Greet(); }`, + 'Loud.cs': `namespace Probe; + public class Loud : IGreeter { public string Greet() => "HI"; }`, + 'Quiet.cs': `namespace Probe; + public class Quiet : IGreeter { public string Greet() => "hi"; }`, + 'Runner.cs': `namespace Probe; + public class Runner { public string Run(IGreeter g) => g.Greet(); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['Loud.cs', 'Quiet.cs']); + }); +}); + +describe('Java generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.java': `package probe; + public interface Validator { boolean check(T item); }`, + 'StringValidator.java': `package probe; + public class StringValidator implements Validator { + public boolean check(String item) { return true; } + }`, + 'NumberValidator.java': `package probe; + public class NumberValidator implements Validator { + public boolean check(Integer item) { return true; } + }`, + 'Runner.java': `package probe; + public class Runner { + public boolean run(Validator v) { return v.check("x"); } + public boolean runAny(Validator v, T item) { return v.check(item); } + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).not.toContain('NumberValidator.java'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).toContain('NumberValidator.java'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe +interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe +class StringValidator : Validator { override fun check(item: String): Boolean = true }`, + 'IntValidator.kt': `package probe +class IntValidator : Validator { override fun check(item: Int): Boolean = true }`, + 'Runner.kt': `package probe +class Runner { + fun run(v: Validator): Boolean = v.check("x") + fun runAny(v: Validator, item: T): Boolean = v.check(item) +}`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('IntValidator.kt'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).toContain('IntValidator.kt'); + }); +}); + +describe('TypeScript generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ts-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.ts': `export interface Validator { check(item: T): boolean; }`, + 'string-validator.ts': `import type { Validator } from './validator.js'; + export class StringValidator implements Validator { + check(item: string): boolean { return true; } + }`, + 'number-validator.ts': `import type { Validator } from './validator.js'; + export class NumberValidator implements Validator { + check(item: number): boolean { return true; } + }`, + 'runner.ts': `import type { Validator } from './validator.js'; + export function run(v: Validator): boolean { return v.check('x'); } + export function runAny(v: Validator, item: T): boolean { return v.check(item); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).not.toContain('number-validator.ts'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).toContain('number-validator.ts'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + // Kotlin needs no per-language wiring: it emits heritage through the shared + // pre-pass, so the arguments are read off the clause's own spelling. The + // `class C : Bar()` shape — a base with a constructor invocation — is + // the one `stripTrailingCallSuffix` exists for, and is covered here by the + // supertype being an interface (no call suffix) plus the unit tests on that + // helper. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe + interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe + class StringValidator : Validator { + override fun check(item: String): Boolean = true + }`, + 'NumberValidator.kt': `package probe + class NumberValidator : Validator { + override fun check(item: Int): Boolean = true + }`, + 'Runner.kt': `package probe + class Runner { fun run(v: Validator): Boolean = v.check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('NumberValidator.kt'); + }); +}); + +describe('Kotlin non-generic interface dispatch is unaffected (#2912)', () => { + // The CONTROL for the case above. Without it, the `not.toContain` there + // passes just as well when Kotlin emits no dispatch edge at all — which is + // exactly what Dart, Python and Rust turned out to do for this receiver + // shape, and why they are not asserted on in this file. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-plain-dispatch-')); + writeFixtureRepo(root, { + 'Greeter.kt': `package probe + interface Greeter { fun greet(): String }`, + 'Loud.kt': `package probe + class Loud : Greeter { override fun greet(): String = "HI" }`, + 'Quiet.kt': `package probe + class Quiet : Greeter { override fun greet(): String = "hi" }`, + 'Runner.kt': `package probe + class Runner { fun run(g: Greeter): String = g.greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'run', 'greet')).toEqual(['Loud.kt', 'Quiet.kt']); + }); +}); + +describe('Go generic interface dispatch (#2912)', () => { + // Go reaches the same filter by a different route: implementors are matched + // STRUCTURALLY rather than by a heritage clause, and the receiver's own + // `Validator[string]` spelling is what carries the instantiation. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.go': `package probe + +type Validator[T any] interface { + Check(item T) bool +}`, + 'string_validator.go': `package probe + +type StringValidator struct{} + +func (s StringValidator) Check(item string) bool { return true }`, + 'number_validator.go': `package probe + +type NumberValidator struct{} + +func (n NumberValidator) Check(item int) bool { return true }`, + 'runner.go': `package probe + +func Run(v Validator[string]) bool { return v.Check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('string_validator.go'); + expect(targets).not.toContain('number_validator.go'); + }); +}); + +describe('Go non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-plain-dispatch-')); + writeFixtureRepo(root, { + 'greeter.go': `package probe + +type Greeter interface { + Greet() string +}`, + 'loud.go': `package probe + +type Loud struct{} + +func (l Loud) Greet() string { return "HI" }`, + 'quiet.go': `package probe + +type Quiet struct{} + +func (q Quiet) Greet() string { return "hi" }`, + 'runner.go': `package probe + +func Run(g Greeter) string { return g.Greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['loud.go', 'quiet.go']); + }); +}); diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 44bfbc46f..5fada9ce6 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; +import { _captureLogger, type PinoLogRecord } from '../../../src/core/logger.js'; import { FIXTURES, CROSS_FILE_FIXTURES, @@ -1246,6 +1247,31 @@ describe('Java record method resolution (#2564)', () => { expect(sumCall).toBeDefined(); }); + // #2936: the implicit accessor is minted at the COMPONENT's position, so on a + // single line it shares (name, line) with an explicit overload. The worker's + // per-class method map keyed on that pair, so the appended implicit entry + // evicted the source-written method and both definitions collapsed onto + // `Scaled.x#0` — the arity-1 call then bound to a zero-argument target. + it('keeps a same-line explicit overload distinct from the implicit accessor (#2936)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-sameline-')); + try { + writeFixtureRepo(root, { + 'Scaled.java': + 'package probe;\npublic record Scaled(int x, int y) { int x(int factor) { return x * factor; } }\n', + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const arities = getNodesByLabelFull(linked, 'Method') + .filter((node) => node.name === 'x') + .map((node) => Number(node.properties.parameterCount)) + .sort((left, right) => left - right); + + expect(arities).toEqual([0, 1]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + it('uses the Record node as a caller source and constructor-call target (#2801)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-link-')); try { @@ -1308,15 +1334,19 @@ describe('Java record method resolution (#2564)', () => { } }, 60000); - it('documents missing dispatch to an implicit Record component accessor (#2917)', async () => { + it('materializes implicit accessors and dispatches them through a Record interface (#2917)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-accessor-')); try { writeFixtureRepo(root, { - 'RecordAccessor.java': `interface Named { String name(); } - record User(String name) implements Named {} - class Reader { - String read(Named value) { return value.name(); } - }`, + 'Named.java': 'interface Named { String name(); }', + 'User.java': 'record User(String name, java.util.List tags) implements Named {}', + 'Explicit.java': `record Explicit(String name) implements Named { + public String name() { return name.toUpperCase(); } + }`, + 'Reader.java': `class Reader { + String read(Named value) { return value.name(); } + java.util.List directTags(User value) { return value.tags(); } + }`, }); const linked = await runPipelineFromRepo(root, () => {}); @@ -1330,11 +1360,193 @@ describe('Java record method resolution (#2564)', () => { edge.rel.reason === 'interface-dispatch', ); + const methods = getNodesByLabelFull(linked, 'Method'); + const userName = methods.find( + (method) => method.name === 'name' && method.properties.filePath.endsWith('User.java'), + ); + const userTags = methods.find( + (method) => method.name === 'tags' && method.properties.filePath.endsWith('User.java'), + ); + const explicitNames = methods.filter( + (method) => method.name === 'name' && method.properties.filePath.endsWith('Explicit.java'), + ); + const userHasMethod = getRelationships(linked, 'HAS_METHOD').filter( + (edge) => edge.source === 'User' && (edge.target === 'name' || edge.target === 'tags'), + ); + const methodImplements = getRelationships(linked, 'METHOD_IMPLEMENTS').filter( + (edge) => edge.source === 'name' && edge.target === 'name', + ); + const directTags = getRelationships(linked, 'CALLS').find( + (edge) => edge.source === 'directTags' && edge.target === 'tags', + ); + expect(implementsEdge?.sourceLabel).toBe('Record'); expect(implementsEdge?.targetLabel).toBe('Interface'); - // TODO(#2917): implicit component accessors are not Method nodes yet. - // Replace this characterization with the expected User.name target. - expect(fanout).toEqual([]); + expect(userName?.properties).toMatchObject({ + parameterCount: 0, + returnType: 'String', + visibility: 'public', + }); + expect(userTags?.properties).toMatchObject({ + parameterCount: 0, + returnType: 'java.util.List', + visibility: 'public', + }); + expect(explicitNames).toHaveLength(1); + expect(userHasMethod.map((edge) => edge.target).sort()).toEqual(['name', 'tags']); + expect(methodImplements.map((edge) => edge.sourceFilePath).sort()).toEqual([ + expect.stringContaining('Explicit.java'), + expect.stringContaining('User.java'), + ]); + expect(fanout.map((edge) => edge.targetFilePath).sort()).toEqual([ + expect.stringContaining('Explicit.java'), + expect.stringContaining('User.java'), + ]); + expect(directTags?.targetFilePath).toContain('User.java'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); +}); + +describe('Java enum interface heritage (#2918)', () => { + it('links and dispatches an Enum interface method (#2918)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-heritage-')); + try { + writeFixtureRepo(root, { + 'EnumHeritage.java': `import java.lang.annotation.ElementType; + import java.lang.annotation.Target; + @Target(ElementType.TYPE_USE) @interface Marker {} + interface Named { String label(); } + enum Status implements @Marker Named { + ACTIVE; + public String label() { return "active"; } + } + class Reader { + String read(Named value) { return value.label(); } + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter( + (edge) => edge.source === 'Status' && edge.target === 'Named', + ); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ); + + expect(implementsEdges).toHaveLength(1); + expect(implementsEdges[0]?.sourceLabel).toBe('Enum'); + expect(implementsEdges[0]?.targetLabel).toBe('Interface'); + expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:EnumHeritage.java:Status.label#0', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps enum constant-body methods distinct while preserving enum heritage (#2918)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-constant-body-')); + try { + writeFixtureRepo(root, { + 'EnumConstantBody.java': `interface Named { String label(); } + enum Status implements Named { + ACTIVE { public String label() { return "active"; } }, + INACTIVE; + public String label() { return "inactive"; } + } + class Reader { + String read(Named value) { return value.label(); } + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter( + (edge) => edge.source === 'Status' && edge.target === 'Named', + ); + + expect(implementsEdges).toHaveLength(1); + expect(implementsEdges[0]?.sourceLabel).toBe('Enum'); + expect(getNodesByLabel(linked, 'Method').filter((name) => name === 'label')).toHaveLength(3); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ); + expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:EnumConstantBody.java:Status$1.label#0', + 'Method:EnumConstantBody.java:Status.label#0', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps non-synthetic implementations ahead of abstract enum constant bodies at the cap', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-fanout-cap-')); + try { + const constants = Array.from( + { length: 40 }, + (_, index) => `A${index} { public String label() { return "enum-${index}"; } }`, + ).join(',\n'); + const classes = Array.from( + { length: 30 }, + (_, index) => + `class ZImpl${index} extends ZBase { public String label() { return "class-${index}"; } }`, + ).join('\n'); + writeFixtureRepo(root, { + 'Fanout.java': `interface Named { String label(); } + enum AaaBig implements Named { + ${constants}; + public abstract String label(); + } + abstract class ZBase implements Named { public abstract String label(); } + ${classes} + class Reader { String read(Named value) { return value.label(); } }`, + }); + + const loggerCapture = _captureLogger(); + let linked: PipelineResult; + let logRecords: PinoLogRecord[]; + try { + linked = await runPipelineFromRepo(root, () => {}); + logRecords = loggerCapture.records(); + } finally { + loggerCapture.restore(); + } + const fanoutIds = getRelationships(linked, 'CALLS') + .filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ) + .map((edge) => edge.rel.targetId); + + expect(fanoutIds).toHaveLength(32); + for (let index = 0; index < 30; index++) { + expect(fanoutIds).toContain(`Method:Fanout.java:ZImpl${index}.label#0`); + } + expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$1.label#0'); + expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$2.label#0'); + + const warning = logRecords.find( + (record) => + record.msg === + 'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)', + ); + expect(warning).toMatchObject({ + dispatchFanoutSkipped: 38, + fanoutCap: 32, + dispatchFanoutSkippedNames: [ + 'Named.label (70 targets; dropped: AaaBig$3.label, AaaBig$4.label, AaaBig$5.label, AaaBig$6.label, AaaBig$7.label, +33 more)', + ], + }); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 5e77834f4..43e36d5de 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -10,7 +10,11 @@ import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from '../../src/core/ingestion/languages/java/analysis-features.js'; const FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, @@ -18,6 +22,8 @@ const FEATURES = [ SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, ] as const; describe('analysis feature versions', () => { @@ -27,6 +33,8 @@ describe('analysis feature versions', () => { }); expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, + 'java.heritage-captures': 1, + 'java.record-component-accessors': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 466d30700..964b9420d 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -53,7 +53,10 @@ import { SPRING_AOP_EVIDENCE_ID_PREFIX, } from '../../src/core/ingestion/frameworks/spring/aop.js'; import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from '../../src/core/ingestion/languages/java/analysis-features.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); @@ -96,6 +99,24 @@ async function setupSpringBeanIncrementalRepo() { return repo; } +async function setupJavaEnumHeritageIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-java-enum-heritage-'); + const src = path.join(repo.dbPath, 'src'); + await mkdir(src, { recursive: true }); + await writeFile( + path.join(src, 'Status.java'), + 'interface Named { String label(); }\n' + + 'enum Status implements Named {\n' + + ' ACTIVE;\n' + + ' public String label() { return "active"; }\n' + + '}\n', + 'utf-8', + ); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial Java enum heritage'); + return repo; +} + async function setupKotlinSpringBeanIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-kotlin-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); @@ -224,6 +245,35 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const rows = (await adapter.executeQuery( + "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + + "WHERE r.type = 'IMPLEMENTS' RETURN count(r) AS c", + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + +async function deleteStatusImplementsNamed(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + await adapter.executeQuery( + "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + + "WHERE r.type = 'IMPLEMENTS' DELETE r", + ); + } finally { + await adapter.closeLbug(); + } +} + /** * Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's * countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes @@ -465,6 +515,43 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('a Java index missing enum heritage evidence rebuilds before the fast path (#2918)', async () => { + const repo = await setupJavaEnumHeritageIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta!.analysisFeatures).toMatchObject({ + [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, + }); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); + + await deleteStatusImplementsNamed(repo.dbPath); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(0); + + await saveMeta( + storagePath, + withoutAnalysisFeature(meta!, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id), + ); + const logs: string[] = []; + const reanalyzed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(reanalyzed.alreadyUpToDate).toBeUndefined(); + expect(logs.join('\n')).toContain(`missing:${JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id}`); + expect((await loadMeta(storagePath))!.analysisFeatures).toMatchObject({ + [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, + }); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); + } finally { + await repo.cleanup(); + } + }, 300_000); + it('a same-commit index with NO fingerprint (pre-#2798) rebuilds once, not grandfathered', async () => { const repo = await setupMiniRepo(); try { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 1901b1df2..4373ebf2c 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -204,24 +204,31 @@ describe('PARSE_CACHE_VERSION', () => { // would take the untagged path, and `check --cycles` would keep reporting the // erased and deferred imports the branch exists to stop reporting: a silent // no-op on incremental analyze while every cold-run test passes. - // 63 rather than 62 or 61: main holds 60, #2935 claims 61, and #2936 claims 62 - // — the next free value above every in-flight MAXIMUM, not above origin/main. - // This branch staged 62 first and was correct when written; #2936 opened four - // hours later, re-checked against main rather than the in-flight claims, and - // took 62 as well. Moving instead of standing on seniority, because 63 is - // right whichever of the two merges first. - it('pins SCHEMA_BUMP to 63 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(63); + // Main subsequently advanced through 63. Values above it must remain distinct + // from both published branch heads and every active in-flight claim. + // Moved 63 -> 64 for Java enum and annotated heritage captures (#2918), + // then 64 -> 66 for the synthetic-declaration sidecar, both now on main. + // Moved 66 -> 67 for #2917's implicit Java record-component accessor + // definitions and scope declarations. This branch staged 65 before #2918's 66 + // landed; 67 is the next free value above every in-flight claim (main 66, + // #2939's 64), re-checked against the claims rather than against main alone. + // Moved 67 -> 68 for #2912's `ReferenceSite.typeArguments` — heritage generic + // arguments derived at extraction time, so a warm cache replays `inherits` + // sites without them and instantiation-aware dispatch degrades silently to + // the pre-fix fan-out. This branch staged 64 above the claims live at the + // time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the + // next free value above every claim at merge — the rule, re-applied. + it('pins SCHEMA_BUMP to 68 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(68); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. - // Every live neighbour is named: 60 is what origin/main holds, so a rebase - // that drops this branch's bump lands there; 61 is claimed by BOTH #2935 and - // #2840 (a live clash of their own); and 62 is #2936's claim, which is what - // this value moved off. - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(60); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(61); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(62); + // Every nearby historical value is rejected: origin/main advanced through + // 67, and this branch previously published 64. Pinning 68 and rejecting all + // prior values makes an accidental conflict resolution loud. + for (const taken of [60, 61, 62, 63, 64, 65, 66, 67]) { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); + } }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 06711a958..7f9a83c43 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js'; +import { javaRecordMethodExtractor } from '../../src/core/ingestion/languages/java/record-components.js'; import { javaMethodConfig, kotlinMethodConfig, @@ -18,6 +19,7 @@ import { phpMethodConfig } from '../../src/core/ingestion/method-extractors/conf import { swiftMethodConfig } from '../../src/core/ingestion/method-extractors/configs/swift.js'; import { goMethodConfig } from '../../src/core/ingestion/method-extractors/configs/go.js'; import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; +import { methodInfoKey } from '../../src/core/ingestion/utils/method-props.js'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import Go from 'tree-sitter-go'; @@ -98,7 +100,7 @@ const csharpCtx: MethodExtractorContext = { // --------------------------------------------------------------------------- describe('Java MethodExtractor', () => { - const extractor = createMethodExtractor(javaMethodConfig); + const extractor = javaRecordMethodExtractor; describe('isTypeDeclaration', () => { it('recognizes class_declaration', () => { @@ -404,6 +406,124 @@ describe('Java MethodExtractor', () => { expect(ctor!.parameters[0].name).toBe('x'); expect(ctor!.parameters[1].name).toBe('y'); }); + + it('synthesizes public zero-argument accessors with full component return types', () => { + const tree = parseJava('public record User(String name, java.util.List tags) {}'); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods).toHaveLength(2); + expect(result!.methods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'name', + returnType: 'String', + parameters: [], + visibility: 'public', + }), + expect.objectContaining({ + name: 'tags', + returnType: 'java.util.List', + parameters: [], + visibility: 'public', + }), + ]), + ); + }); + + it('exposes a varargs component through its array-typed accessor', () => { + const tree = parseJava('public record Samples(String... values) {}'); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods).toContainEqual( + expect.objectContaining({ + name: 'values', + returnType: 'String[]', + parameters: [], + }), + ); + }); + + it('keeps an explicit canonical accessor as the single definition', () => { + const tree = parseJava(` + public record User(String name) { + public String name(/* canonical accessor */) { return name.toUpperCase(); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(1); + expect(accessors[0].line).toBe(3); + }); + + it('does not count an explicit accessor receiver parameter toward arity', () => { + const tree = parseJava(` + public record User(String name) { + public String name(User this) { return name.toUpperCase(); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(1); + expect(accessors[0].parameters).toEqual([]); + expect(accessors[0].line).toBe(3); + }); + + it('retains an explicit overload alongside the implicit accessor', () => { + const tree = parseJava(` + public record User(String name) { + public String name(int repeat) { return name.repeat(repeat); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(2); + expect(accessors.map((method) => method.parameters.length).sort()).toEqual([0, 1]); + }); + + // #2936: the accessor is minted at the COMPONENT's position, so on a single + // line it shares (name, line) with an explicit overload. The worker's + // per-class map keys on that pair, so before `column` the appended implicit + // entry evicted the source-written method and both ids collapsed to `x#0`. + it('gives a same-line implicit accessor and explicit overload distinct map keys', () => { + const tree = parseJava( + 'public record User(String name) { public String name(int repeat) { return name.repeat(repeat); } }', + ); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const keys = result!.methods + .filter((method) => method.name === 'name') + .map((method) => methodInfoKey(method.name, method.line, method.column)) + .sort(); + + expect(keys).toEqual(['name:1:19', 'name:1:34']); + }); + + it.each([ + ['a dropped component type', 'record M(int x, y) {}', ['x']], + ['a nameless varargs component', 'record W(int... ) {}', []], + ['an underscore component', 'record R(int _) {}', []], + ['an underscore varargs component', 'record S(int... _) {}', []], + ])('synthesizes no accessor for %s', (_label, source, expected) => { + const tree = parseJava(source); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods.map((method) => method.name)).toEqual(expected); + }); + + it.each([ + ['a marker annotation', 'record U(@Marker String name) {}', ['@Marker']], + ['an annotation with arguments', 'record U(@Marker("x") String name) {}', ['@Marker']], + ['several annotations', 'record U(@A @B String name) {}', ['@A', '@B']], + ['an annotated varargs component', 'record U(@A String... xs) {}', ['@A']], + ['no annotation', 'record U(String name) {}', []], + ])('propagates %s to the implicit accessor', (_label, source, expected) => { + const tree = parseJava(source); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods[0]!.annotations).toEqual(expected); + }); }); describe('extract primitive varargs', () => { diff --git a/gitnexus/test/unit/method-props.test.ts b/gitnexus/test/unit/method-props.test.ts index cf2fb11ec..cd1be05e0 100644 --- a/gitnexus/test/unit/method-props.test.ts +++ b/gitnexus/test/unit/method-props.test.ts @@ -30,6 +30,7 @@ function makeMethodInfo( annotations: [], sourceFile: 'test.java', line: 1, + column: 0, ...overrides, }; } diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 6c95f6ffd..6ce5cd252 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -406,6 +406,7 @@ describe('parsedfile-store', () => { filePath: 'a.c', type: 'Function', qualifiedName: 'fn', + isSynthetic: true, }; const pf = { filePath: 'a.c', @@ -445,6 +446,7 @@ describe('parsedfile-store', () => { filePath: 'a.c', type: 'Function', qualifiedName: 'fn', + isSynthetic: true, }); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts new file mode 100644 index 000000000..360146886 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts @@ -0,0 +1,367 @@ +/** + * Unit tests for the generic-instantiation matcher behind interface-dispatch + * fan-out (#2912) and for the spelling reader that feeds it. + * + * The integration suite proves the filter reaches real graphs; these pin the + * decisions the filter is MADE of, and above all the fail-open ones — an + * unknown that starts pruning is a silently missing edge, which is the failure + * mode this design is built to avoid. + */ +import { describe, it, expect } from 'vitest'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type HeritageInstantiationStep, +} from '../../../src/core/ingestion/scope-resolution/utils/generic-instantiation.js'; +import { typeApplicationArguments } from '../../../src/core/ingestion/utils/template-arguments.js'; +import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js'; + +/** A step with everything unresolvable and no parameters — the pessimistic + * baseline each test overrides only what it is about. */ +function step(overrides: Partial): HeritageInstantiationStep { + return { + supertypeArguments: undefined, + heritageArguments: undefined, + subtypeParameters: undefined, + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: false }), + resolveHeritageArgument: () => ({ builtIn: false }), + ...overrides, + }; +} + +describe('stepHeritageInstantiation — pruning on positive evidence', () => { + it('prunes an implementor of a different instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['int'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps an implementor of the same instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes on a difference in any position, not just the first', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string', 'User'], heritageArguments: ['string', 'Admin'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('compares what the names RESOLVED to, so a qualifier is not a difference', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Models.User'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes two names that resolved to different declarations', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Admin'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('applies the language normalizer to both sides before comparing', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['String'], + normalize: (name) => (name === 'string' ? 'String' : name), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolved qualified spelling of the same simple name', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['String'], heritageArguments: ['java.lang.String'] }), + ); + expect(result.compatible).toBe(true); + }); +}); + +describe('stepHeritageInstantiation — substitution', () => { + it('binds a type variable instead of comparing it', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('carries the binding in the subtype’s own parameter order', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['V', 'K'], + subtypeParameters: [{ name: 'K' }, { name: 'V' }], + }), + ); + expect(result.subtypeArguments).toEqual(['int', 'string']); + }); + + it('reports an unknown instantiation when a parameter stayed unbound', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }, { name: 'U' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); + + it('prunes a repeated variable the two positions disagree about', () => { + // `class C : Pair` is not a `Pair` at any + // instantiation; the second position must not overwrite the first. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + resolveSupertypeArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps a repeated variable both positions agree about', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'string'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('keeps, without a binding, when a repeated variable cannot be decided', () => { + // `ExternalA` and `ExternalB` are both unresolvable, so the disagreement is + // not proven — and the binding the next hop would inherit is not either. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['ExternalA', 'ExternalB'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); +}); + +describe('stepHeritageInstantiation — every uncertainty keeps the target', () => { + it('keeps when the receiver instantiation is unknown', () => { + const result = stepHeritageInstantiation(step({ heritageArguments: ['int'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the heritage clause recorded no arguments', () => { + const result = stepHeritageInstantiation(step({ supertypeArguments: ['string'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the two argument lists have different lengths', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string', 'int'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps a wildcard receiver argument, which names a SET of types', () => { + // `Repo` genuinely holds a `Repo`; so do Kotlin's + // `Repo<*>` and `Repo`. + for (const wildcard of ['? extends User', '?', '* ', 'out User', 'in User']) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: [wildcard], + heritageArguments: ['User'], + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a nullable spelling of the same argument', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['User?'], heritageArguments: ['User'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('ignores whitespace when comparing nested spellings', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['Map'], + heritageArguments: ['Map'], + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps every implementor for a receiver typed with a CALLER type variable', () => { + // `void Run(IValidator v) { v.Check(x); }`. `T` belongs to the calling + // method, not to the subtype, so `subtypeParametersComplete` — which is + // evidence about the SUBTYPE's list — says nothing about it. An unbounded + // `T` grounds to nothing and a bounded one grounds to its BOUND; both would + // otherwise compare unequal to the implementor's concrete argument. + for (const receiverType of [ + { builtIn: false, typeVariable: true }, + { definitionId: 'def:User', builtIn: false, typeVariable: true }, + ]) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['T'], + heritageArguments: ['Admin'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => receiverType, + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a heritage argument that is a type variable of an ENCLOSING declaration', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: false, typeVariable: true }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolvable argument when the parameter list may be incomplete', () => { + // The `T` of `class Box : IValidator` in a language that captures no + // type parameters: indistinguishable from a concrete type named T, so it + // must not be pruned on. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: false, + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes the same pair once BOTH names are grounded', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['int'], + subtypeParametersComplete: false, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); +}); + +describe('heritageTypeArgumentsKey', () => { + it('keeps a pair distinct from the same ids in the other order', () => { + expect(heritageTypeArgumentsKey('a', 'b')).not.toBe(heritageTypeArgumentsKey('b', 'a')); + }); + + it('separates on a character a file path cannot contain', () => { + // `Class:a b.cs:A` + `Class:c.cs:C` must not be spellable two ways. + expect(heritageTypeArgumentsKey('Class:a b.cs:A', 'Class:c.cs:C')).not.toBe( + heritageTypeArgumentsKey('Class:a', 'b.cs:A Class:c.cs:C'), + ); + }); +}); + +describe('typeApplicationArguments', () => { + it('reads angle-bracket arguments', () => { + expect(typeApplicationArguments('IValidator')).toEqual(['string']); + }); + + it('reads bracket arguments (Go embedding, Python bases)', () => { + expect(typeApplicationArguments('Base[User]')).toEqual(['User']); + }); + + it('splits only at top level', () => { + expect(typeApplicationArguments('Map>')).toEqual(['string', 'List']); + expect(typeApplicationArguments('Cache')).toEqual([ + 'Dict[str, int]', + 'bool', + ]); + }); + + it('declines a plain name, an array spelling, and a constructor call', () => { + expect(typeApplicationArguments('Repository')).toBeUndefined(); + expect(typeApplicationArguments('User[]')).toBeUndefined(); + expect(typeApplicationArguments('Base(args)')).toBeUndefined(); + }); + + it('declines a list that does not close at the end', () => { + expect(typeApplicationArguments('Repo by delegate')).toBeUndefined(); + expect(typeApplicationArguments('(Int) -> Unit')).toBeUndefined(); + }); + + it('declines brackets that cross families', () => { + // A one-family counter never sees the `]`, reaches the final `>` at depth + // zero and reports `Bar]` as a balanced argument list. + expect(typeApplicationArguments('Foo')).toBeUndefined(); + expect(typeApplicationArguments('Foo[Bar>]')).toBeUndefined(); + expect(typeApplicationArguments('Map]')).toBeUndefined(); + // The well-formed mixed nesting it must NOT start declining. + expect(typeApplicationArguments('List')).toEqual(['Dict[a, b]']); + }); +}); + +describe('C# normalizeTypeArgument', () => { + const normalize = csharpScopeResolver.normalizeTypeArgument as (name: string) => string; + + it('makes every spelling of a predefined type one name', () => { + // Including the `global::` alias qualifier, which this repository already + // unwraps when decomposing imports. + for (const spelling of ['string', 'String', 'System.String', 'global::System.String']) { + expect(normalize(spelling)).toBe('String'); + } + expect(normalize('int')).toBe('Int32'); + }); + + it('leaves an unrelated qualified name as written', () => { + expect(normalize('Foo.String')).toBe('Foo.String'); + expect(normalize('Models.User')).toBe('Models.User'); + }); + + it('keeps the qualifier on an ordinary type that merely lives in System', () => { + // Stripping `System.` unconditionally would answer `Custom` here, equating + // this with an unrelated `Custom` elsewhere in the workspace. Only a + // spelling that reduces to a PREDEFINED type earns the strip. + expect(normalize('System.Custom')).toBe('System.Custom'); + expect(normalize('global::System.Custom')).toBe('global::System.Custom'); + expect(normalize('System.Collections.Generic.List')).toBe('System.Collections.Generic.List'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts new file mode 100644 index 000000000..d5683e915 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts @@ -0,0 +1,177 @@ +/** + * Heritage generic ARGUMENTS reach resolution, across languages (#2912). + * + * Three routes exist, and every language uses exactly one of them: + * + * 1. The `@reference.inherits` ANCHOR already spans the whole base, so the + * spelling is read straight off it and no query changed (C#, Java, + * TypeScript, Kotlin, Go, Python, Swift). + * 2. The anchor is the bare NAME node — widening it would move the site's + * range, which is part of every inheritance edge's id — so the arguments + * arrive through the `@reference.type-arguments` sub-tag (Rust, Dart + * `extends`). + * 3. The clause never becomes a reference site at all, and rides a heritage + * MARKER payload instead (Dart `implements` / `with`). + * + * Each is pinned here because instantiation filtering degrades SILENTLY to the + * pre-#2912 fan-out when a capture stops arriving: no error, no failing edge + * count, just an interface reaching implementors of the wrong instantiation + * again. + */ +import { describe, it, expect } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js'; +import { csharpProvider } from '../../../src/core/ingestion/languages/csharp.js'; +import { javaProvider } from '../../../src/core/ingestion/languages/java.js'; +import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js'; +import { kotlinProvider } from '../../../src/core/ingestion/languages/kotlin.js'; +import { goProvider } from '../../../src/core/ingestion/languages/go.js'; +import { pythonProvider } from '../../../src/core/ingestion/languages/python.js'; +import { swiftProvider } from '../../../src/core/ingestion/languages/swift.js'; +import { rustProvider } from '../../../src/core/ingestion/languages/rust.js'; +import { dartProvider } from '../../../src/core/ingestion/languages/dart.js'; +import { decodeMarker } from '../../../src/core/ingestion/utils/heritage-marker.js'; + +function inheritsSites( + provider: LanguageProvider, + source: string, + filePath: string, +): Array<{ name: string; typeArguments?: readonly string[] }> { + const parsed: ParsedFile | undefined = extractParsedFile(provider, source, filePath); + return (parsed?.referenceSites ?? []) + .filter((site) => site.kind === 'inherits') + .map((site) => ({ name: site.name, typeArguments: site.typeArguments })); +} + +describe('heritage type arguments are captured', () => { + it('C# base list', () => { + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record V : IValidator { }', + 'V.cs', + ), + ).toEqual([{ name: 'IValidator', typeArguments: ['string'] }]); + }); + + it('C# record with a primary-constructor base', () => { + // `Base(x)` writes a CALL in the heritage position; the call is not + // part of the type and must not stop the arguments being read. + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record R(int x) : Base(x) { }', + 'R.cs', + ), + ).toEqual([{ name: 'Base', typeArguments: ['int'] }]); + }); + + it('Java implements clause', () => { + expect( + inheritsSites( + javaProvider, + 'package p;\npublic class V implements Validator { }', + 'V.java', + ), + ).toEqual([{ name: 'Validator', typeArguments: ['String'] }]); + }); + + it('TypeScript implements clause', () => { + expect( + inheritsSites(typescriptProvider, 'export class V implements Validator { }', 'v.ts'), + ).toEqual([{ name: 'Validator', typeArguments: ['string'] }]); + }); + + it('Kotlin delegation specifier, with and without a constructor call', () => { + expect(inheritsSites(kotlinProvider, 'class V : Validator() { }', 'v.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + expect(inheritsSites(kotlinProvider, 'class V : Validator { }', 'v2.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Go generic struct embedding (bracket application)', () => { + expect(inheritsSites(goProvider, 'package p\ntype S struct { Base[int] }', 's.go')).toEqual([ + { name: 'Base', typeArguments: ['int'] }, + ]); + }); + + it('Python subscripted base (bracket application)', () => { + expect(inheritsSites(pythonProvider, 'class Repo(Base[User]):\n pass\n', 'r.py')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); + + it('Swift inheritance clause', () => { + expect(inheritsSites(swiftProvider, 'class Repo: Base { }', 'r.swift')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('emitters whose anchor is the bare name use the explicit sub-tag', () => { + it('Rust trait impl', () => { + // The anchor is the trait NAME node inside a `generic_type`, and its range + // is part of the inheritance edge's id — so the arguments arrive through + // `@reference.type-arguments` rather than by widening the anchor. + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v.rs')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Rust trait impl without arguments records none', () => { + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v2.rs')).toEqual([ + { name: 'Validator', typeArguments: undefined }, + ]); + }); + + it('Dart extends clause', () => { + expect(inheritsSites(dartProvider, 'class Repo extends Base { }', 'r.dart')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('heritage that never becomes a reference site', () => { + // Dart's `implements` / `with` travel as heritage MARKERS on parsed imports, + // not as `inherits` sites: `emitDartHeritageEdges` reads the marker and emits + // the edge, so the instantiation has to ride the payload to reach the same + // sink the generic pre-pass writes to (#2912). + function heritageMarkers(source: string, filePath: string): Array { + const parsed = extractParsedFile(dartProvider, source, filePath); + return (parsed?.parsedImports ?? []) + .map((imported) => decodeMarker(String(imported.targetRaw))) + .filter( + (marker): marker is { kind: 'heritage'; fields: string[] } => marker?.kind === 'heritage', + ) + .map((marker) => marker.fields); + } + + it('carries the arguments of a Dart `implements` clause', () => { + expect(heritageMarkers('class V implements Validator { }', 'v.dart')).toEqual([ + ['implements', 'Validator', 'V', ''], + ]); + }); + + it('carries the arguments of a Dart `with` clause', () => { + expect(heritageMarkers('class V extends Base with M { }', 'v2.dart')).toEqual([ + ['with', 'M', 'V', ''], + ]); + }); + + it('omits the field for a non-generic clause, so old payloads stay readable', () => { + expect(heritageMarkers('class V implements Validator { }', 'v3.dart')).toEqual([ + ['implements', 'Validator', 'V'], + ]); + }); +}); + +describe('non-generic heritage stays byte-identical', () => { + it('records no arguments for a plain base', () => { + expect( + inheritsSites(csharpProvider, 'namespace P;\npublic class C : Base { }', 'C.cs'), + ).toEqual([{ name: 'Base', typeArguments: undefined }]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts index 84880fb6f..990ea69de 100644 --- a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -37,6 +37,17 @@ function inheritanceRefs(src: string): string[] { .sort(); } +function recordAccessorDeclarations(src: string) { + return emitJavaScopeCaptures(src, 'C.java') + .filter((m) => m['@declaration.method'] !== undefined) + .map((m) => ({ + name: m['@declaration.name']?.text, + arity: m['@declaration.parameter-count']?.text, + requiredArity: m['@declaration.required-parameter-count']?.text, + returnType: m['@declaration.return-type']?.text, + })); +} + describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => { it('binds the simple name for an unqualified `new User()`', () => { const refs = ctorRefs(wrapExpr('new User()')); @@ -90,7 +101,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () }); }); -describe('emitJavaScopeCaptures — record interface heritage (#2900)', () => { +describe('emitJavaScopeCaptures — record and enum interface heritage (#2900, #2918)', () => { it('captures simple, generic, and qualified record interfaces by lookup name', () => { const refs = inheritanceRefs( 'record User(int id) implements Named, Comparable, audit.Auditable {}', @@ -99,9 +110,119 @@ describe('emitJavaScopeCaptures — record interface heritage (#2900)', () => { expect(refs).toEqual(['Auditable', 'Comparable', 'Named']); }); - it('does not yet emit enum heritage (#2918)', () => { - // Delete this characterization when #2918 adds enum interface heritage. - expect(inheritanceRefs('enum Status implements Named { ACTIVE }')).toEqual([]); + it('captures simple, generic, and qualified enum interfaces by lookup name', () => { + const refs = inheritanceRefs( + 'enum Status implements Named, Tagged, audit.Auditable { ACTIVE }', + ); + + expect(refs).toEqual(['Auditable', 'Named', 'Tagged']); + }); + + it('unwraps type-use annotations on enum interface names', () => { + const refs = inheritanceRefs( + 'enum Status implements @Marker Named, @Marker Tagged, audit.@Marker Auditable { ACTIVE }', + ); + + expect(refs).toEqual(['Auditable', 'Named', 'Tagged']); + }); + + it.each([ + ['class extends', 'class Child extends @Marker Base {}', ['Base']], + ['class implements', 'class Child implements @Marker Named {}', ['Named']], + ['record implements', 'record Child(int id) implements @Marker Named {}', ['Named']], + ['interface extends', 'interface Child extends @Marker Named {}', ['Named']], + ])('unwraps type-use annotations for %s', (_label, source, expected) => { + expect(inheritanceRefs(source)).toEqual(expected); + }); + + it('does not emit an empty inheritance name from a torn annotated base', () => { + expect(inheritanceRefs('enum Status implements @Marker {')).toEqual([]); + }); + + it('preserves the enum constant-body link while adding enum interface heritage', () => { + expect( + inheritanceRefs( + 'enum Status implements Named { ACTIVE { public String label() { return "active"; } } }', + ), + ).toEqual(['Named', 'Status']); + }); +}); + +describe('emitJavaScopeCaptures — record component accessors (#2917)', () => { + it('synthesizes zero-argument declarations with full generic return types', () => { + expect( + recordAccessorDeclarations('record User(String name, java.util.List tags) {}'), + ).toEqual([ + { name: 'name', arity: '0', requiredArity: '0', returnType: 'String' }, + { + name: 'tags', + arity: '0', + requiredArity: '0', + returnType: 'java.util.List', + }, + ]); + }); + + it('uses the array return type for a varargs component accessor', () => { + expect(recordAccessorDeclarations('record Samples(String... values) {}')).toEqual([ + { name: 'values', arity: '0', requiredArity: '0', returnType: 'String[]' }, + ]); + }); + + it('does not duplicate an explicit canonical accessor', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(/* canonical */) { return name; } }', + ); + + expect(declarations.filter((declaration) => declaration.name === 'name')).toHaveLength(1); + }); + + it('does not count an explicit accessor receiver parameter toward arity', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(User this) { return name; } }', + ); + + expect(declarations.filter((declaration) => declaration.name === 'name')).toEqual([ + expect.objectContaining({ arity: '0', requiredArity: '0' }), + ]); + }); + + it('keeps an overload alongside the implicit zero-argument accessor', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(int repeat) { return name; } }', + ).filter((declaration) => declaration.name === 'name'); + + expect(declarations.map((declaration) => declaration.arity).sort()).toEqual(['0', '1']); + }); + + // A component is named by a real `identifier` and nothing else. tree-sitter's + // zero-width MISSING recovery token satisfies `name: (identifier)`, and the + // grammar admits `underscore_pattern` in the same field, so both would mint an + // accessor for source that does not compile. + it.each([ + ['a dropped component type', 'record M(int x, y) {}', ['x']], + ['a nameless varargs component', 'record W(int... ) {}', []], + ['an underscore component', 'record R(int _) {}', []], + ['an underscore varargs component', 'record S(int... _) {}', []], + ])('emits no accessor declaration for %s', (_label, source, expected) => { + expect(recordAccessorDeclarations(source).map((declaration) => declaration.name)).toEqual( + expected, + ); + }); + + it('emits no accessor scope for a component with no usable name', () => { + const scopes = emitJavaScopeCaptures('record M(int x, y) {}', 'C.java') + .filter((m) => m['@scope.function'] !== undefined) + .map((m) => m['@scope.function']?.text); + + expect(scopes).toEqual(['int x']); + }); + + it('is unaffected for a valid record', () => { + expect(recordAccessorDeclarations('record P(int x, String... ys) {}')).toEqual([ + { name: 'x', arity: '0', requiredArity: '0', returnType: 'int' }, + { name: 'ys', arity: '0', requiredArity: '0', returnType: 'String[]' }, + ]); }); }); @@ -120,6 +241,11 @@ describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928) expect(refs.some((r) => r.name === 'Box' && r.arity === '0')).toBe(true); }); + it('unwraps an annotated superclass for explicit `super(...)`', () => { + const refs = ctorRefs('class C extends @Marker Base { C() { super(); } }'); + expect(refs.some((r) => r.name === 'Base' && r.arity === '0')).toBe(true); + }); + it('captures `this(...)` as a constructor ref to the enclosing class name', () => { const src = 'class C { C() { this(1); } C(int x) {} }'; const refs = ctorRefs(src); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index c76ac6932..9e2ac3c4e 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -261,6 +261,22 @@ describe('Pass 2: declarations + local bindings', () => { expect(result.localDefs[0]!.type).toBe('Function'); }); + it('preserves a synthetic declaration marker on the definition', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + declMatch('class', 'Worker$1', 5, 0, 10, 0, { + '@declaration.is-synthetic': cap('@declaration.is-synthetic', 5, 0, 10, 0, 'true'), + }), + ], + 'a.ts', + mockProvider(), + ); + + expect(result.localDefs).toHaveLength(1); + expect(result.localDefs[0]!.isSynthetic).toBe(true); + }); + it('honors `provider.bindingScopeFor` to hoist a binding to an outer scope', () => { // Treat every declaration as hoisted to the module scope. const result = extract( diff --git a/package-lock.json b/package-lock.json index 7ea1b6692..99d1e2c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -330,9 +330,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -411,9 +411,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -943,16 +943,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1386,9 +1386,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1868,9 +1868,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ {