fix(swift,dart): type chained receivers through hoisted return types (#3498) (#3666)

* fix(swift,dart): type chained receivers through hoisted return types (#3498)

Swift and Dart hoist every method's return-type binding out of the class
body onto the Module scope, as C# does, but neither set
hoistTypeBindingsToModule. The receiver walk therefore never looked
there, and svc.getUser().save() had no receiver type. Both now set it.

The flag alone was not safe. The hoisted bindings are keyed by bare
method name, so with class A { getUser() -> User } and
class B { getUser() -> Admin } in one file the Module scope keeps only
B's entry, and a.getUser().save() resolved to Admin.save. C# and
TypeScript already do this on main.

So there is a new ScopeResolver toggle, hoistedTypeBindingsRequireOwner,
which Swift and Dart also set. With it, the walk accepts a hoisted
binding only when its declaration anchor encloses the declaration of a
member of that name owned by the receiver's class. Otherwise it keeps
climbing, and declines if nothing passes. The four walk-ups in
compound-receiver.ts now share one helper.

The toggle is opt-in. With the guard on everywhere, TypeScript loses
correct edges where two same-named methods happen to return the same
type (11 rows in inferred-field-receiver-matrix), because the Module map
cannot hold both. Swift and Dart never had those edges.

Bench: swift plainChain, plainDeepChain, optionalChain and nonNullAssert
(INVISIBLE-GAP) and dart plainChain, plainDeepChain and optionalChain
(VISIBLE-GAP) now RESOLVE with correct targets. No other cell and no
count-arm figure moves. The inferred-field matrix gains correct
Inner.compute edges for Swift (3 rows) and Dart (7 Alien rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): guard hoisted return ownership

* fix(scope): gate hoisted return replay checks

* fix(scope): preserve parameterized return replay

Keep exact callee return facts when ancestor candidates have matching
known parameter types, while refusing same-owner overload ties.

Refs #3666

* fix(scope): preserve known variadic return replay

Compare complete captured parameter vectors across distinct ancestor
owners, including variadic methods with unknown numeric arity. Keep
unknown Swift parameter slots instead of compressing them away.

Refs #3666

* test(swift): retain reviewed receiver fixtures

* fix(scope): retain unknown overload candidates

Keep unknown parameter slots possible without hiding conversion overloads.
Check return-owner precision without requiring valid calls to stay unresolved.

Refs #3498

* fix(scope): preserve conversion suppression

Keep explicit empty conversion results final when parameter metadata is
unknown. Add exact positive controls for receiver-fixture liveness.

Refs #3498

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-10-23 01:52:11 +03:00 • committed by GitHub
parent 673dc6968e
commit cc0b5d54f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1398 additions and 96 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof buildWorkspaceResolutionIndex>,
calleeIds: CalleeIdAccumulator,
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
model: SemanticModel,
requireOwnedHoistedReturn = false,
): number {
let updated = 0;
const returnTypeByGraphId = new Map<string, TypeRef>();
const returnTypeByGraphId = new Map<string, { type: TypeRef; def: SymbolDefinition }>();
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<string, ReferenceSite | null> | 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<string, TypeRef>).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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<each T>(_ 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']);
});
},
);