mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) Interface-dispatch fan-out walked the subtype closure with generic arguments erased, so `IValidator<string>` and `IValidator<int>` — one declaration, one subtype list — were indistinguishable and a call through the first reached `IntValidator.Check(int)`, a target no runtime dispatch can produce. The arguments were already in the capture, unread: every language anchors `@reference.inherits` on the whole base node while `@reference.name` keeps the erased base. `ReferenceSite.typeArguments` is therefore derived generically in `scope-extractor.ts` from the anchor's own spelling — no per-language query changed — covering C#, Java, TypeScript, Kotlin, Go (`Base[int]` embedding), Python (`Base[User]`) and Swift; Rust and Dart anchor on the bare name and get nothing, which reads as "unknown". `preEmitInheritanceEdges` is the only code that pairs a heritage site with a resolved (subtype, supertype), so it records the instantiation there and hands it to the dispatch pass. The closure is then walked carrying a substitution, as a type checker would: `Wrapper<T> : IValidator<T>` binds T to the receiver's argument and stays reachable from every instantiation, while its own subtypes are matched against that binding. An incompatible hop is skipped without being marked seen, so a type reachable by a second, compatible path still gets its edge, and without descending, since its subtypes inherit the mismatch. Pruning happens only on positive evidence that two instantiations differ. Unknown arguments on either side, an arity that does not line up, an unresolved qualified spelling of the same simple name, or an argument that might be a type variable the language never captured all keep the target. Telling an uncaptured type VARIABLE from a concrete type is the crux: `typeParameters` is absent both for a non-generic declaration and for every declaration in a language whose query omits `@declaration.type-parameters`, so the pass reads the evidence in front of it — one run resolves one language, so a single generic declaration anywhere in it proves the captures record parameters. A language recording neither arguments nor parameters keeps exactly its pre-#2912 fan-out. Type arguments are compared as resolved declarations rather than spellings, so `Models.User` and an imported `User` are one type; the new optional `ScopeResolver.normalizeTypeArgument` hook canonicalizes a language's predefined aliases, implemented for C# (`string` ≡ `String`) where mixing the spellings would otherwise delete a real implementor. Fan-out cap, skipped-target reporting, overload selection and non-generic closure behaviour are unchanged. SCHEMA_BUMP 60 -> 64: the heritage arguments are a parse-time capture, so a warm cache would replay pre-fix sites and leave the filter silently inert on unchanged files (61/62/63 are claimed by open PRs). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * fix(scope-resolution): close the two generic-dispatch gaps (#2912) The first commit left two shapes on the pre-#2912 fan-out. Both are now covered, and the second one turned out to need a route the pipeline did not have at all. **Folded receivers (Cases 0 and 3b).** `this._validator.Check(x)` is typed by the compound fold, and the fold answers with a CLASS — which is exactly what loses the instantiation, since `IValidator<string>` and `IValidator<int>` fold to one declaration. The fold now reports the SPELLING it typed each receiver position from, through a pure side channel (`recordReceiverType`) added to the one helper every declared-type route already shares plus the two return-type routes; resolution is unchanged whether or not a caller passes it. The reader keeps the last report and uses it only when it names the class the fold returned, so an intermediate position cannot lend its arguments to another class. This covers the dependency-injection shape the issue is really about — a field-held generic interface — and multi-hop chains, where it is the last hop's spelling that types the receiver. **Rust and Dart heritage.** Neither recorded arguments, for two different reasons, so both routes exist now: - Rust's `@reference.inherits` anchor is the trait identifier INSIDE a `generic_type`. Widening the anchor would move the site's range, and that range is part of every inheritance edge's id, so the arguments arrive through a new `@reference.type-arguments` sub-tag instead. - Dart's `implements` / `with` never become reference sites at all: they travel as heritage MARKERS and their edges are emitted by the language hook. The arguments ride the marker payload as an optional fourth field (dropped, not encoded, when the spelling contains the marker delimiter), and `ScopeResolver.emitHeritageEdges` now receives the same sink `preEmitInheritanceEdges` writes to, so whichever pass emits an edge records that edge's instantiation. Dart also gained the `@declaration.type-parameters` capture, without which its own type VARIABLES are indistinguishable from concrete arguments and `class Box<T> implements Validator<T>` would be pruned from every instantiation. Note this makes Rust and Dart record their instantiations; it does not make them fan out. Interface dispatch still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver has no secondary targets to filter. Widening that gate emits new edges for several languages and belongs to its own issue. **Two matcher rules the wider coverage exposed.** A WILDCARD names a set of types rather than one — `Repo<? extends User>` holds a `Repo<User>`, and Kotlin's `Repo<*>` / `Repo<out User>` say the same — so a position with one on either side is unknown; nullable spellings trip the same test, which costs a little precision in the safe direction. And insignificant whitespace inside a nested spelling (`Map<string, User>` vs `Map<string,User>`) is no longer a difference. One expectation changed in the #2833 field-receiver matrix: a `Repo<Repo<User>>` receiver no longer reaches `UserRepo implements Repo<User>`. That edge is precisely the false positive this issue is about, and the primary edge to the interface's own declaration — which is what the matrix row exists to prove — is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * refactor(scope-resolution): apply the quality pass to the #2912 change Three cleanups, no behaviour change. **One balanced-list scanner, not two.** `erasedTypeApplication` and `typeApplicationArguments` each carried a copy of the same fiddly scan — one bracket list, balanced, closing on the last character, non-empty — differing only in what they did with the result. Both now call `balancedTailList`; the rule that rejects `User[][]` and `Repo<User>?` lives in one place instead of being free to drift between two. **The receiver's arguments are parsed after the gates, not before them.** `emitInterfaceDispatchFor` takes the receiver's declared SPELLING and parses it itself, once the owner is known to be an Interface with subtypes. Every one of the five cases calls it unconditionally and the overwhelming majority of receivers are concrete classes that return at the first line, so the parse was running per resolved receiver site to be discarded immediately. Case 4 and Case 6 now hand over the string they already hold, and the folded-receiver helper returns the recorded spelling rather than parsing it. **One question gates the whole instantiation apparatus.** Inside the closure walk, the graph-id lookups now hang off "is the supertype's instantiation known?" — false for every non-generic receiver and for every language that captures no heritage arguments, which is what makes those walks cost exactly what they cost before #2912. Also lifted the argument-route choice in `pass5CollectReferences` out of a nested ternary into a named `heritageTypeArguments`, where the reason the explicit sub-tag wins over the anchor text can be stated once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * test(scope-resolution): cover generic interface dispatch in Kotlin and Go (#2912) Extends the #2912 dispatch coverage past C#/Java/TypeScript. No production code changes — the derivation is language-agnostic by construction (`heritageTypeArguments` reads the heritage anchor's own spelling), so the question was only which languages actually reach the filter. Kotlin rides the shared heritage pre-pass; Go reaches the same filter from the other side, matching implementors structurally while the receiver's `Validator[string]` spelling carries the instantiation. Both are confirmed to prune the mismatched implementor. Each language gets a NON-GENERIC control asserting the fan-out still reaches every implementor. Without it the `not.toContain` assertion passes just as well when a language emits no dispatch edge at all — which is what Dart, Python and Rust were measured doing for this receiver shape, generic or not. They are deliberately not asserted on here: a "filtered correctly" test over a path that never fans out measures nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * test(bench): re-baseline the Rust and Dart capture fingerprints for #2912 The Rust trait-impl and Dart heritage capture changes this branch makes are additive TEXT on existing matches — each carries the instantiation the clause was written with — so they drift the scope-capture digest without adding or removing a match. The baselines were never re-measured when those captures landed, which left `measure.mjs --check` red on this branch independently of the merge. Re-measured rather than hand-edited. Rust's capture_groups_fp (3556) and fixture_count (202) are unchanged across the move, which is the evidence that this is digest drift and not a capture-set regression. The other 13 languages are byte-identical; 15/15 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * refactor(scope-resolution): quality pass over the #2912 change Cleanup only — no behavior change. Findings from a four-angle review (reuse, simplification, efficiency, altitude), applied where they were verified. Reuse / duplication: * `stripTrailingCallSuffix` was a second copy of `matchingOpenParen`'s backward balanced-paren scan. Both now live in `template-arguments.ts` beside `balancedTailList`, for the reason that helper was shared in the first place: two copies of a scan this fiddly are free to disagree. * The two call-return arms of the compound fold repeated the same four-part expression character for character; they share `classOfReturnType` now, the return-type twin of `classOfDeclaredType`, which keeps the "look up by rawName, report the erased application" pairing in one place. * `pipeline/run.ts` implemented first-writer-wins twice — once in the pre-pass and once in the provider sink. One store, one sink, one rule; the pass keeps its `Set<string>` return and the callable-flow-only arm stops building an empty map to satisfy a widened return shape. Simplification: * `subtypeParametersComplete` dropped a disjunct that could never decide: every `subDef` reaching it comes out of the same loop that sets `languageCapturesTypeParameters`, from exactly those defs. * The heritage-argument lookup asked "is the supertype's instantiation known?" three times; `superGraphId` now gates the block once. * `TypeArgumentResolver` and `HeritageInstantiationResult` un-exported — no consumer outside their module. Efficiency (all on the per-site dispatch walk): * `resolveSupertypeArgument` captures only the site, so it is built once per site instead of once per subtype visited; the subtype's scope id is looked up once per subtype instead of once per argument position. * `erasedTypeApplication` no longer runs on every fold hop through a call — the spelling is built only once the lookup has found a class, since it is discarded otherwise. * `normalize`+`compact` computed once per side rather than twice. * Regex literals and the identity `normalize` fallback hoisted to module scope. * C# `System.` prefix stripped with `startsWith`/`slice` instead of a regex. Verified: tsc clean, build clean, 1994 scope-resolution unit tests, 171 generic-dispatch + generic-field-receiver integration tests, 15/15 capture bench fingerprints unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * style: apply Prettier to the two files the quality pass reformatted Whitespace only — `quality / format` (npx prettier --check .) was red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * fix(scope-resolution): close the generic-dispatch review findings (#2912) Addresses the gitnexus-check review on #2939. A repeated type variable was rebound rather than unified: `class C<T> : Pair<T, T>` accepted a `Pair<string, int>` receiver, with `T = int` silently replacing `T = string` and the bogus substitution carried to the next hop. It now unifies, and prunes only on the same positive evidence the concrete path demands — an undecidable repeat keeps the target with no binding. A type PARAMETER of the declaration enclosing either side is now recognised and never compared. `subtypeParametersComplete` is evidence about the SUBTYPE's parameter list and says nothing about a `T` written at the call site, so `void Run<T>(IValidator<T> v) { v.Check(x); }` pruned every implementor: unbounded, `T` grounds to nothing; bounded, it grounds to its BOUND. Both read as a difference of type. That is the missing-edge failure this filter is built to avoid, and it is the common dependency-injection shape in C#, Java and Kotlin. Making that recognition reliable is why generic METHODS now capture `@declaration.type-parameters` in C#, Java and Kotlin — TypeScript already did, which is why its generic functions never had the defect. The capture feeds the existing `bindsTypeParameter` guard, so a method-level `T` also stops resolving to a same-named class in every other lookup. C# alias normalization additionally strips the `global::` qualifier, which `import-decomposer` already unwraps elsewhere: `global::System.String` read as unequal to `string` and pruned a live implementor. The C# captures golden fixture is regenerated for the new capture; the extractor reads `@declaration.type-parameters` generically, so no reader changed. SCHEMA_BUMP 64 already covers these capture changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * fix(scope-resolution): close the two remaining gitnexus-check findings (#2912) `balancedTailList` counted ONE bracket family, so a crossed pair slipped through: scanning `Foo<Bar]>` it never sees the `]`, reaches the final `>` at depth zero, and reports `Bar]` as a balanced argument list — which `typeApplicationArguments` then splits and `erasedTypeApplication` rebuilds a spelling from. It now tracks a stack of expected closers, so every closer must match the opener it actually closes and a crossed pair declines to `undefined`, the "unknown" both callers already fail open on. Well-formed mixed nesting (`List<Dict[a, b]>`) is unaffected. C# `normalizeTypeArgument` stripped `System.` from every qualified spelling, so `System.Custom` answered `Custom` and compared equal to an unrelated `Custom` elsewhere in the workspace. The strip is now earned: a keyword answers from the alias table first, and the qualifier is dropped only when what remains IS a predefined type. `System.Custom` is returned as written and goes to the identity comparison instead — the step that can actually tell two declarations apart. `global::System.String` still meets `string`. Both are pinned by unit tests, including the well-formed mixed nesting and the `global::`-qualified ordinary type that must keep its qualifier. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * docs(csharp): record why a shadowed `String` keeps its implementor (#2912) Answers a review finding rather than changing behavior. A workspace may declare its own type named `String`, shadowing the BCL simple name, and the alias table then reads `IValidator<String>` as the `string` instantiation and keeps that implementor. That is the SAFE direction, not an oversight: pruning instead would rest on the belief that two spellings differ, which is the missing-edge failure `generic-instantiation.ts` exists to avoid. Resolving rather than normalizing cannot settle it either — the identity comparison needs a `definitionId` from both sides, and a built-in name carries none, so "built-in versus workspace-declared implies different" would be a new prune with no positive evidence behind it. The cost is one surplus edge for that pair, which is exactly the pre-#2912 fan-out and no worse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * refactor(scope-resolution): pair the receiver spelling with the class structurally (#2912) The fan-out needs the spelling a receiver position was typed from, because the class the fold returns has lost the generic arguments. That was carried by a PASS-LEVEL mutable holder, written by every declared-type lookup anywhere in the fold and read back through a def-id coincidence check, with the holder cleared by hand before each call site. Three things were load-bearing and none were enforced: * the reset had to be remembered at every call site. It was not: the Case 3b retry (`rawName` then `rawName + '()'`) reset once, BEFORE the first attempt, so a spelling reported by the attempt that failed could be attributed to the one that succeeded. * the holder outlived every resolution, so a site that resolved through a route reporting nothing could read the previous site's spelling if the def ids happened to line up. * the pairing itself was inferred from "whichever lookup reported last", not from the fold's own bookkeeping — losing branches (an MRO walk that moved on, a step later folded past) report too. `foldReceiverChain` already had the answer and threw it away: its final `FoldState` holds `def` and `declaredType` produced by the SAME step. It now reports that pairing last, so the structural route is the one that stands. `resolveCompoundReceiverTyped` returns `{def, declaredSpelling}` and owns a sink created and read within the single call, which is what removes the reset discipline — a local cannot be forgotten, and each of the two retry attempts carries its own. The def-id guard stays as the check that a report names the class actually returned. Behavior is unchanged: 1975 scope-resolution unit tests, 177 generic-dispatch and generic-field-receiver integration tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d4a95360d
commit
77360e1043
27 changed files with 2252 additions and 101 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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────
|
||||
|
|
@ -1255,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,
|
||||
|
|
@ -1306,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 }
|
||||
: {}),
|
||||
|
|
@ -1322,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`).
|
||||
|
|
@ -1720,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,9 +544,26 @@ 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. Source-written owners
|
||||
|
|
@ -483,16 +582,31 @@ export function emitReceiverBoundCalls(
|
|||
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 },
|
||||
{
|
||||
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 (
|
||||
|
|
@ -501,6 +615,42 @@ export function emitReceiverBoundCalls(
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
|
@ -533,6 +683,7 @@ export function emitReceiverBoundCalls(
|
|||
queue.push({
|
||||
typeId: subDef.nodeId,
|
||||
ancestorImplementationCount: descendantImplementationCount,
|
||||
typeArguments: subtypeArguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -789,7 +940,7 @@ export function emitReceiverBoundCalls(
|
|||
receiverName.includes('(') ||
|
||||
site.receiverChain !== undefined
|
||||
) {
|
||||
const currentClass = resolveCompoundReceiverClass(
|
||||
const resolved = resolveCompoundReceiverTyped(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
|
|
@ -798,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;
|
||||
|
|
@ -902,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,
|
||||
|
|
@ -909,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
|
||||
|
|
@ -1379,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,
|
||||
|
|
@ -1395,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;
|
||||
|
|
@ -1496,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,
|
||||
|
|
@ -1503,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
|
||||
|
|
@ -1776,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,
|
||||
|
|
@ -1783,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
|
||||
|
|
@ -2054,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,
|
||||
|
|
@ -2061,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 };
|
||||
}
|
||||
|
|
@ -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}>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -518,8 +518,22 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// 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 = 67;
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -212,21 +212,23 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// 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.
|
||||
it('pins SCHEMA_BUMP to 67 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(67);
|
||||
// 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 nearby historical value is rejected: origin/main advanced through
|
||||
// 66, and this branch previously published 65. Pinning 67 and rejecting all
|
||||
// 67, and this branch previously published 64. Pinning 68 and rejecting all
|
||||
// prior values makes an accidental conflict resolution loud.
|
||||
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);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(63);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(64);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(65);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(66);
|
||||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue