diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03e3730b3..138530d24 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,6 +277,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`. +Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator` must not reach an implementor of `IValidator`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest. + +The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper : IValidator` stays reachable from every instantiation while `class IntValidator : IValidator` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned. + +The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run(IValidator v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place. + Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning. ### Receiver chains and the drop census (#2766) diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 6629dacd3..b559d32e3 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -82,6 +82,28 @@ export interface ReferenceSite { * otherwise, in which case resolution is unchanged. */ readonly rawQualifiedName?: string; + /** + * Top-level generic/template arguments the source wrote ON this reference — + * `class UserValidator : IValidator` yields `['string']` on the + * `inherits` site whose `name` is `IValidator`. + * + * `name` is the BASE name and stays that way: every lookup in resolution is + * keyed by it, and one declaration answers for every instantiation of itself. + * This records what the erasure threw away, so a consumer that needs the + * INSTANTIATION — receiver-bound interface dispatch, which must not fan a + * `IValidator` receiver out to an `IValidator` implementor + * (#2912) — can ask for it without re-parsing the source. + * + * Derived generically from the anchor capture's own text (see + * `collectReferenceSites`), so no language query change is needed: an emitter + * whose `@reference.inherits` anchor spans the whole base gets this for free, + * and one whose anchor is the bare name simply leaves it absent. + * + * ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable + * here, and only the first is safe to act on. Consumers must fail OPEN on + * absence (keep the target), matching `SymbolDefinition.typeParameters`. + */ + readonly typeArguments?: readonly string[]; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 9ad4261db..b010fa30a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -72,8 +72,9 @@ "fixture_count": 178 }, "rust": { - "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", + "fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", @@ -123,8 +124,9 @@ "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { - "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", + "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field — the type arguments the clause was written with (`implements Validator`) — so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.", "_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.", "_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.", diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 615da3c21..18c37ba2b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -93,8 +93,14 @@ const CSHARP_SCOPE_QUERY = ` name: (identifier) @declaration.name) @declaration.enum ;; Declarations — methods / constructors / properties +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \`void Run(IValidator v)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor of \`IValidator\` from the call's fan-out. (method_declaration - name: (identifier) @declaration.name) @declaration.method + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.method (constructor_declaration name: (identifier) @declaration.name) @declaration.constructor diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index eb1d3b10d..4b50efc67 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -102,6 +102,82 @@ const csharpScopeResolver: ScopeResolver = { // files. The compound-receiver walker needs to walk up from the // class scope to find them; see the contract field for rationale. hoistTypeBindingsToModule: true, + + // `IValidator` and `IValidator` are one instantiation, so the + // dispatch fan-out must not read them as two (#2912). See the alias table. + normalizeTypeArgument: normalizeCsharpTypeArgument, }; +/** + * C# predefined type aliases — the 15 keywords the language defines as exact + * synonyms for `System` types (`string` ≡ `System.String`), plus `nint`/`nuint`. + * A codebase mixing the spellings is common enough that StyleCop ships a rule + * about it (SA1121), so the two forms genuinely meet across files. + * + * Keyword → BCL simple name; anything else is returned unchanged, including the + * BCL names themselves (already canonical) and any qualified spelling, which is + * compared as written. + * + * A workspace may legally declare its OWN type named `String`, which shadows the + * BCL simple name; this table then reads `IValidator` as the `string` + * instantiation and KEEPS that implementor in the fan-out. Deliberate, and the + * safe direction: the alternative is pruning on the belief that two spellings + * differ, which is the missing-edge failure `generic-instantiation.ts` is built + * to avoid. Resolving instead of normalizing cannot settle it either — the + * identity comparison needs a `definitionId` from BOTH sides, and a built-in + * name has none, so "built-in versus workspace-declared" would be a new prune + * with no positive evidence behind it. The result is one surplus edge in a + * shape that is rare on its own terms, i.e. exactly the pre-#2912 fan-out for + * that pair and no worse. + */ +const CSHARP_PREDEFINED_TYPE_ALIASES: ReadonlyMap = new Map([ + ['bool', 'Boolean'], + ['byte', 'Byte'], + ['sbyte', 'SByte'], + ['char', 'Char'], + ['decimal', 'Decimal'], + ['double', 'Double'], + ['float', 'Single'], + ['int', 'Int32'], + ['uint', 'UInt32'], + ['long', 'Int64'], + ['ulong', 'UInt64'], + ['short', 'Int16'], + ['ushort', 'UInt16'], + ['nint', 'IntPtr'], + ['nuint', 'UIntPtr'], + ['object', 'Object'], + ['string', 'String'], +]); + +/** The BCL simple names the keywords alias. A spelling that reduces to one of + * these IS the predefined type; anything else that merely happens to sit in + * `System` is an ordinary type and keeps its qualifier. */ +const CSHARP_PREDEFINED_TYPE_NAMES: ReadonlySet = new Set( + CSHARP_PREDEFINED_TYPE_ALIASES.values(), +); + +const CSHARP_SYSTEM_QUALIFIER = /^(?:global::)?System\./; + +function normalizeCsharpTypeArgument(name: string): string { + const named = name.trim(); + // A keyword answers immediately: `string` → `String`. + const aliased = CSHARP_PREDEFINED_TYPE_ALIASES.get(named); + if (aliased !== undefined) return aliased; + // Otherwise the `System.` qualifier is dropped so the fully-qualified + // spelling of a predefined type meets that keyword: `System.String` → + // `String` ≡ `string` → `String`. The optional `global::` alias qualifier goes + // with it — `import-decomposer` already unwraps that spelling elsewhere, and + // leaving it on would make `global::System.String` unequal to `string` and + // prune a live implementor. + // + // ONLY when what remains is a predefined type. `System.Custom` is an ordinary + // type that happens to live in `System`, and answering `Custom` for it would + // equate it with an unrelated `Custom` elsewhere in the workspace. Returned as + // written instead, which sends it to the identity comparison — the step that + // can actually tell two declarations apart. + const bare = named.replace(CSHARP_SYSTEM_QUALIFIER, ''); + return bare !== named && CSHARP_PREDEFINED_TYPE_NAMES.has(bare) ? bare : named; +} + export { csharpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 351eaee7d..a6c5ef773 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -1069,9 +1069,15 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void { for (let i = 0; i < superclass.namedChildCount; i++) { const c = superclass.namedChild(i); if (c !== null && c.type === 'type_identifier') { + // `extends Base` spells the arguments in a SIBLING node, so the + // anchor's own text cannot carry them; the sub-tag does (#2912). + const args = typeArgumentsAfter(superclass, i); out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', c), '@reference.name': nodeToCapture('@reference.name', c), + ...(args === null + ? {} + : { '@reference.type-arguments': nodeToCapture('@reference.type-arguments', args) }), }); break; } @@ -1144,7 +1150,26 @@ function emitHeritageMarkers( for (let i = 0; i < container.namedChildCount; i++) { const c = container.namedChild(i); if (c === null || c.type !== 'type_identifier') continue; - const payload = encodeMarker('heritage', [kind, c.text, className]); + // `implements Validator` / `with M`: the arguments ride the + // marker payload, because this heritage never becomes a reference SITE — + // `emitDartHeritageEdges` reads the marker and emits the edge (#2912). + // Dropped rather than encoded when the spelling contains the marker's own + // ':' delimiter, which `encodeMarker` rejects outright; absence is the + // fail-open value everywhere this is read. + const args = typeArgumentsAfter(container, i)?.text; + const fields = + args === undefined || args.includes(':') + ? [kind, c.text, className] + : [kind, c.text, className, args]; + const payload = encodeMarker('heritage', fields); out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) }); } } + +/** The `type_arguments` node written immediately after `container`'s named + * child at `index` — the arguments of the type that child names — or `null` + * when that type was written without any. */ +function typeArgumentsAfter(container: SyntaxNode, index: number): SyntaxNode | null { + const next = container.namedChild(index + 1); + return next !== null && next.type === 'type_arguments' ? next : null; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index 38496f2ec..fb93f5fb8 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -42,7 +42,15 @@ const DART_SCOPE_QUERY = ` (enum_declaration) @scope.class ; ── Declarations — types ───────────────────────────────────────────────────── -(class_definition name: (identifier) @declaration.name) @declaration.class +; The type-parameter list is matched as an UNNAMED optional child: the Dart +; grammar hangs \`type_parameters\` off \`class_definition\` without a field name. +; Recording it is what lets instantiation-aware interface dispatch tell a type +; VARIABLE (\`class Box implements Validator\`) from a concrete argument +; (\`class V implements Validator\`) — see #2912; absent parameters are +; indistinguishable from a language that captures none, and read as unknown. +(class_definition + name: (identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.class (mixin_declaration (identifier) @declaration.name) @declaration.trait (extension_declaration name: (identifier) @declaration.name) @declaration.class (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 22e1171d1..76bec5d74 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -38,6 +38,8 @@ import { generateId } from '../../../../lib/utils.js'; import { dartProvider } from '../dart.js'; import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { typeApplicationArguments } from '../../utils/template-arguments.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; interface ClassDefRef { @@ -77,6 +79,7 @@ function emitDartHeritageEdges( graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { const defsByName = new Map(); for (const parsed of parsedFiles) { @@ -110,10 +113,19 @@ function emitDartHeritageEdges( if (decoded?.kind !== 'heritage') continue; const parts = decoded.fields; if (parts.length < 3) continue; - const [kind, baseName, childName] = parts; + const [kind, baseName, childName, rawTypeArguments] = parts; const childId = pickClassByName(childName!, parsed.filePath, defsByName); const baseId = pickClassByName(baseName!, parsed.filePath, defsByName); if (childId === undefined || baseId === undefined || childId === baseId) continue; + // The instantiation this clause was written with — `implements + // Validator` (#2912). Recorded before the dedup below, since the + // FIRST writer wins on both sides and an edge deduped here still needs + // its arguments. A marker from a pre-#2912 cache has no fourth field, + // which reads as unknown. + if (rawTypeArguments !== undefined) { + const typeArguments = typeApplicationArguments(rawTypeArguments); + if (typeArguments !== undefined) recordTypeArguments?.(childId, baseId, typeArguments); + } const key = `${childId}->${baseId}:${kind}`; if (emitted.has(key)) continue; emitted.add(key); @@ -211,8 +223,8 @@ export const dartScopeResolver: ScopeResolver = { // `implements` / `with` IMPLEMENTS edges (extends rides the generic // inherits pre-pass; these need an explicit, kind-independent edge type). - emitHeritageEdges: (graph, parsedFiles, nodeLookup) => - emitDartHeritageEdges(graph, parsedFiles, nodeLookup), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, _scopes, recordTypeArguments) => + emitDartHeritageEdges(graph, parsedFiles, nodeLookup, recordTypeArguments), // Dart is statically typed — the field-fallback heuristic over-connects. fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 99c72dc09..507d3ce79 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -89,7 +89,13 @@ const JAVA_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — methods / constructors +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \` boolean runAny(Validator v)\` writes a receiver +;; whose argument is a type VARIABLE, and a pass that cannot tell that from a +;; concrete type prunes every implementor from the call's dispatch fan-out. (method_declaration + type_parameters: (type_parameters)? @declaration.type-parameters name: (identifier) @declaration.name) @declaration.method (constructor_declaration diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index f442a2b37..94dadc59e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -121,7 +121,13 @@ const KOTLIN_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — functions / methods / properties +;; +;; A generic FUNCTION's parameters are read for the same reason a generic type's +;; are (#2912 review): \`fun runAny(v: Validator)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor from the call's dispatch fan-out. (function_declaration + (type_parameters)? @declaration.type-parameters (simple_identifier) @declaration.name) @declaration.function ;; Lambda bound to a val/var: val handler = { x: Int -> target(x) } diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index ae15c99e2..d6685dde3 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -1,5 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findChild, nodeIfType, nodeToCapture, syntheticCapture, @@ -252,10 +253,22 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const traitName = bareTypeIdentifier(traitField); const structName = bareTypeIdentifier(typeField); if (traitName === null || structName === null) return; + // The trait's generic ARGUMENTS (`impl Validator for V`), so + // interface dispatch can tell one instantiation of a trait from another + // (#2912). Emitted as a sub-tag rather than by widening the anchor: the + // anchor is the bare `type_identifier` inside the `generic_type`, and its + // range is part of the inheritance edge's id. + const traitArguments = + traitField.type === 'generic_type' ? findChild(traitField, 'type_arguments') : null; out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', traitName), '@reference.name': nodeToCapture('@reference.name', traitName), '@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text), + ...(traitArguments === null + ? {} + : { + '@reference.type-arguments': nodeToCapture('@reference.type-arguments', traitArguments), + }), }); }); return out; diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 5fd0f1570..bea53d9fd 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -16,6 +16,7 @@ import { import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; @@ -54,6 +55,7 @@ function emitRustTraitImplEdges( parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes: ScopeResolutionIndexes | undefined, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { if (scopes === undefined) return; @@ -83,6 +85,14 @@ function emitRustTraitImplEdges( const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup); if (structGraphId === undefined || traitGraphId === undefined) continue; + // The instantiation the impl was written with — `impl Validator + // for V` (#2912). Recorded against THIS edge's ids, not the pre-pass's: + // the pre-pass sources its edge from the enclosing def, and interface + // dispatch crosses the corrected one emitted here. + if (site.typeArguments !== undefined) { + recordTypeArguments?.(structGraphId, traitGraphId, site.typeArguments); + } + const edgeKey = `${structGraphId}->${traitGraphId}`; if (emitted.has(edgeKey)) continue; emitted.add(edgeKey); @@ -159,8 +169,8 @@ export const rustScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), - emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => - emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) => + emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes, recordTypeArguments), populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index ab82342e8..39b8667ae 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -80,6 +80,7 @@ import type { CallableFlowOperand, CallableFlowPassingMode, CallableFlowSite, + Capture, CaptureMatch, ImportEdge, ParameterTypeClass, @@ -97,7 +98,11 @@ import type { import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; import { isValidReceiverChain } from './utils/receiver-chain-codec.js'; -import { extractTemplateArguments } from './utils/template-arguments.js'; +import { + extractTemplateArguments, + stripTrailingCallSuffix, + typeApplicationArguments, +} from './utils/template-arguments.js'; import { parseTypeParameterList } from './utils/type-parameters.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -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`); + // `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']` (#2912). + * + * Two shapes are handled before the spelling is read as an application: + * + * - A trailing CONSTRUCTOR INVOCATION is dropped. `record R : Base(x)` + * and Kotlin `class C : Bar()` write a call in the heritage position; + * the call is not part of the type, and leaving it attached would make the + * list fail to close at the end and lose the arguments entirely. + * - The application's base must BE the referenced name (`Other::Inner` + * ends with `Inner`). An anchor that spans more than the base type is not + * read at all rather than read wrongly. + * + * `undefined` for a non-generic base and for every spelling that is not exactly + * one balanced argument list — absence is the "unknown" value that consumers + * fail open on, so declining is always safe here. + */ +function referenceTypeArguments( + anchorText: string, + baseName: string, +): readonly string[] | undefined { + const text = stripTrailingCallSuffix(anchorText.trim()); + const opener = text.search(OPENING_BRACKET); + if (opener === -1) return undefined; + if (!text.slice(0, opener).trimEnd().endsWith(baseName)) return undefined; + return typeApplicationArguments(text); +} + +const OPENING_BRACKET = /[<[]/; + function referenceKindFromAnchor(name: string): ReferenceKind | undefined { const suffix = name.slice('@reference.'.length); // Strip sub-tag after the kind (`@reference.call.member` → `call`). @@ -1720,6 +1786,10 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.qualified-name', + // The generic arguments a heritage base was written with, when the emitter's + // anchor is the bare name and cannot carry them (#2912). A sub-tag for the + // usual reason: it spans a sibling node of the anchor, never the site itself. + '@reference.type-arguments', '@reference.property-key', '@reference.callee-position', '@reference.embedded-pointer', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 1e871cc8e..016aa1a29 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -297,6 +297,7 @@ import { LanguageProvider } from '../../language-provider.js'; import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ConversionRankFn } from '../passes/overload-narrowing.js'; +import type { HeritageTypeArgumentSink } from '../utils/generic-instantiation.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; /** A LinearizeStrategy receives the full ancestor map so C3-style @@ -586,6 +587,16 @@ export interface ScopeResolver { * shape. Must be idempotent (the orchestrator may call it more than once * during re-resolution). * + * `recordTypeArguments` is the same sink `preEmitInheritanceEdges` writes to: + * the generic INSTANTIATION a heritage clause was written with, so + * interface-dispatch fan-out can refuse an implementor of an incompatible one + * (#2912). An implementation that emits an edge for a generic base + * (`impl Validator for V`, `class V implements Validator`) + * should call it with the same (source, target) graph ids it just used; + * anything not recorded reads as "unknown" and keeps the pre-#2912 fan-out. + * Ignoring it entirely is correct for a language whose heritage carries no + * type arguments (Ruby `include`). + * * Default: undefined (no extra heritage edges needed). */ readonly emitHeritageEdges?: ( @@ -593,6 +604,7 @@ export interface ScopeResolver { parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes?: ScopeResolutionIndexes, + recordTypeArguments?: HeritageTypeArgumentSink, ) => void; /** @@ -1006,6 +1018,25 @@ export interface ScopeResolver { */ readonly isStaticOnly?: (def: SymbolDefinition) => boolean; + /** + * Optional canonicalizer for a written GENERIC TYPE ARGUMENT, so two + * spellings of one type compare equal during interface-dispatch + * instantiation matching (#2912). + * + * The case it exists for is a language with predefined ALIASES: C# `string` + * and `String` are the same type, so `IValidator` must still fan out + * to `class V : IValidator`. Without the hook the two spellings look + * like two instantiations and the implementor is pruned — a missing edge, + * which is the failure direction #2912 is most concerned to avoid. + * + * Called ONLY on the two sides of one argument comparison, never on a name + * used for lookup, so it may map to whatever canonical form the language + * prefers (`string` → `String`, or the reverse) as long as it is consistent. + * Languages whose types have one spelling each leave it undefined and the + * comparison stays exact. + */ + readonly normalizeTypeArgument?: (name: string) => string; + /** * Optional predicate to gate free-call fallback emission by caller-side * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 5a82ec455..66453dc3e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -24,7 +24,11 @@ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; -import { erasedTypeApplication, stripTemplateArguments } from '../../utils/template-arguments.js'; +import { + erasedTypeApplication, + matchingOpenParen, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecorationStripper } from '../scope/walkers.js'; @@ -75,7 +79,22 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } return { tupleIdx: Number(idxStr), rhs }; } +/** + * Notified with the spelling a receiver position was typed from and the class + * it resolved to — see {@link noteReceiverType}. Pure side channel: this file + * never reads it back, and resolution is identical whether or not it is set. + */ +type ReceiverTypeRecorder = (spelling: string, defId: string) => void; + interface ResolveCompoundReceiverOptions { + /** + * Optional sink for the DECLARED TYPE SPELLINGS this fold typed receiver + * positions from (#2912). The fold returns a class, and a class has lost the + * generic arguments that decide which implementations an interface-typed + * receiver can dispatch to; the caller keeps the last report whose def id + * matches the returned class and reads the arguments off that spelling. + */ + readonly recordReceiverType?: ReceiverTypeRecorder; /** When true (default), if method lookup fails on the receiver's * class, walk its fields and try the lookup on each field's class. * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ @@ -348,17 +367,65 @@ function classOfDeclaredType( typeRef: TypeRef, scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, + recordReceiverType?: ReceiverTypeRecorder, ): SymbolDefinition | undefined { // `declaredAtScope`, never a scope the caller chose: all five sites passed // exactly this `TypeRef`'s own anchor, and taking it as a parameter is what // would let a sixth quietly not — which is the hole this helper exists to // close, one level up. - return resolveClassBindingForName( + const spelling = erasedTypeApplication(typeRef) ?? typeRef.rawName; + const def = resolveClassBindingForName( typeRef.declaredAtScope, - erasedTypeApplication(typeRef) ?? typeRef.rawName, + spelling, scopes, stripDecoration, ); + return noteReceiverType(recordReceiverType, spelling, def); +} + +/** + * Report the SPELLING a receiver position was typed from, alongside the class + * it resolved to (#2912). + * + * The fold answers "which class", which is all dispatch needed until generic + * instantiation mattered: `IValidator` and `IValidator` fold to + * the same declaration. The spelling is the only place the arguments survive, + * and it exists at every one of these lookups already — reporting it costs a + * function call and changes no resolution. + * + * Pairing it with the def id is what makes it usable: the caller keeps the LAST + * report and uses it only if it names the class the fold ultimately returned, + * so a route that typed an intermediate position, or a later route that + * answered differently, cannot lend its arguments to another class. + */ +function noteReceiverType( + record: ReceiverTypeRecorder | undefined, + spelling: string, + def: SymbolDefinition | undefined, +): SymbolDefinition | undefined { + if (def !== undefined) record?.(spelling, def.nodeId); + return def; +} + +/** + * The class a CALL's return type names, reported to the receiver-type side + * channel — the return-type twin of {@link classOfDeclaredType}. + * + * The pairing it exists to keep in one place: the lookup goes through + * `rawName`, while the SPELLING reported alongside it is the erased type + * application, so an `IValidator` return is reported with its + * arguments intact. The spelling is built only once the lookup has actually + * found a class, because it is discarded otherwise — and every fold hop + * through a call reaches this, generic or not. + */ +function classOfReturnType( + retType: TypeRef, + scopes: ScopeResolutionIndexes, + record: ReceiverTypeRecorder | undefined, +): SymbolDefinition | undefined { + const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + if (def === undefined || record === undefined) return def; + return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); } function typeOfMemberOnClass( @@ -374,7 +441,12 @@ function typeOfMemberOnClass( const classScope = classScopeByDefId.get(ownerId); const memberType = classScope?.typeBindings.get(memberName); if (memberType !== undefined) { - const def = classOfDeclaredType(memberType, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + memberType, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // The declared type is reported even when it resolved to no class: // `Promise` and `[]Repo` name nothing in the workspace, and an // await or index step unwrapping them is exactly how they become @@ -404,7 +476,12 @@ function typeOfMemberOnClass( // Same stripper the primary branch above passes. Omitting it here // meant a decorated declared type (`*Host`) resolved on one branch and // not the other, for the same member of the same class. - const def = classOfDeclaredType(hoisted, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + hoisted, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // Identical to the primary branch: a declared type that named no // class is still a usable position when the next step unwraps it. // Returning `undefined` here made `svc.getMap()['k'].run()` decline @@ -569,9 +646,68 @@ export function foldReceiverChain( } // A chain that ended without a class returns undefined naturally — no // separate guard, because `def` IS the signal. + // + // The receiver-type report is made HERE, from the final `FoldState`, because + // that record pairs the class with the spelling that produced it BY + // CONSTRUCTION — same step, same lookup. The individual `classOfDeclaredType` + // calls inside the fold also report, including from steps that were later + // folded past, so the last of those is not reliably about the class the fold + // returns. Reporting the final state last makes it the one that stands. + if (current.def !== undefined && current.declaredType !== undefined) { + options.recordReceiverType?.(current.declaredType, current.def.nodeId); + } return current.def; } +/** A resolved compound receiver, together with the declared spelling that typed + * the position it came from — see {@link resolveCompoundReceiverTyped}. */ +export interface TypedCompoundReceiver { + readonly def: SymbolDefinition; + /** + * The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the route that answered had no declared type to report — a + * construction expression, a namespace target, a static class receiver. The + * fan-out reads its generic arguments off this and restores the unfiltered + * behaviour when it is absent, so declining is always safe (#2912). + */ + readonly declaredSpelling: string | undefined; +} + +/** + * {@link resolveCompoundReceiverClass}, paired with the spelling that typed the + * position (#2912). + * + * The sink is created and read HERE, per call, which is the whole point: a + * recorder that outlives one resolution has to be reset by hand before every + * call, and the retry shapes in this pass make two calls in a row — a reset + * missed at one of them silently attributes the previous receiver's spelling to + * this one. A local cannot be forgotten. + * + * The def-id guard is the second half. Lookups that lost — an MRO walk that + * moved on, a fold step later folded past — report too, so a report counts only + * when it names the class actually returned. `foldReceiverChain` reports its + * final state last for exactly this reason, so the structural route wins. + */ +export function resolveCompoundReceiverTyped( + receiverText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, + options: ResolveCompoundReceiverOptions = {}, +): TypedCompoundReceiver | undefined { + let spelling: string | undefined; + let spellingDefId: string | undefined; + const def = resolveCompoundReceiverClass(receiverText, inScope, scopes, index, { + ...options, + recordReceiverType: (reported, defId) => { + spelling = reported; + spellingDefId = defId; + }, + }); + if (def === undefined) return undefined; + return { def, declaredSpelling: spellingDefId === def.nodeId ? spelling : undefined }; +} + export function resolveCompoundReceiverClass( receiverText: string, inScope: ScopeId, @@ -676,7 +812,12 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } - const viaTb = classOfDeclaredType(tb, scopes, options.stripTypePreservingDecoration); + const viaTb = classOfDeclaredType( + tb, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); if (viaTb !== undefined) return viaTb; // Member-alias / call-result shapes store the RHS path on rawName @@ -769,7 +910,7 @@ export function resolveCompoundReceiverClass( const viaReturn = retType === undefined ? undefined - : findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + : classOfReturnType(retType, scopes, options.recordReceiverType); if (viaReturn !== undefined) return viaReturn; } // Inline construction — `Service(db).m()` / `new Service(db).m()`. @@ -891,7 +1032,7 @@ export function resolveCompoundReceiverClass( } if (retType === undefined) return undefined; - return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + return classOfReturnType(retType, scopes, options.recordReceiverType); } // Mixed dotted + call chain: `obj.field.method().field.method()…`. @@ -967,7 +1108,7 @@ export function resolveCompoundReceiverClass( // two had a fixture. See `classOfDeclaredType` for why this cannot change a // `TypeRef` that was never reduced. let currentClass: SymbolDefinition | undefined = headType - ? classOfDeclaredType(headType, scopes) + ? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType) : findClassBindingInScope(inScope, headMemberName, scopes); // Whether the walk currently sits on the CLASS ITSELF rather than on a // value of that class. Seeded true only when the head resolved straight to @@ -1097,7 +1238,7 @@ export function resolveCompoundReceiverClass( // grounds fell through here — a declined fold is documented as "no answer", // never a veto — and this walk re-minted `other.py:Mapped` from the // workspace index. Same rule, same lookup, so the two routes now agree. - let nextClass = classOfDeclaredType(memberType, scopes); + let nextClass = classOfDeclaredType(memberType, scopes, undefined, options.recordReceiverType); if (nextClass === undefined) { const fromMap = unwrapMapValueToClass(memberType, scopes); if (fromMap !== undefined) nextClass = fromMap; @@ -1167,22 +1308,6 @@ function isInitializerContext(startScope: ScopeId, scopes: ScopeResolutionIndexe return false; } -/** Find the index of the `(` that matches the trailing `)` of a - * call-expression text. Returns -1 if unbalanced. */ -function matchingOpenParen(text: string): number { - if (!text.endsWith(')')) return -1; - let depth = 0; - for (let i = text.length - 1; i >= 0; i--) { - const ch = text[i]; - if (ch === ')') depth++; - else if (ch === '(') { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - /** Max peel iterations for `stripCastWrappers`. Real cast nesting — * including decompiler output like `((Target)((Object)expr))` — * is a handful of levels, and each cast level costs at most two diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 8c34e8088..0000b2451 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -68,6 +68,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { collectNamespaceTargets } from '../scope/namespace-targets.js'; import { + bindsTypeParameter, findClassBindingInScope, findEnclosingClassDef, isReceiverOwnedButUnbound, @@ -86,8 +87,17 @@ import { type CalleeIdCaptureCtx, } from '../graph-bridge/edges.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; -import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; -import { erasedTypeApplication } from '../../utils/template-arguments.js'; +import { + resolveCompoundReceiverClass, + resolveCompoundReceiverTyped, +} from '../passes/compound-receiver.js'; +import { erasedTypeApplication, typeApplicationArguments } from '../../utils/template-arguments.js'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type GroundedTypeArgument, + type HeritageTypeArguments, +} from '../utils/generic-instantiation.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, @@ -124,6 +134,7 @@ type ReceiverBoundProviderSubset = Pick< | 'conversionOnlyArgTypePrefixes' | 'constraintCompatibility' | 'isStaticOnly' + | 'normalizeTypeArgument' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -298,6 +309,14 @@ export function emitReceiverBoundCalls( * degrades a drop's label to `unknown` (the safe direction) and changes no * edge. */ readonly isBuiltInName?: (name: string) => boolean; + /** The generic arguments each heritage clause instantiated its base with, + * from the passes that emitted those heritage edges — the inheritance + * pre-pass, and the language resolvers that emit their own (Rust `impl T + * for S`, Dart `implements` / `with`) (#2912). Read + * ONLY by the interface-dispatch fan-out, to refuse an implementor of an + * incompatible instantiation. Absent ⇒ every heritage instantiation reads + * as unknown ⇒ the pre-#2912 fan-out, unchanged. */ + readonly heritageTypeArguments?: HeritageTypeArguments; } = {}, ): ReceiverBoundResult { let emitted = 0; @@ -331,6 +350,26 @@ export function emitReceiverBoundCalls( // DefIds, and `pickOverload` keys member lookup by those DefIds. Preserving // every part makes dispatch independent of declaration order. const graphIdToClassDefs = new Map(); + // The same correspondence read the other way, so the dispatch walk can name a + // heritage EDGE (which is keyed by graph ids) from the two DEFS it holds. + const classGraphIdByDefId = new Map(); + /** + * Does THIS language record generic type parameters (#2912)? + * + * `SymbolDefinition.typeParameters` is absent both for a non-generic + * declaration and for every declaration in a language whose captures do not + * emit `@declaration.type-parameters`, and instantiation filtering needs the + * two told apart: in the second case a heritage argument `T` is a type + * VARIABLE that would otherwise read as a concrete type named "T", and + * `class Box : IValidator` would be pruned out of every instantiation. + * + * Evidence rather than a declared capability, because the evidence is exactly + * as good and costs nothing: one run resolves one language (`phase.ts` loops + * per language), so a single generic declaration anywhere in it proves the + * captures record parameters. A run where none exists cannot be harmed by the + * answer — with no generic declaration there is no type variable to mistake. + */ + let languageCapturesTypeParameters = false; for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (!isClassLike(def.type)) continue; @@ -342,6 +381,10 @@ export function emitReceiverBoundCalls( graphIdToClassDefs.set(graphId, defs); } defs.push(def); + classGraphIdByDefId.set(def.nodeId, graphId); + if (def.typeParameters !== undefined && def.typeParameters.length > 0) { + languageCapturesTypeParameters = true; + } } } // Direct subtypes of a type, keyed by the SUPERtype's def id. @@ -433,6 +476,34 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isStatic === true; }; + /** + * What does this written type argument NAME, as seen from `scopeId` (#2912)? + * + * The scope is load-bearing: a heritage argument is resolved from the + * declaring class's own scope and a receiver argument from the call site's, + * because a name means what it means where it was WRITTEN. Resolving both + * makes `Models.User` and an imported `User` one type, which a string + * comparison could only get wrong. + * + * Neither answer is an error: a name that binds nothing and is not built in + * comes back ungrounded, which the matcher reads as "unknown" and keeps. + * + * A TYPE PARAMETER is reported as such rather than left to the ungrounded + * path, because `resolveClassBindingForName` answers a bounded one with its + * BOUND's declaration — grounded, and the wrong thing to compare. + */ + const groundTypeArgument = (name: string, scopeId: string | undefined): GroundedTypeArgument => { + const def = + scopeId === undefined ? undefined : resolveClassBindingForName(scopeId, name, scopes); + return { + ...(def !== undefined ? { definitionId: def.nodeId } : {}), + builtIn: options.isBuiltInName?.(name) === true, + ...(scopeId !== undefined && bindsTypeParameter(scopeId, name, scopes) + ? { typeVariable: true } + : {}), + }; + }; + /** * Emit secondary CALLS edges with reason='interface-dispatch' when the primary * receiver-typed edge targeted an Interface's method. @@ -454,6 +525,17 @@ export function emitReceiverBoundCalls( * override further down is an equally real runtime target — dispatch is an * over-approximation by design, and stopping early would silently prefer the * base. + * + * The closure is walked carrying the receiver's generic INSTANTIATION (#2912). + * `IValidator` and `IValidator` are one declaration and therefore + * one subtype list, so without the substitution a `IValidator` call + * reaches `IntValidator.Check(int)` — a target no dispatch can produce. Each + * hop unifies the arguments the subtype wrote against the ones the supertype + * is known to hold; an incompatible hop is skipped BEFORE the visit is + * recorded, so a type reachable by a second, compatible path still gets its + * edge, and skipped WITHOUT descending, because its own subtypes inherit the + * mismatch. + * Every uncertainty keeps the target — see `generic-instantiation.ts`. */ const emitInterfaceDispatchFor = ( ownerDef: SymbolDefinition, @@ -462,9 +544,26 @@ export function emitReceiverBoundCalls( site: ParsedFile['referenceSites'][number], confidence: number, calleeCapture: CalleeIdCaptureCtx | undefined, + /** The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the case could not recover it — which restores the + * unfiltered fan-out for that site rather than guessing. + * + * The SPELLING rather than the parsed arguments, so the parse happens after + * the two gates below rather than at every resolved receiver site: all five + * cases call this unconditionally, and the overwhelming majority of + * receivers are concrete classes that return at the first line. */ + receiverTypeSpelling: string | undefined, ): number => { if (ownerDef.type !== 'Interface') return 0; if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; + const receiverTypeArguments = + receiverTypeSpelling === undefined + ? undefined + : typeApplicationArguments(receiverTypeSpelling); + // Captures only `site`, so it is built once per SITE rather than once per + // subtype visited. Its partner below cannot be: it is keyed by the subtype. + const resolveSupertypeArgument = (name: string): GroundedTypeArgument => + groundTypeArgument(name, site.inScope); // Collect concrete targets across the closure first, so the cap below counts // real dispatch targets rather than types visited. 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(); const bestIncomingCount = new Map([[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` 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; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 78da450fc..fcc456d66 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -95,6 +95,10 @@ import { } from '../passes/callable-value-flow.js'; import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js'; import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; +import { + heritageTypeArgumentsKey, + type HeritageTypeArgumentSink, +} from '../utils/generic-instantiation.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; @@ -149,11 +153,22 @@ function emitInheritanceEdgeDirect( * reference-edge bridge from re-emitting the same sites later. * * @returns Site keys to seed the downstream handled-site skip set. + * + * The generic INSTANTIATION each heritage edge was written with (#2912) goes to + * `recordTypeArguments` rather than out through the return, because the caller + * shares that sink with the language heritage hook — see + * `HeritageTypeArguments` in `utils/generic-instantiation.ts`. This pass is + * where the pairing exists at all: the site carries the arguments and this is + * the only code that resolves the site to a (subtype, supertype) pair, so + * recording it here costs one map write per generic heritage edge, while + * recovering it downstream would mean redoing the resolution against a graph + * edge that no longer carries the spelling. */ function preEmitInheritanceEdges( graph: KnowledgeGraph, scopes: ReturnType, nodeLookup: ReturnType, + recordTypeArguments: HeritageTypeArgumentSink, ): Set { const handledSites = new Set(); const seen = new Set(); @@ -207,6 +222,12 @@ function preEmitInheritanceEdges( const edgeType: 'EXTENDS' | 'IMPLEMENTS' = targetDef.type === 'Interface' || targetDef.type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'; emitInheritanceEdgeDirect(graph, seen, existing, callerGraphId, targetGraphId, edgeType, site); + // The instantiation this heritage clause wrote (`: IValidator`), + // keyed by the same graph-id pair the edge itself carries. Only generic + // bases produce an entry; the sink owns the first-writer-wins rule. + if (site.typeArguments !== undefined) { + recordTypeArguments(callerGraphId, targetGraphId, site.typeArguments); + } } return handledSites; @@ -704,16 +725,38 @@ export function runScopeResolution( }, }); logHeapProbe('sr-post-finalize', `lang=${provider.language}`); + // One store and ONE writer rule for heritage instantiations (#2912), shared by + // the pre-pass below and by the language hook further down — a heritage shape + // the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records + // through the same sink. FIRST writer wins: a repeated (sub, super) pair is a + // partial declaration or a re-listed base, and letting a later entry overwrite + // the first would make dispatch depend on file order. + const heritageTypeArguments = new Map(); + const recordHeritageTypeArguments: HeritageTypeArgumentSink = ( + subtypeGraphId, + supertypeGraphId, + typeArguments, + ) => { + if (typeArguments.length === 0) return; + const key = heritageTypeArgumentsKey(subtypeGraphId, supertypeGraphId); + if (!heritageTypeArguments.has(key)) heritageTypeArguments.set(key, typeArguments); + }; const preEmittedInheritanceSites = callableFlowOnly ? new Set() - : preEmitInheritanceEdges(graph, finalized, nodeLookup); + : preEmitInheritanceEdges(graph, finalized, nodeLookup, recordHeritageTypeArguments); // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because // the heritage declarations are syntactic method calls, not grammar-level // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees // the freshly-emitted IMPLEMENTS edges. if (!callableFlowOnly) { - provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup, finalized); + provider.emitHeritageEdges?.( + graph, + parsedFiles, + nodeLookup, + finalized, + recordHeritageTypeArguments, + ); } // Implicit IMPORTS-edge hook — for languages whose files have compiler- // implicit cross-file visibility (no syntactic import statement). The @@ -980,6 +1023,10 @@ export function runScopeResolution( // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as // the `emitFreeCallFallback` wiring below. isBuiltInName: provider.languageProvider.isBuiltInName, + // What each heritage clause instantiated its base with, so the + // interface-dispatch fan-out can refuse an incompatible instantiation + // (#2912). Empty under `callableFlowOnly`, which emits no dispatch. + heritageTypeArguments, }, ); const receiverExtras = receiverBound.emitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts new file mode 100644 index 000000000..b7dce0a72 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts @@ -0,0 +1,345 @@ +/** + * Generic-instantiation compatibility for interface-dispatch fan-out (#2912). + * + * ── THE PROBLEM ────────────────────────────────────────────────────────────── + * + * Heritage edges are stored between DECLARATIONS, and a declaration answers for + * every instantiation of itself: `class UserValidator : IValidator` and + * `class IntValidator : IValidator` both land in `IValidator`'s subtype + * list, indistinguishable once the arguments are erased. A call through an + * `IValidator` receiver then fans out to `IntValidator.Check(int)` — a + * target no runtime dispatch can produce, because the two instantiations are + * unrelated types. + * + * ── THE MODEL ──────────────────────────────────────────────────────────────── + * + * The subtype closure is walked carrying a SUBSTITUTION, exactly as a type + * checker would. Each hop takes the arguments the supertype is currently known + * to be instantiated with and the arguments the subtype WROTE on that supertype, + * and unifies them positionally: + * + * receiver `IValidator` → super args ['string'] + * `UserValidator : IValidator` ['string'] ≡ ['string'] → keep + * `IntValidator : IValidator` ['int'] ✗ → prune + * `Wrapper : IValidator` ['T'] binds T = string → keep, + * and the next hop sees `Wrapper` instantiated with ['string'], so + * `IntWrapper : Wrapper` prunes and `StrWrapper : Wrapper` + * survives. + * + * ── WHY EVERY UNCERTAINTY FAILS OPEN ───────────────────────────────────────── + * + * Dispatch fan-out is an over-approximation by design: a missing edge is a + * silently wrong answer to "what can this call reach", while a surplus edge is + * the pre-existing, documented imprecision. So this only ever prunes on POSITIVE + * evidence that two instantiations differ, and returns `compatible` for every + * shape it cannot decide — unknown arguments on either side, an arity it cannot + * line up, or an argument that might be a type variable this pipeline did not + * capture. `SymbolDefinition.typeParameters` and `ReferenceSite.typeArguments` + * are both absent for languages whose captures do not populate them, and absence + * means "unknown", never "not generic"; a language that captures neither is + * therefore left with exactly the pre-#2912 fan-out. + * + * That is also why the arguments are RESOLVED rather than string-compared. Two + * spellings that differ are only certainly different types when both bind to + * something this pipeline can see — an imported `User` and a `Models.User` are + * one type, and a lone `T` may be a type variable the capture layer never + * recorded. The caller supplies the evidence (scope lookup + built-in names); + * anything it cannot ground keeps the target. + */ + +import type { TypeParameter } from 'gitnexus-shared'; + +/** + * What a written type argument turned out to name, as far as the pipeline can + * tell from where it was written. + * + * A spelling is GROUNDED when either field answers: it bound to a declaration, + * or the language calls the name built in. Two grounded arguments that are not + * the same type are the only evidence that licenses a prune. An ungrounded + * spelling is `unknown` — it may be an external type, but it may equally be a + * TYPE VARIABLE in a language whose captures do not record type parameters, and + * pruning on that would delete `class Box : IValidator` from every + * instantiation's fan-out. + */ +export interface GroundedTypeArgument { + /** Identity of the declaration this spelling bound to, when it bound to one. + * Comparing identities rather than spellings is what makes `Models.User` and + * an imported `User` one type. */ + readonly definitionId?: string; + /** The language declares this name built in (`string`, `int`). */ + readonly builtIn: boolean; + /** + * The name is a TYPE PARAMETER of a declaration enclosing where it was + * written — the `T` of `void Run(IValidator v)` at the call site, or of + * an outer class around a nested one's heritage clause. + * + * It stands for a different type at every instantiation, so it cannot be + * compared with anything, and it must be recognised SEPARATELY from + * ungrounded: a bounded `T extends User` grounds to its bound's declaration, + * and comparing that bound against a concrete argument would prune every + * implementor of a call written through `IValidator`. + */ + readonly typeVariable?: boolean; +} + +/** Resolve a written type argument from the scope it was written in. */ +type TypeArgumentResolver = (name: string) => GroundedTypeArgument; + +/** + * Generic arguments written on a heritage clause, keyed by the GRAPH-ID pair of + * the edge they were written on — see {@link heritageTypeArgumentsKey}. + * + * Graph ids rather than def ids because that is the identity the heritage edge + * itself carries, and because same-file partial declarations share one node: a + * base listed on any part is the base of the whole type. Absent for every + * non-generic base, for every language whose captures do not record arguments, + * and for heritage that never passes through the inheritance pre-pass (Ruby's + * `include`, Go's structural implements) — all of which read as "unknown". + */ +export type HeritageTypeArguments = ReadonlyMap; + +/** + * Records one heritage edge's instantiation, from whichever pass emitted that + * edge — the generic inheritance pre-pass, or a language's own + * `ScopeResolver.emitHeritageEdges` for heritage the pre-pass cannot express + * (Rust `impl Trait for S`, Dart's `implements` markers). + * + * The ids MUST be the same pair the emitted edge carries, because the dispatch + * walk looks the instantiation up by the edge it is crossing. Recording nothing + * is always safe: absence reads as "unknown" and keeps every target. + */ +export type HeritageTypeArgumentSink = ( + subtypeGraphId: string, + supertypeGraphId: string, + typeArguments: readonly string[], +) => void; + +/** Key for {@link HeritageTypeArguments}. NUL-separated because a graph id + * embeds a file path, and a path may legally contain every other separator a + * reader would reach for first — `:`, `|`, even a space. */ +export function heritageTypeArgumentsKey(subtypeGraphId: string, supertypeGraphId: string): string { + return `${subtypeGraphId}\u0000${supertypeGraphId}`; +} + +/** One hop of the subtype closure, expressed as a substitution problem. */ +export interface HeritageInstantiationStep { + /** + * Arguments the SUPERTYPE is currently known to be instantiated with, in + * declaration order — `['string']` for a receiver typed `IValidator`. + * `undefined` when the instantiation is unknown, which keeps every subtype. + */ + readonly supertypeArguments: readonly string[] | undefined; + /** + * Arguments the SUBTYPE wrote on the supertype in its own heritage clause — + * `['string']` for `: IValidator`, `['T']` for `: IValidator`. + * `undefined` when the subtype named the supertype without arguments, or when + * the language's captures did not record them. + */ + readonly heritageArguments: readonly string[] | undefined; + /** The SUBTYPE's own declared type parameters, in declaration order. */ + readonly subtypeParameters: readonly TypeParameter[] | undefined; + /** + * Does an EMPTY `subtypeParameters` mean "this declaration is not generic"? + * + * The distinction decides whether an unresolvable argument may be pruned on. + * `SymbolDefinition.typeParameters` is absent both for a plain `class C : + * IValidator` and for every declaration in a language whose captures + * record no parameters at all — and the two demand opposite answers, because + * in the second case the `T` of `class Box : IValidator` is also absent + * and would be read as a concrete type named "T". + * + * True when the caller has evidence the parameters ARE recorded: this + * declaration itself lists some, or some declaration in the same language run + * does. False leaves an unresolvable argument unusable as evidence, which is + * the pre-#2912 fan-out for that language. + */ + readonly subtypeParametersComplete: boolean; + /** Ground a supertype argument — resolved from the RECEIVER's scope. */ + readonly resolveSupertypeArgument: TypeArgumentResolver; + /** Ground a heritage argument — resolved from where the HERITAGE was written, + * a different scope from the call site and usually a different file. */ + readonly resolveHeritageArgument: TypeArgumentResolver; + /** Optional language normalization applied to both sides before they are + * compared, for aliases that denote one type (C# `string` / `String`). */ + readonly normalize?: (name: string) => string; +} + +interface HeritageInstantiationResult { + /** False ONLY when the two instantiations are provably different types. */ + readonly compatible: boolean; + /** + * What the SUBTYPE is instantiated with, for the next hop of the walk: + * its own type parameters resolved through this step's bindings. `undefined` + * whenever any parameter stayed unbound — a partially known list would have to + * be tracked per slot, and the whole-list unknown is the fail-open reading. + */ + readonly subtypeArguments: readonly string[] | undefined; +} + +const UNKNOWN: HeritageInstantiationResult = { compatible: true, subtypeArguments: undefined }; + +/** Stand-in for a language that declares no `normalizeTypeArgument`. Module + * level so the 15 that do not are not charged a closure per hop. */ +const identity = (name: string): string => name; + +/** A resolved declaration, or a name the language calls built in. Anything else + * might be a type variable nobody captured. */ +function grounded(type: GroundedTypeArgument): boolean { + return type.definitionId !== undefined || type.builtIn; +} + +/** + * Does this spelling name a SET of types rather than one — a Java wildcard + * (`?`, `? extends User`, `? super User`), a Kotlin star projection (`*`) or + * use-site variance (`out User`, `in User`)? + * + * Nullable decoration (`User?`, `string?`) matches the `?` test too. Keeping it + * in is deliberate: an argument that may or may not be null is still the same + * type for dispatch purposes, so the only cost is declining to prune a position + * that could have been pruned — the direction every other uncertainty here + * takes. + */ +function isWildcard(name: string): boolean { + return WILDCARD_MARK.test(name) || USE_SITE_VARIANCE.test(name); +} + +const WILDCARD_MARK = /[?*]/; +/** Leading whitespace is matched rather than trimmed off, so a spelling that + * carries none — the overwhelming majority — costs no allocation. */ +const USE_SITE_VARIANCE = /^\s*(?:out|in)\s/; + +/** Drop insignificant whitespace so two spellings of one instantiation compare + * equal: `Map` and `Map` are the same type, and + * which one a capture produced depends on how the source was written. */ +function compact(name: string): string { + return name.replace(INSIGNIFICANT_WHITESPACE, ''); +} + +const INSIGNIFICANT_WHITESPACE = /\s+/g; + +/** Last segment of a qualified spelling: `java.lang.String` → `String`, + * `System::Text::Encoding` → `Encoding`. Used only when a name did not + * resolve, so the qualifier is exactly the part nothing can check. */ +function simpleName(name: string): string { + const cut = Math.max(name.lastIndexOf('.'), name.lastIndexOf(':')); + return cut === -1 ? name : name.slice(cut + 1); +} + +/** + * Unify one heritage hop and carry the substitution to the subtype. + * + * Pure and total: no lookups of its own, no throwing, and every branch it cannot + * decide answers {@link UNKNOWN} — compatible, with an unknown instantiation. + */ +export function stepHeritageInstantiation( + step: HeritageInstantiationStep, +): HeritageInstantiationResult { + const { supertypeArguments, heritageArguments, subtypeParameters } = step; + if (supertypeArguments === undefined || heritageArguments === undefined) return UNKNOWN; + // An arity that does not line up means one of the two lists is not what this + // code thinks it is (a spelling the argument splitter read differently, a + // partial specialization, a variadic parameter pack). Nothing positive can be + // concluded from a mismatched pairing, so nothing is. + if (supertypeArguments.length !== heritageArguments.length) return UNKNOWN; + + const normalize = step.normalize ?? identity; + const bindings = new Map(); + for (let i = 0; i < heritageArguments.length; i++) { + const written = heritageArguments[i] as string; + const actual = supertypeArguments[i] as string; + // A type VARIABLE of the subtype binds rather than compares: `Wrapper : + // IValidator` under an `IValidator` receiver means T = string. + if (subtypeParameters?.some((p) => p.name === written) === true) { + const previous = bindings.get(written); + if (previous !== undefined) { + // The SAME variable in a second position must receive the same type: + // `class C : Pair` cannot be a `Pair`, and + // overwriting the first binding would both accept that and hand the + // next hop a substitution the subtype never had. Unify instead — but + // prune only on the evidence the concrete path below demands, since two + // spellings that differ are not yet two types. + const first = step.resolveSupertypeArgument(previous); + const second = step.resolveSupertypeArgument(actual); + if ( + isWildcard(previous) || + isWildcard(actual) || + first.typeVariable === true || + second.typeVariable === true + ) { + return UNKNOWN; + } + if (compact(normalize(previous)) === compact(normalize(actual))) continue; + if (first.definitionId !== undefined && second.definitionId !== undefined) { + if (first.definitionId === second.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + if (grounded(first) && grounded(second)) { + return { compatible: false, subtypeArguments: undefined }; + } + // One side names something this pipeline cannot see. The position is + // undecided, and so is the binding it would have carried onward. + return UNKNOWN; + } + bindings.set(written, actual); + continue; + } + // A WILDCARD names a set of types, not one: `Repo` holds a + // `Repo` perfectly well, and Kotlin's `Repo<*>` or `Repo` + // say the same thing in their own spelling. Comparing one against a + // concrete argument answers a question neither spelling asked, so the + // position is simply unknown. Nullable decoration (`User?`, `string?`) trips + // the same test, which costs a little precision in the safe direction. + if (isWildcard(written) || isWildcard(actual)) continue; + // Normalized once and reused by the simple-name compare below, so both + // comparisons are visibly made on the same normalization. + const writtenKey = compact(normalize(written)); + const actualKey = compact(normalize(actual)); + if (writtenKey === actualKey) continue; + // Differing spellings, which is not yet a difference of TYPE. Resolve both + // where each was written and compare what they bound to: an imported `User` + // and a `Models.User` are one declaration, and a declaration is what the + // instantiation is actually about. + const heritageType = step.resolveHeritageArgument(written); + const supertypeType = step.resolveSupertypeArgument(actual); + // A type PARAMETER in scope where it was written stands for a different type + // at every instantiation, so it is not comparable with anything — and + // `subtypeParametersComplete` says nothing about it, because that flag is + // evidence about the SUBTYPE's parameter list while this `T` belongs to the + // enclosing generic method or class at the other end. Without this branch a + // call written `void Run(IValidator v) { v.Check(x); }` prunes every + // implementor: `T` is unbounded, so it grounds to nothing, and a bounded one + // grounds to its BOUND and compares unequal to the concrete argument. + if (heritageType.typeVariable === true || supertypeType.typeVariable === true) return UNKNOWN; + if (heritageType.definitionId !== undefined && supertypeType.definitionId !== undefined) { + if (heritageType.definitionId === supertypeType.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + // At least one side names something outside this workspace — `String`, + // `HttpClient`, a generated type. That is the COMMON case for a generic + // argument, so refusing to decide here would make the whole filter inert; + // what is compared instead is the simple name, which cannot tell + // `a.User` from `b.User` (kept, the over-approximating direction) but does + // tell `String` from `Integer`. + if (simpleName(writtenKey) === simpleName(actualKey)) continue; + // The one thing a spelling difference must not be read as: a TYPE VARIABLE + // this pipeline never captured. Where the subtype's parameter list is not + // known to be complete, only a pair of grounded names — resolved or built + // in — is safe to prune on. A variable that IS captured never reaches here: + // the subtype's own bind above, and any other declaration's through the + // `typeVariable` test, which is why that test has to be reliable — see the + // type-parameter captures on generic METHODS. + if (!step.subtypeParametersComplete && !(grounded(heritageType) && grounded(supertypeType))) { + return UNKNOWN; + } + return { compatible: false, subtypeArguments: undefined }; + } + + if (subtypeParameters === undefined || subtypeParameters.length === 0) return UNKNOWN; + const subtypeArguments: string[] = []; + for (const parameter of subtypeParameters) { + const bound = bindings.get(parameter.name); + if (bound === undefined) return UNKNOWN; + subtypeArguments.push(bound); + } + return { compatible: true, subtypeArguments }; +} diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts index 9d1f25e8f..6ed710eb7 100644 --- a/gitnexus/src/core/ingestion/utils/template-arguments.ts +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -47,6 +47,129 @@ export function extractTemplateArguments(text: string): string[] | undefined { return args.length > 0 ? args : undefined; } +/** + * The type ARGUMENTS a reference applies to its base, read from the reference's + * own source spelling: `IValidator` → `['string']`, `Base[User]` → + * `['User']`, `Repository` → `undefined`. + * + * The inverse direction of {@link erasedTypeApplication}, which rebuilds the + * `Base` SPELLING so a lookup can stay grounded; this returns the + * ARGUMENTS so a consumer that has already resolved the base can ask which + * instantiation it was (#2912). + * + * Both bracket families count, because both spell type application in a + * heritage position — `class C : IValidator` and Go's `struct { Base[int] }` + * / Python's `class C(Base[User])`. What is NOT accepted is anything that fails + * to be exactly one balanced, non-empty list closing at the very end: + * + * - `Base(args)` — a C# primary-constructor base, not an application. + * - `Foo[]` — an empty list is an array spelling, not arguments. + * - `(Int) -> Unit` — a Kotlin function type, whose `>` closes nothing. + * + * Declining is the safe outcome for all of them: absence reads as "unknown" + * and every consumer of this fails open on it. + */ +export function typeApplicationArguments(spelling: string): string[] | undefined { + const text = spelling.trim(); + const inner = balancedTailList(text, text.search(OPENING_BRACKET)); + if (inner === undefined) return undefined; + const args = splitTopLevelArguments(inner); + return args.length > 0 ? args : undefined; +} + +const OPENING_BRACKET = /[<[]/; + +/** + * The contents of the ONE balanced bracket list that opens at `start` and closes + * on the LAST character of `text` — `Repo` from index 4 yields `User`. + * + * `undefined` for everything else, which is what both callers need: a list that + * closes early (`User[][]`, `Repo?`), one that never closes + * (`Map Unit>`), an empty one (`User[]`), one whose brackets + * cross families (`Foo`), or no bracket at all (`start === -1`). Shared + * because the rule is one rule — `erasedTypeApplication` rebuilds the spelling + * from it and `typeApplicationArguments` splits it, and two copies of a scan + * this fiddly would be free to disagree about `User[][]`. + */ +function balancedTailList(text: string, start: number): string | undefined { + const opener = text[start]; + if (opener !== '<' && opener !== '[') return undefined; + // A STACK of expected closers rather than one counter for one family: a + // counter scanning `Foo` never sees the `]`, reaches the final `>` at + // depth zero and reports `Bar]` as a balanced argument list. Every closer must + // now match the opener it actually closes, so a crossed pair declines — which + // is what the contract above says and what both callers read as "unknown". + const expected: string[] = []; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '<' || ch === '[') { + expected.push(ch === '<' ? '>' : ']'); + continue; + } + if (ch !== '>' && ch !== ']') continue; + if (expected.pop() !== ch) return undefined; + if (expected.length === 0) { + return i === text.length - 1 && i > start + 1 ? text.slice(start + 1, i) : undefined; + } + } + return undefined; +} + +/** Split `string, Map` on the commas that are not inside a nested + * list. Tracks BOTH bracket families so a mixed spelling (`List`) + * does not split inside the inner one. */ +function splitTopLevelArguments(inner: string): string[] { + const args: string[] = []; + let depth = 0; + let tokenStart = 0; + const push = (end: number): void => { + const token = inner.slice(tokenStart, end).trim(); + if (token.length > 0) args.push(token); + }; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '<' || ch === '[') depth++; + else if (ch === '>' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + push(i); + tokenStart = i + 1; + } + } + push(inner.length); + return args; +} + +/** + * Index of the `(` that matches the trailing `)` of `text`, or -1 when the text + * does not end in a balanced call suffix. + * + * Shared for the same reason as {@link balancedTailList}: this scan is fiddly + * enough that two copies would be free to disagree, and it has two unrelated + * readers — splitting a receiver chain at its call, and stripping a base's + * constructor invocation off a heritage spelling. + */ +export function matchingOpenParen(text: string): number { + if (!text.endsWith(')')) return -1; + let depth = 0; + for (let i = text.length - 1; i >= 0; i--) { + const ch = text[i]; + if (ch === ')') depth++; + else if (ch === '(') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Drop a balanced `(...)` that ENDS the text — the argument list of a base's + * constructor invocation, as in `record R : Base(x)` or Kotlin + * `class C : Bar()`. Anything else is returned unchanged. */ +export function stripTrailingCallSuffix(text: string): string { + const open = matchingOpenParen(text); + return open === -1 ? text : text.slice(0, open).trimEnd(); +} + export function stripTemplateArguments(text: string): string { const start = text.indexOf('<'); if (start === -1) return text; @@ -151,21 +274,8 @@ export function erasedTypeApplication(typeRef: TypeRef): string | undefined { if (spelling === undefined) return undefined; const base = typeRef.rawName.trim(); if (base.length === 0 || !spelling.startsWith(base)) return undefined; - const rest = spelling.slice(base.length).trimStart(); - const opener = rest[0]; - if (opener !== '<' && opener !== '[') return undefined; - const closer = opener === '<' ? '>' : ']'; - let depth = 0; - for (let i = 0; i < rest.length; i++) { - if (rest[i] === opener) depth++; - else if (rest[i] === closer) { - depth--; - // The list the spelling opened must close on the LAST character, and must - // have held something: `Repo[User]` yes, `User[]` no, `User[][]` no. - if (depth === 0) { - return i === rest.length - 1 && i > 1 ? `${base}<${rest.slice(1, i)}>` : undefined; - } - } - } - return undefined; + // The list must open immediately after the base and close on the LAST + // character, holding something: `Repo[User]` yes, `User[]` no, `User[][]` no. + const inner = balancedTailList(spelling.slice(base.length).trimStart(), 0); + return inner === undefined ? undefined : `${base}<${inner}>`; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 4548385ef..270e2c0ad 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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`), 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 diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index ed093716c..86dc818ce 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -41,7 +41,7 @@ }, "csharp-assignment-chain/Program.cs": { "captureGroups": 32, - "digest": "7698bdabe97661a2a2539a13cf9d886cd7efb2f4924b17a15597ce63cec5126a" + "digest": "6b8eddb5525ef0358276dc18fba264ee0443a8adf5036a3300891507b99c36ab" }, "csharp-async-binding/Order.cs": { "captureGroups": 9, @@ -49,11 +49,11 @@ }, "csharp-async-binding/OrderService.cs": { "captureGroups": 14, - "digest": "712fb5f3a791581ab56c37df58d8245a17a674a6d2f7bd25b2bc8d1632f751c3" + "digest": "492b2ceffaeae03b6a9d673f961dc5386bf08496ef4a8ae70dae68a604801c2f" }, "csharp-async-binding/Program.cs": { "captureGroups": 37, - "digest": "73735c3910ed4db423302d9575cec86156d420e9961c592c34e0436301cac7ce" + "digest": "fc6bb5b9887e5193c90c687248d890873f5eb40f6a8e5b597729311dd3cc9f0a" }, "csharp-async-binding/User.cs": { "captureGroups": 9, @@ -61,11 +61,11 @@ }, "csharp-async-binding/UserService.cs": { "captureGroups": 14, - "digest": "a4e6b093fa23a86313bc468f8b6a89a96d5c90b1d16f166417745bd36dfbd10f" + "digest": "e68423fbb601a61a100d01ef070e0d37f817be4a4c6555500b56fa2ed290adce" }, "csharp-call-result-binding/App.cs": { "captureGroups": 27, - "digest": "3d8c7dc0b7f5bd60c74d6a595bd4b49bdb13b62522fb7f0c1434495e10e1171b" + "digest": "7439cd5fada77ae186fb76594404a8650e21b4892ff268dfbcad6c3ec741c478" }, "csharp-calls/Services/UserService.cs": { "captureGroups": 11, @@ -93,7 +93,7 @@ }, "csharp-chain-call/Services/UserService.cs": { "captureGroups": 11, - "digest": "9833795eeb79a08ef9c8afb66a58b789ab314e48c1ae3193fe87fec279c55374" + "digest": "36a822100dccd931041f95be155b426d87f8e3d1dd1e2268f152b6f8fbffc6d5" }, "csharp-child-extends-parent/src/App.cs": { "captureGroups": 13, @@ -125,11 +125,11 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14" + "digest": "daeb42918323c3f79de9b72ffc72d2c61bb085a65ffb1f67ca3a0d9a78ec06e8" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, - "digest": "ee8eb9c569b71d7050f292bc7fbf89b68cdbb60b4bcd557f2523c86831891543" + "digest": "1f6dfccf8eef881d22795dc6aa80bd267f0ee6f12538c7cabf5cac71db6a7f57" }, "csharp-dictionary-keys-values/Repo.cs": { "captureGroups": 7, @@ -153,7 +153,7 @@ }, "csharp-field-types/Service.cs": { "captureGroups": 11, - "digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51" + "digest": "4cb300e33d2dcc08f869b6752c4655a34b67aebeb4baa44f37411117486d5f1d" }, "csharp-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -165,7 +165,7 @@ }, "csharp-foreach/Program.cs": { "captureGroups": 20, - "digest": "810f0f65e956343cf6817918dc5b28ce7bc1f1f755f89e008a6e8b05864ff469" + "digest": "0f71f4a62926fb335d32b456d5778812519c0eb2bf85528e2e9073db1b976749" }, "csharp-frozen-binding-collision/App/Program.cs": { "captureGroups": 18, @@ -189,11 +189,11 @@ }, "csharp-generic-type-refs/Program.cs": { "captureGroups": 25, - "digest": "e0cd6ea7dc08f66b651027f964f7a36fd3c4efb7a4584df5f14935b93faace5a" + "digest": "28246dd1c88ecfe07fcee84ba314dbd9e39ccd0c30f20b8fb4633ec71e48e375" }, "csharp-grandparent-resolution/Models/A.cs": { "captureGroups": 10, - "digest": "3cd545b2cbec5fee82e9e3d09f2d2ff7ff940e3bf4b597d7c9080fcd8b526675" + "digest": "159a52b959e021b1a34cc0dfbf1ba1a0748a0f29c84634948e2e85afc75db003" }, "csharp-grandparent-resolution/Models/B.cs": { "captureGroups": 6, @@ -221,7 +221,7 @@ }, "csharp-inline-constructor-receiver/src/Svc.cs": { "captureGroups": 19, - "digest": "c2ec7f452c244d7fda931e32c16e46cc7c59e15f404a4413d18f522ed6c492bb" + "digest": "cfbc783ca38a1149913b71f5dead7fd7a7048ca313cfaa7806c68ac9f1867e1f" }, "csharp-interface-default-method/App.cs": { "captureGroups": 12, @@ -321,7 +321,7 @@ }, "csharp-method-chain-binding/App.cs": { "captureGroups": 60, - "digest": "2b4761e1dfe2d48ac25cfda7ccce95175607926b47db43726f38ad2a16acc6f1" + "digest": "4cdccde81efbe41cac6bc82e33b365ccd79481a440e65012f78cb543421c9873" }, "csharp-method-enrichment/Animal.cs": { "captureGroups": 18, @@ -393,7 +393,7 @@ }, "csharp-null-check-narrowing/Services/App.cs": { "captureGroups": 36, - "digest": "5d840c524610b6a84a2f09981c15327e9f7ea5c2c4b11b09e959d880ac6b9bc9" + "digest": "6c7fa1daf5d12a60403a63d5d29c1b42b99370d12f4be53c8e56bb31e5b09d8a" }, "csharp-null-conditional/App.cs": { "captureGroups": 17, @@ -425,7 +425,7 @@ }, "csharp-overload-interface/App/Caller.cs": { "captureGroups": 15, - "digest": "f1ea2e564c46dab5fb93fb19e81ab3411f458b172820b5dc0bdc57f49ffa88e0" + "digest": "5769b1eda360cd588192335e47035683dae751b9f67e0528cccc066b1e4ed887" }, "csharp-overload-interface/App/Logger.cs": { "captureGroups": 14, @@ -445,7 +445,7 @@ }, "csharp-overload-param-types/Models/UserService.cs": { "captureGroups": 30, - "digest": "178e1a7dd5b07ba3361e1eb28ce73ca6a6075fa8ecb8f2ca40e8e87a410de552" + "digest": "ba08bc90619c578582465cb9f640fd0bf0640a5a6a84fdfed0be987802086bb3" }, "csharp-parent-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, @@ -465,7 +465,7 @@ }, "csharp-pattern-matching/Services/AnimalService.cs": { "captureGroups": 13, - "digest": "2623dcd94520473dc3dd830cfc21675349b6981185b779db3f5a47200b2f44a3" + "digest": "6f308b4411f9ad397e2789d7f85eaaaed78f7879dd16f4d7b8d8e7639335d302" }, "csharp-primary-ctor-heritage/src/BaseEntity.cs": { "captureGroups": 6, @@ -581,7 +581,7 @@ }, "csharp-return-type/Models/User.cs": { "captureGroups": 23, - "digest": "6681e6830c71c25908e50273e4babb41611bc229395d1dbab70ac9f219b68ca8" + "digest": "8ca5e28d14a29fb1f29ca6f19300b26fd99d20bc99bd9f537787a41cd9fe6196" }, "csharp-return-type/Services/App.cs": { "captureGroups": 16, @@ -621,7 +621,7 @@ }, "csharp-spurious-edges-no-csproj/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-spurious-edges/Legacy/Tasks.cs": { "captureGroups": 8, @@ -633,7 +633,7 @@ }, "csharp-spurious-edges/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-struct-overloads/src/Calc.cs": { "captureGroups": 19, @@ -689,7 +689,7 @@ }, "csharp-var-foreach/Program.cs": { "captureGroups": 32, - "digest": "a9c7bf1f2425cece1ecb698abc0c6fa5e7a3bb24e3cc7d2dba50ef7d0651badc" + "digest": "58052d12af8b6e4f34924d59bd965773ce6083cb557adebe28eb1d3edcd41b7a" }, "csharp-variadic-resolution/Services/App.cs": { "captureGroups": 10, @@ -705,7 +705,7 @@ }, "csharp-write-access/Service.cs": { "captureGroups": 11, - "digest": "aa6d8a61ac39db413df10a6bc8b9bad3305327dcdce09e01cf01eff31f945537" + "digest": "f97be4b109be6bdaa583e2e7b3268b2fb90ac1ce3cfaf0291a7873f09103a2f9" }, "synthetic:dao-20": { "captureGroups": 263, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 1505032d6..793451b4a 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -661,7 +661,7 @@ }, "rust-qualified-trait/src/widget.rs": { "captureGroups": 23, - "digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac" + "digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5" }, "rust-receiver-resolution/src/main.rs": { "captureGroups": 25, diff --git a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts index d3473d034..8b4da76d9 100644 --- a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts @@ -1332,7 +1332,13 @@ class PyMultiSvc: rows: [ { caller: 'runTsNested', - targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + // No `UserRepo.save`, and that is the #2912 filter doing its job rather + // than the receiver failing to resolve: `UserRepo implements Repo` + // is an implementor of a DIFFERENT instantiation from this receiver's + // `Repo>`, so no dispatch through it can reach `UserRepo`. + // The primary edge to the interface's own declaration is unaffected, + // which is what still proves the receiver typed correctly here. + targets: ['Method:a.ts:Repo.save#1'], note: 'DISCRIMINATING nested generic: TypeScript reaches the shared lookup, unlike the Java/Kotlin/Rust spelling rows above', }, { diff --git a/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts new file mode 100644 index 000000000..845f5aed6 --- /dev/null +++ b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts @@ -0,0 +1,492 @@ +/** + * Interface-dispatch fan-out is generic-instantiation aware (#2912). + * + * `IValidator` and `IValidator` are one DECLARATION and therefore + * one subtype list, so an erased fan-out reaches implementors of instantiations + * the receiver can never hold. Each language here declares two incompatible + * instantiations of one interface with the SAME method name — the shape the + * issue was filed with — plus the cases the filter must not break: a generic + * pass-through implementor, a non-generic interface, and (C#) the predefined + * alias spellings of one type. + * + * Both ways a receiver gets its type are covered, because they reach the + * instantiation by different routes: a DECLARED receiver (`Validator v`) + * carries it on the type binding, while a FOLDED one (`this._validator`, + * `this._holder.Validator`) is typed by the compound fold, which answers with a + * class and reports the spelling separately. + * + * Every implementor lives in its own file so a dispatch target can be named by + * `targetFilePath`: the two `Check` methods are otherwise indistinguishable by + * node name alone. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; + +/** Files a dispatch edge out of `caller` landed in, deduped and sorted. */ +function dispatchTargetFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter( + (edge) => + edge.source === caller && + edge.target === member && + edge.rel.reason === 'interface-dispatch', + ) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +/** Files ANY resolved call out of `caller` landed in — primary edges included. */ +function calledFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter((edge) => edge.source === caller && edge.target === member) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +describe('C# generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-generic-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public record UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public record IntValidator : IValidator { public bool Check(int item) => true; }`, + 'AliasValidator.cs': `namespace Probe; + public class AliasValidator : IValidator { public bool Check(String item) => true; }`, + 'GlobalAliasValidator.cs': `namespace Probe; + public class GlobalAliasValidator : IValidator { public bool Check(String item) => true; }`, + 'Wrapper.cs': `namespace Probe; + public class Wrapper : IValidator { public bool Check(T item) => true; }`, + 'Runner.cs': `namespace Probe; + public class Runner { + public bool Run(IValidator v) => v.Check("x"); + public bool RunInt(IValidator v) => v.Check(1); + public bool RunAny(IValidator v, TItem item) => v.Check(item); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('does not fan a string-instantiated receiver out to the int implementor', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).not.toContain('IntValidator.cs'); + }); + + it('still reaches the implementor of the matching instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('UserValidator.cs'); + }); + + it('mirrors the filter for the other instantiation', () => { + const intTargets = dispatchTargetFiles(result, 'RunInt', 'Check'); + expect(intTargets).toContain('IntValidator.cs'); + expect(intTargets).not.toContain('UserValidator.cs'); + }); + + it('keeps a generic pass-through implementor for BOTH instantiations', () => { + // `Wrapper : IValidator` is an implementor of every instantiation — + // T binds to the receiver's argument rather than clashing with it. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('Wrapper.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).toContain('Wrapper.cs'); + }); + + it('treats the predefined alias spelling as the same instantiation', () => { + // `IValidator` ≡ `IValidator`: C# defines the keyword as an + // alias, so pruning on the spelling would delete a real dispatch target. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('AliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('AliasValidator.cs'); + }); + + it('treats the `global::`-qualified spelling as that same instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('GlobalAliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('GlobalAliasValidator.cs'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + // `RunAny(IValidator v)` knows no instantiation, so the filter + // has nothing to prune on and must restore the unfiltered fan-out. `TItem` + // is a type parameter of the calling METHOD, which the subtype's own + // parameter-list evidence says nothing about. + const targets = dispatchTargetFiles(result, 'RunAny', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).toContain('IntValidator.cs'); + }); + + it('still emits the primary edge to the interface declaration', () => { + expect(calledFiles(result, 'Run', 'Check')).toContain('IValidator.cs'); + }); +}); + +describe('C# generic dispatch through a FOLDED receiver (#2912)', () => { + // The dependency-injection shape: the receiver is a field reached through a + // dot, so it is typed by the compound fold rather than by a type binding. + // The fold answers with a CLASS, which no longer carries the instantiation — + // the spelling it typed the position from is what does. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-folded-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public class UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public class IntValidator : IValidator { public bool Check(int item) => true; }`, + 'Service.cs': `namespace Probe; + public class Service { + private readonly IValidator _validator; + public Service(IValidator validator) { _validator = validator; } + public bool Run() => this._validator.Check("x"); + }`, + 'Holder.cs': `namespace Probe; + public class Holder { + public IValidator Validator { get; set; } + }`, + 'ChainRunner.cs': `namespace Probe; + public class ChainRunner { + private readonly Holder _holder; + public ChainRunner(Holder holder) { _holder = holder; } + public bool RunChain() => this._holder.Validator.Check(1); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('filters a field-typed receiver by its own instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).not.toContain('IntValidator.cs'); + }); + + it("filters a two-hop chain by the LAST hop's instantiation", () => { + // `this._holder.Validator` — the fold walks two members, and it is the + // second one's declared spelling that types the receiver. + const targets = dispatchTargetFiles(result, 'RunChain', 'Check'); + expect(targets).toContain('IntValidator.cs'); + expect(targets).not.toContain('UserValidator.cs'); + }); +}); + +describe('C# non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-plain-dispatch-')); + writeFixtureRepo(root, { + 'IGreeter.cs': `namespace Probe; + public interface IGreeter { string Greet(); }`, + 'Loud.cs': `namespace Probe; + public class Loud : IGreeter { public string Greet() => "HI"; }`, + 'Quiet.cs': `namespace Probe; + public class Quiet : IGreeter { public string Greet() => "hi"; }`, + 'Runner.cs': `namespace Probe; + public class Runner { public string Run(IGreeter g) => g.Greet(); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['Loud.cs', 'Quiet.cs']); + }); +}); + +describe('Java generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.java': `package probe; + public interface Validator { boolean check(T item); }`, + 'StringValidator.java': `package probe; + public class StringValidator implements Validator { + public boolean check(String item) { return true; } + }`, + 'NumberValidator.java': `package probe; + public class NumberValidator implements Validator { + public boolean check(Integer item) { return true; } + }`, + 'Runner.java': `package probe; + public class Runner { + public boolean run(Validator v) { return v.check("x"); } + public boolean runAny(Validator v, T item) { return v.check(item); } + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).not.toContain('NumberValidator.java'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).toContain('NumberValidator.java'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe +interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe +class StringValidator : Validator { override fun check(item: String): Boolean = true }`, + 'IntValidator.kt': `package probe +class IntValidator : Validator { override fun check(item: Int): Boolean = true }`, + 'Runner.kt': `package probe +class Runner { + fun run(v: Validator): Boolean = v.check("x") + fun runAny(v: Validator, item: T): Boolean = v.check(item) +}`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('IntValidator.kt'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).toContain('IntValidator.kt'); + }); +}); + +describe('TypeScript generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ts-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.ts': `export interface Validator { check(item: T): boolean; }`, + 'string-validator.ts': `import type { Validator } from './validator.js'; + export class StringValidator implements Validator { + check(item: string): boolean { return true; } + }`, + 'number-validator.ts': `import type { Validator } from './validator.js'; + export class NumberValidator implements Validator { + check(item: number): boolean { return true; } + }`, + 'runner.ts': `import type { Validator } from './validator.js'; + export function run(v: Validator): boolean { return v.check('x'); } + export function runAny(v: Validator, item: T): boolean { return v.check(item); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).not.toContain('number-validator.ts'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).toContain('number-validator.ts'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + // Kotlin needs no per-language wiring: it emits heritage through the shared + // pre-pass, so the arguments are read off the clause's own spelling. The + // `class C : Bar()` shape — a base with a constructor invocation — is + // the one `stripTrailingCallSuffix` exists for, and is covered here by the + // supertype being an interface (no call suffix) plus the unit tests on that + // helper. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe + interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe + class StringValidator : Validator { + override fun check(item: String): Boolean = true + }`, + 'NumberValidator.kt': `package probe + class NumberValidator : Validator { + override fun check(item: Int): Boolean = true + }`, + 'Runner.kt': `package probe + class Runner { fun run(v: Validator): Boolean = v.check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('NumberValidator.kt'); + }); +}); + +describe('Kotlin non-generic interface dispatch is unaffected (#2912)', () => { + // The CONTROL for the case above. Without it, the `not.toContain` there + // passes just as well when Kotlin emits no dispatch edge at all — which is + // exactly what Dart, Python and Rust turned out to do for this receiver + // shape, and why they are not asserted on in this file. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-plain-dispatch-')); + writeFixtureRepo(root, { + 'Greeter.kt': `package probe + interface Greeter { fun greet(): String }`, + 'Loud.kt': `package probe + class Loud : Greeter { override fun greet(): String = "HI" }`, + 'Quiet.kt': `package probe + class Quiet : Greeter { override fun greet(): String = "hi" }`, + 'Runner.kt': `package probe + class Runner { fun run(g: Greeter): String = g.greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'run', 'greet')).toEqual(['Loud.kt', 'Quiet.kt']); + }); +}); + +describe('Go generic interface dispatch (#2912)', () => { + // Go reaches the same filter by a different route: implementors are matched + // STRUCTURALLY rather than by a heritage clause, and the receiver's own + // `Validator[string]` spelling is what carries the instantiation. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.go': `package probe + +type Validator[T any] interface { + Check(item T) bool +}`, + 'string_validator.go': `package probe + +type StringValidator struct{} + +func (s StringValidator) Check(item string) bool { return true }`, + 'number_validator.go': `package probe + +type NumberValidator struct{} + +func (n NumberValidator) Check(item int) bool { return true }`, + 'runner.go': `package probe + +func Run(v Validator[string]) bool { return v.Check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('string_validator.go'); + expect(targets).not.toContain('number_validator.go'); + }); +}); + +describe('Go non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-plain-dispatch-')); + writeFixtureRepo(root, { + 'greeter.go': `package probe + +type Greeter interface { + Greet() string +}`, + 'loud.go': `package probe + +type Loud struct{} + +func (l Loud) Greet() string { return "HI" }`, + 'quiet.go': `package probe + +type Quiet struct{} + +func (q Quiet) Greet() string { return "hi" }`, + 'runner.go': `package probe + +func Run(g Greeter) string { return g.Greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['loud.go', 'quiet.go']); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index ac5223a59..4373ebf2c 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts new file mode 100644 index 000000000..360146886 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts @@ -0,0 +1,367 @@ +/** + * Unit tests for the generic-instantiation matcher behind interface-dispatch + * fan-out (#2912) and for the spelling reader that feeds it. + * + * The integration suite proves the filter reaches real graphs; these pin the + * decisions the filter is MADE of, and above all the fail-open ones — an + * unknown that starts pruning is a silently missing edge, which is the failure + * mode this design is built to avoid. + */ +import { describe, it, expect } from 'vitest'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type HeritageInstantiationStep, +} from '../../../src/core/ingestion/scope-resolution/utils/generic-instantiation.js'; +import { typeApplicationArguments } from '../../../src/core/ingestion/utils/template-arguments.js'; +import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js'; + +/** A step with everything unresolvable and no parameters — the pessimistic + * baseline each test overrides only what it is about. */ +function step(overrides: Partial): HeritageInstantiationStep { + return { + supertypeArguments: undefined, + heritageArguments: undefined, + subtypeParameters: undefined, + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: false }), + resolveHeritageArgument: () => ({ builtIn: false }), + ...overrides, + }; +} + +describe('stepHeritageInstantiation — pruning on positive evidence', () => { + it('prunes an implementor of a different instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['int'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps an implementor of the same instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes on a difference in any position, not just the first', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string', 'User'], heritageArguments: ['string', 'Admin'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('compares what the names RESOLVED to, so a qualifier is not a difference', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Models.User'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes two names that resolved to different declarations', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Admin'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('applies the language normalizer to both sides before comparing', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['String'], + normalize: (name) => (name === 'string' ? 'String' : name), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolved qualified spelling of the same simple name', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['String'], heritageArguments: ['java.lang.String'] }), + ); + expect(result.compatible).toBe(true); + }); +}); + +describe('stepHeritageInstantiation — substitution', () => { + it('binds a type variable instead of comparing it', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('carries the binding in the subtype’s own parameter order', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['V', 'K'], + subtypeParameters: [{ name: 'K' }, { name: 'V' }], + }), + ); + expect(result.subtypeArguments).toEqual(['int', 'string']); + }); + + it('reports an unknown instantiation when a parameter stayed unbound', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }, { name: 'U' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); + + it('prunes a repeated variable the two positions disagree about', () => { + // `class C : Pair` is not a `Pair` at any + // instantiation; the second position must not overwrite the first. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + resolveSupertypeArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps a repeated variable both positions agree about', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'string'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('keeps, without a binding, when a repeated variable cannot be decided', () => { + // `ExternalA` and `ExternalB` are both unresolvable, so the disagreement is + // not proven — and the binding the next hop would inherit is not either. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['ExternalA', 'ExternalB'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); +}); + +describe('stepHeritageInstantiation — every uncertainty keeps the target', () => { + it('keeps when the receiver instantiation is unknown', () => { + const result = stepHeritageInstantiation(step({ heritageArguments: ['int'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the heritage clause recorded no arguments', () => { + const result = stepHeritageInstantiation(step({ supertypeArguments: ['string'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the two argument lists have different lengths', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string', 'int'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps a wildcard receiver argument, which names a SET of types', () => { + // `Repo` genuinely holds a `Repo`; so do Kotlin's + // `Repo<*>` and `Repo`. + for (const wildcard of ['? extends User', '?', '* ', 'out User', 'in User']) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: [wildcard], + heritageArguments: ['User'], + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a nullable spelling of the same argument', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['User?'], heritageArguments: ['User'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('ignores whitespace when comparing nested spellings', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['Map'], + heritageArguments: ['Map'], + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps every implementor for a receiver typed with a CALLER type variable', () => { + // `void Run(IValidator v) { v.Check(x); }`. `T` belongs to the calling + // method, not to the subtype, so `subtypeParametersComplete` — which is + // evidence about the SUBTYPE's list — says nothing about it. An unbounded + // `T` grounds to nothing and a bounded one grounds to its BOUND; both would + // otherwise compare unequal to the implementor's concrete argument. + for (const receiverType of [ + { builtIn: false, typeVariable: true }, + { definitionId: 'def:User', builtIn: false, typeVariable: true }, + ]) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['T'], + heritageArguments: ['Admin'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => receiverType, + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a heritage argument that is a type variable of an ENCLOSING declaration', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: false, typeVariable: true }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolvable argument when the parameter list may be incomplete', () => { + // The `T` of `class Box : IValidator` in a language that captures no + // type parameters: indistinguishable from a concrete type named T, so it + // must not be pruned on. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: false, + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes the same pair once BOTH names are grounded', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['int'], + subtypeParametersComplete: false, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); +}); + +describe('heritageTypeArgumentsKey', () => { + it('keeps a pair distinct from the same ids in the other order', () => { + expect(heritageTypeArgumentsKey('a', 'b')).not.toBe(heritageTypeArgumentsKey('b', 'a')); + }); + + it('separates on a character a file path cannot contain', () => { + // `Class:a b.cs:A` + `Class:c.cs:C` must not be spellable two ways. + expect(heritageTypeArgumentsKey('Class:a b.cs:A', 'Class:c.cs:C')).not.toBe( + heritageTypeArgumentsKey('Class:a', 'b.cs:A Class:c.cs:C'), + ); + }); +}); + +describe('typeApplicationArguments', () => { + it('reads angle-bracket arguments', () => { + expect(typeApplicationArguments('IValidator')).toEqual(['string']); + }); + + it('reads bracket arguments (Go embedding, Python bases)', () => { + expect(typeApplicationArguments('Base[User]')).toEqual(['User']); + }); + + it('splits only at top level', () => { + expect(typeApplicationArguments('Map>')).toEqual(['string', 'List']); + expect(typeApplicationArguments('Cache')).toEqual([ + 'Dict[str, int]', + 'bool', + ]); + }); + + it('declines a plain name, an array spelling, and a constructor call', () => { + expect(typeApplicationArguments('Repository')).toBeUndefined(); + expect(typeApplicationArguments('User[]')).toBeUndefined(); + expect(typeApplicationArguments('Base(args)')).toBeUndefined(); + }); + + it('declines a list that does not close at the end', () => { + expect(typeApplicationArguments('Repo by delegate')).toBeUndefined(); + expect(typeApplicationArguments('(Int) -> Unit')).toBeUndefined(); + }); + + it('declines brackets that cross families', () => { + // A one-family counter never sees the `]`, reaches the final `>` at depth + // zero and reports `Bar]` as a balanced argument list. + expect(typeApplicationArguments('Foo')).toBeUndefined(); + expect(typeApplicationArguments('Foo[Bar>]')).toBeUndefined(); + expect(typeApplicationArguments('Map]')).toBeUndefined(); + // The well-formed mixed nesting it must NOT start declining. + expect(typeApplicationArguments('List')).toEqual(['Dict[a, b]']); + }); +}); + +describe('C# normalizeTypeArgument', () => { + const normalize = csharpScopeResolver.normalizeTypeArgument as (name: string) => string; + + it('makes every spelling of a predefined type one name', () => { + // Including the `global::` alias qualifier, which this repository already + // unwraps when decomposing imports. + for (const spelling of ['string', 'String', 'System.String', 'global::System.String']) { + expect(normalize(spelling)).toBe('String'); + } + expect(normalize('int')).toBe('Int32'); + }); + + it('leaves an unrelated qualified name as written', () => { + expect(normalize('Foo.String')).toBe('Foo.String'); + expect(normalize('Models.User')).toBe('Models.User'); + }); + + it('keeps the qualifier on an ordinary type that merely lives in System', () => { + // Stripping `System.` unconditionally would answer `Custom` here, equating + // this with an unrelated `Custom` elsewhere in the workspace. Only a + // spelling that reduces to a PREDEFINED type earns the strip. + expect(normalize('System.Custom')).toBe('System.Custom'); + expect(normalize('global::System.Custom')).toBe('global::System.Custom'); + expect(normalize('System.Collections.Generic.List')).toBe('System.Collections.Generic.List'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts new file mode 100644 index 000000000..d5683e915 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts @@ -0,0 +1,177 @@ +/** + * Heritage generic ARGUMENTS reach resolution, across languages (#2912). + * + * Three routes exist, and every language uses exactly one of them: + * + * 1. The `@reference.inherits` ANCHOR already spans the whole base, so the + * spelling is read straight off it and no query changed (C#, Java, + * TypeScript, Kotlin, Go, Python, Swift). + * 2. The anchor is the bare NAME node — widening it would move the site's + * range, which is part of every inheritance edge's id — so the arguments + * arrive through the `@reference.type-arguments` sub-tag (Rust, Dart + * `extends`). + * 3. The clause never becomes a reference site at all, and rides a heritage + * MARKER payload instead (Dart `implements` / `with`). + * + * Each is pinned here because instantiation filtering degrades SILENTLY to the + * pre-#2912 fan-out when a capture stops arriving: no error, no failing edge + * count, just an interface reaching implementors of the wrong instantiation + * again. + */ +import { describe, it, expect } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js'; +import { csharpProvider } from '../../../src/core/ingestion/languages/csharp.js'; +import { javaProvider } from '../../../src/core/ingestion/languages/java.js'; +import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js'; +import { kotlinProvider } from '../../../src/core/ingestion/languages/kotlin.js'; +import { goProvider } from '../../../src/core/ingestion/languages/go.js'; +import { pythonProvider } from '../../../src/core/ingestion/languages/python.js'; +import { swiftProvider } from '../../../src/core/ingestion/languages/swift.js'; +import { rustProvider } from '../../../src/core/ingestion/languages/rust.js'; +import { dartProvider } from '../../../src/core/ingestion/languages/dart.js'; +import { decodeMarker } from '../../../src/core/ingestion/utils/heritage-marker.js'; + +function inheritsSites( + provider: LanguageProvider, + source: string, + filePath: string, +): Array<{ name: string; typeArguments?: readonly string[] }> { + const parsed: ParsedFile | undefined = extractParsedFile(provider, source, filePath); + return (parsed?.referenceSites ?? []) + .filter((site) => site.kind === 'inherits') + .map((site) => ({ name: site.name, typeArguments: site.typeArguments })); +} + +describe('heritage type arguments are captured', () => { + it('C# base list', () => { + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record V : IValidator { }', + 'V.cs', + ), + ).toEqual([{ name: 'IValidator', typeArguments: ['string'] }]); + }); + + it('C# record with a primary-constructor base', () => { + // `Base(x)` writes a CALL in the heritage position; the call is not + // part of the type and must not stop the arguments being read. + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record R(int x) : Base(x) { }', + 'R.cs', + ), + ).toEqual([{ name: 'Base', typeArguments: ['int'] }]); + }); + + it('Java implements clause', () => { + expect( + inheritsSites( + javaProvider, + 'package p;\npublic class V implements Validator { }', + 'V.java', + ), + ).toEqual([{ name: 'Validator', typeArguments: ['String'] }]); + }); + + it('TypeScript implements clause', () => { + expect( + inheritsSites(typescriptProvider, 'export class V implements Validator { }', 'v.ts'), + ).toEqual([{ name: 'Validator', typeArguments: ['string'] }]); + }); + + it('Kotlin delegation specifier, with and without a constructor call', () => { + expect(inheritsSites(kotlinProvider, 'class V : Validator() { }', 'v.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + expect(inheritsSites(kotlinProvider, 'class V : Validator { }', 'v2.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Go generic struct embedding (bracket application)', () => { + expect(inheritsSites(goProvider, 'package p\ntype S struct { Base[int] }', 's.go')).toEqual([ + { name: 'Base', typeArguments: ['int'] }, + ]); + }); + + it('Python subscripted base (bracket application)', () => { + expect(inheritsSites(pythonProvider, 'class Repo(Base[User]):\n pass\n', 'r.py')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); + + it('Swift inheritance clause', () => { + expect(inheritsSites(swiftProvider, 'class Repo: Base { }', 'r.swift')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('emitters whose anchor is the bare name use the explicit sub-tag', () => { + it('Rust trait impl', () => { + // The anchor is the trait NAME node inside a `generic_type`, and its range + // is part of the inheritance edge's id — so the arguments arrive through + // `@reference.type-arguments` rather than by widening the anchor. + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v.rs')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Rust trait impl without arguments records none', () => { + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v2.rs')).toEqual([ + { name: 'Validator', typeArguments: undefined }, + ]); + }); + + it('Dart extends clause', () => { + expect(inheritsSites(dartProvider, 'class Repo extends Base { }', 'r.dart')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('heritage that never becomes a reference site', () => { + // Dart's `implements` / `with` travel as heritage MARKERS on parsed imports, + // not as `inherits` sites: `emitDartHeritageEdges` reads the marker and emits + // the edge, so the instantiation has to ride the payload to reach the same + // sink the generic pre-pass writes to (#2912). + function heritageMarkers(source: string, filePath: string): Array { + const parsed = extractParsedFile(dartProvider, source, filePath); + return (parsed?.parsedImports ?? []) + .map((imported) => decodeMarker(String(imported.targetRaw))) + .filter( + (marker): marker is { kind: 'heritage'; fields: string[] } => marker?.kind === 'heritage', + ) + .map((marker) => marker.fields); + } + + it('carries the arguments of a Dart `implements` clause', () => { + expect(heritageMarkers('class V implements Validator { }', 'v.dart')).toEqual([ + ['implements', 'Validator', 'V', ''], + ]); + }); + + it('carries the arguments of a Dart `with` clause', () => { + expect(heritageMarkers('class V extends Base with M { }', 'v2.dart')).toEqual([ + ['with', 'M', 'V', ''], + ]); + }); + + it('omits the field for a non-generic clause, so old payloads stay readable', () => { + expect(heritageMarkers('class V implements Validator { }', 'v3.dart')).toEqual([ + ['implements', 'Validator', 'V'], + ]); + }); +}); + +describe('non-generic heritage stays byte-identical', () => { + it('records no arguments for a plain base', () => { + expect( + inheritsSites(csharpProvider, 'namespace P;\npublic class C : Base { }', 'C.cs'), + ).toEqual([{ name: 'Base', typeArguments: undefined }]); + }); +});