Commit graph

9 commits

Author SHA1 Message Date
Gergo Magyar
42cde0b359 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>
2026-08-01 16:28:35 +00:00
Gergo Magyar
a4002c3c43 fix(go): drop the phantom read site on a member call's callee (#2766)
Found by a three-agent investigation into an A1 regression. The
regression was the symptom; this is the disease, and it SHIPPED on this
branch.

Go's `@reference.read` pattern matches every `selector_expression` with
no call-position exclusion, so `h.dep.Work()` minted THREE sites:

  call  Work  on the call_expression          — the member call
  read  Work  on the outer `h.dep.Work`       — PHANTOM, this is the callee
  read  dep   on the inner `h.dep`            — the genuine field read

The phantom resolves through `findOwnedMember`, which prefers methods
over fields, and emits an ACCESSES edge to the METHOD duplicating the
CALLS edge at the same position.

MY U8 CLAIM WAS WRONG. I asserted that typing the base made the ACCESSES
"correctly retarget to the property". It did not. The property edge was
always a separate site, and the U8 test passed by ACCIDENT: the row it
asserts on has a pointer receiver whose text-cascade lookup failed for an
unrelated reason. The value-receiver twin was emitting the bad edge the
whole time — verified on the pushed branch:

  ACCESSES  RunFromValueReceiver -> DoWork:Method
  ACCESSES  RunLocal             -> DoWork:Method

A selector in FUNCTION position is never a read. Dropped at the emitter.
A method VALUE (`f := h.dep.Work`) is not in function position and is
untouched.

Deliberately NOT fixed by gating on `handledSites`: the three co-located
Go sites share one site key, so that would suppress the correct
`RunSamePackage -> dep` edge depending on match order. Nor by flipping
`findOwnedMember` to prefer fields on reads — that is cross-language and
would break legitimate method-value reads.

The weak assertion is replaced by an exact-set check over the whole
fixture, so a new phantom fails even on a row nobody wrote a targeted
assertion for. My first attempt at that check compared `undefined ===
undefined` and would have passed against anything; caught and rewritten.

Numbers:
  callDrops   102 -> 102   no call lost
  read drops   27 ->  22   five phantoms were being RECORDED AS DROPS
  chain-field  61 ->  60   one reclassified...
  chain-unwrap  0 ->   1   ...to the real call's shape, because the
                           phantom and the call share a site key and the
                           phantom's chain was the one being censused

