From 303791b69864ab8a3bcf0f5769ff77f26e34bb2d Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 30 Jul 2026 12:51:19 +0000 Subject: [PATCH] feat(resolution): type receivers from structure, and bump INCREMENTAL_SCHEMA_VERSION to 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U10. Wires the fold into resolution. Three TypeScript receiver spellings that emitted no CALLS edge now resolve: svc?.getUser().save() svc!.getUser().save() svc.getTyped().save() WIRED IN ONE PLACE, not four. The fold runs at the top of `resolveCompoundReceiverClass`, ahead of the text cascade, so every caller inherits it — Case 0, the Case 4 fallback, and the duplicate walk alike — rather than four call sites each having to remember. Only at `depth === 0`: the chain describes THIS site's receiver, not the inner expressions the cascade recurses into. A failed fold falls THROUGH rather than returning, so structure is an additional route to an answer and never a veto on the existing one. Group A only, which settles U11 by construction. The two sites that consume `typeRef.rawName` cannot be routed: they have no reference site in scope, so there is no `receiverChain` to pass. That is not a decision deferred, it is the shape of the code — Group B needs the type-binding capture layer to carry structure, which is a separate piece of work. U12 needs no code either: the `gitnexus-shared` registry `lookupReceiverType` is a name-keyed map miss, not a text-sniff site, and threading the fold into `RegistryContext` would be a shared contract change across `LookupParams` and both registry option types. `extractMixedChain` also needed a transparent-wrapper unwrap. For `svc!` the walk reached `non_null_expression`, which is not a chain node, so the wrapper's own TEXT became the base — `"svc!"`, matching no binding, silently costing the chain its base. `non_null_expression` and `parenthesized_expression` denote exactly their operand and are now read through. A cast is deliberately NOT unwrapped: `x as T` changes the type an expression denotes, so reading through one would type the receiver as the operand instead of the cast target. `INCREMENTAL_SCHEMA_VERSION` 25→26 lands HERE, at the first commit where edge semantics change. A changed-files top-up against a pre-v26 index keeps the pre-fix graph for every unchanged file — and worse than merely incomplete, the drop summary is a whole-repo recompute while the edges are a changed-files write, so the two would actively disagree. The reuse-gate ladder test is updated with that rationale; it is the guard that caught the missing bump. MEASURED, and the result is the argument for how this was measured. The three shapes flipped to RESOLVES while the call-drop count did not move at all: 99 before, 99 after. The committed fixture corpus contains none of those three spellings, so a gate reading only the drop count would have scored this as "no improvement" and stopped the series. No edge was lost and no new drop appeared. Two shapes remain unresolved, both genuinely and both recorded rather than glossed: `(await svc.getUserAsync()).save()` (the walk reaches `await …`, not a chain node) and `repos[0].save()` (Case 0's punctuation gate never fires for a subscript receiver). One test changed meaning rather than breaking. The #2744 drop-recorder test used `svc!.getUser().save()` as its call-kind example; that shape now resolves, so there is no drop to record. It moves to the await-parenthesized shape, which still drops. The write case is untouched — a write receiver mints no chain at all, since the emitter gates on CALL_TAGS — so calls and writes stay separable, which was the point of the test. 334 tests green across all six touched suites. --- .../bench/receiver-resolution/BASELINE.md | 20 +++ .../bench/receiver-resolution/baseline.json | 6 +- .../passes/compound-receiver.ts | 32 +++++ .../passes/receiver-bound-calls.ts | 7 +- .../src/core/ingestion/utils/call-analysis.ts | 44 +++++- gitnexus/src/storage/repo-manager.ts | 17 ++- .../integration/resolvers/typescript.test.ts | 129 +++++++++++++++++- .../unit/call-summary-schema-version.test.ts | 19 ++- 8 files changed, 257 insertions(+), 17 deletions(-) diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index f3eabbcafa..9a95956299 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,25 @@ # Receiver-resolution baseline +> **Updated after U10** (structural receiver typing wired into Case 0). Three +> TypeScript shapes flipped to `RESOLVES` — `svc?.getUser().save()`, +> `svc!.getUser().save()`, `svc.getTyped().save()` — and the call-drop +> count did **not** move: 99 before, 99 after. +> +> That is the whole argument for the shape arm, now demonstrated rather than +> predicted. The committed fixture corpus contains none of those three +> spellings, so a gate reading only the drop count would have scored a working +> change as "no improvement" and stopped the series. Nothing regressed: no edge +> was lost and no new drop appeared. +> +> Still gaps after U10, both genuine: +> - `(await svc.getUserAsync()).save()` — `extractMixedChain` reaches +> `await …`, which is not a chain node, so no chain is minted. Remains a +> VISIBLE-GAP and is now the call-kind fixture in the drop-recorder test. +> - `repos[0].save()` — Case 0's punctuation gate never fires for a subscript +> receiver, so it stays INVISIBLE. +> +> The tables below are the pre-U10 measurement, kept as the reference point. + Measured with `bench/receiver-resolution/measure.mjs` on `f87b2cbe`. Hygiene (a run without both steps is void — `analyze --force` clears neither cache, diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 1a9c247384..17adb49f74 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -3,10 +3,10 @@ "typescript": { "plainChain": "RESOLVES", "plainDeepChain": "RESOLVES", - "optionalChain": "INVISIBLE-GAP", - "nonNullAssert": "VISIBLE-GAP", + "optionalChain": "RESOLVES", + "nonNullAssert": "RESOLVES", "awaitParen": "VISIBLE-GAP", - "explicitTypeArgs": "INVISIBLE-GAP", + "explicitTypeArgs": "RESOLVES", "indexElement": "INVISIBLE-GAP" }, "php": { 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 8a70dfc3a1..4f942bc806 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -26,6 +26,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { stripTemplateArguments } from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; +import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import { findClassBindingInScope, findEnclosingClassDef, @@ -99,6 +100,13 @@ interface ResolveCompoundReceiverOptions { * rather than re-declared, so a future sub-field cannot be added there * and silently ignored here (#2708). */ readonly constructionSyntax?: ScopeResolver['constructionSyntax']; + /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when + * the language's capture emitter produced one. Present ⇒ the structural fold + * is tried before the text cascade; absent ⇒ behaviour is exactly as before. + * Consumed only at `depth === 0`: it describes the site's own receiver, so + * carrying it into a recursive call would re-fold it against an inner + * expression it does not describe. */ + readonly receiverChain?: string; } /** Is this hop the language's construction selector applied to the class @@ -283,9 +291,13 @@ export function foldReceiverChain( if (chain.truncated) return undefined; if (chain.steps.length === 0) return undefined; + // `receiverChain` is dropped before resolving the base: it describes the + // whole receiver, and handing it back to the resolver would re-enter this + // fold on the base and never terminate. let current = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, { ...options, fieldFallback: false, + receiverChain: undefined, }); if (current === undefined) return undefined; @@ -311,6 +323,26 @@ export function resolveCompoundReceiverClass( if (text.length === 0) return undefined; const fieldFallback = options.fieldFallback ?? true; + // ── Structural fold, ahead of the text cascade ─────────────────── + // When the capture layer produced a chain for this site, type the receiver + // from that structure. The cascade below dispatches on enumerated textual + // shapes, so every new spelling (`?.`, `!`, ``, `->`) needs another branch; + // the AST already knew the answer and the chain carries it. + // + // Only at depth 0 — the chain describes THIS site's receiver, not the inner + // expressions the cascade recurses into. + // + // A failed fold falls through rather than returning: structure is an + // additional route to an answer, never a veto on the existing one, so a site + // the cascade could already resolve keeps resolving. + if (depth === 0 && options.receiverChain !== undefined) { + const decoded = decodeReceiverChain(options.receiverChain); + if (decoded !== undefined) { + const folded = foldReceiverChain(decoded, inScope, scopes, index, options); + if (folded !== undefined) return folded; + } + } + // ── Pre-processing: strip C-style cast expressions (opt-in) ────── // Cast-wrapped receivers like ((Type)((Object)this.field)).method() // produce parenthesized-expression receiver text. For languages that diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 31a3212b54..ee2cd6a217 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -407,7 +407,9 @@ export function emitReceiverBoundCalls( site.inScope, scopes, index, - compoundOpts, + // Group A: the receiver IS this site's expression, so the site's + // captured chain describes it and the structural fold applies. + { ...compoundOpts, receiverChain: site.receiverChain }, ); compoundReceiverUnresolved = currentClass === undefined; if (currentClass !== undefined) { @@ -1046,7 +1048,8 @@ export function emitReceiverBoundCalls( site.inScope, scopes, index, - compoundOpts, + // Group A, same reasoning as Case 0 above. + { ...compoundOpts, receiverChain: site.receiverChain }, ); } if (ownerDef !== undefined) { diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index 831045b0c1..ea43911d91 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -454,7 +454,10 @@ export function extractCallChain( current = innerReceiver; // continue walking } else { // Reached a simple identifier — the base receiver - return { chain, baseReceiverName: innerReceiver.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined, + }; } } @@ -477,6 +480,35 @@ export function extractCallChain( * * Pure field chains and pure call chains are special cases (all steps same kind). */ +/** + * Node types that wrap an expression without changing what it denotes, so a + * receiver chain's BASE can be read through them. + * + * `svc!` (TS non-null assertion) and `(svc)` denote exactly `svc`; taking the + * wrapper's own text instead yields `svc!` / `(svc)`, which matches no binding + * and silently costs the chain its base. + * + * Deliberately EXCLUDES a cast (`x as T`, `(T)x`): a cast changes the type an + * expression denotes, so reading through one would type the receiver as the + * operand rather than as the cast target. + */ +const TRANSPARENT_RECEIVER_WRAPPERS = new Set([ + 'non_null_expression', // TypeScript `svc!` + 'parenthesized_expression', // `(svc)` +]); + +/** Peel transparent wrappers off a base receiver node. */ +function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode { + let current = node; + // Bounded: `((x))` nests twice; nothing real nests deeply. + for (let i = 0; i < MAX_CHAIN_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); i++) { + const inner = current.namedChildren?.find((c) => c !== null); + if (inner === undefined || inner === null) break; + current = inner; + } + return current; +} + export function extractMixedChain( receiverNode: SyntaxNode, ): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined { @@ -546,7 +578,10 @@ export function extractMixedChain( ) { current = innerReceiver; } else { - return { chain, baseReceiverName: innerReceiver.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined, + }; } } else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) { // ── Field/member access: extract property name + inner object ───────── @@ -595,7 +630,10 @@ export function extractMixedChain( ) { current = innerObject; } else { - return { chain, baseReceiverName: innerObject.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerObject).text || undefined, + }; } } else if (current.type === 'selector') { // ── Dart: flat selector siblings (user.address.save() uses selector nodes) ── diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index cca53a6274..d6f4faf284 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -568,6 +568,20 @@ export interface RepoMeta { * — for every unchanged Rust file, which is exactly the symptom #2730 * reported. Force a full re-analyze. * + * v27: receiver expressions are typed from captured structure rather than from + * their source text. `svc?.getUser().save()`, `svc!.getUser().save()` and + * `svc.getTyped().save()` previously emitted NO `CALLS` edge — the text + * cascade split the receiver on punctuation it could not parse — and two of the + * three recorded no drop either, because a later case marked the site handled, + * which suppresses the drop record. So the caller was missing from + * `impact(direction: "upstream")` and `context()` AND the count still claimed + * `epistemic: 'exact'`. Same v11/v12 contract: the incremental write set covers + * only CHANGED files, so a top-up against a pre-v26 index keeps serving the + * pre-fix graph — and the pre-fix confident count — for every unchanged file. + * Worse than merely incomplete: the drop summary is a whole-repo recompute while + * the edges are a changed-files write, so the two would disagree. Force a full + * re-analyze. + * * v24: inline constructor receivers resolve — `Service(db).do_work()` (Python), * `new Service(db).doWork()` (JS/TS, C#), `Service.new.do_work` (Ruby), plus the * generic, qualified, chain-head and keyword-trivia spellings of the same shape @@ -580,6 +594,7 @@ export interface RepoMeta { * the same-commit "already up to date" fast path — keeps returning the pre-fix * graph for every unchanged file, which is exactly the missing-caller symptom * #2708 reported. Force a full re-analyze. + * * v26: unresolved-receiver member names are persisted * (`unresolvedReceiverMembers`) so `impact()`/`context()` can report * `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site @@ -600,7 +615,7 @@ export interface RepoMeta { * index holds ids an incremental top-up cannot reconcile and would simply * strand. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 26; +export const INCREMENTAL_SCHEMA_VERSION = 27; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 52119bf1ef..5082434724 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -3337,15 +3337,24 @@ export class Service { getUser(): User { return new User(); } + async getUserAsync(): Promise { + return new User(); + } } `, 'main.ts': `import { Service } from './models'; -export function droppedCall(svc: Service | null): void { - svc!.getUser().save(); +export async function droppedCall(svc: Service): Promise { + // An await-parenthesized receiver. Structural typing does NOT cover this + // shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node, + // so no chain is minted and the site still reaches the drop recorder. The + // \`!\` spelling used to serve here until structural typing resolved it. + (await svc.getUserAsync()).save(); } export function droppedWrite(svc: Service | null): void { + // A write receiver mints no chain at all (the emitter gates on CALL_TAGS), + // so this keeps dropping and stays separable from the call above. svc!.getUser().name = 'x'; } `, @@ -3371,3 +3380,119 @@ export function droppedWrite(svc: Service | null): void { expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' })); }); }); + +// --------------------------------------------------------------------------- +// Structural receiver typing: receiver spellings the text cascade cannot parse +// now resolve by folding the captured chain. +// +// Both shapes below emitted NO edge and recorded NO drop before this work: +// Case 0's gate fired, `resolveCompoundReceiverClass` returned undefined, and a +// later case marked the site handled — which suppresses the drop record too, so +// the loss was invisible to the epistemic signal as well as to the graph. +// --------------------------------------------------------------------------- + +describe('TypeScript structural receiver chains', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-receiver-chain-')); + writeFixtureRepo(repoDir, { + 'models.ts': `export class Address { + persist(): void {} +} + +export class User { + address: Address = new Address(); + save(): void {} +} + +export class Service { + getUser(): User { + return new User(); + } + getTyped(): User { + return new User(); + } +} +`, + 'main.ts': `import { Service } from './models'; + +export function viaOptionalChain(svc: Service | null): void { + svc?.getUser().save(); +} + +export function viaTypeArgs(svc: Service): void { + svc.getTyped().save(); +} + +export function viaNonNull(svc: Service | null): void { + svc!.getUser().save(); +} + +export function viaMixedChain(svc: Service): void { + svc.getUser().address.persist(); +} + +export function alreadyWorked(svc: Service): void { + svc.getUser().save(); +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves an optional-chained receiver — svc?.getUser().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaOptionalChain' && c.target === 'save')).toMatchObject( + { + target: 'save', + targetFilePath: 'models.ts', + }, + ); + }); + + it('resolves an explicit-type-argument receiver — svc.getTyped().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaTypeArgs' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('resolves a non-null-asserted receiver — svc!.getUser().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaNonNull' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('resolves a mixed call/field chain through the fold', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaMixedChain' && c.target === 'persist')).toMatchObject( + { + target: 'persist', + targetFilePath: 'models.ts', + }, + ); + }); + + it('keeps the shape that already resolved through the text cascade', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'alreadyWorked' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('emits no CALLS edge to a member that exists on no class in the chain', () => { + // A broken middle step must produce NO edge, never a wrong one. + const calls = getRelationships(result, 'CALLS'); + expect(calls.filter((c) => c.target === 'noSuchMember')).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index a02ee2f799..ae53af96a9 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 26 (unresolved-receiver epistemic summary, #2744)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 27 (structural receiver typing)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(26); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(27); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -179,11 +179,18 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { expect(passesReuseGate(23)).toBe(false); // A pre-v25 (v24) index predates mod-qualified Rust node ids (#2742) → must NOT reuse. expect(passesReuseGate(24)).toBe(false); - // A pre-v26 (v25) index predates `unresolvedReceiverMembers` (#2744). An absent - // summary is indistinguishable from "nothing was dropped", so a top-up would keep - // reporting `epistemic: 'exact'` for exactly the symbols whose callers were dropped. + // A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An + // absent summary is indistinguishable from "nothing was dropped", so a + // top-up would keep reporting `epistemic: 'exact'` for exactly the symbols + // whose callers were dropped → must NOT reuse. + expect(passesReuseGate(24)).toBe(false); + // A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`, + // `svc!.m().n()` and `svc.m().n()` emitted no CALLS edge — and two of the + // three recorded no drop either, so the count still claimed `exact`. A + // changed-files top-up keeps both the missing edge and the false confidence + // for every unchanged file → must NOT reuse. expect(passesReuseGate(25)).toBe(false); - // The current stamp passes (incremental top-up eligible). + // The current stamp passes the gate (incremental top-up eligible). expect(passesReuseGate(26)).toBe(true); }); });