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