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