diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 935a56ea9..e587a6ed7 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,42 @@ # Receiver-resolution baseline +## Swift/Dart stack integration after Rust and C# + +The Swift/Dart ownership and replay implementation is unchanged from reviewed +commit `cfeb11a9b`. It is combined with validated Rust parent `b6803fcb4` +(tree `b6728e6dc`) and actual main `e7ef82dfc`, preserving Kotlin, PHP, C# and +Rust behavior. Only the receiver-baseline documentation conflicted; both +parents' sections are retained. + +On this combined source, a fresh package build, both pinned TypeScript +configurations and 283 affected tests across nine files pass. Swift's capture +fingerprint remains unchanged and its existing scaling budget passes; the +historical all-language capture run below was not repeated for this merge. +Receiver update and check pass: 138 call drops, 168 total, 30 reads and no +writes. All count-arm buckets equal the validated Rust parent. Exactly seven +Swift/Dart cells improve from VISIBLE-GAP to RESOLVES; all other shape cells +retain the parent's states, including the C# and Rust field improvements. +The 5,559-record resolution corpus is byte-identical to the Rust parent. +The earlier c304-based 132/160 totals and 5,501-record comparison below are +historical measurements, not the current combined baseline. + +## Swift and Dart hoisted return types (#3498 RC2) + +Swift and Dart hoist every method's return-type binding onto the Module scope, +as C# does, but neither set `hoistTypeBindingsToModule`, so a chained receiver +such as `svc.getUser().save()` never found the type of `getUser()`. Both now set +it. Seven shape cells flip to `RESOLVES`, each with the correct target: swift +`plainChain`, `plainDeepChain`, `optionalChain` and `nonNullAssert` (each +VISIBLE-GAP on main `2b4456553` before), and dart `plainChain`, `plainDeepChain` and +`optionalChain` (each VISIBLE-GAP before). Swift `awaitParen` and dart +`nonNullAssert` still need their own fixes. + +Both languages also set the new `hoistedTypeBindingsRequireOwner`. The hoisted +bindings are keyed by bare member name, so the walk now accepts one only when a +member of the receiver's own class declared it. Without that check, two classes +with a same-named method in one file typed each other's chains. The count arm +does not move, and neither does any cell in another language. + ## Rust smart-pointer field types (#3498) The struct-field normalizer in `rust/range-binding.ts` cut a type at its first @@ -1112,6 +1149,47 @@ Shape states remain unchanged; counts are 133 call drops and 160 drops of all kinds (27 reads, 0 writes). The counts include both main and this branch's fixes. +## Hoisted return ownership after main integration + +Main `c30449394` is merged normally. Valid Swift optional-chain and force- +unwrap fixtures use an optional `optionalAddress` property; plain deep chains +retain the non-optional `address`. The benchmark uses the same split. Four +Swift chain cells and three Dart cells resolve, and every other shape state +matches main. The count arm is unchanged: 132 call drops, 160 drops across +all kinds, 28 reads and 0 writes. The 5,501-record resolution corpus is +byte-identical to main; its fixtures do not exercise the new method-return +chains, which have separate exact-target integration controls. + +Hoisted lookup reads the authoritative method registry and exact callable +return facts. A nearer declaration with an ambiguous or unavailable return +stops ancestor fallback. Swift recovers overwritten declaration returns in +both declaration orders. Dart supplies no exact declaration return facts and +conservatively declines the overwritten owner. Method references remain +callable values. Scope access remains valid after the disk-backed store seals; +no parallel owner/name declaration index is added. + +Replay narrowing applies only to providers that opt into owner-checked hoisted +returns. A uniquely selected Swift override retains its exact return when +ancestor candidates have matching complete captured parameter-type vectors and +count metadata. Matching undefined counts retain variadic overrides; unknown +type slots, differing metadata and same-owner ties decline. Swift preserves an +empty slot when a parameter type is unsupported instead of compressing it out. +The fixed and variadic bound-override controls retain main's exact +`Function:Main.swift:Admin.save#0` target. Both compiled pre-fix cases emitted no +bound `Admin.save` edge, while main and the corrected branch emit one. The +inline forms remain unresolved on both main and the branch. Matching uses +captured normalized types; labels and richer type identity remain outside +this proof. + +A fresh package build and both pinned TypeScript configurations pass on the +combined source. All 283 affected tests in nine files pass. All 16 capture +checks passed after main integration; Swift was rechecked after its +parameter-metadata correction +with unchanged fingerprint and budget. The other capture producers are +unchanged. The receiver baseline update and check pass. Coverage includes +sealed-store Swift/Dart resolution, exact override and owner targets, inherited-overload and tear-off refusal, and TypeScript/Python +preservation controls. Hosted CI is required on the published commit. + ### Inheritance visibility review boundaries Native review controls reproduced production Java imports and Java/Kotlin inheritance selecting nearer test-only declarations, an inherited nested base losing to a package homonym, a resolved Kotlin typealias import losing its claim, and a C# tuple argument counting as two type arguments. The corrected focused run passes 71 tests in five files, including positive test-source ownership controls and ordinary nested/local inheritance. The generic argument splitter now keeps tuple commas together. diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index d016ae96c..4c4df8cca 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -174,10 +174,10 @@ "decoratedFieldType": "RESOLVES" }, "swift": { - "plainChain": "VISIBLE-GAP", - "plainDeepChain": "VISIBLE-GAP", - "optionalChain": "VISIBLE-GAP", - "nonNullAssert": "VISIBLE-GAP", + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "RESOLVES", + "nonNullAssert": "RESOLVES", "awaitParen": "VISIBLE-GAP", "explicitTypeArgs": "N/A", "indexElement": "INVISIBLE-GAP", @@ -186,9 +186,9 @@ "decoratedFieldType": "RESOLVES" }, "dart": { - "plainChain": "VISIBLE-GAP", - "plainDeepChain": "VISIBLE-GAP", - "optionalChain": "VISIBLE-GAP", + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "RESOLVES", "nonNullAssert": "VISIBLE-GAP", "awaitParen": "RESOLVES", "explicitTypeArgs": "N/A", diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 8cfb8e9f5..9b2f57b0e 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -1070,6 +1070,7 @@ class Service { class User { var address: Address = Address() + var optionalAddress: Address? = Address() func save() {} } @@ -1084,8 +1085,8 @@ class Service { shapes: [ { id: 'plainChain', member: 'save', body: 'svc.getUser().save()' }, { id: 'plainDeepChain', member: 'save', body: 'svc.getUser().address.save()' }, - { id: 'optionalChain', member: 'save', body: 'svc.getUser().address?.save()' }, - { id: 'nonNullAssert', member: 'save', body: 'svc.getUser().address!.save()' }, + { id: 'optionalChain', member: 'save', body: 'svc.getUser().optionalAddress?.save()' }, + { id: 'nonNullAssert', member: 'save', body: 'svc.getUser().optionalAddress!.save()' }, { id: 'awaitParen', member: 'save', body: '(await svc.getUserAsync()).save()' }, { id: 'explicitTypeArgs', diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 364551565..858390541 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -241,6 +241,15 @@ export const dartScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // `dartBindingScopeFor` hoists every method's return-type binding out of + // its class body onto the Module scope (as C# does), so the text cascade + // must walk up there to type `svc.getUser().save()`. Those bindings are + // keyed by bare name, so the walk only accepts one a member of the + // receiver's own class declared: a same-named method of another class in + // the file, or an imported top-level function, must not answer. + hoistTypeBindingsToModule: true, + hoistedTypeBindingsRequireOwner: true, + // No `new`: bare `Foo()` resolves to the type; with cross-file imports the // callee is reachable workspace-wide. allowGlobalFreeCallFallback: true, diff --git a/gitnexus/src/core/ingestion/languages/swift/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/swift/arity-metadata.ts index f520e5b8e..da3efaa61 100644 --- a/gitnexus/src/core/ingestion/languages/swift/arity-metadata.ts +++ b/gitnexus/src/core/ingestion/languages/swift/arity-metadata.ts @@ -10,7 +10,8 @@ * unknown" — the candidate stays eligible at `argCount >= required`. * - Defaulted params (`= expr`) contribute to `optionalCount`; * `requiredParameterCount = total − optionalCount`. - * - `parameterTypes` collects declared type names for narrowing; a + * - `parameterTypes` retains one slot per parameter, with an empty string + * for an unknown type so signature comparisons cannot compress it away; a * literal `'variadic'` marker is appended for variadic methods so * `swiftArityCompatibility` can detect them without re-reading AST. */ @@ -33,7 +34,7 @@ export function computeSwiftArityMetadata(fnNode: SyntaxNode): SwiftArityMetadat for (const p of params) { if (p.isVariadic) hasVariadic = true; else if (p.isOptional) optionalCount++; - if (p.type !== null) types.push(p.type); + types.push(p.type ?? ''); } if (hasVariadic) types.push('variadic'); diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index f554ef4d3..f136c8794 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -157,6 +157,15 @@ const swiftScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // `swiftBindingScopeFor` hoists every method's return-type binding out of + // its class body onto the Module scope (as C# does), so the receiver fold + // must walk up there to type `svc.getUser().save()`. Those bindings are + // keyed by bare name, so the walk only accepts one a member of the + // receiver's own class declared: a same-named method of another class in + // the file, or an imported top-level function, must not answer. + hoistTypeBindingsToModule: true, + hoistedTypeBindingsRequireOwner: true, + // Unqualified methods need a reachable owner; retain the full lexical // candidate set so the shared resolver also narrows free overloads by arity. freeCallsRequireInstanceOwnership: true, 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 26c7d09b2..88ab0ff30 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -21,6 +21,8 @@ * - hoistTypeBindingsToModule — enable ONLY when method return * types are stored on the enclosing Module scope; most * languages attach them to the class scope and leave this off + * - hoistedTypeBindingsRequireOwner — with the above, accept a + * hoisted binding only from a member of the receiver's own class * 2. Export a thin entry point: * `runYourLangScopeResolution(input) = runScopeResolution(input, yourScopeResolver)`. * 3. Register the provider in @@ -1824,9 +1826,39 @@ export interface ScopeResolver { * scope leave this undefined — enabling the walk-up for them would * add an unnecessary branch and risk picking up unrelated module- * level bindings. + * + * The hoisted bindings are keyed by bare member name, so on its own the + * walk-up accepts whatever binding of that name it meets first — see + * `hoistedTypeBindingsRequireOwner` for the guard. */ readonly hoistTypeBindingsToModule?: boolean; + /** + * With `hoistTypeBindingsToModule`, accept a hoisted binding only when a + * member of the asked-about class declared it. Default `false`. + * + * The Module scope holds one hoisted binding per member NAME, so with + * `class A { getUser(): User }` and `class B { getUser(): Admin }` in one + * file only B's survives, and the plain walk-up types `a.getUser()` as + * `Admin`. It does the same with an imported top-level function of that + * name, or for a class that has no such member at all. With this toggle, + * authoritative owner-scoped method lookup first selects a unique member + * and reads its exact declared return fact. When that fact is absent, a + * hoisted binding counts only if its `bindingRange` encloses that member's + * declaration. A known ambiguous or untyped member stops ancestor fallback. + * + * Where the provider emits exact declaration return facts, those facts recover + * overwritten bindings for both A and B. A provider without them conservatively + * declines the overwritten owner. + * Multiple declarations sharing one graph ID remain ambiguous. Intermediate + * hops decline inherited overloads without a proven zero-argument signature. + * Exact-callee assignment replay can retain matching complete captured + * parameter metadata, including variadic signatures. Reading a method + * reference does not invoke its return type. Languages that rely on the plain + * walk-up leave this off; its behaviour is unchanged. + */ + readonly hoistedTypeBindingsRequireOwner?: boolean; + /** * Strip ONE layer of type-preserving decoration off a declared type name, * or return `undefined` when there is nothing left to strip. 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 8e6fb77f6..7ff9a714b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -20,7 +20,7 @@ * either don't trigger the call branch or skip this pass entirely. */ -import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; +import type { Range, Scope, 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'; @@ -127,6 +127,15 @@ interface ResolveCompoundReceiverOptions { * languages that hoist return-type bindings to Module scope (C#); * otherwise we risk picking up unrelated module-level bindings. */ readonly hoistTypeBindingsToModule?: boolean; + /** `ScopeResolver.hoistedTypeBindingsRequireOwner`: a binding found by that + * walk counts only when a member of the class being asked about declared + * it — see `hoistedMemberBinding`. */ + readonly hoistedTypeBindingsRequireOwner?: boolean; + /** Authoritative method declarations from SemanticModel; no parallel symbol index. */ + readonly lookupOwnedMethods?: ( + ownerId: string, + memberName: string, + ) => readonly SymbolDefinition[]; /** `ScopeResolver.resolveThisViaEnclosingClass` — the language declares that * `this` IS the enclosing class rather than a per-function-scope binding. * Read only by the `this` head seed below. */ @@ -636,6 +645,84 @@ function classOfReturnType( return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); } +/** Does `inner` lie entirely inside `outer`? Positions compare line-first. */ +function rangeWithin(inner: Range, outer: Range): boolean { + return ( + (inner.startLine > outer.startLine || + (inner.startLine === outer.startLine && inner.startCol >= outer.startCol)) && + (inner.endLine < outer.endLine || + (inner.endLine === outer.endLine && inner.endCol <= outer.endCol)) + ); +} + +/** + * A member's type binding that the language hoisted out of `classScope` onto + * an ancestor (Module) scope. + * + * The hoisted bindings are keyed by the bare member NAME on the Module scope, + * so the name alone cannot say whose they are: `class A { getUser(): User }` + * and `class B { getUser(): Admin }` in one file leave a single `getUser` + * entry (the later one), an imported top-level function of the same name lands + * in the same map, and a class with no such member at all still finds it. + * Accepting by name types `a.getUser().save()` as `Admin.save`. + * + * With `hoistedTypeBindingsRequireOwner`, a unique owned declaration first + * supplies its exact recorded return type. Multiple declarations refuse the + * lookup, even when same-arity overloads share a graph ID. A declared member + * whose binding cannot be recovered returns null, stopping ancestor fallback; + * undefined means this owner declares no usable member. Without the toggle, + * the first ancestor binding is taken as before. + */ +function hoistedMemberBinding( + classScope: Scope, + ownerId: string, + memberName: string, + scopes: ScopeResolutionIndexes, + options: ResolveCompoundReceiverOptions, + index: WorkspaceResolutionIndex, + invoked = true, +): TypeRef | null | undefined { + let declaration: SymbolDefinition | undefined; + if (options.hoistedTypeBindingsRequireOwner === true) { + const lookup = options.lookupOwnedMethods; + if (lookup === undefined) return null; + const owned = [ + ...new Map(lookup(ownerId, memberName).map((def) => [def.nodeId, def])).values(), + ]; + if (owned.length === 0) return undefined; + // A member reference is a callable value, not its invocation's return type. + if (!invoked || owned.length > 1) return null; + const member = owned[0]!; + for (const ancestor of scopes.methodDispatch.mroFor(ownerId)) { + if (ancestor === ownerId) continue; + for (const inherited of lookup(ancestor, memberName)) { + // Without argument labels or arity for this intermediate hop, only + // zero-argument overrides have an unambiguous inherited signature. + if (member.parameterCount !== 0 || inherited.parameterCount !== 0) return null; + } + } + const exact = index.declaredReturnTypeByCallableId.get(member.nodeId); + if (exact !== undefined) return exact; + declaration = member; + } + let curId: ScopeId | null = classScope.parent; + while (curId !== null) { + const curScope = scopes.scopeTree.getScope(curId); + if (curScope === undefined) return undefined; + const hoisted = curScope.typeBindings.get(memberName); + if ( + hoisted !== undefined && + (options.hoistedTypeBindingsRequireOwner !== true || + (declaration?.declarationRange !== undefined && + hoisted.bindingRange !== undefined && + rangeWithin(declaration.declarationRange, hoisted.bindingRange))) + ) + return hoisted; + curId = curScope.parent; + } + return declaration === undefined ? undefined : null; +} + /** * The return type a free-call callee declares for ITSELF — read by definition * identity (`producerTypeRef`), the way the chain follow types the bound form @@ -669,6 +756,7 @@ function typeOfMemberOnClass( scopes: ScopeResolutionIndexes, index: WorkspaceResolutionIndex, options: ResolveCompoundReceiverOptions, + invoked = true, ): FoldState | undefined { const classScopeByDefId = index.classScopeByDefId; const ownerChain = [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)]; @@ -701,37 +789,35 @@ function typeOfMemberOnClass( // without this walk the fold cannot type a single step in the first // language it ships for. Gated on the same contract flag // `resolveCompoundReceiverClass` uses, so a language that does not hoist - // cannot pick up an unrelated module-level binding of the same name. - if (classScope !== undefined && options.hoistTypeBindingsToModule === true) { - let curId: ScopeId | null = classScope.parent; - while (curId !== null) { - const curScope = scopes.scopeTree.getScope(curId); - if (curScope === undefined) break; - const hoisted = curScope.typeBindings.get(memberName); - if (hoisted !== undefined) { - // 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, - options.stripTypePreservingDecoration, - ); - // 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 - // while byte-identical `byId['k'].run()` resolved, purely because ten - // languages route return-type bindings through this hoisted branch - // and the other through the class scope. - return { - def, - declaredType: hoisted.declaredSpelling ?? hoisted.rawName, - declaredAtScope: hoisted.declaredAtScope, - }; - } - curId = curScope.parent; - } + // cannot pick up an unrelated module-level binding of the same name. A + // language that also sets `hoistedTypeBindingsRequireOwner` additionally + // refuses a same-named member of another class (see `hoistedMemberBinding`). + const hoisted = + classScope !== undefined && options.hoistTypeBindingsToModule === true + ? hoistedMemberBinding(classScope, ownerId, memberName, scopes, options, index, invoked) + : undefined; + if (hoisted === null) return undefined; + if (hoisted !== undefined) { + // 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, + options.stripTypePreservingDecoration, + ); + // 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 + // while byte-identical `byId['k'].run()` resolved, purely because ten + // languages route return-type bindings through this hoisted branch + // and the other through the class scope. + return { + def, + declaredType: hoisted.declaredSpelling ?? hoisted.rawName, + declaredAtScope: hoisted.declaredAtScope, + }; } } return undefined; @@ -953,7 +1039,14 @@ function foldReceiverChainState( // get here. if (options.constructionSyntax?.selector === step.name) return undefined; if (current.def === undefined) return undefined; - const next = typeOfMemberOnClass(current.def, step.name, scopes, index, options); + const next = typeOfMemberOnClass( + current.def, + step.name, + scopes, + index, + options, + step.kind === 'call', + ); if (next === undefined) return undefined; current = next; } @@ -1349,19 +1442,12 @@ export function resolveCompoundReceiverClass( // enabling it unconditionally would let other languages pick up // unrelated module-level bindings. See contract doc for the // invariant and `propagateImportedReturnTypes` for how the - // hoisted bindings originate. + // hoisted bindings originate. `hoistedMemberBinding` also applies the + // owner join when the language asks for it. if (cs !== undefined && options.hoistTypeBindingsToModule === true) { - let curId: ScopeId | null = cs.parent; - while (curId !== null) { - const curScope = scopes.scopeTree.getScope(curId); - if (curScope === undefined) break; - const cand = curScope.typeBindings.get(methodName); - if (cand !== undefined) { - retType = cand; - break; - } - curId = curScope.parent; - } + const hoisted = hoistedMemberBinding(cs, ownerId, methodName, scopes, options, index); + if (hoisted === null) return undefined; + retType = hoisted; if (retType !== undefined) break; } } @@ -1613,17 +1699,17 @@ export function resolveCompoundReceiverClass( options.hoistTypeBindingsToModule === true && cs !== undefined ) { - let curId: ScopeId | null = cs.parent; - while (curId !== null) { - const curScope = scopes.scopeTree.getScope(curId); - if (curScope === undefined) break; - const cand = curScope.typeBindings.get(memberName); - if (cand !== undefined) { - memberType = cand; - break; - } - curId = curScope.parent; - } + const hoisted = hoistedMemberBinding( + cs, + currentClass.nodeId, + memberName, + scopes, + options, + index, + segment.includes('('), + ); + if (hoisted === null) return undefined; + memberType = hoisted; } if (memberType === undefined) { // Trailing segment may be a method name without `()` — e.g. @@ -1923,17 +2009,17 @@ function resolveMapValueTypeNameFromPrefix( if (cs === undefined) return undefined; let memberType = cs.typeBindings.get(memberName); if (memberType === undefined && options.hoistTypeBindingsToModule === true) { - let curId: ScopeId | null = cs.parent; - while (curId !== null) { - const curScope = scopes.scopeTree.getScope(curId); - if (curScope === undefined) break; - const cand = curScope.typeBindings.get(memberName); - if (cand !== undefined) { - memberType = cand; - break; - } - curId = curScope.parent; - } + const hoisted = hoistedMemberBinding( + cs, + currentClass.nodeId, + memberName, + scopes, + options, + index, + segment.includes('('), + ); + if (hoisted === null) return undefined; + memberType = hoisted; } if (memberType === undefined) return undefined; lastMemberType = memberType; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index 910e1ff90..b4309ab8a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -24,6 +24,8 @@ * equality. An empty string in `argTypes[i]` means "unknown" and * counts as a match. Mismatches disqualify. A non-empty typed * result wins; otherwise return the arity-filtered candidates. + * Unknown parameter slots may retain candidates whose known slots satisfy + * the active exact/conversion stage; they are not exact-match evidence. * 4b. When the exact-type filter from step 4 returns empty AND a * `conversionRankFn` is provided (via `hookCtx`), rank candidates * via pairwise dominance comparison (ISO C++ [over.ics.rank]): @@ -205,6 +207,39 @@ export function narrowOverloadCandidates( result = []; } } + + // Missing parameter slots remain possible, but cannot establish an exact + // match and bypass conversion ranking for other viable overloads. + const unknownParameterMatches = candidates.filter((candidate) => { + const params = candidate.parameterTypes; + if (params === undefined || !params.slice(0, argTypes.length).includes('')) return false; + for (let i = 0; i < argTypes.length && i < params.length; i++) { + if (argTypes[i] === '' || params[i] === '') continue; + if ( + exactTypeSlotMatches( + argTypes[i], + params[i], + hookCtx?.argumentTypeClasses?.[i], + candidate.parameterTypeClasses?.[i], + ) + ) + continue; + if (typed.length > 0) return false; + const rank = hookCtx?.conversionRankFn?.( + argTypes[i], + params[i], + hookCtx.argumentTypeClasses?.[i], + candidate.parameterTypeClasses?.[i], + ); + if (rank === undefined || !Number.isFinite(rank)) return false; + } + return true; + }); + if (unknownParameterMatches.length > 0 && result.length > 0) { + result = candidates.filter( + (candidate) => result.includes(candidate) || unknownParameterMatches.includes(candidate), + ); + } } // Constraint filter (step 4c; Tier-A — SFINAE / `requires` clauses). 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 68305f3f8..5ebd0774d 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 @@ -194,6 +194,7 @@ type ReceiverBoundProviderSubset = Pick< | 'collapseMemberCallsByCallerTarget' | 'elementTypeOf' | 'hoistTypeBindingsToModule' + | 'hoistedTypeBindingsRequireOwner' | 'stripReceiverCastExpressions' | 'constructionSyntax' | 'isTypeApplicationCall' @@ -936,6 +937,9 @@ export function emitReceiverBoundCalls( namespaceMemberVisible, namespaceSkipsEnclosingClasses: provider.namespaceSkipsEnclosingClasses, hoistTypeBindingsToModule, + hoistedTypeBindingsRequireOwner: provider.hoistedTypeBindingsRequireOwner === true, + lookupOwnedMethods: (ownerId: string, memberName: string) => + model.methods.lookupAllByOwner(ownerId, memberName), stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, constructionSyntax: provider.constructionSyntax, isTypeApplicationCall: provider.isTypeApplicationCall, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 60b927a1a..c5a87e887 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -121,6 +121,7 @@ import { type HeritageTypeArgumentSink, } from '../utils/generic-instantiation.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; +import { narrowOverloadCandidates } from '../passes/overload-narrowing.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; @@ -139,20 +140,31 @@ export function applyPreciseCallResultBindings( workspaceIndex: ReturnType, calleeIds: CalleeIdAccumulator, nodeLookup: ReturnType, + model: SemanticModel, + requireOwnedHoistedReturn = false, ): number { let updated = 0; - const returnTypeByGraphId = new Map(); + const returnTypeByGraphId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { const returnType = workspaceIndex.declaredReturnTypeByCallableId.get(def.nodeId); if (returnType === undefined) continue; const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); - if (graphId !== undefined) returnTypeByGraphId.set(graphId, returnType); + if (graphId !== undefined) returnTypeByGraphId.set(graphId, { type: returnType, def }); } } for (const parsed of parsedFiles) { const resolvedByPosition = calleeIds.get(parsed.filePath); if (resolvedByPosition === undefined) continue; + let callsByPosition: Map | undefined; + if (requireOwnedHoistedReturn) { + callsByPosition = new Map(); + for (const site of parsed.referenceSites) { + if (site.kind !== 'call') continue; + const key = calleeIdPosKey(site.atRange.startLine, site.atRange.startCol); + callsByPosition.set(key, callsByPosition.has(key) ? null : site); + } + } for (const assignment of parsed.callResultAssignmentSites ?? []) { const targets = resolvedByPosition.get( calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol), @@ -160,8 +172,59 @@ export function applyPreciseCallResultBindings( if (targets === undefined || targets.size !== 1) continue; const targetId = targets.values().next().value as string | undefined; if (targetId === undefined) continue; - const returnType = returnTypeByGraphId.get(targetId); - if (returnType === undefined) continue; + const returned = returnTypeByGraphId.get(targetId); + if (returned === undefined) continue; + if (requireOwnedHoistedReturn) { + const site = callsByPosition?.get( + calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol), + ); + if (site == null || site.arity === undefined) continue; + const def = returned.def; + const candidates = + def.ownerId === undefined + ? [def] + : [def.ownerId, ...indexes.methodDispatch.mroFor(def.ownerId)].flatMap((owner) => + model.methods.lookupAllByOwner(owner, site.name), + ); + const narrowed = [ + ...new Map( + narrowOverloadCandidates(candidates, site.arity, site.argumentTypes).map( + (candidate) => [candidate.nodeId, candidate], + ), + ).values(), + ]; + // The exact selected declaration may shadow distinct ancestors only + // with equivalent, fully known parameter metadata. Numeric counts may + // both be absent (variadics); an empty type slot is still unknown. + const ancestors = new Set( + def.ownerId === undefined ? [] : indexes.methodDispatch.mroFor(def.ownerId), + ); + const owners = new Set(narrowed.map((candidate) => candidate.ownerId)); + const parameters = def.parameterTypes; + if ( + narrowed[0]?.nodeId !== def.nodeId || + owners.size !== narrowed.length || + narrowed + .slice(1) + .some( + (candidate) => + candidate.ownerId === undefined || + !ancestors.has(candidate.ownerId) || + candidate.parameterCount !== def.parameterCount || + (def.parameterCount !== 0 && + (parameters === undefined || + parameters.length === 0 || + (def.parameterCount !== undefined && + parameters.length !== def.parameterCount) || + candidate.parameterTypes?.length !== parameters.length || + parameters.some( + (type, index) => type === '' || type !== candidate.parameterTypes?.[index], + ))), + ) + ) + continue; + } + const returnType = returned.type; const scope = indexes.scopeTree.getScope(assignment.inScope); if (scope === undefined) continue; (scope.typeBindings as Map).set(assignment.lhs, { @@ -1407,6 +1470,8 @@ export function runScopeResolution( workspaceIndex, calleeIdAccumulator, postHeritageNodeLookup, + readonlyModel, + provider.hoistedTypeBindingsRequireOwner === true, ); if (replayedCallResultBindings > 0) { const handledBeforeReplay = new Set(handledSites); diff --git a/gitnexus/test/integration/hoisted-receiver-review.test.ts b/gitnexus/test/integration/hoisted-receiver-review.test.ts new file mode 100644 index 000000000..c7ad4e6f7 --- /dev/null +++ b/gitnexus/test/integration/hoisted-receiver-review.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { TransitionalScopeTree } from '../../src/storage/scope-index-store.js'; +import { + isLanguageAvailable, + loadParser, + loadLanguage, +} from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart); +if (dartAvailable) { + try { + await loadParser(); + await loadLanguage(SupportedLanguages.Dart); + } catch { + dartAvailable = false; + } +} +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); +const swift = `class User { func save() {} } +class Admin { func save() {} } +class Service { func getUser() -> User { return User() } } +func probe(svc: Service) { svc.getUser().save() } +`; +const dart = `class User { void save() {} } +class Admin { void save() {} } +class Service { User getUser() => User(); } +void probe(Service svc) { svc.getUser().save(); } +`; +describe('hoisted receiver review regressions', () => { + for (const [label, file, source, available] of [ + ['Swift', 'Models.swift', swift, swiftAvailable], + ['Dart', 'models.dart', dart, dartAvailable], + ] as const) { + it.skipIf(!available)( + `${label} preserves exact edges after the disk seal`, + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hoisted-disk-')); + const store = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hoisted-store-')); + const previous = process.env.GITNEXUS_DISK_SCOPE_INDEX; + const seal = vi.spyOn(TransitionalScopeTree.prototype, 'seal'); + try { + await fs.writeFile(path.join(root, file), source); + const outputs: string[][] = []; + for (const disk of [false, true]) { + process.env.GITNEXUS_DISK_SCOPE_INDEX = disk ? '1' : '0'; + seal.mockClear(); + const result = await runPipelineFromRepo(root, () => {}, { + parsedFileStorePath: path.join(store, disk ? 'disk' : 'resident'), + }); + expect(seal).toHaveBeenCalledTimes(disk ? 1 : 0); + const calls = [...result.graph.iterRelationships()] + .filter( + (e) => + e.type === 'CALLS' && + result.graph.getNode(e.sourceId)?.properties.name === 'probe' && + result.graph.getNode(e.targetId)?.properties.name === 'save', + ) + .map((e) => e.targetId) + .sort(); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('User.save'); + outputs.push(calls); + } + expect(outputs[1]).toEqual(outputs[0]); + } finally { + seal.mockRestore(); + if (previous === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX; + else process.env.GITNEXUS_DISK_SCOPE_INDEX = previous; + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(store, { recursive: true, force: true }); + } + }, + 120000, + ); + } + it.skipIf(!swiftAvailable)( + 'does not borrow a nearer overload return for an inherited call', + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-swift-inherited-overload-')); + try { + await fs.writeFile( + path.join(root, 'Models.swift'), + `class User { func save() {} } +class Admin { func save() {} } +class Base { func getUser() -> User { return User() } } +class Child: Base { func getUser(_ id: Int) -> Admin { return Admin() } } +func probe(svc: Child) { svc.getUser().save() } +func probeBound(svc: Child) { let user = svc.getUser(); user.save() } +func probeControl(user: User) { user.save() } +`, + ); + const result = await runPipelineFromRepo(root, () => {}, {}); + // Check owner precision while intermediate argument evidence is limited. + const saveCalls = [...result.graph.iterRelationships()].filter( + (e) => + e.type === 'CALLS' && + ['probe', 'probeBound'].includes( + result.graph.getNode(e.sourceId)?.properties.name ?? '', + ) && + e.targetId.includes('.save'), + ); + for (const call of saveCalls) { + expect(call.targetId).toBe('Function:Models.swift:User.save#0'); + } + // This supported call checks fixture-local resolution, not completeness + // of the primary calls or coverage of every selective primary-path loss. + const controlCalls = [...result.graph.iterRelationships()] + .filter( + (e) => + e.type === 'CALLS' && + result.graph.getNode(e.sourceId)?.properties.name === 'probeControl' && + e.targetId.includes('.save'), + ) + .map((e) => e.targetId) + .sort(); + expect(controlCalls).toEqual(['Function:Models.swift:User.save#0']); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + 120000, + ); + it.skipIf(!dartAvailable)( + 'does not confuse a tear-off invocation with its return value call', + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-dart-tearoff-')); + try { + await fs.writeFile( + path.join(root, 'models.dart'), + `class Admin { void save() {} } +class User { Admin call() => Admin(); void save() {} } +class Service { User getUser() => User(); } +void probe(Service svc) { svc.getUser.call().save(); } +void probeControl(User user) { user.save(); } +`, + ); + const result = await runPipelineFromRepo(root, () => {}, {}); + const saveCalls = [...result.graph.iterRelationships()].filter( + (e) => + e.type === 'CALLS' && + result.graph.getNode(e.sourceId)?.properties.name === 'probe' && + e.targetId.includes('.save'), + ); + // Check owner precision; callable-return flow is not modeled yet. + for (const call of saveCalls) { + expect(call.targetId).toBe('Method:models.dart:User.save#0'); + } + // This supported call checks fixture-local resolution, not completeness + // of the primary call or coverage of every selective primary-path loss. + const controlCalls = [...result.graph.iterRelationships()] + .filter( + (e) => + e.type === 'CALLS' && + result.graph.getNode(e.sourceId)?.properties.name === 'probeControl' && + e.targetId.includes('.save'), + ) + .map((e) => e.targetId) + .sort(); + expect(controlCalls).toEqual(['Method:models.dart:User.save#0']); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, + 120000, + ); +}); diff --git a/gitnexus/test/integration/hoisted-return-opt-in-boundaries.test.ts b/gitnexus/test/integration/hoisted-return-opt-in-boundaries.test.ts new file mode 100644 index 000000000..4a7a928cc --- /dev/null +++ b/gitnexus/test/integration/hoisted-return-opt-in-boundaries.test.ts @@ -0,0 +1,55 @@ +import { it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { writeFixtureRepo, runPipelineFromRepo, getRelationships } from './resolvers/helpers.js'; +const fixtures = [ + [ + 'models.ts', + `class User { save(): void {} } +class Admin extends User { override save(): void {} } +class Base { find(id: number): User { return new User(); } } +class Child extends Base { override find(id: number): Admin { return new Admin(); } } +function probe(svc: Child) { svc.find(1).save(); } +function probe_bound(svc: Child) { const user = svc.find(1); user.save(); } +`, + ], + [ + 'models.py', + `class User: + def save(self): pass +class Admin(User): + def save(self): pass +class Base: + def find(self, id: int) -> User: return User() +class Child(Base): + def find(self, id: int) -> Admin: return Admin() +def probe(svc: Child): + svc.find(1).save() +def probe_bound(svc: Child): + user = svc.find(1) + user.save() +`, + ], +] as const; +it.each(fixtures)( + '%s keeps parameterized override receivers outside the owner opt-in', + async (file, source) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hoisted-opt-in-')); + try { + writeFixtureRepo(root, { [file]: source }); + const r = await runPipelineFromRepo(root, () => {}); + for (const caller of ['probe', 'probe_bound']) { + const targets = getRelationships(r, 'CALLS') + .filter((e) => e.source === caller && e.target === 'save') + .map((e) => e.rel.targetId) + .sort(); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatch(/:Admin\.save(?:#\d+)?$/); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + 180000, +); diff --git a/gitnexus/test/integration/resolvers/dart.test.ts b/gitnexus/test/integration/resolvers/dart.test.ts index 46045b9ad..3c4017a89 100644 --- a/gitnexus/test/integration/resolvers/dart.test.ts +++ b/gitnexus/test/integration/resolvers/dart.test.ts @@ -6,7 +6,9 @@ * All Dart pipeline features are covered: Property nodes, HAS_PROPERTY edges, * CALLS chain resolution, IMPORTS, call attribution, and ACCESSES field reads. */ -import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'path'; import { FIXTURES, @@ -16,6 +18,7 @@ import { getResolutionOutcomes, edgeSet, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; import { @@ -964,3 +967,179 @@ describe('CF3 — Dart class accessors keep their class owner', () => { expect(owned).toContain('normalField'); }); }); + +// --------------------------------------------------------------------------- +// #3498 RC2: `dartBindingScopeFor` hoists every method's return-type binding +// onto the Module scope, so `svc.getUser().save()` types its receiver only if +// the receiver cascade walks up there (`hoistTypeBindingsToModule`). The +// hoisted entries are keyed by bare method name, so the walk must also refuse +// a same-named method of ANOTHER class — the second describe pins that. +// --------------------------------------------------------------------------- + +/** Every CALLS target id emitted by `source` in `filePath`, sorted. */ +function dartCallTargets(result: PipelineResult, filePath: string, source: string): string[] { + return getRelationships(result, 'CALLS') + .filter((edge) => edge.source === source && edge.sourceFilePath === filePath) + .map((edge) => edge.rel.targetId) + .sort(); +} + +describe.skipIf(!dartAvailable)( + 'Dart chained receivers through hoisted return types (#3498)', + () => { + let root: string; + let result: PipelineResult; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dart-hoisted-return-')); + writeFixtureRepo(root, { + 'models.dart': `class Address { + void save() {} +} + +class User { + Address address = Address(); + void save() {} +} + +class Service { + User getUser() => User(); +} + +class ChildService extends Service {} +`, + 'callers.dart': `import 'models.dart'; + +void plainChain(Service svc) { + svc.getUser().save(); +} + +void plainDeepChain(Service svc) { + svc.getUser().address.save(); +} + +void optionalChain(Service svc) { + svc.getUser().address?.save(); +} + +void inheritedChain(ChildService child) { + child.getUser().save(); +} +`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each([ + { + source: 'plainChain', + targets: ['Method:models.dart:Service.getUser#0', 'Method:models.dart:User.save#0'], + }, + { + source: 'plainDeepChain', + targets: ['Method:models.dart:Address.save#0', 'Method:models.dart:Service.getUser#0'], + }, + { + source: 'optionalChain', + targets: ['Method:models.dart:Address.save#0', 'Method:models.dart:Service.getUser#0'], + }, + { + // The owner itself declares nothing; the walk refuses the binding for + // `ChildService` and accepts it for the `Service` it inherits from. + source: 'inheritedChain', + targets: ['Method:models.dart:Service.getUser#0', 'Method:models.dart:User.save#0'], + }, + ])('resolves every call in $source to $targets', ({ source, targets }) => { + expect(dartCallTargets(result, 'callers.dart', source)).toEqual(targets); + }); + }, +); + +describe.skipIf(!dartAvailable)( + 'Dart same-named methods on two classes do not share a hoisted return type (#3498)', + () => { + let root: string; + let result: PipelineResult; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-dart-hoisted-collision-')); + // One Module-scope entry per method NAME: in each file the later class's + // `getUser` overwrites the earlier one's. Both orders are covered so the + // test does not depend on which one happens to win. + writeFixtureRepo(root, { + 'accounts.dart': `class User { + void save() {} +} + +class Admin { + void save() {} +} + +class A { + User getUser() => User(); +} + +class B { + Admin getUser() => Admin(); +} +`, + 'reversed.dart': `import 'accounts.dart'; + +class C { + Admin getUser() => Admin(); +} + +class D { + User getUser() => User(); +} +`, + 'callers.dart': `import 'accounts.dart'; +import 'reversed.dart'; + +void viaA(A a) { + a.getUser().save(); +} + +void viaB(B b) { + b.getUser().save(); +} + +void viaC(C c) { + c.getUser().save(); +} + +void viaD(D d) { + d.getUser().save(); +} +`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + // `viaA` and `viaC` are the guard: their class's own binding was overwritten + // by the other class's, so the walk finds a binding it must refuse. The safe + // answer is no `save` edge at all — never the other class's return type. + it.each([ + { source: 'viaA', targets: ['Method:accounts.dart:A.getUser#0'] }, + { + source: 'viaB', + targets: ['Method:accounts.dart:Admin.save#0', 'Method:accounts.dart:B.getUser#0'], + }, + { source: 'viaC', targets: ['Method:reversed.dart:C.getUser#0'] }, + { + source: 'viaD', + targets: ['Method:accounts.dart:User.save#0', 'Method:reversed.dart:D.getUser#0'], + }, + ])('resolves every call in $source to $targets', ({ source, targets }) => { + expect(dartCallTargets(result, 'callers.dart', source)).toEqual(targets); + }); + }, +); diff --git a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts index a932b571a..1c929f898 100644 --- a/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/inferred-field-receiver-matrix.test.ts @@ -32,6 +32,12 @@ * Dart Outer.inner Outer.inner (#2807) Outer.inner (#2807) * Swift both links both links (#2807) optional field (#2807) * + * Swift's second link (`Inner.compute`) arrived with #3498, when Swift started + * walking up to the Module scope for hoisted return types. Dart walks up too + * since #3498, but its `Outer` rows keep one link: later classes in the Dart + * fixture redeclare `inner`, which overwrites `Outer.inner`'s hoisted binding, + * and the owner-checked walk declines rather than borrow another class's type. + * * Shapes marked n/a do not exist in that language: Python and Ruby have no * field DECLARATIONS at all (a field is created by assignment, so only the * assigned column is meaningful), PHP property initializers accept only @@ -1901,10 +1907,21 @@ const CASES: readonly LanguageCase[] = [ // {Alien, Alien.inner, Outer.inner}. A regression that merely breaks the // fixture empties the set and the row still fails — it cannot pass // vacuously in either direction. + // + // The witness also carries `Inner.compute#1` since #3498 (Dart sets + // `hoistTypeBindingsToModule`): `Alien.inner()` returns `Inner`, and + // `Alien` is the LAST class in this file to declare `inner`, so its + // binding is the one the Module scope keeps. `Outer.inner`'s binding was + // overwritten by that same name, so the owner-checked walk declines on + // `Outer` receivers and the rows above stay at `Outer.inner` alone. { name: 'loop-var-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:LoopVarReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // Dart 3 patterns are the binder family this branch widened twice, so the @@ -1913,7 +1930,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'pattern-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:PatternReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // ONE ROW PER BINDER SHAPE, because the two rows above pin two of the @@ -1938,7 +1959,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'param-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:ParamReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // A local `var ze;` with NO initializer: the resolver has nothing to type @@ -1947,7 +1972,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'local-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:LocalReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // Deliberately a BARE `catch (zf)` rather than the `on Err catch (w)` the @@ -1958,7 +1987,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'catch-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:CatchReadShadowedField.probe#0`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // The closure parameter is the shape that pins WHERE the mask is emitted. @@ -1972,7 +2005,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'closure-param-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:ClosureParamReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // The second for-in form. `for (var zh in xs)` and the `final` form of @@ -1984,7 +2021,11 @@ const CASES: readonly LanguageCase[] = [ { name: 'loop-var-var-read-does-not-see-the-field', callerId: `Method:${DART_FILE}:LoopVarVarReadShadowedField.probe#1`, - targets: [`Class:${DART_FILE}:Alien`, `Method:${DART_FILE}:Alien.inner#0`], + targets: [ + `Class:${DART_FILE}:Alien`, + `Method:${DART_FILE}:Alien.inner#0`, + `Method:${DART_FILE}:Inner.compute#1`, + ], status: 'resolves', }, // THE COUNTERWEIGHT. `use` reads the same field by bare name and binds @@ -2035,13 +2076,17 @@ const CASES: readonly LanguageCase[] = [ { name: 'control-local', callerId: `Function:${SWIFT_FILE}:ControlLocal.run#1`, - targets: [`Class:${SWIFT_FILE}:Outer`, `Function:${SWIFT_FILE}:Outer.inner#0`], + targets: [ + `Class:${SWIFT_FILE}:Outer`, + `Function:${SWIFT_FILE}:Inner.compute#1`, + `Function:${SWIFT_FILE}:Outer.inner#0`, + ], status: 'resolves', }, { name: 'inferred-field', callerId: `Function:${SWIFT_FILE}:InferredField.run#1`, - targets: [`Function:${SWIFT_FILE}:Outer.inner#0`], + targets: [`Function:${SWIFT_FILE}:Inner.compute#1`, `Function:${SWIFT_FILE}:Outer.inner#0`], status: 'resolves', }, // Swift cannot declare a stored property with neither a type nor an @@ -2054,7 +2099,7 @@ const CASES: readonly LanguageCase[] = [ { name: 'optional-assigned-field', callerId: `Function:${SWIFT_FILE}:OptionalAssignedField.run#1`, - targets: [`Function:${SWIFT_FILE}:Outer.inner#0`], + targets: [`Function:${SWIFT_FILE}:Inner.compute#1`, `Function:${SWIFT_FILE}:Outer.inner#0`], status: 'resolves', }, ], diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 76b25a565..a766467c4 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -6,7 +6,9 @@ * NOTE: Swift is installed as an optional dependency. These tests skip gracefully * if a consumer installs without optional dependencies. */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'path'; import { FIXTURES, @@ -15,6 +17,7 @@ import { getNodesByLabelFull, edgeSet, runPipelineFromRepo, + writeFixtureRepo, type PipelineResult, } from './helpers.js'; import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; @@ -1622,3 +1625,212 @@ describe.skipIf(!swiftAvailable)('Swift enum members (F79)', () => { expect(headingEdges).toHaveLength(1); }); }); + +// --------------------------------------------------------------------------- +// #3498 RC2: `swiftBindingScopeFor` hoists every method's return-type binding +// onto the Module scope, so `svc.getUser().save()` types its receiver only if +// the receiver fold walks up there (`hoistTypeBindingsToModule`). The hoisted +// entries are keyed by bare method name, so the walk must also refuse a +// same-named method of ANOTHER class — the second describe pins that. +// --------------------------------------------------------------------------- + +/** Every CALLS target id emitted by `source` in `filePath`, sorted. */ +function swiftCallTargets(result: PipelineResult, filePath: string, source: string): string[] { + return getRelationships(result, 'CALLS') + .filter((edge) => edge.source === source && edge.sourceFilePath === filePath) + .map((edge) => edge.rel.targetId) + .sort(); +} + +describe.skipIf(!swiftAvailable)( + 'Swift chained receivers through hoisted return types (#3498)', + () => { + let root: string; + let result: PipelineResult; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-swift-hoisted-return-')); + writeFixtureRepo(root, { + 'Models.swift': `class Address { + func save() {} +} + +class User { + var address: Address = Address() + var optionalAddress: Address? = Address() + func save() {} +} + +class Service { + func getUser() -> User { return User() } +} + +class ChildService: Service {} + +extension Service { + func getProfile() -> User { return User() } +} +`, + 'Callers.swift': `func plainChain(svc: Service) { + svc.getUser().save() +} + +func plainDeepChain(svc: Service) { + svc.getUser().address.save() +} + +func optionalChain(svc: Service) { + svc.getUser().optionalAddress?.save() +} + +func nonNullAssert(svc: Service) { + svc.getUser().optionalAddress!.save() +} + +func inheritedChain(child: ChildService) { + child.getUser().save() +} + +func extensionChain(svc: Service) { + svc.getProfile().save() +} +`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each([ + { + source: 'plainChain', + targets: ['Function:Models.swift:Service.getUser#0', 'Function:Models.swift:User.save#0'], + }, + { + source: 'plainDeepChain', + targets: [ + 'Function:Models.swift:Address.save#0', + 'Function:Models.swift:Service.getUser#0', + ], + }, + { + source: 'optionalChain', + targets: [ + 'Function:Models.swift:Address.save#0', + 'Function:Models.swift:Service.getUser#0', + ], + }, + { + source: 'nonNullAssert', + targets: [ + 'Function:Models.swift:Address.save#0', + 'Function:Models.swift:Service.getUser#0', + ], + }, + { + // The owner itself declares nothing; the walk refuses the binding for + // `ChildService` and accepts it for the `Service` it inherits from. + source: 'inheritedChain', + targets: ['Function:Models.swift:Service.getUser#0', 'Function:Models.swift:User.save#0'], + }, + { + // An extension member is owned by the extended type, and its binding is + // hoisted to the same Module scope. + source: 'extensionChain', + targets: [ + 'Function:Models.swift:Service.getProfile#0', + 'Function:Models.swift:User.save#0', + ], + }, + ])('resolves every call in $source to $targets', ({ source, targets }) => { + expect(swiftCallTargets(result, 'Callers.swift', source)).toEqual(targets); + }); + }, +); + +describe.skipIf(!swiftAvailable)( + 'Swift same-named methods on two classes do not share a hoisted return type (#3498)', + () => { + let root: string; + let result: PipelineResult; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-swift-hoisted-collision-')); + // One Module-scope entry per method NAME: in each file the later class's + // `getUser` overwrites the earlier one's. Both orders are covered so the + // test does not depend on which one happens to win. + writeFixtureRepo(root, { + 'Accounts.swift': `class User { + func save() {} +} + +class Admin { + func save() {} +} + +class A { + func getUser() -> User { return User() } +} + +class B { + func getUser() -> Admin { return Admin() } +} +`, + 'Reversed.swift': `class C { + func getUser() -> Admin { return Admin() } +} + +class D { + func getUser() -> User { return User() } +} +`, + 'Callers.swift': `func viaA(a: A) { + a.getUser().save() +} + +func viaB(b: B) { + b.getUser().save() +} + +func viaC(c: C) { + c.getUser().save() +} + +func viaD(d: D) { + d.getUser().save() +} +`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + // The module's bare-name return bindings are overwritten in both orders. + // Exact declaration return types must recover each class's own target. + it.each([ + { + source: 'viaA', + targets: ['Function:Accounts.swift:A.getUser#0', 'Function:Accounts.swift:User.save#0'], + }, + { + source: 'viaB', + targets: ['Function:Accounts.swift:Admin.save#0', 'Function:Accounts.swift:B.getUser#0'], + }, + { + source: 'viaC', + targets: ['Function:Accounts.swift:Admin.save#0', 'Function:Reversed.swift:C.getUser#0'], + }, + { + source: 'viaD', + targets: ['Function:Accounts.swift:User.save#0', 'Function:Reversed.swift:D.getUser#0'], + }, + ])('resolves every call in $source to $targets', ({ source, targets }) => { + expect(swiftCallTargets(result, 'Callers.swift', source)).toEqual(targets); + }); + }, +); diff --git a/gitnexus/test/integration/swift-hoisted-return-ownership.test.ts b/gitnexus/test/integration/swift-hoisted-return-ownership.test.ts new file mode 100644 index 000000000..e9bd294f6 --- /dev/null +++ b/gitnexus/test/integration/swift-hoisted-return-ownership.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + writeFixtureRepo, +} from './resolvers/helpers.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'Swift hoisted return ownership', + () => { + it.each([ + [ + 'same-arity overload', + `class User { func save() {} } +class Admin { func save() {} } +class Service { + func getUser(_ id: Int) -> User { return User() } + func getUser(_ name: String) -> Admin { return Admin() } +} +func probe(svc: Service) { svc.getUser(1).save() } +`, + 'Admin', + false, + ], + [ + 'same-owner overload', + `class User { func save() {} } +class Admin { func save() {} } +class Service { + func getUser() -> User { return User() } + func getUser(_ id: Int) -> Admin { return Admin() } +} +func probe(svc: Service) { svc.getUser().save() } +`, + 'Admin', + false, + ], + [ + 'nearer override shadows late base', + `class User { func save() {} } +class Admin: User { override func save() {} } +class Child: Base { + override func getUser() -> Admin { return Admin() } +} +class Base { + func getUser() -> User { return User() } +} +func probe(svc: Child) { svc.getUser().save() } +`, + 'User', + true, + ], + [ + 'single declaration remains positive', + `class User { func save() {} } +class Service { func getUser() -> User { return User() } } +func probe(svc: Service) { svc.getUser().save() } +`, + 'Admin', + false, + ], + ] as const)( + '%s', + async (label, source, wrongOwner, inherited) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-return-owner-')); + try { + const receiver = inherited ? 'Child' : 'Service'; + const argument = label === 'same-arity overload' ? '1' : ''; + writeFixtureRepo(root, { + 'Models.swift': + source + + ` +func probeBound(svc: ${receiver}) { + let user = svc.getUser(${argument}) + user.save() +} +`, + }); + const result = await runPipelineFromRepo(root, () => {}, {}); + expect(getNodesByLabel(result, 'Class')).toContain('User'); + const edges = getRelationships(result, 'CALLS').filter( + (e) => (e.source === 'probe' || e.source === 'probeBound') && e.target === 'save', + ); + expect(edges.filter((e) => e.rel.targetId.includes(`${wrongOwner}.save`))).toEqual([]); + if (label === 'same-arity overload') { + expect(edges).toEqual([]); + } + if (label === 'same-owner overload') { + const bound = edges.filter((e) => e.source === 'probeBound'); + expect(bound).toHaveLength(1); + expect(bound[0]!.rel.targetId).toContain('User.save'); + } + if (inherited) + expect( + getRelationships(result, 'EXTENDS').some( + (e) => e.source === 'Child' && e.target === 'Base', + ), + ).toBe(true); + if (inherited) { + for (const caller of ['probe', 'probeBound']) { + const targets = edges.filter((e) => e.source === caller).map((e) => e.rel.targetId); + expect(targets).toHaveLength(1); + expect(targets[0]).toContain('Admin.save'); + } + } + if (label === 'single declaration remains positive') expect(edges).toHaveLength(2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + 120000, + ); + }, +); diff --git a/gitnexus/test/integration/swift-parameterized-override-replay.test.ts b/gitnexus/test/integration/swift-parameterized-override-replay.test.ts new file mode 100644 index 000000000..7bf3a6d7c --- /dev/null +++ b/gitnexus/test/integration/swift-parameterized-override-replay.test.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './resolvers/helpers.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; + +describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'Swift assigned override returns', + () => { + it.each([ + { label: 'fixed parameters', parameter: '_ ids: Int', args: '1' }, + { label: 'variadic parameters', parameter: '_ ids: Int...', args: '1, 2' }, + ])('retains the exact bound return for $label', async ({ parameter, args }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-override-replay-')); + try { + writeFixtureRepo(root, { + 'Main.swift': `class User { func save() {} } +class Admin: User { override func save() {} } +class Base { func find(${parameter}) -> User { User() } } +class Child: Base { override func find(${parameter}) -> Admin { Admin() } } +func probeBound(svc: Child) { let u = svc.find(${args}); u.save() } +`, + }); + const result = await runPipelineFromRepo(root, () => {}, {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'probeBound' && edge.target === 'save', + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.rel.targetId).toBe('Function:Main.swift:Admin.save#0'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + label: 'different fixed parameter types', + base: '_ value: Int', + child: '_ value: String', + args: '1', + }, + { + label: 'different variadic parameter types', + base: '_ values: Int...', + child: '_ values: String...', + args: '1, 2', + }, + { + label: 'different metatype parameter types', + base: '_ kind: User.Type, _ values: Int...', + child: '_ kind: Admin.Type, _ values: Int...', + args: 'User.self, 1, 2', + }, + ])('preserves return-owner precision for $label', async ({ base, child, args }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-overload-replay-')); + try { + writeFixtureRepo(root, { + 'Main.swift': `class User { func save() {} } +class Admin { func save() {} } +class Base { func find(${base}) -> User { User() } } +class Child: Base { func find(${child}) -> Admin { Admin() } } +func probeBound(svc: Child) { let u = svc.find(${args}); u.save() } +func probeControl(user: User) { user.save() } +`, + }); + const result = await runPipelineFromRepo(root, () => {}, {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'probeBound' && edge.target === 'save', + ); + // Call argument types are not captured yet. Check owner precision + // without requiring the correct inherited result to stay unresolved. + for (const call of calls) { + expect(call.rel.targetId).toBe('Function:Main.swift:User.save#0'); + } + // This supported call checks fixture-local resolution, not completeness + // of the primary call or coverage of every selective primary-path loss. + const controlCalls = getRelationships(result, 'CALLS') + .filter((edge) => edge.source === 'probeControl' && edge.target === 'save') + .map((edge) => edge.rel.targetId) + .sort(); + expect(controlCalls).toEqual(['Function:Main.swift:User.save#0']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + }, +); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts index 661bf956e..10ae57ee8 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts @@ -150,6 +150,23 @@ describe('cppConversionRank braced-init-list ranks (#1899)', () => { expect(result).toEqual([]); }); + it('keeps conversion-only suppression when a parameter slot is unknown', () => { + const unknown = mkDef('f:unknown', [''], []); + const byInt = mkDef('f:int', ['int'], [value('int')]); + const argument = 'braced-init:unknown:2'; + + expect(cppConversionRank(argument, '')).toBe(Infinity); + expect(cppConversionRank(argument, 'int')).toBe(Infinity); + + const result = narrowOverloadCandidates([unknown, byInt], 1, [argument], { + argumentTypeClasses: [value(argument)], + conversionRankFn: cppConversionRank, + conversionOnlyArgTypePrefixes: CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, + }); + + expect(result).toEqual([]); + }); + it('preserves single-candidate recall for unrankable braced-init lists', () => { const byIntList = mkDef( 'f:int-list', diff --git a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts index 91ae70a0a..98343ff40 100644 --- a/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts +++ b/gitnexus/test/unit/scope-resolution/overload-narrowing.test.ts @@ -147,6 +147,56 @@ describe('narrowOverloadCandidates — type narrowing', () => { expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']); }); + it('retains an unknown parameter slot alongside an exact typed match', () => { + const unknown = { ...byInt, nodeId: 'm:unknown', parameterTypes: [''] }; + const result = narrowOverloadCandidates([byInt, unknown, byString], 1, ['int']); + expect(result.map((d) => d.nodeId)).toEqual(['m:int', 'm:unknown']); + }); + + it('does not let an unknown parameter slot hide a viable conversion overload', () => { + const unknown = { ...byInt, nodeId: 'm:unknown', parameterTypes: [''] }; + const byLong = { ...byInt, nodeId: 'm:long', parameterTypes: ['long'] }; + const result = narrowOverloadCandidates([unknown, byLong], 1, ['int'], { + conversionRankFn: (argument, parameter) => + argument === parameter ? 0 : argument === 'int' && parameter === 'long' ? 1 : Infinity, + }); + expect(result.map((d) => d.nodeId)).toEqual(['m:unknown', 'm:long']); + }); + + it('keeps exact known-slot requirements when an exact overload exists', () => { + const exact = mkDef({ + nodeId: 'm:exact', + parameterCount: 2, + requiredParameterCount: 2, + parameterTypes: ['int', 'long'], + }); + const partial = { ...exact, nodeId: 'm:partial', parameterTypes: ['', 'double'] }; + const result = narrowOverloadCandidates([exact, partial], 2, ['int', 'long'], { + conversionRankFn: (argument, parameter) => + argument === parameter ? 0 : argument === 'long' && parameter === 'double' ? 1 : Infinity, + }); + expect(result.map((d) => d.nodeId)).toEqual(['m:exact']); + }); + + it.each([ + { parameterTypes: ['', 'bool'], expected: ['m:exact', 'm:partial'] }, + { parameterTypes: ['', 'string'], expected: ['m:exact'] }, + { parameterTypes: ['string', ''], expected: ['m:exact'] }, + ])( + 'keeps positional unknown slots without ignoring known mismatches: $parameterTypes', + ({ parameterTypes, expected }) => { + const exact = mkDef({ + nodeId: 'm:exact', + parameterCount: 2, + requiredParameterCount: 2, + parameterTypes: ['int', 'bool'], + }); + const partial = { ...exact, nodeId: 'm:partial', parameterTypes }; + const result = narrowOverloadCandidates([exact, partial], 2, ['int', 'bool']); + expect(result.map((d) => d.nodeId)).toEqual(expected); + }, + ); + it('falls through to arity-filtered candidates when type filter matches nothing', () => { const result = narrowOverloadCandidates([byInt, byString], 1, ['bool']); // Type mismatch against both — falls back to arity candidates. diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts index c4b782f7c..3478041a5 100644 --- a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts @@ -8,6 +8,8 @@ import { describe, it, expect } from 'vitest'; import type { ScopeId } from 'gitnexus-shared'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { reconcileOwnership } from '../../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js'; import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js'; import { foldReceiverChain } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js'; @@ -99,15 +101,18 @@ const ctx: FoldContext = { fixture: tsFixture, inScope: tsFixture.moduleScope }; * what makes these tests measure the same fold `emitReceiverBoundCalls` runs. * * `overrides` replaces individual flags — used to exercise the OFF branch of - * `hoistTypeBindingsToModule` (which six wired languages: c, cobol, dart, - * python, ruby, swift, actually run) and the no-`elementTypeOf` branch (which + * `hoistTypeBindingsToModule` (which c, cobol, python and ruby actually run) + * and the no-`elementTypeOf` branch (which * twelve of the fourteen do). */ function foldIn(ctx: FoldContext, encoded: string, overrides: FoldOptions = {}) { const decoded = decodeReceiverChain(encoded); expect(decoded).toBeDefined(); + const model = createSemanticModel(); + reconcileOwnership([ctx.fixture.parsed], model); return foldReceiverChain(decoded!, ctx.inScope, ctx.fixture.scopes, ctx.fixture.index, { fieldFallback: false, + lookupOwnedMethods: (owner, name) => model.methods.lookupAllByOwner(owner, name), hoistTypeBindingsToModule: true, elementTypeOf: ctx.fixture.resolver.elementTypeOf, ...overrides, @@ -173,6 +178,23 @@ describe('foldReceiverChain', () => { }); expect(foldIn(ctx, '2|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined(); }); + + // `hoistedTypeBindingsRequireOwner` joins a hoisted binding to the member that + // declared it. `Service` declares `getUser`, so that binding joins. `inherited` + // is declared by `Base`, and this unit fixture builds no MRO, so the only way + // `Service` ever reached it was the name-keyed walk — which the toggle refuses + // and the default keeps (the pipeline reaches `Base` through the MRO instead). + it.each([ + { chain: '2|svc|cgetUser', requireOwner: true, expected: 'User' }, + { chain: '2|svc|cinherited', requireOwner: true, expected: undefined }, + { chain: '2|svc|cinherited', requireOwner: false, expected: 'User' }, + ])( + 'folds $chain to $expected when hoistedTypeBindingsRequireOwner is $requireOwner', + ({ chain, requireOwner, expected }) => { + const folded = foldIn(ctx, chain, { hoistedTypeBindingsRequireOwner: requireOwner }); + expect(folded?.qualifiedName).toEqual(expected); + }, + ); }); /** diff --git a/gitnexus/test/unit/swift-arity-metadata-review.test.ts b/gitnexus/test/unit/swift-arity-metadata-review.test.ts new file mode 100644 index 000000000..5ffc61841 --- /dev/null +++ b/gitnexus/test/unit/swift-arity-metadata-review.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { emitSwiftScopeCaptures } from '../../src/core/ingestion/languages/swift/captures.js'; +import { getSwiftParser } from '../../src/core/ingestion/languages/swift/query.js'; +import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js'; + +describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))( + 'Swift parameter signature completeness', + () => { + it('preserves an unknown pack slot before a known variadic parameter', () => { + const source = 'func find(_ kinds: repeat each T, values: Int...) -> User { User() }'; + expect(getSwiftParser().parse(source).rootNode.hasError).toBe(false); + const groups = emitSwiftScopeCaptures(source, 'signature.swift').filter( + (group) => group['@scope.function'], + ); + expect(groups).toHaveLength(1); + expect(groups[0]?.['@declaration.parameter-count']).toBeUndefined(); + const types = groups[0]?.['@declaration.parameter-types']; + expect(types).toBeDefined(); + expect(JSON.parse(types?.text ?? 'null')).toEqual(['', 'Int', 'variadic']); + }); + }, +);