GitNexus/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts
Gergő Magyar 911151e230
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782)
2026-08-01 22:42:18 +01:00

489 lines
19 KiB
TypeScript

/**
* #2744 — the summary `impact()`/`context()` read to decide exact vs
* lower-bound. Keyed by member name because a dropped site's callee is
* unknown; see the module doc for why per-target attribution is impossible.
*/
import { describe, it, expect } from 'vitest';
import {
MAX_UNRESOLVED_RECEIVER_MEMBERS,
lookupExternalCallCount,
lookupUnresolvedCallCount,
summarizeUnresolvedReceivers,
} from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import { classifyReceiverShape } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import type {
ReceiverOrigin,
ResolutionOutcome,
} from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import { classifyReceiverOrigin } from '../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js';
import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../../src/core/ingestion/languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../../src/core/ingestion/languages/java/scope-resolver.js';
import { buildScopeModel, type ScopeModelFixture } from '../../helpers/scope-model.js';
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 };
function dropped(
name: string,
siteKind: 'call' | 'read' | 'write' = 'call',
receiverOrigin?: ReceiverOrigin,
): ResolutionOutcome {
return {
kind: 'suppressed',
reason: 'receiver-unresolved',
candidateIds: [],
phase: 'receiver-bound-calls',
filePath: 'a.py',
name,
range,
siteKind,
...(receiverOrigin === undefined ? {} : { receiverOrigin }),
};
}
describe('summarizeUnresolvedReceivers', () => {
it('returns undefined when nothing was dropped, so a clean repo stores no key', () => {
expect(summarizeUnresolvedReceivers([])).toBeUndefined();
});
it('ignores suppressions that are not receiver-unresolved', () => {
const ambiguous: ResolutionOutcome = {
kind: 'suppressed',
reason: 'member-lookup-ambiguous',
candidateIds: ['a', 'b'],
phase: 'receiver-bound-calls',
filePath: 'a.py',
name: 'save',
range,
};
const resolved: ResolutionOutcome = {
kind: 'resolved',
targetId: 't',
phase: 'receiver-bound-calls',
filePath: 'a.py',
name: 'save',
range,
};
expect(summarizeUnresolvedReceivers([ambiguous, resolved])).toBeUndefined();
});
it('counts dropped sites per member name', () => {
expect(
summarizeUnresolvedReceivers([dropped('save'), dropped('save'), dropped('run')]),
).toMatchObject({
counts: { save: 2, run: 1 },
totalSites: 3,
});
});
it('caps the map, keeps the highest counts, and reports what it omitted', () => {
const outcomes: ResolutionOutcome[] = [];
// One name well past the cap that must survive on count alone.
for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest'));
for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) {
outcomes.push(dropped(`member${i}`));
}
const summary = summarizeUnresolvedReceivers(outcomes);
expect(Object.keys(summary!.counts)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS);
expect(summary!.counts.zzz_hottest).toBe(5);
// The true total always reflects every drop, not just the kept sample.
expect(summary!.totalSites).toBe(MAX_UNRESOLVED_RECEIVER_MEMBERS + 15);
expect(summary!.omittedNames).toBe(11);
});
it('orders deterministically so the persisted metadata does not churn', () => {
const a = summarizeUnresolvedReceivers([dropped('b'), dropped('a'), dropped('c')]);
const b = summarizeUnresolvedReceivers([dropped('c'), dropped('b'), dropped('a')]);
expect(Object.keys(a!.counts)).toEqual(Object.keys(b!.counts));
});
it('counts CALL sites only — a property read or write is not a dropped call', () => {
// Case 0's recorder gates on the receiver's punctuation, not on what the
// reference IS, so reads and writes land in the same bucket as lost calls
// (25 of 124 on the fixture corpus). Counting them made the consumer's
// "N call sites invoking X were dropped" literally false.
const summary = summarizeUnresolvedReceivers([
dropped('save', 'call'),
dropped('name', 'write'),
dropped('kind', 'read'),
]);
expect(summary).toMatchObject({ counts: { save: 1 }, totalSites: 1 });
expect(summary?.counts).not.toHaveProperty('name');
expect(summary?.counts).not.toHaveProperty('kind');
});
it('returns undefined when every drop is a property access', () => {
expect(summarizeUnresolvedReceivers([dropped('name', 'write')])).toBeUndefined();
});
it('does not leak Object.prototype members through the counts lookup', () => {
// `counts` is revived from JSON and carries `Object.prototype`, so a bare
// `counts[symName]` returns a FUNCTION for these names — and `NaN <= 0` is
// false, so a `<= 0` guard lets it through. `impact({target:"constructor"})`
// then reported `epistemic: 'lower-bound'` and interpolated
// `function Object() { [native code] }` as the call count.
const summary = summarizeUnresolvedReceivers([dropped('save')]);
for (const polluted of [
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
'__proto__',
]) {
expect(lookupUnresolvedCallCount(summary, polluted)).toBeUndefined();
}
// A genuinely recorded name still reads back.
expect(lookupUnresolvedCallCount(summary, 'save')).toBe(1);
expect(lookupUnresolvedCallCount(summary, 'neverRecorded')).toBeUndefined();
expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Origin routing. `external` is the one verdict that makes a drop STOP hedging,
// so it is the one verdict that must come from positive evidence — everything
// else has to keep counting.
// ---------------------------------------------------------------------------
describe('summarizeUnresolvedReceivers origin routing', () => {
it('keeps an external-rooted drop out of totalSites but inside the artifact', () => {
const summary = summarizeUnresolvedReceivers([
dropped('save', 'call', 'in-program'),
dropped('log', 'call', 'external'),
]);
expect(summary).toMatchObject({
counts: { save: 1 },
totalSites: 1,
externalCounts: { log: 1 },
externalSites: 1,
});
// Routed, not discarded: the split stays auditable and reversible.
expect(summary?.counts).not.toHaveProperty('log');
});
it('counts an unknown-origin drop, because unproven completeness is the unsafe direction', () => {
// The `droppedCall(svc)` population: an unannotated parameter is recorded
// nowhere in the scope model, so the classifier can prove nothing about it.
// It must hedge, exactly like `in-program`.
expect(
summarizeUnresolvedReceivers([
dropped('save', 'call', 'unknown'),
dropped('run', 'call', 'in-program'),
]),
).toMatchObject({ counts: { save: 1, run: 1 }, totalSites: 2 });
});
it('counts a drop that carries no origin at all', () => {
expect(summarizeUnresolvedReceivers([dropped('save')])).toMatchObject({
counts: { save: 1 },
totalSites: 1,
});
});
it('reports external truncation past the cap, symmetrically with omittedNames', () => {
// Without the twin, `lookupExternalCallCount` returns `undefined` for a
// truncated name — indistinguishable from "this member had no external
// drops" — and `externalSites` exceeds the sum of `externalCounts` with
// nothing in the artifact to explain the gap.
const outcomes: ResolutionOutcome[] = [];
for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest', 'call', 'external'));
for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) {
outcomes.push(dropped(`ext${i}`, 'call', 'external'));
}
const summary = summarizeUnresolvedReceivers(outcomes);
expect(Object.keys(summary!.externalCounts!)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS);
expect(summary).toMatchObject({
totalSites: 0,
externalSites: MAX_UNRESOLVED_RECEIVER_MEMBERS + 15,
externalOmittedNames: 11,
});
// The hottest name survives the cap on count alone and still reads back.
expect(lookupExternalCallCount(summary, 'zzz_hottest')).toBe(5);
});
it('omits the external truncation marker when nothing was truncated', () => {
const summary = summarizeUnresolvedReceivers([dropped('log', 'call', 'external')]);
expect(summary).toMatchObject({ externalSites: 1 });
expect(summary).not.toHaveProperty('externalOmittedNames');
});
});
// ---------------------------------------------------------------------------
// `classifyReceiverOrigin` — the classifier the routing above consumes.
//
// Built from real scope extraction rather than hand-assembled indexes: the
// defect being pinned is that source-level intuition about what a binding
// CONTAINS is wrong (Go normalizes a free parameter's `*Host` to `Host` at
// capture but leaves a method receiver's spelled `*Host` intact), so a fixture
// that asserts the binding shape by hand would pin the intuition, not the code.
// ---------------------------------------------------------------------------
/** The provider-hook bag `classifyReceiverOrigin` reads. Derived from the
* function so the two cannot drift. */
type OriginHooks = Parameters<typeof classifyReceiverOrigin>[4];
/** Classify the receiver of the (unique) reference site invoking `memberName`
* under `hooks`, through exactly the arguments the pass threads at its drop
* recorder. */
function classifyOriginOf(
fixture: ScopeModelFixture,
memberName: string,
hooks: OriginHooks,
): ReceiverOrigin {
const site = fixture.sites.find(
(candidate) => candidate.name === memberName && candidate.explicitReceiver !== undefined,
);
expect(site).toBeDefined();
return classifyReceiverOrigin(
decodeReceiverChain(site!.receiverChain),
site!.inScope,
site!.explicitReceiver!.name,
fixture.scopes,
hooks,
);
}
/** The normal path: the language's own contract hooks, exactly as the pass
* supplies them. */
function originOf(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin {
return classifyOriginOf(fixture, memberName, {
stripTypePreservingDecoration: fixture.resolver.stripTypePreservingDecoration,
isBuiltInName: fixture.resolver.languageProvider.isBuiltInName,
});
}
/** The degradation path: the same site with the language's provider hooks
* WITHHELD, as for a language that declares neither.
*
* Named rather than spelled `originOf(fixture, name, {})` — an empty options
* object reads as "defaults", so a reader "simplifying" it away silently
* flips the assertion from the degradation path to the normal path, and some
* of these would still pass while no longer testing anything. */
function originOfWithoutHooks(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin {
return classifyOriginOf(fixture, memberName, {});
}
const goFixture = buildScopeModel(
goScopeResolver,
`package main
type Host struct{ name string }
func (h *Host) Inner() *Host { return h }
func (h *Host) Run() {
h.Inner().Dispatch()
}
`,
'main.go',
);
const tsFixture = buildScopeModel(
typescriptScopeResolver,
`export class User {
save(): void {}
}
export class Service {
getUser(): User {
return new User();
}
}
// The PR's own integration fixture. An unannotated parameter is recorded
// NOWHERE in the scope model — no type binding, no value binding, no qualified
// name — so nothing about it can be demonstrated in either direction.
export function droppedCall(svc): void {
svc.getUser().save();
}
// A local the program demonstrably declares, whose initializer we cannot type.
export function viaLocal(): void {
const loc = makeIt();
loc.getUser().persist();
}
// Genuinely outside: the language itself names \`console\`.
export function viaConsole(): void {
console.log('x');
}
// Declared type is a bare built-in, so the member lives outside too.
export function viaDate(d: Date): void {
d.getTime();
}
`,
'main.ts',
);
// #2744. Java is the language the boundary signal matters most for (the
// Spring/DI analysis is built on it) and the one that had no built-in set at
// all, so every drop in it hedged. Same construction as the fixtures above —
// real scope extraction, real provider hook — because the cases that matter are
// exactly the ones source-level intuition gets wrong: a `java.util` import does
// NOT produce an in-program binding (so the base still reaches the built-in
// set), and a `List<String>` declaration does not bind its base to `List`.
const javaFixture = buildScopeModel(
javaScopeResolver,
`import java.util.List;
import java.util.Map;
public class Probe {
private UserService svc;
private List<String> names;
public void go(OrderRepository repo, String raw) {
System.out.println("x");
String.format("%s", raw);
raw.trim();
names.iterator();
List.of("a");
Map.entry("a", "b");
Helper.assist();
svc.loadUser();
repo.findAll();
}
}
class Helper {
static void assist() {}
}
class UserService {
User loadUser() { return null; }
}
class User {}
`,
'src/Probe.java',
);
describe('classifyReceiverOrigin', () => {
// #2766. \`func (h *Host)\` binds \`h\` to the literal \`*Host\`; a free parameter
// \`x *Host\` is normalized to \`Host\` at capture. Only the receiver spelling
// needs the stripper, which is why the defect hid behind passing tests.
it('reads a Go pointer receiver as in-program', () => {
expect(originOf(goFixture, 'Dispatch')).toBe('in-program');
});
it('degrades to unknown, never external, when the language gives no stripper', () => {
// The same site with the hook withheld: \`*Host\` still resolves to no class,
// and the honest answer is that we could not tell — NOT that the JDK owns
// it. Returning `external` here is what published `epistemic: 'exact'` over
// every drop in a Go method body.
expect(originOfWithoutHooks(goFixture, 'Dispatch')).toBe('unknown');
});
it('does not call an unannotated parameter external', () => {
expect(originOf(tsFixture, 'save')).not.toBe('external');
expect(originOf(tsFixture, 'save')).toBe('unknown');
});
it('reads a declared local with an untypable initializer as in-program', () => {
expect(originOf(tsFixture, 'persist')).toBe('in-program');
});
// The test that proves the feature was fixed rather than deleted.
it('still reports a language built-in receiver as external', () => {
expect(originOf(tsFixture, 'log')).toBe('external');
});
it('still reports a base whose declared type is a bare built-in as external', () => {
expect(originOf(tsFixture, 'getTime')).toBe('external');
});
it('never claims external without the built-in hook', () => {
// A language that declares no built-in set has no positive external evidence
// available at all, so every one of its drops must hedge. Java used to be
// such a language; COBOL still is.
expect(originOfWithoutHooks(tsFixture, 'log')).toBe('unknown');
expect(originOfWithoutHooks(tsFixture, 'getTime')).toBe('unknown');
});
// ── Java (#2744) ────────────────────────────────────────────────────────
// Before Java had a built-in set, every assertion in this block read
// `lower-bound`-inducing `unknown`.
it('reports a Java static platform receiver as external', () => {
// `System.out.println(...)` — the chain base is `System`, not `out`.
expect(originOf(javaFixture, 'println')).toBe('external');
expect(originOf(javaFixture, 'format')).toBe('external');
});
it('reports a Java base whose declared type is a platform type as external', () => {
// `String raw` — the parameter is in-program, the member it dispatches is not.
expect(originOf(javaFixture, 'trim')).toBe('external');
// A FIELD declared `List<String>`. Java binds a known container to its
// ELEMENT type, so what reaches the built-in check is `String`, not `List` —
// asserting this from the source spelling would pin the wrong thing. Either
// way the verdict is the honest one, and without the set it read `in-program`
// off the value channel and hedged every JDK collection call in the repo.
expect(originOf(javaFixture, 'iterator')).toBe('external');
});
it('reports a java.util static receiver as external despite its import', () => {
// `import java.util.List` resolves to no workspace file, so it leaves no
// in-program binding and `List` falls through to the built-in set.
expect(originOf(javaFixture, 'of')).toBe('external');
});
it('still reports Java receivers the program declares as in-program', () => {
expect(originOf(javaFixture, 'assist')).toBe('in-program');
expect(originOf(javaFixture, 'loadUser')).toBe('in-program');
});
it('still hedges a Java receiver whose declared type is simply unknown here', () => {
// `OrderRepository` is declared nowhere in this program and is not a platform
// name. Absence of evidence stays `unknown` — the safe direction.
expect(originOf(javaFixture, 'findAll')).not.toBe('external');
expect(originOf(javaFixture, 'findAll')).toBe('unknown');
});
it('hedges a platform name deliberately kept OUT of the set', () => {
// `Map.entry(...)` is the exact syntactic twin of the `List.of(...)` above;
// the only thing separating the two verdicts is set membership. `Map` is a
// name applications really do declare (and in Java a same-package type needs
// no import to shadow it), so it stays out and its drops keep hedging. This
// pins the under-inclusion choice as a choice, not an oversight.
expect(originOf(javaFixture, 'entry')).toBe('unknown');
});
it('degrades every Java verdict to unknown when the hook is withheld', () => {
expect(originOfWithoutHooks(javaFixture, 'format')).toBe('unknown');
expect(originOfWithoutHooks(javaFixture, 'trim')).toBe('unknown');
});
});
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',
);
});
});