* test(resolution): pin generic-typed field receivers across languages (#2833) A field whose declared type carries a type argument (`repo: Repo<User>`) emits zero CALLS edges — not a truncated chain, not an edge to the interface declaration, nothing. This adds the cross-language matrix that measures it, modelled on the #2807 inferred-field matrix: every language runs the same two calls, one through a generic-typed field and one through a non-generic control field, and each language is compared against its OWN control row rather than an absolute edge count. Measured state, pinned here as `known-gap` so the file is green on main and flipping a row is a visible edit: affected TypeScript, C#, C++, Python unaffected Java, Kotlin, Go, Rust, Swift, Dart The unaffected six erase type arguments at interpret time (Java's `stripGeneric`, F41 #1928; Swift likewise). TypeScript, C# and Python instead run a container ALLOW-LIST that returns the type ARGUMENT, so a user-defined `Repo<User>` survives verbatim into a lookup that binds nothing. The `ts-local-vs-field` case is the bug in one file: `viaLocal` and `viaParam` both resolve for the identical type, and only `viaField` loses every edge — a bare name reaches Case 4 and its generic-aware lookup, a dotted field receiver does not. Negative controls pin what erasure must NOT do: an unbounded type parameter denotes no declaration, and a C++ explicit specialization is a different class from its primary template. The `Box2<T>` row pins a PRE-EXISTING false edge (a workspace class named `T`) so it cannot later be mistaken for fallout from this work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * refactor(resolution): move resolveClassBindingForName to the shared walkers (#2833) Pure relocation, no behaviour change: the generic-aware class lookup moves from `passes/receiver-bound-calls.ts` to `scope/walkers.ts`, beside the bare `findClassBindingInScope` it wraps. Its two existing callers — `classifyReceiverOrigin` and Case 4 — import it from the new home and are otherwise untouched. The move is required rather than cosmetic: `receiver-bound-calls.ts` already imports from `compound-receiver.ts`, so having the compound receiver call into the pass would close an import cycle. `walkers.ts` is the shared floor both already depend on. Verified behaviour-neutral: the #2833 matrix is 44/44 identical before and after, across all fifteen fixtures. detect_changes attributes `resolveInheritanceBaseInScope`, `resolveQualifiedInheritanceBase` and `EMPTY_BINDINGS` to this commit; those are line-shift artifacts of inserting a function above them, and their bodies are byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(resolution): type generic field receivers through the generic-aware lookup (#2833) A field receiver is spelled `this.repo` — dotted — so it types through the receiver-chain fold and the text cascade, both of which reach `findClassBindingInScope`. That function has no notion of type arguments, so a field declared `Repo<User>` resolved to nothing and the call site emitted NO edge at all: not the interface declaration, not the implementation fan-out, nothing. A local or parameter of the identical type is a bare name, reaches Case 4 and its generic-aware `resolveClassBindingForName`, and resolved fine. The bug was the asymmetry, not the generics. Three receiver-typing lookups now call the generic-aware helper instead: `typeOfMemberOnClass`'s primary and module-hoist branches, and the cascade's bare-identifier type-binding read. Every other one of the 38 `findClassBindingInScope` call sites is untouched — its own docstring records that widening it globally suppresses the `?? otherResolver(...)` fallbacks two dozen callers rely on, which would retarget inheritance edges, and impact rates it CRITICAL with 12 direct dependents. Order matters and is preserved: the helper tries the exact name, then an arity- and token-exact match against `def.templateArguments`, and only then falls back to the base name. Erasing first would collapse a C++ explicit specialization onto its primary template — `Vec<bool>` really is a different class. A bare type parameter carries no type arguments, so it never enters the generic branch and cannot be erased into a class that happens to share its name. Measured: TypeScript and C# generic-typed fields now emit exactly what their non-generic control rows emit, primary plus interface-dispatch fan-out. Java, Kotlin, Go, Rust, Swift and Dart are byte-identical. Both type-parameter negative controls are unchanged. C++ and Python are still open and stay pinned as known-gaps — they fail for different reasons and get their own commits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(cpp,python): bind generic-typed member fields so their calls resolve (#2833) Completes #2833 for the two languages the shared resolution change could not reach. Each failed for its own reason, and both were found by measurement rather than assumed. C++ — a CAPTURE gap, not a resolution one. All three `field_declaration` type-binding rules required `type: (type_identifier)`, so a member declared `Repo<User> repo;` is a `template_type` and matched none of them: the field got no type binding at all, and every call through it lost its edge in both the bare and `this->` spellings. A LOCAL of the identical type resolved the whole time, because the local declaration rules gained their `template_type` variant long ago. Three mirrored rules close it, one per declarator shape (plain, pointer, reference). Written as separate patterns rather than one alternation: a node-type alternation in a field position is a tree-sitter 0.21 hazard this repo has been bitten by before. Python — the bracket spelling never entered the generic branch. Its `stripGeneric` is a container allow-list over `[...]` that returns the type ARGUMENT (`list[User]` to `User`), so a user-defined `Repo[User]` matched nothing and survived verbatim, and the shared lookup's generic branch is gated on `<`. It now reduces a subscripted type neither allow-list claims to its base name — the same rule Java and Swift already apply to `<...>`. Deliberately the LAST resort: a container must reach its own rule first, or `list[User]` would type the receiver as the container and retarget every call in a for-loop chain. The as-written spelling survives on `TypeRef.declaredSpelling`, which is what the fold's index step reads. Both are parse-time and land in the cached ParsedFile, so SCHEMA_BUMP goes 45 -> 46 with its pin test. Verified free against origin/main; the ledger in that file records three prior EXACT clashes, so re-check again immediately before merge. The matrix now covers the spellings real code writes, all measured: a nullable generic, a bounded wildcard, a raw type, a nested generic and a multi-argument one. None needed work beyond the shared lookup, which is the evidence that base-name erasure is the right primitive. The C++ specialization control now asserts what it was written for: `Vec<bool>.save` and `Vec.save` are DIFFERENT target ids, so the arity/token match still wins over erasure. scope-capture is byte-identical for cpp and c, so no rebaseline — the bench corpus contains no generic-typed member field, which is worth its own coverage issue. Two pre-existing gaps were measured and are deliberately NOT fixed here, because in both cases the language's own non-generic CONTROL row fails identically: C++ `this->field.m()` emits nothing, and JavaScript/PHP docblock-declared field types bind nothing at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(python): do not reduce containers or typing special forms to a base name (#2833) Review finding on this branch's own Python change, caught by probing the interpreter directly rather than by reading it. The base-name reduction was reached by FALLTHROUGH: "neither container rule matched" was treated as "not a container". It is not, and two measured shapes proved it: dict[str, list[User]] -> dict (was: the annotation, intact) Dict[str, Repo[User]] -> Dict Callable[[int], User] -> Callable Literal["a"] -> Literal Union[A, B] -> Union tuple[int, ...] -> tuple The dict rule's value group cannot span a nested `]`, so a nested value declines and falls through — and the dict rule's own comment says that shape is deliberately "left for a downstream strip pass". Collapsing it to `dict` destroyed the value type instead. The typing SPECIAL FORMS are worse: `Callable`, `Literal`, `Annotated` and `Union` are not classes, and reducing them to a bare name binds any workspace class that happens to share it — a fabricated edge, which is strictly worse than the missing edge #2833 set out to fix, and those names are ordinary enough for a real codebase to declare. Reduction is now guarded by an explicit deny set covering the containers the two allow-lists already own and the typing special forms. Everything named there keeps its as-written text and resolves exactly as it did before #2833. `arr[0]` also reduces to `arr` in isolation, but that is unreachable and is now documented as such: every Python `@type-binding.type` capture is a `(type)`, `(identifier)`, `(attribute)` or `(dotted_name)` node, so a subscripted VALUE expression never reaches the interpreter. Pinned by a new unit test that asserts all four groups — user generic reduces, container reduces to its ELEMENT, declined container shape stays intact, special form untouched. Reverting the deny set fails three of its five cases. Also corrects `resolveClassBindingForName`'s docstring, which this branch had made false: it claimed only `classifyReceiverOrigin` passes the decoration stripper, while the three receiver-typing lookups in compound-receiver.ts now pass it too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(resolution): rank base-name candidates lexically and refuse arg-pinned defs (#2833) Review of #2855 found that this PR turned a MISSING C++ edge into a CONFIDENTLY WRONG one — the direction this subsystem calls unrecoverable. `resolveClassBindingForName` ended with an unguarded base-name fallback that returned the first same-named class the scope chain reached. A C++ primary template carries `templateArguments === undefined`, so it can never satisfy the exact-args branch, and every non-specialized instantiation fell through to that fallback. Measured through the real pipeline: with the primary forward-declared and the specialization defined first, `Vec<int> vi; vi.save()` emitted `Vec<bool>::save`. Declaring the primary first gave the correct target — selection was SOURCE-ORDER DEPENDENT. Two more triggers behaved the same way: a partial specialization (`Vec<int*>` against `Vec<T*>`), and lexical shadowing between a global `Box<bool>` and a namespaced `N::Box<bool>`. Two changes, neither of which is any of the three remediations the review proposed — each was rejected on measured evidence: - Exact-argument matching is now LEXICAL-FIRST. Candidates come from the scope chain, and the workspace-wide qualified-name bucket is consulted only when the chain produced no exact match, so cross-file specializations still bind. - The base-name route refuses a definition that pinned its own template arguments: if the fallback's answer carries `templateArguments`, the visible candidates are re-decided with those removed — exactly one, or decline. Why not the filed options. "If specializations exist and none matches exactly, return undefined" deletes a green committed row (`neg-cpp-specialization/runInt` legitimately resolves to the primary). "Resolve all defs for the base name, return only on exactly one" deletes a working edge for C# `partial class Repo<T>` split across files — two unspecialized defs under one name is legitimate, and `QualifiedNameIndex`'s own docstring names that case. Preferring the primary alone fixes nothing about shadowing, which is a ranking bug. The guard is expressed as `carriesOwnTemplateArguments`, not as "specialization", so shared pipeline code still names no language (AGENTS.md R6). It can only fire where a declared name carries concrete arguments — measured `undefined` for `class Repo<T>` in TypeScript and C# and for a C++ primary template — so the blast radius is bounded to C++-style specializations. Partial-specialization SELECTION is deliberately not implemented: choosing `Vec<T*>` for `Vec<int*>` needs template-argument deduction, which is a semantics expansion and cannot live in language-neutral shared code. The source-order dependence is what is fixed; the answer is now deterministically the primary. Also in this commit: dropped an unreachable `?? []` (QualifiedNameIndex returns a frozen empty array on miss by contract) whose comment was wrong on both clauses; made the docstring true about argument ERASURE being what widens what binds, rather than only the decoration stripper; and corrected a stale pointer that still placed `resolveClassBindingForName` in `receiver-bound-calls`. `findClassBindingInScope` itself is untouched — 38 call sites, CRITICAL. Verified: matrix 56/56, cpp.test.ts 334, unit scope-resolution 1505. Mutation proof: reverting this file fails the three trigger cases and passes the non-regression cases; restoring it passes all five. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(python): close the deny-set drift axis by case-folding, not by vigilance (#2833) Review of #2855 found `NOT_A_USER_GENERIC` was a closed list over an open universe: four review lanes each escaped it with a DIFFERENT set of names. `Deque` was the sharpest — its lowercase twin `deque` was already listed, so the omission was an internal inconsistency rather than a judgement call, and with a workspace `class Deque` present `self.dq: Deque[User]` fabricated a `Deque.appendleft` edge. The structural cause is PEP 585: nearly every container has two spellings differing only in case (`deque`/`typing.Deque`, `frozenset`/`FrozenSet`). Exact matching forced every pair to be listed twice, so any half-pair was a silent escape. The deny lookup is now CASE-FOLDED, which closes that axis by construction — `Deque` becomes impossible rather than remembered. `SINGLE_ARG_CONTAINERS` and `MAPPING_CONTAINERS` are now the single source of truth: they build the two container regexes (verified byte-identical `.source` and `.flags`, so zero behaviour change) and feed the property test. The deny set is re-scoped to a closed, auditable universe — the documented Python stdlib type-system surface — and grew 39 -> 65 concepts: the `collections.abc` views, `contextlib` managers, `re.Pattern`/`Match`, the `IO` family, ordinary-named stdlib generics (`Queue`, `Task`, `Future`, `PathLike`), the remaining typing special forms, and the generic machinery (`Generic`, `Protocol`, `TypeVar`...). Third-party generics (`Mapped`, `QuerySet`, `Model`) are deliberately NOT added and are pinned as a decision: that universe is open, enumerating it only chases the last escape, and declining `Model` would cost real edges in the many projects that declare one. The review's suggested property test — derive the names from the `single`/`dict` regex sources — would NOT have caught `Deque`: `deque` appears in neither regex, only in the deny set. Both properties are implemented, since they catch different drift. The unit test was also TAUTOLOGICAL: it asserted members OF the deny set, so it structurally could not detect an omission. It now asserts case-fold closure and PEP 585 alias coverage, and the capture fixture drops its `as unknown as` cast for the fully-typed helper pattern the sibling `java-interpret.test.ts` already uses. Still at interpret time, so no further SCHEMA_BUMP (already 45 -> 46). Proving the base is a class the FILE can see — the real fix for the remaining exposure, since `findClassBindingInScope` binds any name with exactly one workspace def regardless of scope or imports — is a follow-up, not reachable from this file. Mutation proof: restoring HEAD's deny-set contents and exact-match lookup fails four assertions including the `Deque` pair, with the pre-existing guard rows still passing; restoring gives 125/125. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(cpp): capture qualified generic member fields, and make the bench gate see them (#2833) Review of #2855 found that the three `field_declaration` rules this PR added only matched a DIRECT `template_type`, so the common real-world spelling still bound nothing: `std::vector<Item> items;`, `ns::Repo<User> r;` and `std::unique_ptr<Repo> p;` parse as a `qualified_identifier` WRAPPING a `template_type`. "C++ fixed" was overstated. Six new patterns — three declarator shapes (plain, pointer, reference) by two qualifier depths — written as separate patterns rather than one alternation, keeping the tree-sitter 0.21 field-position discipline the existing rules follow. The design choice was measured, not assumed. Codex suggested preserving the full qualified spelling and normalizing `::`; preserving resolves NOTHING, because `findClassBindingInScope`'s dotted-tail fallback splits on `.` while C++ writes `::`, and `ns::Repo` is not an index key either (C++ emits no `@declaration.qualified_name`). Measured: `ns::Repo<User>` resolves to nothing, `ns.Repo<User>` resolves to `Repo`. Since a tree-sitter capture is a NODE and not synthesized text, the only lever is which node to capture — so `@type-binding.type` goes on the INNER `template_type`, dropping the qualifier and landing on the same single-match-or-decline path the bare spelling already takes. Qualifier depth 3+ (`a:🅱️:c::Repo<User>`) remains uncaptured. Stated as a limit and pinned by a test row, not claimed as fixed. The bench blindness the review identified is also closed. The `scope-capture` C++ corpus contained ZERO template-typed member fields — confirmed a fourth way by applying six demonstrably behaviour-changing patterns and getting a byte-identical fingerprint. The corpus now carries generic and qualified-generic members, and the gate is load bearing for the first time: three states that all hashed to 856d02f3 before now differ (pre-#2833 0e7cbda7, +this PR's 3 rules de07d8b5, +these 6 rules bd47c82d). Rebaselined for cpp only; c is unchanged. Histogram diff: only 5 tags move with the fields, each by exactly +40 (20 entities x 2), and every `@reference.*` count is unchanged. Over-match is preserved: 20 shapes still produce no field capture, including the 8 original method/pointer/reference/function-pointer/ using/typedef/friend/operator forms plus their `std::`- and `a:🅱️:`-qualified variants. Not fixed here, deliberately: NON-generic qualified fields (`ns::Address addr;`, `std::string name;`) still capture nothing. Closing that needs six more patterns and would newly bind every `std::string`/`std::mutex` member repo-wide, changing edges far outside #2833. Separate issue. The template-template-parameter hazard the review filed against these rules is NOT capture-side: a tree-sitter query has no scope knowledge, so it cannot know `Map` is bound by the enclosing `template <...>` header, and the PRE-EXISTING `type: (type_identifier)` rule already captures a bare `T item;` and erases it the same way. It is handled by the lexical ranking in `walkers.ts` in this series. Mutation proof: reverting this file fails 9 of 32 assertions (all eight qualified spellings return no capture) while every over-match negative still passes; restoring gives ALL PASS. Bench `--check` passes for all 15 languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * test(resolution): pin specialization order, shadowing and the untested spellings (#2833) Grows the generic-field matrix 56 -> 114 tests, closing every coverage gap the #2855 review named and turning the fix-agents' scratch evidence into permanent rows. The rows that discriminate against the resolver fix (they fail if `walkers.ts` is reverted): - C++ specialization must not depend on DECLARATION ORDER: the forward-declared-primary/specialization-first arrangement must land on the primary, same as the mirror arrangement. Plus a cross-case property asserting the two independently built fixtures agree. - Partial specialization is deterministic in both orders. The note says explicitly that selecting `Vec<T*>` would need argument deduction and that flipping this row later is a deliberate expansion, not a regression fix. - Lexical shadowing: the namespace-local `N::Box<bool>` wins for a field inside `N`, and the global specialization wins at global scope. The NON-REGRESSION rows are load-bearing — they are why two of the three proposed remediations were rejected: cross-file C++ specialization binding, and C# `partial class Repo<T>` split across two files with the field in a third (two legitimate unspecialized defs under one name). Coverage the review found missing: C++ pointer and reference generic fields (two of this PR's three original rules had ZERO coverage); all six qualified patterns plus the depth-3 boundary pinned as empty; TS/C# multi-arg container collision; an anti-vacuity sibling for `neg-bounded-type-parameter`; Swift/Dart rows restructured so the ANNOTATION is the only possible source (the old rows gave the field an initializer of the same generic type and could not tell which resolved); and cross-file, inheritance/MRO, import-alias, static-member and the TypeScript module-hoist branch. Six things were measured and pinned AS MEASURED rather than asserted as wishes, each flagged in its row note: a static/class-level member emits nothing for generic AND non-generic alike (a static gap, not a generics one); a cross-file C++ primary template does not bind while the cross-file specialization does; `std::unique_ptr<Payload>` types to `unique_ptr` rather than `Payload` (smart-pointer transparency is not applied on the qualified path); two same-named C++ specializations in one file collapse to one node id; and the container-name collision (`Map<string, User>` binding a workspace `class Map`) is recorded as INTENDED, since the annotation does name that class. The `new Set(...)` dedup was kept rather than narrowed: a per-case surplus-edge sweep measured ZERO duplicate edges anywhere in this file, Swift included, so the quirk that justified a blanket dedup does not reproduce. The sweep now pins zero surplus per case, so a real double-emit fails instead of being absorbed. The file is deliberately NOT split: four assertions compare cases against each other, cost is linear in cases, and the 1,800,000 ms `beforeAll` is kept because the same run measured 271-428 s depending on host load — a tighter bound converts contention into a red suite. The reasoning is recorded in the file header. Also corrects the SCHEMA_BUMP pin-test title, which still said (#2766). Mutation proof: reverting `walkers.ts` fails exactly the five order and shadowing assertions and passes the other 109; restoring gives 114/114. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * feat(resolution): capture declared type parameters so a type variable is not a class (#2833) Three review findings were blocked on one missing fact. `templateArguments` records the arguments a declaration was written AGAINST (`struct Vec<bool>`); nothing recorded the parameter list a declaration DECLARES (`template <class T>`, `class Box<T extends Repo>`). So the resolver could not tell a type variable from a class, and: - `class Box2<T> { t: T }` beside a workspace `class T` emitted a FALSE edge `run2 -> T.foo`. `T` carries no type arguments, so it never entered the generic branch — the plain lookup simply bound a same-named class. The lexical grounding added elsewhere in this series cannot help, because `export class T` IS lexically bound. - `class Box<T extends Repo> { t: T }` resolved to nothing: no recorded bound to resolve through. - A full specialization `template<> struct Vec<T*>` and a partial `template<class T> struct Vec<T*>` were byte-identical (`['T*']`). `SymbolDefinition.typeParameters` now records `{ name, bound? }` in declaration order (substitution is positional). `bound` is kept verbatim and un-split, so `Repo & Closeable` stays whole; ABSENT means UNKNOWN, never "unbounded", which is what keeps unconverted languages behaving exactly as before. Transport is the raw parameter-list node via `@declaration.type-parameters`, read by a language-neutral parser that recognizes TOKENS, not languages: `extends`/`:` introduce a bound, the name is the trailing identifier, so `class T`, `typename T`, `in T`, `out T`, `reified T` and `class... Ts` are one rule. Populated for TypeScript, C++, Java, Kotlin, C# and Rust. JavaScript, C, COBOL, PHP and Ruby have no declared type parameters to capture; Go and Python spell them with SQUARE brackets, which this parser deliberately rejects as ambiguous against subscript and array spellings (Go already has a working main-thread sidecar in this series); Dart and Swift are straightforward follow-ups. Two latent hazards found and closed on the way: - The new capture was not in `KNOWN_SUB_TAGS`, so it could out-span its own declaration and become the anchor — silently DROPPING the whole class def. - A templated C++ struct matches both the standalone and `template_declaration` patterns, minting two defs under one id, and only one twin could see the parameter list. `buildDefIndex` is first-write-wins, so MATCH ORDER decided whether `Vec` remembered `T`. A narrow duplicate-declaration backfill gives both twins the list. Also fixed by its own test: a Rust lifetime `'a` parsed as a parameter named `a`, which would have shadowed a real class. Parse-time output lands in the cached ParsedFile, so SCHEMA_BUMP goes 46 -> 47. Re-checked against origin/main at write time: main is on 45; 46 was taken by this same branch, and a warm cache stamped 46 carries ParsedFiles with no `typeParameters` at all. The csharp and rust capture goldens were regenerated with the tests' own documented `UPDATE_GOLDEN=1`; only digests moved, no captureGroups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(resolution): ground erased base names, and stop a class name from being enough (#2833) The review's central risk was that this PR converts MISSING edges into CONFIDENTLY WRONG ones. Base-name erasure (`Repo<User>` -> `Repo`, `Repo[User]` -> `Repo`) bound through a workspace-wide qualified-name fallback that consults NO scope, NO import and NO module — it bound any name with exactly one workspace def. That is why a Python `Mapped[User]` could bind an unrelated `class Mapped`, and why the language deny lists were papering over an open universe. `resolveErasedBaseName` now admits an erased base on one of four grounds, strongest first: the scope chain binds it; the declaration is in the SAME FILE; the index proves the name is a template family; or the file binds no cross-file class at all, so its silence is no evidence. The last ground fails toward permissive on purpose — every way it can be wrong costs a wrong edge that already existed, never a working one. Two measurements drove that design and refuted the simpler rule. A C++ `#include` materializes NO binding whatever, and C# resolves cross-namespace without `using` through the index — so a pure "require lexical grounding" rule would have deleted every cross-file C++ generic member. Both are now pinned. Python erases at CAPTURE time, so by resolution there is no `<` and the grounded route was never entered. `erasedTypeApplication` rebuilds the application from `TypeRef.declaredSpelling` — strictly: the raw name must be the base and the argument list the whole balanced remainder, so `User[]`, `vector<Item>` and `Repo<User>?` decline and behave exactly as before. Closing it took finding FOUR emitters, not one. Three were in Case 4; the fourth was `emitReferencesViaLookup` re-emitting the refused edge from the pre-resolved reference index, which needed the site marked handled with a recorded `receiver-unresolved`. A fifth lived in the text cascade: a declined fold falls THROUGH by design, and the cascade held its own ungrounded copy of the member-typing lookup. This file typed a receiver from a `TypeRef` in five places and the PR had wired three; all five now go through one `classOfDeclaredType`. Also here, from the same review: - Type parameters no longer bind a same-named class (uses the new `typeParameters`), and a BOUNDED parameter resolves through its bound. - A cross-file C++ PRIMARY template now binds: a ranking bug, not a capture one — the index fallback needs exactly one candidate and `Vec` held two, so removing the argument-pinned declaration leaves one. - `this->field.m()` resolved to nothing for generic AND non-generic alike. A language that declares `this` IS the enclosing class (`resolveThisViaEnclosingClass`) synthesizes no `this` typeBinding, so a chain whose BASE is `this` could never seed its head. Reading the provider flag keeps the rule language-free. - Class-level (static) member receivers emit nothing in TypeScript and Kotlin — for the non-generic control too. Case 6 types them from the DEF side (`isStatic` + `declaredType` on the field node), which needs no capture change; the target lookup stays the ordinary instance walk, so a static field HOLDING an instance still binds an instance method and a genuine static call is untouched. Partial-specialization SELECTION is deliberately not implemented: it needs argument deduction against a parameter list, and full C++ partial ordering is a real algorithm with no measured driving case. The discriminator now exists if someone wants it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * fix(cpp,js,php,go): close the remaining per-language generic-field gaps (#2833) Four language gaps the review measured, each with a different cause. **C++ qualified member fields.** `std::vector<Item> items;`, `ns::Repo<User> r;` and `ns::Address addr;` captured NOTHING: every field rule required the type node to BE a `type_identifier` or `template_type`, and a qualified member type is neither — tree-sitter wraps both in a `qualified_identifier`. Three depth-agnostic rules (one per declarator shape) now match the outer node, which also REMOVES the depth boundary rather than raising it: depths 1-4 capture, generic and non-generic alike. Preserving the qualifier resolves nothing — measured: `ns::Repo<User>` binds neither way, because the dotted-tail fallback splits on `.` while C++ writes `::`, and `ns::Repo` is not an index key. Since a capture is a NODE and not synthesized text, the qualifier is dropped in `interpret.ts` by a top-level-only `::` split, so `std::vector<std::string>` reduces to `vector<std::string>`, not `string`. Measured cost of the non-generic half, which was the reason to hesitate: field captures go 8 -> 32 across the C++ bench corpus, but the resolution-level census over those 13 repos is 32 CALLS edges before and 32 after, BYTE-IDENTICAL. It fabricates only where a workspace class shares a std name (`class string` beside `std::string name;`), which is the same accepted policy the already-landed qualified-generic rules carry, pinned in the matrix as intended. **JavaScript `@type {Repo<User>}` and PHP `@var Repo<User>`.** Neither bound a field type — and neither did the NON-generic control, so this was a docblock gap rather than a generics one. PHP needed TWO captures, not one: with only the type binding, `$this->repo->save()` resolved until a second class declared `save` and then went unresolved, because narrowing a same-named method needs the receiver's member owned. Generics do NOT come free in PHP — `normalizePhpType('Repo<User>')` returns `'User'` by the container-element convention, so passing the raw spelling through would have emitted `User::save`; type arguments are erased at capture instead. In JavaScript they DO come free, verified byte-identical to the TypeScript control. Both decline what they cannot prove: arrays, `list<User>`, unions, `Promise`/`Array` wrappers (via an exported predicate rather than a copied name list), statics, and any property that already has a native type. **Go generic interfaces.** `UserRepo` genuinely DOES implement `Repo[User]` — the spec says a generic type must be instantiated, that instantiation substitutes type arguments and yields a new non-generic type, and that a type implements an interface when it is in its type set. So the old behaviour was a FALSE NEGATIVE and the matrix note calling it "already correct" was wrong. Satisfaction is now checked against POSITIONALLY SUBSTITUTED method sets, so `Repo[Order]` does not match a `Save(x User)` implementor — substitution, not erasure. #2829's exact method-set model is untouched: pointer receivers still follow MS(*T), unexported names stay package-scoped, the declaration's own method set is still checked first, and the harvest is gated so a repo with no generic interface never runs it. `go.test.ts` is unchanged at 296 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * test(resolution): pin every fix from the review, 114 -> 155 rows (#2833) Eight rows in this matrix pinned gaps that the fixes in this series close, so each asserted the opposite of the new truth. All eight are flipped, and the prose describing them as open gaps is corrected. Nine new cases cover the fixes that would otherwise have shipped unpinned. Flipped, each measured: the type-parameter FALSE edge (`run2`) is gone; a bounded parameter now resolves through its bound with fan-out; the cross-file C++ primary binds; the C++ qualifier depth boundary is removed rather than raised; Go gains its two structural implementors and JOINS the paired sweep, which had quietly excluded it — that exclusion was the taxonomy admitting a bug; and both static-member rows resolve. Added: JS `@type` and PHP `@var` docblock fields with three PHP declines; a Kotlin `companion object` receiver (given an INTERFACE control so the paired sweep can check it, which `ts-reach-shapes` cannot — its two sides are not count-comparable); the Python third-party grounding refusal plus the ground that still ADMITS, so an empty row can never be read as "erased names never resolve"; the four mirrors that would break if grounding were tightened (same-file and imported Python, a C++ `#include`, C# cross-namespace without `using`); C++ qualified non-generic fields including the fabrication policy and its absence case; `this->field.m()` for generic and non-generic with bare controls; and a Go negative proving substitution is positional, not erasure. Three shapes are pinned AS MEASURED with notes saying they are deliberate limits so nobody "fixes" them by accident: C++ partial-specialization selection is deterministically the primary (real selection needs argument deduction); `std::unique_ptr<T>` types to the pointer, not the pointee (`.` and `->` are indistinguishable to the resolver, so transparency would trade a recoverable miss for a confident wrong edge); and two same-named C++ specializations in one file collapse to one node id, which is why the shadowing fixture uses two files. One row pins a REMAINING wrong edge rather than hiding it: `m.inner.ping()` on a `Mapped[User]` head still binds the unrelated workspace class, while the one-segment-shallower `m.save(u)` correctly declines. The obvious one-line guard was written and MEASURED not to close it, so the surviving route is elsewhere and wants its own diagnosis — a broader refusal would change chain-head resolution for every language without pinning the shape it is meant to fix. `bench/scope-capture` is rebaselined for the six languages whose captures moved, regenerated from a fresh measurement rather than pasted; `--check` passes for all 15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * perf(resolution): remove three measured hot-path regressions this series added (#2833) A quality pass over the #2833 series found three performance defects it had introduced, all measured, plus dead code and stale docs from six agents having appended to the same files across four rounds. No behaviour change: the resolver suite is identical before and after, and every scope-capture fingerprint is byte-identical. **An accidental quadratic in Go instantiation harvesting.** `collectGoInstantiations` calls `record()` for every type binding and every declared, return and parameter type in every Go file, and the `includes('[')` gate does not filter Go's most common types — `map[string]string`, `[]map[string]*v1.Pod` and `map[string]map[string]int` all produce a `map` candidate. Each false base then failed a full scope-chain walk and fell through to a LINEAR SCAN OF EVERY INTERFACE IN THE PROGRAM, with no dedupe on the spelling, so the same `map[string]string` written 10,000 times paid 10,000 scans. Now a qualified-name index built in `buildDetectionIndexes` (one probe, ambiguity semantics preserved exactly) plus a per-scope base memo: 8,000 interfaces / 80,000 spellings: 6,662 ms -> 104 ms (64x) `resolveEmbeddedInterface` held a byte-identical copy of that scan and now shares the helper. `GoInstantiation` was a single-field wrapper and collapses to the array it wrapped; its two parallel maps fold into one whose inner key IS the dedupe. `candidateStructIdsFor` was rebuilt per instantiation although every substituted method set has the same key set — hoisted, and materialized, because one branch returned a live iterator that would have yielded nothing on a second pass. **`scanForCrossFileClass` asked a name-keyed question that needs no name key.** It answered "does this file bind any cross-file class" by probing every accessible namespace once PER NAME. It now iterates the channels directly, taking whichever side is smaller so a large namespace table cannot reintroduce the product. Predicate and early exit preserved: 5,000 module names x 1,000 namespaces: 159.0 ms -> 1.2 ms (132x) **A duplicated scope walk on every generic receiver.** `resolveClassBindingForName` computed the lexical candidate list, then `resolveErasedBaseName` recomputed the identical `findAllBindingsInScope`. Computed once and passed: receiver at depth 8: 5,617 ns -> 3,091 ns (-45%) **A whole extra AST traversal per JavaScript and PHP file.** The docblock synthesis passes each added a full tree walk to find one node kind — the ninth in the JS emitter, the third in PHP. `node.namedChildren` materializes a wrapper array across the N-API boundary for every node, so one added pass cost 1.9x what parsing the entire file costs. Folded into the existing walks as one more node kind; capture output is byte-identical and every fingerprint is unchanged. Total emit time per file drops 4-7%. Hygiene, all verified stale rather than assumed: - `receiverOriginOpts` passed `resolveThisViaEnclosingClass`, which `classifyReceiverOrigin` never reads — the "both hooks" comment above it is true again. - The `stripDecoration` docstring's caller roll-call claimed the only edge-emitting caller "emits no edge and can only change a diagnostic label". Case 6 passes it and does emit edges. Replaced the roll-call with the rule; six rounds each appending a name to a list is how it went wrong. - A Python comment described the resolution-time grounding as a follow-up that "this parse-time pass cannot do" — it landed in this same branch and is pinned by `py-erased-grounding`. - `classOfDeclaredType` took a `scopeId` all five callers derived from the `TypeRef` they also passed. Dropped, so "these five are the same call" is enforced rather than asserted. - Three exports with no consumer outside their own file. - PHP had three copies of one preceding-comment sibling walk and two regexes for one tag, so a fix to either reader of `@var` would land on one and not the other — the symptom being a field typed differently from its own foreach element type. One walk, one regex. Tests: the new matrix leaked a fixture repo per case; it now carries the sibling suite's `cleanupTempDirSync` and the Windows EBUSY reasoning that goes with it. `PAIRED` was a second hand-maintained list and 19 of 41 cases had silently fallen out of it — it is derived from the cases now, with a new assertion that each case is either swept as a pair or carries a written reason it is not. That recovered one genuine omission (`php-typed-property`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp * test(bench): rebaseline receiver-resolution for the #2833 this-> fix The `Receiver-resolution drop guards` CI step failed on this branch: shapeArm.cpp.fieldReceiverCall: "INVISIBLE-GAP" -> "RESOLVES" shapeArm.cpp.decoratedFieldType: "INVISIBLE-GAP" -> "RESOLVES" Both are the intended improvement. The guard is exact-match by design — the drop count cannot move without a deliberate rebaseline, and the rebaseline path demands the movement be explained — so this records the two shape flips and leaves the call-drop count arm untouched. BASELINE.md still claimed `this->repo.save()` and `this->repo->save()` were INVISIBLE-GAP. That is now false: the `resolveThisViaEnclosingClass` head seed added in this PR resolves both. Also notes what the control established — this was never a generics gap, since the non-generic control failed identically before the fix. * docs(parse-cache): narrow the SCHEMA_BUMP ledger to what the bump delivers The ledger claimed a warm cache would make "the whole fix ... a silent no-op on every incremental analyze". That overstates the constant. The bump invalidates the PARSE half; whether the re-parsed captures reach the graph is gated separately and does not move: - `isIncremental` (core/run-analyze.ts) tests `!options.force`, an existing meta, `!schemaFingerprintMismatch(...)`, feature parity, non-empty `fileHashes` and a git repo. SCHEMA_BUMP is in none of them. - the incremental branch writes back only `hashDiff.toWrite` and logs the rest as "unchanged file rows preserved". - SCHEMA_FINGERPRINT hashes node/relation DDL, untouched here, so it is byte-identical and moves nothing either. So an incremental analyze re-parses an unchanged file correctly but keeps its existing rows; the new edges land on the next full rebuild. That is the pre-existing contract for every capture change, not a regression in this PR — but the comment should not promise more than it delivers. Comment only; no behavior change. SCHEMA_BUMP stays 48. --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
42 KiB
Receiver-resolution baseline
baseline.jsonis the source of truth for every number. It is whatmeasure.mjs --checkenforces byte-exactly. This file is a lab notebook: each section records what was measured AT THAT UNIT and why it changed the plan. A figure here that disagrees withbaseline.jsonis a superseded snapshot, not a live claim — sections carry a snapshot marker where that has already happened. Never quote a count from this file into code, a gate, or a commit message; read it frombaseline.json.
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 |
44 | 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 |
36 | Yes in principle — but see below. |
unknown |
22 | Yes. Casts, ternaries, globalThis.x ??= [], and everything the classifier will not guess about. |
These numbers moved once, in review, and the movement is the point. They were first measured as 76 / 20 / 6, when
externalwas the FALLTHROUGH: any base whose type did not resolve was called external. Review reproduced two triggers where that publishedepistemic: 'exact'over a real in-program loss — a Go pointer receiver (*Host, whose lookup was missing the decoration stripper) and any base with no type binding at all, including this branch's owndroppedCall(svc)fixture.externalis now a POSITIVE determination viaLanguageProvider.isBuiltInName, and everything unproven isunknown, which still hedges. Soexternalfell 76 -> 44 and the difference went toin-program(+16, the drops that really were ours) andunknown(+16, the drops we decline to characterize). Total call drops is unchanged at 102 — this is re-bucketing, not resolution.A controlled A/B over the Java built-in set (off vs on, same tree) reads 7/36/59 vs 44/36/22:
in-programis byte-identical across the toggle, so naming built-ins reclassified nothing the index can demonstrate is ours.
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 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 unknown bucket — 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 the stdlib calls the old fallthrough swept
into external.
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
call-position exclusion. So h.dep.Work() minted three reference sites:
| site | kind | name | what it is |
|---|---|---|---|
| S1 | call |
Work |
the member call |
| S2 | read |
Work |
phantom — the callee h.dep.Work, already captured by S1 |
| S3 | read |
dep |
the genuine field read |
S2 resolved through findOwnedMember, which prefers methods over fields, and
emitted an ACCESSES edge to the method duplicating S1's CALLS edge at the
same position.
The U8 assertion passed by accident. It asserted RunSamePackage → Work was
absent from ACCESSES, and it was — but only because that row has a pointer
receiver whose text-cascade head lookup failed for an unrelated reason. The
value-receiver twin was emitting the bad edge the whole time:
ACCESSES RunFromValueReceiver -> DoWork:Method <- phantom, shipped
ACCESSES RunLocal -> DoWork:Method <- phantom, shipped
First fix, at capture: drop the match outright, on the rule "a selector in
function position is never a read." That rule is false, and review caught
it. In Go a func-typed struct field IS read and then called indirectly —
h.dep.Work() where Work func() error — and isCalleeOfMemberCall cannot
tell a method from a func-valued field, because the AST shape is identical.
Dropping at capture therefore deleted the only ACCESSES evidence for callback
structs, hook structs and hand-rolled mocks (mock.DoFunc, opts.OnEvent).
Second fix, and the one that shipped: split the decision across the two layers
that each hold half of it. Capture records the POSITION as a fact
(@reference.callee-position → ReferenceSite.inCalleePosition) — only the AST
knows it, and it is gone by resolution time. Emit makes the DECISION from the
resolved target's kind — only resolution knows whether the tail is a method or a
field, and it may be declared in another package. Neither layer can answer alone.
The suppression is language-neutral in graph-bridge/edges.ts and keys on the
canonical CALL_TARGET_TYPES, so Macro and Delegate targets are covered too.
A method value (f := h.dep.Work) is not in function position and is
untouched. The assertion is backed by an exact-set check over the whole fixture —
now carrying target KINDS, so it catches both a new phantom and a deleted
genuine read.
What the numbers say
callDropsunchanged at 102 — no call was lost, in either fix.readdrops went 27 → 22 under the capture-time drop, then 22 → 27 again once the marker replaced it. The round trip is the finding: those five sites are genuine field reads, and the first fix was scoring their deletion as an improvement.totalDropsAllKinds124 → 129, the same five sites.- One drop reclassified
chain-field→chain-unwrap. The phantom and the real call share a site key, so the phantom's field-shaped chain was previously the one recorded. The census now describes the actual dropped call.
Caught by three review agents dispatched at the A1 regression; the phantom was the mechanism, not the global-normalization story the first revert note asserted. The func-field regression it introduced was then caught by two more, on the tri-review of #2782 — which is the argument for the exact-set-with-kinds assertion over the targeted one that passed by accident the first time.
U9 (part 2) — no drop ratchet is needed; the gate is already stronger
The plan's R10 set a ZERO supported-shape drop target, and review correctly found that it contradicts R12: a site whose normalized name matches more than one class MUST decline, a decline records a drop, and simple names collide routinely in large Go and Java codebases. The proposed fix was a ratchet — the count may not rise above the value measured after the last unit.
Neither is needed. measure.mjs --check already asserts exact match against
the committed baseline, which is strictly stronger than a ratchet: the count
cannot rise or fall without a deliberate --update-baseline, and that path
prints an instruction to explain the movement in the commit message. A ratchet
would be a weakening.
So R10 as written (zero) was wrong, and the ratchet proposed to repair it is
redundant. The existing gate stands, now also covering callDropsByShape since
the shape census joined the gated projection.
Deferred and NOT done: the impact risk-cutoff recalibration. Review flagged
that added edges push symbols toward the absolute cutoffs (directCount >= 30,
impacted.length >= 200), so edits read HIGHER risk without being more
dangerous, and agents warning on HIGH/CRITICAL escalate more often. That is real,
but measuring it honestly needs a before/after risk distribution over a corpus
large enough for those thresholds to bind — the committed fixtures are nowhere
near 200 impacted symbols. Recording it as owed rather than inventing a number
from fixtures that cannot exercise the cutoffs.
U6 — the depth cap does NOT limit resolution. Measured, not raised.
The premise was that a chain deeper than MAX_CHAIN_DEPTH (3) 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
specifically to make the question answerable — without a chain longer than the
cap, raising the cap measures nothing:
root.getSvc().getUser().address.getCity().save();
// ^step1 ^step2 ^step3 ^step4 receiver of `save` = 4 steps
| Cap | Chain minted? | Cell state |
|---|---|---|
| 3 | none (confirmed by probing the emitter directly) | RESOLVES |
| 4 | 2|root|cgetSvc|cgetUser|faddress|cgetCity |
RESOLVES |
The site resolves at BOTH depths. At 3 it resolves through the text cascade,
which 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 without changing a
single edge — measured 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 instead of the intuition.
What DID need fixing: unwrapTransparentReceiver shared 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 have silently widened the paren peel as a side effect. That
coupling got worse when the await/subscript work added a peel call at loop
entry. Now MAX_TRANSPARENT_WRAPPER_DEPTH, its own constant.
U9 — the epistemic hedge has TWO producers, and only one is a defect
impact reports epistemic: 'lower-bound' for two independent reasons that were
previously indistinguishable in the output:
| Cause | Unit | What it means | Is it a defect? |
|---|---|---|---|
receiverTyping |
call sites | Call sites dropped because the analyzer could not type the receiver | Yes — a resolver gap. This is the population this whole series targets. |
dispatchBoundary |
symbols | The symbol sits behind an interface with real consumers or 2+ implementations; the number is the implementations plus interface-level consumers behind it | No — callers binding through DI or dynamic dispatch are genuinely untraceable statically. A compiler refuses here too. |
externalBoundary |
call sites | The call left the indexed program (System.out.println, fetch(...)) |
No, and not even a shortfall — there is no in-graph node an edge could have reached. An epistemic: 'exact' result can carry it. |
Both collapsed into one enum plus prose, so a consumer — especially a coding agent gating its own edits on the result — could tell THAT a count was short but not WHY, and could not branch on the difference. Worse, it made "the hedge should stop appearing" unfalsifiable: with no way to see which producer fired, there was no way to check whether fixing receiver typing had done anything.
impact and context now carry a structured causes: { receiverTyping, dispatchBoundary, externalBoundary } alongside the prose. Every field counts
MISSING THINGS, never notes: there is one note per symbol name (and one per
boundary node) but each reports N of something, so counting notes published 1
next to prose reading "2 call sites", and a consumer branching on the number
would have read a different magnitude than the human reading the text. The same
rule applies to dispatchBoundary, which counts the implementations plus
interface-level consumers behind the boundary rather than the boundary sentences
— one sentence can describe an interface with 40 implementations. Its unit is
SYMBOLS rather than call sites because per-site multiplicity is not retained on
those edges (consumers are counted DISTINCT, and
collapseMemberCallsByCallerTarget languages emit one CALLS edge per
caller/target pair); the units are stated per field on EpistemicCauses so a
consumer knows which it is holding.
Only the receiverTyping producer is addressed by this series. The dispatch
boundary is untouched and will keep firing for interface-dispatched symbols —
which is correct. Any claim that the hedge has "stopped appearing" has to be read
per-producer, and that is now possible.
Measured on the #2766 reproduction: WithTx went from impactedCount: 0 with a
lower-bound hedge to impactedCount: 1 with epistemic: exact. The hedge is
gone there because its cause is gone, not because it was suppressed.
U10 — recorded drops, censused by receiver shape
ResolutionOutcome's suppressed variant 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:
doing that would mean regex-classifying the number that gates this work, the
same textual-shape dispatch the structural-receiver line exists to remove.
Diagnostic only, so the persisted RepoMeta.unresolvedReceiverMembers artifact
is unchanged.
Census of the call drops on the committed fixture corpus, as measured at U10
— it predates the phantom-read fix documented above, which reclassified one drop
chain-field → chain-unwrap. callDropsByShape in baseline.json is current:
| Shape | Count | Share |
|---|---|---|
chain-field — every step a field (h.repo.save()) |
60 | 59% |
chain-call — every step a call (svc.getUser().save()) |
27 | 27% |
no-chain — no chain minted; the walk found no nameable base |
12 | 12% |
chain-mixed — interleaved (svc.getUser().addr.save()) |
2 | 2% |
Two decisions come out of it.
The .java bucket is not one defect. Its 49 call drops split 30 field-chain
/ 14 call-chain / 5 no-chain, so the open question of whether Java's largest-
single-bucket status hides a single cause is answered: it does not. It is the
same population as everywhere else, just more of it.
Field-receiver chains are where the remaining value is. At 59% of the U10
census they dominate, and they are precisely the shape U1 fixed for Go. The same
defect class in java, csharp, cpp, php, py and rust is the largest addressable
population the count arm can see. (This paragraph used to quote a per-extension ×
per-shape split from the U10 run. baseline.json carries callDropsByExtension
and callDropsByShape but not their cross-product, so that split has to be
re-derived from a fresh run rather than read off the committed baseline.)
What this census CANNOT justify. Await-wrapped and subscript receivers barely
appear, because the committed fixture corpus contains almost no such sites — not
because they are rare in real code. At U10 indexElement was a gap in every
language in the shape arm, so U5's population was real but structurally invisible
to the count arm. (It no longer is uniform — the subscript route resolves in
several languages now; read the current per-language state from indexElement in
baseline.json, not from this paragraph.) The durable point: any decision to fund
or drop U4 and U5 has to be read off the SHAPE arm, because reading it off this
census confuses "absent from these fixtures" with "does not happen".
U2 — shape matrix expanded to a canonical axis
The shape arm was three languages with an ad-hoc shape list each. It is now a
canonical 10-shape axis (SHAPE_IDS) that every language must answer for,
with two states added so a hole cannot masquerade as a measurement:
N/A— the grammar does not admit this spelling. A reason is required. An omitted cell and a genuinely inapplicable cell look identical in a diff otherwise, which is how coverage rots.GRAMMAR-UNAVAILABLE— the parser could not be loaded, so nothing was measured. Neither passes nor fails the gate, anddriftskips it on both sides so the gate cannot fail for the environment it ran in.tree-sitter-dart,-kotlinand-swiftare vendored optional grammars: absent when a run setsGITNEXUS_SKIP_OPTIONAL_GRAMMARS=1, and soft-failing when no vendored prebuild matches the host (the set covers darwin/linux arm64+x64 and win32-arm64 — a win32-x64 or musl host has none). All 14 load on a glibc linux-x64 host, so this state has no producer in the committed baseline — it guards the skip-flag and unsupported-host cases rather than a condition seen here.
assertMatrixComplete throws when a language omits a cell, declares an unknown
id, or writes an N/A with no reason. Languages may declare extraShapeIds for
diagnostics the canonical axis cannot express (PHP's annotated/unannotated
return-type pair, C++'s pointer/value base pair) — an extra must be declared, so
it stays a deliberate diagnostic rather than a typo'd canonical id.
Vue and COBOL are language-level N/A rows: their emitters never call
synthesizeReceiverChainCapture, so there is nothing to measure — but the
language axis now obeys the same no-omitted-cells rule as the shape axis.
What the first expanded run found
Three results that redirected the plan they were built to serve. Snapshot: the
first U2 run, before any of the fixes below landed — these cells state the
problem, and several have since flipped (baseline.json is current):
Go — the root cause, isolated to one cell. Three rows vary receiver decoration and field decoration independently:
| Cell | Receiver | Field | State |
|---|---|---|---|
fieldReceiverCall |
value | value | RESOLVES |
decoratedFieldType |
value | pointer | RESOLVES |
decoratedReceiverBase |
pointer | value | VISIBLE-GAP |
Only the pointer receiver fails. Go already normalizes field type bindings
through normalizeGoTypeName, so the step lookup is sound and the defect is
entirely the base — synthesizeGoReceiverBinding stores typeNode.text raw, so
func (h *Host) binds h to the literal *Host, which
findClassBindingInScope cannot resolve.
PHP — the sigil hypothesis is dead. The two rows differ only in whether the called method declares a return type:
| Cell | Return type | State |
|---|---|---|
arrowCallChain — $svc->getUser()->save() |
unannotated | INVISIBLE-GAP |
plainChain — $svc->getUserTyped()->save() |
annotated | RESOLVES |
Same chain, same ->, same base. PHP chains resolve when the return type is
declared; the $ sigil is not involved. decoratedFieldType (?User $repo)
also resolves, so PHP nullable field types already work.
C++ — the base already resolves, but this-> field receivers do not.
pointerArrowChain and valueDotChain both RESOLVE, so a decorated C++ base is
not a gap. this->repo.save() and this->repo->save() were both INVISIBLE-GAP
when this was written — a distinct defect, not a decoration one — and #2833
closed it: a language that declares this IS the enclosing class
(resolveThisViaEnclosingClass) synthesizes no this typeBinding anywhere, so
a chain whose BASE is this could never seed its head. It was never a generics
gap; the NON-generic control failed identically. C++'s fieldReceiverCall and
decoratedFieldType cells moved INVISIBLE-GAP -> RESOLVES with it.
Rust — the decorated receiver is NOT a gap. &mut self resolves, so Go is
the only language whose method receiver decoration defeats the lookup. Rust's
gap is the field: Box<User> is INVISIBLE-GAP.
The decoration cells, across all 14
The rows U1 exists to fix. Everything else is a different defect. Snapshot: as
measured at U2, i.e. BEFORE U1 landed — it is the statement of the problem, not
of the current state. Go's decoratedReceiverBase and TypeScript's
decoratedFieldType have since moved; baseline.json has the live cells.
| Language | decoratedReceiverBase |
decoratedFieldType |
|---|---|---|
| go | VISIBLE-GAP (*Host) |
RESOLVES |
| rust | RESOLVES (&mut self) |
INVISIBLE-GAP (Box<User>) |
| typescript | N/A | INVISIBLE-GAP (User | null) |
| csharp | N/A | VISIBLE-GAP (User?) |
| swift | N/A | INVISIBLE-GAP (User?) |
| cpp | N/A | INVISIBLE-GAP (User*) |
| python, php, kotlin, dart | N/A | RESOLVES |
| java, c, javascript, ruby | N/A | N/A |
So U1's measured scope is Go's receiver base, plus the field-type gap in Rust, TypeScript, C#, Swift and C++ — and not PHP, Python, Kotlin, Dart or Java, whose decoration handling already works or does not exist. Five of the seven hooks the plan speculatively listed were aimed at languages that need none; three languages that do need one were not on the list at all.
Other gaps this run surfaced, not in the plan
- Swift resolves almost nothing.
plainChain,plainDeepChain,optionalChainandnonNullAssertare all INVISIBLE-GAP, whilefieldReceiverCallresolves. Chained receivers are essentially unsupported. - Ruby chains are VISIBLE-GAPs (
plainChain,plainDeepChain,optionalChain) andfieldReceiverCallon@repois INVISIBLE. - C++
this->field receivers are INVISIBLE-GAP in both the value and pointer form. - C# has four gaps beyond the field one:
optionalChain,nonNullAssert,awaitParen,explicitTypeArgs. - Dart
awaitalready resolves — the only language whereawaitParenis green, which makes it the reference for U4's unwrap direction. indexElementwas INVISIBLE-GAP in all 14 at U2 — uniform, and exactly what U5 targets. (Superseded: several languages resolve it now; seebaseline.json.)
Coverage status
All 14 languages measured, plus vue and cobol as language-level N/A rows.
The cell tally recorded at U2 was 164 cells / 42 RESOLVES / 22 VISIBLE-GAP / 31
INVISIBLE-GAP / 69 N/A / 0 GRAMMAR-UNAVAILABLE — a snapshot, superseded by every
unit since (the axis also gained TypeScript's declared fourHopChain extra).
Count the states off baseline.json rather than quoting this line.
The count arm did not move when the shape axis was expanded — shape fixtures are built in temp directories and never touch the committed corpus, so expanding the shape axis moves the shape arm only.
Updated after U10 (structural receiver typing wired into Case 0). Three TypeScript shapes flipped to
RESOLVES—svc?.getUser().save(),svc!.getUser().save(),svc.getTyped<User>().save()— and the call-drop count did not move: 99 before, 99 after.That is the whole argument for the shape arm, now demonstrated rather than predicted. The committed fixture corpus contains none of those three spellings, so a gate reading only the drop count would have scored a working change as "no improvement" and stopped the series. Nothing regressed: no edge was lost and no new drop appeared.
Two gaps remained open at U10, both genuine at the time:
(await svc.getUserAsync()).save()—extractMixedChainreachedawait …, which is not a chain node, so no chain was minted. It was a VISIBLE-GAP and is the call-kind fixture in the drop-recorder test.repos[0].save()— Case 0's punctuation gate never fired for a subscript receiver, so it was INVISIBLE.Both were subsequently closed for TypeScript by the
await/indexstep kinds (wire format v2) and by Case 0's third gate arm, which admits any site carrying a minted chain regardless of receiver punctuation. Per-language state is inbaseline.json—awaitParenandindexElement.The tables below are the pre-U10 measurement, kept as the reference point.
U7 — the go/no-go gate: PASS
A/B produced by reverting ONLY the fold wiring (compound-receiver.ts +
receiver-bound-calls.ts) to the pre-U10 commit and rebuilding, so capture
emission — and therefore the persisted bytes — is identical in both arms and the
delta isolates the fold. Build + both caches wiped before every run (KTD4).
| Metric | Control | Treatment | Δ | Threshold | Verdict |
|---|---|---|---|---|---|
| scope-resolution wall-clock, median of 3 | 25470.0 ms | 25687.9 ms | +0.86% | ≤ +3% | PASS |
| wall-clock, slowest of 3 | 25520.0 ms | 25832.6 ms | +1.22% | ≤ +5% p95 | PASS |
| serialized bytes per emitting site | — | 35.2 B | — | ≤ 48 B | PASS |
| persisted store growth | 1 234 600 B | 1 235 340 B | +0.0599% | ≤ 3% | PASS |
| retained chain payload | — | 740 B | — | ≤ 6 MB | PASS |
| call drops (no regression) | 99 | 99 | 0 | no new drops | PASS |
| peak RSS | — | — | — | ≤ +2% | NOT RESOLVABLE |
The 35.2 B result confirms KTD7 by measurement rather than by assertion. The 48-byte threshold was set deliberately so the object encoding (~71 B predicted) fails and the compact string (~35 B predicted) passes. Measured: 35.2 B, including the JSON key and quotes. The encoding decision is now evidence-backed.
Peak RSS: the threshold is below this instrument's resolution, so it is
reported as unresolvable rather than as a pass or a fail. Three independent
treatment runs with the code held constant gave 414.9 / 436.6 / 436.9 MB — a
5.3% spread, wider than the ±2% being tested. (An earlier pair of 3-reps-in-one-
process runs read 536 vs 551 MB and looked like a +2.77% regression; that was
heap accumulating across reps, not growth.) Corroborating argument that no growth
exists to find: the change persists 740 bytes across the entire corpus and the
fold allocates nothing retained — it returns SymbolDefinitions the indexes
already hold.
Fold hit-rate. Chains are minted for 21 of 529 TypeScript reference sites (4.0%) — the field costs nothing on the 96% of sites with a bare-name receiver. On the shape corpus, all 5 chain-carrying shapes resolve, so the fold is not pure added cost on this population.
Not measured: a dedicated synthetic miss-dominant scaling corpus. The plan
asks for scaling_ratio < 1.5 on one, on the grounds that a same-name corpus
hits at ownerChain[0] and never exercises the MRO tail. Stated plainly so it is
not mistaken for a silent pass. What bounds the cost instead: the fold runs with
fieldFallback: false, so the O(fields × depth × names) path the threshold exists
to police cannot execute at all, and the remaining work is at most
MAX_CHAIN_DEPTH (3) map lookups per MRO ancestor per chained site, over a
population of 21 sites. The wall-clock A/B above is the empirical check on that
reasoning.
Measured with bench/receiver-resolution/measure.mjs on f87b2cbe.
Hygiene (a run without both steps is void — analyze --force clears neither cache,
and the parse worker runs from dist/):
npm run build
rm -rf .gitnexus/parse-cache .gitnexus/parsedfile-cache
node --import tsx bench/receiver-resolution/measure.mjs --corpus test/fixtures/lang-resolution
Two consecutive runs were byte-identical, not merely within noise.
Count arm — test/fixtures/lang-resolution
Snapshot: the U7-era measurement (commit f87b2cbe), kept as the reference
point for the A/B above. The gate enforces countArm in baseline.json, which
has moved since — read the live call-drop number, site-kind split, and
per-extension breakdown from there.
| Metric | Value at U7 |
|---|---|
| Call drops (the gate number) | 99 |
| Total drops, all site kinds | 124 |
| Split by site kind | call: 99, read: 25 |
Call drops by extension, at U7:
| ext | n | ext | n | ext | n |
|---|---|---|---|---|---|
.java |
49 | .py |
5 | .rs |
3 |
.cs |
8 | .go |
5 | .kt |
3 |
.ts |
7 | .cpp |
5 | .rb |
2 |
.tsx |
6 | .php |
4 | .js |
1 |
.swift |
1 |
Why the split matters (KTD6 defect 1, now measured). About a fifth of the
drops are property reads, not lost calls (25 of 124 at U7; bySiteKind in
baseline.json is current). Case 0's recorder gates on the receiver's
punctuation, not on what the reference is, so d.source.kind lands in the same
bucket as a dropped method call. Gating on the unsplit total would have measured a
population one fifth of which this work does not target.
Shape arm
RESOLVES means an edge exists — not that it points at the right target. A
name-keyed fallback onto a same-named member reads as RESOLVES, so a shape whose
receiver has no well-defined type is not a usable control.
Snapshot: the pre-U10 measurement over three languages, kept because it is the
evidence that the shape arm moves when the count arm does not. Superseded twice —
by U8's rollout table above and by the canonical shape axis in baseline.json.
The three TypeScript rows marked as gaps here (?., !, <T>) all resolve now.
| Language | Shape | State at pre-U10 | siteKind |
|---|---|---|---|
| TypeScript | svc.getUser().save() |
RESOLVES | — |
| TypeScript | svc.getUser().address.save() |
RESOLVES | — |
| TypeScript | svc?.getUser().save() |
INVISIBLE-GAP | — |
| TypeScript | svc!.getUser().save() |
VISIBLE-GAP | call |
| TypeScript | (await svc.getUserAsync()).save() |
VISIBLE-GAP | call |
| TypeScript | svc.getTyped<User>().save() |
INVISIBLE-GAP | — |
| TypeScript | repos[0].save() |
INVISIBLE-GAP | — |
| PHP | $svc->getUser()->save() |
VISIBLE-GAP | call |
| PHP | $this->repo->save() (typed property) |
RESOLVES | — |
| C++ | svc->getUser()->save() |
INVISIBLE-GAP | — |
| C++ | svc2.getUser()->save() |
RESOLVES | — |
Corrections to the plan, forced by measurement
-
Three target shapes are invisible, not one. The plan records only
repos[0].save()as unrecorded. Measured,svc?.getUser().save()andsvc.getTyped<User>().save()are equally invisible: no edge and no drop.This is the load-bearing correction. A gate built on the call-drop count alone would move by zero when those three shapes are fixed, reading a working change as "no improvement" — the same false-negative hazard the plan flags for stale shards, arriving by a different route. Hence the shape arm: it is blind to nothing, because it asks about edge presence rather than about a recorder that has to have fired.
-
Invisibility is NOT a capture-layer gap. Measured directly against
emitTsScopeCaptures, all five TypeScript shapes emit a full call match —@reference.call.member,@reference.name, and crucially@reference.receiver:Shape @reference.receiversvc?.getUser().save()svc?.getUser()svc.getTyped<User>().save()svc.getTyped<User>()repos[0].save()repos[0]So a
ReferenceSiteexists for every one of them, and hanging areceiverChainfield onReferenceSiteis a viable carrier for all of them. That was worth establishing before building on it.The drop suppression is therefore downstream of capture. For
repos[0]the cause is known and matches the plan: the receiver has neither.nor(, so Case 0's gate never fires. For?.and<T>the receiver text satisfies the gate, so Case 0 does run and one of two things happens — the site was marked inhandledSitesby another case, orresolveCompoundReceiverClassreturned a class on which the member was then not found, leavingcompoundReceiverUnresolvedfalse. Those are materially different defects and which one applies is not yet determined; it is the first thing U10 has to establish, since the second would mean the recorder under-reports by mis-attribution rather than by a gate.(An earlier revision of this file asserted that these shapes produce no reference site at all. That was inferred from edge-and-drop absence and is disproven by the capture dump above.)
-
KTD6 defect 2 overstates the PHP blindness. The claim is that Case 0's C-family punctuation test means PHP
->receivers "never record a drop at all". Measured,$svc->getUser()->save()is recorded, because its receiver text$svc->getUser()contains(and satisfies the gate. And the plan's own example,$this->repo->save(), does not need recording — with a typed property it resolves. The genuine PHP gap is the call chain, and it is already visible. -
The C++ defect is the
->base receiver specifically.svc->getUser()->save()is invisible whilesvc2.getUser()->save()resolves. Same chain, same->save()tail — only the base differs. This is exactly whycpp-chain-call/has never caught it: that fixture uses the value.form, which works.
Known blind spots
Every count here is a lower bound on a known-biased population, and any later delta
must be read against the same bias. Kept in sync with KNOWN_BLIND in
measure.mjs, which prints these on every run.
- Case 0 is reached by a receiver-TEXT punctuation test (
.or() or by a minted receiver chain. A receiver spelled without that punctuation — a subscriptrepos[0], a PHP->/::property path — therefore reaches the recorder only where its emitter mints a chain. Where no chain is minted, the call still vanishes with the instrument blind to it. - A drop is recorded only while
compoundReceiverUnresolvedstays true. When the cascade TYPES the receiver but then finds no member on it, the flag is false and no drop is recorded even though no edge was emitted. So an absent drop is not evidence a site resolved — the recorder can under-report by mis-attribution, not only by a gate. (This is what moved PHP'sarrowCallChainfrom VISIBLE-GAP to INVISIBLE-GAP when its fixture parameter was typed; see U8 below.) - Retracted, and left here because it was quoted for several units: the earlier
claim that
?.and explicit type arguments "produce no reference site at all". They do — the capture dump under "Corrections to the plan" §2 shows a full call match with@reference.receiverfor all three ofsvc?.getUser(),svc.getTyped<User>()andrepos[0]. The absence was of an EDGE and of a DROP, never of a site.
U8 — per-language rollout
Emission moved into one shared helper
(utils/receiver-chain-captures.ts) and is wired into all 14 language
emitters. The helper is language-free (R6): its call gate reads the
@reference.call.* tag prefix, a vocabulary every language's .scm query
shares, rather than a per-language tag list. It is self-gating — a non-call
match, an absent receiver, or a chain with no nameable base all leave the match
untouched — so inserting the call before every out.push(grouped) is safe even
in the emitters that have three or four such paths.
| Language | Shape | Before | After |
|---|---|---|---|
| TypeScript | svc?.getUser().save() |
INVISIBLE-GAP | RESOLVES |
| TypeScript | svc!.getUser().save() |
VISIBLE-GAP | RESOLVES |
| TypeScript | svc.getTyped<User>().save() |
INVISIBLE-GAP | RESOLVES |
| C++ | svc->getUser()->save() |
INVISIBLE-GAP | RESOLVES |
| C++ | svc2.getUser()->save() (control) |
RESOLVES | RESOLVES |
| PHP | $svc->getUser()->save() |
VISIBLE-GAP | INVISIBLE-GAP |
| PHP | $this->repo->save() (control) |
RESOLVES | RESOLVES |
The C++ row is the one the plan flagged as having no fixture anywhere —
cpp-chain-call/ uses the value . form, which already worked. It now has one,
plus the value-dot control that proves the defect was the -> base specifically.
PHP: a measured residual, with the trap checked
PHP does not resolve yet, and the plan's named trap — a language whose node
type is missing from extractMixedChain's tables reads as "didn't need it" when
it in fact cannot be measured — is not the cause. Checked directly against
the emitter:
name=save chain=1|$svc|cgetUser recv=$svc.getUser()
The leading 1 is the v1 wire prefix current when this dump was taken; the
codec is at v2 now (2|$svc|cgetUser), and a v2 decoder refuses a v1 payload by
design — do not copy this literal into a fixture.
The chain is minted correctly. The residual is that the fold's base, $svc,
does not bind in the PHP resolver, so the fold returns undefined and the site
falls through to the text cascade. That is PHP binding-key work, not a
chain-layer defect, and it is left as a recorded residual rather than absorbed
into this series.
Two incidental corrections from that check, both to KTD6:
- PHP's receiver capture text is normalized to
$svc.getUser()— DOTS, not->. So Case 0's "C-family punctuation" gate fires for PHP after all, which is why the call chain was recorded as a VISIBLE-GAP to begin with. - Typing the fixture parameter (
function f(Service $svc)) moved the row from VISIBLE-GAP to INVISIBLE-GAP: with a type binding the cascade now types the receiver but finds no member, socompoundReceiverUnresolvedis false and no drop is recorded. An untyped fixture parameter had been reporting a language gap that was really a fixture defect — the same error class as the untyped$repocontrol caught earlier.