mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
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:
parent
809d2eb63b
commit
0f98f16765
8 changed files with 180 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -218,6 +218,12 @@
|
|||
".rb": 2,
|
||||
".js": 1,
|
||||
".swift": 1
|
||||
},
|
||||
"callDropsByShape": {
|
||||
"chain-field": 60,
|
||||
"chain-call": 27,
|
||||
"no-chain": 12,
|
||||
"chain-mixed": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue