diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index e33b844e5..9287d97c5 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -1,5 +1,44 @@ # Receiver-resolution baseline +## U10 — recorded drops, censused by receiver shape + +`ResolutionOutcome`'s suppressed variant now carries `receiverShape`, set by the +emitting case from the site's ENCODED CHAIN — the compact string the capture +emitters mint by walking the real AST. Never re-derived from the source line: +doing that would mean regex-classifying the number that gates this work, the +same textual-shape dispatch the structural-receiver line exists to remove. +Diagnostic only, so the persisted `RepoMeta.unresolvedReceiverMembers` artifact +is unchanged. + +Census of the 101 call drops on the committed fixture corpus: + +| Shape | Count | Share | +|---|---|---| +| `chain-field` — every step a field (`h.repo.save()`) | 60 | 59% | +| `chain-call` — every step a call (`svc.getUser().save()`) | 27 | 27% | +| `no-chain` — no chain minted; the walk found no nameable base | 12 | 12% | +| `chain-mixed` — interleaved (`svc.getUser().addr.save()`) | 2 | 2% | + +Two decisions come out of it. + +**The `.java` bucket is not one defect.** Its 49 call drops split 30 field-chain +/ 14 call-chain / 5 no-chain, so the open question of whether Java's largest- +single-bucket status hides a single cause is answered: it does not. It is the +same population as everywhere else, just more of it. + +**Field-receiver chains are where the remaining value is.** At 59% they dominate, +and they are precisely the shape U1 fixed for Go — whose count fell to 3. +The same defect class in java (30), csharp (6), cpp (4), php (4), py (3) and +rust (3) is the largest addressable population the count arm can see. + +**What this census CANNOT justify.** Await-wrapped and subscript receivers do not +appear, because the committed fixture corpus contains no such sites — not +because they are rare in real code. `indexElement` is an INVISIBLE-GAP in all 14 +languages in the shape arm, so U5's population is real but structurally +invisible to the count arm. Any decision to fund or drop U4 and U5 has to be +read off the SHAPE arm; reading it off this census would confuse "absent from +these fixtures" with "does not happen". + ## U2 — shape matrix expanded to a canonical axis The shape arm was three languages with an ad-hoc shape list each. It is now a diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 1b06e8fba..f960d24da 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -218,6 +218,12 @@ ".rb": 2, ".js": 1, ".swift": 1 + }, + "callDropsByShape": { + "chain-field": 60, + "chain-call": 27, + "no-chain": 12, + "chain-mixed": 2 } } } diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 0bda7ad74..bdd1e5fb3 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -1333,12 +1333,22 @@ async function runCountArm(repoPath) { const byKind = new Map(); const byExtension = new Map(); const callDropsByExtension = new Map(); + // Shape census over the CALL drops only, so the answer to "which kind of + // receiver are we losing?" is not diluted by property reads and writes. + const callDropsByShape = new Map(); + const callDropsByShapeAndExt = new Map(); for (const drop of drops) { const kind = drop.siteKind ?? '<>'; byKind.set(kind, (byKind.get(kind) ?? 0) + 1); const ext = path.extname(drop.filePath); byExtension.set(ext, (byExtension.get(ext) ?? 0) + 1); - if (kind === 'call') callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); + if (kind === 'call') { + callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); + const shape = drop.receiverShape ?? '<>'; + callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1); + const pair = `${ext} ${shape}`; + callDropsByShapeAndExt.set(pair, (callDropsByShapeAndExt.get(pair) ?? 0) + 1); + } } const sortDesc = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1])); @@ -1349,6 +1359,8 @@ async function runCountArm(repoPath) { totalDropsAllKinds: drops.length, bySiteKind: sortDesc(byKind), callDropsByExtension: sortDesc(callDropsByExtension), + callDropsByShape: sortDesc(callDropsByShape), + callDropsByShapeAndExtension: sortDesc(callDropsByShapeAndExt), allDropsByExtension: sortDesc(byExtension), }; } @@ -1464,6 +1476,7 @@ function projection(output) { totalDropsAllKinds: output.countArm.totalDropsAllKinds, bySiteKind: output.countArm.bySiteKind, callDropsByExtension: output.countArm.callDropsByExtension, + callDropsByShape: output.countArm.callDropsByShape, }, }; } 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 01252e355..ce89e5103 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 @@ -82,6 +82,8 @@ import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, } from '../resolution-outcome.js'; +import { classifyReceiverShape } from '../resolution-outcome.js'; +import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; /** Subset of `ScopeResolver` consumed by this pass. Accepting the * subset rather than the full provider keeps tests and partial @@ -1356,6 +1358,11 @@ export function emitReceiverBoundCalls( // recorded here too. Carry the kind so a consumer can separate a // dropped CALL from a dropped property access. siteKind: site.kind, + // Structural, from the AST-derived chain the emitter minted — never + // re-derived from the source line. + receiverShape: classifyReceiverShape( + site.receiverChain === undefined ? undefined : decodeReceiverChain(site.receiverChain), + ), }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 07b9d3083..13b1c5b15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -61,6 +61,60 @@ export type ResolutionOutcome = * persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged. */ readonly siteKind?: ReferenceKind; + /** + * Structural shape of the receiver whose type could not be established. + * + * Derived from the site's ENCODED RECEIVER CHAIN — the compact string the + * capture emitters mint by walking the real AST — never from the source + * line. Re-deriving a shape textually would mean regex-classifying the + * number that gates this work, which is exactly the textual-shape dispatch + * the structural-receiver line of work exists to remove. + * + * Lets a consumer ask "which KIND of receiver are we losing?" instead of + * only "how many". Without it, `callDropsByExtension` is the finest + * available split and a language's bucket says nothing about whether the + * cause is one defect or five. + * + * NOTE ON COVERAGE: only drops that REACH the recorder carry a shape, and + * Case 0's gate fires on receiver punctuation, so shapes that mint no + * reference site at all (`?.`, explicit type args, subscript) are absent + * from this breakdown entirely — they are the INVISIBLE-GAP population the + * bench shape arm exists to see. A shape census here is a census of the + * VISIBLE drops, not of all lost calls. + * + * Diagnostic only. `summarizeUnresolvedReceivers` ignores it, so the + * persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged. + */ + readonly receiverShape?: ReceiverShape; }; +/** + * How a dropped receiver was spelled, structurally. + * + * - `chain-call` every recorded step is a call — `svc.getUser().save()` + * - `chain-field` every recorded step is a field — `h.repo.save()` + * - `chain-mixed` the chain interleaves both — `svc.getUser().addr.save()` + * - `no-chain` the site carried no chain, so the receiver was a compound + * expression the capture walk could not reduce to a nameable + * base (it stopped early, or the base was unencodable) + */ +export type ReceiverShape = 'chain-call' | 'chain-field' | 'chain-mixed' | 'no-chain'; + +/** Classify a dropped receiver from its encoded chain. `undefined` chain ⇒ + * `no-chain`; an undecodable one is also `no-chain`, since what we know about + * it is exactly that no usable structure survived. */ +export function classifyReceiverShape( + decoded: { readonly steps: readonly { readonly kind: string }[] } | undefined, +): ReceiverShape { + if (decoded === undefined || decoded.steps.length === 0) return 'no-chain'; + let calls = 0; + let fields = 0; + for (const step of decoded.steps) { + if (step.kind === 'call') calls++; + else fields++; + } + if (calls > 0 && fields > 0) return 'chain-mixed'; + return calls > 0 ? 'chain-call' : 'chain-field'; +} + export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void; diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go index 40ac92155..2304c4b6d 100644 --- a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go @@ -52,3 +52,20 @@ type ValueHolder struct { func (v ValueHolder) RunFromValueReceiver() error { return v.impl.DoWork() } + +// #2766 / U8 control: SAME-PACKAGE field receiver through a pointer receiver. +// Before the base fix this emitted an ACCESSES edge to the method and NO CALLS +// edge — the member name resolved while the CALLS leg, which needs the +// receiver's class, did not. It is the shape that made the miss look like an +// edge-classification bug rather than a receiver-typing one. +type LocalDep struct{} + +func (d *LocalDep) Work() error { return nil } + +type LocalHost struct { + dep *LocalDep +} + +func (h *LocalHost) RunSamePackage() error { + return h.dep.Work() +} diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index 743918d8a..add00f61c 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -1697,4 +1697,17 @@ describe('Go pointer-receiver field chains (#2766)', () => { it('keeps resolving a value receiver', () => { expect(calls()).toContain('RunFromValueReceiver → DoWork'); }); + + // U8: the same-package field receiver that previously produced an ACCESSES + // edge to the method and no CALLS edge. Typing the base is what emits CALLS; + // the ACCESSES now correctly targets the PROPERTY being read instead. + it('emits CALLS for a same-package field receiver, not ACCESSES alone', () => { + expect(calls()).toContain('RunSamePackage → Work'); + }); + + it('retargets the field ACCESSES to the property, not the method', () => { + const accesses = edgeSet(getRelationships(result, 'ACCESSES')); + expect(accesses).toContain('RunSamePackage → dep'); + expect(accesses).not.toContain('RunSamePackage → Work'); + }); }); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts index 7d6ad9dfe..ba6d30d08 100644 --- a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -10,6 +10,7 @@ import { lookupUnresolvedCallCount, summarizeUnresolvedReceivers, } from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js'; +import { classifyReceiverShape } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }; @@ -127,3 +128,32 @@ describe('summarizeUnresolvedReceivers', () => { expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined(); }); }); + +describe('classifyReceiverShape', () => { + it('reports no-chain when the site carried no chain', () => { + expect(classifyReceiverShape(undefined)).toBe('no-chain'); + }); + + it('reports no-chain for a chain with no steps', () => { + expect(classifyReceiverShape({ steps: [] })).toBe('no-chain'); + }); + + it('reports chain-call when every step is a call', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'call' }] })).toBe( + 'chain-call', + ); + }); + + it('reports chain-field when every step is a field', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'field' }] })).toBe('chain-field'); + }); + + // The distinction that makes the census actionable: a mixed chain fails for + // different reasons than a pure one, so collapsing it into either bucket + // would misattribute the population a fix has to target. + it('reports chain-mixed when the chain interleaves calls and fields', () => { + expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'field' }] })).toBe( + 'chain-mixed', + ); + }); +});