From 42cde0b3598cd53e333086e03e98136f6caa8c5c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sat, 1 Aug 2026 12:26:19 +0000 Subject: [PATCH] feat(resolution): separate the program boundary from analysis uncertainty (#2766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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` 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) --- .../bench/receiver-resolution/BASELINE.md | 48 ++++++++++++++++++ .../bench/receiver-resolution/baseline.json | 5 ++ .../bench/receiver-resolution/measure.mjs | 12 +++++ .../passes/receiver-bound-calls.ts | 50 +++++++++++++++++++ .../scope-resolution/resolution-outcome.ts | 31 ++++++++++++ .../scope-resolution/unresolved-receivers.ts | 12 +++++ 6 files changed, 158 insertions(+) diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index 81fc25a37..b57fd66f1 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -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` 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) 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 diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 3639036d7..f4dd3e62c 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -226,6 +226,11 @@ "no-chain": 12, "chain-mixed": 2, "chain-unwrap": 1 + }, + "callDropsByOrigin": { + "external": 76, + "in-program": 20, + "unknown": 6 } } } diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs index 5d183a377..3aaf68679 100644 --- a/gitnexus/bench/receiver-resolution/measure.mjs +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -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 ?? '<>'; @@ -1377,6 +1385,8 @@ async function runCountArm(repoPath) { callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); const shape = drop.receiverShape ?? '<>'; callDropsByShape.set(shape, (callDropsByShape.get(shape) ?? 0) + 1); + const origin = drop.receiverOrigin ?? '<>'; + 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, }, }; } 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 40fb3528f..a1978e2ae 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 @@ -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`, 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), }); } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 569fb05f4..ecd86c091 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -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' diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts index db6cfb03b..e10d9f7da 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -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); }