fix(ingestion): read more receiver-chain spellings (#3498) (#3667)

* fix(ingestion): name chain steps without their explicit type arguments

C#, C++ and Rust put explicit type arguments inside the callee node, not
beside it as TypeScript, Java and Kotlin do. The receiver-chain walk read
the callee's text, so `svc.GetTyped<User>().Save()` built the step
`GetTyped<User>`, which matches no member, and C++ built `getTyped<User>`.
Rust's `svc.get_typed::<User>()` wraps the whole field access in a
`generic_function`, so the walk found neither a name nor the inner
receiver and built no chain at all.

The walk now peels these callee nodes (C# `generic_name`, C++
`template_method`, `template_function` and `dependent_name`, Rust
`generic_function`) before it reads the step name and the receiver. It is
a node-type table next to the existing ones; nothing branches on a
language. Type arguments choose an instantiation, never a member, so the
name loses nothing.

Shape arm: csharp and rust `explicitTypeArgs` go from VISIBLE-GAP to
RESOLVES, to `User.Save`/`User.save`. cpp `explicitTypeArgs` keeps its drop: the step
is now `getTyped`, but `template <typename T> T* getTyped()` has no
return type until `T` is substituted, which is separate work.

baseline.json and BASELINE.md record the move; the count arm and a
corpus A/B against main are unchanged.

The chain wire format is unchanged, so the receiver-chain codec version
stays at 2. Parse caches need no manual bump: the parse-cache identity
hashes every file under `core/ingestion`.

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

* fix(ingestion): read receiver chains through C# `!` and Swift `(x)`

The chain walk peels wrappers that denote exactly their operand, so the
chain's base is `svc` rather than `svc!` or `(svc)`. Two spellings of that
were missing.

C#'s null-forgiving `svc!` is a `postfix_unary_expression`, so
`svc!.GetUser().Save()` built `2|svc!|cGetUser`, whose base binds nothing.
It now joins the `!`-gated entry Swift and Kotlin already use. The same
node type carries `x++` and `x--`, and the `!` gate leaves those alone.

Swift has no parenthesized-expression node: `(await svc.getUserAsync())`
and `(holder)` parse as a one-element `tuple_expression`, and the walk
stopped there. `(await svc.getUserAsync()).save()` built no chain, and
`(holder).repo.save()` built one whose base `(holder)` binds nothing. A new gate peels a tuple only when it is exactly `(`,
one element, `)`. That excludes Rust's real one-element tuple `(svc,)`,
Swift's labelled `(x: svc)`, and every tuple of two or more.

Shape arm: csharp `nonNullAssert` goes from VISIBLE-GAP to RESOLVES
(`User.Save`). Swift
`awaitParen` now builds `2|svc|cgetUserAsync|a`, but it was already a
VISIBLE-GAP and stays one: Swift return types live on the Module scope,
and the fold looks there only after the Swift/Dart hoist change lands.
Swift `(holder).repo.save()` now resolves to `User.save`.

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

* fix(ingestion): walk C# null-conditional access in receiver chains

C# parses `svc?.GetUser()` as a `conditional_access_expression` whose
receiver sits in a `condition` field and whose member sits in a
`member_binding_expression` child. The chain walk read it like any other
callee: it named the step `.GetUser` and found no inner receiver, so
`svc?.GetUser().Save()` built no chain and could never resolve. As a
receiver (`user?.Address.Save()`, `repos?[0].Save()`) the node stopped
the walk, and its whole text became the base.

A node-type table now says where a conditional access keeps its receiver.
As a callee, the walk names the step from the member binding; an element
binding (`handlers?[0]()`) names no member. As a receiver, a member
binding becomes a field step and an element binding becomes an index step.

Shape arm: csharp `optionalChain` goes from VISIBLE-GAP to RESOLVES
(`User.Save`). `svc?.GetTyped<User>().Save()` also resolves, through the
type-argument peel from the earlier commit. `repos?[0].Save()` now
records its miss, which was silent before: `User[] repos` has no type
binding yet. `user?.Address.Save()` already resolved through the
receiver-text cascade; a resolver row pins it.

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

* fix(ingestion): read Ruby and Swift subscripts as index steps

Two grammars spell a subscript as something the chain walk did not
recognise.

Ruby's `repos[0]` is an `element_reference`, which was missing from the
subscript table, so the walk stopped there. The receiver has no `.` or
`(` either, so the call vanished: no edge and no drop.

Swift has no subscript node at all. `repos[0]` parses as a
`call_expression` whose `call_suffix` holds `value_arguments`, the same
as a call, except that the list is delimited by `[`/`]` instead of
`(`/`)`. Walked as a call, it named no member, and `holder.users[0]`
named `users` as if it were a method. A call node whose suffix argument
list opens with `[` is now read as an index step. The opening token is
the only thing that tells the two apart, and `svc.getUser(0)` stays a
call.

Shape arm: ruby `indexElement` goes from INVISIBLE-GAP to VISIBLE-GAP.
`repos` is untyped, so there is nothing to fold, but the miss is now
counted. swift `indexElement` builds `2|repos|i` and records its miss
too, going from INVISIBLE-GAP to VISIBLE-GAP; resolving it needs a
Swift `elementTypeOf` hook from a separate change. So does
`holder.users[0].save()`, which now builds `2|holder|fusers|i` instead
of a call step named `users`.

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

* docs: clarify receiver walker review limits

* test: pin receiver-chain walker control spellings (#3667)

- Kotlin calls share Swift's `call_suffix → value_arguments`; pin that a
  parenthesized and a generic Kotlin call stay call steps, never index steps.
- Pin that a Swift trailing-closure call stays a call step.
- Pin that C# `GetTyped<T>()` and C++ `ns::getTyped<T>()` without a member
  receiver are named for the member and mint no chain.

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

* test: skip Swift chain rows when its grammar is unavailable (#3667)

The walker rows loaded the optional vendored Swift grammar at module scope,
so a host without its prebuild failed to collect every other language's
test in the file. Gate Swift on isLanguageAvailable and report its rows
as skipped; every other row still runs.

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

* test: expect Swift awaitParen through hoisted returns (#3667)

Rebased onto #3666, whose hoisted Swift return types let the walker's
`(x)` chain resolve awaitParen to User.save. This carries the content the
replaced merge commits held: the walker row now expects that edge and no
drop, the receiver baseline marks swift awaitParen RESOLVES, and
BASELINE.md keeps the measured main and stack integration notes.

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

* docs(bench): label parent-only awaitParen note and drop issue claim (#3667)

The #3666 section said Swift awaitParen still needed a fix. That holds for
#3666 alone; combined with this walker it resolves to User.save with no
drop, while dart nonNullAssert stays unsupported. Also stop calling the
generic-arity limitation tracked by #3609, a general triage tracker.

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

---------

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 02:33:50 +03:00 • committed by GitHub
parent cc0b5d54f7
commit 09b7a5b174
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 980 additions and 19 deletions

View file

@ -1,5 +1,34 @@
# Receiver-resolution baseline
## Receiver-chain walker spellings (#3498)
`extractMixedChain` misread some receiver spellings: it read the wrong step
name, stopped early, or built no chain at all. Each fix below moved only the
cells listed, and the count arm did not move. A same-corpus A/B over
`test/fixtures/lang-resolution` against `origin/main` (`ab0b03846`) emits
identical CALLS/ACCESSES edges (2,924) and identical `receiver-unresolved`
drops (208). No scope-capture fingerprint moved, and a capture dump of 13
emitters over that corpus is byte-identical, because no fixture uses these
spellings.
- **Explicit type arguments inside the callee** (C# `GetTyped<User>`, C++
`getTyped<User>`, Rust `get_typed::<User>`): csharp `explicitTypeArgs`
VISIBLE-GAP → RESOLVES and rust `explicitTypeArgs` VISIBLE-GAP →
RESOLVES, both to `User.Save`/`User.save`. cpp `explicitTypeArgs` stays
VISIBLE-GAP: the step is now `getTyped`, but `T` is not substituted into
the template's return type.
- **C# `svc!` and Swift `(x)`** (a one-element `tuple_expression`): csharp
`nonNullAssert` VISIBLE-GAP → RESOLVES (`User.Save`). swift `awaitParen`
now builds its chain. On its own it stays VISIBLE-GAP; with #3666's hoisted
Swift return types it RESOLVES (`User.save`).
- **C# null-conditional access** (`svc?.GetUser()`): csharp `optionalChain`
VISIBLE-GAP → RESOLVES (`User.Save`).
- **Subscripts spelled as other node types** (Ruby `element_reference`, Swift
call with a bracketed argument list): ruby `indexElement` and swift
`indexElement` INVISIBLE-GAP → VISIBLE-GAP. Ruby's `repos` is untyped, so
there is nothing to fold; Swift has no `elementTypeOf` hook yet. Both
misses are now counted instead of silent.
## Swift/Dart stack integration after Rust and C#
The Swift/Dart ownership and replay implementation is unchanged from reviewed
@ -28,8 +57,11 @@ 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.
`optionalChain` (each VISIBLE-GAP before). On #3666 alone (historical), Swift
`awaitParen` and dart `nonNullAssert` remained unsupported. Combined with the
receiver-chain walker (#3667), Swift `awaitParen` resolves to
`Function:models.swift:User.save#0` with no drop; dart `nonNullAssert` remains
unsupported.
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
@ -1149,6 +1181,18 @@ 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.
### Receiver spelling walkers after #3653
Main `2b4456553` is merged normally. Fresh package build and both TypeScript configurations pass, with 83 unit and native receiver-chain checks in two files. That historical baseline had 133 call drops, 160 total, 27 reads and zero writes. The merge preserves this PR's shape changes listed above; it does not restore main's original shape states. The current-main measurement below supersedes those counts. Final review and hosted CI remain required.
Normal main `caacc3bac` integration: package build, both pinned TypeScript configurations, 83 native/unit chain-walker tests in two files and all 16 capture scaling budgets pass without widening a budget. The regenerated receiver baseline has 130 calls, 156 total, 26 reads and zero writes. Shape states are unchanged by this main merge. Graph coverage remains a lower bound.
The identical corpus comparison establishes no collateral edge changes in those fixtures. Native spelling tests and shape-cell changes establish the new behavior. Generic-arity overload ownership remains a known limitation outside this change: a two-arity C# probe produces the same wrong edges with this branch's walker and with main's walker, so that concrete defect predates this change.
Normal main `c30449394` integration: package build, both pinned TypeScript configurations, the receiver `--check` and all 16 capture scaling checks pass without changing a budget. The regenerated receiver baseline is identical to the merged file: 132 call drops, 160 total, 28 reads and zero writes, with main's shape states plus this PR's six moves. Focused tests pass: 88 chain-walker tests in two files, 143 receiver-origin, unresolved-receiver, C++ and Rust receiver tests in seven files, and 244 C# resolver tests. The C# file aborted natively once under host load and passed when rerun alone. Graph coverage remains a lower bound.
Normal merge of the validated #3666 stack head `684438d88`, which carries main `e7ef82dfc`: the only conflicts were additive BASELINE.md sections. The package build, both pinned TypeScript configurations, 231 tests in the nine focused receiver files, 248 C# and 130 Swift resolver tests pass. With Swift unavailable, the call-form file still collects: 64 pass and the nine Swift rows skip. Swift `awaitParen` now resolves to `User.save`, so its walker row expects that edge and no drop. The regenerated receiver baseline keeps the parent's counts (138 call drops, 168 total, 30 reads, zero writes) and moves seven cells against it: this PR's six plus swift `awaitParen` VISIBLE-GAP → RESOLVES. The 5,559-edge resolution corpus is byte-identical to the parent's. Scope-capture checks for C#, C++, Rust, Ruby, Swift and Kotlin pass with unchanged budgets. Earlier counts in this section are historical. Graph coverage remains a lower bound.
## Hoisted return ownership after main integration
Main `c30449394` is merged normally. Valid Swift optional-chain and force-

View file

@ -116,10 +116,10 @@
"csharp": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "VISIBLE-GAP",
"nonNullAssert": "VISIBLE-GAP",
"optionalChain": "RESOLVES",
"nonNullAssert": "RESOLVES",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "VISIBLE-GAP",
"explicitTypeArgs": "RESOLVES",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
@ -132,7 +132,7 @@
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
@ -143,7 +143,7 @@
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "VISIBLE-GAP",
"explicitTypeArgs": "RESOLVES",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
@ -178,9 +178,9 @@
"plainDeepChain": "RESOLVES",
"optionalChain": "RESOLVES",
"nonNullAssert": "RESOLVES",
"awaitParen": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"

View file

@ -395,8 +395,34 @@ const SUBSCRIPT_NODE_TYPES = new Set([
'element_access_expression', // C#
'array_access', // Java
'indexing_expression', // Kotlin
'element_reference', // Ruby
]);
/**
* Call nodes that spell a SUBSCRIPT. Swift has no subscript node: `repos[0]`
* parses as `call_expression → call_suffix → value_arguments`, exactly like a
* call, except that the argument list is delimited by `[`/`]` rather than
* `(`/`)`. Walked as a call it named no member (or, for `svc.users[0]`, named
* `users` as if it were a method), so the subscript never became an index step.
*
* Keyed by the suffix node type; the value is the argument-list node type
* inside it. The opening `[` token is what tells a subscript from a call.
*/
const BRACKETED_CALL_SUFFIXES = new Map<string, string>([
['call_suffix', 'value_arguments'], // Swift `repos[0]`
]);
/** Is `node` a call node that is really a subscript, `repos[0]`? */
function isBracketedSubscriptCall(node: SyntaxNode): boolean {
if (!CALL_EXPRESSION_TYPES.has(node.type)) return false;
const suffix = node.lastNamedChild;
if (suffix === null) return false;
const argumentListType = BRACKETED_CALL_SUFFIXES.get(suffix.type);
if (argumentListType === undefined) return false;
const argumentList = suffix.firstNamedChild;
return argumentList?.type === argumentListType && argumentList.firstChild?.type === '[';
}
/**
* Can the chain walk descend into this node, or is it the base?
*
@ -413,7 +439,8 @@ function isChainableReceiverNode(node: SyntaxNode): boolean {
CALL_EXPRESSION_TYPES.has(node.type) ||
FIELD_ACCESS_NODE_TYPES.has(node.type) ||
AWAIT_EXPRESSION_NODE_TYPES.has(node.type) ||
SUBSCRIPT_NODE_TYPES.has(node.type)
SUBSCRIPT_NODE_TYPES.has(node.type) ||
CONDITIONAL_ACCESS_NODE_TYPES.has(node.type)
);
}
@ -428,6 +455,34 @@ const FIELD_ACCESS_NODE_TYPES = new Set([
'member_binding_expression', // C# null-conditional (user?.Address)
]);
/**
* Null-conditional access, which splits one member access across two nodes.
* C# parses `svc?.GetUser()` as
* `conditional_access_expression(condition: svc, member_binding_expression)`:
* the receiver sits in the outer node's `condition` field and the member name
* in the binding child. Read like any other callee, the step was named
* `.GetUser` and had no inner receiver, so no chain was built and the call was
* lost without a drop. As a receiver (`svc?.Address.Save()`) the node stopped
* the walk, and its whole text became the base.
*
* Keyed by node type; the value is the field that holds the receiver.
*/
const CONDITIONAL_ACCESS_NODE_TYPES = new Map<string, string>([
['conditional_access_expression', 'condition'], // C# `svc?.GetUser()`, `svc?.Address`, `repos?[0]`
]);
/** The binding child of a conditional access that names a member: C# `?.Name`. */
const MEMBER_BINDING_NODE_TYPES = new Set(['member_binding_expression']);
/** The binding child of a conditional access that is a subscript: C# `?[0]`. */
const ELEMENT_BINDING_NODE_TYPES = new Set(['element_binding_expression']);
/** The receiver of a conditional access, or `null` for any other node. */
function conditionalAccessReceiver(node: SyntaxNode): SyntaxNode | null {
const field = CONDITIONAL_ACCESS_NODE_TYPES.get(node.type);
return field === undefined ? null : node.childForFieldName(field);
}
/**
* One step in a mixed receiver chain.
*
@ -557,8 +612,9 @@ export function extractCallChain(
* operand rather than as the cast target.
*
* Keyed by node type; the value is the operator text that has to TERMINATE the
* node for the peel to apply, or `null` for a node type that is transparent
* unconditionally.
* node for the peel to apply, `null` for a node type that is transparent
* unconditionally, or `SOLE_ELEMENT` for a tuple node that is transparent only
* when it holds exactly one element.
*
* The gated entry exists because Swift force-unwrap (`self.a!`) is the exact
* semantic of TypeScript's `non_null_expression` — it yields the wrapped type —
@ -567,8 +623,17 @@ export function extractCallChain(
* node type unconditionally would type the receiver as the operand and could
* produce a confidently wrong owner. Reading the operator keeps the peel to the
* case that is provably type-preserving.
*
* The `SOLE_ELEMENT` entry exists because Swift has no parenthesized-expression
* node: `(await svc.getUserAsync())` parses as a `tuple_expression` with one
* element. Only that shape denotes its operand. Rust also emits
* `tuple_expression`, and there `(svc,)` is a real one-element tuple; the
* trailing comma is an extra child, so the gate leaves it alone, as it does
* Swift's labelled `(x: svc)` and every tuple of two or more.
*/
const TRANSPARENT_RECEIVER_WRAPPERS = new Map<string, string | null>([
const SOLE_ELEMENT = Symbol('sole-element');
const TRANSPARENT_RECEIVER_WRAPPERS = new Map<string, string | null | typeof SOLE_ELEMENT>([
['non_null_expression', null], // TypeScript `svc!`
['parenthesized_expression', null], // `(svc)`
// NOT Swift-only: Kotlin's `!!` non-null assertion parses as the same node type
@ -578,6 +643,10 @@ const TRANSPARENT_RECEIVER_WRAPPERS = new Map<string, string | null>([
// rather than assumed. Any other grammar emitting `postfix_expression` is
// affected as well; the `!` gate, not the language, is what bounds this.
['postfix_expression', '!'], // Swift `self.a!`, Kotlin `a!!`
// C#'s null-forgiving `svc!`. The same node type carries `x++` and `x--`,
// which the `!` gate leaves alone.
['postfix_unary_expression', '!'], // C# `svc!`
['tuple_expression', SOLE_ELEMENT], // Swift `(await svc.getUserAsync())`
]);
/** Is `node` a wrapper that denotes exactly what its operand denotes? */
@ -591,6 +660,9 @@ function isTransparentReceiverWrapper(node: SyntaxNode): boolean {
// `has` probe, and one crossing of the native `node.type` getter.
if (operator === undefined) return false;
if (operator === null) return true;
// `(`, the element, `)`: anything more is a comma, a label or a second
// element, and then the node is a real tuple.
if (operator === SOLE_ELEMENT) return node.childCount === 3 && node.namedChildCount === 1;
// The operator is an anonymous token, so it is not in `namedChildren`; the
// node's own text is the reliable place to read it. Deliberately NOT
// `node.lastChild` — measured 3x SLOWER (the child wrapper allocation
@ -620,6 +692,64 @@ function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode {
return current;
}
/**
* Callee nodes that carry EXPLICIT TYPE ARGUMENTS inside them.
*
* TypeScript, Java and Kotlin put `<User>` beside the callee, in a sibling field,
* so the walk never sees it. These grammars put it inside the callee node:
* C# `svc.GetTyped<User>()` and C++ `svc->getTyped<User>()` wrap the member
* name, so the step was named `GetTyped<User>` and matched no member. Rust's
* `svc.get_typed::<User>()` wraps the whole field access, so the walk found no
* name and no inner receiver, and minted no chain at all.
*
* Type arguments can select an instantiation or overload. The chain stores
* only the member name; generic-arity overload selection is not represented
* here and remains a resolver limitation. Substituting type arguments into
* a generic return type is also a separate problem: C++
* `template <typename T> T* getTyped()` still has no return type to fold.
*
* Keyed by node type. The value is the field that holds the callee without its
* type arguments, or `null` where the grammar gives that child no field name
* (it is then the first named child).
*/
const TYPE_ARGUMENT_CALLEE_NODE_TYPES = new Map<string, string | null>([
['generic_name', null], // C# `GetTyped<User>`
['template_method', 'name'], // C++ `svc->getTyped<User>`
['template_function', 'name'], // C++ `getTyped<User>`
['dependent_name', null], // C++ `svc->template getTyped<User>`, wraps a template_method
['generic_function', 'function'], // Rust `svc.get_typed::<User>`, wraps the field access
]);
/** Iteration bound for the type-argument peel. `dependent_name` nests a
* `template_method`, so real code needs two. */
const MAX_TYPE_ARGUMENT_PEEL_DEPTH = 3;
/** `node` without the explicit type arguments it carries, if any. */
function stripTypeArguments(node: SyntaxNode): SyntaxNode {
let current = node;
for (let i = 0; i < MAX_TYPE_ARGUMENT_PEEL_DEPTH; i++) {
const field = TYPE_ARGUMENT_CALLEE_NODE_TYPES.get(current.type);
if (field === undefined) break;
const inner = field === null ? current.firstNamedChild : current.childForFieldName(field);
if (inner === null) break;
current = inner;
}
return current;
}
/**
* The member a call's callee names, without type arguments. For a
* null-conditional callee (`svc?.GetUser`) that is the name inside its member
* binding; an element binding (`handlers?[0]()`) names no member.
*/
function calleeMemberName(funcNode: SyntaxNode): string | undefined {
const member = funcNode.lastNamedChild;
if (member === null) return funcNode.text;
if (!CONDITIONAL_ACCESS_NODE_TYPES.has(funcNode.type)) return stripTypeArguments(member).text;
const name = MEMBER_BINDING_NODE_TYPES.has(member.type) ? member.childForFieldName('name') : null;
return name === null ? undefined : stripTypeArguments(name).text;
}
export function extractMixedChain(
receiverNode: SyntaxNode,
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
@ -633,17 +763,21 @@ export function extractMixedChain(
// fell straight through to the base case with an empty chain and minted
// nothing — the await step could never be reached.
current = unwrapTransparentReceiver(current);
if (CALL_EXPRESSION_TYPES.has(current.type)) {
const subscriptCall = isBracketedSubscriptCall(current);
if (CALL_EXPRESSION_TYPES.has(current.type) && !subscriptCall) {
// ── Call expression: extract method name + inner receiver ────────────
const funcNode =
const callee =
current.childForFieldName?.('function') ??
current.childForFieldName?.('name') ??
current.childForFieldName?.('method');
// Rust's turbofish wraps the whole field access, so peel it before
// reading either the name or the receiver.
const funcNode = callee ? stripTypeArguments(callee) : null;
let methodName: string | undefined;
let innerReceiver: SyntaxNode | null = null;
if (funcNode) {
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
methodName = calleeMemberName(funcNode);
}
// Kotlin/Swift: call_expression → navigation_expression
if (!funcNode && current.type === 'call_expression') {
@ -672,7 +806,7 @@ export function extractMixedChain(
funcNode.childForFieldName?.('operand') ??
funcNode.childForFieldName?.('argument') ?? // C/C++ field_expression
funcNode.childForFieldName?.('expression') ??
null;
conditionalAccessReceiver(funcNode);
}
if (!innerReceiver && current.type === 'method_invocation') {
innerReceiver = current.childForFieldName?.('object') ?? null;
@ -764,7 +898,7 @@ export function extractMixedChain(
baseReceiverName: unwrapTransparentReceiver(inner).text || undefined,
};
}
} else if (SUBSCRIPT_NODE_TYPES.has(current.type)) {
} else if (subscriptCall || SUBSCRIPT_NODE_TYPES.has(current.type)) {
// Name-free: a subscript key is a VALUE, not an identifier the resolver
// could look up, so there is no member name to record.
chain.unshift({ kind: 'index' });
@ -790,6 +924,28 @@ export function extractMixedChain(
baseReceiverName: unwrapTransparentReceiver(obj).text || undefined,
};
}
} else if (CONDITIONAL_ACCESS_NODE_TYPES.has(current.type)) {
// ── Null-conditional access as a receiver: `svc?.Address`, `repos?[0]` ──
const binding = current.lastNamedChild;
if (binding !== null && MEMBER_BINDING_NODE_TYPES.has(binding.type)) {
const name = binding.childForFieldName('name')?.text;
if (!name) break;
chain.unshift({ kind: 'field', name });
} else if (binding !== null && ELEMENT_BINDING_NODE_TYPES.has(binding.type)) {
chain.unshift({ kind: 'index' });
} else {
break;
}
const inner = conditionalAccessReceiver(current);
if (!inner) break;
if (isChainableReceiverNode(inner)) {
current = inner;
} else {
return {
chain,
baseReceiverName: unwrapTransparentReceiver(inner).text || undefined,
};
}
} else if (current.type === 'selector') {
// ── Dart: flat selector siblings (user.address.save() uses selector nodes) ──
// Extract field name from unconditional_assignable_selector child

View file

@ -0,0 +1,444 @@
/**
* Receiver chains over callee and receiver spellings the chain walk used to
* misread (#3498 shape arm).
*
* `extractMixedChain` turns the receiver of `x.save()` into a chain of steps the
* resolver folds into a type. Each row below is a spelling where the walk read
* the wrong name, stopped early, or never started, so the call either lost its
* edge or vanished without a recorded drop:
*
* - explicit type arguments inside the callee (`svc.GetTyped<User>()`,
* `svc.get_typed::<User>()`, `svc->getTyped<User>()`);
* - wrappers that denote exactly their operand: C# `svc!` and Swift's
* one-element tuple, which is how Swift parses `(x)`;
* - C# null-conditional access (`svc?.GetUser()`, `user?.Address`,
* `repos?[0]`), which splits the receiver and the member across two nodes;
* - subscripts spelled as other node types: Ruby's `element_reference`, and
* Swift's call with a bracketed argument list.
*
* A row lists every CALLS target of `member` from `source`, and every
* `receiver-unresolved` call drop recorded for `member` in that method. A row
* with no target and one drop is a KNOWN GAP: the chain is now built and
* visible, but folding it needs something this change does not add (noted on
* the row).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
interface Row {
readonly language: string;
/** Name of the function or method holding the chained call. */
readonly source: string;
/** Member called at the end of the chain. */
readonly member: string;
/** Every CALLS target id of `member` from `source`, sorted. */
readonly targets: readonly string[];
/** Every `receiver-unresolved` call drop for `member` in `source`, as `file:line`. */
readonly drops: readonly string[];
}
interface LanguageCase {
readonly language: SupportedLanguages;
readonly files: Readonly<Record<string, string>>;
readonly rows: readonly Row[];
}
const CSHARP_MODELS = `using System.Threading.Tasks;
public class Address {
public void Save() {}
}
public class User {
public Address Address;
public void Save() {}
}
public class Service {
public User GetUser() { return new User(); }
public async Task<User> GetUserAsync() { return new User(); }
public User GetTyped<T>() { return new User(); }
}
`;
const RUST_MODELS = `pub struct Address;
impl Address {
pub fn save(&self) {}
}
pub struct User {
pub address: Address,
}
impl User {
pub fn save(&self) {}
}
pub struct Service;
impl Service {
pub fn get_typed<T>(&self) -> User {
User { address: Address }
}
}
`;
const CPP_MODELS = `#pragma once
class Address {
public:
void save();
};
class User {
public:
Address addr;
void save();
};
class Service {
public:
User* getUser();
template <typename T>
T* getTyped();
};
`;
const SWIFT_MODELS = `class Address {
func save() {}
}
class User {
var address: Address = Address()
func save() {}
}
class Service {
func getUser() -> User { return User() }
func getUserAsync() async -> User { return User() }
}
class Holder {
var repo: User = User()
var users: [User] = []
}
`;
const RUBY_MODELS = `class Address
def save; end
end
class User
attr_reader :address
def initialize
@address = Address.new
end
def save; end
end
`;
const CASES: readonly LanguageCase[] = [
{
language: SupportedLanguages.CSharp,
files: {
'Models.cs': CSHARP_MODELS,
'Main.cs': `public class Caller {
void ExplicitTypeArgs(Service svc) {
svc.GetTyped<User>().Save();
}
void ExplicitTypeArgsThenField(Service svc) {
svc.GetTyped<User>().Address.Save();
}
void NonNullAssert(Service svc) {
svc!.GetUser().Save();
}
void OptionalChain(Service svc) {
svc?.GetUser().Save();
}
void OptionalTypeArgs(Service svc) {
svc?.GetTyped<User>().Save();
}
void OptionalField(User user) {
user?.Address.Save();
}
void OptionalElement(User[] repos) {
repos?[0].Save();
}
}
`,
},
rows: [
{
language: 'csharp',
source: 'ExplicitTypeArgs',
member: 'Save',
targets: ['Method:Models.cs:User.Save#0'],
drops: [],
},
{
language: 'csharp',
source: 'ExplicitTypeArgsThenField',
member: 'Save',
targets: ['Method:Models.cs:Address.Save#0'],
drops: [],
},
{
language: 'csharp',
source: 'NonNullAssert',
member: 'Save',
targets: ['Method:Models.cs:User.Save#0'],
drops: [],
},
{
language: 'csharp',
source: 'OptionalChain',
member: 'Save',
targets: ['Method:Models.cs:User.Save#0'],
drops: [],
},
{
language: 'csharp',
source: 'OptionalTypeArgs',
member: 'Save',
targets: ['Method:Models.cs:User.Save#0'],
drops: [],
},
{
// Resolved before this change too, through the receiver-text cascade.
// Pinned so the structural walk cannot regress it.
language: 'csharp',
source: 'OptionalField',
member: 'Save',
targets: ['Method:Models.cs:Address.Save#0'],
drops: [],
},
{
// KNOWN GAP. The chain `2|repos|i` is now built, so the miss is
// recorded, but an array-typed parameter (`User[] repos`) gets no
// type binding yet.
language: 'csharp',
source: 'OptionalElement',
member: 'Save',
targets: [],
drops: ['Main.cs:21'],
},
],
},
{
language: SupportedLanguages.Rust,
files: {
'models.rs': RUST_MODELS,
'main.rs': `mod models;
use models::{Address, Service, User};
fn explicit_type_args(svc: &Service) {
svc.get_typed::<User>().save();
}
fn explicit_type_args_then_field(svc: &Service) {
svc.get_typed::<User>().address.save();
}
`,
},
rows: [
{
language: 'rust',
source: 'explicit_type_args',
member: 'save',
targets: ['Function:models.rs:User.save#0'],
drops: [],
},
{
language: 'rust',
source: 'explicit_type_args_then_field',
member: 'save',
targets: ['Function:models.rs:Address.save#0'],
drops: [],
},
],
},
{
language: SupportedLanguages.CPlusPlus,
files: {
'models.h': CPP_MODELS,
'main.cpp': `#include "models.h"
void explicitTypeArgs(Service* svc) {
svc->getTyped<User>()->save();
}
`,
},
rows: [
{
// KNOWN GAP. The chain step is now `getTyped`, not `getTyped<User>`, but
// `template <typename T> T* getTyped()` has no return type until the
// call's type arguments are substituted for `T`.
language: 'cpp',
source: 'explicitTypeArgs',
member: 'save',
targets: [],
drops: ['main.cpp:4'],
},
],
},
{
language: SupportedLanguages.Swift,
files: {
'models.swift': SWIFT_MODELS,
'main.swift': `func parenField(holder: Holder) {
(holder).repo.save()
}
func awaitParen(svc: Service) async {
(await svc.getUserAsync()).save()
}
func indexElement(repos: [User]) {
repos[0].save()
}
func fieldIndexElement(holder: Holder) {
holder.users[0].save()
}
`,
},
rows: [
{
language: 'swift',
source: 'parenField',
member: 'save',
targets: ['Function:models.swift:User.save#0'],
drops: [],
},
{
// The chain `2|svc|cgetUserAsync|a` is built through `(x)`, and the
// fold reads Swift's hoisted module-scope return type (#3666).
language: 'swift',
source: 'awaitParen',
member: 'save',
targets: ['Function:models.swift:User.save#0'],
drops: [],
},
{
// KNOWN GAP. `repos[0]` now builds `2|repos|i`, so the miss is
// recorded, but Swift has no `elementTypeOf` hook to read `User` out
// of `[User]` yet.
language: 'swift',
source: 'indexElement',
member: 'save',
targets: [],
drops: ['main.swift:10'],
},
{
// KNOWN GAP, same hook. The chain is `2|holder|fusers|i`; before this
// change the walk read `users` as a method call.
language: 'swift',
source: 'fieldIndexElement',
member: 'save',
targets: [],
drops: ['main.swift:14'],
},
],
},
{
language: SupportedLanguages.Ruby,
files: {
'models.rb': RUBY_MODELS,
'main.rb': `require_relative 'models'
def index_element(repos)
repos[0].save
end
`,
},
rows: [
{
// KNOWN GAP. `repos` is untyped, so there is nothing to fold; the
// `2|repos|i` chain makes the miss visible instead of silent.
language: 'ruby',
source: 'index_element',
member: 'save',
targets: [],
drops: ['main.rb:4'],
},
],
},
];
/** Every function or method named `name`, with its 1-based line span. */
function definitionsNamed(
result: PipelineResult,
name: string,
): Array<{ filePath: string; firstLine: number; lastLine: number }> {
const found: Array<{ filePath: string; firstLine: number; lastLine: number }> = [];
result.graph.forEachNode((node) => {
if (node.label !== 'Function' && node.label !== 'Method') return;
if (node.properties.name !== name) return;
// Graph node lines are 0-based; resolution outcome ranges are 1-based.
found.push({
filePath: String(node.properties.filePath),
firstLine: Number(node.properties.startLine) + 1,
lastLine: Number(node.properties.endLine) + 1,
});
});
return found;
}
describe.each(CASES)('receiver chain spellings ($language)', ({ language, files, rows }) => {
// Swift is a vendored optional grammar; a host without its prebuild skips it.
describe.skipIf(!isLanguageAvailable(language))('with the grammar loaded', () => {
let root: string;
let result: PipelineResult;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), `gitnexus-chain-spellings-${language}-`));
writeFixtureRepo(root, files);
result = await runPipelineFromRepo(root, () => {});
}, 180000);
afterAll(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it.each(rows)(
'$language: $member called from $source reaches $targets with drops $drops',
({ source, member, targets, drops }) => {
const reached = getRelationships(result, 'CALLS')
.filter((edge) => edge.source === source && edge.target === member)
.map((edge) => edge.rel.targetId)
.sort();
expect(reached).toEqual(targets);
const definitions = definitionsNamed(result, source);
expect(definitions).toHaveLength(1);
const [definition] = definitions;
const dropped = getResolutionOutcomes(result)
.filter((outcome) => outcome.kind === 'suppressed')
.filter(
(outcome) =>
outcome.reason === 'receiver-unresolved' &&
outcome.siteKind === 'call' &&
outcome.name === member &&
outcome.filePath === definition.filePath &&
outcome.range.startLine >= definition.firstLine &&
outcome.range.startLine <= definition.lastLine,
)
.map((outcome) => `${outcome.filePath}:${outcome.range.startLine}`)
.sort();
expect(dropped).toEqual(drops);
},
);
});
});

View file

@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, type TestContext } from 'vitest';
import type { MixedChainStep } from 'gitnexus-shared';
import {
inferCallForm,
extractReceiverName,
extractReceiverNode,
extractMixedChain,
} from '../../src/core/ingestion/utils/call-analysis.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
@ -15,11 +18,18 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import CPP from 'tree-sitter-cpp';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { getProvider } from '../../src/core/ingestion/languages/index.js';
// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111).
const Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
// Swift is optional: a host without its prebuild skips the Swift rows rather
// than failing to collect every other language's tests.
const Swift = isLanguageAvailable(SupportedLanguages.Swift)
? requireVendoredGrammar('tree-sitter-swift')
: undefined;
/**
* Helper: parse code, run the language query, and return all @call captures
@ -470,3 +480,310 @@ describe('ownerId on SymbolDefinition', () => {
expect(defs[0].ownerId).toBe('Class:src/foo.ts:User');
});
});
// ---------------------------------------------------------------------------
// extractMixedChain: callee and receiver spellings the chain walk has to read
// ---------------------------------------------------------------------------
/** First leaf whose text is `name`, depth-first. */
function findLeaf(node: SyntaxNode, name: string): SyntaxNode | undefined {
if (node.namedChildCount === 0) return node.text === name ? node : undefined;
for (const child of node.namedChildren) {
const hit = findLeaf(child, name);
if (hit !== undefined) return hit;
}
return undefined;
}
/** What `extractMixedChain` reads off the receiver of the call to `member`. */
function receiverChainOf(language: Parser.Language, code: string, member: string) {
const parser = new Parser();
parser.setLanguage(language);
const nameNode = findLeaf(parser.parse(code).rootNode, member);
const receiver = nameNode === undefined ? undefined : extractReceiverNode(nameNode);
if (receiver === undefined) throw new Error(`no receiver for ${member} in: ${code}`);
return extractMixedChain(receiver);
}
const call = (name: string): MixedChainStep => ({ kind: 'call', name });
const field = (name: string): MixedChainStep => ({ kind: 'field', name });
interface ChainRow {
readonly label: string;
/** `undefined` when an optional grammar did not load on this host. */
readonly language: Parser.Language | undefined;
readonly code: string;
readonly member: string;
readonly expected: ReturnType<typeof extractMixedChain>;
}
/** A row whose grammar did not load is reported skipped, never passed. */
function expectChainRow(row: ChainRow, { skip }: TestContext): void {
if (row.language === undefined) return skip(`grammar not available: ${row.label}`);
expect(receiverChainOf(row.language, row.code, row.member)).toEqual(row.expected);
}
describe('extractMixedChain', () => {
// Explicit type arguments inside the callee node (#3498 RC3). The step must be
// named for the member, and Rust's wrapper must not hide the inner receiver.
const typeArgumentRows: readonly ChainRow[] = [
{
label: 'C# generic method',
language: CSharp,
code: 'class A { void M() { svc.GetTyped<User>().Save(); } }',
member: 'Save',
expected: { chain: [call('GetTyped')], baseReceiverName: 'svc' },
},
{
label: 'C# generic method then a field',
language: CSharp,
code: 'class A { void M() { svc.GetTyped<User>().Address.Save(); } }',
member: 'Save',
expected: { chain: [call('GetTyped'), field('Address')], baseReceiverName: 'svc' },
},
{
label: 'C++ template method through ->',
language: CPP,
code: 'void f() { svc->getTyped<User>()->save(); }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: 'svc' },
},
{
label: 'C++ template method through .',
language: CPP,
code: 'void f() { svc.getTyped<User>().save(); }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: 'svc' },
},
{
label: 'C++ dependent template method',
language: CPP,
code: 'void f() { svc->template getTyped<User>()->save(); }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: 'svc' },
},
{
label: 'Rust turbofish method',
language: Rust,
code: 'fn f() { svc.get_typed::<User>().save(); }',
member: 'save',
expected: { chain: [call('get_typed')], baseReceiverName: 'svc' },
},
{
label: 'Rust turbofish free function has no base, so nothing is minted',
language: Rust,
code: 'fn f() { get_typed::<User>().save(); }',
member: 'save',
expected: { chain: [call('get_typed')], baseReceiverName: undefined },
},
{
label: 'C# generic free call has no base, so nothing is minted',
language: CSharp,
code: 'class A { void M() { GetTyped<User>().Save(); } }',
member: 'Save',
expected: { chain: [call('GetTyped')], baseReceiverName: undefined },
},
{
label: 'C++ qualified template function has no base, so nothing is minted',
language: CPP,
code: 'void f() { ns::getTyped<User>()->save(); }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: undefined },
},
{
label: 'Java type arguments sit beside the name (control)',
language: Java,
code: 'class A { void m() { svc.<User>getTyped().save(); } }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: 'svc' },
},
];
it.for(typeArgumentRows)('$label', expectChainRow);
// Wrappers that denote exactly their operand (#3498 RC4): C#'s null-forgiving
// `svc!` and Swift's single-element tuple, which is how Swift spells `(x)`.
// A wrapper that changes the value (`i++`, a real tuple) must stay the base.
const wrapperRows: readonly ChainRow[] = [
{
label: 'C# null-forgiving base',
language: CSharp,
code: 'class A { void M() { svc!.GetUser().Save(); } }',
member: 'Save',
expected: { chain: [call('GetUser')], baseReceiverName: 'svc' },
},
{
label: 'C# postfix increment is not peeled',
language: CSharp,
code: 'class A { void M() { i++.GetUser().Save(); } }',
member: 'Save',
expected: { chain: [call('GetUser')], baseReceiverName: 'i++' },
},
{
label: 'Swift parenthesized await',
language: Swift,
code: 'func f() async { (await svc.getUserAsync()).save() }',
member: 'save',
expected: { chain: [call('getUserAsync'), { kind: 'await' }], baseReceiverName: 'svc' },
},
{
label: 'Swift parenthesized base',
language: Swift,
code: 'func f() { (svc).getUser().save() }',
member: 'save',
expected: { chain: [call('getUser')], baseReceiverName: 'svc' },
},
{
label: 'Swift labelled one-element tuple is not peeled',
language: Swift,
code: 'func f() { (x: svc).getUser().save() }',
member: 'save',
expected: { chain: [call('getUser')], baseReceiverName: '(x: svc)' },
},
{
label: 'Swift two-element tuple is not peeled',
language: Swift,
code: 'func f() { (a, b).save() }',
member: 'save',
expected: undefined,
},
{
label: 'Rust one-element tuple keeps its trailing comma and is not peeled',
language: Rust,
code: 'fn f() { (svc,).get_user().save(); }',
member: 'save',
expected: { chain: [call('get_user')], baseReceiverName: '(svc,)' },
},
];
it.for(wrapperRows)('$label', expectChainRow);
// C# null-conditional access (#3498 RC5): the receiver sits in the
// `condition` field and the member in a binding child, as a callee and as a
// receiver.
const conditionalRows: readonly ChainRow[] = [
{
label: 'C# null-conditional call',
language: CSharp,
code: 'class A { void M() { svc?.GetUser().Save(); } }',
member: 'Save',
expected: { chain: [call('GetUser')], baseReceiverName: 'svc' },
},
{
label: 'C# null-conditional call on both hops',
language: CSharp,
code: 'class A { void M() { svc?.GetUser()?.Save(); } }',
member: 'Save',
expected: { chain: [call('GetUser')], baseReceiverName: 'svc' },
},
{
label: 'C# null-conditional call then a field',
language: CSharp,
code: 'class A { void M() { svc?.GetUser().Address.Save(); } }',
member: 'Save',
expected: { chain: [call('GetUser'), field('Address')], baseReceiverName: 'svc' },
},
{
label: 'C# null-conditional generic call',
language: CSharp,
code: 'class A { void M() { svc?.GetTyped<User>().Save(); } }',
member: 'Save',
expected: { chain: [call('GetTyped')], baseReceiverName: 'svc' },
},
{
label: 'C# null-conditional field',
language: CSharp,
code: 'class A { void M() { user?.Address.Save(); } }',
member: 'Save',
expected: { chain: [field('Address')], baseReceiverName: 'user' },
},
{
label: 'C# null-conditional element',
language: CSharp,
code: 'class A { void M() { repos?[0].Save(); } }',
member: 'Save',
expected: { chain: [{ kind: 'index' }], baseReceiverName: 'repos' },
},
{
label: 'C# null-conditional element call names no member',
language: CSharp,
code: 'class A { void M() { handlers?[0]().Save(); } }',
member: 'Save',
expected: undefined,
},
];
it.for(conditionalRows)('$label', expectChainRow);
// Subscripts spelled as other node types (#3498 RC8): Ruby's
// `element_reference`, and Swift's call with a bracketed argument list.
const subscriptRows: readonly ChainRow[] = [
{
label: 'Ruby element reference',
language: Ruby,
code: 'def f(repos)\n repos[0].save\nend\n',
member: 'save',
expected: { chain: [{ kind: 'index' }], baseReceiverName: 'repos' },
},
{
label: 'Ruby element reference of a call',
language: Ruby,
code: 'def f(svc)\n svc.get_users[0].save\nend\n',
member: 'save',
expected: { chain: [call('get_users'), { kind: 'index' }], baseReceiverName: 'svc' },
},
{
label: 'Swift bracketed subscript',
language: Swift,
code: 'func f() { repos[0].save() }',
member: 'save',
expected: { chain: [{ kind: 'index' }], baseReceiverName: 'repos' },
},
{
label: 'Swift subscript of a property is a field then an index',
language: Swift,
code: 'func f() { holder.users[0].save() }',
member: 'save',
expected: { chain: [field('users'), { kind: 'index' }], baseReceiverName: 'holder' },
},
{
label: 'Swift subscript of a call result',
language: Swift,
code: 'func f() { svc.getUsers()[0].save() }',
member: 'save',
expected: { chain: [call('getUsers'), { kind: 'index' }], baseReceiverName: 'svc' },
},
{
label: 'Swift parenthesized call stays a call',
language: Swift,
code: 'func f() { svc.getUser(0).save() }',
member: 'save',
expected: { chain: [call('getUser')], baseReceiverName: 'svc' },
},
{
label: 'Swift trailing-closure call stays a call',
language: Swift,
code: 'func f() { svc.getUsers { $0 }.save() }',
member: 'save',
expected: { chain: [call('getUsers')], baseReceiverName: 'svc' },
},
// Kotlin shares `call_suffix → value_arguments`; its subscripts are
// `indexing_expression`, so its calls must never read as index steps.
{
label: 'Kotlin parenthesized call stays a call',
language: Kotlin,
code: 'fun f() { svc.getUser(0).save() }',
member: 'save',
expected: { chain: [call('getUser')], baseReceiverName: 'svc' },
},
{
label: 'Kotlin generic call stays a call',
language: Kotlin,
code: 'fun f() { svc.getTyped<User>().save() }',
member: 'save',
expected: { chain: [call('getTyped')], baseReceiverName: 'svc' },
},
];
it.for(subscriptRows)('$label', expectChainRow);
});