mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
commit
7da1530950
33 changed files with 5879 additions and 291 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,22 +6,22 @@
|
|||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
"_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
|
||||
"_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
|
||||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
|
||||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
|
||||
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5."
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
|
||||
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
|
||||
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.",
|
||||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.",
|
||||
"_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import type { ParsedFile, Range, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* A generic Go interface's type-parameter names, in DECLARATION ORDER, stamped
|
||||
* onto its `Interface` def as a Go-private sidecar.
|
||||
*
|
||||
* Same mechanism and lifecycle as `goReceiverKind` (method-owners.ts): an extra
|
||||
* property on a def the Go resolver owns, written on the main thread and read by
|
||||
* `interface-impls.ts`. It is deliberately NOT a shared `SymbolDefinition` field
|
||||
* and deliberately NOT a capture — see {@link stampGoInterfaceTypeParameters}.
|
||||
*
|
||||
* ORDER IS THE POINT. Substitution is positional (`Repo[User]` binds the FIRST
|
||||
* type parameter), so a set or a name→constraint map would lose exactly the
|
||||
* information this exists to carry.
|
||||
*/
|
||||
type GoGenericInterfaceDefinition = SymbolDefinition & {
|
||||
readonly goTypeParameters?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Stamp every generic interface in `parsedFiles` with its type-parameter names,
|
||||
* read out of the declaration's own source text.
|
||||
*
|
||||
* WHY SOURCE TEXT AND NOT A CAPTURE. The tree has the list right there
|
||||
* (`type_spec` carries a `type_parameters` field), and capturing it would be two
|
||||
* lines. But captures run inside the PARSE WORKER, whose script is resolved from
|
||||
* the compiled `dist/` build, and their output is additionally memoized by the
|
||||
* parse cache and the durable ParsedFile store — so a capture-side change is
|
||||
* invisible until a rebuild AND a cache-version bump, and silently wrong in
|
||||
* between. Everything here runs on the main thread from data the pipeline
|
||||
* already materialized, so it is correct on the first run and needs neither.
|
||||
*
|
||||
* The scan is exact rather than a grep over the file: an interface declaration
|
||||
* owns a `Class` scope whose range spans exactly its `type_spec`
|
||||
* (`Repo[T any] interface{ … }`), so the text is sliced by that range and the
|
||||
* type parameters are, by grammar, whatever sits between the brackets that
|
||||
* IMMEDIATELY follow the name. Comments and strings elsewhere in the file cannot
|
||||
* reach it.
|
||||
*/
|
||||
export function stampGoInterfaceTypeParameters(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
fileContents: ReadonlyMap<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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
210
gitnexus/src/core/ingestion/utils/type-parameters.ts
Normal file
210
gitnexus/src/core/ingestion/utils/type-parameters.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
131
gitnexus/test/unit/scope-resolution/type-parameters.test.ts
Normal file
131
gitnexus/test/unit/scope-resolution/type-parameters.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue