mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
Merge branch 'main' into dependabot/npm_and_yarn/gitnexus/express-rate-limit-8.6.1
This commit is contained in:
commit
e32abfed8a
60 changed files with 3052 additions and 121 deletions
15
.github/workflows/ci-tests.yml
vendored
15
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
f120df925e3b92e78212003e10ae3025dfa3130955fa38cf801b91077dca0783
|
||||
ec879302c3257418edb52c3bc2c1ac0e43d40e742319c493218b7d0ff3466483
|
||||
|
|
|
|||
244
gitnexus/bench/receiver-resolution/BASELINE.md
Normal file
244
gitnexus/bench/receiver-resolution/BASELINE.md
Normal 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.
|
||||
44
gitnexus/bench/receiver-resolution/baseline.json
Normal file
44
gitnexus/bench/receiver-resolution/baseline.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
492
gitnexus/bench/receiver-resolution/measure.mjs
Normal file
492
gitnexus/bench/receiver-resolution/measure.mjs
Normal 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));
|
||||
}
|
||||
|
|
@ -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,21 +36,23 @@
|
|||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget."
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe",
|
||||
"fingerprint": "8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300",
|
||||
"fingerprint": "83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
|
||||
|
|
@ -57,32 +60,36 @@
|
|||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.",
|
||||
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it."
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd",
|
||||
"fingerprint": "3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.",
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558",
|
||||
"fingerprint": "fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget."
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248",
|
||||
"fingerprint": "a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget."
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
|
||||
|
|
@ -95,7 +102,7 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
|
||||
"fingerprint": "310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
|
|
@ -106,15 +113,17 @@
|
|||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
|
||||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
|
||||
"fingerprint": "3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000",
|
||||
"fingerprint": "9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
|
||||
|
|
@ -123,10 +132,11 @@
|
|||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354",
|
||||
"fingerprint": "83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
@ -135,10 +145,11 @@
|
|||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
|
||||
"fingerprint": "d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
|
|
@ -147,6 +158,7 @@
|
|||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) ──
|
||||
|
|
|
|||
68
gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts
Normal file
68
gitnexus/src/core/ingestion/utils/receiver-chain-captures.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
154
gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts
Normal file
154
gitnexus/src/core/ingestion/utils/receiver-chain-codec.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,7 +128,19 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// JLS 13.1 immediate-host chains (#2555).
|
||||
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
|
||||
// v16: direct callee identity.
|
||||
const SCHEMA_BUMP = 32;
|
||||
// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not
|
||||
// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to
|
||||
// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by
|
||||
// an intermediate build of this series is not equivalent to one stamped at this
|
||||
// commit — it would be treated as current while every non-TypeScript file
|
||||
// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14
|
||||
// languages. Bumped here so the version tracks the FINAL capture set rather than
|
||||
// the first divergence.
|
||||
// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact
|
||||
// encoding of a receiver that is itself an expression. (Shipped as v32 on its own
|
||||
// branch; `main` took 32 for #2742 first, so this series is renumbered — see the
|
||||
// v21 note on re-checking against origin/main at MERGE time, not branch time.)
|
||||
const SCHEMA_BUMP = 34;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,6 +602,19 @@ export interface RepoMeta {
|
|||
* the same-commit "already up to date" fast path — keeps returning the pre-fix
|
||||
* graph for every unchanged file, which is exactly the missing-caller symptom
|
||||
* #2708 reported. Force a full re-analyze.
|
||||
*
|
||||
* v26: unresolved-receiver member names are persisted
|
||||
* (`unresolvedReceiverMembers`) so `impact()`/`context()` can report
|
||||
* `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site
|
||||
* was dropped for want of a receiver type (#2744). A pre-v26 index carries no
|
||||
* such summary, and an absent summary is indistinguishable from "nothing was
|
||||
* dropped" — so topping one up incrementally would keep reporting `exact` for
|
||||
* exactly the symbols the signal exists to flag. Force a full re-analyze.
|
||||
*
|
||||
* (This shipped as v25 on its own branch; `main` took 25 for #2742 first, so it
|
||||
* is renumbered here. Re-check both constants against origin/main immediately
|
||||
* before merging — this is the fourth time that collision has bitten.)
|
||||
*
|
||||
* v25: Rust items are qualified by their enclosing `mod` chain (#2742), so
|
||||
* `mod inner { fn dispatch }` and a crate-root `fn dispatch` in one file are
|
||||
* finally DISTINCT nodes instead of collapsing onto `Function:<file>:dispatch`
|
||||
|
|
@ -572,7 +623,7 @@ export interface RepoMeta {
|
|||
* index holds ids an incremental top-up cannot reconcile and would simply
|
||||
* strand. Force a full re-analyze.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 25;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 28;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/main.cpp
vendored
Normal 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();
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/cpp-receiver-chain-arrow/models.h
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#pragma once
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
};
|
||||
|
||||
class Service {
|
||||
public:
|
||||
User* getUser();
|
||||
};
|
||||
7
gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/app.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const { Legacy } = require('./svc');
|
||||
|
||||
function viaInlineNewJs() {
|
||||
return new Legacy().doWork();
|
||||
}
|
||||
|
||||
module.exports = { viaInlineNewJs };
|
||||
7
gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/javascript-inline-constructor-receiver/svc.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class Legacy {
|
||||
doWork() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Legacy };
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const { LegacyService } = require('./svc');
|
||||
|
||||
function viaInlineNewJs() {
|
||||
return new LegacyService().doWork();
|
||||
}
|
||||
|
||||
module.exports = { viaInlineNewJs };
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
class LegacyService {
|
||||
doWork() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { LegacyService };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@
|
|||
},
|
||||
"rust-chain-call/src/main.rs": {
|
||||
"captureGroups": 25,
|
||||
"digest": "4b47bbd3de939f8692c1615442e233ad00440075fafc5b6a7f7db35851492a64"
|
||||
"digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421"
|
||||
},
|
||||
"rust-chain-call/src/models/mod.rs": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -269,7 +269,7 @@
|
|||
},
|
||||
"rust-deep-field-chain/service.rs": {
|
||||
"captureGroups": 16,
|
||||
"digest": "49d6a6bff4bfde6d29d8e63c2899f49688bee98273c9f743d9189dbfdf19ce2c"
|
||||
"digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a"
|
||||
},
|
||||
"rust-default-constructor/src/main.rs": {
|
||||
"captureGroups": 36,
|
||||
|
|
@ -381,7 +381,7 @@
|
|||
},
|
||||
"rust-field-types/service.rs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "5bba21d0f792eb98025ce0531c575dac215492027cd877df09b19877e80da4ad"
|
||||
"digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f"
|
||||
},
|
||||
"rust-for-call-expr/src/main.rs": {
|
||||
"captureGroups": 26,
|
||||
|
|
@ -617,7 +617,7 @@
|
|||
},
|
||||
"rust-nullable-receiver/src/main.rs": {
|
||||
"captureGroups": 41,
|
||||
"digest": "811410fc6afe4722331ff1edfc79ce38a94c241ae52b86c10511d65d8603ef19"
|
||||
"digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05"
|
||||
},
|
||||
"rust-nullable-receiver/src/repo.rs": {
|
||||
"captureGroups": 10,
|
||||
|
|
@ -729,7 +729,7 @@
|
|||
},
|
||||
"rust-self-struct-literal/models.rs": {
|
||||
"captureGroups": 32,
|
||||
"digest": "8928de22daa88f6f311a407e0051d1dbd3e52c81503f4b295eecb3a3c86b23cf"
|
||||
"digest": "f9c89cb8c9f2b9c10140ae6971a09756b27112ab61d483b9ffe7fbcb36fefa8d"
|
||||
},
|
||||
"rust-self-this-resolution/src/repo.rs": {
|
||||
"captureGroups": 10,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
},
|
||||
"swift-field-types/App.swift": {
|
||||
"captureGroups": 7,
|
||||
"digest": "651c5cbbf9afdc0bf4d96217bc1a01cf0bf07a0190ca12a3a6786bb08a67b3ad"
|
||||
"digest": "97750185ce98b162ecaed76917408e715756a9256e1bc961a6439815db3c0715"
|
||||
},
|
||||
"swift-field-types/Models.swift": {
|
||||
"captureGroups": 20,
|
||||
|
|
|
|||
|
|
@ -4548,3 +4548,33 @@ describe('C++ deleted overload selection (#1893 A2)', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural receiver typing for a `->` base receiver.
|
||||
//
|
||||
// The all-language rollout is proven behaviourally for TypeScript only; this is
|
||||
// the second language, and the one the rollout measurably changed: C++
|
||||
// `svc->getUser()->save()` emitted NO edge and recorded NO drop before it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C++ structural receiver chain through a pointer base', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-receiver-chain-arrow'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves svc->getUser()->save() — the `->` base the text cascade could not parse', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(
|
||||
calls.find((c) => c.source === 'pointerArrowChain' && c.target === 'save'),
|
||||
).toMatchObject({ target: 'save' });
|
||||
});
|
||||
|
||||
it('keeps the value-dot base resolving (control — worked before this work)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'valueDotChain' && c.target === 'save')).toMatchObject({
|
||||
target: 'save',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 25 (Rust mod-qualified node ids, #2742)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 28 (structural receiver typing, all languages)', () => {
|
||||
// Moves with every bump BY DESIGN — that is the point of pinning it. A
|
||||
// change that alters emitted ids or edges without bumping would otherwise
|
||||
// ship silently, and an existing index would keep serving the old graph
|
||||
// through the reuse gate below.
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(25);
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(28);
|
||||
});
|
||||
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
|
|
@ -175,14 +175,30 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
|||
// (#2730): every unchanged Rust file would keep the same-name self-loop and
|
||||
// keep reporting the real callee as unreached → must NOT reuse.
|
||||
expect(passesReuseGate(22)).toBe(false);
|
||||
// A pre-v24 (v23) index predates
|
||||
// A pre-v24 (v23) index predates the #2708 inline-constructor receivers.
|
||||
expect(passesReuseGate(23)).toBe(false);
|
||||
// The current stamp passes the gate (incremental top-up eligible).
|
||||
// A pre-v25 (v24) index predates mod-qualified Rust node ids (#2742): every
|
||||
// item inside a `mod` block has a different id now, so a top-up would strand
|
||||
// the old nodes → must NOT reuse.
|
||||
// A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An
|
||||
// absent summary is indistinguishable from "nothing was dropped", so a
|
||||
// top-up would keep reporting `epistemic: 'exact'` for exactly the symbols
|
||||
// whose callers were dropped → must NOT reuse.
|
||||
expect(passesReuseGate(24)).toBe(false);
|
||||
// The current stamp passes (incremental top-up eligible).
|
||||
expect(passesReuseGate(25)).toBe(true);
|
||||
// A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`,
|
||||
// `svc!.m().n()` and `svc.m<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(28)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,11 +101,14 @@ describe('fileContentHash', () => {
|
|||
});
|
||||
|
||||
describe('PARSE_CACHE_VERSION', () => {
|
||||
// 31 -> 32 for the mod-qualified Rust node ids (#2742). Updated deliberately:
|
||||
// this pin exists so a bump cannot ride along unnoticed, and it caught the
|
||||
// collision when the pin arrived from main while this branch already held 32.
|
||||
it('pins SCHEMA_BUMP to 32 so concurrent bumps cannot silently collide (#2736)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(32);
|
||||
// 32 -> 34 for the receiver-chain capture (#2747): 33 is the TypeScript-only
|
||||
// emission, 34 the rollout to the other 13 emitters, which changed the capture
|
||||
// set a second time. Updated deliberately — and this pin earned its keep twice
|
||||
// over: it caught the #2742 collision when it arrived from main, and it caught
|
||||
// this series again on rebase, where `main` had independently taken 32 for the
|
||||
// very number this branch was already using.
|
||||
it('pins SCHEMA_BUMP to 34 so concurrent bumps cannot silently collide (#2736)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(34);
|
||||
});
|
||||
|
||||
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
160
gitnexus/test/unit/receiver-chain-codec.test.ts
Normal file
160
gitnexus/test/unit/receiver-chain-codec.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
159
gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts
Normal file
159
gitnexus/test/unit/scope-resolution/receiver-chain-fold.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(');
|
||||
}
|
||||
});
|
||||
});
|
||||
129
gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts
Normal file
129
gitnexus/test/unit/scope-resolution/unresolved-receivers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
88
gitnexus/test/unit/ts-receiver-chain-capture.test.ts
Normal file
88
gitnexus/test/unit/ts-receiver-chain-capture.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue