feat(resolution): separate the program boundary from analysis uncertainty (#2766)

`impact` reported `epistemic: lower-bound` for calls it could not resolve.
Dumping all 102 call drops with source context shows the count was
measuring two different things and reporting both as uncertainty:

  external     76   System.out.println, fetch(...), os.environ.setdefault,
                    document.body.appendChild, .stream() — the callee is
                    NOT IN THE GRAPH, so no node exists for an edge to
                    point at and nothing was lost
  in-program   20   a real gap in principle
  unknown       6   casts, ternaries, globalThis.x ??= []

Three quarters of the hedge was the program boundary, not doubt about
the program. A compiler resolves `System.out.println` against the JDK;
lacking one, the honest statement is "this call leaves the analyzed
program", NOT "this analysis is incomplete". Collapsing the two is what
made the signal fire on essentially every real codebase, which is what
teaches readers to ignore it.

`ResolutionOutcome.receiverOrigin` records which applies, and
`summarizeUnresolvedReceivers` skips `external`. `unknown` still counts —
assuming a completeness we cannot demonstrate is the unsafe direction.

ORIGIN IS DECIDED BY THE BASE'S DECLARED TYPE, NOT ITS NAME. A first cut
asked whether the base was a local, which marked `inputs.stream()`
in-program: `inputs` IS a local, but its type `List<String>` is JDK, so
the target is external. Asking whether the base's TYPE is one this index
contains moved 28 sites to the correct bucket.

Measured end to end: `impact save` on a TypeScript repo went from
`lower-bound` (2 dropped sites, both `fetch(...).then(...)`) to
`impactedCount: 3, epistemic: exact`.

WHAT THIS DOES NOT DO. The remaining 20 in-program drops are mostly not
product defects either: `user.Address.Save()` resolves cleanly in
isolation — the csharp-deep-field-chain fixture alone emits both expected
edges with ZERO drops — and drops in the count arm only because the
corpus is ~200 mini-projects in one directory and 55 files define
`Address`, so the resolver correctly declines on ambiguity. The genuinely
untypeable population is the 6 `unknown`, and those are the real targets
for type resolution: a cast GIVES you the type, a ternary needs a join of
its branch types. They were invisible under 76 stdlib calls until now.

Full external-target resolution needs stdlib type stubs (JDK / BCL /
lib.d.ts) so those callees exist as nodes at all. That is a program of
work; this change makes the boundary measured rather than guessed.

`callDropsByOrigin` joins the gated projection so the split cannot drift
silently. Both benches pass; scope-capture unchanged (this is
diagnostic + summary, not capture).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-08-01 12:26:19 +00:00
parent a4002c3c43
commit 42cde0b359
6 changed files with 158 additions and 0 deletions

View file

@ -1,5 +1,53 @@
# Receiver-resolution baseline
## Receiver ORIGIN — three quarters of the hedge was the program boundary
The drop count was measuring two different things and reporting both as
uncertainty. Dumping all 102 call drops with source context settles it:
| Origin | Count | Is anything lost? |
|---|---|---|
| `external` | **76** | **No.** `System.out.println`, `fetch(...)`, `os.environ.setdefault`, `document.body.appendChild`, `.stream()`. The callee is not in the graph — there is no node an edge could point at. |
| `in-program` | 20 | Yes in principle — but see below. |
| `unknown` | 6 | Yes. Casts, ternaries, `globalThis.x ??= []`. |
**A compiler resolves `System.out.println` against the JDK.** Lacking the JDK,
the honest statement is *"this call leaves the analyzed program"* — not *"this
analysis is incomplete"*. Those are different epistemic states, and collapsing
them is what made `impact` report a lower bound on essentially every real
codebase, which is what teaches readers to ignore the signal.
`ResolutionOutcome.receiverOrigin` now records which one applies, and
`summarizeUnresolvedReceivers` skips `external`. `unknown` still counts —
assuming a completeness we cannot demonstrate is the unsafe direction.
### How origin is decided
By the receiver base's **declared type**, not its name. A first cut asked
whether the base was a local, which marked `inputs.stream()` in-program:
`inputs` is a local, but its type `List<String>` is JDK, so the target is
external. Asking whether the base's *type* is one this index contains moved 28
sites to the correct bucket.
### What the remaining 20 in-program drops actually are
Mostly **not** product defects. `user.Address.Save()` resolves cleanly in
isolation — the `csharp-deep-field-chain` fixture alone emits both expected
edges with **zero** drops. It drops in the count arm only because the corpus is
~200 independent mini-projects in one directory and **55 files define
`Address`**, so the resolver correctly declines on ambiguity rather than picking
one. That is right behaviour measured on an unrepresentative corpus.
The genuinely untypeable population is the 6 `unknown` — and those are the real
targets for type resolution, because a cast *gives* you the type
(`((Box<String>) obj).open()`) and a ternary needs a join of its branch types.
They were previously invisible under 76 stdlib calls.
`callDropsByOrigin` is now part of the gated projection, so this split cannot
drift silently.
---
## Phantom callee read sites — a duplicate-edge bug the U8 test missed
Go's `@reference.read` pattern matches **every** `selector_expression`, with no

View file

@ -226,6 +226,11 @@
"no-chain": 12,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"external": 76,
"in-program": 20,
"unknown": 6
}
}
}

