From 997fc05b83bf5cd09453a16138e8338c312fbfab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 7 Aug 2026 17:14:13 +0100 Subject: [PATCH] fix(resolution): resolve calls through a generic-typed field receiver in every language (#2833) (#2855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(resolution): pin generic-typed field receivers across languages (#2833) A field whose declared type carries a type argument (`repo: Repo`) 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` 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` 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) 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) 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` 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` 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) 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 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.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) 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) 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 vi; vi.save()` emitted `Vec::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` against `Vec`), and lexical shadowing between a global `Box` and a namespaced `N::Box`. 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` 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` 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` for `Vec` 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) 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) 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 items;`, `ns::Repo r;` and `std::unique_ptr 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` resolves to nothing, `ns.Repo` 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::b::c::Repo`) 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::b::`-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) 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` 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` 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` 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` 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` 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) 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`); nothing recorded the parameter list a declaration DECLARES (`template `, `class Box`). So the resolver could not tell a type variable from a class, and: - `class Box2 { 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: T }` resolved to nothing: no recorded bound to resolve through. - A full specialization `template<> struct Vec` and a partial `template struct Vec` 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) 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` -> `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` and `Repo?` 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) 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 items;`, `ns::Repo 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` 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` reduces to `vector`, 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}` and PHP `@var Repo`.** 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')` 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`, 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) 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` 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) 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) 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 Co-authored-by: Claude Opus 5 (1M context) --- gitnexus-shared/src/index.ts | 6 +- .../src/scope-resolution/symbol-definition.ts | 44 + .../bench/receiver-resolution/BASELINE.md | 9 +- .../bench/receiver-resolution/baseline.json | 4 +- gitnexus/bench/scope-capture/baselines.json | 130 +- gitnexus/bench/scope-capture/measure.mjs | 14 +- .../core/ingestion/languages/cpp/interpret.ts | 67 +- .../src/core/ingestion/languages/cpp/query.ts | 96 + .../core/ingestion/languages/csharp/query.ts | 22 +- .../languages/go/generic-type-parameters.ts | 157 ++ .../ingestion/languages/go/interface-impls.ts | 454 +++- .../ingestion/languages/go/method-owners.ts | 8 + .../core/ingestion/languages/java/query.ts | 12 +- .../languages/javascript/captures.ts | 168 +- .../core/ingestion/languages/kotlin/query.ts | 13 +- .../core/ingestion/languages/php/captures.ts | 357 ++- .../ingestion/languages/python/interpret.ts | 246 +- .../core/ingestion/languages/rust/query.ts | 12 +- .../languages/typescript/interpret.ts | 23 + .../ingestion/languages/typescript/query.ts | 14 +- .../src/core/ingestion/scope-extractor.ts | 48 + .../passes/compound-receiver.ts | 153 +- .../passes/receiver-bound-calls.ts | 404 ++- .../scope-resolution/scope/walkers.ts | 612 ++++- .../ingestion/utils/template-arguments.ts | 83 + .../core/ingestion/utils/type-parameters.ts | 210 ++ gitnexus/src/storage/parse-cache.ts | 47 +- .../expected-captures.json | 8 +- .../expected-captures.json | 6 +- .../generic-field-receiver-matrix.test.ts | 2262 +++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 22 +- ...ython-generic-annotation-reduction.test.ts | 275 ++ .../scope-resolution/type-parameters.test.ts | 131 + 33 files changed, 5848 insertions(+), 269 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/go/generic-type-parameters.ts create mode 100644 gitnexus/src/core/ingestion/utils/type-parameters.ts create mode 100644 gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/python/python-generic-annotation-reduction.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/type-parameters.test.ts diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 284e94268..13c2eac5a 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -30,7 +30,11 @@ export type { PipelinePhase, PipelineProgress } from './pipeline.js'; // ─── Scope-based resolution — RFC #909 (Ring 1 #910) ──────────────────────── // Data model (RFC §2) -export type { ParameterTypeClass, SymbolDefinition } from './scope-resolution/symbol-definition.js'; +export type { + ParameterTypeClass, + SymbolDefinition, + TypeParameter, +} from './scope-resolution/symbol-definition.js'; export type { ScopeId, DefId, diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 64fbce93a..896b0dc04 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -24,6 +24,38 @@ export interface ParameterTypeClass { templateArguments?: string[]; } +/** + * One declared generic/template TYPE PARAMETER — `T` in `class Box`, `template struct Vec`, `interface Repo`. + * + * NOT the same axis as `SymbolDefinition.templateArguments`, and conflating the + * two is the defect this shape exists to end. `templateArguments` records the + * arguments a declaration was written AGAINST (`template <> struct Vec` → + * `['bool']`); `typeParameters` records the parameters it was written IN TERMS + * OF. A declaration can carry both — a C++ partial specialization + * `template struct Vec` has `templateArguments: ['T*']` AND + * `typeParameters: [{name: 'T'}]` — and that pairing is precisely what tells a + * partial specialization apart from the full specialization `template <> struct + * Vec`, which carries the identical `templateArguments` and NO parameters. + */ +export interface TypeParameter { + /** The parameter's declared name, exactly as written (`T`, `Ts`, `TKey`). */ + name: string; + /** + * The declared upper bound / constraint, verbatim and un-split, when the + * declaration states one inline: `T extends Repo` → `Repo`, `T : Repo` → + * `Repo`, `T extends Repo & Closeable` → `Repo & Closeable`. + * + * VERBATIM because the intersection/compound spellings differ per language + * and a shared consumer that wants the first bound can take the first token + * itself, while one that wants to round-trip the source cannot recover what a + * split threw away. Absent when the parameter is unbounded, and absent when + * the bound is declared OUT OF LINE (C# `where T : IRepo`, Kotlin/Rust + * `where` clauses) — see `parseTypeParameterList`. + */ + bound?: string; +} + export interface SymbolDefinition { nodeId: string; filePath: string; @@ -48,6 +80,18 @@ export interface SymbolDefinition { declaredType?: string; /** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */ templateArguments?: string[]; + /** + * Declared generic/template TYPE PARAMETERS, in DECLARATION ORDER — see + * {@link TypeParameter} for how this differs from `templateArguments`. + * + * ORDER IS LOAD-BEARING: substitution is positional (`Repo` binds the + * FIRST parameter), so a set or a name-keyed map would discard exactly the + * information this carries. Absent for a non-generic declaration and for every + * language whose captures do not populate it, so a reader MUST treat absence + * as "unknown", never as "not generic" — the two are indistinguishable here + * and only the first is safe to act on. + */ + typeParameters?: TypeParameter[]; /** Per-language constraint payload for template / generic overloads * (e.g. C++ `enable_if_t` predicate trees, C++20 `requires` clauses). * Opaque to shared code — the producing language adapter owns the shape diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md index f612258bf..329270eaa 100644 --- a/gitnexus/bench/receiver-resolution/BASELINE.md +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -364,8 +364,13 @@ 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. But `this->repo.save()` and `this->repo->save()` are both -INVISIBLE-GAP — a distinct defect, not a decoration one. +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 diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index d4136c53c..540277391 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -61,9 +61,9 @@ "awaitParen": "N/A", "explicitTypeArgs": "VISIBLE-GAP", "indexElement": "RESOLVES", - "fieldReceiverCall": "INVISIBLE-GAP", + "fieldReceiverCall": "RESOLVES", "decoratedReceiverBase": "N/A", - "decoratedFieldType": "INVISIBLE-GAP" + "decoratedFieldType": "RESOLVES" }, "go": { "plainChain": "RESOLVES", diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index fe25691f5..78b6f2818 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -6,22 +6,22 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.", "_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.", "_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", - "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.", + "_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.", "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.", "_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.", "_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5." }, "cobol": { "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", - "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", + "_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", - "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", @@ -29,13 +29,15 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.", - "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", - "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", + "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.", + "_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)." }, "cpp": { - "fingerprint": "856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc", + "fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec", "scaling_budget": 1.5, + "_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.", + "_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo repo;` (bare template_type) and `std::vector items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.", "_rebaselined_callable_flow": "Callable-value-flow facts for C++ function pointers/references, reference aliases, contextual arity, arguments, and member-pointer syntax. Prior 6ab657c8f9bfe988a3759098c2cffdcc0443def75ff263f1282b82c21d96e931 -> 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710; measured scaling ratio 1.069 < 1.5.", @@ -43,37 +45,49 @@ "_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).", "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).", - "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", + "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.", - "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", + "_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.", + "capture_groups_small": 5021, + "capture_groups_large": 16021, + "capture_groups_fp": 4605, + "fixture_count": 279 }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.", - "fingerprint": "476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc", + "fingerprint": "2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.", "_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).", "_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.", + "capture_groups_small": 4259, + "capture_groups_large": 13609, + "capture_groups_fp": 2657, + "fixture_count": 178 }, "rust": { - "fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809", + "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", "scaling_budget": 1.5, - "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", + "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.", - "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", + "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.", "_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.", - "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", + "_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.", "_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.", + "capture_groups_small": 5507, + "capture_groups_large": 17607, + "capture_groups_fp": 3556, + "fixture_count": 202 }, "php": { "fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c", @@ -81,9 +95,9 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.", "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).", - "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." + "_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c." }, "ruby": { "fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57", @@ -91,10 +105,10 @@ "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.", "_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", - "_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", + "_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", @@ -103,8 +117,8 @@ "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", "_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { @@ -118,11 +132,11 @@ "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0." }, "java": { - "fingerprint": "a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9", + "fingerprint": "b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", - "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.", @@ -130,31 +144,39 @@ "_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.", "_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.", "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", + "capture_groups_small": 5005, + "capture_groups_large": 16005, + "capture_groups_fp": 3452, + "fixture_count": 206 }, "java-local-types": { "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", "scaling_budget": 1.5, "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." }, "typescript": { - "fingerprint": "7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949", + "fingerprint": "ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.", "_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.", - "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.", - "_note": "#1968: F44, F85, F87 — fingerprint drift expected.", + "_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.", + "_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", - "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", - "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.", + "_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this. = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.", + "_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped \u2014 so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.", + "capture_groups_small": 4503, + "capture_groups_large": 14403, + "capture_groups_fp": 2097, + "fixture_count": 146 }, "javascript": { "fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594", @@ -166,23 +188,27 @@ "_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", "_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.", "_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.", - "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." + "_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.", + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594." }, "kotlin": { - "fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2", + "fingerprint": "a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.", "_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.", "_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.", - "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", + "_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).", "_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.", "_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.", "_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.", - "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", - "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2." + "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.", + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.", + "_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.", + "capture_groups_small": 4753, + "capture_groups_large": 15203, + "capture_groups_fp": 2334, + "fixture_count": 137 } } diff --git a/gitnexus/bench/scope-capture/measure.mjs b/gitnexus/bench/scope-capture/measure.mjs index 56aa2592d..56887e467 100644 --- a/gitnexus/bench/scope-capture/measure.mjs +++ b/gitnexus/bench/scope-capture/measure.mjs @@ -209,10 +209,22 @@ const LANGS = [ // Heritage-bearing: `: public Base, public Mixin` (single + multiple // inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added // (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956). + // + // Also GENERIC-MEMBER-bearing (#2833): `Repo repo;` is a member + // whose declared type is a bare `template_type`, and + // `std::vector items;` is the far commoner spelling where a + // `qualified_identifier` WRAPS that template_type. Both were absent, and + // their absence is why two successive rounds of `field_declaration` + // type-binding rules landed with a byte-identical cpp fingerprint: the gate + // could not see a member field it had no instance of. With them present, + // reverting either round of rules drifts the fingerprint, which is the + // property that makes the gate worth running. header: - '#include \n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n', + '#include \n#include \n\ntemplate \nclass Repo {\n public:\n void save(T v) {}\n};\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n', unit: (n) => `class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` + + ` Repo repo;\n` + + ` std::vector items;\n` + ` long getId() const { return id; }\n` + ` void setName(std::string v) { name = v; }\n};\n\n`, }, diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index 1627232c5..e5a3635ca 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -77,10 +77,71 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi source = 'annotation'; } - const declaredSpelling = cppPointerSpelling(captures, type, name); + // A member field's type is captured AS WRITTEN, qualifier and all + // (`ns::Repo`), because the query matches the outer + // `qualified_identifier` — one depth-agnostic pattern per declarator shape + // instead of one per qualifier depth. The qualifier is dropped HERE; see the + // "Field type, QUALIFIED" block in query.ts for why the qualified spelling + // resolves to nothing and the tail resolves like the bare one. + // + // FIELDS ONLY. `@type-binding.parameter` and `@type-binding.assignment` also + // capture qualified spellings (their patterns use `type: (_)`), and reducing + // THOSE would newly bind every qualified local and parameter in the workspace + // — a far wider change than the member-field miss this closes, and not one + // anything here has measured. + const effectiveType = + captures['@type-binding.field'] === undefined ? type : cppQualifiedTail(type); + // The reduced spelling is also the AS-WRITTEN one, and saying so is load + // bearing. `collectTypeBindings` derives `TypeRef.declaredSpelling` from + // `@type-binding.type` whenever that text differs from `rawTypeName`, and it + // now does for every qualified member. `declaredSpelling` exists to keep a + // CONTAINER distinguishable from a class of the same name after capture + // reduced it; a qualifier is not a container — `ns::Address` and `Address` + // have the identical member set — so recording one here would answer + // "container, as written" for a plain member and hand `elementTypeOf` a + // spelling it never sees for the bare form. + const declaredSpelling = + cppPointerSpelling(captures, effectiveType, name) ?? + (effectiveType === type ? undefined : effectiveType); return declaredSpelling === undefined - ? { boundName: name, rawTypeName: normalizeCppTypeName(type), source } - : { boundName: name, rawTypeName: normalizeCppTypeName(type), declaredSpelling, source }; + ? { boundName: name, rawTypeName: normalizeCppTypeName(effectiveType), source } + : { + boundName: name, + rawTypeName: normalizeCppTypeName(effectiveType), + declaredSpelling, + source, + }; +} + +/** + * The tail of a `::`-qualified type spelling — `a::b::Repo` → `Repo`, + * `ns::Address` → `Address`, an unqualified spelling unchanged. + * + * Only TOP-LEVEL separators count, so a qualified TYPE ARGUMENT survives: + * `std::vector` reduces to `vector`, not to `string`. + * That is the same string the old per-depth rules produced by capturing the + * inner node, so the reduction is textual where it used to be structural and + * the result is identical for every depth they covered. + */ +function cppQualifiedTail(text: string): string { + let angleDepth = 0; + let lastSeparator = -1; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '<') angleDepth++; + else if (ch === '>') { + if (angleDepth > 0) angleDepth--; + } else if (angleDepth === 0 && ch === ':' && text[i + 1] === ':') { + lastSeparator = i; + i++; + } + } + if (lastSeparator === -1) return text; + const tail = text.slice(lastSeparator + 2).trim(); + // A spelling that ends in `::` has no tail to reduce to. Cannot arise from a + // parsed `qualified_identifier`, but returning an empty type name would make + // the binding claim a type of `""`, so the written spelling is kept instead. + return tail.length === 0 ? text : tail; } /** Anchors whose capture spans a whole declaration, so the declarator — and diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index 52fcc0785..7b708b60b 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -55,12 +55,26 @@ const CPP_SCOPE_QUERY = ` declarator: (type_identifier) @declaration.name) @declaration.struct ;; ─── Declarations — class / struct inside template_declaration ─────── +;; \`parameters:\` is the DECLARED parameter list (\`template \`), which +;; lives on the template_declaration and not on the specifier — the opposite +;; nesting from \`@declaration.template-arguments\` above, which is part of the +;; specifier's own NAME. A partial specialization carries both, and that pairing +;; is the only thing separating it from a full specialization written against +;; the identical arguments. +;; +;; These four patterns are TWINS of the four standalone specifier patterns +;; above: a templated struct matches both, minting two defs with one id, and +;; only this half can see the parameter list. The duplicate-declaration backfill +;; in scope-extractor.ts is what stops match order from deciding which twin +;; keeps the parameters. (template_declaration + parameters: (template_parameter_list) @declaration.type-parameters (class_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.class) (template_declaration + parameters: (template_parameter_list) @declaration.type-parameters (class_specifier name: (template_type (type_identifier) @declaration.name @@ -68,11 +82,13 @@ const CPP_SCOPE_QUERY = ` body: (field_declaration_list)) @declaration.class) (template_declaration + parameters: (template_parameter_list) @declaration.type-parameters (struct_specifier name: (type_identifier) @declaration.name body: (field_declaration_list)) @declaration.struct) (template_declaration + parameters: (template_parameter_list) @declaration.type-parameters (struct_specifier name: (template_type (type_identifier) @declaration.name @@ -537,6 +553,86 @@ const CPP_SCOPE_QUERY = ` declarator: (reference_declarator (field_identifier) @type-binding.name)) @type-binding.field +;; Generic field type: Repo repo; (#2833) +;; The three rules above all require type: (type_identifier), so a member whose +;; type carries template arguments 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 spellings (repo.save() and this->repo.save()), while the same type in +;; a LOCAL resolved fine because the local declaration rules gained their +;; template_type variant long ago (see "Covers: List users;" above). +;; These three mirror the three above, one per declarator shape. Written as +;; separate patterns rather than one alternation: a node-type alternation in a +;; field position is the tree-sitter 0.21 hazard this repo has been bitten by +;; before. +(field_declaration + type: (template_type) @type-binding.type + declarator: (field_identifier) @type-binding.name) @type-binding.field + +;; Generic field, pointer: Repo* repo; +(field_declaration + type: (template_type) @type-binding.type + declarator: (pointer_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.field + +;; Generic field, reference: Repo& repo; +(field_declaration + type: (template_type) @type-binding.type + declarator: (reference_declarator + (field_identifier) @type-binding.name)) @type-binding.field + +;; ─── Field type, QUALIFIED: ns::Address addr; std::vector items; ─ +;; The six rules above require the type node to BE a type_identifier or a +;; template_type, and a qualified member type is NEITHER: tree-sitter-cpp parses +;; ns::Address as a qualified_identifier WRAPPING the type_identifier, and +;; std::vector as one wrapping the template_type. So every qualified +;; member — generic or not — matched none of the six and bound nothing, which +;; covers the commonest member spellings in real C++ (std::string, std::mutex, +;; std::vector, ns::Config). +;; +;; ONE PATTERN PER DECLARATOR SHAPE, MATCHING THE OUTER qualified_identifier, +;; and that is the whole design. A tree-sitter query cannot match a node at +;; arbitrary nesting depth, and a::b::c::Repo nests one +;; qualified_identifier per qualifier — so enumerating the inner node instead +;; costs 3 patterns per depth per genericity and STILL ends at whatever depth +;; the last author enumerated (that boundary was real: depth 3 was uncaptured). +;; Matching the outer node is depth-agnostic and genericity-agnostic, and it is +;; a single node type in the field position, not an alternation — the +;; tree-sitter 0.21 hazard this repo has been bitten by before. +;; +;; The QUALIFIER IS THEN DROPPED, by cppQualifiedTail in interpret.ts, not +;; here — and dropping it was measured rather than assumed. Recording +;; ns::Repo resolves to NOTHING: findClassBindingInScope's dotted-tail +;; fallback splits on "." and C++ writes "::", and resolveClassBindingForName's +;; generic branch then looks up the base ns::Repo, which is not a key either +;; because C++ emits no @declaration.qualified_name and indexes ns::Repo under +;; Repo. Reducing to the tail lands on exactly the path the BARE spelling +;; already takes — one class-like match or decline — so a qualified member field +;; behaves like the bare one instead of like nothing. A tail that names no +;; workspace class (std::string with no "class string" in the repo) binds +;; nothing and emits nothing, which is why this is a miss-closing change rather +;; than an edge-fabricating one. +;; +;; Like the six above, each requires the declarator to reach the field_identifier +;; DIRECTLY, so a method whose return type is qualified (ns::Thing method();) +;; still captures no field — a function_declarator sits in between and none of +;; these match it. Same for a function-pointer member, a using/typedef alias, a +;; friend declaration and an operator declaration. +(field_declaration + type: (qualified_identifier) @type-binding.type + declarator: (field_identifier) @type-binding.name) @type-binding.field + +;; Qualified field, pointer: ns::Address* addr; std::unique_ptr* repo; +(field_declaration + type: (qualified_identifier) @type-binding.type + declarator: (pointer_declarator + declarator: (field_identifier) @type-binding.name)) @type-binding.field + +;; Qualified field, reference: ns::Address& addr; std::vector& items; +(field_declaration + type: (qualified_identifier) @type-binding.type + declarator: (reference_declarator + (field_identifier) @type-binding.name)) @type-binding.field + ;; ─── References — constructor calls (new Foo()) ───────────────────── (new_expression type: (type_identifier) @reference.name) @reference.call.constructor diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 0a0bb65ca..615da3c21 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -63,17 +63,31 @@ const CSHARP_SCOPE_QUERY = ` ;; Anonymous methods / lambdas are not scoped — out of scope per plan. ;; Declarations — types +;; The parameter list is matched as an UNNAMED optional child, not through a +;; \`type_parameters:\` field: the C# grammar gives \`interface_declaration\` that +;; field but \`class_declaration\` / \`struct_declaration\` / \`record_declaration\` +;; only a bare \`type_parameter_list\` child, so the field form would silently +;; capture nothing on exactly the three most common declarations. The unnamed +;; form matches all four. +;; +;; A \`where T : IRepo\` constraint is a SEPARATE sibling clause +;; (\`type_parameter_constraints_clause\`) and is deliberately not read here — the +;; bound stays absent for C#, which reads as "unknown", the safe direction. (class_declaration - name: (identifier) @declaration.name) @declaration.class + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.class (interface_declaration - name: (identifier) @declaration.name) @declaration.interface + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.interface (struct_declaration - name: (identifier) @declaration.name) @declaration.struct + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.struct (record_declaration - name: (identifier) @declaration.name) @declaration.record + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.record (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/languages/go/generic-type-parameters.ts b/gitnexus/src/core/ingestion/languages/go/generic-type-parameters.ts new file mode 100644 index 000000000..d03e7b4e2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/go/generic-type-parameters.ts @@ -0,0 +1,157 @@ +import type { ParsedFile, Range, SymbolDefinition } from 'gitnexus-shared'; + +/** + * A generic Go interface's type-parameter names, in DECLARATION ORDER, stamped + * onto its `Interface` def as a Go-private sidecar. + * + * Same mechanism and lifecycle as `goReceiverKind` (method-owners.ts): an extra + * property on a def the Go resolver owns, written on the main thread and read by + * `interface-impls.ts`. It is deliberately NOT a shared `SymbolDefinition` field + * and deliberately NOT a capture — see {@link stampGoInterfaceTypeParameters}. + * + * ORDER IS THE POINT. Substitution is positional (`Repo[User]` binds the FIRST + * type parameter), so a set or a name→constraint map would lose exactly the + * information this exists to carry. + */ +type GoGenericInterfaceDefinition = SymbolDefinition & { + readonly goTypeParameters?: readonly string[]; +}; + +/** + * Stamp every generic interface in `parsedFiles` with its type-parameter names, + * read out of the declaration's own source text. + * + * WHY SOURCE TEXT AND NOT A CAPTURE. The tree has the list right there + * (`type_spec` carries a `type_parameters` field), and capturing it would be two + * lines. But captures run inside the PARSE WORKER, whose script is resolved from + * the compiled `dist/` build, and their output is additionally memoized by the + * parse cache and the durable ParsedFile store — so a capture-side change is + * invisible until a rebuild AND a cache-version bump, and silently wrong in + * between. Everything here runs on the main thread from data the pipeline + * already materialized, so it is correct on the first run and needs neither. + * + * The scan is exact rather than a grep over the file: an interface declaration + * owns a `Class` scope whose range spans exactly its `type_spec` + * (`Repo[T any] interface{ … }`), so the text is sliced by that range and the + * type parameters are, by grammar, whatever sits between the brackets that + * IMMEDIATELY follow the name. Comments and strings elsewhere in the file cannot + * reach it. + */ +export function stampGoInterfaceTypeParameters( + parsedFiles: readonly ParsedFile[], + fileContents: ReadonlyMap, +): void { + for (const parsed of parsedFiles) { + // Deferred so a file with no interface declaration never indexes its lines. + let lines: { readonly source: string; readonly starts: readonly number[] } | undefined; + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const iface = scope.ownedDefs.find((def) => def.type === 'Interface'); + if (iface?.qualifiedName === undefined) continue; + if (lines === undefined) { + const source = fileContents.get(parsed.filePath); + if (source === undefined) break; + lines = { source, starts: buildLineStarts(source) }; + } + const declaration = sliceRange(lines.source, lines.starts, scope.range); + if (declaration === undefined) continue; + const names = goTypeParameterNames(declaration, simpleGoName(iface.qualifiedName)); + if (names === undefined) continue; + (iface as { goTypeParameters?: readonly string[] }).goTypeParameters = names; + } + } +} + +/** Read back a stamp, rejecting anything whose shape does not match — the + * sidecar is optional and a hand-built fixture def carries none. */ +export function readGoTypeParameters(def: SymbolDefinition): readonly string[] | undefined { + const names = (def as GoGenericInterfaceDefinition).goTypeParameters; + if (!Array.isArray(names) || names.length === 0) return undefined; + return names.every((name): name is string => typeof name === 'string') ? names : undefined; +} + +/** + * The declared type-parameter names of `Name[…] interface{…}`, in source order, + * or `undefined` when the declaration is not generic. + * + * Go spec, Type parameter declarations: the list is comma-separated and one + * entry may declare SEVERAL names sharing one constraint — `[K, V any]` declares + * `K` and `V`, and `[S ~[]E, E any]` declares `S` and `E`. Each entry therefore + * contributes exactly its FIRST token as a name; anything after it is the + * constraint, which is not needed here (satisfaction of a constraint is a + * separate question from implementation of an interface, and constraints are + * never harvested as instantiations — see `interface-impls.ts`). + */ +function goTypeParameterNames(declaration: string, interfaceName: string): string[] | undefined { + if (!declaration.startsWith(interfaceName)) return undefined; + if (declaration[interfaceName.length] !== '[') return undefined; + const close = matchingGoDelimiter(declaration, interfaceName.length); + if (close === -1) return undefined; + const names: string[] = []; + for (const entry of splitTopLevelGoList(declaration.slice(interfaceName.length + 1, close))) { + const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(entry)?.[0]; + if (name === undefined) return undefined; + names.push(name); + } + return names.length === 0 ? undefined : names; +} + +/** Index of the delimiter closing the one at `open`, or -1 when unbalanced. + * Tracks `[]`, `{}` and `()` together so an `interface{ M(a, b int) }` + * constraint cannot end the list early. */ +export function matchingGoDelimiter(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '[' || ch === '{' || ch === '(') depth += 1; + else if (ch === ']' || ch === '}' || ch === ')') { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +/** Split on commas that are not nested inside brackets, braces or parens. */ +export function splitTopLevelGoList(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '[' || ch === '{' || ch === '(') depth += 1; + else if (ch === ']' || ch === '}' || ch === ')') depth -= 1; + else if (ch === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts.map((part) => part.trim()).filter((part) => part.length > 0); +} + +function simpleGoName(qualifiedName: string): string { + const dot = qualifiedName.lastIndexOf('.'); + return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1); +} + +/** Offsets at which each 1-based line begins. */ +function buildLineStarts(source: string): number[] { + const starts = [0, 0]; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '\n') starts.push(i + 1); + } + return starts; +} + +/** `Range` is 1-based on lines and 0-based on columns (`syntheticCapture`). */ +function sliceRange( + source: string, + lineStarts: readonly number[], + range: Range, +): string | undefined { + const start = lineStarts[range.startLine]; + const end = lineStarts[range.endLine]; + if (start === undefined || end === undefined) return undefined; + return source.slice(start + range.startCol, end + range.endCol); +} diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts index fad2ee8f1..5f56345aa 100644 --- a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts +++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts @@ -4,6 +4,11 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js'; import { resolveInheritanceBaseInScope } from '../../scope-resolution/scope/walkers.js'; import { goPackageDir } from './package-clause.js'; +import { + matchingGoDelimiter, + readGoTypeParameters, + splitTopLevelGoList, +} from './generic-type-parameters.js'; type MethodSet = ReadonlyMap; type MutableMethodSet = Map; @@ -38,13 +43,32 @@ type DetectionIndexes = { readonly structsById: ReadonlyMap; readonly methodsByOwner: ReadonlyMap; readonly effectiveMethodsByStructId: ReadonlyMap; - readonly interfaceById: ReadonlyMap; + /** Every interface in the program keyed by `qualifiedName`, `null` where more + * than one declares that name — the single probe behind + * {@link uniqueInterfaceNamed}. */ + readonly interfacesByQualifiedName: ReadonlyMap; readonly interfaceOwnMethodsById: ReadonlyMap; readonly embeddedSitesByInterfaceId: ReadonlyMap; readonly parentStructIdsByStructId: ReadonlyMap; readonly valueMethodsByStructId: ReadonlyMap; readonly structIdsByMethodName: ReadonlyMap>; readonly signatureContextByDefId: ReadonlyMap; + /** Type-parameter names, in declaration order, for every GENERIC interface. + * Absence means "not generic" and is the gate on the whole instantiation + * path — no entry, nothing below runs. */ + readonly typeParametersByInterfaceId: ReadonlyMap; + /** + * Every distinct instantiation of each generic interface observed anywhere in + * the program: interface id → the instantiation's normalized type ARGUMENTS, + * keyed by that list joined — which is what deduplicates it. + * + * An instantiation is nothing but that list. `Repo[User]` reduces to the type + * arguments ALREADY normalized in the signature context of the file that wrote + * them, so a cross-package `repo.Repo[model.User]` and the implementor's own + * `model.User` compare as the same type without either side re-qualifying the + * other's spelling. + */ + readonly instantiationsByInterfaceId: ReadonlyMap>; readonly scopeIndexes: ScopeResolutionIndexes; }; @@ -73,14 +97,21 @@ function buildDetectionIndexes( const signatureContextByDefId = new Map(); const interfaceIdByScopeId = new Map(); const structIdByScopeId = new Map(); + const typeParametersByInterfaceId = new Map(); + const signatureContextByFilePath = new Map(); for (const parsed of parsedFiles) { const signatureContext = signatureContextForFile(parsed, indexes); + signatureContextByFilePath.set(parsed.filePath, signatureContext); for (const def of parsed.localDefs) { signatureContextByDefId.set(def.nodeId, signatureContext); if (def.type === 'Interface') { interfaces.push(def); interfaceById.set(def.nodeId, def); + const typeParameters = readGoTypeParameters(def); + if (typeParameters !== undefined) { + typeParametersByInterfaceId.set(def.nodeId, typeParameters); + } continue; } if (def.type === 'Struct') { @@ -130,6 +161,16 @@ function buildDetectionIndexes( } } + // Built from the nodeId-keyed map, so a def that appears in two ParsedFiles is + // one interface here just as it is one there — not a name collision with + // itself. + const interfacesByQualifiedName = new Map(); + for (const iface of interfaceById.values()) { + const name = iface.qualifiedName; + if (name === undefined || name.length === 0) continue; + interfacesByQualifiedName.set(name, interfacesByQualifiedName.has(name) ? null : iface); + } + for (const parsed of parsedFiles) { for (const scope of parsed.scopes) { const iface = scope.ownedDefs.find((def) => def.type === 'Interface'); @@ -216,17 +257,204 @@ function buildDetectionIndexes( structsById, methodsByOwner, effectiveMethodsByStructId, - interfaceById, + interfacesByQualifiedName, interfaceOwnMethodsById, embeddedSitesByInterfaceId, parentStructIdsByStructId, structIdsByMethodName, valueMethodsByStructId, signatureContextByDefId, + typeParametersByInterfaceId, + // Gated on the repo declaring at least one generic interface. A Go codebase + // with none — the overwhelming majority — never runs the harvest at all, + // which matters because the spellings it would scan (`[]byte`, + // `map[string]X`) are among the commonest types in the language. + instantiationsByInterfaceId: + typeParametersByInterfaceId.size === 0 + ? new Map() + : collectGoInstantiations( + parsedFiles, + signatureContextByFilePath, + typeParametersByInterfaceId, + interfacesByQualifiedName, + indexes, + ), scopeIndexes: indexes, }; } +/** + * Every distinct instantiation of a generic interface written anywhere in the + * program, resolved and deduplicated in one pass. + * + * Go records no instantiation anywhere on the DECLARATION — `Repo[User]` exists + * only where it is written — so the sites are the field/parameter/variable type + * spellings the capture layer already preserved: `TypeRef.declaredSpelling` + * (which keeps the arguments `rawName` drops), and the def-side `declaredType` / + * `parameterTypes` / `returnType`. + * + * A spelling is scanned rather than parsed as a whole, so decorated and nested + * forms yield their inner instantiations too: `[]Repo[User]`, `*Repo[User]` and + * `map[string]Repo[User]` all yield `Repo[User]`, and `Outer[Repo[User]]` yields + * both — each of which really is an instantiation present in the program. False + * bases (`map[` scans as base `map`) resolve to no interface and drop out. + */ +function collectGoInstantiations( + parsedFiles: readonly ParsedFile[], + signatureContextByFilePath: ReadonlyMap, + typeParametersByInterfaceId: ReadonlyMap, + interfacesByQualifiedName: ReadonlyMap, + indexes: ScopeResolutionIndexes, +): ReadonlyMap> { + // One map where there were two on the same key: the inner map's KEY is the + // joined argument list, so holding it is the deduplication. + const argsByInterfaceId = new Map>(); + // A base name resolves once per scope. The bracket gate below cannot filter + // Go's commonest types — `map[string]string` scans as base `map` — so the + // FALSE bases dominate this pass, and each one otherwise re-walks the whole + // scope chain for a name that will never bind. + const basesInScope = new Map(); + const resolveBase = (baseName: string, inScope: string): SymbolDefinition | undefined => { + // NUL-joined for the same reason `methodSetKey` is: it cannot occur in Go + // source, so no two (scope, name) pairs can collide on one key. + const key = `${inScope}\u0000${baseName}`; + const memo = basesInScope.get(key); + if (memo !== undefined) return memo ?? undefined; + const iface = resolveGoInstantiationBase(baseName, inScope, interfacesByQualifiedName, indexes); + basesInScope.set(key, iface ?? null); + return iface; + }; + const record = ( + spelling: string | undefined, + inScope: string, + context: SignatureContext, + ): void => { + // Cheap gate first: most Go type spellings have no bracket at all, and the + // scan below is the only per-spelling cost this pass adds. + if (spelling === undefined || !spelling.includes('[')) return; + for (const { baseName, rawArgs } of parseGoInstantiationSpellings(spelling)) { + const iface = resolveBase(baseName, inScope); + if (iface === undefined) continue; + const typeParameters = typeParametersByInterfaceId.get(iface.nodeId); + // A partial or over-long argument list is not a valid instantiation + // ("For a generic type, all type arguments must always be provided + // explicitly" — go.dev/ref/spec#Instantiations), so there is nothing to + // substitute and the site is dropped. + if (typeParameters === undefined || typeParameters.length !== rawArgs.length) continue; + const normalizedArgs = normalizeGoTypeArguments(rawArgs, context); + if (normalizedArgs === undefined) continue; + let byArgs = argsByInterfaceId.get(iface.nodeId); + if (byArgs === undefined) { + byArgs = new Map(); + argsByInterfaceId.set(iface.nodeId, byArgs); + } + const key = normalizedArgs.join(','); + if (!byArgs.has(key)) byArgs.set(key, normalizedArgs); + } + }; + + for (const parsed of parsedFiles) { + const context = signatureContextByFilePath.get(parsed.filePath); + if (context === undefined) continue; + for (const scope of parsed.scopes) { + for (const binding of scope.typeBindings.values()) { + record(binding.declaredSpelling ?? binding.rawName, scope.id, context); + } + for (const def of scope.ownedDefs) { + record(def.declaredType, scope.id, context); + record(def.returnType, scope.id, context); + for (const parameterType of def.parameterTypes ?? []) { + record(parameterType, scope.id, context); + } + } + } + } + return argsByInterfaceId; +} + +/** Every `Ident[…]` / `pkg.Ident[…]` application in a type spelling, with its + * top-level (comma-separated, delimiter-balanced) arguments. */ +function parseGoInstantiationSpellings( + spelling: string, +): Array<{ readonly baseName: string; readonly rawArgs: readonly string[] }> { + const out: Array<{ baseName: string; rawArgs: string[] }> = []; + const namePattern = /[A-Za-z_][A-Za-z0-9_.]*(?=\[)/g; + let match: RegExpExecArray | null; + while ((match = namePattern.exec(spelling)) !== null) { + const open = match.index + match[0].length; + const close = matchingGoDelimiter(spelling, open); + if (close === -1) continue; + const rawArgs = splitTopLevelGoList(spelling.slice(open + 1, close)); + if (rawArgs.length === 0) continue; + out.push({ baseName: match[0], rawArgs }); + } + return out; +} + +/** Normalize each type argument in the context of the file that WROTE it, or + * `undefined` when any of them carries an unresolvable import qualifier — a + * half-normalized argument list would compare against nothing meaningful. */ +function normalizeGoTypeArguments( + rawArgs: readonly string[], + context: SignatureContext, +): string[] | undefined { + const normalizedArgs: string[] = []; + for (const rawArg of rawArgs) { + const normalized = normalizeSignatureType(rawArg, context); + if (normalized === undefined) return undefined; + normalizedArgs.push(normalized); + } + return normalizedArgs; +} + +/** + * Bind an instantiation's base name to the generic interface it names. + * + * Goes through `resolveInheritanceBaseInScope` first — the same real scope + * resolution the embedded-interface path uses — and falls back to a globally + * UNIQUE name match. + */ +function resolveGoInstantiationBase( + baseName: string, + inScope: string, + interfacesByQualifiedName: ReadonlyMap, + indexes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const bound = resolveInheritanceBaseInScope(inScope, simpleTypeName(baseName), indexes); + if (bound !== undefined) return bound.type === 'Interface' ? bound : undefined; + return uniqueInterfaceNamed(baseName, interfacesByQualifiedName); +} + +/** + * The one interface a written name denotes by NAME ALONE — the fallback both + * name-match routes here share, once the real scope resolution above them has + * declined. + * + * Ambiguity drops the site rather than guessing: two same-named interfaces in + * different packages would otherwise cross-pollinate each other's + * instantiations, and a dropped site only costs fan-out that does not exist + * today anyway. A qualified spelling is tried under both its own name and its + * simple tail, and a hit under EACH is two matches, so it declines as well. + * + * One probe rather than a scan of every interface in the program, which is what + * made this quadratic: the bracket gate in `collectGoInstantiations` cannot + * filter `map[…]` or `[]T`, so a Go program pays this once per bracketed + * spelling it writes. + */ +function uniqueInterfaceNamed( + name: string, + interfacesByQualifiedName: ReadonlyMap, +): SymbolDefinition | undefined { + const exact = interfacesByQualifiedName.get(name); + if (exact === null) return undefined; + const simpleName = simpleTypeName(name); + if (simpleName === name) return exact; + const simple = interfacesByQualifiedName.get(simpleName); + if (simple === null) return undefined; + if (exact !== undefined && simple !== undefined) return undefined; + return exact ?? simple; +} + function detectGoInterfaceImplementationsFromIndexes( indexes: DetectionIndexes, ): Map { @@ -237,30 +465,208 @@ function detectGoInterfaceImplementationsFromIndexes( if (required === undefined || required.size === 0) continue; if (!methodSetHasVerifiableSignatures(required)) continue; - const implementors: GoStructuralImplementor[] = []; - for (const structId of candidateStructIdsFor(required, indexes)) { - const pointerSet = indexes.effectiveMethodsByStructId.get(structId); - if (pointerSet === undefined) continue; - // MS(*T) is the superset: if it does not satisfy, neither does MS(T). - if (!methodSetSatisfies(pointerSet, required, indexes.signatureContextByDefId)) continue; - // Then ask the narrower question separately — does the VALUE type satisfy? - // This is the distinction `var x I = T{}` turns on, and it is a fact about - // the program, not a heuristic. - const valueSet = indexes.valueMethodsByStructId.get(structId); - const satisfiesByValue = - valueSet !== undefined && - methodSetSatisfies(valueSet, required, indexes.signatureContextByDefId); - implementors.push({ - structDefId: structId, - receiverForm: satisfiesByValue ? 'value' : 'pointer', - }); + // Hoisted, not recomputed per set: `substituteMethodSet` rewrites + // SIGNATURES and returns the identical key set, and `candidateStructIdsFor` + // keys off nothing but those method names — so every set below has exactly + // these candidates. Materialized because it is iterated once per + // instantiation and one of the branches behind it yields a live iterator. + const candidateStructIds = [...candidateStructIdsFor(required, indexes)]; + // The declaration's own method set, then one per observed instantiation. + // The declaration set runs FIRST and unconditionally, so this is strictly + // additive: every implementor found before #2855 is still found, in the + // same order, and instantiation only ever appends. + const formByStructId = new Map(); + for (const candidateSet of [required, ...instantiatedMethodSetsFor(iface, required, indexes)]) { + for (const structId of candidateStructIds) { + if (formByStructId.get(structId) === 'value') continue; + const pointerSet = indexes.effectiveMethodsByStructId.get(structId); + if (pointerSet === undefined) continue; + // MS(*T) is the superset: if it does not satisfy, neither does MS(T). + if (!methodSetSatisfies(pointerSet, candidateSet, indexes.signatureContextByDefId)) + continue; + // Then ask the narrower question separately — does the VALUE type satisfy? + // This is the distinction `var x I = T{}` turns on, and it is a fact about + // the program, not a heuristic. + const valueSet = indexes.valueMethodsByStructId.get(structId); + const satisfiesByValue = + valueSet !== undefined && + methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId); + formByStructId.set(structId, satisfiesByValue ? 'value' : 'pointer'); + } } + const implementors: GoStructuralImplementor[] = [...formByStructId].map( + ([structDefId, receiverForm]) => ({ structDefId, receiverForm }), + ); if (implementors.length > 0) implementations.set(iface.nodeId, implementors); } return implementations; } +/** + * The method set of each observed INSTANTIATION of a generic interface. + * + * Go spec, Instantiations: "A generic function or type is instantiated by + * substituting type arguments for the type parameters. … Each type argument is + * substituted for its corresponding type parameter in the generic declaration. … + * Instantiating a type results in a new non-generic named type." Combined with + * Type definitions ("Generic types must be instantiated when they are used") the + * consequence is that `Repo` is not a type at all and `Repo[User]` is — with + * method set `{ Save(x User) }` after substitution. Implementing an interface + * then asks whether a type "is an element of the type set of I", and Basic + * interfaces defines that type set as "the set of types which implement all of + * those methods". `UserRepo`, whose method set contains `Save(x User)`, is an + * element of `Repo[User]`'s type set — so it implements `Repo[User]`, and a call + * through a `Repo[User]`-typed field really can land on `UserRepo.Save`. Before + * this, it could not: the required parameter type stayed the type PARAMETER `T`, + * matched no implementor's `User`, and the interface got no IMPLEMENTS edge at + * all. That is the same false-silence shape as #2813/#2829, one abstraction up. + * + * SUBSTITUTION, NOT ERASURE. `Repo[Order]` instantiates to `Save(x Order)` and + * is NOT satisfied by a `Save(x User)` implementor. Treating `T` as a wildcard + * would satisfy both and mint an edge Go does not have; the whole point of + * #2829 was that an exact model beats an approximate one. + * + * WHAT THIS DELIBERATELY DOES NOT MODEL. GitNexus holds one node per generic + * DECLARATION, not one per instantiation, so an interface instantiated at two + * different arguments in the same program unions their implementors onto the one + * `Repo` node — `Repo[User]` and `Repo[Order]` in the same repo both fan out to + * every type satisfying either. That is the same one-node-per-declaration + * over-approximation every nominal language in the graph already carries (a + * Kotlin `class UserRepo : Repo` yields `UserRepo IMPLEMENTS Repo`, argument + * discarded), and it is bounded by the arguments the program actually writes — + * strictly narrower than erasure, which admits arguments that appear nowhere. + * + * Constraints are out of reach by construction and that is correct: a generic + * interface used as a CONSTRAINT (`func F[T Repo[X]](…)`) is written in a type + * parameter list, which produces no type binding and no declared type, so no + * such site is ever harvested. Non-basic interfaces — the union/type-set kind + * that "may only be used as type constraints" (General interfaces) — declare no + * methods and are already dropped by the empty-method-set guard above. + */ +function instantiatedMethodSetsFor( + iface: SymbolDefinition, + required: MethodSet, + indexes: DetectionIndexes, +): MethodSet[] { + const typeParameters = indexes.typeParametersByInterfaceId.get(iface.nodeId); + if (typeParameters === undefined) return []; + const instantiations = indexes.instantiationsByInterfaceId.get(iface.nodeId); + if (instantiations === undefined || instantiations.size === 0) return []; + const indexByName = new Map(typeParameters.map((name, index) => [name, index])); + const sets: MethodSet[] = []; + for (const normalizedArgs of instantiations.values()) { + const substituted = substituteMethodSet( + required, + indexByName, + normalizedArgs, + indexes.signatureContextByDefId, + ); + if (substituted !== undefined) sets.push(substituted); + } + return sets; +} + +/** + * Rewrite a required method set under one instantiation, or `undefined` when any + * signature in it cannot be normalized (an unresolved import qualifier) — a + * partially substituted set would compare a mix of instantiated and + * uninstantiated types, so the instantiation is dropped whole. + * + * The substituted defs carry a synthetic node id that is deliberately absent + * from `signatureContextByDefId`. Their parameter/return types come out of here + * ALREADY normalized — the type arguments in the context that WROTE them, the + * rest in the interface's own — and `normalizeSignatureType` with no context is + * the identity beyond whitespace, so the comparison in `signaturesCompatible` + * cannot re-qualify a spelling that is already fully qualified. + */ +function substituteMethodSet( + required: MethodSet, + indexByName: ReadonlyMap, + normalizedArgs: readonly string[], + signatureContextByDefId: ReadonlyMap, +): MutableMethodSet | undefined { + const out = new Map(); + for (const [name, overloads] of required) { + const substitutedOverloads: SymbolDefinition[] = []; + for (const def of overloads) { + const context = signatureContextByDefId.get(def.nodeId); + const parameterTypes: string[] = []; + for (const parameterType of def.parameterTypes ?? []) { + const substituted = substituteSignatureType( + parameterType, + indexByName, + normalizedArgs, + context, + ); + if (substituted === undefined) return undefined; + parameterTypes.push(substituted); + } + let returnType: string | undefined; + if (def.returnType !== undefined) { + returnType = substituteSignatureType(def.returnType, indexByName, normalizedArgs, context); + if (returnType === undefined) return undefined; + } + substitutedOverloads.push({ + ...def, + nodeId: `${def.nodeId}\u0000instantiated`, + ...(def.parameterTypes !== undefined ? { parameterTypes } : {}), + ...(returnType !== undefined ? { returnType } : {}), + }); + } + out.set(name, substitutedOverloads); + } + return out; +} + +/** + * Placeholder for the type argument at position `i` while its enclosing type is + * normalized. NUL-delimited — the same separator `methodSetKey` already uses, + * and for the same reason: it cannot occur in Go source. + * + * Both halves of that choice are load-bearing. `qualifyGoSignatureTypes` rewrites + * only tokens matching `[A-Za-z_][A-Za-z0-9_]*`, which can start with neither NUL + * nor a digit, so the placeholder survives normalization untouched; and + * `normalizeSignatureType` strips `\s+` FIRST, so a whitespace-delimited + * placeholder would lose its delimiters and become indistinguishable from an + * array length (`[5]int`). + */ +const TYPE_PARAMETER_PLACEHOLDER = /\u0000(\d+)\u0000/g; + +/** + * Substitute type arguments into one signature type, preserving Go's type + * identity rules for everything around them. + * + * Substitution happens BEFORE normalization and reinstatement AFTER, so the + * argument's own spelling is never re-qualified by the interface's package while + * the rest of the type still is: `[]T` in package `repo` with argument + * `internal/model.User` yields `[]internal/model.User`, not + * `[]repo.internal/model.User`. Pointer, slice, map and variadic shape survive + * because only the identifier token is replaced (`*T` -> `*model.User`), which is + * what makes `Save(x T)` and `Save(x *T)` stay different methods. + */ +function substituteSignatureType( + typeName: string, + indexByName: ReadonlyMap, + normalizedArgs: readonly string[], + context: SignatureContext | undefined, +): string | undefined { + const placeheld = typeName.replace( + /[A-Za-z_][A-Za-z0-9_]*/g, + (token, offset: number, source: string) => { + // `pkg.T` names `T` in package `pkg`, never the type parameter `T`. + if (hasPackageQualifierDot(source, offset)) return token; + const index = indexByName.get(token); + return index === undefined ? token : `\u0000${index}\u0000`; + }, + ); + const normalized = normalizeSignatureType(placeheld, context); + if (normalized === undefined) return undefined; + return normalized.replace(TYPE_PARAMETER_PLACEHOLDER, (_match, digits: string) => { + return normalizedArgs[Number(digits)] ?? _match; + }); +} + /** * The key a method occupies in a method set. * @@ -529,15 +935,7 @@ function resolveEmbeddedInterface( ): SymbolDefinition | undefined { const bound = resolveInheritanceBaseInScope(site.inScope, site.name, indexes.scopeIndexes); if (bound !== undefined) return bound.type === 'Interface' ? bound : undefined; - - const simpleName = simpleTypeName(site.name); - const matches: SymbolDefinition[] = []; - for (const iface of indexes.interfaceById.values()) { - if (iface.qualifiedName === site.name || iface.qualifiedName === simpleName) { - matches.push(iface); - } - } - return matches.length === 1 ? matches[0] : undefined; + return uniqueInterfaceNamed(site.name, indexes.interfacesByQualifiedName); } function simpleTypeName(name: string): string { diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts index 329c94447..d32e4d79b 100644 --- a/gitnexus/src/core/ingestion/languages/go/method-owners.ts +++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts @@ -3,6 +3,7 @@ import { logger } from '../../../logger.js'; import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import { goPackageDir, inferGoPackageName } from './package-clause.js'; +import { stampGoInterfaceTypeParameters } from './generic-type-parameters.js'; /** Bound on the sample of no-package-clause paths named in the warning. */ const SKIPPED_SAMPLE_CAP = 5; @@ -32,6 +33,13 @@ export function populateGoWorkspaceOwners( parsedFiles: readonly ParsedFile[], ctx: { readonly fileContents: ReadonlyMap }, ): void { + // Generic interfaces get their type-parameter list stamped here rather than at + // capture time, because this is the first main-thread hook that sees BOTH the + // parsed scopes and the file text (it already reads `fileContents` for the + // package clause). `detectGoInterfaceImplementations` runs later in the same + // pass and is the only reader. See `stampGoInterfaceTypeParameters`. + stampGoInterfaceTypeParameters(parsedFiles, ctx.fileContents); + const filesByPackage = new Map(); // A file with no resolvable package clause is dropped from ownership // resolution entirely — its methods never attach to a struct declared in a diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index b51daa4e9..99c72dc09 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -58,17 +58,23 @@ const JAVA_SCOPE_QUERY = ` (compact_constructor_declaration) @scope.function ;; Declarations — types +;; Optional-quantifier capture rather than a second pattern: a separate rule +;; would make every GENERIC declaration match twice under one def id, leaving +;; match order to decide which twin kept the parameters. (class_declaration - name: (identifier) @declaration.name) @declaration.class + name: (identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class (interface_declaration - name: (identifier) @declaration.name) @declaration.interface + name: (identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.interface (enum_declaration name: (identifier) @declaration.name) @declaration.enum (record_declaration - name: (identifier) @declaration.name) @declaration.record + name: (identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.record (annotation_type_declaration name: (identifier) @declaration.name) @declaration.class diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index a5cf3059a..d3ba94791 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -18,7 +18,9 @@ * inferred from leading JSDoc comments. A lightweight regex scanner * (`parseJsDocParams` / `parseJsDocReturn`) extracts `@param {T} n` * and `@returns {T}` tags and emits synthetic captures positioned on - * the annotated function node. + * the annotated function node. `@type {T}` on a class FIELD is the same + * story one level down — it is the only way JavaScript can declare a + * field's type at all — and emits `@type-binding.class-field` (#2833). * * 4. **Shared synthesis passes** — destructuring, for-of map-tuple, and * instanceof narrowing passes are duplicated from `typescript/captures.ts` @@ -40,6 +42,7 @@ import { computeTsArityMetadata } from '../typescript/arity-metadata.js'; import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js'; import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js'; import { isStaticClassFieldBinding } from '../typescript/captures.js'; +import { reducesToContainedType } from '../typescript/interpret.js'; /** JavaScript's spelling of a class-field declaration — the TypeScript grammar * calls the same construct `public_field_definition`. Named here, not in the @@ -372,9 +375,140 @@ function parseJsDocType(text: string): string | null { return m ? m[1].trim() : null; } +/** + * A type REFERENCE, possibly qualified, generic, or unioned: + * `Repo`, `Repo`, `models.Repo`, `Handler`, `Repo|null`, + * `Repo | null`. + * + * Applied only to a string already capped by {@link JSDOC_TYPE_MAX_LENGTH}: + * the union and generic groups both nest quantifiers, so an unbounded + * non-matching input is a backtracking hazard, and a docblock's `{…}` payload + * is attacker-shaped text (it is whatever the file says). + * + * JSDoc's `{…}` payload is free text and carries shapes that are not + * references at all — record types (`{{a: number}}`), function types + * (`{function(string): void}`), the any-type `{*}`, parenthesized unions + * (`{(Repo|Other)}`). None of those name a class, so a field annotated with + * one is DECLINED rather than bound to whatever substring survives + * normalization. (`parseJsDocType`'s `[^}]+` also truncates a record type at + * its first `}`, which this rejects too.) + */ +/** Longest `@type {…}` payload considered. A type REFERENCE that names a class + * is far shorter; past this the string is a structural type or generated + * noise, which this pass declines anyway, and the cap is what keeps + * {@link JSDOC_TYPE_REFERENCE_RE}'s nested quantifiers off an unbounded + * input. */ +const JSDOC_TYPE_MAX_LENGTH = 200; + +const JSDOC_TYPE_REFERENCE_RE = + /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*<[\w$.,<>\s]*>)?(?:\s*\|\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*<[\w$.,<>\s]*>)?)*$/; + +/** + * The spelling a JSDoc `@type` should bind a class FIELD to, or `null` to + * decline. + * + * The as-written spelling is returned, NOT a reduced one: `interpretJsTypeBinding` + * carries `Repo` through to `TypeRef.rawName` untouched (user generics are + * not on `stripGeneric`'s wrapper list), and `resolveClassBindingForName` erases + * the arguments to `Repo` at lookup time. That is the same erasure every other + * language in #2833 relies on, so generics need no code here — verified, not + * assumed, by the capture probe in that issue. + * + * Two declines: + * - `reducesToContainedType` — the container spellings whose interpretation + * would yield the ELEMENT (`Repo[]`, `Array`, `Promise`). See + * that predicate for why a field must not take its element's type. + * - anything that is not a type reference (see JSDOC_TYPE_REFERENCE_RE). + * + * The leading `?` / `!` nullability sigils are JSDoc-specific decoration with no + * bearing on which class is named, so they are peeled first — `{?Repo}` binds + * `Repo` exactly as `{Repo|null}` does. + */ +function jsDocFieldTypeSpelling(rawType: string): string | null { + const spelling = rawType + .trim() + .replace(/^[?!]+/, '') + .trim(); + if (spelling === '' || spelling.length > JSDOC_TYPE_MAX_LENGTH) return null; + if (reducesToContainedType(spelling)) return null; + if (!JSDOC_TYPE_REFERENCE_RE.test(spelling)) return null; + return spelling; +} + +/** + * The identifier a JSDoc `@type` may bind a `field_definition` to, or `null` if + * this field takes no docblock binding at all (#2833). + * + * Two refusals, and both are cheaper to answer than the docblock search they + * gate, which is why they run before it: + * + * - `static` fields are dropped, exactly as the query-driven annotation path + * drops them in `emitJsScopeCaptures` — a static member belongs to the class + * object and would silently RETYPE an instance field of the same name. The + * full cost of that trade, measured, is in `isStaticClassFieldBinding` + * (#2807). Re-checked here because the synthesis pass runs outside the + * match loop that applies it. + * - a name that is not a plain identifier (a computed key, a string key) + * names nothing `this.x` could look up. + * + * The JavaScript grammar names a field's name `property:`, not `name:`. `#priv` + * arrives as `private_property_identifier`; TypeScript binds those under their + * `#`-prefixed spelling, which is how `this.#priv` looks it up. + */ +function jsDocBindableFieldName(node: SyntaxNode): SyntaxNode | null { + if (isStaticClassFieldBinding(node, JS_CLASS_FIELD_DEFINITION_TYPES)) return null; + const nameNode = node.childForFieldName('property'); + if ( + nameNode === null || + (nameNode.type !== 'property_identifier' && nameNode.type !== 'private_property_identifier') + ) { + return null; + } + return nameNode; +} + +/** + * Emit the class-FIELD type binding a JSDoc `@type {T}` block declares (#2833). + * + * JavaScript has no type annotations, so a docblock is the only way a field + * can declare one — and measured before this branch, `/** @type {Repo} *​/ + * repo;` bound NOTHING, taking down the non-generic control (`{Plain}`) with + * it. TypeScript's equivalent `repo: Repo` has always bound, via the + * `@type-binding.annotation` rule on `public_field_definition`; this reaches the + * same DESTINATION from the docblock — an annotation-strength binding on the + * enclosing Class scope, which is the only place `typeOfMemberOnClass` reads a + * field's type — so the compound-receiver resolver finds it the way it always + * has. No resolution-side change. See the tag note on the emit below for why + * the marker is `class-field` rather than `annotation`. + */ +function emitJsDocFieldBinding( + docComment: string, + nameNode: SyntaxNode, + out: CaptureMatch[], +): void { + const rawType = parseJsDocType(docComment); + const spelling = rawType === null ? null : jsDocFieldTypeSpelling(rawType); + if (spelling === null) return; + out.push({ + '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text), + '@type-binding.type': syntheticCapture('@type-binding.type', nameNode, spelling), + // `class-field`, not `annotation`: this is the JS provider's own + // marker for a binding that must be HOISTED to the enclosing Class + // scope, which is where `typeOfMemberOnClass` reads a field's type. + // `jsBindingScopeFor` does that walk; `interpretJsTypeBinding` then + // remaps the tag to `annotation` so the source strength is the same + // as TypeScript's `repo: Repo`. Measured: with `annotation` + // the binding lands on the innermost scope and the field never + // types — the same shape `synthesizeConstructorFieldBindings` needs + // for `this.p = new Outer()`. + '@type-binding.class-field': syntheticCapture('@type-binding.class-field', nameNode, '1'), + }); +} + /** * Walk the AST and synthesize `@type-binding.*` captures from JSDoc - * comments immediately preceding function declarations / expressions. + * comments immediately preceding function declarations / expressions and class + * field definitions. * * Only `/** … *​/` block comments are scanned. Line comments (`//`) are * intentionally excluded — JSDoc lives in block comments. @@ -385,10 +519,23 @@ function parseJsDocType(text: string): string | null { * - `@type-binding.annotation` for `@type {T}` on `let`/`const`/`var` * declarations — covers the common `/** @type {User} *​/ const u = …` * pattern (ECMA-262 §14.3.1/§14.3.2 variable declarations). + * - `@type-binding.class-field` for `@type {T}` on a `field_definition` + * (#2833) — see {@link emitJsDocFieldBinding}. * * The binding is anchored on the function node so `tsBindingScopeFor` * can hoist method return-type bindings to Module scope (matching the * TypeScript path where `hoistTypeBindingsToModule: true`). + * + * `field_definition` is a node kind of THIS walk rather than a pass of its own, + * even though a field's anchor and name are the field itself while every other + * branch keys off a function-like anchor. A separate pass would be a ninth + * full-tree traversal of `emitJsScopeCaptures`, and measured on + * `dist/core/ingestion/workers/parse-worker.js` (2.4k lines, 17.3k nodes) one + * `namedChildren` walk costs 14.3 ms against 7.5 ms to PARSE the whole file — + * `node.namedChildren` materializes a fresh array of node wrappers across the + * N-API boundary at every node. The two node kinds share this walk's preceding- + * comment search and nothing else, so the branch below returns as soon as it + * has emitted. */ function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void { const stack: SyntaxNode[] = [root]; @@ -404,8 +551,15 @@ function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void { const isMethodDef = node.type === 'method_definition'; // Also check lexical_declaration containing an arrow/fn-expression const isLexDecl = node.type === 'lexical_declaration' || node.type === 'variable_declaration'; + const isFieldDef = node.type === 'field_definition'; - if (!isFnDecl && !isMethodDef && !isLexDecl) continue; + if (!isFnDecl && !isMethodDef && !isLexDecl && !isFieldDef) continue; + + // Non-null exactly for a field that can carry a binding, so it doubles as + // the branch selector inside the comment search below. Answered before that + // search because an unbindable field has no reason to look for a docblock. + const fieldNameNode = isFieldDef ? jsDocBindableFieldName(node) : null; + if (isFieldDef && fieldNameNode === null) continue; // For `export function foo() { ... }`, the JSDoc comment precedes the // wrapping export_statement, not the inner function_declaration. @@ -418,6 +572,14 @@ function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void { while (sibling !== null && sibling.type === 'comment') { const text = sibling.text; if (text.startsWith('/**')) { + // A field's docblock declares its own type and nothing else — `@param` / + // `@returns` on a field name no callable — so this branch does not fall + // through to the function-like tags below. + if (fieldNameNode !== null) { + emitJsDocFieldBinding(text, fieldNameNode, out); + break; + } + // Found a JSDoc block. const params = parseJsDocParams(text); const retType = parseJsDocReturn(text); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index 7ec7cecc8..f442a2b37 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -81,13 +81,22 @@ const KOTLIN_SCOPE_QUERY = ` (lambda_literal) @scope.block ;; Declarations — types +;; The Kotlin grammar puts NO named fields on \`class_declaration\`, so the +;; parameter list is matched positionally as an optional unnamed child, exactly +;; as the name already is. +;; +;; Only the INLINE bound (\`\`) is read. A \`where T : Repo\` clause is a +;; separate \`type_constraints\` sibling and is left alone, so its bound reads as +;; absent — "unknown", not "unbounded". (class_declaration "interface" - (type_identifier) @declaration.name) @declaration.interface + (type_identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.interface (class_declaration "class" - (type_identifier) @declaration.name) @declaration.class + (type_identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.class (object_declaration (type_identifier) @declaration.name) @declaration.class diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts index de7e0a8c7..7d0ba28c2 100644 --- a/gitnexus/src/core/ingestion/languages/php/captures.ts +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -28,6 +28,11 @@ * a `@type-binding.alias` match binding the loop variable to the * element type of the iterable (resolved from PHPDoc or scopeEnv). * + * 6. **PHPDoc `@var` property synthesis** — a docblock on an UNTYPED + * property emits the `@type-binding.annotation` + `@declaration.property` + * pair the native typed-property rules emit, which is the only way PHP + * can declare a generic field type (#2833). + * * Pure given the input source text. No I/O, no globals consulted. */ @@ -136,6 +141,17 @@ export function emitPhpScopeCaptures( } } + // The one full-tree walk: class/trait heritage, and PHPDoc `@var` on an + // untyped property. Run BEFORE the match loop rather than appended after it, + // because the property declarations the `@var` half claims must join + // `typedPropertyAnchorIds`: it emits the same `@declaration.property` the + // typed rule does, so without this the loose `@declaration.variable` + // catch-all would declare the very same node a second time under its + // `$`-sigilled name — exactly the duplicate the set above exists to suppress. + // Its matches are still appended in the original order after the loop. + const walked = synthesizePhpTreeWalkCaptures(tree.rootNode); + for (const id of walked.docPropertyAnchorIds) typedPropertyAnchorIds.add(id); + for (const m of rawMatches) { // Group captures by their tag name. Tree-sitter strips the leading // `@`; we put it back so the central extractor's prefix lookups work. @@ -360,21 +376,55 @@ export function emitPhpScopeCaptures( out.push(grouped); } - out.push(...synthesizePhpInheritanceReferences(tree.rootNode)); + out.push(...walked.inheritance); + out.push(...walked.docProperties); out.push(...synthesizeCallableFlowCaptures(tree.rootNode, PHP_CALLABLE_CAPTURE_OPTIONS)); return out; } -// ─── PHP inheritance synthesis ─────────────────────────────────────────────── +// ─── PHP whole-tree synthesis ──────────────────────────────────────────────── /** - * Synthesize `@reference.inherits` captures from PHP class/trait heritage so - * the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges - * (mirrors C# `synthesizeCsharpInheritanceReferences` / C++ - * `emitCppInheritanceCaptures`). Without this, PHP inheritance edges came only - * from the legacy heritage-capture leg (removed in #942), which the worker - * pipeline drops for registry-primary languages (issue #1951). + * The single `walkNamedTree` pass of `emitPhpScopeCaptures`, dispatching every + * synthesis that needs to see the whole tree. + * + * ONE walk, not one per synthesis. A tree-sitter node walk is not cheap next to + * the work it feeds: measured on a 1.2k-line PHP source (9.6k nodes), a single + * `walkNamedTree` pass costs 7.4 ms against 2.1 ms to PARSE the file, because + * every step materializes node wrappers across the N-API boundary. So a new + * node kind is a branch here rather than a pass of its own — the two below emit + * into separate arrays, and `emitPhpScopeCaptures` appends them in the order + * they were appended when they were two passes. + * + * The `@reference.inherits` half exists so the registry-primary + * scope-resolution path emits EXTENDS / IMPLEMENTS edges (mirrors C# + * `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`). + * Without it, PHP inheritance edges came only from the legacy heritage-capture + * leg (removed in #942), which the worker pipeline drops for registry-primary + * languages (issue #1951). See {@link emitPhpDocPropertyBinding} for the other. + */ +function synthesizePhpTreeWalkCaptures(root: SyntaxNode): { + readonly inheritance: readonly CaptureMatch[]; + readonly docProperties: readonly CaptureMatch[]; + readonly docPropertyAnchorIds: ReadonlySet; +} { + const inheritance: CaptureMatch[] = []; + const docProperties: CaptureMatch[] = []; + const docPropertyAnchorIds = new Set(); + walkNamedTree(root, (node) => { + if (node.type === 'class_declaration' || node.type === 'trait_declaration') { + emitPhpHeritageReferences(node, inheritance); + } else if (node.type === 'property_declaration') { + emitPhpDocPropertyBinding(node, docProperties, docPropertyAnchorIds); + } + }); + return { inheritance, docProperties, docPropertyAnchorIds }; +} + +/** + * Emit `@reference.inherits` for the heritage of one `class_declaration` or + * `trait_declaration`. * * Scope matches the legacy PHP heritage query (tree-sitter-queries.ts * PHP_QUERIES extends / implements / trait-use captures): @@ -396,24 +446,18 @@ export function emitPhpScopeCaptures( * || type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'`), so `use Trait` resolves to * IMPLEMENTS on both the legacy and registry-primary paths. */ -function synthesizePhpInheritanceReferences(root: SyntaxNode): CaptureMatch[] { - const out: CaptureMatch[] = []; - walkNamedTree(root, (node) => { - if (node.type === 'class_declaration') { - // extends: single base_clause child carrying one base name. - const baseClause = findNamedChild(node, 'base_clause'); - if (baseClause !== null) emitPhpBaseNames(baseClause, out); - // implements: class_interface_clause may list several interfaces. - const ifaceClause = findNamedChild(node, 'class_interface_clause'); - if (ifaceClause !== null) emitPhpBaseNames(ifaceClause, out); - // trait use: `use TraitName;` inside the class body. - emitPhpTraitUses(node, out); - } else if (node.type === 'trait_declaration') { - // trait-uses-trait: `use OtherTrait;` inside a trait body. - emitPhpTraitUses(node, out); - } - }); - return out; +function emitPhpHeritageReferences(node: SyntaxNode, out: CaptureMatch[]): void { + if (node.type === 'class_declaration') { + // extends: single base_clause child carrying one base name. + const baseClause = findNamedChild(node, 'base_clause'); + if (baseClause !== null) emitPhpBaseNames(baseClause, out); + // implements: class_interface_clause may list several interfaces. + const ifaceClause = findNamedChild(node, 'class_interface_clause'); + if (ifaceClause !== null) emitPhpBaseNames(ifaceClause, out); + } + // trait use: `use TraitName;` inside the class body, and trait-uses-trait: + // `use OtherTrait;` inside a trait body. + emitPhpTraitUses(node, out); } /** @@ -648,21 +692,55 @@ const PHP_PRIMITIVES = new Set([ ]); /** - * Collect comment text from siblings immediately before `fnNode`. - * Skips PHP 8+ attribute_list nodes. + * The comment siblings immediately preceding `node`, in SOURCE order (the + * nearest comment last), stopping at the first named sibling that is not a + * comment or a PHP 8+ attribute. + * + * The single implementation of that chain walk. Every PHPDoc reader in this + * file wants the same siblings under the same stop rule — `@param`/`@return` on + * a method, `@var` for a foreach element type, `@var` for a field type — and + * three hand-copied walks meant a fix to the stop rule (attributes between the + * docblock and the declaration, say) could land on one reader and not the + * others, which shows up as a field typed differently from its own foreach + * element type. */ -function collectPrecedingComments(fnNode: SyntaxNode): string { - const texts: string[] = []; - let sibling = fnNode.previousSibling; +function precedingCommentSiblings(node: SyntaxNode): SyntaxNode[] { + const comments: SyntaxNode[] = []; + let sibling = node.previousSibling; while (sibling !== null) { if (sibling.type === 'comment') { - texts.unshift(sibling.text); + comments.unshift(sibling); } else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) { break; } sibling = sibling.previousSibling; } - return texts.join('\n'); + return comments; +} + +/** + * First match of `re` over {@link precedingCommentSiblings}, searched from the + * NEAREST comment outward — a docblock written directly above the declaration + * wins over one further up, and an earlier comment is still reached when the + * nearest one carries no such tag. + */ +function nearestPrecedingCommentMatch(node: SyntaxNode, re: RegExp): RegExpExecArray | null { + const comments = precedingCommentSiblings(node); + for (let i = comments.length - 1; i >= 0; i--) { + const m = re.exec(comments[i].text); + if (m !== null) return m; + } + return null; +} + +/** + * Collect comment text from siblings immediately before `fnNode`. + * Skips PHP 8+ attribute_list nodes. + */ +function collectPrecedingComments(fnNode: SyntaxNode): string { + return precedingCommentSiblings(fnNode) + .map((comment) => comment.text) + .join('\n'); } /** @@ -962,8 +1040,20 @@ function findClassPropertyElementType( return null; } -/** Regex for PHPDoc @var: `@var Type` */ -const PHPDOC_VAR_RE = /@var\s+(\S+)/; +/** + * PHPDoc `@var`, with the optional variable name PHPStan/Psalm allow + * (`@var Repo $repo`). `\S+` for the type deliberately: a docblock type is + * untyped text and everything past the first space is prose. + * + * ONE regex for both readings of the tag. The FIELD type + * ({@link synthesizePhpDocPropertyBindings}) needs group 2 to tell `@var Repo + * $other` from `@var Repo`; the foreach ELEMENT type + * ({@link extractPropertyElementType}) ignores it — and since the trailing group + * is optional it can never change what group 1 captures, so a second, narrower + * copy bought nothing but the chance of the two readings of one annotation + * drifting apart. + */ +const PHPDOC_VAR_RE = /@var\s+(\S+)(?:\s+\$(\w+))?/; /** * Extract element type from a property_declaration node: @@ -971,17 +1061,11 @@ const PHPDOC_VAR_RE = /@var\s+(\S+)/; * 2. PHP 7.4+ native type field (non-array) */ function extractPropertyElementType(propDecl: SyntaxNode): string | null { - // Strategy 1: PHPDoc @var on a preceding comment sibling - let sibling = propDecl.previousSibling; - while (sibling !== null) { - if (sibling.type === 'comment') { - const m = PHPDOC_VAR_RE.exec(sibling.text); - if (m !== null) return normalizePhpDocType(m[1]); - } else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) { - break; - } - sibling = sibling.previousSibling; - } + // Strategy 1: PHPDoc @var on a preceding comment sibling. The `$name` group + // is not consulted: an element type is asked for by the ONE foreach that + // already named this property, so a mismatched name cannot mis-attribute it. + const varTag = nearestPrecedingCommentMatch(propDecl, PHPDOC_VAR_RE); + if (varTag !== null) return normalizePhpDocType(varTag[1]); // Strategy 2: native type field — skip generic 'array' const typeNode = propDecl.childForFieldName('type'); if (typeNode === null) return null; @@ -989,3 +1073,184 @@ function extractPropertyElementType(propDecl: SyntaxNode): string | null { if (typeName === 'array' || typeName === '') return null; return normalizePhpDocType(typeName); } + +// ─── PHPDoc @var property synthesis ────────────────────────────────────────── + +/** + * Container spellings that base-name erasure would turn into a PHANTOM class. + * + * Erasing `list` to `list` names nothing — PHP has no `list` type — so the + * binding could only ever bind a user class that happens to be called `list`, + * i.e. exactly the wrong-edge direction. Every OTHER PHPDoc container erases to + * a name `normalizePhpType` already rejects as a primitive (`array` → + * `array`, `iterable` → `iterable`) or to a real class whose methods are + * what the field's receiver actually calls (`Collection` → `Collection`, + * `Generator` → `Generator`), so this set holds one entry, not a + * catalogue. + * + * Compared CASE-FOLDED, not by listing spellings: a deny-set that must be kept + * in sync by vigilance drifts (#2833, the same lesson python/interpret.ts + * records for its own reduction). + */ +const PHPDOC_PHANTOM_CONTAINER_BASES: ReadonlySet = new Set(['list']); + +/** + * Erase type ARGUMENTS from a docblock type, leaving the base name: + * `Repo` → `Repo`, `Repo>` → `Repo`, `Repo|null` → + * `Repo|null`. Bracket-counting rather than a regex so a nested or + * multi-argument spelling reduces in one pass; an unbalanced `<` simply + * swallows the tail, which is the declining direction. + * + * NOT the shared `stripTemplateArguments`, and the difference is the UNION: + * that one truncates at the first `<`, so `Repo|null` becomes `Repo` and + * the nullability is lost with the arguments. A docblock type is the one place + * a union survives to the binding — `interpretPhpTypeBinding` runs + * `normalizePhpType` over what this returns, and that is what strips `|null` + * exactly as it does for a native `Repo|null` property. So a PHP docblock needs + * the arguments gone and the rest of the spelling intact, which is a different + * operation and not a candidate for a seventh caller of the shared one. + */ +function erasePhpDocTypeArguments(text: string): string { + let out = ''; + let depth = 0; + for (const ch of text) { + if (ch === '<') depth++; + else if (ch === '>') { + if (depth > 0) depth--; + } else if (depth === 0) out += ch; + } + return out; +} + +/** + * The type name a property's PHPDoc `@var` should bind the FIELD to, or `null` + * to decline. + * + * Two normalizations happen here and nowhere else, and each is forced: + * + * 1. TYPE-ARGUMENT ERASURE (`Repo` → `Repo`). Every sibling language in + * #2833 lets the as-written spelling reach `TypeRef.rawName` and leaves the + * erasure to `resolveClassBindingForName`. PHP cannot: `normalizePhpType` + * reduces `X` to `Y` — the CONTAINER-ELEMENT convention, pinned by + * `test/integration/resolvers/php.test.ts` ("normalizePhpType + * ('Collection') must yield 'User', not 'Collection'") because the + * foreach path depends on it. Measured: passing `Repo` through binds + * the field to `User` and `$this->repo->save()` emits `User::save` — a + * WRONG edge, not a missing one. So a field's type arguments are erased + * HERE, before that rule can read them, and the element convention is left + * exactly as it was for `@param` / `@return` / foreach. + * + * 2. ARRAY DECLINE (`Repo[]` → nothing). A field annotated `Repo[]` holds an + * ARRAY; typing it `Repo` is a wrong field type, and the collision is real + * rather than theoretical — a repository class with a `find` / `filter` / + * `map` method would claim `$this->repos->find(…)`. The element type is + * already extracted separately for the one construct that wants it: + * `extractPropertyElementType` reads the same `@var` for `foreach + * ($this->repos as $r)`. Declining here keeps the two readings of one + * annotation from colliding. + * + * Everything else is delegated: `interpretPhpTypeBinding` applies the SAME + * `normalizePhpType` the native typed property (`private Repo $repo;`) goes + * through, so nullable (`?Repo`), null-union (`Repo|null`), intersection, + * fully-qualified (`\App\Models\Repo`, kept qualified on purpose — see that + * function) and every primitive / `mixed` / `self` / `static` rejection behave + * identically for the two spellings by construction, not by duplication. + */ +function phpDocPropertyFieldType(rawType: string): string | null { + const erased = erasePhpDocTypeArguments(rawType).trim(); + if (erased === '') return null; + // Array-of: declined (see 2 above). Checked AFTER erasure so `Repo[]` + // is recognised as an array too. + if (erased.endsWith('[]')) return null; + if (PHPDOC_PHANTOM_CONTAINER_BASES.has(erased.toLowerCase())) return null; + return erased; +} + +/** + * Emit the field type-binding a PHPDoc `@var` block declares on one UNTYPED + * property declaration (`/** @var Repo *​/ private $repo;`), and record its + * anchor id in `anchorIds`. + * + * PHP's own type story leans on docblocks for everything its native syntax + * cannot spell — and generics are exactly that, since `private Repo + * $repo;` is a parse error. The native TYPED property already binds via the + * `@type-binding.annotation` rule in `query.ts`; measured before this pass, the + * docblock form bound NOTHING, so `$this->repo->save()` lost its edge for both + * the generic spelling and its non-generic control (#2833). + * + * The emitted match is byte-identical in SHAPE to what that query rule emits — + * `@type-binding.annotation` anchored on the `property_declaration`, with + * `@type-binding.name` carrying the `$`-sigilled variable name. That is the + * whole design: `interpretPhpTypeBinding` strips the sigil for source + * `'annotation'`, `phpBindingScopeFor` places it on the same scope, and the + * compound-receiver resolver finds it in `typeBindings` the way it always has. + * No resolution-side code changes. + * + * Declines, each because the annotation cannot be ATTRIBUTED rather than + * because the type is unusable: + * - a property that already has a native `type:` — the query rule owns it, + * and a docblock repeating it must not emit a second, competing binding; + * - `private $a, $b;` — one `@var` cannot say which element it types; + * - `@var Repo $other` naming a DIFFERENT property than the one it precedes. + */ +function emitPhpDocPropertyBinding( + node: SyntaxNode, + matches: CaptureMatch[], + anchorIds: Set, +): void { + // A native type hint already produces the binding via query.ts. + if (node.childForFieldName('type') !== null) return; + + const elements = node.namedChildren.filter( + (c): c is SyntaxNode => c !== null && c.type === 'property_element', + ); + if (elements.length !== 1) return; + const varNameNode = elements[0].childForFieldName('name') ?? elements[0].firstNamedChild; + if (varNameNode === null || varNameNode.type !== 'variable_name') return; + + const raw = findPhpDocVarTag(node); + if (raw === null) return; + // `@var Repo $other` on `private $repo;` types neither — decline. + if (raw.varName !== undefined && '$' + raw.varName !== varNameNode.text) return; + + const typeName = phpDocPropertyFieldType(raw.type); + if (typeName === null) return; + + anchorIds.add(node.id); + matches.push({ + '@type-binding.annotation': nodeToCapture('@type-binding.annotation', node), + '@type-binding.name': syntheticCapture('@type-binding.name', varNameNode, varNameNode.text), + '@type-binding.type': syntheticCapture('@type-binding.type', varNameNode, typeName), + }); + // …and the FIELD declaration, which the native rule emits as its own + // separate match. Without it the property stays a `@declaration.variable` + // named `$repo` — a Variable, not a class-owned member — and the type + // binding alone is not enough: measured, `$this->repo->save()` resolved + // while `save` was unique to one class and went UNRESOLVED as soon as a + // second class declared a `save`, because narrowing a same-named method + // needs the receiver's member to be owned. The native typed property + // resolved the identical file. The `$` is stripped for the same reason it + // is on the native path: PHP stores field names unsigilled so `$obj->repo` + // looks up `repo`. + matches.push({ + '@declaration.property': nodeToCapture('@declaration.property', node), + '@declaration.name': syntheticCapture( + '@declaration.name', + varNameNode, + varNameNode.text.replace(/^\$/, ''), + ), + }); +} + +/** + * The `@var` tag on the comment siblings immediately preceding `propDecl` — + * the same chain, the same regex and the same nearest-first order + * `extractPropertyElementType` reads the tag through, so the two readings of + * one annotation cannot disagree about WHICH annotation they read. + */ +function findPhpDocVarTag( + propDecl: SyntaxNode, +): { readonly type: string; readonly varName?: string } | null { + const m = nearestPrecedingCommentMatch(propDecl, PHPDOC_VAR_RE); + return m === null ? null : { type: m[1], varName: m[2] }; +} diff --git a/gitnexus/src/core/ingestion/languages/python/interpret.ts b/gitnexus/src/core/ingestion/languages/python/interpret.ts index 1144a7671..e1a9f60ba 100644 --- a/gitnexus/src/core/ingestion/languages/python/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/python/interpret.ts @@ -147,6 +147,50 @@ function stripForwardRefQuotes(text: string): string { return text; } +/** + * Container bases whose SINGLE type argument is the element type. + * + * The single source of truth for both the matcher below and the property test + * that asserts every one of them is also declined as a user generic — the two + * lists drifting apart is the defect this arrangement exists to make + * impossible. Order is significant only in that it is the regex alternation + * order; keep additions grouped with their family. + */ +export const SINGLE_ARG_CONTAINERS: readonly string[] = [ + 'list', + 'List', + 'set', + 'Set', + 'tuple', + 'Tuple', + 'Iterable', + 'Iterator', + 'Sequence', + 'Generator', + 'AsyncIterable', + 'AsyncIterator', +]; + +/** Container bases whose SECOND type argument is the value type. See {@link SINGLE_ARG_CONTAINERS}. */ +export const MAPPING_CONTAINERS: readonly string[] = [ + 'dict', + 'Dict', + 'Mapping', + 'MutableMapping', + 'OrderedDict', + 'DefaultDict', +]; + +const QUALIFIER = '(?:[A-Za-z_][A-Za-z0-9_]*\\.)?'; + +const SINGLE_ARG_CONTAINER_RE = new RegExp( + `^${QUALIFIER}(?:${SINGLE_ARG_CONTAINERS.join('|')})\\[([^,\\]]+)\\]$`, +); + +const MAPPING_CONTAINER_RE = new RegExp( + `^${QUALIFIER}(?:${MAPPING_CONTAINERS.join('|')})\\[[^,\\]]+,\\s*([^\\]]+)\\]$`, +); + /** * Unwrap a single-arg generic collection wrapper — `list[User]`, * `set[User]`, `Iterable[User]`, `Sequence[User]`, `Iterator[User]`, @@ -159,9 +203,7 @@ function stripForwardRefQuotes(text: string): string { * resolution time. */ function stripGeneric(text: string): string { - const single = text.match( - /^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:list|List|set|Set|tuple|Tuple|Iterable|Iterator|Sequence|Generator|AsyncIterable|AsyncIterator)\[([^,\]]+)\]$/, - ); + const single = text.match(SINGLE_ARG_CONTAINER_RE); if (single !== null) return single[1].trim(); // dict[K, V] / Dict[K, V] / Mapping[K, V] — strip to value type V. // For-loop destructuring of `for k, v in d.items()` binds `v` to @@ -170,13 +212,205 @@ function stripGeneric(text: string): string { // only shape worth handling. Match a top-level K up to the first // comma and a V to the closing bracket; nested generics in V (e.g. // `dict[str, list[User]]`) are left for a downstream strip pass. - const dict = text.match( - /^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:dict|Dict|Mapping|MutableMapping|OrderedDict|DefaultDict)\[[^,\]]+,\s*([^\]]+)\]$/, - ); + const dict = text.match(MAPPING_CONTAINER_RE); if (dict !== null) return dict[1].trim(); + + // A subscripted type the two allow-lists above did NOT claim is a + // user-defined GENERIC, not a container: `Repo[User]`, `Handler[Req, Res]`. + // Its base names one declaration — `Repo[User]` and `Repo[Order]` are the + // same `class Repo(Generic[T])` — so reduce to that base, exactly as Java's + // and Swift's interpreters already do for their `<…>` spelling (#2833). + // + // Guarded by a DENY set rather than reached by fallthrough, because "the two + // rules above did not match" is NOT the same as "not a container". Two + // measured counterexamples, both of which this branch got wrong before the + // guard existed: + // - `dict[str, list[User]]` — the dict rule's value group cannot span a + // nested `]`, so it declines and the shape falls through. Reducing it to + // `dict` destroys the value type the dict rule explicitly leaves "for a + // downstream strip pass"; the annotation must survive intact instead. + // - `Callable[[int], User]`, `Literal["a"]`, `Annotated[int, F()]`, + // `Union[A, B]`, `tuple[int, ...]` — typing SPECIAL FORMS, not classes. + // Reducing them yields a bare `Callable`/`Literal`/`Union`, which binds + // to a workspace class of that name if one exists — a fabricated edge, + // and those names are ordinary enough for a real codebase to declare. + // Anything named here keeps its as-written text and resolves as it did + // before #2833. + // + // Only reached for genuine annotations: every Python `@type-binding.type` + // capture is a `(type)`, `(identifier)`, `(attribute)` or `(dotted_name)` + // node, so a subscripted VALUE expression (`arr[0]`) never arrives here. + // + // The as-written spelling is not lost — `scope-extractor` keeps it on + // `TypeRef.declaredSpelling` whenever it differs from the reduced name, + // which is what the receiver fold's index step reads. + const userGeneric = text.match(/^((?:[A-Za-z_][A-Za-z0-9_]*\.)*[A-Za-z_][A-Za-z0-9_]*)\[.+\]$/s); + if (userGeneric !== null) { + const qualified = userGeneric[1].trim(); + const base = qualified.slice(qualified.lastIndexOf('.') + 1); + if (!isNotAUserGenericBase(base)) return qualified; + } return text; } +/** + * Whether a subscripted annotation's base names a Python type-system construct + * rather than a workspace class — see {@link NOT_A_USER_GENERIC_SPELLINGS}. + * + * CASE-FOLDED, and that is the load-bearing part. PEP 585 gave nearly every + * container two spellings — the builtin/`collections` one and the `typing` + * alias (`deque` / `typing.Deque`, `frozenset` / `typing.FrozenSet`, + * `defaultdict` / `typing.DefaultDict`) — which differ ONLY in case. Matching + * exactly meant each pair had to be listed twice and any half-pair was a silent + * escape: `deque` was listed, `Deque` was not, so `self.dq: Deque[User]` + * reduced to `Deque` and bound to a workspace `class Deque` (#2855). Folding + * case closes that axis by construction instead of by vigilance. + * + * The cost is that a workspace class whose name is a case VARIANT of a stdlib + * construct (`class deque(Generic[T])`) stops reducing. PEP 8 makes such a + * class vanishingly rare, and the loss is a missing edge — recoverable — where + * the gain is not minting a confident wrong one. + */ +function isNotAUserGenericBase(base: string): boolean { + return NOT_A_USER_GENERIC.has(base.toLowerCase()); +} + +/** + * Bases a subscripted annotation may carry that are NOT user-defined generics. + * + * SCOPE — the standard library, and deliberately nothing else. The names below + * are the documented Python type-system surface (`typing`'s deprecated PEP 585 + * aliases and its special forms, plus the stdlib classes those aliases point + * at); that universe is CLOSED and versioned by CPython, so the list is + * auditable against + * . + * + * Third-party generics (`Mapped[int]`, `QuerySet[User]`) are NOT listed. That + * universe is open, so enumerating it only ever chases the last escape, and + * denying an ordinary name like `Model` would cost real edges in the many + * projects that legitimately declare one. Those spellings still reduce to their + * base, and the base is now admitted only on the grounds `resolveErasedBaseName` + * applies at resolution time — the file's scope chain binds it, the declaration + * is in this very file, the index proves the name is a template family, or the + * file has no cross-file class channel to be absent from. A `Mapped[User]` whose + * base the file cannot see therefore binds nothing, which is the structural + * answer this parse-time pass cannot give and no longer has to. + * + * Two distinct reasons to decline, both always-correct at this layer: + * - CONTAINERS, including ones the two rules above do not own. Reducing + * `deque[User]` to `deque` types a receiver as the container and retargets + * every call in a for-loop chain, and reducing `dict[str, list[User]]` to + * `dict` destroys the value type the dict rule leaves for a downstream pass. + * - `typing` SPECIAL FORMS, which are not classes at all. `Callable`, + * `Literal`, `Union` reduce to a bare name that binds to a workspace class + * of that name if one exists — a fabricated edge. + * + * Members are listed ONCE per case-insensitive concept: {@link + * isNotAUserGenericBase} folds case, so the builtin spelling covers its PEP 585 + * `typing` twin (`deque` covers `Deque`, `frozenset` covers `FrozenSet`). + * Non-generic ABCs (`Hashable`, `Sized`) are omitted — they cannot be written + * subscripted, so they never reach this branch. + * + * Exported for the property test that asserts the case-fold closure holds + * behaviourally; nothing else should read it. + */ +export const NOT_A_USER_GENERIC_SPELLINGS: readonly string[] = [ + // ── builtins subscriptable since PEP 585 ────────────────────────────────── + 'list', + 'set', + 'frozenset', + 'tuple', + 'dict', + 'type', + // ── `collections` ───────────────────────────────────────────────────────── + 'defaultdict', + 'OrderedDict', + 'ChainMap', + 'Counter', + 'deque', + // ── `collections.abc`, the subscriptable members ────────────────────────── + 'Mapping', + 'MutableMapping', + 'Sequence', + 'MutableSequence', + 'AbstractSet', + 'MutableSet', + 'Collection', + 'Container', + 'Reversible', + 'Iterable', + 'Iterator', + 'Generator', + 'AsyncIterable', + 'AsyncIterator', + 'AsyncGenerator', + 'Awaitable', + 'Coroutine', + 'KeysView', + 'ValuesView', + 'ItemsView', + 'MappingView', + // ── `contextlib`, and the `typing` aliases to it ────────────────────────── + 'ContextManager', + 'AsyncContextManager', + 'AbstractContextManager', + 'AbstractAsyncContextManager', + // ── `re`, and the `typing` aliases to it ────────────────────────────────── + // `Pattern` and `Match` ARE classes, so reducing them is not wrong the way + // reducing `Callable` is; they are declined because in Python annotations + // these spellings are overwhelmingly the `re` types, while a workspace class + // of the same name is a parser's own `Pattern`/`Match` and would be bound + // with no import evidence whatsoever. Same policy as the receiver-chain + // resolver's: a missing edge is recoverable, a confident wrong one is not. + 'Pattern', + 'Match', + // ── I/O streams (`typing.IO` and its two subclasses) ────────────────────── + 'IO', + 'TextIO', + 'BinaryIO', + // ── stdlib generic classes with ordinary names ──────────────────────────── + // Same policy call as `Pattern`/`Match` above, and the sharpest instance of + // it: `asyncio.Task[Result]` reduces to `asyncio.Task`, whose dotted-tail + // fallback then single-matches an unrelated workspace `class Task`. + 'Queue', + 'Task', + 'Future', + 'PathLike', + // ── `typing` special forms — not classes ────────────────────────────────── + 'Callable', + 'Literal', + 'Annotated', + 'Union', + 'Optional', + 'Final', + 'ClassVar', + // `typing.Type` is the PEP 585 alias for the builtin `type` listed above, and + // the case fold already covers it — see the one-entry-per-concept rule. + 'TypeGuard', + 'TypeIs', + 'Unpack', + 'Required', + 'NotRequired', + 'ReadOnly', + 'Concatenate', + 'LiteralString', + // ── generic machinery: bases and type-parameter declarations ────────────── + // `Generic[T]`/`Protocol[T]` are written subscripted for real. The three + // declaration forms are not subscriptable in valid Python, but this + // interpreter checks no grammar — it reduces whatever text the annotation + // capture carried — so they are declined defensively. + 'Generic', + 'Protocol', + 'TypeVar', + 'ParamSpec', + 'TypeVarTuple', +]; + +/** Case-folded lookup index over {@link NOT_A_USER_GENERIC_SPELLINGS}. */ +const NOT_A_USER_GENERIC: ReadonlySet = new Set( + NOT_A_USER_GENERIC_SPELLINGS.map((name) => name.toLowerCase()), +); + /** * Unwrap nullable type annotations so downstream resolution treats * `User | None`, `None | User`, and `Optional[User]` identically to diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts index 762efb336..f2ef4f4a0 100644 --- a/gitnexus/src/core/ingestion/languages/rust/query.ts +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -22,15 +22,18 @@ const RUST_SCOPE_QUERY = ` ;; Declarations — struct (struct_item - name: (type_identifier) @declaration.name) @declaration.struct + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct ;; Declarations — trait (trait_item - name: (type_identifier) @declaration.name) @declaration.trait + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.trait ;; Declarations — enum (enum_item - name: (type_identifier) @declaration.name) @declaration.enum + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.enum ;; Declarations — union ;; Deliberately tagged @declaration.struct (→ Struct label), NOT a @@ -42,7 +45,8 @@ const RUST_SCOPE_QUERY = ` ;; constructor, so Struct is both the resolvable and the semantically ;; honest label here. #1934 F71. (union_item - name: (type_identifier) @declaration.name) @declaration.struct + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct ;; Declarations — module (mod foo { ... } / mod foo;) ;; A Rust mod is an ITEM, not just a lexical region: rustc resolves the first diff --git a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts index acf511527..d4f15ef9f 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts @@ -310,3 +310,26 @@ function stripQualifier(text: string): string { if (lastDot === -1) return text; return text.slice(lastDot + 1); } + +/** + * Would this interpreter reduce `text` to the type it CONTAINS rather than to + * the type it names? True for the array suffix (`Repo[]`) and for every + * transparent wrapper on {@link stripGeneric}'s list (`Array`, + * `Promise`, `Set`, …). + * + * Exported for the ONE caller that must decline exactly what this returns true + * for: the JavaScript provider's JSDoc `@type` FIELD binding (#2833). Element + * reduction is right where it was built — a chain step, a `for…of` variable, an + * awaited value — and wrong for a field, whose declared type IS the container: + * a field annotated `{Repo[]}` reduced to `Repo` makes `this.repos.find(…)`, + * an Array method call, resolve to a repository class's own `find`. A wrong + * edge, which #2833 treats as strictly worse than a missing one. + * + * A predicate rather than a copied name list on purpose: the list lives in + * `stripGeneric` and a second copy would drift out of sync silently, exactly + * the failure mode `python/interpret.ts` records for its own reduction. + */ +export function reducesToContainedType(text: string): boolean { + const trimmed = stripReadonly(text.trim()); + return stripArraySuffix(trimmed) !== trimmed || stripGeneric(trimmed) !== trimmed; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index c67f8f777..04286ff66 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -150,14 +150,22 @@ export const TYPESCRIPT_SCOPE_QUERY = ` value: (object_type)) @scope.class ;; Declarations — types +;; The type-parameter list is captured with \`?\` rather than as a second +;; pattern: a separate rule would make a GENERIC declaration match twice, and +;; both matches mint the same def id (filePath+range+type+name), so which one +;; survived — the one carrying the parameters or the one without — would be +;; decided by match order. An optional child keeps it at one match either way. (class_declaration - name: (type_identifier) @declaration.name) @declaration.class + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class (abstract_class_declaration - name: (type_identifier) @declaration.name) @declaration.class + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class (interface_declaration - name: (type_identifier) @declaration.name) @declaration.interface + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.interface (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 63a4058fc..75381bcca 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -83,6 +83,7 @@ import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from import type { LanguageProvider } from './language-provider.js'; import { isValidReceiverChain } from './utils/receiver-chain-codec.js'; import { extractTemplateArguments } from './utils/template-arguments.js'; +import { parseTypeParameterList } from './utils/type-parameters.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -544,6 +545,12 @@ function pass2AttachDeclarations( const draftById = new Map(); for (const d of drafts) draftById.set(d.id, d); + // First def seen per `nodeId`, for the duplicate backfill below. Two query + // patterns can legitimately match ONE declaration — a C++ templated struct + // matches both the standalone `struct_specifier` rule and the + // `template_declaration` rule that wraps it — and both mint the same def id. + const firstDefByNodeId = new Map(); + for (const match of matches) { const anchor = anchorCaptureFor(match, '@declaration.'); if (anchor === undefined) continue; @@ -551,6 +558,31 @@ function pass2AttachDeclarations( const def = buildDefFromDeclarationMatch(match, anchor, filePath); if (def === undefined) continue; + // ── Duplicate-declaration backfill ─────────────────────────────────────── + // `buildDefIndex` is FIRST-WRITE-WINS, so when one declaration produces two + // defs under one id, whichever match tree-sitter reported first is the one + // resolution sees. That was harmless while the twins were byte-identical. + // It stops being harmless the moment one twin can carry a field the other + // structurally cannot: a C++ `template struct Vec` has its + // parameter list on the ENCLOSING `template_declaration`, so the standalone + // `struct_specifier` twin can never see it, and match order would silently + // decide whether `Vec` remembers `T`. Source order deciding a resolution + // fact is the failure mode this subsystem rejects everywhere else. + // + // Copying the field onto BOTH twins makes the outcome identical whichever + // one wins. Deliberately narrow — only `typeParameters`, the one field with + // an asymmetric twin today. Widening this to "merge all metadata" would + // change what every existing duplicate resolves to, which is a different + // change with a different blast radius and no evidence behind it yet. + const first = firstDefByNodeId.get(def.nodeId); + if (first === undefined) { + firstDefByNodeId.set(def.nodeId, def); + } else if (first.typeParameters === undefined && def.typeParameters !== undefined) { + first.typeParameters = def.typeParameters; + } else if (def.typeParameters === undefined && first.typeParameters !== undefined) { + def.typeParameters = first.typeParameters; + } + // Find the innermost scope that contains the declaration's anchor range. const innermostId = positionIndex.atPosition( filePath, @@ -638,6 +670,12 @@ function buildDefFromDeclarationMatch( const declaredType = match['@declaration.field-type']?.text; const returnType = match['@declaration.return-type']?.text; const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']); + // The DECLARED parameters, a different axis from `templateArguments` above: + // that reads the arguments written on the name, this reads the list the + // declaration was written in terms of. A declaration can carry both, and for a + // C++ partial specialization the pairing is the only thing that tells it apart + // from a full specialization with the identical arguments. + const typeParameters = parseTypeParameterList(match['@declaration.type-parameters']?.text ?? ''); const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); @@ -653,6 +691,7 @@ function buildDefFromDeclarationMatch( ...(declaredType !== undefined ? { declaredType } : {}), ...(returnType !== undefined ? { returnType } : {}), ...(templateArguments !== undefined ? { templateArguments } : {}), + ...(typeParameters !== undefined ? { typeParameters } : {}), ...(templateConstraints !== undefined ? { templateConstraints } : {}), ...(isExplicit === true ? { isExplicit: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}), @@ -1588,6 +1627,15 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@declaration.parameter-type-classes', '@declaration.return-type', '@declaration.template-constraints', + // MUST be listed, and the failure it prevents is silent def LOSS rather than + // a missing field. `anchorCaptureFor` picks the broadest-span `@declaration.*` + // capture that is not a known sub-tag; a type-parameter list is normally + // narrower than the declaration that owns it, but a C++ `template ` or a multi-line Java `` written above a short + // declaration can out-span it. The anchor would then be `type-parameters`, + // `normalizeNodeLabel` would return undefined for it, and the whole class def + // would be dropped rather than merely losing its parameters. + '@declaration.type-parameters', '@declaration.is-explicit', '@declaration.is-deleted', ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 72f2dcab7..5a82ec455 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -24,12 +24,13 @@ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; -import { stripTemplateArguments } from '../../utils/template-arguments.js'; +import { erasedTypeApplication, stripTemplateArguments } from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecorationStripper } from '../scope/walkers.js'; import { findClassBindingInScope, + resolveClassBindingForName, findEnclosingClassDef, findExportedDef, findExportedDefByName, @@ -91,6 +92,10 @@ interface ResolveCompoundReceiverOptions { * languages that hoist return-type bindings to Module scope (C#); * otherwise we risk picking up unrelated module-level bindings. */ readonly hoistTypeBindingsToModule?: boolean; + /** `ScopeResolver.resolveThisViaEnclosingClass` — the language declares that + * `this` IS the enclosing class rather than a per-function-scope binding. + * Read only by the `this` head seed below. */ + readonly resolveThisViaEnclosingClass?: boolean; /** Strip C-style cast expressions from the receiver text before * resolving it (`stripCastWrappers`). Default `false` — the text * reaches the resolver untouched and no cast logic runs. See the @@ -204,8 +209,8 @@ function resolveConstructionExpressionClass( // Generic construction — `new Box()` arrives here as `Box`, // which names no class binding. Retry on the base name, the same - // normalization `resolveClassBindingForName` in `receiver-bound-calls` - // already applies for typed receivers (#2708). + // normalization `resolveClassBindingForName` (in `scope/walkers.ts`) already + // applies for typed receivers (#2708). const baseName = stripTemplateArguments(calleeName).trim(); const lastDot = baseName.lastIndexOf('.'); if (lastDot !== -1) { @@ -289,6 +294,73 @@ interface FoldState { readonly declaredAtScope?: ScopeId; } +/** + * The class a receiver position's DECLARED TYPE denotes — the one lookup every + * route in this file uses to turn a `TypeRef` into an owner to look the next + * member up on. + * + * ── WHY NOT `findClassBindingInScope(scope, typeRef.rawName)` ──────────────── + * + * `rawName` is post-normalization, and several providers reduce a type + * APPLICATION to its base name at capture time (`Mapped[User]` → `Mapped`, + * `Repo` → `Repo`). Handing that base name to the bare lookup takes its + * workspace-wide qualified-name fallback, which consults no scope, no import + * and no module: it binds whatever the workspace happens to declare under that + * name. A third-party `Mapped[User]` beside an unrelated workspace + * `class Mapped` then produces a confident WRONG edge — strictly worse than the + * missing one it replaced, and not recoverable downstream. + * + * `resolveClassBindingForName` owns the grounding rule for exactly this + * ({@link resolveErasedBaseName}: the scope chain binds the name, or the + * declaration is in the same file, or the index proves the name is a template + * family, or the file has no cross-file class channel to be absent from), but it + * is entered on the SPELLING — a name that already lost its arguments is + * indistinguishable from an ordinary class name. {@link erasedTypeApplication} + * restores the application from `declaredSpelling`, which is what puts a + * capture-time-erased receiver back on the grounded route. + * + * ── WHY IT IS ONE HELPER AND NOT FIVE CALL SITES ───────────────────────────── + * + * This file types a receiver position from a `TypeRef` in five places — the + * structural fold's member step and its module-hoist branch, the cascade's + * bare-identifier binding, the cascade's dotted-chain HEAD, and the cascade's + * per-segment member walk. They are five routes to ONE question, and only three + * were wired to the grounded lookup, which is what left the hole: a Python + * `self.m.save(u)` whose fold step correctly refused fell THROUGH to the + * cascade — a declined fold is documented as "no answer", never a veto — and + * the cascade's own ungrounded member walk re-minted the very edge the grounds + * had just rejected. One shared helper is what makes "the fold refused" and + * "the cascade refused" the same sentence, rather than two lookups that happen + * to agree until one of them is edited. + * + * `stripDecoration` stays a per-caller argument because it is a DIFFERENT + * normalization with its own risk — its own docstring records that turning a + * former `undefined` into a hit suppresses the `?? otherResolver(...)` + * fallbacks two dozen call sites rely on. The three fold/binding callers pass + * the provider's stripper as they always have; the two cascade callers pass + * nothing, as they always have. So the only behaviour this helper changes + * anywhere is the erasure grounding, and a `TypeRef` that was never reduced + * resolves through the identical `findClassBindingInScope` call it did before + * (`resolveClassBindingForName` tries that first, and a name with no `<` + * returns immediately after it). + */ +function classOfDeclaredType( + typeRef: TypeRef, + scopes: ScopeResolutionIndexes, + stripDecoration?: DecorationStripper, +): SymbolDefinition | undefined { + // `declaredAtScope`, never a scope the caller chose: all five sites passed + // exactly this `TypeRef`'s own anchor, and taking it as a parameter is what + // would let a sixth quietly not — which is the hole this helper exists to + // close, one level up. + return resolveClassBindingForName( + typeRef.declaredAtScope, + erasedTypeApplication(typeRef) ?? typeRef.rawName, + scopes, + stripDecoration, + ); +} + function typeOfMemberOnClass( owner: SymbolDefinition, memberName: string, @@ -302,12 +374,7 @@ function typeOfMemberOnClass( const classScope = classScopeByDefId.get(ownerId); const memberType = classScope?.typeBindings.get(memberName); if (memberType !== undefined) { - const def = findClassBindingInScope( - memberType.declaredAtScope, - memberType.rawName, - scopes, - options.stripTypePreservingDecoration, - ); + const def = classOfDeclaredType(memberType, scopes, options.stripTypePreservingDecoration); // The declared type is reported even when it resolved to no class: // `Promise` and `[]Repo` name nothing in the workspace, and an // await or index step unwrapping them is exactly how they become @@ -334,15 +401,10 @@ function typeOfMemberOnClass( if (curScope === undefined) break; const hoisted = curScope.typeBindings.get(memberName); if (hoisted !== undefined) { - const def = findClassBindingInScope( - hoisted.declaredAtScope, - hoisted.rawName, - scopes, - // Same stripper the primary branch above passes. Omitting it here - // meant a decorated declared type (`*Host`) resolved on one branch - // and not the other, for the same member of the same class. - options.stripTypePreservingDecoration, - ); + // Same stripper the primary branch above passes. Omitting it here + // meant a decorated declared type (`*Host`) resolved on one branch and + // not the other, for the same member of the same class. + const def = classOfDeclaredType(hoisted, scopes, options.stripTypePreservingDecoration); // Identical to the primary branch: a declared type that named no // class is still a usable position when the next step unwraps it. // Returning `undefined` here made `svc.getMap()['k'].run()` decline @@ -586,6 +648,20 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } + // A language may declare that `this` IS the enclosing class rather than a + // per-function-scope binding (`ScopeResolver.resolveThisViaEnclosingClass`, + // the same flag Case 0.5 in `receiver-bound-calls` uses for a BARE `this` + // receiver). Such a language synthesizes no `this` typeBinding anywhere, so + // a chain whose BASE is `this` — `this->repo.save(u)`, `this.repo.save(u)` — + // had no way to seed its head and folded to nothing. Measured for the + // NON-generic control too, so it was never a generics gap. + // Placed before the typeBinding read: a language that DOES bind `this` per + // function scope never sets the flag, so nothing else can reach this. + if (workingText === 'this' && options.resolveThisViaEnclosingClass === true) { + const enclosing = findEnclosingClassDef(inScope, scopes); + if (enclosing !== undefined) return enclosing; + } + const tb = findReceiverTypeBinding(inScope, workingText, scopes); if (tb !== undefined) { // Map for-of: binding name is `user` but rawType is @@ -600,12 +676,7 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } - const viaTb = findClassBindingInScope( - tb.declaredAtScope, - tb.rawName, - scopes, - options.stripTypePreservingDecoration, - ); + const viaTb = classOfDeclaredType(tb, scopes, options.stripTypePreservingDecoration); if (viaTb !== undefined) return viaTb; // Member-alias / call-result shapes store the RHS path on rawName @@ -883,8 +954,20 @@ export function resolveCompoundReceiverClass( if (head === undefined) return undefined; const headMemberName = stripCallParens(head); const headType = findReceiverTypeBinding(inScope, headMemberName, scopes); + // The typed arm reads a DECLARED TYPE and so goes through the grounded lookup + // (see {@link classOfDeclaredType}); the untyped arm resolves the head NAME as + // the source WROTE it — a static class receiver — which was never erased and + // keeps the bare lookup. + // + // NO MEASURED CASE OF ITS OWN, and that is worth saying plainly: every fixture + // reaching here has an un-erased head (`self`, `this`, a local), for which the + // two lookups are the same call. It is changed because leaving one of five + // sibling reads of a `TypeRef` on the ungrounded lookup is precisely how the + // hole below survived — three were wired, two were not, and only one of the + // two had a fixture. See `classOfDeclaredType` for why this cannot change a + // `TypeRef` that was never reduced. let currentClass: SymbolDefinition | undefined = headType - ? findClassBindingInScope(headType.declaredAtScope, headType.rawName, scopes) + ? classOfDeclaredType(headType, scopes) : findClassBindingInScope(inScope, headMemberName, scopes); // Whether the walk currently sits on the CLASS ITSELF rather than on a // value of that class. Seeded true only when the head resolved straight to @@ -904,11 +987,21 @@ export function resolveCompoundReceiverClass( // lexically enclosing class would fabricate edges. Head resolution // only; the per-segment walk below is shared with every other // chain shape. + // + // A language may ALSO declare that `this` is always the enclosing class — + // `ScopeResolver.resolveThisViaEnclosingClass`, the same flag Case 0.5 in + // `receiver-bound-calls` already uses for a bare `this` receiver. Such a + // language deliberately synthesizes no `this` typeBinding anywhere, so the + // initializer-context test above can never be true inside a method body and + // every `this->field.m()` / `this.field.m()` chain folded to nothing — + // measured for the NON-generic control too, so it was never a generics gap. + // Reading the provider flag keeps the rule language-free: a language that + // does bind `this` per function scope does not set it, and is unaffected. if ( currentClass === undefined && headType === undefined && headMemberName === 'this' && - isInitializerContext(inScope, scopes) + (isInitializerContext(inScope, scopes) || options.resolveThisViaEnclosingClass === true) ) { currentClass = findEnclosingClassDef(inScope, scopes); } @@ -998,7 +1091,13 @@ export function resolveCompoundReceiverClass( } return undefined; } - let nextClass = findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes); + // THE MEASURED HOLE (#2833 follow-up). This is the cascade's copy of the + // fold's member step, and it read the possibly-erased `rawName` directly. + // A Python `self.m.save(u)` whose fold step refused `Mapped[User]` on + // grounds fell through here — a declined fold is documented as "no answer", + // never a veto — and this walk re-minted `other.py:Mapped` from the + // workspace index. Same rule, same lookup, so the two routes now agree. + let nextClass = classOfDeclaredType(memberType, scopes); if (nextClass === undefined) { const fromMap = unwrapMapValueToClass(memberType, scopes); if (fromMap !== undefined) nextClass = fromMap; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index cbe3112bb..59920a54e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -39,6 +39,15 @@ * (object-literal services). Last-resort fallback for lowercase * receivers with no class-like or type-binding match. Mirrors * the legacy DAG bridge in `call-processor.ts`. + * 10. **Case 6 (class-level member receiver)** — `Holder.repo.save(u)`, + * where the receiver's head is a CLASS and the one hop past it is a + * class-level (`isStatic`) field. Types the receiver from that field + * DEF's declared type rather than from a `typeBindings` entry, which + * is the thing a per-scope binding map cannot hold for a class that + * declares both a static and an instance member of one name. Gated on + * Case 0 having declined the same receiver, so it only ever adds an + * edge where there was none. Emits the interface-dispatch fan-out + * alongside Cases 0, 3b and 4. * * Reordering or merging cases changes resolution semantics. * @@ -69,6 +78,7 @@ import { isClassLike, isNamespaceNameShadowed, type DecorationStripper, + resolveClassBindingForName, } from '../scope/walkers.js'; import { tryEmitEdge, @@ -77,15 +87,12 @@ import { } from '../graph-bridge/edges.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; +import { erasedTypeApplication } from '../../utils/template-arguments.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, isOverloadAmbiguousAfterNormalization, } from './overload-narrowing.js'; -import { - extractTemplateArguments, - stripTemplateArguments, -} from '../../utils/template-arguments.js'; import type { ResolutionOutcomeRecorder, ResolutionSuppressionReason, @@ -119,62 +126,6 @@ type ReceiverBoundProviderSubset = Pick< | 'isStaticOnly' >; -function normalizeTemplateArgToken(value: string): string { - return value.replace(/\s+/g, ''); -} - -function resolveClassBindingForName( - scopeId: string, - rawClassName: string, - scopes: ScopeResolutionIndexes, - /** - * OPT-IN, and deliberately not passed by the emitting cases. `findClass - * BindingInScope`'s own docstring explains why the stripper is opt-in: a name - * that previously bound nothing starts binding, which SUPPRESSES the - * `?? otherResolver(...)` fallbacks several callers rely on. Case 4 therefore - * keeps exact-name behaviour and only `classifyReceiverOrigin` — which emits - * no edge and can only change a diagnostic label — passes it. - */ - stripDecoration?: DecorationStripper, -): SymbolDefinition | undefined { - const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration); - if (direct !== undefined) return direct; - - if (!rawClassName.includes('<')) return undefined; - const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); - if (baseName.length === 0) return undefined; - - const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken); - if (wantedArgs !== undefined && wantedArgs.length > 0) { - // qualifiedNames is a Map and may not contain the stripped base name at all - // (e.g., unresolved type binding or only template-qualified entries), so - // default to [] before checking `.length`. - const qnameIds = scopes.qualifiedNames.get(baseName) ?? []; - if (qnameIds.length === 0) { - return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration); - } - const matches: SymbolDefinition[] = []; - for (const id of qnameIds) { - const def = scopes.defs.get(id); - if (def === undefined || !isClassLike(def.type)) continue; - const defArgs = def.templateArguments?.map(normalizeTemplateArgToken); - if ( - defArgs !== undefined && - defArgs.length === wantedArgs.length && - defArgs.every((value, i) => value === wantedArgs[i]) - ) { - matches.push(def); - } - } - if (matches.length === 1) return matches[0]; - // Scope extractor only records class definitions with bodies in C++, so - // forward declarations are not expected here. Keep fallback behavior for - // safety in non-ODR or mixed-language edge cases. - } - - return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration); -} - /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ const BARE_TYPE_NAME_RE = /^[A-Za-z_$][\w$]*$/; @@ -366,6 +317,7 @@ export function emitReceiverBoundCalls( stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true, constructionSyntax: provider.constructionSyntax, stripTypePreservingDecoration: provider.stripTypePreservingDecoration, + resolveThisViaEnclosingClass: provider.resolveThisViaEnclosingClass, }; // Loop-invariant: both hooks come off the pass arguments, so the options bag // for `classifyReceiverOrigin` is built once here rather than per dropped site. @@ -572,6 +524,42 @@ export function emitReceiverBoundCalls( return n; }; + /** + * Declared type of the CLASS-LEVEL field named `fieldName` on `ownerId`, or + * `undefined` when the owner declares no such field, declares only an + * instance one, or declares one whose type was never captured. + * + * Both facts live on the graph NODE rather than on `SymbolDefinition` — + * `isStatic` is set by the structure phase and `declaredType` by the field + * extractor — which is the same place {@link isUnreachableByInstanceDispatch} + * reads `isStatic` from, so this introduces no new dependency. + * + * `isStatic === true` is required, not merely preferred. The receiver that + * asks this question resolved its head to the CLASS, so an instance field of + * that name is not reachable through it and answering with the instance + * field's type would type the receiver as something the source cannot + * denote. A def that resolves to no node, or a node with no captured type, + * answers `undefined` — the declining direction, matching how the rest of + * this pass treats an unresolvable lookup. + */ + const declaredTypeOfClassLevelField = ( + ownerId: string, + fieldName: string, + ): string | undefined => { + for (const candidate of model.fields.lookupAllByOwner(ownerId, fieldName)) { + const graphId = resolveDefGraphId(candidate.filePath, candidate, nodeLookup); + if (graphId === undefined) continue; + const properties = graph.getNode(graphId)?.properties; + if (properties?.isStatic !== true) continue; + const declaredType = properties.declaredType; + if (typeof declaredType !== 'string') continue; + const trimmed = declaredType.trim(); + if (trimmed.length === 0) continue; + return trimmed; + } + return undefined; + }; + for (const parsed of parsedFiles) { const namespaceTargets = collectNamespaceTargets(parsed, scopes, { receiverPaths: provider.namespaceReceiverPaths, @@ -1466,13 +1454,31 @@ export function emitReceiverBoundCalls( // ── Case 4: simple typeBinding (`u: U`) ────────────────────── if (typeRef !== undefined && !typeRef.rawName.includes('.')) { - let ownerDef = resolveClassBindingForName(site.inScope, typeRef.rawName, scopes); + // A `rawName` the capture layer reduced from a type application is + // resolved through the application it was written as, so the erasure + // takes the GROUNDED route rather than binding whatever the workspace + // declares under that base name — see {@link erasedTypeApplication}. + const typeApplication = erasedTypeApplication(typeRef); + let ownerDef = resolveClassBindingForName( + site.inScope, + typeApplication ?? typeRef.rawName, + scopes, + ); // `findClassBindingInScope(..., typeRef.rawName)` only works when // rawName is itself a class symbol reachable through scope bindings. // For languages with namespace-style imports (Go), imported types // don't create bindings. Fall back to QualifiedNameIndex — single- // match wins; ambiguous/missing falls through. - if (ownerDef === undefined) { + // + // NOT for an erased base name. This fallback consults no scope, no + // import and no module: it binds any name with exactly one workspace + // definition. That is a defensible last resort for a name the source + // WROTE — the file named it, so the only question is which declaration + // it meant — and is not defensible for a name the capture layer + // MANUFACTURED by erasing type arguments, where the file may never + // have named it at all. The lookup above already answered that case on + // grounds; re-asking it here without any would undo them. + if (ownerDef === undefined && typeApplication === undefined) { const qnameIds = scopes.qualifiedNames.get(typeRef.rawName); if (qnameIds.length === 1) { const qdef = scopes.defs.get(qnameIds[0]!); @@ -1482,7 +1488,18 @@ export function emitReceiverBoundCalls( // Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable // aliases (`getUser` → User), and other compound-friendly shapes // need the compound resolver keyed by the receiver identifier. - if (ownerDef === undefined) { + // + // Not asked for a receiver whose declared type IS an erased type + // application the grounded lookup just refused. Those shapes are + // alternatives to a declared type, not readings of one: this receiver + // HAS a declared type, the question "which class does its base name + // denote here" was already put and answered "cannot tell", and the + // compound resolver reaches the same base name through its own + // scope-free routes (its bare-identifier step re-runs the lookup on + // `rawName`; its callable-alias step retries the same name as a + // construction). Asking again by a route that cannot see the grounds + // would make the refusal decorative. + if (ownerDef === undefined && typeApplication === undefined) { ownerDef = resolveCompoundReceiverClass( receiverName, site.inScope, @@ -1492,6 +1509,37 @@ export function emitReceiverBoundCalls( { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); } + // The receiver has a declared type, that type is a type APPLICATION, + // and its base name could not be connected to any declaration this + // file can see. The site is DROPPED, and dropped deliberately, so it + // must be marked handled: `emitReferencesViaLookup` would otherwise + // re-emit the very target the grounds refused, because the pre-resolved + // reference index answers a name with the single workspace definition + // that carries it and knows nothing about erasure. That is exactly why + // the static-only filter above marks handled too — a refusal this pass + // makes is not a refusal until the fallback emitter is told. + // + // Recorded as `receiver-unresolved` rather than silently: the receiver's + // TYPE could not be established, which is the reason's own definition, + // and a consumer counting resolver gaps must see this drop rather than + // read the absence as a resolved site. No `receiverOrigin` — the base + // name resolving in the index is precisely the evidence just rejected, + // so claiming `in-program` from it would relaunder the fabrication as a + // diagnostic, and the absent field hedges (the safe direction). + if (ownerDef === undefined && typeApplication !== undefined) { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + siteKind: site.kind, + }); + handledSites.add(siteKey); + continue; + } if (ownerDef !== undefined) { const languageResolution = provider.resolveReceiverMember?.( ownerDef, @@ -1773,6 +1821,192 @@ export function emitReceiverBoundCalls( } } + // ── Case 6: class-level (static) member receiver ───────────── + // `Holder.repo.save(u)` — the receiver `Holder.repo` reaches a value + // through a CLASS-LEVEL member. Both routes that type a compound + // receiver (the structural fold and the text cascade) read the same + // place for the `repo` hop: the owning class scope's `typeBindings`. + // A scope has ONE `typeBindings` map with no static/instance split, so + // a language that declares both `p` and `static p` cannot record both + // — and at least two resolve that collision by not recording the + // static one at all, leaving `Holder.repo` with nothing to type + // against. A language that nests its class-level members in a scope of + // their own (a companion/singleton body) lands in the same place from + // the other direction: the binding exists, but not in the scope keyed + // by the class the receiver names. Both were MEASURED as emitting no + // edge at all, generic field and non-generic control alike. + // + // The definition side does not have that ambiguity: a class-level + // member and an instance member of one name are two distinct defs, and + // the graph node carries both `isStatic` and the member's declared + // type. So this case types the receiver off the DEF rather than off a + // typeBinding, and needs no scope-tree change to do it. + // + // ── WHY THIS CANNOT MINT A STATIC-TARGETED EDGE ──────────────────── + // + // `Holder.repo` is a static FIELD whose TYPE is `Repo`; the value it + // holds is an INSTANCE. So "reached through a class-level member" says + // nothing about the target: `save` is looked up with the ordinary + // `pickFirstNonStaticOnly` instance walk that Cases 0/3b/4 use, and a + // static-only `save` is skipped exactly as it is there. A genuine + // static CALL (`Repo.create()`) never arrives here — its receiver is a + // bare class name with no dot, which Case 2 owns and this case's + // two-part receiver requirement excludes. + // + // The `isStatic === true` requirement on the FIELD is the load-bearing + // guard in the other direction: the head resolved to the class itself, + // so only a class-level member is reachable through it, and an + // instance field of the same name must not be substituted. That is a + // POSITIVE selection among the defs that exist, never a filter that + // deletes otherwise-valid targets — the distinction that matters for a + // language whose singleton/companion members all carry `isStatic` from + // their OWNER type, where the flag being set is precisely what makes + // reaching them through the type name correct. + // + // Runs LAST, and only for a receiver Case 0 already declined + // (`compoundReceiverUnresolved`): a site any earlier case resolved + // keeps that answer, so this can only turn a missing edge into an edge + // and never retarget an existing one. Contract Invariant I4 holds — + // nothing above moved. + if (compoundReceiverUnresolved) { + const staticMemberReceiver = splitClassLevelMemberReceiver( + receiverName, + site.receiverChain, + ); + const headClass = + staticMemberReceiver === undefined + ? undefined + : findClassBindingInScope(site.inScope, staticMemberReceiver.headName, scopes); + // The head must be the CLASS ITSELF, not a value that happens to + // share its name — the same `currentIsClassConstant` test the text + // cascade makes before it treats a head as a class constant. A head + // with a type binding is an instance and its members are typed by + // the routes above. + if ( + staticMemberReceiver !== undefined && + headClass !== undefined && + findReceiverTypeBinding(site.inScope, staticMemberReceiver.headName, scopes) === undefined + ) { + // MRO walk, so a class-level member declared on an ancestor is + // reachable through a subclass name where the language allows it. + // First owner that declares one wins, matching every other walk in + // this pass. + let fieldOwnerId: string | undefined; + let fieldDeclaredType: string | undefined; + for (const ownerId of [ + headClass.nodeId, + ...scopes.methodDispatch.mroFor(headClass.nodeId), + ]) { + const declared = declaredTypeOfClassLevelField( + ownerId, + staticMemberReceiver.memberName, + ); + if (declared === undefined) continue; + fieldOwnerId = ownerId; + fieldDeclaredType = declared; + break; + } + // Resolve the declared type from where it was WRITTEN — the + // declaring class's own scope — not from the call site. A caller + // in another file need not have the field's type in scope at all, + // and resolving `Repo` against the caller's bindings would either + // miss or, worse, find an unrelated same-named class. + const declaringScope = + fieldOwnerId === undefined ? undefined : index.classScopeByDefId.get(fieldOwnerId)?.id; + const receiverClass = + declaringScope === undefined || fieldDeclaredType === undefined + ? undefined + : resolveClassBindingForName( + declaringScope, + fieldDeclaredType, + scopes, + provider.stripTypePreservingDecoration, + ); + if (receiverClass !== undefined) { + const chain = [ + receiverClass.nodeId, + ...scopes.methodDispatch.mroFor(receiverClass.nodeId), + ]; + let memberDef: SymbolDefinition | undefined; + let ambiguousOwnerId: string | undefined; + for (const ownerId of chain) { + const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider); + if (picked === OVERLOAD_AMBIGUOUS) { + ambiguousOwnerId = ownerId; + break; + } + // Same skip-and-walk-on as Case 4: a static-only candidate at + // this owner must not block an ancestor's instance member. + if (picked === STATIC_ONLY_FILTERED || picked === undefined) continue; + memberDef = picked; + break; + } + if (ambiguousOwnerId !== undefined) { + recordReceiverOverloadSuppression( + options.recordResolutionOutcome, + parsed.filePath, + site, + ambiguousOwnerId, + memberName, + model, + provider, + ); + handledSites.add(siteKey); + continue; + } + if (memberDef !== undefined) { + if ( + suppressDeletedCallTarget( + options.recordResolutionOutcome, + parsed.filePath, + site, + memberDef, + ) + ) { + handledSites.add(siteKey); + continue; + } + const reason = + site.kind === 'write' || site.kind === 'read' + ? site.kind + : memberDef.filePath !== parsed.filePath + ? 'import-resolved' + : 'global'; + const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85; + const ok = tryEmitEdge( + graph, + scopes, + nodeLookup, + site, + memberDef, + reason, + seen, + confidence, + collapse, + calleeCapture, + ); + if (ok) emitted++; + // The receiver's declared type can be an Interface exactly as + // in Cases 0/3b/4 — an interface-typed static field is the + // canonical service-locator shape — so it fans out the same + // way. Omitting it would make the static spelling emit fewer + // targets than the identical instance field, which is the very + // spelling-dependence #2829/#2842 closed elsewhere. + emitted += emitInterfaceDispatchFor( + receiverClass, + memberName, + memberDef, + site, + confidence, + calleeCapture, + ); + handledSites.add(siteKey); + continue; + } + } + } + } + // #2744: the site survived every case with a compound receiver we could // not type, so the call is dropped with no candidate. Record it here — // after the cases, so a site a later case resolved is never reported — @@ -1821,6 +2055,50 @@ export function emitReceiverBoundCalls( return { emitted, dispatchFanoutSkipped, dispatchFanoutSkippedNames }; } +/** A receiver of the exact shape `.` — a head and ONE member + * hop — as split by {@link splitClassLevelMemberReceiver}. */ +interface ClassLevelMemberReceiver { + readonly headName: string; + readonly memberName: string; +} + +/** + * Split a receiver into `Head` + one member hop, or decline. + * + * The STRUCTURE decides when the capture layer minted a chain: exactly one + * step, and that step a FIELD. A `call` step is a different shape entirely + * (`Holder.make().save()` — the value comes from a return type, which the + * routes above already own), and an `await`/`index` step transforms the value + * in a way a field's declared type does not describe. + * + * Without a chain the receiver TEXT answers, and only in the one spelling that + * cannot be read two ways: two bare identifiers around a single dot. Anything + * carrying a call, a subscript, a second dot or a decoration declines rather + * than being parsed here — re-deriving structure from text is what the chain + * exists to replace, and a second, looser text parser beside the cascade's own + * would drift from it. + */ +function splitClassLevelMemberReceiver( + receiverText: string, + receiverChain: string | undefined, +): ClassLevelMemberReceiver | undefined { + const decoded = decodeReceiverChain(receiverChain); + if (decoded !== undefined) { + if (decoded.truncated || decoded.steps.length !== 1) return undefined; + const step = decoded.steps[0]; + if (step === undefined || step.kind !== 'field') return undefined; + return { headName: decoded.baseReceiverName, memberName: step.name }; + } + const match = TWO_PART_RECEIVER_RE.exec(receiverText); + if (match === null) return undefined; + const [, headName, memberName] = match; + if (headName === undefined || memberName === undefined) return undefined; + return { headName, memberName }; +} + +/** `Holder.repo` and nothing looser — see {@link splitClassLevelMemberReceiver}. */ +const TWO_PART_RECEIVER_RE = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/; + /** Resolve a member by name on a class def, narrowing by argument * types when multiple overloads share the name. Falls back to the * first-seen def (legacy `findOwnedMember` semantics) when there's diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index cf9c3560e..69a9ec999 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -20,7 +20,14 @@ * as-is for TypeScript, Java, Kotlin, Ruby, etc. */ -import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; +import type { + BindingRef, + ParsedFile, + ScopeId, + SymbolDefinition, + TypeParameter, + TypeRef, +} from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; @@ -29,6 +36,10 @@ import { splitQualifiedName, stripTrailingTypeArguments, } from '../../utils/qualified-name.js'; +import { + extractTemplateArguments, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]); @@ -407,16 +418,29 @@ export function findAllClassBindingsInScope( name: string, scopes: ScopeResolutionIndexes, ): readonly SymbolDefinition[] { - const inScope = findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type)); - // The scope chain wins outright when it binds the name: an inner binding - // shadows anything the qualified-name index would contribute. - if (inScope.length > 0) return inScope; + return classBindingsVisibleFrom( + lexicalClassBindingsInScope(startScope, name, scopes), + name, + scopes, + ); +} +/** + * {@link findAllClassBindingsInScope} for a caller that already holds the + * scope-chain half (see {@link lexicalClassBindingsInScope}), so the chain is + * walked once rather than once per question asked about the same name. + * + * The chain wins outright when it binds the name: an inner binding shadows + * anything the qualified-name index would contribute. + */ +function classBindingsVisibleFrom( + lexical: readonly SymbolDefinition[], + name: string, + scopes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + if (lexical.length > 0) return lexical; const byNodeId = new Map(); - for (const id of scopes.qualifiedNames.get(name)) { - const def = scopes.defs.get(id); - if (def !== undefined && isClassLike(def.type)) byNodeId.set(def.nodeId, def); - } + for (const def of classDefsByQualifiedName(name, scopes)) byNodeId.set(def.nodeId, def); return [...byNodeId.values()]; } @@ -437,6 +461,162 @@ export type DecorationStripper = (typeName: string) => string | undefined; * shallowly (`*[]T`, `const T&`); three layers is generous. */ const MAX_DECORATION_LAYERS = 3; +/** Memo for {@link typeParameterNamesInScope}, keyed by index bundle then + * scope. One bundle per model, so the outer WeakMap releases with it. */ +const typeParameterNamesByBundle = new WeakMap< + ScopeResolutionIndexes, + Map> +>(); + +const NO_TYPE_PARAMETERS: ReadonlySet = Object.freeze(new Set()); + +/** + * Every name the scope chain above `scopeId` (inclusive) binds as a declared + * TYPE PARAMETER. + * + * Memoized per scope, and each scope's answer is built from its PARENT's, so a + * chain is walked once and every scope on it is O(own defs) rather than + * O(depth × defs). That matters because the caller runs on every class-binding + * lookup, and a module scope's `ownedDefs` is the whole file. + */ +function typeParameterNamesInScope( + scopeId: ScopeId, + scopes: ScopeResolutionIndexes, +): ReadonlySet { + let byScope = typeParameterNamesByBundle.get(scopes); + if (byScope === undefined) { + byScope = new Map>(); + typeParameterNamesByBundle.set(scopes, byScope); + } + const memo = byScope.get(scopeId); + if (memo !== undefined) return memo; + + // Collect the chain first, then fold from the top down, so the recursion is + // an explicit loop (a deep scope chain must not risk the call stack) and + // every scope passed through is memoized on the way back. + const chain: ScopeId[] = []; + const seen = new Set(); + let cursor: ScopeId | null = scopeId; + let inherited: ReadonlySet = NO_TYPE_PARAMETERS; + while (cursor !== null && !seen.has(cursor)) { + seen.add(cursor); + const cached = byScope.get(cursor); + if (cached !== undefined) { + inherited = cached; + break; + } + chain.push(cursor); + cursor = scopes.scopeTree.getScope(cursor)?.parent ?? null; + } + + for (let i = chain.length - 1; i >= 0; i -= 1) { + const id = chain[i]!; + const scope = scopes.scopeTree.getScope(id); + let own: Set | undefined; + for (const def of scope?.ownedDefs ?? []) { + for (const parameter of def.typeParameters ?? []) { + if (parameter.name.length === 0) continue; + own ??= new Set(inherited); + own.add(parameter.name); + } + } + inherited = own ?? inherited; + byScope.set(id, inherited); + } + return inherited; +} + +/** + * Does the scope chain at `scopeId` bind `name` as a declared TYPE PARAMETER? + * + * The question a class-binding lookup has to ask before it answers, because a + * type parameter and a class are spelled identically and only the declaration + * says which one a name is. `class Box { t: T }` beside a workspace + * `export class T` resolved `t` to the CLASS and emitted a confident wrong edge + * from every member call on `t` — the exact failure mode this subsystem treats + * as worse than a missing edge. + * + * WHY LEXICAL GROUNDING CANNOT SUBSTITUTE. The erasure grounds in + * `resolveErasedBaseName` all ask "can the file SEE a declaration by this + * name", and here it plainly can: `export class T` is imported, bound, and + * lexically visible. Visibility is not the defect — the name means something + * else at this site regardless of what else is visible, and only the enclosing + * declaration's parameter list records that. Measured: with the grounding rule + * in place the false edge still emitted. + * + * ABSENCE IS NOT EVIDENCE. `typeParameters` is populated only by the languages + * whose captures were extended for it, and is absent both for a non-generic + * declaration and for every declaration in a language that does not populate it + * yet. So only a POSITIVE match declines; an absent list changes nothing, which + * is what keeps every unconverted language behaving exactly as it does today. + */ +function bindsTypeParameter( + scopeId: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, +): boolean { + if (name.length === 0) return false; + return typeParameterNamesInScope(scopeId, scopes).has(name); +} + +/** + * The declared parameter `name` refers to at `scopeId`, nearest declaration + * first, or `undefined` when `name` is not a type parameter here. + * + * Separate from {@link bindsTypeParameter} because the guard only needs to know + * THAT a name is a parameter, while resolving through a bound needs the + * parameter itself — and the memoized name set deliberately keeps no payload so + * that the guard, which runs on every lookup, stays a single hash probe. + */ +function typeParameterAt( + scopeId: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, +): TypeParameter | undefined { + let cursor: ScopeId | null = scopeId; + const seen = new Set(); + while (cursor !== null && !seen.has(cursor)) { + seen.add(cursor); + const scope = scopes.scopeTree.getScope(cursor); + for (const def of scope?.ownedDefs ?? []) { + const hit = def.typeParameters?.find((parameter) => parameter.name === name); + if (hit !== undefined) return hit; + } + cursor = scope?.parent ?? null; + } + return undefined; +} + +/** + * The single class-like name a declared bound names, or `undefined` when the + * bound names none or names more than one. + * + * DECLINING ON AN INTERSECTION is the point. `T extends Repo & Closeable` and + * `T: Repo + Clone` make a member reachable through EITHER bound, so picking one + * — the first, as erasure would — mints a confidently-attributed edge to a + * declaration that may not own the member at all. Two candidates and no way to + * choose is exactly the case this file already answers with `undefined` in + * `findClassBindingInScope`'s decoration fallback: a missing edge is + * recoverable, a wrong one is not. + * + * Type ARGUMENTS on the bound are erased (`T extends Repo` → `Repo`), + * which is sound here for the same reason the erased base-name route exists: the + * members are declared once, on the declaration written against its parameters. + */ +function soleBoundBaseName(bound: string): string | undefined { + // `&` (Java, TypeScript) and `+` (Rust, Kotlin) both compose bounds. Split on + // whichever appears OUTSIDE brackets, so `Repo` stays one bound. + let depth = 0; + for (let i = 0; i < bound.length; i += 1) { + const ch = bound[i]; + if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth += 1; + else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth -= 1; + else if (depth === 0 && (ch === '&' || ch === '+')) return undefined; + } + const base = stripTemplateArguments(bound).trim(); + return base.length === 0 ? undefined : base; +} + export function findClassBindingInScope( startScope: ScopeId, receiverName: string, @@ -454,6 +634,16 @@ export function findClassBindingInScope( */ stripDecoration?: DecorationStripper, ): SymbolDefinition | undefined { + // A TYPE PARAMETER is not a class, and it is checked before every route below + // rather than inside one of them because each route would otherwise reach a + // same-named class by its own channel: the scope chain when the class is + // imported, the qualified-name index when it is not, and the decoration + // fallback after stripping. The declaration that introduced the parameter is + // the only thing that knows, and it knows for all three. + if (bindsTypeParameter(startScope, receiverName, scopes)) { + return resolveThroughTypeParameterBound(startScope, receiverName, scopes, stripDecoration); + } + const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type)); if (local !== undefined) return local; @@ -500,6 +690,410 @@ export function findClassBindingInScope( return undefined; } +/** + * What a TYPE PARAMETER used in type position resolves to — its declared BOUND + * when it states exactly one, and nothing when it is unbounded. + * + * `class Box { t: T; run() { this.t.save(); } }` has one sound + * answer for `this.t.save()`: the member set a `T` is GUARANTEED to have is its + * bound's, so `Repo.save` is the target the declaration itself licenses. An + * unbounded `class Box2` licenses nothing — `T` has no members — and gets + * `undefined`, which is the whole of the Gap-C fix. + * + * ONE HOP ONLY. The retry is guarded against a bound that is itself a parameter + * (`class Box`), so the recursion cannot chain or + * cycle. Following such a chain is sound in principle but has no measured case + * behind it, and an unbounded step in the middle would have to decline anyway. + */ +function resolveThroughTypeParameterBound( + startScope: ScopeId, + parameterName: string, + scopes: ScopeResolutionIndexes, + stripDecoration?: DecorationStripper, +): SymbolDefinition | undefined { + const bound = typeParameterAt(startScope, parameterName, scopes)?.bound; + if (bound === undefined) return undefined; + const baseName = soleBoundBaseName(bound); + if (baseName === undefined || baseName === parameterName) return undefined; + // A bound naming another parameter terminates here rather than recursing. + if (bindsTypeParameter(startScope, baseName, scopes)) return undefined; + return findClassBindingInScope(startScope, baseName, scopes, stripDecoration); +} + +function normalizeTemplateArgToken(value: string): string { + return value.replace(/\s+/g, ''); +} + +/** + * A definition that pins its OWN concrete type arguments (`templateArguments` + * is set) — the shape a scope extractor records for a declaration written + * against particular arguments rather than against its parameters, e.g. C++ + * `template <> struct Vec` (`['bool']`) or `template struct + * Vec` (`['T*']`). + * + * The distinction that matters to the lookup below: such a definition serves + * exactly ONE family of instantiations, so the only sound way to select it is + * the exact-argument match. A declaration written against its parameters — + * `template struct Vec`, `class Repo` in TypeScript, C# and every + * other language measured — carries NOTHING here (the extractor reads arguments + * off the declared name, and the name is bare), which is precisely why it can + * never win that match and must be reachable by the base-name route instead. + */ +function carriesOwnTemplateArguments(def: SymbolDefinition): boolean { + return def.templateArguments !== undefined && def.templateArguments.length > 0; +} + +/** Class-like defs registered in the workspace-wide qualified-name index under + * `name`. Workspace-WIDE: no scope filtering, so a caller must treat this as + * the weaker source and prefer lexically visible candidates. */ +function classDefsByQualifiedName( + name: string, + scopes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + const out: SymbolDefinition[] = []; + for (const id of scopes.qualifiedNames.get(name)) { + const def = scopes.defs.get(id); + if (def !== undefined && isClassLike(def.type)) out.push(def); + } + return out; +} + +/** Defs from `candidates` whose own template arguments equal `wantedArgs` + * token-for-token (whitespace already squeezed on both sides). */ +function matchingTemplateArguments( + candidates: readonly SymbolDefinition[], + wantedArgs: readonly string[], +): readonly SymbolDefinition[] { + return candidates.filter((def) => { + const defArgs = def.templateArguments?.map(normalizeTemplateArgToken); + return ( + defArgs !== undefined && + defArgs.length === wantedArgs.length && + defArgs.every((value, i) => value === wantedArgs[i]) + ); + }); +} + +/** + * Class-like defs the SCOPE CHAIN binds for `name` — locals, imports, wildcards, + * namespace siblings; everything `findAllBindingsInScope` reaches. No + * workspace-index fallback, which is the entire point: this is the set that + * answers "can the file see a declaration by this name", and + * `findAllClassBindingsInScope` deliberately cannot answer it because it falls + * through to the scope-free index when the chain is silent. + */ +function lexicalClassBindingsInScope( + startScope: ScopeId, + name: string, + scopes: ScopeResolutionIndexes, +): readonly SymbolDefinition[] { + return findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type)); +} + +/** + * The one declaration among `candidates` written against its PARAMETERS rather + * than against particular arguments — or `undefined` when there is not exactly + * one. + * + * ORDER-INDEPENDENT by construction, and that is why it exists separately from + * "take the first": an unordered candidate set (the workspace index, whose order + * is insertion order) must never let source order decide a call target. The + * scope-chain route keeps its nearest-first answer; only the index routes use + * this. + */ +function theInstantiationAgnosticDeclaration( + candidates: readonly SymbolDefinition[], +): SymbolDefinition | undefined { + const parameterized = candidates.filter((def) => !carriesOwnTemplateArguments(def)); + return parameterized.length === 1 ? parameterized[0] : undefined; +} + +/** Memo for {@link bindsAnyCrossFileClass}, keyed by index bundle then module + * scope. One bundle per model, so the outer WeakMap releases with it. */ +const crossFileClassChannelByBundle = new WeakMap>(); + +/** + * Does the FILE containing `scopeId` bind, at its module scope, any class-like + * definition declared in a DIFFERENT file? + * + * This is the question "is a name's absence from this file's scope chain + * evidence of anything", and it has to be asked of the data because the answer + * differs per language while the scope model records no fact that says which. + * Both halves were MEASURED on this pipeline, not assumed: + * + * - A C++ `#include` materializes NO binding. Two files declaring `Repo`, one + * of them `#include`d by the referencing file, resolves to NEITHER — the + * include contributed nothing and the ambiguity was decided by the + * workspace-wide index alone. So a C++ file's chain binds nothing + * cross-file, and the index is the only channel it has. + * - A TypeScript `import` does bind, and so does a C# `using` (through the + * accessible-namespace channel). + * + * So "the chain does not bind `Map`" is real evidence in a TypeScript file and + * no evidence at all in a C++ one. Asking the data which kind of file this is + * keeps the rule out of the business of naming languages (AGENTS.md R6). + * + * FAILS TOWARD PERMISSIVE. `false` — no module scope, no file path, nothing + * cross-file bound — restores exactly the import-blind behaviour that predates + * this check, so every way it can be wrong costs a wrong edge that already + * existed rather than a working edge that did not. + */ +function bindsAnyCrossFileClass(scopeId: ScopeId, scopes: ScopeResolutionIndexes): boolean { + const moduleScopeId = moduleScopeIdOf(scopeId, scopes); + if (moduleScopeId === null) return false; + let byScope = crossFileClassChannelByBundle.get(scopes); + if (byScope === undefined) { + byScope = new Map(); + crossFileClassChannelByBundle.set(scopes, byScope); + } + const memo = byScope.get(moduleScopeId); + if (memo !== undefined) return memo; + + const answer = scanForCrossFileClass(moduleScopeId, scopes); + byScope.set(moduleScopeId, answer); + return answer; +} + +/** + * The uncached scan behind {@link bindsAnyCrossFileClass}. Answers on the FIRST + * hit, so a file with a wide `export *` surface stops at its first imported + * class rather than walking the surface; a file with none is walked in full, but + * its module scope then holds only its own declarations. + * + * Reads the binding CHANNELS rather than asking `lookupBindingsAt` once per + * name, because the question is existential and the per-name route answers a + * question it does not need: a module scope activates the accessibility-gated + * namespace channel, so every one of N bound names re-probed all K accessible + * namespaces (75.6 ms for one C#-shaped file at N=5,000, K=1,000) and paid + * `lookupBindingsAt`'s merge allocation each time. The population considered is + * identical — the two per-scope channels' own buckets, plus the namespace and + * workspace channels under exactly the names those two bind. + */ +function scanForCrossFileClass(moduleScopeId: ScopeId, scopes: ScopeResolutionIndexes): boolean { + const filePath = scopes.scopeTree.getScope(moduleScopeId)?.filePath; + if (filePath === undefined) return false; + const bindsCrossFileClass = (refs: readonly BindingRef[] | undefined): boolean => + refs !== undefined && + refs.some((ref) => isClassLike(ref.def.type) && ref.def.filePath !== filePath); + + // The two per-scope channels, read as whole buckets. An ordinary import lands + // here, so this is where the early exit usually fires. + const finalized = scopes.bindings.get(moduleScopeId); + const augmented = scopes.bindingAugmentations.get(moduleScopeId); + for (const channel of [finalized, augmented]) { + for (const refs of channel?.values() ?? []) { + if (bindsCrossFileClass(refs)) return true; + } + } + + const boundNameCount = (finalized?.size ?? 0) + (augmented?.size ?? 0); + if (boundNameCount === 0) return false; + const bindsName = (name: string): boolean => + finalized?.has(name) === true || augmented?.has(name) === true; + // Materialized once, not per channel — `namesAtScope` allocates when both + // per-scope channels are populated. + let boundNames: readonly string[] | undefined; + const namesBoundHere = (): readonly string[] => + (boundNames ??= [...namesAtScope(moduleScopeId, scopes)]); + + // The accessibility-gated namespace channel: ONE lookup per accessible + // namespace, then whichever of the two sides is smaller is the one iterated — + // so neither a namespace with a large type table nor a file with many bound + // names can reintroduce the product. + for (const ns of scopes.accessibleNamespacesByScope?.get(moduleScopeId) ?? []) { + const inNamespace = scopes.namespaceFqnBindings?.get(ns); + if (inNamespace === undefined || inNamespace.size === 0) continue; + if (inNamespace.size <= boundNameCount) { + for (const [name, refs] of inNamespace) { + if (bindsName(name) && bindsCrossFileClass(refs)) return true; + } + } else { + for (const name of namesBoundHere()) { + if (bindsCrossFileClass(inNamespace.get(name))) return true; + } + } + } + + // The scope-independent workspace channel is keyed by name alone and has no + // per-scope bucket to walk, so it stays a probe per bound name. + const workspace = scopes.workspaceFqnBindings; + if (workspace !== undefined && workspace.size > 0) { + for (const name of namesBoundHere()) { + if (bindsCrossFileClass(workspace.get(name))) return true; + } + } + return false; +} + +/** + * Resolve a class-like binding for a declared type name, tolerating a spelling + * that carries TYPE ARGUMENTS (`Repo`, `Vec`) where the declaration + * itself is registered under the bare base name. + * + * Two normalizations, and they are not the same thing: + * + * 1. DECORATION stripping (`stripDecoration`, opt-in — see the parameter). + * Peels type-PRESERVING wrappers (`*T`, `const T&`) off the name. + * 2. Type-argument ERASURE (unconditional, and the wider of the two). + * `Repo` → `Repo`. This is what actually widens what binds, because + * it makes one declaration answer for EVERY instantiation of it — right + * for a language where a generic class has a single declaration, and a + * hazard where it does not, which is why the exact-argument match runs + * first and why the base-name route below refuses to return a + * declaration that pinned its own arguments. + * + * Order: exact spelling → exact type-argument match (lexically visible + * candidates first, workspace-wide index second) → base name. + */ +export function resolveClassBindingForName( + scopeId: string, + rawClassName: string, + scopes: ScopeResolutionIndexes, + /** + * OPT-IN, and it governs (1) only — argument erasure happens either way. + * `findClassBindingInScope`'s own docstring explains the opt-in: a name that + * previously bound nothing starts binding, which SUPPRESSES the + * `?? otherResolver(...)` fallbacks several callers rely on. + * + * THE RULE, not a roll-call of who currently passes it (that list has been + * appended to once per round of this work and is stale the moment it is + * written): pass it from a receiver-TYPING site, and only where the site + * already forwarded the same `stripTypePreservingDecoration` to the bare + * lookup — so a Go pointer receiver keeps resolving exactly as it did. A site + * that has never stripped must keep calling without it, because starting to + * strip is what suppresses its fallback. + */ + stripDecoration?: DecorationStripper, +): SymbolDefinition | undefined { + const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration); + if (direct !== undefined) return direct; + + if (!rawClassName.includes('<')) return undefined; + const baseName = stripTemplateArguments(rawClassName).replace(/\s+/g, ''); + if (baseName.length === 0) return undefined; + + // The class-like defs the SCOPE CHAIN binds for the base name. Computed once + // and used twice — it is the lexical half of "what can the base name see from + // here" AND ground (1) of the erasure rule below, and the two asked for it + // separately, bottoming out in the same walk for a third of the cost of every + // lookup whose declared type carries type arguments. + const lexical = lexicalClassBindingsInScope(scopeId, baseName, scopes); + + const wantedArgs = extractTemplateArguments(rawClassName)?.map(normalizeTemplateArgToken); + if (wantedArgs !== undefined && wantedArgs.length > 0) { + // LEXICAL FIRST. The workspace-wide index is not scoped, so matching against + // it up front let a field inside `namespace N` be answered by the GLOBAL + // `Box` — or, when both namespaces declare one, by neither: two + // matches, a decline, and a fall through to whatever base-name declaration + // the walk reached first. Candidates the scope chain actually offers are + // ranked ahead of it, exactly as every other lookup in this file does. + const lexicalMatches = matchingTemplateArguments( + classBindingsVisibleFrom(lexical, baseName, scopes), + wantedArgs, + ); + if (lexicalMatches.length === 1) return lexicalMatches[0]; + if (lexicalMatches.length === 0) { + // Workspace-wide fallback — consulted ONLY when the scope chain offered no + // exact match, which is how a declaration specialized in a different file + // than the one instantiating it still binds. + const indexMatches = matchingTemplateArguments( + classDefsByQualifiedName(baseName, scopes), + wantedArgs, + ); + if (indexMatches.length === 1) return indexMatches[0]; + } + } + + // ── Base-name route ──────────────────────────────────────────────────────── + // Nothing matched the arguments as written, so what is left to find is the + // declaration written against its PARAMETERS — the one instantiation-agnostic + // declaration the erasure is entitled to reach. + return resolveErasedBaseName(scopeId, baseName, scopes, lexical); +} + +/** + * The declaration an ERASED base name is entitled to reach — the counterpart of + * `findClassBindingInScope` for a name that lost its type arguments, and the one + * place the grounding rule for that erasure lives. + * + * GROUNDING is the whole difference between a fix and a fabrication. Erasure + * makes ONE declaration answer for EVERY instantiation of a name, so reaching it + * by NAME ALONE is the widest step in this file: it is why `Map` + * bound a workspace `class Map` the file cannot see, and why a third-party + * `Mapped[User]` bound an unrelated workspace `class Mapped` — a family of + * confident wrong edges the language interpreters have been holding back with + * deny-lists over an open universe of names. The name is not evidence. One of + * four grounds must connect the site to the declaration, strongest first. + */ +function resolveErasedBaseName( + scopeId: string, + baseName: string, + scopes: ScopeResolutionIndexes, + /** + * Ground (1) below, already computed: {@link lexicalClassBindingsInScope} for + * `baseName` at `scopeId`. A parameter rather than a call because the only + * caller needs the same list for its exact-argument match, and computing it + * twice walked the scope chain twice. + */ + lexical: readonly SymbolDefinition[], +): SymbolDefinition | undefined { + // (1) THE SCOPE CHAIN binds the base name — a local, an import, a wildcard, a + // namespace sibling. The file demonstrably sees a declaration by that name, so + // erasing to it is what the source meant. + if (lexical.length > 0) { + const nearest = lexical[0]!; + // The walk landed on a declaration that pinned its own arguments — arguments + // the branch above just proved are NOT the ones written. It won on nothing + // but being reached first: `Vec vi` bound the `Vec` + // specialization when the specialization happened to be declared above the + // primary template, and the primary when it did not. Source order deciding a + // call target is a wrong edge, not a missing one. Re-decide over the same + // visible candidates with those declarations removed. + return carriesOwnTemplateArguments(nearest) + ? theInstantiationAgnosticDeclaration(lexical) + : nearest; + } + + // Nothing lexical. Both remaining grounds read the workspace-wide qualified- + // name index, which consults no scope, no import and no module — so each one + // has to supply the connection the index itself cannot. + const indexed = classDefsByQualifiedName(baseName, scopes); + + // (2) THE DECLARATION IS IN THIS VERY FILE. A same-file declaration is visible + // to the site in every language — no import, no `using`, no `#include` — which + // is exactly what makes this ground language-neutral rather than a guess. It + // is also load-bearing rather than theoretical: a member typed `ns::Repo` + // resolves through here, because the qualifier is dropped at capture and a + // sibling NAMESPACE is not on the file's scope chain. + const siteFile = scopes.scopeTree.getScope(scopeId)?.filePath; + const sameFile = siteFile === undefined ? [] : indexed.filter((def) => def.filePath === siteFile); + if (sameFile.length > 0) return theInstantiationAgnosticDeclaration(sameFile); + + // (3) THE INDEX PROVES THE NAME IS A TEMPLATE FAMILY — some declaration under + // it pins its own arguments. That is the same evidence the exact-argument + // index match above already acts on, and acting on it in only one direction + // was incoherent: in one measured fixture `Vec` bound the cross-file + // SPECIALIZATION through the index while `Vec` bound nothing, though both + // are equally import-blind and the primary template is the only declaration + // that can answer `int`. + // + // (4) …or THE FILE HAS NO CROSS-FILE CHANNEL to be absent from, in which case + // the index is not a shortcut around the scope chain — it is the only channel + // that file has, and refusing it deletes every cross-file generic in the + // languages whose visibility is not lexical. Measured, both directions: a C++ + // `#include` binds nothing, so `Repo` in a `.cpp` reaches its header + // declaration ONLY here; a TypeScript `import` binds, so a file that imports + // anything and still cannot see `Map` genuinely cannot see it. + // + // Between them these two grounds are what separates the fix from the + // fabrication: `Map`, `Queue`, `Deque` in a file with a working import channel + // offer nothing but a spelling, and now get nothing. + if (indexed.some(carriesOwnTemplateArguments) || !bindsAnyCrossFileClass(scopeId, scopes)) { + return theInstantiationAgnosticDeclaration(indexed); + } + return undefined; +} + /** * Resolve a class-like inheritance target using the shared inheritance * resolution chain. Keeps pre-emitted heritage edges and language-specific diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts index a808c9ae8..9d1f25e8f 100644 --- a/gitnexus/src/core/ingestion/utils/template-arguments.ts +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -1,3 +1,5 @@ +import type { TypeRef } from 'gitnexus-shared'; + /** * Parse top-level generic/template arguments from a type-like string. * @@ -86,3 +88,84 @@ export function templateConstraintsIdTag(payload: unknown): string { if (payload === undefined || payload === null) return ''; return `~c:${constraintsHash(JSON.stringify(payload))}`; } + +/** + * The type APPLICATION a type reference was reduced from — `Mapped[User]`, + * `Repo` — restored to the `Base` spelling, or `undefined` when + * this reference is not that shape. + * + * ── WHY A LOOKUP MUST NOT BE HANDED THE REDUCED NAME ───────────────────────── + * + * `rawName` is post-normalization (see its docstring on `TypeRef`), and several + * providers reduce a type application to its BASE NAME at capture time — + * `Mapped[User]` → `Mapped`, `Repo` → `Repo`. That erasure is what lets + * one declaration answer for every instantiation of it, and it is also the + * widest step any lookup in this pipeline takes: reaching a declaration by NAME + * ALONE binds whatever the workspace happens to declare under that name. A + * third-party `Mapped[User]` beside an unrelated workspace `class Mapped` is + * then a confident WRONG edge, which is strictly worse than the missing one it + * replaced. + * + * `resolveClassBindingForName` already owns the rule for this — it admits an + * erased base name only on grounds that connect the site to the declaration + * (the scope chain binds the name; the declaration is in the same file; the + * index proves the name is a template family; the file has no cross-file class + * channel to be absent from). But that route is entered on the SPELLING: a name + * carrying its arguments takes it, a name already reduced to its base cannot, + * because nothing distinguishes it from an ordinary class name. So a provider + * that reduces at capture time sends its receivers down the ungrounded route by + * construction, whatever the shared lookup does. + * + * Restoring the application from `declaredSpelling` — which keeps the + * annotation exactly as written whenever normalization changed it — puts those + * receivers back on the grounded route. Restoring rather than reimplementing + * the grounding here is deliberate: the rule is one rule, and a second copy of + * it in this file would be free to drift from the one in `scope/walkers.ts` + * that every other caller uses. (Its predicate is not exported; the exported + * entry point is the spelling.) + * + * ── WHAT COUNTS AS AN APPLICATION ──────────────────────────────────────────── + * + * `rawName` must be the base the spelling APPLIES arguments to, and the + * argument list must be the whole of the rest of the spelling — one list, + * balanced, non-empty. Everything else is left exactly as it resolves today, + * because a transform that is not certain is a worse failure than no transform: + * + * - `User[]` — an array whose ELEMENT the capture layer already reduced to + * `User`. The position is the element, not an application of `User`, and + * the empty list is what says so. + * - `User[][]` — likewise, and it closes its first list before the end. + * - `std::vector` reduced to `vector` — the spelling does not + * start with the reduced name, so nothing was erased that this can restore. + * - `Repo?`, `Map Unit>` — trailing decoration and an + * argument list that does not close where it must. Declining leaves the + * pre-existing behaviour, which is what "no transform" has to mean. + * + * The rebuilt spelling uses ANGLE brackets because that is the spelling + * `resolveClassBindingForName`'s contract is written against; the punctuation a + * language spells type application with is not otherwise meaningful here, and + * nothing downstream reads this string except that lookup. + */ +export function erasedTypeApplication(typeRef: TypeRef): string | undefined { + const spelling = typeRef.declaredSpelling?.trim(); + if (spelling === undefined) return undefined; + const base = typeRef.rawName.trim(); + if (base.length === 0 || !spelling.startsWith(base)) return undefined; + const rest = spelling.slice(base.length).trimStart(); + const opener = rest[0]; + if (opener !== '<' && opener !== '[') return undefined; + const closer = opener === '<' ? '>' : ']'; + let depth = 0; + for (let i = 0; i < rest.length; i++) { + if (rest[i] === opener) depth++; + else if (rest[i] === closer) { + depth--; + // The list the spelling opened must close on the LAST character, and must + // have held something: `Repo[User]` yes, `User[]` no, `User[][]` no. + if (depth === 0) { + return i === rest.length - 1 && i > 1 ? `${base}<${rest.slice(1, i)}>` : undefined; + } + } + } + return undefined; +} diff --git a/gitnexus/src/core/ingestion/utils/type-parameters.ts b/gitnexus/src/core/ingestion/utils/type-parameters.ts new file mode 100644 index 000000000..0d1f60c7b --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/type-parameters.ts @@ -0,0 +1,210 @@ +/** + * Parse a declared TYPE-PARAMETER LIST out of its own source text. + * + * The sibling of `template-arguments.ts`, on the other axis: that file reads the + * arguments a declaration was written AGAINST (`Vec` → `['bool']`), this + * one reads the parameters it was written IN TERMS OF (`template `, + * `class Box`). See `TypeParameter` in `gitnexus-shared` for why + * conflating them is a defect rather than a simplification. + * + * ── WHY TEXT AND NOT A PER-LANGUAGE JSON PAYLOAD ───────────────────────────── + * + * The `@declaration.parameter-types` precedent synthesizes JSON inside each + * language's `captures.ts`, because a *parameter type* can itself contain a + * comma (`Dict[str, int]`) and needs a quoting convention. A type-parameter list + * needs none: every language that has one delimits it with `<…>` and separates + * entries with commas, and the nesting those commas can hide (`T extends + * Map`) is bracket nesting the same scanner already has to track. So the + * capture can be the raw list node and the whole parse is shared, which keeps + * the per-language cost at one query capture instead of a branch in six + * emitters. + * + * ── WHY THIS NAMES NO LANGUAGE (AGENTS.md R6) ──────────────────────────────── + * + * It recognizes TOKENS, not languages, and every token it recognizes is + * recognized for all input. `extends` and `:` both introduce a bound wherever + * they appear; the name is the last identifier ahead of the bound wherever it + * appears, which is what makes `class T`, `typename T`, `in T`, `out T`, + * `reified T` and a bare `T` one rule rather than six. No caller passes a + * language tag and none is inspected — the direct analogue of + * `extractTemplateArguments`, which has parsed `<…>` for every language from + * shared code since it was written. + */ + +import type { TypeParameter } from 'gitnexus-shared'; + +/** Matches a trailing identifier: the parameter's name sits at the END of the + * pre-bound text, after any keyword or variance modifier. Unicode is not + * attempted — every language measured restricts type-parameter names to ASCII + * identifier characters, and a name this rejects yields no parameter rather + * than a wrong one. */ +const TRAILING_IDENTIFIER = /([A-Za-z_$][A-Za-z0-9_$]*)\s*$/; + +/** + * The declared type parameters in `text`, in source order, or `undefined` when + * `text` holds no parseable list. + * + * `text` is the raw source of the list node — ``, + * ``, `[T any]` is NOT accepted (see the bracket note + * below). Leading content before the first `<` is skipped, so a capture that + * spans `template ` parses identically to one spanning ``. + * + * ANGLE BRACKETS ONLY. Every language this is wired to delimits with `<…>`. + * Square brackets would be ambiguous against an array/subscript spelling in the + * same position, and the one language that uses them for this (Go) is served by + * its own main-thread reader — so accepting `[…]` here would buy nothing and + * risk reading `int[]` as a parameter list. + */ +export function parseTypeParameterList(text: string): TypeParameter[] | undefined { + const inner = innerListText(text); + if (inner === undefined) return undefined; + + const out: TypeParameter[] = []; + for (const entry of splitTopLevel(inner)) { + const parameter = parseEntry(entry); + if (parameter !== undefined) out.push(parameter); + } + return out.length > 0 ? out : undefined; +} + +/** The text between the outermost `<` and its matching `>`, or `undefined` when + * there is no balanced pair or it is empty. */ +function innerListText(text: string): string | undefined { + const start = text.indexOf('<'); + if (start === -1) return undefined; + let depth = 0; + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '<') depth += 1; + else if (ch === '>') { + depth -= 1; + if (depth === 0) { + const inner = text.slice(start + 1, i); + return inner.trim().length === 0 ? undefined : inner; + } + if (depth < 0) return undefined; + } + } + return undefined; +} + +/** + * Split on commas that no bracket encloses. + * + * All four bracket families are tracked together because a bound can carry any + * of them and each hides commas that are NOT entry separators: `T extends + * Map` (angle), `T extends Fn<(a, b) => void>` (paren), `N: [usize; 2]` + * (square), `T : suspend (Int, Int) -> Unit` (paren again). + */ +function splitTopLevel(inner: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < inner.length; i += 1) { + const ch = inner[i]; + if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth += 1; + else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth -= 1; + else if (ch === ',' && depth === 0) { + parts.push(inner.slice(start, i)); + start = i + 1; + } + } + parts.push(inner.slice(start)); + return parts; +} + +/** + * One list entry → its parameter, or `undefined` when the entry declares no + * type parameter this can name. + * + * DECLINING IS A RESULT, not a failure to handle: a Rust lifetime (`'a`) and a + * C++ non-type parameter spelled without a trailing identifier declare nothing + * a member lookup can be performed on, and admitting them under a made-up name + * would put a binding in the shadowing guard that shadows nothing real. + */ +function parseEntry(entry: string): TypeParameter | undefined { + // A default (`= int`, `= Repo`) is not part of either the name or the + // bound. Cut it first so `class T = int` still ends in its name. Only a + // top-level `=` counts — `T extends Fn<() => void>` must keep its bound. + const head = beforeTopLevelDefault(entry); + + const boundAt = findBoundIntroducer(head); + const namePart = boundAt === undefined ? head : head.slice(0, boundAt.index); + const bound = + boundAt === undefined ? undefined : head.slice(boundAt.index + boundAt.length).trim(); + + // The NAME is the trailing identifier of the pre-bound text. That one rule + // covers a bare `T`, a keyword-prefixed `class T` / `typename T`, a + // variance-annotated `in T` / `out T`, a modifier-prefixed `reified T`, and a + // variadic `class... Ts` — every measured spelling puts the name last. + const matched = TRAILING_IDENTIFIER.exec(namePart); + const name = matched?.[1]; + if (matched === undefined || matched === null || name === undefined) return undefined; + + // A LIFETIME (`'a`) is not a type parameter. Its name would otherwise be read + // as the bare identifier after the sigil, putting `a` into the shadowing set + // and hiding any real declaration by that name from every lookup in the + // declaration's body — a missing edge invented out of a construct that + // declares no type at all. + if (namePart[matched.index - 1] === "'") return undefined; + + return bound === undefined || bound.length === 0 ? { name } : { name, bound }; +} + +/** `entry` up to a top-level `=`, which introduces a DEFAULT rather than a + * bound. `=>` and `>=`/`<=` are not defaults; only a bare `=` at depth 0 is. */ +function beforeTopLevelDefault(entry: string): string { + let depth = 0; + for (let i = 0; i < entry.length; i += 1) { + const ch = entry[i]; + if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth += 1; + else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth -= 1; + else if (ch === '=' && depth === 0 && entry[i + 1] !== '=' && entry[i + 1] !== '>') { + return entry.slice(0, i); + } + } + return entry; +} + +/** + * Where the bound starts in `head`, or `undefined` when the entry declares none. + * + * Two introducers, both at depth 0 only: the keyword `extends` and a bare `:`. + * `:` is checked as a single character rather than a word, and `extends` is + * required to stand as a whole word so a parameter named `extendsFoo` is not + * mistaken for one. + * + * A `:` that is NOT a bound — C++ `template ` has none, and a Rust const + * generic `const N: usize` states a const parameter's TYPE — yields a `bound` + * that no consumer can resolve to a class and therefore falls out harmlessly at + * lookup. Reading it as a bound is the conservative direction: it can only fail + * to find a member, never invent one. + */ +function findBoundIntroducer(head: string): { index: number; length: number } | undefined { + let depth = 0; + for (let i = 0; i < head.length; i += 1) { + const ch = head[i]; + if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth += 1; + else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth -= 1; + else if (depth !== 0) continue; + else if (ch === ':') return { index: i, length: 1 }; + else if ( + ch === 'e' && + head.startsWith('extends', i) && + isWholeWord(head, i, 'extends'.length) + ) { + return { index: i, length: 'extends'.length }; + } + } + return undefined; +} + +function isWholeWord(text: string, index: number, length: number): boolean { + const before = index === 0 ? '' : text[index - 1]!; + const after = text[index + length] ?? ''; + return !isIdentifierChar(before) && !isIdentifierChar(after); +} + +function isIdentifierChar(ch: string): boolean { + return ch.length === 1 && /[A-Za-z0-9_$]/.test(ch); +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 9966197e7..c9a39303a 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -255,7 +255,52 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the previous version would make the fix a no-op for every unchanged Java file. // PR #2856 claims 46, so this branch owns 47. Verified against upstream/main at // 021ac3037 (still 45). RE-CHECK BEFORE MERGE. -const SCHEMA_BUMP = 47; + +// 47 -> 48: #2833 makes a generic-typed FIELD usable as a call receiver. Three +// parse-time changes ride on this one value: +// - C++ (`languages/cpp/query.ts`) gains `field_declaration` rules whose +// `type:` is a `template_type` or a `qualified_identifier` wrapping one. +// The rules that existed all required a bare `type_identifier`, so +// `Repo repo;` and `std::vector items;` matched NONE of them and +// the member got no type binding at all — new captures where there were none. +// - Python (`languages/python/interpret.ts`) reduces a subscripted type its +// container allow-lists do not claim to its base name, so `Repo[User]` binds +// as `Repo`. That rewrites `TypeRef.rawName`, which is serialized into the +// cached ParsedFile. +// - `SymbolDefinition.typeParameters` — the DECLARED parameter list +// (`template `, `class Box`), captured nowhere +// before and on a different axis from the existing `templateArguments`. Six +// per-language declaration queries gained `@declaration.type-parameters` and +// `scope-extractor.ts` reads it onto every class-like def. +// A warm cache would replay the pre-fix ParsedFiles, so every file served from +// it would carry the old captures while passing every cold-run test — the exact +// failure this constant exists to prevent. +// +// WHAT THE BUMP DOES NOT COVER. It invalidates the PARSE half only. Whether the +// re-parsed captures reach the graph is a separate gate: `isIncremental` +// (`core/run-analyze.ts`) tests `!options.force`, an existing meta, +// `!schemaFingerprintMismatch(...)`, feature parity, non-empty `fileHashes` and +// a git repo — SCHEMA_BUMP appears in none of them — and an incremental run then +// writes back only `hashDiff.toWrite`, logging the rest as "unchanged file rows +// preserved". SCHEMA_FINGERPRINT is a hash of node/relation DDL, which this +// branch does not touch, so it is byte-identical and moves nothing either. +// Net: after this bump an incremental analyze re-parses an unchanged file +// correctly but keeps its existing rows, and the new edges land on the next full +// rebuild (`--force`, or any run whose runner identity or DDL moved). That is +// the pre-existing contract for every capture change, not a regression here. +// +// THIS BRANCH COLLIDED TWICE, which is why it lands on 48 rather than 46. +// It first took 46 (the C++/Python captures) and then 47 (typeParameters), both +// verified free against origin/main at 021ac3037. By merge time main had moved: +// #2856 claims 46 and #2857 took 47 and merged first. The eleventh entry in this +// ledger and the FOURTH and FIFTH exact clashes — and note what caught them. +// Not the pin test: this branch asserted `toBe(47)` and so did #2857, and both +// pass, because a literal pin cannot see the other side. Only diffing +// origin/main at the moment of merge surfaces it. Every value this branch +// published (46, 47) is superseded by 48, so a warm cache stamped with either is +// correctly invalidated. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 48; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 6271e6115..ed093716c 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -177,7 +177,7 @@ }, "csharp-generic-parent-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, - "digest": "af08cdad747c53a0be8e32344d7af5bae07e215ce5be51685b6dd233c9e1cb6d" + "digest": "6e31bdbc26fc967d0855ebb8f7867989492d65a6a9e329d9ab9547775896ffb0" }, "csharp-generic-parent-resolution/src/Models/Repo.cs": { "captureGroups": 8, @@ -189,7 +189,7 @@ }, "csharp-generic-type-refs/Program.cs": { "captureGroups": 25, - "digest": "11958e1426be1f2f06d82c93fd1f813607b7665072e95356a8584aa21c0c2119" + "digest": "e0cd6ea7dc08f66b651027f964f7a36fd3c4efb7a4584df5f14935b93faace5a" }, "csharp-grandparent-resolution/Models/A.cs": { "captureGroups": 10, @@ -477,7 +477,7 @@ }, "csharp-primary-ctor-heritage/src/Repo.cs": { "captureGroups": 7, - "digest": "33d96df54df2f50a1f6a443d203625f3dd6c61e70ed10a71ba920597c6810035" + "digest": "d18339e871d79e3d51c27f3c83768e5dd46102d51279da04e283b0d3546c3fbe" }, "csharp-primary-ctor-heritage/src/Service.cs": { "captureGroups": 6, @@ -529,7 +529,7 @@ }, "csharp-qualified-constructor/Models/Box.cs": { "captureGroups": 8, - "digest": "be25e3fb2b928fda0b47cf2bccda6857fe76be0173fe540d34170d44b84ec35d" + "digest": "f49b819de0b6f192bf2e5f555903dbbfb82f938f69fd65134d19677e1daf725c" }, "csharp-qualified-constructor/Models/Widget.cs": { "captureGroups": 11, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 86150a01d..1505032d6 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -409,7 +409,7 @@ }, "rust-generic-impl-same-method-name/lib.rs": { "captureGroups": 21, - "digest": "b947e11ce51005eeba5cb7720f33be8829959aedbd344d7a2531f7b00d51ad37" + "digest": "854a7e9888c4e83de5d37324bed8c12726a8124a0c2f2ad577cd3fcdfe529c8b" }, "rust-grouped-imports/src/helpers/mod.rs": { "captureGroups": 16, @@ -609,7 +609,7 @@ }, "rust-nested-tail-collision-generic/lib.rs": { "captureGroups": 33, - "digest": "ab9d8bdfc674adac69965aa9b214fe6c5028184a98a504db7219af7bc9835265" + "digest": "11894223dc878e2cd96796172103976e4069b10baa5b454b5c1ec15ae8bab82b" }, "rust-nested-tail-collision/lib.rs": { "captureGroups": 19, @@ -657,7 +657,7 @@ }, "rust-qualified-trait/src/traits.rs": { "captureGroups": 9, - "digest": "10f3bba4c2a16cdac77de0498ff506910ac09cc1a85e7daebfc5743c54e26015" + "digest": "de4f2cf25c265d6cf6a15449f914a50bcac6fdcece855bb4a330eb01098a74d5" }, "rust-qualified-trait/src/widget.rs": { "captureGroups": 23, diff --git a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts new file mode 100644 index 000000000..d3473d034 --- /dev/null +++ b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts @@ -0,0 +1,2262 @@ +/** + * Cross-language matrix for #2833: can a class field whose declared type carries + * a TYPE ARGUMENT (`repo: Repo`) act as a call receiver? + * + * ── HOW TO READ A ROW ───────────────────────────────────────────────────────── + * + * Every language runs the same two statements — one call through a field whose + * type is generic, one through a field whose type is not — and the question is + * never "did edges appear" but **does the generic field behave like that + * language's own control field**. Languages differ in how many edges one call + * site produces (an interface receiver fans out to its implementations since + * #2829/#2842; a concrete receiver does not), so an absolute count would accuse + * and clear the wrong languages. The control row is the yardstick. + * + * ── WHY THE LANGUAGES SPLIT ─────────────────────────────────────────────────── + * + * 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`, which has no notion of type arguments. A local or a + * parameter of the IDENTICAL type is a bare name, so it types through Case 4 and + * `resolveClassBindingForName`, which strips them. That asymmetry is #2833, and + * it is the same shape as #2813/#2829 (Case 0 lacked Case 4's fan-out) and + * #2832/#2842 (Case 3b lacked it too): a property of how the receiver is + * SPELLED rather than of what it resolves to. + * + * Six languages never reach that asymmetry because they erase type arguments at + * INTERPRET time — `java/interpret.ts` runs `stripGeneric` over the annotation + * (F41, #1928) and Swift does the same — so their `rawName` is already `Repo`. + * TypeScript, C# and Python do not: their `stripGeneric` is a container + * ALLOW-LIST (`Promise`, `Array`, `list[X]`…) that returns the type + * ARGUMENT, and a user-defined `Repo` matches nothing in it, so the + * literal spelling survives into a lookup that binds nothing. + * + * Python was affected twice over: it spells type application with SQUARE + * brackets (`Repo[User]`), and the generic branch of the shared lookup is gated + * on `.includes('<')`. That is why Python lost its local and parameter rows too, + * where TypeScript and C# kept theirs, and why the shared lookup change alone + * did not lift it. Its interpreter now reduces a subscripted type its container + * allow-lists do not claim to the base name — the same rule Java and Swift + * already applied to `<…>`. + * + * C++ failed for a third reason entirely, and it was a CAPTURE gap rather than a + * resolution one: all three `field_declaration` type-binding rules required + * `type: (type_identifier)`, so `Repo repo;` — a `template_type` — matched + * none of them and the member got no type binding at all. Both spellings failed + * while a LOCAL of the same type resolved, because the local declaration rules + * had gained their `template_type` variant long ago. Three mirrored rules close + * the bare spelling. The QUALIFIED spelling (`std::vector`, `ns::Address`) + * is a `qualified_identifier` WRAPPING either node, so it matched none of the + * six — GENERIC OR NOT, which is why `std::string name;` bound nothing either — + * and three further rules match that outer node directly. Matching the outer + * node rather than enumerating the inner one is what makes them depth-agnostic: + * there is no longer a qualifier depth at which a member field stops being + * captured, and `cppQualifiedTail` in `interpret.ts` reduces `a::b::c::Deeper` + * to `Deeper` the way the bare spelling already resolved. + * + * ── MEASURED STATE (all rows below are measured, none predicted) ───────────── + * + * language generic field why + * ----------- -------------- ------------------------------------------ + * TypeScript fixed shared lookup now generic-aware + * C# fixed same + * C++ fixed + new template_type/qualified field captures + * Python fixed + base-name erasure for `Name[...]` + * Java already ok erases generics at interpret time + * Kotlin already ok same + * Go fixed + generic-interface instantiation fan-out + * Rust already ok erases generics at interpret time + * Swift already ok same + * Dart already ok same + * JavaScript fixed + `@type {Repo}` docblock field capture + * PHP fixed + `@var Repo` docblock property capture; + * its native typed property has no generic syntax + * Ruby, C, n/a no generics in the language + * COBOL + * + * ── BASE-NAME ERASURE IS THE WIDEST STEP HERE, AND IT IS GROUNDED ──────────── + * + * Reaching a declaration by NAME ALONE binds whatever the workspace happens to + * declare under that name, so a third-party `Mapped[User]` beside an unrelated + * workspace `class Mapped` would become a confident WRONG edge — strictly worse + * than the missing one it replaced. `resolveClassBindingForName` therefore + * admits an erased base name only on grounds that connect the SITE to the + * declaration (the scope chain binds the name; the declaration is in the same + * file; the index proves the name is a template family; the file has no + * cross-file class channel to be absent from). `py-erased-grounding` pins the + * refusal and `py-generic-grounding-mirrors`, `cpp-csharp-index-channel` and the + * `-crossfile` rows pin the four shapes that would break if it were stricter. + * + * ── GAPS THIS FILE ONCE PINNED, NOW CLOSED ─────────────────────────────────── + * + * Every one of these was a measured empty row here, each with a CONTROL that + * failed identically — which is what classified it as a gap in some other + * mechanism rather than in generic typing. The mechanism named is what closed + * it, and every row below is now asserted non-empty: + * + * - C++ `this->field.m()` emitted nothing even for a NON-generic field: a + * `this`-head seed gap. The compound fold now seeds the head from the + * enclosing class for a language that sets `resolveThisViaEnclosingClass` + * (`cpp-this-head-field`). + * - JavaScript `@type {…}` and PHP `@var …` docblock field types bound nothing + * at all — the only way either language can spell a field's type at all in + * the generic case. Both now synthesize the same annotation-strength binding + * the native syntax emits (`js-docblock-field`, `php-docblock-property`). + * - A STATIC/class-level member receiver (`Holder.repo.save(u)`) emitted + * nothing, and so did `Holder.plain.save(u)`. Case 6 types the receiver off + * the static field DEF, which is the thing a per-scope `typeBindings` map + * cannot hold for a class declaring both a static and an instance member of + * one name (`ts-reach-shapes`, `kotlin-companion-static-member`). + * - A C++ generic field qualified THREE deep was not captured; that was the + * cost of one query pattern per qualifier depth, and the depth-agnostic + * rules removed the boundary rather than raising it + * (`cpp-qualified-generic-field`, now pinned at depth 3 AND 4). + * - A C++ PRIMARY template did not bind cross-file when the instantiating file + * named it nowhere lexically. The base-name route now re-decides over the + * declarations that pin no template arguments of their own, and exactly one + * of the two `Vec` declarations does (`cpp-spec-cross-file`). + * - A workspace `class T` shadowed a type PARAMETER named `T` and answered for + * it. Declarations now carry their declared `typeParameters`, and a name a + * lexically enclosing declaration binds as a parameter is refused + * (`neg-type-parameter`). + * + * ── DELIBERATE LIMITS STILL PINNED HERE ────────────────────────────────────── + * + * These rows are NOT gaps waiting to be closed. Each states a decision, and + * changing one is a semantics change to argue for, not a bug to fix quietly: + * + * - C++ partial-specialization SELECTION resolves to the PRIMARY template. + * Selecting `Vec` for `Vec` needs template-argument DEDUCTION, + * ruled out of scope; what IS pinned is that the answer does not depend on + * declaration order (`cpp-partial-spec-*`). + * - `std::unique_ptr` types to `unique_ptr`, NOT to the pointee. + * Smart-pointer transparency is not applied on this path. + * - A C++ node id drops the namespace, so two same-named specializations in + * ONE file collapse to one node. `cpp-spec-lexical-shadowing` puts its two + * `Box` declarations in two FILES for exactly that reason. + * - A chain HEAD whose own type was erased still binds through the + * bare-identifier branch's callable-alias retry: `m.inner.ping()` where + * `m: Mapped[User]` resolves, while the one-segment-shallower `m.save(u)` + * correctly refuses. `py-erased-grounding` / `run_head_chain` pins it. + * + * ── THE NEGATIVE CONTROLS ───────────────────────────────────────────────────── + * + * Erasing `Repo` to `Repo` is right for finding a DECLARATION — one + * declaration serves every instantiation in each language here. Four shapes must + * not be swept up with it, and each gets a row: + * + * - A bare TYPE PARAMETER (`class Box { t: TItem }`) denotes no + * declaration at all. Inventing one is a false edge, which is strictly worse + * than the missing edge #2833 is about. `Box2` beside a workspace class + * literally named `T` is the hard case, because `T` carries no type + * arguments and so never enters the generic path at all — visibility was + * never the defect, and no grounding rule could have declined it. Only the + * enclosing declaration's own parameter list records that the name means + * something else here. + * - A C++ explicit specialization (`template<> struct Vec`) genuinely IS + * a different class from the primary template. Erasing to `Vec` before + * trying an exact argument match would silently retarget it, which is why + * `resolveClassBindingForName` matches `templateArguments` FIRST and only + * then falls back to the base name — and why, when the base-name walk lands + * on a declaration that pinned arguments the caller did NOT write, it + * re-decides over the parameterized declarations instead of keeping it. + * Without that last step the answer depended on SOURCE ORDER: `Vec vi` + * bound `Vec` when the specialization happened to be written above the + * primary. `cpp-spec-order-*` pins both arrangements. + * - A workspace class whose name collides with a CONTAINER + * (`class Map` beside `m: Map`) now binds where it did not + * before, because base-name erasure reaches it. `container-name-collision` + * makes that policy visible instead of accidental, and the C++ qualified + * rows state the same policy for `std::string name;` when the workspace + * really does declare a `string`. + * - Go interface satisfaction against a generic interface is SUBSTITUTION, not + * erasure: `Repo[Order]` instantiates to `Save(x Order)`, which an + * implementor of `Save(x User)` does not satisfy and must not fan out to + * (`go-instantiation-mismatch`). + * + * A BOUNDED type parameter (`T extends Repo`) now resolves through its bound, + * because the parameter list that declines the unbounded case carries the bound + * with it. The row asserts the fan-out that the bound's own field produces, + * beside that sibling in the same fixture, so neither can be the sound of a + * fixture that stopped parsing. + * + * ── WHY THIS IS STILL ONE FILE ──────────────────────────────────────────────── + * + * Five assertions at the bottom read the whole matrix rather than one case: the + * pairing-completeness gate, the control/generic pairing sweep, the + * control-non-emptiness sweep, the duplicate-edge sweep, and the C++ + * source-order property (which is only meaningful as an equality between two + * separately-built fixtures). Splitting out, say, a `-cpp` sibling would either + * duplicate those sweeps or drop the cases they cover, and a matrix whose sweeps + * do not see every row is the thing this file exists to prevent. The cost is + * linear in cases — one pipeline run each, in one vitest worker — not quadratic, + * so the file grows in wall time the way a list does, not the way a matrix does. + * + * The pairing sweep is DERIVED from `Row.pairsWith` rather than restated in a + * list of its own, and the gate makes every case declare either a pair or an + * `unpaired` reason. A second hand-maintained list of caller names is exactly + * the way a case gets quietly left out of a sweep this file says nothing may be + * left out of — measured, 19 of the 41 cases were. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import type { PipelineResult } from './helpers.js'; +import { cleanupTempDirSync } from '../../helpers/test-db.js'; + +/** One measured call site: the method the call is written in, and every distinct + * CALLS target it emits, by node id. */ +interface Row { + /** Simple name of the enclosing method — resolved to a node at run time so no + * id scheme is hard-coded on the CALLER side. */ + readonly caller: string; + /** Sorted, deduplicated target node ids AS MEASURED TODAY. An EMPTY array is + * only ever written next to a `caller` the suite has separately proven + * exists, in a fixture some other row proves resolved, so an empty + * expectation can never pass vacuously. */ + readonly targets: readonly string[]; + readonly note: string; + /** `caller` of the CONTROL row this one is measured against, when this row is + * a generic (or otherwise-decorated) receiver with a plain counterpart in the + * same fixture. The control/generic sweep near the bottom of this file is + * DERIVED from these, so a pairing lives beside the fixture it pairs and + * cannot be forgotten in a second list. */ + readonly pairsWith?: string; + /** Set only where the pair is a PINNED NON-MATCH — the generic row must NOT + * emit as many targets as its control. States a decision; see the row note + * for the argument. Absent means "matches", which is what every other pair + * claims. */ + readonly matchesControl?: false; +} + +interface Case { + readonly name: string; + readonly file: string; + readonly source: string; + /** Further files in the same fixture repo, for the shapes a single file + * cannot express: a declaration split across files, a specialization + * declared away from its instantiation, or two languages whose answers are + * compared side by side. Written after `file`, in literal order. */ + readonly extraFiles?: Readonly>; + readonly rows: readonly Row[]; + /** Why NO row of this case carries `pairsWith`, for the cases the + * control/generic sweep cannot measure. Required whenever the case has no + * paired row, and asserted below: an omission has to be a sentence someone + * wrote, not a case that quietly fell out of the sweep. */ + readonly unpaired?: string; +} + +const CASES: readonly Case[] = [ + { + name: 'typescript', + file: 'a.ts', + source: ` +export class User {} +export interface Repo { save(x: T): void; } +export class UserRepo implements Repo { save(x: User): void {} } +export interface Plain { save(x: User): void; } +export class PlainRepo implements Plain { save(x: User): void {} } +export class GenericSvc { + private repo: Repo; + constructor(r: Repo) { this.repo = r; } + runGeneric(u: User): void { this.repo.save(u); } +} +export class ControlSvc { + private plain: Plain; + constructor(p: Plain) { this.plain = p; } + runControl(u: User): void { this.plain.save(u); } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:a.ts:Plain.save#1', 'Method:a.ts:PlainRepo.save#1'], + note: 'control: interface-typed field, primary + dispatch fan-out', + }, + { + caller: 'runGeneric', + targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + note: '#2833: generic-typed field matches the control exactly, primary + fan-out', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'csharp', + file: 'A.cs', + source: ` +class User {} +interface IRepo { void Save(T x); } +class UserRepo : IRepo { public void Save(User x) {} } +interface IPlain { void Save(User x); } +class PlainRepo : IPlain { public void Save(User x) {} } +class GenericSvc { + private IRepo repo; + public void RunGeneric(User u) { this.repo.Save(u); } +} +class ControlSvc { + private IPlain plain; + public void RunControl(User u) { this.plain.Save(u); } +} +`, + rows: [ + { + caller: 'RunControl', + targets: ['Method:A.cs:IPlain.Save#1', 'Method:A.cs:PlainRepo.Save#1'], + note: 'control', + }, + { + caller: 'RunGeneric', + targets: ['Method:A.cs:IRepo.Save#1', 'Method:A.cs:UserRepo.Save#1'], + note: '#2833: matches the control exactly, primary + fan-out', + pairsWith: 'RunControl', + }, + ], + }, + { + name: 'java', + file: 'A.java', + source: ` +class User {} +interface Repo { void save(T x); } +class UserRepo implements Repo { public void save(User x) {} } +interface Plain { void save(User x); } +class PlainRepo implements Plain { public void save(User x) {} } +class GenericSvc { + private Repo repo; + void runGeneric(User u) { this.repo.save(u); } +} +class ControlSvc { + private Plain plain; + void runControl(User u) { this.plain.save(u); } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:A.java:Plain.save#1', 'Method:A.java:PlainRepo.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Method:A.java:Repo.save#1', 'Method:A.java:UserRepo.save#1'], + note: 'ALREADY CORRECT — interpret-time erasure. Pinned against regression.', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'kotlin', + file: 'A.kt', + source: ` +class User +interface Repo { fun save(x: T) } +class UserRepo : Repo { override fun save(x: User) {} } +interface Plain { fun save(x: User) } +class PlainRepo : Plain { override fun save(x: User) {} } +class GenericSvc(private val repo: Repo) { + fun runGeneric(u: User) { repo.save(u) } +} +class ControlSvc(private val plain: Plain) { + fun runControl(u: User) { plain.save(u) } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:A.kt:Plain.save#1', 'Method:A.kt:PlainRepo.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Method:A.kt:Repo.save#1', 'Method:A.kt:UserRepo.save#1'], + note: 'ALREADY CORRECT. Pinned against regression.', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'go', + file: 'a.go', + source: `package main + +type User struct{} + +type Repo[T any] interface{ Save(x T) } + +type UserRepo struct{} + +func (r UserRepo) Save(x User) {} + +type Plain interface{ Save(x User) } + +type PlainRepo struct{} + +func (r PlainRepo) Save(x User) {} + +type GenericSvc struct{ repo Repo[User] } + +func (s GenericSvc) RunGeneric(u User) { s.repo.Save(u) } + +type ControlSvc struct{ plain Plain } + +func (s ControlSvc) RunControl(u User) { s.plain.Save(u) } +`, + rows: [ + { + caller: 'RunControl', + targets: [ + 'Method:a.go:Plain.Save#1', + 'Method:a.go:PlainRepo.Save#1', + 'Method:a.go:UserRepo.Save#1', + ], + note: 'control: Go structural satisfaction fans out to both value-receiver impls (#2829)', + }, + { + caller: 'RunGeneric', + targets: [ + 'Method:a.go:PlainRepo.Save#1', + 'Method:a.go:Repo.Save#1', + 'Method:a.go:UserRepo.Save#1', + ], + note: 'the bracket spelling always typed the receiver; what it lacked was structural satisfaction against a GENERIC interface. `Repo[User]` now instantiates to `interface{ Save(x User) }`, which both value-receiver impls satisfy — so Go finally matches its own control instead of stopping at the declaration.', + // Go was EXCLUDED from this sweep while its generic field stopped at + // the declaration and its control fanned out — an exclusion that was + // the taxonomy quietly admitting a bug rather than describing a + // language. Generic-interface instantiation closed it, so Go is now an + // ordinary pair. + pairsWith: 'RunControl', + }, + ], + }, + { + name: 'rust', + file: 'a.rs', + source: ` +pub struct User {} +pub struct Repo { pub item: T } +impl Repo { pub fn save(&self, x: &User) {} } +pub struct Plain {} +impl Plain { pub fn save(&self, x: &User) {} } +pub struct GenericSvc { repo: Repo } +impl GenericSvc { pub fn run_generic(&self, u: &User) { self.repo.save(u); } } +pub struct ControlSvc { plain: Plain } +impl ControlSvc { pub fn run_control(&self, u: &User) { self.plain.save(u); } } +`, + rows: [ + { + caller: 'run_control', + targets: ['Function:a.rs:Plain.save#1'], + note: 'control: concrete receiver, no fan-out', + }, + { + caller: 'run_generic', + targets: ['Function:a.rs:Repo.save#1'], + note: 'ALREADY CORRECT. Pinned against regression.', + pairsWith: 'run_control', + }, + ], + }, + { + name: 'swift', + file: 'A.swift', + source: ` +class User {} +class BoxRepo { func save(x: User) {} } +class Plain { func save(x: User) {} } +class GenericSvc { + let repo: BoxRepo = BoxRepo() + func runGeneric(u: User) { repo.save(x: u) } +} +class ControlSvc { + let plain: Plain = Plain() + func runControl(u: User) { plain.save(x: u) } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Function:A.swift:Plain.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Function:A.swift:BoxRepo.save#1'], + note: 'ALREADY CORRECT — but the initializer is a constructor of the SAME generic type, so this row cannot separate "the annotation resolved" from "the construction resolved". It pins the INITIALIZER path only; `annotation-only-swift-dart` pins the annotation on its own.', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'dart', + file: 'a.dart', + source: ` +class User {} +class Repo { void save(User x) {} } +class Plain { void save(User x) {} } +class GenericSvc { + Repo repo = Repo(); + void runGeneric(User u) { this.repo.save(u); } +} +class ControlSvc { + Plain plain = Plain(); + void runControl(User u) { this.plain.save(u); } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:a.dart:Plain.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Method:a.dart:Repo.save#1'], + note: 'ALREADY CORRECT — same caveat as the Swift row: initializer of the identical generic type, so it pins the INITIALIZER path. `annotation-only-swift-dart` pins the annotation on its own.', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'cpp', + file: 'a.cpp', + source: ` +struct User {}; +template struct Repo { void save(User x) {} }; +struct Plain { void save(User x) {} }; +struct GenericSvc { + Repo repo; + void runGeneric(User u) { repo.save(u); } +}; +struct ControlSvc { + Plain plain; + void runControl(User u) { plain.save(u); } +}; +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: '#2833: fixed by the new template_type field_declaration captures — a C++ member whose type carried template arguments previously bound nothing at all', + pairsWith: 'runControl', + }, + ], + }, + { + name: 'python', + file: 'a.py', + source: ` +from typing import Generic, TypeVar + +T = TypeVar("T") + +class User: + pass + +class Repo(Generic[T]): + def save(self, x): + pass + +class Plain: + def save(self, x): + pass + +class GenericSvc: + def __init__(self, repo: Repo[User]): + self.repo = repo + + def run_generic(self, u: User) -> None: + self.repo.save(u) + +class ControlSvc: + def __init__(self, plain: Plain): + self.plain = plain + + def run_control(self, u: User) -> None: + self.plain.save(u) +`, + rows: [ + { + caller: 'run_control', + targets: ['Method:a.py:Plain.save#1'], + note: 'control', + }, + { + caller: 'run_generic', + targets: ['Method:a.py:Repo.save#1'], + note: '#2833: fixed by base-name erasure in the Python interpreter — the bracket spelling never reached the `<`-gated generic branch', + pairsWith: 'run_control', + }, + ], + }, + { + name: 'ts-local-vs-field', + file: 'a.ts', + source: ` +export class User {} +export interface Repo { save(x: T): void; } +export class UserRepo implements Repo { save(x: User): void {} } +export class Svc { + private field: Repo; + constructor(r: Repo) { this.field = r; } + viaField(u: User): void { this.field.save(u); } + viaLocal(u: User): void { const local: Repo = this.field; local.save(u); } + viaParam(p: Repo, u: User): void { p.save(u); } +} +`, + rows: [ + { + caller: 'viaLocal', + targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + note: 'MUTATION CONTROL: a local of the identical generic type resolves today', + }, + { + caller: 'viaParam', + targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + note: 'MUTATION CONTROL: a parameter of the identical generic type resolves today', + }, + { + caller: 'viaField', + targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + note: 'the whole bug in one file — same type, same class, only the FIELD lost it', + }, + ], + unpaired: + 'its three rows pin the IDENTICAL target list for a local, a parameter and a field of the same generic type, which is a stronger statement than an equal COUNT — and none of the three is a non-generic control', + }, + { + name: 'neg-type-parameter', + file: 'a.ts', + source: ` +export class T { foo(): void {} } +export class Plain { foo(): void {} } +export class Box { + private t: TItem; + constructor(t: TItem) { this.t = t; } + run(): void { this.t.foo(); } +} +export class Box2 { + private t: T; + constructor(t: T) { this.t = t; } + run2(): void { this.t.foo(); } +} +export class BoxControl { + private p: Plain; + constructor(p: Plain) { this.p = p; } + runPlain(): void { this.p.foo(); } +} +`, + rows: [ + { + caller: 'runPlain', + targets: ['Method:a.ts:Plain.foo#0'], + note: 'ANTI-VACUITY CONTROL for the two negatives below, and the reason it must be here: both of them are empty now, so without an ordinary field receiver resolving in this same fixture the case could go green on a repo that never parsed.', + }, + { + caller: 'run', + targets: [], + note: 'NEGATIVE: an unbounded type parameter denotes no declaration — no edge, ever', + }, + { + caller: 'run2', + targets: [], + note: "THE FALSE EDGE, now closed: a workspace `class T` used to answer for the type parameter `T`. Visibility was never the defect — `export class T` is declared, exported and lexically in scope, so no grounding rule could decline it, and `T` carries no type arguments so it never entered the generic path either. Only `Box2`'s own declared `typeParameters` record that `T` means something else inside it, and `findClassBindingInScope` now refuses a name a lexically enclosing declaration binds as a parameter.", + }, + ], + unpaired: + 'the two rows under test are asserted EMPTY on purpose and `runPlain` is an ANTI-VACUITY control for them, not a yardstick: pairing an empty row against it would pin 0 !== 1 forever and say nothing about type parameters', + }, + { + name: 'neg-bounded-type-parameter', + file: 'a.ts', + source: ` +export interface Repo { save(): void; } +export class RepoImpl implements Repo { save(): void {} } +export class Box { + private t: T; + private direct: Repo; + constructor(t: T, d: Repo) { this.t = t; this.direct = d; } + run(): void { this.t.save(); } + runDirect(): void { this.direct.save(); } +} +`, + rows: [ + { + caller: 'run', + targets: ['Method:a.ts:Repo.save#0', 'Method:a.ts:RepoImpl.save#0'], + note: 'a BOUNDED type parameter now resolves THROUGH its bound, with the same interface-dispatch fan-out a field of the bound produces. It comes for free with the parameter list that declines the UNBOUNDED case one fixture up: refusing `T` requires knowing `Box` declares it, and the same entry carries `extends Repo`. Resolving to the bound is the sound direction — every `T` here IS a `Repo` — so this is a deliberate semantics expansion, not fallout.', + // Was a PINNED NON-MATCH while a bounded type parameter resolved to + // nothing. It now resolves THROUGH the bound, so it matches a field of + // that bound exactly — which is the claim the row makes. + pairsWith: 'runDirect', + }, + { + caller: 'runDirect', + targets: ['Method:a.ts:Repo.save#0', 'Method:a.ts:RepoImpl.save#0'], + note: 'THE YARDSTICK for the row above: a field of the BOUND itself, in the same class of the same fixture. The two are now asserted to the same target set, which is the whole claim — resolving through a bound must land where naming the bound lands, not merely somewhere.', + }, + ], + }, + { + name: 'neg-cpp-specialization', + file: 'a.cpp', + source: ` +template struct Vec { void save() {} }; +template <> struct Vec { void save() {} }; +struct Svc { + Vec vb; + Vec vi; + void runBool() { vb.save(); } + void runInt() { vi.save(); } +}; +`, + rows: [ + { + caller: 'runBool', + targets: ['Method:a.cpp:Vec.save#0'], + note: 'NEGATIVE: lands on the SPECIALIZATION, not the primary template. Asserted by node id because both members are named `save` — the ids differ (`Vec.save` vs `Vec.save~c:…`), which is exactly what would collapse if a naive strip ran before the arity/token match.', + }, + { + caller: 'runInt', + targets: ['Method:a.cpp:Vec.save#0~c:16619u1'], + note: 'the primary template instantiation — distinct target id from the specialization above', + }, + ], + unpaired: + "both rows are generic instantiations and the claim is WHICH declaration each lands on, not how many targets it emits; C++'s control/generic pair is the `cpp` case", + }, + // ── C++ specialization must not depend on SOURCE ORDER ──────────────────── + // The two cases below are byte-identical except for the order of the two + // `Vec` declarations, and both are asserted to the SAME target ids. That is + // the property, and it was measured false before the fix: with the + // specialization written first, `Vec vi` bound `Vec`, because the + // base-name walk returned whichever declaration it reached first and + // `Vec` had pinned arguments the instantiation never wrote. A wrong + // edge, not a missing one — which is why the base-name route now refuses a + // declaration that carries its own template arguments and re-decides over the + // parameterized ones. + // + // The forward declaration is what makes the specialization-first arrangement + // legal C++ rather than merely parseable; it registers no definition of its + // own (the C++ scope extractor records only class specifiers WITH bodies), so + // it does not itself change what is visible. + { + name: 'cpp-spec-order-specialization-first', + file: 'a.cpp', + source: ` +template struct Vec; +template <> struct Vec { void save() {} }; +template struct Vec { void save() {} }; +struct Svc { + Vec vb; + Vec vi; + void runBool() { vb.save(); } + void runInt() { vi.save(); } +}; +`, + rows: [ + { + caller: 'runBool', + targets: ['Method:a.cpp:Vec.save#0'], + note: 'the exact-argument match still wins when the specialization is declared FIRST', + }, + { + caller: 'runInt', + targets: ['Method:a.cpp:Vec.save#0~c:16619u1'], + note: 'THE ORDER BUG: `Vec` bound the `Vec` specialization in this arrangement before the fix, purely because that declaration was written above the primary. Must be the primary.', + }, + ], + unpaired: + 'both rows are generic instantiations, and the property these order fixtures carry is the cross-fixture EQUALITY asserted in its own test below, not a count against a control', + }, + { + name: 'cpp-spec-order-primary-first', + file: 'a.cpp', + source: ` +template struct Vec; +template struct Vec { void save() {} }; +template <> struct Vec { void save() {} }; +struct Svc { + Vec vb; + Vec vi; + void runBool() { vb.save(); } + void runInt() { vi.save(); } +}; +`, + rows: [ + { + caller: 'runBool', + targets: ['Method:a.cpp:Vec.save#0'], + note: 'mirror arrangement — same answer as specialization-first', + }, + { + caller: 'runInt', + targets: ['Method:a.cpp:Vec.save#0~c:16619u1'], + note: 'mirror arrangement — same answer as specialization-first. Asserted as an equality between the two cases as well, below.', + }, + ], + unpaired: + 'both rows are generic instantiations, and the property these order fixtures carry is the cross-fixture EQUALITY asserted in its own test below, not a count against a control', + }, + // ── Partial specialization: DETERMINISM, not deduction ──────────────────── + // `Vec` against `template struct Vec` would need + // template-argument DEDUCTION to select the partial specialization, and + // deduction was ruled out of scope for #2833. So the answer these two rows + // pin is deliberately the PRIMARY template, and the point of pinning it is + // that it is the same in both declaration orders instead of whichever + // declaration the walk happened to reach first. + // + // If deduction is ever implemented, these rows SHOULD change to + // `Vec.save` — that is a deliberate semantics expansion, not a + // regression. Do not "fix" them by accident in the other direction. + { + name: 'cpp-partial-spec-primary-first', + file: 'a.cpp', + source: ` +template struct Vec; +template struct Vec { void save() {} }; +template struct Vec { void save() {} }; +struct Svc { + Vec vp; + void runPtrArg() { vp.save(); } +}; +`, + rows: [ + { + caller: 'runPtrArg', + targets: ['Method:a.cpp:Vec.save#0~c:16619u1'], + note: 'primary template — `Vec` would require deduction (out of scope)', + }, + ], + unpaired: + 'a single generic row, whose property is the cross-fixture EQUALITY with `cpp-partial-spec-partial-first` asserted below', + }, + { + name: 'cpp-partial-spec-partial-first', + file: 'a.cpp', + source: ` +template struct Vec; +template struct Vec { void save() {} }; +template struct Vec { void save() {} }; +struct Svc { + Vec vp; + void runPtrArg() { vp.save(); } +}; +`, + rows: [ + { + caller: 'runPtrArg', + targets: ['Method:a.cpp:Vec.save#0~c:16619u1'], + note: 'same target as primary-first: the partial specialization pins `T*`, which is not the `int*` written, so it cannot win the exact match and is excluded from the base-name re-decision', + }, + ], + unpaired: + 'a single generic row, whose property is the cross-fixture EQUALITY with `cpp-partial-spec-primary-first` asserted below', + }, + { + name: 'cpp-spec-lexical-shadowing', + file: 'global.cpp', + source: ` +template struct Box { void save() {} }; +template <> struct Box { void save() {} }; +struct OuterSvc { + Box b; + void runOuter() { b.save(); } +}; +`, + extraFiles: { + 'ns.cpp': ` +namespace N { +template struct Box { void save() {} }; +template <> struct Box { void save() {} }; +struct InnerSvc { + Box b; + void runInner() { b.save(); } +}; +} +`, + }, + rows: [ + { + caller: 'runOuter', + targets: ['Method:global.cpp:Box.save#0'], + note: 'the GLOBAL specialization, from a field declared at global scope', + }, + { + caller: 'runInner', + targets: ['Method:ns.cpp:Box.save#0'], + note: 'LEXICAL SHADOWING: the namespace-local `N::Box` wins for a field inside `N`. The two specializations are separated into two FILES because a same-named specialization in the same file collapses to one node id, which would make this row unable to tell the two apart. Before the fix the workspace-wide index was consulted first: it offered two `Box` matches, declined, and fell through to the base-name walk — landing on a PRIMARY template for both services.', + }, + ], + unpaired: + 'both rows are the same generic spelling resolved from two different scopes; the claim is which declaration wins, and neither row is a control for the other', + }, + { + name: 'cpp-spec-cross-file', + file: 'vec_primary.cpp', + source: ` +template struct Vec { void save() {} }; +`, + extraFiles: { + 'vec_bool.cpp': ` +template struct Vec; +template <> struct Vec { void save() {} }; +`, + 'svc.cpp': ` +template struct Vec; +struct Svc { + Vec vb; + Vec vi; + void runBoolCrossFile() { vb.save(); } + void runIntCrossFile() { vi.save(); } +}; +`, + }, + rows: [ + { + caller: 'runBoolCrossFile', + targets: ['Method:vec_bool.cpp:Vec.save#0'], + note: 'NON-REGRESSION: a specialization declared in a DIFFERENT file than the instantiation binds through the workspace-wide index. This is the row that ruled out narrowing the exact-argument match to lexically visible candidates only — the scope chain of `svc.cpp` offers no `Vec` at all.', + }, + { + caller: 'runIntCrossFile', + targets: ['Method:vec_primary.cpp:Vec.save#0~c:16619u1'], + note: 'the PRIMARY template now binds cross-file too. Nothing matches `int` exactly and two workspace defs are registered under `Vec`, which used to be a flat decline; the base-name route now re-decides over the declarations that pin NO template arguments of their own, and exactly one of the two does — the primary. `Vec` is excluded by the same rule that keeps the sibling row above landing on it.', + }, + ], + unpaired: + 'both rows are generic instantiations reached across files; the claim is which declaration each binds, not a count against a non-generic control', + }, + { + name: 'csharp-partial-generic-cross-file', + file: 'RepoA.cs', + source: ` +partial class Repo { public void Save(T x) {} } +`, + extraFiles: { + 'RepoB.cs': ` +partial class Repo { public void Load(T x) {} } +`, + 'Svc.cs': ` +class User {} +class Plain { public void Save(User x) {} } +class Svc { + private Repo repo; + private Plain plain; + public void RunPartial(User u) { this.repo.Save(u); } + public void RunPartialControl(User u) { this.plain.Save(u); } +} +`, + }, + rows: [ + { + caller: 'RunPartialControl', + targets: ['Method:Svc.cs:Plain.Save#1'], + note: 'control', + }, + { + caller: 'RunPartial', + targets: ['Method:RepoA.cs:Repo.Save#1'], + note: 'NON-REGRESSION: a generic `partial class` split across two files, with the field in a third, resolves — and TWO defs are registered under the base name `Repo`. This is the row that ruled out "return only on exactly one base-name candidate": that rule would have deleted this edge. The base-name route keeps `findClassBindingInScope`\'s own single-match-or-decline behaviour instead, which the partial halves survive because neither pins template arguments.', + pairsWith: 'RunPartialControl', + }, + ], + }, + // ── C++ field DECORATION and QUALIFICATION ──────────────────────────────── + // The bare `Repo repo;` rule was only one of the three the fix added; + // the pointer and reference forms had no row at all until now, and the + // qualified forms (three further rules, one per declarator shape and + // depth-agnostic) none either. `cpp-qualified-non-generic-field` below pins + // the half of that second group which is not about generics at all. + { + name: 'cpp-pointer-reference-generic-field', + file: 'a.cpp', + source: ` +struct User {}; +template struct Repo { void save(User x) {} }; +struct Plain { void save(User x) {} }; +struct Svc { + Repo* gp; + Repo& gr; + Repo> gn; + Plain* cp; + Plain& cr; + void runGenericPtr(User u) { gp->save(u); } + void runGenericRef(User u) { gr.save(u); } + void runGenericNested(User u) { gn.save(u); } + void runControlPtr(User u) { cp->save(u); } + void runControlRef(User u) { cr.save(u); } +}; +`, + rows: [ + { caller: 'runControlPtr', targets: ['Method:a.cpp:Plain.save#1'], note: 'control, pointer' }, + { + caller: 'runControlRef', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'control, reference', + }, + { + caller: 'runGenericPtr', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'the `pointer_declarator` rule of the three the fix added — previously untested', + pairsWith: 'runControlPtr', + }, + { + caller: 'runGenericRef', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'the `reference_declarator` rule — previously untested', + pairsWith: 'runControlRef', + }, + { + caller: 'runGenericNested', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'nested `Repo>` — the depth-aware argument scan must not stop at the inner `>`', + pairsWith: 'runControlRef', + }, + ], + }, + { + name: 'cpp-qualified-generic-field', + file: 'a.cpp', + source: ` +struct Item {}; +struct User {}; +struct Payload { void reset() {} }; +struct Plain { void save(User x) {} }; +namespace std { +template struct vector { void push_back(Item x) {} }; +template struct unique_ptr { void reset() {} }; +} +namespace ns { +template struct Repo { void save(User x) {} }; +} +namespace a { namespace b { +template struct Deep { void go(User x) {} }; +} } +namespace a { namespace b { namespace c { +template struct Deeper { void go3(User x) {} }; +} } } +namespace a { namespace b { namespace c { namespace d { +template struct Deepest { void go4(User x) {} }; +} } } } +struct Svc { + std::vector items; + ns::Repo r; + a::b::Deep d; + std::vector* pitems; + ns::Repo& rr; + std::unique_ptr up; + a::b::c::Deeper deep3; + a::b::c::d::Deepest deep4; + Plain plain; + void runQualStd(Item i) { items.push_back(i); } + void runQualNs(User u) { r.save(u); } + void runQualDeep(User u) { d.go(u); } + void runQualPtr(Item i) { pitems->push_back(i); } + void runQualRef(User u) { rr.save(u); } + void runQualUnique() { up.reset(); } + void runQualDepth3(User u) { deep3.go3(u); } + void runQualDepth4(User u) { deep4.go4(u); } + void runQualControl(User u) { plain.save(u); } +}; +`, + rows: [ + { + caller: 'runQualControl', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'control: unqualified, non-generic field in the same struct', + }, + { + caller: 'runQualStd', + targets: ['Method:a.cpp:vector.push_back#1~c:16619u1'], + note: 'depth-1 qualifier, `std::vector` — the commonest real spelling of a generic member and the one the qualified rules exist for', + }, + { + caller: 'runQualNs', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'depth-1 qualifier, user namespace `ns::Repo`', + pairsWith: 'runQualControl', + }, + { + caller: 'runQualPtr', + targets: ['Method:a.cpp:vector.push_back#1~c:16619u1'], + note: 'depth-1 qualifier, pointer form', + }, + { + caller: 'runQualRef', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'depth-1 qualifier, reference form', + }, + { + caller: 'runQualDeep', + targets: ['Method:a.cpp:Deep.go#1~c:16619u1'], + note: 'depth-2 qualifier, `a::b::Deep`', + pairsWith: 'runQualControl', + }, + { + caller: 'runQualUnique', + targets: ['Method:a.cpp:unique_ptr.reset#0~c:16619u1'], + note: 'DELIBERATE LIMIT, measured: `std::unique_ptr` types to `unique_ptr`, NOT deref-stripped to `Payload` — `Payload` also declares `reset()` and does not receive the edge. The qualifier is dropped and the template head kept, so smart-pointer transparency is not applied on this path. Flipping this row means implementing that transparency, which is a semantics expansion.', + }, + { + caller: 'runQualDepth3', + targets: ['Method:a.cpp:Deeper.go3#1~c:16619u1'], + note: 'depth 3 (`a::b::c::Deeper`) was the DOCUMENTED BOUNDARY this file used to pin at empty, because the rules enumerated the INNER node and each level cost three more patterns. The rules now match the outer `qualified_identifier` instead, which is one node type whatever the depth, so the boundary is gone rather than moved.', + // Was a PINNED NON-MATCH while qualifier depth 3 was uncaptured. The + // depth-agnostic rules removed the boundary, so it is an ordinary pair. + pairsWith: 'runQualControl', + }, + { + caller: 'runQualDepth4', + targets: ['Method:a.cpp:Deepest.go4#1~c:16619u1'], + note: 'depth 4, and the row that says the boundary was REMOVED and not merely raised by one: if depth were still enumerated per level, closing depth 3 would have left this one empty. Nothing in the query mentions a depth, so there is no next boundary to find.', + pairsWith: 'runQualControl', + }, + ], + }, + // ── Container-name collision: making an unguarded policy visible ─────────── + // Base-name erasure is not scoped to user-defined types. A workspace class + // whose name collides with a standard container now answers for a field + // annotated with that container, and the multi-argument shape is exactly + // where the container ALLOW-LISTS decline to help, so nothing else competes. + // Both rows are measured; both are what this project wants when the workspace + // really does declare the class (the annotation names it, so the edge is + // right), and both would emit nothing if it did not. + { + name: 'container-name-collision', + file: 'a.ts', + source: ` +export class User { save(): void {} } +export class Map { save(): void {} } +export class Plain { save(): void {} } +export class TsSvc { + private m: Map; + private plain: Plain; + constructor(m: Map, p: Plain) { this.m = m; this.plain = p; } + runTsContainer(): void { this.m.save(); } + runTsControl(): void { this.plain.save(); } +} +`, + extraFiles: { + 'A.cs': ` +class CsUser { public void Save() {} } +class Dictionary { public void Save() {} } +class CsPlain { public void Save() {} } +class CsSvc { + private Dictionary d; + private CsPlain plain; + public void RunCsContainer() { this.d.Save(); } + public void RunCsControl() { this.plain.Save(); } +} +`, + }, + rows: [ + { caller: 'runTsControl', targets: ['Method:a.ts:Plain.save#0'], note: 'control (TS)' }, + { caller: 'RunCsControl', targets: ['Method:A.cs:CsPlain.Save#0'], note: 'control (C#)' }, + { + caller: 'runTsContainer', + targets: ['Method:a.ts:Map.save#0'], + note: 'INTENDED, and new: `Map` binds the workspace `class Map`, not the value type `User`. The annotation names `Map`, so naming `Map` is the right answer; the row exists because base-name erasure reaches it on the shared path with no container guard, and that policy should be readable here rather than inferred from an absence.', + pairsWith: 'runTsControl', + }, + { + caller: 'RunCsContainer', + targets: ['Method:A.cs:Dictionary.Save#0'], + note: 'INTENDED, and new: same policy in C# for `Dictionary`. `CsUser` also declares `Save()` and does NOT receive the edge, which is what proves the base name won rather than the container allow-list unwrapping to the value type.', + pairsWith: 'RunCsControl', + }, + ], + }, + // ── Generic SPELLINGS ───────────────────────────────────────────────────── + // The rows above all use the simplest possible generic, `Repo`. A fix + // that only handles that spelling is not a fix, so these pin the shapes real + // code actually writes: a nullable generic, a wildcard, a raw type, a nested + // generic, and a multi-argument one. + // + // READ THE LANGUAGE BEFORE READING THE ROW. Java, Kotlin and Rust erase type + // arguments at INTERPRET time, so their spellings never reach the shared + // lookup at all and their rows pass with or without it — they are regression + // pins for the interpret-time path, and they cannot fail on a revert of the + // shared change. The discriminating spelling rows are the TypeScript, C# and + // Python ones: `ts-multiarg-generic` and `ts-python-nested-and-multiarg`. + { + name: 'kotlin-nullable-generic', + file: 'A.kt', + source: ` +class User +interface Repo { fun save(x: T) } +class UserRepo : Repo { override fun save(x: User) {} } +class Svc(private val repo: Repo?) { + fun runNullableGeneric(u: User) { repo?.save(u) } +} +`, + rows: [ + { + caller: 'runNullableGeneric', + targets: ['Method:A.kt:Repo.save#1', 'Method:A.kt:UserRepo.save#1'], + note: 'nullable generic `Repo?` — decoration and type arguments compose. INTERPRET-TIME PATH: cannot fail on a revert of the shared lookup.', + }, + ], + unpaired: + 'one row, and no non-generic control field on purpose: this case adds a SPELLING (`Repo?`) to a language whose control/generic pair is the `kotlin` case', + }, + { + name: 'java-wildcard-and-raw-generic', + file: 'A.java', + source: ` +class User {} +interface Repo { void save(T x); } +class UserRepo implements Repo { public void save(User x) {} } +class Svc { + private Repo wild; + private Repo raw; + void runWildcard(User u) { this.wild.save(u); } + void runRaw(User u) { this.raw.save(u); } +} +`, + rows: [ + { + caller: 'runWildcard', + targets: ['Method:A.java:Repo.save#1', 'Method:A.java:UserRepo.save#1'], + note: 'bounded wildcard `Repo` names the same declaration. INTERPRET-TIME PATH: cannot fail on a revert of the shared lookup.', + }, + { + caller: 'runRaw', + targets: ['Method:A.java:Repo.save#1', 'Method:A.java:UserRepo.save#1'], + note: 'raw type `Repo` — the erased spelling Java itself permits. Carries no type arguments at all, so it never enters the generic path in any build.', + }, + ], + unpaired: + "both rows are generic SPELLINGS (`Repo`, raw `Repo`) with no plain control between them; Java's control/generic pair is the `java` case", + }, + { + name: 'rust-nested-generic', + file: 'a.rs', + source: ` +pub struct User {} +pub struct Repo { pub item: T } +impl Repo { pub fn save(&self, x: &User) {} } +pub struct Svc { repo: Repo> } +impl Svc { pub fn run_nested(&self, u: &User) { self.repo.save(u); } } +`, + rows: [ + { + caller: 'run_nested', + targets: ['Function:a.rs:Repo.save#1'], + note: 'nested generic `Repo>` — the depth-aware scan must not stop at the inner `>`. INTERPRET-TIME PATH: cannot fail on a revert of the shared lookup.', + }, + ], + unpaired: + "one row, adding the nested SPELLING `Repo>`; Rust's control/generic pair is the `rust` case", + }, + { + name: 'ts-multiarg-generic', + file: 'a.ts', + source: ` +export class User {} +export class Key {} +export interface Handler { handle(k: K, v: V): void; } +export class Svc { + private h: Handler; + constructor(h: Handler) { this.h = h; } + run(k: Key, u: User): void { this.h.handle(k, u); } +} +`, + rows: [ + { + caller: 'run', + targets: ['Method:a.ts:Handler.handle#2'], + note: 'multi-argument generic `Handler` — the container allow-lists deliberately ignore multi-arg shapes, so this only resolves via base-name erasure. DISCRIMINATING: TypeScript is on the shared path.', + }, + ], + unpaired: + "one row, adding the multi-argument SPELLING `Handler`; TypeScript's control/generic pair is the `typescript` case", + }, + { + name: 'ts-python-nested-and-multiarg', + file: 'a.ts', + source: ` +export class User {} +export interface Repo { save(x: T): void; } +export class UserRepo implements Repo { save(x: User): void {} } +export class NestSvc { + private nested: Repo>; + constructor(n: Repo>) { this.nested = n; } + runTsNested(u: User): void { this.nested.save(u); } +} +`, + extraFiles: { + 'a.py': ` +from typing import Generic, TypeVar + +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") + +class PyUser: + pass + +class PyKey: + pass + +class PyRepo(Generic[T]): + def save(self, x): + pass + +class PyHandler(Generic[K, V]): + def handle(self, k, v): + pass + +class PyNestSvc: + def __init__(self, nested: PyRepo[PyRepo[PyUser]]): + self.nested = nested + + def run_py_nested(self, u: PyUser) -> None: + self.nested.save(u) + +class PyMultiSvc: + def __init__(self, h: PyHandler[PyKey, PyUser]): + self.h = h + + def run_py_multiarg(self, k: PyKey, u: PyUser) -> None: + self.h.handle(k, u) +`, + }, + rows: [ + { + caller: 'runTsNested', + targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + note: 'DISCRIMINATING nested generic: TypeScript reaches the shared lookup, unlike the Java/Kotlin/Rust spelling rows above', + }, + { + caller: 'run_py_nested', + targets: ['Method:a.py:PyRepo.save#1'], + note: 'DISCRIMINATING nested generic in the SQUARE-bracket spelling, `PyRepo[PyRepo[PyUser]]`', + }, + { + caller: 'run_py_multiarg', + targets: ['Method:a.py:PyHandler.handle#2'], + note: 'DISCRIMINATING multi-argument generic in the square-bracket spelling, `PyHandler[PyKey, PyUser]` — the shape the container allow-lists decline', + }, + ], + unpaired: + 'every row is a generic spelling, across two languages; the control/generic pairs for both live in the `typescript` and `python` cases', + }, + // ── Annotation-only Swift and Dart ──────────────────────────────────────── + // The `swift` and `dart` cases above give the field an initializer of the + // SAME generic type, so they cannot separate "the annotation resolved" from + // "the construction resolved". Here the initializer cannot be the source: the + // Swift property is an optional with no initializer, and the Dart one is + // `late` with no initializer. Each has a non-generic control declared exactly + // the same way, so a failure of the DECORATION (`?`, `late`) reads + // differently from a failure of the type argument. + { + name: 'annotation-only-swift-dart', + file: 'A.swift', + source: ` +class User {} +class BoxRepo { func save(x: User) {} } +class Plain { func save(x: User) {} } +class AnnSvc { + var repo: BoxRepo? + func runSwiftAnnotationOnly(u: User) { repo?.save(x: u) } +} +class AnnControl { + var plain: Plain? + func runSwiftAnnotationControl(u: User) { plain?.save(x: u) } +} +`, + extraFiles: { + 'a.dart': ` +class DUser {} +class DRepo { void save(DUser x) {} } +class DPlain { void save(DUser x) {} } +class DAnnSvc { + late DRepo repo; + void runDartAnnotationOnly(DUser u) { this.repo.save(u); } +} +class DAnnControl { + late DPlain plain; + void runDartAnnotationControl(DUser u) { this.plain.save(u); } +} +`, + }, + rows: [ + { + caller: 'runSwiftAnnotationControl', + targets: ['Function:A.swift:Plain.save#1'], + note: 'control (Swift), same optional decoration and no initializer', + }, + { + caller: 'runDartAnnotationControl', + targets: ['Method:a.dart:DPlain.save#1'], + note: 'control (Dart), same `late` and no initializer', + }, + { + caller: 'runSwiftAnnotationOnly', + targets: ['Function:A.swift:BoxRepo.save#1'], + note: 'the ANNOTATION alone types the receiver — no initializer exists to be the source', + pairsWith: 'runSwiftAnnotationControl', + }, + { + caller: 'runDartAnnotationOnly', + targets: ['Method:a.dart:DRepo.save#1'], + note: 'the ANNOTATION alone types the receiver — `late` with no initializer', + pairsWith: 'runDartAnnotationControl', + }, + ], + }, + // ── How the field is REACHED ────────────────────────────────────────────── + // Every row above declares and uses the generic field in one file, in one + // class, off `this`. These are the other ways `typeOfMemberOnClass` gets + // there, in one fixture repo: through an import, through the MRO, through a + // renamed import, through the module-HOISTED type-binding branch (the second + // `resolveClassBindingForName` call this PR switched, which TypeScript is the + // only language to reach today), and through a static member. + { + name: 'ts-reach-shapes', + file: 'repo.ts', + source: ` +export class User {} +export interface Repo { save(x: T): void; } +export class UserRepo implements Repo { save(x: User): void {} } +export class Plain { save(x: User): void {} } +`, + extraFiles: { + 'base.ts': ` +import { Repo, User } from './repo'; +export class Base { + protected repo: Repo; + constructor(r: Repo) { this.repo = r; } +} +`, + 'derived.ts': ` +import { Base } from './base'; +import { User } from './repo'; +export class Derived extends Base { + runInherited(u: User): void { this.repo.save(u); } +} +`, + 'holder.ts': ` +import { Repo, User, Plain } from './repo'; +export class Holder { + static repo: Repo; + static plain: Plain; +} +export class StaticSvc { + runStatic(u: User): void { Holder.repo.save(u); } + runStaticControl(u: User): void { Holder.plain.save(u); } +} +`, + 'aliased.ts': ` +import { Repo as R, User } from './repo'; +export class AliasSvc { + private r: R; + constructor(r: R) { this.r = r; } + runAliased(u: User): void { this.r.save(u); } +} +`, + 'hoist.ts': ` +import { Repo, User } from './repo'; +export class HoistSvc { + private inner: Repo; + constructor(i: Repo) { this.inner = i; } + getRepo(): Repo { return this.inner; } + runHoisted(u: User): void { this.getRepo().save(u); } +} +`, + 'crossfile.ts': ` +import { Repo, User } from './repo'; +export class CrossSvc { + private repo: Repo; + constructor(r: Repo) { this.repo = r; } + runCrossFile(u: User): void { this.repo.save(u); } +} +`, + }, + rows: [ + { + caller: 'runCrossFile', + targets: ['Method:repo.ts:Repo.save#1', 'Method:repo.ts:UserRepo.save#1'], + note: 'CROSS-FILE: the generic type is declared and imported from another file. Every other #2833 row is single-file.', + }, + { + caller: 'runInherited', + targets: ['Method:repo.ts:Repo.save#1', 'Method:repo.ts:UserRepo.save#1'], + note: 'INHERITANCE: the field is declared on the BASE class, so `typeOfMemberOnClass` finds it by walking the MRO — a different owner scope than the one the call is written in', + }, + { + caller: 'runAliased', + targets: ['Method:repo.ts:Repo.save#1', 'Method:repo.ts:UserRepo.save#1'], + note: 'IMPORT ALIAS: the field is annotated `R` where `R` is `Repo` renamed at import. Base-name erasure yields `R`, which must still resolve through the alias.', + }, + { + caller: 'runHoisted', + targets: [ + 'Method:hoist.ts:HoistSvc.getRepo#0', + 'Method:repo.ts:Repo.save#1', + 'Method:repo.ts:UserRepo.save#1', + ], + note: 'MODULE-HOIST BRANCH: `this.getRepo().save(u)` types its step off a RETURN-type binding, which TypeScript hoists out of the class body onto the module scope — the second of the two `typeOfMemberOnClass` lookups this PR switched, and the one no other row reaches. The `getRepo` edge is the call itself and is part of the expectation.', + }, + { + caller: 'runStaticControl', + targets: ['Method:repo.ts:Plain.save#1'], + note: 'the control that CLASSIFIED the gap: a NON-generic static member receiver emitted nothing either, so `Holder.repo.save(u)` was never a generics failure. Case 6 closed both at once, and this row is the half that proves it was not a generics fix.', + }, + { + caller: 'runStatic', + targets: ['Method:repo.ts:Repo.save#1', 'Method:repo.ts:UserRepo.save#1'], + note: 'STATIC/class-level member receiver: `Holder.repo.save(u)` resolves, with the interface-dispatch fan-out every other reach shape in this fixture gets. A per-scope `typeBindings` map cannot hold both `repo` and `static repo` for one class, so Case 6 types the receiver off the static field DEF instead. The extra target relative to the control is the fan-out, not the staticness — `Repo` is an interface and `Plain` is a class.', + }, + ], + unpaired: + '`runStaticControl` IS a control, but a class-typed one against an interface-typed generic, so the two differ by the dispatch fan-out BY DESIGN and an equal-count yardstick would misread it (the row notes say so); each row pins its exact target set instead, and the static REACH is swept as a pair by `kotlin-companion-static-member`, whose two sides are both class-typed', + }, + { + name: 'php-typed-property', + file: 'a.php', + source: `repo->save($u); } +} +class ControlSvc { + private Plain $plain; + public function runControl(User $u) { $this->plain->save($u); } +} +`, + rows: [ + { + caller: 'runControl', + targets: ['Method:a.php:Plain.save#1'], + note: 'control', + }, + { + caller: 'runGeneric', + targets: ['Method:a.php:Repo.save#1'], + note: 'PHP has no generic type syntax — `private Repo $repo;` is a parse error — so a NATIVE typed property is the closest analogue and is pinned so neither the shared change nor the new docblock pass can regress it. The docblock spelling PHP actually uses for generics is a separate capture path with its own case below; this row must keep passing whatever that one does.', + // Newly swept: this case always had a control and a generic row in one + // fixture and was simply absent from the hand-maintained list the sweep + // used to be — which is the failure mode deriving it from the rows + // removes. + pairsWith: 'runControl', + }, + ], + }, + // ── DOCBLOCK-declared field types ───────────────────────────────────────── + // JavaScript has no type annotations at all and PHP cannot spell a generic in + // its own syntax, so for both languages a docblock is the ONLY way a field + // declares one — and neither bound anything, generic or not. Both now + // synthesize the same annotation-strength `@type-binding` the native syntax + // emits, so no resolution-side code distinguishes them. + { + name: 'js-docblock-field', + file: 'a.js', + source: ` +export class User {} +export class Repo { save(x) {} } +export class Plain { save(x) {} } +export class GenericSvc { + /** @type {Repo} */ + repo; + runJsGeneric(u) { this.repo.save(u); } +} +export class ControlSvc { + /** @type {Plain} */ + plain; + runJsControl(u) { this.plain.save(u); } +} +`, + rows: [ + { + caller: 'runJsControl', + targets: ['Method:a.js:Plain.save#1'], + note: 'control, and the row that says this was never a generics gap: a NON-generic `@type {Plain}` bound nothing either before the docblock pass', + }, + { + caller: 'runJsGeneric', + targets: ['Method:a.js:Repo.save#1'], + note: '`@type {Repo}` on a class field. `Repo` is an ordinary class here because JavaScript has no way to declare a generic one — the type ARGUMENT is the part that has to survive the docblock and then erase, and it does, matching the control exactly.', + pairsWith: 'runJsControl', + }, + ], + }, + { + name: 'php-docblock-property', + file: 'a.php', + source: ` */ + private $repo; + /** @var Plain */ + private $plain; + /** @var Repo[] */ + private $many; + /** @var list */ + private $listed; + /** @var Repo|Plain */ + private $united; + public function runPhpDocGeneric(User $u) { $this->repo->save($u); } + public function runPhpDocControl(User $u) { $this->plain->save($u); } + public function runPhpDocArray(User $u) { $this->many->save($u); } + public function runPhpDocList(User $u) { $this->listed->save($u); } + public function runPhpDocUnion(User $u) { $this->united->save($u); } +} +`, + rows: [ + { + caller: 'runPhpDocControl', + targets: ['Method:a.php:Plain.save#1'], + note: 'control: a NON-generic `@var Plain` on an untyped property bound nothing either, which is what makes the row below a docblock gap rather than a generics one', + }, + { + caller: 'runPhpDocGeneric', + targets: ['Method:a.php:Repo.save#1'], + note: '`@var Repo` — the only spelling PHP has for a generic field, and the reason the arguments are erased in the PHP capture rather than left to the shared lookup: `normalizePhpType` reduces `X` to `Y` for the foreach/element convention, so passing the spelling through bound the field to `User` and emitted `User::save`. A WRONG edge, so the erasure happens before that rule can read it.', + pairsWith: 'runPhpDocControl', + }, + { + caller: 'runPhpDocArray', + targets: [], + note: 'DECLINE, pinned: `@var Repo[]` types an ARRAY. Binding it to `Repo` would claim `$this->many->find(...)` for a repository class, so the array spelling emits no field binding at all — `extractPropertyElementType` reads the same annotation for `foreach`, and the two readings must not collide.', + }, + { + caller: 'runPhpDocList', + targets: [], + note: 'DECLINE, pinned: `@var list` erases to `list`, which PHP has no type for — so the only class it could ever bind is a workspace class that happens to be called `list`, i.e. exactly the wrong-edge direction. Compared case-folded rather than by listing spellings.', + }, + { + caller: 'runPhpDocUnion', + targets: [], + note: 'DECLINE, pinned, and NOT by a rule of the docblock pass: `Repo|Plain` reaches the same `normalizePhpType` a native `private Repo|Plain $x;` goes through and is rejected there. The row is here because "delegated" is a claim about behaviour, and this is the measurement of it.', + }, + ], + }, + // ── Class-level (static) member receivers ───────────────────────────────── + // `ts-reach-shapes` pins the TypeScript spelling among its other reach + // shapes. Kotlin reaches the same Case 6 by a different syntax — a + // `companion object` rather than a `static` modifier — and was equally broken + // for its non-generic control, so it gets its own case rather than a row. + { + name: 'kotlin-companion-static-member', + file: 'A.kt', + source: ` +class User +interface Repo { fun save(x: T) } +class UserRepo : Repo { override fun save(x: User) {} } +interface Plain { fun save(x: User) } +class PlainRepo : Plain { override fun save(x: User) {} } +class Holder { + companion object { + lateinit var repo: Repo + lateinit var plain: Plain + } +} +class StaticSvc { + fun runKtStatic(u: User) { Holder.repo.save(u) } + fun runKtStaticControl(u: User) { Holder.plain.save(u) } +} +`, + rows: [ + { + caller: 'runKtStaticControl', + targets: ['Method:A.kt:Plain.save#1', 'Method:A.kt:PlainRepo.save#1'], + note: 'control: a NON-generic companion member, emitting nothing before Case 6. Both members here are interface-typed so the pair is count-comparable, unlike the TypeScript one.', + }, + { + caller: 'runKtStatic', + targets: ['Method:A.kt:Repo.save#1', 'Method:A.kt:UserRepo.save#1'], + note: '`Holder.repo.save(u)` through a `companion object` member matches its control exactly, fan-out included. Kotlin erases type arguments at interpret time, so this row is about the class-level RECEIVER and nothing else — which is the point: the gap was never about generics in any language that reached it.', + pairsWith: 'runKtStaticControl', + }, + ], + }, + // ── Erased base names must be GROUNDED ──────────────────────────────────── + // Reaching a declaration by name alone binds whatever the workspace declares + // under that name. Python is where this bites hardest — it reduces + // `Mapped[User]` to `Mapped` at capture time, so every such receiver arrives + // as a bare class name with nothing to distinguish it from one — and + // `sqlalchemy.orm.Mapped` beside a workspace `class Mapped` is not a + // hypothetical collision. + { + name: 'py-erased-grounding', + file: 'a.py', + source: ` +from models import User +from sqlalchemy.orm import Mapped + +class Local: + def ping(self, u): + pass + +def run_param(m: Mapped[User], u: User) -> None: + m.save(u) + +def run_local_control(l: Local, u: User) -> None: + l.ping(u) + +def run_head_chain(m: Mapped[User], u: User) -> None: + m.inner.ping() + +class InferredSvc: + def __init__(self, m: Mapped[User]): + self.m = m + + def run_inferred_field(self, u: User) -> None: + self.m.save(u) + +class AnnotatedSvc: + m: Mapped[User] + + def run_annotated_field(self, u: User) -> None: + self.m.save(u) +`, + extraFiles: { + 'models.py': ` +class User: + def touch(self): + pass +`, + 'other.py': ` +class Inner: + def ping(self): + pass + +class Mapped: + inner: Inner + + def save(self, x): + pass +`, + 'b.py': ` +class BUser: + pass + +def run_no_import_channel(m: Mapped[BUser], u: BUser) -> None: + m.save(u) +`, + }, + rows: [ + { + caller: 'run_local_control', + targets: ['Method:a.py:Local.ping#1'], + note: 'ANTI-VACUITY CONTROL: an ordinary same-file parameter receiver in the same module as the refusal below. Without it the empty row would also be what a file that stopped parsing produces.', + }, + { + caller: 'run_head_chain', + targets: ['Method:other.py:Inner.ping#0'], + note: 'REMAINING WRONG EDGE, pinned at its measured value so closing it is a visible flip. `m.inner.ping()` binds the unrelated workspace `Mapped` through a route that survives the refusal, while the one-segment-shallower `m.save(u)` (`run_param`, above) correctly declines — same receiver, same declared type, one more segment. The obvious one-line guard in the bare-identifier branch (decline every retry once an erased application failed to ground) was tried and MEASURED not to close it, so the surviving route is elsewhere and this needs its own diagnosis rather than a guess. Deliberately not fixed here: a broader refusal would change chain-head resolution for every language without pinning the shape it is meant to fix.', + }, + { + caller: 'run_param', + targets: [], + note: 'THE REFUSAL: `Mapped[User]` no longer binds the unrelated workspace `class Mapped` in `other.py` (measured as `Method:other.py:Mapped.save#1` before). The erased base name is admitted only on a ground that connects this site to that declaration, and none holds — the name is imported from a module the workspace does not contain, `other.py` is a different file, and the index knows no `Mapped` template family.', + }, + { + caller: 'run_no_import_channel', + targets: ['Method:other.py:Mapped.save#1'], + note: 'THE GROUND THAT ADMITS, pinned so the refusal above cannot be read as "erased names never resolve": `b.py` imports nothing at all, so its failure to import `Mapped` is no evidence of anything, and the workspace-wide index answers. Identical spelling to `run_param`, opposite answer, and the file\'s import channel is the only difference.', + }, + { + caller: 'run_inferred_field', + targets: [], + note: 'THE FIELD-SIDE REFUSAL, and the row that says the grounding is no longer PARAMETER-side only: `self.m.save(u)` on a field inferred from the constructor parameter now declines exactly as `run_param` does. The wrong edge was never a defect in the grounding — the structural fold applied it and refused correctly — it was that a declined fold falls THROUGH to the text cascade by design, and the cascade held its own ungrounded copy of the member-typing lookup that re-minted the refused target from the workspace index. Both routes now ask through one lookup.', + }, + { + caller: 'run_annotated_field', + targets: [], + note: 'THE SAME REFUSAL FROM AN EXPLICIT ANNOTATION, which is what proves the fix is not about the binding SOURCE: a class-level `m: Mapped[User]` and a constructor-inferred `self.m` reach the identical answer, as they always did — both wrong before, both empty now. The receiver spelling was never the discriminator either; the two spellings simply entered different lookups, and only one of them was grounded.', + }, + ], + unpaired: + 'the rows are grounding routes and refusals, three of them asserted EMPTY, and `run_local_control` is an ANTI-VACUITY control for those rather than a count to match', + }, + // The mirrors. A grounding rule is only as good as what it still admits, and + // these are the four shapes that would break if it were tightened: two Python + // channels (same-file and imported), a C++ `#include` — which materializes no + // lexical binding whatever, so the index is its only channel — and a C# + // cross-namespace reference with no `using`. + { + name: 'py-generic-grounding-mirrors', + file: 'a.py', + source: ` +from typing import Generic, TypeVar +from repo import CrossRepo + +T = TypeVar("T") + +class User: + pass + +class SameRepo(Generic[T]): + def save(self, x): + pass + +def run_same_file(r: SameRepo[User], u: User) -> None: + r.save(u) + +def run_imported(r: CrossRepo[User], u: User) -> None: + r.save(u) +`, + extraFiles: { + 'repo.py': ` +from typing import Generic, TypeVar + +T = TypeVar("T") + +class CrossRepo(Generic[T]): + def save(self, x): + pass +`, + }, + rows: [ + { + caller: 'run_same_file', + targets: ['Method:a.py:SameRepo.save#1'], + note: 'MIRROR: a genuine same-file `SameRepo[User]` still resolves — the declaration is in the file the site is in, which is a ground on its own', + }, + { + caller: 'run_imported', + targets: ['Method:repo.py:CrossRepo.save#1'], + note: 'MIRROR: a genuine imported `CrossRepo[User]` still resolves — the import binds the name in the scope chain, the strongest ground. The two classes are named differently on purpose so neither can answer for the other.', + }, + ], + unpaired: + "both rows are generic and both are MIRRORS — shapes the grounding rule must still admit — so neither is the other's control; Python's control/generic pair is the `python` case", + }, + { + name: 'cpp-csharp-index-channel', + file: 'a.cpp', + source: ` +#include "repo.h" +void runCppIncluded(User u) { Repo r; r.save(u); } +`, + extraFiles: { + 'repo.h': ` +class User {}; +template struct Repo { void save(T x) {} }; +`, + 'Repo.cs': ` +namespace Data { + class Repo { public void Save(T x) {} } +} +`, + 'A.cs': ` +namespace App { + class User {} + class Svc { + public void RunCsNoUsing(User u) { Repo r = null; r.Save(u); } + } +} +`, + }, + rows: [ + { + caller: 'runCppIncluded', + targets: ['Method:repo.h:Repo.save#1~c:132qlr3'], + note: 'MIRROR: a C++ `#include` binds no name lexically — it is a textual include, not an import — so the workspace-wide index is the ONLY channel this site has. A grounding rule that required a lexical binding would delete this edge for every C++ program.', + }, + { + caller: 'RunCsNoUsing', + targets: ['Method:Repo.cs:Repo.Save#1'], + note: "MIRROR: C# resolves `Data.Repo` from `App` with no `using` at all, which is how C# actually behaves. Same shape as the C++ row and a second language, so the ground that admits them is not one language's quirk.", + }, + ], + unpaired: + "one C++ row and one C# row, both generic and both mirrors of the index channel; each language's control/generic pair is its own case", + }, + // ── C++ qualified fields that are not generic at all ────────────────────── + // The qualified rules were written for `std::vector`, but the node they + // match is the outer `qualified_identifier` — which is also what wraps a + // PLAIN `ns::Address`. So the same three rules closed a much larger miss + // than the generic one, and the rows below are that half. + { + name: 'cpp-qualified-non-generic-field', + file: 'a.cpp', + source: ` +struct User {}; +struct Other { void ping() {} }; +struct Plain { void save(User x) {} }; +namespace std { +struct string { void size() {} }; +} +namespace ns { +struct Address { void city() {} }; +} +struct Svc { + std::string name; + ns::Address addr; + ns::Address* paddr; + std::mutex mu; + Plain plain; + void runQualStdString() { name.size(); } + void runQualNsStruct() { addr.city(); } + void runQualNsPtr() { paddr->city(); } + void runQualAbsent() { mu.ping(); } + void runQualNonGenericControl(User u) { plain.save(u); } +}; +`, + rows: [ + { + caller: 'runQualNonGenericControl', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'control: unqualified, non-generic field in the same struct', + }, + { + caller: 'runQualStdString', + targets: ['Method:a.cpp:string.size#0'], + note: 'INTENDED, and the same accepted policy as `container-name-collision`, stated in C++: the qualifier is dropped and the tail `string` names a class this workspace really declares, so it binds. The annotation names `string`, so naming `string` is the right answer — a resolver that refused it would have to know which names are "someone else\'s", and this pipeline deliberately does not. Where the workspace does NOT declare the tail, the binding is simply absent (`runQualAbsent`), so the policy closes misses without minting edges.', + }, + { + caller: 'runQualNsStruct', + targets: ['Method:a.cpp:Address.city#0'], + note: 'a plain `ns::Address addr;` — no template arguments anywhere. This member had no type binding at all before the qualified rules, which is why the fix is not describable as a generics fix.', + // The one pair on a different axis: QUALIFIED against UNQUALIFIED rather + // than generic against plain. It belongs in this sweep because the rules + // that closed the qualified generic field closed this one with it, so a + // narrowing of them has to fail here too. + pairsWith: 'runQualNonGenericControl', + }, + { + caller: 'runQualNsPtr', + targets: ['Method:a.cpp:Address.city#0'], + note: 'the `pointer_declarator` shape of the same thing, reached through `->`', + }, + { + caller: 'runQualAbsent', + targets: [], + note: 'THE OTHER HALF OF THE POLICY, pinned: `std::mutex mu;` reduces to `mutex`, which this workspace declares nowhere, so the field binds nothing and the call emits nothing. `struct Other` declares a `ping()` and does NOT receive the edge — dropping the qualifier widens what can MATCH, it does not invent a match.', + }, + ], + }, + // ── C++ `this->field.m()` ───────────────────────────────────────────────── + // A `this`-head seed gap, not a generics one: the fold reads `this` out of a + // per-function-scope typeBinding, and C++ deliberately synthesizes none + // because it declares `this` to BE the enclosing class + // (`resolveThisViaEnclosingClass`). So every `this->x.m()` chain folded to + // nothing, for a generic member and a plain one alike. + { + name: 'cpp-this-head-field', + file: 'a.cpp', + source: ` +struct User {}; +template struct Repo { void save(User x) {} }; +struct Plain { void save(User x) {} }; +struct Svc { + Repo repo; + Plain plain; + void runThisGeneric(User u) { this->repo.save(u); } + void runThisControl(User u) { this->plain.save(u); } + void runBareGeneric(User u) { repo.save(u); } + void runBareControl(User u) { plain.save(u); } +}; +`, + rows: [ + { + caller: 'runBareControl', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'BARE-RECEIVER CONTROL, non-generic: the spelling that always worked, so the two `this->` rows can be read as a statement about the HEAD and not about the member', + }, + { + caller: 'runBareGeneric', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: 'BARE-RECEIVER CONTROL, generic: same member as `runThisGeneric`, same target, different receiver spelling', + }, + { + caller: 'runThisControl', + targets: ['Method:a.cpp:Plain.save#1'], + note: 'THE CLASSIFYING ROW: `this->plain.save(u)` names no generic anywhere and emitted nothing either. Fixing it required seeding the chain head from the enclosing class for languages that declare `this` that way — the same provider flag Case 0.5 already used for a BARE `this` receiver.', + }, + { + caller: 'runThisGeneric', + targets: ['Method:a.cpp:Repo.save#1~c:16619u1'], + note: '`this->repo.save(u)` now matches both its non-generic sibling and its bare-receiver twin, which is the definition of fixed for this file', + pairsWith: 'runThisControl', + }, + ], + }, + // ── Go: substitution, not erasure ───────────────────────────────────────── + // The `go` case above pins the fan-out an instantiation SHOULD produce. This + // one pins that it is positional: an implementor whose method takes the wrong + // type argument is not an implementor of that instantiation. + { + name: 'go-instantiation-mismatch', + file: 'a.go', + source: `package main + +type User struct{} + +type Order struct{} + +type Repo[T any] interface{ Save(x T) } + +type UserRepo struct{} + +func (r UserRepo) Save(x User) {} + +type Plain interface{ Ping() } + +type Pinger struct{} + +func (p Pinger) Ping() {} + +type GenericSvc struct{ repo Repo[Order] } + +func (s GenericSvc) RunOrderRepo(o Order) { s.repo.Save(o) } + +type ControlSvc struct{ plain Plain } + +func (s ControlSvc) RunPlainControl() { s.plain.Ping() } +`, + rows: [ + { + caller: 'RunPlainControl', + targets: ['Method:a.go:Pinger.Ping#0', 'Method:a.go:Plain.Ping#0'], + note: 'ANTI-VACUITY CONTROL: an ordinary non-generic interface field in the same file DOES fan out to its implementor, so the single target below is a refusal and not a dead fixture', + }, + { + caller: 'RunOrderRepo', + targets: ['Method:a.go:Repo.Save#1'], + note: 'NEGATIVE: `Repo[Order]` substitutes to `interface{ Save(x Order) }`, which `UserRepo.Save(x User)` does not satisfy — so the declaration is the only target and there is no fan-out. `Repo[Order]` is the ONLY instantiation written in this repo, deliberately: GitNexus holds one node per generic DECLARATION, so a repo that also wrote `Repo[User]` would union both method sets onto it and this row could not distinguish substitution from erasure.', + // PINNED NON-MATCH, and a DELIBERATE one: `Repo[Order]` must NOT fan out + // to an implementor of `Save(x User)`, while the non-generic control + // interface does fan out to its own. Substitution is positional; a match + // here would mean erasure had crept back in (see the row notes). + pairsWith: 'RunPlainControl', + matchesControl: false, + }, + ], + }, +]; + +describe('generic-typed field receivers across languages (#2833)', () => { + const results = new Map(); + + beforeAll(async () => { + for (const testCase of CASES) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `gn-2833-${testCase.name}-`)); + try { + writeFixtureRepo(dir, { [testCase.file]: testCase.source, ...(testCase.extraFiles ?? {}) }); + // CALLS resolution is complete before the graph phases run and nothing + // here reads what they produce, so skipping them narrows each run to the + // phase under test. + results.set( + testCase.name, + await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + ); + } finally { + // Not a bare `rmSync`: a pipeline run can still hold a handle open when + // this fires, which surfaces as EBUSY/EPERM on Windows — `force` does + // not suppress that — and this suite runs in the sharded Windows CI. + // One fixture repo (each with its own LadybugDB) per CASE, so without + // this the suite leaks 41 of them per run. + cleanupTempDirSync(dir); + } + } + }, 1800000); + + function resultFor(name: string): PipelineResult { + const result = results.get(name); + if (result === undefined) throw new Error(`no pipeline result for ${name}`); + return result; + } + + /** Node ids of every function-like node whose simple name is `caller`. + * Returned as a list so the suite can assert there is exactly ONE — an id + * scheme change or fixture drift would otherwise turn a row into a + * vacuous empty-vs-empty comparison, which is how a pinned gap rots into a + * passing lie. */ + function callerIds(name: string, caller: string): string[] { + const ids: string[] = []; + resultFor(name).graph.forEachNode((node) => { + if (node.properties.name === caller) ids.push(node.id); + }); + return ids.sort(); + } + + /** Every CALLS target id emitted by the one node named `caller`, sorted and + * WITH multiplicity. Row assertions use the deduplicated view below; the + * duplicate sweep at the bottom of this file is the only reader of this one, + * and exists so that deduplicating cannot hide a double-emit regression. */ + function rawCallTargets(name: string, caller: string): string[] { + const ids = new Set(callerIds(name, caller)); + return getRelationships(resultFor(name), 'CALLS') + .filter((edge) => ids.has(edge.rel.sourceId)) + .map((edge) => edge.rel.targetId) + .sort(); + } + + /** Distinct CALLS target ids emitted by the one node named `caller`, sorted. + * Edge MULTIPLICITY is a different question from whether the receiver typed + * at all, and every row here asks the second one. */ + function callTargets(name: string, caller: string): string[] { + return [...new Set(rawCallTargets(name, caller))].sort(); + } + + for (const testCase of CASES) { + describe(testCase.name, () => { + it('every row names exactly one live caller node', () => { + const found = Object.fromEntries( + testCase.rows.map((row) => [row.caller, callerIds(testCase.name, row.caller).length]), + ); + expect(found).toEqual(Object.fromEntries(testCase.rows.map((row) => [row.caller, 1]))); + }); + + for (const row of testCase.rows) { + it(`${row.caller}: ${row.note}`, () => { + expect(callTargets(testCase.name, row.caller)).toEqual([...row.targets].sort()); + }); + } + }); + } + + // A generic-typed field must not merely emit SOMETHING — it must emit exactly + // as many targets as the SAME language's non-generic control field. Asserted + // across the whole matrix in one place so adding a language cannot quietly + // skip it, and DERIVED from the rows rather than restated: a second + // hand-maintained list of caller names is exactly how a case gets left out of + // a sweep the file's own header says nothing may be left out of (measured: + // 19 of 41 cases had no entry in that list). Keyed by case AND generic + // caller, because one case can pin several pairs (the C++ pointer/reference + // forms, and the two languages of a shared fixture); `matchesControl: false` + // on a row is what makes a pinned NON-match visible in the expectation. + const PAIRED: readonly { + readonly name: string; + readonly control: string; + readonly generic: string; + readonly matches: boolean; + }[] = CASES.flatMap((testCase) => + testCase.rows.flatMap((row) => + row.pairsWith === undefined + ? [] + : [ + { + name: testCase.name, + control: row.pairsWith, + generic: row.caller, + matches: row.matchesControl ?? true, + }, + ], + ), + ); + + // The derivation above can only sweep a case that CLAIMS a pair, so this is + // the gate that keeps "no pair" a decision instead of an oversight: every + // case must either carry a `pairsWith` row or state in `unpaired` why the + // control/generic yardstick does not apply to it — and never both, which + // would be a case arguing with itself. A count of 0 is the omission this + // exists to catch; a count of 2 is a contradiction. + it('every case is either swept as a control/generic pair or says why it is not', () => { + const classified = Object.fromEntries( + CASES.map((testCase) => [ + testCase.name, + [ + testCase.rows.some((row) => row.pairsWith !== undefined), + testCase.unpaired !== undefined, + ].filter(Boolean).length, + ]), + ); + expect(classified).toEqual(Object.fromEntries(CASES.map((testCase) => [testCase.name, 1]))); + + // A `pairsWith` naming a caller no row of the same case declares would sweep + // a control that does not exist, which reads as an ordinary count mismatch + // rather than as the typo it is. + const danglingControls = CASES.flatMap((testCase) => + testCase.rows.flatMap((row) => + row.pairsWith === undefined || testCase.rows.some((other) => other.caller === row.pairsWith) + ? [] + : [`${testCase.name}/${row.caller} -> ${row.pairsWith}`], + ), + ); + expect(danglingControls).toEqual([]); + }); + + const pairKey = (pair: { readonly name: string; readonly generic: string }): string => + `${pair.name}/${pair.generic}`; + + it('each language generic field matches (or, where pinned, does not match) its own control row', () => { + const observed = Object.fromEntries( + PAIRED.map((p) => [ + pairKey(p), + callTargets(p.name, p.generic).length === callTargets(p.name, p.control).length, + ]), + ); + expect(observed).toEqual(Object.fromEntries(PAIRED.map((p) => [pairKey(p), p.matches]))); + }); + + // Every control row must emit SOMETHING. Without this, a fixture that stopped + // parsing would make the comparison above pass by emptying both sides — the + // exact way a matrix rots into a green lie. + it('every control row emits at least one edge', () => { + const observed = Object.fromEntries( + PAIRED.map((p) => [pairKey(p), callTargets(p.name, p.control).length > 0]), + ); + expect(observed).toEqual(Object.fromEntries(PAIRED.map((p) => [pairKey(p), true]))); + }); + + // Which target a C++ instantiation lands on must be a function of the + // arguments written, never of which declaration the file happens to write + // first. Asserted as an EQUALITY between two independently-built fixtures + // rather than against literals, so it states the property; the literals are + // pinned by the four rows those fixtures already own. + it('C++ specialization selection does not depend on declaration order', () => { + expect({ + explicitBool: callTargets('cpp-spec-order-primary-first', 'runBool'), + explicitInt: callTargets('cpp-spec-order-primary-first', 'runInt'), + partial: callTargets('cpp-partial-spec-primary-first', 'runPtrArg'), + }).toEqual({ + explicitBool: callTargets('cpp-spec-order-specialization-first', 'runBool'), + explicitInt: callTargets('cpp-spec-order-specialization-first', 'runInt'), + partial: callTargets('cpp-partial-spec-partial-first', 'runPtrArg'), + }); + }); + + // `callTargets` deduplicates, which is right for the question every row asks + // and wrong as a blanket policy: a language that started emitting each CALLS + // edge twice would go unnoticed. This is the counterweight — the number of + // SURPLUS edges (raw minus distinct) summed over a case's rows, pinned per + // case. Anything not listed must be exactly zero, so a new duplicate anywhere + // fails here even though the rows themselves stay green. + // + // MEASURED: the map is EMPTY. The dedup was originally justified by Swift + // emitting the same edge twice for one call site — no fixture in this file + // reproduces that, Swift's included, so every case is pinned at zero surplus + // rather than the dedup being excused wholesale on one language's behalf. If + // a Swift shape that really does double-emit is added here, pin it as a + // number on that case and leave every other case at zero. + const SURPLUS_EDGES: Readonly> = {}; + + it('no case emits a CALLS edge more than once per call site, except where pinned', () => { + const observed = Object.fromEntries( + CASES.map((testCase) => [ + testCase.name, + testCase.rows.reduce( + (surplus, row) => + surplus + + rawCallTargets(testCase.name, row.caller).length - + callTargets(testCase.name, row.caller).length, + 0, + ), + ]), + ); + expect(observed).toEqual( + Object.fromEntries( + CASES.map((testCase) => [testCase.name, SURPLUS_EDGES[testCase.name] ?? 0]), + ), + ); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c8d5e4b09..e7bf7c392 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -133,8 +133,26 @@ describe('PARSE_CACHE_VERSION', () => { // drift apart, which is what forces the re-check to happen at all. // Moved 45 -> 46 for method-level Spring `@RequestMapping` routes (#2824): // cached ParseWorkerResults otherwise replay the pre-fix empty route set. - it('pins SCHEMA_BUMP to 47 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(47); + // + // Moved 47 -> 48 for #2833's three parse-time changes: C++ + // `field_declaration` captures for `template_type` and qualified generic + // member types (those members had NO type binding before), a Python interpret + // change that reduces `Repo[User]` to `Repo` in `TypeRef.rawName`, and the new + // `SymbolDefinition.typeParameters` field read from a + // `@declaration.type-parameters` capture in six languages. All three are + // serialized into the cached ParsedFile, so an older warm cache replays + // pre-fix bindings and the fix is a silent no-op on incremental analyze while + // every cold-run test still passes. + // + // 48, not 46, because this branch collided TWICE: it staged 46 and then 47, + // both free when written, and by merge time #2856 claimed 46 and #2857 took 47 + // and merged first. This assertion is exactly what CANNOT detect that — the + // branch asserted `toBe(47)` and so did #2857, and both passed. What this pin + // does do is fail loudly the moment the constant and this expectation drift + // apart, which is what forces the merge-time diff against origin/main to + // happen at all. + it('pins SCHEMA_BUMP to 48 so concurrent bumps cannot silently collide (#2833)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(48); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/scope-resolution/python/python-generic-annotation-reduction.test.ts b/gitnexus/test/unit/scope-resolution/python/python-generic-annotation-reduction.test.ts new file mode 100644 index 000000000..c7429f14a --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/python/python-generic-annotation-reduction.test.ts @@ -0,0 +1,275 @@ +/** + * `interpretPythonTypeBinding` annotation reduction (#2833, #2855). + * + * Python spells type application with SQUARE brackets, so the reduction that + * makes `Repo[User]` usable as a receiver type shares its syntax with three + * other things that must NOT be reduced the same way: + * + * - a CONTAINER, which reduces to its ELEMENT (`list[User]` -> `User`), never + * to its base — reducing to `list` would type a receiver as the container + * and retarget every call in a for-loop chain; + * - a container shape the container rules decline, notably a nested value + * (`dict[str, list[User]]`): the dict rule's value group cannot span a + * nested `]`, so it falls through, and the annotation must survive INTACT + * for the downstream strip pass rather than collapsing to `dict`; + * - a `typing` SPECIAL FORM (`Callable`, `Literal`, `Annotated`, `Union`), + * which is not a class at all. Reducing one yields a bare `Callable` or + * `Literal`, which binds to a workspace class of that name if the codebase + * declares one — a fabricated edge, and those names are ordinary enough to + * collide for real. + * + * Every row below was measured against the implementation; the three groups + * exist because the first cut of #2833 reduced by fallthrough alone and got the + * last two wrong. + * + * ── The #2855 lesson ────────────────────────────────────────────────────── + * The first cut of that guard was a hand-written deny set checked by EXACT + * match, and the tests asserted members OF THAT SET — tautological with respect + * to omissions, so every name nobody thought of escaped silently. `Deque` was + * the proof: its lowercase twin `deque` was listed, `Deque` was not, and + * `self.dq: Deque[User]` reduced to `Deque` and bound to a workspace + * `class Deque`. + * + * The tests below are therefore written so that an OMISSION fails, not just a + * regression on a name someone already remembered. Each derives its inputs from + * something other than the deny set's own membership: + * - `PEP_585_TYPING_ALIASES` comes from the CPython documentation, not the + * implementation; + * - the case-fold closure derives spellings mechanically from every listed + * name, so a half-listed pair fails; + * - the container coverage derives from the two container-matcher name + * arrays, so adding a container without declining it fails. + */ +import { describe, it, expect } from 'vitest'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + interpretPythonTypeBinding, + NOT_A_USER_GENERIC_SPELLINGS, + SINGLE_ARG_CONTAINERS, + MAPPING_CONTAINERS, +} from '../../../../src/core/ingestion/languages/python/interpret.js'; + +const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; +const cap = (name: string, text: string): Capture => ({ name, text, range: ZERO_RANGE }); + +/** Minimal annotation capture — the only fields the interpreter reads. */ +function annotation(typeText: string): CaptureMatch { + return { + '@type-binding.name': cap('@type-binding.name', 'x'), + '@type-binding.type': cap('@type-binding.type', typeText), + '@type-binding.annotation': cap('@type-binding.annotation', typeText), + }; +} + +function reduce(typeText: string): string | null { + return interpretPythonTypeBinding(annotation(typeText))?.rawTypeName ?? null; +} + +/** + * A subscripted shape BOTH container rules decline: the single-arg rule's + * element group cannot span a comma, and the mapping rule's value group cannot + * span the nested `]`. So every name reaches the last-resort user-generic + * branch, and the only thing that can stop it collapsing to the bare base is + * being declined as a non-user-generic. One probe, uniform across every name, + * whatever that name's real arity. + */ +const probe = (base: string): string | null => reduce(`${base}[str, list[User]]`); + +/** The names from `names` that the last-resort branch collapsed to a bare base. */ +const collapsing = (names: readonly string[]): readonly string[] => + names.filter((name) => probe(name) === name); + +const capitalize = (name: string): string => + name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + +describe('Python annotation reduction (#2833)', () => { + it('reduces a user-defined generic to the declaration its base names', () => { + expect({ + simple: reduce('Repo[User]'), + qualified: reduce('mod.Repo[User]'), + multiArg: reduce('Handler[Req, Res]'), + nullable: reduce('Optional[Repo[User]]'), + unionNullable: reduce('Repo[User] | None'), + }).toEqual({ + simple: 'Repo', + qualified: 'mod.Repo', + multiArg: 'Handler', + nullable: 'Repo', + unionNullable: 'Repo', + }); + }); + + it('still reduces a container to its ELEMENT, never to its base', () => { + expect({ + list: reduce('list[User]'), + List: reduce('List[User]'), + sequence: reduce('Sequence[User]'), + dict: reduce('dict[str, User]'), + }).toEqual({ list: 'User', List: 'User', sequence: 'User', dict: 'User' }); + }); + + // The regression the deny set exists for. Without it these collapse to the + // CONTAINER name, destroying the value type the dict rule deliberately leaves + // for a downstream pass. + it('leaves a container shape its own rules declined completely intact', () => { + expect({ + nestedValue: reduce('dict[str, list[User]]'), + nestedGenericValue: reduce('Dict[str, Repo[User]]'), + variadicTuple: reduce('tuple[int, ...]'), + }).toEqual({ + nestedValue: 'dict[str, list[User]]', + nestedGenericValue: 'Dict[str, Repo[User]]', + variadicTuple: 'tuple[int, ...]', + }); + }); + + // Reducing these would bind a receiver to a workspace class that merely + // shares a name with a typing construct — a fabricated edge, and strictly + // worse than the missing edge #2833 set out to fix. + it('never reduces a typing special form to its base name', () => { + expect({ + callable: reduce('Callable[[int], User]'), + literal: reduce('Literal["a"]'), + annotated: reduce('Annotated[int, Field()]'), + union: reduce('Union[A, B]'), + }).toEqual({ + callable: 'Callable[[int], User]', + literal: 'Literal["a"]', + annotated: 'Annotated[int, Field()]', + union: 'Union[A, B]', + }); + }); + + it('leaves an unsubscripted or malformed annotation alone', () => { + expect({ plain: reduce('User'), empty: reduce('Repo[]') }).toEqual({ + plain: 'User', + empty: 'Repo[]', + }); + }); +}); + +describe('Python annotation reduction — non-user-generic bases (#2855)', () => { + // The sharpest escape, and the reason the closure test below exists: this is + // not a judgement call about an exotic name, it is an INTERNAL INCONSISTENCY. + // `deque` was declined; its own `typing` alias was not. End to end: with a + // workspace `class Deque`, `self.dq: Deque[User]` followed by + // `self.dq.appendleft(x)` emitted a fabricated `Deque.appendleft` edge. + it('declines a `typing` alias whose lowercase twin is already declined', () => { + expect({ builtinSpelling: reduce('deque[User]'), typingAlias: reduce('Deque[User]') }).toEqual({ + builtinSpelling: 'deque[User]', + typingAlias: 'Deque[User]', + }); + }); + + /** + * The `typing` deprecated aliases to `builtins` and `collections`, from + * — an + * EXTERNAL source of truth, which is what makes this test able to fail on a + * name the implementation forgot. Listed here because `capitalize` cannot + * derive the multi-word spellings (`frozenset` -> `FrozenSet`) that the + * mechanical closure below approximates. + */ + const PEP_585_TYPING_ALIASES: readonly string[] = [ + 'List', + 'Set', + 'FrozenSet', + 'Tuple', + 'Dict', + 'Type', + 'DefaultDict', + 'OrderedDict', + 'ChainMap', + 'Counter', + 'Deque', + 'Pattern', + 'Match', + 'ContextManager', + 'AsyncContextManager', + ]; + + it('declines every documented PEP 585 `typing` alias', () => { + expect(collapsing(PEP_585_TYPING_ALIASES)).toEqual([]); + }); + + /** + * The property that makes the whole bug class mechanical. PEP 585 gave nearly + * every container two spellings differing ONLY in case, so a deny set matched + * exactly had to carry both and any half-pair was a silent escape. Deriving + * the spellings from every listed name means a half-pair cannot survive + * review — which is exactly how `Deque` would have been caught for free. + * + * `capitalize` is a deliberately over-inclusive approximation of the `typing` + * alias spelling: it yields `Deque` from `deque` (the case that mattered) and + * `Frozenset` from `frozenset` (not the real alias, but declining it is + * harmless and the real `FrozenSet` is pinned by the table above). + */ + it('declines every case spelling of every non-user-generic it lists', () => { + const spellings = NOT_A_USER_GENERIC_SPELLINGS.flatMap((name) => [ + name, + name.toLowerCase(), + capitalize(name), + ]); + expect(collapsing([...new Set(spellings)])).toEqual([]); + }); + + /** + * The other direction: a container the matchers OWN must also be declined as + * a user generic, because a shape those matchers decline (a nested value) + * falls through to the last-resort branch. Derived from the matcher's own + * name arrays, so adding a container to the matcher without declining it + * fails here rather than silently destroying its element type. + */ + it('declines every container its own matchers name', () => { + expect(collapsing([...SINGLE_ARG_CONTAINERS, ...MAPPING_CONTAINERS])).toEqual([]); + }); + + // The families measured escaping in the #2855 review, one representative row + // per family, asserted end to end rather than through the deny set. + it('leaves the stdlib type-system surface intact', () => { + expect({ + collectionsView: reduce('KeysView[User]'), + mappingView: reduce('MappingView[User]'), + contextManager: reduce('ContextManager[User]'), + genericBase: reduce('Generic[T]'), + protocolBase: reduce('Protocol[T]'), + narrowingForm: reduce('TypeIs[User]'), + typedDictQualifier: reduce('ReadOnly[int]'), + paramSpecForm: reduce('Concatenate[int, P]'), + qualifiedRePattern: reduce('re.Pattern[str]'), + ioStream: reduce('BinaryIO[str]'), + stdlibQueue: reduce('Queue[User]'), + qualifiedAsyncioTask: reduce('asyncio.Task[User]'), + }).toEqual({ + collectionsView: 'KeysView[User]', + mappingView: 'MappingView[User]', + contextManager: 'ContextManager[User]', + genericBase: 'Generic[T]', + protocolBase: 'Protocol[T]', + narrowingForm: 'TypeIs[User]', + typedDictQualifier: 'ReadOnly[int]', + paramSpecForm: 'Concatenate[int, P]', + qualifiedRePattern: 're.Pattern[str]', + ioStream: 'BinaryIO[str]', + stdlibQueue: 'Queue[User]', + qualifiedAsyncioTask: 'asyncio.Task[User]', + }); + }); + + /** + * The deliberate BOUNDARY of the deny set, pinned so it is a decision rather + * than an oversight. Third-party generics keep reducing: that universe is + * open, enumerating it only ever chases the last escape, and declining an + * ordinary name like `Model` would cost real edges in the many projects that + * declare one. These reductions are also semantically CORRECT — the base does + * name the declaration. What is not correct is the resolution-side binding of + * that base by `findClassBindingInScope`'s scope-free single-match fallback, + * which is where the follow-up to #2855 belongs. + */ + it('still reduces a third-party generic, by design', () => { + expect({ + sqlalchemy: reduce('Mapped[int]'), + django: reduce('QuerySet[User]'), + ordinaryName: reduce('Model[User]'), + }).toEqual({ sqlalchemy: 'Mapped', django: 'QuerySet', ordinaryName: 'Model' }); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/type-parameters.test.ts b/gitnexus/test/unit/scope-resolution/type-parameters.test.ts new file mode 100644 index 000000000..273d5c90c --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/type-parameters.test.ts @@ -0,0 +1,131 @@ +/** + * `parseTypeParameterList` — the shared reader behind + * `SymbolDefinition.typeParameters` (#2833). + * + * ── WHAT THIS FILE PINS ─────────────────────────────────────────────────────── + * + * The DECLARED type-parameter list was captured nowhere before this. Three + * separate defects traced back to that one absence, and the first block below + * pins the parse that supplies it while the second pins the fact it unblocks. + * + * The parser is deliberately language-NEUTRAL (AGENTS.md R6): it recognizes + * tokens, not languages, and every token it recognizes it recognizes for all + * input. So the spellings are asserted together, in one table, rather than + * per-language — a rule that only fires for one language's spelling would be a + * language name in shared code wearing a disguise. + * + * ── THE C++ SPECIALIZATION DISCRIMINATOR (Gap A) ────────────────────────────── + * + * The second block pins the fact rather than an algorithm. Before this work a + * full specialization `template <> struct Vec` and a partial + * `template struct Vec` were BYTE-IDENTICAL to the resolver — + * both carried `templateArguments: ['T*']` and nothing else — so no partial + * specialization rule could be written at all, correct or otherwise. They are + * now three distinguishable shapes. Partial ORDERING ("most specialized wins" + * across several partials) is a real algorithm and is deliberately NOT + * implemented here; this pins the input it would need, so that whoever writes it + * finds the discriminator already load-bearing and gets a failure rather than a + * silent regression if a capture change takes it away again. + */ +import { describe, it, expect } from 'vitest'; +import { parseTypeParameterList } from '../../../src/core/ingestion/utils/type-parameters.js'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { cppProvider } from '../../../src/core/ingestion/languages/c-cpp.js'; +import type { SymbolDefinition } from 'gitnexus-shared'; + +describe('parseTypeParameterList', () => { + it.each([ + // spelling expected + ['', [{ name: 'T' }]], + ['', [{ name: 'T' }, { name: 'U' }]], + // `extends` (TypeScript, Java) and `:` (Kotlin, Rust) both introduce a bound. + ['', [{ name: 'T', bound: 'Repo' }]], + ['', [{ name: 'T', bound: 'Repo' }]], + // The name is the LAST identifier before the bound, which is what makes a + // keyword prefix, a variance annotation and a bare name one rule. + ['', [{ name: 'T' }]], + ['', [{ name: 'T' }]], + ['', [{ name: 'T' }]], + ['', [{ name: 'T' }]], + ['', [{ name: 'T', bound: 'Repo' }]], + ['', [{ name: 'Ts' }]], + // A default is neither name nor bound. + ['', [{ name: 'T' }]], + ['', [{ name: 'T', bound: 'Repo' }]], + // Commas inside a bound are not entry separators. + [', K>', [{ name: 'T', bound: 'Map' }, { name: 'K' }]], + // An intersection bound is kept VERBATIM — splitting it is the consumer's + // decision, and `soleBoundBaseName` declines on it rather than guessing. + ['', [{ name: 'T', bound: 'Repo & Closeable' }]], + // A capture that spans the `template` keyword parses like a bare list. + ['template ', [{ name: 'T' }]], + ])('parses %s', (text, expected) => { + expect(parseTypeParameterList(text)).toEqual(expected); + }); + + it.each([ + ['a non-generic declaration has no list', 'Plain'], + ['an EMPTY list is not a parameter list — this is a C++ FULL specialization', '<>'], + ['an unbalanced list yields nothing rather than a partial read', ' { + expect(parseTypeParameterList(text)).toBeUndefined(); + }); + + it('declines a Rust lifetime rather than inventing a name for it', () => { + // `'a` declares nothing a member lookup can be performed on. The sibling + // type parameter in the same list still parses. + expect(parseTypeParameterList("<'a, T: Repo>")).toEqual([{ name: 'T', bound: 'Repo' }]); + }); +}); + +describe('C++ specialization discriminator (#2833 Gap A input)', () => { + const SOURCE = `template struct Vec { T* data; }; +template <> struct Vec { int bits; }; +template struct Vec { T* p; }; +`; + + /** The distinct `Vec` shapes, deduped by def id — the C++ query matches a + * templated struct through both its standalone and its `template_declaration` + * pattern, so each declaration mints two defs under one id. */ + function vecShapes(): { templateArguments?: string[]; typeParameters?: unknown }[] { + const parsed = extractParsedFile(cppProvider, SOURCE, 'vec.cpp'); + expect(parsed).toBeDefined(); + const byId = new Map(); + for (const def of parsed!.localDefs) { + if (def.qualifiedName === 'Vec' && !byId.has(def.nodeId)) byId.set(def.nodeId, def); + } + return [...byId.values()].map((def) => ({ + templateArguments: def.templateArguments, + typeParameters: def.typeParameters, + })); + } + + it('tells the primary, the full specialization and the partial apart', () => { + expect(vecShapes()).toEqual([ + // PRIMARY — written against its parameters, pins no arguments. + { templateArguments: undefined, typeParameters: [{ name: 'T' }] }, + // FULL specialization — pins arguments, declares NO parameters (`template <>`). + { templateArguments: ['bool'], typeParameters: undefined }, + // PARTIAL specialization — pins arguments AND declares a parameter. This + // row is the one that did not exist before #2833: without + // `typeParameters` it was byte-identical to the full specialization. + { templateArguments: ['T*'], typeParameters: [{ name: 'T' }] }, + ]); + }); + + it('gives both twins of one declaration the same parameters, whichever wins', () => { + // `buildDefIndex` is first-write-wins, so if only the `template_declaration` + // twin carried the parameters, match ORDER would decide whether a templated + // struct remembers them. The extractor backfills across the twins precisely + // so this assertion cannot depend on that order. + const parsed = extractParsedFile(cppProvider, SOURCE, 'vec.cpp'); + const primaries = parsed!.localDefs.filter( + (def) => def.qualifiedName === 'Vec' && def.templateArguments === undefined, + ); + expect(primaries.length).toBeGreaterThan(1); + for (const twin of primaries) expect(twin.typeParameters).toEqual([{ name: 'T' }]); + }); +});