From aced4456cc3ea4c496cbabba8f8e5cd39dd2f30f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 30 Jul 2026 14:09:44 +0000 Subject: [PATCH] feat(languages): roll the receiver chain out to every emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bench/receiver-resolution/BASELINE.md | 54 ++++++++++++ .../bench/receiver-resolution/baseline.json | 4 +- .../bench/receiver-resolution/measure.mjs | 5 +- .../core/ingestion/languages/c/captures.ts | 7 ++ .../core/ingestion/languages/cpp/captures.ts | 7 ++ .../ingestion/languages/csharp/captures.ts | 19 +++++ .../core/ingestion/languages/dart/captures.ts | 19 +++++ .../core/ingestion/languages/go/captures.ts | 13 +++ .../core/ingestion/languages/java/captures.ts | 19 +++++ .../languages/javascript/captures.ts | 7 ++ .../ingestion/languages/kotlin/captures.ts | 13 +++ .../core/ingestion/languages/php/captures.ts | 19 +++++ .../ingestion/languages/python/captures.ts | 25 ++++++ .../core/ingestion/languages/ruby/captures.ts | 25 ++++++ .../core/ingestion/languages/rust/captures.ts | 7 ++ .../ingestion/languages/swift/captures.ts | 25 ++++++ .../languages/typescript/captures.ts | 42 ++-------- .../passes/compound-receiver.ts | 12 +++ .../utils/receiver-chain-captures.ts | 68 +++++++++++++++ .../receiver-chain-fold.test.ts | 18 ++++ .../receiver-chain-wiring.test.ts | 83 +++++++++++++++++++ 21 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts create mode 100644 gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 113583b499..49ac4979b5 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -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().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. diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 17adb49f74..d0c05d498a 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -10,11 +10,11 @@ "indexElement": "INVISIBLE-GAP" }, "php": { - "arrowCallChain": "VISIBLE-GAP", + "arrowCallChain": "INVISIBLE-GAP", "arrowPropertyPath": "RESOLVES" }, "cpp": { - "pointerArrowChain": "INVISIBLE-GAP", + "pointerArrowChain": "RESOLVES", "valueDotChain": "RESOLVES" } }, diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 5864c0c4f0..e5611968b1 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -148,7 +148,10 @@ class Service { `, }, header: ` `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', diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index 8253019357..14049375d2 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 069459470b..a13cba09db 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index fbe338adc0..cb1adb8de0 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 6587a7302d..3157c08419 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index 0179c3d1e2..ffc624c877 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 1314dd5bc6..dd463c83e7 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index 2ce0561dc5..b0eed8d687 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 657e6bcb66..00868c1743 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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); diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts index 7e3ce5b150..de7e0a8c7d 100644 --- a/gitnexus/src/core/ingestion/languages/php/captures.ts +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -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['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); } diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 2a196bb329..0b0e981156 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index ea4c5cf6ca..285e52afa2 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -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 = 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); } diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index cae5ee0034..ae15c99e26 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index cff4e13664..b9b111454a 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -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); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 9770acc894..34146686a8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 4f942bc806..0a3425777e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts new file mode 100644 index 0000000000..687e687b26 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts @@ -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): 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, + 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, + ); +} diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts index 52717a6961..f44d6a2223 100644 --- a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts @@ -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(); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts new file mode 100644 index 0000000000..e4d68ff696 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts @@ -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('); + } + }); +});