feat(languages): roll the receiver chain out to every emitter

U8. Emission moves into one shared helper and is wired into all 14 language
emitters that build a capture node map.

The helper is language-free (AGENTS.md R6): its call gate reads the
`@reference.call.*` tag prefix — a vocabulary every language's `.scm` query
already shares — rather than a per-language tag list. Only TypeScript and
JavaScript defined a `CALL_TAGS` constant, so a per-language list would have
meant inventing one twelve times. `@reference.callable` is excluded by the
trailing dot: it marks a callable VALUE, not a call site.

It is also self-gating — a non-call match, an absent receiver, or a chain with
no nameable base each leave the match untouched — which is what makes it safe to
call before every `out.push(grouped)` in the emitters that have three or four
such paths.

Measured, not assumed:

    C++   svc->getUser()->save()    INVISIBLE-GAP → RESOLVES
    TS    (three shapes, from U10)                  RESOLVES

The C++ row is the one the plan flagged as having no fixture ANYWHERE:
`cpp-chain-call/` uses the value `.` form, which already worked. It now has one,
alongside a value-dot control proving the defect was the `->` base specifically.

A WRONG-EDGE REGRESSION, caught by the full multi-language suite and fixed.
Ruby `Factory.new.run` resolved correctly to `Factory#run` before this and to
`Product.run` after — a correct edge turned into a wrong one, which is precisely
the failure mode this work exists to avoid. Cause: `Factory.new` denotes an
INSTANCE of Factory, and the cascade encodes that through
`isConstructionSelectorHop` together with the class-constant test separating it
from an instance method genuinely named `new`. A chain step records only a name,
so the fold cannot make that distinction. It now declines any step matching the
language's construction selector and lets the cascade answer. Pinned by a unit
test.

PHP is a recorded residual, and the plan's named trap is NOT the cause. The trap
— a language whose node type is missing from `extractMixedChain`'s tables reads
as "didn't need it" when it cannot be measured — was checked directly against the
emitter, which emits `chain=1|$svc|cgetUser` correctly. The residual is that the
fold's base `$svc` does not bind in the PHP resolver. That is PHP binding-key
work, not a chain-layer defect.

Two incidental corrections to KTD6 fell out of that check. PHP's receiver capture
text is normalized to `$svc.getUser()` — dots, not `->` — so Case 0's "C-family
punctuation" gate does fire for PHP, contrary to the plan. And typing the fixture
parameter moved the PHP row from VISIBLE-GAP to INVISIBLE-GAP, because with a
type binding the cascade types the receiver but finds no member, so no drop is
recorded. An untyped fixture parameter had been reporting a language gap that was
really a fixture defect — the same error class as the untyped `$repo` control
caught earlier in this series.

The wiring inventory is pinned by a test. The helper being self-gating makes the
roster cheap to change silently, which is exactly why: a new entry means someone
wired a language without measuring it, a removed one means a refactor dropped
coverage. `cobol` and `vue` stay unwired — they group no capture node map, so
there is no receiver node to walk.

4367 tests green. The 7 remaining failures are all `*-captures-golden` snapshots,
which U9 regenerates once at the tip.
This commit is contained in:
Gergo Magyar 2026-07-30 14:09:44 +00:00
parent 66beec5f69
commit aced4456cc
21 changed files with 453 additions and 38 deletions

View file

