feat(resolution): census receiver drops by structural shape (#2766)

`callDropsByExtension` was the finest split available, so a language's
bucket said nothing about whether its drops were one defect or five. The
suppressed `ResolutionOutcome` 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. `resolution-outcome.ts` already
rejects that for `siteKind`, and for the same reason: regex-classifying
the number that gates this work is the 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 corpus:

  chain-field  60 (59%)   h.repo.save()
  chain-call   27 (27%)   svc.getUser().save()
  no-chain     12 (12%)   no nameable base survived the walk
  chain-mixed   2  (2%)   svc.getUser().addr.save()

Two questions the plan could not answer are now answered.

The `.java` bucket is NOT one defect: its 49 drops split 30 field-chain /
14 call-chain / 5 no-chain — the same mix as everywhere else, just more
of it. And field-receiver chains dominate at 59%, which is precisely the
shape the pointer-receiver fix closed for Go (now down to 3). The same
class remains in java (30), csharp (6), cpp (4), php (4), py (3),
rust (3).

Also records what the census CANNOT decide: await-wrapped and subscript
receivers appear nowhere in it, because the committed fixtures contain no
such sites — not because they are rare. `indexElement` is an
INVISIBLE-GAP in all 14 languages in the shape arm, so that population is
real but structurally invisible here. Funding those shapes has to be read
off the shape arm; reading it off this census would confuse "absent from
these fixtures" with "does not happen".

Also closes the ACCESSES-vs-CALLS question as RESOLVED BY the
pointer-receiver fix, diagnosed rather than assumed. Go `h.dep.Work()`
previously emitted ACCESSES to the METHOD and no CALLS: the member-name
path emitted the access while the CALLS leg needed the receiver's class,
which failed to type. With the base typed, CALLS emits and the ACCESSES
correctly retargets to the PROPERTY being read. Verified across the
fixture that no Method target now carries ACCESSES without a matching
CALLS. Locked by a same-package control asserting both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-07-31 14:05:04 +00:00
parent 809d2eb63b
commit 0f98f16765
8 changed files with 180 additions and 1 deletions

View file

@ -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

View file

@ -218,6 +218,12 @@
".rb": 2,
".js": 1,
".swift": 1
},
"callDropsByShape": {
"chain-field": 60,
"chain-call": 27,
"no-chain": 12,
"chain-mixed": 2
}
}
}

View file

@ -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 ?? '<<unset>>';
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 ?? '<<unclassified>>';
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,
},
};
}

View file

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

View file

@ -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;

View file

@ -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()
}

View file

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

View file

@ -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',
);
});
});