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-web/babel/types-8.0.4
This commit is contained in:
commit
ff73231104
82 changed files with 4129 additions and 214 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,6 +1,6 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/callable-value-flow/measure.mjs --check (#2693). `fingerprint` is an order-independent sha256 over every (defNodeId -> graphId) pair buildGraphTargetIndex resolves on the synthetic corpus; it is a CORRECTNESS gate, so drift means the callable-value target set moved and must be explained, never re-baselined to make CI green. The two budgets are timing gates and carry deliberate headroom for shared CI runners.",
|
||||
"fingerprint": "70bebf6a26ff6fc9f231a0933678274b44c4883ddab5e719a61a9c77d6223e51",
|
||||
"_comment": "Baselines for bench/callable-value-flow/measure.mjs --check (#2693). `fingerprint` is an order-independent sha256 over every (defNodeId -> graphId) pair buildGraphTargetIndex resolves on the synthetic corpus; it is a CORRECTNESS gate, so drift means the callable-value target set moved and must be explained, never re-baselined to make CI green. The fingerprint changed when the synthetic corpus adopted production-shaped def ids; target cardinality remains 4000 (3200 callable-only plus 800 value bindings). The two budgets are timing gates and carry deliberate headroom for shared CI runners.",
|
||||
"fingerprint": "6599dda7d0ee5942e1995a1dcfb137c312eb8e4690bc82a8bbff429f08bd839d",
|
||||
"scaling_budget": 1.6,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250). ~1.0 is linear; measured 1.14-1.16. The index build is one pass over defs plus map lookups, so a jump toward 3.x means someone made the per-def work depend on corpus size (e.g. a scan inside the loop).",
|
||||
"widening_overhead_budget": 1.9,
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ function buildCorpus(fileCount) {
|
|||
return id;
|
||||
};
|
||||
const addDef = (type, filePath, qualifiedName, line) => {
|
||||
const nodeId = `${filePath}#${line}:0:${qualifiedName}`;
|
||||
const nodeId = `def:${filePath}#${line}:0:${type}:${qualifiedName}`;
|
||||
defs.set(nodeId, { nodeId, type, filePath, qualifiedName });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,20 +36,23 @@
|
|||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget."
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe",
|
||||
"fingerprint": "8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855."
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5",
|
||||
"fingerprint": "83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
|
||||
|
|
@ -56,32 +60,36 @@
|
|||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.",
|
||||
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it."
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd",
|
||||
"fingerprint": "3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.",
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected."
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558",
|
||||
"fingerprint": "fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget."
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248",
|
||||
"fingerprint": "a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget."
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
|
||||
|
|
@ -94,7 +102,7 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197",
|
||||
"fingerprint": "310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
|
|
@ -105,15 +113,17 @@
|
|||
"_rebaselined_2555_enum_constant_bodies": "PR for #2555: enum constant bodies emit synthesized E$N classes + @reference.inherits to the host enum; anonymous naming follows JLS 13.1 immediately-enclosing-type chains INCLUDING anonymous enclosing types (NestHost$1$1, N$1$1); six new java-* fixtures joined the corpus. Prior d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90 -> 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca; scaling 1.05 < 1.5.",
|
||||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5."
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b",
|
||||
"fingerprint": "3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000",
|
||||
"fingerprint": "9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
|
||||
|
|
@ -122,10 +132,11 @@
|
|||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354",
|
||||
"fingerprint": "83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
@ -134,10 +145,11 @@
|
|||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
|
||||
"fingerprint": "d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
|
|
@ -146,6 +158,7 @@
|
|||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,10 +49,11 @@ export interface RustModuleIndex {
|
|||
/** Crate-root directories, longest first, so nested crates win. */
|
||||
readonly crateRoots: readonly string[];
|
||||
/**
|
||||
* Every module name that exists anywhere in the workspace, as a flat set of
|
||||
* single segments. Used only as a fast negative filter: a qualifier whose head
|
||||
* is not in here cannot name a workspace module, so type-qualified calls
|
||||
* (`Vec::new()`, `String::from()`) are rejected before any candidate search.
|
||||
* Every module name derivable from a FILE PATH, as a flat set of single
|
||||
* segments. Inline `mod x { … }` names are absent by construction — they exist
|
||||
* in no path — so this is only half of the negative filter's input; the
|
||||
* resolver unions it with the inline module names it collects from the scope
|
||||
* model (#2742).
|
||||
*/
|
||||
readonly moduleNames: ReadonlySet<string>;
|
||||
/**
|
||||
|
|
@ -125,12 +126,15 @@ export function buildRustModuleIndex(allFilePaths: ReadonlySet<string>): RustMod
|
|||
* to test is the first non-anchor segment); an all-anchor qualifier (`self::f()`)
|
||||
* names the caller's own module and is always worth trying.
|
||||
*/
|
||||
export function couldNameAModule(qualifier: readonly string[], index: RustModuleIndex): boolean {
|
||||
export function couldNameAModule(
|
||||
qualifier: readonly string[],
|
||||
knownModuleNames: ReadonlySet<string>,
|
||||
): boolean {
|
||||
for (const segment of qualifier) {
|
||||
if (segment === 'crate' || segment === '$crate' || segment === 'self' || segment === 'super') {
|
||||
continue;
|
||||
}
|
||||
return index.moduleNames.has(segment);
|
||||
return knownModuleNames.has(segment);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { isOverloadableCallable } from '../../utils/callable-labels.js';
|
||||
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js';
|
||||
import {
|
||||
|
|
@ -88,15 +89,25 @@ export function resolveRustQualifiedFreeCall(
|
|||
const qualifier = segments.slice(0, -1);
|
||||
if (qualifier.length === 0) return undefined;
|
||||
|
||||
// Cheap rejection BEFORE any index work. The capture that carries
|
||||
// Cheap rejection before the candidate SEARCH. The capture that carries
|
||||
// `rawQualifiedName` matches every `scoped_identifier` callee, so this hook is
|
||||
// reached by `Vec::new()`, `String::from()`, `Self::method()` and every other
|
||||
// type-qualified call — the overwhelming majority of `::` calls in real Rust,
|
||||
// none of which name a module. Letting those run the full candidate search
|
||||
// meant each one paid a same-name-bucket scan plus a walk of every module
|
||||
// scope in the workspace before returning undefined (#2741 review).
|
||||
// none of which name a module. Letting those through meant each one paid a
|
||||
// same-name-bucket scan plus a walk of every module scope in the workspace
|
||||
// before returning undefined (#2741 review).
|
||||
//
|
||||
// `passIndexFor` is not free on its FIRST call in a pass — it walks the def
|
||||
// index once — so this is not "before any index work", as an earlier version of
|
||||
// this comment claimed. It is memoized per resolution pass on a WeakMap, over
|
||||
// structures already resident in memory, and every later site here is a set
|
||||
// lookup. What the filter still buys is skipping the per-site candidate search,
|
||||
// which is the part that scales with the workspace.
|
||||
const index = moduleIndexFor(allFilePaths);
|
||||
if (!couldNameAModule(qualifier, index)) return undefined;
|
||||
const pass = passIndexFor(workspaceIndex, index, scopes);
|
||||
if (!couldNameAModule(qualifier, pass.knownModuleNames)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const callerModule = callerModuleOf(callerParsed, site.inScope, scopes, index);
|
||||
if (callerModule === undefined) return undefined;
|
||||
|
|
@ -183,7 +194,10 @@ function* candidateModules(
|
|||
// A `mod` declaration — inline or file-backed — emits a `Namespace` def
|
||||
// bound in the declaring scope, so the check is a binding lookup.
|
||||
const head = qualifier[0];
|
||||
if (head !== undefined && declaresSubmodule(callerParsed, head, workspaceIndex)) {
|
||||
if (
|
||||
head !== undefined &&
|
||||
declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes)
|
||||
) {
|
||||
yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] };
|
||||
}
|
||||
|
||||
|
|
@ -213,21 +227,96 @@ function* candidateModules(
|
|||
|
||||
// 3. Crate-root-relative (`a::b::f()` written from a nested module — 2015
|
||||
// edition style, and still what a single-file crate looks like).
|
||||
if (callerModule.segments.length > 0) {
|
||||
//
|
||||
// Skipped when the head already names something else in the caller's own
|
||||
// module. This is the loosest candidate — a guess at a path the caller never
|
||||
// wrote — and in Rust 2018 a bare first segment resolves in the caller's
|
||||
// module, not at the crate root, so a local binding for it settles the
|
||||
// question. Without the check, a crate-root `mod` whose name matches an
|
||||
// imported TYPE captured the call:
|
||||
//
|
||||
// // src/lib.rs
|
||||
// pub mod Buffer { pub fn with_capacity() -> usize { 111 } }
|
||||
// // src/b.rs
|
||||
// use crate::c::Buffer; // the real target, in c.rs
|
||||
// pub fn call() -> usize { Buffer::with_capacity() }
|
||||
//
|
||||
// which yielded `CALLS b::call -> lib.rs:Buffer.with_capacity`, an edge to a
|
||||
// callee the source never names. The base emitted no edge at all, and per the
|
||||
// doctrine quoted in `ids.ts` that is the correct failure direction: a missing
|
||||
// edge is recoverable, a fabricated caller silently misleads `impact`.
|
||||
if (callerModule.segments.length > 0 && !headBoundLocally(callerParsed, head, scopes)) {
|
||||
yield { crateRoot: callerModule.crateRoot, segments: [...qualifier] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the calling file declare `name` as a submodule (`mod name;` or
|
||||
* `mod name { … }`)? Both forms emit a `Namespace` def bound locally in the
|
||||
* declaring scope, so this is a binding lookup rather than a filesystem probe.
|
||||
* Is `name` bound in the caller's own module to anything that is NOT a module?
|
||||
*
|
||||
* A `use crate::c::Buffer;` binds the TYPE `Buffer`, so a bare `Buffer::…` path in
|
||||
* that file resolves through the import — never to a same-named module elsewhere in
|
||||
* the crate. Module bindings are excluded so the legitimate `use crate::tools;`
|
||||
* case, which candidate 2 already handles, is not double-counted here.
|
||||
*/
|
||||
function headBoundLocally(
|
||||
callerParsed: ParsedFile,
|
||||
name: string | undefined,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): boolean {
|
||||
if (name === undefined) return false;
|
||||
// Read through `lookupBindingsAt`, not the raw `Scope.bindings` map: a `use`
|
||||
// binding is finalize OUTPUT and is absent from the scope's own local table, so
|
||||
// the direct read saw nothing for exactly the imported-type case this guards
|
||||
// (contract I8 in `contract/scope-resolver.ts` requires this channel anyway).
|
||||
for (const ref of lookupBindingsAt(callerParsed.moduleScope, name, scopes)) {
|
||||
if (ref.def.type !== 'Namespace') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Tail segment of a dot-joined qualified name (`outer.tools` → `tools`). */
|
||||
function tailSegment(qualifiedName: string): string {
|
||||
return qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the caller's own module declare `name` as a submodule (`mod name;` or
|
||||
* `mod name { … }`)?
|
||||
*
|
||||
* Answered against the set of module paths the workspace actually DECLARES, so it
|
||||
* holds at any nesting depth. The previous binding lookup went through
|
||||
* `moduleScopeByFile`, which maps a file to its root `Module` scope only: a `mod`
|
||||
* nested inside an inline `mod` binds in that parent module's scope and was
|
||||
* therefore invisible. `mod outer { mod tools { … } fn dispatch() { tools::dispatch() } }`
|
||||
* skipped this candidate entirely and fell through to the lexical tier, which
|
||||
* reinstated the very #2730 self-loop this hook exists to prevent.
|
||||
*
|
||||
* Still a declaration check, not a filesystem probe: the set is built from
|
||||
* `Namespace` defs, so an undeclared (or `cfg`-gated) file sitting on disk
|
||||
* contributes nothing and cannot outrank a real `use` binding (#2741 review).
|
||||
*/
|
||||
function declaresSubmodule(
|
||||
callerParsed: ParsedFile,
|
||||
callerModule: RustModule,
|
||||
name: string,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): boolean {
|
||||
// Inline modules at any depth, keyed by full path.
|
||||
const inline = passIndexFor(workspaceIndex, index, scopes).inlineModuleKeys;
|
||||
if (
|
||||
inline.has(
|
||||
moduleKey({ crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, name] }),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// File-backed submodules (`mod tools;`) still go through the declaring scope's
|
||||
// binding table. A `Namespace` def bound locally is the DECLARATION; probing the
|
||||
// file set instead would let an undeclared or `cfg`-gated file on disk outrank a
|
||||
// real `use` binding (#2741 review).
|
||||
const moduleScope = workspaceIndex.moduleScopeByFile.get(callerParsed.filePath);
|
||||
if (moduleScope === undefined) return false;
|
||||
for (const ref of moduleScope.bindings.get(name) ?? []) {
|
||||
|
|
@ -345,7 +434,7 @@ function findReexportedMember(
|
|||
let unique: SymbolDefinition | undefined;
|
||||
let count = 0;
|
||||
|
||||
for (const moduleScope of moduleScopesFor(targetModule, workspaceIndex, index)) {
|
||||
for (const moduleScope of moduleScopesFor(targetModule, workspaceIndex, index, scopes)) {
|
||||
for (const edge of scopes.imports.get(moduleScope.id) ?? []) {
|
||||
if (edge.localName !== name) continue;
|
||||
// Only a `pub use` re-exports. A private `use` (`named`) makes the name
|
||||
|
|
@ -380,35 +469,106 @@ function findReexportedMember(
|
|||
*
|
||||
* Keyed on the `WorkspaceResolutionIndex` identity, which is rebuilt per pass.
|
||||
*/
|
||||
const MODULE_SCOPE_CACHE = new WeakMap<
|
||||
WorkspaceResolutionIndex,
|
||||
ReadonlyMap<string, readonly Scope[]>
|
||||
>();
|
||||
interface PassModuleIndex {
|
||||
/** Module identity key → the file-module scopes realising it. */
|
||||
readonly scopesByModule: ReadonlyMap<string, readonly Scope[]>;
|
||||
/**
|
||||
* Every module name resolution may legitimately see: the file-derived names
|
||||
* from the path index, UNIONED with inline `mod x { … }` names, which exist in
|
||||
* no file path and would otherwise be rejected by the negative filter before
|
||||
* any candidate ran (#2742).
|
||||
*/
|
||||
readonly knownModuleNames: ReadonlySet<string>;
|
||||
/**
|
||||
* `moduleKey` of every INLINE module path in the workspace, at any depth.
|
||||
* Distinct from `knownModuleNames`, a flat name set used only as a negative
|
||||
* filter: this one answers "is `<callerModule>::<name>` a real inline
|
||||
* submodule", which the name set cannot (two unrelated modules share a tail).
|
||||
*/
|
||||
readonly inlineModuleKeys: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const MODULE_SCOPE_CACHE = new WeakMap<WorkspaceResolutionIndex, PassModuleIndex>();
|
||||
|
||||
function moduleKey(module: RustModule): string {
|
||||
return `${module.crateRoot}\u0000${module.segments.join('::')}`;
|
||||
}
|
||||
|
||||
function passIndexFor(
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): PassModuleIndex {
|
||||
let pass = MODULE_SCOPE_CACHE.get(workspaceIndex);
|
||||
if (pass === undefined) {
|
||||
const scopesByModule = new Map<string, Scope[]>();
|
||||
const knownModuleNames = new Set<string>(index.moduleNames);
|
||||
|
||||
// Inline `mod x { … }` never appears in a file path, so its name exists only
|
||||
// as a `Namespace` def. Read from the def index rather than from module-scope
|
||||
// bindings: `moduleScopeByFile` holds each file's ROOT `Module` scope only, so
|
||||
// a binding walk saw depth-1 inline modules and missed every nested one —
|
||||
// `mod outer { mod tools { … } }` left `tools` out of the set, and this gate
|
||||
// then rejected `tools::dispatch()` before any module walk could resolve it.
|
||||
//
|
||||
// One pass over the defs, memoized per resolution pass on the WeakMap above,
|
||||
// over an already-resident in-memory index — the same order of work as the
|
||||
// binding walk it replaces, and it subsumes it (a `mod` declaration at any
|
||||
// depth is a `Namespace` def).
|
||||
//
|
||||
// The same pass records every INLINE module path, which is what
|
||||
// `declaresSubmodule` needs at depth > 1.
|
||||
//
|
||||
// Derived from the MEMBERS, not from the `Namespace` defs: a `mod` def carries
|
||||
// no nesting information of its own (`mod outer { mod tools { … } }` gives the
|
||||
// inner def `qualifiedName: 'tools'` and NO `namespacePrefix`), whereas every
|
||||
// def inside it is stamped `outer.tools` by `tagNamespacePrefixes`. A
|
||||
// `Namespace` scope also owns its OWN def rather than its children's, so
|
||||
// walking scopes cannot answer this either — the `mod outer` scope lists
|
||||
// `outer`, never `tools`.
|
||||
//
|
||||
// Restricted to non-empty prefixes on purpose: those come from `mod` blocks in
|
||||
// source, so this stays a DECLARATION check. Including file-derived modules
|
||||
// here would let an undeclared (or `cfg`-gated) file on disk outrank a real
|
||||
// `use` binding — the #2741 review regression. File-backed submodules keep
|
||||
// going through the binding check in `declaresSubmodule`.
|
||||
//
|
||||
// A module containing no defs at all is absent, which is harmless: it has no
|
||||
// member for a qualified call to resolve to.
|
||||
const inlineModuleKeys = new Set<string>();
|
||||
for (const def of scopes.defs.byId.values()) {
|
||||
if (def.type === 'Namespace' && def.qualifiedName !== undefined) {
|
||||
knownModuleNames.add(tailSegment(def.qualifiedName));
|
||||
}
|
||||
if (def.namespacePrefix === undefined || def.namespacePrefix === '') continue;
|
||||
const declaringModule = moduleOfDef(def.filePath, def.namespacePrefix, index);
|
||||
if (declaringModule !== undefined) inlineModuleKeys.add(moduleKey(declaringModule));
|
||||
}
|
||||
|
||||
for (const [filePath, moduleScope] of workspaceIndex.moduleScopeByFile) {
|
||||
const fileModule = moduleOfFile(filePath, index);
|
||||
if (fileModule === undefined) continue;
|
||||
const key = moduleKey(fileModule);
|
||||
const bucket = scopesByModule.get(key);
|
||||
if (bucket === undefined) scopesByModule.set(key, [moduleScope]);
|
||||
else bucket.push(moduleScope);
|
||||
}
|
||||
pass = { scopesByModule, knownModuleNames, inlineModuleKeys };
|
||||
MODULE_SCOPE_CACHE.set(workspaceIndex, pass);
|
||||
}
|
||||
return pass;
|
||||
}
|
||||
|
||||
function moduleScopesFor(
|
||||
targetModule: RustModule,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly Scope[] {
|
||||
let byModule = MODULE_SCOPE_CACHE.get(workspaceIndex);
|
||||
if (byModule === undefined) {
|
||||
const built = new Map<string, Scope[]>();
|
||||
for (const [filePath, moduleScope] of workspaceIndex.moduleScopeByFile) {
|
||||
const fileModule = moduleOfFile(filePath, index);
|
||||
if (fileModule === undefined) continue;
|
||||
const key = moduleKey(fileModule);
|
||||
const bucket = built.get(key);
|
||||
if (bucket === undefined) built.set(key, [moduleScope]);
|
||||
else bucket.push(moduleScope);
|
||||
}
|
||||
byModule = built;
|
||||
MODULE_SCOPE_CACHE.set(workspaceIndex, byModule);
|
||||
}
|
||||
return byModule.get(moduleKey(targetModule)) ?? EMPTY_SCOPES;
|
||||
return (
|
||||
passIndexFor(workspaceIndex, index, scopes).scopesByModule.get(moduleKey(targetModule)) ??
|
||||
EMPTY_SCOPES
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_SCOPES: readonly Scope[] = Object.freeze([]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
} from '../../utils/callable-labels.js';
|
||||
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
|
||||
import { parameterShapeIdTag } from '../../utils/method-props.js';
|
||||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
|
|
@ -99,9 +100,9 @@ function scopeIsCallableBody(
|
|||
range: { startLine: number; startCol: number },
|
||||
def: SymbolDefinition,
|
||||
): boolean {
|
||||
const m = def.nodeId.match(/#(\d+):(\d+):/);
|
||||
if (m === null) return false;
|
||||
return Number(m[1]) === range.startLine && Number(m[2]) === range.startCol;
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
if (position === undefined) return false;
|
||||
return position.line === range.startLine && position.column === range.startCol;
|
||||
}
|
||||
|
||||
/** Pick the callable that owns `atRange` when multiple overloads share a class scope. */
|
||||
|
|
@ -165,12 +166,8 @@ function pickCallerCallableDef(
|
|||
* Extract the 1-based declaration line from a scope-resolution def id.
|
||||
* Shape: `def:<filePath>#<line>:<col>:<...>`; `undefined` when it doesn't match.
|
||||
*/
|
||||
function defStartLine(nodeId: string | undefined): number | undefined {
|
||||
if (nodeId === undefined) return undefined;
|
||||
const m = nodeId.match(/#(\d+):(\d+):/);
|
||||
if (m === null) return undefined;
|
||||
const line = Number(m[1]);
|
||||
return Number.isFinite(line) ? line : undefined;
|
||||
function defStartLine(nodeId: string | undefined, filePath: string): number | undefined {
|
||||
return definitionIdPosition(nodeId, filePath)?.line;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -221,7 +218,7 @@ export function resolveDefGraphId(
|
|||
// without either side having to model the scope chain. Node ids are
|
||||
// 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two
|
||||
// callables on one line) falls through to the name-based keys below.
|
||||
const line = defStartLine(def.nodeId);
|
||||
const line = defStartLine(def.nodeId, filePath);
|
||||
if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) {
|
||||
const simple = simpleNameOf(qn);
|
||||
const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple));
|
||||
|
|
@ -240,6 +237,36 @@ export function resolveDefGraphId(
|
|||
return undefined;
|
||||
}
|
||||
}
|
||||
// Name forms to try for every keyed lookup below, most specific first.
|
||||
//
|
||||
// #1982/#2742: some scope-extractors qualify a def by its enclosing CLASS
|
||||
// chain (`A.Inner`) or leave it a bare tail, while the structure-phase node is
|
||||
// keyed by the full path (`NS.A.Inner`, or a Rust `mod` chain). The
|
||||
// namespace-prefixed form is therefore the more specific one and must be tried
|
||||
// first — the bare form happily matches a same-named item at a DIFFERENT
|
||||
// namespace depth in the same file and returns it before any retry is reached
|
||||
// (#2742: a call into `mod inner { fn dispatch }` bound to the crate-root
|
||||
// `fn dispatch` and rendered as a self-loop).
|
||||
//
|
||||
// Applied to the TAGGED keys too, not just the plain one. Previously only the
|
||||
// plain key had the prefixed retry, so for a namespace/mod-qualified def every
|
||||
// tagged key — constraints, parameter types, parameter shape, arity, template
|
||||
// arguments — composed from the bare tail and simply missed the node
|
||||
// `node-lookup.ts` had registered under the qualified name. Those keys exist to
|
||||
// separate overloads, so leaving them dead meant a mod-scoped overload set
|
||||
// depended on whichever later key happened to catch it.
|
||||
const defType = def.type;
|
||||
const nsPrefix = def.namespacePrefix;
|
||||
const nameForms =
|
||||
nsPrefix !== undefined && nsPrefix.length > 0 ? [`${nsPrefix}.${qn}`, qn] : [qn];
|
||||
const lookupTagged = (tag: string): string | undefined => {
|
||||
for (const form of nameForms) {
|
||||
const hit = nodeLookup.get(qualifiedKey(filePath, defType, `${form}${tag}`));
|
||||
if (hit !== undefined) return hit;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// SFINAE / `requires`-clause disambiguation (issue #1579) — try the
|
||||
// constraint-fingerprinted key FIRST. Two function-template overloads
|
||||
// with identical `parameterTypes` but mutually-exclusive SFINAE
|
||||
|
|
@ -250,12 +277,7 @@ export function resolveDefGraphId(
|
|||
(def.type === 'Function' || def.type === 'Method') &&
|
||||
def.templateConstraints !== undefined
|
||||
) {
|
||||
const cKey = qualifiedKey(
|
||||
filePath,
|
||||
def.type,
|
||||
`${qn}${templateConstraintsIdTag(def.templateConstraints)}`,
|
||||
);
|
||||
const cHit = nodeLookup.get(cKey);
|
||||
const cHit = lookupTagged(templateConstraintsIdTag(def.templateConstraints));
|
||||
if (cHit !== undefined) return cHit;
|
||||
}
|
||||
if (
|
||||
|
|
@ -265,8 +287,7 @@ export function resolveDefGraphId(
|
|||
) {
|
||||
const shapeTag = parameterShapeIdTag(def.parameterTypes, def.parameterTypeClasses);
|
||||
if (shapeTag !== '') {
|
||||
const shapeKey = qualifiedKey(filePath, def.type, `${qn}${shapeTag}`);
|
||||
const shapeHit = nodeLookup.get(shapeKey);
|
||||
const shapeHit = lookupTagged(shapeTag);
|
||||
if (shapeHit !== undefined) return shapeHit;
|
||||
}
|
||||
}
|
||||
|
|
@ -282,8 +303,7 @@ export function resolveDefGraphId(
|
|||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.length > 0
|
||||
) {
|
||||
const pKey = qualifiedKey(filePath, def.type, `${qn}~${def.parameterTypes.join(',')}`);
|
||||
const pHit = nodeLookup.get(pKey);
|
||||
const pHit = lookupTagged(`~${def.parameterTypes.join(',')}`);
|
||||
if (pHit !== undefined) return pHit;
|
||||
}
|
||||
// Arity-disambiguating key (see node-lookup.ts): route a same-name overload
|
||||
|
|
@ -291,8 +311,7 @@ export function resolveDefGraphId(
|
|||
// zero-arg overload (no parameterTypes) that would otherwise collapse onto a
|
||||
// sibling overload via the source-order-dependent qualified key.
|
||||
if (isOverloadableCallable(def.type) && def.parameterCount !== undefined) {
|
||||
const aKey = qualifiedKey(filePath, def.type, `${qn}#${def.parameterCount}`);
|
||||
const aHit = nodeLookup.get(aKey);
|
||||
const aHit = lookupTagged(`#${def.parameterCount}`);
|
||||
if (aHit !== undefined) return aHit;
|
||||
}
|
||||
if (
|
||||
|
|
@ -304,23 +323,11 @@ export function resolveDefGraphId(
|
|||
def.templateArguments !== undefined &&
|
||||
def.templateArguments.length > 0
|
||||
) {
|
||||
const tKey = qualifiedKey(filePath, def.type, `${qn}~${def.templateArguments.join(',')}`);
|
||||
const tHit = nodeLookup.get(tKey);
|
||||
const tHit = lookupTagged(`~${def.templateArguments.join(',')}`);
|
||||
if (tHit !== undefined) return tHit;
|
||||
}
|
||||
const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn));
|
||||
const qualifiedHit = lookupTagged('');
|
||||
if (qualifiedHit !== undefined) return qualifiedHit;
|
||||
// #1982: some scope-extractors qualify a type by its enclosing CLASS chain
|
||||
// (`A.Inner`) but drop the enclosing NAMESPACE, while the structure-phase
|
||||
// node is keyed by the full path (`NS.A.Inner`). Retry with the
|
||||
// namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the
|
||||
// simple-name fallback, so same-tail nested bases don't collapse across
|
||||
// sibling namespace members via `simpleKey`.
|
||||
const nsPrefix = def.namespacePrefix;
|
||||
if (nsPrefix !== undefined && nsPrefix.length > 0) {
|
||||
const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`));
|
||||
if (nsHit !== undefined) return nsHit;
|
||||
}
|
||||
}
|
||||
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
return nodeLookup.get(simpleKey(filePath, simpleName));
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
simpleQualifiedName,
|
||||
} from '../graph-bridge/ids.js';
|
||||
import { resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
||||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
import { narrowOverloadCandidates } from './overload-narrowing.js';
|
||||
|
||||
export const MAX_CALLABLE_VALUE_TARGETS = 32;
|
||||
|
|
@ -815,7 +816,7 @@ const withoutSigil = (name: string): string => name.replace(/^[$@]+/, '');
|
|||
*/
|
||||
function valueBindingPositionKey(def: SymbolDefinition): string | undefined {
|
||||
if (!VALUE_BINDING_DEF_TYPES.has(def.type)) return undefined;
|
||||
const line = def.nodeId.match(/#(\d+):(\d+):/)?.[1];
|
||||
const line = definitionIdPosition(def.nodeId, def.filePath)?.line;
|
||||
const name = simpleQualifiedName(def);
|
||||
if (line === undefined || name === undefined) return undefined;
|
||||
return `${def.filePath}\0${line}\0${withoutSigil(name)}`;
|
||||
|
|
@ -921,8 +922,10 @@ function canonicalizeCallableDeclarations(
|
|||
}
|
||||
|
||||
function definitionPosition(def: SymbolDefinition): string | undefined {
|
||||
const match = def.nodeId.match(/#(\d+):(\d+):/);
|
||||
return match === null ? undefined : `${def.filePath}\0${match[1]}\0${match[2]}`;
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
return position === undefined
|
||||
? undefined
|
||||
: `${def.filePath}\0${position.line}\0${position.column}`;
|
||||
}
|
||||
|
||||
function declarationSignatureCompatible(
|
||||
|
|
@ -1003,7 +1006,7 @@ function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes
|
|||
}
|
||||
|
||||
function definitionAnchorKey(def: SymbolDefinition): string | undefined {
|
||||
const line = def.nodeId.match(/#(\d+):(\d+):/)?.[1];
|
||||
const line = definitionIdPosition(def.nodeId, def.filePath)?.line;
|
||||
const name = simpleName(def.qualifiedName);
|
||||
if (line === undefined || name === undefined) return undefined;
|
||||
return `${def.filePath}\0${def.type}\0${line}\0${name}`;
|
||||
|
|
@ -1437,8 +1440,8 @@ function dedupeDefinitions(defs: readonly SymbolDefinition[]): SymbolDefinition[
|
|||
}
|
||||
|
||||
function definitionStartsAt(def: SymbolDefinition, line: number, column: number): boolean {
|
||||
const match = def.nodeId.match(/#(\d+):(\d+):/);
|
||||
return match !== null && Number(match[1]) === line && Number(match[2]) === column;
|
||||
const position = definitionIdPosition(def.nodeId, def.filePath);
|
||||
return position !== undefined && position.line === line && position.column === column;
|
||||
}
|
||||
|
||||
function expandMemberTargets(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export interface DefinitionIdPosition {
|
||||
readonly line: number;
|
||||
readonly column: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract coordinates from `def:<filePath>#<line>:<column>:<type>:<name>`.
|
||||
*
|
||||
* File paths are embedded verbatim and may themselves contain fragments such
|
||||
* as `#12:34:`, while names may themselves contain `#` (TypeScript private
|
||||
* members). Anchor on the known file path so neither side can be mistaken for
|
||||
* the declaration separator.
|
||||
*/
|
||||
export function definitionIdPosition(
|
||||
nodeId: string | undefined,
|
||||
filePath: string,
|
||||
): DefinitionIdPosition | undefined {
|
||||
if (nodeId === undefined) return undefined;
|
||||
const prefix = `def:${filePath}#`;
|
||||
if (!nodeId.startsWith(prefix)) return undefined;
|
||||
const match = /^(\d+):(\d+):/.exec(nodeId.slice(prefix.length));
|
||||
if (match === null) return undefined;
|
||||
return { line: Number(match[1]), column: Number(match[2]) };
|
||||
}
|
||||
|
|
@ -14,24 +14,36 @@ import { isOverloadableCallable } from './callable-labels.js';
|
|||
export type SyntaxNode = Parser.SyntaxNode;
|
||||
|
||||
/**
|
||||
* Qualify a Rust inherent-impl target (`impl Inner { ... }`) by its enclosing
|
||||
* `mod_item` scope, so a bare same-tail target nested under different modules
|
||||
* resolves to a DISTINCT path (`outer.Inner` vs `other.Inner`) — the #1982
|
||||
* follow-up to #1975. Walks `mod_item` ancestors (outermost → innermost) and
|
||||
* joins them with the normalized raw target via the shared `splitQualifiedName`.
|
||||
* A top-level `impl Inner` (no enclosing mod) returns the bare target unchanged.
|
||||
* Keyed purely on tree-sitter node types (no language name), matching the
|
||||
* inherent-impl branch in `findEnclosingClassInfo`; the caller restricts this to
|
||||
* UNSCOPED targets (`type_identifier`) so a SCOPED `impl a::Inner` keeps its full
|
||||
* raw text (#1975). The Impl-node materialization in parsing-processor /
|
||||
* parse-worker mirrors this so the owner edge and node id agree byte-for-byte.
|
||||
* Qualify a name by its enclosing `mod_item` scope, so two same-tail items nested
|
||||
* under different modules get DISTINCT paths (`outer.Inner` vs `other.Inner`).
|
||||
* Walks `mod_item` ancestors (outermost → innermost) and joins them with the
|
||||
* normalized raw text via the shared `splitQualifiedName`. Keyed purely on
|
||||
* tree-sitter node types (no language name), so it is a no-op for every grammar
|
||||
* without such a node.
|
||||
*
|
||||
* TWO callers, with different contracts — read both before widening either:
|
||||
*
|
||||
* 1. The inherent-impl target (`impl Inner { … }`) — the #1982 follow-up to
|
||||
* #1975, reachable through the {@link qualifyRustImplTargetByModScope} alias
|
||||
* and mirrored by the inherent-impl branch in `findEnclosingClassInfo` so the
|
||||
* owner edge and the node id agree byte-for-byte. That caller gates on an
|
||||
* UNSCOPED `type_identifier`, which is what keeps a SCOPED `impl a::Inner` on
|
||||
* its full raw text.
|
||||
*
|
||||
* 2. Free items, for module node identity (#2742). That caller gates on the node
|
||||
* being on neither side of an owner edge (`MEMBER_OWNER_NODE_TYPES`,
|
||||
* `enclosingClassInfo`) and not inside a callable, because only the id moves
|
||||
* here — every owner-edge anchor is minted separately and does not follow.
|
||||
*
|
||||
* A name with NO enclosing `mod` is returned verbatim, never normalized: rewriting
|
||||
* a scoped target's separator (`a::Inner` → `a.Inner`) would move its node id away
|
||||
* from the id its owner edge emits, which is how caller 2 first broke caller 1's
|
||||
* #1975 contract. Splitting an unscoped name has always been the identity, so
|
||||
* caller 1 is unaffected either way.
|
||||
*/
|
||||
export const qualifyRustImplTargetByModScope = (
|
||||
implNode: SyntaxNode,
|
||||
rawTargetText: string,
|
||||
): string => {
|
||||
export const qualifyByEnclosingModScope = (node: SyntaxNode, rawText: string): string => {
|
||||
const modSegments: string[] = [];
|
||||
let current = implNode.parent;
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (current.type === 'mod_item') {
|
||||
const nameNode =
|
||||
|
|
@ -41,9 +53,22 @@ export const qualifyRustImplTargetByModScope = (
|
|||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return [...modSegments, ...splitQualifiedName(rawTargetText)].filter(Boolean).join('.');
|
||||
// No enclosing `mod`: return the raw text UNTOUCHED. Normalizing here would
|
||||
// rewrite a scoped target's separator (`a::Inner` -> `a.Inner`) and silently
|
||||
// move its node id away from the one the owner edge emits, which is how this
|
||||
// helper first broke the #1975 scoped-impl ownership when it was generalized
|
||||
// beyond unscoped targets. Callers that pass an unscoped name are unaffected,
|
||||
// since splitting one has always been the identity.
|
||||
if (modSegments.length === 0) return rawText;
|
||||
return [...modSegments, ...splitQualifiedName(rawText)].filter(Boolean).join('.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Impl-target alias of {@link qualifyByEnclosingModScope}, kept as its own name
|
||||
* because the caller gates it on UNSCOPED targets (see the contract above).
|
||||
*/
|
||||
export const qualifyRustImplTargetByModScope = qualifyByEnclosingModScope;
|
||||
|
||||
/**
|
||||
* #1991: scope-label predicate that single-sources the `nodeLabel === 'Trait'`
|
||||
* checks in parsing-processor.ts / parse-worker.ts. A Ruby `module` maps to the
|
||||
|
|
@ -338,6 +363,37 @@ export const CLASS_CONTAINER_TYPES = new Set([
|
|||
'interface_type',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types whose OWN node id must not be re-keyed by an enclosing-scope
|
||||
* qualifier (see {@link qualifyByEnclosingModScope}) unless the owner-edge
|
||||
* anchor moves in the same change.
|
||||
*
|
||||
* These are the containers a member can be declared inside. Their members'
|
||||
* `HAS_METHOD` / `HAS_PROPERTY` edges anchor on `findEnclosingClassInfo().classId`,
|
||||
* which is minted from the container's bare `nameNode.text` further down this
|
||||
* file and only follows a qualified shape when the provider opts in via
|
||||
* `classExtractor.qualifiedNodeId`. So qualifying a container's id alone points
|
||||
* every one of its member edges at a node that does not exist — the edges are
|
||||
* dropped at COPY time and the container silently loses all its members.
|
||||
*
|
||||
* Derived from `CLASS_CONTAINER_TYPES` on purpose: that set is already the
|
||||
* single source of "this node type owns member edges", carries the INVARIANT
|
||||
* note above binding it to `CONTAINER_TYPE_TO_LABEL`, and so a language adding
|
||||
* a container cannot gain a mismatched id shape here without also failing that
|
||||
* invariant. Keyed purely on tree-sitter node types — no language names.
|
||||
*/
|
||||
export const MEMBER_OWNER_NODE_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
...CLASS_CONTAINER_TYPES,
|
||||
// Rust `union_item` owns a `field_declaration_list` exactly as `struct_item`
|
||||
// does, and its fields ARE captured as `Property`, but it is absent from
|
||||
// `CLASS_CONTAINER_TYPES`, so `findEnclosingClassInfo` does not recognize it as
|
||||
// an owner: union fields carry no `HAS_PROPERTY` edge at all and therefore
|
||||
// cannot dangle. Listed here so the union's own id keeps the same shape as the
|
||||
// struct beside it, and so making it a real owner later starts from a
|
||||
// consistent id rather than having to move one.
|
||||
'union_item',
|
||||
]);
|
||||
|
||||
export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
||||
class_declaration: 'Class',
|
||||
abstract_class_declaration: 'Class',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -96,6 +96,8 @@ import {
|
|||
isSuppressedConcreteTypedefDuplicate,
|
||||
isValueDefinitionLabel,
|
||||
isQualifiableScopeLabel,
|
||||
MEMBER_OWNER_NODE_TYPES,
|
||||
qualifyByEnclosingModScope,
|
||||
qualifyRustImplTargetByModScope,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
PARAMETER_LIST_NODE_TYPES,
|
||||
|
|
@ -2396,7 +2398,7 @@ const processFileGroup = (
|
|||
? qualifyRustImplTargetByModScope(definitionNode, nodeName)
|
||||
: undefined;
|
||||
|
||||
const qualifiedName =
|
||||
const qualifiedNameBeforeModScope =
|
||||
rustImplQualifiedName !== undefined
|
||||
? rustImplQualifiedName
|
||||
: // #1991: LOCKSTEP — include Trait so a Ruby mixin module's qualified
|
||||
|
|
@ -2417,6 +2419,63 @@ const processFileGroup = (
|
|||
? `${objectLiteralOwnerInfo.ownerName}.${nodeName}`
|
||||
: nodeName;
|
||||
|
||||
// #2742: qualify by the enclosing `mod` chain, so two same-named items at
|
||||
// different module depths in one file are DISTINCT nodes. Without this,
|
||||
// `mod inner { fn dispatch }` and a crate-root `fn dispatch` both keyed
|
||||
// `Function:<file>:dispatch`, first-wins — so a correctly resolved call
|
||||
// into the inline module still rendered as a self-loop, and `impact`
|
||||
// reported the real callee as unreached.
|
||||
//
|
||||
// Keyed purely on the `mod_item` node type, exactly as the impl-target
|
||||
// qualifier above (#1982) already is, so it is a no-op for every language
|
||||
// whose grammar has no such node. The impl branch already applied it and
|
||||
// is left alone rather than qualified twice.
|
||||
//
|
||||
// Scoped to items that sit on NEITHER side of an owner edge, because only
|
||||
// the id is re-keyed here — the anchor is minted independently by
|
||||
// `findEnclosingClassInfo` and does not move with it:
|
||||
//
|
||||
// - `!enclosingClassInfo` excludes the MEMBER side. A method already
|
||||
// carries its owner's name (`Inner.method`), and for an unscoped
|
||||
// inherent impl that owner is mod-scoped by the impl qualifier above,
|
||||
// so qualifying the member again would break the byte-for-byte
|
||||
// agreement #1975/#1982 established.
|
||||
// - `!MEMBER_OWNER_NODE_TYPES.has(...)` excludes the OWNER side. A
|
||||
// `struct` / `trait` / `enum` / `impl` declared directly in a `mod` has
|
||||
// no enclosing class, so the member-side guard alone let it through
|
||||
// while its own anchor stayed bare — `mod engine { struct Config { … } }`
|
||||
// minted `Struct:<file>:engine.Config` against a `HAS_PROPERTY` edge
|
||||
// anchored on `Struct:<file>:Config`, dangling every field. The same
|
||||
// gap put `impl a::Inner` inside a `mod` back on the #1975 rake this
|
||||
// helper's own docblock warns about: the impl branch above deliberately
|
||||
// fires only for an UNSCOPED `type_identifier`, and this gate was
|
||||
// picking up the scoped targets it had just excluded.
|
||||
// - `nestedCallablePrefix === undefined` excludes an item inside a
|
||||
// CALLABLE. `fn wrapper() { mod helper { fn dispatch } }` composed the
|
||||
// mod segment outermost — `helper.wrapper.dispatch@2:8` — inverting the
|
||||
// real nesting, because the mod prefix is prepended to a name the
|
||||
// enclosing-callable pass has already qualified. Nothing dangled (the
|
||||
// `@line:col` suffix keeps such ids unique on its own, which is also why
|
||||
// the mod segment adds no identity here), but the path read as a lie
|
||||
// about the source. Skipping is the honest answer; reordering would mean
|
||||
// interleaving two qualifier passes for a shape that only ever produces
|
||||
// already-unique ids.
|
||||
//
|
||||
// Same-named members on same-named types in sibling modules therefore
|
||||
// still collapse, as do the containers themselves — unchanged from before
|
||||
// this fix, and owned by the owner edge rather than worked around here.
|
||||
const qualifiesByEnclosingModScope =
|
||||
rustImplQualifiedName === undefined &&
|
||||
definitionNode !== undefined &&
|
||||
!MEMBER_OWNER_NODE_TYPES.has(definitionNode.type) &&
|
||||
nestedCallablePrefix === undefined &&
|
||||
!enclosingClassInfo &&
|
||||
objectLiteralOwnerInfo?.ownerName === undefined;
|
||||
const qualifiedName =
|
||||
qualifiesByEnclosingModScope && definitionNode !== undefined
|
||||
? qualifyByEnclosingModScope(definitionNode, qualifiedNameBeforeModScope)
|
||||
: qualifiedNameBeforeModScope;
|
||||
|
||||
// Extract method metadata BEFORE generating node ID — parameterCount is needed
|
||||
// to disambiguate overloaded methods via #<arity> suffix in the ID.
|
||||
let declaredType: string | undefined;
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// the main thread (the #1983 OOM). Because the two stores share this version,
|
||||
// any future change to the `ParsedFile` serialization shape MUST bump
|
||||
// SCHEMA_BUMP so both invalidate in lockstep.
|
||||
// v32: Rust items are qualified by their enclosing `mod` chain (#2742). The
|
||||
// qualified name is computed in the parse worker, so a warm cache replays the
|
||||
// old unqualified ids verbatim and the collapse persists.
|
||||
// v31: Rust `mod_item` gained `@declaration.namespace` and scoped call sites
|
||||
// gained `@reference.qualified-name` (#2730). Both are PARSE-TIME captures, so a
|
||||
// warm cache replays the old capture set verbatim: `rawQualifiedName` comes back
|
||||
|
|
@ -125,7 +128,19 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// JLS 13.1 immediate-host chains (#2555).
|
||||
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
|
||||
// v16: direct callee identity.
|
||||
const SCHEMA_BUMP = 31;
|
||||
// v34: the receiver-chain capture is emitted by ALL 14 language emitters, not
|
||||
// just TypeScript. v33 landed with the TypeScript-only emission; the rollout to
|
||||
// the other 13 languages changed the capture set AGAIN, so a cache stamped 33 by
|
||||
// an intermediate build of this series is not equivalent to one stamped at this
|
||||
// commit — it would be treated as current while every non-TypeScript file
|
||||
// replayed pre-rollout captures, leaving the feature silently inert for 13 of 14
|
||||
// languages. Bumped here so the version tracks the FINAL capture set rather than
|
||||
// the first divergence.
|
||||
// v33: TypeScript call matches carry `@reference.receiver-chain`, a compact
|
||||
// encoding of a receiver that is itself an expression. (Shipped as v32 on its own
|
||||
// branch; `main` took 32 for #2742 first, so this series is renumbered — see the
|
||||
// v21 note on re-checking against origin/main at MERGE time, not branch time.)
|
||||
const SCHEMA_BUMP = 34;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -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,8 +602,28 @@ export interface RepoMeta {
|
|||
* the same-commit "already up to date" fast path — keeps returning the pre-fix
|
||||
* graph for every unchanged file, which is exactly the missing-caller symptom
|
||||
* #2708 reported. Force a full re-analyze.
|
||||
*
|
||||
* v26: unresolved-receiver member names are persisted
|
||||
* (`unresolvedReceiverMembers`) so `impact()`/`context()` can report
|
||||
* `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site
|
||||
* was dropped for want of a receiver type (#2744). A pre-v26 index carries no
|
||||
* such summary, and an absent summary is indistinguishable from "nothing was
|
||||
* dropped" — so topping one up incrementally would keep reporting `exact` for
|
||||
* exactly the symbols the signal exists to flag. Force a full re-analyze.
|
||||
*
|
||||
* (This shipped as v25 on its own branch; `main` took 25 for #2742 first, so it
|
||||
* is renumbered here. Re-check both constants against origin/main immediately
|
||||
* before merging — this is the fourth time that collision has bitten.)
|
||||
*
|
||||
* v25: Rust items are qualified by their enclosing `mod` chain (#2742), so
|
||||
* `mod inner { fn dispatch }` and a crate-root `fn dispatch` in one file are
|
||||
* finally DISTINCT nodes instead of collapsing onto `Function:<file>:dispatch`
|
||||
* first-wins. Node IDS CHANGE for every Rust item inside any `mod` block —
|
||||
* `#[cfg(test)] mod tests` makes that close to every Rust repo — so a pre-v25
|
||||
* index holds ids an incremental top-up cannot reconcile and would simply
|
||||
* strand. Force a full re-analyze.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 24;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 28;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
|
|
@ -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
gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/a.rs
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/a.rs
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub struct Inner;
|
||||
45
gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/main.rs
vendored
Normal file
45
gitnexus/test/fixtures/lang-resolution/rust-2742-mod-members/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
mod a;
|
||||
|
||||
// Containers declared DIRECTLY inside an inline `mod`. Each owns member edges
|
||||
// (HAS_METHOD / HAS_PROPERTY) whose anchor is minted by findEnclosingClassInfo
|
||||
// from the container's bare name, independently of the container's own node id.
|
||||
// Re-keying only the id points every member edge at a node that does not exist.
|
||||
pub mod engine {
|
||||
pub struct Config {
|
||||
pub retries: usize,
|
||||
}
|
||||
|
||||
pub trait Runner {
|
||||
fn go(&self) -> usize;
|
||||
}
|
||||
|
||||
impl Runner for Config {
|
||||
fn go(&self) -> usize {
|
||||
self.retries
|
||||
}
|
||||
}
|
||||
|
||||
// Fields are captured as Property, but `union_item` is not a recognized
|
||||
// owner, so they carry no HAS_PROPERTY edge in any build.
|
||||
pub union Slot {
|
||||
pub int: u32,
|
||||
pub float: f32,
|
||||
}
|
||||
}
|
||||
|
||||
// A SCOPED inherent impl inside a `mod`. The unscoped-impl qualifier is gated on
|
||||
// a bare `type_identifier`, so this target keeps its full raw text (#1975).
|
||||
pub mod outer {
|
||||
use crate::a;
|
||||
|
||||
impl a::Inner {
|
||||
pub fn helper(&self) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let c = engine::Config { retries: 1 };
|
||||
c.go();
|
||||
}
|
||||
55
gitnexus/test/fixtures/lang-resolution/rust-2742-nested-mods/src/main.rs
vendored
Normal file
55
gitnexus/test/fixtures/lang-resolution/rust-2742-nested-mods/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// A `mod` nested inside an inline `mod`. The declaring scope is the PARENT
|
||||
// module's own scope, which `moduleScopeByFile` does not hold — it maps a file to
|
||||
// its root `Module` scope only. A binding lookup there saw depth-1 inline modules
|
||||
// and missed every nested one, so `tools::dispatch()` below fell through to the
|
||||
// lexical tier and bound to the enclosing same-name `dispatch` — the #2730
|
||||
// self-loop, one level deeper than the fixture that first caught it.
|
||||
pub mod outer {
|
||||
pub mod tools {
|
||||
pub fn dispatch() -> usize {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatch() -> usize {
|
||||
tools::dispatch()
|
||||
}
|
||||
}
|
||||
|
||||
// Three levels, to prove the fix is depth-agnostic rather than depth-2 special-cased.
|
||||
pub mod a {
|
||||
pub mod b {
|
||||
pub mod c {
|
||||
pub fn deep() -> usize {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mid() -> usize {
|
||||
c::deep()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top() -> usize {
|
||||
b::mid()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
outer::dispatch();
|
||||
a::top();
|
||||
}
|
||||
|
||||
// A `mod` declared inside a FUNCTION. The enclosing-callable pass already
|
||||
// position-qualifies these ids, so prepending the mod segment put it OUTSIDE the
|
||||
// callable — `helper.wrapper.dispatch@L:C` — inverting the real nesting. The
|
||||
// `@line:col` suffix makes such ids unique on its own, so the mod segment adds no
|
||||
// identity and is skipped rather than reordered.
|
||||
pub fn wrapper() -> usize {
|
||||
mod helper {
|
||||
pub fn dispatch() -> usize {
|
||||
3
|
||||
}
|
||||
}
|
||||
helper::dispatch()
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/b.rs
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/b.rs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// `Buffer` here is the TYPE from `c`, not the crate-root module of the same name.
|
||||
// In Rust 2018 a bare first segment resolves in this module, so the import wins.
|
||||
use crate::c::Buffer;
|
||||
|
||||
pub fn call() -> usize {
|
||||
Buffer::with_capacity()
|
||||
}
|
||||
9
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/c.rs
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/c.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub struct Buffer {
|
||||
pub v: usize,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn with_capacity() -> usize {
|
||||
222
|
||||
}
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/lib.rs
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/rust-2742-type-vs-module/src/lib.rs
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// A crate-root module whose name matches a TYPE imported elsewhere in the crate.
|
||||
// Nothing in `b.rs` names this module, so no call from `b.rs` may reach it.
|
||||
pub mod Buffer {
|
||||
pub fn with_capacity() -> usize {
|
||||
111
|
||||
}
|
||||
}
|
||||
|
||||
pub mod b;
|
||||
pub mod c;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,30 @@
|
|||
"captureGroups": 9,
|
||||
"digest": "fdbdd68d6920a9d7861562c562acc4fbd4580836467cad1ce8428c57a11d0d19"
|
||||
},
|
||||
"rust-2742-mod-members/src/a.rs": {
|
||||
"captureGroups": 3,
|
||||
"digest": "cf9bb9565b1d9ab7a7fcb287240198d27d41eeeb4d0953818074e4654bc831ca"
|
||||
},
|
||||
"rust-2742-mod-members/src/main.rs": {
|
||||
"captureGroups": 43,
|
||||
"digest": "59bf9f7c931cbba2c58b581e15a79a4d7a59c3088a9b62eda940298f7a150088"
|
||||
},
|
||||
"rust-2742-nested-mods/src/main.rs": {
|
||||
"captureGroups": 50,
|
||||
"digest": "505ff37c8c8959076865a05e5237b0aaad4d00a2a88f9676ce4b5d694a25d48b"
|
||||
},
|
||||
"rust-2742-type-vs-module/src/b.rs": {
|
||||
"captureGroups": 7,
|
||||
"digest": "1066fc99e6a4570fba07c641942a536145e82ff71e87c3992828ec8f1f11048a"
|
||||
},
|
||||
"rust-2742-type-vs-module/src/c.rs": {
|
||||
"captureGroups": 10,
|
||||
"digest": "bb98c27950620193afb1593213d476bde0f9c8a12581656cf81146603d95ec22"
|
||||
},
|
||||
"rust-2742-type-vs-module/src/lib.rs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "8bb48d12a411f6918fc5dfb726434e9f5b2888f5b0bea6f7615f274b0f18ce5d"
|
||||
},
|
||||
"rust-abstract-dispatch/src/lib.rs": {
|
||||
"captureGroups": 34,
|
||||
"digest": "973679363065ecd54c4e5128a9fab214ea27eca24f0f079c63a3c6f285e678b0"
|
||||
|
|
@ -173,7 +197,7 @@
|
|||
},
|
||||
"rust-chain-call/src/main.rs": {
|
||||
"captureGroups": 25,
|
||||
"digest": "4b47bbd3de939f8692c1615442e233ad00440075fafc5b6a7f7db35851492a64"
|
||||
"digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421"
|
||||
},
|
||||
"rust-chain-call/src/models/mod.rs": {
|
||||
"captureGroups": 5,
|
||||
|
|
@ -245,7 +269,7 @@
|
|||
},
|
||||
"rust-deep-field-chain/service.rs": {
|
||||
"captureGroups": 16,
|
||||
"digest": "49d6a6bff4bfde6d29d8e63c2899f49688bee98273c9f743d9189dbfdf19ce2c"
|
||||
"digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a"
|
||||
},
|
||||
"rust-default-constructor/src/main.rs": {
|
||||
"captureGroups": 36,
|
||||
|
|
@ -357,7 +381,7 @@
|
|||
},
|
||||
"rust-field-types/service.rs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "5bba21d0f792eb98025ce0531c575dac215492027cd877df09b19877e80da4ad"
|
||||
"digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f"
|
||||
},
|
||||
"rust-for-call-expr/src/main.rs": {
|
||||
"captureGroups": 26,
|
||||
|
|
@ -593,7 +617,7 @@
|
|||
},
|
||||
"rust-nullable-receiver/src/main.rs": {
|
||||
"captureGroups": 41,
|
||||
"digest": "811410fc6afe4722331ff1edfc79ce38a94c241ae52b86c10511d65d8603ef19"
|
||||
"digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05"
|
||||
},
|
||||
"rust-nullable-receiver/src/repo.rs": {
|
||||
"captureGroups": 10,
|
||||
|
|
@ -705,7 +729,7 @@
|
|||
},
|
||||
"rust-self-struct-literal/models.rs": {
|
||||
"captureGroups": 32,
|
||||
"digest": "8928de22daa88f6f311a407e0051d1dbd3e52c81503f4b295eecb3a3c86b23cf"
|
||||
"digest": "f9c89cb8c9f2b9c10140ae6971a09756b27112ab61d483b9ffe7fbcb36fefa8d"
|
||||
},
|
||||
"rust-self-this-resolution/src/repo.rs": {
|
||||
"captureGroups": 10,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
},
|
||||
"swift-field-types/App.swift": {
|
||||
"captureGroups": 7,
|
||||
"digest": "651c5cbbf9afdc0bf4d96217bc1a01cf0bf07a0190ca12a3a6786bb08a67b3ad"
|
||||
"digest": "97750185ce98b162ecaed76917408e715756a9256e1bc961a6439815db3c0715"
|
||||
},
|
||||
"swift-field-types/Models.swift": {
|
||||
"captureGroups": 20,
|
||||
|
|
|
|||
|
|
@ -596,6 +596,21 @@ describeIfWorkerBuilt('a closure binding as a call SOURCE (#2699 part B)', () =>
|
|||
expect(targets).toEqual(['rel:CALLS:Function:A.kt:handler->Function:A.kt:target']);
|
||||
});
|
||||
|
||||
it('Kotlin: a call at block level is not attributed to a nested named function (#2736)', async () => {
|
||||
// A Block-kind scope may own a nested function without being that
|
||||
// function's body. The start-position alignment check is what keeps the
|
||||
// block-level call attributed to `outer`, rather than swallowing it into
|
||||
// the uncalled `nested`.
|
||||
const targets = await callEdgeIdsFor(
|
||||
'Block.kt',
|
||||
'fun target(): Int = 1\n\nfun outer(): Int {\n' +
|
||||
' if (true) {\n fun nested(): Int = 0\n return target()\n }\n' +
|
||||
' return 0\n}\n',
|
||||
);
|
||||
|
||||
expect(targets).toEqual(['rel:CALLS:Function:Block.kt:outer->Function:Block.kt:target']);
|
||||
});
|
||||
|
||||
it('PHP: a call inside the closure IS attributed to the binding (#2699 S1)', async () => {
|
||||
// FLIPPED by #2699 S1. php/query.ts now carries the closure-binding
|
||||
// declaration rule with javascript/query.ts's anchor discipline
|
||||
|
|
|
|||
|
|
@ -18,12 +18,16 @@ import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
|
|||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
||||
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
|
||||
const describeIfPathSupportsColon =
|
||||
process.platform === 'win32' ? describe.skip : describeIfWorkerBuilt;
|
||||
|
||||
/** Every CALLS edge in a one-file repo, as `src -> dst`, sorted. */
|
||||
const callEdges = async (filename: string, source: string): Promise<string[]> => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-review-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, filename), source, 'utf-8');
|
||||
const filePath = path.join(dir, filename);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, source, 'utf-8');
|
||||
const result = await runPipelineFromRepo(dir, () => {}, {
|
||||
workerPoolSize: 1,
|
||||
workerUrlForTest: DIST_WORKER_URL,
|
||||
|
|
@ -85,6 +89,20 @@ describeIfWorkerBuilt(
|
|||
},
|
||||
);
|
||||
|
||||
describeIfPathSupportsColon('#2734 — def ids parse coordinates after the file path', () => {
|
||||
it('a path containing #<line>:<column>: still attributes closure calls', async () => {
|
||||
const edges = await callEdges(
|
||||
'src/bugs#12:34:test/a.php',
|
||||
'<?php\nfunction target($x) { return $x; }\nfunction outer() {\n' +
|
||||
' $handler = function ($x) { return target($x); };\n return 1;\n}\n',
|
||||
);
|
||||
|
||||
expect(edges).toEqual([
|
||||
'Function:src/bugs#12:34:test/a.php:outer.$handler@3:2 -> Function:src/bugs#12:34:test/a.php:target',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describeIfWorkerBuilt(
|
||||
'#2699 review P1-2 — widening identity to values must not touch class members',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -25,11 +25,23 @@
|
|||
* pass. Every assertion here is therefore on the EDGE, whose source and target
|
||||
* are produced by the two different phases: it can only pass if both agree.
|
||||
*/
|
||||
import {
|
||||
buildDefIndex,
|
||||
buildScopeTree,
|
||||
type Reference,
|
||||
type Scope,
|
||||
type ScopeId,
|
||||
type SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { emitReferencesViaLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js';
|
||||
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
|
||||
|
||||
vi.setConfig({ testTimeout: 90_000 });
|
||||
|
|
@ -352,3 +364,94 @@ describeIfWorkerBuilt('function-local VALUES carry their own identity (#2699 A1)
|
|||
expect(ids).toEqual(['Const:v.ts:handler', 'Const:v.ts:run.handler@3:2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function-local value identity resolves through to emitted edges (#2736)', () => {
|
||||
it('targets the position-qualified local rather than its file-level twin', () => {
|
||||
const filePath = 'v.ts';
|
||||
const caller: SymbolDefinition = {
|
||||
nodeId: 'def:v.ts#3:0:Function:run',
|
||||
filePath,
|
||||
type: 'Function',
|
||||
qualifiedName: 'run',
|
||||
};
|
||||
const topLevel: SymbolDefinition = {
|
||||
nodeId: 'def:v.ts#1:0:Const:handler',
|
||||
filePath,
|
||||
type: 'Const',
|
||||
qualifiedName: 'handler',
|
||||
};
|
||||
const local: SymbolDefinition = {
|
||||
nodeId: 'def:v.ts#4:2:Const:run.handler',
|
||||
filePath,
|
||||
type: 'Const',
|
||||
qualifiedName: 'run.handler',
|
||||
};
|
||||
const moduleScope: Scope = {
|
||||
id: 'scope:v.ts#1:0-100:0:Module',
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [caller, topLevel],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
};
|
||||
const functionScope: Scope = {
|
||||
id: 'scope:v.ts#3:0-6:1:Function',
|
||||
parent: moduleScope.id,
|
||||
kind: 'Function',
|
||||
range: { startLine: 3, startCol: 0, endLine: 6, endCol: 1 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [local],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
};
|
||||
const indexes = {
|
||||
scopeTree: buildScopeTree([moduleScope, functionScope]),
|
||||
defs: buildDefIndex([caller, topLevel, local]),
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode({
|
||||
id: 'Function:v.ts:run',
|
||||
label: 'Function',
|
||||
properties: { name: 'run', qualifiedName: 'run', filePath, startLine: 2 },
|
||||
});
|
||||
graph.addNode({
|
||||
id: 'Const:v.ts:handler',
|
||||
label: 'Const',
|
||||
properties: { name: 'handler', qualifiedName: 'handler', filePath, startLine: 0 },
|
||||
});
|
||||
graph.addNode({
|
||||
id: 'Const:v.ts:run.handler@3:2',
|
||||
label: 'Const',
|
||||
properties: { name: 'handler', qualifiedName: 'run.handler', filePath, startLine: 3 },
|
||||
});
|
||||
|
||||
const reference: Reference = {
|
||||
fromScope: functionScope.id,
|
||||
toDef: local.nodeId,
|
||||
atRange: { startLine: 5, startCol: 9, endLine: 5, endCol: 16 },
|
||||
kind: 'read',
|
||||
confidence: 1,
|
||||
evidence: [],
|
||||
};
|
||||
const emitted = emitReferencesViaLookup(
|
||||
graph,
|
||||
indexes,
|
||||
{ bySourceScope: new Map<ScopeId, readonly Reference[]>([[functionScope.id, [reference]]]) },
|
||||
buildGraphNodeLookup(graph),
|
||||
);
|
||||
|
||||
expect(emitted).toEqual({ emitted: 1, skipped: 0 });
|
||||
expect(graph.relationships).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceId: 'Function:v.ts:run',
|
||||
targetId: 'Const:v.ts:run.handler@3:2',
|
||||
type: 'ACCESSES',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2724,6 +2724,18 @@ describe('C++ two-phase template lookup — dependent-base deep nesting suppress
|
|||
const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f');
|
||||
expect(leaks.length).toBe(0);
|
||||
});
|
||||
|
||||
// The base is `ns::a::b::Inner`, and this fixture deliberately also declares a
|
||||
// global-scope `Inner` to force the multi-candidate path. `resolveDefGraphId`
|
||||
// must reach the deep node, not the global decoy: the scope-extractor leaves
|
||||
// the base's `qualifiedName` a bare `Inner` with the path on `namespacePrefix`,
|
||||
// so only the namespace-prefixed key can distinguish them, and it has to be
|
||||
// tried BEFORE the bare key or the decoy answers first (#2745 review — the
|
||||
// reorder that fixed this was previously unpinned by any assertion).
|
||||
it('EXTENDS anchors on the deep ns.a.b.Inner, not the global Inner decoy', () => {
|
||||
const extendsEdges = getRelationships(result, 'EXTENDS').filter((e) => e.source === 'Derived');
|
||||
expect(extendsEdges).toMatchObject([{ rel: { targetId: 'Struct:lib.h:ns.a.b.Inner' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base sibling-namespace suppression', () => {
|
||||
|
|
@ -4536,3 +4548,33 @@ describe('C++ deleted overload selection (#1893 A2)', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural receiver typing for a `->` base receiver.
|
||||
//
|
||||
// The all-language rollout is proven behaviourally for TypeScript only; this is
|
||||
// the second language, and the one the rollout measurably changed: C++
|
||||
// `svc->getUser()->save()` emitted NO edge and recorded NO drop before it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C++ structural receiver chain through a pointer base', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-receiver-chain-arrow'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves svc->getUser()->save() — the `->` base the text cascade could not parse', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(
|
||||
calls.find((c) => c.source === 'pointerArrowChain' && c.target === 'save'),
|
||||
).toMatchObject({ target: 'save' });
|
||||
});
|
||||
|
||||
it('keeps the value-dot base resolving (control — worked before this work)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'valueDotChain' && c.target === 'save')).toMatchObject({
|
||||
target: 'save',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2788,3 +2788,221 @@ describe('Rust type-qualified calls are not treated as module paths (#2730 revie
|
|||
expect(edges[0]).toMatchObject({ target: 'new', targetFilePath: 'src/client/mod.rs' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2742 — same-named items at different module depths are distinct nodes.
|
||||
//
|
||||
// Node identity was `<label>:<file>:<qualifiedName>` with no module path, so an
|
||||
// inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
|
||||
// file collapsed onto one node, first-wins. Resolution already picked the right
|
||||
// definition; the target simply was not representable, so a correct resolution
|
||||
// still rendered as a self-loop and `impact` reported the real callee unreached.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust items are qualified by their enclosing mod chain (#2742)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-gaps'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('gives an inline-mod member its own node, distinct from the crate-root item', () => {
|
||||
const ids: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
|
||||
});
|
||||
expect(ids).toContain('Function:src/main.rs:inner.dispatch');
|
||||
expect(ids).toContain('Function:src/main.rs:dispatch');
|
||||
});
|
||||
|
||||
it('resolves inner::dispatch() to the inline member, not back to the caller', () => {
|
||||
const edges = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.sourceFilePath === 'src/main.rs' && c.source === 'dispatch',
|
||||
);
|
||||
expect(edges.length).toBe(1);
|
||||
expect(edges[0].rel.targetId).toBe('Function:src/main.rs:inner.dispatch');
|
||||
});
|
||||
|
||||
// Fixture-wide rather than pinned to one edge, because the #2730 symptom is a
|
||||
// CLASS of mis-binding: any qualified call whose leading segment names an
|
||||
// inline module can land back on the enclosing same-name item. The preceding
|
||||
// test pins the one target we know regressed; this one fails if the same fault
|
||||
// reappears through any other path in the fixture. `rust-2730-gaps` contains no
|
||||
// self-recursive function, so an empty result is the correct invariant — adding
|
||||
// one to the fixture means narrowing this filter, not deleting the test.
|
||||
it('emits no self-loop for the inline-mod wrapper', () => {
|
||||
const selfLoops = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.rel.sourceId === c.rel.targetId,
|
||||
);
|
||||
expect(selfLoops).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2745 review — mod-qualifying a node id must not orphan its member edges.
|
||||
//
|
||||
// The mint (parse-worker) and the owner-edge anchor (findEnclosingClassInfo) are
|
||||
// computed independently. #2742 re-keyed ids by the enclosing `mod` chain but the
|
||||
// anchor is minted from the container's BARE name and only follows a qualified
|
||||
// shape when the provider sets `classExtractor.qualifiedNodeId`, which Rust does
|
||||
// not. Every `struct` / `trait` / `enum` / `impl` inside a `mod` therefore had a
|
||||
// node id no member edge pointed at, and the rows were dropped at COPY time.
|
||||
//
|
||||
// Guarded here with the UNFILTERED findDanglingEdges. Every other dangling
|
||||
// assertion in this file passes `['HAS_METHOD']`, which is exactly why the
|
||||
// HAS_PROPERTY breakage shipped green.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust containers inside a mod keep their member edges (#2745 review)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-mod-members'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('leaves no dangling edge of ANY type', () => {
|
||||
expect(findDanglingEdges(result)).toEqual([]);
|
||||
});
|
||||
|
||||
// Asserts the NODE id, not just the edge's anchor: the anchor was already bare
|
||||
// while the bug was live, so an edge-only assertion passes in both builds. The
|
||||
// half that moved is the mint.
|
||||
it('keeps a mod-scoped struct and its field on one agreed id', () => {
|
||||
const structIds: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Struct' && n.properties.name === 'Config') structIds.push(n.id);
|
||||
});
|
||||
expect(structIds).toEqual(['Struct:src/main.rs:Config']);
|
||||
|
||||
const hasProperty = getRelationships(result, 'HAS_PROPERTY').filter(
|
||||
(e) => e.target === 'retries',
|
||||
);
|
||||
expect(hasProperty).toMatchObject([
|
||||
{
|
||||
rel: {
|
||||
sourceId: 'Struct:src/main.rs:Config',
|
||||
targetId: 'Property:src/main.rs:Config.retries',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a scoped inherent-impl target inside a mod at its raw path (#1975)', () => {
|
||||
const implIds: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Impl') implIds.push(n.id);
|
||||
});
|
||||
expect(implIds).toEqual(['Impl:src/main.rs:a::Inner']);
|
||||
|
||||
const hasMethod = getRelationships(result, 'HAS_METHOD').filter((e) => e.target === 'helper');
|
||||
expect(hasMethod).toMatchObject([{ rel: { sourceId: 'Impl:src/main.rs:a::Inner' } }]);
|
||||
});
|
||||
|
||||
it('still links a trait impl declared inside a mod to the trait method', () => {
|
||||
const implementsEdges = getRelationships(result, 'METHOD_IMPLEMENTS');
|
||||
expect(implementsEdges).toMatchObject([
|
||||
{
|
||||
rel: {
|
||||
sourceId: 'Function:src/main.rs:Config.go#0',
|
||||
targetId: 'Function:src/main.rs:Runner.go#0',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2745 review — the #2730 self-loop survived one `mod` deeper.
|
||||
//
|
||||
// A `mod` nested inside an inline `mod` binds in the PARENT module's scope.
|
||||
// `declaresSubmodule` looked it up through `moduleScopeByFile`, which maps a file
|
||||
// to its root `Module` scope only, so a nested inline module was invisible: the
|
||||
// candidate was never yielded, the hook refused, and the shared lexical tier bound
|
||||
// `tools::dispatch()` to the enclosing same-name `dispatch`.
|
||||
//
|
||||
// Inline module paths are now derived from the MEMBERS' `namespacePrefix` — a `mod`
|
||||
// def carries no nesting information of its own, and a `Namespace` scope owns its
|
||||
// own def rather than its children's, so neither channel could answer this.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust nested inline modules resolve (#2745 review)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-nested-mods'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves a nested inline module member instead of the enclosing same-name fn', () => {
|
||||
const edges = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.rel.sourceId === 'Function:src/main.rs:outer.dispatch',
|
||||
);
|
||||
expect(edges).toMatchObject([
|
||||
{ rel: { targetId: 'Function:src/main.rs:outer.tools.dispatch' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves three levels deep', () => {
|
||||
const midToDeep = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.rel.sourceId === 'Function:src/main.rs:a.b.mid',
|
||||
);
|
||||
expect(midToDeep).toMatchObject([{ rel: { targetId: 'Function:src/main.rs:a.b.c.deep' } }]);
|
||||
});
|
||||
|
||||
it('emits no self-loop anywhere in the nested fixture', () => {
|
||||
const selfLoops = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.rel.sourceId === c.rel.targetId,
|
||||
);
|
||||
expect(selfLoops).toEqual([]);
|
||||
});
|
||||
|
||||
// A `mod` inside a `fn` is already position-qualified by the enclosing-callable
|
||||
// pass, so prepending the mod segment placed it OUTSIDE the callable and the id
|
||||
// read as `helper.wrapper.dispatch@L:C` — the inverse of the real nesting.
|
||||
it('does not hoist a mod declared inside a fn above the callable', () => {
|
||||
const ids: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
|
||||
});
|
||||
// Prefix rather than an exact `@line:col`, so adding a line to the fixture
|
||||
// above does not turn this into a coordinate-maintenance test.
|
||||
expect(
|
||||
ids.filter((id) => id.startsWith('Function:src/main.rs:wrapper.dispatch@')),
|
||||
).toHaveLength(1);
|
||||
expect(ids.filter((id) => id.includes(':helper.'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2745 review — an imported type outranks a same-named module at the crate root.
|
||||
//
|
||||
// Widening the negative filter to inline `mod` names let a type-qualified call
|
||||
// through when a module happened to share the type's name. The crate-root-relative
|
||||
// candidate — the loosest one, a guess at a path the caller never wrote — then
|
||||
// captured it, producing an edge to a callee the source does not name. The base
|
||||
// emitted no edge, which per the doctrine quoted in `ids.ts` is the correct failure
|
||||
// direction: a missing edge is recoverable, a fabricated caller misleads `impact`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust type-qualified calls outrank a same-named module (#2745 review)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-type-vs-module'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('never binds an imported type to the crate-root module of the same name', () => {
|
||||
const fromCall = getRelationships(result, 'CALLS').filter(
|
||||
(c) => c.rel.sourceId === 'Function:src/b.rs:call',
|
||||
);
|
||||
const targets = fromCall.map((c) => c.rel.targetId);
|
||||
expect(targets).not.toContain('Function:src/lib.rs:Buffer.with_capacity');
|
||||
});
|
||||
|
||||
it('leaves the module member itself intact as a node', () => {
|
||||
const ids: string[] = [];
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'Function' && n.properties.name === 'with_capacity') ids.push(n.id);
|
||||
});
|
||||
expect(ids).toContain('Function:src/lib.rs:Buffer.with_capacity');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 24 (inline constructor receivers, #2708)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 28 (structural receiver typing, all languages)', () => {
|
||||
// Moves with every bump BY DESIGN — that is the point of pinning it. A
|
||||
// change that alters emitted ids or edges without bumping would otherwise
|
||||
// ship silently, and an existing index would keep serving the old graph
|
||||
// through the reuse gate below.
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(24);
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(28);
|
||||
});
|
||||
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
|
|
@ -175,9 +175,30 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
|||
// (#2730): every unchanged Rust file would keep the same-name self-loop and
|
||||
// keep reporting the real callee as unreached → must NOT reuse.
|
||||
expect(passesReuseGate(22)).toBe(false);
|
||||
// A pre-v24 (v23) index predates
|
||||
// A pre-v24 (v23) index predates the #2708 inline-constructor receivers.
|
||||
expect(passesReuseGate(23)).toBe(false);
|
||||
// A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An
|
||||
// absent summary is indistinguishable from "nothing was dropped", so a
|
||||
// top-up would keep reporting `epistemic: 'exact'` for exactly the symbols
|
||||
// whose callers were dropped → must NOT reuse.
|
||||
expect(passesReuseGate(24)).toBe(false);
|
||||
// A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`,
|
||||
// `svc!.m().n()` and `svc.m<T>().n()` emitted no CALLS edge — and two of the
|
||||
// three recorded no drop either, so the count still claimed `exact`. A
|
||||
// changed-files top-up keeps both the missing edge and the false confidence
|
||||
// for every unchanged file → must NOT reuse.
|
||||
expect(passesReuseGate(25)).toBe(false);
|
||||
// A pre-v27 (v26) index was stamped by an intermediate build of this same
|
||||
// series: structural typing was TypeScript-only at that point, and the fold
|
||||
// still typed a bare identifier that merely shadowed a class name as that
|
||||
// class — so such an index carries both pre-rollout edges for 13 languages
|
||||
// and fabricated ones. The gate is a strict `===`, so it must NOT reuse.
|
||||
expect(passesReuseGate(26)).toBe(false);
|
||||
// A pre-v28 (v27) index was stamped mid-series: TypeScript-only structural
|
||||
// typing, and the fold still typed a local that merely shadowed a class name
|
||||
// as that class — so it carries pre-rollout edges AND fabricated ones.
|
||||
expect(passesReuseGate(27)).toBe(false);
|
||||
// The current stamp passes the gate (incremental top-up eligible).
|
||||
expect(passesReuseGate(24)).toBe(true);
|
||||
expect(passesReuseGate(28)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,6 +101,16 @@ describe('fileContentHash', () => {
|
|||
});
|
||||
|
||||
describe('PARSE_CACHE_VERSION', () => {
|
||||
// 32 -> 34 for the receiver-chain capture (#2747): 33 is the TypeScript-only
|
||||
// emission, 34 the rollout to the other 13 emitters, which changed the capture
|
||||
// set a second time. Updated deliberately — and this pin earned its keep twice
|
||||
// over: it caught the #2742 collision when it arrived from main, and it caught
|
||||
// this series again on rebase, where `main` had independently taken 32 for the
|
||||
// very number this branch was already using.
|
||||
it('pins SCHEMA_BUMP to 34 so concurrent bumps cannot silently collide (#2736)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(34);
|
||||
});
|
||||
|
||||
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
|
||||
// Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version
|
||||
expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ const graphOf = (nodes: readonly StubNode[]): KnowledgeGraph => {
|
|||
|
||||
/** `line` is 1-based, matching the definition-id convention. */
|
||||
const def = (type: string, filePath: string, qualifiedName: string, line: number) => ({
|
||||
nodeId: `${filePath}#${line}:0:${qualifiedName}`,
|
||||
nodeId: `def:${filePath}#${line}:0:${type}:${qualifiedName}`,
|
||||
type,
|
||||
filePath,
|
||||
qualifiedName,
|
||||
|
|
@ -69,7 +69,7 @@ describe('buildGraphTargetIndex — value bindings join by position', () => {
|
|||
// the same construct, so they share file, line and name.
|
||||
expect(
|
||||
targetsFor([node('Function', 'a.kt', 'handler', 3)], [def('Property', 'a.kt', 'handler', 3)]),
|
||||
).toEqual(['a.kt#3:0:handler => Function:a.kt:handler']);
|
||||
).toEqual(['def:a.kt#3:0:Property:handler => Function:a.kt:handler']);
|
||||
});
|
||||
|
||||
it('REJECTS a value binding that merely shares a name with a callable elsewhere', () => {
|
||||
|
|
@ -106,12 +106,12 @@ describe('buildGraphTargetIndex — value bindings join by position', () => {
|
|||
[node('Function', 'a.php', '$save', 5), node('Function', 'a.php', 'save', 2)],
|
||||
[def('Variable', 'a.php', 'save', 5)],
|
||||
),
|
||||
).toEqual(['a.php#5:0:save => Function:a.php:$save']);
|
||||
).toEqual(['def:a.php#5:0:Variable:save => Function:a.php:$save']);
|
||||
});
|
||||
|
||||
it('keeps ordinary callable defs, which never take the positional path', () => {
|
||||
expect(
|
||||
targetsFor([node('Function', 'a.ts', 'fn', 1)], [def('Function', 'a.ts', 'fn', 1)]),
|
||||
).toEqual(['a.ts#1:0:fn => Function:a.ts:fn']);
|
||||
).toEqual(['def:a.ts#1:0:Function:fn => Function:a.ts:fn']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
30
gitnexus/test/unit/scope-resolution/definition-id.test.ts
Normal file
30
gitnexus/test/unit/scope-resolution/definition-id.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { definitionIdPosition } from '../../../src/core/ingestion/scope-resolution/utils/definition-id.js';
|
||||
|
||||
describe('definitionIdPosition', () => {
|
||||
it('reads coordinates after a path containing a coordinate-like fragment (#2734)', () => {
|
||||
expect(
|
||||
definitionIdPosition(
|
||||
'def:src/bugs#12:34:test/a.php#4:2:Function:$handler',
|
||||
'src/bugs#12:34:test/a.php',
|
||||
),
|
||||
).toEqual({ line: 4, column: 2 });
|
||||
});
|
||||
|
||||
it('does not mistake a private-name hash for the path separator', () => {
|
||||
expect(definitionIdPosition('def:src/a.ts#4:2:Method:#secret', 'src/a.ts')).toEqual({
|
||||
line: 4,
|
||||
column: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string | undefined, string]>([
|
||||
[undefined, 'src/plain.php'],
|
||||
['def:src/plain.php', 'src/plain.php'],
|
||||
['def:src/plain.php#x:2:Function:handler', 'src/plain.php'],
|
||||
['def:src/plain.php#4:x:Function:handler', 'src/plain.php'],
|
||||
['def:src/other.php#4:2:Function:handler', 'src/plain.php'],
|
||||
])('rejects malformed or mismatched definition id %s', (nodeId, filePath) => {
|
||||
expect(definitionIdPosition(nodeId, filePath)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
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(');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Unit tests for the enclosing-`mod` qualifier and the module-name filter
|
||||
* (#2742, hardened by the #2745 review).
|
||||
*
|
||||
* The integration suite proves the resolution outcomes; these pin the two
|
||||
* primitives directly, including the branches a fixture cannot reach — a scoped
|
||||
* target with no enclosing `mod`, and an all-anchor qualifier.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
import {
|
||||
qualifyByEnclosingModScope,
|
||||
type SyntaxNode,
|
||||
} from '../../../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import { couldNameAModule } from '../../../../src/core/ingestion/languages/rust/module-path.js';
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Rust as unknown as Parser.Language);
|
||||
|
||||
/** First node of `type` in a post-order walk — the innermost such node. */
|
||||
function findNode(root: SyntaxNode, type: string): SyntaxNode {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
const hits: SyntaxNode[] = [];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop() as SyntaxNode;
|
||||
if (node.type === type) hits.push(node);
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null) stack.push(child);
|
||||
}
|
||||
}
|
||||
const deepest = hits.sort((a, b) => b.startIndex - a.startIndex)[0];
|
||||
expect(deepest).toBeDefined();
|
||||
return deepest;
|
||||
}
|
||||
|
||||
function qualify(code: string, nodeType: string, rawText: string): string {
|
||||
const tree = parser.parse(code);
|
||||
return qualifyByEnclosingModScope(findNode(tree.rootNode, nodeType), rawText);
|
||||
}
|
||||
|
||||
describe('qualifyByEnclosingModScope', () => {
|
||||
it('leaves an item at the crate root untouched', () => {
|
||||
expect(qualify('fn dispatch() {}', 'function_item', 'dispatch')).toBe('dispatch');
|
||||
});
|
||||
|
||||
it('prefixes one enclosing mod', () => {
|
||||
expect(qualify('mod inner { fn dispatch() {} }', 'function_item', 'dispatch')).toBe(
|
||||
'inner.dispatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefixes the whole chain, outermost first', () => {
|
||||
expect(
|
||||
qualify('mod outer { mod tools { fn dispatch() {} } }', 'function_item', 'dispatch'),
|
||||
).toBe('outer.tools.dispatch');
|
||||
});
|
||||
|
||||
// The reason the no-enclosing-mod case returns raw text rather than
|
||||
// normalizing: `splitQualifiedName` rewrites `::` to `.`, which would move a
|
||||
// scoped impl target's node id away from the id its owner edge emits (#1975).
|
||||
it('returns a SCOPED target verbatim when there is no enclosing mod', () => {
|
||||
expect(qualify('impl a::Inner { fn helper(&self) {} }', 'impl_item', 'a::Inner')).toBe(
|
||||
'a::Inner',
|
||||
);
|
||||
});
|
||||
|
||||
it('is the identity for an unscoped name, mod or no mod', () => {
|
||||
expect(qualify('impl Inner { fn helper(&self) {} }', 'impl_item', 'Inner')).toBe('Inner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('couldNameAModule', () => {
|
||||
const known = new Set(['tools', 'inner']);
|
||||
|
||||
it('accepts a qualifier whose head names a known module', () => {
|
||||
expect(couldNameAModule(['tools'], known)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a type-qualified call (`Vec::new`) whose head names no module', () => {
|
||||
expect(couldNameAModule(['Vec'], known)).toBe(false);
|
||||
});
|
||||
|
||||
it('tests the first NON-anchor segment, not the anchor', () => {
|
||||
expect(couldNameAModule(['crate', 'tools'], known)).toBe(true);
|
||||
expect(couldNameAModule(['crate', 'Vec'], known)).toBe(false);
|
||||
expect(couldNameAModule(['super', 'inner'], known)).toBe(true);
|
||||
});
|
||||
|
||||
// `self::f()` names the caller's own module, which is always worth trying —
|
||||
// there is no head segment to test against the name set.
|
||||
it('accepts an all-anchor qualifier', () => {
|
||||
expect(couldNameAModule(['self'], known)).toBe(true);
|
||||
expect(couldNameAModule(['crate'], known)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the macro-expansion spelling of the crate anchor', () => {
|
||||
expect(couldNameAModule(['$crate', 'tools'], known)).toBe(true);
|
||||
});
|
||||
});
|
||||
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