Merge branch 'main' into dependabot/github_actions/actions/checkout-7.0.1

This commit is contained in:
Gergő Magyar 2026-08-14 07:52:22 +01:00 committed by GitHub
commit 7be100e710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 3381 additions and 217 deletions

View file

@ -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<string>` must not reach an implementor of `IValidator<int>`, 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<A> 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<T> : IValidator<T>` stays reachable from every instantiation while `class IntValidator : IValidator<int>` 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<T>(IValidator<T> 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)

View file

@ -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<string>` 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<string>` receiver out to an `IValidator<int>` 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;
/**

View file

@ -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`)

View file

@ -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": {

View file

@ -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",

View file

@ -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<String> 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<String>`) — 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",

View file

@ -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": {

View file

@ -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'. */

View file

@ -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<T>(IValidator<T> 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

View file

@ -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<string>` and `IValidator<String>` 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<String>` 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<string, string> = 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<string> = 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 };

View file

@ -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<User>` 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<String>` / `with M<int>`: 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;
}

View file

@ -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<T> implements Validator<T>\`) from a concrete argument
; (\`class V implements Validator<String>\`) — 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

View file

@ -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<string, ClassDefRef[]>();
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<String>` (#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,

View file

@ -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),

View file

@ -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')),
};

View file

@ -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<T>`, `implements IFoo<T>`) and qualified bases
* (`a.b.Base`, `a.b.Box<T>`, `a.b.IFoo<T>`) 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<String>` → 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;
}

View file

@ -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): \`<T> boolean runAny(Validator<T> 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

View file

@ -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<SyntaxNode, Set<string>>();
function explicitZeroArgAccessorNames(recordNode: SyntaxNode): Set<string> {
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<string> {
const names = new Set<string>();
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);
}

View file

@ -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 <T> runAny(v: Validator<T>)\` 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) }

View file

@ -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<String> 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;

View file

@ -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<String>
// 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),

View file

@ -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,
};
},
};

View file

@ -256,5 +256,6 @@ function buildMethod(
annotations: config.extractAnnotations?.(node) ?? [],
sourceFile: context.filePath,
line: node.startPosition.row + 1,
column: node.startPosition.column,
};
}

View file

@ -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 {

View file

@ -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<string>`);
// `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>` `['string']` (#2912).
*
* Two shapes are handled before the spelling is read as an application:
*
* - A trailing CONSTRUCTOR INVOCATION is dropped. `record R : Base<int>(x)`
* and Kotlin `class C : Bar<Int>()` 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<T>`
* 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<string> = new Set<string>([
'@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',

View file

@ -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<String> for V`, `class V implements Validator<String>`)
* 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<string>` must still fan out
* to `class V : IValidator<String>`. 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

View file

@ -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<string>` and `IValidator<int>` 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<string>` 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<User>` 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<string>`), 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

View file

@ -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<string, SymbolDefinition[]>();
// 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<string, string>();
/**
* 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<T> : IValidator<T>` 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<string>` and `IValidator<int>` are one declaration and therefore
* one subtype list, so without the substitution a `IValidator<string>` 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<string>`), 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<string>([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<string, DispatchTarget>();
const bestIncomingCount = new Map<string, number>([[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<Args>` 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;

View file

@ -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<typeof finalizeScopeModel>,
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
recordTypeArguments: HeritageTypeArgumentSink,
): Set<string> {
const handledSites = new Set<string>();
const seen = new Set<string>();
@ -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<string>`),
// 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<string, readonly string[]>();
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<string>()
: 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;

View file

@ -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<string>` and
* `class IntValidator : IValidator<int>` both land in `IValidator`'s subtype
* list, indistinguishable once the arguments are erased. A call through an
* `IValidator<string>` 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<string>` super args ['string']
* `UserValidator : IValidator<string>` ['string'] ['string'] keep
* `IntValidator : IValidator<int>` ['int'] prune
* `Wrapper<T> : IValidator<T>` ['T'] binds T = string keep,
* and the next hop sees `Wrapper` instantiated with ['string'], so
* `IntWrapper : Wrapper<int>` prunes and `StrWrapper : Wrapper<string>`
* 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<T> : IValidator<T>` 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<T>(IValidator<T> 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<T>`.
*/
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<string, readonly string[]>;
/**
* 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<string>`.
* `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<string>`, `['T']` for `: IValidator<T>`.
* `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<string>` 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<T> : IValidator<T>` 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<string, User>` and `Map<string,User>` 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<string, string>();
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<T> :
// IValidator<T>` under an `IValidator<string>` 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<T> : Pair<T, T>` cannot be a `Pair<string, int>`, 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<? extends User>` holds a
// `Repo<User>` perfectly well, and Kotlin's `Repo<*>` or `Repo<out User>`
// 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<T>(IValidator<T> 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 };
}

View file

@ -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

View file

@ -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<string, MethodInfo>,
methodName: string,

View file

@ -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>` `['string']`, `Base[User]`
* `['User']`, `Repository` `undefined`.
*
* The inverse direction of {@link erasedTypeApplication}, which rebuilds the
* `Base<Args>` 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<string>` 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<User>` from index 4 yields `User`.
*
* `undefined` for everything else, which is what both callers need: a list that
* closes early (`User[][]`, `Repo<User>?`), one that never closes
* (`Map<String, (Int) -> Unit>`), an empty one (`User[]`), one whose brackets
* cross families (`Foo<Bar]>`), 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<Bar]>` 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<int, bool>` on the commas that are not inside a nested
* list. Tracks BOTH bracket families so a mixed spelling (`List<Dict[a, b]>`)
* 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<int>(x)` or Kotlin
* `class C : Bar<Int>()`. 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}>`;
}

View file

@ -133,6 +133,7 @@ import {
constTagForId,
buildCollisionGroups,
parameterShapeIdTag,
methodInfoKey,
} from '../utils/method-props.js';
import {
extractTemplateArguments,
@ -739,9 +740,12 @@ const methodInfoCache = new Map<number, Map<string, MethodInfo>>();
/**
* 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<string, MethodInfo>();
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);

View file

@ -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 {

View file

@ -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<string>`), 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

View file

@ -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,

View file

@ -661,7 +661,7 @@
},
"rust-qualified-trait/src/widget.rs": {
"captureGroups": 23,
"digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac"
"digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5"
},
"rust-receiver-resolution/src/main.rs": {
"captureGroups": 25,

View file

@ -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<User>`
// is an implementor of a DIFFERENT instantiation from this receiver's
// `Repo<Repo<User>>`, 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',
},
{

View file

@ -0,0 +1,492 @@
/**
* Interface-dispatch fan-out is generic-instantiation aware (#2912).
*
* `IValidator<string>` and `IValidator<int>` 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<string> 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<T> { bool Check(T item); }`,
'UserValidator.cs': `namespace Probe;
public record UserValidator : IValidator<string> { public bool Check(string item) => true; }`,
'IntValidator.cs': `namespace Probe;
public record IntValidator : IValidator<int> { public bool Check(int item) => true; }`,
'AliasValidator.cs': `namespace Probe;
public class AliasValidator : IValidator<String> { public bool Check(String item) => true; }`,
'GlobalAliasValidator.cs': `namespace Probe;
public class GlobalAliasValidator : IValidator<global::System.String> { public bool Check(String item) => true; }`,
'Wrapper.cs': `namespace Probe;
public class Wrapper<T> : IValidator<T> { public bool Check(T item) => true; }`,
'Runner.cs': `namespace Probe;
public class Runner {
public bool Run(IValidator<string> v) => v.Check("x");
public bool RunInt(IValidator<int> v) => v.Check(1);
public bool RunAny<TItem>(IValidator<TItem> 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<T> : IValidator<T>` 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<String>` ≡ `IValidator<string>`: 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<TItem>(IValidator<TItem> 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<T> { bool Check(T item); }`,
'UserValidator.cs': `namespace Probe;
public class UserValidator : IValidator<string> { public bool Check(string item) => true; }`,
'IntValidator.cs': `namespace Probe;
public class IntValidator : IValidator<int> { public bool Check(int item) => true; }`,
'Service.cs': `namespace Probe;
public class Service {
private readonly IValidator<string> _validator;
public Service(IValidator<string> validator) { _validator = validator; }
public bool Run() => this._validator.Check("x");
}`,
'Holder.cs': `namespace Probe;
public class Holder {
public IValidator<int> 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<T> { boolean check(T item); }`,
'StringValidator.java': `package probe;
public class StringValidator implements Validator<String> {
public boolean check(String item) { return true; }
}`,
'NumberValidator.java': `package probe;
public class NumberValidator implements Validator<Integer> {
public boolean check(Integer item) { return true; }
}`,
'Runner.java': `package probe;
public class Runner {
public boolean run(Validator<String> v) { return v.check("x"); }
public <T> boolean runAny(Validator<T> 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<T> { fun check(item: T): Boolean }`,
'StringValidator.kt': `package probe
class StringValidator : Validator<String> { override fun check(item: String): Boolean = true }`,
'IntValidator.kt': `package probe
class IntValidator : Validator<Int> { override fun check(item: Int): Boolean = true }`,
'Runner.kt': `package probe
class Runner {
fun run(v: Validator<String>): Boolean = v.check("x")
fun <T> runAny(v: Validator<T>, 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<T> { check(item: T): boolean; }`,
'string-validator.ts': `import type { Validator } from './validator.js';
export class StringValidator implements Validator<string> {
check(item: string): boolean { return true; }
}`,
'number-validator.ts': `import type { Validator } from './validator.js';
export class NumberValidator implements Validator<number> {
check(item: number): boolean { return true; }
}`,
'runner.ts': `import type { Validator } from './validator.js';
export function run(v: Validator<string>): boolean { return v.check('x'); }
export function runAny<T>(v: Validator<T>, 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<Int>()` 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<T> { fun check(item: T): Boolean }`,
'StringValidator.kt': `package probe
class StringValidator : Validator<String> {
override fun check(item: String): Boolean = true
}`,
'NumberValidator.kt': `package probe
class NumberValidator : Validator<Int> {
override fun check(item: Int): Boolean = true
}`,
'Runner.kt': `package probe
class Runner { fun run(v: Validator<String>): 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']);
});
});

View file

@ -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<String> 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<String> 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<String>',
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 });
}

View file

@ -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,

View file

@ -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<string[]
}
}
async function countStatusImplementsNamed(repoPath: string): Promise<number> {
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<void> {
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 {

View file

@ -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)', () => {

View file

@ -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<String> 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<String>',
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', () => {

View file

@ -30,6 +30,7 @@ function makeMethodInfo(
annotations: [],
sourceFile: 'test.java',
line: 1,
column: 0,
...overrides,
};
}

View file

@ -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 });

View file

@ -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>): 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 subtypes 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<T> : Pair<T, T>` is not a `Pair<string, int>` 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<? extends User>` genuinely holds a `Repo<User>`; so do Kotlin's
// `Repo<*>` and `Repo<out User>`.
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<string, User>'],
heritageArguments: ['Map<string,User>'],
}),
);
expect(result.compatible).toBe(true);
});
it('keeps every implementor for a receiver typed with a CALLER type variable', () => {
// `void Run<T>(IValidator<T> 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<T> : IValidator<T>` 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<string>')).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<string, List<int>>')).toEqual(['string', 'List<int>']);
expect(typeApplicationArguments('Cache<Dict[str, int], bool>')).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<User> 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<Bar]>')).toBeUndefined();
expect(typeApplicationArguments('Foo[Bar>]')).toBeUndefined();
expect(typeApplicationArguments('Map<Dict[a, b>]')).toBeUndefined();
// The well-formed mixed nesting it must NOT start declining.
expect(typeApplicationArguments('List<Dict[a, b]>')).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');
});
});

View file

@ -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<string> { }',
'V.cs',
),
).toEqual([{ name: 'IValidator', typeArguments: ['string'] }]);
});
it('C# record with a primary-constructor base', () => {
// `Base<int>(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<int>(x) { }',
'R.cs',
),
).toEqual([{ name: 'Base', typeArguments: ['int'] }]);
});
it('Java implements clause', () => {
expect(
inheritsSites(
javaProvider,
'package p;\npublic class V implements Validator<String> { }',
'V.java',
),
).toEqual([{ name: 'Validator', typeArguments: ['String'] }]);
});
it('TypeScript implements clause', () => {
expect(
inheritsSites(typescriptProvider, 'export class V implements Validator<string> { }', 'v.ts'),
).toEqual([{ name: 'Validator', typeArguments: ['string'] }]);
});
it('Kotlin delegation specifier, with and without a constructor call', () => {
expect(inheritsSites(kotlinProvider, 'class V : Validator<String>() { }', 'v.kt')).toEqual([
{ name: 'Validator', typeArguments: ['String'] },
]);
expect(inheritsSites(kotlinProvider, 'class V : Validator<String> { }', '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<User> { }', '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<String> 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<User> { }', '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<string[]> {
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<String> { }', 'v.dart')).toEqual([
['implements', 'Validator', 'V', '<String>'],
]);
});
it('carries the arguments of a Dart `with` clause', () => {
expect(heritageMarkers('class V extends Base with M<int> { }', 'v2.dart')).toEqual([
['with', 'M', 'V', '<int>'],
]);
});
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 }]);
});
});

View file

@ -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<User>, 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<Status>, 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<Status>, 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<String> tags) {}'),
).toEqual([
{ name: 'name', arity: '0', requiredArity: '0', returnType: 'String' },
{
name: 'tags',
arity: '0',
requiredArity: '0',
returnType: 'java.util.List<String>',
},
]);
});
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);

View file

@ -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(

32
package-lock.json generated
View file

@ -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": [
{