mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Merge branch 'main' into dependabot/github_actions/actions/checkout-7.0.1
This commit is contained in:
commit
7be100e710
52 changed files with 3381 additions and 217 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
32
gitnexus-web/package-lock.json
generated
32
gitnexus-web/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
gitnexus/package-lock.json
generated
8
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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'. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
233
gitnexus/src/core/ingestion/languages/java/record-components.ts
Normal file
233
gitnexus/src/core/ingestion/languages/java/record-components.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -256,5 +256,6 @@ function buildMethod(
|
|||
annotations: config.extractAnnotations?.(node) ?? [],
|
||||
sourceFile: context.filePath,
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -661,7 +661,7 @@
|
|||
},
|
||||
"rust-qualified-trait/src/widget.rs": {
|
||||
"captureGroups": 23,
|
||||
"digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac"
|
||||
"digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5"
|
||||
},
|
||||
"rust-receiver-resolution/src/main.rs": {
|
||||
"captureGroups": 25,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function makeMethodInfo(
|
|||
annotations: [],
|
||||
sourceFile: 'test.java',
|
||||
line: 1,
|
||||
column: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 subtype’s own parameter order', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string', 'int'],
|
||||
heritageArguments: ['V', 'K'],
|
||||
subtypeParameters: [{ name: 'K' }, { name: 'V' }],
|
||||
}),
|
||||
);
|
||||
expect(result.subtypeArguments).toEqual(['int', 'string']);
|
||||
});
|
||||
|
||||
it('reports an unknown instantiation when a parameter stayed unbound', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['T'],
|
||||
subtypeParameters: [{ name: 'T' }, { name: 'U' }],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.subtypeArguments).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prunes a repeated variable the two positions disagree about', () => {
|
||||
// `class C<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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
32
package-lock.json
generated
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue