From 27ab37c4329a8321cdefb3b3a0afdda26f72c0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Fri, 31 Jul 2026 07:12:57 +0100 Subject: [PATCH] feat(resolution): type receiver chains from AST structure across all 14 languages (#2708) + epistemic lower-bound (#2744) (#2747) --- .github/workflows/ci-tests.yml | 15 + ARCHITECTURE.md | 2 + gitnexus-shared/src/index.ts | 7 +- .../src/scope-resolution/reference-site.ts | 30 ++ .../python-scope/baseline-fingerprint.txt | 2 +- .../bench/receiver-resolution/BASELINE.md | 244 +++++++++ .../bench/receiver-resolution/baseline.json | 44 ++ .../bench/receiver-resolution/measure.mjs | 492 ++++++++++++++++++ gitnexus/bench/scope-capture/baselines.json | 62 ++- .../core/ingestion/languages/c/captures.ts | 7 + .../core/ingestion/languages/cpp/captures.ts | 7 + .../ingestion/languages/csharp/captures.ts | 19 + .../core/ingestion/languages/dart/captures.ts | 19 + .../core/ingestion/languages/go/captures.ts | 13 + .../core/ingestion/languages/java/captures.ts | 19 + .../languages/javascript/captures.ts | 7 + .../ingestion/languages/kotlin/captures.ts | 13 + .../core/ingestion/languages/php/captures.ts | 19 + .../ingestion/languages/python/captures.ts | 25 + .../core/ingestion/languages/ruby/captures.ts | 25 + .../core/ingestion/languages/rust/captures.ts | 7 + .../ingestion/languages/swift/captures.ts | 25 + .../languages/typescript/captures.ts | 7 + .../src/core/ingestion/scope-extractor.ts | 22 + .../passes/compound-receiver.ts | 160 ++++++ .../passes/receiver-bound-calls.ts | 35 +- .../scope-resolution/resolution-outcome.ts | 29 +- .../scope-resolution/unresolved-receivers.ts | 104 ++++ .../src/core/ingestion/utils/call-analysis.ts | 56 +- .../utils/receiver-chain-captures.ts | 68 +++ .../ingestion/utils/receiver-chain-codec.ts | 154 ++++++ gitnexus/src/core/run-analyze.ts | 7 + gitnexus/src/mcp/local/local-backend.ts | 59 ++- gitnexus/src/storage/parse-cache.ts | 14 +- gitnexus/src/storage/parsedfile-store.ts | 95 +++- gitnexus/src/storage/repo-manager.ts | 53 +- .../expected-captures.json | 16 +- .../go-captures-golden/expected-captures.json | 8 +- .../cpp-receiver-chain-arrow/main.cpp | 14 + .../cpp-receiver-chain-arrow/models.h | 11 + .../app.js | 7 + .../svc.js | 7 + .../legacy/app.js | 7 - .../legacy/svc.js | 7 - .../expected-captures.json | 8 +- .../expected-captures.json | 18 +- .../expected-captures.json | 12 +- .../expected-captures.json | 10 +- .../expected-captures.json | 2 +- .../test/integration/resolvers/cpp.test.ts | 30 ++ .../integration/resolvers/javascript.test.ts | 28 + .../integration/resolvers/typescript.test.ts | 248 ++++++++- .../unit/call-summary-schema-version.test.ts | 34 +- .../test/unit/incremental-parse-cache.test.ts | 13 +- gitnexus/test/unit/parsedfile-store.test.ts | 108 ++++ .../test/unit/receiver-chain-codec.test.ts | 160 ++++++ .../receiver-chain-fold.test.ts | 159 ++++++ .../receiver-chain-wiring.test.ts | 83 +++ .../unresolved-receivers.test.ts | 129 +++++ .../unit/ts-receiver-chain-capture.test.ts | 88 ++++ 60 files changed, 3052 insertions(+), 121 deletions(-) create mode 100644 gitnexus/bench/receiver-resolution/BASELINE.md create mode 100644 gitnexus/bench/receiver-resolution/baseline.json create mode 100644 gitnexus/bench/receiver-resolution/measure.mjs create mode 100644 gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts create mode 100644 gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts create mode 100644 gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js delete mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/app.js delete mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/svc.js create mode 100644 gitnexus/test/unit/receiver-chain-codec.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts create mode 100644 gitnexus/test/unit/ts-receiver-chain-capture.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index bf564bb236..c9448184fc 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -500,6 +500,21 @@ jobs: run: node --import tsx bench/callable-value-flow/measure.mjs --check working-directory: gitnexus + - name: Receiver-resolution drop guards + # NOT build-free: this one runs the real pipeline, so it needs dist/ + # (the setup action above builds). ~2m15s. + # + # Two arms, because neither gates alone. The count arm asserts the + # call-only drop count per language — call-only because Case 0's + # recorder gates on the receiver's punctuation, not on what the + # reference is, so property reads would inflate it by ~20%. The shape + # arm asserts the state of each receiver spelling by EDGE PRESENCE, + # which is the only arm that can see the shapes the recorder is blind + # to (`?.`, explicit type args, `repos[0]`): those emit no edge AND no + # drop, so fixing them moves the count by zero. + run: node --import tsx bench/receiver-resolution/measure.mjs --check + working-directory: gitnexus + - name: Scope-emission guards (#2699) # Build-free: asserts the JS/TS scope set is unchanged. Block scopes are # what make `let`/`const` in sibling blocks distinct bindings, but a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fae270bf3a..20d8b719d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -257,6 +257,8 @@ Single interface a language implements to plug into the pipeline. Contract fully | `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | | `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | | `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions | +| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in | +| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) | ### Per-language registration diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 860558016a..0ac74b1ed3 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -83,7 +83,12 @@ export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref. // ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) export type { ParsedFile } from './scope-resolution/parsed-file.js'; -export type { ReferenceSite, ReferenceKind, CallForm } from './scope-resolution/reference-site.js'; +export type { + ReferenceSite, + ReferenceKind, + CallForm, + MixedChainStep, +} from './scope-resolution/reference-site.js'; export type { CallableFlowOperand, CallableFlowExpectedSignature, diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 5ced6e1762..e40e0a5113 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -123,4 +123,34 @@ export interface ReferenceSite { * for existing overload narrowing and conversion-rank logic. */ readonly argumentTypeClasses?: readonly ParameterTypeClass[]; + /** + * Compact encoding of a receiver that is itself an expression, so resolution + * can type it by folding over structure instead of re-parsing the receiver's + * source text. + * + * Format and the reason it is a string rather than `MixedChainStep[]` live in + * `receiver-chain-codec.ts` — briefly, the store's interning reviver re-shares + * objects only when they carry `nodeId` + `filePath`, which a chain step does + * not, so an object encoding would survive every warm load as fresh + * allocations. + * + * Absent whenever the receiver is a bare name, which is the overwhelming + * majority of sites — the field costs nothing where it is not needed. + */ + readonly receiverChain?: string; } + +/** + * One step in a mixed receiver chain — the decoded form of a receiver that is + * itself an expression rather than a bare name. + * + * For `svc.getUser().address.save()`, the receiver of `save` decodes to + * `[{ kind: 'call', name: 'getUser' }, { kind: 'field', name: 'address' }]` + * over a base receiver of `svc`. + * + * Lives here rather than beside its producer because it is part of the + * ScopeExtractor output contract that this package owns: the producer + * (`extractMixedChain`) walks a tree-sitter AST and so must stay in the + * analyzer, but the shape it yields crosses into resolution. + */ +export type MixedChainStep = { kind: 'field' | 'call'; name: string }; diff --git a/gitnexus/bench/python-scope/baseline-fingerprint.txt b/gitnexus/bench/python-scope/baseline-fingerprint.txt index ef5c14ba27..2c682ce694 100644 --- a/gitnexus/bench/python-scope/baseline-fingerprint.txt +++ b/gitnexus/bench/python-scope/baseline-fingerprint.txt @@ -1 +1 @@ -f120df925e3b92e78212003e10ae3025dfa3130955fa38cf801b91077dca0783 +ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483 diff --git a/gitnexus/bench/receiver-resolution/BASELINE.md b/gitnexus/bench/receiver-resolution/BASELINE.md new file mode 100644 index 0000000000..49ac4979b5 --- /dev/null +++ b/gitnexus/bench/receiver-resolution/BASELINE.md @@ -0,0 +1,244 @@ +# Receiver-resolution baseline + +> **Updated after U10** (structural receiver typing wired into Case 0). Three +> TypeScript shapes flipped to `RESOLVES` — `svc?.getUser().save()`, +> `svc!.getUser().save()`, `svc.getTyped().save()` — and the call-drop +> count did **not** move: 99 before, 99 after. +> +> That is the whole argument for the shape arm, now demonstrated rather than +> predicted. The committed fixture corpus contains none of those three +> spellings, so a gate reading only the drop count would have scored a working +> change as "no improvement" and stopped the series. Nothing regressed: no edge +> was lost and no new drop appeared. +> +> Still gaps after U10, both genuine: +> - `(await svc.getUserAsync()).save()` — `extractMixedChain` reaches +> `await …`, which is not a chain node, so no chain is minted. Remains a +> VISIBLE-GAP and is now the call-kind fixture in the drop-recorder test. +> - `repos[0].save()` — Case 0's punctuation gate never fires for a subscript +> receiver, so it stays INVISIBLE. +> +> The tables below are the pre-U10 measurement, kept as the reference point. + +## U7 — the go/no-go gate: PASS + +A/B produced by reverting ONLY the fold wiring (`compound-receiver.ts` + +`receiver-bound-calls.ts`) to the pre-U10 commit and rebuilding, so capture +emission — and therefore the persisted bytes — is identical in both arms and the +delta isolates the fold. Build + both caches wiped before every run (KTD4). + +| Metric | Control | Treatment | Δ | Threshold | Verdict | +|---|---|---|---|---|---| +| scope-resolution wall-clock, median of 3 | 25470.0 ms | 25687.9 ms | +0.86% | ≤ +3% | **PASS** | +| wall-clock, slowest of 3 | 25520.0 ms | 25832.6 ms | +1.22% | ≤ +5% p95 | **PASS** | +| serialized bytes per emitting site | — | **35.2 B** | — | ≤ 48 B | **PASS** | +| persisted store growth | 1 234 600 B | 1 235 340 B | **+0.0599%** | ≤ 3% | **PASS** | +| retained chain payload | — | 740 B | — | ≤ 6 MB | **PASS** | +| call drops (no regression) | 99 | 99 | 0 | no new drops | **PASS** | +| peak RSS | — | — | — | ≤ +2% | **NOT RESOLVABLE** | + +**The 35.2 B result confirms KTD7 by measurement rather than by assertion.** The +48-byte threshold was set deliberately so the object encoding (~71 B predicted) +fails and the compact string (~35 B predicted) passes. Measured: 35.2 B, +including the JSON key and quotes. The encoding decision is now evidence-backed. + +**Peak RSS: the threshold is below this instrument's resolution, so it is +reported as unresolvable rather than as a pass or a fail.** Three *independent* +treatment runs with the code held constant gave 414.9 / 436.6 / 436.9 MB — a +5.3% spread, wider than the ±2% being tested. (An earlier pair of 3-reps-in-one- +process runs read 536 vs 551 MB and looked like a +2.77% regression; that was +heap accumulating across reps, not growth.) Corroborating argument that no growth +exists to find: the change persists 740 bytes across the entire corpus and the +fold allocates nothing retained — it returns `SymbolDefinition`s the indexes +already hold. + +**Fold hit-rate.** Chains are minted for 21 of 529 TypeScript reference sites +(4.0%) — the field costs nothing on the 96% of sites with a bare-name receiver. +On the shape corpus, all 5 chain-carrying shapes resolve, so the fold is not pure +added cost on this population. + +**Not measured: a dedicated synthetic miss-dominant scaling corpus.** The plan +asks for `scaling_ratio < 1.5` on one, on the grounds that a same-name corpus +hits at `ownerChain[0]` and never exercises the MRO tail. Stated plainly so it is +not mistaken for a silent pass. What bounds the cost instead: the fold runs with +`fieldFallback: false`, so the O(fields × depth × names) path the threshold exists +to police cannot execute at all, and the remaining work is at most +`MAX_CHAIN_DEPTH` (3) map lookups per MRO ancestor per chained site, over a +population of 21 sites. The wall-clock A/B above is the empirical check on that +reasoning. + +Measured with `bench/receiver-resolution/measure.mjs` on `f87b2cbe`. + +Hygiene (a run without both steps is void — `analyze --force` clears neither cache, +and the parse worker runs from `dist/`): + +``` +npm run build +rm -rf .gitnexus/parse-cache .gitnexus/parsedfile-cache +node --import tsx bench/receiver-resolution/measure.mjs --corpus test/fixtures/lang-resolution +``` + +Two consecutive runs were byte-identical, not merely within noise. + +## Count arm — `test/fixtures/lang-resolution` + +| Metric | Value | +|---|---| +| **Call drops (the gate number)** | **99** | +| Total drops, all site kinds | 124 | +| Split by site kind | `call: 99`, `read: 25` | + +Call drops by extension: + +| ext | n | ext | n | ext | n | +|---|---|---|---|---|---| +| `.java` | 49 | `.py` | 5 | `.rs` | 3 | +| `.cs` | 8 | `.go` | 5 | `.kt` | 3 | +| `.ts` | 7 | `.cpp` | 5 | `.rb` | 2 | +| `.tsx` | 6 | `.php` | 4 | `.js` | 1 | +| | | | | `.swift` | 1 | + +**Why the split matters (KTD6 defect 1, now measured).** 25 of the 124 drops — 20% — +are property *reads*, not lost calls. Case 0's recorder gates on the receiver's +punctuation, not on what the reference is, so `d.source.kind` lands in the same +bucket as a dropped method call. Gating on the unsplit 124 would have measured a +population one fifth of which this work does not target. + +## Shape arm + +`RESOLVES` means an edge exists — **not** that it points at the right target. A +name-keyed fallback onto a same-named member reads as `RESOLVES`, so a shape whose +receiver has no well-defined type is not a usable control. + +| Language | Shape | State | siteKind | +|---|---|---|---| +| TypeScript | `svc.getUser().save()` | RESOLVES | — | +| TypeScript | `svc.getUser().address.save()` | RESOLVES | — | +| TypeScript | `svc?.getUser().save()` | **INVISIBLE-GAP** | — | +| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | `call` | +| TypeScript | `(await svc.getUserAsync()).save()` | VISIBLE-GAP | `call` | +| TypeScript | `svc.getTyped().save()` | **INVISIBLE-GAP** | — | +| TypeScript | `repos[0].save()` | **INVISIBLE-GAP** | — | +| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | `call` | +| PHP | `$this->repo->save()` (typed property) | RESOLVES | — | +| C++ | `svc->getUser()->save()` | **INVISIBLE-GAP** | — | +| C++ | `svc2.getUser()->save()` | RESOLVES | — | + +## Corrections to the plan, forced by measurement + +1. **Three target shapes are invisible, not one.** The plan records only + `repos[0].save()` as unrecorded. Measured, `svc?.getUser().save()` and + `svc.getTyped().save()` are equally invisible: no edge and no drop. + + This is the load-bearing correction. A gate built on the call-drop count alone + would move by **zero** when those three shapes are fixed, reading a working + change as "no improvement" — the same false-negative hazard the plan flags for + stale shards, arriving by a different route. Hence the shape arm: it is blind + to nothing, because it asks about edge presence rather than about a recorder + that has to have fired. + +2. **Invisibility is NOT a capture-layer gap.** Measured directly against + `emitTsScopeCaptures`, all five TypeScript shapes emit a full call match — + `@reference.call.member`, `@reference.name`, and crucially + `@reference.receiver`: + + | Shape | `@reference.receiver` | + |---|---| + | `svc?.getUser().save()` | `svc?.getUser()` | + | `svc.getTyped().save()` | `svc.getTyped()` | + | `repos[0].save()` | `repos[0]` | + + So a `ReferenceSite` exists for every one of them, and hanging a + `receiverChain` field on `ReferenceSite` is a viable carrier for all of them. + That was worth establishing before building on it. + + The drop suppression is therefore downstream of capture. For `repos[0]` the + cause is known and matches the plan: the receiver has neither `.` nor `(`, so + Case 0's gate never fires. For `?.` and `` the receiver text satisfies the + gate, so Case 0 *does* run and one of two things happens — the site was marked + in `handledSites` by another case, or `resolveCompoundReceiverClass` returned a + class on which the member was then not found, leaving + `compoundReceiverUnresolved` false. Those are materially different defects and + which one applies is **not yet determined**; it is the first thing U10 has to + establish, since the second would mean the recorder under-reports by + mis-attribution rather than by a gate. + + *(An earlier revision of this file asserted that these shapes produce no + reference site at all. That was inferred from edge-and-drop absence and is + disproven by the capture dump above.)* + +3. **KTD6 defect 2 overstates the PHP blindness.** The claim is that Case 0's + C-family punctuation test means PHP `->` receivers "never record a drop at + all". Measured, `$svc->getUser()->save()` *is* recorded, because its receiver + text `$svc->getUser()` contains `(` and satisfies the gate. And the plan's own + example, `$this->repo->save()`, does not need recording — with a typed property + it resolves. The genuine PHP gap is the call chain, and it is already visible. + +4. **The C++ defect is the `->` base receiver specifically.** `svc->getUser()->save()` + is invisible while `svc2.getUser()->save()` resolves. Same chain, same `->save()` + tail — only the base differs. This is exactly why `cpp-chain-call/` has never + caught it: that fixture uses the value `.` form, which works. + +## Known blind spots + +Every count here is a lower bound on a known-biased population, and any later delta +must be read against the same bias. + +- Case 0's gate is a C-family punctuation test (`.` or `(`), so a receiver that is a + plain property path (`$this->repo`, `a::b`) never reaches the recorder. +- `repos[0].save()` has neither `.` nor `(` in its receiver — same result. +- `?.` and explicit type arguments produce no reference site at all. + +## U8 — per-language rollout + +Emission moved into one shared helper +(`utils/receiver-chain-captures.ts`) and is wired into all 14 language +emitters. The helper is language-free (R6): its call gate reads the +`@reference.call.*` tag prefix, a vocabulary every language's `.scm` query +shares, rather than a per-language tag list. It is self-gating — a non-call +match, an absent receiver, or a chain with no nameable base all leave the match +untouched — so inserting the call before every `out.push(grouped)` is safe even +in the emitters that have three or four such paths. + +| Language | Shape | Before | After | +|---|---|---|---| +| TypeScript | `svc?.getUser().save()` | INVISIBLE-GAP | **RESOLVES** | +| TypeScript | `svc!.getUser().save()` | VISIBLE-GAP | **RESOLVES** | +| TypeScript | `svc.getTyped().save()` | INVISIBLE-GAP | **RESOLVES** | +| C++ | `svc->getUser()->save()` | INVISIBLE-GAP | **RESOLVES** | +| C++ | `svc2.getUser()->save()` (control) | RESOLVES | RESOLVES | +| PHP | `$svc->getUser()->save()` | VISIBLE-GAP | INVISIBLE-GAP | +| PHP | `$this->repo->save()` (control) | RESOLVES | RESOLVES | + +The C++ row is the one the plan flagged as having **no fixture anywhere** — +`cpp-chain-call/` uses the value `.` form, which already worked. It now has one, +plus the value-dot control that proves the defect was the `->` base specifically. + +### PHP: a measured residual, with the trap checked + +PHP does **not** resolve yet, and the plan's named trap — a language whose node +type is missing from `extractMixedChain`'s tables reads as "didn't need it" when +it in fact cannot be measured — is **not** the cause. Checked directly against +the emitter: + +``` +name=save chain=1|$svc|cgetUser recv=$svc.getUser() +``` + +The chain is minted correctly. The residual is that the fold's base, `$svc`, +does not bind in the PHP resolver, so the fold returns `undefined` and the site +falls through to the text cascade. That is PHP binding-key work, not a +chain-layer defect, and it is left as a recorded residual rather than absorbed +into this series. + +Two incidental corrections from that check, both to KTD6: + +- PHP's receiver capture text is normalized to `$svc.getUser()` — DOTS, not + `->`. So Case 0's "C-family punctuation" gate fires for PHP after all, which + is why the call chain was recorded as a VISIBLE-GAP to begin with. +- Typing the fixture parameter (`function f(Service $svc)`) moved the row from + VISIBLE-GAP to INVISIBLE-GAP: with a type binding the cascade now types the + receiver but finds no member, so `compoundReceiverUnresolved` is false and no + drop is recorded. An untyped fixture parameter had been reporting a language + gap that was really a fixture defect — the same error class as the untyped + `$repo` control caught earlier. diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json new file mode 100644 index 0000000000..904bc658b5 --- /dev/null +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -0,0 +1,44 @@ +{ + "shapeArm": { + "typescript": { + "plainChain": "RESOLVES", + "plainDeepChain": "RESOLVES", + "optionalChain": "RESOLVES", + "nonNullAssert": "RESOLVES", + "awaitParen": "VISIBLE-GAP", + "explicitTypeArgs": "RESOLVES", + "indexElement": "INVISIBLE-GAP" + }, + "php": { + "arrowCallChain": "INVISIBLE-GAP", + "arrowPropertyPath": "RESOLVES" + }, + "cpp": { + "pointerArrowChain": "RESOLVES", + "valueDotChain": "RESOLVES" + } + }, + "countArm": { + "callDrops": 101, + "totalDropsAllKinds": 128, + "bySiteKind": { + "call": 101, + "read": 27 + }, + "callDropsByExtension": { + ".java": 49, + ".cs": 8, + ".ts": 7, + ".cpp": 7, + ".tsx": 6, + ".py": 5, + ".go": 5, + ".php": 4, + ".rs": 3, + ".kt": 3, + ".rb": 2, + ".js": 1, + ".swift": 1 + } + } +} diff --git a/gitnexus/bench/receiver-resolution/measure.mjs b/gitnexus/bench/receiver-resolution/measure.mjs new file mode 100644 index 0000000000..7a279ea872 --- /dev/null +++ b/gitnexus/bench/receiver-resolution/measure.mjs @@ -0,0 +1,492 @@ +/** + * Receiver-resolution measurement harness. + * + * Answers one question — "how many method calls does GitNexus lose because it + * could not establish the receiver's type, and which source shapes are they?" — + * and answers it in a way that can gate a decision. + * + * TWO ARMS, because neither one alone is trustworthy: + * + * 1. SHAPE ARM (`--shapes`, default). A fixed corpus of receiver spellings, + * each classified by what the graph actually contains: + * + * RESOLVES edge emitted. A test written against this shape starts + * green and proves nothing. Note this states only that an + * edge EXISTS — not that it points at the right target. A + * name-keyed fallback onto a same-named member reads as + * RESOLVES here, so a shape whose receiver has no + * well-defined type is not a usable control. + * VISIBLE-GAP no edge, and a `receiver-unresolved` drop was recorded. + * Measurable by the count arm below. + * INVISIBLE-GAP no edge, and NO drop was recorded. The call is lost and + * the instrument cannot see it. + * + * The third state is why this arm exists. Case 0's recorder is reached only + * when the receiver text contains `.` or `(` AND the capture layer produced + * a reference site at all. Measured on this corpus, `svc?.getUser().save()`, + * `svc.getTyped().save()` and `repos[0].save()` are all INVISIBLE — + * so fixing them moves the count arm by exactly zero. Gating solely on a + * drop count would read a working fix as "no improvement". + * + * 2. COUNT ARM (`--corpus `). Runs the real pipeline over a repo and + * reports drops SPLIT BY SITE KIND. The gate number is `call` only: the + * recorder's gate tests the receiver's punctuation, not the site's kind, so + * property reads (`d.source.kind`) and writes (`x.argtypes = [...]`) land in + * the same bucket as lost method calls and would inflate it. + * + * KNOWN BLIND SPOTS — reported in every run, deliberately, because the number + * is a lower bound on a KNOWN-BIASED population and any delta measured later + * must be read against the same bias: + * + * - Case 0's gate is a C-family punctuation test, so PHP `$this->repo->save()` + * and `::` receivers never record a drop. + * - `repos[0].save()` has neither `.` nor `(` in its receiver, same result. + * - `?.` and explicit type arguments produce no reference site to begin with. + * + * MEASUREMENT HYGIENE — a run that skips this is void: + * + * npm run build # the parse worker runs from dist/ + * rm -rf .gitnexus/parse-cache .gitnexus/parsedfile-cache + * node --import tsx bench/receiver-resolution/measure.mjs --corpus + * + * `analyze --force` clears NEITHER cache, so a stale shard will happily serve + * the previous capture set and produce a confident, wrong number. + * + * Usage: + * node --import tsx bench/receiver-resolution/measure.mjs + * node --import tsx bench/receiver-resolution/measure.mjs --corpus /path/to/repo + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.ts'; +import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baseline.json'); +/** The `--check` corpus. Committed and multi-language, so the gate is + * deterministic and does not depend on anything outside the repo. */ +const DEFAULT_CORPUS = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'lang-resolution'); + +// --------------------------------------------------------------------------- +// Shape corpus +// --------------------------------------------------------------------------- + +/** + * Each entry is one receiver spelling. `entry` is the function that contains + * it; `member` is the method it should reach. Classification asks only two + * questions of the result — is there a CALLS edge from `entry` to `member`, + * and was a drop recorded on that line — so it never guesses from an id shape. + */ +const CORPORA = [ + { + lang: 'typescript', + ext: '.ts', + support: { + 'models.ts': `export class Address { + save(): void {} +} + +export class User { + name: string = ''; + address: Address = new Address(); + save(): void {} +} + +export class Service { + getUser(): User { + return new User(); + } + async getUserAsync(): Promise { + return new User(); + } + getTyped(): User { + return new User(); + } +} +`, + }, + header: `import { Service, User } from './models';\n`, + wrap: (entry, body) => + `export async function ${entry}(svc: Service, repos: User[]): Promise {\n ${body}\n}\n`, + shapes: [ + { id: 'plainChain', member: 'save', body: 'svc.getUser().save();', note: 'control' }, + { + id: 'plainDeepChain', + member: 'save', + body: 'svc.getUser().address.save();', + note: 'control', + }, + { id: 'optionalChain', member: 'save', body: 'svc?.getUser().save();', note: 'PF1' }, + { id: 'nonNullAssert', member: 'save', body: 'svc!.getUser().save();', note: 'PF2' }, + { + id: 'awaitParen', + member: 'save', + body: '(await svc.getUserAsync()).save();', + note: 'PF3', + }, + { id: 'explicitTypeArgs', member: 'save', body: 'svc.getTyped().save();', note: 'PF4' }, + { id: 'indexElement', member: 'save', body: 'repos[0].save();', note: 'PF5' }, + ], + }, + { + lang: 'php', + ext: '.php', + support: { + 'models.php': ` `function ${entry}(Service $svc) {\n ${body}\n}\n`, + shapes: [ + { + id: 'arrowCallChain', + member: 'save', + body: '$svc->getUser()->save();', + note: 'PF6 — recorded, because the receiver text contains `(`', + }, + { + // The discriminating control for KTD6 defect 2. This receiver + // (`$this->repo`) contains neither `.` nor `(`, so Case 0's gate never + // fires and the drop is never recorded — while the call chain above IS + // recorded. "PHP records no drops" is too coarse: it is the + // property-path receiver that is invisible, not the language. + id: 'arrowPropertyPath', + member: 'save', + body: '$this->repo->save();', + raw: `class Holder { + public User $repo; + public function arrowPropertyPath() { + $this->repo->save(); + } +} +`, + note: 'PF6-control — property-path receiver, no `.` and no `(`', + }, + ], + }, + { + lang: 'cpp', + ext: '.cpp', + support: { + 'models.h': `#pragma once + +class User { +public: + void save(); +}; + +class Service { +public: + User* getUser(); +}; +`, + }, + header: `#include "models.h"\n`, + wrap: (entry, body) => `void ${entry}(Service* svc, Service svc2) {\n ${body}\n}\n`, + shapes: [ + { + id: 'pointerArrowChain', + member: 'save', + body: 'svc->getUser()->save();', + note: 'PF7 — no fixture exists today; cpp-chain-call/ uses value `.`', + }, + { + // The discriminating control for PF7. Same chain, same `->save()` tail, + // but a value `.` on the BASE receiver — and it resolves. So the defect + // is the `->` base specifically, not C++ chaining, and the existing + // `cpp-chain-call/` fixture cannot catch it because it uses this form. + id: 'valueDotChain', + member: 'save', + body: 'svc2.getUser()->save();', + note: 'PF7-control — value `.` base resolves', + }, + ], + }, +]; + +function classify(corpus, result) { + const calls = []; + for (const rel of result.graph.iterRelationships()) { + if (rel.type !== 'CALLS') continue; + calls.push({ + from: result.graph.getNode(rel.sourceId)?.properties.name ?? '', + to: result.graph.getNode(rel.targetId)?.properties.name ?? '', + }); + } + const drops = (result.resolutionOutcomes ?? []).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + + return corpus.shapes.map((shape) => { + const hasEdge = calls.some((call) => call.from === shape.id && call.to === shape.member); + // A drop belongs to this shape when it names the shape's member and sits on + // the shape's own line — matched on the generated source, not on an id. + const drop = drops.find( + (candidate) => candidate.name === shape.member && candidate.shapeId === shape.id, + ); + return { + shape: shape.body, + id: shape.id, + note: shape.note, + state: hasEdge ? 'RESOLVES' : drop ? 'VISIBLE-GAP' : 'INVISIBLE-GAP', + siteKind: drop?.siteKind ?? null, + }; + }); +} + +async function runShapeArm() { + const results = []; + for (const corpus of CORPORA) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `gn-recv-${corpus.lang}-`)); + try { + for (const [rel, content] of Object.entries(corpus.support)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content, 'utf8'); + } + // Each shape's statement gets its own line, so a recorded drop's line + // identifies which shape produced it without matching on an id. + const lineOfShape = new Map(); + let text = corpus.header; + for (const shape of corpus.shapes) { + // A shape whose receiver needs surrounding structure (a class with a + // property, say) supplies `raw`; everything else is wrapped in a plain + // function. Either way the statement itself is `body`, and its offset + // is found by locating it in the generated block. + const block = shape.raw ?? corpus.wrap(shape.id, shape.body); + const blockStartLine = text.split('\n').length; + const offset = block.split('\n').findIndex((line) => line.includes(shape.body)); + lineOfShape.set(shape.id, blockStartLine + offset); + text += block; + } + fs.writeFileSync(path.join(root, `main${corpus.ext}`), text, 'utf8'); + + const result = await runPipelineFromRepo(root, () => {}); + // Attach the owning shape to each drop by line before classifying. + for (const outcome of result.resolutionOutcomes ?? []) { + for (const [id, line] of lineOfShape) { + if (outcome.range?.startLine === line) outcome.shapeId = id; + } + } + results.push({ language: corpus.lang, shapes: classify(corpus, result) }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Count arm +// --------------------------------------------------------------------------- + +async function runCountArm(repoPath) { + const result = await runPipelineFromRepo(repoPath, () => {}); + const drops = (result.resolutionOutcomes ?? []).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + + const byKind = new Map(); + const byExtension = new Map(); + const callDropsByExtension = new Map(); + for (const drop of drops) { + const kind = drop.siteKind ?? '<>'; + byKind.set(kind, (byKind.get(kind) ?? 0) + 1); + const ext = path.extname(drop.filePath); + byExtension.set(ext, (byExtension.get(ext) ?? 0) + 1); + if (kind === 'call') callDropsByExtension.set(ext, (callDropsByExtension.get(ext) ?? 0) + 1); + } + + const sortDesc = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1])); + return { + repo: repoPath, + // THE gate number. Property reads and writes are excluded deliberately. + callDrops: byKind.get('call') ?? 0, + totalDropsAllKinds: drops.length, + bySiteKind: sortDesc(byKind), + callDropsByExtension: sortDesc(callDropsByExtension), + allDropsByExtension: sortDesc(byExtension), + }; +} + +// --------------------------------------------------------------------------- +// Perf arm — the U7 thresholds +// --------------------------------------------------------------------------- + +/** + * Wall-clock, peak RSS, persisted chain bytes and cache-dir growth for one + * pipeline run over a corpus. + * + * The A/B control is produced by reverting ONLY the fold wiring + * (`compound-receiver.ts` + `receiver-bound-calls.ts`) to the pre-U10 commit and + * rebuilding, so the capture emission — and therefore the persisted bytes — is + * identical in both arms and the delta isolates the fold itself. + */ +/** Every file under `root` with one of `exts`. */ +function walkFiles(root, exts) { + const out = []; + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (exts.some((e) => entry.name.endsWith(e))) out.push(full); + } + } + return out; +} + +async function runPerfArm(repoPath, reps) { + const timings = []; + let peakRss = 0; + let chainSites = 0; + let chainBytes = 0; + let referenceSites = 0; + + for (let i = 0; i < reps; i++) { + const started = process.hrtime.bigint(); + const result = await runPipelineFromRepo(repoPath, () => {}); + timings.push(Number(process.hrtime.bigint() - started) / 1e6); + peakRss = Math.max(peakRss, process.memoryUsage().rss); + + void result; + } + + // Persisted chain payload, counted from the emitter rather than from the + // pipeline result: `PipelineResult` exposes no ParsedFiles, and the emitter is + // the side that decides what gets written, so this is the authoritative count. + for (const file of walkFiles(repoPath, ['.ts', '.tsx'])) { + const src = fs.readFileSync(file, 'utf8'); + for (const match of emitTsScopeCaptures(src, path.relative(repoPath, file))) { + if (match['@reference.name'] === undefined) continue; + referenceSites++; + const chain = match['@reference.receiver-chain']; + if (chain === undefined) continue; + chainSites++; + chainBytes += Buffer.byteLength(chain.text, 'utf8'); + } + } + + timings.sort((a, b) => a - b); + const median = timings[Math.floor(timings.length / 2)]; + return { + reps, + wallClockMsMedian: +median.toFixed(1), + wallClockMsAll: timings.map((t) => +t.toFixed(1)), + peakRssBytes: peakRss, + referenceSites, + chainSites, + chainBytes, + bytesPerChainSite: chainSites === 0 ? 0 : +(chainBytes / chainSites).toFixed(1), + }; +} + +// --------------------------------------------------------------------------- + +const KNOWN_BLIND = [ + "Case 0's gate is a C-family punctuation test (`.` or `(`), so PHP `->` and `::` receivers record no drop.", + '`repos[0].save()` has neither `.` nor `(` in its receiver — same result.', + '`?.` and explicit type arguments produce no reference site at all, so no drop is recorded.', + 'Every count is therefore a LOWER BOUND on a known-biased population. A later delta must be read against the same bias.', +]; + +/** + * The gated projection: shape states per language, plus the call-drop counts. + * Deliberately EXACT rather than budgeted — two consecutive runs are + * byte-identical, so a range would only hide real movement. Adding fixtures + * moves these numbers and requires a rebaseline; that treadmill is the accepted + * cost of the guard, the same trade the scope-capture bench already makes. + */ +/** + * NOTE ON SCOPE: this is the GATED projection — shape states plus call-drop + * counts. The perf arm (`--perf N`: wall-clock, RSS, bytes/site) is deliberately + * NOT part of it: those measurements need an A/B against a control build, which + * `--check` has no way to construct, so asserting them here would compare against + * numbers from a different machine and fail on noise. The perf figures recorded in + * BASELINE.md are therefore a POINT-IN-TIME MEASUREMENT, not a CI guard — do not + * read a green `--check` as evidence that performance has not regressed. + */ +function projection(output) { + return { + shapeArm: Object.fromEntries( + output.shapeArm.map((corpus) => [ + corpus.language, + Object.fromEntries(corpus.shapes.map((shape) => [shape.id, shape.state])), + ]), + ), + countArm: { + callDrops: output.countArm.callDrops, + totalDropsAllKinds: output.countArm.totalDropsAllKinds, + bySiteKind: output.countArm.bySiteKind, + callDropsByExtension: output.countArm.callDropsByExtension, + }, + }; +} + +/** Every leaf whose value differs, as `dotted.path: expected -> actual`. */ +function drift(expected, actual, prefix = '') { + const out = []; + const keys = new Set([...Object.keys(expected ?? {}), ...Object.keys(actual ?? {})]); + for (const key of keys) { + const want = expected?.[key]; + const got = actual?.[key]; + const at = prefix === '' ? key : `${prefix}.${key}`; + if (want !== null && typeof want === 'object') out.push(...drift(want, got ?? {}, at)); + else if (want !== got) out.push(`${at}: ${JSON.stringify(want)} -> ${JSON.stringify(got)}`); + } + return out; +} + +const args = process.argv.slice(2); +const corpusIndex = args.indexOf('--corpus'); +const check = args.includes('--check'); +const corpusPath = + corpusIndex === -1 ? (check ? DEFAULT_CORPUS : undefined) : path.resolve(args[corpusIndex + 1]); + +const output = { knownBlind: KNOWN_BLIND }; +if (corpusPath === undefined || check || args.includes('--shapes')) { + output.shapeArm = await runShapeArm(); +} +if (corpusPath !== undefined) { + output.countArm = await runCountArm(corpusPath); +} +const perfIndex = args.indexOf('--perf'); +if (perfIndex !== -1) { + const reps = Number(args[perfIndex + 1] ?? '3'); + output.perfArm = await runPerfArm(corpusPath ?? DEFAULT_CORPUS, Number.isFinite(reps) ? reps : 3); +} + +if (args.includes('--update-baseline')) { + fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(projection(output), null, 2)}\n`, 'utf8'); + console.error(`[receiver-resolution] wrote ${BASELINE_PATH}`); +} else if (check) { + const expected = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const diffs = drift(expected, projection(output)); + if (diffs.length > 0) { + console.error('[receiver-resolution] FAIL — drift against the committed baseline:'); + for (const line of diffs) console.error(` ${line}`); + console.error( + '\nIf this is intended (a fixture was added, or a shape genuinely changed state),' + + '\nre-run with --update-baseline and explain the movement in the commit message.', + ); + process.exit(1); + } + console.error('[receiver-resolution] OK — shape states and call-drop counts match baseline.'); +} else { + console.log(JSON.stringify(output, null, 2)); +} diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 3a9862f241..d0ef387ca3 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,11 +1,12 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).", "go": { - "fingerprint": "57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a", + "fingerprint": "5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb", "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 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": "#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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb." }, "cobol": { "fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e", @@ -24,7 +25,7 @@ "_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": "a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1", + "fingerprint": "7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5", "scaling_budget": 1.5, "_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.", @@ -35,21 +36,23 @@ "_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 \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) \u2014 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." }, "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": "05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe", + "fingerprint": "8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855", "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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855." }, "rust": { - "fingerprint": "05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300", + "fingerprint": "83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c", "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.", @@ -57,32 +60,36 @@ "_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 \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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c." }, "php": { - "fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd", + "fingerprint": "3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28", "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 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 \u2014 fixture count 138\u2192140, fingerprint drift expected." + "_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." }, "ruby": { - "fingerprint": "fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558", + "fingerprint": "fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83", "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 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 \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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83." }, "swift": { - "fingerprint": "115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248", + "fingerprint": "a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b", "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 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b." }, "dart": { "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", @@ -95,7 +102,7 @@ "_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": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197", + "fingerprint": "310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee", "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.", @@ -106,15 +113,17 @@ "_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.", "_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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee." }, "java-local-types": { - "fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b", + "fingerprint": "3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236", "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." + "_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236." }, "typescript": { - "fingerprint": "cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000", + "fingerprint": "9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc", "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.", @@ -123,10 +132,11 @@ "_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) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699." + "_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." }, "javascript": { - "fingerprint": "90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354", + "fingerprint": "83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc", "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 b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.", @@ -135,10 +145,11 @@ "_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) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354." + "_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." }, "kotlin": { - "fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195", + "fingerprint": "d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1", "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.", @@ -147,6 +158,7 @@ "_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_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 \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1." } } diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts index 8253019357..14049375d2 100644 --- a/gitnexus/src/core/ingestion/languages/c/captures.ts +++ b/gitnexus/src/core/ingestion/languages/c/captures.ts @@ -11,6 +11,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitCInclude } from './import-decomposer.js'; import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js'; import { markStaticName } from './static-linkage.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { synthesizeCallableFlowCaptures, type CallableCaptureSignature, @@ -163,6 +164,12 @@ export function emitCScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 069459470b..a13cba09db 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -24,6 +24,7 @@ import { extractCppTemplateConstraints } from './constraint-extractor.js'; import { captureCppMemberLookupFacts } from './member-lookup.js'; import { CPP_BRACED_INIT_TYPE_PREFIX } from './conversion-rank.js'; import { logger } from '../../../logger.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { synthesizeCallableFlowCaptures, type CallableCaptureSignature, @@ -604,6 +605,12 @@ export function emitCppScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index fbe338adc0..cb1adb8de0 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -31,6 +31,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = [ @@ -159,6 +160,12 @@ export function emitCsharpScopeCaptures( } // Defensive fallback: emit the raw match so the extractor at // least sees an anchor, even without markers. + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -177,6 +184,12 @@ export function emitCsharpScopeCaptures( // the AST in code. Mirrors Python's `self`/`cls` synthesis on // `@scope.function` matches. if (grouped['@scope.function'] !== undefined) { + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { @@ -287,6 +300,12 @@ export function emitCsharpScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); // Synthesize primary-constructor declarations on class/record diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 6587a7302d..3157c08419 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -46,6 +46,7 @@ import { encodeMarker } from '../../utils/heritage-marker.js'; import { DART_BUILT_INS } from './built-ins.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { preprocessDartExtensionTypes } from './extension-type-preprocess.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const FUNCTION_DECL_TAGS = [ '@declaration.function', @@ -155,6 +156,12 @@ export function emitDartScopeCaptures( const bodyNode = findFunctionBody(declNode); attachArityMetadata(grouped, declNode); + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); if (bodyNode !== null) { @@ -181,6 +188,12 @@ export function emitDartScopeCaptures( fieldType, ); } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); if (fieldType !== null) { out.push({ @@ -192,6 +205,12 @@ export function emitDartScopeCaptures( continue; } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts index 0179c3d1e2..ffc624c877 100644 --- a/gitnexus/src/core/ingestion/languages/go/captures.ts +++ b/gitnexus/src/core/ingestion/languages/go/captures.ts @@ -14,6 +14,7 @@ import { synthesizeGoTypeBindings, extractSimpleTypeNameText } from './type-bind import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const GO_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']), @@ -155,6 +156,12 @@ export function emitGoScopeCaptures( ); } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -176,6 +183,12 @@ export function emitGoScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 1314dd5bc6..dd463c83e7 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -43,6 +43,7 @@ import { captureJavaPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureJavaSpringConfigConsumerFacts } from './spring-config-bindings.js'; import { captureJavaSpringDiClassFact, type JavaSpringDiClassFact } from './spring-di.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, @@ -204,6 +205,12 @@ export function emitJavaScopeCaptures( continue; } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -257,6 +264,12 @@ export function emitJavaScopeCaptures( // Synthesize `this` / `super` receiver type-bindings on every // instance method-like. if (grouped['@scope.function'] !== undefined) { + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); // `@scope.function` is captured directly on the method/constructor node. const fnNode = findFunctionNode(nodeMap['@scope.function']); @@ -348,6 +361,12 @@ export function emitJavaScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index 2ce0561dc5..b0eed8d687 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -48,6 +48,7 @@ import { import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, @@ -997,6 +998,12 @@ export function emitJsScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); // Synthesize `this` receiver type-bindings on class member functions. diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 657e6bcb66..00868c1743 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -26,6 +26,7 @@ import { import { captureKotlinPackageFact } from './package-facts.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { captureKotlinSpringDiClassFact, type KotlinSpringDiClassFact } from './spring-di.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { captureKotlinSpringConditionalFacts, type KotlinSpringConditionalFact, @@ -246,6 +247,12 @@ export function emitKotlinScopeCaptures( } if (grouped['@scope.function'] !== undefined) { + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); const fnNode = nodeIfType(groupedNodes['@scope.function'], 'function_declaration'); if (fnNode !== null) { @@ -303,6 +310,12 @@ export function emitKotlinScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); const extensionFallback = extensionFreeCallFallback(grouped, groupedNodes); diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts index 7e3ce5b150..de7e0a8c7d 100644 --- a/gitnexus/src/core/ingestion/languages/php/captures.ts +++ b/gitnexus/src/core/ingestion/languages/php/captures.ts @@ -46,6 +46,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; type SyntaxNode = ReturnType['parse']>['rootNode']; @@ -228,6 +229,12 @@ export function emitPhpScopeCaptures( } } // Defensive fallback: emit the raw match. + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -235,6 +242,12 @@ export function emitPhpScopeCaptures( // Synthesize `$this` / `parent` receiver type-bindings on every // non-static method-like. Mirrors C#'s `this` / `base` synthesis. if (grouped['@scope.function'] !== undefined) { + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); const fnNode = nodeIfType(nodeMap['@scope.function'], ...FUNCTION_NODE_TYPES); if (fnNode !== null) { @@ -338,6 +351,12 @@ export function emitPhpScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index 2a196bb329..0b0e981156 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -35,6 +35,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { pythonFunctionDefinitionLabel } from './simple-hooks.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const PYTHON_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_definition', 'lambda']), @@ -120,12 +121,24 @@ export function emitPythonScopeCaptures( for (const piece of splitImportStatement(stmtNode)) out.push(piece); } else { // Defensive fallback: emit the raw match. + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } continue; } if (grouped['@scope.function'] !== undefined) { + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); // `@scope.function` is captured directly on the `function_definition` // node (query: `(function_definition) @scope.function`), so it IS the @@ -183,10 +196,22 @@ export function emitPythonScopeCaptures( ); } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index ea4c5cf6ca..285e52afa2 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -15,6 +15,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitQualifiedName } from '../../utils/qualified-name.js'; import { encodeMarker } from '../../utils/heritage-marker.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); @@ -147,6 +148,12 @@ export function emitRubyScopeCaptures( continue; } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -159,6 +166,12 @@ export function emitRubyScopeCaptures( const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode); if (receiver !== null) out.push(receiver); } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -204,6 +217,12 @@ export function emitRubyScopeCaptures( ); } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -304,6 +323,12 @@ export function emitRubyScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index cae5ee0034..ae15c99e26 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -13,6 +13,7 @@ import { synthesizeRustReceiverBinding } from './receiver-binding.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; const RUST_CALLABLE_CAPTURE_OPTIONS = { functionNodeTypes: new Set(['function_item', 'closure_expression']), @@ -197,6 +198,12 @@ export function emitRustScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index cff4e13664..b9b111454a 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -50,6 +50,7 @@ import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -129,6 +130,12 @@ export function emitSwiftScopeCaptures( continue; } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); // defensive fallback continue; } @@ -174,6 +181,12 @@ export function emitSwiftScopeCaptures( const span = `${navNode.startIndex}-${navNode.endIndex}`; if (seenReadSpans.has(span)) continue; seenReadSpans.add(span); + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); continue; } @@ -236,6 +249,12 @@ export function emitSwiftScopeCaptures( ...FUNCTION_NODE_TYPES, ); if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity); + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); const recvNode = nodeIfType(nodeMap['@scope.function'], ...RECEIVER_NODE_TYPES); @@ -296,6 +315,12 @@ export function emitSwiftScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, nodeMap['@reference.receiver']); out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 31e01e5391..34146686a8 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -47,6 +47,7 @@ import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js'; import { synthesizeCjsModuleExports } from './cjs-module-exports.js'; +import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js'; import { deriveDefaultExportHocName, isBlockedDefaultExportHoc, @@ -494,6 +495,12 @@ export function emitTsScopeCaptures( } } + // Structural receiver chain for a call whose receiver is itself an + // expression, so resolution can type it by folding over structure + // instead of re-parsing the receiver's source text. Self-gating: a + // non-call match, an absent receiver, or a chain with no nameable base + // all leave `grouped` untouched. + synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']); out.push(grouped); // Synthesize `this` receiver type-bindings on every function-like diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 16caf03f6b..449ac47af0 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -81,6 +81,7 @@ import type { } from 'gitnexus-shared'; import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; +import { isValidReceiverChain } from './utils/receiver-chain-codec.js'; import { extractTemplateArguments } from './utils/template-arguments.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -1076,6 +1077,13 @@ function pass5CollectReferences( // consumed by the property-dispatch pass (#2437). const propertyKeyCap = match['@reference.property-key']; + // Compact receiver chain, when the emitter produced one. Validated HERE as + // well as at the store boundary: bounds applied only on load are a + // recurring defect in this codebase — the writer keeps minting payloads the + // reader keeps rejecting, which is a permanent warm-cache-miss reparse loop + // that logs nothing. + const receiverChain = extractReceiverChain(match); + const site: ReferenceSite = { name: nameCap.text, atRange: anchor.range, @@ -1092,6 +1100,7 @@ function pass5CollectReferences( ...(arity !== undefined ? { arity } : {}), ...(argumentTypes !== undefined ? { argumentTypes } : {}), ...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}), + ...(receiverChain !== undefined ? { receiverChain } : {}), }; referenceSites.push(site); } @@ -1162,6 +1171,19 @@ function extractExplicitReceiver(match: CaptureMatch): { readonly name: string } return { name: cap.text }; } +/** + * The compact receiver chain, when the language emitter synthesized one. + * + * Returns `undefined` for anything that does not decode, so a malformed or + * over-bound payload degrades to the existing text cascade rather than + * poisoning the durable store. Never throws — this runs per reference site. + */ +function extractReceiverChain(match: CaptureMatch): string | undefined { + const cap = match['@reference.receiver-chain']; + if (cap === undefined) return undefined; + return isValidReceiverChain(cap.text) ? cap.text : undefined; +} + function extractArity(match: CaptureMatch): number | undefined { const cap = match['@reference.arity']; if (cap === undefined) return undefined; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 9518b8c994..39a040a7ed 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -25,6 +25,8 @@ import type { 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 type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; +import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import { findClassBindingInScope, findEnclosingClassDef, @@ -98,6 +100,24 @@ interface ResolveCompoundReceiverOptions { * rather than re-declared, so a future sub-field cannot be added there * and silently ignored here (#2708). */ readonly constructionSyntax?: ScopeResolver['constructionSyntax']; + /** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when + * the language's capture emitter produced one. Present ⇒ the structural fold + * is tried before the text cascade; absent ⇒ behaviour is exactly as before. + * Consumed only at `depth === 0`: it describes the site's own receiver, so + * carrying it into a recursive call would re-fold it against an inner + * expression it does not describe. */ + readonly receiverChain?: string; + /** Resolve a BARE identifier the way the dotted-chain head does: when a + * receiver typeBinding exists for the name, that binding decides the type and + * nothing else does. Off by default, so the text cascade keeps its existing + * (more permissive) behaviour; the structural fold turns it ON. + * + * Without it, the bare-identifier branch falls through to a plain class-name + * lookup EVEN WHEN a binding existed but named no class — which types a local + * that merely SHADOWS a class as that class. That fabricated a `CALLS` edge + * (`const Config = make(1); Config.db.query()` emitted `entry → Database.query`), + * the exact wrong-edge failure this work exists to avoid. */ + readonly strictBaseBinding?: boolean; } /** Is this hop the language's construction selector applied to the class @@ -199,6 +219,121 @@ function resolveConstructionExpressionClass( return viaSimpleName !== undefined && isClassLike(viaSimpleName.type) ? viaSimpleName : undefined; } +/** + * One step of the fold: the type of `memberName` on `owner`, or `undefined`. + * + * A method's return type and a field's declared type both live in the owning + * class scope's `typeBindings`, keyed by name, so a `call` step and a `field` + * step are the same lookup — the step's `kind` carries no resolution + * difference, only intent. Walks the MRO so an inherited member resolves. + * + * Deliberately does NOT consult the field fallback that + * `resolveCompoundReceiverClass` offers. That fallback iterates every field of + * the owner and re-resolves each one's class looking for a same-named member — + * O(fields x depth x names) per step, the shape behind the 128 GB blowup in + * #1871 — and it answers a DIFFERENT question ("does any field's type have a + * member of this name?"), which is a guess. Structure exists precisely so this + * does not have to guess. + */ +function typeOfMemberOnClass( + owner: SymbolDefinition, + memberName: string, + scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, + options: ResolveCompoundReceiverOptions, +): SymbolDefinition | undefined { + const classScopeByDefId = index.classScopeByDefId; + const ownerChain = [owner.nodeId, ...scopes.methodDispatch.mroFor(owner.nodeId)]; + for (const ownerId of ownerChain) { + const classScope = classScopeByDefId.get(ownerId); + const memberType = classScope?.typeBindings.get(memberName); + if (memberType !== undefined) { + return findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes); + } + // Languages whose binding-scope hook hoists a method's return-type binding + // out of the class body and onto an ancestor (Module) scope keep NOTHING in + // the class scope to find — TypeScript is one, via `tsBindingScopeFor`, so + // without this walk the fold cannot type a single step in the first + // language it ships for. Gated on the same contract flag + // `resolveCompoundReceiverClass` uses, so a language that does not hoist + // cannot pick up an unrelated module-level binding of the same name. + if (classScope !== undefined && options.hoistTypeBindingsToModule === true) { + let curId: ScopeId | null = classScope.parent; + while (curId !== null) { + const curScope = scopes.scopeTree.getScope(curId); + if (curScope === undefined) break; + const hoisted = curScope.typeBindings.get(memberName); + if (hoisted !== undefined) { + return findClassBindingInScope(hoisted.declaredAtScope, hoisted.rawName, scopes); + } + curId = curScope.parent; + } + } + } + return undefined; +} + +/** + * Type a receiver from its decoded structure instead of from its source text. + * + * Resolves the base, then folds the steps base-first, each step typed against + * the class the previous step produced. Returns `undefined` the moment any step + * fails to type, so the caller falls back to the existing text cascade rather + * than receiving a partially-folded guess — a missing edge is recoverable, a + * confidently wrong one is not. + * + * The base is resolved by handing it to `resolveCompoundReceiverClass`, which + * already owns the bare-identifier path in full: type binding, static + * class-name receivers, map-tuple sentinels, member aliases and call-result + * aliases. A second implementation of that would drift from it. + * + * Called from `resolveCompoundReceiverClass` ahead of the text cascade, and only + * when the site carries a `receiverChain` (see the `depth === 0` gate below). + */ +export function foldReceiverChain( + chain: DecodedReceiverChain, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, + options: ResolveCompoundReceiverOptions = {}, +): SymbolDefinition | undefined { + // A truncated chain is missing the head that decides the final type. The + // producer refuses to mint one, so this is defence in depth against a + // future producer that does. + if (chain.truncated) return undefined; + if (chain.steps.length === 0) return undefined; + + // `receiverChain` is dropped before resolving the base: it describes the + // whole receiver, and handing it back to the resolver would re-enter this + // fold on the base and never terminate. + let current = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, { + ...options, + fieldFallback: false, + receiverChain: undefined, + strictBaseBinding: true, + }); + if (current === undefined) return undefined; + + for (const step of chain.steps) { + // Construction is NOT an ordinary member lookup. `Factory.new` on a class + // constant denotes an instance of Factory, and the cascade already encodes + // that (`isConstructionSelectorHop`) along with the class-constant test that + // separates it from an instance method genuinely named `new`. The fold + // carries no such distinction — a chain step records a name, not whether its + // base was a class reference or a value — so folding one would resolve + // `Factory.new.run` against whatever member named `new` the lookup reaches + // first. That turned a correct edge into a WRONG one (Ruby + // `Factory.new.run` → `Product.run`), which is the failure mode this whole + // line of work exists to avoid. Decline and let the cascade answer. + if (options.constructionSyntax?.selector === step.name) return undefined; + + const next = typeOfMemberOnClass(current, step.name, scopes, index, options); + if (next === undefined) return undefined; + current = next; + } + return current; +} + export function resolveCompoundReceiverClass( receiverText: string, inScope: ScopeId, @@ -213,6 +348,26 @@ export function resolveCompoundReceiverClass( if (text.length === 0) return undefined; const fieldFallback = options.fieldFallback ?? true; + // ── Structural fold, ahead of the text cascade ─────────────────── + // When the capture layer produced a chain for this site, type the receiver + // from that structure. The cascade below dispatches on enumerated textual + // shapes, so every new spelling (`?.`, `!`, ``, `->`) needs another branch; + // the AST already knew the answer and the chain carries it. + // + // Only at depth 0 — the chain describes THIS site's receiver, not the inner + // expressions the cascade recurses into. + // + // A failed fold falls through rather than returning: structure is an + // additional route to an answer, never a veto on the existing one, so a site + // the cascade could already resolve keeps resolving. + if (depth === 0 && options.receiverChain !== undefined) { + const decoded = decodeReceiverChain(options.receiverChain); + if (decoded !== undefined) { + const folded = foldReceiverChain(decoded, inScope, scopes, index, options); + if (folded !== undefined) return folded; + } + } + // ── Pre-processing: strip C-style cast expressions (opt-in) ────── // Cast-wrapped receivers like ((Type)((Object)this.field)).method() // produce parenthesized-expression receiver text. For languages that @@ -325,6 +480,11 @@ export function resolveCompoundReceiverClass( if (compound !== undefined) return compound; } } + // Mirror the dotted-chain head rule below (`headType ? … : …`): a binding + // that EXISTS but resolves to no class means "not a typed receiver", not + // "try the class namespace instead". Only the structural fold opts in; the + // cascade keeps its historical fallthrough so no existing edge moves. + if (tb !== undefined && options.strictBaseBinding === true) return undefined; return findClassBindingInScope(inScope, workingText, scopes); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index a94148fb5e..ee2cd6a217 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -397,14 +397,21 @@ export function emitReceiverBoundCalls( } // ── Case 0: compound receiver ──────────────────────────────── + // #2744: remember a compound receiver we could not type. Reported at + // the end of the site loop, not here — a later case may still resolve + // the site, and only a site that survives every case is a real drop. + let compoundReceiverUnresolved = false; if (receiverName.includes('.') || receiverName.includes('(')) { const currentClass = resolveCompoundReceiverClass( receiverName, site.inScope, scopes, index, - compoundOpts, + // Group A: the receiver IS this site's expression, so the site's + // captured chain describes it and the structural fold applies. + { ...compoundOpts, receiverChain: site.receiverChain }, ); + compoundReceiverUnresolved = currentClass === undefined; if (currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; @@ -1041,7 +1048,8 @@ export function emitReceiverBoundCalls( site.inScope, scopes, index, - compoundOpts, + // Group A, same reasoning as Case 0 above. + { ...compoundOpts, receiverChain: site.receiverChain }, ); } if (ownerDef !== undefined) { @@ -1324,6 +1332,29 @@ export function emitReceiverBoundCalls( 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 — + // keyed by the MEMBER name, which is the only thing still known about a + // dropped site (its callee is unknown by definition, so the drop cannot + // be attributed to any target symbol). + if (compoundReceiverUnresolved && !handledSites.has(siteKey)) { + options.recordResolutionOutcome?.({ + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: parsed.filePath, + name: site.name, + range: site.atRange, + // The gate above tests the receiver's punctuation, not the site's + // kind, so property reads and writes with a compound receiver are + // recorded here too. Carry the kind so a consumer can separate a + // dropped CALL from a dropped property access. + siteKind: site.kind, + }); + } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts index 91ab557c02..07b9d30833 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/resolution-outcome.ts @@ -1,4 +1,4 @@ -import type { Range } from 'gitnexus-shared'; +import type { Range, ReferenceKind } from 'gitnexus-shared'; export type ResolutionSuppressionReason = | 'adl-ordinary-lookup-blocked' @@ -12,7 +12,15 @@ export type ResolutionSuppressionReason = /** #2701 — the receiver is rebound by its own scope and has no type * there (a JS/TS ordinary `function`'s `this`), so no enclosing type * can be its type. See `isReceiverOwnedButUnbound`. */ - | 'receiver-owned-but-unbound'; + | 'receiver-owned-but-unbound' + /** #2744 — the receiver is a compound expression (a call, a chain, a + * construction) whose TYPE could not be established, so the member call + * was dropped with no candidate at all. Distinct from the ambiguity + * reasons above: there is nothing to be ambiguous between. Consumers use + * this to report `impact()`/`context()` counts as a lower bound rather + * than as exact, since a dropped site's callee is by definition unknown + * and cannot be attributed to any target. */ + | 'receiver-unresolved'; export type ResolutionOutcome = | { @@ -36,6 +44,23 @@ export type ResolutionOutcome = readonly filePath: string; readonly name: string; readonly range: Range; + /** + * The reference kind of the site the suppression happened at, when the + * emitting case knows it. + * + * Set for `receiver-unresolved` because Case 0's gate fires on any + * compound receiver regardless of what the reference *is*, so a property + * write (`x.argtypes = [...]`) and a property read (`d.source.kind`) land + * in the same bucket as a genuinely dropped method call. Anything + * measuring resolver gaps has to separate them, and the site kind is the + * only authoritative signal — re-deriving it from the source line means + * regex-classifying the number that gates the work, which is the same + * textual-shape dispatch the structural-receiver work exists to remove. + * + * Diagnostic only. `summarizeUnresolvedReceivers` ignores it, so the + * persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged. + */ + readonly siteKind?: ReferenceKind; }; export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void; diff --git a/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts new file mode 100644 index 0000000000..db6cfb03b1 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/unresolved-receivers.ts @@ -0,0 +1,104 @@ +/** + * Aggregate `receiver-unresolved` resolution outcomes into the small, + * index-persisted summary that `impact()` / `context()` read to decide whether + * their result is exact or a lower bound (#2744, the second half of #2708). + * + * **Why keyed by member name.** A dropped site's callee is unknown — that is + * what "unresolved" means — so the drop cannot be attributed to any target + * symbol. The one thing still known is the member NAME being invoked: + * `Service(db).do_work()` tells us a call to something called `do_work` was + * lost even though the receiver's type was not established. That is exactly + * the granularity the epistemic signal needs: a query about `do_work` can + * report its caller count as a lower bound, while a query about an unrelated + * symbol stays exact. + */ + +import type { ResolutionOutcome } from './resolution-outcome.js'; + +/** Cap on distinct member names persisted. Well above what a real repo + * produces (the whole point of the signal is that drops are the exception), + * but bounded so a pathological repo cannot grow the metadata file without + * limit. Truncation is reported, never silent — see `truncated`. */ +export const MAX_UNRESOLVED_RECEIVER_MEMBERS = 500; + +export interface UnresolvedReceiverSummary { + /** Member name → number of call sites dropped with an untyped receiver. + * Capped at `MAX_UNRESOLVED_RECEIVER_MEMBERS` entries, highest count first. */ + readonly counts: Readonly>; + /** Total dropped sites, including any beyond the cap. Always the true total, + * so a consumer can tell that `counts` is a sample rather than the whole. */ + readonly totalSites: number; + /** Distinct member names beyond the cap, omitted from `counts`. Absent when + * nothing was dropped from the map. */ + readonly omittedNames?: number; +} + +/** + * Build the summary, or `undefined` when nothing was dropped — an index with + * no unresolved receivers stores no key at all, so `epistemic` keeps its + * existing "exact unless proven otherwise" behaviour for every repo that + * resolves cleanly. + */ +export function summarizeUnresolvedReceivers( + outcomes: readonly ResolutionOutcome[], +): UnresolvedReceiverSummary | undefined { + const counts = new Map(); + let totalSites = 0; + for (const outcome of outcomes) { + if (outcome.kind !== 'suppressed' || outcome.reason !== 'receiver-unresolved') continue; + if (outcome.name.length === 0) continue; + // CALL sites only. Case 0's recorder gates on the receiver's punctuation, not + // on what the reference IS, so property reads (`d.source.kind`) and writes + // (`x.argtypes = [...]`) are recorded alongside lost method calls — measured at + // 25 of 124 drops on the fixture corpus. Counting them made the consumer's + // "N call sites invoking X were dropped" literally false, and flagged symbols + // whose CALL count was never short. `siteKind` exists to make this separable. + // A missing `siteKind` counts as a call: the only emitter always sets it, and + // erring toward `lower-bound` is the safe direction for an epistemic signal. + if (outcome.siteKind !== undefined && outcome.siteKind !== 'call') continue; + totalSites++; + counts.set(outcome.name, (counts.get(outcome.name) ?? 0) + 1); + } + if (totalSites === 0) return undefined; + + // Highest count first, name as a tiebreak so the persisted map is stable + // across runs — an unstable ordering would churn the metadata file (and its + // diff) on every analyze for no behavioural reason. + const ranked = [...counts.entries()].sort( + ([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName), + ); + const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS); + const omittedNames = ranked.length - kept.length; + + return { + counts: Object.fromEntries(kept), + totalSites, + ...(omittedNames > 0 ? { omittedNames } : {}), + }; +} + +/** + * Look up the dropped-CALL count for a member name. + * + * THE one place that reads `UnresolvedReceiverSummary.counts`. The map is + * revived from JSON, so it carries `Object.prototype`: a bare + * `counts[symName]` returns a FUNCTION for `constructor`, `toString`, + * `valueOf`, `hasOwnProperty` and friends, and a `<= 0` guard does not catch it + * because `Number(fn)` is `NaN` and `NaN <= 0` is false. `constructor` is an + * ordinary member name in a code graph, so that was reachable in normal use and + * interpolated a function into user-facing text. + * + * Returns `undefined` when the name was never recorded, and only ever returns a + * finite positive number otherwise. + */ +export function lookupUnresolvedCallCount( + summary: UnresolvedReceiverSummary | undefined, + symName: string, +): number | undefined { + const counts = summary?.counts; + if (counts === undefined || symName.length === 0) return undefined; + if (!Object.hasOwn(counts, symName)) return undefined; + const sites = counts[symName]; + if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined; + return sites; +} diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index c7eb8e45fd..ea43911d91 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -1,3 +1,5 @@ +import type { MixedChainStep } from 'gitnexus-shared'; + import type { SyntaxNode } from './ast-helpers.js'; import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js'; @@ -362,8 +364,14 @@ const FIELD_ACCESS_NODE_TYPES = new Set([ 'member_binding_expression', // C# null-conditional (user?.Address) ]); -/** One step in a mixed receiver chain. */ -export type MixedChainStep = { kind: 'field' | 'call'; name: string }; +/** + * One step in a mixed receiver chain. + * + * Owned by `gitnexus-shared` — it is part of the ScopeExtractor output + * contract, and resolution consumes it. Re-exported here so the producer's + * existing importers keep a single import site. + */ +export type { MixedChainStep }; /** * Walk a receiver AST node that is itself a call expression, accumulating the @@ -446,7 +454,10 @@ export function extractCallChain( current = innerReceiver; // continue walking } else { // Reached a simple identifier — the base receiver - return { chain, baseReceiverName: innerReceiver.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined, + }; } } @@ -469,6 +480,35 @@ export function extractCallChain( * * Pure field chains and pure call chains are special cases (all steps same kind). */ +/** + * Node types that wrap an expression without changing what it denotes, so a + * receiver chain's BASE can be read through them. + * + * `svc!` (TS non-null assertion) and `(svc)` denote exactly `svc`; taking the + * wrapper's own text instead yields `svc!` / `(svc)`, which matches no binding + * and silently costs the chain its base. + * + * Deliberately EXCLUDES a cast (`x as T`, `(T)x`): a cast changes the type an + * expression denotes, so reading through one would type the receiver as the + * operand rather than as the cast target. + */ +const TRANSPARENT_RECEIVER_WRAPPERS = new Set([ + 'non_null_expression', // TypeScript `svc!` + 'parenthesized_expression', // `(svc)` +]); + +/** Peel transparent wrappers off a base receiver node. */ +function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode { + let current = node; + // Bounded: `((x))` nests twice; nothing real nests deeply. + for (let i = 0; i < MAX_CHAIN_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); i++) { + const inner = current.namedChildren?.find((c) => c !== null); + if (inner === undefined || inner === null) break; + current = inner; + } + return current; +} + export function extractMixedChain( receiverNode: SyntaxNode, ): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined { @@ -538,7 +578,10 @@ export function extractMixedChain( ) { current = innerReceiver; } else { - return { chain, baseReceiverName: innerReceiver.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined, + }; } } else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) { // ── Field/member access: extract property name + inner object ───────── @@ -587,7 +630,10 @@ export function extractMixedChain( ) { current = innerObject; } else { - return { chain, baseReceiverName: innerObject.text || undefined }; + return { + chain, + baseReceiverName: unwrapTransparentReceiver(innerObject).text || undefined, + }; } } else if (current.type === 'selector') { // ── Dart: flat selector siblings (user.address.save() uses selector nodes) ── diff --git a/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts new file mode 100644 index 0000000000..687e687b26 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts @@ -0,0 +1,68 @@ +/** + * Synthesize `@reference.receiver-chain` for a call match whose receiver is + * itself an expression. + * + * Shared across every language emitter, and deliberately language-free (AGENTS.md + * R6): the call gate reads the `@reference.call.*` tag vocabulary, which is + * shared by every language's `.scm` query, rather than a per-language tag list. + * + * Lives in the emitter layer rather than the query because a `.scm` pattern + * cannot call `extractMixedChain`, and the receiver capture originates in + * several patterns per language. + */ + +import type { Capture } from 'gitnexus-shared'; + +import type { SyntaxNode } from './ast-helpers.js'; +import { syntheticCapture } from './ast-helpers.js'; +import { extractMixedChain } from './call-analysis.js'; +import { encodeReceiverChain } from './receiver-chain-codec.js'; + +/** Tag prefix marking a match as a CALL. `@reference.callable` is deliberately + * excluded by the trailing dot — it marks a callable VALUE, not a call site. */ +const CALL_TAG_PREFIX = '@reference.call.'; + +/** Does this match represent a call? */ +function isCallMatch(grouped: Record): boolean { + for (const tag of Object.keys(grouped)) { + if (tag.startsWith(CALL_TAG_PREFIX)) return true; + } + return false; +} + +/** + * Add `@reference.receiver-chain` to `grouped` when the match is a call whose + * receiver decodes to a chain over a nameable base. Mutates `grouped` in place + * and returns nothing — mirroring how the other synthesized captures are added. + * + * Gated on the call tags: without that gate this fires on read, write and JSX + * receivers too, which pay the AST walk and the persisted bytes for a field no + * call-site resolver reads. + * + * A chain is minted ONLY when the walk reached a nameable base. + * `extractMixedChain` reports `baseReceiverName: undefined` whenever it stopped + * early — at `MAX_CHAIN_DEPTH`, or because a step could not be read — and what it + * collected then is a partial chain missing exactly the head that decides the + * final type. Encoding that would turn a receiver we cannot type into one we type + * WRONGLY, so those fall through to the resolver's existing text cascade. + */ +export function synthesizeReceiverChainCapture( + grouped: Record, + receiverNode: SyntaxNode | undefined | null, +): void { + if (receiverNode === undefined || receiverNode === null) return; + if (grouped['@reference.receiver-chain'] !== undefined) return; + if (!isCallMatch(grouped)) return; + + const extracted = extractMixedChain(receiverNode); + if (extracted === undefined || extracted.baseReceiverName === undefined) return; + + const encoded = encodeReceiverChain(extracted.baseReceiverName, extracted.chain); + if (encoded === undefined) return; + + grouped['@reference.receiver-chain'] = syntheticCapture( + '@reference.receiver-chain', + receiverNode, + encoded, + ); +} diff --git a/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts b/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts new file mode 100644 index 0000000000..4bb68eace7 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts @@ -0,0 +1,154 @@ +/** + * Receiver-chain codec. + * + * THE one encoder/decoder for the compact receiver chain carried on + * `ReferenceSite.receiverChain`: the capture emitters write it, the + * scope-resolution fold reads it, and the durable ParsedFile store validates + * it. Two hand-rolled copies of a wire format drift — every side MUST import + * from here. (`taint/path-codec.ts` is the in-repo precedent for both the + * discipline and the shape.) + * + * ## Why a string rather than `MixedChainStep[]` + * + * `makeInterningReviver` (parsedfile-store.ts) interns strings, but it + * re-shares OBJECTS only when they carry `nodeId` + `filePath`. A + * `MixedChainStep` has neither, so an object encoding leaves every step object + * and every chain array as a distinct allocation on every warm load, while a + * string collapses to one interned instance per distinct chain. + * + * ## Wire format (version `1`) + * + * ``` + * 1|||…[|~] + * ``` + * + * - One-character version prefix, then the BASE receiver name, then ordered + * base-first steps. + * - Each step is a one-character kind sigil followed by the member name: + * `c` = call (`getUser()`), `f` = field (`address`). The sigil is the first + * character of the segment and the name follows immediately, so a member + * whose name begins with `c` or `f` needs no escaping (`ccount` decodes as a + * call to `count`). + * - A trailing `|~` segment is the TRUNCATION MARKER. NOTE: no current producer + * mints one. `extractMixedChain` signals "stopped early" by returning + * `baseReceiverName: undefined`, and the encoder requires a base, so a + * truncated chain is unrepresentable rather than merely unused. The marker and + * the decoder/fold guards are kept as a forward-compatible contract: a future + * producer that CAN report a partial chain must set it, and the fold already + * refuses such chains. Read the `truncated` field as "reserved", not "live". the chain hit + * `MAX_CHAIN_DEPTH` and what is encoded is a base-side PREFIX of the real + * chain. A consumer MUST treat a truncated chain as unusable for typing — + * the missing tail is exactly what determines the final type — but never as + * an error. + * + * `|` and `~` cannot appear in an identifier in any supported language, so the + * format needs no escaping and a malformed payload cannot silently decode as a + * different valid chain. + * + * For `svc.getUser().address.save()`, the receiver of `save` encodes as + * `1|svc|cgetUser|faddress` — 23 bytes. + */ + +import type { MixedChainStep } from 'gitnexus-shared'; + +import { MAX_CHAIN_DEPTH } from './call-analysis.js'; + +const VERSION = '1'; +const SEPARATOR = '|'; +const TRUNCATED = '~'; + +/** Hard cap on the encoded payload. `MAX_CHAIN_DEPTH` already bounds the step + * COUNT; this bounds the total bytes so a pathological identifier cannot grow + * a shard without limit. Generous against real identifiers — the encoding for + * a full-depth chain of 30-character names is still under 130 bytes. */ +export const MAX_RECEIVER_CHAIN_BYTES = 512; + +export interface DecodedReceiverChain { + readonly baseReceiverName: string; + readonly steps: readonly MixedChainStep[]; + /** The encoded chain is a base-side prefix — the real chain was longer. + * Typing off a truncated chain would type off the wrong member. */ + readonly truncated: boolean; +} + +/** A segment is safe when it contains no structural character. Identifiers in + * every supported language satisfy this; anything that does not is a payload + * we refuse to mint rather than escape. */ +function isEncodableSegment(value: string): boolean { + // `\s` does NOT match zero-width characters (U+200B ZWS, U+200C/D ZWNJ/ZWJ, + // U+200E/F LRM/RLM, U+FEFF BOM). An identifier carrying one is a trojan-source + // vector: it would encode and persist cleanly, then match no binding at + // resolution time — a silent, unexplained miss. Refuse to mint instead. + return ( + value.length > 0 && + !value.includes(SEPARATOR) && + !value.includes(TRUNCATED) && + !/[\s\u200B-\u200F\u2028\u2029\uFEFF]/.test(value) + ); +} + +/** + * Encode a chain, or `undefined` when it cannot be represented — an empty + * chain (nothing to fold), an unencodable name, or a payload over the byte + * cap. Refusing to mint is deliberate: a half-encoded chain would be decoded + * as a complete-but-different one. + */ +export function encodeReceiverChain( + baseReceiverName: string, + steps: readonly MixedChainStep[], + options?: { readonly truncated?: boolean }, +): string | undefined { + if (steps.length === 0 || steps.length > MAX_CHAIN_DEPTH) return undefined; + if (!isEncodableSegment(baseReceiverName)) return undefined; + + const parts = [VERSION, baseReceiverName]; + for (const step of steps) { + if (!isEncodableSegment(step.name)) return undefined; + parts.push(`${step.kind === 'call' ? 'c' : 'f'}${step.name}`); + } + if (options?.truncated === true) parts.push(TRUNCATED); + + const encoded = parts.join(SEPARATOR); + return encoded.length > MAX_RECEIVER_CHAIN_BYTES ? undefined : encoded; +} + +/** + * Decode a payload, or `undefined` when it is not a well-formed chain. Total + * function: every malformed input returns `undefined` rather than throwing, + * because the two callers that matter — the extractor and the untrusted store + * boundary — are both on paths where a throw would cost the whole file. + */ +export function decodeReceiverChain(value: unknown): DecodedReceiverChain | undefined { + if (typeof value !== 'string') return undefined; + if (value.length === 0 || value.length > MAX_RECEIVER_CHAIN_BYTES) return undefined; + + const parts = value.split(SEPARATOR); + if (parts.length < 3) return undefined; // version + base + at least one step + if (parts[0] !== VERSION) return undefined; + + // `parts.length < 3` above already proves this element exists. + const baseReceiverName = parts[1] as string; + if (!isEncodableSegment(baseReceiverName)) return undefined; + + let stepParts = parts.slice(2); + const truncated = stepParts[stepParts.length - 1] === TRUNCATED; + if (truncated) stepParts = stepParts.slice(0, -1); + if (stepParts.length === 0 || stepParts.length > MAX_CHAIN_DEPTH) return undefined; + + const steps: MixedChainStep[] = []; + for (const part of stepParts) { + const sigil = part[0]; + const name = part.slice(1); + if (sigil !== 'c' && sigil !== 'f') return undefined; + if (!isEncodableSegment(name)) return undefined; + steps.push({ kind: sigil === 'c' ? 'call' : 'field', name }); + } + + return { baseReceiverName, steps, truncated }; +} + +/** Whether a persisted value is a chain this build can use. The store boundary + * wants the predicate, not the decoded value. */ +export function isValidReceiverChain(value: unknown): value is string { + return decodeReceiverChain(value) !== undefined; +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 027fb01bac..0fe83cc6b7 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; import { acquireIndexLock } from '../storage/index-lock.js'; import { runPipelineFromRepo } from './ingestion/pipeline.js'; +import { summarizeUnresolvedReceivers } from './ingestion/scope-resolution/unresolved-receivers.js'; import type { KnowledgeGraph } from './graph/types.js'; import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js'; import { @@ -2398,6 +2399,9 @@ async function runFullAnalysisInner( embeddings, }, schemaVersion: hasGitDir(repoPath) ? INCREMENTAL_SCHEMA_VERSION : undefined, + unresolvedReceiverMembers: summarizeUnresolvedReceivers( + pipelineResult.resolutionOutcomes ?? [], + ), analysisFeatures: currentAnalysisFeatures, cjkSegmentation: getSearchFTSCjkSegmentation(), fileHashes: hasGitDir(repoPath) ? fileHashes : undefined, @@ -2562,6 +2566,9 @@ async function runFullAnalysisInner( // incrementalInProgress to undefined explicitly clears any prior // dirty flag (full and incremental success paths converge here). schemaVersion: hasGitDir(repoPath) ? INCREMENTAL_SCHEMA_VERSION : undefined, + unresolvedReceiverMembers: summarizeUnresolvedReceivers( + pipelineResult.resolutionOutcomes ?? [], + ), analysisFeatures: currentAnalysisFeatures, // Always stamped with the live resolved mode (#2331/#2339) — unlike // `pdg` below, 'none' is a meaningful value to compare, not an diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 2f95c3ff1b..dd96779440 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -93,6 +93,7 @@ import { findImportCycles } from '../../core/graph/import-cycles.js'; import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js'; +import { lookupUnresolvedCallCount } from '../../core/ingestion/scope-resolution/unresolved-receivers.js'; import { fnLineOf, isPdgDegradedLayerStatus, @@ -464,6 +465,17 @@ export interface CodebaseContext { }; } +/** Collapse dropped-site boundary notes into an epistemic verdict: any note at + * all means the count is a lower bound, none means it is exact (#2744). */ +function epistemicFrom(droppedBoundaries: readonly string[]): { + epistemic: 'exact' | 'lower-bound'; + boundaries?: string[]; +} { + return droppedBoundaries.length === 0 + ? { epistemic: 'exact' } + : { epistemic: 'lower-bound', boundaries: [...droppedBoundaries] }; +} + interface RepoHandle { id: string; // unique key = repo name (basename) name: string; @@ -5596,6 +5608,13 @@ export class LocalBackend { ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[] }> { const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES; const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES; + // #2744 — call sites dropped for want of a receiver type. Checked BEFORE + // the heritage probe below and reported even when that probe finds nothing: + // the two are independent reasons a count can be short, and this one is the + // reason #2708 was filed. A dropped site's callee is unknown, so the index + // records the member NAME invoked at the drop; a match on the queried + // symbol's name means at least one call to something of that name was lost. + const droppedBoundaries = await this.unresolvedReceiverBoundaries(repo, symName); try { // Discover the interface / abstract supertypes on the target's boundary. // If the target is itself an interface, it is its own boundary node. @@ -5620,7 +5639,7 @@ export class LocalBackend { }); } } - if (boundary.size === 0) return { epistemic: 'exact' }; + if (boundary.size === 0) return epistemicFrom(droppedBoundaries); const ifaceIds = Array.from(boundary.keys()); // Count per interface id with scalar equality. A parameterized @@ -5676,10 +5695,42 @@ export class LocalBackend { ); } } - if (boundaries.length === 0) return { epistemic: 'exact' }; - return { epistemic: 'lower-bound', boundaries }; + if (boundaries.length === 0) return epistemicFrom(droppedBoundaries); + return { epistemic: 'lower-bound', boundaries: [...droppedBoundaries, ...boundaries] }; } catch { - return { epistemic: 'exact' }; + // Never let the heritage probe's failure suppress a drop we already know + // about — the whole point is that silence must not read as certainty. + return epistemicFrom(droppedBoundaries); + } + } + + /** + * Boundary notes for call sites the analyzer dropped because it could not + * type their receiver, when the queried symbol's name is among them (#2744). + * Empty when the index records no drops for this name — including every + * index written before the summary existed, which is why the schema version + * was bumped rather than treating "absent" as "none". + */ + private async unresolvedReceiverBoundaries(repo: RepoHandle, symName: string): Promise { + if (symName.length === 0) return []; + try { + const meta = await loadMeta(path.dirname(repo.lbugPath)); + const summary = meta?.unresolvedReceiverMembers; + // Prototype-safe: see `lookupUnresolvedCallCount`. A bare `counts[symName]` + // returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false, + // so the old guard let it through into user-facing text. + const sites = lookupUnresolvedCallCount(summary, symName); + if (sites === undefined) return []; + return [ + `${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${ + sites === 1 ? 'was' : 'were' + } dropped at index time because the receiver's type could not be ` + + `established (e.g. an unresolved constructor, factory or chained ` + + `expression). Those callers are absent from this result — actual ` + + `impact may be higher.`, + ]; + } catch { + return []; } } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index f8672fd8de..3612bc9dd1 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -128,7 +128,19 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // JLS 13.1 immediate-host chains (#2555). // v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity. // v16: direct callee identity. -const SCHEMA_BUMP = 32; +// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not +// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to +// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by +// an intermediate build of this series is not equivalent to one stamped at this +// commit — it would be treated as current while every non-TypeScript file +// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14 +// languages. Bumped here so the version tracks the FINAL capture set rather than +// the first divergence. +// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact +// encoding of a receiver that is itself an expression. (Shipped as v32 on its own +// branch; `main` took 32 for #2742 first, so this series is renumbered — see the +// v21 note on re-checking against origin/main at MERGE time, not branch time.) +const SCHEMA_BUMP = 34; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts index 608e1afdc8..c9a6e0482c 100644 --- a/gitnexus/src/storage/parsedfile-store.ts +++ b/gitnexus/src/storage/parsedfile-store.ts @@ -52,7 +52,13 @@ import { promises as fs, mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import v8 from 'node:v8'; import vm from 'node:vm'; -import type { CallableFlowSite, ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { + CallableFlowSite, + ParsedFile, + ReferenceSite, + SymbolDefinition, +} from 'gitnexus-shared'; +import { isValidReceiverChain } from '../core/ingestion/utils/receiver-chain-codec.js'; import { logger } from '../core/logger.js'; import { mapReplacer, mapReviver } from './parse-cache.js'; @@ -247,6 +253,8 @@ export const loadParsedFilesForPaths = async ( const pool = new Map(); let droppedSites = 0; let filesWithDroppedSites = 0; + let droppedChains = 0; + let rejectedFiles = 0; for (let i = 0; i < shards.length; i++) { // Per-shard def pool: a SymbolDefinition's three serialized copies live within // a single shard (one ParsedFile), so the dedup is shard-local. A cross-shard @@ -265,13 +273,27 @@ export const loadParsedFilesForPaths = async ( for (const pf of parsed) { if (!pf || typeof pf.filePath !== 'string' || !wantPaths.has(pf.filePath)) continue; const flow = sanitizeCallableFlowSites(pf.callableFlowSites); - if (flow === undefined) continue; // non-array garbage → distrust the file, re-extract - if (flow.dropped === 0) { + if (flow === undefined) { + // non-array garbage → distrust the file, re-extract + rejectedFiles++; + continue; + } + const chains = sanitizeReceiverChains(pf.referenceSites); + if (chains === undefined) { + rejectedFiles++; + continue; + } + if (flow.dropped === 0 && chains.dropped === 0) { out.set(pf.filePath, pf); } else { droppedSites += flow.dropped; + droppedChains += chains.dropped; filesWithDroppedSites++; - out.set(pf.filePath, { ...pf, callableFlowSites: flow.sites }); + out.set(pf.filePath, { + ...pf, + ...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }), + ...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }), + }); } } // Every few shards, reclaim the transient pre-intern parse churn before it @@ -282,13 +304,23 @@ export const loadParsedFilesForPaths = async ( await new Promise((resolve) => setImmediate(resolve)); } } - if (droppedSites > 0) { + if (droppedSites > 0 || droppedChains > 0) { // Facts for the dropped sites are omitted this run (the file itself is // retained, so no re-extract happens) — surface it so a recurring drop // on every warm load is observable rather than silent (#2522 review). logger.warn( - { droppedSites, files: filesWithDroppedSites }, - 'parsedfile-store: dropped malformed/over-bound callable-flow sites at load; files retained without those facts', + { droppedSites, droppedChains, files: filesWithDroppedSites }, + 'parsedfile-store: dropped malformed/over-bound sites at load; files retained without those facts', + ); + } + if (rejectedFiles > 0) { + // The other half of the same defect. A rejected file silently falls back to + // a fresh extract EVERY load, so a writer that keeps minting what this + // reader keeps refusing is a permanent warm-cache miss that costs real time + // and says nothing about why. + logger.warn( + { rejectedFiles }, + 'parsedfile-store: rejected shard entries at load (untrusted shape); those files re-extract every run', ); } return out; @@ -317,6 +349,55 @@ function sanitizeCallableFlowSites( return { sites, dropped: value.length - sites.length }; } +/** + * Untrusted-boundary handling for the compact `receiverChain` on a reference site. + * + * NARROWER than its sibling `sanitizeCallableFlowSites`, deliberately and worth + * stating plainly: that function validates every element with a full type + * predicate, whereas this one inspects only the `receiverChain` sub-field and, in + * the common case where nothing was stripped, returns the original array typed as + * `ReferenceSite[]` without structurally validating `name` / `kind` / `atRange`. + * That is still a strict improvement — `referenceSites` had NO sanitation at all + * before this — but it is not parity, and a future change that needs per-site + * structural validation must add it rather than assume it is already here. + * + * Sanitation is per-FIELD, not per-site: a chain that does not decode is + * stripped and the site is kept, because the site is still perfectly usable + * through the text cascade — dropping the whole reference would turn a + * degraded receiver into a missing edge. Only a non-array `referenceSites` + * rejects the file, and `undefined` (a store written before this field + * existed) passes through untouched, which is what makes an old shard load + * without error. + * + * Returns the ORIGINAL array when nothing was stripped, so the overwhelmingly + * common case allocates nothing on a hot warm-load path. + */ +function sanitizeReceiverChains( + value: unknown, +): { sites: readonly ReferenceSite[] | undefined; dropped: number } | undefined { + if (value === undefined) return { sites: undefined, dropped: 0 }; + if (!Array.isArray(value)) return undefined; + + let dropped = 0; + for (const site of value) { + if ( + isRecord(site) && + site.receiverChain !== undefined && + !isValidReceiverChain(site.receiverChain) + ) + dropped++; + } + if (dropped === 0) return { sites: value as readonly ReferenceSite[], dropped: 0 }; + + const sites = value.map((site) => { + if (!isRecord(site) || site.receiverChain === undefined) return site; + if (isValidReceiverChain(site.receiverChain)) return site; + const { receiverChain: _dropped, ...rest } = site; + return rest; + }) as readonly ReferenceSite[]; + return { sites, dropped }; +} + function isValidCallableFlowSite(value: unknown): value is CallableFlowSite { if (!isRecord(value) || typeof value.kind !== 'string') return false; switch (value.kind) { diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 30e7649b4a..560091a9dc 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -226,6 +226,22 @@ export interface RepoMeta { * compare, not an absence. */ cjkSegmentation?: string; + /** + * Member names whose call sites were DROPPED because the receiver's type + * could not be established (#2744, the second half of #2708). Read by + * `impact()` / `context()` to report a result as `epistemic: 'lower-bound'` + * instead of `'exact'` when the queried symbol's name appears here. + * + * Keyed by member name, not by target symbol, on purpose: a dropped site's + * callee is unknown by definition, so the drop cannot be attributed to any + * target. Absent when a run dropped nothing, which is the common case and + * keeps `epistemic` exact for cleanly-resolving repos. + */ + unresolvedReceiverMembers?: { + counts: Record; + totalSites: number; + omittedNames?: number; + }; /** * SHA-256 of every file's content at the time of the last successful * indexing run. The next run computes current hashes and diffs against @@ -552,6 +568,28 @@ export interface RepoMeta { * — for every unchanged Rust file, which is exactly the symptom #2730 * reported. Force a full re-analyze. * + * v28: structural receiver typing is active for ALL 14 languages, and the fold no + * longer types a bare identifier that merely SHADOWS a class name as that class. + * v27 landed with TypeScript-only emission and with the permissive base lookup, so + * an index stamped 27 by an intermediate build carries both pre-rollout edges for 13 + * languages AND the fabricated edges the shadowing bug produced. The reuse gate is a + * strict `===`, so such an index would be treated as current. Re-bumped here so the + * version tracks the final edge semantics. Force a full re-analyze. + * + * v26: receiver expressions are typed from captured structure rather than from + * their source text. `svc?.getUser().save()`, `svc!.getUser().save()` and + * `svc.getTyped().save()` previously emitted NO `CALLS` edge — the text + * cascade split the receiver on punctuation it could not parse — and two of the + * three recorded no drop either, because a later case marked the site handled, + * which suppresses the drop record. So the caller was missing from + * `impact(direction: "upstream")` and `context()` AND the count still claimed + * `epistemic: 'exact'`. Same v11/v12 contract: the incremental write set covers + * only CHANGED files, so a top-up against a pre-v26 index keeps serving the + * pre-fix graph — and the pre-fix confident count — for every unchanged file. + * Worse than merely incomplete: the drop summary is a whole-repo recompute while + * the edges are a changed-files write, so the two would disagree. Force a full + * re-analyze. + * * v24: inline constructor receivers resolve — `Service(db).do_work()` (Python), * `new Service(db).doWork()` (JS/TS, C#), `Service.new.do_work` (Ruby), plus the * generic, qualified, chain-head and keyword-trivia spellings of the same shape @@ -564,6 +602,19 @@ export interface RepoMeta { * the same-commit "already up to date" fast path — keeps returning the pre-fix * graph for every unchanged file, which is exactly the missing-caller symptom * #2708 reported. Force a full re-analyze. + * + * v26: unresolved-receiver member names are persisted + * (`unresolvedReceiverMembers`) so `impact()`/`context()` can report + * `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site + * was dropped for want of a receiver type (#2744). A pre-v26 index carries no + * such summary, and an absent summary is indistinguishable from "nothing was + * dropped" — so topping one up incrementally would keep reporting `exact` for + * exactly the symbols the signal exists to flag. Force a full re-analyze. + * + * (This shipped as v25 on its own branch; `main` took 25 for #2742 first, so it + * is renumbered here. Re-check both constants against origin/main immediately + * before merging — this is the fourth time that collision has bitten.) + * * v25: Rust items are qualified by their enclosing `mod` chain (#2742), so * `mod inner { fn dispatch }` and a crate-root `fn dispatch` in one file are * finally DISTINCT nodes instead of collapsing onto `Function::dispatch` @@ -572,7 +623,7 @@ export interface RepoMeta { * index holds ids an incremental top-up cannot reconcile and would simply * strand. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 25; +export const INCREMENTAL_SCHEMA_VERSION = 28; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index 0a65a58f09..bed0213240 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -89,7 +89,7 @@ }, "csharp-chain-call/Program.cs": { "captureGroups": 12, - "digest": "db5c68b0370c594cd006cabb3c836c7a0c5d9ab3a9be8f3cb4420ec76bd31d8a" + "digest": "595cb902e60ce3e2b2dbc5036dda3738fe3d5f2ae3e438f9fc58374d0c43878d" }, "csharp-chain-call/Services/UserService.cs": { "captureGroups": 11, @@ -125,7 +125,7 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "f988dfa882e07384c055a431d970550281aa1fa5d14de0daf7ecdde3a1b9e6f2" + "digest": "f1994535de8f70d68adfe640c6f904f5e71ef4c2d0e7e0e91d4ebb0cda9b46a7" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, @@ -153,7 +153,7 @@ }, "csharp-field-types/Service.cs": { "captureGroups": 11, - "digest": "6f6a983bab2d73fb0f83d879311a4822356b2aa6ee1029fc12d04b7a1c3acd58" + "digest": "d66a8bd0a78fe20a843c36793e222a33c3bcae4b2eaadf47632dd782ad1d1c41" }, "csharp-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -209,15 +209,15 @@ }, "csharp-grandparent-resolution/Services/App.cs": { "captureGroups": 14, - "digest": "ec4326a3db72676fb5e17b9fad3de503989a7f34b1b7159fe86093728fbf1737" + "digest": "0223f17f864b5de421fa5906f3e0746bf855800c3a65122479fcc59cdd1a55bd" }, "csharp-hello/Hello.cs": { "captureGroups": 22, - "digest": "0765643d95d2c40a64d3f9b71fd488fc6cbfdb0cc72cc91eec94e46da1267722" + "digest": "f942922d1a30fe794f9c807491f1a54c7ba6881a255a9234eab68ffe0953f88e" }, "csharp-inline-constructor-receiver/src/Runner.cs": { "captureGroups": 22, - "digest": "4ce5c87777f7cb1f4a6b35dff7c120ac6bc8322a8a3084670b525f98022bf1e3" + "digest": "0a73830a8629fae4d4f7212c982747b43bebb744ad5e20a566b0097f5220a9e7" }, "csharp-inline-constructor-receiver/src/Svc.cs": { "captureGroups": 19, @@ -305,11 +305,11 @@ }, "csharp-local-shadow/App/Main.cs": { "captureGroups": 14, - "digest": "12c7b2f27fc6e68ece2e588ab060d60d5ab0b2ac679bbccc236c170dacd5d130" + "digest": "be38b4045eac483dbd95e7cea8af82a38923e4b7ef5b2caa6915304ce012c4e3" }, "csharp-local-shadow/Utils/Logger.cs": { "captureGroups": 10, - "digest": "0b65e06e2c32dabd8e3c5ede48d088323f145ad2f55760b79f4f4af59aa5f033" + "digest": "40206dabd6f5b316fb40ac523d7a8fb66fcdd180f48de6b9313e9dfea02a6fd3" }, "csharp-member-calls/Models/User.cs": { "captureGroups": 8, diff --git a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json index 3e928e0adb..a27bb8c1ea 100644 --- a/gitnexus/test/fixtures/go-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/go-captures-golden/expected-captures.json @@ -53,7 +53,7 @@ }, "go-chain-call/cmd/main.go": { "captureGroups": 20, - "digest": "c60375f48bb2765060f068dc986abc9ed6ed4489c14115b6fa544ee3beb3d3d8" + "digest": "149b55b1cf523c13f9ed06cd02ce293f00407c08dd7d3bc0dd9173bc016e72e5" }, "go-chain-call/models/repo.go": { "captureGroups": 10, @@ -97,7 +97,7 @@ }, "go-deep-field-chain/cmd/main.go": { "captureGroups": 14, - "digest": "2a2d67f1fd9813558ce13d969fd9f6a50368f303c7e925b4cf8df9b52e66d54f" + "digest": "f9a16407d136307fa11299fadb4dff527a910dd9f2ffe4a50afc95dc3e1dd483" }, "go-deep-field-chain/models/models.go": { "captureGroups": 33, @@ -105,7 +105,7 @@ }, "go-field-types/cmd/main.go": { "captureGroups": 10, - "digest": "358f4003af7b957c35bb049af4941f8a23c36981b7e777bf1b943bcf734c7d1b" + "digest": "d79a7120276b1a5297f6e39bc88574a18b82ad26faede903dd449309544293c6" }, "go-field-types/models/models.go": { "captureGroups": 22, @@ -181,7 +181,7 @@ }, "go-mixed-chain/cmd/main.go": { "captureGroups": 22, - "digest": "486c3edfe09e6bc6ba3652f55e167c1e73cdac3b5419ef513811a1e6bfcdb9d5" + "digest": "ee4f286d8b6baa15476e6316665f6d51a891701f9c994e14514aaee2738e811a" }, "go-mixed-chain/models/models.go": { "captureGroups": 41, diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp new file mode 100644 index 0000000000..b15d7eb1e6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp @@ -0,0 +1,14 @@ +#include "models.h" + +// The `->` BASE receiver. Before structural receiver typing this emitted no +// CALLS edge to `save` at all, and `cpp-chain-call/` could not catch it because +// that fixture uses the value `.` form, which already worked. +void pointerArrowChain(Service* svc) { + svc->getUser()->save(); +} + +// Control: same chain, same `->save()` tail, value `.` on the base. Resolved +// before this work and must keep resolving. +void valueDotChain(Service svc) { + svc.getUser()->save(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h new file mode 100644 index 0000000000..1294fa6e21 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h @@ -0,0 +1,11 @@ +#pragma once + +class User { +public: + void save(); +}; + +class Service { +public: + User* getUser(); +}; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js b/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js new file mode 100644 index 0000000000..e494497f0e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js @@ -0,0 +1,7 @@ +const { Legacy } = require('./svc'); + +function viaInlineNewJs() { + return new Legacy().doWork(); +} + +module.exports = { viaInlineNewJs }; diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js b/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js new file mode 100644 index 0000000000..3c90a1e126 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js @@ -0,0 +1,7 @@ +class Legacy { + doWork() { + return 1; + } +} + +module.exports = { Legacy }; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/app.js b/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/app.js deleted file mode 100644 index 6bb3724340..0000000000 --- a/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/app.js +++ /dev/null @@ -1,7 +0,0 @@ -const { LegacyService } = require('./svc'); - -function viaInlineNewJs() { - return new LegacyService().doWork(); -} - -module.exports = { viaInlineNewJs }; diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/svc.js b/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/svc.js deleted file mode 100644 index 52beb6db12..0000000000 --- a/gitnexus/test/fixtures/lang-resolution/typescript-inline-constructor-receiver/legacy/svc.js +++ /dev/null @@ -1,7 +0,0 @@ -class LegacyService { - doWork() { - return 1; - } -} - -module.exports = { LegacyService }; diff --git a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json index 476165c996..5f926cb938 100644 --- a/gitnexus/test/fixtures/php-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/php-captures-golden/expected-captures.json @@ -133,7 +133,7 @@ }, "php-constructor-promotion-fields/Service.php": { "captureGroups": 9, - "digest": "c5efa7143b83d8677edfdb3cdedf27b63b5587babd12b861cb90dd05bf739327" + "digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240" }, "php-constructor-type-inference/app/Models/Repo.php": { "captureGroups": 7, @@ -161,7 +161,7 @@ }, "php-deep-field-chain/Service.php": { "captureGroups": 10, - "digest": "f5bb845f2a06252e7d030cc1bb8adca213dece5162ea6240b52b2545a9c6bf4d" + "digest": "fa5a413c11f99286cbc16ad337f7f2a6c3f399bd39c25fea1775c97c4b3c707f" }, "php-default-params/app.php": { "captureGroups": 11, @@ -185,7 +185,7 @@ }, "php-field-types/Service.php": { "captureGroups": 9, - "digest": "c5efa7143b83d8677edfdb3cdedf27b63b5587babd12b861cb90dd05bf739327" + "digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240" }, "php-foreach-call-expr/Repo.php": { "captureGroups": 19, @@ -265,7 +265,7 @@ }, "php-grandparent-resolution/app/Services/App.php": { "captureGroups": 12, - "digest": "7273ba524f587c01ca3ba9ededfdfef635c20fd9c6ade95225e81aff07ace6a6" + "digest": "2b4b12fae10aeeb2e4c0987cce02171237b68976731d1b34ac0d74b8d005de53" }, "php-grouped-imports/app/Models/Repo.php": { "captureGroups": 7, diff --git a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json index 48e2951b25..86af1fb871 100644 --- a/gitnexus/test/fixtures/python-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/python-captures-golden/expected-captures.json @@ -109,7 +109,7 @@ }, "python-chain-call/app.py": { "captureGroups": 9, - "digest": "abc26d2f53c4dc0f091009b120e7869ca74b015bad637c5643fc4fdd69df946e" + "digest": "63798aa4bc97cd70bac26d971dd8980030a35a6bab5e1f88acff139bd1dcd67b" }, "python-chain-call/models/repo.py": { "captureGroups": 7, @@ -177,7 +177,7 @@ }, "python-constructor-field-receiver/memory_service.py": { "captureGroups": 59, - "digest": "ad9c3be5a7c10e112bb20eac20b8603196fc2e739b7567159c58a607639ce192" + "digest": "c46b8b91e9f7a41f8fcdd13a8dbc6132458ea2e4e4d19b852b054ced848e91c4" }, "python-constructor-field-receiver/test_fixture.py": { "captureGroups": 13, @@ -273,7 +273,7 @@ }, "python-django-app-imports/config/asgi.py": { "captureGroups": 7, - "digest": "79729019cda1dd6f41848365b98416fc610aaa846464bed569573c1fde0892a3" + "digest": "44e7118a9123e137d361f2cde199c1ed653a01503449577f8e260d6670f8c243" }, "python-django-app-imports/config/settings.py": { "captureGroups": 21, @@ -285,11 +285,11 @@ }, "python-django-app-imports/config/wsgi.py": { "captureGroups": 7, - "digest": "2c57f45963ef158d2ad6a504c5d09fb4f3aa53d6198b91e91f8f824aae1de452" + "digest": "32a6daf4cf7ff4938c7077e27f4f911a4630890f72ba4d6c11c25d05fab18f69" }, "python-django-app-imports/manage.py": { "captureGroups": 11, - "digest": "c5d3eb457808eb9c17502c751cd045dc89c469fb8e308498d1c21a6da2fcafa3" + "digest": "4c86987fe515c4417d6157dabbed6f0abe50b21f9fe08497a0d2a5e04223b322" }, "python-enumerate-loop/app.py": { "captureGroups": 27, @@ -309,7 +309,7 @@ }, "python-field-type-disambig/service.py": { "captureGroups": 7, - "digest": "ebe4ba05a566f775df15b9eff96980b496068c8b5504036b41de29b203aeb755" + "digest": "d406758347cfa612e0a7c69e9613ce19d9b7658744c2a7c8d079182eded41fde" }, "python-field-type-disambig/user.py": { "captureGroups": 12, @@ -321,7 +321,7 @@ }, "python-field-types/service.py": { "captureGroups": 7, - "digest": "03cf02243ec15b64694c0f5b33b80daf1689049123f91ade18ae9557aff1b028" + "digest": "6022bc25143590d4736a7a470c0df6442c9b1478738b0812d53b038b5843822d" }, "python-for-call-expr/main.py": { "captureGroups": 15, @@ -349,7 +349,7 @@ }, "python-grandparent-resolution/app.py": { "captureGroups": 9, - "digest": "509f6937b263ae9eb420bdf916664af3f0a0cf4fce708bb912f259b56e7c0938" + "digest": "dd0012c9188d9145bdfa8b74a2c3f213ec658b0efaa0c2ac9a09c1f6f7d6c4d3" }, "python-grandparent-resolution/models/__init__.py": { "captureGroups": 0, @@ -605,7 +605,7 @@ }, "python-overload-dispatch/service.py": { "captureGroups": 37, - "digest": "b5bbb6744e86850bbea1afe2e7b38f4e14e01b27bcf68777695dffd8065623bb" + "digest": "a0b60d32ee7065e8e5b0eb9d1bf180b81485e0899d141542663615b74ef55f95" }, "python-parent-resolution/models/__init__.py": { "captureGroups": 0, diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 68c0dea131..dea96d9265 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -53,7 +53,7 @@ }, "ruby-chain-call/lib/app.rb": { "captureGroups": 13, - "digest": "ea7f8a06ec8cb89b47a3c5ebfce79211a51abf643fe9a48e480729b8323491c4" + "digest": "5d99da3bf38492726eb120eca6ed43a5c16fe97de65981f177a62754b077b86a" }, "ruby-chain-call/lib/repo.rb": { "captureGroups": 6, @@ -101,7 +101,7 @@ }, "ruby-construction-selector/lib/app.rb": { "captureGroups": 18, - "digest": "cb4e68f9bf239ea190ba03422a18943927e93e4961d8abc53f67d34a1de26ecd" + "digest": "890fe5496c51f93745d1c0b0a9a9dcc402cc46a84709498ce21078f34ca81366" }, "ruby-construction-selector/lib/factory.rb": { "captureGroups": 25, @@ -133,7 +133,7 @@ }, "ruby-field-type-disambig/service.rb": { "captureGroups": 9, - "digest": "beafde5670e8b6f2f2f7966bcfe71c9d3adec92ec0fa188f8b47587e2d559a4a" + "digest": "7a935707c6a6c5a7c72fa377f4d8d2816d9a1bfa502491770512701d0fa27090" }, "ruby-field-type-disambig/user.rb": { "captureGroups": 13, @@ -145,7 +145,7 @@ }, "ruby-field-types/service.rb": { "captureGroups": 9, - "digest": "209a8f57b1f5a55c293ce3617c3040fe55d348168bc56e96a416e8ed8220cf58" + "digest": "fe9329f558d73e6dd006085dc8f8aecff3814d7f6712014e21beea0d1bcb87cc" }, "ruby-for-in-loop/app.rb": { "captureGroups": 9, @@ -161,7 +161,7 @@ }, "ruby-grandparent-resolution/lib/app.rb": { "captureGroups": 10, - "digest": "118450b872d82f6762e0081b85afaff8c28f683395ecd3c1eec7efdf8dd420a6" + "digest": "9c684d0b2e8f247a93a3d4ab6db75e4a13f3878a670fbc25f5e19ee7e38099df" }, "ruby-grandparent-resolution/lib/models/a.rb": { "captureGroups": 10, @@ -181,7 +181,7 @@ }, "ruby-inline-constructor-receiver/lib/app.rb": { "captureGroups": 17, - "digest": "b686ac42b4eac84ab9f486b687965df28f9ac8c292cc6ebcfc903832281023f3" + "digest": "41c63faf13560f27f183a49da10b7e64ffa772b30d37611dbdcf083a9ac0efd3" }, "ruby-inline-constructor-receiver/lib/svc.rb": { "captureGroups": 6, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 5e3a1f5d51..4856f886fa 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -197,7 +197,7 @@ }, "rust-chain-call/src/main.rs": { "captureGroups": 25, - "digest": "4b47bbd3de939f8692c1615442e233ad00440075fafc5b6a7f7db35851492a64" + "digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421" }, "rust-chain-call/src/models/mod.rs": { "captureGroups": 5, @@ -269,7 +269,7 @@ }, "rust-deep-field-chain/service.rs": { "captureGroups": 16, - "digest": "49d6a6bff4bfde6d29d8e63c2899f49688bee98273c9f743d9189dbfdf19ce2c" + "digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a" }, "rust-default-constructor/src/main.rs": { "captureGroups": 36, @@ -381,7 +381,7 @@ }, "rust-field-types/service.rs": { "captureGroups": 11, - "digest": "5bba21d0f792eb98025ce0531c575dac215492027cd877df09b19877e80da4ad" + "digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f" }, "rust-for-call-expr/src/main.rs": { "captureGroups": 26, @@ -617,7 +617,7 @@ }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 41, - "digest": "811410fc6afe4722331ff1edfc79ce38a94c241ae52b86c10511d65d8603ef19" + "digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05" }, "rust-nullable-receiver/src/repo.rs": { "captureGroups": 10, @@ -729,7 +729,7 @@ }, "rust-self-struct-literal/models.rs": { "captureGroups": 32, - "digest": "8928de22daa88f6f311a407e0051d1dbd3e52c81503f4b295eecb3a3c86b23cf" + "digest": "f9c89cb8c9f2b9c10140ae6971a09756b27112ab61d483b9ffe7fbcb36fefa8d" }, "rust-self-this-resolution/src/repo.rs": { "captureGroups": 10, diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index 7abab94aa4..cd815cc993 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -85,7 +85,7 @@ }, "swift-field-types/App.swift": { "captureGroups": 7, - "digest": "651c5cbbf9afdc0bf4d96217bc1a01cf0bf07a0190ca12a3a6786bb08a67b3ad" + "digest": "97750185ce98b162ecaed76917408e715756a9256e1bc961a6439815db3c0715" }, "swift-field-types/Models.swift": { "captureGroups": 20, diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 3004277877..4bcb4e0bd9 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -4548,3 +4548,33 @@ describe('C++ deleted overload selection (#1893 A2)', () => { ]); }); }); + +// --------------------------------------------------------------------------- +// Structural receiver typing for a `->` base receiver. +// +// The all-language rollout is proven behaviourally for TypeScript only; this is +// the second language, and the one the rollout measurably changed: C++ +// `svc->getUser()->save()` emitted NO edge and recorded NO drop before it. +// --------------------------------------------------------------------------- + +describe('C++ structural receiver chain through a pointer base', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-receiver-chain-arrow'), () => {}); + }, 60000); + + it('resolves svc->getUser()->save() — the `->` base the text cascade could not parse', () => { + const calls = getRelationships(result, 'CALLS'); + expect( + calls.find((c) => c.source === 'pointerArrowChain' && c.target === 'save'), + ).toMatchObject({ target: 'save' }); + }); + + it('keeps the value-dot base resolving (control — worked before this work)', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'valueDotChain' && c.target === 'save')).toMatchObject({ + target: 'save', + }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/javascript.test.ts b/gitnexus/test/integration/resolvers/javascript.test.ts index acd8412782..deadef3af7 100644 --- a/gitnexus/test/integration/resolvers/javascript.test.ts +++ b/gitnexus/test/integration/resolvers/javascript.test.ts @@ -663,3 +663,31 @@ describe('JavaScript factory-pattern singleton resolution (issue #1358 sub-case) ]); }); }); + +// --------------------------------------------------------------------------- +// Inline constructor receiver: new Legacy().doWork() (#2708) +// The javascript provider is wired `{ keyword: 'new' }` independently of +// typescript, so it needs its own fixture. Kept under a `javascript-` prefix +// deliberately: the capture-fingerprint bench globs fixture directories by +// language prefix, so `.js` sources parked under a `typescript-` directory +// would be covered by neither language's guard. +// --------------------------------------------------------------------------- + +describe('JavaScript inline constructor receiver resolution', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'javascript-inline-constructor-receiver'), + () => {}, + ); + }, 60000); + + it('resolves new Legacy().doWork() to Legacy.doWork', () => { + const call = getRelationships(result, 'CALLS').find( + (c) => c.source === 'viaInlineNewJs' && c.target === 'doWork', + ); + expect(call).toMatchObject({ target: 'doWork', targetFilePath: 'svc.js' }); + expect(call!.rel.targetId).toContain('Legacy'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index a897a5c05b..d01f68609a 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -11,6 +11,7 @@ import { getNodesByLabel, getNodesByLabelFull, edgeSet, + getResolutionOutcomes, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -3250,13 +3251,6 @@ describe('TypeScript inline constructor receiver resolution', () => { expect(twoStep!.rel.targetId).toContain('Service'); }); - it('resolves the same shape in a plain .js file (javascript provider)', () => { - const calls = getRelationships(result, 'CALLS'); - const jsCall = calls.find((c) => c.source === 'viaInlineNewJs' && c.target === 'doWork'); - expect(jsCall).toBeDefined(); - expect(jsCall!.rel.targetId).toContain('LegacyService'); - }); - it('resolves a generic constructor receiver — new Box().unwrap()', () => { const calls = getRelationships(result, 'CALLS'); const genericCall = calls.find((c) => c.source === 'viaGenericCtor' && c.target === 'unwrap'); @@ -3312,3 +3306,243 @@ describe('TypeScript inline constructor receiver resolution', () => { expect(factoryCall!.rel.targetId).toContain('Other'); }); }); + +// --------------------------------------------------------------------------- +// #2744 drop recorder — the site kind travels with the drop. +// +// Case 0's gate tests the RECEIVER's punctuation, not the site's kind, so a +// compound-receiver property write is recorded in the same bucket as a dropped +// method call. Anything measuring resolver gaps has to tell those apart, and +// the site kind is the only authoritative signal for it. +// +// Both shapes below are empirically confirmed drops: `!` produces a reference +// site that reaches Case 0. (`?.` and explicit type arguments do NOT record a +// drop at all — they are invisible to this recorder, which is a property of the +// capture layer, not of this field.) +// --------------------------------------------------------------------------- + +describe('TypeScript receiver-unresolved drops carry their site kind (#2744)', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-drop-site-kind-')); + writeFixtureRepo(repoDir, { + 'models.ts': `export class User { + name: string = ''; + save(): void {} +} + +export class Service { + getUser(): User { + return new User(); + } + async getUserAsync(): Promise { + return new User(); + } +} +`, + 'main.ts': `import { Service } from './models'; + +export async function droppedCall(svc: Service): Promise { + // An await-parenthesized receiver. Structural typing does NOT cover this + // shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node, + // so no chain is minted and the site still reaches the drop recorder. The + // \`!\` spelling used to serve here until structural typing resolved it. + (await svc.getUserAsync()).save(); +} + +export function droppedWrite(svc: Service | null): void { + // A write receiver mints no chain at all (the emitter gates on CALL_TAGS), + // so this keeps dropping and stays separable from the call above. + svc!.getUser().name = 'x'; +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('tags a dropped method call as a call site', () => { + const drops = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + expect(drops).toContainEqual(expect.objectContaining({ name: 'save', siteKind: 'call' })); + }); + + it('tags a dropped property write as a write site, so it is separable from calls', () => { + const drops = getResolutionOutcomes(result).filter( + (outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved', + ); + expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' })); + }); +}); + +// --------------------------------------------------------------------------- +// Structural receiver typing: receiver spellings the text cascade cannot parse +// now resolve by folding the captured chain. +// +// Both shapes below emitted NO edge and recorded NO drop before this work: +// Case 0's gate fired, `resolveCompoundReceiverClass` returned undefined, and a +// later case marked the site handled — which suppresses the drop record too, so +// the loss was invisible to the epistemic signal as well as to the graph. +// --------------------------------------------------------------------------- + +describe('TypeScript structural receiver chains', () => { + let repoDir: string; + let result: PipelineResult; + + beforeAll(async () => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-receiver-chain-')); + writeFixtureRepo(repoDir, { + 'models.ts': `export class Database { + query(): void {} +} + +export class Config { + db: Database = new Database(); +} + +export function make(n: number): number { + return n; +} + +export class Address { + persist(): void {} +} + +export class User { + address: Address = new Address(); + save(): void {} +} + +export class Service { + getUser(): User { + return new User(); + } + getTyped(): User { + return new User(); + } +} +`, + 'main.ts': `import { Service, Config, make } from './models'; + +export function viaOptionalChain(svc: Service | null): void { + svc?.getUser().save(); +} + +// A local that merely SHADOWS an imported class name. Its value is a number, +// so it has NO members — the fold must not type it as the class. +export function shadowsAClassName(): void { + const Config = make(1); + Config.db.query(); +} + +export function viaTypeArgs(svc: Service): void { + svc.getTyped().save(); +} + +export function viaNonNull(svc: Service | null): void { + svc!.getUser().save(); +} + +// A mixed call/field chain behind a spelling the TEXT cascade cannot parse +// (optional chaining), so this discriminates the fold rather than re-testing +// the pre-existing cascade path. +export function viaMixedChain(svc: Service | null): void { + svc?.getUser().address.persist(); +} + +// User has no member named missing, so the chain dies at its middle step. +export function brokenMiddleStep(svc: Service): void { + svc.getUser().missing.persist(); +} + +export function alreadyWorked(svc: Service): void { + svc.getUser().save(); +} +`, + }); + result = await runPipelineFromRepo(repoDir, () => {}, {}); + }, 60000); + + afterAll(() => { + if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + it('resolves an optional-chained receiver — svc?.getUser().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaOptionalChain' && c.target === 'save')).toMatchObject( + { + target: 'save', + targetFilePath: 'models.ts', + }, + ); + }); + + it('resolves an explicit-type-argument receiver — svc.getTyped().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaTypeArgs' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('resolves a non-null-asserted receiver — svc!.getUser().save()', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaNonNull' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('resolves a mixed call/field chain through the fold', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'viaMixedChain' && c.target === 'persist')).toMatchObject( + { + target: 'persist', + targetFilePath: 'models.ts', + }, + ); + }); + + it('keeps the shape that already resolved through the text cascade', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.find((c) => c.source === 'alreadyWorked' && c.target === 'save')).toMatchObject({ + target: 'save', + targetFilePath: 'models.ts', + }); + }); + + it('does NOT fabricate an edge when a local shadows a class name', () => { + // Regression: the fold resolved its base through the permissive + // bare-identifier path, which falls through to a plain class-name lookup + // even when a receiver typeBinding exists but names no class. A local + // `const Config = make(1)` (a number) was therefore typed as the imported + // `class Config`, emitting a confident `CALLS` edge to `Database.query` + // that the text cascade never produced. A missing edge is recoverable; a + // wrong one is not. + const calls = getRelationships(result, 'CALLS'); + expect(calls.filter((c) => c.source === 'shadowsAClassName' && c.target === 'query')).toEqual( + [], + ); + }); + + it('emits no CALLS edge when a middle step names no member of the previous class', () => { + // A broken MIDDLE step must produce no edge, never a wrong one. `User` has no + // member `missing`, so the chain cannot be typed past it and `persist` must + // not bind to anything — in particular not to `Address.persist`, which a + // field-walking fallback could otherwise reach. + // `getUser` itself still resolves — only the tail past the broken step must not. + const calls = getRelationships(result, 'CALLS'); + expect(calls.filter((c) => c.source === 'brokenMiddleStep' && c.target === 'persist')).toEqual( + [], + ); + expect( + calls.filter((c) => c.source === 'brokenMiddleStep' && c.target === 'getUser'), + ).toHaveLength(1); + }); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 41aa3efbc7..2404ad1e60 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 25 (Rust mod-qualified node ids, #2742)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 28 (structural receiver typing, all languages)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(25); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(28); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -175,14 +175,30 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // (#2730): every unchanged Rust file would keep the same-name self-loop and // keep reporting the real callee as unreached → must NOT reuse. expect(passesReuseGate(22)).toBe(false); - // A pre-v24 (v23) index predates + // A pre-v24 (v23) index predates the #2708 inline-constructor receivers. expect(passesReuseGate(23)).toBe(false); - // The current stamp passes the gate (incremental top-up eligible). - // A pre-v25 (v24) index predates mod-qualified Rust node ids (#2742): every - // item inside a `mod` block has a different id now, so a top-up would strand - // the old nodes → must NOT reuse. + // A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An + // absent summary is indistinguishable from "nothing was dropped", so a + // top-up would keep reporting `epistemic: 'exact'` for exactly the symbols + // whose callers were dropped → must NOT reuse. expect(passesReuseGate(24)).toBe(false); - // The current stamp passes (incremental top-up eligible). - expect(passesReuseGate(25)).toBe(true); + // A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`, + // `svc!.m().n()` and `svc.m().n()` emitted no CALLS edge — and two of the + // three recorded no drop either, so the count still claimed `exact`. A + // changed-files top-up keeps both the missing edge and the false confidence + // for every unchanged file → must NOT reuse. + expect(passesReuseGate(25)).toBe(false); + // A pre-v27 (v26) index was stamped by an intermediate build of this same + // series: structural typing was TypeScript-only at that point, and the fold + // still typed a bare identifier that merely shadowed a class name as that + // class — so such an index carries both pre-rollout edges for 13 languages + // and fabricated ones. The gate is a strict `===`, so it must NOT reuse. + expect(passesReuseGate(26)).toBe(false); + // A pre-v28 (v27) index was stamped mid-series: TypeScript-only structural + // typing, and the fold still typed a local that merely shadowed a class name + // as that class — so it carries pre-rollout edges AND fabricated ones. + expect(passesReuseGate(27)).toBe(false); + // The current stamp passes the gate (incremental top-up eligible). + expect(passesReuseGate(28)).toBe(true); }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 341ba1d4a9..fa58cd8430 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -101,11 +101,14 @@ describe('fileContentHash', () => { }); describe('PARSE_CACHE_VERSION', () => { - // 31 -> 32 for the mod-qualified Rust node ids (#2742). Updated deliberately: - // this pin exists so a bump cannot ride along unnoticed, and it caught the - // collision when the pin arrived from main while this branch already held 32. - it('pins SCHEMA_BUMP to 32 so concurrent bumps cannot silently collide (#2736)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(32); + // 32 -> 34 for the receiver-chain capture (#2747): 33 is the TypeScript-only + // emission, 34 the rollout to the other 13 emitters, which changed the capture + // set a second time. Updated deliberately — and this pin earned its keep twice + // over: it caught the #2742 collision when it arrived from main, and it caught + // this series again on rebase, where `main` had independently taken 32 for the + // very number this branch was already using. + it('pins SCHEMA_BUMP to 34 so concurrent bumps cannot silently collide (#2736)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(34); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index c94f253b20..f59f3fd533 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -504,3 +504,111 @@ describe('parsedfile-store', () => { } }); }); + +/** + * `receiverChain` at the untrusted boundary. Unlike `callableFlowSites`, + * `referenceSites` had no sanitizer here at all, so this field arrives with the + * first one. + */ +describe('parsedfile-store receiverChain sanitation', () => { + const siteWith = (receiverChain: unknown) => ({ + name: 'save', + atRange: { startLine: 3, startCol: 2, endLine: 3, endCol: 6 }, + inScope: 'x.ts:module', + kind: 'call', + ...(receiverChain === undefined ? {} : { receiverChain }), + }); + + it('round-trips a well-formed chain', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-')); + try { + await persistParsedFileChunk(dir, 'chunk-0', [ + makeStoreEntry('x.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }), + ]); + const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; + expect(loaded.referenceSites[0]).toMatchObject({ + name: 'save', + receiverChain: '1|svc|cgetUser', + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('loads a shard written before the field existed, unchanged', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-old-')); + try { + await persistParsedFileChunk(dir, 'chunk-0', [ + makeStoreEntry('x.ts', { referenceSites: [siteWith(undefined)] }), + ]); + const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; + expect(loaded.referenceSites[0]).toMatchObject({ name: 'save' }); + expect(loaded.referenceSites[0]).not.toHaveProperty('receiverChain'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it.each([ + ['malformed', 'not-a-chain'], + ['wrong version', '2|svc|cgetUser'], + ['over depth', '1|svc|ca|cb|cc|cd'], + ['non-string', 42], + ])( + 'strips a %s chain but KEEPS the site — it still resolves via the text cascade', + async (_label, payload) => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-bad-')); + try { + await persistParsedFileChunk(dir, 'chunk-0', [ + makeStoreEntry('x.ts', { referenceSites: [siteWith(payload)] }), + ]); + const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; + expect(loaded.referenceSites).toHaveLength(1); + expect(loaded.referenceSites[0]).toMatchObject({ name: 'save' }); + expect(loaded.referenceSites[0]).not.toHaveProperty('receiverChain'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + + it('rejects the file when referenceSites is not an array at all', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-garbage-')); + try { + await persistParsedFileChunk(dir, 'chunk-0', [ + makeStoreEntry('garbage.ts', { referenceSites: 'nonsense' }), + makeStoreEntry('ok.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }), + ]); + const loaded = await loadParsedFilesForPaths(dir, new Set(['garbage.ts', 'ok.ts'])); + expect(loaded.has('garbage.ts')).toBe(false); + expect(loaded.has('ok.ts')).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('strips only the invalid chain and leaves a valid sibling intact', async () => { + // Sanitation is per-FIELD, not per-site or per-file. Every other case here + // uses a single-element array, so the `dropped > 0` .map() branch was never + // shown to preserve a good neighbour. + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-mixed-')); + try { + await persistParsedFileChunk(dir, 'chunk-0', [ + makeStoreEntry('x.ts', { + referenceSites: [ + siteWith('1|svc|cgetUser'), + siteWith('not-a-chain'), + siteWith('1|other|ffield'), + ], + }), + ]); + const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!; + expect(loaded.referenceSites).toHaveLength(3); + expect(loaded.referenceSites[0]).toMatchObject({ receiverChain: '1|svc|cgetUser' }); + expect(loaded.referenceSites[1]).not.toHaveProperty('receiverChain'); + expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '1|other|ffield' }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/receiver-chain-codec.test.ts b/gitnexus/test/unit/receiver-chain-codec.test.ts new file mode 100644 index 0000000000..7518e862b2 --- /dev/null +++ b/gitnexus/test/unit/receiver-chain-codec.test.ts @@ -0,0 +1,160 @@ +/** + * Receiver-chain wire format. The emitter, the resolver fold and the untrusted + * store boundary all read this format, so a decode that accepts something the + * encoder cannot mint — or vice versa — is a silent divergence between what is + * written and what is trusted. + */ + +import { describe, it, expect } from 'vitest'; +import { + MAX_RECEIVER_CHAIN_BYTES, + decodeReceiverChain, + encodeReceiverChain, + isValidReceiverChain, +} from '../../src/core/ingestion/utils/receiver-chain-codec.js'; +import { MAX_CHAIN_DEPTH } from '../../src/core/ingestion/utils/call-analysis.js'; + +describe('receiver-chain codec', () => { + it('round-trips a mixed call/field chain base-first', () => { + const encoded = encodeReceiverChain('svc', [ + { kind: 'call', name: 'getUser' }, + { kind: 'field', name: 'address' }, + ]); + expect(encoded).toBe('1|svc|cgetUser|faddress'); + expect(decodeReceiverChain(encoded)).toEqual({ + baseReceiverName: 'svc', + steps: [ + { kind: 'call', name: 'getUser' }, + { kind: 'field', name: 'address' }, + ], + truncated: false, + }); + }); + + it('stays well under the U7 per-site byte threshold for a realistic chain', () => { + // The gate is <= 48 serialized bytes per emitting site, set so an object + // encoding fails and this one passes. + expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }])!.length).toBeLessThan( + 48, + ); + }); + + it('needs no escaping for a member whose name starts with a kind sigil', () => { + expect( + decodeReceiverChain(encodeReceiverChain('a', [{ kind: 'call', name: 'count' }])), + ).toEqual({ + baseReceiverName: 'a', + steps: [{ kind: 'call', name: 'count' }], + truncated: false, + }); + expect( + decodeReceiverChain(encodeReceiverChain('a', [{ kind: 'field', name: 'field' }])), + ).toEqual({ + baseReceiverName: 'a', + steps: [{ kind: 'field', name: 'field' }], + truncated: false, + }); + }); + + it('marks a truncated chain, because its missing tail is what decides the type', () => { + const encoded = encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }], { + truncated: true, + }); + expect(encoded).toBe('1|svc|cgetUser|~'); + expect(decodeReceiverChain(encoded)).toMatchObject({ truncated: true }); + }); + + it('refuses to mint an empty chain — there is nothing to fold', () => { + expect(encodeReceiverChain('svc', [])).toBeUndefined(); + }); + + it('refuses to mint beyond MAX_CHAIN_DEPTH rather than silently dropping a step', () => { + const steps = Array.from({ length: MAX_CHAIN_DEPTH + 1 }, (_unused, i) => ({ + kind: 'call' as const, + name: `m${i}`, + })); + expect(encodeReceiverChain('svc', steps)).toBeUndefined(); + }); + + it('refuses to mint a name carrying a structural character, instead of escaping it', () => { + expect(encodeReceiverChain('sv|c', [{ kind: 'call', name: 'getUser' }])).toBeUndefined(); + expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'get~User' }])).toBeUndefined(); + expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'get User' }])).toBeUndefined(); + }); + + it('refuses to mint over the byte cap', () => { + const huge = 'x'.repeat(MAX_RECEIVER_CHAIN_BYTES); + expect(encodeReceiverChain('svc', [{ kind: 'call', name: huge }])).toBeUndefined(); + }); + + it.each([ + ['not a string', 42], + ['undefined', undefined], + ['empty', ''], + ['no version', 'svc|cgetUser'], + ['wrong version', '2|svc|cgetUser'], + ['no steps', '1|svc'], + ['unknown kind sigil', '1|svc|xgetUser'], + ['empty step name', '1|svc|c'], + ['empty base', '1||cgetUser'], + ['over depth', '1|svc|ca|cb|cc|cd'], + ['truncation marker only', '1|svc|~'], + ])('decodes %s as undefined rather than throwing', (_label, payload) => { + expect(decodeReceiverChain(payload)).toBeUndefined(); + expect(isValidReceiverChain(payload)).toBe(false); + }); + + it('refuses to mint a name carrying a zero-width character', () => { + // `\s` does not match these, so without an explicit class they encode and + // persist cleanly and then match no binding at resolution time — a silent, + // unexplained miss, and a trojan-source vector. + for (const invisible of ['\u200B', '\u200C', '\u200D', '\u200E', '\u200F', '\uFEFF']) { + expect( + encodeReceiverChain('svc', [{ kind: 'call', name: `get${invisible}User` }]), + ).toBeUndefined(); + expect( + encodeReceiverChain(`sv${invisible}c`, [{ kind: 'call', name: 'getUser' }]), + ).toBeUndefined(); + } + }); + + it('round-trips a chain of exactly MAX_CHAIN_DEPTH steps', () => { + // The refusal at MAX_CHAIN_DEPTH + 1 is pinned above; pin the boundary that + // must still WORK, so a future off-by-one narrowing is caught too. + const steps = Array.from({ length: MAX_CHAIN_DEPTH }, (_unused, i) => ({ + kind: 'call' as const, + name: `m${i}`, + })); + expect(decodeReceiverChain(encodeReceiverChain('svc', steps))).toMatchObject({ + baseReceiverName: 'svc', + truncated: false, + }); + expect(decodeReceiverChain(encodeReceiverChain('svc', steps))?.steps).toHaveLength( + MAX_CHAIN_DEPTH, + ); + }); + + it('survives adversarial payloads without throwing', () => { + // The "total function" claim was previously verified by reading only. + for (const hostile of [ + '|'.repeat(512), + '1|' + '|'.repeat(400), + '1|svc|c\u0000name', + '1|svc|c\uD800', + `1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`, + '1|'.repeat(300), + {}, + [], + null, + ]) { + expect(() => decodeReceiverChain(hostile)).not.toThrow(); + } + }); + + it('rejects an over-cap payload at decode, matching the emit-side refusal', () => { + // Emit and load must agree. A bound applied only on load is a writer that + // keeps minting what the reader keeps refusing — a permanent, unlogged + // warm-cache-miss reparse loop. + expect(isValidReceiverChain(`1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts new file mode 100644 index 0000000000..77eededc23 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts @@ -0,0 +1,159 @@ +/** + * U6 — `foldReceiverChain` types a receiver from decoded structure rather than + * from its source text. + * + * The fold is called directly: U6 deliberately wires it into no resolution + * path (that is U10), so there is no pipeline behaviour to assert yet. + */ + +import { describe, it, expect } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js'; +import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js'; +import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; +import { foldReceiverChain } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js'; +import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js'; + +const SOURCE = `export class Address { + save(): void {} +} + +export class User { + address: Address = new Address(); + save(): void {} +} + +export class Base { + inherited(): User { + return new User(); + } +} + +export class Service extends Base { + getUser(): User { + return new User(); + } +} + +// A class with NO member \`save\`, whose FIELD's type has one. The field +// fallback finds \`save\` by walking in here; the fold must not. +export class Holder { + user: User = new User(); +} + +const svc: Service = new Service(); +const holder: Holder = new Holder(); +`; + +function build() { + const parsed = extractParsedFile(typescriptScopeResolver.languageProvider, SOURCE, 'main.ts'); + if (parsed === undefined) throw new Error('scope extraction failed'); + typescriptScopeResolver.populateOwners(parsed); + const parsedFiles: ParsedFile[] = [parsed]; + const allFilePaths = new Set(parsedFiles.map((p) => p.filePath)); + const scopes = finalizeScopeModel(parsedFiles, { + hooks: { + resolveImportTarget: (targetRaw, fromFile) => + typescriptScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths), + mergeBindings: (existing, incoming, scopeId) => + typescriptScopeResolver.mergeBindings(existing, incoming, scopeId), + }, + }); + const index = buildWorkspaceResolutionIndex(parsedFiles); + return { scopes, index, inScope: parsed.moduleScope }; +} + +const ctx = build(); + +/** Fold with the language contract flags overridden — used to exercise the + * OFF branch of `hoistTypeBindingsToModule`, which six wired languages + * (c, cobol, dart, python, ruby, swift) actually run. */ +function foldWith(encoded: string, overrides: Record) { + const decoded = decodeReceiverChain(encoded); + expect(decoded).toBeDefined(); + return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { + fieldFallback: false, + hoistTypeBindingsToModule: true, + ...overrides, + }); +} + +function fold(encoded: string) { + const decoded = decodeReceiverChain(encoded); + expect(decoded).toBeDefined(); + // The language's own contract flags, exactly as the resolver pass would + // pass them. TypeScript hoists method return-type bindings out of the class + // body, so the fold needs that flag to find any of them. + return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { + fieldFallback: false, + hoistTypeBindingsToModule: true, + }); +} + +describe('foldReceiverChain', () => { + it('types a single call step through the method return type', () => { + expect(fold('1|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + }); + + it('types a mixed call/field chain, base-first', () => { + expect(fold('1|svc|cgetUser|faddress')).toMatchObject({ + qualifiedName: 'Address', + type: 'Class', + }); + }); + + it('types a step inherited through the MRO', () => { + expect(fold('1|svc|cinherited')).toMatchObject({ qualifiedName: 'User', type: 'Class' }); + }); + + it('returns undefined when a step names no member of the previous class', () => { + expect(fold('1|svc|cgetUser|fnoSuchField')).toBeUndefined(); + }); + + it('returns undefined when the base does not resolve', () => { + expect(fold('1|noSuchLocal|cgetUser')).toBeUndefined(); + }); + + it('does not consult the field fallback', () => { + // `Holder` has no member `save` — only its field's TYPE does. The field + // fallback would walk Holder's fields, find `User.save` and answer: a + // guess, at O(fields x depth x names) per step. The fold declines. + expect(fold('1|holder|csave')).toBeUndefined(); + }); + + it('declines a truncated chain even though the producer refuses to mint one', () => { + expect(fold('1|svc|cgetUser|~')).toBeUndefined(); + }); + + it('declines a construction-selector step and leaves it to the cascade', () => { + // `Factory.new` denotes an INSTANCE of Factory, not the result of looking up + // a member named `new`. The cascade encodes that, together with the + // class-constant test that separates it from an instance method genuinely + // named `new`; a chain step records only a name, so the fold cannot make the + // distinction and must not try. Folding it turned a correct Ruby edge + // (`Factory.new.run` → `Factory#run`) into a wrong one (`Product.run`). + const decoded = decodeReceiverChain('1|svc|cnew'); + expect(decoded).toBeDefined(); + expect( + foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, { + fieldFallback: false, + hoistTypeBindingsToModule: true, + constructionSyntax: { selector: 'new' }, + }), + ).toBeUndefined(); + }); + + it('does NOT climb to module scope when hoistTypeBindingsToModule is off', () => { + // The flag exists because TypeScript hoists a method's return-type binding + // OUT of the class body, so the fold must walk to Module scope to find it. + // Languages that do NOT hoist must not get that walk — climbing anyway is + // how an unrelated module-level binding of the same name gets picked up, + // which is exactly what the flag's own contract warns against. Same chain, + // opposite answers, so this pins the branch rather than the happy path. + expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: true })).toMatchObject({ + qualifiedName: 'User', + }); + expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts b/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts new file mode 100644 index 0000000000..e4d68ff696 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/receiver-chain-wiring.test.ts @@ -0,0 +1,83 @@ +/** + * Which language emitters synthesize `@reference.receiver-chain`. + * + * The helper is self-gating — a non-call match, an absent receiver, or a chain + * with no nameable base all leave the match untouched — so wiring a language + * costs nothing where the shape does not occur. That makes the inventory easy to + * change silently, which is exactly why it is pinned: a new entry means someone + * wired a language without measuring it, and a removed entry means a refactor + * quietly dropped coverage. + * + * Source-level rather than behavioural, because the thing being pinned IS the + * wiring: a behavioural check would pass for a language whose fixture happens to + * contain no chained receiver. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const LANGUAGES_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'src', + 'core', + 'ingestion', + 'languages', +); + +function emittersCallingHelper(): string[] { + const out: string[] = []; + for (const entry of readdirSync(LANGUAGES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const captures = path.join(LANGUAGES_DIR, entry.name, 'captures.ts'); + let source: string; + try { + source = readFileSync(captures, 'utf8'); + } catch { + continue; // no captures.ts (cobol/vue emit through another path) + } + if (source.includes('synthesizeReceiverChainCapture(')) out.push(entry.name); + } + return out.sort(); +} + +describe('receiver-chain capture wiring inventory', () => { + it('is wired into exactly the emitters that build a capture node map', () => { + expect(emittersCallingHelper()).toEqual([ + 'c', + 'cpp', + 'csharp', + 'dart', + 'go', + 'java', + 'javascript', + 'kotlin', + 'php', + 'python', + 'ruby', + 'rust', + 'swift', + 'typescript', + ]); + }); + + it('leaves emitters without a node map unwired', () => { + // These two do not group captures with their originating syntax nodes, so + // there is no receiver node to walk. Wiring them would need an emitter + // change, not a one-line call. + for (const language of ['cobol', 'vue'] as const) { + const captures = path.join(LANGUAGES_DIR, language, 'captures.ts'); + let source = ''; + try { + source = readFileSync(captures, 'utf8'); + } catch { + continue; + } + expect(source, `${language} stays unwired`).not.toContain('synthesizeReceiverChainCapture('); + } + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts new file mode 100644 index 0000000000..7d6ad9dfed --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts @@ -0,0 +1,129 @@ +/** + * #2744 — the summary `impact()`/`context()` read to decide exact vs + * lower-bound. Keyed by member name because a dropped site's callee is + * unknown; see the module doc for why per-target attribution is impossible. + */ + +import { describe, it, expect } from 'vitest'; +import { + MAX_UNRESOLVED_RECEIVER_MEMBERS, + lookupUnresolvedCallCount, + summarizeUnresolvedReceivers, +} from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js'; +import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js'; + +const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 }; + +function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): ResolutionOutcome { + return { + kind: 'suppressed', + reason: 'receiver-unresolved', + candidateIds: [], + phase: 'receiver-bound-calls', + filePath: 'a.py', + name, + range, + siteKind, + }; +} + +describe('summarizeUnresolvedReceivers', () => { + it('returns undefined when nothing was dropped, so a clean repo stores no key', () => { + expect(summarizeUnresolvedReceivers([])).toBeUndefined(); + }); + + it('ignores suppressions that are not receiver-unresolved', () => { + const ambiguous: ResolutionOutcome = { + kind: 'suppressed', + reason: 'member-lookup-ambiguous', + candidateIds: ['a', 'b'], + phase: 'receiver-bound-calls', + filePath: 'a.py', + name: 'save', + range, + }; + const resolved: ResolutionOutcome = { + kind: 'resolved', + targetId: 't', + phase: 'receiver-bound-calls', + filePath: 'a.py', + name: 'save', + range, + }; + expect(summarizeUnresolvedReceivers([ambiguous, resolved])).toBeUndefined(); + }); + + it('counts dropped sites per member name', () => { + expect( + summarizeUnresolvedReceivers([dropped('save'), dropped('save'), dropped('run')]), + ).toMatchObject({ + counts: { save: 2, run: 1 }, + totalSites: 3, + }); + }); + + it('caps the map, keeps the highest counts, and reports what it omitted', () => { + const outcomes: ResolutionOutcome[] = []; + // One name well past the cap that must survive on count alone. + for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest')); + for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) { + outcomes.push(dropped(`member${i}`)); + } + const summary = summarizeUnresolvedReceivers(outcomes); + expect(Object.keys(summary!.counts)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS); + expect(summary!.counts.zzz_hottest).toBe(5); + // The true total always reflects every drop, not just the kept sample. + expect(summary!.totalSites).toBe(MAX_UNRESOLVED_RECEIVER_MEMBERS + 15); + expect(summary!.omittedNames).toBe(11); + }); + + it('orders deterministically so the persisted metadata does not churn', () => { + const a = summarizeUnresolvedReceivers([dropped('b'), dropped('a'), dropped('c')]); + const b = summarizeUnresolvedReceivers([dropped('c'), dropped('b'), dropped('a')]); + expect(Object.keys(a!.counts)).toEqual(Object.keys(b!.counts)); + }); + + it('counts CALL sites only — a property read or write is not a dropped call', () => { + // Case 0's recorder gates on the receiver's punctuation, not on what the + // reference IS, so reads and writes land in the same bucket as lost calls + // (25 of 124 on the fixture corpus). Counting them made the consumer's + // "N call sites invoking X were dropped" literally false. + const summary = summarizeUnresolvedReceivers([ + dropped('save', 'call'), + dropped('name', 'write'), + dropped('kind', 'read'), + ]); + expect(summary).toMatchObject({ counts: { save: 1 }, totalSites: 1 }); + expect(summary?.counts).not.toHaveProperty('name'); + expect(summary?.counts).not.toHaveProperty('kind'); + }); + + it('returns undefined when every drop is a property access', () => { + expect(summarizeUnresolvedReceivers([dropped('name', 'write')])).toBeUndefined(); + }); + + it('does not leak Object.prototype members through the counts lookup', () => { + // `counts` is revived from JSON and carries `Object.prototype`, so a bare + // `counts[symName]` returns a FUNCTION for these names — and `NaN <= 0` is + // false, so a `<= 0` guard lets it through. `impact({target:"constructor"})` + // then reported `epistemic: 'lower-bound'` and interpolated + // `function Object() { [native code] }` as the call count. + const summary = summarizeUnresolvedReceivers([dropped('save')]); + for (const polluted of [ + 'constructor', + 'toString', + 'valueOf', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + '__proto__', + ]) { + expect(lookupUnresolvedCallCount(summary, polluted)).toBeUndefined(); + } + // A genuinely recorded name still reads back. + expect(lookupUnresolvedCallCount(summary, 'save')).toBe(1); + expect(lookupUnresolvedCallCount(summary, 'neverRecorded')).toBeUndefined(); + expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/ts-receiver-chain-capture.test.ts b/gitnexus/test/unit/ts-receiver-chain-capture.test.ts new file mode 100644 index 0000000000..86394456a1 --- /dev/null +++ b/gitnexus/test/unit/ts-receiver-chain-capture.test.ts @@ -0,0 +1,88 @@ +/** + * U5 — TypeScript emits `@reference.receiver-chain` for a receiver that is + * itself an expression. + * + * Capture-level, not resolution-level: nothing consumes the field yet, so these + * assert what is written, and that nothing extra is. + */ + +import { describe, it, expect } from 'vitest'; +import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.js'; + +/** The chain captures produced for a source, keyed by the member being called. */ +function chainsFor(src: string): Record { + const out: Record = {}; + for (const match of emitTsScopeCaptures(src, 'main.ts')) { + const chain = match['@reference.receiver-chain']; + const name = match['@reference.name']; + if (chain === undefined || name === undefined) continue; + out[name.text] = chain.text; + } + return out; +} + +const MODELS = `class Address { save(): void {} } +class User { name = ''; address = new Address(); save(): void {} } +class Service { + getUser(): User { return new User(); } + getTyped(): User { return new User(); } +} +declare const svc: Service; +declare const repos: User[]; +`; + +describe('TypeScript receiver-chain capture', () => { + it('emits a chain for a plain call-chain receiver', () => { + expect(chainsFor(`${MODELS}\nsvc.getUser().save();\n`)).toMatchObject({ + save: '1|svc|cgetUser', + }); + }); + + it('emits a mixed call/field chain base-first', () => { + expect(chainsFor(`${MODELS}\nsvc.getUser().address.save();\n`)).toMatchObject({ + save: '1|svc|cgetUser|faddress', + }); + }); + + it('emits a chain for an optional-chained receiver — one of the shapes that resolves to nothing today', () => { + expect(chainsFor(`${MODELS}\nsvc?.getUser().save();\n`)).toMatchObject({ + save: '1|svc|cgetUser', + }); + }); + + it('emits a chain for an explicit-type-argument receiver', () => { + expect(chainsFor(`${MODELS}\nsvc.getTyped().save();\n`)).toMatchObject({ + save: '1|svc|cgetTyped', + }); + }); + + it('emits no chain for a bare-name receiver — there is nothing to fold', () => { + expect(chainsFor(`${MODELS}\nconst u = new User();\nu.save();\n`)).toEqual({}); + }); + + it('emits no chain for a property read or write receiver (CALL_TAGS gate)', () => { + // Without the gate these pay the walk and the bytes for a field no + // call-site resolver reads. + const chains = chainsFor( + `${MODELS}\nsvc.getUser().name = 'x';\nconst n = svc.getUser().name;\n`, + ); + expect(chains).not.toHaveProperty('name'); + }); + + it('emits a plain string payload, so the capture stays structured-clone safe', () => { + const chains = chainsFor(`${MODELS}\nsvc.getUser().save();\n`); + expect(typeof chains.save).toBe('string'); + }); + + it('emits NO chain past MAX_CHAIN_DEPTH rather than a truncated one', () => { + // `extractMixedChain` reports no base receiver when it stops early, and a + // partial chain is missing exactly the head that decides the final type — + // encoding it would turn an untypeable receiver into a wrongly-typed one. + const deep = `${MODELS} +class C5 { e(): C5 { return this; } f(): void {} } +declare const a: C5; +a.e().e().e().e().f(); +`; + expect(chainsFor(deep)).not.toHaveProperty('f'); + }); +});