Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/babel/types-8.0.4

This commit is contained in:
Gergő Magyar 2026-07-31 07:14:02 +01:00 • committed by GitHub
commit ff73231104
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 4129 additions and 214 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 });
};

View file

@ -1 +1 @@
f120df925e3b92e78212003e10ae3025dfa3130955fa38cf801b91077dca0783
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483

View file

@ -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<User>().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<User>().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<User>().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<User>().save()` | `svc.getTyped<User>()` |
| `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 `<T>` 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<User>().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.

View file

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

View file

@ -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<User>().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 <repoPath>`). 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 <repo>
*
* `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<User> {
return new User();
}
getTyped<T>(): User {
return new User();
}
}
`,
},
header: `import { Service, User } from './models';\n`,
wrap: (entry, body) =>
`export async function ${entry}(svc: Service, repos: User[]): Promise<void> {\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<User>().save();', note: 'PF4' },
{ id: 'indexElement', member: 'save', body: 'repos[0].save();', note: 'PF5' },
],
},
{
lang: 'php',
ext: '.php',
support: {
'models.php': `<?php
class User {
public function save() {}
}
class Service {
public function getUser() {
return new User();
}
}
`,
},
header: `<?php\nrequire_once 'models.php';\n`,
// TYPED parameter. An untyped `$svc` has no type binding to resolve the
// chain's base against, which would make this row report a language gap
// that is really a fixture defect.
wrap: (entry, body) => `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 ?? '<<unset>>';
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));
}

View file

@ -1,11 +1,12 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* 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."
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

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

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

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

View file

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

View file

@ -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<ReturnType<typeof getPhpParser>['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);
}

View file

@ -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);
}

View file

@ -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<string> = 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);
}

View file

@ -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);
}

View file

@ -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<string>;
/**
@ -125,12 +126,15 @@ export function buildRustModuleIndex(allFilePaths: ReadonlySet<string>): 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<string>,
): 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;
}

View file

@ -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<string, readonly Scope[]>
>();
interface PassModuleIndex {
/** Module identity key → the file-module scopes realising it. */
readonly scopesByModule: ReadonlyMap<string, readonly Scope[]>;
/**
* 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<string>;
/**
* `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 `<callerModule>::<name>` a real inline
* submodule", which the name set cannot (two unrelated modules share a tail).
*/
readonly inlineModuleKeys: ReadonlySet<string>;
}
const MODULE_SCOPE_CACHE = new WeakMap<WorkspaceResolutionIndex, PassModuleIndex>();
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<string, Scope[]>();
const knownModuleNames = new Set<string>(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<string>();
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<string, Scope[]>();
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([]);

View file

@ -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);
}

View file

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

View file

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

View file

@ -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:<filePath>#<line>:<col>:<...>`; `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));

View file

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

View file

@ -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 (`?.`, `!`, `<T>`, `->`) 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);
}

View file

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

View file

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

View file

@ -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<Record<string, number>>;
/** 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<string, number>();
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;
}

View file

@ -0,0 +1,24 @@
export interface DefinitionIdPosition {
readonly line: number;
readonly column: number;
}
/**
* Extract coordinates from `def:<filePath>#<line>:<column>:<type>:<name>`.
*
* 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]) };
}

View file

@ -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<string> = new Set<string>([
...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<string, string> = {
class_declaration: 'Class',
abstract_class_declaration: 'Class',

View file

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

View file

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

View file

@ -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|<base>|<step>|<step>…[|~]
* ```
*
* - 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;
}

View file

@ -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:<file>: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:<file>:engine.Config` against a `HAS_PROPERTY` edge
// anchored on `Struct:<file>: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 #<arity> suffix in the ID.
let declaredType: string | undefined;

View file

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

View file

@ -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<string[]> {
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 [];
}
}

View file

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

View file

@ -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<string, string>();
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<void>((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) {

View file

@ -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<string, number>;
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<User>().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:<file>: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;

View file

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

View file

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

View file

@ -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();
}

View file

@ -0,0 +1,11 @@
#pragma once
class User {
public:
void save();
};
class Service {
public:
User* getUser();
};

View file

@ -0,0 +1,7 @@
const { Legacy } = require('./svc');
function viaInlineNewJs() {
return new Legacy().doWork();
}
module.exports = { viaInlineNewJs };

View file

@ -0,0 +1,7 @@
class Legacy {
doWork() {
return 1;
}
}
module.exports = { Legacy };

View file

@ -0,0 +1 @@
pub struct Inner;

View file

@ -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();
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -0,0 +1,9 @@
pub struct Buffer {
pub v: usize,
}
impl Buffer {
pub fn with_capacity() -> usize {
222
}
}

View file

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

View file

@ -1,7 +0,0 @@
const { LegacyService } = require('./svc');
function viaInlineNewJs() {
return new LegacyService().doWork();
}
module.exports = { viaInlineNewJs };

View file

@ -1,7 +0,0 @@
class LegacyService {
doWork() {
return 1;
}
}
module.exports = { LegacyService };

View file

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

View file

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

View file

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

View file

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

View file

@ -85,7 +85,7 @@
},
"swift-field-types/App.swift": {
"captureGroups": 7,
"digest": "651c5cbbf9afdc0bf4d96217bc1a01cf0bf07a0190ca12a3a6786bb08a67b3ad"
"digest": "97750185ce98b162ecaed76917408e715756a9256e1bc961a6439815db3c0715"
},
"swift-field-types/Models.swift": {
"captureGroups": 20,

View file

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

View file

@ -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<string[]> => {
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 #<line>:<column>: still attributes closure calls', async () => {
const edges = await callEdges(
'src/bugs#12:34:test/a.php',
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
' $handler = function ($x) { return target($x); };\n return 1;\n}\n',
);
expect(edges).toEqual([
'Function:src/bugs#12:34:test/a.php:outer.$handler@3:2 -> Function:src/bugs#12:34:test/a.php:target',
]);
});
});
describeIfWorkerBuilt(
'#2699 review P1-2 — widening identity to values must not touch class members',
() => {

View file

@ -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<ScopeId, readonly Reference[]>([[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',
}),
]);
});
});

View file

@ -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',
});
});
});

View file

@ -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');
});
});

View file

@ -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 `<label>:<file>:<qualifiedName>` with no module path, so an
// inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
// file collapsed onto one node, first-wins. Resolution already picked the right
// definition; the target simply was not representable, so a correct resolution
// still rendered as a self-loop and `impact` reported the real callee unreached.
// ---------------------------------------------------------------------------
describe('Rust items are qualified by their enclosing mod chain (#2742)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-gaps'), () => {});
}, 60000);
it('gives an inline-mod member its own node, distinct from the crate-root item', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
});
expect(ids).toContain('Function:src/main.rs:inner.dispatch');
expect(ids).toContain('Function:src/main.rs:dispatch');
});
it('resolves inner::dispatch() to the inline member, not back to the caller', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/main.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0].rel.targetId).toBe('Function:src/main.rs:inner.dispatch');
});
// Fixture-wide rather than pinned to one edge, because the #2730 symptom is a
// CLASS of mis-binding: any qualified call whose leading segment names an
// inline module can land back on the enclosing same-name item. The preceding
// test pins the one target we know regressed; this one fails if the same fault
// reappears through any other path in the fixture. `rust-2730-gaps` contains no
// self-recursive function, so an empty result is the correct invariant — adding
// one to the fixture means narrowing this filter, not deleting the test.
it('emits no self-loop for the inline-mod wrapper', () => {
const selfLoops = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === c.rel.targetId,
);
expect(selfLoops).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — mod-qualifying a node id must not orphan its member edges.
//
// The mint (parse-worker) and the owner-edge anchor (findEnclosingClassInfo) are
// computed independently. #2742 re-keyed ids by the enclosing `mod` chain but the
// anchor is minted from the container's BARE name and only follows a qualified
// shape when the provider sets `classExtractor.qualifiedNodeId`, which Rust does
// not. Every `struct` / `trait` / `enum` / `impl` inside a `mod` therefore had a
// node id no member edge pointed at, and the rows were dropped at COPY time.
//
// Guarded here with the UNFILTERED findDanglingEdges. Every other dangling
// assertion in this file passes `['HAS_METHOD']`, which is exactly why the
// HAS_PROPERTY breakage shipped green.
// ---------------------------------------------------------------------------
describe('Rust containers inside a mod keep their member edges (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-mod-members'), () => {});
}, 60000);
it('leaves no dangling edge of ANY type', () => {
expect(findDanglingEdges(result)).toEqual([]);
});
// Asserts the NODE id, not just the edge's anchor: the anchor was already bare
// while the bug was live, so an edge-only assertion passes in both builds. The
// half that moved is the mint.
it('keeps a mod-scoped struct and its field on one agreed id', () => {
const structIds: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct' && n.properties.name === 'Config') structIds.push(n.id);
});
expect(structIds).toEqual(['Struct:src/main.rs:Config']);
const hasProperty = getRelationships(result, 'HAS_PROPERTY').filter(
(e) => e.target === 'retries',
);
expect(hasProperty).toMatchObject([
{
rel: {
sourceId: 'Struct:src/main.rs:Config',
targetId: 'Property:src/main.rs:Config.retries',
},
},
]);
});
it('keeps a scoped inherent-impl target inside a mod at its raw path (#1975)', () => {
const implIds: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Impl') implIds.push(n.id);
});
expect(implIds).toEqual(['Impl:src/main.rs:a::Inner']);
const hasMethod = getRelationships(result, 'HAS_METHOD').filter((e) => e.target === 'helper');
expect(hasMethod).toMatchObject([{ rel: { sourceId: 'Impl:src/main.rs:a::Inner' } }]);
});
it('still links a trait impl declared inside a mod to the trait method', () => {
const implementsEdges = getRelationships(result, 'METHOD_IMPLEMENTS');
expect(implementsEdges).toMatchObject([
{
rel: {
sourceId: 'Function:src/main.rs:Config.go#0',
targetId: 'Function:src/main.rs:Runner.go#0',
},
},
]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — the #2730 self-loop survived one `mod` deeper.
//
// A `mod` nested inside an inline `mod` binds in the PARENT module's scope.
// `declaresSubmodule` looked it up through `moduleScopeByFile`, which maps a file
// to its root `Module` scope only, so a nested inline module was invisible: the
// candidate was never yielded, the hook refused, and the shared lexical tier bound
// `tools::dispatch()` to the enclosing same-name `dispatch`.
//
// Inline module paths are now derived from the MEMBERS' `namespacePrefix` — a `mod`
// def carries no nesting information of its own, and a `Namespace` scope owns its
// own def rather than its children's, so neither channel could answer this.
// ---------------------------------------------------------------------------
describe('Rust nested inline modules resolve (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-nested-mods'), () => {});
}, 60000);
it('resolves a nested inline module member instead of the enclosing same-name fn', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/main.rs:outer.dispatch',
);
expect(edges).toMatchObject([
{ rel: { targetId: 'Function:src/main.rs:outer.tools.dispatch' } },
]);
});
it('resolves three levels deep', () => {
const midToDeep = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/main.rs:a.b.mid',
);
expect(midToDeep).toMatchObject([{ rel: { targetId: 'Function:src/main.rs:a.b.c.deep' } }]);
});
it('emits no self-loop anywhere in the nested fixture', () => {
const selfLoops = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === c.rel.targetId,
);
expect(selfLoops).toEqual([]);
});
// A `mod` inside a `fn` is already position-qualified by the enclosing-callable
// pass, so prepending the mod segment placed it OUTSIDE the callable and the id
// read as `helper.wrapper.dispatch@L:C` — the inverse of the real nesting.
it('does not hoist a mod declared inside a fn above the callable', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
});
// Prefix rather than an exact `@line:col`, so adding a line to the fixture
// above does not turn this into a coordinate-maintenance test.
expect(
ids.filter((id) => id.startsWith('Function:src/main.rs:wrapper.dispatch@')),
).toHaveLength(1);
expect(ids.filter((id) => id.includes(':helper.'))).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — an imported type outranks a same-named module at the crate root.
//
// Widening the negative filter to inline `mod` names let a type-qualified call
// through when a module happened to share the type's name. The crate-root-relative
// candidate — the loosest one, a guess at a path the caller never wrote — then
// captured it, producing an edge to a callee the source does not name. The base
// emitted no edge, which per the doctrine quoted in `ids.ts` is the correct failure
// direction: a missing edge is recoverable, a fabricated caller misleads `impact`.
// ---------------------------------------------------------------------------
describe('Rust type-qualified calls outrank a same-named module (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-type-vs-module'), () => {});
}, 60000);
it('never binds an imported type to the crate-root module of the same name', () => {
const fromCall = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/b.rs:call',
);
const targets = fromCall.map((c) => c.rel.targetId);
expect(targets).not.toContain('Function:src/lib.rs:Buffer.with_capacity');
});
it('leaves the module member itself intact as a node', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'with_capacity') ids.push(n.id);
});
expect(ids).toContain('Function:src/lib.rs:Buffer.with_capacity');
});
});

View file

@ -11,6 +11,7 @@ import {
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
getResolutionOutcomes,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
@ -3250,13 +3251,6 @@ describe('TypeScript inline constructor receiver resolution', () => {
expect(twoStep!.rel.targetId).toContain('Service');
});
it('resolves the same shape in a plain .js file (javascript provider)', () => {
const calls = getRelationships(result, 'CALLS');
const jsCall = calls.find((c) => c.source === 'viaInlineNewJs' && c.target === 'doWork');
expect(jsCall).toBeDefined();
expect(jsCall!.rel.targetId).toContain('LegacyService');
});
it('resolves a generic constructor receiver — new Box<string>().unwrap()', () => {
const calls = getRelationships(result, 'CALLS');
const genericCall = calls.find((c) => c.source === 'viaGenericCtor' && c.target === 'unwrap');
@ -3312,3 +3306,243 @@ describe('TypeScript inline constructor receiver resolution', () => {
expect(factoryCall!.rel.targetId).toContain('Other');
});
});
// ---------------------------------------------------------------------------
// #2744 drop recorder — the site kind travels with the drop.
//
// Case 0's gate tests the RECEIVER's punctuation, not the site's kind, so a
// compound-receiver property write is recorded in the same bucket as a dropped
// method call. Anything measuring resolver gaps has to tell those apart, and
// the site kind is the only authoritative signal for it.
//
// Both shapes below are empirically confirmed drops: `!` produces a reference
// site that reaches Case 0. (`?.` and explicit type arguments do NOT record a
// drop at all — they are invisible to this recorder, which is a property of the
// capture layer, not of this field.)
// ---------------------------------------------------------------------------
describe('TypeScript receiver-unresolved drops carry their site kind (#2744)', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-drop-site-kind-'));
writeFixtureRepo(repoDir, {
'models.ts': `export class User {
name: string = '';
save(): void {}
}
export class Service {
getUser(): User {
return new User();
}
async getUserAsync(): Promise<User> {
return new User();
}
}
`,
'main.ts': `import { Service } from './models';
export async function droppedCall(svc: Service): Promise<void> {
// An await-parenthesized receiver. Structural typing does NOT cover this
// shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node,
// so no chain is minted and the site still reaches the drop recorder. The
// \`!\` spelling used to serve here until structural typing resolved it.
(await svc.getUserAsync()).save();
}
export function droppedWrite(svc: Service | null): void {
// A write receiver mints no chain at all (the emitter gates on CALL_TAGS),
// so this keeps dropping and stays separable from the call above.
svc!.getUser().name = 'x';
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {}, {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('tags a dropped method call as a call site', () => {
const drops = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
expect(drops).toContainEqual(expect.objectContaining({ name: 'save', siteKind: 'call' }));
});
it('tags a dropped property write as a write site, so it is separable from calls', () => {
const drops = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' }));
});
});
// ---------------------------------------------------------------------------
// Structural receiver typing: receiver spellings the text cascade cannot parse
// now resolve by folding the captured chain.
//
// Both shapes below emitted NO edge and recorded NO drop before this work:
// Case 0's gate fired, `resolveCompoundReceiverClass` returned undefined, and a
// later case marked the site handled — which suppresses the drop record too, so
// the loss was invisible to the epistemic signal as well as to the graph.
// ---------------------------------------------------------------------------
describe('TypeScript structural receiver chains', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-receiver-chain-'));
writeFixtureRepo(repoDir, {
'models.ts': `export class Database {
query(): void {}
}
export class Config {
db: Database = new Database();
}
export function make(n: number): number {
return n;
}
export class Address {
persist(): void {}
}
export class User {
address: Address = new Address();
save(): void {}
}
export class Service {
getUser(): User {
return new User();
}
getTyped<T>(): User {
return new User();
}
}
`,
'main.ts': `import { Service, Config, make } from './models';
export function viaOptionalChain(svc: Service | null): void {
svc?.getUser().save();
}
// A local that merely SHADOWS an imported class name. Its value is a number,
// so it has NO members — the fold must not type it as the class.
export function shadowsAClassName(): void {
const Config = make(1);
Config.db.query();
}
export function viaTypeArgs(svc: Service): void {
svc.getTyped<User>().save();
}
export function viaNonNull(svc: Service | null): void {
svc!.getUser().save();
}
// A mixed call/field chain behind a spelling the TEXT cascade cannot parse
// (optional chaining), so this discriminates the fold rather than re-testing
// the pre-existing cascade path.
export function viaMixedChain(svc: Service | null): void {
svc?.getUser().address.persist();
}
// User has no member named missing, so the chain dies at its middle step.
export function brokenMiddleStep(svc: Service): void {
svc.getUser().missing.persist();
}
export function alreadyWorked(svc: Service): void {
svc.getUser().save();
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {}, {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('resolves an optional-chained receiver — svc?.getUser().save()', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'viaOptionalChain' && c.target === 'save')).toMatchObject(
{
target: 'save',
targetFilePath: 'models.ts',
},
);
});
it('resolves an explicit-type-argument receiver — svc.getTyped<User>().save()', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'viaTypeArgs' && c.target === 'save')).toMatchObject({
target: 'save',
targetFilePath: 'models.ts',
});
});
it('resolves a non-null-asserted receiver — svc!.getUser().save()', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'viaNonNull' && c.target === 'save')).toMatchObject({
target: 'save',
targetFilePath: 'models.ts',
});
});
it('resolves a mixed call/field chain through the fold', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'viaMixedChain' && c.target === 'persist')).toMatchObject(
{
target: 'persist',
targetFilePath: 'models.ts',
},
);
});
it('keeps the shape that already resolved through the text cascade', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'alreadyWorked' && c.target === 'save')).toMatchObject({
target: 'save',
targetFilePath: 'models.ts',
});
});
it('does NOT fabricate an edge when a local shadows a class name', () => {
// Regression: the fold resolved its base through the permissive
// bare-identifier path, which falls through to a plain class-name lookup
// even when a receiver typeBinding exists but names no class. A local
// `const Config = make(1)` (a number) was therefore typed as the imported
// `class Config`, emitting a confident `CALLS` edge to `Database.query`
// that the text cascade never produced. A missing edge is recoverable; a
// wrong one is not.
const calls = getRelationships(result, 'CALLS');
expect(calls.filter((c) => c.source === 'shadowsAClassName' && c.target === 'query')).toEqual(
[],
);
});
it('emits no CALLS edge when a middle step names no member of the previous class', () => {
// A broken MIDDLE step must produce no edge, never a wrong one. `User` has no
// member `missing`, so the chain cannot be typed past it and `persist` must
// not bind to anything — in particular not to `Address.persist`, which a
// field-walking fallback could otherwise reach.
// `getUser` itself still resolves — only the tail past the broken step must not.
const calls = getRelationships(result, 'CALLS');
expect(calls.filter((c) => c.source === 'brokenMiddleStep' && c.target === 'persist')).toEqual(
[],
);
expect(
calls.filter((c) => c.source === 'brokenMiddleStep' && c.target === 'getUser'),
).toHaveLength(1);
});
});

View file

@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 24 (inline constructor receivers, #2708)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 28 (structural receiver typing, all languages)', () => {
// Moves with every bump BY DESIGN — that is the point of pinning it. A
// change that alters emitted ids or edges without bumping would otherwise
// ship silently, and an existing index would keep serving the old graph
// through the reuse gate below.
expect(INCREMENTAL_SCHEMA_VERSION).toBe(24);
expect(INCREMENTAL_SCHEMA_VERSION).toBe(28);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@ -175,9 +175,30 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// (#2730): every unchanged Rust file would keep the same-name self-loop and
// keep reporting the real callee as unreached → must NOT reuse.
expect(passesReuseGate(22)).toBe(false);
// A pre-v24 (v23) index predates
// A pre-v24 (v23) index predates the #2708 inline-constructor receivers.
expect(passesReuseGate(23)).toBe(false);
// A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An
// absent summary is indistinguishable from "nothing was dropped", so a
// top-up would keep reporting `epistemic: 'exact'` for exactly the symbols
// whose callers were dropped → must NOT reuse.
expect(passesReuseGate(24)).toBe(false);
// A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`,
// `svc!.m().n()` and `svc.m<T>().n()` emitted no CALLS edge — and two of the
// three recorded no drop either, so the count still claimed `exact`. A
// changed-files top-up keeps both the missing edge and the false confidence
// for every unchanged file → must NOT reuse.
expect(passesReuseGate(25)).toBe(false);
// A pre-v27 (v26) index was stamped by an intermediate build of this same
// series: structural typing was TypeScript-only at that point, and the fold
// still typed a bare identifier that merely shadowed a class name as that
// class — so such an index carries both pre-rollout edges for 13 languages
// and fabricated ones. The gate is a strict `===`, so it must NOT reuse.
expect(passesReuseGate(26)).toBe(false);
// A pre-v28 (v27) index was stamped mid-series: TypeScript-only structural
// typing, and the fold still typed a local that merely shadowed a class name
// as that class — so it carries pre-rollout edges AND fabricated ones.
expect(passesReuseGate(27)).toBe(false);
// The current stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(24)).toBe(true);
expect(passesReuseGate(28)).toBe(true);
});
});

View file

@ -101,6 +101,16 @@ describe('fileContentHash', () => {
});
describe('PARSE_CACHE_VERSION', () => {
// 32 -> 34 for the receiver-chain capture (#2747): 33 is the TypeScript-only
// emission, 34 the rollout to the other 13 emitters, which changed the capture
// set a second time. Updated deliberately — and this pin earned its keep twice
// over: it caught the #2742 collision when it arrived from main, and it caught
// this series again on rebase, where `main` had independently taken 32 for the
// very number this branch was already using.
it('pins SCHEMA_BUMP to 34 so concurrent bumps cannot silently collide (#2736)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(34);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
// Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version
expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/);

View file

@ -504,3 +504,111 @@ describe('parsedfile-store', () => {
}
});
});
/**
* `receiverChain` at the untrusted boundary. Unlike `callableFlowSites`,
* `referenceSites` had no sanitizer here at all, so this field arrives with the
* first one.
*/
describe('parsedfile-store receiverChain sanitation', () => {
const siteWith = (receiverChain: unknown) => ({
name: 'save',
atRange: { startLine: 3, startCol: 2, endLine: 3, endCol: 6 },
inScope: 'x.ts:module',
kind: 'call',
...(receiverChain === undefined ? {} : { receiverChain }),
});
it('round-trips a well-formed chain', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }),
]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!;
expect(loaded.referenceSites[0]).toMatchObject({
name: 'save',
receiverChain: '1|svc|cgetUser',
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('loads a shard written before the field existed, unchanged', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-old-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', { referenceSites: [siteWith(undefined)] }),
]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!;
expect(loaded.referenceSites[0]).toMatchObject({ name: 'save' });
expect(loaded.referenceSites[0]).not.toHaveProperty('receiverChain');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it.each([
['malformed', 'not-a-chain'],
['wrong version', '2|svc|cgetUser'],
['over depth', '1|svc|ca|cb|cc|cd'],
['non-string', 42],
])(
'strips a %s chain but KEEPS the site — it still resolves via the text cascade',
async (_label, payload) => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-bad-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', { referenceSites: [siteWith(payload)] }),
]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!;
expect(loaded.referenceSites).toHaveLength(1);
expect(loaded.referenceSites[0]).toMatchObject({ name: 'save' });
expect(loaded.referenceSites[0]).not.toHaveProperty('receiverChain');
} finally {
await rm(dir, { recursive: true, force: true });
}
},
);
it('rejects the file when referenceSites is not an array at all', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-garbage-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('garbage.ts', { referenceSites: 'nonsense' }),
makeStoreEntry('ok.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }),
]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['garbage.ts', 'ok.ts']));
expect(loaded.has('garbage.ts')).toBe(false);
expect(loaded.has('ok.ts')).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('strips only the invalid chain and leaves a valid sibling intact', async () => {
// Sanitation is per-FIELD, not per-site or per-file. Every other case here
// uses a single-element array, so the `dropped > 0` .map() branch was never
// shown to preserve a good neighbour.
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-mixed-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', {
referenceSites: [
siteWith('1|svc|cgetUser'),
siteWith('not-a-chain'),
siteWith('1|other|ffield'),
],
}),
]);
const loaded = (await loadParsedFilesForPaths(dir, new Set(['x.ts']))).get('x.ts')!;
expect(loaded.referenceSites).toHaveLength(3);
expect(loaded.referenceSites[0]).toMatchObject({ receiverChain: '1|svc|cgetUser' });
expect(loaded.referenceSites[1]).not.toHaveProperty('receiverChain');
expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '1|other|ffield' });
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,160 @@
/**
* Receiver-chain wire format. The emitter, the resolver fold and the untrusted
* store boundary all read this format, so a decode that accepts something the
* encoder cannot mint — or vice versa — is a silent divergence between what is
* written and what is trusted.
*/
import { describe, it, expect } from 'vitest';
import {
MAX_RECEIVER_CHAIN_BYTES,
decodeReceiverChain,
encodeReceiverChain,
isValidReceiverChain,
} from '../../src/core/ingestion/utils/receiver-chain-codec.js';
import { MAX_CHAIN_DEPTH } from '../../src/core/ingestion/utils/call-analysis.js';
describe('receiver-chain codec', () => {
it('round-trips a mixed call/field chain base-first', () => {
const encoded = encodeReceiverChain('svc', [
{ kind: 'call', name: 'getUser' },
{ kind: 'field', name: 'address' },
]);
expect(encoded).toBe('1|svc|cgetUser|faddress');
expect(decodeReceiverChain(encoded)).toEqual({
baseReceiverName: 'svc',
steps: [
{ kind: 'call', name: 'getUser' },
{ kind: 'field', name: 'address' },
],
truncated: false,
});
});
it('stays well under the U7 per-site byte threshold for a realistic chain', () => {
// The gate is <= 48 serialized bytes per emitting site, set so an object
// encoding fails and this one passes.
expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }])!.length).toBeLessThan(
48,
);
});
it('needs no escaping for a member whose name starts with a kind sigil', () => {
expect(
decodeReceiverChain(encodeReceiverChain('a', [{ kind: 'call', name: 'count' }])),
).toEqual({
baseReceiverName: 'a',
steps: [{ kind: 'call', name: 'count' }],
truncated: false,
});
expect(
decodeReceiverChain(encodeReceiverChain('a', [{ kind: 'field', name: 'field' }])),
).toEqual({
baseReceiverName: 'a',
steps: [{ kind: 'field', name: 'field' }],
truncated: false,
});
});
it('marks a truncated chain, because its missing tail is what decides the type', () => {
const encoded = encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }], {
truncated: true,
});
expect(encoded).toBe('1|svc|cgetUser|~');
expect(decodeReceiverChain(encoded)).toMatchObject({ truncated: true });
});
it('refuses to mint an empty chain — there is nothing to fold', () => {
expect(encodeReceiverChain('svc', [])).toBeUndefined();
});
it('refuses to mint beyond MAX_CHAIN_DEPTH rather than silently dropping a step', () => {
const steps = Array.from({ length: MAX_CHAIN_DEPTH + 1 }, (_unused, i) => ({
kind: 'call' as const,
name: `m${i}`,
}));
expect(encodeReceiverChain('svc', steps)).toBeUndefined();
});
it('refuses to mint a name carrying a structural character, instead of escaping it', () => {
expect(encodeReceiverChain('sv|c', [{ kind: 'call', name: 'getUser' }])).toBeUndefined();
expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'get~User' }])).toBeUndefined();
expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'get User' }])).toBeUndefined();
});
it('refuses to mint over the byte cap', () => {
const huge = 'x'.repeat(MAX_RECEIVER_CHAIN_BYTES);
expect(encodeReceiverChain('svc', [{ kind: 'call', name: huge }])).toBeUndefined();
});
it.each([
['not a string', 42],
['undefined', undefined],
['empty', ''],
['no version', 'svc|cgetUser'],
['wrong version', '2|svc|cgetUser'],
['no steps', '1|svc'],
['unknown kind sigil', '1|svc|xgetUser'],
['empty step name', '1|svc|c'],
['empty base', '1||cgetUser'],
['over depth', '1|svc|ca|cb|cc|cd'],
['truncation marker only', '1|svc|~'],
])('decodes %s as undefined rather than throwing', (_label, payload) => {
expect(decodeReceiverChain(payload)).toBeUndefined();
expect(isValidReceiverChain(payload)).toBe(false);
});
it('refuses to mint a name carrying a zero-width character', () => {
// `\s` does not match these, so without an explicit class they encode and
// persist cleanly and then match no binding at resolution time — a silent,
// unexplained miss, and a trojan-source vector.
for (const invisible of ['\u200B', '\u200C', '\u200D', '\u200E', '\u200F', '\uFEFF']) {
expect(
encodeReceiverChain('svc', [{ kind: 'call', name: `get${invisible}User` }]),
).toBeUndefined();
expect(
encodeReceiverChain(`sv${invisible}c`, [{ kind: 'call', name: 'getUser' }]),
).toBeUndefined();
}
});
it('round-trips a chain of exactly MAX_CHAIN_DEPTH steps', () => {
// The refusal at MAX_CHAIN_DEPTH + 1 is pinned above; pin the boundary that
// must still WORK, so a future off-by-one narrowing is caught too.
const steps = Array.from({ length: MAX_CHAIN_DEPTH }, (_unused, i) => ({
kind: 'call' as const,
name: `m${i}`,
}));
expect(decodeReceiverChain(encodeReceiverChain('svc', steps))).toMatchObject({
baseReceiverName: 'svc',
truncated: false,
});
expect(decodeReceiverChain(encodeReceiverChain('svc', steps))?.steps).toHaveLength(
MAX_CHAIN_DEPTH,
);
});
it('survives adversarial payloads without throwing', () => {
// The "total function" claim was previously verified by reading only.
for (const hostile of [
'|'.repeat(512),
'1|' + '|'.repeat(400),
'1|svc|c\u0000name',
'1|svc|c\uD800',
`1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`,
'1|'.repeat(300),
{},
[],
null,
]) {
expect(() => decodeReceiverChain(hostile)).not.toThrow();
}
});
it('rejects an over-cap payload at decode, matching the emit-side refusal', () => {
// Emit and load must agree. A bound applied only on load is a writer that
// keeps minting what the reader keeps refusing — a permanent, unlogged
// warm-cache-miss reparse loop.
expect(isValidReceiverChain(`1|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`)).toBe(false);
});
});

View file

@ -33,7 +33,7 @@ const graphOf = (nodes: readonly StubNode[]): KnowledgeGraph => {
/** `line` is 1-based, matching the definition-id convention. */
const def = (type: string, filePath: string, qualifiedName: string, line: number) => ({
nodeId: `${filePath}#${line}:0:${qualifiedName}`,
nodeId: `def:${filePath}#${line}:0:${type}:${qualifiedName}`,
type,
filePath,
qualifiedName,
@ -69,7 +69,7 @@ describe('buildGraphTargetIndex — value bindings join by position', () => {
// the same construct, so they share file, line and name.
expect(
targetsFor([node('Function', 'a.kt', 'handler', 3)], [def('Property', 'a.kt', 'handler', 3)]),
).toEqual(['a.kt#3:0:handler => Function:a.kt:handler']);
).toEqual(['def:a.kt#3:0:Property:handler => Function:a.kt:handler']);
});
it('REJECTS a value binding that merely shares a name with a callable elsewhere', () => {
@ -106,12 +106,12 @@ describe('buildGraphTargetIndex — value bindings join by position', () => {
[node('Function', 'a.php', '$save', 5), node('Function', 'a.php', 'save', 2)],
[def('Variable', 'a.php', 'save', 5)],
),
).toEqual(['a.php#5:0:save => Function:a.php:$save']);
).toEqual(['def:a.php#5:0:Variable:save => Function:a.php:$save']);
});
it('keeps ordinary callable defs, which never take the positional path', () => {
expect(
targetsFor([node('Function', 'a.ts', 'fn', 1)], [def('Function', 'a.ts', 'fn', 1)]),
).toEqual(['a.ts#1:0:fn => Function:a.ts:fn']);
).toEqual(['def:a.ts#1:0:Function:fn => Function:a.ts:fn']);
});
});

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { definitionIdPosition } from '../../../src/core/ingestion/scope-resolution/utils/definition-id.js';
describe('definitionIdPosition', () => {
it('reads coordinates after a path containing a coordinate-like fragment (#2734)', () => {
expect(
definitionIdPosition(
'def:src/bugs#12:34:test/a.php#4:2:Function:$handler',
'src/bugs#12:34:test/a.php',
),
).toEqual({ line: 4, column: 2 });
});
it('does not mistake a private-name hash for the path separator', () => {
expect(definitionIdPosition('def:src/a.ts#4:2:Method:#secret', 'src/a.ts')).toEqual({
line: 4,
column: 2,
});
});
it.each<[string | undefined, string]>([
[undefined, 'src/plain.php'],
['def:src/plain.php', 'src/plain.php'],
['def:src/plain.php#x:2:Function:handler', 'src/plain.php'],
['def:src/plain.php#4:x:Function:handler', 'src/plain.php'],
['def:src/other.php#4:2:Function:handler', 'src/plain.php'],
])('rejects malformed or mismatched definition id %s', (nodeId, filePath) => {
expect(definitionIdPosition(nodeId, filePath)).toBeUndefined();
});
});

View file

@ -0,0 +1,159 @@
/**
* U6 — `foldReceiverChain` types a receiver from decoded structure rather than
* from its source text.
*
* The fold is called directly: U6 deliberately wires it into no resolution
* path (that is U10), so there is no pipeline behaviour to assert yet.
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js';
import { foldReceiverChain } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js';
import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js';
const SOURCE = `export class Address {
save(): void {}
}
export class User {
address: Address = new Address();
save(): void {}
}
export class Base {
inherited(): User {
return new User();
}
}
export class Service extends Base {
getUser(): User {
return new User();
}
}
// A class with NO member \`save\`, whose FIELD's type has one. The field
// fallback finds \`save\` by walking in here; the fold must not.
export class Holder {
user: User = new User();
}
const svc: Service = new Service();
const holder: Holder = new Holder();
`;
function build() {
const parsed = extractParsedFile(typescriptScopeResolver.languageProvider, SOURCE, 'main.ts');
if (parsed === undefined) throw new Error('scope extraction failed');
typescriptScopeResolver.populateOwners(parsed);
const parsedFiles: ParsedFile[] = [parsed];
const allFilePaths = new Set(parsedFiles.map((p) => p.filePath));
const scopes = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
typescriptScopeResolver.resolveImportTarget(targetRaw, fromFile, allFilePaths),
mergeBindings: (existing, incoming, scopeId) =>
typescriptScopeResolver.mergeBindings(existing, incoming, scopeId),
},
});
const index = buildWorkspaceResolutionIndex(parsedFiles);
return { scopes, index, inScope: parsed.moduleScope };
}
const ctx = build();
/** Fold with the language contract flags overridden — used to exercise the
* OFF branch of `hoistTypeBindingsToModule`, which six wired languages
* (c, cobol, dart, python, ruby, swift) actually run. */
function foldWith(encoded: string, overrides: Record<string, unknown>) {
const decoded = decodeReceiverChain(encoded);
expect(decoded).toBeDefined();
return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, {
fieldFallback: false,
hoistTypeBindingsToModule: true,
...overrides,
});
}
function fold(encoded: string) {
const decoded = decodeReceiverChain(encoded);
expect(decoded).toBeDefined();
// The language's own contract flags, exactly as the resolver pass would
// pass them. TypeScript hoists method return-type bindings out of the class
// body, so the fold needs that flag to find any of them.
return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, {
fieldFallback: false,
hoistTypeBindingsToModule: true,
});
}
describe('foldReceiverChain', () => {
it('types a single call step through the method return type', () => {
expect(fold('1|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
});
it('types a mixed call/field chain, base-first', () => {
expect(fold('1|svc|cgetUser|faddress')).toMatchObject({
qualifiedName: 'Address',
type: 'Class',
});
});
it('types a step inherited through the MRO', () => {
expect(fold('1|svc|cinherited')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
});
it('returns undefined when a step names no member of the previous class', () => {
expect(fold('1|svc|cgetUser|fnoSuchField')).toBeUndefined();
});
it('returns undefined when the base does not resolve', () => {
expect(fold('1|noSuchLocal|cgetUser')).toBeUndefined();
});
it('does not consult the field fallback', () => {
// `Holder` has no member `save` — only its field's TYPE does. The field
// fallback would walk Holder's fields, find `User.save` and answer: a
// guess, at O(fields x depth x names) per step. The fold declines.
expect(fold('1|holder|csave')).toBeUndefined();
});
it('declines a truncated chain even though the producer refuses to mint one', () => {
expect(fold('1|svc|cgetUser|~')).toBeUndefined();
});
it('declines a construction-selector step and leaves it to the cascade', () => {
// `Factory.new` denotes an INSTANCE of Factory, not the result of looking up
// a member named `new`. The cascade encodes that, together with the
// class-constant test that separates it from an instance method genuinely
// named `new`; a chain step records only a name, so the fold cannot make the
// distinction and must not try. Folding it turned a correct Ruby edge
// (`Factory.new.run` → `Factory#run`) into a wrong one (`Product.run`).
const decoded = decodeReceiverChain('1|svc|cnew');
expect(decoded).toBeDefined();
expect(
foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, {
fieldFallback: false,
hoistTypeBindingsToModule: true,
constructionSyntax: { selector: 'new' },
}),
).toBeUndefined();
});
it('does NOT climb to module scope when hoistTypeBindingsToModule is off', () => {
// The flag exists because TypeScript hoists a method's return-type binding
// OUT of the class body, so the fold must walk to Module scope to find it.
// Languages that do NOT hoist must not get that walk — climbing anyway is
// how an unrelated module-level binding of the same name gets picked up,
// which is exactly what the flag's own contract warns against. Same chain,
// opposite answers, so this pins the branch rather than the happy path.
expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: true })).toMatchObject({
qualifiedName: 'User',
});
expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined();
});
});

View file

@ -0,0 +1,83 @@
/**
* Which language emitters synthesize `@reference.receiver-chain`.
*
* The helper is self-gating — a non-call match, an absent receiver, or a chain
* with no nameable base all leave the match untouched — so wiring a language
* costs nothing where the shape does not occur. That makes the inventory easy to
* change silently, which is exactly why it is pinned: a new entry means someone
* wired a language without measuring it, and a removed entry means a refactor
* quietly dropped coverage.
*
* Source-level rather than behavioural, because the thing being pinned IS the
* wiring: a behavioural check would pass for a language whose fixture happens to
* contain no chained receiver.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const LANGUAGES_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'src',
'core',
'ingestion',
'languages',
);
function emittersCallingHelper(): string[] {
const out: string[] = [];
for (const entry of readdirSync(LANGUAGES_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const captures = path.join(LANGUAGES_DIR, entry.name, 'captures.ts');
let source: string;
try {
source = readFileSync(captures, 'utf8');
} catch {
continue; // no captures.ts (cobol/vue emit through another path)
}
if (source.includes('synthesizeReceiverChainCapture(')) out.push(entry.name);
}
return out.sort();
}
describe('receiver-chain capture wiring inventory', () => {
it('is wired into exactly the emitters that build a capture node map', () => {
expect(emittersCallingHelper()).toEqual([
'c',
'cpp',
'csharp',
'dart',
'go',
'java',
'javascript',
'kotlin',
'php',
'python',
'ruby',
'rust',
'swift',
'typescript',
]);
});
it('leaves emitters without a node map unwired', () => {
// These two do not group captures with their originating syntax nodes, so
// there is no receiver node to walk. Wiring them would need an emitter
// change, not a one-line call.
for (const language of ['cobol', 'vue'] as const) {
const captures = path.join(LANGUAGES_DIR, language, 'captures.ts');
let source = '';
try {
source = readFileSync(captures, 'utf8');
} catch {
continue;
}
expect(source, `${language} stays unwired`).not.toContain('synthesizeReceiverChainCapture(');
}
});
});

View file

@ -0,0 +1,101 @@
/**
* Unit tests for the enclosing-`mod` qualifier and the module-name filter
* (#2742, hardened by the #2745 review).
*
* The integration suite proves the resolution outcomes; these pin the two
* primitives directly, including the branches a fixture cannot reach — a scoped
* target with no enclosing `mod`, and an all-anchor qualifier.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Rust from 'tree-sitter-rust';
import {
qualifyByEnclosingModScope,
type SyntaxNode,
} from '../../../../src/core/ingestion/utils/ast-helpers.js';
import { couldNameAModule } from '../../../../src/core/ingestion/languages/rust/module-path.js';
const parser = new Parser();
parser.setLanguage(Rust as unknown as Parser.Language);
/** First node of `type` in a post-order walk — the innermost such node. */
function findNode(root: SyntaxNode, type: string): SyntaxNode {
const stack: SyntaxNode[] = [root];
const hits: SyntaxNode[] = [];
while (stack.length > 0) {
const node = stack.pop() as SyntaxNode;
if (node.type === type) hits.push(node);
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child !== null) stack.push(child);
}
}
const deepest = hits.sort((a, b) => b.startIndex - a.startIndex)[0];
expect(deepest).toBeDefined();
return deepest;
}
function qualify(code: string, nodeType: string, rawText: string): string {
const tree = parser.parse(code);
return qualifyByEnclosingModScope(findNode(tree.rootNode, nodeType), rawText);
}
describe('qualifyByEnclosingModScope', () => {
it('leaves an item at the crate root untouched', () => {
expect(qualify('fn dispatch() {}', 'function_item', 'dispatch')).toBe('dispatch');
});
it('prefixes one enclosing mod', () => {
expect(qualify('mod inner { fn dispatch() {} }', 'function_item', 'dispatch')).toBe(
'inner.dispatch',
);
});
it('prefixes the whole chain, outermost first', () => {
expect(
qualify('mod outer { mod tools { fn dispatch() {} } }', 'function_item', 'dispatch'),
).toBe('outer.tools.dispatch');
});
// The reason the no-enclosing-mod case returns raw text rather than
// normalizing: `splitQualifiedName` rewrites `::` to `.`, which would move a
// scoped impl target's node id away from the id its owner edge emits (#1975).
it('returns a SCOPED target verbatim when there is no enclosing mod', () => {
expect(qualify('impl a::Inner { fn helper(&self) {} }', 'impl_item', 'a::Inner')).toBe(
'a::Inner',
);
});
it('is the identity for an unscoped name, mod or no mod', () => {
expect(qualify('impl Inner { fn helper(&self) {} }', 'impl_item', 'Inner')).toBe('Inner');
});
});
describe('couldNameAModule', () => {
const known = new Set(['tools', 'inner']);
it('accepts a qualifier whose head names a known module', () => {
expect(couldNameAModule(['tools'], known)).toBe(true);
});
it('rejects a type-qualified call (`Vec::new`) whose head names no module', () => {
expect(couldNameAModule(['Vec'], known)).toBe(false);
});
it('tests the first NON-anchor segment, not the anchor', () => {
expect(couldNameAModule(['crate', 'tools'], known)).toBe(true);
expect(couldNameAModule(['crate', 'Vec'], known)).toBe(false);
expect(couldNameAModule(['super', 'inner'], known)).toBe(true);
});
// `self::f()` names the caller's own module, which is always worth trying —
// there is no head segment to test against the name set.
it('accepts an all-anchor qualifier', () => {
expect(couldNameAModule(['self'], known)).toBe(true);
expect(couldNameAModule(['crate'], known)).toBe(true);
});
it('accepts the macro-expansion spelling of the crate anchor', () => {
expect(couldNameAModule(['$crate', 'tools'], known)).toBe(true);
});
});

View file

@ -0,0 +1,129 @@
/**
* #2744 — the summary `impact()`/`context()` read to decide exact vs
* lower-bound. Keyed by member name because a dropped site's callee is
* unknown; see the module doc for why per-target attribution is impossible.
*/
import { describe, it, expect } from 'vitest';
import {
MAX_UNRESOLVED_RECEIVER_MEMBERS,
lookupUnresolvedCallCount,
summarizeUnresolvedReceivers,
} from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 };
function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): ResolutionOutcome {
return {
kind: 'suppressed',
reason: 'receiver-unresolved',
candidateIds: [],
phase: 'receiver-bound-calls',
filePath: 'a.py',
name,
range,
siteKind,
};
}
describe('summarizeUnresolvedReceivers', () => {
it('returns undefined when nothing was dropped, so a clean repo stores no key', () => {
expect(summarizeUnresolvedReceivers([])).toBeUndefined();
});
it('ignores suppressions that are not receiver-unresolved', () => {
const ambiguous: ResolutionOutcome = {
kind: 'suppressed',
reason: 'member-lookup-ambiguous',
candidateIds: ['a', 'b'],
phase: 'receiver-bound-calls',
filePath: 'a.py',
name: 'save',
range,
};
const resolved: ResolutionOutcome = {
kind: 'resolved',
targetId: 't',
phase: 'receiver-bound-calls',
filePath: 'a.py',
name: 'save',
range,
};
expect(summarizeUnresolvedReceivers([ambiguous, resolved])).toBeUndefined();
});
it('counts dropped sites per member name', () => {
expect(
summarizeUnresolvedReceivers([dropped('save'), dropped('save'), dropped('run')]),
).toMatchObject({
counts: { save: 2, run: 1 },
totalSites: 3,
});
});
it('caps the map, keeps the highest counts, and reports what it omitted', () => {
const outcomes: ResolutionOutcome[] = [];
// One name well past the cap that must survive on count alone.
for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest'));
for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) {
outcomes.push(dropped(`member${i}`));
}
const summary = summarizeUnresolvedReceivers(outcomes);
expect(Object.keys(summary!.counts)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS);
expect(summary!.counts.zzz_hottest).toBe(5);
// The true total always reflects every drop, not just the kept sample.
expect(summary!.totalSites).toBe(MAX_UNRESOLVED_RECEIVER_MEMBERS + 15);
expect(summary!.omittedNames).toBe(11);
});
it('orders deterministically so the persisted metadata does not churn', () => {
const a = summarizeUnresolvedReceivers([dropped('b'), dropped('a'), dropped('c')]);
const b = summarizeUnresolvedReceivers([dropped('c'), dropped('b'), dropped('a')]);
expect(Object.keys(a!.counts)).toEqual(Object.keys(b!.counts));
});
it('counts CALL sites only — a property read or write is not a dropped call', () => {
// Case 0's recorder gates on the receiver's punctuation, not on what the
// reference IS, so reads and writes land in the same bucket as lost calls
// (25 of 124 on the fixture corpus). Counting them made the consumer's
// "N call sites invoking X were dropped" literally false.
const summary = summarizeUnresolvedReceivers([
dropped('save', 'call'),
dropped('name', 'write'),
dropped('kind', 'read'),
]);
expect(summary).toMatchObject({ counts: { save: 1 }, totalSites: 1 });
expect(summary?.counts).not.toHaveProperty('name');
expect(summary?.counts).not.toHaveProperty('kind');
});
it('returns undefined when every drop is a property access', () => {
expect(summarizeUnresolvedReceivers([dropped('name', 'write')])).toBeUndefined();
});
it('does not leak Object.prototype members through the counts lookup', () => {
// `counts` is revived from JSON and carries `Object.prototype`, so a bare
// `counts[symName]` returns a FUNCTION for these names — and `NaN <= 0` is
// false, so a `<= 0` guard lets it through. `impact({target:"constructor"})`
// then reported `epistemic: 'lower-bound'` and interpolated
// `function Object() { [native code] }` as the call count.
const summary = summarizeUnresolvedReceivers([dropped('save')]);
for (const polluted of [
'constructor',
'toString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'toLocaleString',
'__proto__',
]) {
expect(lookupUnresolvedCallCount(summary, polluted)).toBeUndefined();
}
// A genuinely recorded name still reads back.
expect(lookupUnresolvedCallCount(summary, 'save')).toBe(1);
expect(lookupUnresolvedCallCount(summary, 'neverRecorded')).toBeUndefined();
expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined();
});
});

View file

@ -0,0 +1,88 @@
/**
* U5 — TypeScript emits `@reference.receiver-chain` for a receiver that is
* itself an expression.
*
* Capture-level, not resolution-level: nothing consumes the field yet, so these
* assert what is written, and that nothing extra is.
*/
import { describe, it, expect } from 'vitest';
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/index.js';
/** The chain captures produced for a source, keyed by the member being called. */
function chainsFor(src: string): Record<string, string> {
const out: Record<string, string> = {};
for (const match of emitTsScopeCaptures(src, 'main.ts')) {
const chain = match['@reference.receiver-chain'];
const name = match['@reference.name'];
if (chain === undefined || name === undefined) continue;
out[name.text] = chain.text;
}
return out;
}
const MODELS = `class Address { save(): void {} }
class User { name = ''; address = new Address(); save(): void {} }
class Service {
getUser(): User { return new User(); }
getTyped<T>(): User { return new User(); }
}
declare const svc: Service;
declare const repos: User[];
`;
describe('TypeScript receiver-chain capture', () => {
it('emits a chain for a plain call-chain receiver', () => {
expect(chainsFor(`${MODELS}\nsvc.getUser().save();\n`)).toMatchObject({
save: '1|svc|cgetUser',
});
});
it('emits a mixed call/field chain base-first', () => {
expect(chainsFor(`${MODELS}\nsvc.getUser().address.save();\n`)).toMatchObject({
save: '1|svc|cgetUser|faddress',
});
});
it('emits a chain for an optional-chained receiver — one of the shapes that resolves to nothing today', () => {
expect(chainsFor(`${MODELS}\nsvc?.getUser().save();\n`)).toMatchObject({
save: '1|svc|cgetUser',
});
});
it('emits a chain for an explicit-type-argument receiver', () => {
expect(chainsFor(`${MODELS}\nsvc.getTyped<User>().save();\n`)).toMatchObject({
save: '1|svc|cgetTyped',
});
});
it('emits no chain for a bare-name receiver — there is nothing to fold', () => {
expect(chainsFor(`${MODELS}\nconst u = new User();\nu.save();\n`)).toEqual({});
});
it('emits no chain for a property read or write receiver (CALL_TAGS gate)', () => {
// Without the gate these pay the walk and the bytes for a field no
// call-site resolver reads.
const chains = chainsFor(
`${MODELS}\nsvc.getUser().name = 'x';\nconst n = svc.getUser().name;\n`,
);
expect(chains).not.toHaveProperty('name');
});
it('emits a plain string payload, so the capture stays structured-clone safe', () => {
const chains = chainsFor(`${MODELS}\nsvc.getUser().save();\n`);
expect(typeof chains.save).toBe('string');
});
it('emits NO chain past MAX_CHAIN_DEPTH rather than a truncated one', () => {
// `extractMixedChain` reports no base receiver when it stops early, and a
// partial chain is missing exactly the head that decides the final type —
// encoding it would turn an untypeable receiver into a wrongly-typed one.
const deep = `${MODELS}
class C5 { e(): C5 { return this; } f(): void {} }
declare const a: C5;
a.e().e().e().e().f();
`;
expect(chainsFor(deep)).not.toHaveProperty('f');
});
});