@ -188,3 +188,57 @@ must be read against the same bias.
plain property path (`$this->repo`, `a::b`) never reaches the recorder.
- `repos[0].save()` has neither `.` nor `(` in its receiver — same result.
- `?.` and explicit type arguments produce no reference site at all.
## U8 — per-language rollout
Emission moved into one shared helper
(`utils/receiver-chain-captures.ts`) and is wired into all 14 language
emitters. The helper is language-free (R6): its call gate reads the
`@reference.call.*` tag prefix, a vocabulary every language's `.scm` query
shares, rather than a per-language tag list. It is self-gating — a non-call
match, an absent receiver, or a chain with no nameable base all leave the match
untouched — so inserting the call before every `out.push(grouped)` is safe even
in the emitters that have three or four such paths.
| Language | Shape | Before | After |
|---|---|---|---|
| TypeScript | `svc?.getUser().save()` | INVISIBLE-GAP | **RESOLVES** |
| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | **RESOLVES** |
| TypeScript | `svc.getTyped<User>().save()` | INVISIBLE-GAP | **RESOLVES** |
| C++ | `svc->getUser()->save()` | INVISIBLE-GAP | **RESOLVES** |
| C++ | `svc2.getUser()->save()` (control) | RESOLVES | RESOLVES |
| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | INVISIBLE-GAP |
| PHP | `$this->repo->save()` (control) | RESOLVES | RESOLVES |
The C++ row is the one the plan flagged as having **no fixture anywhere** —
`cpp-chain-call/` uses the value `.` form, which already worked. It now has one,
plus the value-dot control that proves the defect was the `->` base specifically.
### PHP: a measured residual, with the trap checked
PHP does **not** resolve yet, and the plan's named trap — a language whose node
type is missing from `extractMixedChain`'s tables reads as "didn't need it" when
it in fact cannot be measured — is **not** the cause. Checked directly against
the emitter:
```
name=save chain=1|$svc|cgetUser recv=$svc.getUser()
```
The chain is minted correctly. The residual is that the fold's base, `$svc`,
does not bind in the PHP resolver, so the fold returns `undefined` and the site
falls through to the text cascade. That is PHP binding-key work, not a
chain-layer defect, and it is left as a recorded residual rather than absorbed
into this series.
Two incidental corrections from that check, both to KTD6:
- PHP's receiver capture text is normalized to `$svc.getUser()` — DOTS, not
`->`. So Case 0's "C-family punctuation" gate fires for PHP after all, which
is why the call chain was recorded as a VISIBLE-GAP to begin with.
- Typing the fixture parameter (`function f(Service $svc)`) moved the row from
VISIBLE-GAP to INVISIBLE-GAP: with a type binding the cascade now types the
receiver but finds no member, so `compoundReceiverUnresolved` is false and no
drop is recorded. An untyped fixture parameter had been reporting a language
gap that was really a fixture defect — the same error class as the untyped
`$repo` control caught earlier.

View file

@ -10,11 +10,11 @@
"indexElement": "INVISIBLE-GAP"
},
"php": {
"arrowCallChain": "VISIBLE-GAP",
"arrowCallChain": "INVISIBLE-GAP",
"arrowPropertyPath": "RESOLVES"
},
"cpp": {
"pointerArrowChain": "INVISIBLE-GAP",
"pointerArrowChain": "RESOLVES",
"valueDotChain": "RESOLVES"
}
},

View file

@ -148,7 +148,10 @@ class Service {
`,
},
header: `<?php\nrequire_once 'models.php';\n`,
wrap: (entry, body) => `function ${entry}($svc) {\n ${body}\n}\n`,
// TYPED parameter. An untyped `$svc` has no type binding to resolve the
// chain's base against, which would make this row report a language gap
// that is really a fixture defect.
wrap: (entry, body) => `function ${entry}(Service $svc) {\n ${body}\n}\n`,
shapes: [
{
id: 'arrowCallChain',

View file

@ -11,6 +11,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitCInclude } from './import-decomposer.js';
import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js';
import { markStaticName } from './static-linkage.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
synthesizeCallableFlowCaptures,
type CallableCaptureSignature,
@ -163,6 +164,12 @@ export function emitCScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -24,6 +24,7 @@ import { extractCppTemplateConstraints } from './constraint-extractor.js';
import { captureCppMemberLookupFacts } from './member-lookup.js';
import { CPP_BRACED_INIT_TYPE_PREFIX } from './conversion-rank.js';
import { logger } from '../../../logger.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
synthesizeCallableFlowCaptures,
type CallableCaptureSignature,
@ -604,6 +605,12 @@ export function emitCppScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -31,6 +31,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = [
@ -159,6 +160,12 @@ export function emitCsharpScopeCaptures(
}
// Defensive fallback: emit the raw match so the extractor at
// least sees an anchor, even without markers.
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -177,6 +184,12 @@ export function emitCsharpScopeCaptures(
// the AST in code. Mirrors Python's `self`/`cls` synthesis on
// `@scope.function` matches.
if (grouped['@scope.function'] !== undefined) {
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
if (fnNode !== null) {
@ -287,6 +300,12 @@ export function emitCsharpScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
// Synthesize primary-constructor declarations on class/record

View file

@ -46,6 +46,7 @@ import { encodeMarker } from '../../utils/heritage-marker.js';
import { DART_BUILT_INS } from './built-ins.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { preprocessDartExtensionTypes } from './extension-type-preprocess.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
const FUNCTION_DECL_TAGS = [
'@declaration.function',
@ -155,6 +156,12 @@ export function emitDartScopeCaptures(
const bodyNode = findFunctionBody(declNode);
attachArityMetadata(grouped, declNode);
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
if (bodyNode !== null) {
@ -181,6 +188,12 @@ export function emitDartScopeCaptures(
fieldType,
);
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
if (fieldType !== null) {
out.push({
@ -192,6 +205,12 @@ export function emitDartScopeCaptures(
continue;
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -14,6 +14,7 @@ import { synthesizeGoTypeBindings, extractSimpleTypeNameText } from './type-bind
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
const GO_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']),
@ -155,6 +156,12 @@ export function emitGoScopeCaptures(
);
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -176,6 +183,12 @@ export function emitGoScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -43,6 +43,7 @@ import { captureJavaPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js';
import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
captureJavaSpringConditionalFacts,
type JavaSpringConditionalFact,
@ -204,6 +205,12 @@ export function emitJavaScopeCaptures(
continue;
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -257,6 +264,12 @@ export function emitJavaScopeCaptures(
// Synthesize `this` / `super` receiver type-bindings on every
// instance method-like.
if (grouped['@scope.function'] !== undefined) {
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
// `@scope.function` is captured directly on the method/constructor node.
const fnNode = findFunctionNode(nodeMap['@scope.function']);
@ -348,6 +361,12 @@ export function emitJavaScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -48,6 +48,7 @@ import {
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
deriveDefaultExportHocName,
isBlockedDefaultExportHoc,
@ -997,6 +998,12 @@ export function emitJsScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped);
// Synthesize `this` receiver type-bindings on class member functions.

View file

@ -26,6 +26,7 @@ import {
import { captureKotlinPackageFact } from './package-facts.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
captureKotlinSpringConditionalFacts,
type KotlinSpringConditionalFact,
@ -246,6 +247,12 @@ export function emitKotlinScopeCaptures(
}
if (grouped['@scope.function'] !== undefined) {
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped);
const fnNode = nodeIfType(groupedNodes['@scope.function'], 'function_declaration');
if (fnNode !== null) {
@ -303,6 +310,12 @@ export function emitKotlinScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped);
const extensionFallback = extensionFreeCallFallback(grouped, groupedNodes);

View file

@ -46,6 +46,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
type SyntaxNode = ReturnType<ReturnType<typeof getPhpParser>['parse']>['rootNode'];
@ -228,6 +229,12 @@ export function emitPhpScopeCaptures(
}
}
// Defensive fallback: emit the raw match.
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -235,6 +242,12 @@ export function emitPhpScopeCaptures(
// Synthesize `$this` / `parent` receiver type-bindings on every
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
if (grouped['@scope.function'] !== undefined) {
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES);
if (fnNode !== null) {
@ -338,6 +351,12 @@ export function emitPhpScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -35,6 +35,7 @@ import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
const PYTHON_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_definition', 'lambda']),
@ -120,12 +121,24 @@ export function emitPythonScopeCaptures(
for (const piece of splitImportStatement(stmtNode)) out.push(piece);
} else {
// Defensive fallback: emit the raw match.
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}
continue;
}
if (grouped['@scope.function'] !== undefined) {
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
// `@scope.function` is captured directly on the `function_definition`
// node (query: `(function_definition) @scope.function`), so it IS the
@ -183,10 +196,22 @@ export function emitPythonScopeCaptures(
);
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -15,6 +15,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitQualifiedName } from '../../utils/qualified-name.js';
import { encodeMarker } from '../../utils/heritage-marker.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
@ -147,6 +148,12 @@ export function emitRubyScopeCaptures(
continue;
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -159,6 +166,12 @@ export function emitRubyScopeCaptures(
const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode);
if (receiver !== null) out.push(receiver);
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -204,6 +217,12 @@ export function emitRubyScopeCaptures(
);
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -304,6 +323,12 @@ export function emitRubyScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -13,6 +13,7 @@ import { synthesizeRustReceiverBinding } from './receiver-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
const RUST_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_item', 'closure_expression']),
@ -197,6 +198,12 @@ export function emitRustScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -50,6 +50,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
@ -129,6 +130,12 @@ export function emitSwiftScopeCaptures(
continue;
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped); // defensive fallback
continue;
}
@ -174,6 +181,12 @@ export function emitSwiftScopeCaptures(
const span = `${navNode.startIndex}-${navNode.endIndex}`;
if (seenReadSpans.has(span)) continue;
seenReadSpans.add(span);
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
continue;
}
@ -236,6 +249,12 @@ export function emitSwiftScopeCaptures(
...FUNCTION_NODE_TYPES,
);
if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity);
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
const recvNode = nodeIfType(nodeMap['@scope.function'], ...RECEIVER_NODE_TYPES);
@ -296,6 +315,12 @@ export function emitSwiftScopeCaptures(
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']);
out.push(grouped);
}

View file

@ -46,9 +46,8 @@ import {
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { extractMixedChain } from '../../utils/call-analysis.js';
import { encodeReceiverChain } from '../../utils/receiver-chain-codec.js';
import { synthesizeCjsModuleExports } from './cjs-module-exports.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
import {
deriveDefaultExportHocName,
isBlockedDefaultExportHoc,
@ -496,39 +495,12 @@ export function emitTsScopeCaptures(
}
}
// Synthesize the compact receiver chain for a receiver that is itself an
// expression, so resolution can type it by folding over structure instead
// of re-parsing the receiver's source text.
//
// Emitted HERE rather than in the query: the receiver capture originates in
// several `.scm` patterns, and a query cannot call `extractMixedChain`.
//
// Gated on CALL_TAGS. Without that gate this fires on read, write and JSX
// receivers too, which pay the walk and the bytes for a field no call-site
// resolver will read.
//
// A chain is minted only when the walk reached a NAMEABLE base.
// `extractMixedChain` returns `baseReceiverName: undefined` when it stopped
// early — either it hit `MAX_CHAIN_DEPTH` or it could not read a step — and
// in both cases what it collected is a partial chain whose missing head is
// exactly what decides the final type. Encoding that would turn a receiver
// we cannot type into one we type WRONGLY, so those fall through to the
// existing text cascade instead.
const receiverChainNode = groupedNodes['@reference.receiver'];
if (receiverChainNode !== undefined && callAnchor !== undefined) {
const extracted = extractMixedChain(receiverChainNode);
if (extracted !== undefined && extracted.baseReceiverName !== undefined) {
const encoded = encodeReceiverChain(extracted.baseReceiverName, extracted.chain);
if (encoded !== undefined) {
grouped['@reference.receiver-chain'] = syntheticCapture(
'@reference.receiver-chain',
receiverChainNode,
encoded,
);
}
}
}
// Structural receiver chain for a call whose receiver is itself an
// expression, so resolution can type it by folding over structure
// instead of re-parsing the receiver's source text. Self-gating: a
// non-call match, an absent receiver, or a chain with no nameable base
// all leave `grouped` untouched.
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
out.push(grouped);
// Synthesize `this` receiver type-bindings on every function-like

View file

@ -302,6 +302,18 @@ export function foldReceiverChain(
if (current === undefined) return undefined;
for (const step of chain.steps) {
// Construction is NOT an ordinary member lookup. `Factory.new` on a class
// constant denotes an instance of Factory, and the cascade already encodes
// that (`isConstructionSelectorHop`) along with the class-constant test that
// separates it from an instance method genuinely named `new`. The fold
// carries no such distinction — a chain step records a name, not whether its
// base was a class reference or a value — so folding one would resolve
// `Factory.new.run` against whatever member named `new` the lookup reaches
// first. That turned a correct edge into a WRONG one (Ruby
// `Factory.new.run` → `Product.run`), which is the failure mode this whole
// line of work exists to avoid. Decline and let the cascade answer.
if (options.constructionSyntax?.selector === step.name) return undefined;
const next = typeOfMemberOnClass(current, step.name, scopes, index, options);
if (next === undefined) return undefined;
current = next;

View file

@ -0,0 +1,68 @@
/**
* Synthesize `@reference.receiver-chain` for a call match whose receiver is
* itself an expression.
*
* Shared across every language emitter, and deliberately language-free (AGENTS.md
* R6): the call gate reads the `@reference.call.*` tag vocabulary, which is
* shared by every language's `.scm` query, rather than a per-language tag list.
*
* Lives in the emitter layer rather than the query because a `.scm` pattern
* cannot call `extractMixedChain`, and the receiver capture originates in
* several patterns per language.
*/
import type { Capture } from 'gitnexus-shared';
import type { SyntaxNode } from './ast-helpers.js';
import { syntheticCapture } from './ast-helpers.js';
import { extractMixedChain } from './call-analysis.js';
import { encodeReceiverChain } from './receiver-chain-codec.js';
/** Tag prefix marking a match as a CALL. `@reference.callable` is deliberately
* excluded by the trailing dot — it marks a callable VALUE, not a call site. */
const CALL_TAG_PREFIX = '@reference.call.';
/** Does this match represent a call? */
function isCallMatch(grouped: Record<string, Capture>): boolean {
for (const tag of Object.keys(grouped)) {
if (tag.startsWith(CALL_TAG_PREFIX)) return true;
}
return false;
}
/**
* Add `@reference.receiver-chain` to `grouped` when the match is a call whose
* receiver decodes to a chain over a nameable base. Mutates `grouped` in place
* and returns nothing — mirroring how the other synthesized captures are added.
*
* Gated on the call tags: without that gate this fires on read, write and JSX
* receivers too, which pay the AST walk and the persisted bytes for a field no
* call-site resolver reads.
*
* A chain is minted ONLY when the walk reached a nameable base.
* `extractMixedChain` reports `baseReceiverName: undefined` whenever it stopped
* early — at `MAX_CHAIN_DEPTH`, or because a step could not be read — and what it
* collected then is a partial chain missing exactly the head that decides the
* final type. Encoding that would turn a receiver we cannot type into one we type
* WRONGLY, so those fall through to the resolver's existing text cascade.
*/
export function synthesizeReceiverChainCapture(
grouped: Record<string, Capture>,
receiverNode: SyntaxNode | undefined | null,
): void {
if (receiverNode === undefined || receiverNode === null) return;
if (grouped['@reference.receiver-chain'] !== undefined) return;
if (!isCallMatch(grouped)) return;
const extracted = extractMixedChain(receiverNode);
if (extracted === undefined || extracted.baseReceiverName === undefined) return;
const encoded = encodeReceiverChain(extracted.baseReceiverName, extracted.chain);
if (encoded === undefined) return;
grouped['@reference.receiver-chain'] = syntheticCapture(
'@reference.receiver-chain',
receiverNode,
encoded,
);
}

View file

@ -112,4 +112,22 @@ describe('foldReceiverChain', () => {
it('declines a truncated chain even though the producer refuses to mint one', () => {
expect(fold('1|svc|cgetUser|~')).toBeUndefined();
});
it('declines a construction-selector step and leaves it to the cascade', () => {
// `Factory.new` denotes an INSTANCE of Factory, not the result of looking up
// a member named `new`. The cascade encodes that, together with the
// class-constant test that separates it from an instance method genuinely
// named `new`; a chain step records only a name, so the fold cannot make the
// distinction and must not try. Folding it turned a correct Ruby edge
// (`Factory.new.run` → `Factory#run`) into a wrong one (`Product.run`).
const decoded = decodeReceiverChain('1|svc|cnew');
expect(decoded).toBeDefined();
expect(
foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, {
fieldFallback: false,
hoistTypeBindingsToModule: true,
constructionSyntax: { selector: 'new' },
}),
).toBeUndefined();
});
});

View file

@ -0,0 +1,83 @@
/**
* Which language emitters synthesize `@reference.receiver-chain`.
*
* The helper is self-gating — a non-call match, an absent receiver, or a chain
* with no nameable base all leave the match untouched — so wiring a language
* costs nothing where the shape does not occur. That makes the inventory easy to
* change silently, which is exactly why it is pinned: a new entry means someone
* wired a language without measuring it, and a removed entry means a refactor
* quietly dropped coverage.
*
* Source-level rather than behavioural, because the thing being pinned IS the
* wiring: a behavioural check would pass for a language whose fixture happens to
* contain no chained receiver.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LANGUAGES_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'src',
'core',
'ingestion',
'languages',
);
function emittersCallingHelper(): string[] {
const out: string[] = [];
for (const entry of readdirSync(LANGUAGES_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const captures = path.join(LANGUAGES_DIR, entry.name, 'captures.ts');
let source: string;
try {
source = readFileSync(captures, 'utf8');
} catch {
continue; // no captures.ts (cobol/vue emit through another path)
}
if (source.includes('synthesizeReceiverChainCapture(')) out.push(entry.name);
}
return out.sort();
}
describe('receiver-chain capture wiring inventory', () => {
it('is wired into exactly the emitters that build a capture node map', () => {
expect(emittersCallingHelper()).toEqual([
'c',
'cpp',
'csharp',
'dart',
'go',
'java',
'javascript',
'kotlin',
'php',
'python',
'ruby',
'rust',
'swift',
'typescript',
]);
});
it('leaves emitters without a node map unwired', () => {
// These two do not group captures with their originating syntax nodes, so
// there is no receiver node to walk. Wiring them would need an emitter
// change, not a one-line call.
for (const language of ['cobol', 'vue'] as const) {
const captures = path.join(LANGUAGES_DIR, language, 'captures.ts');
let source = '';
try {
source = readFileSync(captures, 'utf8');
} catch {
continue;
}
expect(source, `${language} stays unwired`).not.toContain('synthesizeReceiverChainCapture(');
}
});
});