Merge remote-tracking branch 'origin/main' into fix/blind-spots-impact-honest-absence

# Conflicts:
#	gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts
#	gitnexus/src/storage/parse-cache.ts
#	gitnexus/test/unit/incremental-parse-cache.test.ts
This commit is contained in:
ReidenXerx 2026-08-07 19:55:46 +03:00
commit 7da1530950
33 changed files with 5879 additions and 291 deletions

View file

@ -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,

View file

@ -24,6 +24,38 @@ export interface ParameterTypeClass {
templateArguments?: string[];
}
/**
* One declared generic/template TYPE PARAMETER `T` in `class Box<T extends
* Repo>`, `template <class T> struct Vec`, `interface Repo<T>`.
*
* 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>`
* `['bool']`); `typeParameters` records the parameters it was written IN TERMS
* OF. A declaration can carry both a C++ partial specialization
* `template <class T> struct Vec<T*>` 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<T*>`, 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<User>` 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<P, T>` predicate trees, C++20 `requires` clauses).
* Opaque to shared code the producing language adapter owns the shape

View file

@ -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

View file

@ -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",

View file

@ -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 `NamespaceRecord` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), 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 <file>), 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<User>` (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<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> 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<T>), 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<T>), 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<T>()`); 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.<field> = 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.<field> = 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
}
}

View file

@ -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<Entity_n> repo;` is a member
// whose declared type is a bare `template_type`, and
// `std::vector<Entity_n> 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 <string>\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
'#include <string>\n#include <vector>\n\ntemplate <typename T>\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<Entity${n}> repo;\n` +
` std::vector<Entity${n}> items;\n` +
` long getId() const { return id; }\n` +
` void setName(std::string v) { name = v; }\n};\n\n`,
},

View file

@ -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<User>`), 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<User>` `Repo<User>`,
* `ns::Address` `Address`, an unqualified spelling unchanged.
*
* Only TOP-LEVEL separators count, so a qualified TYPE ARGUMENT survives:
* `std::vector<std::string>` reduces to `vector<std::string>`, 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

View file

@ -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 <class T>\`), 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<User> 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<User> 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<User>* 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<User>& 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<Item> 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<Item> 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<T>, 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<User> 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<User> 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>* 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<Item>& 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

View file

@ -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

View file

@ -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 nameconstraint 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<string, string>,
): 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);
}

View file

@ -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<string, readonly SymbolDefinition[]>;
type MutableMethodSet = Map<string, SymbolDefinition[]>;
@ -38,13 +43,32 @@ type DetectionIndexes = {
readonly structsById: ReadonlyMap<string, SymbolDefinition>;
readonly methodsByOwner: ReadonlyMap<string, MethodSet>;
readonly effectiveMethodsByStructId: ReadonlyMap<string, MethodSet>;
readonly interfaceById: ReadonlyMap<string, SymbolDefinition>;
/** 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<string, SymbolDefinition | null>;
readonly interfaceOwnMethodsById: ReadonlyMap<string, MethodSet>;
readonly embeddedSitesByInterfaceId: ReadonlyMap<string, readonly ReferenceSite[]>;
readonly parentStructIdsByStructId: ReadonlyMap<string, readonly EmbeddedParent[]>;
readonly valueMethodsByStructId: ReadonlyMap<string, MethodSet>;
readonly structIdsByMethodName: ReadonlyMap<string, ReadonlySet<string>>;
readonly signatureContextByDefId: ReadonlyMap<string, SignatureContext>;
/** 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<string, readonly string[]>;
/**
* 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<string, ReadonlyMap<string, readonly string[]>>;
readonly scopeIndexes: ScopeResolutionIndexes;
};
@ -73,14 +97,21 @@ function buildDetectionIndexes(
const signatureContextByDefId = new Map<string, SignatureContext>();
const interfaceIdByScopeId = new Map<string, string>();
const structIdByScopeId = new Map<string, string>();
const typeParametersByInterfaceId = new Map<string, readonly string[]>();
const signatureContextByFilePath = new Map<string, SignatureContext>();
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<string, SymbolDefinition | null>();
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<string, SignatureContext>,
typeParametersByInterfaceId: ReadonlyMap<string, readonly string[]>,
interfacesByQualifiedName: ReadonlyMap<string, SymbolDefinition | null>,
indexes: ScopeResolutionIndexes,
): ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> {
// 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<string, Map<string, readonly string[]>>();
// 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<string, SymbolDefinition | null>();
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<string, readonly string[]>();
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<string, SymbolDefinition | null>,
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<string, SymbolDefinition | null>,
): 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<string, GoStructuralImplementor[]> {
@ -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<string, GoReceiverForm>();
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<User>` 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<string, number>,
normalizedArgs: readonly string[],
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
): MutableMethodSet | undefined {
const out = new Map<string, SymbolDefinition[]>();
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<string, number>,
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 {

View file

@ -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<string, string> },
): 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<string, ParsedFile[]>();
// A file with no resolvable package clause is dropped from ownership
// resolution entirely — its methods never attach to a struct declared in a

View file

@ -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

View file

@ -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<User>`, `models.Repo`, `Handler<Req, Res>`, `Repo|null`,
* `Repo<User> | 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<User>` 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<Repo>`, `Promise<Repo>`). 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<User>} */
* repo;` bound NOTHING, taking down the non-generic control (`{Plain}`) with
* it. TypeScript's equivalent `repo: Repo<User>` 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<User>`. 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);

View file

@ -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 (\`<T : Repo>\`) 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

View file

@ -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<number>;
} {
const inheritance: CaptureMatch[] = [];
const docProperties: CaptureMatch[] = [];
const docPropertyAnchorIds = new Set<number>();
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<User> $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<User>` 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<int,User>`
* `array`, `iterable<User>` `iterable`) or to a real class whose methods are
* what the field's receiver actually calls (`Collection<User>` `Collection`,
* `Generator<User>` `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<string> = new Set(['list']);
/**
* Erase type ARGUMENTS from a docblock type, leaving the base name:
* `Repo<User>` `Repo`, `Repo<Repo<User>>` `Repo`, `Repo<User>|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<User>|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<User>` `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<Y>` to `Y` the CONTAINER-ELEMENT convention, pinned by
* `test/integration/resolvers/php.test.ts` ("normalizePhpType
* ('Collection<User>') must yield 'User', not 'Collection'") because the
* foreach path depends on it. Measured: passing `Repo<User>` 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<User>[]`
// 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<User>
* $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<number>,
): 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] };
}

View file

@ -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
* <https://docs.python.org/3/library/typing.html#deprecated-aliases>.
*
* 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<string> = 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

View file

@ -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

View file

@ -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<Repo>`,
* `Promise<Repo>`, `Set<Repo>`, ).
*
* 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;
}

View file

@ -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

View file

@ -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<ScopeId, ScopeDraft>();
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<string, SymbolDefinition>();
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 <class T> 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<string> = new Set<string>([
'@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 <class A,
// class B, …>` or a multi-line Java `<T extends A & B>` 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',
]);

View file

@ -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<string>()` arrives here as `Box<string>`,
// 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<User>` `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<User>` 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;

View file

@ -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.
*
@ -60,7 +69,6 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import { collectNamespaceTargets } from '../scope/namespace-targets.js';
import {
findClassBindingInScope,
findShapeBindingInScope,
findEnclosingClassDef,
isReceiverOwnedButUnbound,
findExportedDef,
@ -70,6 +78,7 @@ import {
isClassLike,
isNamespaceNameShadowed,
type DecorationStripper,
resolveClassBindingForName,
} from '../scope/walkers.js';
import {
tryEmitEdge,
@ -78,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,
@ -120,72 +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;
// A receiver may be typed as an object-type ALIAS, which declares members
// exactly as an interface does but is not class-like, so it binds nothing
// above. Tried only after the class lookup misses, so a class of the same
// name always wins and nothing that resolved before changes. Confined to
// this helper on purpose: its two callers are member dispatch and an
// origin-classifying diagnostic, never inheritance — see `isShapeLike` for
// why the two questions must not share a predicate.
const shape = findShapeBindingInScope(scopeId, rawClassName, scopes);
if (shape !== undefined) return shape;
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$]*$/;
@ -377,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.
@ -583,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,
@ -1477,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]!);
@ -1493,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,
@ -1503,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,
@ -1784,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 —
@ -1832,6 +2055,50 @@ export function emitReceiverBoundCalls(
return { emitted, dispatchFanoutSkipped, dispatchFanoutSkippedNames };
}
/** A receiver of the exact shape `<name>.<name>` 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

View file

@ -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([]);
@ -438,16 +449,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<string, SymbolDefinition>();
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()];
}
@ -468,6 +492,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<ScopeId, ReadonlySet<string>>
>();
const NO_TYPE_PARAMETERS: ReadonlySet<string> = Object.freeze(new Set<string>());
/**
* 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<string> {
let byScope = typeParameterNamesByBundle.get(scopes);
if (byScope === undefined) {
byScope = new Map<ScopeId, ReadonlySet<string>>();
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<ScopeId>();
let cursor: ScopeId | null = scopeId;
let inherited: ReadonlySet<string> = 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<string> | undefined;
for (const def of scope?.ownedDefs ?? []) {
for (const parameter of def.typeParameters ?? []) {
if (parameter.name.length === 0) continue;
own ??= new Set<string>(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: 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<ScopeId>();
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<User>` `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<A & B>` 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,
@ -485,6 +665,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;
@ -531,6 +721,423 @@ 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 extends Repo> { 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<T>` 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<T extends U, U extends Repo>`), 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>` (`['bool']`) or `template <class T> struct
* Vec<T*>` (`['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 <class T> struct Vec`, `class Repo<T>` 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<ScopeResolutionIndexes, Map<ScopeId, boolean>>();
/**
* 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<ScopeId, boolean>();
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<User>`, `Vec<int>`) 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<User>` `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;
// NO object-type-ALIAS fallback here, and that is a decision rather than an
// omission. This function carried one before #2833 moved it out of
// `passes/receiver-bound-calls.ts`; the move dropped it, and re-applying it at
// merge time turned out to be wrong twice over. It is unexercised — deleting
// it fails no test, because alias MEMBERS resolve through the precise path
// instead (`type_alias_declaration value: (object_type)` emits `@scope.class`,
// so a typed receiver reaches the shape's own scope). And re-adding it
// unconditionally walked straight past the type-parameter refusal #2833 had
// just introduced, re-opening through an alias the exact false edge that
// change closed — their `neg-type-parameter` fixture caught it.
//
// If a future case genuinely needs it, it must be gated on
// `bindsTypeParameter` and land with a test that fails without it.
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<bool>` — 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<string, User>`
* 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<int> vi` bound the `Vec<bool>`
// 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<User>`
// 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<bool>` bound the cross-file
// SPECIALIZATION through the index while `Vec<int>` 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<User>` 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

View file

@ -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<User>` restored to the `Base<Args>` 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<User>` `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<Item>` reduced to `vector<Item>` the spelling does not
* start with the reduced name, so nothing was erased that this can restore.
* - `Repo<User>?`, `Map<String, (Int) -> 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;
}

View file

@ -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>` `['bool']`), this
* one reads the parameters it was written IN TERMS OF (`template <class T>`,
* `class Box<T extends Repo>`). 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<K, V>`) 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 extends Repo, U>`,
* `<class T, typename U = int>`, `[T any]` is NOT accepted (see the bracket note
* below). Leading content before the first `<` is skipped, so a capture that
* spans `template <class T>` parses identically to one spanning `<class T>`.
*
* 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<K, V>` (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<User>`) 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 <int N>` 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);
}

View file

@ -283,7 +283,12 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// numbers did, which is the whole point of re-checking at merge rather than at
// review. Ledger entries 11 through 15.
//
// 47 -> 48 for the round-2 capture work: object literals behind an
// SIXTH clash, same shape, one merge later: #2833 then took 48 for a generic-
// receiver fix, so this branch's chain shifted +1 AGAIN and now runs 49-53.
// The capture sets have never moved; only the numbers have. This is the cost of
// a single global counter with concurrent PRs, and #2860 is the mechanical fix.
//
// 48 -> 49 for the round-2 capture work: object literals behind an
// identity-preserving wrapper (`const X = Object.freeze({ ... })`) now mint
// `@definition.property` for their keys. Parse-time like every entry above, and
// this one was ALSO observed as a false negative first: `analyze --force`
@ -294,7 +299,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// for this bump.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
//
// 48 -> 49 for the TypeScript object-literal captures (R3-3): named
// 49 -> 50 for the TypeScript object-literal captures (R3-3): named
// object-literal keys and the identity-wrapper form now mint `@definition.property`
// in TYPESCRIPT_QUERIES, as they already did for JavaScript. Parse-time, so a
// warm cache would replay ParsedFiles carrying none of those matches and the
@ -305,31 +310,31 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// clashes. It is NOT on this branch, so until that one merges the re-check
// below is still manual.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// 49 -> 50 for the return-shape and shorthand captures (R3-4): keys of an
// 50 -> 51 for the return-shape and shorthand captures (R3-4): keys of an
// anonymous literal in return position, and shorthand keys in both that and the
// variable-bound form. Parse-time again.
//
// The v34 hazard, and this branch has already tripped it: a build stamped 48
// (now 49) was installed and used to analyze two repos BEFORE these captures
// (now 50) was installed and used to analyze two repos BEFORE these captures
// existed, so caches stamped 48 exist that carry none of them. Within one PR the version
// only has to differ from main's, but an INTERMEDIATE build of the same series
// is a different capture set wearing the same number — which is exactly what
// the note above records for 33/34.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
// 50 -> 51 is NOT needed for R3-5: that pass is scope-resolution, not
// 51 -> 52 is NOT needed for R3-5: that pass is scope-resolution, not
// parse-time capture, so a warm cache replays ParsedFiles that already carry
// everything it reads. Recorded because the reflex on this branch has been to
// bump, and a bump nobody needs still forces every user a full re-parse.
//
// 50 -> 51 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now
// 51 -> 52 IS needed for dispatch-guard routes (R3-7): the JS/TS providers now
// implement `extractDecoratorRoutes`, and decorator routes are worker output
// carried in the parse cache. A warm cache replays a worker result whose
// `decoratorRoutes` predates the extractor entirely, so every hand-rolled route
// stays invisible and `route_map` keeps answering empty — the exact symptom the
// change exists to fix, wearing the mask of "the extractor does not work".
//
// 51 -> 52 for the same-file constant folding that followed it. The v34 hazard
// again, and this branch has now tripped it TWICE: a build stamped 50 (now 51)
// 52 -> 53 for the same-file constant folding that followed it. The v34 hazard
// again, and this branch has now tripped it TWICE: a build stamped 50 (now 52)
// was used to analyze before folding existed, so those caches carry the unfolded
// route set. Caught by measuring — the post-folding run came back suspiciously
// fast and would have reported the pre-folding number, which is precisely how
@ -337,7 +342,53 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the same number" shows up in practice. Within one PR the version only has to
// differ from main's; against a cache YOU wrote, it has to differ from itself.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 52;
//
// 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<User> repo;` and `std::vector<Item> 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 T>`, `class Box<T extends Repo>`), 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 = 53;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -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,

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -150,15 +150,34 @@ describe('PARSE_CACHE_VERSION', () => {
// everything above it had to be renumbered +1 at merge time. Capture sets
// unchanged; only the numbers moved.
//
// Moved 50 -> 51 for dispatch-guard routes (R3-7): the JS/TS providers now
// Moved 51 -> 52 for dispatch-guard routes (R3-7): the JS/TS providers now
// implement `extractDecoratorRoutes`, and decorator routes are worker output
// carried in the cache. A v50 warm cache replays a worker result whose
// `decoratorRoutes` predates the extractor, so `route_map` keeps answering
// empty — the exact symptom the change fixes, disguised as "it does not work".
// Moved 51 -> 52 for the same-file constant folding that followed, because a
// build stamped 50 (now 51) had already been used to analyze without it.
it('pins SCHEMA_BUMP to 52 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(52);
// Moved 52 -> 53 for the same-file constant folding that followed, because a
// build stamped 50 (now 52) had already been used to analyze without it.
//
//
// 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 53 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(53);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {

View file

@ -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
* <https://docs.python.org/3/library/typing.html#deprecated-aliases> — 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' });
});
});

View file

@ -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<T*>` and a partial
* `template <class T> struct Vec<T*>` 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
['<T>', [{ name: 'T' }]],
['<T, U>', [{ name: 'T' }, { name: 'U' }]],
// `extends` (TypeScript, Java) and `:` (Kotlin, Rust) both introduce a bound.
['<T extends Repo>', [{ name: 'T', bound: 'Repo' }]],
['<T : 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.
['<class T>', [{ name: 'T' }]],
['<typename T>', [{ name: 'T' }]],
['<out T>', [{ name: 'T' }]],
['<in T>', [{ name: 'T' }]],
['<reified T : Repo>', [{ name: 'T', bound: 'Repo' }]],
['<class... Ts>', [{ name: 'Ts' }]],
// A default is neither name nor bound.
['<class T = int>', [{ name: 'T' }]],
['<T extends Repo = DefaultRepo>', [{ name: 'T', bound: 'Repo' }]],
// Commas inside a bound are not entry separators.
['<T extends Map<K, V>, K>', [{ name: 'T', bound: 'Map<K, V>' }, { name: 'K' }]],
// An intersection bound is kept VERBATIM — splitting it is the consumer's
// decision, and `soleBoundBaseName` declines on it rather than guessing.
['<T extends Repo & Closeable>', [{ name: 'T', bound: 'Repo & Closeable' }]],
// A capture that spans the `template` keyword parses like a bare list.
['template <class T>', [{ 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', '<T'],
// Go declares parameters in SQUARE brackets and is served by its own
// main-thread reader; accepting `[…]` here would read `int[]` as a list.
['square brackets are deliberately not a parameter list', '[T any]'],
])('returns undefined when %s', (_why, text) => {
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 <class T> struct Vec { T* data; };
template <> struct Vec<bool> { int bits; };
template <class T> struct Vec<T*> { 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<string, SymbolDefinition>();
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' }]);
});
});