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/callable-value-flow/baselines.json b/gitnexus/bench/callable-value-flow/baselines.json index c69d63c834..472cc5cd61 100644 --- a/gitnexus/bench/callable-value-flow/baselines.json +++ b/gitnexus/bench/callable-value-flow/baselines.json @@ -1,6 +1,6 @@ { - "_comment": "Baselines for bench/callable-value-flow/measure.mjs --check (#2693). `fingerprint` is an order-independent sha256 over every (defNodeId -> graphId) pair buildGraphTargetIndex resolves on the synthetic corpus; it is a CORRECTNESS gate, so drift means the callable-value target set moved and must be explained, never re-baselined to make CI green. The two budgets are timing gates and carry deliberate headroom for shared CI runners.", - "fingerprint": "70bebf6a26ff6fc9f231a0933678274b44c4883ddab5e719a61a9c77d6223e51", + "_comment": "Baselines for bench/callable-value-flow/measure.mjs --check (#2693). `fingerprint` is an order-independent sha256 over every (defNodeId -> graphId) pair buildGraphTargetIndex resolves on the synthetic corpus; it is a CORRECTNESS gate, so drift means the callable-value target set moved and must be explained, never re-baselined to make CI green. The fingerprint changed when the synthetic corpus adopted production-shaped def ids; target cardinality remains 4000 (3200 callable-only plus 800 value bindings). The two budgets are timing gates and carry deliberate headroom for shared CI runners.", + "fingerprint": "6599dda7d0ee5942e1995a1dcfb137c312eb8e4690bc82a8bbff429f08bd839d", "scaling_budget": 1.6, "_scaling_note": "(t_large/t_small)/(800/250). ~1.0 is linear; measured 1.14-1.16. The index build is one pass over defs plus map lookups, so a jump toward 3.x means someone made the per-def work depend on corpus size (e.g. a scan inside the loop).", "widening_overhead_budget": 1.9, diff --git a/gitnexus/bench/callable-value-flow/measure.mjs b/gitnexus/bench/callable-value-flow/measure.mjs index d8d7a23730..e0a5f320b2 100644 --- a/gitnexus/bench/callable-value-flow/measure.mjs +++ b/gitnexus/bench/callable-value-flow/measure.mjs @@ -89,7 +89,7 @@ function buildCorpus(fileCount) { return id; }; const addDef = (type, filePath, qualifiedName, line) => { - const nodeId = `${filePath}#${line}:0:${qualifiedName}`; + const nodeId = `def:${filePath}#${line}:0:${type}:${qualifiedName}`; defs.set(nodeId, { nodeId, type, filePath, qualifiedName }); }; 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 e984096f74..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,20 +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": "90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5", + "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 \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.", @@ -56,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", @@ -94,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.", @@ -105,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.", @@ -122,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.", @@ -134,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.", @@ -146,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/rust/module-path.ts b/gitnexus/src/core/ingestion/languages/rust/module-path.ts index 9125392cad..bf81901ddd 100644 --- a/gitnexus/src/core/ingestion/languages/rust/module-path.ts +++ b/gitnexus/src/core/ingestion/languages/rust/module-path.ts @@ -49,10 +49,11 @@ export interface RustModuleIndex { /** Crate-root directories, longest first, so nested crates win. */ readonly crateRoots: readonly string[]; /** - * Every module name that exists anywhere in the workspace, as a flat set of - * single segments. Used only as a fast negative filter: a qualifier whose head - * is not in here cannot name a workspace module, so type-qualified calls - * (`Vec::new()`, `String::from()`) are rejected before any candidate search. + * Every module name derivable from a FILE PATH, as a flat set of single + * segments. Inline `mod x { … }` names are absent by construction — they exist + * in no path — so this is only half of the negative filter's input; the + * resolver unions it with the inline module names it collects from the scope + * model (#2742). */ readonly moduleNames: ReadonlySet; /** @@ -125,12 +126,15 @@ export function buildRustModuleIndex(allFilePaths: ReadonlySet): RustMod * to test is the first non-anchor segment); an all-anchor qualifier (`self::f()`) * names the caller's own module and is always worth trying. */ -export function couldNameAModule(qualifier: readonly string[], index: RustModuleIndex): boolean { +export function couldNameAModule( + qualifier: readonly string[], + knownModuleNames: ReadonlySet, +): boolean { for (const segment of qualifier) { if (segment === 'crate' || segment === '$crate' || segment === 'self' || segment === 'super') { continue; } - return index.moduleNames.has(segment); + return knownModuleNames.has(segment); } return true; } diff --git a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts index 14a26cec05..65d1dd5f4f 100644 --- a/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts +++ b/gitnexus/src/core/ingestion/languages/rust/qualified-call.ts @@ -34,6 +34,7 @@ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import { isOverloadableCallable } from '../../utils/callable-labels.js'; +import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js'; import { @@ -88,15 +89,25 @@ export function resolveRustQualifiedFreeCall( const qualifier = segments.slice(0, -1); if (qualifier.length === 0) return undefined; - // Cheap rejection BEFORE any index work. The capture that carries + // Cheap rejection before the candidate SEARCH. The capture that carries // `rawQualifiedName` matches every `scoped_identifier` callee, so this hook is // reached by `Vec::new()`, `String::from()`, `Self::method()` and every other // type-qualified call — the overwhelming majority of `::` calls in real Rust, - // none of which name a module. Letting those run the full candidate search - // meant each one paid a same-name-bucket scan plus a walk of every module - // scope in the workspace before returning undefined (#2741 review). + // none of which name a module. Letting those through meant each one paid a + // same-name-bucket scan plus a walk of every module scope in the workspace + // before returning undefined (#2741 review). + // + // `passIndexFor` is not free on its FIRST call in a pass — it walks the def + // index once — so this is not "before any index work", as an earlier version of + // this comment claimed. It is memoized per resolution pass on a WeakMap, over + // structures already resident in memory, and every later site here is a set + // lookup. What the filter still buys is skipping the per-site candidate search, + // which is the part that scales with the workspace. const index = moduleIndexFor(allFilePaths); - if (!couldNameAModule(qualifier, index)) return undefined; + const pass = passIndexFor(workspaceIndex, index, scopes); + if (!couldNameAModule(qualifier, pass.knownModuleNames)) { + return undefined; + } const callerModule = callerModuleOf(callerParsed, site.inScope, scopes, index); if (callerModule === undefined) return undefined; @@ -183,7 +194,10 @@ function* candidateModules( // A `mod` declaration — inline or file-backed — emits a `Namespace` def // bound in the declaring scope, so the check is a binding lookup. const head = qualifier[0]; - if (head !== undefined && declaresSubmodule(callerParsed, head, workspaceIndex)) { + if ( + head !== undefined && + declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes) + ) { yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] }; } @@ -213,21 +227,96 @@ function* candidateModules( // 3. Crate-root-relative (`a::b::f()` written from a nested module — 2015 // edition style, and still what a single-file crate looks like). - if (callerModule.segments.length > 0) { + // + // Skipped when the head already names something else in the caller's own + // module. This is the loosest candidate — a guess at a path the caller never + // wrote — and in Rust 2018 a bare first segment resolves in the caller's + // module, not at the crate root, so a local binding for it settles the + // question. Without the check, a crate-root `mod` whose name matches an + // imported TYPE captured the call: + // + // // src/lib.rs + // pub mod Buffer { pub fn with_capacity() -> usize { 111 } } + // // src/b.rs + // use crate::c::Buffer; // the real target, in c.rs + // pub fn call() -> usize { Buffer::with_capacity() } + // + // which yielded `CALLS b::call -> lib.rs:Buffer.with_capacity`, an edge to a + // callee the source never names. The base emitted no edge at all, and per the + // doctrine quoted in `ids.ts` that is the correct failure direction: a missing + // edge is recoverable, a fabricated caller silently misleads `impact`. + if (callerModule.segments.length > 0 && !headBoundLocally(callerParsed, head, scopes)) { yield { crateRoot: callerModule.crateRoot, segments: [...qualifier] }; } } /** - * Does the calling file declare `name` as a submodule (`mod name;` or - * `mod name { … }`)? Both forms emit a `Namespace` def bound locally in the - * declaring scope, so this is a binding lookup rather than a filesystem probe. + * Is `name` bound in the caller's own module to anything that is NOT a module? + * + * A `use crate::c::Buffer;` binds the TYPE `Buffer`, so a bare `Buffer::…` path in + * that file resolves through the import — never to a same-named module elsewhere in + * the crate. Module bindings are excluded so the legitimate `use crate::tools;` + * case, which candidate 2 already handles, is not double-counted here. + */ +function headBoundLocally( + callerParsed: ParsedFile, + name: string | undefined, + scopes: ScopeResolutionIndexes, +): boolean { + if (name === undefined) return false; + // Read through `lookupBindingsAt`, not the raw `Scope.bindings` map: a `use` + // binding is finalize OUTPUT and is absent from the scope's own local table, so + // the direct read saw nothing for exactly the imported-type case this guards + // (contract I8 in `contract/scope-resolver.ts` requires this channel anyway). + for (const ref of lookupBindingsAt(callerParsed.moduleScope, name, scopes)) { + if (ref.def.type !== 'Namespace') return true; + } + return false; +} + +/** Tail segment of a dot-joined qualified name (`outer.tools` → `tools`). */ +function tailSegment(qualifiedName: string): string { + return qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1); +} + +/** + * Does the caller's own module declare `name` as a submodule (`mod name;` or + * `mod name { … }`)? + * + * Answered against the set of module paths the workspace actually DECLARES, so it + * holds at any nesting depth. The previous binding lookup went through + * `moduleScopeByFile`, which maps a file to its root `Module` scope only: a `mod` + * nested inside an inline `mod` binds in that parent module's scope and was + * therefore invisible. `mod outer { mod tools { … } fn dispatch() { tools::dispatch() } }` + * skipped this candidate entirely and fell through to the lexical tier, which + * reinstated the very #2730 self-loop this hook exists to prevent. + * + * Still a declaration check, not a filesystem probe: the set is built from + * `Namespace` defs, so an undeclared (or `cfg`-gated) file sitting on disk + * contributes nothing and cannot outrank a real `use` binding (#2741 review). */ function declaresSubmodule( callerParsed: ParsedFile, + callerModule: RustModule, name: string, workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, + scopes: ScopeResolutionIndexes, ): boolean { + // Inline modules at any depth, keyed by full path. + const inline = passIndexFor(workspaceIndex, index, scopes).inlineModuleKeys; + if ( + inline.has( + moduleKey({ crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, name] }), + ) + ) { + return true; + } + + // File-backed submodules (`mod tools;`) still go through the declaring scope's + // binding table. A `Namespace` def bound locally is the DECLARATION; probing the + // file set instead would let an undeclared or `cfg`-gated file on disk outrank a + // real `use` binding (#2741 review). const moduleScope = workspaceIndex.moduleScopeByFile.get(callerParsed.filePath); if (moduleScope === undefined) return false; for (const ref of moduleScope.bindings.get(name) ?? []) { @@ -345,7 +434,7 @@ function findReexportedMember( let unique: SymbolDefinition | undefined; let count = 0; - for (const moduleScope of moduleScopesFor(targetModule, workspaceIndex, index)) { + for (const moduleScope of moduleScopesFor(targetModule, workspaceIndex, index, scopes)) { for (const edge of scopes.imports.get(moduleScope.id) ?? []) { if (edge.localName !== name) continue; // Only a `pub use` re-exports. A private `use` (`named`) makes the name @@ -380,35 +469,106 @@ function findReexportedMember( * * Keyed on the `WorkspaceResolutionIndex` identity, which is rebuilt per pass. */ -const MODULE_SCOPE_CACHE = new WeakMap< - WorkspaceResolutionIndex, - ReadonlyMap ->(); +interface PassModuleIndex { + /** Module identity key → the file-module scopes realising it. */ + readonly scopesByModule: ReadonlyMap; + /** + * Every module name resolution may legitimately see: the file-derived names + * from the path index, UNIONED with inline `mod x { … }` names, which exist in + * no file path and would otherwise be rejected by the negative filter before + * any candidate ran (#2742). + */ + readonly knownModuleNames: ReadonlySet; + /** + * `moduleKey` of every INLINE module path in the workspace, at any depth. + * Distinct from `knownModuleNames`, a flat name set used only as a negative + * filter: this one answers "is `::` a real inline + * submodule", which the name set cannot (two unrelated modules share a tail). + */ + readonly inlineModuleKeys: ReadonlySet; +} + +const MODULE_SCOPE_CACHE = new WeakMap(); function moduleKey(module: RustModule): string { return `${module.crateRoot}\u0000${module.segments.join('::')}`; } +function passIndexFor( + workspaceIndex: WorkspaceResolutionIndex, + index: RustModuleIndex, + scopes: ScopeResolutionIndexes, +): PassModuleIndex { + let pass = MODULE_SCOPE_CACHE.get(workspaceIndex); + if (pass === undefined) { + const scopesByModule = new Map(); + const knownModuleNames = new Set(index.moduleNames); + + // Inline `mod x { … }` never appears in a file path, so its name exists only + // as a `Namespace` def. Read from the def index rather than from module-scope + // bindings: `moduleScopeByFile` holds each file's ROOT `Module` scope only, so + // a binding walk saw depth-1 inline modules and missed every nested one — + // `mod outer { mod tools { … } }` left `tools` out of the set, and this gate + // then rejected `tools::dispatch()` before any module walk could resolve it. + // + // One pass over the defs, memoized per resolution pass on the WeakMap above, + // over an already-resident in-memory index — the same order of work as the + // binding walk it replaces, and it subsumes it (a `mod` declaration at any + // depth is a `Namespace` def). + // + // The same pass records every INLINE module path, which is what + // `declaresSubmodule` needs at depth > 1. + // + // Derived from the MEMBERS, not from the `Namespace` defs: a `mod` def carries + // no nesting information of its own (`mod outer { mod tools { … } }` gives the + // inner def `qualifiedName: 'tools'` and NO `namespacePrefix`), whereas every + // def inside it is stamped `outer.tools` by `tagNamespacePrefixes`. A + // `Namespace` scope also owns its OWN def rather than its children's, so + // walking scopes cannot answer this either — the `mod outer` scope lists + // `outer`, never `tools`. + // + // Restricted to non-empty prefixes on purpose: those come from `mod` blocks in + // source, so this stays a DECLARATION check. Including file-derived modules + // here would let an undeclared (or `cfg`-gated) file on disk outrank a real + // `use` binding — the #2741 review regression. File-backed submodules keep + // going through the binding check in `declaresSubmodule`. + // + // A module containing no defs at all is absent, which is harmless: it has no + // member for a qualified call to resolve to. + const inlineModuleKeys = new Set(); + for (const def of scopes.defs.byId.values()) { + if (def.type === 'Namespace' && def.qualifiedName !== undefined) { + knownModuleNames.add(tailSegment(def.qualifiedName)); + } + if (def.namespacePrefix === undefined || def.namespacePrefix === '') continue; + const declaringModule = moduleOfDef(def.filePath, def.namespacePrefix, index); + if (declaringModule !== undefined) inlineModuleKeys.add(moduleKey(declaringModule)); + } + + for (const [filePath, moduleScope] of workspaceIndex.moduleScopeByFile) { + const fileModule = moduleOfFile(filePath, index); + if (fileModule === undefined) continue; + const key = moduleKey(fileModule); + const bucket = scopesByModule.get(key); + if (bucket === undefined) scopesByModule.set(key, [moduleScope]); + else bucket.push(moduleScope); + } + pass = { scopesByModule, knownModuleNames, inlineModuleKeys }; + MODULE_SCOPE_CACHE.set(workspaceIndex, pass); + } + return pass; +} + function moduleScopesFor( targetModule: RustModule, workspaceIndex: WorkspaceResolutionIndex, index: RustModuleIndex, + scopes: ScopeResolutionIndexes, ): readonly Scope[] { - let byModule = MODULE_SCOPE_CACHE.get(workspaceIndex); - if (byModule === undefined) { - const built = new Map(); - for (const [filePath, moduleScope] of workspaceIndex.moduleScopeByFile) { - const fileModule = moduleOfFile(filePath, index); - if (fileModule === undefined) continue; - const key = moduleKey(fileModule); - const bucket = built.get(key); - if (bucket === undefined) built.set(key, [moduleScope]); - else bucket.push(moduleScope); - } - byModule = built; - MODULE_SCOPE_CACHE.set(workspaceIndex, byModule); - } - return byModule.get(moduleKey(targetModule)) ?? EMPTY_SCOPES; + return ( + passIndexFor(workspaceIndex, index, scopes).scopesByModule.get(moduleKey(targetModule)) ?? + EMPTY_SCOPES + ); } const EMPTY_SCOPES: readonly Scope[] = Object.freeze([]); 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/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 42dc67acc8..c4e1383c51 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -34,6 +34,7 @@ import { } from '../../utils/callable-labels.js'; import { templateConstraintsIdTag } from '../../utils/template-arguments.js'; import { parameterShapeIdTag } from '../../utils/method-props.js'; +import { definitionIdPosition } from '../utils/definition-id.js'; /** * Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the * source ("caller"). A Variable / Property can be the TARGET of an @@ -99,9 +100,9 @@ function scopeIsCallableBody( range: { startLine: number; startCol: number }, def: SymbolDefinition, ): boolean { - const m = def.nodeId.match(/#(\d+):(\d+):/); - if (m === null) return false; - return Number(m[1]) === range.startLine && Number(m[2]) === range.startCol; + const position = definitionIdPosition(def.nodeId, def.filePath); + if (position === undefined) return false; + return position.line === range.startLine && position.column === range.startCol; } /** Pick the callable that owns `atRange` when multiple overloads share a class scope. */ @@ -165,12 +166,8 @@ function pickCallerCallableDef( * Extract the 1-based declaration line from a scope-resolution def id. * Shape: `def:#::<...>`; `undefined` when it doesn't match. */ -function defStartLine(nodeId: string | undefined): number | undefined { - if (nodeId === undefined) return undefined; - const m = nodeId.match(/#(\d+):(\d+):/); - if (m === null) return undefined; - const line = Number(m[1]); - return Number.isFinite(line) ? line : undefined; +function defStartLine(nodeId: string | undefined, filePath: string): number | undefined { + return definitionIdPosition(nodeId, filePath)?.line; } /** @@ -221,7 +218,7 @@ export function resolveDefGraphId( // without either side having to model the scope chain. Node ids are // 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two // callables on one line) falls through to the name-based keys below. - const line = defStartLine(def.nodeId); + const line = defStartLine(def.nodeId, filePath); if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) { const simple = simpleNameOf(qn); const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple)); @@ -240,6 +237,36 @@ export function resolveDefGraphId( return undefined; } } + // Name forms to try for every keyed lookup below, most specific first. + // + // #1982/#2742: some scope-extractors qualify a def by its enclosing CLASS + // chain (`A.Inner`) or leave it a bare tail, while the structure-phase node is + // keyed by the full path (`NS.A.Inner`, or a Rust `mod` chain). The + // namespace-prefixed form is therefore the more specific one and must be tried + // first — the bare form happily matches a same-named item at a DIFFERENT + // namespace depth in the same file and returns it before any retry is reached + // (#2742: a call into `mod inner { fn dispatch }` bound to the crate-root + // `fn dispatch` and rendered as a self-loop). + // + // Applied to the TAGGED keys too, not just the plain one. Previously only the + // plain key had the prefixed retry, so for a namespace/mod-qualified def every + // tagged key — constraints, parameter types, parameter shape, arity, template + // arguments — composed from the bare tail and simply missed the node + // `node-lookup.ts` had registered under the qualified name. Those keys exist to + // separate overloads, so leaving them dead meant a mod-scoped overload set + // depended on whichever later key happened to catch it. + const defType = def.type; + const nsPrefix = def.namespacePrefix; + const nameForms = + nsPrefix !== undefined && nsPrefix.length > 0 ? [`${nsPrefix}.${qn}`, qn] : [qn]; + const lookupTagged = (tag: string): string | undefined => { + for (const form of nameForms) { + const hit = nodeLookup.get(qualifiedKey(filePath, defType, `${form}${tag}`)); + if (hit !== undefined) return hit; + } + return undefined; + }; + // SFINAE / `requires`-clause disambiguation (issue #1579) — try the // constraint-fingerprinted key FIRST. Two function-template overloads // with identical `parameterTypes` but mutually-exclusive SFINAE @@ -250,12 +277,7 @@ export function resolveDefGraphId( (def.type === 'Function' || def.type === 'Method') && def.templateConstraints !== undefined ) { - const cKey = qualifiedKey( - filePath, - def.type, - `${qn}${templateConstraintsIdTag(def.templateConstraints)}`, - ); - const cHit = nodeLookup.get(cKey); + const cHit = lookupTagged(templateConstraintsIdTag(def.templateConstraints)); if (cHit !== undefined) return cHit; } if ( @@ -265,8 +287,7 @@ export function resolveDefGraphId( ) { const shapeTag = parameterShapeIdTag(def.parameterTypes, def.parameterTypeClasses); if (shapeTag !== '') { - const shapeKey = qualifiedKey(filePath, def.type, `${qn}${shapeTag}`); - const shapeHit = nodeLookup.get(shapeKey); + const shapeHit = lookupTagged(shapeTag); if (shapeHit !== undefined) return shapeHit; } } @@ -282,8 +303,7 @@ export function resolveDefGraphId( def.parameterTypes !== undefined && def.parameterTypes.length > 0 ) { - const pKey = qualifiedKey(filePath, def.type, `${qn}~${def.parameterTypes.join(',')}`); - const pHit = nodeLookup.get(pKey); + const pHit = lookupTagged(`~${def.parameterTypes.join(',')}`); if (pHit !== undefined) return pHit; } // Arity-disambiguating key (see node-lookup.ts): route a same-name overload @@ -291,8 +311,7 @@ export function resolveDefGraphId( // zero-arg overload (no parameterTypes) that would otherwise collapse onto a // sibling overload via the source-order-dependent qualified key. if (isOverloadableCallable(def.type) && def.parameterCount !== undefined) { - const aKey = qualifiedKey(filePath, def.type, `${qn}#${def.parameterCount}`); - const aHit = nodeLookup.get(aKey); + const aHit = lookupTagged(`#${def.parameterCount}`); if (aHit !== undefined) return aHit; } if ( @@ -304,23 +323,11 @@ export function resolveDefGraphId( def.templateArguments !== undefined && def.templateArguments.length > 0 ) { - const tKey = qualifiedKey(filePath, def.type, `${qn}~${def.templateArguments.join(',')}`); - const tHit = nodeLookup.get(tKey); + const tHit = lookupTagged(`~${def.templateArguments.join(',')}`); if (tHit !== undefined) return tHit; } - const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn)); + const qualifiedHit = lookupTagged(''); if (qualifiedHit !== undefined) return qualifiedHit; - // #1982: some scope-extractors qualify a type by its enclosing CLASS chain - // (`A.Inner`) but drop the enclosing NAMESPACE, while the structure-phase - // node is keyed by the full path (`NS.A.Inner`). Retry with the - // namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the - // simple-name fallback, so same-tail nested bases don't collapse across - // sibling namespace members via `simpleKey`. - const nsPrefix = def.namespacePrefix; - if (nsPrefix !== undefined && nsPrefix.length > 0) { - const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`)); - if (nsHit !== undefined) return nsHit; - } } const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); return nodeLookup.get(simpleKey(filePath, simpleName)); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts index f0240a2d3f..1f39bf3045 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/callable-value-flow.ts @@ -26,6 +26,7 @@ import { simpleQualifiedName, } from '../graph-bridge/ids.js'; import { resolveInheritanceBaseInScope } from '../scope/walkers.js'; +import { definitionIdPosition } from '../utils/definition-id.js'; import { narrowOverloadCandidates } from './overload-narrowing.js'; export const MAX_CALLABLE_VALUE_TARGETS = 32; @@ -815,7 +816,7 @@ const withoutSigil = (name: string): string => name.replace(/^[$@]+/, ''); */ function valueBindingPositionKey(def: SymbolDefinition): string | undefined { if (!VALUE_BINDING_DEF_TYPES.has(def.type)) return undefined; - const line = def.nodeId.match(/#(\d+):(\d+):/)?.[1]; + const line = definitionIdPosition(def.nodeId, def.filePath)?.line; const name = simpleQualifiedName(def); if (line === undefined || name === undefined) return undefined; return `${def.filePath}\0${line}\0${withoutSigil(name)}`; @@ -921,8 +922,10 @@ function canonicalizeCallableDeclarations( } function definitionPosition(def: SymbolDefinition): string | undefined { - const match = def.nodeId.match(/#(\d+):(\d+):/); - return match === null ? undefined : `${def.filePath}\0${match[1]}\0${match[2]}`; + const position = definitionIdPosition(def.nodeId, def.filePath); + return position === undefined + ? undefined + : `${def.filePath}\0${position.line}\0${position.column}`; } function declarationSignatureCompatible( @@ -1003,7 +1006,7 @@ function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes } function definitionAnchorKey(def: SymbolDefinition): string | undefined { - const line = def.nodeId.match(/#(\d+):(\d+):/)?.[1]; + const line = definitionIdPosition(def.nodeId, def.filePath)?.line; const name = simpleName(def.qualifiedName); if (line === undefined || name === undefined) return undefined; return `${def.filePath}\0${def.type}\0${line}\0${name}`; @@ -1437,8 +1440,8 @@ function dedupeDefinitions(defs: readonly SymbolDefinition[]): SymbolDefinition[ } function definitionStartsAt(def: SymbolDefinition, line: number, column: number): boolean { - const match = def.nodeId.match(/#(\d+):(\d+):/); - return match !== null && Number(match[1]) === line && Number(match[2]) === column; + const position = definitionIdPosition(def.nodeId, def.filePath); + return position !== undefined && position.line === line && position.column === column; } function expandMemberTargets( 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/scope-resolution/utils/definition-id.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/definition-id.ts new file mode 100644 index 0000000000..93ee50013b --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/definition-id.ts @@ -0,0 +1,24 @@ +export interface DefinitionIdPosition { + readonly line: number; + readonly column: number; +} + +/** + * Extract coordinates from `def:#:::`. + * + * File paths are embedded verbatim and may themselves contain fragments such + * as `#12:34:`, while names may themselves contain `#` (TypeScript private + * members). Anchor on the known file path so neither side can be mistaken for + * the declaration separator. + */ +export function definitionIdPosition( + nodeId: string | undefined, + filePath: string, +): DefinitionIdPosition | undefined { + if (nodeId === undefined) return undefined; + const prefix = `def:${filePath}#`; + if (!nodeId.startsWith(prefix)) return undefined; + const match = /^(\d+):(\d+):/.exec(nodeId.slice(prefix.length)); + if (match === null) return undefined; + return { line: Number(match[1]), column: Number(match[2]) }; +} diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index cb38f19a25..a1832335e6 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -14,24 +14,36 @@ import { isOverloadableCallable } from './callable-labels.js'; export type SyntaxNode = Parser.SyntaxNode; /** - * Qualify a Rust inherent-impl target (`impl Inner { ... }`) by its enclosing - * `mod_item` scope, so a bare same-tail target nested under different modules - * resolves to a DISTINCT path (`outer.Inner` vs `other.Inner`) — the #1982 - * follow-up to #1975. Walks `mod_item` ancestors (outermost → innermost) and - * joins them with the normalized raw target via the shared `splitQualifiedName`. - * A top-level `impl Inner` (no enclosing mod) returns the bare target unchanged. - * Keyed purely on tree-sitter node types (no language name), matching the - * inherent-impl branch in `findEnclosingClassInfo`; the caller restricts this to - * UNSCOPED targets (`type_identifier`) so a SCOPED `impl a::Inner` keeps its full - * raw text (#1975). The Impl-node materialization in parsing-processor / - * parse-worker mirrors this so the owner edge and node id agree byte-for-byte. + * Qualify a name by its enclosing `mod_item` scope, so two same-tail items nested + * under different modules get DISTINCT paths (`outer.Inner` vs `other.Inner`). + * Walks `mod_item` ancestors (outermost → innermost) and joins them with the + * normalized raw text via the shared `splitQualifiedName`. Keyed purely on + * tree-sitter node types (no language name), so it is a no-op for every grammar + * without such a node. + * + * TWO callers, with different contracts — read both before widening either: + * + * 1. The inherent-impl target (`impl Inner { … }`) — the #1982 follow-up to + * #1975, reachable through the {@link qualifyRustImplTargetByModScope} alias + * and mirrored by the inherent-impl branch in `findEnclosingClassInfo` so the + * owner edge and the node id agree byte-for-byte. That caller gates on an + * UNSCOPED `type_identifier`, which is what keeps a SCOPED `impl a::Inner` on + * its full raw text. + * + * 2. Free items, for module node identity (#2742). That caller gates on the node + * being on neither side of an owner edge (`MEMBER_OWNER_NODE_TYPES`, + * `enclosingClassInfo`) and not inside a callable, because only the id moves + * here — every owner-edge anchor is minted separately and does not follow. + * + * A name with NO enclosing `mod` is returned verbatim, never normalized: rewriting + * a scoped target's separator (`a::Inner` → `a.Inner`) would move its node id away + * from the id its owner edge emits, which is how caller 2 first broke caller 1's + * #1975 contract. Splitting an unscoped name has always been the identity, so + * caller 1 is unaffected either way. */ -export const qualifyRustImplTargetByModScope = ( - implNode: SyntaxNode, - rawTargetText: string, -): string => { +export const qualifyByEnclosingModScope = (node: SyntaxNode, rawText: string): string => { const modSegments: string[] = []; - let current = implNode.parent; + let current = node.parent; while (current) { if (current.type === 'mod_item') { const nameNode = @@ -41,9 +53,22 @@ export const qualifyRustImplTargetByModScope = ( } current = current.parent; } - return [...modSegments, ...splitQualifiedName(rawTargetText)].filter(Boolean).join('.'); + // No enclosing `mod`: return the raw text UNTOUCHED. Normalizing here would + // rewrite a scoped target's separator (`a::Inner` -> `a.Inner`) and silently + // move its node id away from the one the owner edge emits, which is how this + // helper first broke the #1975 scoped-impl ownership when it was generalized + // beyond unscoped targets. Callers that pass an unscoped name are unaffected, + // since splitting one has always been the identity. + if (modSegments.length === 0) return rawText; + return [...modSegments, ...splitQualifiedName(rawText)].filter(Boolean).join('.'); }; +/** + * Impl-target alias of {@link qualifyByEnclosingModScope}, kept as its own name + * because the caller gates it on UNSCOPED targets (see the contract above). + */ +export const qualifyRustImplTargetByModScope = qualifyByEnclosingModScope; + /** * #1991: scope-label predicate that single-sources the `nodeLabel === 'Trait'` * checks in parsing-processor.ts / parse-worker.ts. A Ruby `module` maps to the @@ -338,6 +363,37 @@ export const CLASS_CONTAINER_TYPES = new Set([ 'interface_type', ]); +/** + * Node types whose OWN node id must not be re-keyed by an enclosing-scope + * qualifier (see {@link qualifyByEnclosingModScope}) unless the owner-edge + * anchor moves in the same change. + * + * These are the containers a member can be declared inside. Their members' + * `HAS_METHOD` / `HAS_PROPERTY` edges anchor on `findEnclosingClassInfo().classId`, + * which is minted from the container's bare `nameNode.text` further down this + * file and only follows a qualified shape when the provider opts in via + * `classExtractor.qualifiedNodeId`. So qualifying a container's id alone points + * every one of its member edges at a node that does not exist — the edges are + * dropped at COPY time and the container silently loses all its members. + * + * Derived from `CLASS_CONTAINER_TYPES` on purpose: that set is already the + * single source of "this node type owns member edges", carries the INVARIANT + * note above binding it to `CONTAINER_TYPE_TO_LABEL`, and so a language adding + * a container cannot gain a mismatched id shape here without also failing that + * invariant. Keyed purely on tree-sitter node types — no language names. + */ +export const MEMBER_OWNER_NODE_TYPES: ReadonlySet = new Set([ + ...CLASS_CONTAINER_TYPES, + // Rust `union_item` owns a `field_declaration_list` exactly as `struct_item` + // does, and its fields ARE captured as `Property`, but it is absent from + // `CLASS_CONTAINER_TYPES`, so `findEnclosingClassInfo` does not recognize it as + // an owner: union fields carry no `HAS_PROPERTY` edge at all and therefore + // cannot dangle. Listed here so the union's own id keeps the same shape as the + // struct beside it, and so making it a real owner later starts from a + // consistent id rather than having to move one. + 'union_item', +]); + export const CONTAINER_TYPE_TO_LABEL: Record = { class_declaration: 'Class', abstract_class_declaration: 'Class', 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/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index cb4fba48bd..f467ef8529 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -96,6 +96,8 @@ import { isSuppressedConcreteTypedefDuplicate, isValueDefinitionLabel, isQualifiableScopeLabel, + MEMBER_OWNER_NODE_TYPES, + qualifyByEnclosingModScope, qualifyRustImplTargetByModScope, CLASS_CONTAINER_TYPES, PARAMETER_LIST_NODE_TYPES, @@ -2396,7 +2398,7 @@ const processFileGroup = ( ? qualifyRustImplTargetByModScope(definitionNode, nodeName) : undefined; - const qualifiedName = + const qualifiedNameBeforeModScope = rustImplQualifiedName !== undefined ? rustImplQualifiedName : // #1991: LOCKSTEP — include Trait so a Ruby mixin module's qualified @@ -2417,6 +2419,63 @@ const processFileGroup = ( ? `${objectLiteralOwnerInfo.ownerName}.${nodeName}` : nodeName; + // #2742: qualify by the enclosing `mod` chain, so two same-named items at + // different module depths in one file are DISTINCT nodes. Without this, + // `mod inner { fn dispatch }` and a crate-root `fn dispatch` both keyed + // `Function::dispatch`, first-wins — so a correctly resolved call + // into the inline module still rendered as a self-loop, and `impact` + // reported the real callee as unreached. + // + // Keyed purely on the `mod_item` node type, exactly as the impl-target + // qualifier above (#1982) already is, so it is a no-op for every language + // whose grammar has no such node. The impl branch already applied it and + // is left alone rather than qualified twice. + // + // Scoped to items that sit on NEITHER side of an owner edge, because only + // the id is re-keyed here — the anchor is minted independently by + // `findEnclosingClassInfo` and does not move with it: + // + // - `!enclosingClassInfo` excludes the MEMBER side. A method already + // carries its owner's name (`Inner.method`), and for an unscoped + // inherent impl that owner is mod-scoped by the impl qualifier above, + // so qualifying the member again would break the byte-for-byte + // agreement #1975/#1982 established. + // - `!MEMBER_OWNER_NODE_TYPES.has(...)` excludes the OWNER side. A + // `struct` / `trait` / `enum` / `impl` declared directly in a `mod` has + // no enclosing class, so the member-side guard alone let it through + // while its own anchor stayed bare — `mod engine { struct Config { … } }` + // minted `Struct::engine.Config` against a `HAS_PROPERTY` edge + // anchored on `Struct::Config`, dangling every field. The same + // gap put `impl a::Inner` inside a `mod` back on the #1975 rake this + // helper's own docblock warns about: the impl branch above deliberately + // fires only for an UNSCOPED `type_identifier`, and this gate was + // picking up the scoped targets it had just excluded. + // - `nestedCallablePrefix === undefined` excludes an item inside a + // CALLABLE. `fn wrapper() { mod helper { fn dispatch } }` composed the + // mod segment outermost — `helper.wrapper.dispatch@2:8` — inverting the + // real nesting, because the mod prefix is prepended to a name the + // enclosing-callable pass has already qualified. Nothing dangled (the + // `@line:col` suffix keeps such ids unique on its own, which is also why + // the mod segment adds no identity here), but the path read as a lie + // about the source. Skipping is the honest answer; reordering would mean + // interleaving two qualifier passes for a shape that only ever produces + // already-unique ids. + // + // Same-named members on same-named types in sibling modules therefore + // still collapse, as do the containers themselves — unchanged from before + // this fix, and owned by the owner edge rather than worked around here. + const qualifiesByEnclosingModScope = + rustImplQualifiedName === undefined && + definitionNode !== undefined && + !MEMBER_OWNER_NODE_TYPES.has(definitionNode.type) && + nestedCallablePrefix === undefined && + !enclosingClassInfo && + objectLiteralOwnerInfo?.ownerName === undefined; + const qualifiedName = + qualifiesByEnclosingModScope && definitionNode !== undefined + ? qualifyByEnclosingModScope(definitionNode, qualifiedNameBeforeModScope) + : qualifiedNameBeforeModScope; + // Extract method metadata BEFORE generating node ID — parameterCount is needed // to disambiguate overloaded methods via # suffix in the ID. let declaredType: string | 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 6ebc78e675..3612bc9dd1 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,6 +55,9 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. +// v32: Rust items are qualified by their enclosing `mod` chain (#2742). The +// qualified name is computed in the parse worker, so a warm cache replays the +// old unqualified ids verbatim and the collapse persists. // v31: Rust `mod_item` gained `@declaration.namespace` and scoped call sites // gained `@reference.qualified-name` (#2730). Both are PARSE-TIME captures, so a // warm cache replays the old capture set verbatim: `rawQualifiedName` comes back @@ -125,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 = 31; +// 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 481de20356..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,8 +602,28 @@ 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` + * first-wins. Node IDS CHANGE for every Rust item inside any `mod` block — + * `#[cfg(test)] mod tests` makes that close to every Rust repo — so a pre-v25 + * index holds ids an incremental top-up cannot reconcile and would simply + * strand. Force a full re-analyze. */ -export const INCREMENTAL_SCHEMA_VERSION = 24; +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/rust-2742-mod-members/src/a.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/a.rs new file mode 100644 index 0000000000..aa84f78cc2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/a.rs @@ -0,0 +1 @@ +pub struct Inner; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/main.rs new file mode 100644 index 0000000000..2136e89441 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/main.rs @@ -0,0 +1,45 @@ +mod a; + +// Containers declared DIRECTLY inside an inline `mod`. Each owns member edges +// (HAS_METHOD / HAS_PROPERTY) whose anchor is minted by findEnclosingClassInfo +// from the container's bare name, independently of the container's own node id. +// Re-keying only the id points every member edge at a node that does not exist. +pub mod engine { + pub struct Config { + pub retries: usize, + } + + pub trait Runner { + fn go(&self) -> usize; + } + + impl Runner for Config { + fn go(&self) -> usize { + self.retries + } + } + + // Fields are captured as Property, but `union_item` is not a recognized + // owner, so they carry no HAS_PROPERTY edge in any build. + pub union Slot { + pub int: u32, + pub float: f32, + } +} + +// A SCOPED inherent impl inside a `mod`. The unscoped-impl qualifier is gated on +// a bare `type_identifier`, so this target keeps its full raw text (#1975). +pub mod outer { + use crate::a; + + impl a::Inner { + pub fn helper(&self) -> usize { + 0 + } + } +} + +fn main() { + let c = engine::Config { retries: 1 }; + c.go(); +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2742-nested-mods/src/main.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-nested-mods/src/main.rs new file mode 100644 index 0000000000..cb160d7c07 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-nested-mods/src/main.rs @@ -0,0 +1,55 @@ +// A `mod` nested inside an inline `mod`. The declaring scope is the PARENT +// module's own scope, which `moduleScopeByFile` does not hold — it maps a file to +// its root `Module` scope only. A binding lookup there saw depth-1 inline modules +// and missed every nested one, so `tools::dispatch()` below fell through to the +// lexical tier and bound to the enclosing same-name `dispatch` — the #2730 +// self-loop, one level deeper than the fixture that first caught it. +pub mod outer { + pub mod tools { + pub fn dispatch() -> usize { + 1 + } + } + + pub fn dispatch() -> usize { + tools::dispatch() + } +} + +// Three levels, to prove the fix is depth-agnostic rather than depth-2 special-cased. +pub mod a { + pub mod b { + pub mod c { + pub fn deep() -> usize { + 2 + } + } + + pub fn mid() -> usize { + c::deep() + } + } + + pub fn top() -> usize { + b::mid() + } +} + +fn main() { + outer::dispatch(); + a::top(); +} + +// A `mod` declared inside a FUNCTION. The enclosing-callable pass already +// position-qualifies these ids, so prepending the mod segment put it OUTSIDE the +// callable — `helper.wrapper.dispatch@L:C` — inverting the real nesting. The +// `@line:col` suffix makes such ids unique on its own, so the mod segment adds no +// identity and is skipped rather than reordered. +pub fn wrapper() -> usize { + mod helper { + pub fn dispatch() -> usize { + 3 + } + } + helper::dispatch() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/b.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/b.rs new file mode 100644 index 0000000000..1e0b2bed87 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/b.rs @@ -0,0 +1,7 @@ +// `Buffer` here is the TYPE from `c`, not the crate-root module of the same name. +// In Rust 2018 a bare first segment resolves in this module, so the import wins. +use crate::c::Buffer; + +pub fn call() -> usize { + Buffer::with_capacity() +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/c.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/c.rs new file mode 100644 index 0000000000..ccc8b6cb0a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/c.rs @@ -0,0 +1,9 @@ +pub struct Buffer { + pub v: usize, +} + +impl Buffer { + pub fn with_capacity() -> usize { + 222 + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/lib.rs new file mode 100644 index 0000000000..319746ab02 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/lib.rs @@ -0,0 +1,10 @@ +// A crate-root module whose name matches a TYPE imported elsewhere in the crate. +// Nothing in `b.rs` names this module, so no call from `b.rs` may reach it. +pub mod Buffer { + pub fn with_capacity() -> usize { + 111 + } +} + +pub mod b; +pub mod c; 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 d9bc4901a6..4856f886fa 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -87,6 +87,30 @@ "captureGroups": 9, "digest": "fdbdd68d6920a9d7861562c562acc4fbd4580836467cad1ce8428c57a11d0d19" }, + "rust-2742-mod-members/src/a.rs": { + "captureGroups": 3, + "digest": "cf9bb9565b1d9ab7a7fcb287240198d27d41eeeb4d0953818074e4654bc831ca" + }, + "rust-2742-mod-members/src/main.rs": { + "captureGroups": 43, + "digest": "59bf9f7c931cbba2c58b581e15a79a4d7a59c3088a9b62eda940298f7a150088" + }, + "rust-2742-nested-mods/src/main.rs": { + "captureGroups": 50, + "digest": "505ff37c8c8959076865a05e5237b0aaad4d00a2a88f9676ce4b5d694a25d48b" + }, + "rust-2742-type-vs-module/src/b.rs": { + "captureGroups": 7, + "digest": "1066fc99e6a4570fba07c641942a536145e82ff71e87c3992828ec8f1f11048a" + }, + "rust-2742-type-vs-module/src/c.rs": { + "captureGroups": 10, + "digest": "bb98c27950620193afb1593213d476bde0f9c8a12581656cf81146603d95ec22" + }, + "rust-2742-type-vs-module/src/lib.rs": { + "captureGroups": 11, + "digest": "8bb48d12a411f6918fc5dfb726434e9f5b2888f5b0bea6f7615f274b0f18ce5d" + }, "rust-abstract-dispatch/src/lib.rs": { "captureGroups": 34, "digest": "973679363065ecd54c4e5128a9fab214ea27eca24f0f079c63a3c6f285e678b0" @@ -173,7 +197,7 @@ }, "rust-chain-call/src/main.rs": { "captureGroups": 25, - "digest": "4b47bbd3de939f8692c1615442e233ad00440075fafc5b6a7f7db35851492a64" + "digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421" }, "rust-chain-call/src/models/mod.rs": { "captureGroups": 5, @@ -245,7 +269,7 @@ }, "rust-deep-field-chain/service.rs": { "captureGroups": 16, - "digest": "49d6a6bff4bfde6d29d8e63c2899f49688bee98273c9f743d9189dbfdf19ce2c" + "digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a" }, "rust-default-constructor/src/main.rs": { "captureGroups": 36, @@ -357,7 +381,7 @@ }, "rust-field-types/service.rs": { "captureGroups": 11, - "digest": "5bba21d0f792eb98025ce0531c575dac215492027cd877df09b19877e80da4ad" + "digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f" }, "rust-for-call-expr/src/main.rs": { "captureGroups": 26, @@ -593,7 +617,7 @@ }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 41, - "digest": "811410fc6afe4722331ff1edfc79ce38a94c241ae52b86c10511d65d8603ef19" + "digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05" }, "rust-nullable-receiver/src/repo.rs": { "captureGroups": 10, @@ -705,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/closure-binding-labels.test.ts b/gitnexus/test/integration/closure-binding-labels.test.ts index 66235040cc..629a9b8c94 100644 --- a/gitnexus/test/integration/closure-binding-labels.test.ts +++ b/gitnexus/test/integration/closure-binding-labels.test.ts @@ -596,6 +596,21 @@ describeIfWorkerBuilt('a closure binding as a call SOURCE (#2699 part B)', () => expect(targets).toEqual(['rel:CALLS:Function:A.kt:handler->Function:A.kt:target']); }); + it('Kotlin: a call at block level is not attributed to a nested named function (#2736)', async () => { + // A Block-kind scope may own a nested function without being that + // function's body. The start-position alignment check is what keeps the + // block-level call attributed to `outer`, rather than swallowing it into + // the uncalled `nested`. + const targets = await callEdgeIdsFor( + 'Block.kt', + 'fun target(): Int = 1\n\nfun outer(): Int {\n' + + ' if (true) {\n fun nested(): Int = 0\n return target()\n }\n' + + ' return 0\n}\n', + ); + + expect(targets).toEqual(['rel:CALLS:Function:Block.kt:outer->Function:Block.kt:target']); + }); + it('PHP: a call inside the closure IS attributed to the binding (#2699 S1)', async () => { // FLIPPED by #2699 S1. php/query.ts now carries the closure-binding // declaration rule with javascript/query.ts's anchor discipline diff --git a/gitnexus/test/integration/closure-review-findings.test.ts b/gitnexus/test/integration/closure-review-findings.test.ts index aed6d959b8..8879f7e57c 100644 --- a/gitnexus/test/integration/closure-review-findings.test.ts +++ b/gitnexus/test/integration/closure-review-findings.test.ts @@ -18,12 +18,16 @@ import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js'; vi.setConfig({ testTimeout: 90_000 }); const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip; +const describeIfPathSupportsColon = + process.platform === 'win32' ? describe.skip : describeIfWorkerBuilt; /** Every CALLS edge in a one-file repo, as `src -> dst`, sorted. */ const callEdges = async (filename: string, source: string): Promise => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-review-')); try { - fs.writeFileSync(path.join(dir, filename), source, 'utf-8'); + const filePath = path.join(dir, filename); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, source, 'utf-8'); const result = await runPipelineFromRepo(dir, () => {}, { workerPoolSize: 1, workerUrlForTest: DIST_WORKER_URL, @@ -85,6 +89,20 @@ describeIfWorkerBuilt( }, ); +describeIfPathSupportsColon('#2734 — def ids parse coordinates after the file path', () => { + it('a path containing #:: still attributes closure calls', async () => { + const edges = await callEdges( + 'src/bugs#12:34:test/a.php', + ' Function:src/bugs#12:34:test/a.php:target', + ]); + }); +}); + describeIfWorkerBuilt( '#2699 review P1-2 — widening identity to values must not touch class members', () => { diff --git a/gitnexus/test/integration/function-local-identity.test.ts b/gitnexus/test/integration/function-local-identity.test.ts index e8050718ed..0012c30143 100644 --- a/gitnexus/test/integration/function-local-identity.test.ts +++ b/gitnexus/test/integration/function-local-identity.test.ts @@ -25,11 +25,23 @@ * pass. Every assertion here is therefore on the EDGE, whose source and target * are produced by the two different phases: it can only pass if both agree. */ +import { + buildDefIndex, + buildScopeTree, + type Reference, + type Scope, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; import { describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js'; +import { emitReferencesViaLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js'; import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js'; vi.setConfig({ testTimeout: 90_000 }); @@ -352,3 +364,94 @@ describeIfWorkerBuilt('function-local VALUES carry their own identity (#2699 A1) expect(ids).toEqual(['Const:v.ts:handler', 'Const:v.ts:run.handler@3:2']); }); }); + +describe('function-local value identity resolves through to emitted edges (#2736)', () => { + it('targets the position-qualified local rather than its file-level twin', () => { + const filePath = 'v.ts'; + const caller: SymbolDefinition = { + nodeId: 'def:v.ts#3:0:Function:run', + filePath, + type: 'Function', + qualifiedName: 'run', + }; + const topLevel: SymbolDefinition = { + nodeId: 'def:v.ts#1:0:Const:handler', + filePath, + type: 'Const', + qualifiedName: 'handler', + }; + const local: SymbolDefinition = { + nodeId: 'def:v.ts#4:2:Const:run.handler', + filePath, + type: 'Const', + qualifiedName: 'run.handler', + }; + const moduleScope: Scope = { + id: 'scope:v.ts#1:0-100:0:Module', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [caller, topLevel], + imports: [], + typeBindings: new Map(), + }; + const functionScope: Scope = { + id: 'scope:v.ts#3:0-6:1:Function', + parent: moduleScope.id, + kind: 'Function', + range: { startLine: 3, startCol: 0, endLine: 6, endCol: 1 }, + filePath, + bindings: new Map(), + ownedDefs: [local], + imports: [], + typeBindings: new Map(), + }; + const indexes = { + scopeTree: buildScopeTree([moduleScope, functionScope]), + defs: buildDefIndex([caller, topLevel, local]), + } as unknown as ScopeResolutionIndexes; + + const graph = createKnowledgeGraph(); + graph.addNode({ + id: 'Function:v.ts:run', + label: 'Function', + properties: { name: 'run', qualifiedName: 'run', filePath, startLine: 2 }, + }); + graph.addNode({ + id: 'Const:v.ts:handler', + label: 'Const', + properties: { name: 'handler', qualifiedName: 'handler', filePath, startLine: 0 }, + }); + graph.addNode({ + id: 'Const:v.ts:run.handler@3:2', + label: 'Const', + properties: { name: 'handler', qualifiedName: 'run.handler', filePath, startLine: 3 }, + }); + + const reference: Reference = { + fromScope: functionScope.id, + toDef: local.nodeId, + atRange: { startLine: 5, startCol: 9, endLine: 5, endCol: 16 }, + kind: 'read', + confidence: 1, + evidence: [], + }; + const emitted = emitReferencesViaLookup( + graph, + indexes, + { bySourceScope: new Map([[functionScope.id, [reference]]]) }, + buildGraphNodeLookup(graph), + ); + + expect(emitted).toEqual({ emitted: 1, skipped: 0 }); + expect(graph.relationships).toEqual([ + expect.objectContaining({ + sourceId: 'Function:v.ts:run', + targetId: 'Const:v.ts:run.handler@3:2', + type: 'ACCESSES', + }), + ]); + }); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 58e0677400..4bcb4e0bd9 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -2724,6 +2724,18 @@ describe('C++ two-phase template lookup — dependent-base deep nesting suppress const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f'); expect(leaks.length).toBe(0); }); + + // The base is `ns::a::b::Inner`, and this fixture deliberately also declares a + // global-scope `Inner` to force the multi-candidate path. `resolveDefGraphId` + // must reach the deep node, not the global decoy: the scope-extractor leaves + // the base's `qualifiedName` a bare `Inner` with the path on `namespacePrefix`, + // so only the namespace-prefixed key can distinguish them, and it has to be + // tried BEFORE the bare key or the decoy answers first (#2745 review — the + // reorder that fixed this was previously unpinned by any assertion). + it('EXTENDS anchors on the deep ns.a.b.Inner, not the global Inner decoy', () => { + const extendsEdges = getRelationships(result, 'EXTENDS').filter((e) => e.source === 'Derived'); + expect(extendsEdges).toMatchObject([{ rel: { targetId: 'Struct:lib.h:ns.a.b.Inner' } }]); + }); }); describe('C++ two-phase template lookup — dependent-base sibling-namespace suppression', () => { @@ -4536,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/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index cb730d9f6b..cab984b8f5 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2788,3 +2788,221 @@ describe('Rust type-qualified calls are not treated as module paths (#2730 revie expect(edges[0]).toMatchObject({ target: 'new', targetFilePath: 'src/client/mod.rs' }); }); }); + +// --------------------------------------------------------------------------- +// #2742 — same-named items at different module depths are distinct nodes. +// +// Node identity was `