Baselines: go scope-capture fingerprint and the go capture golden
rebaselined with reasons — fewer capture matches for Go, and go was the
only fingerprint of 15 that moved, which is the check that this touches
Go's emitter alone. 4369 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:28:34 +00:00
Gergo Magyar
10d872529e fix(resolution): name-free steps were vetoed by the construction selector (#2766)
A defect I introduced two commits earlier, not a pre-existing one.

`foldReceiverChain` opens each step with:

    if (options.constructionSyntax?.selector === step.name) return undefined;

Correct as written. It became wrong the moment name-free step kinds were
added: `step.name` is `undefined` for `await` and `index`, and
`constructionSyntax?.selector` is `undefined` for any language that
declares none — so `undefined === undefined` matched EVERY name-free step
and vetoed the whole fold before it ran.

That is why subscript and await receivers minted a chain, fired the Case 0
gate, had a resolvable base binding, and still produced no edge. The fold
logic downstream was fine; nothing downstream ever executed. Three reading
passes missed it because the line looks obviously correct — you have to
watch `step.name` be `undefined` at runtime. An instrumented run found it
immediately, which is the lesson: when captures, plumbing, payload and
gate all verify clean, stop reading and instrument.

Guarded on `step.name !== undefined`. Load-bearing, not defensive.

Shape matrix, RESOLVES 46 -> 55, VISIBLE-GAP 32 -> 23:

  awaitParen    now resolves in typescript, python, csharp, kotlin, dart
  indexElement  now resolves in typescript, cpp, go, python, rust, kotlin

Every canonical TypeScript shape now resolves, which broke the #2744
drop-recorder test: it needs a receiver that FAILS to type, and its own
comment records that `!` served until structural typing resolved it, then
await-paren served until this fix. Both were shapes the resolver merely
did not SUPPORT yet, so each improvement moved the goalposts. Re-fixtured
to an UNANNOTATED parameter — no type information exists, so no resolver
work can type it. Stable by construction rather than by
not-yet-implemented.

Verified: resolver integration + scope-resolution unit 4367 green,
typescript resolver 270 green, scope-capture unchanged (this is resolution,
not capture), callDrops unchanged at 102.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:28:32 +00:00
Gergo Magyar
a4b75ffdb2 fix(resolution): decouple the wrapper peel bound; leave the chain cap at 3 (#2766)
Two halves, and the headline one is a negative result.

DECOUPLED (the real fix). `unwrapTransparentReceiver` used
`MAX_CHAIN_DEPTH` as its iteration bound. The two answer unrelated
questions — how many chain hops do we type, versus how many redundant
parens might someone write — so raising the chain cap would silently
widen the paren peel as a side effect, and the shared name reads as
intentional enough to hide it. The coupling got worse when the
await/subscript work added a peel call at loop entry. Now
`MAX_TRANSPARENT_WRAPPER_DEPTH`, its own constant. The parse-worker
comment that hardcoded "(3)" no longer restates the number.

NOT RAISED, on measurement. The premise was that a chain deeper than the
cap is discarded whole rather than truncated, so a 4-hop builder chain
"contributes nothing at all". The first half is true. The second is not.

`fourHopChain` was added to the TypeScript corpus as a declared extra to
make the question answerable at all — without a chain longer than the cap,
raising the cap measures nothing:

  cap 3: NO chain minted (confirmed by probing the emitter) -> RESOLVES
  cap 4: chain minted                                       -> RESOLVES

It resolves at both. At 3 the TEXT CASCADE answers, because it owns the
fallback path and runs to its own `COMPOUND_RECEIVER_MAX_DEPTH` of 8. So
the cap bounds which chains are typed STRUCTURALLY, not which calls
resolve — raising it moves work from the cascade to the fold and changes
no edge. Verified across the whole matrix: totals identical at 3 and 4,
callDrops 102 at both.

Left at 3. The fixture is committed so the next person to reach for this
number inherits the measurement rather than the intuition.

(An earlier version of that fixture was three steps, not four —
`root.getSvc().getUser().address` counts hops in the source text, not
steps in the receiver — so it fit inside the old cap and measured
nothing. Corrected before drawing any conclusion.)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:28:31 +00:00
Gergo Magyar
075ad0508d feat(resolution): mint chains through await and subscript receivers (#2766)
Two changes, one of which matters more than the shape work it was meant
to enable.

EMISSION. `extractMixedChain` stopped at await and subscript nodes —
neither is a call or a field access — so `(await svc.getUserAsync()).save()`
and `repos[0].save()` minted NO chain at all. Adds the node vocabularies
for both, records them as the codec's name-free `await` / `index` steps,
and peels transparent wrappers at LOOP ENTRY rather than only where a base
is returned (an await receiver arrives wrapped in a
`parenthesized_expression`, which matched no branch and fell straight
through with an empty chain). Verified directly: the two shapes now mint
`2|svc|cgetUserAsync|a` and `2|repos|i`.

CASE 0's GATE. The bigger one. That case fired on
`receiverName.includes('.') || includes('(')` — a C-family punctuation
test. A subscript receiver contains neither, so the case never ran, the
fold was never consulted, AND no drop was recorded: the call vanished with
the instrument blind to it. The bench's own KNOWN BLIND SPOTS section
names this. PHP `->` and `::` receivers are lost the same way.

A minted chain answers the same question structurally — the capture layer
walked the real AST and found the receiver is an expression, whatever
punctuation it is spelled with. The gate now accepts that signal, which is
the textual-shape-dispatch removal this whole line of work exists to make.

Measured on the shape matrix: 13 cells move INVISIBLE-GAP -> VISIBLE-GAP,
and kotlin `awaitParen` -> RESOLVES.

  RESOLVES       43 -> 45
  VISIBLE-GAP    21 -> 32
  INVISIBLE-GAP  31 -> 18

INCOMPLETE, deliberately stated: most subscript and await receivers still
do not RESOLVE. The chain mints, the gate fires, and the base binding
exists (`repos` -> `User[]`, confirmed) — the edge is still absent and I
have not isolated why. Three attempts (identity step, collection-element
hook, base carrying an unresolved container) did not produce it. The hook
and the relaxed base are kept because both are correct in their own right
and are needed by whatever the real fix turns out to be; they are not
load-bearing for anything claimed here.

`callDrops` 101 -> 102 on the fixture corpus. That is previously-invisible
loss becoming countable, NOT a regression — and it means the drop ratchet
must be baselined after this lands, or it would ratchet against a number
that understated reality.

Baselines: go and kotlin scope-capture fingerprints and the go capture
golden rebaselined — more sites carry a chain; no existing chain changed
shape. Only 2 of 15 languages drifted, the two whose fixtures contain such
receivers. Unit 1402 green, resolver integration 2995 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:28:30 +00:00
Gergo Magyar
0f98f16765 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>
2026-08-01 16:24:31 +00:00
Gergo Magyar
809d2eb63b fix(resolution): type Go pointer-receiver bases at the class lookup (#2766)
A Go method with a pointer receiver binds its receiver to the literal
string `*Holder` — `synthesizeGoReceiverBinding` stores `typeNode.text`
raw. `findClassBindingInScope` normalizes exactly one decoration, a
dotted qualifier, so `*Holder` matched nothing: the scope walk missed,
the qualified-name index missed, and the dotted-tail fallback never fired
because there is no dot. Receiver typing declined at the BASE, so every
`h.field.Method()` in a pointer-receiver method — the dominant Go idiom —
emitted no CALLS edge. The reporter measured 178 handler and 585 service
call sites lost on a 470k LOC codebase.

The defect is the base ALONE. Go already normalizes field type bindings
at capture through `normalizeGoTypeName`, so the step lookup was always
sound. Isolating the two independently proves it: value receiver + value
field resolves, value receiver + POINTER field resolves, pointer receiver
+ value field does not.

Adds an OPT-IN decoration fallback to the class lookup, consulted only
after every undecorated branch has failed:

- `findAllClassBindingsInScope` enumerates every class-like candidate
  from both the scope chain and the qualified-name index. Needed because
  `walkScopeChain` returns the FIRST match and structurally cannot report
  a collision, so widening what a name matches without it would pick the
  nearest of several and mint a confident wrong edge. It stops at the
  first scope that binds the name, so an inner binding shadowing an outer
  one is not misreported as ambiguity.
- The fallback strips one layer at a time and requires exactly ONE
  surviving nodeId, or it declines.
- `stripTypePreservingDecoration` on the ScopeResolver contract carries
  the per-language vocabulary, so the core names no language (AGENTS.md
  R6). Go strips `*` only — `[]` and `map[…]` are CONTAINERS whose member
  set differs from the element's, and stripping one would let
  `repos: Repo[]` fold `repos.find(x)` to `Repo.find`. Those are unwrapped
  only by an index step that consumed a subscript.

Opt-in rather than global because ~two dozen call sites are shaped
`findClassBindingInScope(...) ?? otherResolver(...)`: turning a former
`undefined` into a hit SUPPRESSES the fallback that used to answer, which
would retarget inheritance edges and bypass generic-specialization
selection. Only receiver-chain base and step resolution opts in. The
stored `*T` binding is left decorated — `method-owners.ts` consumes the
`*T` vs `T` distinction to model Go's value and pointer method sets, so
this normalizes at LOOKUP, never by rewriting the binding.

Verification:
- bench cell `go.decoratedReceiverBase` VISIBLE-GAP -> RESOLVES, and it
  is the ONLY cell that moved of 164.
- #2766's reproduction goes from 4 CALLS edges to 10; cross-package
  interface field, cross-package concrete field and same-package field
  all resolve.
- Regression test proven to fail without the fix: with the stripper
  disabled the 3 pointer-receiver assertions fail while both controls
  (local-variable receiver, value receiver) still pass — so the test
  targets the changed line and the fix adds edges rather than moving one.
- Full resolver suite green (2988 tests).

Baseline movement, both fixture-corpus growth rather than code:
receiver-resolution callDrops unchanged at 101; scope-capture go
fingerprint rebaselined with a documented reason, and go was the only
language of 15 that drifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:24:30 +00:00
Gergo Magyar
b442e04bf4 test(bench): canonical shape axis across all 14 languages (#2766)
The shape arm was three languages with an ad-hoc shape list each, so
"covers all supported languages" was an assertion rather than a
measurement. Replace it with a canonical 10-shape axis every language
must answer for, and add two states so a hole stops masquerading as a
result:

- `N/A` — the grammar does not admit the spelling. A reason is REQUIRED;
  an omitted cell and an inapplicable one are otherwise identical in a
  diff, which is how coverage rots.
- `GRAMMAR-UNAVAILABLE` — the parser could not load, so nothing was
  measured. `drift` skips it on BOTH sides, so the gate cannot fail for
  the environment it ran in. Dart, Kotlin and Swift are vendored
  OPTIONAL grammars, absent under GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 or
  when no vendored prebuild matches the host. All 14 load on glibc
  linux-x64, so this state has no producer in the committed baseline —
  it guards the skip-flag and unsupported-host cases.

`assertMatrixComplete` throws on a missing cell, an unknown id, or a
reasonless N/A. It caught four real ids on its first run; those became
declared `extraShapeIds`, because PHP's annotated/unannotated pair and
C++'s pointer/value pair discriminate things the axis cannot express.

164 cells: 42 RESOLVES, 22 VISIBLE-GAP, 31 INVISIBLE-GAP, 69 N/A. Count
arm unmoved at 101 call drops — shape fixtures build in temp directories
and never touch the committed corpus.

The measurement contradicts what was assumed about the decoration bug:

- Go isolates it to ONE cell. Varying receiver and field decoration
  independently: value/value RESOLVES, value/pointer RESOLVES,
  pointer/value is a VISIBLE-GAP. Only the pointer receiver fails; field
  bindings already go through normalizeGoTypeName, so the step lookup is
  sound and the defect is entirely the base.
- Go is the ONLY language whose receiver decoration defeats the lookup.
  Rust `&mut self` resolves.
- The field-type gap is real in rust (Box<User>), typescript
  (User|null), csharp (User?), swift (User?) and cpp (User*) — but php,
  python, kotlin and dart already resolve theirs.
- PHP's sigil hypothesis is dead: `$svc->getUser()->save()` (unannotated
  return) is an INVISIBLE-GAP while `$svc->getUserTyped()->save()`
  (annotated) RESOLVES. Same chain, same `->`, same base.

Also surfaced, previously unknown: Swift resolves almost no chained
receiver; Ruby's chains are VISIBLE-GAPs; C++ `this->` field receivers
are INVISIBLE; `indexElement` is INVISIBLE-GAP in all 14; and Dart is
the only language where `awaitParen` already resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:24:29 +00:00
Gergő Magyar
27ab37c432
feat(resolution): type receiver chains from AST structure across all 14 languages (#2708) + epistemic lower-bound (#2744) (#2747) 2026-07-31 07:12:57 +01:00