View file

@ -1367,6 +1367,14 @@ async function runCountArm(repoPath) {
// 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();
// THE number that matters now. A drop whose receiver is rooted outside the
// indexed program (`System.out.println`, `fetch(...)`) reaches code with no
// node to point an edge at — nothing was lost. Only `in-program` and
// `unknown` drops represent a real resolver gap, and only those make an
// `impact` count a lower bound. Measured on this corpus: 76 external, 20
// in-program, 6 unknown — i.e. three quarters of the raw count was the
// program boundary rather than uncertainty about the program.
const callDropsByOrigin = new Map();
const callDropsByShapeAndExt = new Map();
for (const drop of drops) {
const kind = drop.siteKind ?? '<<unset>>';
@ -1377,6 +1385,8 @@ async function runCountArm(repoPath) {
callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1);
const shape = drop.receiverShape ?? '<<unclassified>>';
callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1);
const origin = drop.receiverOrigin ?? '<<unclassified>>';
callDropsByOrigin.set(origin, (callDropsByOrigin.get(origin) ?? 0) + 1);
const pair = `${ext} ${shape}`;
callDropsByShapeAndExt.set(pair, (callDropsByShapeAndExt.get(pair) ?? 0) + 1);
}
@ -1391,6 +1401,7 @@ async function runCountArm(repoPath) {
bySiteKind: sortDesc(byKind),
callDropsByExtension: sortDesc(callDropsByExtension),
callDropsByShape: sortDesc(callDropsByShape),
callDropsByOrigin: sortDesc(callDropsByOrigin),
callDropsByShapeAndExtension: sortDesc(callDropsByShapeAndExt),
allDropsByExtension: sortDesc(byExtension),
};
@ -1508,6 +1519,7 @@ function projection(output) {
bySiteKind: output.countArm.bySiteKind,
callDropsByExtension: output.countArm.callDropsByExtension,
callDropsByShape: output.countArm.callDropsByShape,
callDropsByOrigin: output.countArm.callDropsByOrigin,
},
};
}

View file

@ -83,6 +83,7 @@ import type {
ResolutionSuppressionReason,
} from '../resolution-outcome.js';
import { classifyReceiverShape } from '../resolution-outcome.js';
import type { ReceiverOrigin } from '../resolution-outcome.js';
import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
@ -155,6 +156,52 @@ function resolveClassBindingForName(
return findClassBindingInScope(scopeId, baseName, scopes);
}
/**
* Is this dropped receiver rooted inside the analyzed program?
*
* Asks one question of the receiver's BASE the leftmost name the chain hangs
* off: does this index know anything at all by that name? A local, a parameter,
* a field, a class, or any qualified name counts as in-program. Nothing at all
* means the expression is rooted in code we do not have.
*
* That is the difference between `user.address.save()` where `user` is a
* parameter and a real edge was lost and `System.out.println(...)`, where
* `System` is the JDK and no node exists to point an edge at. A compiler
* resolves the latter against the standard library; lacking one, the honest
* report is "outside the program", not "could not analyze".
*
* Uses the AST-derived chain base when one was minted, and falls back to the
* head of the receiver text otherwise never a regex over the source line.
*/
function classifyReceiverOrigin(
site: { readonly receiverChain?: string; readonly inScope: string },
receiverName: string,
scopes: ScopeResolutionIndexes,
): ReceiverOrigin {
const decoded = decodeReceiverChain(site.receiverChain);
// The chain's base is authoritative. Without one, take the head of the
// receiver text up to the first member/call punctuation.
const base = decoded?.baseReceiverName ?? /^[A-Za-z_$][\w$]*/.exec(receiverName)?.[0];
if (base === undefined || base.length === 0) return 'unknown';
// A value the program declares — parameter, local, field, `this`. Being
// declared here is NOT enough: `inputs.stream()` has an in-program base bound
// to `List<String>`, whose `stream` lives in the JDK. What decides the target
// is whether the base's declared TYPE is one this index contains.
const binding = findReceiverTypeBinding(site.inScope, base, scopes);
if (binding !== undefined) {
return findClassBindingInScope(binding.declaredAtScope, binding.rawName, scopes) !== undefined
? 'in-program'
: 'external';
}
// A type the program declares, used as a static receiver.
if (findClassBindingInScope(site.inScope, base, scopes) !== undefined) return 'in-program';
// Anything else this index knows by that name (namespace, module, free fn).
if (scopes.qualifiedNames.get(base).length > 0) return 'in-program';
return 'external';
}
export function emitReceiverBoundCalls(
graph: KnowledgeGraph,
scopes: ScopeResolutionIndexes,
@ -1379,6 +1426,9 @@ export function emitReceiverBoundCalls(
// `decodeReceiverChain` opens with a non-string guard, so the
// undefined case needs no ternary here.
receiverShape: classifyReceiverShape(decodeReceiverChain(site.receiverChain)),
// Whether anything was actually lost. An external target has no node
// to point at, so its absence is completeness, not uncertainty.
receiverOrigin: classifyReceiverOrigin(site, receiverName, scopes),
});
}
}

View file

@ -87,6 +87,27 @@ export type ResolutionOutcome =
* persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged.
*/
readonly receiverShape?: ReceiverShape;
/**
* Whether the receiver is rooted INSIDE the analyzed program.
*
* The single most important distinction a static-analysis tool can make
* about a call it did not resolve, and the one this codebase previously
* collapsed:
*
* - `in-program` the receiver's base is a local, parameter, field or a
* type this index knows. Failing to type it is a RESOLVER DEFECT: a real
* caller exists in the graph and was lost.
* - `external` the base is rooted in code this index does not contain
* (`System.out.println`, `fetch(...)`, `os.environ.setdefault`). There is
* NO node to point an edge at, so nothing was lost. A compiler resolves
* these against the JDK / BCL / lib.d.ts; without those, the honest
* answer is "outside the program", not "unknown".
*
* Only `in-program` makes an `impact` count a lower bound. Reporting an
* external target as uncertainty is what made the hedge fire on nearly
* every real codebase and taught readers to ignore it.
*/
readonly receiverOrigin?: ReceiverOrigin;
};
/**
@ -103,6 +124,16 @@ export type ResolutionOutcome =
* expression the capture walk could not reduce to a nameable
* base (it stopped early, or the base was unencodable)
*/
/**
* Is the receiver rooted inside the analyzed program, or outside it?
*
* `unknown` is for a site carrying no usable base at all neither a chain nor
* an explicit receiver where the question cannot be asked. It is treated as
* `in-program` for hedging purposes, because assuming completeness we cannot
* demonstrate is the unsafe direction.
*/
export type ReceiverOrigin = 'in-program' | 'external' | 'unknown';
export type ReceiverShape =
| 'chain-call'
| 'chain-field'

View file

@ -56,6 +56,18 @@ export function summarizeUnresolvedReceivers(
// A missing `siteKind` counts as a call: the only emitter always sets it, and
// erring toward `lower-bound` is the safe direction for an epistemic signal.
if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue;
// EXTERNAL targets are not uncertainty. `System.out.println(...)`,
// `fetch(...)`, `os.environ.setdefault(...)` reach code this index does not
// contain, so there is no node an edge could have pointed at and nothing was
// lost. Counting them made `impact` report a lower bound on essentially
// every real codebase — 75% of the drops on this corpus — which is what
// taught readers to ignore the signal.
//
// A compiler resolves these against the JDK / BCL / lib.d.ts. Lacking those,
// the honest statement is "this call leaves the analyzed program", not "this
// analysis is incomplete". `unknown` still counts: assuming completeness we
// cannot demonstrate is the unsafe direction.
if (outcome.receiverOrigin === 'external') continue;
totalSites++;
counts.set(outcome.name, (counts.get(outcome.name) ?? 0) + 1);
}