Merge branch 'main' into fix/skill-evolution-gate

This commit is contained in:
Gergő Magyar 2026-08-01 22:42:41 +01:00 committed by GitHub
commit 56fc7936d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 6857 additions and 508 deletions

View file

@ -509,9 +509,19 @@ jobs:
# 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.
# which is the only arm that can see shapes the recorder is blind to:
# they emit no edge AND no drop, so fixing them moves the count by zero.
#
# `repos[0]` is no longer among them (#2766): Case 0's gate now accepts
# a minted receiver chain instead of testing the receiver's punctuation,
# so subscript receivers record a drop and ARE countable. 13 shapes moved
# INVISIBLE -> VISIBLE that way. `?.` and explicit type args remain
# invisible on some languages, so the shape arm still earns its keep.
#
# The check is EXACT-MATCH, which is strictly stronger than a ratchet:
# the count cannot rise without a deliberate rebaseline, and the
# rebaseline path demands the movement be explained. No separate
# drop-ratchet gate is needed on top of this.
run: node --import tsx bench/receiver-resolution/measure.mjs --check
working-directory: gitnexus

View file

@ -234,6 +234,14 @@ Property-key dispatch remains a separate conservative fallback. Its per-key fan-
Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning.
### Receiver chains and the drop census (#2766)
A compound receiver (`svc.getUser().address.save()`) is captured as a compact string on `ReferenceSite.receiverChain`. `utils/receiver-chain-codec.ts` is the ONE encoder/decoder — capture emitters, the scope-resolution fold, and the durable ParsedFile store all import it rather than hand-rolling the format.
Wire format is **v2**: `2|<base>|<step>|<step>…`, one-character version prefix, then base-first steps, each a one-character kind sigil plus the member name (`c` = call, `f` = field). `a` (await) and `i` (index) are **name-free** and encode as a bare sigil — an awaited call's name already lives on its `c` step, and a subscript key is a value, not a lookup-able identifier. The version went 1 → 2 when those two kinds were added, and a decoder REFUSES a foreign version rather than decoding the prefix it understands: a chain missing its await/index hop decodes cleanly as a different, shorter chain and would type the receiver against the wrong member. The format is unescaped (`|` and `~` cannot occur in an identifier), so an unencodable name is refused rather than escaped, and the payload is capped at `MAX_RECEIVER_CHAIN_BYTES` / `MAX_CHAIN_DEPTH` steps. Because these strings live in the incremental parse cache and the durable ParsedFile store, a format change requires a `PARSE_CACHE_VERSION` schema bump — a stale cache would otherwise replay v1 chains this build discards.
Receivers the resolver could not type are not silently dropped. Each records a `ResolutionOutcome` (`scope-resolution/resolution-outcome.ts`) carrying the receiver's *shape* (`classifyReceiverShape`: `chain-call` / `chain-field` / `chain-mixed` / `chain-unwrap` / `no-chain` — the bench censuses these) and its *origin* (`in-program` / `external` / `unknown`). `scope-resolution/unresolved-receivers.ts` aggregates them per member name into the index-persisted `unresolvedReceiverMembers` summary, keeping in-program and external counts under separate keys. Only in-program drops make a count short: an external-rooted call (`System.out.println`, `fetch(...)`) has no in-graph node an edge could have reached, so it is reported but does not hedge. `impact` / `context` read that summary and publish `epistemic: 'exact' | 'lower-bound'`, prose `boundaries`, and the machine-readable `causes` split (`EpistemicCauses` in `mcp/local/local-backend.ts`).
### Optional CFG/PDG emission (`--pdg`, #2081#2086)
On a `--pdg` run the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript today) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits the program-dependence layers from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and each layer is bounded by a per-function edge cap that logs any dropped edges. All layers are `BasicBlock → BasicBlock` edges in the single `CodeRelation` table, keyed by `type`; there is **no** `Function → BasicBlock` edge — the symbol↔block join is reconstructed from the BasicBlock id prefix + line span. The layers build on each other:
@ -263,7 +271,8 @@ Single interface a language implements to plug into the pipeline. Contract fully
| `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field |
| `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) |
| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) |
| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off |
| `elementTypeOf?` | `(containerType, via: {kind:'index'} \| {kind:'accessor',name}) → elementType \| undefined` — element type of a container, reached by subscript (`repos[0]`) or by a property-style collection view (`data.Values`). ONE hook for both routes (it replaced the split `unwrapCollectionAccessor` / `unwrapCollectionElement`, where implementing one silently answered nothing for the other). Consulted only where the source actually performed the access — never as a general type-name normalizer |
| `stripTypePreservingDecoration?` | `(typeName) → strippedName \| undefined` — strip ONE layer of TYPE-PRESERVING decoration (pointer, reference, `const`, nullable, borrow, sigil) so a receiver declared `*Host` still finds the `Host` binding (#2766). Never a container: unwrapping `Repo[]` here would fold `repos.find(x)` to `Repo.find` — that is `elementTypeOf`'s job, and only after a real subscript. Consulted only after every undecorated lookup fails, and only by receiver-chain base/step resolution — default off |
| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off |
| `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 |

View file

@ -138,6 +138,30 @@ export interface ReferenceSite {
* majority of sites the field costs nothing where it is not needed.
*/
readonly receiverChain?: string;
/**
* This site sits in CALLEE position: it is the expression being invoked by an
* enclosing call, not a value the program otherwise consumes. Only ever set on
* `kind: 'read'` sites, and only by languages whose member-read capture also
* matches the callee of a member call (`obj.f()` yields both a `call` site on
* `f` and a `read` site on `obj.f`).
*
* It is a POSITION FACT, not a decision. Whether that read is redundant
* depends on what the tail resolves to, which the capture layer cannot know:
*
* - tail is a METHOD the read duplicates the call's own edge and must be
* suppressed (an `ACCESSES → m` beside a `CALLS → m`
* at the same position is a phantom).
* - tail is a FIELD the read is GENUINE. `h.dep.Work()` where
* `Work func() error` selects a func-typed field and
* then calls the value it holds; deleting the read
* erases the only evidence that the field was used
* (callback/hook structs, hand-rolled mocks).
*
* The suppression is therefore applied at edge emission, where the resolved
* target's kind is known see `tryEmitEdge`. Absent on every site that is not
* in callee position, so nothing changes for languages that never set it.
*/
readonly inCalleePosition?: boolean;
}
/**
@ -153,4 +177,16 @@ export interface ReferenceSite {
* (`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 };
/**
* One hop in a receiver chain.
*
* `field` and `call` carry the member name they reach. `await` and `index` are
* NAME-FREE: the call step already holds the method name for an awaited call,
* and a subscript has no member name at all an index expression's key is a
* value, not an identifier the resolver could look up. The codec encodes them
* as a bare sigil and rejects any trailing characters, so the encoder's
* non-empty-name guard stays live for exactly the two kinds it was written for.
*/
export type MixedChainStep =
| { kind: 'field' | 'call'; name: string }
| { kind: 'await' | 'index'; name?: undefined };

View file

@ -264,8 +264,24 @@ export type ParsedImport =
export interface ParsedTypeBinding {
/** The name being bound (parameter name, `self`, assignment LHS, …). */
readonly boundName: string;
/** The raw type name as written in source (`'User'`, `'models.User'`, …). */
/** The type name AFTER this provider's normalization (`'User'`,
* `'models.User'`, ) see `TypeRef.rawName`. */
readonly rawTypeName: string;
/**
* Optional override for `TypeRef.declaredSpelling`, for a grammar that does
* not keep the whole written type under `@type-binding.type`.
*
* The scope extractor derives the spelling from that capture by default,
* which is right for every language whose type node spans the annotation.
* C++ is the exception: `User* repos` parses with the `*` on the DECLARATOR,
* so the type capture is a bare `User` and the container-ness the index step
* needs is nowhere in the captures the extractor reads. A provider that can
* reconstruct it exactly sets it here.
*
* Leave undefined otherwise the extractor's derivation is preferred to a
* per-language reimplementation of it.
*/
readonly declaredSpelling?: string;
readonly source: TypeRef['source'];
}
@ -370,8 +386,36 @@ export interface BindingRef {
* re-exports, and nested modules. Generics deferred to V2 via `typeArgs`.
*/
export interface TypeRef {
/** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */
/**
* The type name AFTER the language's capture-time normalization NOT
* necessarily what the source says. Every provider's `interpretTypeBinding`
* reduces the annotation before it gets here: TypeScript runs
* `stripGeneric` + `stripArraySuffix` to a FIXED POINT (`User[][]` `User`),
* Go's `normalizeGoTypeName` drops `[]` and `map[K]`, C#/Python/Kotlin/Rust
* strip their single-arg collection wrappers. What survives is the name a
* class lookup can use (`'User'`, `'models.User'`, `'List'`).
*
* A consumer that needs the CONTAINER, not the element, must read
* `declaredSpelling` see below.
*/
readonly rawName: string;
/**
* The annotation exactly as written, kept ONLY when `rawName` is not it.
*
* `rawName` alone cannot distinguish `repos: User[]` (a container the capture
* layer already reduced, so the position IS the element) from `grid: Grid`
* (an ordinary class the source happened to subscript). Both arrive as a bare
* class name that resolves. An index step reading only `rawName` therefore had
* no choice but to guess, and guessing "already reduced" typed `grid[0]` as
* `Grid` a confidently WRONG owner for the next member.
*
* Absent when the provider's normalization was a no-op (nothing was lost, so
* `rawName` is already the written spelling), and absent for TypeRefs
* synthesized outside the capture path (a `this` receiver binding, a
* propagated return type). Consumers must treat absence as "no container
* evidence" and decline, never as "not a container".
*/
readonly declaredSpelling?: string;
/** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */
readonly declaredAtScope: ScopeId;
readonly source:

View file

@ -1 +1 @@
b57c5479f158c41a6328fa8d61c234aaec88fc41780252d2fbd7baca21491aa2
a0da3e7c00f603e4bdad91a376b3fc181577a73c2ca1719ab7449d3463c671e0

View file

@ -1,5 +1,430 @@
# Receiver-resolution baseline
> **`baseline.json` is the source of truth for every number.** It is what
> `measure.mjs --check` enforces byte-exactly. This file is a lab notebook:
> each section records what was measured AT THAT UNIT and why it changed the
> plan. A figure here that disagrees with `baseline.json` is a superseded
> snapshot, not a live claim — sections carry a snapshot marker where that has
> already happened. Never quote a count from this file into code, a gate, or a
> commit message; read it from `baseline.json`.
## Receiver ORIGIN — three quarters of the hedge was the program boundary
The drop count was measuring two different things and reporting both as
uncertainty. Dumping all 102 call drops with source context settles it:
| Origin | Count | Is anything lost? |
| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external` | **44** | **No.** `System.out.println`, `fetch(...)`, `os.environ.setdefault`, `document.body.appendChild`, `.stream()`. The callee is not in the graph — there is no node an edge could point at. |
| `in-program` | 36 | Yes in principle — but see below. |
| `unknown` | 22 | Yes. Casts, ternaries, `globalThis.x ??= []`, and everything the classifier will not guess about. |
> **These numbers moved once, in review, and the movement is the point.** They
> were first measured as 76 / 20 / 6, when `external` was the FALLTHROUGH: any
> base whose type did not resolve was called external. Review reproduced two
> triggers where that published `epistemic: 'exact'` over a real in-program loss
> — a Go pointer receiver (`*Host`, whose lookup was missing the decoration
> stripper) and any base with no type binding at all, including this branch's own
> `droppedCall(svc)` fixture. `external` is now a POSITIVE determination via
> `LanguageProvider.isBuiltInName`, and everything unproven is `unknown`, which
> still hedges. So `external` fell 76 -> 44 and the difference went to
> `in-program` (+16, the drops that really were ours) and `unknown` (+16, the
> drops we decline to characterize). Total call drops is unchanged at 102 —
> this is re-bucketing, not resolution.
>
> A controlled A/B over the Java built-in set (off vs on, same tree) reads
> 7/36/59 vs 44/36/22: `in-program` is byte-identical across the toggle, so
> naming built-ins reclassified nothing the index can demonstrate is ours.
**A compiler resolves `System.out.println` against the JDK.** Lacking the JDK,
the honest statement is _"this call leaves the analyzed program"_ — not _"this
analysis is incomplete"_. Those are different epistemic states, and collapsing
them is what made `impact` report a lower bound on essentially every real
codebase, which is what teaches readers to ignore the signal.
`ResolutionOutcome.receiverOrigin` now records which one applies, and
`summarizeUnresolvedReceivers` skips `external`. `unknown` still counts —
assuming a completeness we cannot demonstrate is the unsafe direction.
### How origin is decided
By the receiver base's **declared type**, not its name. A first cut asked
whether the base was a local, which marked `inputs.stream()` in-program:
`inputs` is a local, but its type `List<String>` is JDK, so the target is
external. Asking whether the base's _type_ is one this index contains moved 28
sites to the correct bucket.
### What the remaining in-program drops actually are
Mostly **not** product defects. `user.Address.Save()` resolves cleanly in
isolation — the `csharp-deep-field-chain` fixture alone emits both expected
edges with **zero** drops. It drops in the count arm only because the corpus is
~200 independent mini-projects in one directory and **55 files define
`Address`**, so the resolver correctly declines on ambiguity rather than picking
one. That is right behaviour measured on an unrepresentative corpus.
The genuinely untypeable population is the `unknown` bucket — and those are the real
targets for type resolution, because a cast _gives_ you the type
(`((Box<String>) obj).open()`) and a ternary needs a join of its branch types.
They were previously invisible under the stdlib calls the old fallthrough swept
into `external`.
`callDropsByOrigin` is now part of the gated projection, so this split cannot
drift silently.
---
## Phantom callee read sites — a duplicate-edge bug the U8 test missed
Go's `@reference.read` pattern matches **every** `selector_expression`, with no
call-position exclusion. So `h.dep.Work()` minted **three** reference sites:
| site | kind | name | what it is |
| ---- | ------ | ------ | ------------------------------------------------------------- |
| S1 | `call` | `Work` | the member call |
| S2 | `read` | `Work` | **phantom** — the callee `h.dep.Work`, already captured by S1 |
| S3 | `read` | `dep` | the genuine field read |
S2 resolved through `findOwnedMember`, which prefers methods over fields, and
emitted an `ACCESSES` edge to the **method** duplicating S1's `CALLS` edge at the
same position.
**The U8 assertion passed by accident.** It asserted `RunSamePackage → Work` was
absent from `ACCESSES`, and it was — but only because that row has a _pointer_
receiver whose text-cascade head lookup failed for an unrelated reason. The
value-receiver twin was emitting the bad edge the whole time:
```
ACCESSES RunFromValueReceiver -> DoWork:Method <- phantom, shipped
ACCESSES RunLocal -> DoWork:Method <- phantom, shipped
```
First fix, at capture: drop the match outright, on the rule _"a selector in
function position is never a read."_ **That rule is false, and review caught
it.** In Go a func-typed struct field IS read and then called indirectly —
`h.dep.Work()` where `Work func() error` — and `isCalleeOfMemberCall` cannot
tell a method from a func-valued field, because the AST shape is identical.
Dropping at capture therefore deleted the only `ACCESSES` evidence for callback
structs, hook structs and hand-rolled mocks (`mock.DoFunc`, `opts.OnEvent`).
Second fix, and the one that shipped: **split the decision across the two layers
that each hold half of it.** Capture records the POSITION as a fact
(`@reference.callee-position``ReferenceSite.inCalleePosition`) — only the AST
knows it, and it is gone by resolution time. Emit makes the DECISION from the
resolved target's kind — only resolution knows whether the tail is a method or a
field, and it may be declared in another package. Neither layer can answer alone.
The suppression is language-neutral in `graph-bridge/edges.ts` and keys on the
canonical `CALL_TARGET_TYPES`, so `Macro` and `Delegate` targets are covered too.
A method _value_ (`f := h.dep.Work`) is not in function position and is
untouched. The assertion is backed by an exact-set check over the whole fixture —
now carrying target KINDS, so it catches both a new phantom and a deleted
genuine read.
### What the numbers say
- `callDrops` **unchanged at 102** — no call was lost, in either fix.
- `read` drops went **27 → 22** under the capture-time drop, then **22 → 27**
again once the marker replaced it. The round trip is the finding: those five
sites are genuine field reads, and the first fix was scoring their deletion as
an improvement.
- `totalDropsAllKinds` **124 → 129**, the same five sites.
- One drop reclassified `chain-field``chain-unwrap`. The phantom and the real
call share a site key, so the phantom's field-shaped chain was previously the
one recorded. The census now describes the actual dropped call.
Caught by three review agents dispatched at the A1 regression; the phantom was
the mechanism, not the global-normalization story the first revert note asserted.
The func-field regression it introduced was then caught by two more, on the
tri-review of #2782 — which is the argument for the exact-set-with-kinds
assertion over the targeted one that passed by accident the first time.
---
## U9 (part 2) — no drop ratchet is needed; the gate is already stronger
The plan's R10 set a ZERO supported-shape drop target, and review correctly
found that it contradicts R12: a site whose normalized name matches more than
one class MUST decline, a decline records a drop, and simple names collide
routinely in large Go and Java codebases. The proposed fix was a ratchet — the
count may not rise above the value measured after the last unit.
Neither is needed. `measure.mjs --check` already asserts **exact match** against
the committed baseline, which is strictly stronger than a ratchet: the count
cannot rise _or_ fall without a deliberate `--update-baseline`, and that path
prints an instruction to explain the movement in the commit message. A ratchet
would be a weakening.
So R10 as written (zero) was wrong, and the ratchet proposed to repair it is
redundant. The existing gate stands, now also covering `callDropsByShape` since
the shape census joined the gated projection.
**Deferred and NOT done: the `impact` risk-cutoff recalibration.** Review flagged
that added edges push symbols toward the absolute cutoffs (`directCount >= 30`,
`impacted.length >= 200`), so edits read HIGHER risk without being more
dangerous, and agents warning on HIGH/CRITICAL escalate more often. That is real,
but measuring it honestly needs a before/after risk distribution over a corpus
large enough for those thresholds to bind — the committed fixtures are nowhere
near 200 impacted symbols. Recording it as owed rather than inventing a number
from fixtures that cannot exercise the cutoffs.
---
## U6 — the depth cap does NOT limit resolution. Measured, not raised.
The premise was that a chain deeper than `MAX_CHAIN_DEPTH` (3) is discarded
whole rather than truncated, so a 4-hop builder chain "contributes nothing at
all". The first half is true; the second is not.
`fourHopChain` was added to the TypeScript corpus as a declared extra
specifically to make the question answerable — without a chain longer than the
cap, raising the cap measures nothing:
```ts
root.getSvc().getUser().address.getCity().save();
// ^step1 ^step2 ^step3 ^step4 receiver of `save` = 4 steps
```
| Cap | Chain minted? | Cell state |
| --- | ---------------------------------------------------- | ------------ |
| 3 | **none** (confirmed by probing the emitter directly) | **RESOLVES** |
| 4 | `2\|root\|cgetSvc\|cgetUser\|faddress\|cgetCity` | RESOLVES |
The site resolves at BOTH depths. At 3 it resolves through the text cascade,
which owns the fallback path and runs to its own
`COMPOUND_RECEIVER_MAX_DEPTH` of 8.
**So the cap bounds which chains are typed structurally, not which calls
resolve.** Raising it moves work from the cascade to the fold without changing a
single edge — measured across the whole matrix: totals identical at 3 and 4,
`callDrops` 102 at both.
Left at 3. The fixture is committed so the next person to reach for this number
inherits the measurement instead of the intuition.
What DID need fixing: `unwrapTransparentReceiver` shared `MAX_CHAIN_DEPTH` as
its iteration bound. The two answer unrelated questions — how many chain hops do
we type, versus how many redundant parens might someone write — so raising the
chain cap would have silently widened the paren peel as a side effect. That
coupling got worse when the await/subscript work added a peel call at loop
entry. Now `MAX_TRANSPARENT_WRAPPER_DEPTH`, its own constant.
---
## U9 — the epistemic hedge has TWO producers, and only one is a defect
`impact` reports `epistemic: 'lower-bound'` for two independent reasons that were
previously indistinguishable in the output:
| Cause | Unit | What it means | Is it a defect? |
| ------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `receiverTyping` | call sites | Call sites dropped because the analyzer could not type the receiver | **Yes** — a resolver gap. This is the population this whole series targets. |
| `dispatchBoundary` | symbols | The symbol sits behind an interface with real consumers or 2+ implementations; the number is the implementations plus interface-level consumers behind it | **No** — callers binding through DI or dynamic dispatch are genuinely untraceable statically. A compiler refuses here too. |
| `externalBoundary` | call sites | The call left the indexed program (`System.out.println`, `fetch(...)`) | **No**, and not even a shortfall — there is no in-graph node an edge could have reached. An `epistemic: 'exact'` result can carry it. |
Both collapsed into one enum plus prose, so a consumer — especially a coding
agent gating its own edits on the result — could tell THAT a count was short but
not WHY, and could not branch on the difference. Worse, it made "the hedge should
stop appearing" unfalsifiable: with no way to see which producer fired, there was
no way to check whether fixing receiver typing had done anything.
`impact` and `context` now carry a structured `causes: { receiverTyping,
dispatchBoundary, externalBoundary }` alongside the prose. Every field counts
MISSING THINGS, never notes: there is one note per symbol name (and one per
boundary node) but each reports N of something, so counting notes published `1`
next to prose reading "2 call sites", and a consumer branching on the number
would have read a different magnitude than the human reading the text. The same
rule applies to `dispatchBoundary`, which counts the implementations plus
interface-level consumers behind the boundary rather than the boundary sentences
— one sentence can describe an interface with 40 implementations. Its unit is
SYMBOLS rather than call sites because per-site multiplicity is not retained on
those edges (consumers are counted `DISTINCT`, and
`collapseMemberCallsByCallerTarget` languages emit one CALLS edge per
caller/target pair); the units are stated per field on `EpistemicCauses` so a
consumer knows which it is holding.
**Only the `receiverTyping` producer is addressed by this series.** The dispatch
boundary is untouched and will keep firing for interface-dispatched symbols —
which is correct. Any claim that the hedge has "stopped appearing" has to be read
per-producer, and that is now possible.
Measured on the #2766 reproduction: `WithTx` went from `impactedCount: 0` with a
`lower-bound` hedge to `impactedCount: 1` with `epistemic: exact`. The hedge is
gone there because its cause is gone, not because it was suppressed.
---
## U10 — recorded drops, censused by receiver shape
`ResolutionOutcome`'s suppressed variant now carries `receiverShape`, set by the
emitting case from the site's ENCODED CHAIN — the compact string the capture
emitters mint by walking the real AST. Never re-derived from the source line:
doing that would mean regex-classifying the number that gates this work, the
same textual-shape dispatch the structural-receiver line exists to remove.
Diagnostic only, so the persisted `RepoMeta.unresolvedReceiverMembers` artifact
is unchanged.
Census of the call drops on the committed fixture corpus, **as measured at U10**
— it predates the phantom-read fix documented above, which reclassified one drop
`chain-field``chain-unwrap`. `callDropsByShape` in `baseline.json` is current:
| Shape | Count | Share |
| ------------------------------------------------------------- | ----- | ----- |
| `chain-field` — every step a field (`h.repo.save()`) | 60 | 59% |
| `chain-call` — every step a call (`svc.getUser().save()`) | 27 | 27% |
| `no-chain` — no chain minted; the walk found no nameable base | 12 | 12% |
| `chain-mixed` — interleaved (`svc.getUser().addr.save()`) | 2 | 2% |
Two decisions come out of it.
**The `.java` bucket is not one defect.** Its 49 call drops split 30 field-chain
/ 14 call-chain / 5 no-chain, so the open question of whether Java's largest-
single-bucket status hides a single cause is answered: it does not. It is the
same population as everywhere else, just more of it.
**Field-receiver chains are where the remaining value is.** At 59% of the U10
census they dominate, and they are precisely the shape U1 fixed for Go. The same
defect class in java, csharp, cpp, php, py and rust is the largest addressable
population the count arm can see. (This paragraph used to quote a per-extension ×
per-shape split from the U10 run. `baseline.json` carries `callDropsByExtension`
and `callDropsByShape` but not their cross-product, so that split has to be
re-derived from a fresh run rather than read off the committed baseline.)
**What this census CANNOT justify.** Await-wrapped and subscript receivers barely
appear, because the committed fixture corpus contains almost no such sites — not
because they are rare in real code. At U10 `indexElement` was a gap in every
language in the shape arm, so U5's population was real but structurally invisible
to the count arm. (It no longer is uniform — the subscript route resolves in
several languages now; read the current per-language state from `indexElement` in
`baseline.json`, not from this paragraph.) The durable point: any decision to fund
or drop U4 and U5 has to be read off the SHAPE arm, because reading it off this
census confuses "absent from these fixtures" with "does not happen".
## U2 — shape matrix expanded to a canonical axis
The shape arm was three languages with an ad-hoc shape list each. It is now a
**canonical 10-shape axis** (`SHAPE_IDS`) that every language must answer for,
with two states added so a hole cannot masquerade as a measurement:
- `N/A` — the grammar does not admit this spelling. **A reason is required.** An
omitted cell and a genuinely inapplicable cell look identical in a diff
otherwise, which is how coverage rots.
- `GRAMMAR-UNAVAILABLE` — the parser could not be loaded, so nothing was
measured. Neither passes nor fails the gate, and `drift` skips it on **both**
sides so the gate cannot fail for the environment it ran in. `tree-sitter-dart`,
`-kotlin` and `-swift` are vendored _optional_ grammars: absent when a run sets
`GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`, and soft-failing when no vendored prebuild
matches the host (the set covers darwin/linux arm64+x64 and win32-arm64 — a
win32-x64 or musl host has none). **All 14 load on a glibc linux-x64 host, so
this state has no producer in the committed baseline** — it guards the
skip-flag and unsupported-host cases rather than a condition seen here.
`assertMatrixComplete` throws when a language omits a cell, declares an unknown
id, or writes an `N/A` with no reason. Languages may declare `extraShapeIds` for
diagnostics the canonical axis cannot express (PHP's annotated/unannotated
return-type pair, C++'s pointer/value base pair) — an extra must be declared, so
it stays a deliberate diagnostic rather than a typo'd canonical id.
**Vue and COBOL** are language-level `N/A` rows: their emitters never call
`synthesizeReceiverChainCapture`, so there is nothing to measure — but the
language axis now obeys the same no-omitted-cells rule as the shape axis.
### What the first expanded run found
Three results that redirected the plan they were built to serve. **Snapshot: the
first U2 run, before any of the fixes below landed** — these cells state the
problem, and several have since flipped (`baseline.json` is current):
**Go — the root cause, isolated to one cell.** Three rows vary receiver
decoration and field decoration independently:
| Cell | Receiver | Field | State |
| ----------------------- | ----------- | ----------- | --------------- |
| `fieldReceiverCall` | value | value | RESOLVES |
| `decoratedFieldType` | value | **pointer** | RESOLVES |
| `decoratedReceiverBase` | **pointer** | value | **VISIBLE-GAP** |
Only the pointer _receiver_ fails. Go already normalizes field type bindings
through `normalizeGoTypeName`, so the step lookup is sound and the defect is
entirely the base — `synthesizeGoReceiverBinding` stores `typeNode.text` raw, so
`func (h *Host)` binds `h` to the literal `*Host`, which
`findClassBindingInScope` cannot resolve.
**PHP — the sigil hypothesis is dead.** The two rows differ only in whether the
called method declares a return type:
| Cell | Return type | State |
| --------------------------------------------- | ------------- | ------------- |
| `arrowCallChain``$svc->getUser()->save()` | unannotated | INVISIBLE-GAP |
| `plainChain``$svc->getUserTyped()->save()` | **annotated** | **RESOLVES** |
Same chain, same `->`, same base. PHP chains resolve when the return type is
declared; the `$` sigil is not involved. `decoratedFieldType` (`?User $repo`)
also resolves, so PHP nullable field types already work.
**C++ — the base already resolves, but `this->` field receivers do not.**
`pointerArrowChain` and `valueDotChain` both RESOLVE, so a decorated C++ base is
not a gap. But `this->repo.save()` and `this->repo->save()` are both
INVISIBLE-GAP — a distinct defect, not a decoration one.
**Rust — the decorated receiver is NOT a gap.** `&mut self` resolves, so Go is
the only language whose method receiver decoration defeats the lookup. Rust's
gap is the field: `Box<User>` is INVISIBLE-GAP.
### The decoration cells, across all 14
The rows U1 exists to fix. Everything else is a different defect. **Snapshot: as
measured at U2, i.e. BEFORE U1 landed** — it is the statement of the problem, not
of the current state. Go's `decoratedReceiverBase` and TypeScript's
`decoratedFieldType` have since moved; `baseline.json` has the live cells.
| Language | `decoratedReceiverBase` | `decoratedFieldType` |
| ------------------------- | ------------------------- | ---------------------------------- |
| go | **VISIBLE-GAP** (`*Host`) | RESOLVES |
| rust | RESOLVES (`&mut self`) | **INVISIBLE-GAP** (`Box<User>`) |
| typescript | N/A | **INVISIBLE-GAP** (`User \| null`) |
| csharp | N/A | **VISIBLE-GAP** (`User?`) |
| swift | N/A | **INVISIBLE-GAP** (`User?`) |
| cpp | N/A | **INVISIBLE-GAP** (`User*`) |
| python, php, kotlin, dart | N/A | RESOLVES |
| java, c, javascript, ruby | N/A | N/A |
So U1's measured scope is **Go's receiver base**, plus the field-type gap in
**Rust, TypeScript, C#, Swift and C++** — and _not_ PHP, Python, Kotlin, Dart or
Java, whose decoration handling already works or does not exist. Five of the
seven hooks the plan speculatively listed were aimed at languages that need
none; three languages that do need one were not on the list at all.
### Other gaps this run surfaced, not in the plan
- **Swift resolves almost nothing.** `plainChain`, `plainDeepChain`,
`optionalChain` and `nonNullAssert` are all INVISIBLE-GAP, while
`fieldReceiverCall` resolves. Chained receivers are essentially unsupported.
- **Ruby chains are VISIBLE-GAPs** (`plainChain`, `plainDeepChain`,
`optionalChain`) and `fieldReceiverCall` on `@repo` is INVISIBLE.
- **C++ `this->` field receivers** are INVISIBLE-GAP in both the value and
pointer form.
- **C# has four gaps** beyond the field one: `optionalChain`, `nonNullAssert`,
`awaitParen`, `explicitTypeArgs`.
- **Dart `await` already resolves** — the only language where `awaitParen` is
green, which makes it the reference for U4's unwrap direction.
- **`indexElement` was INVISIBLE-GAP in all 14** at U2 — uniform, and exactly what
U5 targets. (Superseded: several languages resolve it now; see `baseline.json`.)
### Coverage status
All 14 languages measured, plus `vue` and `cobol` as language-level `N/A` rows.
The cell tally recorded at U2 was 164 cells / 42 RESOLVES / 22 VISIBLE-GAP / 31
INVISIBLE-GAP / 69 N/A / 0 GRAMMAR-UNAVAILABLE — a snapshot, superseded by every
unit since (the axis also gained TypeScript's declared `fourHopChain` extra).
Count the states off `baseline.json` rather than quoting this line.
The **count arm did not move when the shape axis was expanded** — shape fixtures
are built in temp directories and never touch the committed corpus, so expanding
the shape axis moves the shape arm only.
---
> **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
@ -11,12 +436,18 @@
> 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.
> Two gaps remained open **at U10**, both genuine at the time:
>
> - `(await svc.getUserAsync()).save()``extractMixedChain` reached `await …`,
> which is not a chain node, so no chain was minted. It was a VISIBLE-GAP and is
> the call-kind fixture in the drop-recorder test.
> - `repos[0].save()` — Case 0's punctuation gate never fired for a subscript
> receiver, so it was INVISIBLE.
>
> Both were subsequently closed for TypeScript by the `await`/`index` step kinds
> (wire format v2) and by Case 0's third gate arm, which admits any site carrying
> a minted chain regardless of receiver punctuation. Per-language state is in
> `baseline.json``awaitParen` and `indexElement`.
>
> The tables below are the pre-U10 measurement, kept as the reference point.
@ -27,15 +458,15 @@ A/B produced by reverting ONLY the fold wiring (`compound-receiver.ts` +
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** |
| 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)
@ -43,7 +474,7 @@ 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*
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
@ -82,26 +513,32 @@ 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` |
**Snapshot: the U7-era measurement (commit `f87b2cbe`), kept as the reference
point for the A/B above.** The gate enforces `countArm` in `baseline.json`, which
has moved since — read the live call-drop number, site-kind split, and
per-extension breakdown from there.
Call drops by extension:
| Metric | Value at U7 |
| -------------------------------- | ---------------------- |
| **Call drops (the gate number)** | **99** |
| Total drops, all site kinds | 124 |
| Split by site kind | `call: 99`, `read: 25` |
| 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 |
Call drops by extension, at U7:
**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
| 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).** About a fifth of the
drops are property _reads_, not lost calls (25 of 124 at U7; `bySiteKind` in
`baseline.json` is current). 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
bucket as a dropped method call. Gating on the unsplit total would have measured a
population one fifth of which this work does not target.
## Shape arm
@ -110,19 +547,24 @@ population one fifth of which this work does not target.
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 | — |
**Snapshot: the pre-U10 measurement over three languages**, kept because it is the
evidence that the shape arm moves when the count arm does not. Superseded twice —
by U8's rollout table above and by the canonical shape axis in `baseline.json`.
The three TypeScript rows marked as gaps here (`?.`, `!`, `<T>`) all resolve now.
| Language | Shape | State at pre-U10 | 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
@ -142,11 +584,11 @@ receiver has no well-defined type is not a usable control.
`@reference.call.member`, `@reference.name`, and crucially
`@reference.receiver`:
| Shape | `@reference.receiver` |
|---|---|
| `svc?.getUser().save()` | `svc?.getUser()` |
| Shape | `@reference.receiver` |
| ----------------------------- | ---------------------- |
| `svc?.getUser().save()` | `svc?.getUser()` |
| `svc.getTyped<User>().save()` | `svc.getTyped<User>()` |
| `repos[0].save()` | `repos[0]` |
| `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.
@ -155,7 +597,7 @@ receiver has no well-defined type is not a usable control.
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
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
@ -163,13 +605,13 @@ receiver has no well-defined type is not a usable control.
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
_(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.)*
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
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.
@ -182,12 +624,26 @@ receiver has no well-defined type is not a usable control.
## 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.
must be read against the same bias. Kept in sync with `KNOWN_BLIND` in
`measure.mjs`, which prints these on every run.
- 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.
- Case 0 is reached by a receiver-TEXT punctuation test (`.` or `(`) **or** by a
minted receiver chain. A receiver spelled without that punctuation — a subscript
`repos[0]`, a PHP `->` / `::` property path — therefore reaches the recorder only
where its emitter mints a chain. Where no chain is minted, the call still
vanishes with the instrument blind to it.
- A drop is recorded only while `compoundReceiverUnresolved` stays true. When the
cascade TYPES the receiver but then finds no member on it, the flag is false and
no drop is recorded even though no edge was emitted. So an absent drop is not
evidence a site resolved — the recorder can under-report by mis-attribution, not
only by a gate. (This is what moved PHP's `arrowCallChain` from VISIBLE-GAP to
INVISIBLE-GAP when its fixture parameter was typed; see U8 below.)
- Retracted, and left here because it was quoted for several units: the earlier
claim that `?.` and explicit type arguments _"produce no reference site at all"_.
They do — the capture dump under "Corrections to the plan" §2 shows a full call
match with `@reference.receiver` for all three of `svc?.getUser()`,
`svc.getTyped<User>()` and `repos[0]`. The absence was of an EDGE and of a DROP,
never of a site.
## U8 — per-language rollout
@ -200,15 +656,15 @@ 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 |
| 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,
@ -225,6 +681,10 @@ the emitter:
name=save chain=1|$svc|cgetUser recv=$svc.getUser()
```
The leading `1` is the **v1** wire prefix current when this dump was taken; the
codec is at v2 now (`2|$svc|cgetUser`), and a v2 decoder refuses a v1 payload by
design — do not copy this literal into a fixture.
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

View file

@ -1,28 +1,208 @@
{
"shapeArm": {
"vue": {
"plainChain": "N/A",
"plainDeepChain": "N/A",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "N/A",
"fieldReceiverCall": "N/A",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"cobol": {
"plainChain": "N/A",
"plainDeepChain": "N/A",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "N/A",
"fieldReceiverCall": "N/A",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"typescript": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "RESOLVES",
"nonNullAssert": "RESOLVES",
"awaitParen": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "RESOLVES",
"indexElement": "INVISIBLE-GAP"
"indexElement": "RESOLVES",
"fourHopChain": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
},
"php": {
"arrowCallChain": "INVISIBLE-GAP",
"arrowPropertyPath": "RESOLVES"
"arrowPropertyPath": "RESOLVES",
"plainChain": "RESOLVES",
"plainDeepChain": "INVISIBLE-GAP",
"optionalChain": "INVISIBLE-GAP",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "N/A",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
},
"cpp": {
"pointerArrowChain": "RESOLVES",
"valueDotChain": "RESOLVES"
"valueDotChain": "RESOLVES",
"plainChain": "N/A",
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "VISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "INVISIBLE-GAP",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "INVISIBLE-GAP"
},
"go": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "RESOLVES"
},
"javascript": {
"plainChain": "VISIBLE-GAP",
"plainDeepChain": "VISIBLE-GAP",
"optionalChain": "VISIBLE-GAP",
"nonNullAssert": "N/A",
"awaitParen": "VISIBLE-GAP",
"explicitTypeArgs": "N/A",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"python": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "N/A",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
},
"java": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "RESOLVES",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"csharp": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "INVISIBLE-GAP",
"nonNullAssert": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "VISIBLE-GAP",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "VISIBLE-GAP"
},
"ruby": {
"plainChain": "VISIBLE-GAP",
"plainDeepChain": "VISIBLE-GAP",
"optionalChain": "VISIBLE-GAP",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "INVISIBLE-GAP",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"rust": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "INVISIBLE-GAP",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "INVISIBLE-GAP"
},
"c": {
"plainChain": "N/A",
"plainDeepChain": "VISIBLE-GAP",
"optionalChain": "N/A",
"nonNullAssert": "N/A",
"awaitParen": "N/A",
"explicitTypeArgs": "N/A",
"indexElement": "VISIBLE-GAP",
"fieldReceiverCall": "INVISIBLE-GAP",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "N/A"
},
"kotlin": {
"plainChain": "RESOLVES",
"plainDeepChain": "RESOLVES",
"optionalChain": "RESOLVES",
"nonNullAssert": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "RESOLVES",
"indexElement": "RESOLVES",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
},
"swift": {
"plainChain": "INVISIBLE-GAP",
"plainDeepChain": "INVISIBLE-GAP",
"optionalChain": "INVISIBLE-GAP",
"nonNullAssert": "INVISIBLE-GAP",
"awaitParen": "VISIBLE-GAP",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "INVISIBLE-GAP"
},
"dart": {
"plainChain": "VISIBLE-GAP",
"plainDeepChain": "VISIBLE-GAP",
"optionalChain": "VISIBLE-GAP",
"nonNullAssert": "VISIBLE-GAP",
"awaitParen": "RESOLVES",
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "N/A",
"decoratedFieldType": "RESOLVES"
}
},
"countArm": {
"callDrops": 101,
"totalDropsAllKinds": 128,
"callDrops": 102,
"totalDropsAllKinds": 129,
"bySiteKind": {
"call": 101,
"call": 102,
"read": 27
},
"callDropsByExtension": {
@ -34,11 +214,23 @@
".py": 5,
".go": 5,
".php": 4,
".kt": 4,
".rs": 3,
".kt": 3,
".rb": 2,
".js": 1,
".swift": 1
},
"callDropsByShape": {
"chain-field": 60,
"chain-call": 27,
"no-chain": 12,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"external": 44,
"in-program": 36,
"unknown": 22
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,17 @@
{
"_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": "5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb",
"fingerprint": "e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3",
"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_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."
"_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.",
"_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103."
},
"cobol": {
"fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e",
@ -25,7 +30,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": "7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5",
"fingerprint": "856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc",
"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.",
@ -37,20 +42,22 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc."
},
"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": "8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855",
"fingerprint": "476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc",
"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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc."
},
"rust": {
"fingerprint": "83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c",
"fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809",
"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.",
@ -61,35 +68,39 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809."
},
"php": {
"fingerprint": "3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28",
"fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c",
"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.",
"_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
},
"ruby": {
"fingerprint": "fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83",
"fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57",
"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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
},
"swift": {
"fingerprint": "a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b",
"fingerprint": "2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7",
"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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7."
},
"dart": {
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
@ -102,7 +113,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": "310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee",
"fingerprint": "a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9",
"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.",
@ -114,16 +125,18 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9."
},
"java-local-types": {
"fingerprint": "3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236",
"fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633",
"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.",
"_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633."
},
"typescript": {
"fingerprint": "9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc",
"fingerprint": "cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff",
"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.",
@ -133,10 +146,11 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff."
},
"javascript": {
"fingerprint": "83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc",
"fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594",
"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.",
@ -146,10 +160,11 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594."
},
"kotlin": {
"fingerprint": "d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1",
"fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2",
"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.",
@ -159,6 +174,8 @@
"_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_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."
"_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.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2."
}
}

View file

@ -250,10 +250,17 @@ export function registerGroupCommands(program: Command): void {
} else {
const summary = (raw as { summary?: Record<string, number> })?.summary;
const risk = (raw as { risk?: string })?.risk;
const boundaryOnly =
(
raw as {
cross?: Array<{ fanout_status?: string }>;
}
)?.cross?.filter((entry) => entry.fanout_status === 'not_attempted').length ?? 0;
console.log(`Group impact for "${name}" (${String(opts.repo)}): risk=${risk ?? '?'}`);
if (summary) {
const boundaryNote = boundaryOnly > 0 ? ` (${boundaryOnly} boundary-only)` : '';
console.log(
` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}`,
` direct=${summary.direct ?? 0} processes=${summary.processes_affected ?? 0} cross=${summary.cross_repo_hits ?? 0}${boundaryNote}`,
);
}
}

View file

@ -11,6 +11,7 @@ import {
resolveLanguageKey,
} from '../tree-sitter/parser-loader.js';
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { getProvider } from '../ingestion/languages/index.js';
const parserCache = new Map<string, any>();
@ -30,7 +31,15 @@ export const ensureAndParse = async (content: string, filePath: string): Promise
parserCache.set(parserKey, parserInstance);
}
return parseSourceSafe(parserInstance, content);
// Same text the ingestion worker parses — otherwise a provider whose
// `preprocessSource` repairs a declaration (Swift conditional directives,
// C++ UE macros, Dart extension types) would leave embeddings looking at an
// error-recovered tree. Resolved from `language` so the transform and the
// parser always come from the same provider. Length-preserving, so node
// offsets still index `content`.
const parseContent = getProvider(language).preprocessSource?.(content, filePath) ?? content;
return parseSourceSafe(parserInstance, parseContent);
};
const FUNCTION_LIKE_TYPES = new Set([

View file

@ -349,14 +349,31 @@ function extractProcessNames(impact: unknown): string[] {
// No behavior change — `'UNKNOWN'` was already handled correctly at the
// `(localRisk === 'LOW' || localRisk === 'UNKNOWN')` branch below.
export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
const highConf = cross.some((c) => c.contract.confidence >= 0.85);
const traversed = cross.filter((c) => c.fanout_status !== 'not_attempted');
const highConf = traversed.some((c) => c.contract.confidence >= 0.85);
if (localRisk === 'CRITICAL') return 'CRITICAL';
if (cross.length >= 3) return 'CRITICAL';
if (traversed.length >= 3) return 'CRITICAL';
if (highConf) return 'HIGH';
if (cross.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN')) return 'MEDIUM';
if (traversed.length > 0 && (localRisk === 'LOW' || localRisk === 'UNKNOWN')) return 'MEDIUM';
return localRisk;
}
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
const sameBoundary = (entry: CrossRepoImpact): boolean =>
entry.repo_path === candidate.repo_path && entry.contract.id === candidate.contract.id;
if (candidate.fanout_status === 'not_attempted') {
if (cross.some(sameBoundary)) return;
} else {
const boundaryOnlyIndex = cross.findIndex(
(entry) => sameBoundary(entry) && entry.fanout_status === 'not_attempted',
);
if (boundaryOnlyIndex >= 0) cross.splice(boundaryOnlyIndex, 1);
}
cross.push(candidate);
}
export async function ensureBridgeReady(
groupDir: string,
): Promise<{ handle: BridgeHandle } | { error: string }> {
@ -587,7 +604,12 @@ export async function runGroupImpact(
const seen = new Set<string>();
for (const n of neighbors) {
if (servicePrefix && !fileMatchesServicePrefix(n.neighborFilePath, servicePrefix)) {
const manifestOnly = n.neighborUid.startsWith('manifest::');
if (
servicePrefix &&
!manifestOnly &&
!fileMatchesServicePrefix(n.neighborFilePath, servicePrefix)
) {
continue;
}
if (!repoInSubgroup(n.neighborRepo, subgroup)) {
@ -604,8 +626,7 @@ export async function runGroupImpact(
if (seen.has(key)) continue;
seen.add(key);
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
if (!manifestOnly && deadline - Date.now() <= 0) {
truncatedRepos.push(n.neighborRepo);
continue;
}
@ -621,6 +642,35 @@ export async function runGroupImpact(
continue;
}
// A manifest link can prove the repository boundary even when its far
// endpoint has no graph symbol of its own (for example, a string-based
// RPC dispatch). Those endpoints deliberately use a deterministic
// `manifest::...` UID. Calling impactByUid with that synthetic value can
// never succeed because it is not a node in the verified neighbor
// repository; dropping the crossing here made `group sync` report the
// manifest link while `group impact` silently returned cross=0 (#2722).
//
// Preserve the proven crossing with an empty local fan-out. Real UIDs
// still take the normal impactByUid path below, where failures remain
// truncations rather than false successful traversals.
if (manifestOnly) {
addCrossImpact(cross, {
repo: regName,
repo_path: n.neighborRepo,
contract: {
id: n.contractId,
type: n.contractType as ContractType,
match_type: 'manifest',
confidence: n.confidence,
},
by_depth: {},
affected_processes: [],
fanout_status: 'not_attempted',
});
continue;
}
const remainingMs = deadline - Date.now();
// Phase-2 hardening: race each impactByUid against a per-call
// timeout derived from the remaining budget. Without this wrap a
// single hung neighbor would pin the request past the clamped
@ -644,7 +694,7 @@ export async function runGroupImpact(
continue;
}
cross.push({
addCrossImpact(cross, {
repo: regName,
repo_path: n.neighborRepo,
contract: {

View file

@ -177,6 +177,12 @@ export interface CrossRepoImpact {
};
by_depth: Record<string, unknown[]>;
affected_processes: string[];
/**
* Present when the bridge proves a repository boundary but the far endpoint
* has no graph symbol, so local fan-out cannot be attempted. Omitted for
* completed fan-out to preserve the existing serialized result shape.
*/
fanout_status?: 'not_attempted';
}
export interface OutOfScopeLink {

View file

@ -114,9 +114,17 @@ interface LanguageProviderConfig {
* The current C++ UE-macro preprocessor relies on the practical fact that
* UE reflection macros and module-export tokens are ASCII-only.
*
* Must be a pure function same input always yields the same output. Called
* once per file, on every code path that re-parses (parsing-processor, import
* processor, heritage processor, call processor, parse worker).
* Must be a pure function same input always yields the same output, and
* re-applying it to its own output changes nothing.
*
* Applied by the parse worker (`parse-worker.ts`), by `extractParsedFile`
* (`scope-extractor-bridge.ts`) on the parse-cache-miss path, and by the
* embedding parse (`embeddings/ast-utils.ts`, which does not go through the
* bridge). Any *new* path that re-parses a file must apply it too, or the two
* halves of the pipeline analyze different programs and note the set is not
* closed today: language-owned re-parse helpers reached through other
* provider hooks (e.g. `populateRangeBindings`) still see raw text.
* `test/unit/preprocess-source-parity.test.ts` pins the bridge equivalence.
*
* Default: undefined (no preprocessing `file.content` is parsed verbatim).
*/

View file

@ -8,6 +8,7 @@ import {
import { getCppParser, getCppScopeQuery } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { stripUeMacros } from '../../cpp-ue-preprocessor.js';
import { normalizeQualifiedName } from '../../utils/qualified-name.js';
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
import {
@ -159,8 +160,12 @@ export function emitCppScopeCaptures(
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getCppParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getCppParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
// Idempotent re-application: `extractParsedFile` already preprocesses, but
// direct emitter callers (benchmarks, capture goldens) must see the same
// program the pipeline does.
const parseText = stripUeMacros(sourceText);
tree = parseSourceSafe(getCppParser(), parseText, undefined, {
bufferSize: getTreeSitterBufferSize(parseText),
});
}

View file

@ -77,7 +77,76 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi
source = 'annotation';
}
return { boundName: name, rawTypeName: normalizeCppTypeName(type), source };
const declaredSpelling = cppPointerSpelling(captures, type, name);
return declaredSpelling === undefined
? { boundName: name, rawTypeName: normalizeCppTypeName(type), source }
: { boundName: name, rawTypeName: normalizeCppTypeName(type), declaredSpelling, source };
}
/** Anchors whose capture spans a whole declaration, so the declarator and
* with it the pointer is inside the captured text. */
const CPP_WHOLE_DECLARATION_ANCHORS = [
'@type-binding.parameter',
'@type-binding.annotation',
'@type-binding.assignment',
] as const;
const squeezeWhitespace = (text: string): string => text.replace(/\s+/g, '');
/**
* `User* repos` the written spelling, when tree-sitter-cpp hangs the pointer
* off the DECLARATOR rather than the type.
*
* `@type-binding.type` is a bare `User` there, so the binding records `User` and
* nothing downstream can tell `repos[0]` (pointer subscript, element `User`)
* from `grid[0]` on a class with `operator[]` (element: whatever the operator
* returns). The index step needs that distinction and declines without it, so
* the pointer is reconstructed here at the capture layer, from the anchor,
* which spans the whole declaration.
*
* EXACT SHAPE ONLY: the declaration must be precisely `<type> * <name>` once
* whitespace is removed. `const T*`, `T**`, an array declarator, a reference,
* a function pointer none match, and none get a spelling, so the index step
* declines for them. A loose match would hand back container evidence that is
* not there and re-mint the confidently wrong edge this exists to prevent.
*/
function cppPointerSpelling(
captures: CaptureMatch,
typeText: string,
nameText: string,
): string | undefined {
// The target below always contains a literal `*`, so an anchor whose text has
// none can never equal it. Testing that FIRST is the whole optimisation: this
// runs on every C++ type binding and the overwhelming majority declare no
// pointer, so the squeezes (three `replace` passes over the declaration, the
// type and the name) are skipped entirely for them. `target` is built lazily
// on the first star-bearing anchor for the same reason.
let target: string | undefined;
for (const anchor of CPP_WHOLE_DECLARATION_ANCHORS) {
const text = captures[anchor]?.text;
if (text === undefined || !text.includes('*')) continue;
if (target === undefined) {
const type = squeezeWhitespace(typeText);
const name = squeezeWhitespace(nameText);
if (type.length === 0 || name.length === 0) return undefined;
target = `${type}*${name}`;
}
if (squeezeWhitespace(text) === target) return `${typeText.trim()}*`;
}
return undefined;
}
/** Declaration specifiers that carry no type identity. Held in ONE place so
* every consumer agrees on the keyword list the resolver's element-type
* hook strips exactly the same set before deciding whether a spelling is a
* pointer, and a silently diverging copy there would make the two disagree
* about what `const T*` is. */
const CPP_SPECIFIER_RE =
/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g;
/** Remove C++ declaration specifiers from `text` and trim the result. */
export function stripCppSpecifiers(text: string): string {
return text.replace(CPP_SPECIFIER_RE, '').trim();
}
/**
@ -90,11 +159,7 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi
* by base-name fallback in resolveClassBindingForName.
*/
export function normalizeCppTypeName(text: string): string {
let t = text.trim();
// Strip const, volatile, restrict, static, extern, inline, mutable, constexpr
t = t
.replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '')
.trim();
let t = stripCppSpecifiers(text);
// Strip pointer stars
while (t.endsWith('*')) t = t.slice(0, -1).trim();
while (t.startsWith('*')) t = t.slice(1).trim();

View file

@ -9,6 +9,7 @@ import {
tagNamespacePrefixes,
} from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { extractElementTypeFromString } from '../../type-extractors/shared.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js';
@ -46,6 +47,12 @@ import {
clearCppMemberLookupState,
resolveCppReceiverMember,
} from './member-lookup.js';
import { stripCppSpecifiers } from './interpret.js';
/** A pointee worth binding: a bare identifier, not `T**`, `T[]`, `A::B` or a
* template spelling. Hoisted a literal here would mint a fresh RegExp on
* every subscript the resolver folds. */
const CPP_SIMPLE_POINTEE_RE = /^[A-Za-z_]\w*$/;
/**
* Per-pass memo of the augmented `#include`-resolution file set
@ -247,6 +254,29 @@ export const cppScopeResolver: ScopeResolver = {
return mro.includes(lhsDef.nodeId);
},
// Subscript route only — C++ collection views are method calls.
//
// Two container spellings reach a subscript. A POINTER is one of them: `p[i]`
// on a `User*` is pointer arithmetic yielding a `User`, so the trailing `*` is
// peeled here rather than by `stripTypePreservingDecoration` (C++ declares
// none) — and peeling it at a bare class lookup instead would be wrong, since
// that is the route by which `p->m()` must keep seeing `User`'s own members.
// The other is a template container (`std::vector<User>`), left to the shared
// extractor.
//
// Fed the annotation AS WRITTEN, since `normalizeCppTypeName` strips both the
// star and the array brackets at capture. `undefined` = "not a container", so
// an `operator[]`-bearing class does not fold `grid[0]` onto `Grid`.
elementTypeOf: (containerType, via) => {
if (via.kind !== 'index') return undefined;
const t = stripCppSpecifiers(containerType);
if (t.endsWith('*')) {
const pointee = t.slice(0, -1).trim();
return CPP_SIMPLE_POINTEE_RE.test(pointee) ? pointee : undefined;
}
return extractElementTypeFromString(t);
},
// C++ is statically typed — disable field fallback heuristic
fieldFallbackOnMethodLookup: false,
// C++ needs return type propagation across #include boundaries

View file

@ -1,16 +1,18 @@
import type { ElementAccessRoute } from '../../scope-resolution/contract/scope-resolver.js';
import { extractElementTypeFromString } from '../../type-extractors/shared.js';
/**
* C# collection-accessor unwrapping.
* C# container element-type unwrapping.
*
* When the compound-receiver resolver encounters a trailing
* `.Values` / `.Keys` on a dotted member-access chain, it calls the
* provider's `unwrapCollectionAccessor` hook to find the element
* provider's `elementTypeOf` hook to find the element
* type. This module supplies the C# implementation recognizing
* Dictionary-family generics and returning the value or key type.
*
* Other languages (Python, Java, TypeScript) use method-call syntax
* for the same access (`.values()` / `.keys()`), which the compound-
* receiver's call-expression branch already handles; they leave this
* hook undefined.
* Other languages (Python, Java, TypeScript) use method-call syntax for the
* same access (`.values()` / `.keys()`), which the compound-receiver's
* call-expression branch already handles; they answer only the `index` route.
*/
/** Extract (K, V) from `Dictionary<K, V>` / `IDictionary<K, V>` /
@ -46,12 +48,19 @@ function extractDictionaryArgs(rawName: string): { key: string; value: string }
* receiver / accessor combination we don't recognize, letting the
* compound-receiver pass fall through to the regular field walk.
*/
export function unwrapCsharpCollectionAccessor(
receiverType: string,
accessor: string,
export function unwrapCsharpElementType(
containerType: string,
via: ElementAccessRoute,
): string | undefined {
if (accessor !== 'Values' && accessor !== 'Keys') return undefined;
const args = extractDictionaryArgs(receiverType);
const args = extractDictionaryArgs(containerType);
// Subscript on a dictionary yields the VALUE type — `dict["k"]` is a V, never
// a K. Previously this route returned nothing at all for C#, because the
// dictionary parse below was reachable only from the accessor hook.
if (via.kind === 'index') {
if (args !== undefined) return args.value;
return extractElementTypeFromString(containerType);
}
if (via.name !== 'Values' && via.name !== 'Keys') return undefined;
if (args === undefined) return undefined;
return accessor === 'Values' ? args.value : args.key;
return via.name === 'Values' ? args.value : args.key;
}

View file

@ -21,7 +21,7 @@ import {
} from './index.js';
import { populateCsharpNamespaceSiblings } from './namespace-siblings.js';
import { loadCsharpResolutionConfig, type CsharpResolutionConfig } from './resolution-config.js';
import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js';
import { unwrapCsharpElementType } from './accessor-unwrap.js';
const csharpScopeResolver: ScopeResolver = {
// Construction is keyword-prefixed: `new Service(db).doWork()` (#2708).
@ -89,7 +89,7 @@ const csharpScopeResolver: ScopeResolver = {
// `data.Values` / `data.Keys` on Dictionary-like receivers unwrap
// to the value / key element type. Other languages use method-call
// syntax for the same access and leave this hook undefined.
unwrapCollectionAccessor: unwrapCsharpCollectionAccessor,
elementTypeOf: unwrapCsharpElementType,
// C# matches legacy DAG by collapsing member-call CALLS edges to
// `(caller, target)` — multiple `g.Greet(...)` sites from Main

View file

@ -39,13 +39,13 @@ import { computeDartArityMetadata } from './arity-metadata.js';
import { synthesizeDartReceiverBinding } from './receiver-binding.js';
import { synthesizeDartSignatureBindings } from './signature-bindings.js';
import { getDartParser, getDartScopeQuery } from './query.js';
import { preprocessDartExtensionTypes } from './extension-type-preprocess.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
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 = [
@ -113,6 +113,9 @@ export function emitDartScopeCaptures(
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Idempotent re-application: `extractParsedFile` already preprocesses, but
// direct emitter callers (benchmarks, capture goldens) must see the same
// program the pipeline does.
const parseText = preprocessDartExtensionTypes(sourceText);
let tree: Parser.Tree;
if (cachedTree !== undefined && cachedTree !== null) {

View file

@ -16,6 +16,31 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
/** Range for a capture that marks no source span. */
const ZERO_RANGE = Object.freeze({ startLine: 0, startCol: 0, endLine: 0, endCol: 0 });
/**
* The callee-position marker, allocated once for the whole process.
*
* This capture is a PRESENCE FLAG, not a payload: `scope-extractor.ts` reads it
* only as `match['@reference.callee-position'] !== undefined`, and the tag is
* listed in `KNOWN_SUB_TAGS`, so `anchorCaptureFor` never considers its range
* when picking a reference anchor. Nothing else in the pipeline touches it.
* (Its range used to duplicate `@reference.read`'s exactly, and that capture is
* still emitted, so the one place that scans every capture's range the
* synthetic Module-scope span in `scope-extractor.ts` sees the same maximum.)
*
* Minting it per site from the read node was therefore pure cost, and not a
* cheap one: `node.text` is an `input.substring` behind N-API index marshals,
* and `startPosition` / `endPosition` are two more round-trips each. A frozen
* singleton carries exactly the same information its presence for free.
*/
const CALLEE_POSITION_MARKER: Capture = Object.freeze({
name: '@reference.callee-position',
range: ZERO_RANGE,
text: '',
});
const GO_CALLABLE_CAPTURE_OPTIONS = {
functionNodeTypes: new Set(['function_declaration', 'method_declaration', 'func_literal']),
callNodeTypes: new Set(['call_expression']),
@ -45,6 +70,25 @@ const GO_CALLABLE_CAPTURE_OPTIONS = {
},
} as const;
/**
* Is this `selector_expression` in CALLEE position the `function` child of an
* enclosing `call_expression` rather than free-standing?
*
* `h.dep.Work` in `h.dep.Work()` is in callee position; `f := h.dep.Work` (a
* method value, or a func-typed field value) is not.
*
* Callee position alone does NOT make the selector a non-read. Go dispatches
* `h.dep.Work()` through a func-typed struct field just as readily as through a
* method, and this predicate cannot tell the two apart `call_expression` has
* the same shape either way, and the tail's declaration may live in another
* package. See {@link emitGoScopeCaptures} for what is done with the answer.
*/
function isCalleeOfMemberCall(node: SyntaxNode): boolean {
const parent = node.parent;
if (parent === null || parent.type !== 'call_expression') return false;
return parent.childForFieldName('function')?.id === node.id;
}
export function emitGoScopeCaptures(
sourceText: string,
_filePath: string,
@ -183,6 +227,34 @@ export function emitGoScopeCaptures(
}
}
// Mark — do NOT drop — the read site on a member call's callee.
//
// The `@reference.read` pattern matches every `selector_expression`, so
// `h.dep.Work()` yields THREE sites: the call on `Work`, a read on the inner
// `h.dep` (the genuine field read), and a read on the OUTER `h.dep.Work`.
//
// That third site is a phantom ONLY when `Work` is a method: it then
// resolves through `findOwnedMember` (which prefers methods over fields) and
// emits an ACCESSES edge to the METHOD duplicating the CALLS edge at the
// same position — `v.impl.DoWork()` emitting both `CALLS -> DoWork` and
// `ACCESSES -> DoWork`.
//
// When `Work` is a FUNC-TYPED STRUCT FIELD (`Work func() error` — callback
// structs, hook structs, hand-rolled mocks) the very same syntax is a
// genuine field read followed by an indirect call through the value it
// holds, and that read is the field's only ACCESSES evidence. Dropping the
// site here deleted it (#2782 review).
//
// Method-vs-field is not knowable at capture: `call_expression` has the same
// shape either way and the tail's declaration may live in another package.
// So the capture layer records the POSITION and edge emission — which knows
// the resolved target's kind — decides. See `ReferenceSite.inCalleePosition`
// and `tryEmitEdge`.
const readNode = nodeMap['@reference.read'];
if (readNode !== undefined && isCalleeOfMemberCall(readNode)) {
grouped['@reference.callee-position'] = CALLEE_POSITION_MARKER;
}
// 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

View file

@ -79,11 +79,23 @@ export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBindin
return { boundName: name, rawTypeName: normalizedType, source };
}
/** Shallow `map[K]V` spelling match the key is anything up to the first `]`,
* so a nested map key (`map[map[a]b]V`) is deliberately NOT recognised. Held
* in one place so the capture-side normalizers and the resolver's element-type
* hook cannot drift on what counts as a map spelling. */
const GO_MAP_VALUE_RE = /^map\[[^\]]+\]\s*(.+)$/;
/** `map[K]V` → `V`, trimmed. `undefined` when `text` is not a map spelling. */
export function goMapValueType(text: string): string | undefined {
const match = GO_MAP_VALUE_RE.exec(text);
return match === null ? undefined : match[1].trim();
}
export function normalizeGoTypeName(text: string): string {
let t = text.trim();
t = stripGoOuterTypePrefixes(t);
const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
if (mapMatch) t = mapMatch[1].trim();
const mapValue = goMapValueType(t);
if (mapValue !== undefined) t = mapValue;
t = stripGoOuterTypePrefixes(t.replace(/^(?:<-)?chan(?:<-)?\s+/, ''));
if (t.startsWith('func(')) {
const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);
@ -111,8 +123,8 @@ export function normalizeGoReturnType(text: string): string {
t = t.slice(1, closeIdx).trim();
}
t = stripGoOuterTypePrefixes(t);
const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
if (mapMatch) t = mapMatch[1].trim();
const mapValue = goMapValueType(t);
if (mapValue !== undefined) t = mapValue;
t = stripGoOuterTypePrefixes(t.replace(/^(?:<-)?chan(?:<-)?\s+/, ''));
if (t.startsWith('func(')) {
const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);

View file

@ -15,6 +15,11 @@ import {
import { detectGoInterfaceImplementations } from './interface-impls.js';
import { populateGoRangeBindings } from './range-binding.js';
import { expandGoWildcardNames } from './expand-wildcards.js';
import { goMapValueType } from './interpret.js';
/** Slice `[]T` and array `[N]T` / `[...]T` the element spelling. Hoisted
* a literal inside the hook would mint a fresh RegExp per folded subscript. */
const GO_SLICE_ELEMENT_RE = /^\[[^\]]*\]\s*(.+)$/;
export const goScopeResolver: ScopeResolver = {
language: SupportedLanguages.Go,
@ -33,6 +38,41 @@ export const goScopeResolver: ScopeResolver = {
arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite),
// Only `*` — a pointer leaves the method set reachable by selector unchanged,
// so `*Host` and `Host` name the same class for receiver typing. `[]` and
// `map[…]` are deliberately NOT stripped here: they are containers whose
// member set differs from the element's, and unwrapping them belongs to the
// index step that consumed a subscript. (Field bindings never reach this
// anyway — `normalizeGoTypeName` already strips them at capture. The one
// binding that arrives decorated is the receiver self-binding, kept raw on
// purpose for `method-owners.ts`.)
stripTypePreservingDecoration: (typeName) =>
typeName.startsWith('*') ? typeName.slice(1).trim() : undefined,
// The subscript counterpart of the stripper above, and the reason the two are
// separate hooks: `*` is safe to strip at any class lookup, `[]` / `map[K]`
// only where the source actually indexed. Answers the `index` route only —
// Go has no property-style collection view.
//
// Reads the AS-WRITTEN spelling (`[]*User`), not the capture-normalized name:
// `normalizeGoTypeName` already collapsed that to `User`, which is exactly the
// ambiguity that made an unanswered index step fold onto the container. The
// element is returned STILL DECORATED (`*User`) — the index step looks it up
// through `stripTypePreservingDecoration` above, so the pointer resolves.
elementTypeOf: (containerType, via) => {
if (via.kind !== 'index') return undefined;
const t = containerType.trim();
// `map[K]V` — a subscript yields V. Literally the same matcher
// `normalizeGoTypeName` uses, so the two cannot disagree on what a map
// spelling is.
const mapValue = goMapValueType(t);
if (mapValue !== undefined) return mapValue;
// Slice `[]T` and array `[N]T` / `[...]T`.
const sliceMatch = GO_SLICE_ELEMENT_RE.exec(t);
if (sliceMatch !== null) return sliceMatch[1]!.trim();
return undefined;
},
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),

View file

@ -43,6 +43,76 @@ import {
resolveJavaImportTarget,
} from './java/index.js';
/**
* Java names the platform owns, matched against a BARE IDENTIFIER a dropped
* receiver's chain base (`System` in `System.out.println(...)`) or the bare
* spelling of that base's declared type (`String raw` `raw.trim()`). This set
* is the ONLY positive evidence `classifyReceiverOrigin` has that a lost edge
* pointed OUTSIDE the analyzed program; without it every Java drop hedges
* `impact()` down to `epistemic: 'lower-bound'` (#2744).
*
* TYPE names only deliberately no method names, unlike `csharp.ts`. The same
* hook also gates `type-env.ts`'s return-type inference and the #2545 free-call
* shadow guard, both keyed on the CALLEE name; Java method names are camelCase
* and collide constantly with user code (`format`, `add`, `get`, `run`, `apply`),
* so listing them would silently suppress real in-program resolutions. Receiver
* bases are what this pass actually asks about, and those are types.
*
* Inclusion rule, applied to every entry below: a name earns a place only when
* (a) it plausibly appears as a receiver base or bare declared type, and (b) an
* application defining its OWN type by that name is implausible. Rule (b) is the
* hard gate. A name listed here can never be reported as in-program from the
* fallthrough arm, so a wrong entry silently erases a real uncertainty signal,
* whereas a missing one only costs a hedge the failure is asymmetric, so this
* set under-includes on purpose.
*
* Deliberately ABSENT, each for rule (b) all are ordinary domain nouns an
* application really does declare, and `Map`/`Set`/`Collection` are the worst
* case because a same-package Java type needs no import to shadow them:
* `Map`, `Set`, `Collection`, `Stream`, `Number`, `Record`, `Error`.
* (`Record` and `Error` also fail rule (a): `java.lang.Record` has no callable
* static surface and application code never receives a bare `Error`.) `Void`
* is absent on rule (a) alone no `Void` instance exists to be a receiver.
* `List` IS included: unlike `Map`, a hand-rolled `List` would fight the
* near-universal `java.util.List` import, and it is the highest-value declared
* receiver type in the language.
*/
const BUILT_INS: ReadonlySet<string> = new Set([
// java.lang — implicitly imported, so these appear unqualified everywhere.
'System',
'String',
'Integer',
'Long',
'Double',
'Boolean',
'Character',
'Byte',
'Short',
'Float',
'Object',
'Math',
'Thread',
'Runtime',
'Class',
'StringBuilder',
'StringBuffer',
'Exception',
'RuntimeException',
'Throwable',
'Iterable',
'Comparable',
'Runnable',
'Enum',
// java.util — an explicit import, but an unresolvable one: the import target
// is outside the workspace, so it produces no in-program binding and the base
// still reaches this set (verified against real scope extraction, not assumed).
'Optional',
'List',
'Arrays',
'Collections',
'Objects',
]);
const orderJavaSameNameTypeCandidates = ({
callSiteFilePath,
candidates,
@ -122,6 +192,7 @@ export const javaProvider = defineLanguage({
// ── Javadoc → description (issue #2270) ──
descriptionExtractor: createLeadingDocDescriptionExtractor(),
builtInNames: BUILT_INS,
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitJavaScopeCaptures,

View file

@ -5,6 +5,7 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { kotlinProvider } from '../kotlin.js';
import {
kotlinArityCompatibility,
@ -117,6 +118,16 @@ export const kotlinScopeResolver: ScopeResolver = {
isSuperReceiver: (text) => text.trim() === 'super',
// Subscript route only — Kotlin's collection views (`.values`, `.keys`) are
// properties on the stdlib types, resolved by the ordinary member walk rather
// than by unwrapping a generic here.
//
// Fed the annotation AS WRITTEN (`List<User>`, `Map<String, User>`), which
// `normalizeKotlinType` had already reduced to `User`. `undefined` means "not
// a container", so an `operator fun get` class does not fold `cache[k]` onto
// `Cache` itself.
elementTypeOf: indexOnlyElementType,
isStaticOnly: isKotlinStaticOnly,
fieldFallbackOnMethodLookup: false,

View file

@ -17,6 +17,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { pythonProvider } from '../python.js';
import {
isPythonImportedModule,
@ -75,6 +76,17 @@ const pythonScopeResolver: ScopeResolver = {
isSuperReceiver: (text) => /^super\s*\(/.test(text),
// Subscript route only — Python spells collection views as method calls
// (`.values()`), which the compound resolver's call branch already handles.
//
// The hook receives the annotation AS WRITTEN (`List[User]`, `Dict[str, User]`),
// not the name `interpret.ts`'s `stripGeneric` reduced it to, so `undefined`
// here means "this spelling is not a container" — which is what stops
// `cfg['k'].run()` on a `__getitem__`-bearing class from folding onto the
// class itself. Answering the route at all is what keeps `repos[0].save()`
// resolving.
elementTypeOf: indexOnlyElementType,
// Python is dynamically typed — field-fallback heuristic on, return-
// type propagation across imports on. Both default to true; listed
// explicitly here for documentation.

View file

@ -2,6 +2,7 @@ import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { rustProvider } from '../rust.js';
import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js';
import { populateRustOwners } from './method-owners.js';
@ -165,6 +166,16 @@ export const rustScopeResolver: ScopeResolver = {
isSuperReceiver: () => false,
// Subscript route only — Rust has no property-style collection view; `.iter()`
// / `.values()` are method calls the compound resolver's call branch handles.
//
// Fed the annotation AS WRITTEN (`&Vec<User>`, `HashMap<String, User>`), which
// `normalizeRustTypeName` had already collapsed to `User` by the time the fold
// saw it. Returning `undefined` is the answer "not a container" and makes the
// index step decline rather than fold onto the receiver's own class — an
// `Index`-impl type would otherwise take its own members as the element's.
elementTypeOf: indexOnlyElementType,
populateRangeBindings: populateRustRangeBindings,
fieldFallbackOnMethodLookup: false,

View file

@ -37,6 +37,7 @@ import {
swiftMergeBindings,
swiftArityCompatibility,
} from './swift/index.js';
import { preprocessSwiftConditionalDirectives } from './swift/conditional-directive-preprocess.js';
/** Swift init/deinit declarations have special names and Constructor label. */
const swiftExtractFunctionName = (
@ -178,6 +179,7 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const swiftProvider = defineLanguage({
id: SupportedLanguages.Swift,
extensions: ['.swift'],
preprocessSource: preprocessSwiftConditionalDirectives,
entryPointPatterns: [
/^viewDidLoad$/,
/^viewWillAppear$/,

View file

@ -46,6 +46,7 @@ import { computeSwiftArityMetadata } from './arity-metadata.js';
import { synthesizeSwiftReceiverBinding } from './receiver-binding.js';
import { synthesizeSwiftSignatureBindings } from './signature-bindings.js';
import { getSwiftParser, getSwiftScopeQuery } from './query.js';
import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
@ -89,10 +90,16 @@ export function emitSwiftScopeCaptures(
cachedTree?: unknown,
): readonly CaptureMatch[] {
// Reuse the parse phase's cached Tree when available; otherwise parse.
// `extractParsedFile` already applies `preprocessSource` on this path, but
// this emitter is also called directly (benchmarks, capture goldens, the
// scope-capture tripwire), and those callers must see the same program the
// pipeline does. The transform is idempotent, so applying it twice is a
// no-op; it is length-preserving, so offsets still index `sourceText`.
let tree = cachedTree as ReturnType<ReturnType<typeof getSwiftParser>['parse']> | undefined;
if (tree === undefined) {
tree = parseSourceSafe(getSwiftParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
const parseText = preprocessSwiftConditionalDirectives(sourceText);
tree = parseSourceSafe(getSwiftParser(), parseText, undefined, {
bufferSize: getTreeSitterBufferSize(parseText),
});
recordCacheMiss();
} else {

View file

@ -0,0 +1,283 @@
/**
* A conditional-compilation directive occupying a whole line.
*
* `[^\S\r\n]` (horizontal whitespace) rather than `[ \t]` so NBSP /
* ideographic-space indentation and a leading BOM are recognized too. Leading
* whitespace is *not* required: nesting is decided from the scanner's brace
* depth, not from indentation (a column-0 `#if` inside a class body is exactly
* the shape that loses its enclosing declaration).
*/
const SWIFT_CONDITIONAL_DIRECTIVE_RE = /^[^\S\r\n]*#(if|elseif|else|endif)\b[^\r\n]*$/;
/**
* Cheap whole-file precondition, mirroring `stripUeMacros`'s `HAS_UE_HINT`.
* Derived from the line pattern so the two can never drift apart.
*/
const HAS_CONDITIONAL_DIRECTIVE_HINT = new RegExp(SWIFT_CONDITIONAL_DIRECTIVE_RE.source, 'm');
interface SwiftPreprocessScanState {
blockCommentDepth: number;
multilineStringPounds: number | null;
/** Net `{` minus `}` seen in code position. May go negative on broken source. */
braceDepth: number;
}
/**
* An `#if` `#endif` run. Blanking is decided per group, so a group is either
* fully blanked or fully preserved never half-erased.
*/
interface DirectiveGroup {
braceDepthAtStart: number;
/** Indices into the alternating `parts` array of this group's directive lines. */
directiveLines: number[];
/** Net brace delta of every branch so far — what the parser sees once all survive. */
totalDelta: number;
/** False as soon as one branch is not brace-balanced on its own. */
allBranchesBalanced: boolean;
currentBranchDelta: number;
}
function hasTripleQuoteAt(line: string, index: number): boolean {
return line.startsWith('"""', index);
}
/**
* Length of the multiline terminator at `index`, or 0.
*
* A plain `"""` string always closes at `"""`, whatever follows it an
* adjacent `#` is the next token, not part of the delimiter. A raw `#"""`
* string closes at `"""` followed by *at least* its own pound count.
*/
function matchingMultilineCloseLength(line: string, index: number, poundCount: number): number {
if (!hasTripleQuoteAt(line, index)) return 0;
if (poundCount === 0) return 3;
return line.startsWith('#'.repeat(poundCount), index + 3) ? 3 + poundCount : 0;
}
function skipRegularString(line: string, startIndex: number, rawPoundCount: number): number {
const endPounds = '#'.repeat(rawPoundCount);
let index = startIndex + (rawPoundCount > 0 ? rawPoundCount + 1 : 1);
while (index < line.length) {
if (line[index] === '"') {
if (rawPoundCount === 0) return index + 1;
if (line.startsWith(endPounds, index + 1)) return index + 1 + rawPoundCount;
}
if (rawPoundCount === 0 && line[index] === '\\') index++;
index++;
}
return line.length;
}
/**
* Skip an extended regex literal (`#/…/#`), whose body may legally contain
* `/*` which would otherwise open a block comment that never closes.
* `slashIndex` points at the `/` that follows the opening pound run.
*/
function skipExtendedRegexLiteral(line: string, slashIndex: number, poundCount: number): number {
const closePounds = '#'.repeat(poundCount);
let index = slashIndex + 1;
while (index < line.length) {
if (line[index] === '\\') {
index += 2;
continue;
}
if (line[index] === '/' && line.startsWith(closePounds, index + 1)) {
return index + 1 + poundCount;
}
index++;
}
return line.length;
}
function scanSwiftLine(line: string, state: SwiftPreprocessScanState): void {
let index = 0;
while (index < line.length) {
if (state.blockCommentDepth > 0) {
if (line.startsWith('/*', index)) {
state.blockCommentDepth++;
index += 2;
} else if (line.startsWith('*/', index)) {
state.blockCommentDepth--;
index += 2;
} else {
index++;
}
continue;
}
if (state.multilineStringPounds !== null) {
const closeLength = matchingMultilineCloseLength(line, index, state.multilineStringPounds);
if (closeLength > 0) {
state.multilineStringPounds = null;
index += closeLength;
continue;
}
// Non-raw multiline strings honour backslash escapes, so `\"""` is string
// data and not a terminator. Raw strings escape with `\#`, so a bare
// backslash there is literal.
index += state.multilineStringPounds === 0 && line[index] === '\\' ? 2 : 1;
continue;
}
if (line.startsWith('//', index)) return;
if (line.startsWith('/*', index)) {
state.blockCommentDepth = 1;
index += 2;
continue;
}
if (line[index] === '#') {
// Count the pound run exactly once and always advance past it, so a long
// run of bare `#` stays linear instead of being re-walked per character.
let poundCount = 1;
while (line[index + poundCount] === '#') poundCount++;
const afterPounds = index + poundCount;
if (hasTripleQuoteAt(line, afterPounds)) {
state.multilineStringPounds = poundCount;
index = afterPounds + 3;
} else if (line[afterPounds] === '"') {
index = skipRegularString(line, index, poundCount);
} else if (line[afterPounds] === '/') {
index = skipExtendedRegexLiteral(line, afterPounds, poundCount);
} else {
index = afterPounds;
}
continue;
}
if (hasTripleQuoteAt(line, index)) {
state.multilineStringPounds = 0;
index += 3;
continue;
}
if (line[index] === '"') {
index = skipRegularString(line, index, 0);
continue;
}
if (line[index] === '{') state.braceDepth++;
else if (line[index] === '}') state.braceDepth--;
index++;
}
}
/**
* Blank nested Swift conditional-compilation directives before parsing.
*
* tree-sitter-swift 0.7.1 does not admit `directive` as a `class_body` child
* (`vendor/tree-sitter-swift/src/node-types.json`), so error recovery can
* discard the enclosing declaration. Replacing only the directive text with
* spaces preserves `.length`, line endings, and every declaration offset.
*
* ponytail: delete this file (and the `preprocessSource` wiring in `swift.ts`)
* once a vendored tree-sitter-swift release includes upstream PR #583 ("Allow
* #if/#elseif/#else/#endif directives inside type bodies"); see also upstream
* issue #298 and PR #599. `.github/vendored-grammars.json` has no `"hold"` on
* swift, so the bump bot will land that release on its own.
*
* A directive group is blanked only when **all** of these hold:
*
* - it opens at `braceDepth > 0` nesting, not indentation, is what the
* grammar rejects; top-level directives are valid source-file members and
* are left intact
* - it is not inside a multiline string literal or a block comment blanking
* a line that carries a block-comment terminator would un-terminate the
* comment and swallow the rest of the file, and blanking inside a literal
* would rewrite program data
* - every branch is brace-balanced. A group that splits a declaration header
* (`#if` `func f() async {` `#else` `func f() {` `#endif`) leaves
* one unmatched `{` once both branches survive, which re-parents every
* later top-level declaration. Such a group degrades to the pre-fix
* behavior instead.
*
* Known residuals, all of which degrade to "directive left in place" rather
* than to corrupted output: string interpolation is not parsed with full Swift
* expression fidelity, so a nested multiline string inside an interpolation can
* confuse the scanner; a bare `/…/` regex literal containing `/*` opens a
* phantom block comment; and a multiline extended regex literal is treated as
* ending at its first line.
*
* Byte length is *not* preserved when a blanked directive line carries
* non-ASCII trailing text (` #if os(iOS) // 日本語 🔥` is 23 UTF-16 code units
* either way, but shrinks from 31 UTF-8 bytes to 23). C++ sidesteps this
* because UE macro tokens are ASCII-only; a Swift directive line can carry any
* trailing comment. Per the `LanguageProvider.preprocessSource` contract this is
* safe only while no consumer slices the original UTF-8 bytes by `startIndex`:
* node-tree-sitter reports UTF-16 code-unit indices, and every in-process
* consumer slices the JS string, whose `.length` *is* preserved.
*
* Must stay pure and idempotent see `LanguageProvider.preprocessSource`.
*/
export function preprocessSwiftConditionalDirectives(sourceText: string): string {
if (!HAS_CONDITIONAL_DIRECTIVE_HINT.test(sourceText)) return sourceText;
// Alternating [line, terminator, line, terminator, …, line]; joining it back
// reproduces the input byte for byte, including bare-`\r` endings.
const parts = sourceText.split(/(\r\n|\n|\r)/);
const state: SwiftPreprocessScanState = {
blockCommentDepth: 0,
multilineStringPounds: null,
braceDepth: 0,
};
const openGroups: DirectiveGroup[] = [];
let blankedAny = false;
for (let index = 0; index < parts.length; index += 2) {
const text = parts[index]!;
const insideLiteralOrComment =
state.multilineStringPounds !== null || state.blockCommentDepth > 0;
const match = insideLiteralOrComment ? null : SWIFT_CONDITIONAL_DIRECTIVE_RE.exec(text);
const braceDepthAtStart = state.braceDepth;
scanSwiftLine(text, state);
const braceDelta = state.braceDepth - braceDepthAtStart;
const openGroup = openGroups[openGroups.length - 1];
if (match === null) {
if (openGroup !== undefined) openGroup.currentBranchDelta += braceDelta;
continue;
}
// Directive lines are blanked as a unit, so their own braces (if any) never
// reach the parser and must not count toward a branch's balance.
if (match[1] === 'if') {
openGroups.push({
braceDepthAtStart,
directiveLines: [index],
totalDelta: 0,
allBranchesBalanced: true,
currentBranchDelta: 0,
});
continue;
}
// A `#else`/`#endif` with no open `#if` is malformed source — leave it be.
if (openGroup === undefined) continue;
openGroup.directiveLines.push(index);
openGroup.totalDelta += openGroup.currentBranchDelta;
openGroup.allBranchesBalanced &&= openGroup.currentBranchDelta === 0;
openGroup.currentBranchDelta = 0;
if (match[1] !== 'endif') continue;
openGroups.pop();
const parentGroup = openGroups[openGroups.length - 1];
// Every branch survives preprocessing, so the enclosing branch sees the sum
// — not one branch's delta.
if (parentGroup !== undefined) parentGroup.currentBranchDelta += openGroup.totalDelta;
if (openGroup.braceDepthAtStart > 0 && openGroup.allBranchesBalanced) {
// Safe to mutate in place: every line of this group has already been
// scanned, and the decision never reopens.
for (const line of openGroup.directiveLines) parts[line] = ' '.repeat(parts[line]!.length);
blankedAny = true;
}
}
return blankedAny ? parts.join('') : sourceText;
}

View file

@ -23,6 +23,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import { typescriptProvider } from '../typescript.js';
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import {
typescriptArityCompatibility,
typescriptMergeBindings,
@ -106,6 +107,12 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
}
const typescriptScopeResolver: ScopeResolver = {
// One hook, both routes. TypeScript exposes collection views as METHOD calls
// (`.values()`), which the call-expression branch already handles, so the
// accessor route yields nothing here — but it is now the same hook rather
// than a second one left undefined.
elementTypeOf: indexOnlyElementType,
// Construction is keyword-prefixed: `new Service(db).doWork()` (#2708).
constructionSyntax: { keyword: 'new' },
language: SupportedLanguages.TypeScript,
@ -155,10 +162,10 @@ const typescriptScopeResolver: ScopeResolver = {
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
// TypeScript uses `.values()` / `.keys()` method-call syntax for
// collection views -- no property-style accessors like C#'s
// `Dictionary<K,V>.Values`. Leave `unwrapCollectionAccessor`
// undefined and let the regular member-call branch handle them.
// TypeScript uses `.values()` / `.keys()` method-call syntax for collection
// views -- no property-style accessors like C#'s `Dictionary<K,V>.Values` --
// so `elementTypeOf` answers only the `index` route and lets the regular
// member-call branch handle the rest.
//
// `collapseMemberCallsByCallerTarget` left undefined (= false) --
// TypeScript legacy DAG emits one edge per call site, so

View file

@ -49,7 +49,16 @@ export function extractParsedFile(
if (provider.emitScopeCaptures === undefined) return undefined;
if (sourceText.trim().length === 0) return undefined;
try {
const captures = provider.emitScopeCaptures(sourceText, filePath, cachedTree, { sourceKind });
// A provider that rewrites source before parsing must see the same text
// here that the parse worker fed tree-sitter, or the two halves of the
// pipeline analyze different programs. Only the cache-miss path re-parses;
// with a cached tree the emitter ignores the text. The transform is
// length-preserving, so every offset still indexes the original.
const parseText =
cachedTree === undefined
? (provider.preprocessSource?.(sourceText, filePath) ?? sourceText)
: sourceText;
const captures = provider.emitScopeCaptures(parseText, filePath, cachedTree, { sourceKind });
return extractScope(captures, filePath, provider);
} catch (err) {
const message = `scope extraction failed for ${filePath}: ${

View file

@ -911,6 +911,13 @@ function pass3CollectImports(
// ─── Pass 4: collect type bindings ─────────────────────────────────────────
/** Cap on the retained as-written annotation. Real container spellings are a
* handful of characters; a multi-line mapped/conditional type is neither a
* container any `elementTypeOf` parses nor worth keeping one copy of per
* binding on a kernel-scale repo. Over the cap the spelling is dropped, which
* makes an index step decline the safe direction. */
const MAX_DECLARED_SPELLING_LENGTH = 256;
function pass4CollectTypeBindings(
matches: readonly CaptureMatch[],
drafts: readonly ScopeDraft[],
@ -953,11 +960,41 @@ function pass4CollectTypeBindings(
provider.bindingScopeFor?.(match, draftToScope(innermost), scopeTree) ?? autoHostedId;
const host = draftById.get(hostId) ?? innermost;
const typeRef: TypeRef = {
rawName: parsed.rawTypeName,
declaredAtScope: host.id,
source: parsed.source,
};
// The annotation as the source wrote it, kept only when the provider's
// interpretation is not already it. `interpretTypeBinding` normalizes
// container spellings away (`User[]` → `User`, `List[User]` → `User`,
// `[]*User` → `User`), which makes a reduced container indistinguishable
// from a class of the same name — and an index step folding on that
// ambiguity typed `grid[0]` as `Grid`. Read at the one place the
// distinction matters; see `TypeRef.declaredSpelling`.
//
// Read from the capture rather than from `ParsedTypeBinding` deliberately:
// `@type-binding.type` is the shared anchor EVERY provider already reads to
// build `rawTypeName`, so nothing has to be threaded through fourteen
// interpreters (and none can forget to).
// A provider may override when its grammar keeps part of the written type
// outside `@type-binding.type` (C++ hangs `*` on the declarator).
const writtenType = (parsed.declaredSpelling ?? match['@type-binding.type']?.text)?.trim();
const declaredSpelling =
writtenType !== undefined &&
writtenType.length > 0 &&
writtenType.length <= MAX_DECLARED_SPELLING_LENGTH &&
writtenType !== parsed.rawTypeName
? writtenType
: undefined;
const typeRef: TypeRef =
declaredSpelling === undefined
? {
rawName: parsed.rawTypeName,
declaredAtScope: host.id,
source: parsed.source,
}
: {
rawName: parsed.rawTypeName,
declaredSpelling,
declaredAtScope: host.id,
source: parsed.source,
};
// Prefer stronger sources when multiple matches fire for the same
// bound name in the same scope. Example: `u: User = find()` matches
// both the annotation and constructor-inferred patterns; the explicit
@ -1105,6 +1142,14 @@ function pass5CollectReferences(
// that logs nothing.
const receiverChain = extractReceiverChain(match);
// Callee-position marker: a member-read capture that is actually the callee
// of an enclosing call (`obj.f` in `obj.f()`). Recorded, not acted on —
// whether the read is a phantom or a genuine func-typed-field read depends
// on the resolved tail's kind, which only edge emission knows. Emitted by
// languages whose read pattern has no call-position exclusion; absent
// everywhere else, so the site stays byte-identical for them.
const inCalleePosition = match['@reference.callee-position'] !== undefined;
const site: ReferenceSite = {
name: nameCap.text,
atRange: anchor.range,
@ -1122,6 +1167,7 @@ function pass5CollectReferences(
...(argumentTypes !== undefined ? { argumentTypes } : {}),
...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}),
...(receiverChain !== undefined ? { receiverChain } : {}),
...(inCalleePosition ? { inCalleePosition: true } : {}),
};
referenceSites.push(site);
}
@ -1522,6 +1568,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@reference.name',
'@reference.qualified-name',
'@reference.property-key',
'@reference.callee-position',
'@reference.receiver',
'@reference.operator',
'@reference.arity',

View file

@ -15,7 +15,7 @@
* - propagatesReturnTypesAcrossImports (default true)
* - fieldFallbackOnMethodLookup (default true turn OFF for
* statically-typed languages; the heuristic over-connects)
* - unwrapCollectionAccessor property-style collection views
* - elementTypeOf container element type, by subscript or accessor
* - collapseMemberCallsByCallerTarget one edge per caller/target
* - populateNamespaceSiblings cross-file implicit visibility
* - hoistTypeBindingsToModule enable ONLY when method return
@ -268,6 +268,7 @@
* `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
*/
import type { DecorationStripper } from '../scope/walkers.js';
import type {
BindingRef,
Callsite,
@ -313,6 +314,11 @@ export interface ImportResolutionContext {
* `RegistryProviders.constraintCompatibility`'s third parameter. */
export type { ConstraintContext } from 'gitnexus-shared';
/** How a container's element was reached in the source. */
export type ElementAccessRoute =
| { readonly kind: 'index' }
| { readonly kind: 'accessor'; readonly name: string };
export interface ScopeResolver {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
@ -708,22 +714,56 @@ export interface ScopeResolver {
readonly fieldFallbackOnMethodLookup?: boolean;
/**
* Unwrap a property-style collection accessor on a typed receiver
* to its element type. Called by `resolveCompoundReceiverClass`
* when walking dotted member-access chains of the form
* `receiver.Accessor`. The provider returns the element type's
* simple name, or `undefined` when the accessor doesn't unwrap
* in which case the regular field-walk resumes.
* Element type of a container, reached either by a subscript (`repos[0]`) or
* by a property-style collection view (`dict.Values`). Returns the element
* type's simple name, or `undefined` when the container does not unwrap by
* that route in which case the caller resumes its normal walk.
*
* Use this only for languages that expose collection views as
* properties rather than method calls; languages whose collection
* views are `.values()` / `.keys()` method calls leave this
* undefined and let the normal call-expression branch handle them.
* ONE hook for both routes, deliberately. They were previously two
* (`unwrapCollectionAccessor` for the property route, `unwrapCollectionElement`
* for the subscript route), which meant a language implementing one silently
* got nothing for the other: C# parsed `Dictionary<K,V>` for `.Values` but
* returned nothing for `list[0]`, and TypeScript did the reverse. Two entries
* answering one question, each accreting an implementation per language.
*
* `via` carries the route so a provider can distinguish them where it matters
* (a `Dictionary` yields its VALUE type by subscript but either type by
* accessor name); a provider that does not care can ignore it.
*
* Consulted ONLY where the source actually performed the access. It is NOT a
* general type-name normalizer: unwrapping a container at a bare class lookup
* would let `repos.find(x)` fold to `Repo.find`, because a container's member
* set is not its element's. For the same reason it is deliberately separate
* from `stripTypePreservingDecoration`: a pointer or a nullable leaves the
* member set unchanged and is safe to strip at the lookup; a container is not.
*
* ## The `index` route is REQUIRED, and `undefined` means "not a container"
*
* A language that leaves the `index` route unanswered gets NO index folding
* the structural fold declines the step rather than passing the position
* through. That is not a default worth softening. `undefined` used to mean
* "fall back to identity", on the theory that a capture layer which already
* reduced the container (Go's `normalizeGoTypeName`, C#/TypeScript's
* `stripGeneric`) leaves nothing to unwrap. The theory holds for a container;
* it is false for an ordinary class the source happened to subscript, and
* `rawName` cannot tell those apart `repos: User[]` and `grid: Grid` both
* arrive as a bare resolvable class name. Identity there typed `grid[0].run()`
* as `Grid.run` and `t[0].Render()` as `Table.Render`: a wrong owner, which
* this pipeline ranks strictly below a missing edge.
*
* The hook is therefore handed `TypeRef.declaredSpelling` the annotation AS
* WRITTEN, retained by the scope extractor precisely because capture-time
* normalization destroys it falling back to `rawName` only when nothing was
* normalized away. So a provider sees `User[]`, `List[User]`, `[]*User` or
* `Dictionary<string, User>`, never the post-reduction `User`, and answering
* `undefined` for a spelling it does not recognize as a container is an
* ANSWER, not an absence.
*
* Implementations may return a still-DECORATED element name (Go's `[]*User`
* yields `*User`): the index step looks the element up through
* `stripTypePreservingDecoration`, so the pointer resolves.
*/
readonly unwrapCollectionAccessor?: (
receiverType: string,
accessor: string,
) => string | undefined;
readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined;
/**
* Collapse member-call CALLS edges by `(caller, target)` rather
@ -1114,6 +1154,39 @@ export interface ScopeResolver {
*/
readonly hoistTypeBindingsToModule?: boolean;
/**
* Strip ONE layer of type-preserving decoration off a declared type name,
* or return `undefined` when there is nothing left to strip.
*
* Exists because a declared type is stored as written. Go's
* `synthesizeGoReceiverBinding` keeps `typeNode.text`, so a pointer-receiver
* method binds its receiver to the literal `*Host` which matches no class
* binding, so receiver-chain resolution declines at the base and every
* `h.field.method()` in the dominant Go idiom loses its `CALLS` edge (#2766).
* The stored binding is deliberately left decorated (`method-owners.ts`
* consumes `*T` vs `T` to model Go's value and pointer method sets), so the
* normalization belongs at LOOKUP, never as a rewrite of the binding.
*
* TYPE-PRESERVING ONLY. Pointer, reference, `const`, nullable, borrow,
* deref-transparent smart pointer and sigil all leave the member set
* unchanged. A CONTAINER array, slice, map, `Option` does not: stripping
* one here would type `repos: Repo[]` as `Repo` and let `repos.find(x)` fold
* to `Repo.find`, a confident wrong edge the ambiguity gate cannot catch
* because `Repo` binds uniquely. Containers are unwrapped only by an index
* step that consumed a subscript.
*
* Consulted ONLY after every undecorated lookup has failed, and only by
* receiver-chain base and step resolution the shared class lookup keeps
* exact-name behaviour for its other ~two dozen callers, several of which are
* shaped `findClassBindingInScope(...) ?? otherResolver(...)` and would have
* their fallback suppressed by a global widening.
*
* Leave undefined for languages whose declared types carry no type-preserving
* decoration. Measured: only Go needs it for a receiver base; Rust, C#, Swift,
* TypeScript and C++ need it for field types.
*/
readonly stripTypePreservingDecoration?: DecorationStripper;
/**
* Whether the compound-receiver resolver should strip C-style cast
* expressions from receiver-position text before resolving it
@ -1140,7 +1213,7 @@ export interface ScopeResolver {
*
* A second opting language must extend the classifier grammar or
* convert this toggle into a per-language classifier hook (the
* `unwrapCollectionAccessor` pattern) do not flip this flag for
* `elementTypeOf` pattern) do not flip this flag for
* another language as-is.
*
* Known non-goal: the compound-receiver options built from this

View file

@ -15,9 +15,10 @@
* language-agnostic no language needs to change it.
*/
import type { Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { NodeLabel, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { CALL_TARGET_TYPES } from '../../model/symbol-table.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import type { CalleeIdSink } from './callee-id-sink.js';
@ -70,6 +71,41 @@ export function mapReferenceKindToEdgeType(
}
}
/**
* Is this read site a PHANTOM a duplicate of the call happening beside it?
*
* Some languages' member-read capture also matches the callee of a member call,
* so `obj.f()` produces a `call` site on `f` AND a `read` site on `obj.f` at the
* same position (Go's `@reference.read` matches every `selector_expression`).
* The capture layer marks that second site `inCalleePosition` rather than
* dropping it, because the two cases it covers are indistinguishable there:
*
* - tail resolves to a METHOD phantom. The ACCESSES edge duplicates the
* CALLS edge emitted for the same source position; suppress it.
* - tail resolves to a FIELD genuine. `h.dep.Work()` where
* `Work func() error` reads a func-typed struct field and calls the value
* it holds. That read is the field's only ACCESSES evidence keep it.
*
* Only the resolved target's kind separates them, which is why this decision
* lives at emission and not in any language's capture code. Sites without the
* marker are untouched, so a genuine method VALUE (`f := obj.method`) never in
* callee position keeps its read.
*
* "Invoked rather than read" is `CALL_TARGET_TYPES` the canonical callable
* target set (`FREE_CALLABLE_TYPES` Method/Constructor), not a local copy. A
* hand-rolled `{Function, Method, Constructor}` silently omits `Macro` (C/C++)
* and `Delegate` (C#), the two labels that set exists to add, and drops the
* `satisfies` guard in `symbol-table.ts` that compile-enforces every free
* callable label against `LABEL_BEHAVIOR`.
*/
function isPhantomCalleeRead(
site: { readonly kind: string; readonly inCalleePosition?: boolean },
targetDef: { readonly type: NodeLabel },
): boolean {
if (site.kind !== 'read' || site.inCalleePosition !== true) return false;
return CALL_TARGET_TYPES.has(targetDef.type);
}
/**
* Resolve caller + target to graph ids and emit the edge. Returns true
* if the edge was emitted (not deduped, not skipped).
@ -87,6 +123,9 @@ export function tryEmitEdge(
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
/** See {@link isPhantomCalleeRead}. Set by the extractor from the
* language's `@reference.callee-position` marker; absent otherwise. */
readonly inCalleePosition?: boolean;
},
targetDef: SymbolDefinition,
reason: string,
@ -95,6 +134,10 @@ export function tryEmitEdge(
collapseByCallerTarget = false,
calleeCapture?: CalleeIdCaptureCtx,
): boolean {
// A read that only exists because it is the callee of the call beside it, and
// whose tail resolved to a callable, is that call restated as an access —
// checked first because it needs no id resolution.
if (isPhantomCalleeRead(site, targetDef)) return false;
// Inheritance edges are emitted directly by `preEmitInheritanceEdges` (which
// owns the enclosing-class caller and the EXTENDS-vs-IMPLEMENTS type), so this
// generic bridge derives caller + edge type purely from the site.
@ -152,6 +195,15 @@ export function tryEmitEdge(
*
* All other invariants of `tryEmitEdge` apply: dedup key shape, collapse
* flag honoring, edge-type mapping, caller-id resolution.
*
* ONE deliberate exception: the {@link isPhantomCalleeRead} suppression is not
* applied here, because it keys on the target's node label and this entry point
* is handed an id rather than a def. Reachable only for a `read` site marked
* `inCalleePosition` whose receiver typed as an object-literal VALUE a
* JS/TS-shaped registration. No language that sets the marker resolves through
* this bridge today (verified for Go, whose func-valued struct-literal fields
* resolve through the owned-member path instead). A language adding the marker
* must re-check this path.
*/
export function tryEmitEdgeWithExplicitTargetId(
graph: KnowledgeGraph,

View file

@ -21,12 +21,13 @@
*/
import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import type { ElementAccessRoute, 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 type { DecorationStripper } from '../scope/walkers.js';
import {
findClassBindingInScope,
findEnclosingClassDef,
@ -77,14 +78,13 @@ interface ResolveCompoundReceiverOptions {
* class, walk its fields and try the lookup on each field's class.
* Phase-9C "unified fixpoint" Python-shaped heuristic. */
readonly fieldFallback?: boolean;
/** Language-specific accessor unwrap `data.Values` on a
* Dictionary<K,V>-typed receiver yields V (C#), etc. Returns the
* element type's simple name, or `undefined` to let the regular
* field-walk handle the access. */
readonly unwrapCollectionAccessor?: (
receiverType: string,
accessor: string,
) => string | undefined;
/** Container -> element, by subscript (`repos[0]`) or accessor (`data.Values`
* on a `Dictionary<K,V>` yields V). Returns the element type's simple name,
* or `undefined`. See the `ScopeResolver` field of the same name for why the
* two routes share one hook, and for why `undefined` on the `index` route
* means "not a container" a step that gets it DECLINES, so a language must
* answer that route to get index folding at all. */
readonly elementTypeOf?: (containerType: string, via: ElementAccessRoute) => string | undefined;
/** Walk up from the class scope to ancestor (Module) scopes when
* looking up a method's return-type typeBinding. Only enable for
* languages that hoist return-type bindings to Module scope (C#);
@ -121,6 +121,13 @@ interface ResolveCompoundReceiverOptions {
* (`const Config = make(1); Config.db.query()` emitted `entry → Database.query`),
* the exact wrong-edge failure this work exists to avoid. */
readonly strictBaseBinding?: boolean;
/** Per-language type-preserving decoration stripper, from the `ScopeResolver`
* contract. Passed to the class lookup at the base and step sites so a
* decorated declared type (`*Host`) resolves to its class. Absent for
* languages whose declared types carry no such decoration, and never applied
* by the shared lookup's other callers — see the contract's own note on why
* this is opt-in rather than global. */
readonly stripTypePreservingDecoration?: DecorationStripper;
}
/** Is this hop the language's construction selector applied to the class
@ -276,35 +283,77 @@ function resolveConstructionExpressionClass(
}
/**
* One step of the fold: the type of `memberName` on `owner`, or `undefined`.
* One position in a fold: the class the chain has reached, PLUS the declared
* type text that produced it.
*
* 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.
* The declared type is carried for two reasons, and the index step is its only
* reader. First, it is the CONTAINER evidence that step demands: capture
* normalizes `repos: User[]` down to `User`, so the resolved class alone cannot
* tell a reduced container from a class the source merely subscripted, and
* folding on that ambiguity typed `grid[0].run()` as `Grid.run`. Second, a
* declared type that named NO class is still a usable position
* `Promise<User>` and `[]Repo` match nothing in the workspace because a later
* step may unwrap it; keeping only the class would strand those shapes at the
* step that produced them.
*
* 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.
* `undefined` when the position was reached by a route that has no declared type
* to report (a static class receiver, say). A step needing one declines rather
* than guessing.
*/
interface FoldState {
/**
* Absent when the position's declared type named no class `Promise<User>`
* and `[]Repo` name nothing in the workspace. ONE signal, not two: an earlier
* version carried a separate `unresolvedDeclaredType` flag alongside a `def`
* holding the PREVIOUS position, which no path ever read. Two sources of
* truth for one fact, and the dead `def` read as intentional.
*
* Only an unwrapping step (await, index) can advance from an absent `def`;
* every other step declines, because folding on against the previous class
* would look the next member up on the wrong owner.
*/
readonly def: SymbolDefinition | undefined;
/**
* The declared type AS WRITTEN the spelling when capture normalized one
* away (`TypeRef.declaredSpelling`), else `rawName` (they are the same string
* when nothing was normalized). Only the index step reads it, and only the
* as-written form separates `repos: User[]` from `grid: Grid`: both reduce to
* a bare, resolvable class name.
*/
readonly declaredType?: string;
readonly declaredAtScope?: ScopeId;
}
function typeOfMemberOnClass(
owner: SymbolDefinition,
memberName: string,
scopes: ScopeResolutionIndexes,
index: WorkspaceResolutionIndex,
options: ResolveCompoundReceiverOptions,
): SymbolDefinition | undefined {
): FoldState | 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);
const def = findClassBindingInScope(
memberType.declaredAtScope,
memberType.rawName,
scopes,
options.stripTypePreservingDecoration,
);
// The declared type is reported even when it resolved to no class:
// `Promise<User>` and `[]Repo` name nothing in the workspace, and an
// await or index step unwrapping them is exactly how they become
// resolvable. Returning `undefined` here would strand those shapes. A
// `def` that stayed absent is reported as absent, NOT as the previous
// owner, so nothing may fold an ordinary member off this position.
return {
def,
declaredType: memberType.declaredSpelling ?? memberType.rawName,
declaredAtScope: memberType.declaredAtScope,
};
}
// 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
@ -320,7 +369,26 @@ function typeOfMemberOnClass(
if (curScope === undefined) break;
const hoisted = curScope.typeBindings.get(memberName);
if (hoisted !== undefined) {
return findClassBindingInScope(hoisted.declaredAtScope, hoisted.rawName, scopes);
const def = findClassBindingInScope(
hoisted.declaredAtScope,
hoisted.rawName,
scopes,
// Same stripper the primary branch above passes. Omitting it here
// meant a decorated declared type (`*Host`) resolved on one branch
// and not the other, for the same member of the same class.
options.stripTypePreservingDecoration,
);
// Identical to the primary branch: a declared type that named no
// class is still a usable position when the next step unwraps it.
// Returning `undefined` here made `svc.getMap()['k'].run()` decline
// while byte-identical `byId['k'].run()` resolved, purely because ten
// languages route return-type bindings through this hoisted branch
// and the other through the class scope.
return {
def,
declaredType: hoisted.declaredSpelling ?? hoisted.rawName,
declaredAtScope: hoisted.declaredAtScope,
};
}
curId = curScope.parent;
}
@ -362,15 +430,92 @@ export function foldReceiverChain(
// `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, {
const baseDef = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, {
...options,
fieldFallback: false,
receiverChain: undefined,
strictBaseBinding: true,
});
if (current === undefined) return undefined;
// The base's own declared type is carried too, so a chain whose FIRST step is
// an unwrap (`repos[0].save()` — index applied directly to the base) has the
// container spelling available. Without it that shape declines at step 1.
// Looked up ONLY when something will read it: the base failed to resolve (so
// an unwrap step is the last chance), or an index step will unwrap the
// container. `resolveCompoundReceiverClass` already walked the scope chain for
// this same name above, so doing it unconditionally duplicated that walk on
// every fold — and the U10 census says 86% of chains are pure field/call and
// never read it.
const needsBaseDeclaredType =
baseDef === undefined || chain.steps.some((step) => step.kind === 'index');
const baseBinding = needsBaseDeclaredType
? findReceiverTypeBinding(inScope, chain.baseReceiverName, scopes)
: undefined;
// A base whose declared type names no class is NOT automatically a dead end:
// `repos: User[]` binds to the literal `User[]`, which matches no class
// because a container is not one. That position is still usable IF the next
// step unwraps it — which is exactly what an index step does. Carrying it
// forward with the marker lets that step recover; every other step kind
// declines on the marker, so nothing folds against a phantom owner.
if (baseDef === undefined && baseBinding === undefined) return undefined;
let current: FoldState = {
def: baseDef,
declaredType: baseBinding?.declaredSpelling ?? baseBinding?.rawName,
declaredAtScope: baseBinding?.declaredAtScope,
};
for (const step of chain.steps) {
// A position whose declared type named no class can only be advanced by an
// unwrapping step. Folding an ordinary member off it would look the member
// up on the PREVIOUS class — a wrong owner, silently.
//
// `await` IS identity, and soundly so: every language whose capture layer
// reduces the wrapper has ALREADY done it by the time a binding reaches the
// fold (TypeScript strips `Promise<X>`, C# strips `Task<X>`), and awaiting a
// value that is NOT a thenable yields that same value — so both regimes land
// on the identical answer and there is nothing to distinguish.
if (step.kind === 'await') continue;
if (step.kind === 'index') {
// An index step is NOT identity, and that asymmetry with `await` above is
// the whole point. `await` on a non-promise is a no-op; a subscript on a
// non-container is not — it yields the element of an indexer whose type
// is a different class entirely.
//
// The NORMALIZED type name cannot tell the two regimes apart: capture
// reduces `repos: User[]` to `User`, so a reduced container and a class
// the source merely subscripted (`grid: Grid` where `Grid` declares an
// index signature) both arrive as a bare, resolvable class name. Falling
// back to identity there kept `current` on the CONTAINER and looked the
// next member up on it — `grid[0].run()` → `Grid.run`, `t[0].Render()` →
// `Table.Render`. A confidently wrong owner, which is strictly worse than
// no edge and invisible to a bench that scores edge PRESENCE.
//
// So the step demands positive evidence instead: `declaredType`, the
// AS-WRITTEN spelling (`TypeRef.declaredSpelling`, preserved precisely
// because `rawName` threw it away), handed to the language's
// `elementTypeOf`. A provider that does not recognize the spelling as a
// container is answering "not a container", and the only sound move is to
// decline — a language must therefore answer the `index` route to get
// index folding at all.
const declared = current.declaredType;
const element =
declared === undefined ? undefined : options.elementTypeOf?.(declared, { kind: 'index' });
if (element === undefined) return undefined;
const scopeForLookup = current.declaredAtScope ?? inScope;
const elementClass = findClassBindingInScope(
scopeForLookup,
element,
scopes,
options.stripTypePreservingDecoration,
);
if (elementClass === undefined) return undefined;
current = {
def: elementClass,
declaredType: element,
declaredAtScope: scopeForLookup,
};
continue;
}
// 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
@ -381,13 +526,23 @@ export function foldReceiverChain(
// 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.
//
// Placed AFTER the name-free continues above, deliberately. When it sat
// first, `options.constructionSyntax?.selector === step.name` compared
// `undefined === undefined` for every await/index step in a language with no
// construction selector, vetoing the entire fold before it ran — which is
// why those receivers minted a chain, fired the gate, and produced no edge.
// Position, not a guard, is what makes that unreachable: only named steps
// get here.
if (options.constructionSyntax?.selector === step.name) return undefined;
const next = typeOfMemberOnClass(current, step.name, scopes, index, options);
if (current.def === undefined) return undefined;
const next = typeOfMemberOnClass(current.def, step.name, scopes, index, options);
if (next === undefined) return undefined;
current = next;
}
return current;
// A chain that ended without a class returns undefined naturally — no
// separate guard, because `def` IS the signal.
return current.def;
}
export function resolveCompoundReceiverClass(
@ -480,7 +635,12 @@ export function resolveCompoundReceiverClass(
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
const viaTb = findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
const viaTb = findClassBindingInScope(
tb.declaredAtScope,
tb.rawName,
scopes,
options.stripTypePreservingDecoration,
);
if (viaTb !== undefined) return viaTb;
// Member-alias / call-result shapes store the RHS path on rawName
@ -714,7 +874,7 @@ export function resolveCompoundReceiverClass(
// the final segment and unwraps the receiver's generic, return
// the element class directly. Resolved before the field-walk
// because Dictionary-family types aren't local class defs.
if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) {
if (options.elementTypeOf !== undefined && parts.length >= 2) {
const last = parts[parts.length - 1];
const headInner = parts[0];
if (last === undefined || headInner === undefined) return undefined;
@ -742,7 +902,12 @@ export function resolveCompoundReceiverClass(
prefixType = cur;
}
if (prefixType !== undefined) {
const elemName = options.unwrapCollectionAccessor(prefixType.rawName, last);
// `rawName`, not `declaredSpelling`, and deliberately: the accessor route
// only fires on a multi-arg container (`Dictionary<K,V>`), which every
// provider's capture-time normalization leaves ALONE — so the two are the
// same string here, and reading the spelling would change nothing except
// to widen an unmeasured surface.
const elemName = options.elementTypeOf(prefixType.rawName, { kind: 'accessor', name: last });
if (elemName !== undefined) {
return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes);
}

View file

@ -61,6 +61,7 @@ import {
findReceiverTypeBinding,
findValueBindingInScope,
isClassLike,
type DecorationStripper,
} from '../scope/walkers.js';
import {
tryEmitEdge,
@ -82,6 +83,10 @@ import type {
ResolutionOutcomeRecorder,
ResolutionSuppressionReason,
} from '../resolution-outcome.js';
import { classifyReceiverShape } from '../resolution-outcome.js';
import type { ReceiverOrigin } from '../resolution-outcome.js';
import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
@ -92,10 +97,11 @@ type ReceiverBoundProviderSubset = Pick<
| 'isSuperReceiverInContext'
| 'fieldFallbackOnMethodLookup'
| 'collapseMemberCallsByCallerTarget'
| 'unwrapCollectionAccessor'
| 'elementTypeOf'
| 'hoistTypeBindingsToModule'
| 'stripReceiverCastExpressions'
| 'constructionSyntax'
| 'stripTypePreservingDecoration'
| 'resolveQualifiedReceiverMember'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
@ -113,8 +119,17 @@ function resolveClassBindingForName(
scopeId: string,
rawClassName: string,
scopes: ScopeResolutionIndexes,
/**
* OPT-IN, and deliberately not passed by the emitting cases. `findClass
* BindingInScope`'s own docstring explains why the stripper is opt-in: a name
* that previously bound nothing starts binding, which SUPPRESSES the
* `?? otherResolver(...)` fallbacks several callers rely on. Case 4 therefore
* keeps exact-name behaviour and only `classifyReceiverOrigin` which emits
* no edge and can only change a diagnostic label passes it.
*/
stripDecoration?: DecorationStripper,
): SymbolDefinition | undefined {
const direct = findClassBindingInScope(scopeId, rawClassName, scopes);
const direct = findClassBindingInScope(scopeId, rawClassName, scopes, stripDecoration);
if (direct !== undefined) return direct;
if (!rawClassName.includes('<')) return undefined;
@ -128,7 +143,7 @@ function resolveClassBindingForName(
// default to [] before checking `.length`.
const qnameIds = scopes.qualifiedNames.get(baseName) ?? [];
if (qnameIds.length === 0) {
return findClassBindingInScope(scopeId, baseName, scopes);
return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration);
}
const matches: SymbolDefinition[] = [];
for (const id of qnameIds) {
@ -149,7 +164,123 @@ function resolveClassBindingForName(
// safety in non-ODR or mixed-language edge cases.
}
return findClassBindingInScope(scopeId, baseName, scopes);
return findClassBindingInScope(scopeId, baseName, scopes, stripDecoration);
}
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
const BARE_TYPE_NAME_RE = /^[A-Za-z_$][\w$]*$/;
/**
* A type name a built-in test may be asked about: a bare, undecorated
* identifier and nothing else.
*
* `Promise<User>`, `[]Repo` and `Option<Repo>` all name a built-in CONTAINER
* whose ELEMENT is very often in-program, and an await/index/unwrap step is
* exactly how a receiver chain reaches that element. Answering "external"
* because the outer spelling matched a built-in would relabel a real in-program
* drop, which is the failure this whole function exists to stop. A decorated or
* dotted spelling is likewise not a built-in name, it merely contains one.
*/
function isBareTypeName(rawName: string): boolean {
return BARE_TYPE_NAME_RE.test(rawName);
}
/**
* Is this dropped receiver rooted inside the analyzed program?
*
* Asks of the receiver's BASE the leftmost name the chain hangs off what
* this index can DEMONSTRATE. Three answers, and the asymmetry between them is
* the whole point:
*
* - `in-program` the base's declared type resolves here, or the base itself is
* a class, a qualified name, or a value this program declares. A real edge was
* lost; the hedge must fire.
* - `external` POSITIVE evidence that the target is outside: the language
* itself names the base (or its bare declared type) a built-in. `console.log`,
* `fetch(...)`, `JSON.stringify` reach code no index contains, so there is no
* node an edge could have pointed at and nothing was lost.
* - `unknown` everything else. An absence of evidence is NOT evidence of
* externality: an unannotated parameter (`function f(svc) { svc.a().b(); }`)
* is recorded nowhere in the scope model at all, and calling that "external"
* published `epistemic: 'exact'` over a genuinely missing in-program caller
* strictly worse than hedging, because it is a confident wrong answer rather
* than an admitted gap. `unknown` counts WITH `in-program` in
* `summarizeUnresolvedReceivers`, which is the safe direction.
*
* Uses the AST-derived chain base when one was minted, and falls back to the
* head of the receiver text otherwise never a regex over the source line.
*
* Exported for the unit tests that pin the three-way split; the pass is its only
* production caller.
*/
export function classifyReceiverOrigin(
decoded: DecodedReceiverChain | undefined,
inScope: string,
receiverName: string,
scopes: ScopeResolutionIndexes,
options: {
/** The language's type-preserving decoration stripper. Without it a Go
* pointer receiver `func (h *Host)` binds `h` to the literal `*Host`
* resolves to no class and the whole method body's drops were reported as
* external. Same hook the three receiver-chain lookups in
* `compound-receiver.ts` already receive. */
readonly stripTypePreservingDecoration?: DecorationStripper;
/** `LanguageProvider.isBuiltInName`, threaded through the pass options the
* same way `emitFreeCallFallback` receives it. THE only source of positive
* external evidence available here; languages that declare no built-in set
* simply never produce an `external` verdict, which is the safe default. */
readonly isBuiltInName?: (name: string) => boolean;
} = {},
): ReceiverOrigin {
// The chain's base is authoritative. Without one, take the head of the
// receiver text up to the first member/call punctuation.
const base = decoded?.baseReceiverName ?? /^[A-Za-z_$][\w$]*/.exec(receiverName)?.[0];
if (base === undefined || base.length === 0) return 'unknown';
const strip = options.stripTypePreservingDecoration;
const isBuiltIn = options.isBuiltInName;
// The base's declared TYPE, when it has one, is the strongest signal about
// where the member lives: `inputs.stream()` has an in-program base bound to
// `List<String>`, whose `stream` is in the JDK.
const binding = findReceiverTypeBinding(inScope, base, scopes);
if (binding !== undefined) {
// `resolveClassBindingForName`, not a bare lookup: it also strips template
// arguments, so an in-program generic base (`Box<String> b; b.open()`)
// resolves instead of being mislabelled and dropped from the hedge.
if (
resolveClassBindingForName(binding.declaredAtScope, binding.rawName, scopes, strip) !==
undefined
) {
return 'in-program';
}
// The declared type is not one this index contains. That is only proof of
// externality when the language itself names it — otherwise the type merely
// failed to resolve (an alias, an inferred callable, a generic parameter),
// and we fall through to ask what the index knows about the base itself.
if (isBareTypeName(binding.rawName) && isBuiltIn?.(binding.rawName) === true) {
return 'external';
}
}
// Anything else this index knows by that name (namespace, module, free fn).
// O(1), so it goes ahead of the scope-chain walks below: all three checks are
// arms of the same `in-program` disjunction and none has a side effect, so
// answering from the index first is free and changes no verdict.
if (scopes.qualifiedNames.has(base)) return 'in-program';
// A type the program declares, used as a static receiver.
if (findClassBindingInScope(inScope, base, scopes, strip) !== undefined) return 'in-program';
// A VALUE the program declares — an object-literal service, a local whose
// initializer we could not type (`const loc = makeIt(); loc.getUser().save()`),
// a field. The type channel had nothing usable to say about these, but the
// program demonstrably declares the name, so the lost edge is in-program and
// failing to type it is a resolver defect. This is the channel Case 5 already
// dispatches on; consulting it here keeps the diagnostic honest about the
// same population.
if (findValueBindingInScope(inScope, base, scopes) !== undefined) return 'in-program';
// Positive external evidence, and the only kind reachable from this pass.
if (isBuiltIn?.(base) === true) return 'external';
return 'unknown';
}
export function emitReceiverBoundCalls(
@ -168,6 +299,14 @@ export function emitReceiverBoundCalls(
* `undefined` zero overhead, byte-identity (R4). Per-file capture
* contexts are built from this + `parsed.filePath` in the loop. */
readonly calleeIdSink?: CalleeIdSink;
/** `LanguageProvider.isBuiltInName`. Passed through the options bag rather
* than widened into `ReceiverBoundProviderSubset`, mirroring how
* `emitFreeCallFallback` receives the same hook the subset exists to keep
* test providers small, and this pass reads nothing else off the language
* provider. Consumed ONLY by `classifyReceiverOrigin`, so leaving it unset
* degrades a drop's label to `unknown` (the safe direction) and changes no
* edge. */
readonly isBuiltInName?: (name: string) => boolean;
} = {},
): number {
let emitted = 0;
@ -180,10 +319,17 @@ export function emitReceiverBoundCalls(
const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true;
const compoundOpts = {
fieldFallback,
unwrapCollectionAccessor: provider.unwrapCollectionAccessor,
elementTypeOf: provider.elementTypeOf,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
stripTypePreservingDecoration: provider.stripTypePreservingDecoration,
};
// Loop-invariant: both hooks come off the pass arguments, so the options bag
// for `classifyReceiverOrigin` is built once here rather than per dropped site.
const receiverOriginOpts = {
stripTypePreservingDecoration: provider.stripTypePreservingDecoration,
isBuiltInName: options.isBuiltInName,
};
// Build an interface → implementors map from IMPLEMENTS edges.
@ -402,7 +548,23 @@ export function emitReceiverBoundCalls(
// 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('(')) {
// The punctuation test is a C-family heuristic and it is the reason
// `repos[0].save()` is INVISIBLE in all 14 languages: a subscript receiver
// contains neither `.` nor `(`, so this case never fired, the fold was
// never consulted, and no drop was recorded either — the call vanished
// with the instrument blind to it. PHP `->` and `::` receivers are lost
// the same way.
//
// A minted receiver chain is the STRUCTURAL answer to the same question:
// the capture layer walked the real AST and found the receiver is an
// expression, whatever punctuation it happens to be spelled with. Trusting
// that instead of the text is the substitution this whole line of work
// exists to make.
if (
receiverName.includes('.') ||
receiverName.includes('(') ||
site.receiverChain !== undefined
) {
const currentClass = resolveCompoundReceiverClass(
receiverName,
site.inScope,
@ -1341,6 +1503,10 @@ export function emitReceiverBoundCalls(
// dropped site (its callee is unknown by definition, so the drop cannot
// be attributed to any target symbol).
if (compoundReceiverUnresolved && !handledSites.has(siteKey)) {
// Decoded once: both the shape census and the origin classifier read the
// same chain, and this is inside the drop guard so a resolved site pays
// nothing.
const decodedChain = decodeReceiverChain(site.receiverChain);
options.recordResolutionOutcome?.({
kind: 'suppressed',
reason: 'receiver-unresolved',
@ -1354,6 +1520,22 @@ export function emitReceiverBoundCalls(
// recorded here too. Carry the kind so a consumer can separate a
// dropped CALL from a dropped property access.
siteKind: site.kind,
// Structural, from the AST-derived chain the emitter minted — never
// re-derived from the source line.
// `decodeReceiverChain` opens with a non-string guard, so the
// undefined case needs no ternary here.
receiverShape: classifyReceiverShape(decodedChain),
// Whether anything was actually lost. An external target has no node
// to point at, so its absence is completeness, not uncertainty — but
// ONLY a positive built-in match may say so. Everything the index
// cannot demonstrate stays `unknown` and keeps hedging.
receiverOrigin: classifyReceiverOrigin(
decodedChain,
site.inScope,
receiverName,
scopes,
receiverOriginOpts,
),
});
}
}

View file

@ -810,6 +810,10 @@ export function runScopeResolution(
{
recordResolutionOutcome,
calleeIdSink: calleeIdAccumulator,
// The pass's only source of positive EXTERNAL evidence for a dropped
// receiver (`console.log`, `fetch(...)`). Same hook, same spelling as
// the `emitFreeCallFallback` wiring below.
isBuiltInName: provider.languageProvider.isBuiltInName,
},
);
const unresolvedReceiverExtras =

View file

@ -1,4 +1,5 @@
import type { Range, ReferenceKind } from 'gitnexus-shared';
import type { DecodedReceiverChain } from '../utils/receiver-chain-codec.js';
export type ResolutionSuppressionReason =
| 'adl-ordinary-lookup-blocked'
@ -61,6 +62,120 @@ export type ResolutionOutcome =
* persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged.
*/
readonly siteKind?: ReferenceKind;
/**
* Structural shape of the receiver whose type could not be established.
*
* Derived from the site's ENCODED RECEIVER CHAIN the compact string the
* capture emitters mint by walking the real AST never from the source
* line. Re-deriving a shape textually would mean regex-classifying the
* number that gates this work, which is exactly the textual-shape dispatch
* the structural-receiver line of work exists to remove.
*
* Lets a consumer ask "which KIND of receiver are we losing?" instead of
* only "how many". Without it, `callDropsByExtension` is the finest
* available split and a language's bucket says nothing about whether the
* cause is one defect or five.
*
* NOTE ON COVERAGE: only drops that REACH the recorder carry a shape, and
* Case 0's gate fires on receiver punctuation, so shapes that mint no
* reference site at all (`?.`, explicit type args, subscript) are absent
* from this breakdown entirely they are the INVISIBLE-GAP population the
* bench shape arm exists to see. A shape census here is a census of the
* VISIBLE drops, not of all lost calls.
*
* Diagnostic only. `summarizeUnresolvedReceivers` ignores it, so the
* persisted `RepoMeta.unresolvedReceiverMembers` artifact is unchanged.
*/
readonly receiverShape?: ReceiverShape;
/**
* Whether the receiver is rooted INSIDE the analyzed program.
*
* The single most important distinction a static-analysis tool can make
* about a call it did not resolve, and the one this codebase previously
* collapsed:
*
* - `in-program` the receiver's base is a local, parameter, field or a
* type this index knows. Failing to type it is a RESOLVER DEFECT: a real
* caller exists in the graph and was lost.
* - `external` the base is rooted in code this index does not contain
* (`System.out.println`, `fetch(...)`, `os.environ.setdefault`). There is
* NO node to point an edge at, so nothing was lost. A compiler resolves
* these against the JDK / BCL / lib.d.ts; without those, the honest
* answer is "outside the program", not "unknown".
*
* Only `in-program` makes an `impact` count a lower bound. Reporting an
* external target as uncertainty is what made the hedge fire on nearly
* every real codebase and taught readers to ignore it.
*/
readonly receiverOrigin?: ReceiverOrigin;
};
/**
* How a dropped receiver was spelled, structurally.
*
* - `chain-call` every recorded step is a call `svc.getUser().save()`
* - `chain-field` every recorded step is a field `h.repo.save()`
* - `chain-mixed` the chain interleaves both `svc.getUser().addr.save()`
* - `chain-unwrap` the chain contains an `await` or `index` step the shapes
* this work exists to expose. They were previously counted as
* FIELDS, because the classifier's parameter widened `kind` to
* `string` and they fell into its `else`.
* - `no-chain` the site carried no chain, so the receiver was a compound
* expression the capture walk could not reduce to a nameable
* base (it stopped early, or the base was unencodable)
*/
export type ReceiverShape =
| 'chain-call'
| 'chain-field'
| 'chain-mixed'
| 'chain-unwrap'
| 'no-chain';
/**
* Is the receiver rooted inside the analyzed program, or outside it?
*
* `unknown` is for a site carrying no usable base at all neither a chain nor
* an explicit receiver where the question cannot be asked. It is treated as
* `in-program` for hedging purposes, because assuming completeness we cannot
* demonstrate is the unsafe direction.
*/
export type ReceiverOrigin = 'in-program' | 'external' | 'unknown';
/** Classify a dropped receiver from its encoded chain. `undefined` chain
* `no-chain`; an undecodable one is also `no-chain`, since what we know about
* it is exactly that no usable structure survived.
*
* Takes `DecodedReceiverChain` rather than a structural duck-type: widening
* `kind` to `string` let `await` and `index` fall into an `else` branch and be
* counted as FIELDS, so the two shapes this work exists to expose were
* censused as `chain-field`. The discriminated union makes a new step kind a
* compile error instead of a silent bucket. */
export function classifyReceiverShape(decoded: DecodedReceiverChain | undefined): ReceiverShape {
if (decoded === undefined || decoded.steps.length === 0) return 'no-chain';
let calls = 0;
let fields = 0;
let unwraps = 0;
for (const step of decoded.steps) {
switch (step.kind) {
case 'call':
calls++;
break;
case 'field':
fields++;
break;
case 'await':
case 'index':
unwraps++;
break;
}
}
// An unwrap step dominates: a chain containing one fails for reasons a pure
// field or call chain does not, so folding it into either bucket would
// misattribute the population a fix has to target.
if (unwraps > 0) return 'chain-unwrap';
if (calls > 0 && fields > 0) return 'chain-mixed';
return calls > 0 ? 'chain-call' : 'chain-field';
}
export type ResolutionOutcomeRecorder = (outcome: ResolutionOutcome) => void;

View file

@ -321,10 +321,67 @@ export function moduleScopeIdOf(
*
* Without (2) we'd miss every cross-file class-receiver call.
*/
/**
* Every class-like definition visible for `name`, from the scope chain AND the
* qualified-name index, deduped by `nodeId`.
*
* Exists because `walkScopeChain` returns the FIRST match and cannot report a
* collision, so a caller that widens what a name can match (the decoration
* normalizer below) has no way to tell "one answer" from "picked the nearest of
* several". Mirrors `findAllCallableBindingsInScope`, which solved the same
* problem for callables.
*/
export function findAllClassBindingsInScope(
startScope: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
): readonly SymbolDefinition[] {
const inScope = findAllBindingsInScope(startScope, name, scopes, (def) => isClassLike(def.type));
// The scope chain wins outright when it binds the name: an inner binding
// shadows anything the qualified-name index would contribute.
if (inScope.length > 0) return inScope;
const byNodeId = new Map<string, SymbolDefinition>();
for (const id of scopes.qualifiedNames.get(name)) {
const def = scopes.defs.get(id);
if (def !== undefined && isClassLike(def.type)) byNodeId.set(def.nodeId, def);
}
return [...byNodeId.values()];
}
/**
* Strip one layer of type-preserving decoration off a declared type name, or
* `undefined` when there is nothing left to strip. Supplied per language through
* the `ScopeResolver` contract; the core never names a language (AGENTS.md R6).
*
* TYPE-PRESERVING only pointer, reference, `const`, nullable, borrow,
* deref-transparent smart pointer, sigil. A CONTAINER (array, slice, map,
* `Option`) changes the member set, so stripping one here would type
* `repos: Repo[]` as `Repo` and let `repos.find(x)` fold to `Repo.find`. Those
* are unwrapped only by an index step that consumed a subscript.
*/
export type DecorationStripper = (typeName: string) => string | undefined;
/** Bounded so a pathological stripper cannot spin. Real decoration nests
* shallowly (`*[]T`, `const T&`); three layers is generous. */
const MAX_DECORATION_LAYERS = 3;
export function findClassBindingInScope(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
/**
* OPT-IN. When supplied, a name that binds nothing is retried with decoration
* stripped one layer at a time, and each retry must resolve to exactly ONE
* class-like definition or it declines.
*
* Opt-in rather than global because roughly two dozen call sites use the shape
* `findClassBindingInScope(...) ?? otherResolver(...)`: turning a former
* `undefined` into a hit SUPPRESSES the fallback that used to answer, which
* would retarget inheritance edges and bypass generic-specialization
* selection. Only receiver-chain base and step resolution passes this.
*/
stripDecoration?: DecorationStripper,
): SymbolDefinition | undefined {
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
if (local !== undefined) return local;
@ -350,6 +407,25 @@ export function findClassBindingInScope(
}
}
}
// Decoration fallback (opt-in). Every branch above works on the name exactly
// as written; only when none of them bound anything do we consider that the
// name may be a decorated spelling of one that would.
if (stripDecoration !== undefined) {
let current = receiverName;
for (let layer = 0; layer < MAX_DECORATION_LAYERS; layer++) {
const stripped = stripDecoration(current);
if (stripped === undefined || stripped === current || stripped.length === 0) break;
current = stripped;
const candidates = findAllClassBindingsInScope(startScope, current, scopes);
// Exactly one, or decline. Two same-named classes reachable from here mean
// the decoration was carrying the only disambiguating information, and
// picking the nearest would mint a confident wrong edge — the failure this
// whole line of work exists to avoid. A missing edge is recoverable.
if (candidates.length === 1) return candidates[0];
if (candidates.length > 1) return undefined;
}
}
return undefined;
}
@ -787,10 +863,28 @@ export function findAllCallableBindingCandidatesInScope(
* `findCallableBindingInScope`: once any callable binding is found in a
* scope, outer scopes are not consulted.
*/
export function findAllCallableBindingsInScope(
/**
* Every definition visible for `name` at the NEAREST scope that binds it,
* filtered by `predicate` and deduped by `nodeId`.
*
* THE shared "collect all at the nearest binding scope" walk. `walkScopeChain`
* answers the first-match question; this answers the how-many question, which is
* what a caller needs before it can decline on ambiguity.
*
* Stops at the first scope that binds the name at all: an inner binding SHADOWS
* an outer one, so continuing would report a shadowed outer definition as a
* competing candidate and decline a name that is unambiguous at this point.
*
* Returns `[]` on a cycle or a missing scope. That is deliberate and matters:
* an earlier copy of this walk `break`-ed instead and fell through to a
* qualified-name fallback, so the same malformed input produced a different
* answer depending on which copy the caller happened to reach.
*/
function findAllBindingsInScope(
startScope: ScopeId,
callableName: string,
name: string,
scopes: ScopeResolutionIndexes,
predicate: (def: SymbolDefinition) => boolean,
): readonly SymbolDefinition[] {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
@ -805,24 +899,16 @@ export function findAllCallableBindingsInScope(
if (scope.kind !== 'Object') {
const out: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushCallable = (def: SymbolDefinition): void => {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return;
const push = (def: SymbolDefinition): void => {
if (!predicate(def)) return;
if (seen.has(def.nodeId)) return;
seen.add(def.nodeId);
out.push(def);
};
const localBindings = scope.bindings.get(callableName);
if (localBindings !== undefined) {
for (const b of localBindings) {
pushCallable(b.def);
}
}
const importedBindings = lookupBindingsAt(currentId, callableName, scopes);
for (const b of importedBindings) {
pushCallable(b.def);
}
// Local first: a binding in this scope shadows an imported one.
for (const b of scope.bindings.get(name) ?? []) push(b.def);
for (const b of lookupBindingsAt(currentId, name, scopes)) push(b.def);
if (out.length > 0) return out;
}
@ -831,6 +917,19 @@ export function findAllCallableBindingsInScope(
return [];
}
export function findAllCallableBindingsInScope(
startScope: ScopeId,
callableName: string,
scopes: ScopeResolutionIndexes,
): readonly SymbolDefinition[] {
return findAllBindingsInScope(
startScope,
callableName,
scopes,
(def) => def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor',
);
}
/**
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
* unqualified lookup finds:

View file

@ -31,6 +31,58 @@ export interface UnresolvedReceiverSummary {
/** Distinct member names beyond the cap, omitted from `counts`. Absent when
* nothing was dropped from the map. */
readonly omittedNames?: number;
/**
* Call sites dropped whose receiver was rooted OUTSIDE the indexed program,
* by member name `System.out.println`, `fetch(...)`, `os.environ.*`.
*
* Kept SEPARATE rather than filtered away. These do not make a count a lower
* bound (there is no in-graph node an edge could have reached), but erasing
* them at summary time would leave the persisted artifact unable to
* distinguish "clean index" from "76 drops we judged external" with no
* audit path and no way back without a re-index. That is the same collapse
* `EpistemicCauses` exists to undo, and the judgement being recorded here is a
* heuristic, so it must stay reversible.
*/
readonly externalCounts?: Readonly<Record<string, number>>;
/** Total external-rooted call sites, including any beyond the cap. */
readonly externalSites?: number;
/**
* Distinct member names beyond the cap, omitted from `externalCounts`. Absent
* when nothing was dropped from that map.
*
* The exact twin of `omittedNames`, and it exists for the same reason. Past
* the cap `lookupExternalCallCount` returns `undefined` for a truncated name,
* which is indistinguishable from "this member had no external drops" so a
* symbol with real boundary evidence reads as having none. One map carrying a
* truncation marker and the other silently losing entries also made the
* persisted artifact self-contradictory: `externalSites` would exceed the sum
* of `externalCounts` with nothing to explain the difference.
*/
readonly externalOmittedNames?: number;
}
/**
* Rank a namecount map and cap it at {@link MAX_UNRESOLVED_RECEIVER_MEMBERS}.
*
* 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. ONE comparator, shared by the
* in-program and external maps: two hand-copied comparators that must stay
* identical or the artifact churns on one map and not the other is exactly the
* drift this contract cannot tolerate.
*
* `omitted` is the number of distinct names past the cap, so the caller can
* report truncation rather than silently losing entries.
*/
function rankAndCap(counts: Map<string, number>): {
kept: [string, number][];
omitted: number;
} {
const ranked = [...counts.entries()].sort(
([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName),
);
const kept = ranked.slice(0, MAX_UNRESOLVED_RECEIVER_MEMBERS);
return { kept, omitted: ranked.length - kept.length };
}
/**
@ -43,7 +95,9 @@ export function summarizeUnresolvedReceivers(
outcomes: readonly ResolutionOutcome[],
): UnresolvedReceiverSummary | undefined {
const counts = new Map<string, number>();
const externalCounts = new Map<string, number>();
let totalSites = 0;
let externalSites = 0;
for (const outcome of outcomes) {
if (outcome.kind !== 'suppressed' || outcome.reason !== 'receiver-unresolved') continue;
if (outcome.name.length === 0) continue;
@ -56,24 +110,43 @@ export function summarizeUnresolvedReceivers(
// 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;
// Routed, not discarded. External-rooted drops (`console.log(...)`,
// `fetch(...)`) reach code this index does not contain, so there is no node
// an edge could have pointed at and nothing was lost — they must not hedge.
// But they stay in the artifact under their own key so the split is
// auditable and reversible.
//
// `external` is a POSITIVE determination made by `classifyReceiverOrigin`
// from a language built-in match, never a fallthrough: a receiver the
// classifier could not place lands in `unknown`, which counts here WITH
// `in-program`, because assuming a completeness we cannot demonstrate is
// the unsafe direction.
if (outcome.receiverOrigin === 'external') {
externalSites++;
externalCounts.set(outcome.name, (externalCounts.get(outcome.name) ?? 0) + 1);
continue;
}
totalSites++;
counts.set(outcome.name, (counts.get(outcome.name) ?? 0) + 1);
}
if (totalSites === 0) return undefined;
// An index whose only drops were external-rooted still reports the split, so
// "nothing was lost" is distinguishable from "nothing was measured".
if (totalSites === 0 && externalSites === 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;
const { kept, omitted: omittedNames } = rankAndCap(counts);
const { kept: externalKept, omitted: externalOmittedNames } = rankAndCap(externalCounts);
return {
counts: Object.fromEntries(kept),
totalSites,
...(omittedNames > 0 ? { omittedNames } : {}),
...(externalSites > 0
? {
externalCounts: Object.fromEntries(externalKept),
externalSites,
...(externalOmittedNames > 0 ? { externalOmittedNames } : {}),
}
: {}),
};
}
@ -102,3 +175,22 @@ export function lookupUnresolvedCallCount(
if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined;
return sites;
}
/**
* Look up the EXTERNAL-rooted dropped-call count for a member name.
*
* Companion to `lookupUnresolvedCallCount`, and prototype-safe for the same
* reason: the map is revived from JSON, so `constructor` / `toString` and
* friends would otherwise return a function.
*/
export function lookupExternalCallCount(
summary: UnresolvedReceiverSummary | undefined,
symName: string,
): number | undefined {
const counts = summary?.externalCounts;
if (counts === undefined || symName.length === 0) return undefined;
if (!Object.hasOwn(counts, symName)) return undefined;
const sites = counts[symName];
if (typeof sites !== 'number' || !Number.isFinite(sites) || sites <= 0) return undefined;
return sites;
}

View file

@ -1,3 +1,4 @@
import type { ElementAccessRoute } from '../scope-resolution/contract/scope-resolver.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
/** Which type argument to extract from a multi-arg generic container.
@ -698,6 +699,27 @@ export function extractElementTypeFromString(
return undefined;
}
/**
* `elementTypeOf` for a language with no property-style collection view:
* answer the subscript route from the written spelling, decline the accessor
* route.
*
* Languages whose collection views are spelled as method calls (`.values()`,
* `.iter()`) never reach the accessor route the compound resolver's
* call-expression branch handles those so the whole hook is this one
* decision. Declining (returning `undefined`) is the answer "this spelling is
* not a container", which is what stops an index-overloading class from
* folding `x[k].m()` onto its own members.
*
* Languages that DO expose a collection view as a property (C#'s `.Values`)
* need a bespoke body instead.
*/
export const indexOnlyElementType = (
containerType: string,
via: ElementAccessRoute,
): string | undefined =>
via.kind === 'index' ? extractElementTypeFromString(containerType) : undefined;
// ── Return type text helpers ─────────────────────────────────────────────
// extractReturnTypeName works on raw return-type text already stored in
// SymbolDefinition (e.g. "User", "Promise<User>", "User | null", "*User").

View file

@ -2,6 +2,7 @@ import type { MixedChainStep } from 'gitnexus-shared';
import type { SyntaxNode } from './ast-helpers.js';
import { CALL_ARGUMENT_LIST_TYPES } from './ast-helpers.js';
import { subscriptBase } from './callable-flow-captures.js';
/** Node types representing call expressions across supported languages. */
export const CALL_EXPRESSION_TYPES = new Set([
@ -16,6 +17,24 @@ export const CALL_EXPRESSION_TYPES = new Set([
/**
* Hard limit on chain depth to prevent runaway recursion.
* For `a.b().c().d()`, the chain has depth 2 (b and c before d).
*
* A chain deeper than this is DISCARDED WHOLE, not truncated:
* `extractMixedChain` returns an undefined base and the encoder refuses to mint
* a partial chain, because a base-side prefix decodes cleanly as a shorter,
* complete-looking chain and would type the receiver against the wrong member.
* Correct, but it means a builder chain one hop too long contributes nothing at
* all rather than degrading.
*
* DELIBERATELY NOT RAISED. Measured (see bench/receiver-resolution/BASELINE.md,
* `fourHopChain`): a 4-step chain mints NOTHING at this cap confirmed by
* probing the emitter directly and the site still RESOLVES, because the text
* cascade that owns the fallback path runs to `COMPOUND_RECEIVER_MAX_DEPTH` (8)
* and answers where the structural fold declined.
*
* So the cap bounds which chains are typed STRUCTURALLY, not which calls
* resolve. Raising it moves work from the cascade to the fold without changing
* any edge, and the fixture that proves it is committed so the next person to
* reach for this number has the measurement rather than the intuition.
*/
export const MAX_CHAIN_DEPTH = 3;
@ -353,6 +372,51 @@ export const extractReceiverNode = (nameNode: SyntaxNode): SyntaxNode | undefine
// ── Chained-call extraction ───────────────────────────────────────────────
/** Node types representing member/field access across languages. */
/**
* Await expressions, per grammar. The walk previously stopped here an await
* node is neither a call nor a field access so `(await svc.getUserAsync()).save()`
* minted NO chain at all and the receiver fell to the text cascade.
*/
const AWAIT_EXPRESSION_NODE_TYPES = new Set([
'await_expression', // TS/JS/C#/Rust
'await', // Python
]);
/**
* Subscript / index expressions, per grammar. Same story as await: the walk
* stopped, so `repos[0].save()` minted no chain which is why `indexElement`
* is an INVISIBLE-GAP (no edge AND no recorded drop) in all 14 languages, the
* most uniform cell in the matrix.
*/
const SUBSCRIPT_NODE_TYPES = new Set([
'subscript_expression', // TS/JS/PHP/C/C++
'subscript', // Python
'index_expression', // Go/Rust
'element_access_expression', // C#
'array_access', // Java
'indexing_expression', // Kotlin
]);
/**
* Can the chain walk descend into this node, or is it the base?
*
* ONE predicate for all four branches. The call and field branches previously
* tested only the call/field sets, so a receiver like `x[0].f().g()` stopped at
* the subscript and returned the literal text `x[0]` as the base which
* `isEncodableSegment` accepts, minting a chain whose base binds to nothing. And
* `(await f()).g.h()` returned `await f()`, rejected on whitespace, minting no
* chain at all. Adding a fifth step kind must not require remembering four
* separate call sites.
*/
function isChainableReceiverNode(node: SyntaxNode): boolean {
return (
CALL_EXPRESSION_TYPES.has(node.type) ||
FIELD_ACCESS_NODE_TYPES.has(node.type) ||
AWAIT_EXPRESSION_NODE_TYPES.has(node.type) ||
SUBSCRIPT_NODE_TYPES.has(node.type)
);
}
const FIELD_ACCESS_NODE_TYPES = new Set([
'member_expression', // TS/JS
'member_access_expression', // C#
@ -497,11 +561,25 @@ const TRANSPARENT_RECEIVER_WRAPPERS = new Set([
'parenthesized_expression', // `(svc)`
]);
/**
* Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`.
*
* The two answer unrelated questions "how many chain hops do we type?" versus
* "how many redundant parens might someone write?" and sharing one number
* meant raising the chain cap silently widened this loop as a side effect. That
* coupling is easy to miss precisely because the shared name reads as
* intentional. `((x))` nests twice; nothing real nests deeply.
*/
const MAX_TRANSPARENT_WRAPPER_DEPTH = 3;
/** 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++) {
for (
let i = 0;
i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type);
i++
) {
const inner = current.namedChildren?.find((c) => c !== null);
if (inner === undefined || inner === null) break;
current = inner;
@ -516,6 +594,12 @@ export function extractMixedChain(
let current: SyntaxNode = receiverNode;
while (chain.length < MAX_CHAIN_DEPTH) {
// Peel transparent wrappers at LOOP ENTRY, not only where a base is
// returned. `(await svc.getUserAsync()).save()` hands this walk a
// `parenthesized_expression`, which matches no branch below, so the walk
// fell straight through to the base case with an empty chain and minted
// nothing — the await step could never be reached.
current = unwrapTransparentReceiver(current);
if (CALL_EXPRESSION_TYPES.has(current.type)) {
// ── Call expression: extract method name + inner receiver ────────────
const funcNode =
@ -572,10 +656,7 @@ export function extractMixedChain(
}
if (!innerReceiver) break;
if (
CALL_EXPRESSION_TYPES.has(innerReceiver.type) ||
FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)
) {
if (isChainableReceiverNode(innerReceiver)) {
current = innerReceiver;
} else {
return {
@ -624,10 +705,7 @@ export function extractMixedChain(
if (!innerObject) break;
if (
CALL_EXPRESSION_TYPES.has(innerObject.type) ||
FIELD_ACCESS_NODE_TYPES.has(innerObject.type)
) {
if (isChainableReceiverNode(innerObject)) {
current = innerObject;
} else {
return {
@ -635,6 +713,50 @@ export function extractMixedChain(
baseReceiverName: unwrapTransparentReceiver(innerObject).text || undefined,
};
}
} else if (AWAIT_EXPRESSION_NODE_TYPES.has(current.type)) {
// Name-free: the awaited call's method name already lives on its own
// `call` step, so this records only that an await happened.
chain.unshift({ kind: 'await' });
const inner =
current.childForFieldName?.('argument') ??
current.childForFieldName?.('expression') ??
current.namedChildren?.find((c: SyntaxNode) => c !== null) ??
null;
if (!inner) break;
if (isChainableReceiverNode(inner)) {
current = inner;
} else {
return {
chain,
baseReceiverName: unwrapTransparentReceiver(inner).text || undefined,
};
}
} else if (SUBSCRIPT_NODE_TYPES.has(current.type)) {
// Name-free: a subscript key is a VALUE, not an identifier the resolver
// could look up, so there is no member name to record.
chain.unshift({ kind: 'index' });
// Shared per-grammar table — it knows Python's `value` and Java's `array`,
// which a locally-written ladder omitted (they worked only because the
// container happened to be the first named child, an ordering coincidence
// rather than a contract). Falls back for grammars whose subscript node
// carries no `index` field.
const obj =
subscriptBase(current) ??
current.childForFieldName?.('object') ??
current.childForFieldName?.('argument') ??
current.childForFieldName?.('operand') ??
current.childForFieldName?.('expression') ??
current.namedChildren?.find((c: SyntaxNode) => c !== null) ??
null;
if (!obj) break;
if (isChainableReceiverNode(obj)) {
current = obj;
} else {
return {
chain,
baseReceiverName: unwrapTransparentReceiver(obj).text || undefined,
};
}
} else if (current.type === 'selector') {
// ── Dart: flat selector siblings (user.address.save() uses selector nodes) ──
// Extract field name from unconditional_assignable_selector child

View file

@ -1092,7 +1092,10 @@ function unaryOperator(node: SyntaxNode): string | undefined {
* `tbl[i]()` join (#2522 review). Field names cover the grammars that field
* their subscript nodes; others keep the generic traversal.
*/
function subscriptBase(node: SyntaxNode): SyntaxNode | null {
/** The container operand of a subscript node, per grammar. Exported because the
* receiver-chain walk needs the same per-grammar answer two divergent field
* tables for one question is how a new grammar gets half-supported. */
export function subscriptBase(node: SyntaxNode): SyntaxNode | null {
if (node.childForFieldName('index') === null) return null;
return (
node.childForFieldName('argument') ?? // C/C++ subscript_expression

View file

@ -16,10 +16,10 @@
* 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`)
* ## Wire format (version `2`)
*
* ```
* 1|<base>|<step>|<step>[|~]
* 2|<base>|<step>|<step>[|~]
* ```
*
* - One-character version prefix, then the BASE receiver name, then ordered
@ -29,6 +29,12 @@
* 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` = await and `i` = index are NAME-FREE and encode as a BARE sigil: an
* awaited call's name already lives on its `c` step, and a subscript's key is
* a value rather than an identifier the resolver could look up. The decoder
* rejects any trailing characters after `a` or `i`, which is what keeps an
* accidentally empty-name `c` or `f` segment refusing instead of decoding as
* one of these.
* - 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
@ -46,17 +52,44 @@
* different valid chain.
*
* For `svc.getUser().address.save()`, the receiver of `save` encodes as
* `1|svc|cgetUser|faddress` 23 bytes.
* `2|svc|cgetUser|faddress` 23 bytes. For `(await svc.getUserAsync()).save()`
* it is `2|svc|cgetUserAsync|a`.
*/
import type { MixedChainStep } from 'gitnexus-shared';
import { MAX_CHAIN_DEPTH } from './call-analysis.js';
const VERSION = '1';
/** Wire version. Bumped 1 2 when the name-free `await` and `index` step kinds
* were added: a v1 decoder reading a v2 payload must REFUSE, not decode the
* prefix it happens to understand, because a chain missing its await or index
* hop decodes cleanly as a different, shorter chain and would type the receiver
* against the wrong member. */
const VERSION = '2';
const SEPARATOR = '|';
const TRUNCATED = '~';
/** Sigil per step kind. ONE table, and both directions derive from it the
* decoder used to hand-write `sigil === 'c' ? 'call' : 'field'` and its own
* await/index comparison, which meant the decoder was exactly the side that
* could drift from the table claiming to prevent drift. */
const SIGIL_BY_KIND = {
call: 'c',
field: 'f',
await: 'a',
index: 'i',
} as const;
type StepKind = keyof typeof SIGIL_BY_KIND;
/** Kinds that encode as a BARE sigil, because they have no member name to
* carry. Derived from the step union rather than listed twice. */
const NAME_FREE_KINDS = new Set<StepKind>(['await', 'index']);
const KIND_BY_SIGIL: ReadonlyMap<string, StepKind> = new Map(
Object.entries(SIGIL_BY_KIND).map(([kind, sigil]) => [sigil, kind as StepKind]),
);
/** 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
@ -103,8 +136,15 @@ export function encodeReceiverChain(
const parts = [VERSION, baseReceiverName];
for (const step of steps) {
// Name-free kinds encode as a bare sigil. They are exempt from the
// non-empty-name guard because they HAVE no name to check — not because the
// guard is relaxed: an empty-name `call` or `field` is still refused below.
if (NAME_FREE_KINDS.has(step.kind)) {
parts.push(SIGIL_BY_KIND[step.kind]);
continue;
}
if (!isEncodableSegment(step.name)) return undefined;
parts.push(`${step.kind === 'call' ? 'c' : 'f'}${step.name}`);
parts.push(`${SIGIL_BY_KIND[step.kind]}${step.name}`);
}
if (options?.truncated === true) parts.push(TRUNCATED);
@ -139,9 +179,20 @@ export function decodeReceiverChain(value: unknown): DecodedReceiverChain | unde
for (const part of stepParts) {
const sigil = part[0];
const name = part.slice(1);
if (sigil !== 'c' && sigil !== 'f') return undefined;
// Name-free kinds must be EXACTLY their sigil. Rejecting a trailing tail is
// what keeps an accidentally empty-name call or field from decoding as one
// of these: `c` alone stays malformed, it does not become an await.
const kind = sigil === undefined ? undefined : KIND_BY_SIGIL.get(sigil);
if (kind === undefined) return undefined;
if (NAME_FREE_KINDS.has(kind)) {
// Must be EXACTLY the sigil. Rejecting a trailing tail is what keeps an
// accidentally empty-name call or field from decoding as one of these.
if (name.length > 0) return undefined;
steps.push({ kind: kind as 'await' | 'index' });
continue;
}
if (!isEncodableSegment(name)) return undefined;
steps.push({ kind: sigil === 'c' ? 'call' : 'field', name });
steps.push({ kind: kind as 'call' | 'field', name });
}
return { baseReceiverName, steps, truncated };

View file

@ -283,7 +283,10 @@ export interface ExtractedCall {
* `svc.getUser().save()` chain=[{kind:'call',name:'getUser'}], receiverName='svc'
* `user.address.save()` chain=[{kind:'field',name:'address'}], receiverName='user'
* `svc.getUser().address.save()` chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}]
* Length is capped at MAX_CHAIN_DEPTH (3).
* Length is capped at MAX_CHAIN_DEPTH. Deliberately NOT restating the number
* here: this comment previously hardcoded `(3)` and would have drifted the
* moment the cap moved, which is exactly the kind of stale doc that reads as
* authoritative.
*/
receiverMixedChain?: MixedChainStep[];
argTypes?: (string | undefined)[];

View file

@ -94,7 +94,10 @@ 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 {
lookupExternalCallCount,
lookupUnresolvedCallCount,
} from '../../core/ingestion/scope-resolution/unresolved-receivers.js';
import {
fnLineOf,
isPdgDegradedLayerStatus,
@ -472,13 +475,101 @@ 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[]): {
/**
* Why a count is a lower bound, as a machine-readable split.
*
* `epistemic` is a single enum and `boundaries` is prose, so a consumer that is
* not a human a coding agent gating its own edits on this result can tell
* THAT the answer is short but not WHY, and cannot branch on the difference.
* The two causes are independent and have opposite remedies:
*
* - `receiverTyping` the analyzer dropped call sites because it could not
* establish the receiver's type. A resolver defect. Fixable, and shrinking:
* this is the population the structural-receiver work targets.
* - `dispatchBoundary` the symbol sits behind an interface with real
* consumers or multiple implementations, so callers binding through a DI
* container or dynamic dispatch are genuinely untraceable statically. NOT a
* defect; a compiler would refuse here too.
*
* Collapsing them told the reader "impact may be higher" for both, which made
* the fixable cause indistinguishable from the irreducible one and made
* "the hedge should stop appearing" an unfalsifiable goal, because there was no
* way to see which producer was still firing.
*
* Every field counts MISSING THINGS, never notes. The unit is stated per field
* because the two producers can only measure at different granularities (see
* `dispatchBoundary`), and a consumer comparing the numbers has to know which
* it is holding. Counting notes here is the specific mistake to avoid: there is
* one note per symbol name / per boundary node, so a note count reports the
* number of SENTENCES, which has no relation to how much is missing.
*/
export interface EpistemicCauses {
/**
* Call SITES dropped at index time because the receiver's type could not be
* established. Unit: call sites, taken from the index's
* `unresolvedReceiverMembers` summary the same number the prose note quotes.
*/
readonly receiverTyping: number;
/**
* Symbols on the far side of a dispatch boundary that the traversal could not
* attribute to the queried symbol: implementations plus interface-level
* consumers, summed over the boundary nodes that were flagged.
*
* Unit: SYMBOLS, not call sites deliberately, because a call-site count is
* not derivable on this side. The graph does not retain per-site multiplicity
* for these edges: consumers are counted with `COUNT(DISTINCT other.id)`, and
* languages that set `collapseMemberCallsByCallerTarget` emit one CALLS edge
* per (caller, target) pair no matter how many syntactic sites exist. A
* symbol reachable through two flagged boundary nodes is counted once per
* node, so this is itself a lower bound.
*
* It is still directly comparable in magnitude with `receiverTyping` both
* answer "how much is missing" which `boundaries.length` was not.
*/
readonly dispatchBoundary: number;
/**
* Call sites whose receiver was rooted OUTSIDE the indexed program
* `System.out.println`, `fetch(...)`, `os.environ.*`. Reported, but NOT a
* reason the count is short: there is no in-graph node an edge could have
* reached, so the analysis is complete for the program as given.
*
* Surfaced so "no uncertainty" is distinguishable from "we judged 76 calls to
* be outside the program". A compiler resolves these against the JDK / BCL /
* lib.d.ts; lacking those, this number IS the boundary.
*
* Unit: call sites same unit and same source as `receiverTyping`.
*/
readonly externalBoundary: number;
}
function epistemicFrom(dropped: { notes: readonly string[]; sites: number; external: number }): {
epistemic: 'exact' | 'lower-bound';
boundaries?: string[];
causes?: EpistemicCauses;
} {
return droppedBoundaries.length === 0
? { epistemic: 'exact' }
: { epistemic: 'lower-bound', boundaries: [...droppedBoundaries] };
// An index whose only drops were external still reports `exact` — nothing was
// lost — but carries the boundary count so "complete" is distinguishable from
// "we judged N calls to leave the program".
return dropped.notes.length === 0
? dropped.external > 0
? {
epistemic: 'exact',
causes: { receiverTyping: 0, dispatchBoundary: 0, externalBoundary: dropped.external },
}
: { epistemic: 'exact' }
: {
epistemic: 'lower-bound',
boundaries: [...dropped.notes],
// SITES, not notes. There is one note per symbol name but it reports N
// dropped sites, so counting notes would have published `1` next to
// prose saying `2 call sites` — a consumer branching on the number
// would read a different magnitude than the human reading the text.
causes: {
receiverTyping: dropped.sites,
dispatchBoundary: 0,
externalBoundary: dropped.external,
},
};
}
interface RepoHandle {
@ -5738,7 +5829,11 @@ export class LocalBackend {
symId: string,
symType: string,
symName: string,
): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[] }> {
): Promise<{
epistemic: 'exact' | 'lower-bound';
boundaries?: string[];
causes?: EpistemicCauses;
}> {
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
@ -5803,6 +5898,12 @@ export class LocalBackend {
]);
const boundaries: string[] = [];
// Magnitude, not note count: see `EpistemicCauses.dispatchBoundary`. One
// note can describe an interface with 40 implementations and hundreds of
// interface-level consumers, so publishing `boundaries.length` would put
// `1` next to a `receiverTyping` of `12` and tell a consumer branching on
// the numbers that receiver typing dominates — the opposite of the truth.
let dispatchBoundarySymbols = 0;
for (const [id, info] of boundary) {
const impls = implCounts.get(id) ?? 0;
const consumers = consumerCounts.get(id) ?? 0;
@ -5811,6 +5912,7 @@ export class LocalBackend {
// (runtime dispatch is ambiguous). A concrete type implementing an
// interface nothing references is fully traced → stays exact.
if (consumers >= 1 || impls >= 2) {
dispatchBoundarySymbols += impls + consumers;
const label = (info.label || 'Interface').toLowerCase();
const name = info.name || '(unnamed)';
const article = /^[aeiou]/.test(label) ? 'an' : 'a';
@ -5829,7 +5931,15 @@ export class LocalBackend {
}
}
if (boundaries.length === 0) return epistemicFrom(droppedBoundaries);
return { epistemic: 'lower-bound', boundaries: [...droppedBoundaries, ...boundaries] };
return {
epistemic: 'lower-bound',
boundaries: [...droppedBoundaries.notes, ...boundaries],
causes: {
receiverTyping: droppedBoundaries.sites,
dispatchBoundary: dispatchBoundarySymbols,
externalBoundary: droppedBoundaries.external,
},
};
} catch {
// 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.
@ -5844,8 +5954,11 @@ export class LocalBackend {
* 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 [];
private async unresolvedReceiverBoundaries(
repo: RepoHandle,
symName: string,
): Promise<{ notes: string[]; sites: number; external: number }> {
if (symName.length === 0) return { notes: [], sites: 0, external: 0 };
try {
const meta = await loadMeta(path.dirname(repo.lbugPath));
const summary = meta?.unresolvedReceiverMembers;
@ -5853,8 +5966,9 @@ export class LocalBackend {
// 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 [
const external = lookupExternalCallCount(summary, symName) ?? 0;
if (sites === undefined) return { notes: [], sites: 0, external };
const notes = [
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${
sites === 1 ? 'was' : 'were'
} dropped at index time because the receiver's type could not be ` +
@ -5862,8 +5976,9 @@ export class LocalBackend {
`expression). Those callers are absent from this result — actual ` +
`impact may be higher.`,
];
return { notes, sites, external };
} catch {
return [];
return { notes: [], sites: 0, external: 0 };
}
}
@ -5934,9 +6049,14 @@ export class LocalBackend {
// optional here (the union's `{}` subtype). computeEpistemicBoundary's own
// return keeps `epistemic` REQUIRED — only this promise widens to the skip
// subtype.
// `causes` is part of the annotation, not just of the runtime value: the
// spread below is what publishes these fields, and a narrower annotation
// erases `causes` at the type level while still shipping it at runtime —
// so every consumer would be reading a field the compiler says is absent.
const epistemicPromise: Promise<{
epistemic?: 'exact' | 'lower-bound';
boundaries?: string[];
causes?: EpistemicCauses;
}> = opts.skipEpistemic
? Promise.resolve({})
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string);

View file

@ -284,6 +284,16 @@ Handles disambiguation: if multiple symbols share the same name, returns ranked
NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).
COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries the same epistemic envelope impact() returns:
- epistemic: 'exact' | 'lower-bound' 'lower-bound' means callers exist that this view provably does not list.
- boundaries: string[] one plain-language sentence per reason. Prose for humans; branch on causes instead.
- causes: { receiverTyping, dispatchBoundary, externalBoundary } machine-readable WHY. Every field counts MISSING THINGS, never sentences:
- causes.receiverTyping (unit: call sites) > 0 RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists.
- causes.externalBoundary (unit: call sites) > 0 the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible.
REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary come from index-time metadata only a current analyzer writes; against an older index they read as absent/0, which is indistinguishable from "nothing was dropped". Re-run \`gitnexus analyze\` before trusting a zero there.
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
@ -448,6 +458,14 @@ Output includes:
- affected_processes: which execution flows break and at which step
- affected_modules: which functional areas are hit (direct vs indirect)
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list.
- epistemic: 'exact' | 'lower-bound' whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out).
- boundaries: string[] one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead.
- causes: { receiverTyping, dispatchBoundary, externalBoundary } the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
- causes.receiverTyping (unit: call sites) > 0 the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming.
- causes.externalBoundary (unit: call sites) > 0 those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count per-site multiplicity is not retained for these edges so compare its magnitude with receiverTyping, not its exact value.
REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" re-run \`gitnexus analyze\` before trusting a zero there.
Depth groups:
- d=1: WILL BREAK (direct callers/importers)
@ -463,7 +481,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge.
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,

View file

@ -149,7 +149,30 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// v37: Java/Kotlin capture side-channels include Spring AOP owner/advice facts
// (#2416). Warm cache entries at v36 do not carry those facts and would silently
// omit ADVISED_BY evidence.
const SCHEMA_BUMP = 37;
// v38: Swift nested conditional-compilation directives are blanked before the
// parse (#2771), so a class body that previously error-recovered away now
// survives. The chunk key hashes raw on-disk bytes and `preprocessSource` runs
// after it is computed, so unchanged Swift files would otherwise replay their
// pre-fix `ParseWorkerResult` verbatim — including across `--force`. Allocated
// on `main`, NOT by this branch — kept so the number is not reused a third time.
// v39: receiver-chain wire format v2 — the encoded chain gained name-free
// `await` and `index` step kinds, so the VERSION prefix moved 1 -> 2 and every
// persisted chain string changed. A v2 decoder REFUSES a v1 payload (that is
// the point: a chain missing its await or index hop decodes cleanly as a
// different, shorter chain and would type the receiver against the wrong
// member), so a stale cache replays chains this build silently discards —
// the feature degrades to the text cascade with no error anywhere. Bumped so
// the stale cache is rejected rather than half-read.
//
// NUMBERED 39, AFTER TWO REALLOCATIONS. This branch first used 37; `main` took
// 37 for Spring AOP (#2416) mid-flight, so it moved to 38; `main` then took 38
// for the Swift directive fix (#2771), landing on the branch's number AGAIN.
// That is the EIGHTH collision in this series and the SECOND exact clash — two
// incompatible schemas claiming one number, twice running. The lesson is not
// "pick a bigger number": it is that the check must happen immediately before
// merge, because the window between review and merge is exactly when `main`
// allocates. Re-check against origin/main before merging this.
const SCHEMA_BUMP = 39;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -23,6 +23,7 @@ import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { retryRename } from './fs-atomic.js';
import { logger } from '../core/logger.js';
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
import {
branchSlug,
@ -240,12 +241,14 @@ export interface RepoMeta {
* 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.
*
* The persisted shape IS `UnresolvedReceiverSummary` referenced, not
* re-declared. The writer stores the whole summary, so a structural mirror
* here silently drops any field added on the producing side (a reader then
* sees `undefined` for keys that are present on disk). Type-only import, so
* this adds no runtime dependency from storage/ on core/.
*/
unresolvedReceiverMembers?: {
counts: Record<string, number>;
totalSites: number;
omittedNames?: number;
};
unresolvedReceiverMembers?: UnresolvedReceiverSummary;
/**
* 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
@ -673,8 +676,19 @@ export interface RepoMeta {
* (#2416). LadybugDB fixes allowed endpoint pairs when the relation table is
* created, so an older index cannot persist these edges through incremental
* writeback. Force a full re-analyze.
*
* v34: receiver-chain wire format v2 (name-free `await` / `index` steps). Every
* persisted `ReferenceSite.receiverChain` string changed prefix, and a v2
* decoder refuses a v1 payload by design, so a pre-v34 index carries chains this
* build cannot read. Resolution would silently fall back to the text cascade for
* every chain-carrying site no error, just quietly worse edges. Force a full
* re-analyze.
*
* Numbered 34, not 33: `main` took 33 for Spring AOP (#2416) mid-flight, landing
* on exactly this branch's number the seventh collision in this series and the
* first exact clash. Re-check against origin/main before merge.
*/
export const INCREMENTAL_SCHEMA_VERSION = 33;
export const INCREMENTAL_SCHEMA_VERSION = 34;
export interface IndexedRepo {
repoPath: string;

View file

@ -89,7 +89,7 @@
},
"csharp-chain-call/Program.cs": {
"captureGroups": 12,
"digest": "595cb902e60ce3e2b2dbc5036dda3738fe3d5f2ae3e438f9fc58374d0c43878d"
"digest": "3bde63f4bc679c03ef068eb5464d22c4b068028fa635067a3ad2d33d6004d760"
},
"csharp-chain-call/Services/UserService.cs": {
"captureGroups": 11,
@ -125,7 +125,7 @@
},
"csharp-deep-field-chain/Service.cs": {
"captureGroups": 14,
"digest": "f1994535de8f70d68adfe640c6f904f5e71ef4c2d0e7e0e91d4ebb0cda9b46a7"
"digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14"
},
"csharp-dictionary-keys-values/App.cs": {
"captureGroups": 21,
@ -153,7 +153,7 @@
},
"csharp-field-types/Service.cs": {
"captureGroups": 11,
"digest": "d66a8bd0a78fe20a843c36793e222a33c3bcae4b2eaadf47632dd782ad1d1c41"
"digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51"
},
"csharp-foreach/Models/Repo.cs": {
"captureGroups": 8,
@ -209,15 +209,15 @@
},
"csharp-grandparent-resolution/Services/App.cs": {
"captureGroups": 14,
"digest": "0223f17f864b5de421fa5906f3e0746bf855800c3a65122479fcc59cdd1a55bd"
"digest": "29a9220fcd1726794e0335200048b297d770a2feef9303431097537f8b07eaba"
},
"csharp-hello/Hello.cs": {
"captureGroups": 22,
"digest": "f942922d1a30fe794f9c807491f1a54c7ba6881a255a9234eab68ffe0953f88e"
"digest": "8952ec5a995332bc204cab3a9132370dfdf78594f592d569aca3c08db2da74a6"
},
"csharp-inline-constructor-receiver/src/Runner.cs": {
"captureGroups": 22,
"digest": "0a73830a8629fae4d4f7212c982747b43bebb744ad5e20a566b0097f5220a9e7"
"digest": "10da1e899928afc576e66e5effaf9bcab52d9f83cbc1dd8f0d95174de7410ecb"
},
"csharp-inline-constructor-receiver/src/Svc.cs": {
"captureGroups": 19,
@ -305,11 +305,11 @@
},
"csharp-local-shadow/App/Main.cs": {
"captureGroups": 14,
"digest": "be38b4045eac483dbd95e7cea8af82a38923e4b7ef5b2caa6915304ce012c4e3"
"digest": "b326e2800d010f3f0daa3ffa75444c59de53dbe4962ce4af4e90c76f8b6271c9"
},
"csharp-local-shadow/Utils/Logger.cs": {
"captureGroups": 10,
"digest": "40206dabd6f5b316fb40ac523d7a8fb66fcdd180f48de6b9313e9dfea02a6fd3"
"digest": "8089cf6d43685f35d479d58106d2a1a7a35994ddd52522cb256e009943bc7844"
},
"csharp-member-calls/Models/User.cs": {
"captureGroups": 8,

View file

@ -5,7 +5,7 @@
},
"go-aliased-package-import/main.go": {
"captureGroups": 7,
"digest": "3eb2e6d441dadede554b271b7a87b7b9ca557ab418bfb9e8524e6ace4c8fc547"
"digest": "3dad1a14917f597d87776732ed4e1e221af133a6465984fecf0a6841d402e88a"
},
"go-ambiguous/internal/models/handler.go": {
"captureGroups": 9,
@ -21,7 +21,7 @@
},
"go-assignment-chain/cmd/main.go": {
"captureGroups": 54,
"digest": "2bd7f8e2df13345ed77b4b438fa5af0b666aa238fe32be0d9ef39865b04a27e2"
"digest": "2bf328aa2aaa17f6e4cd04d7f3df27f2e275c12e9177a6ec29eab1cb83830760"
},
"go-assignment-chain/models/repo.go": {
"captureGroups": 8,
@ -33,7 +33,7 @@
},
"go-call-result-binding/cmd/main.go": {
"captureGroups": 17,
"digest": "fadab2167d8037193746a4bd13b50c63a7053c18a4810b75da16e931cfcdbb8a"
"digest": "a15141026f87c7c189b9392ce91effaa42f5dde5617c481dfb38eb0be089806f"
},
"go-call-result-binding/models/user.go": {
"captureGroups": 10,
@ -53,7 +53,7 @@
},
"go-chain-call/cmd/main.go": {
"captureGroups": 20,
"digest": "149b55b1cf523c13f9ed06cd02ce293f00407c08dd7d3bc0dd9173bc016e72e5"
"digest": "90aed1ac18f7d992d47f24b7a432b9077cdc113e1b356ba9bd4e430be81c76f2"
},
"go-chain-call/models/repo.go": {
"captureGroups": 10,
@ -73,7 +73,7 @@
},
"go-child-extends-parent/services/app.go": {
"captureGroups": 10,
"digest": "05f5df0369c90bc6e0da5a8a76e0661be36cddbe913ab2f83da2ee3733a46ec2"
"digest": "0794d3e8cbe530daeb5c09d8e325f66a4cd8c32bdba66b913092ef65c439d62c"
},
"go-cmd-helper/cmd/server/internal/config/config.go": {
"captureGroups": 5,
@ -81,11 +81,11 @@
},
"go-cmd-helper/cmd/server/main.go": {
"captureGroups": 7,
"digest": "a1f9453bd71926d60e3f148f43b9af813cbd1cccc11b323896a55bdb443f8931"
"digest": "78fcfc3f827e0531593208f5b76fe0d14edc56473a46a2149a04105d00d6cf4b"
},
"go-constructor-type-inference/cmd/main.go": {
"captureGroups": 19,
"digest": "3cfb964e7c0fa6d5b702a1312e4cd7dc5c6153d45e29f8d422afebcf131930f4"
"digest": "4f2e8bb6dbca59bcb82fc2efd43899b7a92816af28811cfc1858f0ce7ba9f6fe"
},
"go-constructor-type-inference/models/repo.go": {
"captureGroups": 8,
@ -97,7 +97,7 @@
},
"go-deep-field-chain/cmd/main.go": {
"captureGroups": 14,
"digest": "f9a16407d136307fa11299fadb4dff527a910dd9f2ffe4a50afc95dc3e1dd483"
"digest": "0c58d64b8f1cbd8aa30197a97eeada471215e70290e150f16ff3d7684f21009b"
},
"go-deep-field-chain/models/models.go": {
"captureGroups": 33,
@ -105,7 +105,7 @@
},
"go-field-types/cmd/main.go": {
"captureGroups": 10,
"digest": "d79a7120276b1a5297f6e39bc88574a18b82ad26faede903dd449309544293c6"
"digest": "dfe8fb6cff7e28cc209ad1237b28e11ee25ca34e89d7101dfa3a69de3186cac9"
},
"go-field-types/models/models.go": {
"captureGroups": 22,
@ -113,7 +113,7 @@
},
"go-for-call-expr/cmd/main.go": {
"captureGroups": 27,
"digest": "95217f85260d85baeb57638fff470c0a358be92cc49a5f918b084d809f47ba2a"
"digest": "e6d355eb633b2ca03848caf4eae79c3425ee2546bd37e637ce6b8121f26a13a0"
},
"go-for-call-expr/models/repo.go": {
"captureGroups": 14,
@ -137,7 +137,7 @@
},
"go-make-builtin/main.go": {
"captureGroups": 18,
"digest": "7c56328d8416338ae0075ae7dd9669b16f2035aa353fac7ee1e46109a58e80a6"
"digest": "e4ab42c7af140f1322325c69f9f0cbc69ea48ed14714642981bb25895455c6c4"
},
"go-make-builtin/models.go": {
"captureGroups": 15,
@ -145,7 +145,7 @@
},
"go-map-range/main.go": {
"captureGroups": 11,
"digest": "5e8b9ed5580cd51f988049c4a43e212bf9d5b655f5de85c80a956baf5892a6a0"
"digest": "9add416b9d98bfc08a0d7c96d3c9c1cd937269d6cd78fc200bce0e5a352c51b4"
},
"go-map-range/models/repo.go": {
"captureGroups": 9,
@ -157,7 +157,7 @@
},
"go-member-calls/cmd/main.go": {
"captureGroups": 11,
"digest": "b65bbfd200e4be9a991403a44bc68fb0a0abfe0bbf764b2acbd472d3eaf6742e"
"digest": "fbb2a3747e4b5da351c6895b0898440e57bf96de6a7e7167880e6dbb02b52f7e"
},
"go-member-calls/models/user.go": {
"captureGroups": 8,
@ -165,7 +165,7 @@
},
"go-method-chain-binding/cmd/main.go": {
"captureGroups": 22,
"digest": "ede80f2009f68d880d957bc90af74898dc0b3f7d86a48bf26e4d9d3587dec2d4"
"digest": "3b23b286ee2da646434fac5eb98199422fb2701d962a62840bb83961ec134987"
},
"go-method-chain-binding/models/user.go": {
"captureGroups": 24,
@ -177,11 +177,11 @@
},
"go-method-enrichment/app.go": {
"captureGroups": 15,
"digest": "ac0bdc2e6daf7d4e28fd255fe2edd143e8fab7ff316f4e32e2ca04a3b33f71c2"
"digest": "35929a31f6921feb074f50385aa91879e93f25b88d7f50774fddcfbb7b4e8539"
},
"go-mixed-chain/cmd/main.go": {
"captureGroups": 22,
"digest": "ee4f286d8b6baa15476e6316665f6d51a891701f9c994e14514aaee2738e811a"
"digest": "1c927e49a4271c1bd088b504051a9f02f2f30ec13fdec14b37c85e1afb5bb721"
},
"go-mixed-chain/models/models.go": {
"captureGroups": 41,
@ -189,7 +189,7 @@
},
"go-multi-assign/app.go": {
"captureGroups": 18,
"digest": "6ca3fdfb8708da1503367902ea458927bd2eb33582eeccbbb0a1dab585cc30c1"
"digest": "42821f44316f1b03c306f502fb4f80b344587f77795ce12cc9f91faa5017194d"
},
"go-multi-assign/models.go": {
"captureGroups": 19,
@ -197,7 +197,7 @@
},
"go-multi-return-inference/cmd/main.go": {
"captureGroups": 38,
"digest": "9f7320349e8fd04f40c23a9a04ab67826140034e98d9851538db1b8a6f9f2b7b"
"digest": "a1246c7603634e4e417310ca8cf105ff6465e3b466450f597b2f78ac25713c05"
},
"go-multi-return-inference/models/repo.go": {
"captureGroups": 10,
@ -209,7 +209,7 @@
},
"go-new-builtin/main.go": {
"captureGroups": 12,
"digest": "1177b99217a42a28b0d768d29e1df4198f0d5ca5f1c2b584d528f5f02354b38f"
"digest": "e8e3ebc47c4b0fdff1f4b4a3631544a58699bcf43ed21505a2362965af32f774"
},
"go-new-builtin/models.go": {
"captureGroups": 17,
@ -217,7 +217,7 @@
},
"go-nullable-receiver/cmd/main.go": {
"captureGroups": 27,
"digest": "7430c0c8f4877d1b41e62aee9017eadc66d2a8f01b70acf2836584a84f254335"
"digest": "93fe7c4b261918716d48cedae9c83759a223c7e4a9a97587a385abd80a9a9635"
},
"go-nullable-receiver/models/repo.go": {
"captureGroups": 8,
@ -237,11 +237,11 @@
},
"go-pkg/cmd/main.go": {
"captureGroups": 15,
"digest": "b763b372c88c64d31b4a746d24a422b490dfad0f33a55ac4e0f697979995b34f"
"digest": "5e4954f0f7815b0dcc832dbc63c9b1a279e4d657ce5d8ebea775a1c64ca6aee7"
},
"go-pkg/internal/auth/service.go": {
"captureGroups": 21,
"digest": "8a5a2fef24f2803396b2314ffc951487b1adbc2a28a733bd2dcb65e2961fc479"
"digest": "9e8fa89a195ce26a79aeb7178a9727c42a4aff0cd5995ad5b0a7010f8733cc2b"
},
"go-pkg/internal/models/admin.go": {
"captureGroups": 17,
@ -257,7 +257,7 @@
},
"go-pointer-constructor-inference/cmd/main.go": {
"captureGroups": 15,
"digest": "39f9030e909a37f0e13724f50673dd4a72ba240fb6ac263192ee4feadbc88adf"
"digest": "621f11069c2b44f9aec20a17834685f643bd9eaa2ef14b62194bd73ace840a3e"
},
"go-pointer-constructor-inference/models/repo.go": {
"captureGroups": 10,
@ -267,6 +267,14 @@
"captureGroups": 10,
"digest": "4193f6356f75505415e36d4b090dc9f5265937efe72b4251c33e5b5f06aaa99d"
},
"go-pointer-receiver-field-chain/handlers/handler.go": {
"captureGroups": 116,
"digest": "ba27c7e7ddc71de0a2696963a50a1afcd2b0faed9716c55c02ce66a4232cd7fa"
},
"go-pointer-receiver-field-chain/repository/repo.go": {
"captureGroups": 20,
"digest": "9e1c600c201dd4f5fdcb15820aaa3ad7fa1a3722e4c29bdf8daadf2e2f9f4b9e"
},
"go-qualified-base/base/base.go": {
"captureGroups": 17,
"digest": "c2f2241a4e31ad1b003c1649d7511437f3884815412aeac65754cf3cfb9b6dc1"
@ -289,7 +297,7 @@
},
"go-receiver-resolution/cmd/main.go": {
"captureGroups": 13,
"digest": "e92c59312a46972a6083ba489888b550cdd024b809acbbf367ad025efb844a5c"
"digest": "8e3f9109f70a44604836ab3542dc79cc9e4c505ccabe00e20104276bc7bdbaf4"
},
"go-receiver-resolution/models/repo.go": {
"captureGroups": 8,
@ -301,7 +309,7 @@
},
"go-return-type-inference/cmd/main.go": {
"captureGroups": 41,
"digest": "6133d61b247732eddad431c28c1374e46d85937ab42e88d7b1ab134aca1e593b"
"digest": "fb0dfc3a89d5a4047f58680f4f36bcbcd03b6e2d9f32aa9a2e4c4f38a2911f2b"
},
"go-return-type-inference/models/repo.go": {
"captureGroups": 17,
@ -313,7 +321,7 @@
},
"go-same-package-factory/main.go": {
"captureGroups": 14,
"digest": "4638aceb638f4cf9c11d9f51f43f50edf6e5358da69cde78d4bffee1f3dce3cc"
"digest": "16b78486154a15e873cf72f7085f0840bb14948632b5dbf38d843329ab3871d4"
},
"go-same-package-factory/repo.go": {
"captureGroups": 8,
@ -325,7 +333,7 @@
},
"go-split-method-owner/main.go": {
"captureGroups": 9,
"digest": "e9c105208ad6ef2f087f758972475aa403294e886751d75113b4ca45fa4e0b8a"
"digest": "6ea2f1057754c77d90b9ca71818db4c7d8f20f7a38e649468650f635dcb082c3"
},
"go-split-method-owner/repo.go": {
"captureGroups": 8,
@ -341,7 +349,7 @@
},
"go-struct-literals/app.go": {
"captureGroups": 11,
"digest": "bcdf97254bb1518219d85ff75795f72ef7a41c4e43ddeb7a09648349d46ec347"
"digest": "15d17d1c75ce72dfe9e569210fa9bfe84f9eed2cb1b044bfc4c2e8b706d3cff1"
},
"go-struct-literals/user.go": {
"captureGroups": 10,
@ -353,7 +361,7 @@
},
"go-structural-interface-cross-package/cmd/main.go": {
"captureGroups": 33,
"digest": "340f5f3f501be8e4f0fb7fdc63949ec2c6ee535eac1480d0090f9f5aab0dfd11"
"digest": "4392284404a0cc21f355ce851bbd481b44dd4fa6bef94bfdc07be423ea433d79"
},
"go-structural-interface-cross-package/contracts/read_closer.go": {
"captureGroups": 6,
@ -373,11 +381,11 @@
},
"go-structural-interface-dispatch/repository.go": {
"captureGroups": 142,
"digest": "986d18b1db74887f5f6f91876aeb332111d85be658dd5d737d104a86204b5575"
"digest": "5bb89d0695cd94082b0657abd3435726372fafa5c570d01b1440ac604217574b"
},
"go-type-assertion/main.go": {
"captureGroups": 12,
"digest": "046533d699a7a037ea6b891b0e5dbcdeda19cc1554c2c0aca14005151a72c7f5"
"digest": "21c3bd9cc1321544f2d027c1b847fd041befbb697d0b0324e813b9ad44a75396"
},
"go-type-assertion/models.go": {
"captureGroups": 18,

View file

@ -0,0 +1,3 @@
module fixture
go 1.21

View file

@ -0,0 +1,111 @@
// Regression fixture for #2766.
//
// A Go method with a POINTER receiver binds its receiver to the literal string
// `*Holder` (synthesizeGoReceiverBinding stores typeNode.text raw, deliberately,
// because method-owners.ts consumes the `*T` vs `T` distinction to model Go's
// value and pointer method sets). Before the decoration fallback in
// findClassBindingInScope, that string matched no class binding, so receiver
// typing declined at the BASE and every `h.field.Method()` here emitted no CALLS
// edge — the dominant Go idiom, silently missing from the graph.
//
// The value-receiver twin at the bottom is the control: it resolved before the
// fix and must keep resolving after it. Field decoration is NOT the variable —
// Go already normalizes field type bindings at capture via normalizeGoTypeName.
package handlers
import "fixture/repository"
type Holder struct {
thing repository.Thing
impl *repository.Impl
cart *repository.CartRepo
}
// Pointer receiver, interface-typed cross-package field.
func (h *Holder) RunInterface() error {
return h.thing.DoWork()
}
// Pointer receiver, concrete-typed cross-package field.
func (h *Holder) RunConcrete() error {
return h.impl.DoWork()
}
// Pointer receiver, concrete-typed cross-package field returning a value.
func (h *Holder) RunCart(tx int) *repository.CartRepo {
return h.cart.WithTx(tx)
}
// Control: a local variable receiver typed in the same function resolved even
// before the fix, via the text cascade rather than the decorated base.
func (h *Holder) RunLocal() error {
local := &repository.Impl{}
return local.DoWork()
}
type ValueHolder struct {
impl *repository.Impl
}
// Control: VALUE receiver. Binds as `ValueHolder` with no decoration, so this
// resolved before the fix and must not change.
func (v ValueHolder) RunFromValueReceiver() error {
return v.impl.DoWork()
}
// #2766 / U8 control: SAME-PACKAGE field receiver through a pointer receiver.
// Before the base fix this emitted an ACCESSES edge to the method and NO CALLS
// edge — the member name resolved while the CALLS leg, which needs the
// receiver's class, did not. It is the shape that made the miss look like an
// edge-classification bug rather than a receiver-typing one.
type LocalDep struct{}
func (d *LocalDep) Work() error { return nil }
type LocalHost struct {
dep *LocalDep
}
func (h *LocalHost) RunSamePackage() error {
return h.dep.Work()
}
// #2782 review: a FUNC-TYPED STRUCT FIELD is dispatched with exactly the same
// `x.f()` syntax as a method, so a selector in callee position is NOT always a
// phantom read. `Callbacks` is the shape of every callback struct, hook struct
// and hand-rolled mock in real Go (`mock.DoFunc`, `opts.OnEvent`), and the field
// read is their ONLY ACCESSES evidence — dropping callee-position reads at the
// capture layer erased it.
//
// The receivers below are deliberately BARE names rather than field chains:
// `h.x.y` reads do not resolve at all today (a separate, pre-existing compound
// receiver gap), so a chained spelling would assert nothing either way.
type Callbacks struct {
OnEvent func() error
Label string
}
// The row the callee-position drop deleted. `OnEvent` is a field, so the
// selector is a genuine read; the call goes through the value it holds.
func CallFuncField(c *Callbacks) error {
return c.OnEvent()
}
// Control: a plain (non-func) field read is never in callee position.
func ReadPlainField(c *Callbacks) string {
return c.Label
}
// Control: a METHOD VALUE is not in callee position either, so its read must
// survive even though the tail resolves to a Method — the kind test alone would
// wrongly suppress it.
func MethodValue(i *repository.Impl) func() error {
f := i.DoWork
return f
}
// Control: the ORIGINAL defect. A real method call emits CALLS only; an ACCESSES
// to the same method at the same position is the phantom that must stay gone.
func RealMethodCall(i *repository.Impl) error {
return i.DoWork()
}

View file

@ -0,0 +1,17 @@
package repository
// Thing is an interface-typed dependency, the shape a DI-wired Go service
// stores in a struct field.
type Thing interface {
DoWork() error
}
// Impl is the concrete implementation behind Thing.
type Impl struct{}
func (i *Impl) DoWork() error { return nil }
// CartRepo is a concrete-typed dependency reached through a struct field.
type CartRepo struct{}
func (c *CartRepo) WithTx(tx int) *CartRepo { return c }

View file

@ -133,7 +133,7 @@
},
"php-constructor-promotion-fields/Service.php": {
"captureGroups": 9,
"digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240"
"digest": "f0f326d5b73b7d34e5ff0aacb1cef79389e22402bc9b6590a154a961ea758b6a"
},
"php-constructor-type-inference/app/Models/Repo.php": {
"captureGroups": 7,
@ -161,7 +161,7 @@
},
"php-deep-field-chain/Service.php": {
"captureGroups": 10,
"digest": "fa5a413c11f99286cbc16ad337f7f2a6c3f399bd39c25fea1775c97c4b3c707f"
"digest": "c9944b2f72a722048a39332c77aa9661c7302413d5d13673a529fabe518a25e8"
},
"php-default-params/app.php": {
"captureGroups": 11,
@ -185,7 +185,7 @@
},
"php-field-types/Service.php": {
"captureGroups": 9,
"digest": "1b75dbc0f3123399ba9fd8be0ccac4c145fd1d36baceb62e0d0cc18d4f0a1240"
"digest": "f0f326d5b73b7d34e5ff0aacb1cef79389e22402bc9b6590a154a961ea758b6a"
},
"php-foreach-call-expr/Repo.php": {
"captureGroups": 19,
@ -265,7 +265,7 @@
},
"php-grandparent-resolution/app/Services/App.php": {
"captureGroups": 12,
"digest": "2b4b12fae10aeeb2e4c0987cce02171237b68976731d1b34ac0d74b8d005de53"
"digest": "27cc7ce04ef5628a918e968bd51319888294592c2567093601d8bb6e7f7d4822"
},
"php-grouped-imports/app/Models/Repo.php": {
"captureGroups": 7,

View file

@ -109,7 +109,7 @@
},
"python-chain-call/app.py": {
"captureGroups": 9,
"digest": "63798aa4bc97cd70bac26d971dd8980030a35a6bab5e1f88acff139bd1dcd67b"
"digest": "32a50d4cb43aecefbd97efd812eb1c8d63fc3034fb3c948ca2f4cc9116e11303"
},
"python-chain-call/models/repo.py": {
"captureGroups": 7,
@ -177,7 +177,7 @@
},
"python-constructor-field-receiver/memory_service.py": {
"captureGroups": 59,
"digest": "c46b8b91e9f7a41f8fcdd13a8dbc6132458ea2e4e4d19b852b054ced848e91c4"
"digest": "7fbc07c361997c2e44b401b301f10081345a0bcadf86feae30bc895a1858f5d5"
},
"python-constructor-field-receiver/test_fixture.py": {
"captureGroups": 13,
@ -273,7 +273,7 @@
},
"python-django-app-imports/config/asgi.py": {
"captureGroups": 7,
"digest": "44e7118a9123e137d361f2cde199c1ed653a01503449577f8e260d6670f8c243"
"digest": "c6f0099622890188e8aed9b0cd0631040bc44d7e47a261a1a6ba00a144d3f6d8"
},
"python-django-app-imports/config/settings.py": {
"captureGroups": 21,
@ -285,11 +285,11 @@
},
"python-django-app-imports/config/wsgi.py": {
"captureGroups": 7,
"digest": "32a6daf4cf7ff4938c7077e27f4f911a4630890f72ba4d6c11c25d05fab18f69"
"digest": "fad4cb18554c2433f6a9ce32de800ef22d5e78def2387e30f2eb2ebf97a80312"
},
"python-django-app-imports/manage.py": {
"captureGroups": 11,
"digest": "4c86987fe515c4417d6157dabbed6f0abe50b21f9fe08497a0d2a5e04223b322"
"digest": "49022e52142b861dbeab7f2aac57cf76c2d782d7e37fc37ad2e9e6b3b995d0ff"
},
"python-enumerate-loop/app.py": {
"captureGroups": 27,
@ -309,7 +309,7 @@
},
"python-field-type-disambig/service.py": {
"captureGroups": 7,
"digest": "d406758347cfa612e0a7c69e9613ce19d9b7658744c2a7c8d079182eded41fde"
"digest": "4460b3bf3219f1a7c7633839adfd50a293cc2df6fa0db436358b1c112b12d6b9"
},
"python-field-type-disambig/user.py": {
"captureGroups": 12,
@ -321,7 +321,7 @@
},
"python-field-types/service.py": {
"captureGroups": 7,
"digest": "6022bc25143590d4736a7a470c0df6442c9b1478738b0812d53b038b5843822d"
"digest": "11fce687b8092ea0ebc12348dd7fb314ab95ddf61bae813474ec3994fbb60c52"
},
"python-for-call-expr/main.py": {
"captureGroups": 15,
@ -341,7 +341,7 @@
},
"python-from-module-alias/pkg/app.py": {
"captureGroups": 23,
"digest": "afb50d3e98def472f9215e986dd4f964eedd197a9acfc8048d92b882e8fe868a"
"digest": "eac5246ce801935a97cb9be9f55a3307bd86392bdac8fec8715171485d82a90e"
},
"python-from-module-alias/pkg/models.py": {
"captureGroups": 14,
@ -365,7 +365,7 @@
},
"python-grandparent-resolution/app.py": {
"captureGroups": 9,
"digest": "dd0012c9188d9145bdfa8b74a2c3f213ec658b0efaa0c2ac9a09c1f6f7d6c4d3"
"digest": "e8658e76b1e0e6a4a8847bb60554a102a7b72ef582cf4065a19f5da78a73f568"
},
"python-grandparent-resolution/models/__init__.py": {
"captureGroups": 0,
@ -621,7 +621,7 @@
},
"python-overload-dispatch/service.py": {
"captureGroups": 37,
"digest": "a0b60d32ee7065e8e5b0eb9d1bf180b81485e0899d141542663615b74ef55f95"
"digest": "fd23d8926c3debb335ab3ae7126d4314ec2f5d81f5e81f47c12a5a7d8b25537d"
},
"python-parent-resolution/models/__init__.py": {
"captureGroups": 0,

View file

@ -53,7 +53,7 @@
},
"ruby-chain-call/lib/app.rb": {
"captureGroups": 13,
"digest": "5d99da3bf38492726eb120eca6ed43a5c16fe97de65981f177a62754b077b86a"
"digest": "6822e1574762e81aac1bde8b00e6e91d6b054b7c41c4e78eb866e488378655e5"
},
"ruby-chain-call/lib/repo.rb": {
"captureGroups": 6,
@ -101,7 +101,7 @@
},
"ruby-construction-selector/lib/app.rb": {
"captureGroups": 18,
"digest": "890fe5496c51f93745d1c0b0a9a9dcc402cc46a84709498ce21078f34ca81366"
"digest": "644fe79259b7d9370dc9c4b4229ece41df3278e76055863b052a8792bf437728"
},
"ruby-construction-selector/lib/factory.rb": {
"captureGroups": 25,
@ -133,7 +133,7 @@
},
"ruby-field-type-disambig/service.rb": {
"captureGroups": 9,
"digest": "7a935707c6a6c5a7c72fa377f4d8d2816d9a1bfa502491770512701d0fa27090"
"digest": "e4c2d56d115a996e64143e2a0a51b5e1e9a69686f3eb9096bb72a835bc5d6b9a"
},
"ruby-field-type-disambig/user.rb": {
"captureGroups": 13,
@ -145,7 +145,7 @@
},
"ruby-field-types/service.rb": {
"captureGroups": 9,
"digest": "fe9329f558d73e6dd006085dc8f8aecff3814d7f6712014e21beea0d1bcb87cc"
"digest": "4c48517a1efd7d556ea445d98887178aafc96568874a47bc5889854b52957698"
},
"ruby-for-in-loop/app.rb": {
"captureGroups": 9,
@ -161,7 +161,7 @@
},
"ruby-grandparent-resolution/lib/app.rb": {
"captureGroups": 10,
"digest": "9c684d0b2e8f247a93a3d4ab6db75e4a13f3878a670fbc25f5e19ee7e38099df"
"digest": "d3609320dda5e0ca184f5adb2510babd58c1150e42e3226eceadfcbea3f752c3"
},
"ruby-grandparent-resolution/lib/models/a.rb": {
"captureGroups": 10,
@ -181,7 +181,7 @@
},
"ruby-inline-constructor-receiver/lib/app.rb": {
"captureGroups": 17,
"digest": "41c63faf13560f27f183a49da10b7e64ffa772b30d37611dbdcf083a9ac0efd3"
"digest": "ec09516e939d1283eeb6af78f215391043b93ce829485cf120f77a83628377f3"
},
"ruby-inline-constructor-receiver/lib/svc.rb": {
"captureGroups": 6,

View file

@ -197,7 +197,7 @@
},
"rust-chain-call/src/main.rs": {
"captureGroups": 25,
"digest": "4ec0ae45ee576d539774b3dd6b0cc102665278f86f0290c643c9823d091e3421"
"digest": "b9325f0a7f5398c7695eaac5a8381df3f714b19763487d193cfe136c1d5f1fda"
},
"rust-chain-call/src/models/mod.rs": {
"captureGroups": 5,
@ -269,7 +269,7 @@
},
"rust-deep-field-chain/service.rs": {
"captureGroups": 16,
"digest": "82cd5c09f7db44a7f22697edefc7a66eb2b9d1baabdfd15443e052bdcfaae73a"
"digest": "84b1da3df9c40b1dd044d1cb33b6334ad4d17ab9609e9b1d63dfab4860908be9"
},
"rust-default-constructor/src/main.rs": {
"captureGroups": 36,
@ -381,7 +381,7 @@
},
"rust-field-types/service.rs": {
"captureGroups": 11,
"digest": "d7a51207d8056e862523bae284bdbc3bd0fda03c85374fd71e449148731f9b9f"
"digest": "24a82b599a0ba6a669608ef368f42410cef7b7f42ce7d7927705666895de4386"
},
"rust-for-call-expr/src/main.rs": {
"captureGroups": 26,
@ -617,7 +617,7 @@
},
"rust-nullable-receiver/src/main.rs": {
"captureGroups": 41,
"digest": "ea8a4e442cacf4d5647aa3db5bdd3c2ea5485e8dae72c9a2f9c61eb062a6bb05"
"digest": "10b8b250f5bc19363013357c3ce47e6681f89b5db5d89532ee1ebea6b55a6494"
},
"rust-nullable-receiver/src/repo.rs": {
"captureGroups": 10,
@ -729,7 +729,7 @@
},
"rust-self-struct-literal/models.rs": {
"captureGroups": 32,
"digest": "f9c89cb8c9f2b9c10140ae6971a09756b27112ab61d483b9ffe7fbcb36fefa8d"
"digest": "6745d17a43e50c7dc1cdd985f3f892be5f5116a096bad0db3deb69dde0b4cf94"
},
"rust-self-this-resolution/src/repo.rs": {
"captureGroups": 10,

View file

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

View file

@ -0,0 +1,76 @@
/**
* Shared scope-model builder for scope-resolution unit tests.
*
* `finalizeScopeModel({ hooks: { resolveImportTarget, mergeBindings } })` is
* the incantation a test must perform before it can call any resolution pass,
* and it was being hand-copied per file and, inside a single file, once per
* language. The only thing that ever varied was the resolver, the source and
* the file path, so those are the parameters here.
*
* The model is built from REAL extraction rather than a hand-assembled index
* on purpose: the defects these tests pin are cases where source-level
* intuition about what a binding CONTAINS is wrong (Go normalizes a free
* parameter's `*Host` to `Host` at capture but leaves a method receiver's
* spelled `*Host` intact), so a fixture that asserted the binding shape by
* hand would pin the intuition instead of the code.
*/
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.js';
import {
buildWorkspaceResolutionIndex,
type WorkspaceResolutionIndex,
} from '../../src/core/ingestion/scope-resolution/workspace-index.js';
import type { ScopeResolver } from '../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
export interface ScopeModelFixture {
/** The extracted file for tests that must start a fold in a scope other
* than the module scope, or read the raw scope list. */
readonly parsed: ParsedFile;
readonly scopes: ScopeResolutionIndexes;
readonly index: WorkspaceResolutionIndex;
/** `parsed.referenceSites`: the sites a resolution pass walks. */
readonly sites: ParsedFile['referenceSites'];
/** `parsed.moduleScope`: the default `inScope` for a top-level position. */
readonly moduleScope: ScopeId;
/** Carried so callers can thread the language's own contract hooks
* (`elementTypeOf`, `stripTypePreservingDecoration`, ) without naming the
* resolver a second time. */
readonly resolver: ScopeResolver;
}
/**
* Extract `source` with `resolver`'s provider, run owner population and the
* shared finalize, and return the resolution indexes a pass needs.
*
* Throws rather than asserting, because callers build fixtures at module load
* where a failed `expect` has no test to attach to.
*/
export function buildScopeModel(
resolver: ScopeResolver,
source: string,
filePath: string,
): ScopeModelFixture {
const parsed = extractParsedFile(resolver.languageProvider, source, filePath);
if (parsed === undefined) throw new Error(`scope extraction failed for ${filePath}`);
resolver.populateOwners(parsed);
const parsedFiles: ParsedFile[] = [parsed];
const allFilePaths = new Set(parsedFiles.map((p) => p.filePath));
const scopes = finalizeScopeModel(parsedFiles, {
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
resolver.resolveImportTarget(targetRaw, fromFile, allFilePaths),
mergeBindings: (existing, incoming, scopeId) =>
resolver.mergeBindings(existing, incoming, scopeId),
},
});
return {
parsed,
scopes,
index: buildWorkspaceResolutionIndex(parsedFiles),
sites: parsed.referenceSites,
moduleScope: parsed.moduleScope,
resolver,
};
}

View file

@ -12,6 +12,8 @@ import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from './resolvers/helpers.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
function createTsRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-desc-e2e-'));
@ -32,6 +34,27 @@ function createTsRepo(): string {
return dir;
}
function createSwiftRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-desc-swift-e2e-'));
fs.writeFileSync(
path.join(dir, 'Widget.swift'),
[
'class Widget {',
' /**',
' Renders the widget, marker SWIFTDOC.',
' Example:',
' #if os(iOS)',
' useUIKit()',
' #endif',
' */',
' func render() {}',
'}',
'',
].join('\n'),
);
return dir;
}
describe('doc-comment description end-to-end (issue #2270)', () => {
it('surfaces an exported function JSDoc as its node description through the pipeline', async () => {
const result: PipelineResult = await runPipelineFromRepo(createTsRepo(), () => {}, {
@ -48,4 +71,26 @@ describe('doc-comment description end-to-end (issue #2270)', () => {
expect(result.usedWorkerPool).toBe(true);
expect(descriptions.get('Function:computeBalance')).toContain('EXPORTEDDOC');
});
// Swift preprocessing blanks nested conditional directives before parsing;
// doing that inside a doc comment would delete lines from `description` (#2771).
it.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))(
'keeps directive lines inside a Swift doc comment in the node description',
async () => {
const result: PipelineResult = await runPipelineFromRepo(createSwiftRepo(), () => {}, {
skipGraphPhases: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
});
const descriptions = new Map<string, unknown>();
result.graph.forEachNode((node) => {
descriptions.set(`${node.label}:${node.properties.name}`, node.properties.description);
});
expect(descriptions.get('Function:render')).toBe(
'Renders the widget, marker SWIFTDOC. Example: #if os(iOS) useUIKit() #endif',
);
},
);
});

View file

@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { runGroupImpact } from '../../../src/core/group/cross-impact.js';
import { closeAllCachedBridges, writeBridge } from '../../../src/core/group/bridge-db.js';
import type { GroupToolPort } from '../../../src/core/group/service.js';
import type { CrossLink, StoredContract } from '../../../src/core/group/types.js';
// LadybugDB does not reliably release the writer handle before an immediate
// read-only reopen on Windows; the existing real-bridge tests use the same
// platform guard. Linux CI executes this regression against the real DB.
const itRealBridge = process.platform === 'win32' ? it.skip : it;
describe('manifest-only group impact through a real bridge', () => {
let home: string;
let groupDir: string;
beforeEach(async () => {
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-manifest-impact-lbug-'));
groupDir = path.join(home, 'groups', 'waveful');
await fsp.mkdir(groupDir, { recursive: true });
await fsp.writeFile(
path.join(groupDir, 'group.yaml'),
`version: 1
name: waveful
description: ""
repos:
backend: backend-registry
app: app-registry
links: []
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
'utf8',
);
});
afterEach(async () => {
await closeAllCachedBridges();
await fsp.rm(home, { recursive: true, force: true });
});
itRealBridge(
'round-trips a synthetic uid and reports the boundary without attempting fan-out',
async () => {
const contractId = 'custom::executeAddDynamicLinkMS';
const providerUid = 'Function:src/functions.ts:executeAddDynamicLinkMS';
const syntheticUid = `manifest::app::${contractId}`;
const provider: StoredContract = {
repo: 'backend',
role: 'provider',
contractId,
type: 'custom',
symbolUid: providerUid,
symbolRef: { filePath: 'src/functions.ts', name: 'executeAddDynamicLinkMS' },
symbolName: 'executeAddDynamicLinkMS',
confidence: 1,
meta: {},
};
const consumer: StoredContract = {
repo: 'app',
role: 'consumer',
contractId,
type: 'custom',
symbolUid: syntheticUid,
symbolRef: { filePath: '', name: contractId },
symbolName: contractId,
confidence: 1,
meta: { source: 'manifest' },
};
const link: CrossLink = {
from: { repo: 'app', symbolUid: syntheticUid, symbolRef: consumer.symbolRef },
to: { repo: 'backend', symbolUid: providerUid, symbolRef: provider.symbolRef },
type: 'custom',
contractId,
matchType: 'manifest',
confidence: 1,
};
const report = await writeBridge(groupDir, {
contracts: [provider, consumer],
crossLinks: [link],
repoSnapshots: {},
missingRepos: [],
});
expect(report.linksInserted).toBe(1);
const impactByUid = vi.fn(async () => null);
const port: GroupToolPort = {
resolveRepo: vi.fn(async (name: string) => ({
id: name,
name,
repoPath: name,
storagePath: path.join(home, name),
})),
impact: vi.fn(async () => ({
target: { id: providerUid, filePath: 'src/functions.ts' },
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
})),
impactByUid,
query: vi.fn(),
context: vi.fn(),
};
const result = await runGroupImpact(
{ port, gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
if ('error' in result) throw new Error(result.error);
expect(result.cross).toEqual([
expect.objectContaining({
repo_path: 'app',
contract: expect.objectContaining({ id: contractId, match_type: 'manifest' }),
fanout_status: 'not_attempted',
}),
]);
expect(result.summary.cross_repo_hits).toBe(1);
expect(result.risk).toBe('LOW');
expect(result.truncated).toBe(false);
expect(impactByUid).not.toHaveBeenCalled();
},
);
});

View file

@ -63,6 +63,25 @@ withTestLbugDB(
expect(result.boundaries.join(' ')).toContain('Logger');
});
it('publishes causes.dispatchBoundary as untraced SYMBOLS, not the note count', async () => {
const result = await backend.callTool('impact', {
target: 'EmailLogger',
direction: 'upstream',
});
// One boundary node (Logger) produces exactly one note, so publishing
// `boundaries.length` here would print `1` — the number of SENTENCES —
// next to a `receiverTyping` that counts call sites, and a consumer
// branching on the two would read the smaller cause as the dominant one.
// The magnitude behind this boundary is 2 implementations (EmailLogger,
// FileLogger) + 1 interface-level consumer (SignupController) = 3.
expect(result.boundaries).toHaveLength(1);
expect(result.causes).toMatchObject({
receiverTyping: 0,
dispatchBoundary: 3,
externalBoundary: 0,
});
});
it('flags impact() on the interface itself as lower-bound', async () => {
const result = await backend.callTool('impact', {
target: 'Logger',

View file

@ -1655,3 +1655,142 @@ describe('Go Child embeds Parent — inherited method resolution (SM-9)', () =>
expect(parentMethodCall!.source).toBe('Run');
});
});
// ---------------------------------------------------------------------------
// #2766: pointer-receiver base resolution
// ---------------------------------------------------------------------------
describe('Go pointer-receiver field chains (#2766)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-pointer-receiver-field-chain'),
() => {},
);
}, 60000);
const calls = (): string[] => edgeSet(getRelationships(result, 'CALLS'));
/** ACCESSES rows with each target's KIND appended ` Work` alone cannot
* tell a func-typed field apart from the method that shadowed it. */
const accesses = (): string[] =>
getRelationships(result, 'ACCESSES').map((e) => `${e.source}${e.target}:${e.targetLabel}`);
// The three rows that emitted nothing before the decoration fallback. All
// three have a POINTER receiver, which bound as the literal `*Holder` and
// matched no class, so receiver typing declined at the base.
it('resolves an interface-typed cross-package field through a pointer receiver', () => {
expect(calls()).toContain('RunInterface → DoWork');
});
it('resolves a concrete-typed cross-package field through a pointer receiver', () => {
expect(calls()).toContain('RunConcrete → DoWork');
});
it('resolves a concrete cross-package field returning a value', () => {
expect(calls()).toContain('RunCart → WithTx');
});
// Controls: these resolved BEFORE the fix. R11 requires they still resolve to
// the same target, so a regression here means the fallback moved an edge
// rather than adding one.
it('keeps resolving a local-variable receiver', () => {
expect(calls()).toContain('RunLocal → DoWork');
});
it('keeps resolving a value receiver', () => {
expect(calls()).toContain('RunFromValueReceiver → DoWork');
});
// U8: the same-package field receiver that previously produced an ACCESSES
// edge to the method and no CALLS edge. Typing the base is what emits CALLS;
// the ACCESSES now correctly targets the PROPERTY being read instead.
it('emits CALLS for a same-package field receiver, not ACCESSES alone', () => {
expect(calls()).toContain('RunSamePackage → Work');
});
it('retargets the field ACCESSES to the property, not the method', () => {
// Unlabeled on purpose: the negative must reject `→ Work` under ANY target
// kind, which the kind-qualified rows below cannot express.
const accessEdges = edgeSet(getRelationships(result, 'ACCESSES'));
expect(accessEdges).toContain('RunSamePackage → dep');
expect(accessEdges).not.toContain('RunSamePackage → Work');
});
// The assertion above used to pass by ACCIDENT: a pointer receiver's text
// cascade failed for an unrelated reason, so the phantom never resolved. The
// value-receiver twin proves the rule holds when the lookup SUCCEEDS — before
// the callee read-site was dropped, this emitted `RunFromValueReceiver →
// DoWork` as an ACCESSES edge duplicating its own CALLS edge.
it('emits no method-targeted ACCESSES for a value receiver either', () => {
const accessEdges = edgeSet(getRelationships(result, 'ACCESSES'));
expect(accessEdges).toContain('RunFromValueReceiver → impl');
expect(accessEdges).not.toContain('RunFromValueReceiver → DoWork');
});
// The invariant, stated once rather than per-fixture: a member call whose
// callee resolves to a METHOD must not also emit a field read for it.
// Asserted as the EXACT edge set INCLUDING each target's kind, so the two
// failure directions are both caught on rows nobody wrote a targeted
// assertion for: a new phantom (an ACCESSES to a Method at a call position)
// fails here, and so does a deleted genuine read (a missing ACCESSES to a
// Property). The kinds are load-bearing — `→ Work` alone cannot tell a
// func-typed field apart from the method that shadowed it.
it('emits exactly the expected ACCESSES set, target kinds included', () => {
expect(accesses().sort()).toEqual([
// #2782 review: callee position is a POSITION, not a verdict. Go
// dispatches `c.OnEvent()` through a func-typed struct field with exactly
// the same syntax as a method call, so the capture layer cannot decide
// which it is — `call_expression` looks identical and the tail may be
// declared in another package. Suppressing every callee-position read
// deleted the only ACCESSES evidence for callback structs, hook structs
// and hand-rolled mocks; this row is that evidence.
'CallFuncField → OnEvent:Property',
// A METHOD VALUE resolves to a Method just like the phantom does, and is
// the reason the suppression cannot key on target kind alone.
'MethodValue → DoWork:Method',
// A plain (non-func) field read, never in callee position.
'ReadPlainField → Label:Property',
'RunCart → cart:Property',
'RunConcrete → impl:Property',
'RunFromValueReceiver → impl:Property',
'RunInterface → thing:Property',
'RunSamePackage → dep:Property',
]);
});
it('keeps CALLS for every field-receiver call', () => {
expect(calls()).toContain('RunSamePackage → Work');
expect(calls()).toContain('RunFromValueReceiver → DoWork');
});
// ---------------------------------------------------------------------
// #2782 review: callee position is a POSITION, not a verdict
// ---------------------------------------------------------------------
// That the READS survive is asserted by the exact-set test above, row by
// row. What that set cannot show is that keeping them did not cost the
// CALLS edge those same sites must still emit — which is what remains here.
it('still emits the call through the func-typed field', () => {
expect(calls()).toContain('CallFuncField → OnEvent');
});
// The original defect, on a bare-name receiver whose lookup definitely
// succeeds: CALLS only, and no ACCESSES duplicating it.
it('emits CALLS but no duplicate ACCESSES for a real method call', () => {
expect(calls()).toContain('RealMethodCall → DoWork');
// Unlabeled: the duplicate must be absent under any target kind.
expect(edgeSet(getRelationships(result, 'ACCESSES'))).not.toContain('RealMethodCall → DoWork');
});
// #2782 review finding 2: the fixture spans two packages and imports
// `fixture/repository`, but carried no `go.mod` — so `resolveGoImportTarget`
// matched NEITHER tier (tier 1 needs the module prefix; tier 2's ≥2-segment
// suffix rule cannot match `fixture/repository` against `repository/`) and the
// guard for this PR's headline fix exercised an import path no real Go repo
// takes. With `module fixture` the go.mod tier resolves.
it('resolves the cross-package import through the go.mod tier', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.map((e) => `${e.source}${e.target}`)).toContain('handler.go → repo.go');
});
});

View file

@ -1,6 +1,7 @@
/**
* Shared test helpers for language resolution integration tests.
*/
import fs from 'node:fs';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineOptions } from '../../../src/core/ingestion/pipeline.js';
@ -16,6 +17,23 @@ export const CROSS_FILE_FIXTURES = path.resolve(
'cross-file-binding',
);
/**
* Materialize an in-memory fixture repo under `root`, creating parent
* directories as needed.
*
* Writes in `Object.entries` order, i.e. the literal's own key order some
* callers depend on that (a fixture whose second file must be written after
* the first for the property under test to mean anything), so this must stay
* insertion-ordered rather than sorting or parallelizing the writes.
*/
export function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
export type RelEdge = {
source: string;
target: string;
@ -95,6 +113,28 @@ export function getNodesByLabel(result: PipelineResult, label: string): string[]
return names.sort();
}
/**
* Every node a single fixture file contributed, as sorted `Label:name` and
* `Label:qualifiedName` strings for exact `toEqual` assertions on the whole
* file's output rather than spot checks.
*/
export function getNodesForFile(
result: PipelineResult,
filePathSuffix: string,
): { names: string[]; labelled: string[]; qualified: string[] } {
const names: string[] = [];
const labelled: string[] = [];
const qualified: string[] = [];
result.graph.forEachNode((n) => {
if (!String(n.properties.filePath ?? '').endsWith(filePathSuffix)) return;
const name = n.properties.name;
names.push(name);
labelled.push(`${n.label}:${name}`);
qualified.push(`${n.label}:${String(n.properties.qualifiedName ?? name)}`);
});
return { names: names.sort(), labelled: labelled.sort(), qualified: qualified.sort() };
}
export function edgeSet(edges: Array<{ source: string; target: string }>): string[] {
return edges.map((e) => `${e.source}${e.target}`).sort();
}

View file

@ -1,8 +1,10 @@
/**
* Java: class extends + implements multiple interfaces + ambiguous package disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
@ -10,7 +12,9 @@ import {
getNodesByLabel,
getNodesByLabelFull,
edgeSet,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
@ -3207,3 +3211,70 @@ describe('Java enum-constant receiver dispatch (#2561)', () => {
expect(dispatch!.rel.targetId).toBe('Method:src/Plain.java:Plain.m#0');
});
});
// ---------------------------------------------------------------------------
// Program boundary vs. analysis uncertainty (#2744).
//
// The origin classifier's only source of positive EXTERNAL evidence is
// `LanguageProvider.isBuiltInName`. Java declared no built-in set, so no Java
// drop could ever be judged external and every one of them hedged `impact()`
// down to `epistemic: 'lower-bound'` — safe, but it left the language unable to
// name its own boundary. This exercises the whole wiring end to end (provider →
// `runScopeResolution` → pass options → classifier → drop record), which the
// classifier unit tests deliberately do not.
// ---------------------------------------------------------------------------
describe('Java receiver-unresolved drops name the program boundary (#2744)', () => {
let repoDir: string;
let result: PipelineResult;
beforeAll(async () => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-java-receiver-origin-'));
writeFixtureRepo(repoDir, {
'src/Boundary.java': `public class Boundary {
public void platform(String raw) {
// Rooted at \`System\`, which the language itself names. Nothing was
// lost: no node in this index could have been the target.
System.out.println(raw);
}
public void unknownReceiver(OrderRepository repo) {
// \`OrderRepository\` is declared nowhere here and is not a platform
// name. Absence of evidence is not evidence of externality — this must
// stay hedged, or a genuinely missing in-program caller gets published
// as \`exact\`. Spelled as a CHAIN on purpose: a bare \`repo.findAll()\`
// never reaches the drop recorder at all (the pass takes a different
// arm and records nothing), so it would assert on an empty set.
repo.find().save();
}
}
`,
});
result = await runPipelineFromRepo(repoDir, () => {}, {});
}, 60000);
afterAll(() => {
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
});
it('marks a System.out.println(...) drop external', () => {
const drops = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
expect(drops).toContainEqual(
expect.objectContaining({ name: 'println', siteKind: 'call', receiverOrigin: 'external' }),
);
});
it('does not mark a drop on an undeclared user receiver external', () => {
const drops = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
expect(drops).toContainEqual(
expect.objectContaining({ name: 'save', siteKind: 'call', receiverOrigin: 'unknown' }),
);
expect(drops).not.toContainEqual(
expect.objectContaining({ name: 'save', receiverOrigin: 'external' }),
);
});
});

View file

@ -13,17 +13,10 @@ import {
getNodesByLabelFull,
edgeSet,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
// ---------------------------------------------------------------------------
// Heritage: relative imports + class inheritance
// ---------------------------------------------------------------------------

View file

@ -8,15 +8,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import path from 'path';
import fs from 'node:fs';
import os from 'node:os';
import { getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
describe('TypeScript ESM .js extension → CALLS edges', () => {
let result: PipelineResult;

View file

@ -17,6 +17,7 @@ import os from 'node:os';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
type RelEdge,
} from './helpers.js';
@ -26,14 +27,6 @@ import {
} from '../../../src/core/ingestion/scope-resolution/passes/property-dispatch.js';
import { _captureLogger, type PinoLogRecord } from '../../../src/core/logger.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
function edgesFrom(edges: RelEdge[], sourceFile: string): RelEdge[] {
return edges.filter((c) => c.sourceFilePath === sourceFile);
}

View file

@ -13,17 +13,10 @@ import {
edgeSet,
getResolutionOutcomes,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './helpers.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
}
// ---------------------------------------------------------------------------
// Generic-base heritage (#1951): extends Box<T> already worked (value: identifier
// captures Base; type_args are a sibling), and `implements IFoo<T>` is resolved
@ -3344,12 +3337,17 @@ export class Service {
`,
'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 async function droppedCall(svc): Promise<void> {
// An UNANNOTATED parameter. The chain mints fine, but the base has no type
// binding to resolve against, so the site reaches the drop recorder.
//
// Third fixture for this case: \`!\` served until structural typing resolved
// it, then the await-parenthesized form served until name-free step kinds
// resolved that too. Both were shapes the resolver merely did not SUPPORT
// yet, so each fix moved the goalposts. An untyped receiver carries no type
// information at all, so no amount of resolver work can type it — which is
// what makes it a stable choice rather than the next one to be fixed.
svc.getUser().save();
}
export function droppedWrite(svc: Service | null): void {
@ -3379,6 +3377,24 @@ export function droppedWrite(svc: Service | null): void {
);
expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' }));
});
// The origin travels with the drop too, and its value here is the whole
// point: `svc` is an UNANNOTATED parameter, so the scope model records it
// nowhere — no type binding, no value binding, no qualified name. A
// classifier that read that silence as `external` published
// `epistemic: 'exact'` over a call it had genuinely lost. `unknown` is the
// honest answer and it counts toward the hedge exactly like `in-program`.
it('does not call an untyped in-program receiver external', () => {
const drops = getResolutionOutcomes(result).filter(
(outcome) => outcome.kind === 'suppressed' && outcome.reason === 'receiver-unresolved',
);
expect(drops).toContainEqual(
expect.objectContaining({ name: 'save', siteKind: 'call', receiverOrigin: 'unknown' }),
);
expect(drops).not.toContainEqual(
expect.objectContaining({ name: 'save', receiverOrigin: 'external' }),
);
});
});
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,119 @@
import { afterAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import { getNodesForFile } from './resolvers/helpers.js';
import { preprocessSwiftConditionalDirectives } from '../../src/core/ingestion/languages/swift/conditional-directive-preprocess.js';
const swiftFixture = `class Outer {
enum A { case x }
#if os(iOS)
enum B { case y }
#endif
}
`;
const swiftMultilineStringFixture = `class StringHolder {
let payload = """
#if string-data
#elseif more-string-data
#else
#endif
"""
#if REAL_DIRECTIVE
func afterString() {}
#endif
}
`;
const swiftColumnZeroFixture = `class ColumnZero {
enum A { case x }
#if os(iOS)
enum B { case y }
#endif
}
`;
const swiftHeaderSplitFixture = `class NetworkClient {
#if swift(>=5.5)
func fetch() async {
#else
func fetch() {
#endif
perform()
}
}
struct SessionStore {}
`;
const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift);
const scratchDirs: string[] = [];
/** Analyze a one-file Swift repo through the real worker pool. */
async function runFixture(prefix: string, source: string) {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
scratchDirs.push(repo);
fs.writeFileSync(path.join(repo, 'Fixture.swift'), source, 'utf8');
const result = await runPipelineFromRepo(repo, () => {}, { workerPoolSize: 1 });
return getNodesForFile(result, 'Fixture.swift');
}
describe.skipIf(!swiftAvailable)('Swift conditional-directive pipeline regression', () => {
afterAll(() => {
for (const scratchDir of scratchDirs) fs.rmSync(scratchDir, { recursive: true, force: true });
});
it('keeps Outer and both nested declarations in the real worker pipeline', async () => {
const { names } = await runFixture('gitnexus-swift-directive-', swiftFixture);
expect(names).toEqual(['A', 'B', 'Fixture.swift', 'Outer', 'x', 'y']);
}, 60000);
it('keeps a column-zero directive inside a class body from discarding the class', async () => {
const { names } = await runFixture('gitnexus-swift-column-zero-', swiftColumnZeroFixture);
expect(names).toEqual(['A', 'B', 'ColumnZero', 'Fixture.swift', 'x', 'y']);
}, 60000);
it('keeps later top-level types out of a class whose header is split across branches', async () => {
// Blanking an unbalanced group re-parents unrelated declarations, which
// shows up as a fabricated `NetworkClient.` qualified-name prefix.
expect(preprocessSwiftConditionalDirectives(swiftHeaderSplitFixture)).toBe(
swiftHeaderSplitFixture,
);
const { qualified } = await runFixture('gitnexus-swift-header-split-', swiftHeaderSplitFixture);
expect(qualified).toEqual([
'Class:NetworkClient',
'File:Fixture.swift',
'Function:fetch',
'Function:fetch',
'Struct:SessionStore',
]);
}, 60000);
it('preserves a multiline string property while blanking a real directive between strings', async () => {
const rewritten = preprocessSwiftConditionalDirectives(swiftMultilineStringFixture);
const opening = swiftMultilineStringFixture.indexOf('"""') + 3;
const closing = swiftMultilineStringFixture.indexOf('"""', opening);
expect([opening, closing]).toEqual([40, 105]);
expect(rewritten.slice(opening, closing)).toBe(
swiftMultilineStringFixture.slice(opening, closing),
);
const { labelled } = await runFixture('gitnexus-swift-string-', swiftMultilineStringFixture);
expect(labelled).toEqual([
'Class:StringHolder',
'File:Fixture.swift',
'Function:afterString',
'Property:payload',
]);
}, 60000);
});

View file

@ -1,7 +1,11 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
import {
loadParser,
loadLanguage,
isLanguageAvailable,
} from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
import { getProvider } from '../../src/core/ingestion/languages/index.js';
import Parser from 'tree-sitter';
@ -493,6 +497,178 @@ describe('Tree-sitter multi-language parsing', () => {
expect(defs.length).toBeGreaterThan(0);
});
// Grammar-load failures are expected on the platform-sensitive matrix, so
// skip rather than hard-fail — same guard the sibling tests apply inline.
describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))(
'conditional-compilation directives',
() => {
const provider = getProvider(SupportedLanguages.Swift);
const preprocess = (content: string): string =>
provider.preprocessSource?.(content, 'Fixture.swift') ?? content;
beforeAll(async () => {});
it('captures a class whose body contains indented conditional directives after preprocessing', () => {
const content = [
'class Outer {',
' enum A { case x }',
' #if os(iOS)',
' enum B { case y }',
' #endif',
'}',
].join('\n');
const { tree, matches } = parseAndQuery(
parser,
preprocess(content),
provider.treeSitterQueries,
);
const defs = extractDefinitions(matches);
expect(tree.rootNode.hasError).toBe(false);
expect(defs).toEqual([
{ type: 'definition.class', name: 'Outer' },
{ type: 'definition.enum', name: 'A' },
{ type: 'definition.property', name: 'x' },
{ type: 'definition.enum', name: 'B' },
{ type: 'definition.property', name: 'y' },
]);
});
it('captures a class whose body contains column-zero conditional directives', () => {
const content = [
'class Outer {',
' enum A { case x }',
'#if os(iOS)',
' enum B { case y }',
'#endif',
'}',
].join('\n');
const { tree, matches } = parseAndQuery(
parser,
preprocess(content),
provider.treeSitterQueries,
);
const defs = extractDefinitions(matches);
expect(tree.rootNode.hasError).toBe(false);
expect(defs).toEqual([
{ type: 'definition.class', name: 'Outer' },
{ type: 'definition.enum', name: 'A' },
{ type: 'definition.property', name: 'x' },
{ type: 'definition.enum', name: 'B' },
{ type: 'definition.property', name: 'y' },
]);
});
it('leaves top-level conditional directives intact while capturing their declarations', () => {
const content = [
'#if os(iOS)',
'struct PlatformValue {',
' let value: Int = 1',
'}',
'#else',
'struct PlatformValue {',
' let value: Int = 2',
'}',
'#endif',
].join('\n');
const parseContent = preprocess(content);
const { tree, matches } = parseAndQuery(parser, parseContent, provider.treeSitterQueries);
const defs = extractDefinitions(matches);
expect(tree.rootNode.hasError).toBe(false);
expect(parseContent).toBe(content);
expect(defs).toEqual([
{ type: 'definition.struct', name: 'PlatformValue' },
{ type: 'definition.property', name: 'value' },
{ type: 'definition.struct', name: 'PlatformValue' },
{ type: 'definition.property', name: 'value' },
]);
});
it('keeps source that comments out a conditional block parseable', () => {
const content = [
'class Foo {',
' /* temporarily disabled:',
' #if DEBUG',
' func f() {}',
' #endif */',
' func g() {}',
'}',
].join('\n');
const parseContent = preprocess(content);
const { tree, matches } = parseAndQuery(parser, parseContent, provider.treeSitterQueries);
const defs = extractDefinitions(matches);
// Erasing the comment terminator would swallow `g()` and the rest.
expect(parseContent).toBe(content);
expect(tree.rootNode.hasError).toBe(false);
expect(defs).toEqual([
{ type: 'definition.class', name: 'Foo' },
{ type: 'definition.function', name: 'g' },
]);
});
it('does not re-parent later declarations when branches split a declaration header', () => {
const content = [
'class NetworkClient {',
' #if swift(>=5.5)',
' func fetch() async {',
' #else',
' func fetch() {',
' #endif',
' perform()',
' }',
'}',
'struct SessionStore {}',
'enum Unrelated { case a }',
].join('\n');
const parseContent = preprocess(content);
const { tree } = parseAndQuery(parser, parseContent, provider.treeSitterQueries);
const topLevelTypes = tree.rootNode.namedChildren.map((child) => child.type);
// Blanking both markers would leave `NetworkClient` unterminated and
// collapse every later top-level declaration into it (5 nodes -> 1).
// `struct`/`enum` both surface as `class_declaration` in this grammar.
expect(parseContent).toBe(content);
expect(topLevelTypes).toEqual([
'class_declaration',
'directive',
'function_declaration',
'class_declaration',
'class_declaration',
]);
});
it('keeps a declaration from every branch once the directives are blanked', () => {
const content = [
'class Themed {',
' #if os(iOS)',
' func accent(alpha: Int) {}',
' #else',
' func accent() {}',
' #endif',
'}',
].join('\n');
const { tree, matches } = parseAndQuery(
parser,
preprocess(content),
provider.treeSitterQueries,
);
const defs = extractDefinitions(matches);
// Branch selection is not modelled: mutually exclusive declarations
// both reach the graph. Pinned so changing it shows up as a diff.
expect(tree.rootNode.hasError).toBe(false);
expect(defs).toEqual([
{ type: 'definition.class', name: 'Themed' },
{ type: 'definition.function', name: 'accent' },
{ type: 'definition.function', name: 'accent' },
]);
});
},
);
it('gracefully handles missing tree-sitter-swift', async () => {
// If Swift is NOT available, loadLanguage should throw
// If it IS available, this test just passes

View file

@ -21,7 +21,10 @@ vi.mock('../../src/core/tree-sitter/safe-parse.js', async () => {
return buildSafeParseMock(parseSourceSafeSpy);
});
vi.mock('gitnexus-shared', () => ({
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
// `preprocessSource`, and that graph needs the real shared exports (#2771).
vi.mock('gitnexus-shared', async (importOriginal) => ({
...(await importOriginal<typeof import('gitnexus-shared')>()),
getLanguageFromFilename,
}));

View file

@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 33 (Spring AOP relation pairs, #2416)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 34 (Spring AOP relation pairs #2416, then receiver-chain wire format v2)', () => {
// 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(33);
expect(INCREMENTAL_SCHEMA_VERSION).toBe(34);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@ -194,7 +194,9 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// 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
// A pre-v31 index predates the receiver-chain wire format v2, so its
// persisted chains carry the v1 prefix a v2 decoder refuses by design.
// Original note: 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);
@ -216,7 +218,12 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// A pre-v33 (v32) index predates the Spring AOP Interface→CodeElement
// relation pair (#2416), so it cannot persist all evidence edges.
expect(passesReuseGate(32)).toBe(false);
// A pre-v34 (v33) index carries `receiverChain` strings in wire format v1,
// which the v2 decoder refuses by design (#2766) — an incremental top-up
// would silently fall back to the text cascade for every chain-carrying
// site → must NOT reuse.
expect(passesReuseGate(33)).toBe(false);
// The current stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(33)).toBe(true);
expect(passesReuseGate(34)).toBe(true);
});
});

View file

@ -22,7 +22,10 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
),
}));
vi.mock('gitnexus-shared', () => ({
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
// `preprocessSource`, and that graph needs the real shared exports (#2771).
vi.mock('gitnexus-shared', async (importOriginal) => ({
...(await importOriginal<typeof import('gitnexus-shared')>()),
getLanguageFromFilename,
}));

View file

@ -22,7 +22,10 @@ const { getLanguageFromFilename } = vi.hoisted(() => ({
getLanguageFromFilename: vi.fn().mockReturnValue('typescript'),
}));
vi.mock('gitnexus-shared', () => ({
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
// `preprocessSource`, and that graph needs the real shared exports (#2771).
vi.mock('gitnexus-shared', async (importOriginal) => ({
...(await importOriginal<typeof import('gitnexus-shared')>()),
getLanguageFromFilename,
}));

View file

@ -0,0 +1,323 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { BridgeHandle } from '../../../src/core/group/types.js';
import type { GroupToolPort } from '../../../src/core/group/service.js';
const bridgeHandle = {
_db: {},
_conn: {},
groupDir: '',
_readOnly: true,
} as BridgeHandle;
const bridgeRows = vi.hoisted(() => ({
value: [] as Array<Record<string, unknown>>,
}));
vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/core/group/bridge-db.js')>();
return {
...actual,
readBridgeMeta: vi.fn(async () => ({ version: 1, generatedAt: '', missingRepos: [] })),
getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle),
queryBridge: vi.fn(async () => bridgeRows.value),
closeBridgeDb: vi.fn(async () => undefined),
};
});
const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js');
describe('group impact through manifest-only endpoints', () => {
let home: string;
beforeEach(async () => {
bridgeRows.value = [
{
neighborRepo: 'app',
neighborUid: 'manifest::app::custom::executeAddDynamicLinkMS',
neighborFilePath: '',
matchType: 'manifest',
confidence: 1,
contractId: 'custom::executeAddDynamicLinkMS',
contractType: 'custom',
},
];
home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-manifest-impact-'));
const groupDir = path.join(home, 'groups', 'waveful');
await fsp.mkdir(groupDir, { recursive: true });
await fsp.writeFile(
path.join(groupDir, 'group.yaml'),
`version: 1
name: waveful
description: ""
repos:
backend: backend-registry
app: app-registry
links: []
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
'utf8',
);
await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), '');
});
afterEach(async () => {
await fsp.rm(home, { recursive: true, force: true });
vi.restoreAllMocks();
});
function makePort(impactByUid: GroupToolPort['impactByUid']): GroupToolPort {
return {
resolveRepo: vi.fn(async (name: string) => ({
id: name,
name,
repoPath: name,
storagePath: path.join(home, name),
})),
impact: vi.fn(async () => ({
target: {
id: 'Function:src/functions.ts:executeAddDynamicLinkMS',
filePath: 'src/functions.ts',
},
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
})),
impactByUid,
query: vi.fn(),
context: vi.fn(),
};
}
it('reports a proven manifest crossing when the far endpoint has only a synthetic UID', async () => {
const impactByUid = vi.fn(async () => null);
const resolveRepo = vi.fn(async (name: string) => ({
id: name,
name,
repoPath: name,
storagePath: path.join(home, name),
}));
const port: GroupToolPort = {
resolveRepo,
impact: vi.fn(async () => ({
target: {
id: 'Function:src/functions.ts:executeAddDynamicLinkMS',
filePath: 'src/functions.ts',
},
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
})),
impactByUid,
query: vi.fn(),
context: vi.fn(),
};
const result = await runGroupImpact(
{ port, gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.summary.cross_repo_hits).toBe(1);
expect(result.cross).toEqual([
expect.objectContaining({
repo: 'app-registry',
repo_path: 'app',
contract: expect.objectContaining({
id: 'custom::executeAddDynamicLinkMS',
match_type: 'manifest',
confidence: 1,
}),
by_depth: {},
affected_processes: [],
fanout_status: 'not_attempted',
}),
]);
expect(result.truncated).toBe(false);
expect(result.risk).toBe('LOW');
expect(resolveRepo).toHaveBeenCalledWith('app-registry');
expect(impactByUid).not.toHaveBeenCalled();
});
it('keeps a boundary-only crossing visible when its service scope is unknown', async () => {
const impactByUid = vi.fn(async () => null);
const result = await runGroupImpact(
{ port: makePort(impactByUid), gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
service: 'src',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.summary.cross_repo_hits).toBe(1);
expect(result.cross[0]).toMatchObject({
repo_path: 'app',
fanout_status: 'not_attempted',
});
expect(result.risk).toBe('LOW');
expect(impactByUid).not.toHaveBeenCalled();
});
it('labels a synthetic endpoint as manifest even when exact matching emitted the link', async () => {
bridgeRows.value[0].matchType = 'exact';
const result = await runGroupImpact(
{ port: makePort(vi.fn(async () => null)), gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.cross[0].contract.match_type).toBe('manifest');
expect(result.cross[0].fanout_status).toBe('not_attempted');
});
it('prefers completed fan-out over a duplicate manifest-only boundary', async () => {
bridgeRows.value.push({
neighborRepo: 'app',
neighborUid: 'Function:src/handler.ts:executeAddDynamicLinkMS',
neighborFilePath: 'src/handler.ts',
matchType: 'exact',
confidence: 1,
contractId: 'custom::executeAddDynamicLinkMS',
contractType: 'custom',
});
const impactByUid = vi.fn(async () => ({
byDepth: {
1: [{ id: 'Function:src/caller.ts:callDynamicLink' }],
},
affected_processes: [{ name: 'dynamic-link-flow' }],
}));
const result = await runGroupImpact(
{ port: makePort(impactByUid), gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(impactByUid).toHaveBeenCalledTimes(1);
expect(result.cross).toHaveLength(1);
expect(result.cross[0]).toMatchObject({
repo_path: 'app',
contract: { id: 'custom::executeAddDynamicLinkMS', match_type: 'exact' },
by_depth: {
1: [{ id: 'Function:src/caller.ts:callDynamicLink' }],
},
affected_processes: ['dynamic-link-flow'],
});
expect(result.cross[0].fanout_status).toBeUndefined();
expect(result.risk).toBe('HIGH');
});
it('does not escalate risk from multiple boundary-only crossings', async () => {
bridgeRows.value = [1, 2, 3].map((suffix) => ({
neighborRepo: 'app',
neighborUid: `manifest::app::custom::dynamic-${suffix}`,
neighborFilePath: '',
matchType: 'manifest',
confidence: 1,
contractId: `custom::dynamic-${suffix}`,
contractType: 'custom',
}));
const result = await runGroupImpact(
{ port: makePort(vi.fn(async () => null)), gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.cross).toHaveLength(3);
expect(result.cross.every((entry) => entry.fanout_status === 'not_attempted')).toBe(true);
expect(result.risk).toBe('LOW');
});
it('keeps an unavailable synthetic neighbor truncated instead of reporting a hit', async () => {
const impactByUid = vi.fn(async () => null);
const port: GroupToolPort = {
resolveRepo: vi.fn(async (name) => {
if (name === 'app-registry') throw new Error('repository unavailable');
return {
id: name,
name,
repoPath: name,
storagePath: path.join(home, name),
};
}),
impact: vi.fn(async () => ({
target: {
id: 'Function:src/functions.ts:executeAddDynamicLinkMS',
filePath: 'src/functions.ts',
},
byDepth: {},
summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
risk: 'LOW',
})),
impactByUid,
query: vi.fn(),
context: vi.fn(),
};
const result = await runGroupImpact(
{ port, gitnexusDir: home },
{
name: 'waveful',
repo: 'backend',
target: 'executeAddDynamicLinkMS',
direction: 'upstream',
},
);
expect('error' in result).toBe(false);
if ('error' in result) return;
expect(result.cross).toEqual([]);
expect(result.summary.cross_repo_hits).toBe(0);
expect(result.truncated).toBe(true);
expect(result.truncatedRepos).toEqual(['app']);
expect(impactByUid).not.toHaveBeenCalled();
});
});

View file

@ -101,9 +101,22 @@ describe('fileContentHash', () => {
});
describe('PARSE_CACHE_VERSION', () => {
// 36 -> 37 for Java/Kotlin Spring AOP capture side-channels (#2416).
it('pins SCHEMA_BUMP to 37 so concurrent bumps cannot silently collide (#2416)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(37);
// 35 -> 36 for the bound-callable start-line join (#2735), 36 -> 37 for
// Java/Kotlin Spring AOP capture side-channels (#2416), 37 -> 38 for the Swift
// conditional-directive parse-semantics change (#2771), 38 -> 39 for
// receiver-chain wire format v2: every persisted chain string changed prefix
// and a v2 decoder refuses v1 by design, so a stale cache replays chains this
// build silently discards.
//
// This pin has now earned its keep EIGHT times, and twice it caught an EXACT
// clash rather than a near-miss: main took 37 for #2416 while this branch
// already used 37, and then took 38 for #2771 after this branch had moved to
// 38. Both times two incompatible schemas claimed one number. Note when the
// second clash was caught — after review, while the branch sat waiting to
// merge — which is precisely the window in which `main` allocates. Re-check
// against origin/main immediately before merge, not at review time.
it('pins SCHEMA_BUMP to 39 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(39);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {

View file

@ -71,17 +71,66 @@ describe('isBuiltInOrNoise (per-language)', () => {
});
describe('languages without builtInNames', () => {
it('Java has no language-specific noise', () => {
expect(isBuiltIn('System', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('println', SupportedLanguages.Java)).toBe(false);
});
it('Go has no language-specific noise', () => {
expect(isBuiltIn('fmt', SupportedLanguages.Go)).toBe(false);
expect(isBuiltIn('Println', SupportedLanguages.Go)).toBe(false);
});
});
// Java's set exists so `classifyReceiverOrigin` can name the program boundary
// (#2744): without it no Java drop could ever be judged `external` and every
// one of them hedged `impact()` to `lower-bound`. It lists TYPE names only —
// the shape a receiver base actually has — never method names.
describe('Java platform types (#2744)', () => {
it('names the java.lang types that appear unqualified as receiver bases', () => {
expect(isBuiltIn('System', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('String', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Integer', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Math', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Thread', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('StringBuilder', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('RuntimeException', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Object', SupportedLanguages.Java)).toBe(true);
});
it('names the java.util utility holders whose imports never resolve in-workspace', () => {
expect(isBuiltIn('Optional', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('List', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Arrays', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Collections', SupportedLanguages.Java)).toBe(true);
expect(isBuiltIn('Objects', SupportedLanguages.Java)).toBe(true);
});
// The asymmetry that governs the set: an entry here can NEVER be reported
// as in-program, so every name an application plausibly declares itself
// stays out. Missing one only costs a hedge.
it('omits platform names that double as ordinary domain nouns', () => {
expect(isBuiltIn('Map', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Set', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Collection', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Stream', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Record', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Error', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('Number', SupportedLanguages.Java)).toBe(false);
});
// The same hook gates `type-env.ts` return-type inference and the #2545
// free-call shadow guard, both keyed on the CALLEE name. Java method names
// are camelCase and collide with user code, so none are listed.
it('lists no method names, so Java callee resolution is untouched', () => {
expect(isBuiltIn('println', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('format', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('toString', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('run', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('get', SupportedLanguages.Java)).toBe(false);
});
it('does not name user-defined types', () => {
expect(isBuiltIn('UserService', SupportedLanguages.Java)).toBe(false);
expect(isBuiltIn('OrderRepository', SupportedLanguages.Java)).toBe(false);
});
});
describe('domain names not filtered', () => {
it('does not filter arbitrary names', () => {
expect(isBuiltIn('processOrder', SupportedLanguages.TypeScript)).toBe(false);

View file

@ -523,12 +523,12 @@ describe('parsedfile-store receiverChain sanitation', () => {
const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-chain-'));
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }),
makeStoreEntry('x.ts', { referenceSites: [siteWith('2|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',
receiverChain: '2|svc|cgetUser',
});
} finally {
await rm(dir, { recursive: true, force: true });
@ -551,8 +551,9 @@ describe('parsedfile-store receiverChain sanitation', () => {
it.each([
['malformed', 'not-a-chain'],
['wrong version', '2|svc|cgetUser'],
['over depth', '1|svc|ca|cb|cc|cd'],
['unknown future version', '3|svc|cgetUser'],
['superseded v1 payload', '1|svc|cgetUser'],
['over depth', '2|svc|ca|cb|cc|cd'],
['non-string', 42],
])(
'strips a %s chain but KEEPS the site — it still resolves via the text cascade',
@ -577,7 +578,7 @@ describe('parsedfile-store receiverChain sanitation', () => {
try {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('garbage.ts', { referenceSites: 'nonsense' }),
makeStoreEntry('ok.ts', { referenceSites: [siteWith('1|svc|cgetUser')] }),
makeStoreEntry('ok.ts', { referenceSites: [siteWith('2|svc|cgetUser')] }),
]);
const loaded = await loadParsedFilesForPaths(dir, new Set(['garbage.ts', 'ok.ts']));
expect(loaded.has('garbage.ts')).toBe(false);
@ -596,17 +597,17 @@ describe('parsedfile-store receiverChain sanitation', () => {
await persistParsedFileChunk(dir, 'chunk-0', [
makeStoreEntry('x.ts', {
referenceSites: [
siteWith('1|svc|cgetUser'),
siteWith('2|svc|cgetUser'),
siteWith('not-a-chain'),
siteWith('1|other|ffield'),
siteWith('2|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[0]).toMatchObject({ receiverChain: '2|svc|cgetUser' });
expect(loaded.referenceSites[1]).not.toHaveProperty('receiverChain');
expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '1|other|ffield' });
expect(loaded.referenceSites[2]).toMatchObject({ receiverChain: '2|other|ffield' });
} finally {
await rm(dir, { recursive: true, force: true });
}

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import { providers, getProvider } from '../../src/core/ingestion/languages/index.js';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { ensureAndParse } from '../../src/core/embeddings/ast-utils.js';
/**
* Every provider that defines `preprocessSource` must produce the same
* `ParsedFile` whether it is handed raw source or already-preprocessed source.
*
* The parse worker applies the hook, but `emitScopeCaptures` re-parses on a
* parse-cache miss and the embedding pipeline parses independently so unless
* those paths see the same transform the halves of the pipeline analyze
* different programs and the graph depends on whether the run was warm (#2771).
*
* Fixtures are keyed by language and cross-checked against the registry, so a
* new provider adopting the hook fails here until it adds one.
*/
const FIXTURES: Partial<Record<SupportedLanguages, { filePath: string; source: string }>> = {
[SupportedLanguages.Swift]: {
filePath: 'Fixture.swift',
source: [
'class Outer {',
' enum A { case x }',
' #if os(iOS)',
' enum B { case y }',
' #endif',
'}',
'',
].join('\n'),
},
[SupportedLanguages.CPlusPlus]: {
filePath: 'Actor.cpp',
source: [
'UCLASS()',
'class MYGAME_API AGameActor : public AActor {',
' GENERATED_BODY()',
'public:',
' UPROPERTY(EditAnywhere) int Health;',
' UFUNCTION(BlueprintCallable) void Tick(float DeltaTime) { Health = 1; }',
'};',
'',
].join('\n'),
},
[SupportedLanguages.Dart]: {
filePath: 'meters.dart',
source: ['extension type Meters(int value) {', ' int get raw => value;', '}', ''].join('\n'),
},
};
const languagesWithHook = Object.entries(providers)
.filter(([, provider]) => provider.preprocessSource !== undefined)
.map(([language]) => language)
.sort();
describe('LanguageProvider.preprocessSource parity', () => {
it('has a fixture for every provider defining the hook', () => {
expect(Object.keys(FIXTURES).sort()).toEqual(languagesWithHook);
});
describe.each(languagesWithHook)('%s', (language) => {
const provider = getProvider(language as SupportedLanguages);
const { filePath, source } = FIXTURES[language as SupportedLanguages]!;
describe.skipIf(!isLanguageAvailable(language as SupportedLanguages))(
'with the grammar',
() => {
it('extracts the same ParsedFile from raw and preprocessed source', () => {
const preprocessed = provider.preprocessSource!(source, filePath);
expect(preprocessed).not.toBe(source);
expect(preprocessed).toHaveLength(source.length);
expect(extractParsedFile(provider, source, filePath, () => {})).toEqual(
extractParsedFile(provider, preprocessed, filePath, () => {}),
);
});
it('parses the preprocessed text on the embedding path too', async () => {
const tree = await ensureAndParse(source, filePath);
expect(tree.rootNode.hasError).toBe(false);
});
},
);
});
});

View file

@ -20,7 +20,7 @@ describe('receiver-chain codec', () => {
{ kind: 'call', name: 'getUser' },
{ kind: 'field', name: 'address' },
]);
expect(encoded).toBe('1|svc|cgetUser|faddress');
expect(encoded).toBe('2|svc|cgetUser|faddress');
expect(decodeReceiverChain(encoded)).toEqual({
baseReceiverName: 'svc',
steps: [
@ -60,7 +60,7 @@ describe('receiver-chain codec', () => {
const encoded = encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }], {
truncated: true,
});
expect(encoded).toBe('1|svc|cgetUser|~');
expect(encoded).toBe('2|svc|cgetUser|~');
expect(decodeReceiverChain(encoded)).toMatchObject({ truncated: true });
});
@ -92,13 +92,14 @@ describe('receiver-chain codec', () => {
['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'],
['unknown future version', '3|svc|cgetUser'],
['superseded v1 payload', '1|svc|cgetUser'],
['no steps', '2|svc'],
['unknown kind sigil', '2|svc|xgetUser'],
['empty step name', '2|svc|c'],
['empty base', '1||cgetUser'],
['over depth', '1|svc|ca|cb|cc|cd'],
['truncation marker only', '1|svc|~'],
['over depth', '2|svc|ca|cb|cc|cd'],
['truncation marker only', '2|svc|~'],
])('decodes %s as undefined rather than throwing', (_label, payload) => {
expect(decodeReceiverChain(payload)).toBeUndefined();
expect(isValidReceiverChain(payload)).toBe(false);
@ -139,9 +140,9 @@ describe('receiver-chain codec', () => {
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)}`,
'2|svc|c\u0000name',
'2|svc|c\uD800',
`2|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`,
'1|'.repeat(300),
{},
[],
@ -155,6 +156,67 @@ describe('receiver-chain codec', () => {
// 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);
expect(isValidReceiverChain(`2|svc|c${'x'.repeat(MAX_RECEIVER_CHAIN_BYTES)}`)).toBe(false);
});
});
describe('codec v2 — name-free step kinds', () => {
it('round-trips an await step', () => {
const encoded = encodeReceiverChain('svc', [
{ kind: 'call', name: 'getUserAsync' },
{ kind: 'await' },
]);
expect(encoded).toBe('2|svc|cgetUserAsync|a');
expect(decodeReceiverChain(encoded)).toMatchObject({
baseReceiverName: 'svc',
steps: [{ kind: 'call', name: 'getUserAsync' }, { kind: 'await' }],
truncated: false,
});
});
it('round-trips an index step', () => {
const encoded = encodeReceiverChain('repos', [{ kind: 'index' }]);
expect(encoded).toBe('2|repos|i');
expect(decodeReceiverChain(encoded)).toMatchObject({
baseReceiverName: 'repos',
steps: [{ kind: 'index' }],
});
});
it('decodes a chain mixing named and name-free steps', () => {
const encoded = encodeReceiverChain('svc', [
{ kind: 'index' },
{ kind: 'field', name: 'address' },
]);
expect(decodeReceiverChain(encoded)).toMatchObject({
steps: [{ kind: 'index' }, { kind: 'field', name: 'address' }],
});
});
// The guard that keeps the name-free exemption from widening: a name-free
// sigil must be EXACTLY the sigil, so a corrupt payload cannot smuggle a
// trailing tail through the branch that skips the non-empty-name check.
it('refuses a name-free sigil carrying a trailing tail', () => {
expect(decodeReceiverChain('2|svc|await')).toBeUndefined();
expect(decodeReceiverChain('2|repos|i0')).toBeUndefined();
});
// An empty-name call or field is still malformed — it does NOT become an
// await or index just because those kinds are name-free.
it('still refuses an empty-name call or field segment', () => {
expect(decodeReceiverChain('2|svc|c')).toBeUndefined();
expect(decodeReceiverChain('2|svc|f')).toBeUndefined();
});
// The whole reason the version moved: a v1 payload decoded under v2 rules
// would be a chain missing whichever hop v1 could not express, which reads as
// a complete-but-different chain and types the receiver against the wrong
// member. Refusing is the correct, lossy-but-safe outcome.
it('refuses a v1 payload outright', () => {
expect(decodeReceiverChain('1|svc|cgetUser|faddress')).toBeUndefined();
});
it('emits the v2 prefix for an ordinary named chain', () => {
expect(encodeReceiverChain('svc', [{ kind: 'call', name: 'getUser' }])).toBe('2|svc|cgetUser');
});
});

View file

@ -560,3 +560,57 @@ describe('emitCppScopeCaptures — callable-flow passing modes (#2522 review, M5
});
});
});
/**
* `TypeRef.declaredSpelling` for a C++ pointer parameter.
*
* tree-sitter-cpp hangs the `*` on the DECLARATOR, so `@type-binding.type` is a
* bare `User` and the binding records `User` indistinguishable from a class
* the source subscripted through `operator[]`. The receiver-chain fold declines
* an index step without container evidence, so `repos[0].save()` depends
* entirely on the spelling being reconstructed here.
*/
describe('C++ pointer parameter — declared spelling', () => {
function bindingsFor(src: string): Record<string, { raw: string; spelling: string | undefined }> {
const parsed = extractParsedFile(cppProvider, src, 'test.cpp');
const out: Record<string, { raw: string; spelling: string | undefined }> = {};
for (const scope of parsed?.scopes ?? []) {
for (const [name, ref] of scope.typeBindings) {
out[name] = { raw: ref.rawName, spelling: ref.declaredSpelling };
}
}
return out;
}
it('reconstructs `User*` for a pointer parameter and leaves a value parameter alone', () => {
expect(bindingsFor('struct User {};\nvoid f(User* repos, User one) {}\n')).toMatchObject({
repos: { raw: 'User', spelling: 'User*' },
// Nothing was normalized away, so there is no spelling to keep — and an
// index step on it correctly finds no container evidence.
one: { raw: 'User', spelling: undefined },
});
});
it('reconstructs the same spelling regardless of where the star is written', () => {
expect(bindingsFor('struct User {};\nvoid f(User *repos) {}\n')).toMatchObject({
repos: { raw: 'User', spelling: 'User*' },
});
});
it('reconstructs NOTHING for shapes the exact match rejects', () => {
// Each of these is a real pointer-ish declaration whose element type is NOT
// the captured type: a reference is not a container at all, and `const T*`
// is not the shape being matched. A loose match would claim container
// evidence and re-mint the wrong edge.
const bindings = bindingsFor(
'struct User {};\nvoid f(User** grid, User& one, const User* ro, User (*fn)(int)) {}\n',
);
expect(bindings).toMatchObject({
one: { spelling: undefined },
ro: { spelling: undefined },
});
// `User**` matches no type-binding pattern at all, so there is no binding to
// carry evidence — the same safe outcome by a different route.
expect(bindings).not.toHaveProperty('grid');
});
});

View file

@ -63,6 +63,41 @@ func main() {
expect(tags).toContain('@reference.write');
});
// #2782: a selector in CALLEE position is MARKED, not dropped. The capture
// layer cannot tell `h.dep.Work()` dispatching through a method from the same
// syntax dispatching through a `Work func() error` struct field — only the
// resolved tail's kind can, so the position is recorded and edge emission
// decides. Dropping the site here deleted the func-typed field's only read.
it('marks a member call callee as callee-position instead of dropping the read', () => {
const src = `
package main
type Dep struct{ Work func() error }
type Host struct{ dep *Dep }
func (h *Host) Run() error {
f := h.dep.Work
_ = f
return h.dep.Work()
}
`;
const reads = emitGoScopeCaptures(src, 'main.go')
.filter((m) => m['@reference.read'] !== undefined)
.map((m) => ({
text: m['@reference.read']!.text,
calleePosition: m['@reference.callee-position'] !== undefined,
}));
// Four reads, in source order: the method VALUE's inner `h.dep` and outer
// `h.dep.Work` (neither in callee position), then the CALL's inner `h.dep`
// and its outer `h.dep.Work` — the only one in callee position.
expect(reads).toEqual([
{ text: 'h.dep', calleePosition: false },
{ text: 'h.dep.Work', calleePosition: false },
{ text: 'h.dep', calleePosition: false },
{ text: 'h.dep.Work', calleePosition: true },
]);
});
it('emits every name from multi-name const, var, and field declarations', () => {
const src = `
package main

View file

@ -7,13 +7,12 @@
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import type { ScopeId } from 'gitnexus-shared';
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 { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js';
import { foldReceiverChain } from '../../../src/core/ingestion/scope-resolution/passes/compound-receiver.js';
import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js';
import { buildScopeModel, type ScopeModelFixture } from '../../helpers/scope-model.js';
const SOURCE = `export class Address {
save(): void {}
@ -30,10 +29,23 @@ export class Base {
}
}
export class Item {
run(): void {}
}
export class Service extends Base {
getUser(): User {
return new User();
}
getUserAsync(): Promise<User> {
return Promise.resolve(new User());
}
getItems(): Item[] {
return [];
}
getMap(): Map<string, Item> {
return new Map();
}
}
// A class with NO member \`save\`, whose FIELD's type has one. The field
@ -42,88 +54,101 @@ 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 };
// NOT a container: an ordinary class that happens to be subscriptable, with a
// member whose name COLLIDES with the element type's. Indexing it yields an
// \`Item\`, so \`grid[0].run()\` is \`Item.run\` — but an index step that folded
// on identity stayed on \`Grid\` and found \`Grid.run\` instead.
export class Grid {
run(): void {}
[i: number]: Item;
}
const ctx = build();
// Not subscriptable at all, and shares the member name too.
export class Plain {
run(): void {}
}
/** 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 svc: Service = new Service();
const holder: Holder = new Holder();
declare const grid: Grid;
declare const plain: Plain;
declare const repos: User[];
declare const nested: User[][];
declare const byId: Record<string, Item>;
`;
/** A built scope model plus the scope a fold starts in. */
interface FoldContext {
readonly fixture: ScopeModelFixture;
readonly inScope: ScopeId;
}
/** `foldReceiverChain`'s options bag. The interface itself is module-private
* in `compound-receiver`, so it is read off the function rather than
* re-declared (which would let the two drift). */
type FoldOptions = NonNullable<Parameters<typeof foldReceiverChain>[4]>;
const tsFixture = buildScopeModel(typescriptScopeResolver, SOURCE, 'main.ts');
const ctx: FoldContext = { fixture: tsFixture, inScope: tsFixture.moduleScope };
/**
* Fold `encoded` in `ctx`, under 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; `elementTypeOf` is what an index step consults, and passing it here is
* what makes these tests measure the same fold `emitReceiverBoundCalls` runs.
*
* `overrides` replaces individual flags used to exercise the OFF branch of
* `hoistTypeBindingsToModule` (which six wired languages: c, cobol, dart,
* python, ruby, swift, actually run) and the no-`elementTypeOf` branch (which
* twelve of the fourteen do).
*/
function foldIn(ctx: FoldContext, encoded: string, overrides: FoldOptions = {}) {
const decoded = decodeReceiverChain(encoded);
expect(decoded).toBeDefined();
return foldReceiverChain(decoded!, ctx.inScope, ctx.scopes, ctx.index, {
return foldReceiverChain(decoded!, ctx.inScope, ctx.fixture.scopes, ctx.fixture.index, {
fieldFallback: false,
hoistTypeBindingsToModule: true,
elementTypeOf: ctx.fixture.resolver.elementTypeOf,
...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,
});
}
const fold = (encoded: string) => foldIn(ctx, encoded);
describe('foldReceiverChain', () => {
it('types a single call step through the method return type', () => {
expect(fold('1|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
expect(fold('2|svc|cgetUser')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
});
it('types a mixed call/field chain, base-first', () => {
expect(fold('1|svc|cgetUser|faddress')).toMatchObject({
expect(fold('2|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' });
expect(fold('2|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();
expect(fold('2|svc|cgetUser|fnoSuchField')).toBeUndefined();
});
it('returns undefined when the base does not resolve', () => {
expect(fold('1|noSuchLocal|cgetUser')).toBeUndefined();
expect(fold('2|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();
expect(fold('2|holder|csave')).toBeUndefined();
});
it('declines a truncated chain even though the producer refuses to mint one', () => {
expect(fold('1|svc|cgetUser|~')).toBeUndefined();
expect(fold('2|svc|cgetUser|~')).toBeUndefined();
});
it('declines a construction-selector step and leaves it to the cascade', () => {
@ -133,15 +158,7 @@ describe('foldReceiverChain', () => {
// 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();
expect(foldIn(ctx, '2|svc|cnew', { constructionSyntax: { selector: 'new' } })).toBeUndefined();
});
it('does NOT climb to module scope when hoistTypeBindingsToModule is off', () => {
@ -151,9 +168,165 @@ describe('foldReceiverChain', () => {
// 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({
expect(foldIn(ctx, '2|svc|cgetUser', { hoistTypeBindingsToModule: true })).toMatchObject({
qualifiedName: 'User',
});
expect(foldWith('1|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined();
expect(foldIn(ctx, '2|svc|cgetUser', { hoistTypeBindingsToModule: false })).toBeUndefined();
});
});
/**
* The `index` step. Every case here would have folded onto the CONTAINER before
* the step demanded positive evidence `rawName` is `User` for both
* `repos: User[]` and `grid: Grid`, so identity could not tell an element from
* the thing that holds it.
*/
describe('foldReceiverChain — index step', () => {
it('declines a subscript on a non-container class whose member name collides with the element type', () => {
// `Grid` declares `[i: number]: Item` AND its own `run`. Identity kept the
// fold on `Grid`, so `grid[0].run()` emitted `Grid.run`; the element's owner
// is `Item`. The element type of a TypeScript index signature is not
// recorded anywhere the fold can read, so declining is the only sound
// answer — and it must never be the container.
expect(fold('2|grid|i')).toBeUndefined();
});
it('declines a subscript on a class that is not subscriptable at all', () => {
// `plain: Plain` reduces to nothing — `Plain` IS the written spelling — so
// there is no container evidence and `plain[0]` types to nothing. Identity
// answered `Plain`, which then owned every member looked up after it.
expect(fold('2|plain|i')).toBeUndefined();
});
it('resolves a genuine container whose spelling capture already reduced away', () => {
// `repos: User[]` binds to the bare `User`; only `declaredSpelling` still
// says `User[]`. This is the shape the feature exists for.
expect(fold('2|repos|i')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
});
it('resolves a container spelling capture left intact', () => {
// A multi-arg generic survives TypeScript's capture-time normalization, so
// `rawName` IS the container here and the hook unwraps it to the value type.
expect(fold('2|byId|i')).toMatchObject({ qualifiedName: 'Item', type: 'Class' });
});
it('declines a nested container: ONE subscript leaves a container, not an element', () => {
// `nested: User[][]` reduces to the SAME `User` a single-level `User[]`
// produces — the strip loop runs to a fixed point. So one index step leaves
// `User[]`, and `nested[0].map(...)` is `Array.map`, never `User.map`.
// Identity answered `User` and handed the next member to the wrong owner.
expect(fold('2|nested|i')).toBeUndefined();
});
it('declines when the language answers no index route at all', () => {
// Twelve of the fourteen wired languages leave `elementTypeOf` undefined.
// They used to get identity — every index step in every one of them folded
// onto the container. Answering the route is now the price of index folding.
expect(foldIn(ctx, '2|repos|i', { elementTypeOf: undefined })).toBeUndefined();
});
it('declines when the hook names an element that binds to no class', () => {
expect(
foldIn(ctx, '2|repos|i', { elementTypeOf: () => 'NoSuchClassAnywhere' }),
).toBeUndefined();
});
it('declines an index step on a position that carries no declared type', () => {
// A static class-name base (`Service`) resolves to a def with NO type
// binding behind it, so there is no spelling to hand the hook.
expect(fold('2|Service|i')).toBeUndefined();
});
it('unwraps a container returned by a method, through the hoisted binding', () => {
// `getItems(): Item[]` — the return-type binding reduces to `Item` and only
// the spelling remembers `Item[]`. TypeScript hoists it out of the class
// body, so this also pins that `typeOfMemberOnClass` carries the spelling
// along the hoisted branch.
expect(fold('2|svc|cgetItems|i')).toMatchObject({ qualifiedName: 'Item', type: 'Class' });
});
it('treats await as identity, and does not require container evidence for it', () => {
// `getUserAsync(): Promise<User>` reduces to `User` at capture, and awaiting
// a non-thenable yields the value itself — both regimes agree, which is
// exactly why `await` may stay identity where `index` may not.
expect(fold('2|svc|cgetUserAsync|a')).toMatchObject({ qualifiedName: 'User', type: 'Class' });
});
});
/**
* P3: the hoisted branch of `typeOfMemberOnClass` returned `undefined` when a
* member's declared type named no class, while the primary branch deliberately
* carried the declared type forward for exactly that case. Ten languages set
* `hoistTypeBindingsToModule`, so the two branches disagreed about the same
* declared type depending only on where the binding happened to live.
*/
describe('foldReceiverChain — a member whose declared type names no class', () => {
it('unwraps it identically whether the binding is reached by base lookup or by the hoisted walk', () => {
// `Map<string, Item>` names no workspace class on either route.
expect(fold('2|byId|i')).toMatchObject({ qualifiedName: 'Item' });
expect(fold('2|svc|cgetMap|i')).toMatchObject({ qualifiedName: 'Item' });
});
it('still yields nothing when the chain ENDS on a type that named no class', () => {
// Carrying the declared type forward must not start answering with a class
// the position never had — only an unwrapping step may advance from here.
expect(fold('2|svc|cgetMap')).toBeUndefined();
expect(fold('2|svc|cgetMap|fnoSuchField')).toBeUndefined();
});
});
// ── C#: a real indexer, and the containers around it ────────────────────────
const CSHARP_SOURCE = `using System.Collections.Generic;
class Row {
public void Render() {}
}
class Table {
public Row this[int i] { get { return null; } }
public void Render() {}
}
class Cap {
void Entry(Table t, Dictionary<string, Row> rows, List<Row> items, Row bare) {
}
}
`;
const csFixture = buildScopeModel(csharpScopeResolver, CSHARP_SOURCE, 'main.cs');
// The parameter type bindings live in `Entry`'s own scope, so the fold must
// start there rather than at the module scope. Selected by the binding it
// must carry, not by position — every method body in the file is a Function
// scope and `Row.Render` comes first.
const csEntryScope = csFixture.parsed.scopes.find(
(s) =>
s.kind === 'Function' &&
csFixture.scopes.scopeTree.getScope(s.id)?.typeBindings.has('t') === true,
);
if (csEntryScope === undefined) throw new Error('no C# scope binding `t`');
const csCtx: FoldContext = { fixture: csFixture, inScope: csEntryScope.id as ScopeId };
describe('foldReceiverChain — C# indexer vs C# containers', () => {
it('declines a subscript on a class that declares an indexer', () => {
// `Table` has `public Row this[int i]` AND its own `Render`. `t[0].Render()`
// is `Row.Render`; identity made it `Table.Render`.
expect(foldIn(csCtx, '2|t|i')).toBeUndefined();
});
it('declines a subscript on a class with neither indexer nor container spelling', () => {
expect(foldIn(csCtx, '2|bare|i')).toBeUndefined();
});
it('resolves a dictionary subscript to the VALUE type', () => {
expect(foldIn(csCtx, '2|rows|i')).toMatchObject({ qualifiedName: 'Row', type: 'Class' });
});
it('resolves a list subscript whose spelling capture reduced away', () => {
// C#'s `stripGeneric` collapses `List<Row>` to `Row` at capture, so this
// cell depends entirely on the retained spelling.
expect(foldIn(csCtx, '2|items|i')).toMatchObject({ qualifiedName: 'Row', type: 'Class' });
});
});

View file

@ -490,6 +490,46 @@ describe('Pass 5: reference sites', () => {
);
expect(result.referenceSites[0]!.arity).toBe(2);
});
// #2782: languages whose member-read pattern also matches the callee of a
// member call mark that site rather than dropping it — the phantom-vs-genuine
// decision needs the resolved tail's kind and so belongs at edge emission.
it('records @reference.callee-position as inCalleePosition without becoming the anchor', () => {
const result = extract(
[
scopeMatch('module', 1, 0, 100, 0),
refMatch('read', 'Work', 3, 0, 3, 10, {
// Widest capture in the match: if it were not a known sub-tag it
// would win `anchorCaptureFor` and route the site to an unknown kind.
'@reference.callee-position': cap(
'@reference.callee-position',
3,
0,
3,
20,
'h.dep.Work',
),
}),
],
'a.ts',
mockProvider(),
);
expect(result.referenceSites).toHaveLength(1);
expect(result.referenceSites[0]).toMatchObject({
name: 'Work',
kind: 'read',
inCalleePosition: true,
});
});
it('leaves inCalleePosition unset on an ordinary read', () => {
const result = extract(
[scopeMatch('module', 1, 0, 100, 0), refMatch('read', 'Label', 3, 0, 3, 10)],
'a.ts',
mockProvider(),
);
expect(result.referenceSites[0]!.inCalleePosition).toBeUndefined();
});
});
// ─── §Pass 6: callable-value-flow facts ───────────────────────────────────

View file

@ -7,14 +7,29 @@
import { describe, it, expect } from 'vitest';
import {
MAX_UNRESOLVED_RECEIVER_MEMBERS,
lookupExternalCallCount,
lookupUnresolvedCallCount,
summarizeUnresolvedReceivers,
} from '../../../src/core/ingestion/scope-resolution/unresolved-receivers.js';
import type { ResolutionOutcome } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import { classifyReceiverShape } from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import type {
ReceiverOrigin,
ResolutionOutcome,
} from '../../../src/core/ingestion/scope-resolution/resolution-outcome.js';
import { classifyReceiverOrigin } from '../../../src/core/ingestion/scope-resolution/passes/receiver-bound-calls.js';
import { decodeReceiverChain } from '../../../src/core/ingestion/utils/receiver-chain-codec.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { goScopeResolver } from '../../../src/core/ingestion/languages/go/scope-resolver.js';
import { javaScopeResolver } from '../../../src/core/ingestion/languages/java/scope-resolver.js';
import { buildScopeModel, type ScopeModelFixture } from '../../helpers/scope-model.js';
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 1 };
function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): ResolutionOutcome {
function dropped(
name: string,
siteKind: 'call' | 'read' | 'write' = 'call',
receiverOrigin?: ReceiverOrigin,
): ResolutionOutcome {
return {
kind: 'suppressed',
reason: 'receiver-unresolved',
@ -24,6 +39,7 @@ function dropped(name: string, siteKind: 'call' | 'read' | 'write' = 'call'): Re
name,
range,
siteKind,
...(receiverOrigin === undefined ? {} : { receiverOrigin }),
};
}
@ -127,3 +143,347 @@ describe('summarizeUnresolvedReceivers', () => {
expect(lookupUnresolvedCallCount(undefined, 'save')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Origin routing. `external` is the one verdict that makes a drop STOP hedging,
// so it is the one verdict that must come from positive evidence — everything
// else has to keep counting.
// ---------------------------------------------------------------------------
describe('summarizeUnresolvedReceivers origin routing', () => {
it('keeps an external-rooted drop out of totalSites but inside the artifact', () => {
const summary = summarizeUnresolvedReceivers([
dropped('save', 'call', 'in-program'),
dropped('log', 'call', 'external'),
]);
expect(summary).toMatchObject({
counts: { save: 1 },
totalSites: 1,
externalCounts: { log: 1 },
externalSites: 1,
});
// Routed, not discarded: the split stays auditable and reversible.
expect(summary?.counts).not.toHaveProperty('log');
});
it('counts an unknown-origin drop, because unproven completeness is the unsafe direction', () => {
// The `droppedCall(svc)` population: an unannotated parameter is recorded
// nowhere in the scope model, so the classifier can prove nothing about it.
// It must hedge, exactly like `in-program`.
expect(
summarizeUnresolvedReceivers([
dropped('save', 'call', 'unknown'),
dropped('run', 'call', 'in-program'),
]),
).toMatchObject({ counts: { save: 1, run: 1 }, totalSites: 2 });
});
it('counts a drop that carries no origin at all', () => {
expect(summarizeUnresolvedReceivers([dropped('save')])).toMatchObject({
counts: { save: 1 },
totalSites: 1,
});
});
it('reports external truncation past the cap, symmetrically with omittedNames', () => {
// Without the twin, `lookupExternalCallCount` returns `undefined` for a
// truncated name — indistinguishable from "this member had no external
// drops" — and `externalSites` exceeds the sum of `externalCounts` with
// nothing in the artifact to explain the gap.
const outcomes: ResolutionOutcome[] = [];
for (let i = 0; i < 5; i++) outcomes.push(dropped('zzz_hottest', 'call', 'external'));
for (let i = 0; i < MAX_UNRESOLVED_RECEIVER_MEMBERS + 10; i++) {
outcomes.push(dropped(`ext${i}`, 'call', 'external'));
}
const summary = summarizeUnresolvedReceivers(outcomes);
expect(Object.keys(summary!.externalCounts!)).toHaveLength(MAX_UNRESOLVED_RECEIVER_MEMBERS);
expect(summary).toMatchObject({
totalSites: 0,
externalSites: MAX_UNRESOLVED_RECEIVER_MEMBERS + 15,
externalOmittedNames: 11,
});
// The hottest name survives the cap on count alone and still reads back.
expect(lookupExternalCallCount(summary, 'zzz_hottest')).toBe(5);
});
it('omits the external truncation marker when nothing was truncated', () => {
const summary = summarizeUnresolvedReceivers([dropped('log', 'call', 'external')]);
expect(summary).toMatchObject({ externalSites: 1 });
expect(summary).not.toHaveProperty('externalOmittedNames');
});
});
// ---------------------------------------------------------------------------
// `classifyReceiverOrigin` — the classifier the routing above consumes.
//
// Built from real scope extraction rather than hand-assembled indexes: the
// defect being pinned is that source-level intuition about what a binding
// CONTAINS is wrong (Go normalizes a free parameter's `*Host` to `Host` at
// capture but leaves a method receiver's spelled `*Host` intact), so a fixture
// that asserts the binding shape by hand would pin the intuition, not the code.
// ---------------------------------------------------------------------------
/** The provider-hook bag `classifyReceiverOrigin` reads. Derived from the
* function so the two cannot drift. */
type OriginHooks = Parameters<typeof classifyReceiverOrigin>[4];
/** Classify the receiver of the (unique) reference site invoking `memberName`
* under `hooks`, through exactly the arguments the pass threads at its drop
* recorder. */
function classifyOriginOf(
fixture: ScopeModelFixture,
memberName: string,
hooks: OriginHooks,
): ReceiverOrigin {
const site = fixture.sites.find(
(candidate) => candidate.name === memberName && candidate.explicitReceiver !== undefined,
);
expect(site).toBeDefined();
return classifyReceiverOrigin(
decodeReceiverChain(site!.receiverChain),
site!.inScope,
site!.explicitReceiver!.name,
fixture.scopes,
hooks,
);
}
/** The normal path: the language's own contract hooks, exactly as the pass
* supplies them. */
function originOf(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin {
return classifyOriginOf(fixture, memberName, {
stripTypePreservingDecoration: fixture.resolver.stripTypePreservingDecoration,
isBuiltInName: fixture.resolver.languageProvider.isBuiltInName,
});
}
/** The degradation path: the same site with the language's provider hooks
* WITHHELD, as for a language that declares neither.
*
* Named rather than spelled `originOf(fixture, name, {})` an empty options
* object reads as "defaults", so a reader "simplifying" it away silently
* flips the assertion from the degradation path to the normal path, and some
* of these would still pass while no longer testing anything. */
function originOfWithoutHooks(fixture: ScopeModelFixture, memberName: string): ReceiverOrigin {
return classifyOriginOf(fixture, memberName, {});
}
const goFixture = buildScopeModel(
goScopeResolver,
`package main
type Host struct{ name string }
func (h *Host) Inner() *Host { return h }
func (h *Host) Run() {
h.Inner().Dispatch()
}
`,
'main.go',
);
const tsFixture = buildScopeModel(
typescriptScopeResolver,
`export class User {
save(): void {}
}
export class Service {
getUser(): User {
return new User();
}
}
// The PR's own integration fixture. An unannotated parameter is recorded
// NOWHERE in the scope model — no type binding, no value binding, no qualified
// name — so nothing about it can be demonstrated in either direction.
export function droppedCall(svc): void {
svc.getUser().save();
}
// A local the program demonstrably declares, whose initializer we cannot type.
export function viaLocal(): void {
const loc = makeIt();
loc.getUser().persist();
}
// Genuinely outside: the language itself names \`console\`.
export function viaConsole(): void {
console.log('x');
}
// Declared type is a bare built-in, so the member lives outside too.
export function viaDate(d: Date): void {
d.getTime();
}
`,
'main.ts',
);
// #2744. Java is the language the boundary signal matters most for (the
// Spring/DI analysis is built on it) and the one that had no built-in set at
// all, so every drop in it hedged. Same construction as the fixtures above —
// real scope extraction, real provider hook — because the cases that matter are
// exactly the ones source-level intuition gets wrong: a `java.util` import does
// NOT produce an in-program binding (so the base still reaches the built-in
// set), and a `List<String>` declaration does not bind its base to `List`.
const javaFixture = buildScopeModel(
javaScopeResolver,
`import java.util.List;
import java.util.Map;
public class Probe {
private UserService svc;
private List<String> names;
public void go(OrderRepository repo, String raw) {
System.out.println("x");
String.format("%s", raw);
raw.trim();
names.iterator();
List.of("a");
Map.entry("a", "b");
Helper.assist();
svc.loadUser();
repo.findAll();
}
}
class Helper {
static void assist() {}
}
class UserService {
User loadUser() { return null; }
}
class User {}
`,
'src/Probe.java',
);
describe('classifyReceiverOrigin', () => {
// #2766. \`func (h *Host)\` binds \`h\` to the literal \`*Host\`; a free parameter
// \`x *Host\` is normalized to \`Host\` at capture. Only the receiver spelling
// needs the stripper, which is why the defect hid behind passing tests.
it('reads a Go pointer receiver as in-program', () => {
expect(originOf(goFixture, 'Dispatch')).toBe('in-program');
});
it('degrades to unknown, never external, when the language gives no stripper', () => {
// The same site with the hook withheld: \`*Host\` still resolves to no class,
// and the honest answer is that we could not tell — NOT that the JDK owns
// it. Returning `external` here is what published `epistemic: 'exact'` over
// every drop in a Go method body.
expect(originOfWithoutHooks(goFixture, 'Dispatch')).toBe('unknown');
});
it('does not call an unannotated parameter external', () => {
expect(originOf(tsFixture, 'save')).not.toBe('external');
expect(originOf(tsFixture, 'save')).toBe('unknown');
});
it('reads a declared local with an untypable initializer as in-program', () => {
expect(originOf(tsFixture, 'persist')).toBe('in-program');
});
// The test that proves the feature was fixed rather than deleted.
it('still reports a language built-in receiver as external', () => {
expect(originOf(tsFixture, 'log')).toBe('external');
});
it('still reports a base whose declared type is a bare built-in as external', () => {
expect(originOf(tsFixture, 'getTime')).toBe('external');
});
it('never claims external without the built-in hook', () => {
// A language that declares no built-in set has no positive external evidence
// available at all, so every one of its drops must hedge. Java used to be
// such a language; COBOL still is.
expect(originOfWithoutHooks(tsFixture, 'log')).toBe('unknown');
expect(originOfWithoutHooks(tsFixture, 'getTime')).toBe('unknown');
});
// ── Java (#2744) ────────────────────────────────────────────────────────
// Before Java had a built-in set, every assertion in this block read
// `lower-bound`-inducing `unknown`.
it('reports a Java static platform receiver as external', () => {
// `System.out.println(...)` — the chain base is `System`, not `out`.
expect(originOf(javaFixture, 'println')).toBe('external');
expect(originOf(javaFixture, 'format')).toBe('external');
});
it('reports a Java base whose declared type is a platform type as external', () => {
// `String raw` — the parameter is in-program, the member it dispatches is not.
expect(originOf(javaFixture, 'trim')).toBe('external');
// A FIELD declared `List<String>`. Java binds a known container to its
// ELEMENT type, so what reaches the built-in check is `String`, not `List` —
// asserting this from the source spelling would pin the wrong thing. Either
// way the verdict is the honest one, and without the set it read `in-program`
// off the value channel and hedged every JDK collection call in the repo.
expect(originOf(javaFixture, 'iterator')).toBe('external');
});
it('reports a java.util static receiver as external despite its import', () => {
// `import java.util.List` resolves to no workspace file, so it leaves no
// in-program binding and `List` falls through to the built-in set.
expect(originOf(javaFixture, 'of')).toBe('external');
});
it('still reports Java receivers the program declares as in-program', () => {
expect(originOf(javaFixture, 'assist')).toBe('in-program');
expect(originOf(javaFixture, 'loadUser')).toBe('in-program');
});
it('still hedges a Java receiver whose declared type is simply unknown here', () => {
// `OrderRepository` is declared nowhere in this program and is not a platform
// name. Absence of evidence stays `unknown` — the safe direction.
expect(originOf(javaFixture, 'findAll')).not.toBe('external');
expect(originOf(javaFixture, 'findAll')).toBe('unknown');
});
it('hedges a platform name deliberately kept OUT of the set', () => {
// `Map.entry(...)` is the exact syntactic twin of the `List.of(...)` above;
// the only thing separating the two verdicts is set membership. `Map` is a
// name applications really do declare (and in Java a same-package type needs
// no import to shadow it), so it stays out and its drops keep hedging. This
// pins the under-inclusion choice as a choice, not an oversight.
expect(originOf(javaFixture, 'entry')).toBe('unknown');
});
it('degrades every Java verdict to unknown when the hook is withheld', () => {
expect(originOfWithoutHooks(javaFixture, 'format')).toBe('unknown');
expect(originOfWithoutHooks(javaFixture, 'trim')).toBe('unknown');
});
});
describe('classifyReceiverShape', () => {
it('reports no-chain when the site carried no chain', () => {
expect(classifyReceiverShape(undefined)).toBe('no-chain');
});
it('reports no-chain for a chain with no steps', () => {
expect(classifyReceiverShape({ steps: [] })).toBe('no-chain');
});
it('reports chain-call when every step is a call', () => {
expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'call' }] })).toBe(
'chain-call',
);
});
it('reports chain-field when every step is a field', () => {
expect(classifyReceiverShape({ steps: [{ kind: 'field' }] })).toBe('chain-field');
});
// The distinction that makes the census actionable: a mixed chain fails for
// different reasons than a pure one, so collapsing it into either bucket
// would misattribute the population a fix has to target.
it('reports chain-mixed when the chain interleaves calls and fields', () => {
expect(classifyReceiverShape({ steps: [{ kind: 'call' }, { kind: 'field' }] })).toBe(
'chain-mixed',
);
});
});

View file

@ -0,0 +1,334 @@
import { describe, expect, it } from 'vitest';
import { preprocessSwiftConditionalDirectives } from '../../src/core/ingestion/languages/swift/conditional-directive-preprocess.js';
/**
* Assert the WHOLE output: `source` with exactly `blankedLines` replaced by
* spaces of the same width. Widths come from the source line, so a wrong-length
* blank fails, and an unexpected blank anywhere else fails too.
*/
function expectBlanked(source: string, blankedLines: readonly number[], separator = '\n'): void {
const lines = source.split(separator);
expect(preprocessSwiftConditionalDirectives(source).split(separator)).toEqual(
lines.map((line, index) => (blankedLines.includes(index) ? ' '.repeat(line.length) : line)),
);
}
describe('Swift conditional-directive preprocessing', () => {
it('blanks every directive of a nested group and leaves the top-level one alone', () => {
const source = [
'#if os(macOS)',
'class TopLevel {}',
'#endif',
'class Outer {',
' #if os(iOS) // platform branch',
' enum A { case x }',
'\t#elseif DEBUG && canImport(UIKit) // fallback',
' enum B { case y }',
' #else',
' enum C { case z }',
' #endif // end branch',
'}',
].join('\n');
expectBlanked(source, [4, 6, 8, 10]);
});
it('preserves JavaScript string length and newline count', () => {
const source = '#if DEBUG\nclass Outer {\n #else\n}\n#endif\n';
const rewritten = preprocessSwiftConditionalDirectives(source);
expect(rewritten).toHaveLength(source.length);
expect(rewritten.match(/\n/g)?.length ?? 0).toBe(source.match(/\n/g)?.length ?? 0);
expectBlanked(source, []);
});
it('returns directive-free Swift source unchanged', () => {
const source = 'class Plain {\n var value: Int = 0\n func read() -> Int { value }\n}\n';
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('preserves CRLF line endings and offsets', () => {
const source = 'class Outer {\r\n\t#if os(iOS)\r\n\tenum A { case x }\r\n\t#endif\r\n}\r\n';
const rewritten = preprocessSwiftConditionalDirectives(source);
expect(rewritten).toHaveLength(source.length);
expect(rewritten.indexOf('enum A')).toBe(source.indexOf('enum A'));
expectBlanked(source, [1, 3], '\r\n');
});
it('treats a bare carriage return as a line terminator', () => {
const source = 'class Outer {\r #if os(iOS)\r enum A { case x }\r #endif\r}\r';
expect(preprocessSwiftConditionalDirectives(source)).toHaveLength(source.length);
expectBlanked(source, [1, 3], '\r');
});
it('leaves regular and raw multiline string interiors byte-identical', () => {
const source = [
'struct Strings {',
' let regular = """',
' #if os(iOS)',
' #elseif DEBUG',
' #else',
' #endif',
' """',
' #if REAL_DIRECTIVE',
' let between = true',
' #endif',
' let raw = #"""',
' #if raw(iOS)',
' #elseif raw(DEBUG)',
' #else',
' #endif',
' """#',
' let doubleRaw = ##"""',
' #if double-raw-string-data',
' #endif',
' """##',
'}',
].join('\n');
expectBlanked(source, [7, 9]);
});
it('does not let an unterminated multiline string blank later lines', () => {
const source = ['let text = """', ' #if this-is-string-data', ' still string data'].join(
'\n',
);
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('leaves non-conditional hash directives untouched', () => {
const source = [
'class Directives {',
' #warning("warning")',
' #error("error")',
' #available(iOS 17, *)',
' #selector(getter: Directives.value)',
' #if DEBUG',
' #endif',
'}',
].join('\n');
expectBlanked(source, [5, 6]);
});
it('keeps nested block comments out of string state and never blanks inside them', () => {
const source = [
'/*',
' #if in-comment',
' /* nested comment */',
' #endif',
'*/',
'let text = """',
' #if in-string',
'"""',
].join('\n');
expectBlanked(source, []);
});
it('keeps a block-comment terminator that shares its line with a directive', () => {
const source = [
'class Foo {',
' /* temporarily disabled:',
' #if DEBUG',
' func f() {}',
' #endif */',
' func g() {}',
'}',
].join('\n');
// Blanking ` #endif */` would un-terminate the comment and swallow `g()`.
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('blanks a column-zero directive nested inside a class body', () => {
const source = [
'class Outer {',
' enum A { case x }',
'#if os(iOS)',
' enum B { case y }',
'#endif',
'}',
].join('\n');
expectBlanked(source, [2, 4]);
});
it('leaves an indented directive that is still at file scope intact', () => {
const source = [' #if DEBUG', ' struct Debugged {}', ' #endif', ''].join('\n');
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('recognizes non-ASCII indentation and a leading byte-order mark', () => {
const nbspSource = [
'class Outer {',
' #if os(iOS)',
' enum A { case x }',
' #endif',
'}',
].join('\n');
const bomSource = `class Outer {\n #if os(iOS)\n enum A { case x }\n #endif\n}\n`;
expectBlanked(nbspSource, [1, 3]);
expectBlanked(bomSource, [1, 3]);
});
it('refuses to blank a group whose branches split a declaration header', () => {
const source = [
'class NetworkClient {',
' #if swift(>=5.5)',
' func fetch() async {',
' #else',
' func fetch() {',
' #endif',
' perform()',
' }',
'}',
'struct SessionStore {}',
].join('\n');
// Both branch bodies open a brace and only one closes; blanking would leave
// `NetworkClient` unterminated and re-parent `SessionStore` under it.
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('blanks nested balanced groups at every level', () => {
const source = [
'class Outer {',
' #if os(iOS)',
' func inner() {',
' #if DEBUG',
' log()',
' #endif',
' }',
' #endif',
'}',
].join('\n');
expectBlanked(source, [1, 3, 5, 7]);
});
it('lets an unbalanced nested group also block its enclosing group', () => {
const source = [
'class Outer {',
' #if os(iOS)',
' func inner() {',
' #if DEBUG',
' if x {',
' #else',
' if y {',
' #endif',
' log()',
' }',
' }',
' #endif',
'}',
].join('\n');
// Both `if` branches survive blanking, so the enclosing branch is +1 too.
// Conservative propagation degrades the whole nest to pre-fix behavior.
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
it('does not wedge on an escaped triple quote inside a multiline string', () => {
const source = [
'class Outer {',
' let text = """',
' escaped \\""" still string data',
' #if in-string',
' """',
' #if REAL_DIRECTIVE',
' func after() {}',
' #endif',
'}',
].join('\n');
expectBlanked(source, [5, 7]);
});
it('closes a plain multiline string whose terminator is followed by a pound', () => {
const source = [
'class Outer {',
' let text = """',
' body',
' """#hashAfterClose',
' #if REAL_DIRECTIVE',
' func after() {}',
' #endif',
'}',
].join('\n');
expectBlanked(source, [4, 6]);
});
it('closes a raw multiline string terminated by extra pounds', () => {
const source = [
'class Outer {',
' let raw = #"""',
' body',
' """##',
' #if REAL_DIRECTIVE',
' func after() {}',
' #endif',
'}',
].join('\n');
expectBlanked(source, [4, 6]);
});
it('does not let an extended regex literal open a phantom block comment', () => {
const source = [
'class Outer {',
' let pattern = #/a/*b/#',
' #if REAL_DIRECTIVE',
' func after() {}',
' #endif',
'}',
].join('\n');
expectBlanked(source, [2, 4]);
});
it('stays linear on a long run of bare pound signs', () => {
// The pre-fix scanner re-walked the whole run at every index: ~10.6s for
// n=64000. The bound is a per-test timeout rather than a measured
// elapsed-time assertion; the fixed scanner runs this in ~1ms.
const source = `class Outer {\n let s = ${'#'.repeat(64000)}\n #if REAL_DIRECTIVE\n func after() {}\n #endif\n}\n`;
expectBlanked(source, [2, 4]);
}, 2000);
it('preserves JavaScript length on a directive carrying non-ASCII comment text', () => {
const source = ['class Outer {', ' #if os(iOS) // 日本語 🔥', ' #endif', '}'].join('\n');
const rewritten = preprocessSwiftConditionalDirectives(source);
// UTF-16 length is preserved; UTF-8 byte length is not (56 -> 48).
// Documented as safe because no consumer slices the original bytes by
// `startIndex` — node-tree-sitter reports UTF-16 code-unit indices.
expect(rewritten).toHaveLength(source.length);
expect(Buffer.byteLength(source, 'utf8')).toBe(56);
expect(Buffer.byteLength(rewritten, 'utf8')).toBe(48);
expectBlanked(source, [1, 2]);
});
it('is idempotent', () => {
const source = ['class Outer {', ' #if os(iOS)', ' enum A { case x }', ' #endif', '}'].join(
'\n',
);
const once = preprocessSwiftConditionalDirectives(source);
expect(preprocessSwiftConditionalDirectives(once)).toBe(once);
});
it('leaves an unmatched directive untouched', () => {
const source = ['class Outer {', ' #endif', ' #if NEVER_CLOSED', '}'].join('\n');
expect(preprocessSwiftConditionalDirectives(source)).toBe(source);
});
});

View file

@ -34,28 +34,45 @@ 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',
save: '2|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',
save: '2|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',
save: '2|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',
save: '2|svc|cgetTyped',
});
});
it('emits a name-free index step for a subscript receiver', () => {
// The declaration `repos: User[]` at the top of MODELS exists for this.
// A subscript receiver contains neither `.` nor `(`, so Case 0's old
// punctuation gate never fired and this call was INVISIBLE — no edge and no
// recorded drop. The chain is what makes it visible; the `i` step carries no
// name because a subscript key is a value, not a member.
expect(chainsFor(`${MODELS}\nrepos[0].save();\n`)).toMatchObject({ save: '2|repos|i' });
});
it('emits one index step per subscript, so a nested container is not flattened', () => {
// `User[][]` reduces to the same `User` a single `User[]` does, so the STEP
// COUNT is the only thing that distinguishes `nested[0]` (still a container)
// from `nested[0][1]` (an element).
const src = `${MODELS}\ndeclare const nested: User[][];\nnested[0][1].save();\n`;
expect(chainsFor(src)).toMatchObject({ save: '2|nested|i|i' });
});
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({});
});

View file

@ -91,6 +91,7 @@ export default defineConfig({
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/manifest-synthetic-impact-lbug.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',
'test/integration/fts-stemmer-sweep.test.ts',
'test/integration/lbug-multiwriter-deadlock.test.ts',
@ -138,6 +139,7 @@ export default defineConfig({
'test/integration/lbug-non-ascii-path.test.ts',
'test/integration/lbug-conn-serialization.test.ts',
'test/integration/group/manifest-resolve-symbol-2325.test.ts',
'test/integration/group/manifest-synthetic-impact-lbug.test.ts',
'test/integration/group/http-route-resolve-symbol.test.ts',
'test/integration/skills-e2e.test.ts',
'test/integration/fts-extension-e2e.test.ts',