Commit graph

28 commits

Author SHA1 Message Date
azizur100389
7f0ab16ffe
feat(routes): support JS data route tables (#2972)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-18 04:39:45 +01:00
MyShining
fe3d7e56be
feat(spring): detect non-HTTP handler entry points (#2891)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-16 15:16:21 +01:00
Gergő Magyar
77360e1043
fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912)

Interface-dispatch fan-out walked the subtype closure with generic arguments
erased, so `IValidator<string>` and `IValidator<int>` — one declaration, one
subtype list — were indistinguishable and a call through the first reached
`IntValidator.Check(int)`, a target no runtime dispatch can produce.

The arguments were already in the capture, unread: every language anchors
`@reference.inherits` on the whole base node while `@reference.name` keeps the
erased base. `ReferenceSite.typeArguments` is therefore derived generically in
`scope-extractor.ts` from the anchor's own spelling — no per-language query
changed — covering C#, Java, TypeScript, Kotlin, Go (`Base[int]` embedding),
Python (`Base[User]`) and Swift; Rust and Dart anchor on the bare name and get
nothing, which reads as "unknown". `preEmitInheritanceEdges` is the only code
that pairs a heritage site with a resolved (subtype, supertype), so it records
the instantiation there and hands it to the dispatch pass.

The closure is then walked carrying a substitution, as a type checker would:
`Wrapper<T> : IValidator<T>` binds T to the receiver's argument and stays
reachable from every instantiation, while its own subtypes are matched against
that binding. An incompatible hop is skipped without being marked seen, so a
type reachable by a second, compatible path still gets its edge, and without
descending, since its subtypes inherit the mismatch.

Pruning happens only on positive evidence that two instantiations differ.
Unknown arguments on either side, an arity that does not line up, an unresolved
qualified spelling of the same simple name, or an argument that might be a type
variable the language never captured all keep the target. Telling an uncaptured
type VARIABLE from a concrete type is the crux: `typeParameters` is absent both
for a non-generic declaration and for every declaration in a language whose
query omits `@declaration.type-parameters`, so the pass reads the evidence in
front of it — one run resolves one language, so a single generic declaration
anywhere in it proves the captures record parameters. A language recording
neither arguments nor parameters keeps exactly its pre-#2912 fan-out.

Type arguments are compared as resolved declarations rather than spellings, so
`Models.User` and an imported `User` are one type; the new optional
`ScopeResolver.normalizeTypeArgument` hook canonicalizes a language's predefined
aliases, implemented for C# (`string` ≡ `String`) where mixing the spellings
would otherwise delete a real implementor.

Fan-out cap, skipped-target reporting, overload selection and non-generic
closure behaviour are unchanged. SCHEMA_BUMP 60 -> 64: the heritage arguments
are a parse-time capture, so a warm cache would replay pre-fix sites and leave
the filter silently inert on unchanged files (61/62/63 are claimed by open PRs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* fix(scope-resolution): close the two generic-dispatch gaps (#2912)

The first commit left two shapes on the pre-#2912 fan-out. Both are now
covered, and the second one turned out to need a route the pipeline did not
have at all.

**Folded receivers (Cases 0 and 3b).** `this._validator.Check(x)` is typed by
the compound fold, and the fold answers with a CLASS — which is exactly what
loses the instantiation, since `IValidator<string>` and `IValidator<int>` fold
to one declaration. The fold now reports the SPELLING it typed each receiver
position from, through a pure side channel (`recordReceiverType`) added to the
one helper every declared-type route already shares plus the two return-type
routes; resolution is unchanged whether or not a caller passes it. The reader
keeps the last report and uses it only when it names the class the fold
returned, so an intermediate position cannot lend its arguments to another
class. This covers the dependency-injection shape the issue is really about —
a field-held generic interface — and multi-hop chains, where it is the last
hop's spelling that types the receiver.

**Rust and Dart heritage.** Neither recorded arguments, for two different
reasons, so both routes exist now:

  - Rust's `@reference.inherits` anchor is the trait identifier INSIDE a
    `generic_type`. Widening the anchor would move the site's range, and that
    range is part of every inheritance edge's id, so the arguments arrive
    through a new `@reference.type-arguments` sub-tag instead.
  - Dart's `implements` / `with` never become reference sites at all: they
    travel as heritage MARKERS and their edges are emitted by the language
    hook. The arguments ride the marker payload as an optional fourth field
    (dropped, not encoded, when the spelling contains the marker delimiter),
    and `ScopeResolver.emitHeritageEdges` now receives the same sink
    `preEmitInheritanceEdges` writes to, so whichever pass emits an edge
    records that edge's instantiation. Dart also gained the
    `@declaration.type-parameters` capture, without which its own type
    VARIABLES are indistinguishable from concrete arguments and
    `class Box<T> implements Validator<T>` would be pruned from every
    instantiation.

Note this makes Rust and Dart record their instantiations; it does not make
them fan out. Interface dispatch still fires only for a receiver whose folded
type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class`
receiver has no secondary targets to filter. Widening that gate emits new
edges for several languages and belongs to its own issue.

**Two matcher rules the wider coverage exposed.** A WILDCARD names a set of
types rather than one — `Repo<? extends User>` holds a `Repo<User>`, and
Kotlin's `Repo<*>` / `Repo<out User>` say the same — so a position with one on
either side is unknown; nullable spellings trip the same test, which costs a
little precision in the safe direction. And insignificant whitespace inside a
nested spelling (`Map<string, User>` vs `Map<string,User>`) is no longer a
difference.

One expectation changed in the #2833 field-receiver matrix: a
`Repo<Repo<User>>` receiver no longer reaches `UserRepo implements Repo<User>`.
That edge is precisely the false positive this issue is about, and the primary
edge to the interface's own declaration — which is what the matrix row exists
to prove — is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* refactor(scope-resolution): apply the quality pass to the #2912 change

Three cleanups, no behaviour change.

**One balanced-list scanner, not two.** `erasedTypeApplication` and
`typeApplicationArguments` each carried a copy of the same fiddly scan — one
bracket list, balanced, closing on the last character, non-empty — differing
only in what they did with the result. Both now call `balancedTailList`; the
rule that rejects `User[][]` and `Repo<User>?` lives in one place instead of
being free to drift between two.

**The receiver's arguments are parsed after the gates, not before them.**
`emitInterfaceDispatchFor` takes the receiver's declared SPELLING and parses it
itself, once the owner is known to be an Interface with subtypes. Every one of
the five cases calls it unconditionally and the overwhelming majority of
receivers are concrete classes that return at the first line, so the parse was
running per resolved receiver site to be discarded immediately. Case 4 and Case
6 now hand over the string they already hold, and the folded-receiver helper
returns the recorded spelling rather than parsing it.

**One question gates the whole instantiation apparatus.** Inside the closure
walk, the graph-id lookups now hang off "is the supertype's instantiation
known?" — false for every non-generic receiver and for every language that
captures no heritage arguments, which is what makes those walks cost exactly
what they cost before #2912.

Also lifted the argument-route choice in `pass5CollectReferences` out of a
nested ternary into a named `heritageTypeArguments`, where the reason the
explicit sub-tag wins over the anchor text can be stated once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef

* test(scope-resolution): cover generic interface dispatch in Kotlin and Go (#2912)

Extends the #2912 dispatch coverage past C#/Java/TypeScript. No production
code changes — the derivation is language-agnostic by construction
(`heritageTypeArguments` reads the heritage anchor's own spelling), so the
question was only which languages actually reach the filter.

Kotlin rides the shared heritage pre-pass; Go reaches the same filter from
the other side, matching implementors structurally while the receiver's
`Validator[string]` spelling carries the instantiation. Both are confirmed
to prune the mismatched implementor.

Each language gets a NON-GENERIC control asserting the fan-out still reaches
every implementor. Without it the `not.toContain` assertion passes just as
well when a language emits no dispatch edge at all — which is what Dart,
Python and Rust were measured doing for this receiver shape, generic or not.
They are deliberately not asserted on here: a "filtered correctly" test over
a path that never fans out measures nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(bench): re-baseline the Rust and Dart capture fingerprints for #2912

The Rust trait-impl and Dart heritage capture changes this branch makes are
additive TEXT on existing matches — each carries the instantiation the clause
was written with — so they drift the scope-capture digest without adding or
removing a match. The baselines were never re-measured when those captures
landed, which left `measure.mjs --check` red on this branch independently of
the merge.

Re-measured rather than hand-edited. Rust's capture_groups_fp (3556) and
fixture_count (202) are unchanged across the move, which is the evidence that
this is digest drift and not a capture-set regression. The other 13 languages
are byte-identical; 15/15 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): quality pass over the #2912 change

Cleanup only — no behavior change. Findings from a four-angle review (reuse,
simplification, efficiency, altitude), applied where they were verified.

Reuse / duplication:
* `stripTrailingCallSuffix` was a second copy of `matchingOpenParen`'s backward
  balanced-paren scan. Both now live in `template-arguments.ts` beside
  `balancedTailList`, for the reason that helper was shared in the first place:
  two copies of a scan this fiddly are free to disagree.
* The two call-return arms of the compound fold repeated the same four-part
  expression character for character; they share `classOfReturnType` now, the
  return-type twin of `classOfDeclaredType`, which keeps the "look up by
  rawName, report the erased application" pairing in one place.
* `pipeline/run.ts` implemented first-writer-wins twice — once in the pre-pass
  and once in the provider sink. One store, one sink, one rule; the pass keeps
  its `Set<string>` return and the callable-flow-only arm stops building an
  empty map to satisfy a widened return shape.

Simplification:
* `subtypeParametersComplete` dropped a disjunct that could never decide: every
  `subDef` reaching it comes out of the same loop that sets
  `languageCapturesTypeParameters`, from exactly those defs.
* The heritage-argument lookup asked "is the supertype's instantiation known?"
  three times; `superGraphId` now gates the block once.
* `TypeArgumentResolver` and `HeritageInstantiationResult` un-exported — no
  consumer outside their module.

Efficiency (all on the per-site dispatch walk):
* `resolveSupertypeArgument` captures only the site, so it is built once per
  site instead of once per subtype visited; the subtype's scope id is looked up
  once per subtype instead of once per argument position.
* `erasedTypeApplication` no longer runs on every fold hop through a call — the
  spelling is built only once the lookup has found a class, since it is
  discarded otherwise.
* `normalize`+`compact` computed once per side rather than twice.
* Regex literals and the identity `normalize` fallback hoisted to module scope.
* C# `System.` prefix stripped with `startsWith`/`slice` instead of a regex.

Verified: tsc clean, build clean, 1994 scope-resolution unit tests, 171
generic-dispatch + generic-field-receiver integration tests, 15/15 capture
bench fingerprints unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* style: apply Prettier to the two files the quality pass reformatted

Whitespace only — `quality / format` (npx prettier --check .) was red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the generic-dispatch review findings (#2912)

Addresses the gitnexus-check review on #2939.

A repeated type variable was rebound rather than unified: `class C<T> :
Pair<T, T>` accepted a `Pair<string, int>` receiver, with `T = int` silently
replacing `T = string` and the bogus substitution carried to the next hop.
It now unifies, and prunes only on the same positive evidence the concrete
path demands — an undecidable repeat keeps the target with no binding.

A type PARAMETER of the declaration enclosing either side is now recognised
and never compared. `subtypeParametersComplete` is evidence about the
SUBTYPE's parameter list and says nothing about a `T` written at the call
site, so `void Run<T>(IValidator<T> v) { v.Check(x); }` pruned every
implementor: unbounded, `T` grounds to nothing; bounded, it grounds to its
BOUND. Both read as a difference of type. That is the missing-edge failure
this filter is built to avoid, and it is the common dependency-injection
shape in C#, Java and Kotlin.

Making that recognition reliable is why generic METHODS now capture
`@declaration.type-parameters` in C#, Java and Kotlin — TypeScript already
did, which is why its generic functions never had the defect. The capture
feeds the existing `bindsTypeParameter` guard, so a method-level `T` also
stops resolving to a same-named class in every other lookup.

C# alias normalization additionally strips the `global::` qualifier, which
`import-decomposer` already unwraps elsewhere: `global::System.String` read
as unequal to `string` and pruned a live implementor.

The C# captures golden fixture is regenerated for the new capture; the
extractor reads `@declaration.type-parameters` generically, so no reader
changed. SCHEMA_BUMP 64 already covers these capture changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(scope-resolution): close the two remaining gitnexus-check findings (#2912)

`balancedTailList` counted ONE bracket family, so a crossed pair slipped
through: scanning `Foo<Bar]>` it never sees the `]`, reaches the final `>` at
depth zero, and reports `Bar]` as a balanced argument list — which
`typeApplicationArguments` then splits and `erasedTypeApplication` rebuilds a
spelling from. It now tracks a stack of expected closers, so every closer must
match the opener it actually closes and a crossed pair declines to `undefined`,
the "unknown" both callers already fail open on. Well-formed mixed nesting
(`List<Dict[a, b]>`) is unaffected.

C# `normalizeTypeArgument` stripped `System.` from every qualified spelling, so
`System.Custom` answered `Custom` and compared equal to an unrelated `Custom`
elsewhere in the workspace. The strip is now earned: a keyword answers from the
alias table first, and the qualifier is dropped only when what remains IS a
predefined type. `System.Custom` is returned as written and goes to the identity
comparison instead — the step that can actually tell two declarations apart.
`global::System.String` still meets `string`.

Both are pinned by unit tests, including the well-formed mixed nesting and the
`global::`-qualified ordinary type that must keep its qualifier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* docs(csharp): record why a shadowed `String` keeps its implementor (#2912)

Answers a review finding rather than changing behavior.

A workspace may declare its own type named `String`, shadowing the BCL simple
name, and the alias table then reads `IValidator<String>` as the `string`
instantiation and keeps that implementor. That is the SAFE direction, not an
oversight: pruning instead would rest on the belief that two spellings differ,
which is the missing-edge failure `generic-instantiation.ts` exists to avoid.

Resolving rather than normalizing cannot settle it either — the identity
comparison needs a `definitionId` from both sides, and a built-in name carries
none, so "built-in versus workspace-declared implies different" would be a new
prune with no positive evidence behind it. The cost is one surplus edge for that
pair, which is exactly the pre-#2912 fan-out and no worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* refactor(scope-resolution): pair the receiver spelling with the class structurally (#2912)

The fan-out needs the spelling a receiver position was typed from, because the
class the fold returns has lost the generic arguments. That was carried by a
PASS-LEVEL mutable holder, written by every declared-type lookup anywhere in the
fold and read back through a def-id coincidence check, with the holder cleared
by hand before each call site. Three things were load-bearing and none were
enforced:

* the reset had to be remembered at every call site. It was not: the Case 3b
  retry (`rawName` then `rawName + '()'`) reset once, BEFORE the first attempt,
  so a spelling reported by the attempt that failed could be attributed to the
  one that succeeded.
* the holder outlived every resolution, so a site that resolved through a route
  reporting nothing could read the previous site's spelling if the def ids
  happened to line up.
* the pairing itself was inferred from "whichever lookup reported last", not
  from the fold's own bookkeeping — losing branches (an MRO walk that moved on,
  a step later folded past) report too.

`foldReceiverChain` already had the answer and threw it away: its final
`FoldState` holds `def` and `declaredType` produced by the SAME step. It now
reports that pairing last, so the structural route is the one that stands.

`resolveCompoundReceiverTyped` returns `{def, declaredSpelling}` and owns a sink
created and read within the single call, which is what removes the reset
discipline — a local cannot be forgotten, and each of the two retry attempts
carries its own. The def-id guard stays as the check that a report names the
class actually returned.

Behavior is unchanged: 1975 scope-resolution unit tests, 177 generic-dispatch
and generic-field-receiver integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:50:53 +01:00
azizur100389
3d4a95360d
fix(java): materialize record component accessors (#2936)
* fix(java): materialize record component accessors

* fix(java): ignore receiver params in record accessor arity

* fix(java): address record-accessor review findings (#2917)

Five findings from the tri-review of #2936.

P1 — a synthesized callable evicted a source-written one from the method map.
`getMethodInfo` keyed its per-class map by `name:line`, but a callable that is
SYNTHESIZED at a position that is not its own declaration shares its owner's
line: a record's implicit accessor is minted at the component, and a C# 12
primary constructor at the owner's `parameter_list`. Both are appended last by
their extractor, so on a single line the synthesized entry overwrote the
explicit method's MethodInfo and both definitions collapsed onto one id —
`record P(int x, int y) { int x(int s) {...} }` lost `P.x#1` and rebound the
arity-1 call to the zero-argument accessor. Adds a required `MethodInfo.column`
and keys the map by `name:line:column` through a single `methodInfoKey` helper.
Required, not optional: an absent column would key an entry no lookup could
reach — a silent, whole-language loss of enrichment instead of a compile error.
All three lookup sites move together; the file's own lockstep docblock warns
that a half-applied change loses caller edges silently rather than dangling.
This also fixes the same collision in C#, which never touched record code.

Degenerate component names no longer mint a node. tree-sitter's zero-width
MISSING recovery token satisfies `name: (identifier)`, so `record M(int x, y) {}`
minted an empty-named Method whose returnType was the neighbouring `y`; and the
grammar admits `underscore_pattern` in the same field, which the query rejected
but the scope path accepted, so `record R(int _) {}` left a scope declaration
with no node behind it. One `isRecordComponentName` predicate now gates all
three emitters — query suppression, scope synthesis, and the method extractor —
so they cannot drift apart again.

Component annotations reach the implicit accessor (JLS 8.10.3 / 9.7.4) by
reusing the shared `extractAnnotations` helper. Deliberately over-approximate
and commented as such: `@Target` lives in another file and parsing is per-file.

`explicitZeroArgAccessorNames` is memoised per record node. It was rebuilt on
every component capture — O(components x body members) for one record, measured
at ~4x per 2x input — while the scope path already hoisted the identical call.

Docs: the `java-local-types` baseline now stores the `capture_groups_fp` its own
note cites, the SCHEMA_BUMP ledger no longer claims a v65 that nothing holds,
and `shouldSkipDefinitionCapture` documents that `defaultLabel` may be ignored.

Scope-capture fingerprints are unchanged (`measure.mjs --check` PASS, 15
languages): the bench corpus contains no degenerate components, so the new
predicate is inert on it. SCHEMA_BUMP stays 67 — this branch's existing claim
already covers the changed worker output; re-check it against origin/main before
merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* docs(ingestion): reunite the overload-suffix JSDoc with typeTagForId

The block describing the `~type1,type2` same-arity discriminator was stranded
above `buildCollisionGroups` when that function was inserted between it and the
`typeTagForId` it documents (#658). Adding `methodInfoKey` in this branch parked
it directly above yet another unrelated function, which gitnexus-check flagged.

Moves the comment down to the function it describes. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 08:43:11 +00:00
azizur100389
cdc98a9cf8
fix(java): capture enum interface heritage (#2935)
* fix(java): capture enum interface heritage

* fix(java): harden enum heritage dispatch

* test(java): refresh synthetic capture baselines

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-13 07:04:00 +01:00
Gergő Magyar
d540b00184
fix(check): stop reporting erased and deferred imports as initialization cycles (#2934)
Some checks failed
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Skill copy sync / shipped skills drift guard (push) Has been cancelled
2026-08-12 17:09:32 +00:00
Carter LaSalle
81100e2c74
fix(python): resolve calls through __init__.py re-exports (#2864)
* fix(python): resolve calls through `__init__.py` re-exports

A call to a name imported from a package never resolved when the package's
`__init__.py` re-exported it rather than defining it:

    pkg/impl.py       def target_fn(x): ...
    pkg/__init__.py   from pkg.impl import target_fn
    caller.py         from pkg import target_fn
                      def calls_it(): return target_fn(21)   # no CALLS edge

`caller.py` gets no CALLS edge. Both IMPORTS hops are recorded, and all four
functions are extracted as nodes — only the call binding is missing. Because
`__init__.py` re-exports are how Python packages declare a public surface, this
misses a large fraction of real call edges, and the failure is silent: the
defining file looks like dead code with zero callers.

The re-export closure that should carry this already exists and is fully general
(`buildReexportClosures` — SCC over the re-export subgraph, bounded fixpoint for
cycles, transitive `via` chains). Python just never fed it: the subgraph admits
only `kind: 'reexport'` and `kind: 'wildcard'`, and Python emits neither for
`from m import x`.

Python has no dedicated re-export form. A module-level `from pkg.impl import X`
binds X locally AND publishes it as `pkg.X`, so it is both a named import and a
re-export. Emitting `kind: 'reexport'` would be wrong — that form drops the local
binding, which Python's does create. Instead add an optional `reexportsName` flag
to the `named`/`alias` variants, alongside the existing provider-specific
`importedSymbolKind` / `targetIncludesImportedName` flags, and admit flagged
imports into the closure subgraph. Languages with an explicit form keep emitting
`kind: 'reexport'` and leave the flag unset, so nothing changes for them — a
negative-control test asserts a plain named import still does not resolve.

Verified on a fixture covering the three shapes (direct, top-level-via-re-export,
function-local-via-re-export): 1 of 3 CALLS edges resolved before, 3 of 3 after.

On a 12.4k-file Python/Go/TypeScript repository: edges 294,416 -> 301,443
(+7,027) and execution flows 300 -> 813. A previously "100% orphaned" module
(`shared/db/event_writer.py`) now correctly reports its caller.

5 new finalize tests (single hop, 3-hop chain, alias keying, cycle termination,
and the negative control) plus 6 updated Python fixture shapes.
`npx tsc --noEmit` clean in both packages; full unit suite shows no regression
against baseline (remaining failures are pre-existing load-sensitive flakes in
analyzer-identity / evidence-provenance-helper / skip-git-cli / hooks, each
verified passing in isolation).

* fix(python): set reexportsName only for module-level imports

`interpretPythonImport` flagged every `from m import x` as republishing the
name, but only a module-level statement does. A `from m import X` inside a
`def` or `class` body binds locally and puts nothing in the module namespace,
so flagging it fabricates a re-export of a name no importer can reach:

    # pkg/__init__.py
    def loader():
        from pkg.impl import InternalHelper
    # caller.py
    from pkg import InternalHelper      # CPython: ImportError

resolved to `def:pkg.impl.InternalHelper`. Worse, with declaration-order
first-wins in the closure, a scope-blind entry could claim a name ahead of the
real module-level import and give a WRONG def for legal, running code.

`interpretImport` receives a `CaptureMatch`, which is `{name, range, text}`
with no syntax node, so the scope is not recoverable there — and it is not
recoverable downstream either: `pass3CollectImports` applies no scope filter
and `ImportEdgeDraft.fromScope` is hardcoded to the module scope. The decision
therefore moves up to `import-decomposer.ts`, which still holds the live
`import_from_statement` node, and rides down as an `@import.publishes` marker.
Computed once per statement, not once per imported name, with the existing
`findAncestorBeforeBoundary` helper.

Only `function_definition` and `class_definition` suppress publication.
`if` / `try` / `for` / `with` do NOT — Python has no block scope — so the
predicate is an ancestor walk for those two node types and nothing else.
Verified against CPython 3.11 in both directions; both are now pinned by
tests, including the counterpart control that a branch-nested import still
republishes.

Also corrects the docblock in `scope-extractor.ts` that sent this change the
wrong way. It claims pass 3 attaches imports "not to any `Scope` — finalize
reconstructs the owning scope via `provider.importOwningScope` during Phase
2". Finalize does no such thing: `importOwningScope` is declared on
`LanguageProvider` and implemented by a dozen providers, and
`grep -rnE "\.importOwningScope\b" gitnexus/src/` returns exactly one hit —
that doc comment. Nothing invokes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* fix(shared): stop guessing ambiguous and namespace re-exports; bound the via chain

Four changes to the re-export closure, all reachable only now that Python
feeds it.

1. AMBIGUOUS NAMES ARE DROPPED, NOT GUESSED. `populateFileClosure` documented
   "declaration order first-wins for duplicates of the same exported name",
   which is sound only where a duplicate export is illegal — two
   `export { X } from …` is a TypeScript compile error, so the rule never
   fires. Python has no such guarantee:

       from .v1 import Client   # legacy, left behind
       from .v2 import Client   # the actual public Client

   CPython binds v2 (verified on 3.11); first-wins attributed every
   `from pkg import Client` in the repo to the DEAD implementation, and
   `impact("Client")` pointed at the wrong file. Last-wins is not the fix
   either: for the equally common `try:`/`except ImportError:` and
   `if sys.version_info` pairs exactly one branch runs, and which one is not
   decidable here. Both directions are wrong on real code, so the entry is
   dropped — the importer stays unresolved, which is exactly the pre-#2864
   answer, and the file-level IMPORTS edge is untouched.

   `collectAmbiguousReexports` runs as a PRE-PASS over data phase 0 froze,
   so the poisoned set is constant across the fixpoint. That matters: a set
   that grew mid-fixpoint would need retraction to propagate to files that
   already inherited the name, would make `myClosure.size > before` an
   unsound progress signal, and would invalidate the `|SCC| + 1` cap. As a
   pre-pass the closure map stays monotone and every existing termination
   argument survives unchanged. Only two flagged drafts resolving to two
   DIFFERENT in-workspace files count; duplicates of one target are
   harmless, and unresolvable targets never entered the closure.

   Checked in both loops. Named re-exports take precedence over wildcards,
   so suppressing only the named loop would hand the name to a later
   `import *` and reinstate an arbitrary winner through the back door.

2. NAMESPACE-RECLASSIFIED DRAFTS ARE EXCLUDED. The admission guards tested
   `draft.source.kind` while `tryFinalize` tests the post-reclassification
   `draft.base.kind`. Python's `from . import logger` is emitted as `named`,
   reclassified to `namespace` by `isNamespaceImport`, and was still
   admitted — republishing whatever def shared the module's simple name. For
   a `logger.py` holding a module-level `logger = logging.getLogger(...)`,
   importers of `from pkg import logger` bound to that Variable instead of
   the module. Reproduced end to end. Both predicates now take the draft and
   test `base.kind`; this is a no-op for TS/Rust, whose only
   `isNamespaceImport` implementation is Python's.

3. `transitiveVia` IS CAPPED AT 32. Each hop copies the inherited path, so
   an unbounded chain is Theta(depth^2) in time AND retained memory, and
   Theta(|SCC|^2) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH =
   100` covered this until fc919ad6 removed it — correct for the shallow
   TypeScript barrels that were then the only input, and invisible until the
   input class changed. Measured at depth 400: 67 ms / 145 MB uncapped vs
   25 ms / 40 MB capped. 32 against a real-world worst case of ~6 for
   `__init__.py` chains. Safe because `ImportEdge.transitiveVia` has no
   production reader — it is diagnostic provenance, emitted and typed but
   dropped by graph emission.

4. `localDefs` ARE INDEXED BY SIMPLE NAME. `findExportByName` linearly
   scanned a target's defs on every call, and the phase-3 fixpoint rescans
   the same target once per iteration. Memoized on the array identity, which
   `FinalizeFile` documents as static input. Worth 12-14% where lookups
   repeat and neutral elsewhere.

The 46-line algorithm docblock was also ORPHANED by the helpers inserted
between it and `buildReexportClosures` — AST-verified, that function had zero
jsdoc blocks, so the cross-reference elsewhere in the file landed on an
undocumented function. Helpers move below it (declarations hoist), and its
step 1, precedence and complexity sections are rewritten: they still claimed
regular imports do not contribute to the export surface, and justified the
via-copy cost by TypeScript barrels being shallow.

The `reexportsName` contract consolidates onto `ParsedImport`, where its
"`kind: 'reexport'` would drop the local binding" rationale is corrected —
`materializeBindings` creates a module-scope binding for every linked edge,
re-export included. The real reasons are that `origin` flips, changing
evidence weight and priority, and that it misreports Python's syntax.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* test(shared): add a re-export closure scaling guard to CI

No bench covered `buildReexportClosures` at all. Until #2864 its input was
TypeScript barrel files — a handful of shallow edges — and it admitted only
`reexport` and `wildcard` drafts. It now admits every module-level Python
`from m import x`, measured ~20x more edges on the CPython stdlib and cyclic
SCCs where there were none. The pass went from "rarely runs" to "runs over
the whole named import graph" with nothing watching it.

The regression this guards has already happened once: fc919ad6 removed
`MAX_REEXPORT_DEPTH`, which was correct for shallow barrels and stayed
invisible for as long as the input stayed shallow.

The depth arm is an EXACT structural assertion — build a chain far past the
cap, assert the longest emitted `transitiveVia` is exactly `MAX_VIA_LENGTH`.
It started as a `depth_ratio` timing arm and that was a bad gate: sampled
five times capped it scored 2.71-3.52 and three times uncapped 5.87-7.65, so
the ranges nearly touch and one uncapped run came in UNDER budget. A gate
that passes a third of the time on a broken build is worse than none, because
it gets read as evidence. The structural form fails 3/3 with 401 vs 32.

`width_ms` stays a timing arm with a deliberately loose budget, because a
structural check cannot see a constant factor: restoring a per-lookup linear
scan of `localDefs` leaves every array length untouched while making every
real analyze slower.

Both arms drive `finalize` through INDEXED hooks. Reusing the unit tests'
`defaultHooks` is the trap — its `resolveImportTarget` does `files.some(...)`
per import, which is O(imports x files) in the FIXTURE and swamps the pass so
completely that removing the cap measures as no change at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* fix(cache): bump SCHEMA_BUMP 53 -> 60 for ParsedImport.reexportsName

`reexportsName` is a new field on `ParsedImport`, and `parsedfile-store.ts`
serializes the whole `ParsedFile` generically — so it is part of the cached
shape even though it is not a capture, which is the easy-to-miss variant of
the rule `parse-cache.ts` states as a MUST. (The `@import.publishes` marker
added alongside it moves the capture output too, so this qualifies twice; the
python captures golden confirms the drift.)

Without the bump, a warm `parsedfile-cache` replays pre-fix `ParsedImport`s
carrying no flag, `isNamedReexport`'s strict `=== true` takes the old path,
and the entire fix is a SILENT NO-OP on incremental analyze while every
cold-run test passes. It lands hardest on `__init__.py` — the rarest-changing,
highest-cache-hit files in a Python repo, i.e. exactly the target. A published
npm release invalidates via `GITNEXUS_PKG_VERSION`; dev trees, main-HEAD
installs and CI with a restored cache dir do not.

60, not 54, because the value has to clear every in-flight claim rather than
just origin/main: main is at 53 while open PR #2899 claims 54 and #2891 claims
59. Five exact clashes are recorded in the ledger, and the pin test cannot
detect a tie — both sides assert the same number and both pass. RE-CHECK
against origin/main immediately before merging.

Also documents the divergence between `pythonFileExportsName` and the
re-export closure. That predicate answers "does this package expose X?" from
`localDefs` alone, so with `pkg/__init__.py: from .impl import log`,
`pkg/impl.py: def log` and a same-named `pkg/log.py`, `from pkg import log`
still targets the submodule and the closure is never consulted — for exactly
the case it was built for.

Deliberately NOT fixed by reusing the flag, which is the obvious three-line
change and is WRONG: `reexportsName` is also set for `from . import log`,
where CPython binds `pkg.log` to the MODULE, not a name (verified on 3.11
against the `from .impl import log` form, which binds the function). Returning
true there would kill the correct namespace edge. Separating the two needs the
re-export's own resolved target — i.e. re-entering `resolvePythonImportTarget`
from a different `fromFile` — and that classification is the subject of open
issue #2882, so it belongs with that fix. Not a regression: both halves behave
exactly as they did before #2864.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

* test(python): re-baseline the scope-capture fingerprint for @import.publishes

CI's `bench/python-scope/measure.mjs --check` failed on capture fingerprint
drift. Intentional: the module-level marker added for `reexportsName` is a new
synthetic capture, and that guard hashes `tag|text|range` over every
`emitPythonScopeCaptures` output.

Attributed before re-baselining rather than after. Reverting ONLY the
`@import.publishes` emission — nothing else — restores the previous hash
a0da3e7c exactly, so the whole drift is that one marker. `capture_groups_fp`
is 3246 either way and `scaling_ratio` stays ~1.0, so no capture group
appeared or vanished and the pass is still linear.

The other nine bench guards were run rather than assumed: scope-capture,
callable-value-flow, finalize-reexport, cpp-qualified-ns,
kotlin-import-target, receiver-resolution, scope-emission, import-target and
cfg all pass. The benchmarks job runs under `-e`, so this failure masked
whatever followed it — worth checking the rest before pushing a one-line
baseline change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rzsb6mdGtbu66BG1EaF6Zz

---------

Co-authored-by: Carter LaSalle <carterlasalle@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:21:06 +01:00
DuduPhudu
fa31a7d824
fix: close the nine follow-up review findings from #2856 (routes, receiver typing, truncation honesty) (#2899)
* fix(typescript): a type parameter shadows a declared type of the same name (W2-8)

First item of wave 2, promised to the reviewer on #2856.

`export function unwrap<Result>(value: Result): Result` names the PARAMETER, not
the `interface Result` beside it — tsc resolves both annotations to the
parameter. The type-reference capture that makes a contract answerable ("what
breaks if I remove this field?") had no notion of a parameter binding, so every
annotation mentioning `Result` inside `unwrap` minted a `USES` edge into the
interface, at the same confidence as a real consumer and indistinguishable from
one. Measured on the new fixture: `unwrap` produced TWO false edges while the
genuine consumer produced one.

Blast radius is every generic whose parameter name collides with a declared
type, and the colliding names are ordinary choices for both: `Result`, `Key`,
`Value`, `Item`, `Node`, `Options`, `Config`, `Props`, `State`, `Response`.

TWO HALVES, and the first is why upstream's fix could not reach this. #2833
introduced `bindsTypeParameter` for the CALL-receiver path, where a workspace
`class T` was answering for `<T>`. Reusing it here changed nothing at first, and
the reason is its own documented contract: `@declaration.type-parameters` was
captured for class/interface declarations ONLY, so a generic FUNCTION recorded
no parameter list and the predicate correctly returned false — absence is not
evidence. The data was missing, not the logic. So:

  - TYPESCRIPT_SCOPE_QUERY now captures type parameters on `function_declaration`,
    `generator_function_declaration` and `type_alias_declaration`;
  - the graph bridge consults `bindsTypeParameter` before emitting `USES`.

Both are load-bearing — removing either one fails the fixture.

The fixture carries two controls, because the obvious wrong fix is to stop
emitting: a genuine consumer of the interface must still link, and a generic
whose parameter does NOT collide must still link its real reference. Both are
asserted, and the "genuine consumer" case is asserted FIRST so the absences
below it cannot pass vacuously.

SCHEMA_BUMP 53 -> 54: parse-time capture change. A warm cache replays defs with
no parameter list, so the guard reads nothing and the feature is inert while
looking implemented.

Capture fingerprint re-baselined with justification. NO NEW CAPTURE NAME —
diffing the capture-name sets against the wave-1 branch returns empty; the tag
existed and now fires on more declarations. capture_groups_fp 2338 -> 2371,
fixture_count 151 -> 152, scaling 1.06 < 1.5, and JavaScript's fingerprint does
not move at all, which is the check that this is the TS declaration rules rather
than something broader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): close the four false-success paths in the graph-write-collapse guard (W2-6)

Second wave-2 item, promised on #2856. All four were reported; all four
reproduced by reading the code they name.

(a) A SAME-COMMIT RE-RUN REPORTED SUCCESS FOREVER. Every other meta-driven
    trigger — schema fingerprint, PDG mode, runner identity, CJK segmentation,
    embedding dims — has a block that forces a rebuild before the
    `alreadyUpToDate` fast path. `graphWriteCollapsed` had none; `grep -rn` found
    writes and no reads. So the one state meaning "most of your edges are gone"
    was the one state that repaired itself only if the user happened to pass
    `--force`. Now forces a full rebuild, and forcing is right rather than merely
    re-running: the persisted graph disagrees with what the pipeline produced, so
    an incremental pass over unchanged files would write nothing and re-stamp the
    same broken index as fresh.

(b) AN INCREMENTAL RE-RUN ERASED THE STAMP. `saveMeta` is a full atomic
    overwrite, and the field was spread in only when the CURRENT run had a
    verdict. `undefined` meant two different things at that site — "full run, no
    collapse" (a positive all-clear) and "incremental write, not comparable" (no
    opinion) — so the second case silently dropped `graph-write-collapsed` from
    meta.json while the edges were still missing. Now three-way: stamp on
    detection, CLEAR on a healthy full run, CARRY FORWARD when there is no
    verdict. That is the shape `branch: branchLabel ?? existingMeta?.branch` two
    lines away had all along.

(c) THE SERVER PATH NEVER CONSUMED IT. `analyze-worker-ipc.ts` projects the field
    "so a server-side caller sees the same degraded outcome the CLI does" — but
    nothing read it, so the comment described an intention and every collapsed
    run reported `complete` to the UI and to every API consumer. Now reports
    `failed` with the counts and the remedy, matching the CLI, which prints
    `Repository indexed INCOMPLETELY` and exits non-zero. A consumer that reads
    "complete" will query the index and get confident wrong answers.

(d) --pdg ROWS MASKED TOTAL STRUCTURAL LOSS. `expected` counts the in-memory
    graph plus the streamed STRUCTURAL manifest; the streamed PDG layers never
    enter `graph.relationshipCount`. But `persisted` was `stats.edges`, a count
    of EVERY `CodeRelation` row, and PDG writes into that same table. With 1,000
    structural edges expected and 4,000 PDG rows persisted, losing every
    structural edge still read `persisted = 4000`, cleared the ratio, and stayed
    silent — on exactly the large repos `--pdg` is used for.

    Worth recording that the OBVIOUS fix does not work. Padding `expected` with
    the PDG rows makes the two universes match but leaves the ratio judging a
    minority population: 4,000 of 5,000 still clears 0.5. I wrote that first, and
    the test I wrote to prove it failed. Only comparing structural against
    structural asks the question the check exists to ask, so `getLbugStats` gains
    a `structuralEdges` count excluding `PDG_EDGE_TYPES`. `TAINT_PATH` is
    deliberately NOT in that set — it is a whole-program Function→Function edge
    persisted by the normal emit, so it is structural and stays counted on both
    sides.

    `index-freshness-graph-collapse.test.ts` had pinned the masking as correct
    (`detectGraphWriteCollapse(1000, 4000)` → undefined, "PDG layers write into
    the same table, so persisted > expected is normal"). True about the table,
    and it licensed the hole. Replaced with the case that matters and a note on
    why the fix is at the caller.

The new `structuralEdges` assertion in `lbug-core-adapter` is there because the
failure mode is silent: the query sits in a try/catch that yields `undefined`,
and `undefined` makes the collapse check decline to compare — so a typo in the
Cypher would throw nothing, fail nothing, and switch the guard off. Verified
against a real LadybugDB and mutation-checked by breaking the query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes): make process selection insertion-order invariant (W2-5)

Third wave-2 item. Reproduced before fixing: two equal three-step flows with
`maxProcesses: 1` select `handleAlpha`; inserting the identical nodes and CALLS
edges in reverse select `handleBeta`. Same repository, same commit, a different
persisted graph — so a filesystem that enumerates differently, or an incremental
run that reorders assembly, silently changes what the tool reports.

Four sorts ranked by score or length alone and returned 0 on a tie.
`Array.prototype.sort` is stable, so a 0 preserves INPUT order, which traces
back to `graph.iterNodes()`. Under `maxProcesses` capping that decided which
`Process` and `STEP_IN_PROCESS` nodes were persisted at all. Each now falls
through to a totally-ordered, content-derived key — node id for entry points,
the joined path for traces.

WHAT IS ACTUALLY VERIFIED, stated precisely because "four fixes" would overclaim:

  - the ENTRY-POINT sort is individually mutation-verified;
  - the two DEDUP sorts are collectively mutation-verified;
  - the TRACE-RANK tiebreak is NOT individually observable, and the source says
    so. The dedup sorts already impose a total order on the list that reaches
    it, so removing it alone fails nothing. Kept as defence in depth: it cannot
    misbehave — it only makes an already-deterministic order explicit — and it
    is what stops a change to dedup ordering from silently re-opening this.

Finding that out took two fixtures. The first (three chains, three entry points)
is separated by the entry-point sort before trace ranking is reached, so it never
exercises the trace comparator at all; the second gives ONE entry point two
equal-length branches to different terminals, which is the only shape where the
trace comparator decides. Both are kept — they gate different sites.

The invariance tests assert the INVARIANT rather than any single sort, so they
cover all four sites and any future one without needing to know where they are.
Three assertions: same selection under a cap, identical set uncapped, and
identical ORDER — the last because order is what the cap consumes, so a set-only
assertion would pass while the defect persisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(impact): UNKNOWN dominates a mixed candidate set, instead of reporting the known floor (W2-4)

Fourth wave-2 item. The all-UNKNOWN branch here was reasoned about carefully and
is correct — its comment even names the two ways a set can be all-UNKNOWN. The
MIXED case fell straight through it.

`RISK_ORDER` is `['LOW','MEDIUM','HIGH','CRITICAL']` and has no `UNKNOWN` entry,
so `indexOf('UNKNOWN')` is -1 and an UNKNOWN candidate can never win the reduce.
An ambiguous name with one caller-less candidate (UNKNOWN, per the round-1 fix)
beside one single-caller candidate (LOW) reported `maxRisk: 'LOW'` — a confident
floor over a set containing an interpretation nobody measured. That is the same
false-safe the all-UNKNOWN branch exists to prevent, one case over, and it
surfaced in the UI as "Max blast radius N (LOW risk)".

`maxRisk` answers "how bad could this be?", and an unresolved candidate could be
CRITICAL — so any UNKNOWN in the set makes the aggregate UNKNOWN. Narrowing it
that way would normally cost information, so the measured part travels alongside
as `knownMaxRisk`, present only when the two differ: absent on a fully-resolved
set, where it would duplicate `maxRisk`, and absent on a fully-unknown one, where
there is no measured part. A reader gets "at least LOW among what resolved, and
one interpretation could not be walked at all", which is strictly more than
either value alone. The human-readable message says the same thing.

The seed gained a mixed pair because the existing one could not reach this: both
its twins are caller-less, so it only ever exercises the all-UNKNOWN branch —
which is precisely why the gap survived a round of review. Three assertions,
both halves mutation-verified.

`eval-server.ts` needs no change: it renders `result.maxRisk ?? 'UNKNOWN'`, so it
now shows UNKNOWN where it previously showed the floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(routes): track ternary polarity in dispatch guards, so a selected verb cannot be inverted (W2-9)

`if ((req.method === 'GET' ? false : true) && pathname === '/api/i')` emitted
`GET /api/i` — the one method that branch guarantees the request does NOT have.
A ternary SELECTS between its arms, so a verb inside one is not reached merely
because the whole condition is truthy, but `findVerbInSubtree` descended into
both arms and returned the first verb it saw. Same inversion `!` produced before
d4dcba8c, one level up.

Handled by folding the ternary where an arm is a boolean literal, which is what
collapses the selection into a conjunction:

    c ? A : false  ==  c && A     both hold, so search both
    c ? false : B  ==  !c && B    c must NOT hold, so search it at flipped parity
    c ? true : B   ==  c || B     a disjunction guarantees neither operand
    c ? A : true   ==  !c || A    likewise

Two non-literal arms leave the verb chosen by an unknown condition, so the
ternary guarantees nothing. Refusing every ternary would also have fixed the
reported bug, but three of the four shapes measured were ALREADY correct and
would have silently lost their verb; they are pinned now.

A second defect in the same walk, found while reproducing: the `!` rule was
keyed on PRESENCE, returning null at the first negation it saw, while
`isNegatedContext` two functions above states the rule is PARITY and says so
outright — `!!x` is `x`. So `!!(req.method === 'GET')` dropped a verb the source
states plainly. The existing double-negation test covered the PATH position,
where the parity walk already ran, and so never saw it. The verb walk now tracks
parity too, and the two agree.

Verb-less, not route-less: the path comparison is untouched evidence that the
branch serves that path, so an inverted verb becomes a missing verb rather than
a missing route.

SCHEMA_BUMP 54 -> 55. Routes are emitted at parse time and replayed verbatim
from a warm cache, so without the bump an already-indexed repo keeps serving the
inverted verb and the fix looks inert. Free against origin/main (48).

Every rule mutation-checked: removing the ternary dispatch, either literal-arm
rule, the negated-ternary guard, or the parity walk each fails exactly the tests
that claim it. One assertion I wrote survived all five mutations and was removed
rather than kept.

Not a recall win on crypto-trading-bot, which contains neither shape — this is
precision insurance for dispatchers that do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(routes): report every method a dispatch guard serves, not just the first (R3-8 part 1)

`if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch)` is two
routes. The verb walk returned the FIRST verb it found, so `route_map` presented
a two-method route as GET-only and `impact` on the POST path found nothing.
Taken verbatim from the reporting repo's researchRunRoutes.js.

`governingVerb` -> `governingVerbs`, returning a list; `findVerbInSubtree` and
`verbFromTernary` likewise. A guard with several verbs emits one route per verb
via the new `pushPerVerb` — they share a path and a handler but not a method,
and `(method, url)` is the key every downstream consumer dedups and looks up on.

A disjunction yields ALL its verbs or NONE, which also fixes an over-attribution
the first-match rule had:

    req.method === 'GET' || req.method === 'POST'   ->  GET, POST
    req.method === 'GET' || isAdmin                 ->  no verb

The second is reached for ANY method when `isAdmin` holds. Reporting `GET` — as
first-match did — describes a route open to everything as single-method, which
is the direction this module treats as more expensive than saying nothing.
Negated, `!(A || B)` is `!A && !B`, so it excludes verbs rather than offering
them and yields none.

Generic descent deliberately stays FIRST-match rather than unioning across
children: an arbitrary node says nothing about how its children combine, and two
verbs found under one are far more likely unrelated than alternatives. `||` is
the one construct that genuinely means "either of these".

Pinned against regression: the pre-existing rule that distributes ONE verb
across an OR of PATHS must not start multiplying methods, and switch arms
inherit the full method set.

SCHEMA_BUMP 55 -> 56. Routes are parse-time output replayed verbatim from a warm
cache. Free against origin/main (48).

Four mutations, each failing exactly the tests that claim it: removing the
disjunction dispatch, dropping the all-operands rule, allowing a disjunction at
odd parity, and emitting only the first verb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(routes): read `.match()` dispatch, and the capturing wildcard it needs (R3-8 part 2)

`RE.test(pathname)` and `pathname.match(RE)` are the same test with the operands
swapped. Only `.test` was read, which is why 28 of the reporting repo's 75 routes
still named the shared route table as their handler rather than the module that
serves them: those modules dispatch with `.match`.

THE CAPTURING WILDCARD, which is the part that made the rest inert.
`regexToRoutePath` accepted `[^/]+` and refused `([^/]+)` — `(` fell through to
the metacharacter bail. So the non-capturing form translated and the capturing
form produced nothing, and every existing test passed because every existing
test used the non-capturing form. The tests were written against the
implementation rather than against the corpus, and the reporting repo contains
no non-capturing path wildcard at all: a dispatcher captures the segment because
it needs the id. This alone also repairs the already-shipped `.test` rule.
A capture around anything that is NOT one segment still bails — `(.+)` spans
slashes — and the alternation is balanced, so `([^/]+` unclosed is not a match.

`.match` differs from `.test` in one way that matters: its result is USED, so it
is almost always BOUND, and the verb then lives in a later `if`:

    const runMatch = pathname.match(/^\/api\/research-runs\/([^/]+)$/)
    if (req.method === 'GET' && runMatch) { … }

Reading the verb off the CALL would report every one of those verb-less. So a
bound match records `name -> path` and the route is emitted where the binding is
TESTED, once per test site — one binding tested for GET and for PUT is two
routes. A reference counts only in a truthiness position (`&&`/`||` operand, or
a whole `if` condition), which is what separates `if (m && …)` from `m[1]`: a
read of the captured segment says nothing about dispatch and would otherwise
mint a duplicate route per use of the id. A binding never tested still emits one
verb-less route — the code did compute an anchored match against the path.

Regexes named by a same-file const resolve too (`pathname.match(POSITION_REPLAY_RE)`),
with the same ambiguity refusal the string-constant map uses: bound twice to
different patterns means dropped, because a half-right regex is a wrong route.

SCHEMA_BUMP 56 -> 57. Free against origin/main (48).

Nine mutations, each failing exactly the tests that claim it. TWO of my own
tests initially survived their mutation and were rewritten, not kept:
- the non-path-receiver case had no path token anywhere in the fixture, so
  PATH_TOKEN_HINT skipped the file and the assertion was satisfied by a file
  that was never examined;
- the negation case used `!m`, which never reaches the negation check at all —
  a `unary_expression` parent is not a truthiness position to begin with. The
  shape that exercises it is `!(req.method === 'GET' && m)`.
A declaration-site skip written alongside them proved unreachable for the same
reason and was removed rather than left to imply a hazard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(processes): report what the detection ceilings dropped, instead of logging it at debug (W2-3)

`processProcesses` has five ceilings - the entry-point trace quota, the
per-entry trace budget, `maxTraceDepth`, `maxBranching` and `maxProcesses` -
and every one of them fired silently. The result came back looking whole and no
consumer could tell it was a sample. The code's own comment already said so:

    // A silently truncating cap reads as "this is everything", which is the
    // same class of confident-empty answer this work is about.

and then only called `logger.debug`. A log nobody has enabled is not a
disclosure.

`stats.truncation` is additive, so every existing consumer of `totalProcesses` /
`crossCommunityCount` / `avgStepCount` / `entryPointsFound` is unchanged. It
carries one boolean to branch on plus a counter per ceiling, kept SEPARATE
rather than summed because they mean different things: unexplored entry points
mean whole flows are missing, while a depth-capped trace means a flow is present
but shorter than it really is.

`processesDropped` counts against the DEDUPED population, not the raw trace
list - the gap between those two is deduplication doing its job, and counting it
as truncation would report a permanent non-zero on every healthy repo.

`truncated` is DERIVED from the counters rather than set at each site, so a
ceiling added later only has to increment its own counter to be reported.

Surfaced at `warn` and NOT gated on `isDev`: "823 flows" printed without it
reads as the complete set, which is the confident-empty failure wearing its
other face - a confident-COMPLETE one. The debug line stays for the per-entry
detail it carries.

Seven mutations, each failing exactly the tests that claim it, including BOTH
directions of the flag: hardcoding `truncated` false fails the four positive
cases, and hardcoding it true fails the nothing-was-truncated case, which is
asserted first precisely so the positives cannot pass vacuously. The
`walksCutByBudget` fixture gives every node exactly `maxBranching` callees so it
asserts its own counter and not a neighbour's.

Also fixes a defect this work exposed: 10b0c7a1 (W2-5) embedded a RAW NUL BYTE
in `trace.join(...)` instead of the backslash-u escape the rest of the repo
uses. It behaves identically at runtime, but `file` reports the source as
`data`, and grep, git diff and code search treat it as binary - several greps
against this file silently returned nothing while I was reading it. main was
clean here; two other files carry the same raw byte from before this branch and
are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve members through a MEMBER-CALL producer's return shape (W2-1)

    const svc = new SignalService()
    const r = svc.make()
    return r.secretFlag        // <- no edge

`return-shape-members` types `r` to the producer that made it, but a member call
binds the spelling `svc.make`, and slicing that to its last segment leaves
`make` — a METHOD, never a callable binding in scope. The producer lookup failed
and the pass declined.

The limit shipped documented as needing inter-procedural receiver typing. It does
not. Measured on a fixture, the pipeline had already done the hard part:

  - `readMake -> Method:...SignalService.make#0` already resolves as an ordinary
    CALLS edge, so the receiver is already typed; and
  - `Property:...SignalService.make.secretFlag@N:C` already exists, because R3-4
    anchors a returned literal's keys to the METHOD that returns them, not only
    to free functions.

Both halves were present and unjoined — the same shape as R3-5 itself.

ADDITIVE, not a reroute. The new branch sits inside `if (producerFile ===
undefined)`, so it can only fire where the callable lookup already declined;
every reference that resolved before resolves identically, by construction
rather than by test.

Nothing new is inferred. The receiver is typed by the SAME predicate that typed
`r`, and it must itself resolve to a class — a receiver that cannot be typed
still declines, so `make.<member>` is never matched by name across the graph.
That fabrication is what the existing guards exist to stop and they all carry
over unchanged: the owner must resolve, its file must match the candidate's, and
`ownFilePaths` keeps the polyglot class registry from walking a JS read into a
Java field.

The owner segment is TWO parts for a method (`SignalService.make`) and one for a
free function (`makeSignal`), which is exactly how R3-4 qualifies each. That is
what separates two methods of one class returning the same key name from each
other AND from a free function of that name — the fixture gives `secretFlag`
three owners so a wrong resolution is detectable rather than a coin flip that
happens to look right.

Four mutations, each failing exactly the two tests that claim it: removing the
fallback, using the method alone as the owner segment, taking the producer file
from the reading file instead of the owner class, and dropping the
receiver-type requirement. 3,408 resolver tests pass, including
`polyglot-property-isolation`, which is the one this could plausibly break.

No SCHEMA_BUMP: this is a resolution pass over ParsedFiles, not parse-time
output, so a warm cache replays the same input and produces the new edges.

Measured on crypto-trading-bot: ZERO new edges, byte-identical at 62,158. Its
170 `const x = new Y()` bindings are overwhelmingly built-ins (Map, Set,
Promise, S3Client) rather than workspace classes whose methods return object
literals — it is a module-style JS codebase. Correctness fix for class-shaped
code, not a recall win on this corpus, and it should not be presented as one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): type a bare parameter from what its callers pass (W2-2)

    function readSpike(spike) { return spike.wickRatio }

had nothing to type `spike` from, so the read fell through to the 0.5 name tier.
That is the standing limit of R3-5 and, measured, by far the largest: 11,012 of
13,672 property edges on the reporting repo (81%) rest on that name guess.

The two facts needed were already extracted, for a different consumer. For JS
and TS among others, `callable-flow-captures` synthesizes:

    formal    owner=readSpike  binding=spike  parameter-index=0
    argument  source=s  parameter-index=0  direct-callee-name=readSpike

Joining them on (callee, parameterIndex) says which cell reaches which
parameter, and the argument's own binding is typed by the same
`findReceiverTypeBinding` a directly-bound receiver already uses. So the
parameter inherits the producer and `spike.wickRatio` resolves as evidence
rather than inference.

No new capture, no parse-time change, NO SCHEMA_BUMP. And deliberately not a
change to the callable-value-flow solver that owns these sites: that pass is
guarded by a fingerprint CORRECTNESS gate plus a timing budget, so this reads
the same facts and computes its own map.

AMBIGUITY DECLINES. A parameter whose callers pass different producers resolves
to nothing. Picking one would fabricate at the 0.9 PRECISE tier, which no
`minConfidence` floor can filter out — the same reason `buildConstantMap` drops
an ambiguous constant instead of taking the first.

Keyed by the formal's (scope, name), not by a definition id. The first attempt
used a def and measured `paramDef=NONE`: a parameter is not reachable through
`findValueBindingInScope` (its predicate is `isOwnableValueLabel`, which lists
Const/Variable/Property/Static because it exists for OWNERSHIP registration, and
a parameter is owned by nothing) and it is not a `local` binding either. The
formal site already states the scope its parameter binds in, which is enough.

Formals carry their DECLARING FILE in the key, so two same-named functions in
different files cannot answer for each other — dropping it makes both go
ambiguous and both readers silently lose their edge.

COVERAGE, counted rather than assumed. The synthesis skips an argument that is
itself a call result (an explicit `continue` in `callable-flow-captures`), so
`f(makeSignal())` emits no argument site and only the bound spelling
`const s = makeSignal(); f(s)` is served. That looked fatal until measured: in
the reporting repo, bare-identifier arguments outnumber call-result arguments
2,563 to 50 — 51:1. Extending the shared, benched capture synthesis for the 2%
case is not worth its risk.

Four mutations, each failing exactly the tests that claim it: keeping the first
producer instead of declining on conflict, dropping the read-site lookup,
matching a formal at index 0 regardless of the argument's index, and dropping
the declaring file from the formal key. Two of those could not be caught by the
first fixture at all — it had a single parameter and a single consumer file — so
the fixture gained a two-parameter callee and a same-named twin in a second file
before they were meaningful. The test helper also had to start filtering by
source FILE, or two different `readSpike` symbols merged into one count.

Measured on crypto-trading-bot: 36 reads left the 0.5 name-guess tier. 26 became
precise 0.9 edges (return-shape reads 1,130 -> 1,156, which is the whole delta),
and 10 became honest absences — the receiver was typed, the producer's shape was
known, and the member is NOT on it, so the site is claimed as disproved rather
than left for the name fallback to invent an answer for.

That is ~0.3% of the 11,012, and it should be reported as such. The 81% figure
is the size of the PROBLEM, not of this fix: the shape requires a bound
argument, a producer that returns an object literal, and a parameter read as a
receiver, and that intersection is narrow. The remaining name-tier reads are
mostly receivers no workspace producer types at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes,ci): anchor trace subsumption, cover the sink wiring, stop one bench guard hiding the rest (#2894, #2896, #2895)

Three follow-ups reported against #2856 after it merged. Each was reproduced
before it was fixed.

#2894 — trace subsumption matched mid-identifier.

`deduplicateTraces` decided whether one trace is a sub-path of another with an
UNANCHORED `String.includes`, so a match could begin in the middle of a node id:

    'X->AA->B'.includes('A->B')   ->   true

and `A -> B` was discarded as redundant against a chain `A` is not a step of at
all. Reproduced directly against the function before fixing.

Padding both keys with the separator makes `includes` match whole steps only.
Reported as measured-inert and that holds — the collision needs one node id to
be a strict suffix of another at a `->` boundary, which real ids
(`Function:<path>:<name>`) do not produce. Fixed anyway because the predicate
did not mean what the surrounding code says it means, in a function whose entire
job is deciding what to delete, and nothing pinned it.

`deduplicateTraces` is exported for the test, matching how `traceFromEntryPoint`
and `buildSinkFunctionSet` are already reached. The tests use bare ids because
the shape cannot be built from realistic ones — which is exactly why nothing
caught it. Alongside the regression case, two tests pin that GENUINE subsumption
still happens, prefix and suffix, so the fix cannot degenerate into "subsume
nothing" and pass the first test trivially. Mutation-checked: reverting the
padding fails the mid-identifier test and only that one.

The encoding assumes `->` never appears IN a node id; a C++ `operator->` would
defeat the join regardless of padding. Out of scope, but the assumption is now
written down where the join happens.

#2896 — the sink wiring was only ever exercised through its fail-open catch.

`processesPhase` reads `allFetchCalls` / `allORMQueries` off the parse output
inside a try/catch that falls open to "no sinks", and every phase-level test
omitted `parse` — so all of them took the CATCH branch and the success path had
no coverage. `getPhaseOutput` is a raw `as T` cast, so a field rename would make
the phase detect zero sinks while every test still passed, because zero sinks is
what they already assert.

The new test asserts the one thing only the success path can produce: a flow
ENDING at the sink while a longer chain continues past it. Its control is the
same graph with no `parse` dep, which must NOT produce that terminal — without
the control the assertion could pass for an unrelated reason. Also asserts
`processesPhase.deps` contains `parse`, so the read and the declaration cannot
diverge, and that a parse output missing those fields still fails open rather
than losing every process.

Mutation-checked, including the exact drift scenario reported: renaming
`allFetchCalls` at the read site, dropping `parse` from `deps`, and passing no
sinks to `processProcesses` each fail exactly the test that claims them.

#2895 — a failing bench guard aborted the job and masked every later guard.

Every step in the benchmarks job was fail-fast, so the first failing `--check`
aborted it and the rest reported `skipped`, which reads identically to "nothing
to do". Audited over 13 runs on #2856: the job succeeded zero times and the last
two guards executed zero times for the life of the PR, while two reviews read
the checks summary and saw nothing wrong. Both guards did in fact pass — that
was luck, not verification.

`if: ${{ !cancelled() }}` on all ten steps after the first, so one stale
baseline reports one red step instead of hiding nine. `!cancelled()` rather than
`always()` so an explicit cancel still stops the job instead of running seven
minutes of benchmarks nobody is waiting for.

The two steps easiest to miss are covered: `Receiver-resolution drop guards`,
whose `run:` sits twenty lines below its `name:` behind a long comment, and the
final `Cross-language pipeline benchmarks` step, which is not a `--check` and so
falls outside any grep for one — and is one of the two that never ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(parse): capture a fetch call site even when its URL is not a literal (#2897)

The `fetch` rule required the argument to be a string or template literal:

    arguments: (arguments
      [(string (string_fragment) @route.url)
       (template_string) @route.template_url])

so `fetch(url)` with a variable matched nothing at all. Measured across this
repository's own TypeScript sources: **44 of 47 fetch calls pass a variable**, so
94% produced no site.

That is what makes R3-6 look inert. The sink set is built entirely from
`allFetchCalls` / `allORMQueries`, so a function performing an outward call
through a computed URL was never a sink, no flow could terminate there, and the
sink-first ranking rule never changed an ordering. The feature was fine; the
signal underneath it was almost always empty.

The URL alternation is now OPTIONAL, so one match covers both shapes. The R3-6
sink set needs only WHERE the program reaches outward, not where to.

Route linking is untouched, by construction rather than by hope:
`processNextjsFetchRoutes` normalizes the URL first and skips anything that
yields nothing, so a URL-less entry cannot mint a FETCHES edge. Verified on this
repo — FETCHES went 8 -> 9 across the change, i.e. the widening added sink sites
without inventing route edges, which was the one real risk here.

Tested in BOTH JavaScript and TypeScript, since the rule is duplicated in each
query block and fixing one would have left the other blind:

  - a variable argument is captured, with no URL   <- the regression case
  - a computed argument (`fetch(buildUrl(), {...})`) likewise
  - a literal URL is still captured WITH its URL   <- route linking depends on it
  - a template URL likewise
  - exactly ONE site per call — an optional alternation must not make a literal
    match twice, which would double-count the site and could mint two edges
  - `prefetch('/x')` is still not a fetch

Mutation-checked: restoring the mandatory alternation fails six of the twelve,
three in each language.

SCHEMA_BUMP 57 -> 58. Parse-time capture output is replayed verbatim from a warm
cache, so without the bump an already-indexed repo keeps its empty sink set and
the fix looks inert — which is the failure this constant exists to prevent, and
would have reproduced the very symptom being fixed.

Not addressed here, and worth stating: this widens `fetch` only. The reporter's
broader point stands — anything keyed on FETCHES / QUERIES is only as good as
the extraction underneath it, and the ORM side has not been measured. A guard
that fails when a corpus known to contain outward calls yields zero sites is the
right follow-up; this change makes such a guard meaningful rather than
tautological.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline receiver-resolution for the two fixtures this PR adds

`receiver-resolution --check` failed on:

    countArm.totalDropsAllKinds: 140 -> 148
    countArm.bySiteKind.write:    11 ->  19

Investigated before touching the baseline, because a guard that exists to catch
unexplained movement should not be silenced by an unverified story.

WHAT IT IS: the count arm runs the real pipeline over a corpus that includes
`test/fixtures/lang-resolution/`, and this PR adds two fixtures there —
`member-call-producer` (W2-1) and `parameter-producer` (W2-2). Each returns an
object literal with two keys, and a producer writing its own returned key is a
write site the receiver recorder logs. Four each, eight total.

Attributed by dumping the individual drops rather than reading the aggregate:

    member-call-producer/src/producer.js   secretFlag, wickRatio  (2 lines) = 4
    parameter-producer/src/producer.js     source, wickRatio      (2 lines) = 4

The eleven drops already in the baseline are all `javascript-object-properties`
fixtures of exactly the same shape, so the new ones are not a new KIND of drop —
they are more of one the baseline already records. This is the first case the
guard's own failure message names: "a fixture was added".

WHAT IT IS NOT: `callDrops` — THE gate number, and `call`-only by deliberate
design because reads and writes "would inflate it" — is unchanged at 102. `read`
drops unchanged at 27. The SHAPE ARM shows no drift at all: no receiver spelling
moved between RESOLVES / VISIBLE-GAP / INVISIBLE-GAP, so no resolution
regressed.

HOW IT WAS ISOLATED, since the first attempt was misleading and the record is
worth having: reverting `return-shape-members.ts` alone did NOT reproduce it and
pointed away from W2-1/W2-2. Only a commit-level bisect was trustworthy —
`origin/main` OK, W2-8 OK, W2-3 OK, then W2-1 +4 and W2-2 +4, which matches the
fixture count exactly. A file-level revert leaves the fixtures in the tree, and
the fixtures are the cause.

The update is two numbers. Nothing else in the baseline moves.

Worth noting where this failure became visible at all: under the fail-fast
benchmarks job it would have aborted the run and shown the five guards after it
as `skipped`. It is legible here because #2895 — fixed in this same PR — now
lets every later guard run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): stop `--pdg` runs reporting a healthy index as INCOMPLETE

Every `gitnexus analyze --pdg` reported a graph-write collapse and exited 1 on an
index where every row had persisted. Reported by a user hitting it on a real
repo; introduced by this PR's own W2-6(d).

    Repository indexed INCOMPLETELY
    the pipeline produced 200,501 relationships but only 64,764 are readable

The index was complete: 200,190 rows present, 109,905 PDG and the rest
structural, all queryable.

WHAT WENT WRONG. W2-6(d) made the persisted side count STRUCTURAL rows only —
correct, and the reason is in its own comment: PDG writes into the same table, so
counting everything let PDG surplus mask real structural loss. But the expected
side kept using `graphEmitManifest.totalRows`, and that is a BUFFER-POOL SIZE
HINT which counts every streamed row. PDG streams through that same sink, so the
check compared a structural-plus-PDG expectation against a structural
measurement. On any repo with a PDG layer that is a guaranteed false collapse.

It compounds rather than merely misreporting: the run stamps
`graph-write-collapsed`, and W2-6(a)'s rebuild trigger — added alongside it —
forces a full re-analyze next run, which collapses again. A permanent rebuild
loop, on an index that was never damaged, at ~100s a cycle.

MEASURED RATHER THAN ASSUMED, because the first attempt was wrong. I first
subtracted PDG edges RESIDENT in `graph.relationshipCount`, rebuilt, re-ran the
failing command and got byte-identical numbers. Instrumenting the three terms
showed why:

    relationshipCount=20,825  graphManifestTotalRows=179,676
    pdgEmitManifest=absent    residentPdgInGraph=0

PDG is not resident in the graph AND has no separate manifest — it streams
through the ordinary `GraphEmitSink`. The reverted attempt is not in this diff.

THE FIX. A pair key cannot separate them: it is `From|To` NODE LABELS, and a CFG
edge shares `Function|Function` with CALLS. Only the write path sees
`relationship.type`, so the sink now counts a `structuralRows` subtotal there and
publishes it on the manifest. `totalRows` is unchanged — it still sizes the
buffer pool, which is what it was for.

WHY THIS SHIPPED UNCAUGHT, and what changed about that. The wiring test kept a
LOCAL MIRROR of the expected-count expression "because the production expression
is inline in a 3000-line function". A mirror cannot catch a term the original got
wrong. That expression is now an exported
`computeExpectedStructuralRelationships` which production calls and the test
imports.

It also takes the MANIFEST rather than a pre-selected number, deliberately: the
defect was choosing the wrong FIELD, and a numeric parameter leaves that choice
at a call site no unit test can reach. Verified — with the helper taking a
number, reverting to `totalRows` failed nothing; taking the manifest, the same
revert fails four tests.

Verified end to end on the reported command: `analyze --force --embeddings 0
--pdg` now exits 0 with "indexed successfully", 86,963 nodes / 200,217 edges, and
the run clears the stale collapse stamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(routes): scope match bindings, and intersect ternary conjunctions

Two ways the dispatch-guard walk minted a route that does not exist — the one
thing this module's header says is worse than missing one.

MATCH BINDINGS WERE KEYED BY BARE NAME, FILE-WIDE. `collectFromMatchBindings`
walked from `tree.rootNode` and resolved `matchBindings.get(node.text)` at every
identifier in a truthiness position, so a same-named binding in ANOTHER function
answered for it. The poison check only fired on a second REGEX match with a
different URL; a non-match binding never entered `collectFromRegexDispatch`, so
nothing refused it. Reproduced:

    function handleReplay(req, res) {
      const m = pathname.match(/^\/api\/live\/positions\/([^/]+)\/replay$/);
      if (req.method === 'GET' && m) { … }
    }
    function handleSettings(req, res) {
      const m = req.headers['x-mode'];        // unrelated value, same name
      if (req.method === 'DELETE' && m) { … }
    }

    GET    /api/live/positions/{param1}/replay  handler=handleReplay    correct
    DELETE /api/live/positions/{param1}/replay  handler=handleSettings  FABRICATED

Wrong in method, handler and line. `m`, `match`, `result` are the ordinary names
here. Two ways the truth was then lost: the fabricated route is VERBED, so
`reconcileDispatchGuardRoutes` kept it and dropped the true verb-less one — the
#2856 `/api/report` shape, through the channel this series added — and `tested`
was name-keyed too, so the tail loop suppressed the real binding's own honest
verb-less emit before reconciliation ever ran.

`matchBindings` and `tested` are now keyed on (enclosing function, name).
`enclosingFunction` is extracted from the walk `enclosingHandlerName` already
did, so there is one function-boundary mechanism, not two. A second declarator
for a key refuses it, and an assignment refuses the name in its own scope and
every enclosing one. `buildRegexConstantMap` refuses a name rebound to anything
that is not a regex literal, closing `let RE = /…/; RE = buildDynamic(req)` and
the `new RegExp(prefix + '/x')` twin.

A use resolves only within its own function. Resolving outward would need a
complete declaration model — params, imports, catch bindings — and a miss there
fabricates exactly the route this fixes. Declining costs the verb, not the path.

THE TERNARY TOOK FIRST-MATCH WHERE THE ALGEBRA IS INTERSECTION. The docblock
proves `c ? A : false ≡ c && A` and says "both hold, so search both", but
`firstNonEmpty` returned one operand's set unintersected:

    (req.method === 'GET' || req.method === 'POST')
      ? (req.method === 'POST' || req.method === 'PUT')
      : false                                    emitted GET and POST
                                                 only POST is reachable
    req.method === 'GET' ? req.method === 'POST' : false
                                                 emitted GET, unsatisfiable

`intersectVerbs` replaces it for both conjunction shapes. An empty side still
yields to the other — "names no method" is not "admits none", which is what the
`isAdmin && POST` fallthrough is for — but two non-empty sides intersect, and an
empty intersection is an unsatisfiable guard that yields no verb.

Both changes strictly REMOVE routes, so SCHEMA_BUMP 58 -> 59: routes are
parse-time output replayed verbatim from a warm cache, and without the bump an
indexed repo keeps serving the fabricated verbed route while the fix looks
implemented.

10 tests added, 9 of which fail without the change. All 86 existing assertions
pass unchanged; none was weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): bind a type parameter only inside the scope it opened

W2-8 captured `@declaration.type-parameters` on EVERY `type_alias_declaration`,
but an alias becomes a SCOPE only when its value is an `object_type`
(`typescript/query.ts:149`). For a union, array, conditional, mapped, tuple or
function alias there is no scope, so the def — now carrying `typeParameters` —
attached to the innermost enclosing scope, which is the MODULE. And
`typeParameterNamesInScope` folds each scope's set from its PARENT'S, so the
name landed in every scope in the file. The `USES` guard then deleted every edge
whose target had that simple name:

    export interface Result { ok: boolean }
    export type Maybe<Result> = Result | null      // one ordinary line
    export function readResult(r: Result) { … }    // its USES edge is DELETED

Silent data loss, in the edge class whose whole purpose is answering "what
breaks if I remove this field?". Measured: adding two scope-less generic aliases
emptied the fixture of USES edges entirely.

The existing fixture could not see it — it wrote `type Box<Result> = { held: Result }`,
the ONE alias form that opens a scope.

`typeParameterNamesInScope` now reads a def's `typeParameters` only when that
declaration OPENED the scope owning it: `scope.kind !== 'Module'` and the def-id
position equals the scope range start, via the canonical `definitionIdPosition`
rather than slicing the id. That is the same alignment test `pickCallerCallableDef`
uses to tell a closure from a nested function, and it is language-neutral — it
also covers `function f() { type W<Result> = Result[] }`, which a module-scope-only
stopgap would miss.

Every language populating the capture was audited (ts, java, csharp, kotlin,
rust, cpp): all anchor it on a declaration that IS a scope node, including C++
where the capture rides `template_declaration` but the anchor is the inner
`class_specifier`. Go uses a separate sidecar. The TypeScript non-object alias
was the only mismatch in the codebase. `query.ts` is untouched.

THE GUARD ALSO SAT AT THE WRONG LAYER, which forced three defects at once. It
keyed on `edgeType === 'USES'` — and `mapReferenceKindToEdgeType` maps THREE
kinds there, `type-reference`, `value-ref` (#2437) and `macro` (#1934) — and,
because `Reference` carries no spelled name, substituted the resolved def's name
via `simpleNameOfDefId`. So `import { Result as ApiResult }` inside
`function unwrap<Result>()` deleted a REAL edge, while a namespace-qualified
target (`Host.Result`) kept a FALSE one, and a positional `@row:col` suffix broke
the last-colon parse outright.

Moved to `lookupForSite`'s `case 'type-reference'` in `resolve-references.ts`,
which has the spelled `site.name` and the reference kind in hand. One line closes
all three, deletes `simpleNameOfDefId` — a byte-identical duplicate of
`simpleNameOfGraphId` — and removes the only `graph-bridge/` -> `scope/` import
in that directory.

Honest scope: all three sub-defects are real in the code but none is observable
end-to-end today (`value-ref` never reaches this path; TypeScript emits no
cross-file USES for a type annotation at all — a separate pre-existing gap). Those
arms are labelled forward guards in the test rather than claimed as repros.

Fixture grows 1 file -> 4; 3 of 9 assertions fail without the change. The
scope-capture TypeScript fingerprint moves for FIXTURE-CORPUS GROWTH ONLY, with
per-file accounting that sums to the delta and JavaScript unchanged as the
control — see the `_rebaselined_` key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): refuse an ambiguous formal, stop the walk at the nearest binding

Two ways W2-2 typed a parameter from the wrong caller, both at the PRECISE 0.9
tier — above every `minConfidence` floor, so nothing downstream can filter them.

`formals` WAS LAST-WRITE-WINS. The key is (filePath, ownerName, parameterIndex),
`ownerName` is a bare identifier, and `emitFormalFacts` emits one site per
parameter of EVERY function collected, nested functions and class methods
included. A plain `.set` let two same-named callables in one file collide — a
free `parse` and a nested `parse`, a free `apply` and `Runner.apply` — so the
last one visited won, fabricating an edge on the loser and leaving the genuine
consumer untyped. The file's own comment covers only the cross-FILE axis.

The correct shape was thirty lines below, in the `producers` map, which does
`producers.delete(cell); conflicted.add(cell)`. `formals` now refuses the same
way: a key claimed by two DIFFERENT parameters is deleted and recorded, so a
third same-named formal cannot re-claim it. Re-stating the same cell is not a
disagreement, so a benign duplicate capture cannot poison a real key.

THE SCOPE WALK CLIMBED PAST A NEARER BINDING. The docblock claimed it stops at
the first scope carrying the name, but it consulted only `parameterProducers` —
a shadowing `const`, a catch binding or an arrow parameter is not in that map, so
the walk went straight past it to the enclosing formal:

    function readSpike(spike) { … items.map((spike) => spike.wickRatio) … }

typed the ARRAY ELEMENT from the outer parameter. `parameterProducerFor` now
stops at the first scope that binds the name AT ALL — reading the scope's own
tables, the same channels and the same reasoning as the sibling
`isNamespaceNameShadowed` — and then stops at a Function boundary. That boundary
is what covers the anonymous arrow: `collectFunctions` drops a callable it cannot
name, so an anonymous arrow emits no formal site and its scope looks empty while
in fact rebinding the name. The cost — a closure genuinely reading an enclosing
parameter now declines — is documented as the deliberate trade.

No cycle guard, deliberately and with the reason stated: both constructions of
`indexes.scopeTree` validate through `buildScopeTree`, which enforces strict
parent-contains-child ranges, so a cycle needs a scope strictly containing
itself. A per-site Set on every read/write site in the repo would defend against
a state the builder rejects.

5 fixtures, 5 assertions; 4 fail without the change and the control passes both
ways. Still uncovered and not faked: a `for (const x of …)` binder shadow — the
binder lives in the loop header, so JS emits no scope to stop at and no Function
boundary intervenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(analyze): measure one population in every config, split the stamp on the verdict

`1b41c9df6` fixed the collapse check for the STREAMED configuration by giving
the sink a `structuralRows` subtotal. It does not cover the other one.

`resolveStreamGraphEmit` and `resolveStreamPdgEmit` both open with a
`force === true` gate, so a run without `--force` streams nothing: there is no
manifest, `structuralRows ?? 0` contributes 0, and
`scope-resolution/pipeline/run.ts:1222` (`input.pdgEmitSink ?? graph`) writes PDG
into the ordinary in-memory graph, where `relationshipCount` counts it. And
`isIncremental` requires an existing meta, so a FIRST run is a full write and the
check runs. A first-time `gitnexus analyze --pdg` on a fresh repo therefore
compared structural+PDG against structural and exited non-zero with
"Repository indexed INCOMPLETELY" on a healthy index.

MEASURED, not assumed — `runScopeResolution({ pdg: true })` with no sink:

    pdgEmitSink        = absent (non-force shape)
    relationshipCount  = 1
    residentPdgInGraph = 1
    byType             = [["CFG",1]]

The prior `residentPdgInGraph=0` was taken on a `--force` run, where
`input.graph` IS the sink; it never spoke to this case. `graph-collapse-wiring.test.ts`
had pinned the gap, asserting a PDG-inclusive in-memory count was a valid
structural expectation.

`countStructuralRelationships(graph)` filters `PDG_EDGE_TYPES` over
`forEachRelationshipFields` — the same predicate the sink uses for
`structuralRows` and the adapter for `structuralEdges` — so all three terms
measure one population in every configuration. Declining whenever
`pdg && !streaming` was rejected: that is the DEFAULT PDG shape, so the guard
would be off for every non-force run including the only full write most users
ever do. An unscannable graph (mocked pipelines) yields NaN, the same fact the
old `undefined + rows` produced and one `detectGraphWriteCollapse` already
documents as expected input.

THE THREE-WAY STAMP WAS A TWO-WAY. The comment enumerated collapse -> stamp,
healthy -> clear, no verdict -> carry forward, but the code split on the WRITE
MODE. `graphWriteCollapsed` is undefined for two different reasons, and one of
them is "the structural query threw" — so on a full run where the count could
not be READ, the code took "healthy, clear it" and erased a stamp recording real
edge loss. Run 3 then printed "Already up to date" forever: the exact failure the
comment says it fixed, reachable through the new code's own `catch {}`.

`detectGraphWriteCollapse` now returns `'collapsed' | 'healthy' | 'unmeasurable'`
with a reason, and `selectPersistedCollapseStamp` is a pure exported function
production calls. Two boundaries worth naming: `expected === 0` is unmeasurable
(its own docstring calls it "could not report a total"), but the small-repo
exemption and a cleared ratio are HEALTHY — both counts were taken. Making the
exemption a non-verdict would leave a stamp unclearable on any repo that shrank
below 100 edges, relocating the wedge rather than fixing it.

`getLbugStats` now reports `structuralEdgesError` and warns, and `run-analyze`
falls back to `stats.edges` only when the run had no PDG layer, where the two are
equal by construction. With `--pdg` on there is no substitute, so the absence
becomes an explicit unmeasurable verdict — which preserves the stamp.

13 tests added; 8 fail without the change. The integration suite now seeds a CFG
row and asserts `edges` moves while `structuralEdges` does not — the exclusion
filter was previously unexercised, its own comment conceding "structural == total
here".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(server): check the collapse before publishing the index

W2-6 marked a collapsed run's job `failed`, but the check ran INSIDE
`.then(() => backend.init())` — after the publish. `LocalBackend.init()` is the
publish step: it refreshes the registry and atomically swaps the in-memory repo
map every MCP tool and HTTP route resolves through, and its `validate` pass
prunes only entries whose metadata is provably gone, so it can publish but never
quarantine. The known-incomplete database was therefore live and queryable before
the job was ever marked failed — the job status was a label on a published index,
not a gate. The pre-existing comment two lines above says so outright: "the repo
is actually queryable when the client receives the SSE complete event."

`backend-client.ts` routes `failed` to `onError` and never calls `onComplete`, so
the UI showed an error toast while every query against that repo answered from
the incomplete graph — precisely the confident-wrong-answers failure this guard
exists to prevent.

The collapse branch now returns before publishing; the healthy path publishes via
a nested `backend.init()` so the trailing `.catch` still converts init failures
into the same message. `closeDbHandle()` runs on both paths — it is eviction, not
publication, and the worker rewrote the DB files regardless of outcome, so
skipping it would leave a stale pre-rewrite handle.

Honest limit, stated in the error string rather than overclaimed: this keeps a
FIRST-TIME analyze unpublished, which is the UI's main flow. On re-analysis of an
already-published repo the existing map entry survives and points at the same
storagePath. A real quarantine needs an un-register hook on `LocalBackend`, which
does not exist today — follow-up.

`'partial'` was considered and rejected on evidence: it is not a status. It is an
embedding-specific detail object in the `updateJob` allowlist; the status union
excludes it. Adding it would make `isTerminalJobStatus` false, so `sse-progress`
never writes a terminal frame and never calls `res.end()` — the stream hangs
open — while `backend-client` falls through to `onMessage` and `api.ts` spins the
full hold-queue timeout. `failed` at least terminates.

The failure branch also now sets `repoName`, which only the success path did.

First tests this file has ever had: 4, of which 2 fail without the change. They
assert the ORDERING, not just the final status, and build the worker message by
calling the production `projectAnalyzeResultForIpc` so a field rename breaks the
test instead of silently disabling the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(processes): count the entry-point cap, and make the disclosure proportionate

W2-3 added a truncation disclosure and then missed the largest ceiling it was
written to report. `findEntryPoints` ends `.slice(0, 200)` and
`entryPointsUnexplored` counted against the POST-slice list, so candidates
201..N were invisible — while the derivation docblock claimed "a new ceiling
added later cannot be forgotten here". An existing one was. On this repo's own
corpus the new counter reads 780 of 980 candidates never ranked in.

`entryPointCandidatesDropped` reports the pre-slice count, folded into
`truncated`, with `ENTRY_POINT_CANDIDATE_LIMIT` extracted and `findEntryPoints`
taking the same optional out-parameter `traceFromEntryPoint` already uses. Its
return contract is unchanged.

THE WARN FIRED ON EVERY RUN. At the shipped defaults — only `maxProcesses` is
overridden — `calleesDropped` fires for any function with 5+ callees and
`tracesDepthCapped` for any chain deeper than 10, so an ungated `logger.warn`
was constant background noise, and a warning that always fires is one nobody
reads. The split is the module's own, from the `ProcessTruncationStats` docblock:
"unexplored entry points mean whole flows are missing, while a depth-capped trace
means a flow is present but shorter than it really is."

So `warn` iff whole flows are absent — candidates dropped, entry points never
traced, or flows dropped at `maxProcesses` — and `debug` for a run truncated only
in depth or breadth. `stats.truncation` still carries all six counters; the
machine-readable channel is unchanged, only the log level moves.
`entryPointCandidatesDropped` stays in the loud set deliberately: it is the only
ceiling that GROWS with repo size, while the other two can only fire while
`maxProcesses` is small enough to bind, so gating on those alone would go silent
on exactly the large repos where 200-of-several-thousand is the thinnest sample.
The message leads with the ratio so the line carries a fact, not an alarm.

THREE COMPARATORS ALLOCATED PER COMPARISON, in the function whose own comment
explains the hoist that removed this shape (`deep_chain` 1233 -> 102 ms).
Measured here: +99 ms once per analyze at 80k functions — small, because `n` is
capped at 200 entry points x a 12-trace budget = 2,400 traces regardless of repo
size. Worth fixing anyway: 23,851 comparisons cost 70,524 joins.

One shared `sortByDepthThenPath` (Schwartzian, key built once per trace) now
serves all three sites, and `rankedByInterest` additionally hoists the `isSink`
test that ran twice per comparison. It also settles a separator inconsistency:
`deduplicateByEndpoints` joined on a SPACE while `traceOrderKey` used NUL, and
node ids embed file paths, so two different traces could produce the same key and
the tiebreak fell back to the insertion order it exists to remove — the same
hazard this series' own `->`-padding fix addresses. Order identity is pinned by a
seeded 200-trace corpus asserting the new sort equals the old one exactly.

11 tests added, 9 failing without the change, including two end-to-end
insertion-order arms. The W2-5 determinism block is unregressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(docs): restore the agent guidance, and put it in the generator that deleted it

Commit `9e602aef0` — whose message is entirely about the fetch capture — also
regenerated the machine-managed `<!-- gitnexus:start -->` block from a local
non-`--pdg` index, deleting from both AGENTS.md and CLAUDE.md:

  - the whole `MUST treat risk: UNKNOWN as unresolved, not as low` bullet
  - the `pdg_query({mode:"controls"/"flows"})` bullet
  - the `mode: "pdg"` text on the impact bullet
  - `…never read UNKNOWN as an all-clear…` from Never Do

and regressing the stats 248612/565510/918 -> 42853/135955/758. All four document
SHIPPED features: `pdg_query` at `mcp/tools.ts:675`, dispatched at
`local-backend.ts:2233`; `mode: "pdg"` at `tools.ts:448`; `riskNote` at eight
sites.

It matters more than a docs nit because the SAME series makes `UNKNOWN` dominate
a mixed candidate set (`local-backend.ts:6058`) — correct, and it makes UNKNOWN
far more common. The surviving rule only warns on HIGH/CRITICAL, so a set
measuring CRITICAL now reports UNKNOWN and that rule no longer fires, while the
rule that covered the gap was deleted in the same commit range, from all three
files agents actually read.

ROOT CAUSE, which is why restoring the files alone would not have held.
`cli/ai-context.ts` is the template. The `pdg_query` and `mode: "pdg"` text IS in
it, correctly `hasPdg`-gated — a non-PDG analyze SHOULD drop those. The
`risk: UNKNOWN` rules were never in the template at all: they had been hand-added
INSIDE the machine-managed region, so every `gitnexus analyze` on any repo
silently deleted them. This was the second occurrence; #2856's `8f8261021` was
the first. Both lines are now generated unconditionally — they describe impact's
risk semantics, which are not PDG-dependent — so regeneration restores them
instead of removing them.

AGENTS.md and CLAUDE.md are byte-identical to origin/main again, and the fixed
template reproduces that block exactly for `hasPdg: true` plus the real stats.
`.claude/skills/gitnexus-guide/SKILL.md` regains the "Inline staleness signal"
section for a live feature (`local-backend.ts:921`, `:1017-1024`, `:1995`); the
npm mirror's lack of it is pre-existing drift and is left alone, so the new sync
guard is scoped to the canonical and plugin copies.

Guards added, both demonstrated failing against the unrestored files: the managed
block must contain the UNKNOWN policy and its Always-Do/Never-Do bullet counts
must not fall below a floor, and `generateGitNexusContent` must render both lines
for `hasPdg` true AND false while keeping `pdg_query` gated. The existing
fragment lists could never have caught this — they assert presence, and this was
a deletion.

One deliberate loosening, called out rather than buried: the restored text pushes
`ai-context.test.ts`'s block-size ratio past 0.55, so it moves to 0.65. That test
argues against exactly this nudge-the-number pattern. The defence is that the
wording is origin/main's own and the 0.55 budget was calibrated against a block
already missing it; trimming shipped guidance to fit a budget would be the wrong
direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-09 11:44:52 +01:00
DuduPhudu
223ac7010d
feat: close reported graph blind spots in reference resolution, analyze and storage (#2856)
* fix(mcp): report UNKNOWN risk when an upstream impact walk finds no callers

`risk: LOW` asserts "safe to change" — a claim ABOUT callers. An upstream
walk that resolved none has nothing to base it on: the symbol may be
genuinely unused, or reached only through a reference class the index does
not record (a property access on a plain object, a bare-identifier read of a
module-scope const). Seeding LOW from an empty result is the false-safe
signal `anyKnownRisk` already refuses to emit on the ambiguous-candidate
path, and that #2687 removed by making an undetermined impactedCount `null`
rather than `0`.

Zero-caller upstream results now report risk UNKNOWN with a riskNote saying
absence of edges is not evidence of disuse. Downstream is untouched: an
empty downstream walk reports resolved callees, not safety.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts

A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default
parameter value, `return LIMIT` — minted no reference site at all, because
JS captured only `@reference.read.member`, which requires a receiver a bare
identifier does not have. So "who uses this constant?", the question behind
every dead-code trim and constants refactor, answered with a confident zero
in both directions.

The rest of the machinery was already in place: `FIELD_KINDS` accepts
`Const`, the scope query already declares it via `@declaration.const`, and
`read` maps to ACCESSES for any resolved target. This adds the missing
capture in VALUE POSITIONS ONLY (call arguments, default-parameter values,
return statements) — a blanket `(identifier)` rule would mint a site for
every token in the file, which is unaffordable at repo scale and would keep
alive the block-local symbols `pruneLocalSymbols` exists to drop.

Cross-file readers are NOT yet covered: the site exists and a call through
the same import statement resolves, but a value-kind def does not link
across the import edge. Recorded as a todo with the investigation.

PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so
a warm cache replays the pre-change capture set and the new edges never
appear — observed directly, a full `analyze --force` produced a
byte-identical graph until the cache was cleared by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(javascript): pin A1/A5 plain-object property acceptance criteria

Fixture plus todo specs for the four shapes plain-object property access has
to answer: object-literal keys indexed as Property nodes, a read through the
holding variable, a property WRITE, and a read through an untyped param.

Records the investigation so the work is resumable: the parse-query pattern
scoped to literals bound to a variable matches correctly (verified against
the raw JAVASCRIPT_QUERIES), but no Property node reaches the graph and
local-symbol-pruner is not the cause — it drops only Const/Variable/Static.
The remaining gate is in the parse worker's node-creation path.

No production code — specs only, so the suite stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(javascript): index object-literal keys of a named object as Property nodes

Idiomatic JS models configuration as an object literal, not a class, but
Property definition nodes existed only for DECLARED CLASS FIELDS. A config
field therefore had no symbol at all: `context({name: 'exitMinAtrMult'})`
answered "not found" for a field read and written throughout a live code
path, and ACCESSES had no target to point at.

Both halves are added for keys of a literal BOUND TO A VARIABLE — the parse
query mints the graph node, the scope query mints the def the resolver can
aim at. Unbound literals are deliberately excluded: an inline call argument
or a JSX prop bag is call-site data, not a named surface other code
references, so a node per key there would add volume without adding an
answerable question.

This lands the definition-node half only. The ACCESSES edges still require
receiver resolution — typing the const that holds the literal to the
literal's scope for the precise case, and name-based matching at reduced
confidence for the untyped-param (option bag) case. Both are recorded as
todos with the mechanism each needs.

Also records a trap that cost a wrong conclusion: under vitest the parse
worker runs the BUILT dist code (parse-impl resolves parse-worker.js, absent
under src/, and falls back to dist), so parse-query changes are invisible to
tests until `npm run build`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(cache): move the SCHEMA_BUMP pin to 45

The pin is the guard that makes two branches claiming one cache-schema
number fail loudly instead of silently serving each other's entries, so a
bump is only half-done until the pin moves with it. The bump itself landed
with the JavaScript bare-identifier captures; this is the other half.

Caught by the guard working exactly as designed — the suite failed with
"expected 45 to be 44" rather than letting a mismatched pair through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve plain-object property access by unique name

Idiomatic JS reads configuration off an object whose receiver cannot be
typed — an options bag passed as a parameter, a destructured handle, an
imported literal. No precise pass resolves those, so a field read and
written across a live code path produced no ACCESSES edge at all and "who
reads this setting?" answered a confident zero.

A last-resort pass runs after every precise pass and sees only what they
left behind. For each still-unresolved read/write site it asks whether
exactly ONE Property in the workspace carries that name. If so the read
almost certainly means it. If two or more do, nothing is emitted and the
site is COUNTED as ambiguous — a guess between them would be a coin flip,
and a wrong edge in the pre-edit safety gate is worse than a missing one.

Uniqueness is the right gate because it recovers exactly the names worth
recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt)
are unique in a repo and resolve, while generic keys (id, name, data) are
not and are skipped — which is where name matching would over-connect.

Bounded four ways:
- Confidence 0.5, the global tier, with the inference named in the reason,
  so a consumer can filter inferences without losing scope-resolved edges.
- Never second-guesses a precise result: sites already resolved are
  excluded, because first-write-wins stops a duplicate but NOT a second
  edge to a different target.
- Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts
  out of name matching precisely because it over-connects; inferring an
  ACCESSES edge by name is the same claim and must obey the same opt-out.
- Requires an explicit receiver — a bare identifier is not a property
  access, and matching one by name would link a local to an unrelated key.

Indexes graph nodes rather than scope defs because an object-literal key
mints a Property NODE but no scope-resolution DEF: `localDefs` and
`scope.bindings` are both empty for exactly the population this serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(analyze): record a collapsed graph write instead of reporting fresh

The dangerous half of a broken refresh: metadata IS written, so the index
reads as fresh, hooks re-arm, and every tool answers from a graph missing
most of its edges — indistinguishable from a codebase that genuinely has no
such relationships. Reported in the field as edges collapsing 23009 -> 2170
and as a CodeRelation table that never materialized.

`analyze` now compares the relationship count the pipeline PRODUCED against
what the DB hands back after the write. Both numbers are already in scope at
the same point, so the shortfall is provable rather than inferred — no
comparison against the previous index, which cannot distinguish a failed
write from a repo that legitimately shrank. A missing relation table needs
no special case: it reads back as a persisted count of zero.

On a collapse the run records `graphWriteCollapsed` in metadata, which
`getIndexIncompleteReasons` turns into `graph-write-collapsed` so status and
the MCP resources report the index INCOMPLETE rather than fresh.

A ratio, not equality: some relationship types do not round-trip one-for-one
and `--pdg` writes MORE rows into the same table, so demanding equality
would fire on healthy runs. Only a collapse is a defect. Fail-safe when the
expected count is unavailable — an implementation that offloads
relationships out of memory may not be able to report a total, and a false
"your index is broken" is worse than a missed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ingestion): qualify object-literal Property ids by their owning object

Two config objects in one file that share a key name generated the same
`Property:<file>:<key>` id and COLLAPSED INTO ONE node, so two distinct
settings became a single symbol. Worse, the merged name then looked
workspace-unique to name inference, which happily resolved reads of it to a
node representing both — a wrong edge in the pre-edit safety gate, which is
precisely what the unique-name pass is bounded to avoid.

`objectLiteralOwnerInfo` already existed for exactly this ("so two
constructors in one file that both define `bar` stay distinct nodes") but
was gated to `Method`. `Property` now opts in.

`findObjectLiteralBindingInfo` returns `ownerName` only when asked. Its
`Method` ids must stay byte-identical — qualifying them would rewrite every
object-literal method id in every indexed repo — while object-literal KEYS,
indexed only since A1/A5, have no such history to preserve.

Found by a test written for the ambiguity path rather than by review: the
suite reported one node where two were expected, and an edge where none
should exist. Both are now pinned, along with the detection boundaries of
the B2 collapse check, which was previously an untestable inline expression
and is now a pure function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): index type aliases and shape members as symbols

A TS frontend models its API contracts as `type X = { … }` and `interface`,
so a field on one is exactly what "who breaks if I remove this?" is asked
about. Three gaps made that unanswerable, all in the TypeScript queries:

1. No `type_alias_declaration` -> `@definition.type`, so an alias minted NO
   NODE AT ALL and a context() lookup on an exported contract type answered
   "Symbol not found". TypeScript was the ONLY language missing this — Rust
   (type_item), Kotlin (type_alias), Swift (typealias_declaration) and Dart
   all emit it. The alias was declared for scope resolution but never became
   a graph symbol.
2. No `property_signature` in the parse query, so INTERFACE members minted no
   Property nodes either — the upstream report's "class/interface index fine"
   holds only for the type, not its fields.
3. No `property_signature` in the scope query, so even with nodes present the
   resolver had no member declaration to aim at. Its sibling
   `method_signature` -> `@declaration.method` already existed; only
   properties were missing.

Interface bodies and object-type aliases both spell members as
property_signature, so one pattern per query covers both shapes.

Lands the SYMBOLS, not yet the ACCESSES edges: the shape is already a
class-like scope and now has member declarations, but no edge forms — the
remaining link is owner/type-binding, recorded as todos with the diagnosis.
Note TypeScript sets fieldFallbackOnMethodLookup:false, so unlike JavaScript
there is deliberately no name-based fallback here; the precise path is the
only route by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(golden): accept interface members in the mini-repo snapshot

Drift is entirely the new TypeScript shape-member indexing: the fixture's
three interfaces (ValidationResult 2, DbRecord 3, LogEntry 3) contribute
exactly 8 Property nodes, each with exactly one HAS_PROPERTY owner edge.

Verified before regenerating rather than after: every pre-existing count is
untouched (CALLS 9, IMPORTS 12, DEFINES 16, HAS_METHOD 1, MEMBER_OF 12,
STEP_IN_PROCESS 12), so nothing was rewired — the digest moved only because
8 edges were added. The fixture's inline `return { valid: false, … }`
literals correctly produced nothing, confirming the object-literal rule
stays scoped to variable-bound literals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): never report a collapse from a non-numeric count

The B2 check reported healthy runs as total graph-write collapses. A
non-numeric `expected` (a graph implementation reporting no total, a
lightweight pipeline result) does not skip the guards — it INVERTS them:
`undefined < 100` is false, so the small-repo exemption never fires, and
`0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check
"passes" as well. Both bounds silently evaporate and every such run is
flagged.

That is precisely the failure this check was written to catch, reproduced
inside the check itself: an unmeasurable quantity treated as a measured
zero. Both sides are now validated as finite numbers before any comparison.

`persisted` is also passed as UNKNOWN rather than zero when the DB was not
demonstrably readable: `getLbugStats` flattens "no connection", "query
threw" and "empty table" all into `edges: 0`, so `stats.nodes > 0` is used
as independent evidence the read happened at all.

Caught by the existing run-analyze suites, not by the new unit tests — those
exercised the pure function with well-formed numbers and were blind to the
integration's actual inputs. Both cases are now pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): make object-type aliases own their members

A TS object-type alias declares the same `property_signature` members as the
interface beside it and answers the same question, but was not a member
owner: its fields were minted with bare ids and no owner edge, so two
aliases in one file sharing a field name collapsed onto one node, while the
identical interface resolved normally.

`type_alias_declaration` joins CLASS_CONTAINER_TYPES (and
CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container
missing there gets orphaned member edges or a wrong owner label). Aliases
with no object type (`type Id = string`) declare no members, so they own
nothing and are unaffected.

This also lands the INTERFACE field -> consumer edges, verified on the
mini-repo fixture rather than only on a purpose-built one: `saveToDb` now
links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level`
and `LogEntry.message` — three real contract-field reads that previously had
no graph path at all. Golden updated: +3 ACCESSES, no node changes.

The ALIAS field -> consumer edge is still not linked and is recorded as a
todo with the exact blocker: resolving a receiver typed as the alias needs
the NAME to resolve to a class-like def, and `isClassLike` is
Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12
sites including MRO and heritage, and every language mints TypeAlias, so
widening it would enrol aliases in linearizations where they do not belong.
Widening only the scope index was tried and reverted — the type-name walkers
gate on it independently, so it fixed nothing and left dead code. That needs
a deliberate "shape-like" concept, not more call-site widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): record the traced diagnosis for the unlinked alias field edge

Traced to the end rather than left as "needs investigation", so the next
attempt starts from facts:

  1. Graph side is COMPLETE and symmetric with the interface —
     Property:...:LiveModeConfig.bookSlots is owner-qualified and carries
     HAS_PROPERTY.
  2. Resolution DOES reach resolveClassBindingForName('LiveModeConfig')
     (instrumented) and misses.
  3. It misses because the module scope binds LiveModeIface:Interface,
     renderAlias, renderIface — and not LiveModeConfig. The alias has no
     binding on the receiver's scope chain at all.
  4. The TS scope query tags aliases @declaration.type, but normalizeNodeLabel
     accepts only typealias / type_alias and has no "type" case, so it returns
     undefined. Kotlin and Dart use @declaration.type_alias; TypeScript is
     alone on the dead tag.
  5. Retagging is NECESSARY BUT NOT SUFFICIENT — tried, and the binding still
     does not appear, so a second gate exists in how a declaration anchored on
     a node that is ALSO a @scope.class anchor is attached: the alias appears
     to bind inside its own scope rather than hoisting to Module, where
     interface_declaration evidently does hoist.

An isShapeLike predicate (the nominal-vs-structural split: shapes declare
members, nominal types participate in MRO) plus a mirrored
findShapeBindingInScope were built and REVERTED along with the retag. With no
binding on the chain they never fire, and shipping inert widening is worse
than shipping none — the same standard applied to the earlier scope-index
attempt. The design is recorded here; it is worth doing once step 5 is fixed,
and it also unblocks Rust's parked union_item, which the MEMBER_OWNER_NODE_TYPES
comment documents as the same gap in another language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve cross-file value references, skip block-locals

Two halves of the same question, "who uses this constant?".

CROSS-FILE. `resolveReferenceSites` runs against the registries and, as its
own comment says, "imports live in finalized bindings the registries can't
see" — which is why free CALLS need `emitFreeCallFallback`. Reads had no
counterpart, so `import { LIMIT }` followed by a bare use resolved to nothing
while a CALL through the very same import statement resolved fine. This adds
the read/write counterpart, reusing `findValueBindingInScope` (which walks the
FINALIZED chain) rather than inventing a lookup. Confidence 0.9: the import
names the def, so this is precise resolution, not inference.

BLOCK-LOCALS. Bare-identifier capture also matches a read of a block-local
`const`, and an edge to one keeps alive exactly the inert locals
`pruneLocalSymbols` exists to drop — a pruned node becomes a retained node
plus an edge, in every function of every indexed repo. Emission now takes the
set of value defs bound at MODULE scope and drops ACCESSES to
Const/Variable/Static outside it. The cross-file pass carries the same
guarantee structurally: a def in another file cannot be a block-local of this
one, so it skips same-file hits entirely.

The block-local leak was already shipped in the intra-file A2 commit and was
found only because a test was written for the guard rather than the feature —
the same way the object-literal id collision surfaced.

Verified on the full resolver matrix: 3172 tests, golden unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(lbug): diagnose a vanished staging CSV instead of surfacing a Binder error

A forced rebuild could fail with "COPY failed for File: Binder exception: No
file found that matches the pattern .gitnexus/csv/file.csv" and then an ENOENT
on .gitnexus/csv/rel_Folder_File.csv — two engine-level messages that name
neither a cause nor a remedy, which is where several field reports end.

Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so an
absent file was WRITTEN during this run and removed since. Both COPY loops now
preflight and say exactly that, with the row count, both causes the reports
point at (a second `gitnexus analyze` on the same repo — they share
.gitnexus/csv — or an external cleanup of .gitnexus/), and the action to take.

Scope note, deliberately narrow: this does not attempt to fix WAL corruption
or checkpoint rotation. Those already have detection and recovery hints
(isWalCorruptionError, WAL_RECOVERY_SUGGESTION, the configurable
wal-checkpoint-threshold), and the ~6000 lines added to lbug/ + storage/ since
v1.6.9 — index-lock.ts most of all, which serializes writers and plausibly
closes the concurrent-run class outright — postdate every report in the
window. Guessing at unreproducible durability faults would be speculation;
making the one failure with NO handling legible is not.

An existing overlap test induced this exact scenario (a manifest entry
pointing at a missing csv) and asserted on the engine's wording. Its intent —
that a node-COPY failure is rethrown at the FK barrier rather than swallowed —
is unchanged and still asserted; only the message it matches moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): split shape-like from class-like, linking alias fields

Completes A4: a field on a TypeScript object-type alias now links to the code
that reads it, the last unanswerable half of "who breaks if I remove this?"
for a TS frontend that models contracts as `type X = { … }`.

`isClassLike` answered two questions that only coincide for classes:
  1. does this declare MEMBERS I can look up?   — a SHAPE (structural)
  2. does this participate in inheritance / MRO? — a NOMINAL TYPE
An object-type alias is (1) and emphatically not (2) — it has no supertypes
and no place in a linearization. Widening `isClassLike` to buy (1) would have
enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart
typealias, C typedef) into MRO and heritage, so the two questions now get two
predicates. Call sites split by which they ask, and their names already said
which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase`
keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`.

Three parts, each necessary and none sufficient alone:
- `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established
  relationship to `findClassBindingInScope` (same walker, different accepted
  def-type), consulted only AFTER the class lookup misses so a class of the
  same name always wins.
- `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and
  are registered under the alias. Without this the receiver resolved to the
  alias and then found no members under it.
- The TS scope query tags aliases `@declaration.type_alias`, not
  `@declaration.type`: `normalizeNodeLabel` accepts typealias / type_alias and
  has no "type" case, so the old tag mapped to NO label and TypeScript aliases
  produced no scope-resolution def at all. Kotlin and Dart already spelled it
  this way; TypeScript alone was on the dead tag.

An earlier attempt concluded a further "scope-attachment gate" existed. That
was wrong and is worth recording: scope extraction runs in the parse WORKER,
which loads built `dist`, so the retag was never executed. Rebuilt, the alias
hoists to Module scope exactly as the interface does. Same trap as the parse
query — `src` edits to anything the worker runs are invisible until
`npm run build`.

Typedef and Union stay out of `isShapeLike` deliberately: they belong
conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same
gap) but neither is wired as a member container, so including them would widen
a predicate nothing exercises.

Verified on the full resolver matrix: 3173 tests, golden unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(typescript): pin the type-alias capture to a tag that maps to a label

The capture test asserted `@declaration.type`, the tag that
`normalizeNodeLabel` does not recognize (it accepts typealias / type_alias and
has no "type" case). So the test passed for as long as the tag was broken: it
checked only that the capture FIRED, never that it resolved to anything, while
TypeScript aliases produced no scope-resolution def at all.

Updated to the working tag and given a second assertion that the derived kind
string is one the label mapper accepts — the property that actually matters,
and the one whose absence let a dead tag sit pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(lbug): declare TypeAlias member pairs so analyze does not abort

Making object-type aliases member owners emits HAS_PROPERTY from a `TypeAlias`,
and the relation schema declared no such pair. The emit therefore threw
`UndeclaredRelationPairError` and the ENTIRE analyze died on any repo
containing `type X = { ... }` — a hard stop, not a dropped edge. Found by
running the analyzer over a real 16k-node TypeScript repo, not by a test.

`Method` is declared alongside `Property`: a member written
`type Handler = { onClick(): void }` is a method_signature and would fail in
exactly the same way.

Why every existing test missed it: the resolver suites build an in-memory
graph via `runPipelineFromRepo` and never write to LadybugDB, so the schema
constraint was never exercised. `structural-pair-coverage.test.ts` is the one
suite that does run the emitters against the declared pairs — and its own
docstring names the gap: coverage is bounded by NON_BRIDGE_CORPUS, "a new
structural emitter should land with an entry here". This adds that entry,
pinning TypeAlias|Property and Interface|Property as sentinels.

Verified the guard is not vacuous: removing the pair again makes the suite
fail with undeclaredPairs: ["TypeAlias|Property"].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes): trace depth-first so multi-hop flows are detected

D1 ("query ranks frontend components above the backend module that owns the
concept") and D2 ("processes is dominated by trivial mechanical chains") are
the same defect, and neither is about ranking or selection.

The walk stops after a fixed NUMBER of traces, so traversal order decides which
traces those are. Breadth-first reaches every shallow terminal before any deep
one, so the quota filled with the shortest paths in the graph and the walk
stopped — `maxTraceDepth: 10` was never approached. Measured on a real repo
before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A
multi-hop business flow (signal → order → exit) therefore had no process that
could represent it, and `query` could only rank the mechanical pairs that did
exist. Step 4 of the caller already sorts by length and dedupes by endpoint —
it was always asking for the deepest traces this walk could give it.

Depth-first descends to a terminal first, so the same quota is spent on paths
worth keeping. Cost is unchanged: same budget, same cycle guard, same depth
ceiling — only the order differs.

Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an
earlier comparison was discarded as confounded — it crossed builds and --pdg):

  steps   6-8:  50 → 168   (3.4x)
  totals:      844 → 806

and the reported query moved from `LiveSetupView → Cn` (a React component) to
`ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side
exit management, which is what was asked for.

`traceFromEntryPoint` is exported for the test. Traversal order is unobservable
through `processProcesses`: `findEntryPoints` supplies several starting points,
so a deep chain is traced from inside it whatever the order does. A test at
that level passes under BOTH traversals — the first version of this test did
exactly that and guarded nothing. Driving the walk directly, it fails under
breadth-first with "expected 3 to be greater than 3".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): correct a stale status note left behind by a later fix

The A1/A5 header still said "edge resolution REMAINING ... neither is
implemented". Both shapes resolve — the typeable receiver precisely, the
untyped one by workspace-unique name — and the tests below assert exactly that,
so the note contradicted the file it sat on.

It was accurate when written and went stale when the work continued past it.
Left as-is it would tell a reviewer that a landed feature is missing.

The TRAP note is kept: the parse worker still runs built dist under vitest, and
that is still the trap it describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): index literals behind identity-preserving wrappers

`export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no
`Property` node for any of its keys. The object-literal rule matches
`variable_declarator > value: (object)` as a DIRECT child, and freezing puts a
call expression in between — so the shape whose fields are most worth querying
was the one shape the rule could not see. Freezing a config object is how JS
publishes an immutable contract, which is why this reads as a confident zero
on exactly the fields a reader cares about.

The allowlist is three functions, not "any call". `Object.freeze`, `seal` and
`preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes
the literal's keys members of the bound name. For `const x = compute({ a: 1 })`
the literal is an argument and `x` holds compute's return value, so attributing
`a` to `x` would be a fabrication.

Two negative controls, because the obvious one is vacuous: a bare-identifier
callee is rejected structurally and would pass with no allowlist at all, so the
assertion that actually pins the predicate uses `Object.entries` — identical
shape, differing only by name. Verified load-bearing by adding `entries` to the
allowlist and watching that test alone fail.

SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix
capture set. Observed as a false negative first — `analyze --force` returned
the old node set until the on-disk cache was removed by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): narrow multi-candidate property names by scope

Workspace uniqueness was the wrong denominator. Measured on the reporting
repo: `exitMinAtrMult` has 26 `Property` definitions — 16 in one-off
`scripts/`, 7 in the frontend, one in a test, and exactly ONE in the backend
that reads it. Every backend read was refused because of competitors the
reader cannot see. The gate was not too permissive or too strict, it was
scope-blind.

A name with several definitions is now narrowed before being abandoned:
same-file first, then files the reading file directly imports, using the
finalized import graph rather than a path-shape heuristic. Exactly one
survivor at the first non-empty tier resolves; anything else stays refused.
A tier holding several candidates stops the walk instead of falling through —
local evidence that is itself ambiguous still contradicts reaching further out.

Confidence stays 0.5 at every tier. Narrowing changes which candidate is
chosen, not the kind of claim: it is still a name match, and the round-1
contract is that filtering on confidence drops all name inference at once.
The reason string now names the tier that fired.

Ambiguity reporting goes from a count to the actual names (capped), because a
count says a gap exists while the names say which fields are unanswerable.

Measured on that repo, backend readers of `exitMinAtrMult` go 0 -> 24 and
total readers 9 -> 45, including the two call sites in
`oppositeSignalExitManager.js` the report singled out. Both narrowing tests
were mutation-checked by dropping the import evidence and confirming they, and
only they, fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): capture destructured parameter keys as property reads

`function exit({ exitMinAtrMult = 0 })` reads that property off whatever the
caller passes, exactly as `cfg.exitMinAtrMult` would. It never appears in a
member_expression, so it had no reference site at all — and this is the shape
the function that IMPLEMENTS a behaviour uses, so the most relevant reader was
the one systematically missing from "who reads this setting?".

Uses a distinct `@reference.read.destructured` anchor rather than
`@reference.read.member`. The latter is filtered emit-side to matches with a
member_expression ancestor, because calls and writes share its shape, and a
destructuring pattern has none — reusing the tag would have been silently
dropped by that filter. The `read.` head already maps to a read kind, so no
mapping change is needed.

Scoped to formal_parameters. A destructuring binding elsewhere
(`const { x } = require('m')`) is frequently an import rather than a field
read, and minting a property read there would attribute module bindings to
unrelated same-named keys.

All three cases (default value, bare shorthand, renamed key) mutation-checked
by removing the patterns and confirming those three tests, and only those,
fail. The renamed case also asserts the edge points at the KEY and that the
local alias mints nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): link type consumers to the type they name

An exported contract type owned its members after round 1 and still answered
`incoming: {}`, so "what breaks if I remove this field?" — the question a
contract type exists to answer — had no edge to walk. Measured on the
reporting repo: all 324 TypeAlias nodes AND every Interface node had DEFINES
as their only incoming edge.

Two independent causes, and the second is why the first was not enough.

TypeScript captured no type references at all — only cpp and csharp did — so
an annotation naming a declared type minted no reference site. Added for
annotations, generic arguments and `as` assertions, anchored to those contexts
rather than a bare `(type_identifier)`, which would also match the name in
`type X = …` and make every declaration a consumer of itself.

That alone fixed interfaces and left aliases still empty. `TypeAlias` was
missing from `LINKABLE_LABELS`, so alias graph nodes were never indexed in
`nodeLookup` and `resolveDefGraphId` could not bridge a def to its node — the
edge was dropped AFTER a successful lookup. `CLASS_KINDS` has always listed
TypeAlias and the ClassRegistry returned the def correctly, which is what made
this read as a resolution failure; instrumenting the lookup showed it
returning the right def all along and moved the search one table over. Exactly
the bug already documented two entries above it for Trait.

Fixes every language that spells an alias this way — TypeScript, Kotlin, Dart
and Rust all emit `@declaration.type_alias`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): capture record construction as property writes

The read side answered well after the narrowing work while "who SETS this
field?" still missed the code that stamps the value. A record built inline —
`return { exitContract: { exitMinAtrMult: settings.x } }` — is bound to no
variable, so it minted no definition and its keys referenced nothing.

Modelled as WRITE REFERENCES, deliberately not definitions. The round-1 rule
already mints Property nodes for literals bound to a variable; minting more for
anonymous records would add same-named competitors to the very name-narrowing
that makes these fields resolvable — measured at 26 competing definitions for
one field on the reporting repo, which is what made every backend read
unanswerable in the first place. A construction site is a USE of a field, not
another declaration of it.

Two positions only: nested under a key, and returned. Both are records with a
name attached (the key, or the function). An inline call argument
(`doThing({ id: 1 })`) stays excluded for the same reason round 1 excluded it
from definitions — it is call-site data, not a named surface — and is asserted
as such.

The enclosing literal is the receiver and it is anonymous, so these route
through the same narrowing and the same refusal-to-guess as every other
untyped receiver.

Verified on the reporting repo: `entryPlan.js` went from no rows to
`selectExitEnvelope` as a writer of `exitMinAtrMult`. Both captures
mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(processes): select round-robin by terminal so the list is not one flow repeated

Ranking was `sort by length` alone, so the top of the list was one behaviour
described many ways: eleven of the top fourteen processes on the reporting
repo were four entry points crossed with three terminals of the SAME
date-window utility cluster. Genuine call chains, but a reader learns one
thing from fourteen entries, and the repo's own domain flows sat below them.

Selection now round-robins across TERMINALS, deepest first. Depth still orders
within a terminal and still leads the list; what changes is that no terminal
takes a second slot until every other has had a first.

Keying on the entry point was tried first and made it worse — many files
declare a `main`, so each was a distinct entry that round-robin then awarded
its own slot, and `Main -> AlignWindowEnd` went from one row to eight. The
repetition was never in where a flow starts.

Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its
domain flows (`ReconcilePositions -> ...`) moved into the top 4%.

Two things this deliberately does not claim. The reported cause — ranking
rewarding fan-in, promoting chains ending in widely-called helpers — measured
FALSE: those terminals have one caller each (`alignWindowStart` 1,
`validateSymbol` 1). A fan-in discount was implemented against that hypothesis,
measured, and reverted for moving nothing. And a business flow still cannot be
a process in its own right: the walk only emits at a leaf, at max depth, or on
a cycle, so a flow whose meaningful endpoint calls onward survives only as
whatever leaf it bottoms out in. Both are recorded in the code so neither
reads as settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(structural-pairs): pin the type-annotation USES pair

R2-2 emits USES INTO a `TypeAlias`, so the pair is `Function|TypeAlias` — a
different table from the `TypeAlias|Property` entry added in round 1, and one
that entry stays green without. `TypeAlias` is on the eleven-table list this
suite exists for, and an undeclared pair does not degrade: it throws
`UndeclaredRelationPairError` and kills the entire analyze on any repo
containing an annotated type. Every resolver suite still passes, because they
build an in-memory graph and never write to the DB.

That exact failure shipped once in this PR already. Two emitters into the same
label, each with its own way to reach a released build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): build the module-level set before the out-of-core seal

Review blocker. Under `GITNEXUS_DISK_SCOPE_INDEX=1` the seal replaces every
ParsedFile with a scope-STRIPPED copy, and the block-local filter's set was
built after it — so it walked `scopes: []` for every file, came out empty, and
the filter read that as "no def is module-level" and dropped EVERY
`Const`/`Variable`/`Static` ACCESSES edge in the repo. All languages, all
files, including the module-scope-const edges this PR exists to add. Nothing
threw and nothing logged, on the path the largest repos take: the exact
confident-empty answer the PR is about.

Built above the seal now, from `parsedFiles`, and passed as `undefined` rather
than an empty set when no scope was inspectable — an empty set is a legitimate
answer ("this repo has no module-level value defs") and must not be
indistinguishable from "could not look". Fails open; the block-local exclusion
is still asserted under the seal, since that is correctness rather than
optimization.

Also widens module level past `kind === 'Module'`. A `Namespace` scope (TS
`namespace`, Rust `mod`, C++/C# `namespace`) holds importable values too, and
treating its consts as function-locals dropped their reads. Included only when
the whole chain to the root is Module/Namespace, so a namespace declared inside
a function body stays local — asserted both ways.

That fixture then failed for a third reason: `@reference.read.identifier`
existed ONLY in the JavaScript query, so A2 did not work for TypeScript at all.
Added there, and both languages widened to `variable_declarator value:` and
`binary_expression` operands — the gaps review named between what A2 claimed
and what it matched.

Nothing covered `GITNEXUS_DISK_SCOPE_INDEX`. The new parity test asserts the
seal changes no edge, and was verified against an emulation of the original
bug: same-file readers vanish and only the cross-file reader survives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(typescript): anchor property_signature to declared shapes

Review blocker, and it reproduces end to end. `property_signature` occurs in
EVERY object_type in the TS grammar, not only in an interface body or an
alias's object type, so inline parameter types, inline return types and nested
object types all matched — and the enclosing-container walk hung each one off
the nearest class, interface or alias. Measured against the unanchored rule,
all four appeared as members of shapes that do not have them:

  Property:contracts.ts:Svc.inlineParamOnlyKey
  Property:contracts.ts:Repo.inlineQueryOnlyKey
  Property:contracts.ts:NestedConfig.nestedOnlyKey
  Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33

When the inline member shares a name with a real one — `run(opts: { retries:
number })` inside a class that declares `retries` — `addNode` is
first-write-wins and the two distinct symbols merge onto one node, so every
context()/impact()/rename() answer about that field describes the merge. The
sibling JS object-literal rule in this same PR is anchored for exactly this
reason; this is the TypeScript half of the same fix.

`(A (B))` matches DIRECT children, so nested object types are excluded by the
same anchor rather than by a second rule.

The first version of these tests was VACUOUS and is recorded here because the
reason generalizes: a collision and a correct exclusion both leave exactly one
node behind, so counting ids cannot distinguish them. Every inline member in
the fixture is now uniquely named, which is the only thing that discriminates —
verified by restoring the unanchored rule and watching exactly those four
assertions fail. A fifth test asserts anchoring costs no real member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analyze): correct the numbers feeding the graph-write-collapse guard

Review blocker. The predicate itself held under adversarial probing; every
defect was in what it was handed and what happened after it fired.

(a) `expected` was wrong twice. Under `GraphEmitSink` streaming the bulk types
leave the heap at parse time and never enter `relationshipCount`, so the count
understated the real volume by most of it and the ratio passed trivially —
on `force === true` runs, which include crash recovery AND the
`analyze --force` retry this check's own warning tells the operator to run.
Adds the manifest totals, the same correction the buffer-pool hint in this file
already makes for the same reason. Separately, an incremental run persists only
the changed subgraph while both counts are whole-scope: a 10,000-edge index
that lost 200 replacements reads 9,800 and is certified complete. The check is
skipped on that path rather than answered wrongly.

(b) A throwing edge count became a measured zero. `getLbugStats` initialised
its total to 0 and ran the query in a swallowing catch, so WAL/lock contention
during finalize — documented on this exact call — reported a healthy index as a
total collapse. It now returns `number | undefined`, and the caller requires
both a readable node count and a defined edge count.

(c) A total loss was exempted for being small. The min-edges rule tested
`expected` before looking at `persisted` at all, so `expected = 99,
persisted = 0` — every edge gone — stayed fresh and reported success. Total
loss is now decided first. The existing test asserted the defect; it now
asserts a PARTIAL shortfall, which is the case the exemption was written for.

(d) A detected collapse reported success and exited 0. It is different in kind
from the other incomplete reasons: those describe a run that did what it said
and left work for later, this one means most of your edges are gone and every
query answers a confident empty. The CLI now prints INCOMPLETE with the counts
and sets a non-zero exit code, and the flag crosses IPC so the worker cannot
send a clean `complete` either.

Nothing exercised this wiring — only the pure helper. Adds tests for all four,
each written so the pre-fix arithmetic fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): keep unique-name property inference inside one language

The pass indexed `Property` nodes from the whole shared graph. Per-language
gating decides whether it RUNS for a language; it never restricted which nodes
could be TARGETS. So the only carrier of a name could be in another language
entirely, and a read here resolved to it on name uniqueness alone — no owner,
no file, no call path.

Reproduced: a Java class declaring `private int loyaltyPointsBalance` and a JS
`cfg.loyaltyPointsBalance` on an untyped parameter produced an ACCESSES edge
from the JS function to the Java private field. Confidence does not mitigate
it, because `minConfidence` defaults to 0 — the tier is only a filter for
consumers who ask for one.

Candidates are now restricted to files in the language's own `parsedFiles`,
which is a precise restriction rather than a heuristic and needs no new node
property.

Every other fixture in the suite is single-language, so this could not be
caught anywhere by construction. The new fixture is deliberately polyglot and
asserts both halves: no cross-language edge, and a same-language unique name
still resolves.

Known and not addressed here: the index is still O(total graph nodes) and is
rebuilt once per qualifying language, the per-language whole-graph-scan pattern
`phase.ts` hoisted out for `sharedNodeLookup`. Hoisting it belongs with that
machinery rather than in this fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(processes): explore siblings in source order, log the exhausted budget

`slice(0, maxBranching)` selected the FIRST N callees while `pop()` explored
them LAST-first, so the trace budget went to the last-declared branch. For
`main() { init(); loadConfig(); run(); shutdown(); }` the walk spends itself on
`shutdown` and can drop `init` — the earliest steps of a flow, which is the
opposite of what a process describes. Selecting first-N and exploring last-first
was simply inconsistent; pushing in reverse makes the stack pop in source order.

Measured on the reporting repo, this costs depth: 6-8 step processes go 168 ->
146 of 816. Still roughly three times the pre-PR baseline of 50, and the right
trade — a deep branch is no longer reached by accident of being declared last.

The remaining limit is the BUDGET, not the traversal: with a fixed quota a deep
branch declared after enough shallow ones is not reached at all. That is now
asserted in both directions rather than left implicit, and the walk logs when it
stops with branches unexplored — a silently truncating cap reads as "this is
everything", the same confident-empty answer this work is about, and the repo
already sets that precedent for `dispatchFanoutSkipped`.

Removes the second depth test, which was vacuous: the note twelve lines above
it already said a `processProcesses`-level depth assertion passes under BOTH
traversals, and measured it does — breadth-first yields the same deepest
stepCount of 8, so it passed with the production change reverted. Traversal
order is asserted against `traceFromEntryPoint` directly; what is observable at
the pipeline level is which traces survive selection, which the diversity tests
cover.

Also renames `queue` to `stack` and corrects the BFS references in the module
docstring and the function's own JSDoc, which is what an IDE hover shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(impact): carry riskNote onto ambiguous candidates and separate UNKNOWN's two meanings

Two problems on the ambiguous fan-out, which builds its own candidate object
rather than returning the single-symbol shape.

The narrowed type had no `riskNote` field and never read one, so a candidate
that resolved and found no callers reported `risk: UNKNOWN` with nothing
attached — losing the entire point of the change on the path where the reader
has the least context, since the name is ambiguous there by definition.

And `UNKNOWN` used to mean exactly one thing on this path: the probe threw. The
zero-caller branch gives it a second meaning, so an all-UNKNOWN fan-out could
no longer be told apart from a broken one. Candidates now carry `probeFailed`,
and the comment asserting the old reading is corrected.

Also aligns `gitnexus-web`, which review flagged as giving a different verdict
for the same symbol. That surface answers in prose rather than an enum, and its
message said the symbol "appears to be unused (not called by anything)" — the
identical false certainty in words. It now carries the same MEANING rather than
the same field. Downstream wording is unchanged: no outgoing dependencies
really is a fact about the symbol itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: replace assertions that cannot fail

Four from review, each satisfied by the defect it was meant to catch.

`new Set(props).size === 2` over two different literal strings can only ever
be 2, so it could not detect the node merge its title promises — that is a
difference in COUNT, now asserted on the raw array.

The ambiguity test asserted only an empty edge set, which is satisfied equally
by "the gate fired" and "the name was never looked up". It now also requires
the ambiguity counter to have moved.

`Interface|Property` was listed as a structural-pair sentinel beside
`TypeAlias|Property`, but both its labels are in the SCOPE_BRIDGE cross-product
so the pair is generated by construction and the sentinel cannot fail. Dropped
rather than left reading as coverage; `TypeAlias|Property` is the load-bearing
one.

`TypeAlias|Method` was declared in the schema with no fixture emitting it — a
declared pair no emitter exercises is indistinguishable from a missing one
until an analyze aborts on a real repo. Adds a method-shaped alias member, and
the suite requires sentinels to actually appear, so it is not vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document the new incomplete reason, the UNKNOWN verdict and the id churn

Review found the code changes landed without the guidance around them, and an
agent following this repo's own rules would have been told the wrong thing.

`graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block
and no recovery section, while the precedent it cites
(`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a
new string naming silent wrong answers with nothing explaining trigger or
remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also
fails the exit code.

`AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and
the shipped impact skill's risk table had no UNKNOWN row and still implied
few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN`
and proceeds, which negates the change the verdict exists to make. Both copies
of both skills updated.

`MIGRATION.md` now records that process ids do not survive this release —
positional ids plus depth-first tracing, source-order siblings and round-robin
selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly:
nothing in-repo joins on a raw process id, so it is index churn, not a broken
consumer.

`ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages.
The guide skill's node list gains `Property` and `TypeAlias` — the two node
types this work most prominently creates.

Also, on the pair-CSV preflight review asked to confirm: the hard abort IS
deliberate, because a fallback recovering zero rows is the confident-empty
failure this work targets. But the transient the message itself names — a second
concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now
re-looks three times over ~150ms before declaring the file gone. Long enough to
ride out a rename, far too short to mask a file that is genuinely missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: drop redundant TypeAlias pairs and keep bare identifiers off class members

Two regressions the full suite caught after the review fixes, both real.

`schema-pair-coverage` failed with eleven hand-declared pairs that a rule now
generates. Adding `TypeAlias` to `LINKABLE_LABELS` — needed so
`resolveDefGraphId` can bridge an alias def to its node — also makes it a
SCOPE_BRIDGE source and target, so the cross-product produces `File|TypeAlias`,
`TypeAlias|Property` and nine others that round 1 had declared by hand. Removed;
the invariant is that no pair is both generated and hand-declared.

This also changes what the structural-pair sentinel means, and the comment is
corrected rather than left overstating it: `TypeAlias|Property` is no longer
load-bearing because the label is off the generated grid — it is load-bearing
because it now depends on `TypeAlias` being IN `LINKABLE_LABELS`. Remove it and
the pair stops being generated while the hand declaration is gone, which is the
same state that silently breaks alias consumer edges.

`block-scope-shadowing` failed because a bare identifier resolved to a class
`Property`. `class Box { baseUrl = '...'; pick() { const baseUrl = ...; return
baseUrl; } }` linked the block-local read to `Box.baseUrl`, duplicating the
legitimate `this.baseUrl` edge. A bare identifier is not a member access: with
no receiver there is no object whose property it could be, and in JS/TS a field
read needs `this.`. Receiver-less read/write sites no longer accept `Property`
hits; callables stay reachable, so `cb = save` naming a top-level function is
unaffected.

That defect PREDATES this branch's TypeScript captures — JavaScript has emitted
bare-identifier reads since A2 and no class fixture exercised the shadow. The
TS parity added here is what surfaced it.

Golden snapshot regenerated after verifying the drift line by line: exactly
+5 USES from type annotations in the mini-repo, every pre-existing count
unchanged, so nothing was rewired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): share the property-name index across language passes

Review follow-up. `indexPropertyNodesByName` scanned every node in the graph
and was rebuilt inside each qualifying language pass, reintroducing exactly the
pattern `phase.ts` hoisted out for `sharedNodeLookup` — whose comment records
why it matters: "the previous per-language rebuild burned that CPU+heap N times
and, on a huge repo, a tiny language's full-graph copy overlapped the next
language's — a real contributor to the scope-resolution memory peak."

Built once in `phase.ts` beside `sharedNodeLookup` and `sharedFnNodeIndex`, and
threaded through the same `prebuilt*` seam, so tests and isolated calls still
build their own.

Sharing is only safe because the per-language restriction MOVED rather than
disappeared: the shared index is whole-graph, and candidates are filtered to
the language's own files at lookup time. That also fixes a subtlety the
per-language build had backwards — the cap now applies to the FILTERED set, so
a name carried by forty properties across a polyglot monorepo but only two in
the language being resolved is still answerable, where a global cap would have
refused it.

The tri-state at the lookup boundary is deliberate and the three outcomes are
not interchangeable: no property of this name in this language (nothing to say,
and NOT an ambiguity), too many to choose between (reportable), or a list to
narrow.

Caught mid-change by the polyglot fixture: an intermediate state shared the
index without moving the filter, and the cross-language edge came straight
back. That test earning its keep twice is the reason it exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): report when a field's only anchor is another language

Round 3, found OUT-OF-SAMPLE — six field names appearing in no prior report, so
nothing here was tuned against them. All six answered 0 backend ACCESSES while
their definitions sat in `apps/research-dashboard/**`: TypeScript only. The
in-sample set scored 5/5 and the out-of-sample set 0/6, and the gap is entirely
this.

Per-language inference (`3c5eadc7`) is right and stays. What was wrong is that
declining is INVISIBLE: an empty result for a field anchored only in TypeScript
is byte-identical to an empty result for a field nobody reads. One says "look
in the other language or grep"; the other says "delete it". That is the same
confident-empty failure this series exists to remove, one surface over — and
this time the missing fact is about the ANALYZER's reach rather than the code.

Declines are now counted and named, with the languages the anchors actually
live in, kept SEPARATE from ambiguity because the remedies differ: ambiguity
wants better receiver typing, this wants an anchor in the reading language.
Collapsing them would tell a reader the wrong thing to do. A non-zero count
warns at analyze time regardless of dev mode.

The facts are published as `PipelineResult.propertyInference`, which they had
to be for any of this to be testable — and that exposed a second defect. The
round-2 ambiguity assertion, which I told the reviewer of #2856 I had
strengthened, read its stat off a `scopeResolution` field that does not exist
on PipelineResult: the `if (undefined) return` guard swallowed it and the test
passed with the production code deleted. Both that assertion and the new ones
now read the published field, and the guard is an assertion rather than an
escape. Verified by deleting the counter and watching them fail.

Reported by the same round-3 method note that caught it: verifying a fix
against the cases it was written for only proves those cases pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(context): explain an empty property result caused by a cross-language anchor

The other half of R3-1. The analyze pass now knows which fields it declined to
link because every definition of the name lives in another language; this puts
that fact where it is actually read.

`context()` on such a field previously returned an incoming list byte-identical
to a genuinely unread field. The two demand opposite actions — "look in the
other language, or grep" versus "delete it" — so the difference has to travel
with the answer:

  unresolved: property reads of this name were NOT linked: every definition of
              it is typescript, and name inference does not cross languages.
              An empty or short incoming list here is not evidence the field is
              unused — confirm with a text search, or give it an anchor in the
              reading language.
  anchorLanguages: ['typescript']

Carried through repo meta because the graph cannot answer it: the unlinked
reads mint no edge and no node, so the only record is the pass that declined
them.

Keyed on the NAME, not on the resolved label. Gating on `=== 'Property'` was
tried first and is wrong — the label reads `''` on this path for a plain
Property node, so the gate silently suppressed the entire feature while every
test still passed. Caught by asserting the field is DEFINED rather than
guarding on it, which is the same anti-pattern that made two earlier
assertions vacuous. The meta list only ever contains property names, so
matching the name is itself the type check.

Cached per (index, indexedAt): `ensureInitialized` deliberately avoids a
per-call `loadMeta` because every tool routes through it, so this re-reads
exactly when a re-analyze could have changed the answer and never otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): report declined property reads for opt-out languages too

Generalizing R3-1 rather than waiting for it to be re-reported in the other
direction. The reported case was a JavaScript read whose only anchor was
TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was
still silent, because a language that sets `fieldFallbackOnMethodLookup: false`
had the whole pass skipped, and skipping emission also skipped REPORTING.

Detection is not inference. Counting what could not be linked asserts nothing
about what it means, so `reportOnly` runs the pass for its facts while emitting
no edge, and the opt-out keeps protecting exactly what it protected before.

Two things this turned up that a single-instance fix would have missed:

The cross-language fixture could NOT prove `reportOnly` is load-bearing — the
per-language candidate filter already blocks those edges, so the assertion
passed with the flag forced off. The case that discriminates is a SAME-language
TypeScript read that name inference could legitimately link and the opt-out
forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which
is the violation.

Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the
object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }`
in a `.ts` file mints no node and its keys are invisible. The first draft of
this fixture used exactly that shape and could not discriminate for that reason.
It is the TypeScript half of R2-1a and wants its own change, not a rider on
this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(typescript): index object-literal keys, as JavaScript already did

The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the
named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived
only in JAVASCRIPT_QUERIES, so the single most common config idiom in
TypeScript —

    export const tsRuntimeConfig = { tsConfigRetries: 3 };

— minted no node for any key. `context()` answered "Symbol not found" and a
precise read through the holding variable had nothing to resolve to.

TypeScript sets `fieldFallbackOnMethodLookup: false`, so these gain no
name-based inference. What they gain is the PRECISE path, which is the route
TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable
receiver and now resolves. A read through an untyped receiver stays unresolved
and, since `0c5a4f64`, is reported as such rather than answering an empty set.

Scoped exactly as the JavaScript rules are — bound to a variable, and for the
wrapper only the three functions that return the argument they were given —
with the same `Object.entries` negative control pinning the allowlist.

Found by fixture, not by report: the first draft of the `reportOnly` test used
a TS `const CONFIG = { ... }` as its discriminator and could not discriminate,
because the shape mints nothing. That is the whole argument for sweeping a
class instead of waiting for each instance to be filed.

SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying
none of these matches and the keys stay invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): anchor anonymous returned object literals to their function

The last gap round 3 named, and the dominant shape in idiomatic JS: 437
`return {` sites in a single backend directory of the reporting repo, including
the ~25-field payload of its entire signal pipeline. The literal binds to
nothing, so its keys could not even be named — "who reads wickRatio?" had no
symbol to ask about.

The enclosing FUNCTION is the owner: the literal is that function's return
shape, a contract its callers consume. Keys qualify as `<function>.<key>`, so
two functions returning the same name stay two shapes rather than one merged
symbol, and multiple returns in one function stay distinct by position.

RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES
to avoid adding same-named competitors to narrowing. These are definitions, but
narrowing now ranks DECLARED anchors — named literals, class fields, interface
and alias members — strictly above return shapes. A name that already resolved
keeps resolving to what it resolved to before, so the competitor problem R2-1b
was avoiding cannot come back. Mutation-checked: dropping that ranking breaks
five pre-existing R2 resolutions.

That also required an R2-1b assertion to change, and the change is a
strengthening rather than a concession. It asserted `toHaveLength(1)` — no new
definition — as a proxy for "adding definitions must not move an existing
answer". The proxy is now false while the property still holds, so the property
itself is asserted directly.

No `HAS_PROPERTY` edge from the function: that would be a `Function|Property`
relation pair the schema does not declare, and an undeclared pair does not
degrade — it throws and kills the whole analyze. That already shipped once in
this PR.

Two things found by dumping rather than assuming, both fixed here:

SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is
the commonest spelling and the reporting repo's own payload is mostly this form,
but tree-sitter models it as `shorthand_property_identifier`, which `(pair)`
does not match. Caught by dumping the golden fixture and seeing a literal
returning `{ level, message, timestamp: Date.now() }` had indexed only
`timestamp`. Now covered in return position AND in the variable-bound rule,
which had the same gap.

Provenance was flagged by owner-presence, which mislabelled the anonymous case:
a callback's return shape yields no name to qualify by, so it looked like a
DECLARED anchor and would have outranked real declarations. Flagged by position
now — a different question from whether a name could be derived.

SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's,
but a build stamped 48 was installed and used to analyze before these captures
existed, so caches stamped 48 carry none of them — the intermediate-build hazard
this ledger already records for 33/34.

Golden regenerated after verifying the drift: exactly +10 Property and +10
DEFINES, every pre-existing count unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): rank production anchors above test fixtures

Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring
returned literals took `wickRatio` from 6 definitions to 13 — and backend reads
still resolved to nothing, because SEVEN of the new JavaScript anchors compete
and four of them are in `tests/`. A test constructs throwaway shapes carrying
production field names; a read in shipped code cannot mean one of them.

Applied before the declared/return-shape split, because "is this the shipped
program" is the stronger signal — a declaration inside a test fixture is still a
test fixture. Skipped when the READER is itself a test, since a read there
legitimately means the test's own shape.

The first version of this test was vacuous and the mutation check caught it: the
reader sat in the same file as the production anchor, so the same-file tier
resolved it whether or not this tier existed. The reader now lives in a file
that imports neither anchor, which leaves production-vs-test as the only thing
that can decide.

Honest about what this does NOT do: it narrows `wickRatio` from seven candidates
to three, and three functions in different files each returning that field is
GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and
named rather than silent. The reported question ("who reads wickRatio?") is
answerable only where one producer exists; where several do, the honest answer
is the list of producers, which R3-4 made nameable for the first time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): resolve members through a call result's return shape

The question three rounds of reports could not answer, and the one narrowing
must refuse by design: a field produced by SEVERAL functions. A read of
`spike.wickRatio` could mean any producer, so name inference correctly declines
and no amount of tier-tuning changes that. It needs evidence, not inference.

The evidence existed in two halves that had never been joined. The call-result
type binding (`const alert = formatSpikeAlert(row)` binds `alert` to a TypeRef
whose rawName is the callee) predates all of this work; it simply had nothing to
resolve to when the callee returned an anonymous literal, because an anonymous
literal named nothing. R3-4 gave it a name. Joining them:

    const alert = formatSpikeAlert(row);
    alert.wickRatio   ->   Property:...:formatSpikeAlert.wickRatio

Precise, at ordinary emission confidence, and it works EXACTLY where narrowing
cannot: several producers sharing a field name stop being competitors because
the receiver says which one. Runs before the name fallback and claims its sites,
so a precise answer is never second-guessed by a name match.

Measured on the reporting repo: 1,410 precise edges, and all six fields round 3
verified OUT-OF-SAMPLE go from 0 backend readers to 7, 11, 10, 7, 6 and 14.
Round 3 scored 0/6 on that set; this is 6/6.

The bound is asserted, not just documented: a read off a BARE PARAMETER has no
binding here, because typing it needs the caller's type to flow in — that is
inter-procedural and genuinely larger. Those reads still fall through to name
inference and are still reported when it declines. The fixture has two producers
sharing a field name precisely so the test cannot pass by name matching, and
mutation-checking the owner lookup fails it.

No SCHEMA_BUMP: this is scope resolution, not parse-time capture, so a warm
cache already carries everything it reads. Noted in the ledger because the
reflex on this branch has been to bump, and an unnecessary bump costs every user
a full re-parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "return-shape anchoring" (R3-4/R3-5): it degrades query

Reverts af5eec5c, c764847a and 4f93f32e. The capability was real and measured —
all six fields round 3 verified OUT-OF-SAMPLE went from 0 backend readers to
7/11/10/7/6/14, 0/6 to 6/6, via 1,410 precise return-shape edges. It is reverted
anyway, because it costs more than it buys in its current form.

`cli-limit-e2e` caught it. Bisected to af5eec5c: on the mini-repo fixture,
`query('message')` returned two processes before and NONE after. The mechanism
is not window displacement — that hypothesis was tested with a partition that
kept function-local property keys from taking window slots, and it changed
nothing. Indexing the keys of every returned literal adds many nodes whose names
are ordinary words, which moves the BM25 CORPUS statistics: "message" gets less
discriminating, and `createLogEntry` — the callable that actually carries the
processes — stops ranking at all. A corpus-level effect is not repairable by a
tie-break.

Trading a regression in `query`, one of the core tools, for coverage in
`context` is the wrong trade, and shipping it because the number was good would
be the same mistake this PR spent three rounds removing: a confident answer that
is worse than the honest one.

What the work established, and what re-landing needs:

  - The mechanism is right. Joining the existing call-result type binding to a
    named return shape resolves `alert.wickRatio` by EVIDENCE, which is why it
    succeeds exactly where name inference must refuse.
  - The cost is search dilution, and it needs to be measured on BM25 ranking
    BEFORE the capture lands — not discovered by a downstream e2e test.
  - The likely shape of the fix is keeping return-shape keys out of the text
    search corpus while keeping them in the graph, which needs persisted
    provenance rather than the in-memory flag used here.

Kept: everything through 8972d223, which is verified green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5

Reverts the revert. The return-shape work was correct and measured — 1,410
precise edges, and all six fields round 3 verified out-of-sample going 0/6 to
6/6 — and it was dropped for a regression that was really a MISSING LAYER: the
search index had no way to say "this symbol is queryable but is not a concept a
text search should surface on its own".

Indexing the keys of anonymous returned literals adds many nodes whose names are
ordinary words (`message`, `value`, `timestamp`). Without that notion they
compete on equal terms in FTS, push the CALLABLES named after the same concept
past the search's row cap, and `query('message')` returned two processes before
and none after.

The layer, rather than a workaround:

  - `Property.isDetail`, persisted. A Property-only column, which that table
    already precedents with `declaredType`, set where the key is minted.
  - `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row
    cap. That placement is the whole point: rows crowded out never reach the
    caller, so no downstream re-ranking can recover them. Two downstream fixes
    were tried first — a tie-break and a partition of the merge window — and
    recovered nothing, which is what located the real seam.
  - `IS NULL`-tolerant, so an index written before the column existed still
    answers instead of returning nothing.

Verified by the A/B that found the regression: the query's result order is now
byte-identical to the pre-R3-4 baseline —
`proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry,
Property:LogEntry.message` — with the return-shape coverage retained.

The determinism guard then caught prose in the new DDL comment containing the
token this repo scans for, which would have read as an unordered query. Reworded;
that suite is doing exactly its job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(processes): let a flow end where the program reaches outward

The item three rounds kept circling. A trace was only emitted at a node with NO
outgoing calls, so a real flow — scan, score, arm, PLACE THE ORDER — is always a
PREFIX of some longer chain that runs on into date helpers, and could never be a
process in its own right. Ranking could not fix that; the flow was never a
candidate to rank.

What blocked it was signal granularity, and the fix is the layer that was
missing rather than a heuristic. GitNexus already knew where the program reaches
outward: the parse phase collects fetch calls and ORM queries carrying
`filePath` + `lineNumber`. Those facts only ever produced FILE-level edges
(`File -[FETCHES]-> Route`), which cannot end a trace — every function in a file
containing one would qualify. Attributing each site to the function whose range
CONTAINS it turns the same facts into the function-level signal the walk needs:
no new extraction, no new relation pair, no schema change. Innermost wins, so a
closure that performs the call is the sink rather than the function spanning it.

Three touch points, and the second is the one that makes or breaks it:

  - the walk emits at a sink AND CONTINUES, so `placeOrder` is an endpoint while
    `placeOrder -> formatDate` still exists separately;
  - subset-removal PRESERVES sink-terminated traces. A sink flow is by
    definition a prefix of the chain that runs past it, so emitting one at the
    walk and deleting it one step later would have been a no-op. Mutation-
    checked: removing this preservation fails all three sink tests, including
    the one asserting the sink is reached at all;
  - selection ranks sink-terminated above leaf-terminated, then by depth.

`processes` now declares `parse` as a dependency. It historically avoided that
on the grounds the dependency was spurious for a progress counter — it is no
longer spurious, so it is declared rather than reached for implicitly, and the
read fails open so a pipeline without that output detects no sinks instead of
losing every process.

Bounded honestly: this fires where fetch/ORM extraction fires. On the reporting
repo it will do nothing until route detection handles hand-rolled dispatchers,
since that codebase routes with `pathname === '/api/...'` on raw node:http and
produces zero Route nodes — a separate gap, and the next one worth closing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(processes): the comment above the sink ranking still described it as unreachable

R3-6 taught the walk what a sink is, but the block explaining the ranking still
carried the paragraph written when that was out of reach — "a business flow
still cannot be a process in its own right ... fixing that means teaching the
walk what a sink is" — sitting directly above the code that does exactly that.
A reader arriving at `rankedByInterest` would take the limitation as current.

The measured-false fan-in finding stays; it is still true and still worth not
re-deriving. What replaces the stale half is the bound that IS current: sinks
fire where fetch/ORM extraction fires, so a codebase whose outward calls are not
detected as such still sees leaf-terminated traces only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(routes): read a route that is declared by a comparison, not by a framework

`route_map` on the reporting repo returned

    {"routes": [], "total": 0, "message": "No routes found in this project."}

for a codebase with SEVENTEEN route modules, an `apiRouteTable.js`, and 113
path comparisons. Not a partial answer — a statement about the code, and a
false one. Same confident-empty class as the rest of this branch, except here
it takes out a whole tool.

Four route-discovery paths existed — filesystem convention, single-file
framework route, cross-file framework route, decorator — and every one of them
needs a FRAMEWORK to declare the route. A raw `node:http` server declares it
the only way the language offers:

    if (req.method === 'GET' && pathname === '/api/live/portfolio') { … }

A path, a verb, and a handler. Nothing in the pipeline could read it.

The failure modes are not symmetric, so the rules are weighted accordingly: a
route this misses is a coverage limit, a route it invents is `route_map`
asserting something false. A comparison therefore qualifies only against a
demonstrable request path (`pathname`, `*.pathname`, `req.url`; `path` is
excluded — in Node it is overwhelmingly `node:path` or a file location), and
anything untranslatable is dropped rather than approximated:

  - `pathname.startsWith('/api/')` is a namespace test; minting `/api` would
    claim a route nobody serves;
  - a bare `pathname === '/'` with no verb is more often the static-file
    normalisation branch (`pathname === '/' ? '/index.html' : pathname`) than a
    route — WITH a verb the intent is unambiguous, so that form IS taken;
  - an anchored regex converts only when its body is a literal path plus
    single-segment wildcards, so `/^\/api\/research-runs\/[^/]+$/` becomes
    `/api/research-runs/{param1}` while an optional group or an alternation
    bails.

Three things went in that nobody reported, each found by measuring rather than
by a second report.

`switch (pathname) { case '/api/x': }` is the same dispatch in different
syntax, and waiting for a bug report per shape is how a graph stays permanently
one idiom behind the code it indexes.

The reconciliation had to move up a level. The reporting repo keeps its path
table (`isKnownApiPath`) in one module and its handlers in sixteen others, so a
per-file rule sees each half separately and lists every route twice — once
verb-less with the table as its "handler", once properly. Measured: 22 of the
first 94 routes were that shadow. Only the whole registry can tell them apart,
so the rule lives in the routes phase and touches dispatch-guard routes only —
a framework route without a verb is method-agnostic BY DECLARATION (a Django
function view, a Laravel resource), a fact rather than a weaker observation.

And a path composed from a constant needed folding. One of those seventeen
modules writes every one of its routes as `` `${autoTradeBasePath}/rules` ``,
where the base is an alias of a module-level literal. Refusing that lost the
whole file — and lost it INVISIBLY, since a module with unfoldable paths and a
module with no routes are the same empty answer. Same-file only, literals only,
one alias hop, and it refuses on ambiguity: a name declared twice with
different values is dropped rather than guessed, because a partially-folded
path is a wrong route and a wrong route is the failure this module exists to
avoid.

Wiring is a LanguageProvider hook, not a language check in shared code.
`extractDecoratorRoutes` was already the general "route from this file's own
AST" channel rather than a decorator-only one — express routes have flowed
through it as `decorator-express.get` for a while — so the transport, the
`(method, url)` dedup and the handler-symbol resolution all apply unchanged.
`ExtractedDecoratorRoute.source` carries the one thing that genuinely differs:
a decorator route is DECLARED, a dispatch-guard route is INFERRED. The walk is
gated behind a substring pre-filter so it costs nothing on files that cannot
produce a route, and the gate is sound by construction — every rule reaches a
route only through `isPathExpression`, which needs one of exactly those tokens.

SCHEMA_BUMP 49 -> 51, two entries. Decorator routes are worker output carried
in the parse cache, so a warm cache replays results predating the extractor and
`route_map` stays empty — the symptom this fixes, wearing the mask of "the
extractor does not work". The second bump is the v34 hazard tripping again: a
build stamped 50 had already been used to analyze before folding existed, so
caches stamped 50 carry the unfolded route set. Caught by measuring — the
post-folding run came back suspiciously fast and would have reported the
pre-folding number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): ask whether a value def is FUNCTION-LOCAL, not whether it is module-level

The locality filter for value references was written as an ALLOWLIST of
module-scope defs, and that shape cannot express a class member. A value def has
three homes, not two: module level, a function body, and a CLASS body. Java and
C# fields and Python class attributes live in the third, so an allowlist keyed on
"module level" excludes every one of them by construction.

The guard written to make that safe could not fire either. The set arms whenever
a Module scope is FOUND, and Java has module scopes while having no module-level
values at all — so for Java it armed permanently empty, which is exactly the
state the guard exists to distinguish from "there genuinely are none".

Inverting it removes the class. A blocklist of defs positively identified as
function-local fails safe: a Java field, a Python class attribute, or a language
whose scopes could not be inspected is emitted rather than dropped. That also
retires the arming flag — an empty blocklist and an uninspected one mean the same
thing, and both mean "emit". The failure mode moves from "silently deletes an
edge class" to "retains an inert local", which is the right direction for a tool
whose stated principle is that a confident empty answer is the worst outcome.

MEASURED, because the review that prompted this reported it as a P0 deleting
every Java/C#/Python field ACCESSES edge, and that half does not reproduce.
Instrumenting the bridge over `java-write-access` shows ZERO value-ACCESSES
candidates reaching the filter: Java field references resolve to a `Property`
target and `isValueDefinitionLabel` covers only Const/Static/Variable, so the
filter is never consulted there. Pipeline-level edge sets are byte-identical with
the filter forced on and forced off, across four shapes — Java cross-file field
writes, Java cross-file constant reads, Java bare same-class constant reads, and
a Python module-constant/class-attribute mix. The defect is real and latent; the
blast radius is not. Fixed anyway, because the predicate asks the wrong question
and the next change that makes the bridge the sole emitter would ship the
deletion for real.

New `value-ref-locality.test.ts` pins the invariant triple — local dropped,
module-scope kept, class member kept — by TARGET rather than by `reason`. The
per-language suites filter on `rel.reason === 'read'|'write'` while the bridge
stamps `scope-resolution: read|write`, so they are blind to bridge-side change in
both directions. The file states plainly which half gates the mechanism (JS,
mutation-verified) and which gates only the outcome (Java, because the mechanism
is unreachable there), so it cannot be mistaken for a stronger gate than it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(docs): restore the agent guidance a generated-block refresh deleted

Commit 8f8261021's message is entirely about cross-language anchor reporting; it
also regenerated the `gitnexus:start` block in AGENTS.md and CLAUDE.md against a
LOCAL, non-PDG index and swept six documentation/config files along with it. The
review caught this and it is correct. Restored:

  - the index stats, which regressed 248612 symbols / 565510 relationships /
    918 flows -> 29969 / 118986 / 762 — my machine's index described as the
    project's;
  - the whole `pdg_query` bullet and the PDG half of the impact bullet, while
    both capabilities remain live in `mcp/tools.ts` and `local-backend.ts`;
  - the "Inline staleness signal" section in the guide skill, content that never
    left `origin/main` and that this branch had no reason to touch;
  - `.mcp.json`, which had moved from `npx -y gitnexus@latest mcp` to a bare
    `gitnexus` — a fresh clone with no global install gets a dead MCP server.

The worst of it is self-inflicted in a specific way worth naming: commit
411cac9b9, four hours earlier on this same branch, ADDED the instruction telling
agents not to read `risk: UNKNOWN` as an all-clear. The refresh deleted it. So
the branch shipped a new UNKNOWN verdict and simultaneously removed the guidance
for reading it — the exact false-safe this PR exists to remove, reintroduced one
layer up in the docs.

Re-applied that guidance, and found the drift is wider than reported. The review
noted the `.claude/` copy contradicting the plugin mirror; in fact the UNKNOWN
block was present in ONE of five shipped distributions. `gitnexus/skills/` (the
npm package), `gitnexus-cursor-integration/`, and `.agents/` were missing it too,
so every non-Claude consumer of this skill had the old table.

`shipped-skills-sync.test.ts` passed 54/54 through all of that. Its byte-identical
check covers only the plan/work/review/lfg family, and the standard skills are
guarded solely by per-skill fragment lists — so a fragment nobody listed is a
fragment nothing protects. Added the UNKNOWN fragments to that list, plus a
`copies.length > 1` assertion so an empty copy list cannot make the loop vacuous.
Verified it fails against the pre-fix tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): require the return-shape producer to RESOLVE, not merely to name-match

Review finding 2, reached independently by three Claude lanes and two Codex
legs, and reproduced here. `emitReturnShapeMemberAccesses` took the receiver's
type binding, then filtered a WHOLE-GRAPH property index with `idNamesMember` —
a textual match on the node id. Any node whose id happened to read
`<producer>.<member>` qualified, in any file and any language, and it emitted at
the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. The
sibling unique-name pass was given a per-language restriction for exactly this
hazard; this pass consumed the same shared index with none.

Three guards, catching different shapes:

  - the producer must RESOLVE to a definition (`findCallableBindingInScope` — a
    CALLABLE lookup: the producer is the function whose return shape owns the
    member, and it resolves through finalized import bindings so a producer in
    another file still yields its own file);
  - the member must live in that definition's file;
  - that file must belong to the language being resolved.

The third is not redundant with the second, which is the part worth recording.
A receiver typed by CONSTRUCTION (`const bound = new Loyalty()`) resolves through
the shared class registry, which is polyglot — so the producer resolves into
`Loyalty.java`, its members legitimately live in that same file, and file
equality waves the cross-language edge straight through.

Also fixes the sibling P2: a site where the receiver IS typed to a producer that
owns no such member now claims the site. That branch is the strongest negative
evidence the pipeline can produce, and letting it fall through meant the 0.5 name
fallback answered a question the precise pass had just DISPROVED — measured,
linking a read to an unrelated same-named key in another file.

`polyglot-property-isolation` gains the bound-receiver arm the review asked for,
and it is the right arm: the pre-existing case has an untyped receiver and so
only ever exercised the unique-name pass, while one extra token routes an
identical read through this one. Mutation-verified — restoring the pre-fix
matching makes exactly the new leak assertion fail. The first version of that arm
was silently vacuous (it introduced a JS key of the same name, which destroyed
the fixture's Java-only premise), which is why it now asserts on the TARGET FILE
rather than on the absence of a name.

KNOWN LIMIT, stated rather than papered over: a member-call producer
(`const r = svc.make()`) binds `svc.make`, which resolves to no callable, so this
pass now declines it. Codex B3 raised that converse case and it is real. Fixing
it means typing `svc` and then finding `make` on that type — a larger piece of
work, queued for the follow-up PR. Declining is the correct interim behaviour:
the alternative is matching `make.<member>` by name across the graph, which is
the fabrication this commit removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): resolve the import map by point lookup so the seal cannot empty it

Review finding 4, reproduced end-to-end by two lanes: the same commit and the
same repo produced a DIFFERENT graph depending on `GITNEXUS_DISK_SCOPE_INDEX`.

`buildDirectImportMap` built `scopeToFile` by walking `parsed.scopes`. The
out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — that is
its documented contract, scopes are reachable only via `scopeTree.getScope`
afterwards — so under the seal the map came out empty, every `directImports`
lookup returned undefined, and tier-2 narrowing died repo-wide.

The reporting is the worse half. The loss surfaced as `ambiguous`, which means
"several candidates and the pass refused to choose". The truth was "the evidence
was discarded one function earlier". A reader acting on that would go looking for
better receiver typing to fix a problem that was not there.

This is the SECOND consumer of `parsed.scopes` on this branch to hit the seal.
The first was hoisted above it. This one is converted to the point lookup
instead, which is the stronger fix: a point lookup survives the seal by contract,
so there is no ordering left for a future edit to get wrong.

The parity assertion that would have caught it now exists. The sealed harness in
`javascript-const-references` already ran the fixture both ways, but every
assertion in it pinned ONE field's readers — which is exactly how a second
instance slipped in, since no assertion happened to cover a narrowed name. It now
also compares the WHOLE ACCESSES edge set between the two runs, as a sorted diff
so a failure names the edges that moved, with a non-empty guard so two empty sets
cannot compare equal and assert nothing. Mutation-verified: forcing the map empty
fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): bind a producer's own returned key to itself, and stop claiming uniqueness for a ranked answer

Review finding 3, accepting the two defects it demonstrates and declining the
remedy it proposes. Both halves are mutation-verified.

1. A SITE INSIDE ITS OWN RETURN SHAPE NOW BINDS TO ITS OWN KEY.

   `export function buildB(row) { return { tickIntervalMs: row.b } }` writes the
   key that IS `buildB.tickIntervalMs`. Ranking declared anchors above return
   shapes is correct for a READ through a receiver, but applied to this site it
   handed the write to a same-named module const that `buildB` never touches —
   a wrong edge — while the node the key actually defines was left with no
   writer at all. Both halves wrong from one rule applied to the wrong shape.

   Checked before every other rule, because it is evidence rather than ranking:
   the owner qualifier on the candidate id and the enclosing callable are the
   same symbol. Nothing outranks that.

2. THE TIER NO LONGER LIES.

   `workspace-unique` is a claim that exactly one node in the workspace carries
   the name — a fact about the graph, and the label a reader trusts most. An
   answer reached by FILTERING (tests down-ranked, return shapes down-ranked)
   is a weaker claim, and it was reported under the same label. The edge is
   unchanged; what it is allowed to say about itself is not. `narrowed` now
   counts these correctly too, since it keys off the tier.

WHAT I AM NOT DOING, and why. The review proposes dropping the same-file and
imported-file tiers "and keeping only genuine workspace-uniqueness". That would
revert the measured R2 result taking backend readers of `exitMinAtrMult` from
0 to 24. Workspace uniqueness was already measured too strict on that repo: the
field carries 26 Property definitions — 16 in one-off scripts, 7 in the
frontend, one in a test, and exactly one in the backend that reads it. Strict
uniqueness declines all 24.

The alternative suggestion — require the receiver to bind to the owning object —
has the same effect by another route: the population this pass exists for is the
untyped option bag, whose receiver binds to nothing. Requiring a binding turns
the pass off for its own use case. So the two demonstrated defects are fixed and
the capability around them is kept, at half confidence, naming its inference in
the reason string, and honoured only where `fieldFallbackOnMethodLookup` allows.

The R3-5 precision test needed rescoping rather than relaxing: it asserted that
EVERY edge to the contested field is a precise return-shape edge, which the
producer's own (correct, name-tier) write now violates. It asserts the reader
edges are precise and the producer's write binds to its own key — two different
claims reached two different ways, which is what the code now models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the JS/TS scope-capture fingerprints for this branch's capture additions

The `Cross-language scope-capture fingerprint + scaling guards` CI step was
failing on TypeScript and JavaScript, and it had been failing for the whole PR —
the branch changed both SCOPE queries without ever updating the guard's
baseline. It only surfaced now because a merge conflict had prevented CI from
running at all, so nothing reported it.

Re-baselined per the file's own instruction ("re-baseline intentionally on a
legitimate capture change"), and verified first rather than rubber-stamped. The
capture-name sets in both scope queries, diffed against `origin/main`:

  TypeScript  + @reference.read.identifier      (A2, bare-identifier reads)
              + @reference.type                 (R2-2, type references)
  JavaScript  + @reference.read.identifier      (A2)
              + @reference.read.destructured    (R2-1c)
              + @reference.write.property-key   (R2-1b)

Nothing removed on either side. A pure superset is the check that no EXISTING
capture moved — which is the failure mode a fingerprint guard exists to catch,
and the reason to look before regenerating.

Consistent everywhere else too: `capture_groups_small`/`_large` are unchanged
(4503/14403) because those measure the SYNTHETIC scaling source this branch does
not touch, so only the fixture-corpus number moves — 2097 -> 2338 across 21 new
lang-resolution fixtures, 146 -> 151 files. Scaling stayed linear and inside
budget (typescript 1.116, javascript 1.010, both < 1.5), so the added rules cost
no super-linear time. Prior and new hashes are recorded in the baseline note, as
every previous entry in that file does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the receiver-resolution drop guard for the new WRITE site kind

Second of the two bench guards that had been failing for the whole PR without
anyone seeing it — CI could not run while the branch was conflicted, so both
went unreported until the merge cleared.

The drift is a new site KIND, not a movement in an existing one:

    totalDropsAllKinds  129 -> 140
    bySiteKind          {call: 102, read: 27}
                     -> {call: 102, read: 27, write: 11}

`call` and `read` are byte-identical, which is the check that matters. This
branch added write-site captures the corpus never had — `@reference.write.
property-key` (R2-1b record construction) and the destructured-read rules — so
write sites reach receiver resolution for the first time, and 11 of them have a
receiver that does not resolve. A drop is the honest outcome for those; the
alternative is the name-inferred guess this series spent three rounds bounding.

Verified it is NOT caused by this session's review fixes before re-baselining:
removing the `memberNotOnShape` site-claim added in 69047086 and re-running gives
the identical 129 -> 140 / write: 11 drift, so the movement predates today and
belongs to the capture work, exactly as the arithmetic above says.

The sibling `scope-emission` guard still PASSES untouched, and the fingerprint
guard passes after 20a937f4 — so all three arms of the benchmarks job are green
locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(routes): track boolean polarity in dispatch guards, so a negated condition cannot invent a route

Reproduced exactly as reported. `dispatch-guard.ts` refuses to inherit a verb
from an `if` whose `else` branch holds the comparison — the module's own doc
comment explains why: that branch runs precisely when the condition did NOT
hold, so attributing it is backwards. `!` is the same fact written as an
operator, and it was not handled. A stated invariant with half an
implementation, which is worse than an absent one, because the comment reads as
though it were covered.

Measured against the real extractor before fixing:

    if (!(pathname === '/api/admin'))                  ->  '' /api/admin   INVENTED
    if (!(req.method === 'GET') && pathname === '/x')  ->  GET /x          INVERTED
    if (!(req.method === 'POST' && pathname === '/w')) ->  POST /w         BOTH

And the review is right that this is not additive-only. Driven through the real
pipeline with a policy module that serves nothing plus a one-line route table,
the invented `GET /api/report` collected into `verbedUrls` and
`reconcileDispatchGuardRoutes` then EVICTED the true verb-less route for that
path. A false route deleted a real one. After the fix that repo yields exactly
one route, verb-less, path intact.

Parity, not presence: `!!x` is `x`, so counting negations and testing the parity
is the only rule that keeps a doubly-negated guard working. A negated VERB drops
to verb-less rather than dropping the route — `!(method === 'GET')` means every
method except GET, which no single value expresses, while the path evidence is
untouched. Applies to the regex arm too; `!/^\/api\/x$/.test(pathname)` had the
identical hole.

Deliberately NOT keeping the `statement_block` break from the suggested patch.
It is unreachable — the `!` in `if (!cond) { … }` lives in the condition, a
SIBLING of the block, never an ancestor of anything inside it, and the only
shape that puts a `!` above a block is an IIFE, which the function-boundary stop
catches first. Unreachable in the UNSAFE direction, too: breaking early
under-counts negations, and an under-count reads a negated guard as positive and
invents the route. Verified by mutation — with the break present, deleting it
fails nothing; the other three guards each fail a test when removed.

Six new cases, all previously absent (`grep -c '!(' ` over both test files was 0,
and the only negation covered was `!==`, the form that already worked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bench): re-baseline the emit-persistence byte-identity fingerprint for the isDetail column

The third bench guard this branch left red, and the one the earlier
rebaseline pass missed: the `benchmarks (GITNEXUS_BENCH)` job has never
succeeded once in eleven attempts, and since step 11 aborts the job, the
two steps after it — the streaming PDG-emit guard and the cross-language
pipeline benchmarks — have never executed at all.

    [emit-persistence --check] FAIL: byte-identity fingerprint drift
      (got 4ee15e74…, expected 69e9182a…)

Cause is this branch's own `isDetail` BOOLEAN on the Property table
(PROPERTY_SCHEMA), which `streamAllCSVsToDisk` writes as one more header
field and one more cell per Property row.

Verified header-only rather than regenerated on faith. Dumping every CSV
the bench emits on both `origin/main` and this branch and diffing them
per file (name, byte length, sha256): the file set is identical at 35
CSVs, 34 of the 35 are byte-identical, and the sole difference is
property.csv growing 68 -> 77 bytes as the header gains `,isDetail`. The
synthetic graph mints no Property nodes, so not one data row moved —
which is the thing this fingerprint exists to catch. Both timing gates
were green throughout (scaling_ratio 0.783 against a 1.8 budget,
elapsed_ms_large 229ms against the 1000ms backstop), so no throughput
claim is being rebaselined away.

Justification recorded in a `_rebaselined_<reason>` key, the convention
bench/scope-capture/baselines.json already sets, and the note now says so
explicitly so the next regeneration records its reasoning too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* perf(processes): build each trace key once, not once per comparison

`deduplicateTraces` held its `join('->')` inside the `some()` callback, so
every already-kept trace had its key rebuilt from scratch against every
candidate: O(T*U) joins of O(depth * id-length) characters. The
allocation, not the substring scan, is what the pass spends its time on.

Nothing about breadth-first search made that safe. It only hid the cost by
keeping traces short — measured on this repo the walk averaged 4.3 steps
before D1 and 9.4 after, which roughly doubles both the number of
surviving traces and the length of every key, so the same quadratic that
was affordable under BFS is about six times the work under DFS. That is
the whole of the slowdown D1 was carrying; the depth-first walk itself is
cheaper than the queue it replaced (`pop()` against an O(frontier)
`shift()`), and its frontier is bounded by depth rather than by breadth.

Hoisting the join into a `uniqueKeys` array removes the multiplication.
Measured back to back on one host, 5 reps, 25k callables, production sink
path (main -> this branch before -> this branch after):

    deep_chain      876.8ms -> 1233.1ms -> 101.9ms
    mixed_cycles    731.4ms -> 1130.6ms -> 132.8ms
    shallow_wide    572.5ms ->  531.8ms ->  49.6ms

and on the real gitnexus/src corpus (11,490 symbols) process detection
goes 204ms -> 89ms against main, having been slower than main before.

Output is unchanged, which is the property that matters here: swapping the
file back and forth and diffing every non-timing field across all sixteen
shape x scale x sink-variant configurations gives no difference, and the
real corpus returns the same 936 processes / 4,648 steps either way. Sink
keys are pushed alongside the traces they belong to, so the comparison set
is the same set it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

* fix(processes): type the parse-output read as ParseOutput

The R3-6 sink read declared its own structural shape for the parse output
instead of naming `ParseOutput`, which made it the only one of the five
parse consumers in the repo not bound to the real type — cross-file.ts,
orm.ts, routes.ts and tools.ts all pass the type argument.

`getPhaseOutput` is a raw `as T` cast, so a local shape checks nothing at
runtime and only severs the compile-time link: renaming `allFetchCalls` on
`ParseOutput` would still compile here and silently detect zero sinks
forever. Verified with a real `tsc --noEmit --strict` run over exactly that
rename — the typed consumers error, this one did not. The runtime `.filter`
stays, since it is the only thing actually guarding the cast.

Also brings the phase docblock back in line with the deps array, which was
missing `structure` (pre-existing) and `parse` (added by this branch), and
records the two parse fields the phase now reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-08-08 09:58:14 +01:00
Gergő Magyar
997fc05b83
fix(resolution): resolve calls through a generic-typed field receiver in every language (#2833) (#2855)
* test(resolution): pin generic-typed field receivers across languages (#2833)

A field whose declared type carries a type argument (`repo: Repo<User>`)
emits zero CALLS edges — not a truncated chain, not an edge to the
interface declaration, nothing. This adds the cross-language matrix that
measures it, modelled on the #2807 inferred-field matrix: every language
runs the same two calls, one through a generic-typed field and one
through a non-generic control field, and each language is compared
against its OWN control row rather than an absolute edge count.

Measured state, pinned here as `known-gap` so the file is green on main
and flipping a row is a visible edit:

  affected    TypeScript, C#, C++, Python
  unaffected  Java, Kotlin, Go, Rust, Swift, Dart

The unaffected six erase type arguments at interpret time (Java's
`stripGeneric`, F41 #1928; Swift likewise). TypeScript, C# and Python
instead run a container ALLOW-LIST that returns the type ARGUMENT, so a
user-defined `Repo<User>` survives verbatim into a lookup that binds
nothing.

The `ts-local-vs-field` case is the bug in one file: `viaLocal` and
`viaParam` both resolve for the identical type, and only `viaField`
loses every edge — a bare name reaches Case 4 and its generic-aware
lookup, a dotted field receiver does not.

Negative controls pin what erasure must NOT do: an unbounded type
parameter denotes no declaration, and a C++ explicit specialization is a
different class from its primary template. The `Box2<T>` row pins a
PRE-EXISTING false edge (a workspace class named `T`) so it cannot later
be mistaken for fallout from this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* refactor(resolution): move resolveClassBindingForName to the shared walkers (#2833)

Pure relocation, no behaviour change: the generic-aware class lookup
moves from `passes/receiver-bound-calls.ts` to `scope/walkers.ts`, beside
the bare `findClassBindingInScope` it wraps. Its two existing callers —
`classifyReceiverOrigin` and Case 4 — import it from the new home and are
otherwise untouched.

The move is required rather than cosmetic: `receiver-bound-calls.ts`
already imports from `compound-receiver.ts`, so having the compound
receiver call into the pass would close an import cycle. `walkers.ts` is
the shared floor both already depend on.

Verified behaviour-neutral: the #2833 matrix is 44/44 identical before
and after, across all fifteen fixtures.

detect_changes attributes `resolveInheritanceBaseInScope`,
`resolveQualifiedInheritanceBase` and `EMPTY_BINDINGS` to this commit;
those are line-shift artifacts of inserting a function above them, and
their bodies are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(resolution): type generic field receivers through the generic-aware lookup (#2833)

A field receiver is spelled `this.repo` — dotted — so it types through
the receiver-chain fold and the text cascade, both of which reach
`findClassBindingInScope`. That function has no notion of type arguments,
so a field declared `Repo<User>` resolved to nothing and the call site
emitted NO edge at all: not the interface declaration, not the
implementation fan-out, nothing. A local or parameter of the identical
type is a bare name, reaches Case 4 and its generic-aware
`resolveClassBindingForName`, and resolved fine. The bug was the
asymmetry, not the generics.

Three receiver-typing lookups now call the generic-aware helper instead:
`typeOfMemberOnClass`'s primary and module-hoist branches, and the
cascade's bare-identifier type-binding read. Every other one of the 38
`findClassBindingInScope` call sites is untouched — its own docstring
records that widening it globally suppresses the `?? otherResolver(...)`
fallbacks two dozen callers rely on, which would retarget inheritance
edges, and impact rates it CRITICAL with 12 direct dependents.

Order matters and is preserved: the helper tries the exact name, then an
arity- and token-exact match against `def.templateArguments`, and only
then falls back to the base name. Erasing first would collapse a C++
explicit specialization onto its primary template — `Vec<bool>` really is
a different class. A bare type parameter carries no type arguments, so it
never enters the generic branch and cannot be erased into a class that
happens to share its name.

Measured: TypeScript and C# generic-typed fields now emit exactly what
their non-generic control rows emit, primary plus interface-dispatch
fan-out. Java, Kotlin, Go, Rust, Swift and Dart are byte-identical. Both
type-parameter negative controls are unchanged. C++ and Python are still
open and stay pinned as known-gaps — they fail for different reasons and
get their own commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(cpp,python): bind generic-typed member fields so their calls resolve (#2833)

Completes #2833 for the two languages the shared resolution change could
not reach. Each failed for its own reason, and both were found by
measurement rather than assumed.

C++ — a CAPTURE gap, not a resolution one. All three `field_declaration`
type-binding rules required `type: (type_identifier)`, so a member
declared `Repo<User> repo;` is a `template_type` and matched none of
them: the field got no type binding at all, and every call through it
lost its edge in both the bare and `this->` spellings. A LOCAL of the
identical type resolved the whole time, because the local declaration
rules gained their `template_type` variant long ago. Three mirrored
rules close it, one per declarator shape (plain, pointer, reference).
Written as separate patterns rather than one alternation: a node-type
alternation in a field position is a tree-sitter 0.21 hazard this repo
has been bitten by before.

Python — the bracket spelling never entered the generic branch. Its
`stripGeneric` is a container allow-list over `[...]` that returns the
type ARGUMENT (`list[User]` to `User`), so a user-defined `Repo[User]`
matched nothing and survived verbatim, and the shared lookup's generic
branch is gated on `<`. It now reduces a subscripted type neither
allow-list claims to its base name — the same rule Java and Swift
already apply to `<...>`. Deliberately the LAST resort: a container must
reach its own rule first, or `list[User]` would type the receiver as the
container and retarget every call in a for-loop chain. The as-written
spelling survives on `TypeRef.declaredSpelling`, which is what the fold's
index step reads.

Both are parse-time and land in the cached ParsedFile, so SCHEMA_BUMP
goes 45 -> 46 with its pin test. Verified free against origin/main; the
ledger in that file records three prior EXACT clashes, so re-check again
immediately before merge.

The matrix now covers the spellings real code writes, all measured: a
nullable generic, a bounded wildcard, a raw type, a nested generic and a
multi-argument one. None needed work beyond the shared lookup, which is
the evidence that base-name erasure is the right primitive. The C++
specialization control now asserts what it was written for:
`Vec<bool>.save` and `Vec.save` are DIFFERENT target ids, so the
arity/token match still wins over erasure.

scope-capture is byte-identical for cpp and c, so no rebaseline — the
bench corpus contains no generic-typed member field, which is worth its
own coverage issue.

Two pre-existing gaps were measured and are deliberately NOT fixed here,
because in both cases the language's own non-generic CONTROL row fails
identically: C++ `this->field.m()` emits nothing, and JavaScript/PHP
docblock-declared field types bind nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(python): do not reduce containers or typing special forms to a base name (#2833)

Review finding on this branch's own Python change, caught by probing the
interpreter directly rather than by reading it.

The base-name reduction was reached by FALLTHROUGH: "neither container rule
matched" was treated as "not a container". It is not, and two measured
shapes proved it:

  dict[str, list[User]]   ->  dict          (was: the annotation, intact)
  Dict[str, Repo[User]]   ->  Dict
  Callable[[int], User]   ->  Callable
  Literal["a"]            ->  Literal
  Union[A, B]             ->  Union
  tuple[int, ...]         ->  tuple

The dict rule's value group cannot span a nested `]`, so a nested value
declines and falls through — and the dict rule's own comment says that
shape is deliberately "left for a downstream strip pass". Collapsing it to
`dict` destroyed the value type instead. The typing SPECIAL FORMS are worse:
`Callable`, `Literal`, `Annotated` and `Union` are not classes, and reducing
them to a bare name binds any workspace class that happens to share it —
a fabricated edge, which is strictly worse than the missing edge #2833 set
out to fix, and those names are ordinary enough for a real codebase to
declare.

Reduction is now guarded by an explicit deny set covering the containers the
two allow-lists already own and the typing special forms. Everything named
there keeps its as-written text and resolves exactly as it did before #2833.

`arr[0]` also reduces to `arr` in isolation, but that is unreachable and is
now documented as such: every Python `@type-binding.type` capture is a
`(type)`, `(identifier)`, `(attribute)` or `(dotted_name)` node, so a
subscripted VALUE expression never reaches the interpreter.

Pinned by a new unit test that asserts all four groups — user generic
reduces, container reduces to its ELEMENT, declined container shape stays
intact, special form untouched. Reverting the deny set fails three of its
five cases.

Also corrects `resolveClassBindingForName`'s docstring, which this branch
had made false: it claimed only `classifyReceiverOrigin` passes the
decoration stripper, while the three receiver-typing lookups in
compound-receiver.ts now pass it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(resolution): rank base-name candidates lexically and refuse arg-pinned defs (#2833)

Review of #2855 found that this PR turned a MISSING C++ edge into a
CONFIDENTLY WRONG one — the direction this subsystem calls unrecoverable.

`resolveClassBindingForName` ended with an unguarded base-name fallback
that returned the first same-named class the scope chain reached. A C++
primary template carries `templateArguments === undefined`, so it can
never satisfy the exact-args branch, and every non-specialized
instantiation fell through to that fallback. Measured through the real
pipeline: with the primary forward-declared and the specialization
defined first, `Vec<int> vi; vi.save()` emitted `Vec<bool>::save`.
Declaring the primary first gave the correct target — selection was
SOURCE-ORDER DEPENDENT. Two more triggers behaved the same way: a
partial specialization (`Vec<int*>` against `Vec<T*>`), and lexical
shadowing between a global `Box<bool>` and a namespaced `N::Box<bool>`.

Two changes, neither of which is any of the three remediations the
review proposed — each was rejected on measured evidence:

- Exact-argument matching is now LEXICAL-FIRST. Candidates come from the
  scope chain, and the workspace-wide qualified-name bucket is consulted
  only when the chain produced no exact match, so cross-file
  specializations still bind.
- The base-name route refuses a definition that pinned its own template
  arguments: if the fallback's answer carries `templateArguments`, the
  visible candidates are re-decided with those removed — exactly one, or
  decline.

Why not the filed options. "If specializations exist and none matches
exactly, return undefined" deletes a green committed row
(`neg-cpp-specialization/runInt` legitimately resolves to the primary).
"Resolve all defs for the base name, return only on exactly one" deletes
a working edge for C# `partial class Repo<T>` split across files — two
unspecialized defs under one name is legitimate, and
`QualifiedNameIndex`'s own docstring names that case. Preferring the
primary alone fixes nothing about shadowing, which is a ranking bug.

The guard is expressed as `carriesOwnTemplateArguments`, not as
"specialization", so shared pipeline code still names no language
(AGENTS.md R6). It can only fire where a declared name carries concrete
arguments — measured `undefined` for `class Repo<T>` in TypeScript and
C# and for a C++ primary template — so the blast radius is bounded to
C++-style specializations.

Partial-specialization SELECTION is deliberately not implemented:
choosing `Vec<T*>` for `Vec<int*>` needs template-argument deduction,
which is a semantics expansion and cannot live in language-neutral
shared code. The source-order dependence is what is fixed; the answer is
now deterministically the primary.

Also in this commit: dropped an unreachable `?? []` (QualifiedNameIndex
returns a frozen empty array on miss by contract) whose comment was
wrong on both clauses; made the docstring true about argument ERASURE
being what widens what binds, rather than only the decoration stripper;
and corrected a stale pointer that still placed
`resolveClassBindingForName` in `receiver-bound-calls`.

`findClassBindingInScope` itself is untouched — 38 call sites, CRITICAL.

Verified: matrix 56/56, cpp.test.ts 334, unit scope-resolution 1505.
Mutation proof: reverting this file fails the three trigger cases and
passes the non-regression cases; restoring it passes all five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(python): close the deny-set drift axis by case-folding, not by vigilance (#2833)

Review of #2855 found `NOT_A_USER_GENERIC` was a closed list over an
open universe: four review lanes each escaped it with a DIFFERENT set of
names. `Deque` was the sharpest — its lowercase twin `deque` was already
listed, so the omission was an internal inconsistency rather than a
judgement call, and with a workspace `class Deque` present
`self.dq: Deque[User]` fabricated a `Deque.appendleft` edge.

The structural cause is PEP 585: nearly every container has two
spellings differing only in case (`deque`/`typing.Deque`,
`frozenset`/`FrozenSet`). Exact matching forced every pair to be listed
twice, so any half-pair was a silent escape. The deny lookup is now
CASE-FOLDED, which closes that axis by construction — `Deque` becomes
impossible rather than remembered.

`SINGLE_ARG_CONTAINERS` and `MAPPING_CONTAINERS` are now the single
source of truth: they build the two container regexes (verified
byte-identical `.source` and `.flags`, so zero behaviour change) and
feed the property test. The deny set is re-scoped to a closed, auditable
universe — the documented Python stdlib type-system surface — and grew
39 -> 65 concepts: the `collections.abc` views, `contextlib` managers,
`re.Pattern`/`Match`, the `IO` family, ordinary-named stdlib generics
(`Queue`, `Task`, `Future`, `PathLike`), the remaining typing special
forms, and the generic machinery (`Generic`, `Protocol`, `TypeVar`...).

Third-party generics (`Mapped`, `QuerySet`, `Model`) are deliberately
NOT added and are pinned as a decision: that universe is open,
enumerating it only chases the last escape, and declining `Model` would
cost real edges in the many projects that declare one.

The review's suggested property test — derive the names from the
`single`/`dict` regex sources — would NOT have caught `Deque`: `deque`
appears in neither regex, only in the deny set. Both properties are
implemented, since they catch different drift.

The unit test was also TAUTOLOGICAL: it asserted members OF the deny
set, so it structurally could not detect an omission. It now asserts
case-fold closure and PEP 585 alias coverage, and the capture fixture
drops its `as unknown as` cast for the fully-typed helper pattern the
sibling `java-interpret.test.ts` already uses.

Still at interpret time, so no further SCHEMA_BUMP (already 45 -> 46).
Proving the base is a class the FILE can see — the real fix for the
remaining exposure, since `findClassBindingInScope` binds any name with
exactly one workspace def regardless of scope or imports — is a
follow-up, not reachable from this file.

Mutation proof: restoring HEAD's deny-set contents and exact-match
lookup fails four assertions including the `Deque` pair, with the
pre-existing guard rows still passing; restoring gives 125/125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(cpp): capture qualified generic member fields, and make the bench gate see them (#2833)

Review of #2855 found that the three `field_declaration` rules this PR
added only matched a DIRECT `template_type`, so the common real-world
spelling still bound nothing: `std::vector<Item> items;`,
`ns::Repo<User> r;` and `std::unique_ptr<Repo> p;` parse as a
`qualified_identifier` WRAPPING a `template_type`. "C++ fixed" was
overstated.

Six new patterns — three declarator shapes (plain, pointer, reference)
by two qualifier depths — written as separate patterns rather than one
alternation, keeping the tree-sitter 0.21 field-position discipline the
existing rules follow.

The design choice was measured, not assumed. Codex suggested preserving
the full qualified spelling and normalizing `::`; preserving resolves
NOTHING, because `findClassBindingInScope`'s dotted-tail fallback splits
on `.` while C++ writes `::`, and `ns::Repo` is not an index key either
(C++ emits no `@declaration.qualified_name`). Measured: `ns::Repo<User>`
resolves to nothing, `ns.Repo<User>` resolves to `Repo`. Since a
tree-sitter capture is a NODE and not synthesized text, the only lever
is which node to capture — so `@type-binding.type` goes on the INNER
`template_type`, dropping the qualifier and landing on the same
single-match-or-decline path the bare spelling already takes.

Qualifier depth 3+ (`a:🅱️:c::Repo<User>`) remains uncaptured. Stated as
a limit and pinned by a test row, not claimed as fixed.

The bench blindness the review identified is also closed. The
`scope-capture` C++ corpus contained ZERO template-typed member fields —
confirmed a fourth way by applying six demonstrably behaviour-changing
patterns and getting a byte-identical fingerprint. The corpus now
carries generic and qualified-generic members, and the gate is load
bearing for the first time: three states that all hashed to 856d02f3
before now differ (pre-#2833 0e7cbda7, +this PR's 3 rules de07d8b5,
+these 6 rules bd47c82d). Rebaselined for cpp only; c is unchanged.
Histogram diff: only 5 tags move with the fields, each by exactly +40
(20 entities x 2), and every `@reference.*` count is unchanged.

Over-match is preserved: 20 shapes still produce no field capture,
including the 8 original method/pointer/reference/function-pointer/
using/typedef/friend/operator forms plus their `std::`- and
`a:🅱️:`-qualified variants.

Not fixed here, deliberately: NON-generic qualified fields
(`ns::Address addr;`, `std::string name;`) still capture nothing.
Closing that needs six more patterns and would newly bind every
`std::string`/`std::mutex` member repo-wide, changing edges far outside
#2833. Separate issue.

The template-template-parameter hazard the review filed against these
rules is NOT capture-side: a tree-sitter query has no scope knowledge,
so it cannot know `Map` is bound by the enclosing `template <...>`
header, and the PRE-EXISTING `type: (type_identifier)` rule already
captures a bare `T item;` and erases it the same way. It is handled by
the lexical ranking in `walkers.ts` in this series.

Mutation proof: reverting this file fails 9 of 32 assertions (all eight
qualified spellings return no capture) while every over-match negative
still passes; restoring gives ALL PASS. Bench `--check` passes for all
15 languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* test(resolution): pin specialization order, shadowing and the untested spellings (#2833)

Grows the generic-field matrix 56 -> 114 tests, closing every coverage
gap the #2855 review named and turning the fix-agents' scratch evidence
into permanent rows.

The rows that discriminate against the resolver fix (they fail if
`walkers.ts` is reverted):

- C++ specialization must not depend on DECLARATION ORDER: the
  forward-declared-primary/specialization-first arrangement must land on
  the primary, same as the mirror arrangement. Plus a cross-case
  property asserting the two independently built fixtures agree.
- Partial specialization is deterministic in both orders. The note says
  explicitly that selecting `Vec<T*>` would need argument deduction and
  that flipping this row later is a deliberate expansion, not a
  regression fix.
- Lexical shadowing: the namespace-local `N::Box<bool>` wins for a field
  inside `N`, and the global specialization wins at global scope.

The NON-REGRESSION rows are load-bearing — they are why two of the three
proposed remediations were rejected: cross-file C++ specialization
binding, and C# `partial class Repo<T>` split across two files with the
field in a third (two legitimate unspecialized defs under one name).

Coverage the review found missing: C++ pointer and reference generic
fields (two of this PR's three original rules had ZERO coverage); all
six qualified patterns plus the depth-3 boundary pinned as empty;
TS/C# multi-arg container collision; an anti-vacuity sibling for
`neg-bounded-type-parameter`; Swift/Dart rows restructured so the
ANNOTATION is the only possible source (the old rows gave the field an
initializer of the same generic type and could not tell which resolved);
and cross-file, inheritance/MRO, import-alias, static-member and the
TypeScript module-hoist branch.

Six things were measured and pinned AS MEASURED rather than asserted as
wishes, each flagged in its row note: a static/class-level member emits
nothing for generic AND non-generic alike (a static gap, not a generics
one); a cross-file C++ primary template does not bind while the
cross-file specialization does; `std::unique_ptr<Payload>` types to
`unique_ptr` rather than `Payload` (smart-pointer transparency is not
applied on the qualified path); two same-named C++ specializations in
one file collapse to one node id; and the container-name collision
(`Map<string, User>` binding a workspace `class Map`) is recorded as
INTENDED, since the annotation does name that class.

The `new Set(...)` dedup was kept rather than narrowed: a per-case
surplus-edge sweep measured ZERO duplicate edges anywhere in this file,
Swift included, so the quirk that justified a blanket dedup does not
reproduce. The sweep now pins zero surplus per case, so a real
double-emit fails instead of being absorbed.

The file is deliberately NOT split: four assertions compare cases
against each other, cost is linear in cases, and the 1,800,000 ms
`beforeAll` is kept because the same run measured 271-428 s depending on
host load — a tighter bound converts contention into a red suite. The
reasoning is recorded in the file header.

Also corrects the SCHEMA_BUMP pin-test title, which still said (#2766).

Mutation proof: reverting `walkers.ts` fails exactly the five order and
shadowing assertions and passes the other 109; restoring gives 114/114.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* feat(resolution): capture declared type parameters so a type variable is not a class (#2833)

Three review findings were blocked on one missing fact. `templateArguments`
records the arguments a declaration was written AGAINST (`struct Vec<bool>`);
nothing recorded the parameter list a declaration DECLARES (`template <class T>`,
`class Box<T extends Repo>`). So the resolver could not tell a type variable
from a class, and:

- `class Box2<T> { t: T }` beside a workspace `class T` emitted a FALSE edge
  `run2 -> T.foo`. `T` carries no type arguments, so it never entered the
  generic branch — the plain lookup simply bound a same-named class. The
  lexical grounding added elsewhere in this series cannot help, because
  `export class T` IS lexically bound.
- `class Box<T extends Repo> { t: T }` resolved to nothing: no recorded bound
  to resolve through.
- A full specialization `template<> struct Vec<T*>` and a partial
  `template<class T> struct Vec<T*>` were byte-identical (`['T*']`).

`SymbolDefinition.typeParameters` now records `{ name, bound? }` in declaration
order (substitution is positional). `bound` is kept verbatim and un-split, so
`Repo & Closeable` stays whole; ABSENT means UNKNOWN, never "unbounded", which
is what keeps unconverted languages behaving exactly as before.

Transport is the raw parameter-list node via `@declaration.type-parameters`,
read by a language-neutral parser that recognizes TOKENS, not languages:
`extends`/`:` introduce a bound, the name is the trailing identifier, so
`class T`, `typename T`, `in T`, `out T`, `reified T` and `class... Ts` are one
rule. Populated for TypeScript, C++, Java, Kotlin, C# and Rust. JavaScript, C,
COBOL, PHP and Ruby have no declared type parameters to capture; Go and Python
spell them with SQUARE brackets, which this parser deliberately rejects as
ambiguous against subscript and array spellings (Go already has a working
main-thread sidecar in this series); Dart and Swift are straightforward
follow-ups.

Two latent hazards found and closed on the way:

- The new capture was not in `KNOWN_SUB_TAGS`, so it could out-span its own
  declaration and become the anchor — silently DROPPING the whole class def.
- A templated C++ struct matches both the standalone and `template_declaration`
  patterns, minting two defs under one id, and only one twin could see the
  parameter list. `buildDefIndex` is first-write-wins, so MATCH ORDER decided
  whether `Vec` remembered `T`. A narrow duplicate-declaration backfill gives
  both twins the list.

Also fixed by its own test: a Rust lifetime `'a` parsed as a parameter named
`a`, which would have shadowed a real class.

Parse-time output lands in the cached ParsedFile, so SCHEMA_BUMP goes 46 -> 47.
Re-checked against origin/main at write time: main is on 45; 46 was taken by
this same branch, and a warm cache stamped 46 carries ParsedFiles with no
`typeParameters` at all.

The csharp and rust capture goldens were regenerated with the tests' own
documented `UPDATE_GOLDEN=1`; only digests moved, no captureGroups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(resolution): ground erased base names, and stop a class name from being enough (#2833)

The review's central risk was that this PR converts MISSING edges into
CONFIDENTLY WRONG ones. Base-name erasure (`Repo<User>` -> `Repo`,
`Repo[User]` -> `Repo`) bound through a workspace-wide qualified-name
fallback that consults NO scope, NO import and NO module — it bound any name
with exactly one workspace def. That is why a Python `Mapped[User]` could bind
an unrelated `class Mapped`, and why the language deny lists were papering
over an open universe.

`resolveErasedBaseName` now admits an erased base on one of four grounds,
strongest first: the scope chain binds it; the declaration is in the SAME
FILE; the index proves the name is a template family; or the file binds no
cross-file class at all, so its silence is no evidence. The last ground fails
toward permissive on purpose — every way it can be wrong costs a wrong edge
that already existed, never a working one.

Two measurements drove that design and refuted the simpler rule. A C++
`#include` materializes NO binding whatever, and C# resolves cross-namespace
without `using` through the index — so a pure "require lexical grounding" rule
would have deleted every cross-file C++ generic member. Both are now pinned.

Python erases at CAPTURE time, so by resolution there is no `<` and the
grounded route was never entered. `erasedTypeApplication` rebuilds the
application from `TypeRef.declaredSpelling` — strictly: the raw name must be
the base and the argument list the whole balanced remainder, so `User[]`,
`vector<Item>` and `Repo<User>?` decline and behave exactly as before.

Closing it took finding FOUR emitters, not one. Three were in Case 4; the
fourth was `emitReferencesViaLookup` re-emitting the refused edge from the
pre-resolved reference index, which needed the site marked handled with a
recorded `receiver-unresolved`. A fifth lived in the text cascade: a declined
fold falls THROUGH by design, and the cascade held its own ungrounded copy of
the member-typing lookup. This file typed a receiver from a `TypeRef` in five
places and the PR had wired three; all five now go through one
`classOfDeclaredType`.

Also here, from the same review:

- Type parameters no longer bind a same-named class (uses the new
  `typeParameters`), and a BOUNDED parameter resolves through its bound.
- A cross-file C++ PRIMARY template now binds: a ranking bug, not a capture
  one — the index fallback needs exactly one candidate and `Vec` held two, so
  removing the argument-pinned declaration leaves one.
- `this->field.m()` resolved to nothing for generic AND non-generic alike. A
  language that declares `this` IS the enclosing class
  (`resolveThisViaEnclosingClass`) synthesizes no `this` typeBinding, so a
  chain whose BASE is `this` could never seed its head. Reading the provider
  flag keeps the rule language-free.
- Class-level (static) member receivers emit nothing in TypeScript and Kotlin
  — for the non-generic control too. Case 6 types them from the DEF side
  (`isStatic` + `declaredType` on the field node), which needs no capture
  change; the target lookup stays the ordinary instance walk, so a static
  field HOLDING an instance still binds an instance method and a genuine
  static call is untouched.

Partial-specialization SELECTION is deliberately not implemented: it needs
argument deduction against a parameter list, and full C++ partial ordering is
a real algorithm with no measured driving case. The discriminator now exists
if someone wants it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* fix(cpp,js,php,go): close the remaining per-language generic-field gaps (#2833)

Four language gaps the review measured, each with a different cause.

**C++ qualified member fields.** `std::vector<Item> items;`, `ns::Repo<User> r;`
and `ns::Address addr;` captured NOTHING: every field rule required the type
node to BE a `type_identifier` or `template_type`, and a qualified member type
is neither — tree-sitter wraps both in a `qualified_identifier`. Three
depth-agnostic rules (one per declarator shape) now match the outer node, which
also REMOVES the depth boundary rather than raising it: depths 1-4 capture,
generic and non-generic alike.

Preserving the qualifier resolves nothing — measured: `ns::Repo<User>` binds
neither way, because the dotted-tail fallback splits on `.` while C++ writes
`::`, and `ns::Repo` is not an index key. Since a capture is a NODE and not
synthesized text, the qualifier is dropped in `interpret.ts` by a top-level-only
`::` split, so `std::vector<std::string>` reduces to `vector<std::string>`, not
`string`.

Measured cost of the non-generic half, which was the reason to hesitate: field
captures go 8 -> 32 across the C++ bench corpus, but the resolution-level census
over those 13 repos is 32 CALLS edges before and 32 after, BYTE-IDENTICAL. It
fabricates only where a workspace class shares a std name (`class string` beside
`std::string name;`), which is the same accepted policy the already-landed
qualified-generic rules carry, pinned in the matrix as intended.

**JavaScript `@type {Repo<User>}` and PHP `@var Repo<User>`.** Neither bound a
field type — and neither did the NON-generic control, so this was a docblock gap
rather than a generics one. PHP needed TWO captures, not one: with only the type
binding, `$this->repo->save()` resolved until a second class declared `save` and
then went unresolved, because narrowing a same-named method needs the receiver's
member owned. Generics do NOT come free in PHP — `normalizePhpType('Repo<User>')`
returns `'User'` by the container-element convention, so passing the raw spelling
through would have emitted `User::save`; type arguments are erased at capture
instead. In JavaScript they DO come free, verified byte-identical to the
TypeScript control. Both decline what they cannot prove: arrays, `list<User>`,
unions, `Promise`/`Array` wrappers (via an exported predicate rather than a
copied name list), statics, and any property that already has a native type.

**Go generic interfaces.** `UserRepo` genuinely DOES implement `Repo[User]` —
the spec says a generic type must be instantiated, that instantiation
substitutes type arguments and yields a new non-generic type, and that a type
implements an interface when it is in its type set. So the old behaviour was a
FALSE NEGATIVE and the matrix note calling it "already correct" was wrong.
Satisfaction is now checked against POSITIONALLY SUBSTITUTED method sets, so
`Repo[Order]` does not match a `Save(x User)` implementor — substitution, not
erasure. #2829's exact method-set model is untouched: pointer receivers still
follow MS(*T), unexported names stay package-scoped, the declaration's own
method set is still checked first, and the harvest is gated so a repo with no
generic interface never runs it. `go.test.ts` is unchanged at 296 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* test(resolution): pin every fix from the review, 114 -> 155 rows (#2833)

Eight rows in this matrix pinned gaps that the fixes in this series close, so
each asserted the opposite of the new truth. All eight are flipped, and the
prose describing them as open gaps is corrected. Nine new cases cover the fixes
that would otherwise have shipped unpinned.

Flipped, each measured: the type-parameter FALSE edge (`run2`) is gone; a
bounded parameter now resolves through its bound with fan-out; the cross-file
C++ primary binds; the C++ qualifier depth boundary is removed rather than
raised; Go gains its two structural implementors and JOINS the paired sweep,
which had quietly excluded it — that exclusion was the taxonomy admitting a bug;
and both static-member rows resolve.

Added: JS `@type` and PHP `@var` docblock fields with three PHP declines; a
Kotlin `companion object` receiver (given an INTERFACE control so the paired
sweep can check it, which `ts-reach-shapes` cannot — its two sides are not
count-comparable); the Python third-party grounding refusal plus the ground that
still ADMITS, so an empty row can never be read as "erased names never resolve";
the four mirrors that would break if grounding were tightened (same-file and
imported Python, a C++ `#include`, C# cross-namespace without `using`); C++
qualified non-generic fields including the fabrication policy and its absence
case; `this->field.m()` for generic and non-generic with bare controls; and a Go
negative proving substitution is positional, not erasure.

Three shapes are pinned AS MEASURED with notes saying they are deliberate limits
so nobody "fixes" them by accident: C++ partial-specialization selection is
deterministically the primary (real selection needs argument deduction);
`std::unique_ptr<T>` types to the pointer, not the pointee (`.` and `->` are
indistinguishable to the resolver, so transparency would trade a recoverable
miss for a confident wrong edge); and two same-named C++ specializations in one
file collapse to one node id, which is why the shadowing fixture uses two files.

One row pins a REMAINING wrong edge rather than hiding it: `m.inner.ping()` on
a `Mapped[User]` head still binds the unrelated workspace class, while the
one-segment-shallower `m.save(u)` correctly declines. The obvious one-line guard
was written and MEASURED not to close it, so the surviving route is elsewhere
and wants its own diagnosis — a broader refusal would change chain-head
resolution for every language without pinning the shape it is meant to fix.

`bench/scope-capture` is rebaselined for the six languages whose captures moved,
regenerated from a fresh measurement rather than pasted; `--check` passes for all
15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* perf(resolution): remove three measured hot-path regressions this series added (#2833)

A quality pass over the #2833 series found three performance defects it had
introduced, all measured, plus dead code and stale docs from six agents having
appended to the same files across four rounds. No behaviour change: the
resolver suite is identical before and after, and every scope-capture
fingerprint is byte-identical.

**An accidental quadratic in Go instantiation harvesting.** `collectGoInstantiations`
calls `record()` for every type binding and every declared, return and parameter
type in every Go file, and the `includes('[')` gate does not filter Go's most
common types — `map[string]string`, `[]map[string]*v1.Pod` and
`map[string]map[string]int` all produce a `map` candidate. Each false base then
failed a full scope-chain walk and fell through to a LINEAR SCAN OF EVERY
INTERFACE IN THE PROGRAM, with no dedupe on the spelling, so the same
`map[string]string` written 10,000 times paid 10,000 scans. Now a
qualified-name index built in `buildDetectionIndexes` (one probe, ambiguity
semantics preserved exactly) plus a per-scope base memo:

    8,000 interfaces / 80,000 spellings:  6,662 ms -> 104 ms   (64x)

`resolveEmbeddedInterface` held a byte-identical copy of that scan and now
shares the helper. `GoInstantiation` was a single-field wrapper and collapses
to the array it wrapped; its two parallel maps fold into one whose inner key IS
the dedupe. `candidateStructIdsFor` was rebuilt per instantiation although
every substituted method set has the same key set — hoisted, and materialized,
because one branch returned a live iterator that would have yielded nothing on
a second pass.

**`scanForCrossFileClass` asked a name-keyed question that needs no name key.**
It answered "does this file bind any cross-file class" by probing every
accessible namespace once PER NAME. It now iterates the channels directly,
taking whichever side is smaller so a large namespace table cannot reintroduce
the product. Predicate and early exit preserved:

    5,000 module names x 1,000 namespaces:  159.0 ms -> 1.2 ms   (132x)

**A duplicated scope walk on every generic receiver.** `resolveClassBindingForName`
computed the lexical candidate list, then `resolveErasedBaseName` recomputed
the identical `findAllBindingsInScope`. Computed once and passed:

    receiver at depth 8:  5,617 ns -> 3,091 ns   (-45%)

**A whole extra AST traversal per JavaScript and PHP file.** The docblock
synthesis passes each added a full tree walk to find one node kind — the ninth
in the JS emitter, the third in PHP. `node.namedChildren` materializes a
wrapper array across the N-API boundary for every node, so one added pass cost
1.9x what parsing the entire file costs. Folded into the existing walks as one
more node kind; capture output is byte-identical and every fingerprint is
unchanged. Total emit time per file drops 4-7%.

Hygiene, all verified stale rather than assumed:

- `receiverOriginOpts` passed `resolveThisViaEnclosingClass`, which
  `classifyReceiverOrigin` never reads — the "both hooks" comment above it is
  true again.
- The `stripDecoration` docstring's caller roll-call claimed the only
  edge-emitting caller "emits no edge and can only change a diagnostic label".
  Case 6 passes it and does emit edges. Replaced the roll-call with the rule;
  six rounds each appending a name to a list is how it went wrong.
- A Python comment described the resolution-time grounding as a follow-up that
  "this parse-time pass cannot do" — it landed in this same branch and is
  pinned by `py-erased-grounding`.
- `classOfDeclaredType` took a `scopeId` all five callers derived from the
  `TypeRef` they also passed. Dropped, so "these five are the same call" is
  enforced rather than asserted.
- Three exports with no consumer outside their own file.
- PHP had three copies of one preceding-comment sibling walk and two regexes
  for one tag, so a fix to either reader of `@var` would land on one and not
  the other — the symptom being a field typed differently from its own foreach
  element type. One walk, one regex.

Tests: the new matrix leaked a fixture repo per case; it now carries the
sibling suite's `cleanupTempDirSync` and the Windows EBUSY reasoning that goes
with it. `PAIRED` was a second hand-maintained list and 19 of 41 cases had
silently fallen out of it — it is derived from the cases now, with a new
assertion that each case is either swept as a pair or carries a written reason
it is not. That recovered one genuine omission (`php-typed-property`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KtNfG6EPn738Y51AYs7wDp

* test(bench): rebaseline receiver-resolution for the #2833 this-> fix

The `Receiver-resolution drop guards` CI step failed on this branch:

  shapeArm.cpp.fieldReceiverCall:  "INVISIBLE-GAP" -> "RESOLVES"
  shapeArm.cpp.decoratedFieldType: "INVISIBLE-GAP" -> "RESOLVES"

Both are the intended improvement. The guard is exact-match by design —
the drop count cannot move without a deliberate rebaseline, and the
rebaseline path demands the movement be explained — so this records the
two shape flips and leaves the call-drop count arm untouched.

BASELINE.md still claimed `this->repo.save()` and `this->repo->save()`
were INVISIBLE-GAP. That is now false: the `resolveThisViaEnclosingClass`
head seed added in this PR resolves both. Also notes what the control
established — this was never a generics gap, since the non-generic
control failed identically before the fix.

* docs(parse-cache): narrow the SCHEMA_BUMP ledger to what the bump delivers

The ledger claimed a warm cache would make "the whole fix ... a silent
no-op on every incremental analyze". That overstates the constant. The
bump invalidates the PARSE half; whether the re-parsed captures reach the
graph is gated separately and does not move:

  - `isIncremental` (core/run-analyze.ts) tests `!options.force`, an
    existing meta, `!schemaFingerprintMismatch(...)`, feature parity,
    non-empty `fileHashes` and a git repo. SCHEMA_BUMP is in none of them.
  - the incremental branch writes back only `hashDiff.toWrite` and logs
    the rest as "unchanged file rows preserved".
  - SCHEMA_FINGERPRINT hashes node/relation DDL, untouched here, so it is
    byte-identical and moves nothing either.

So an incremental analyze re-parses an unchanged file correctly but keeps
its existing rows; the new edges land on the next full rebuild. That is
the pre-existing contract for every capture change, not a regression in
this PR — but the comment should not promise more than it delivers.

Comment only; no behavior change. SCHEMA_BUMP stays 48.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:14:13 +01:00
azizur100389
1fa751d76d
fix(spring): extract method-level RequestMapping routes (#2857)
* fix(spring): extract RequestMapping route methods

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(spring): address RequestMapping review findings

* fix(spring): accept trivia in request methods

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-07 11:43:19 +01:00
Gergő Magyar
a033b04c46
fix(go): scope and define each type_spec, not the type_declaration (#2837) (#2843) 2026-08-06 00:55:48 +01:00
Gergő Magyar
aaa78f9590
fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832) (#2842)
* fix(scope-resolution): fan out interface dispatch from Case 3b receivers (#2832)

Case 3b (chain-typebinding) folds a receiver through the same
`resolveCompoundReceiverClass` call and the same `[owner, ...mroFor(owner)]`
walk Case 0 uses, but emitted its edge without calling
`emitInterfaceDispatchFor`. When that fold landed on an Interface the site got
one edge to the interface's bodiless declaration and none to any
implementation — the defect #2813 reported for field receivers, in the half
#2829 did not cover.

The gap was a property of how a receiver was SPELLED rather than of what it
resolved to. `d.repo.save()` contains a dot, so it took Case 0 and fanned out;
binding the identical field to a local first — `const r = d.repo; r.save()` —
made the receiver a bare name with a dotted typeBinding, which is Case 3b, and
lost every implementation edge.

`ownerDef` is the receiver's own folded type, matching Case 0's `currentClass`
and Case 4's `ownerDef`, not the owner of the member the MRO walk settled on:
a receiver folding to a concrete class that merely inherits an interface method
must not fan out, because its runtime type is that class. The closure
self-gates on `ownerDef.type !== 'Interface'`, so the call is inert for every
concrete receiver and needs no language check. Confidence is the 0.85 literal
this case's own primary emits, so dispatch edges never outrank the edge they
hang off; Case 4's site.kind-dependent value has no counterpart here because
Case 3b's primary does not vary that way.

The new fixture pins the route as well as the fix. `const r = d.repo` reaches
Case 3b and nothing else can take the site: Case 0 needs a `.`/`(` in the
receiver name or a minted receiver chain, and `encodeReceiverChain` returns
undefined for the empty step list a bare identifier produces; Case 4 excludes
itself on the dot. Before the fix the primary assertion passed while the
fan-out came back empty — the exact "reached Case 3b and stopped at the
declaration" signature.

Resolution-side only: this changes what the resolver produces, not how it is
stored, so no SCHEMA_BUMP applies. An existing index must be re-analyzed to
show the new edges.

Follow-up from #2829.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(scope-resolution): record Case 3b's interface-dispatch fan-out in I4 (#2832)

Invariant I4 documented the fan-out as something "Cases 0 and 4 both perform"
and spelled out Case 0.5's exclusion, while saying nothing about Case 3b —
which is what made 3b's missing fan-out an undocumented asymmetry rather than
a deliberate exclusion someone could defend or point at.

With the fan-out added, Case 0.5 is the only case that folds or walks to a
receiver type without dispatching to implementations, and its exclusion is
gated behind `resolveThisViaEnclosingClass`. Saying so explicitly keeps the
next reader from having to re-derive which cases fan out by reading the pass.

Comment-only; `detect-changes --scope staged` reports no graph change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(scope-resolution): add the concrete-implementor control for Case 3b (#2832)

The Case 3b fan-out shipped with one negative control — a chain folding to
PlainCache, a class that implements nothing. That proves only the weak claim:
no interface anywhere near the site, no fan-out.

Add the stronger negative. SqlRepo implements Repo, so an interface IS in
scope and `save` is a name Repo declares, yet the receiver's folded type is
the concrete class and nothing may fan out. This is the control that fails if
a later change fans out from the interface a member is DECLARED in rather than
from the receiver's own folded type.

The comment says what the control cannot do, too: it cannot catch "member
owner passed instead of folded type" in TypeScript, because an implementing
class always declares the member itself, so the MRO walk never settles on the
interface's bodiless declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* docs(scope-resolution): correct four overclaims the review found (#2832)

A multi-lane review of this PR reproduced, against the real pipeline, that
several claims in the comments and one test name assert more than the code
delivers. No behavior changes here — only the text, and one test rename.

1. The test comment gave the WRONG REASON why the concrete-implementor control
   cannot catch "member owner passed instead of folded type". It said an
   implementing class always declares the member itself; `class C extends Base
   implements I {}` is valid TypeScript and inherits it. The real reason is that
   TypeScript's MRO chain never contains an implemented interface, so the walk
   cannot settle on an interface declaration for a concrete receiver. The
   mutation IS expressible where a concrete class inherits a `default` interface
   method (Java, Kotlin) — reproduced during review — so this is language-scoped,
   not inherent, and a follow-up fixture is tracked.

2. "fans out to every implementation of the folded interface" certified a
   completeness that does not exist. TypeScript emits heritage edges for
   `class_declaration` only (languages/typescript/captures.ts:749, stated in its
   own docstring at :732-733), so `abstract class X implements I` and `interface
   B extends A` produce no heritage edge and still dead-end on the bodiless
   declaration. Renamed to name the shape actually covered, with a KNOWN GAP
   note. The gap is in the capture layer and predates this fan-out.

3. Invariant I4 said Case 0.5 is the ONLY case that resolves a receiver type
   without fanning out. Cases 3 and 5 do too, by direct lookup rather than a fold
   or MRO walk. The sentence now says which distinction it means and states the
   reachability argument (no known language reaches Case 3 with an Interface —
   every one that could strips the namespace qualifier first, sending it to
   Case 4) instead of implying a completed audit.

4. The gate's rationale claimed the `ownerDef.type !== 'Interface'` test is right
   for every non-Interface receiver. An abstract-class receiver also dead-ends on
   a declaration-only member and does not fan out. Noted, with why widening the
   gate belongs to Cases 0 and 4 across all languages rather than to #2832.

Also completes the module-level case ladder, which still credited the fan-out to
Case 0 alone and omitted it from the Case 3b entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* docs(scope-resolution): name Case 2 in the I4 exclusion list too (#2832)

The first pass at this correction listed Cases 3 and 5 as the other cases that
resolve a receiver type without fanning out, and was itself incomplete: Case 2
also walks an MRO and its binding admits `Interface`. It is excluded for a
different reason than 3 and 5 — its receiver IS the type name, so the site is
static dispatch and a fan-out would be wrong, whereas 3 and 5 resolve by direct
lookup rather than a fold or MRO walk. Say both rather than enumerate one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

* fix(scope-resolution): close the two gaps the #2842 review left open

Both were pre-existing and reached by Cases 0 and 4 as well; the Case 3b
fan-out only widened the population of sites that hit them. Researched against
the real TypeScript compiler and the language service before choosing
semantics, plus how comparable tools draw the same lines.

1. THE FAN-OUT COULD TARGET A STATIC MEMBER

`class C implements I { static save() {} }` does not satisfy `I` — TypeScript
rejects it as TS2420, "Property 'save' is missing" — so an edge from an
`I`-typed receiver to a static member names a target dispatch can never
produce. The closure picked targets with `pickOverload`, which applies no
static filter, while the surrounding cases pick their own primary with
`pickFirstNonStaticOnly`: the speculative edges were picked with weaker rules
than the certain edge they hang off. A same-name static+instance pair also made
`pickOverload` return OVERLOAD_AMBIGUOUS, suppressing the CORRECT edge too, so
this was a false negative as well as a false positive.

Every comparable tool draws this line: tsserver partitions static from instance
results, clangd gates on `isVirtual()` (C++ forbids virtual statics), jdtls
filters abstract-or-static, and class-hierarchy analysis expands only VIRTUAL
call sites.

The guard prefers `provider.isStaticOnly` where a language declares it and
falls back to the graph node's `isStatic`. That order is load-bearing, not
stylistic: the method extractor derives `isStatic` from the OWNER type as well
as the member (`staticOwnerTypes`), and the JVM config lists
`object_declaration` — so reading the flag first would delete Kotlin `object`
implementations, which are singleton INSTANCES and genuinely reachable. Kotlin
is the only hook implementor and marks exactly the companion-promoted set;
Ruby's `singleton_class` (`def self.foo`) is correctly filtered by the
fallback.

2. TYPESCRIPT HERITAGE WAS CLASS-ONLY

`interface B extends A` and `abstract class X implements I` emitted no heritage
edge at all, so the subtype closure had nothing to descend and both shapes
dead-ended on a bodiless declaration — including the very example the closure's
own docstring cites as the reason it exists. Since Case 3b's dotted-alias
binding survives qualifier-stripping only in TS/JS, this was the language that
actually reaches the new path.

The two shapes reach their bases differently: an abstract class carries the
same `class_heritage` child a concrete one does, while an interface's bases
hang off `extends_type_clause` directly. That clause's `type` field is
`multiple: true`, so `childForFieldName('type')` would silently drop `C` from
`interface B extends A, C` — hence iterating named children.

Deliberately NOT structural matching. TypeScript is structurally typed, so a
class satisfies an interface without `implements`, but tsc's own navigation is
declaration-only and says why: "users are typically only interested in explicit
implementations... The type checker doesn't let us make the distinction between
structurally compatible implementations and explicit implementations, so we
must use the AST." scip-typescript reached the same design independently. gopls
does match structurally, but only because Go has no `implements` keyword to
prefer.

Abstract declarations are still walked THROUGH rather than targeted — the rule
everywhere is "does it have a body?", which is what `isDeclarationOnly`
already tests.

VERSIONING. The capture change is parse-time, so a v43 warm cache would serve
entries missing the new matches: SCHEMA_BUMP 43 -> 44 with its pin test moved
in the same commit, verified against origin/main at a857f4c5a (still 43).
Rebaselined only the `typescript` scope-capture fingerprint, justified by a
capture-name histogram diff over the same 145-file corpus: the only deltas are
@reference.inherits 17 -> 20 and its paired @reference.name 245 -> 248, emitted
together by `emitTsInheritanceBase`. Every other capture count is byte-identical
and javascript is unchanged, the language having no interfaces.

Tests: the fan-out now covers a static-shadowing subclass, a concrete class
below an abstract intermediate, and an implementor of an extending interface.
Resolvers 3176 passed; all five bench gates pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NdZtWXQJUGB1ZGLH2YNw3o

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:50:08 +01:00
Gergő Magyar
cabd5b82f9
fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813) (#2829)
* test(go): pin calls through an interface-typed struct field (#2813)

A call through an interface-typed struct field never reaches the
implementation: the CALLS edge stops at the interface DECLARATION, so
`impact()` on the implementing method reports 0 callers. This commit adds
the executable statement of that defect; the fixes follow.

Two stacked defects produce it, and either alone is enough to reproduce —
which is why no existing fixture could observe it:

  D1  `buildDetectionIndexes` skips every POINTER-receiver method, so a
      struct whose methods are all `func (r *T)` has an empty method set,
      structurally satisfies nothing, and gets no IMPLEMENTS edge. Go's
      rule is that the method set of *T includes pointer-receiver methods,
      and idiomatic Go stores *T in an interface-typed field.
  D2  Case 0 (compound receiver) emits its primary edge and short-circuits
      without the interface-dispatch fan-out Case 4 performs. A struct
      field receiver `s.orderRepo` contains a dot and so always takes
      Case 0; a local or parameter receiver is a bare name and reaches
      Case 4.

Every implementor in both pre-existing structural-dispatch fixtures uses a
VALUE receiver, and the one pointer-receiver type is pinned as a negative
(`not.toContain('PointerOnlyThing -> PointerOnly')`), so the corpus could
not see D1 by construction. The new fixture is pointer-receiver
throughout, cross-package, and carries concrete-field controls in the same
structs.

Failing-first, verified against this tree: 7 of the 11 new assertions fail
and 4 pass. The 4 that pass are exactly the controls that must not
regress — the primary edge to the interface declaration, the concrete-field
call, the absence of fan-out on a concrete field, and the partial-signature
negative — so the suite discriminates rather than merely failing.

Two recorded artifacts move here because the FIXTURE was added, not
because capture output changed:

  - test/fixtures/go-captures-golden/expected-captures.json — regenerated
    additively (32 insertions, 0 deletions).
  - bench/scope-capture/baselines.json — go fingerprint, fixture_count
    102 -> 110.

Both are regenerated in this commit rather than deferred to the end of the
series: the fixture is their only cause, no later commit touches capture
emission, so they cannot re-drift and every commit stays green. The check
that this is corpus growth and not a capture regression is that go was the
only one of 15 language fingerprints to move on the same run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(go): count pointer-receiver methods toward structural interface satisfaction (#2813)

D1 of two stacked defects. `buildDetectionIndexes` skipped every method whose
receiver is a pointer, so a struct declaring `func (r *OrderRepo) DeleteItem(...)`
had an EMPTY method set, structurally satisfied nothing, and produced no
IMPLEMENTS edge at all.

Go's method-set rule is per-type, and there are two types involved: the method
set of `T` holds only value-receiver methods, while the method set of `*T` holds
both. #1966 implemented the `T` reading, which is exactly right for `T` — and
leaves `*T` permanently empty. GitNexus models one Struct node per type with no
separate `*T` node, so only one of the two can be represented, and the `T`
reading is the one idiomatic Go almost never uses: methods take pointer
receivers so they can mutate, and `*T` is what gets stored in an interface-typed
field.

The cost was silence rather than caution. With no IMPLEMENTS edge, a call
through an interface-typed field resolved to the interface DECLARATION and
`impact()` on the implementing method returned 0 callers — byte-identical to a
symbol that genuinely has none, which is what made the reporter's blast-radius
check unusable rather than merely incomplete.

This picks the `*T` reading: the graph now answers "which types provide this
interface's behaviour", and no longer proves `var x I = T{}` invalid. The trade
is deliberate and was checked against every consumer of IMPLEMENTS before being
made — MRO/METHOD_IMPLEMENTS derivation, community clustering, the
receiver-dispatch fan-out index, and the epistemic heritage probe. None performs
value-assignability checking.

Two negative pins encoded the #1966 decision and are REVERSED here rather than
deleted, each keeping a comment that explains why the polarity moved:
  - go.test.ts: `PointerOnlyThing -> PointerOnly` now expected to be emitted.
  - go-hooks.test.ts: the pointer-receiver-only unit case now expects the
    implementor instead of `undefined`.

`goReceiverKind` is still stamped in method-owners.ts — it is the hook a future
value/pointer-aware model would read — but is deliberately no longer a filter.
Its now-dead local predicate and type alias are removed so the file no longer
carries a helper asserting the reverted rule.

Measured on the #2813 fixture, this commit alone: the two IMPLEMENTS assertions
flip to passing (6 pass, up from 4) while the five interface-dispatch fan-out
assertions still fail — those are D2, fixed in the next commit. Keeping the two
commits separate is what makes that attribution visible.

Go unit suite: 91 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(resolution): fan out interface dispatch from a compound receiver (#2813)

D2 of two stacked defects, and the one that closes the issue. Case 0
(compound receiver) emitted its primary edge and short-circuited without the
interface-dispatch fan-out that Case 4 performs, so a call whose receiver is a
struct FIELD stopped at the interface's method DECLARATION and never reached
any implementation.

The gap was a property of receiver SYNTAX rather than of types. Case 0 is
selected by `receiverName.includes('.')`, so a field receiver (`s.orderRepo`)
always lands there, while the very same interface reached through a local or a
parameter is a bare name and falls through to Case 4 — which fans out
correctly. Field-held interfaces, i.e. dependency injection, were the half that
silently lost every implementation edge; the pre-existing fixtures exercise the
local and parameter forms only, which is why the suite was green.

The fix is the call Case 4 already makes, placed after Case 0's primary
`tryEmitEdge` and before its `handledSites.add`. It stays language-agnostic
(AGENTS.md section 42): `emitInterfaceDispatchFor` self-gates on
`ownerDef.type !== 'Interface'`, so a receiver that folds to a Struct emits
nothing extra and no language check is needed. Confidence is Case 0's own 0.85
literal, not Case 4's site.kind-dependent value — Case 0 has no read/write arm
to mirror.

The case ladder itself is untouched: invariant I4 in contract/scope-resolver.ts
makes the ordering a contract, so the fan-out is added INSIDE Case 0 rather
than by reordering or merging cases.

Also flips a second, previously unnoticed encoding of the #1966 value-only
reading that the full sweep surfaced: the exact-set assertion at
go.test.ts:361 enumerates every structural IMPLEMENTS edge, and D1 correctly
adds `PointerOnlyThing -> PointerOnly` to it. It is D1 fallout rather than D2's,
but D1 had already landed; recording it here with its reason beats amending a
commit whose separate measurability is the point.

Measured:
  - #2813 suite: 11 of 11 pass (was 7 failing after D1 alone, which fixed only
    the two IMPLEMENTS rows).
  - go.test.ts: 160 passed.
  - Full cross-language sweep, test/integration/resolvers: 3027 passed,
    1 skipped, across 52 files. The single failure in that run was the
    exact-set assertion above, fixed here; no other language regressed.

`detect_changes` rates this HIGH (6 affected flows, all EmitReceiverBoundCalls
at step 1) — inherent to editing a hub symbol in the resolution pipeline. The
sweep above is the empirical answer to that label.

An existing index must be re-analyzed to show the new edges; this changes what
the resolver produces, not how it is stored, so no SCHEMA_BUMP applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(go): pin the heritage edges that make impact() hedge an interface-bound count (#2813)

The epistemic half of the issue, resolved by MEASUREMENT rather than by new
code, and pinned at its mechanism.

The reporter's disqualifying complaint was that `impact()` reported
`impactedCount 0, epistemic "exact", risk LOW` for a method reachable only
through an interface-typed field — byte-identical to what it reports for a
symbol that genuinely has no callers. A zero therefore could not be used
defensively, which was the entire use case.

That verdict comes from `computeEpistemicBoundary`, which has two producers and
neither fired: the call sites were not DROPPED (they resolved, just to the
interface declaration, so the #2744 receiver-typing producer saw nothing), and
its heritage probe walks IMPLEMENTS/METHOD_IMPLEMENTS edges out of the queried
symbol — of which there were none, because the pointer-receiver exclusion (D1)
meant no such edge was ever emitted.

Restoring those edges fixes the epistemics as a side effect, so the planned
conditional change to local-backend.ts is NOT needed. Measured on this fixture
against the fixed tree:

  impact(OrderRepo.DeleteItem, upstream)
    before: impactedCount 0,  epistemic "exact"
    after:  impactedCount 3,  epistemic "lower-bound", with an interface
            boundary note; the three callers are OrderHandlers.Delete,
            PickService.StartSession and WaveService.Release — all correct.

  impact(CartRepo.Get, upstream)  [concrete receiver, no interface]
    after:  impactedCount 1,  epistemic "exact"

The second row is the one that matters for trust: the hedge discriminates
instead of firing on everything, so "exact" still means exact.

This test asserts the METHOD_IMPLEMENTS edges the probe walks. Pinning the
mechanism keeps the resolver suite from reaching into the MCP layer while still
failing loudly if the edges regress; the impact() numbers above are recorded in
the commit message and PR body rather than re-asserted here.

#2813 suite: 12 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(go): model Go method sets exactly so interface satisfaction is decidable (#2813)

Replaces the approximate structural-interface model with the rules the Go spec
actually defines, so the graph answers what the compiler answers instead of a
useful-but-wrong summary of it. Three answers were provably wrong before; all
three are now exact and covered.

Method sets (go.dev/ref/spec#Method_sets):
  MS(T)  = methods declared with receiver T
  MS(*T) = methods declared with receiver *T OR T

Promotion (#Struct_types):
  S embeds T  -> MS(S) and MS(*S) get promoted methods with receiver T;
                 MS(*S) ALSO gets those with receiver *T
  S embeds *T -> MS(S) AND MS(*S) both get receiver T or *T

Identifier identity (#Uniqueness_of_identifiers): "Two identifiers are different
if they are spelled differently, OR IF THEY APPEAR IN DIFFERENT PACKAGES AND ARE
NOT EXPORTED."

  func (b *Base) Ping()      // pointer receiver
  type ByValue struct{ Base }
  type ByPointer struct{ *Base }

  type            before          exact answer
  Base            IMPLEMENTS      only *Base implements
  ByValue         IMPLEMENTS      only *ByValue implements
  ByPointer       IMPLEMENTS      the VALUE type implements

All three were the same edge. Two of the three were wrong, and nothing in the
graph could tell them apart.

Worse, in a different direction:

  package sealed;  type Sealed interface { seal() }
  package foreign; func (t *T) seal() {}

`foreign.T` cannot implement `sealed.Sealed` in Go — `seal` is unexported, so the
two identifiers are DIFFERENT. Matching on the bare name emitted a FALSE
IMPLEMENTS edge, and the interface-dispatch fan-out then turned it into an
impossible CALLS edge. That is the entire basis of the sealed-interface idiom.

- `methodSetKey` qualifies UNEXPORTED method names with their declaring package,
  leaving exported names unqualified (which is what makes cross-package
  satisfaction work at all). Exactness, not a heuristic: the sealed case now
  emits no edge, while the legitimate same-package implementor is retained.
- `collectStructMethodEntries` builds MS(T) and MS(*T) together and applies the
  promotion table above. The embed FORM is load-bearing, so it is now captured:
  `@reference.embedded-pointer` records `*T` versus `T`, which the parser
  previously discarded (the `*` is an unnamed token).
- Detection returns `{ structDefId, receiverForm }`. `receiverForm: 'pointer'`
  means the value type does NOT implement and only `*T` does — the fact
  `var x I = T{}` turns on.
- The form rides in the edge `reason` (`-structural-implements-pointer`).
  Relationships carry no arbitrary properties, so a new field would change the
  relation DDL, move SCHEMA_FINGERPRINT and force a rebuild for a fact a string
  already expresses. Value-form implementors keep the ORIGINAL unsuffixed
  reason, so a consumer matching the old string now sees exactly the assignable
  set — which is what that string always claimed to mean.

- `emitInterfaceDispatchFor` walks the SUBTYPE CLOSURE (IMPLEMENTS + EXTENDS) and
  skips bodiless declarations, instead of stopping at depth 1. Two reproduced
  Java shapes emitted an edge to a second abstract declaration while the only
  class with a body got nothing: a sub-interface that re-declares the method, and
  an abstract base between interface and implementation. Both now reach the
  implementation and neither emits the declaration edge.
- The fan-out is bounded by `MAX_INTERFACE_DISPATCH_FANOUT` (32,
  `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and reports what it dropped, mirroring
  `MAX_PROPERTY_DISPATCH_FANOUT`. A bare cap would silently discard valid dispatch
  targets, which is the same false-safe silence this issue is about.
- Corrects a rationale comment that was factually wrong about the code 70 lines
  above it (Case 0 DOES branch on `site.kind`, at :713-716; what it lacks is a
  read/write branch in its reason/confidence computation).
- Updates both copies of the case-ladder contract, which still described the
  fan-out as Case-4-exclusive.

The embed-pointer marker is PARSE-TIME capture emission, so a warm cache would
replay the pre-marker capture set and the distinction would never appear —
silently, the v27/v30 failure mode. 43 and not 40 because origin/main allocated
40, 41 and 42 while this branch was in review, which is exactly the window this
file's history records both prior EXACT clashes landing in. Pin moved with it.
RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGE.

- Go unit: 93 passed, including new rows pinning that `populateGoOwners` stamps
  `goReceiverKind` (previously the field had no reader and could rot silently)
  and that a pointer-receiver-only type implements in POINTER form only.
- Cross-language sweep, test/integration/resolvers: 3034 passed, 1 skipped,
  52 files, zero regressions.
- scope-capture bench: PASS (15 languages). Go is the ONLY fingerprint that
  moved, which is the check that this is a Go capture change and not a
  cross-language regression; rebaselined with rationale.
- Also closes review gaps in this PR's own tests: the concrete-field control was
  vacuous with respect to the type gate (repointed at a struct that IS an
  implementor), the two-service-file row could not distinguish the two files it
  is named for (both ends now file-qualified), plus new rows for signature
  mismatch, emitted confidence, and an exact N-by-M fan-out bound.

An existing index must be re-analyzed; the schema bump forces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 21:52:37 +01:00
Gergő Magyar
c1103f38f2
fix: type an inference-typed class field so it can act as a call receiver (#2807) (#2810)
* test(helpers): add the shared temp-repo lifecycle helper

`createTempDirPool` gives a suite one owner for its temp fixture repos —
create on demand, remove them all in one `afterAll` — instead of a hand-rolled
mkdtemp/rmSync pair per file. The PDG receiver pin added in the next commit
uses it.

Cherry-picked verbatim from ec36c6dda on the #2802 branch, where it was
extracted to collapse five hand-rolled cleanups. Identical content, so if both
branches land the add resolves as a duplicate rather than a divergence.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(typescript): type a class field from its initializer so it can be a receiver

A field whose type had to be inferred from its initializer produced no CALLS
edge at all — not a truncated chain, nothing. `this.p.inner().compute(x)` lost
`Outer.inner` too, an ordinary named-receiver call, because `typeOfMemberOnClass`
found no `typeBindings` entry for `p` and `foldReceiverChain` declines at its
first untypeable step rather than folding on a guessed owner.

The initializer was never invisible: `new Outer()` emitted its own constructor
edge exactly as the annotated twin does. What was missing was the step turning
that initializer into a TYPE BINDING, i.e. capture patterns for the two shapes
the query never covered:

  private p = new Outer();                       // public_field_definition value:
  private p; constructor() { this.p = new … }    // this.<field> = new …

Both are `@type-binding.constructor`, so `annotation` still outranks them in
`typeBindingStrength` and an annotated field keeps resolving through its
annotation. The assignment form carries a narrow `@type-binding.this-field`
marker on its `(this)` node — anchorCaptureFor takes the broadest range, so the
statement stays the anchor — which `tsBindingScopeFor` reads to hoist the
binding onto the Class scope, the only place `typeOfMemberOnClass` looks. The
marker must stay specific to that pattern: hoisting every constructor-inferred
binding would move method-local `const o = new Outer()` out of its own scope.

Kotlin and Swift needed no such pattern for the initializer form because one
grammar node (property_declaration) covers both a local and a stored property;
TypeScript splits them, and only the local half was ever covered.

Both self-diffing pins flip and gain rows: a method-assigned field, and a
deliberately mistyped `private p: Mismatch = new Outer()` that asserts the
source-strength tie-break executably. That row also pins a pre-existing
artifact — `Inner.compute` still resolves through the hoisted module-level
return-type binding — verified byte-identical on the pre-fix tree.

Fixes #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(javascript): type a class field from its initializer so it can be a receiver

JavaScript has no field annotations at all, so a class field's type can only
ever come from its initializer — which made this the strictly worse half of
#2807: `class C { p = new Outer(); }` gave `this.p` no type, and
`this.p.inner()` emitted nothing.

`synthesizeConstructorFieldBindings` in captures.ts already covered the sibling
shape, `this.p = new Outer()`, which is why THAT row resolved — but it only
walks `constructor` bodies, so a field initialized at its declaration matched
no pattern anywhere.

Adds the `field_definition` + `value: (new_expression)` patterns (the JS grammar
names the field `property:`, not `name:`), anchored so the binding lands in the
class body scope where `typeOfMemberOnClass` reads it. No hook change needed:
`jsBindingScopeFor` already delegates to `tsBindingScopeFor`, so it inherits the
`@type-binding.this-field` branch too.

Measured: `InferredField.run` now emits `Outer.inner`, exact parity with both
the local-const control and the constructor-assigned row. The second chain link
(`Inner.compute`) stays absent in ALL THREE rows — that is JavaScript's separate
return-type-inference gap, not this one.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): infer an instance field's type from the constructor it calls

`self.outer = Outer()` in `__init__` bound nothing, so `self.outer.inner()`
had no receiver type and the fold declined the whole chain — the Python half
of #2807. An annotated field (`self.outer: Outer = ...`) or one assigned from
an annotated parameter already worked.

`synthesizeConstructorFieldTypeBindings` deliberately refused to infer "from
arbitrary unannotated RHS expressions ... not a name-only guess". A CALL is not
that: Python has no `new`, so a call to a plain (or dotted) name is the only
syntactic construction form there is, and it is the same positive evidence
every other language reads from `= new X()`. A bare name, subscript, await or
comprehension is still refused.

Adds it as a THIRD and weakest tier. The existing explicit/parameter boolean
becomes a rank, so precedence is now explicit annotation > parameter annotation
> construction, and a later same-tier assignment still wins (the last write in
`__init__` is the live one). `interpretPythonTypeBinding` maps the new marker to
`constructor-inferred` (strength 1) — checked before the parameter branch, which
would otherwise have read the absent parameter marker as `annotation` and
promoted a guess to the strongest tier.

The Class-scope hoist needed no change: `@type-binding.instance-field` already
carries it in `pythonBindingScopeFor`.

Measured: `AssignedField.run` now emits `Outer.inner`, exact parity with the
annotated-field and local-const rows.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ruby): infer an instance variable's type from the constructor it calls

`@service = UserService.new` in `initialize` bound nothing, so `@service.inner`
had no receiver type and the fold declined the whole chain — the Ruby half of
#2807. An instance variable is the ONLY way a Ruby object gets a field, and
Ruby has no annotations, so this was the single shape that could have worked
and did not: the existing constructor-inferred patterns bind a local
(`x = Foo.new`) and a constant (`SERVICE = Foo.new`), never an ivar.

Adds the plain and `Foo::Bar` qualified ivar forms. `@type-binding.name` is
captured on the `instance_variable` node so the bound name keeps its `@` sigil
and matches the receiver text at the call site verbatim — the resolver compares
spellings, and `service` would never have matched `@service`.

`rubyBindingScopeFor` gains a Class hoist gated on a narrow
`@type-binding.ivar-field` marker riding the same node: an ivar declares a field
of the enclosing class, so the binding must live on the Class scope or no other
method can see it. Gated on the dedicated marker, never on
`@type-binding.constructor` at large, which also fires for `x = Foo.new` locals
that must stay in their own method.

Measured: `AssignedField.run` now emits BOTH chain links, exact parity with the
local-const control.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(resolvers): pin inference-typed field receivers across eight languages

#2807 was filed against TypeScript, but the defect class is cross-language:
"can a field whose type is inferred act as a call receiver". This measures all
eight languages where the shape exists at all, in one table.

The metric is parity with EACH LANGUAGE'S OWN CONTROL ROW, not "both chain
links present". JavaScript, Python, Dart and PHP lose the second link
(`Inner.compute`) even for a plain local, because nothing annotates `inner()`'s
return type — a separate return-type-inference gap. Scoring against "both
links" would have accused those four of a bug they do not have; scoring against
their own control isolates the field-typing question cleanly.

Recorded state: TypeScript, JavaScript, Python and Ruby now match their
controls. Kotlin and PHP already did before #2807 and are pinned so the shared
fold cannot regress them unnoticed — the languages that got receiver typing for
free are precisely the ones nobody re-checks.

Two rows stay pinned BROKEN, at their exact current value:

  Dart  — real and narrow: the annotated control resolves, the inferred one
          does not. Its bindings are synthesized in dart/captures.ts rather
          than by a query, so the fix is its own change.
  Swift — blocked by a different defect found while measuring: with several
          classes each defining `run`, every `run`'s edges are attributed to
          the FIRST-declared one, which collects duplicates while its siblings
          — including the ANNOTATED control — collect none. Receiver typing
          cannot be measured there until that is fixed, and "fixing" it against
          this observable would be fitting to a broken measurement.

Both gap rows carry a `callerExists` probe in the same assertion object, so an
empty list can never read as "resolved fine, wrong node id", plus a whole-matrix
guard that every language keeps a resolving control — that is what makes a gap
row mean "broken" instead of "fixture never worked".

Targets are deduplicated before comparison: Swift emits one edge more than once
per call site, and edge multiplicity is a different question from whether the
receiver typed at all.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): a method call on the receiver is not a construction

Review finding on f4e1ead0d. `constructorCallTypeName` accepted ANY call with
an identifier or attribute callee, so `self.p = self.build()` bound `p` to the
non-type `"self.build"` — and because that shares the weakest tier with a real
construction, a later such assignment DISPLACED an earlier `self.p = Outer()`
and left the field untyped again.

Measured before the fix: `self.p = Outer()` followed by `self.p = self.rebuild()`
emitted no CALLS edge at all from a method chaining off `self.p`, and
`self.q = self.make()` bound a type name that resolves to nothing. After:
the real construction survives the reassignment, and a pure method call binds
nothing rather than something wrong.

Rejects a callee rooted at the receiver name. `models.Outer()` still binds —
only `self`-rooted callees are refused, which is exactly the method-call shape.

The matrix gains a `reassigned-from-method-call` row that fails without this
rejection; that discrimination is the only reason the row exists.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(swift): resolve a method def to its own node when the labels disagree

Two classes in one Swift file each declaring `func run` collapsed onto one
node: every call in BOTH bodies was attributed to whichever `run` registered
first, which collected duplicate edges while its twin collected none. Renaming
one method fixed it; moving it to another file fixed it; so the collision was
name-keyed and per-file, not positional.

Root cause is a LABEL split, not a name. Swift's structure phase emits a type's
methods as `Function` nodes, while the scope extractor derives `Method` from the
`@declaration.method` anchor. Every key in `resolveDefGraphId` — qualified,
parameter-types, arity, shape — is label-scoped, so such a pair misses all of
them and lands on the bottom fallback, `simpleKey(filePath, name)`, which is
deliberately label-agnostic and first-write-wins.

Fixed at both ends:

  - Swift qualifies a method def as `<Type>.<method>`, matching the qualifier
    the structure phase already encoded in the node id. `class`, `struct` and
    `extension` all parse to `class_declaration`, so one ancestor walk covers
    them; a generic `class Box<T>` and an `extension Foo` wrapping a `user_type`
    both reduce to the bare owner name.
  - The bridge retries the qualified keys under the sibling callable label.
    Gated on the name containing a dot: `A.run` names one construct whatever the
    label, while a bare `run` is exactly the top-level-vs-method aliasing the
    label was added to prevent, so the original guarantee is untouched.

This also unmasked Swift's #2807 row. `let p = Outer()` had always bound
correctly — its edges were being credited to the wrong caller, so the
inference-typed receiver looked broken when it was not. `InferredField.run` now
emits `Outer.inner`, matching its control.

Verified on the full resolver + CFG suite: 3165 passed, 0 failed, against a
3164-passing baseline.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dart): declare inference-typed class fields so they can be receivers

`var b = Outer();` produced no `@declaration.property` capture at all — no
Property node, and nothing for the capture layer to hang a type binding on — so
`b.inner()` could not type its receiver while the annotated twin
`Outer b = Outer();` resolved fine (#2807).

The gap was in the query, one layer below where the binding is emitted: both
class-field patterns require a leading `(type_identifier)` or `(nullable_type)`,
i.e. a WRITTEN type. Dart puts the keyword there instead for an inferred field,
and spells it two ways — `inferred_type` for `var`, `final_builtin` for `final`
and `late final`. Covering only `var` would have left the more idiomatic Dart
style broken, so both are matched.

With the field declared, the capture layer types it from the constructor its
initializer calls, as `constructor-inferred` — the weakest source, and the
annotated branch returns before it, so an annotated field is untouched. Only a
direct construction is accepted (a bare identifier followed by a `selector`
carrying an `argument_part`, the same shape `findDirectCallValue` accepts for
locals); a literal, member call or await is left alone rather than guessed at.

Note this is the LOCAL/field split that made the gap invisible: `emitVarTypeBinding`
already handled `initialized_variable_definition`, but a class field is
`declaration(<keyword>, initialized_identifier_list(initialized_identifier))`.

`InferredField.run` now emits `Outer.inner`, matching its control. Dart's
`var r; C() { r = Outer(); }` shape stays pinned as a known gap: Dart writes the
field with no receiver prefix, so binding it means treating assignment to a bare
identifier as a field write, indistinguishable from a constructor-local.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(resolvers): record Swift and Dart reaching parity in the matrix

Both languages' inference-typed field rows move from KNOWN GAP to resolving,
which is the self-diffing signal this file was built to produce: closing either
gap failed it with the newly resolved ids in the diff.

The header table and prose are corrected together with the rows, as the file's
own instructions require — including WHY Swift moved. Its `let p = Outer()`
binding had always been correct; a separate label-split defect attributed the
second same-named method's calls to the first, which masked this row entirely.
Recording that is the point: a future reader comparing the table against the
code needs to know the row was never a receiver-typing failure.

One row stays pinned: Dart's `var r; C() { r = Outer(); }`. Dart writes fields
without a receiver prefix, so binding it means treating assignment to a bare
identifier as a field write — indistinguishable from a constructor-local until
the field set is known. Idiomatic Dart writes `final r = Outer();`, which the
inferred-field row now covers.

Every language keeps its resolving control row, so the remaining gap still means
"broken" rather than "fixture never worked".

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dart): type a field from a constructor assigned to it

`var r; C() { r = Outer(); }` bound nothing, so `r.inner()` had no receiver
type — the last inference-typed field shape still failing after the initializer
form was fixed (#2807).

Dart is the one language here that writes a field with NO receiver prefix, so
`r = Outer()` inside a constructor is syntactically identical to assigning a
constructor-local. That ambiguity is why this was initially left pinned — but
the field set IS knowable: the class body declares `var r`, which the
initializer fix already turned into a property declaration. So a bare name binds
exactly when Dart itself resolves it to the field: the enclosing class declares
it AND the enclosing body declares no local of that name. A `this.`-prefixed
write is unambiguous and needs neither test.

The shadowing case is asserted, not assumed: with a body-local `var s = Outer()`
in scope, the field stays unbound while the local still resolves on its own.

Binds `constructor-inferred` (weakest source, so an annotation still wins), and
only for a direct construction — an identifier followed by a `selector` carrying
an `argument_part`, the same shape accepted for locals. The narrow
`@type-binding.dart-field` marker drives the Class-scope hoist in
`dartBindingScopeFor`; gating on it rather than on `@type-binding.constructor`
at large is what keeps genuine locals in their own scope.

All three shapes now match their control: bare `r = Outer()`, `this.s = …`, and
a non-constructor `setUp()` assignment.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(swift): type an optional field and read through its force-unwrap

Swift cannot declare a stored property with neither a type nor an initializer,
so its "declare now, assign in init" idiom is an OPTIONAL field read back
through a force-unwrap. That shape resolved nothing, and it was broken in two
independent places — each alone leaves it broken:

  1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`,
     but the property-annotation pattern required the `user_type` to be a DIRECT
     child, so an optional field was never typed at all. The pattern added here
     captures the INNER `type_identifier`, so the binding is `Outer` without
     relying on `stripOptional` reducing an `Outer?` spelling.
  2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel,
     so even a typed field could not be read through the unwrap.

For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS`
outright: unlike TypeScript's `non_null_expression` — which is only ever `!` —
Swift's node also carries user-defined postfix operators, which can return
anything. Peeling those would type the receiver as the operand and mint a
confidently WRONG owner, the failure mode compound-receiver.ts calls strictly
worse than no edge. So the peel is operator-gated: transparent only when the
node's text ends in `!`, which is provably type-preserving.

Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and
the plain annotated field all resolve; previously only the plain one did.

The gate keeps this off every other language — `postfix_expression` is not a
node type the other grammars produce here — and the full resolver + CFG suite is
green at 3166 passed / 0 failed, against a 3165 baseline.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(resolvers): close the last two matrix gaps

Dart's `assigned-field` and Swift's new `optional-assigned-field` rows now
resolve, leaving no known-gap row in the matrix: every language reaches parity
with its own control on both the initializer and the assigned shape it can
express.

The Swift row is new because the shape it covers did not exist in the fixture:
Swift cannot declare a stored property with neither type nor initializer, so its
assigned form is an optional field written in `init` and read through a
force-unwrap — a shape that needed both an optional-annotation pattern and an
operator-gated receiver peel, which is why the row's comment names both.

The header records how the two hard cases were fixed, including the Dart
shadowing rule the fix depends on: a bare `r = Outer()` binds only when the class
declares that field and the body declares no local of the same name.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(bench): rebaseline the receiver-resolution and scope-capture gates

Both gates are exact-match, so the improvements in this branch fail CI until the
baselines move and the movement is explained. Caught by running the CI gates
locally — the resolver and CFG suites are green throughout and never see these.

receiver-resolution — three shapes moved to RESOLVES, no drop-count changed:

  ruby.fieldReceiverCall     INVISIBLE-GAP -> RESOLVES  (`@ivar = Foo.new`)
  swift.decoratedFieldType   INVISIBLE-GAP -> RESOLVES  (`var a: Outer?`)
  kotlin.nonNullAssert       VISIBLE-GAP   -> RESOLVES  (`x!!` receiver)

scope-capture — swift and typescript fingerprints, both ADD captures and remove
none; the per-language `_rebaselined_inferred_field_receiver_2807` notes carry
the detail and the prior digests. The other 13 languages are unchanged, which is
the check that this is the intended emission and not a capture regression.

CORRECTION to d5d878033's message, which claimed the operator-gated
`postfix_expression` peel "keeps this off every other language — postfix_expression
is not a node type the other grammars produce here". That is wrong: Kotlin's
grammar produces it too, and `kotlin.nonNullAssert` moving to RESOLVES is the
proof. The peel is still correct there — Kotlin `!!` is a non-null assertion with
exactly the type-preserving semantics the `!` gate tests for — but it is a
BEHAVIOUR CHANGE IN KOTLIN, not Swift-only as stated. The gate is what surfaced
it; the claim should have been verified rather than asserted.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cache): bump SCHEMA_BUMP for the six-language capture change, + review fixes

SCHEMA_BUMP 39 -> 40. THIS IS THE MERGE-BLOCKER of the review: every language
change in this PR is PARSE-TIME capture emission, and `analyze` skips tree-sitter
dispatch for byte-unchanged chunks (GUARDRAILS.md:34), so a warm cache replays
the pre-fix capture set verbatim and the new receiver edges never appear —
silently, no error. Exactly the v27/v30 failure mode this file already documents.
The PR description's claim that "no schema or version constant applies" was
wrong on both counts: a bump IS required, and a plain re-analyze does NOT
surface the captures without it. Re-check against origin/main before merging —
main was also at 39 when 40 was allocated, and this file records eight prior
collisions.

Also from the review:

- dart/simple-hooks.ts hand-rolled a 9-line parent walk byte-identical to the
  shared `walkToScope(innermost, tree, 'Class')` that TypeScript and Ruby call
  in one line in this same PR. Now uses the helper.
- utils/call-analysis.ts: the doc framed the postfix-`!` peel as Swift-only. It
  is not — Kotlin `!!` parses as the same node and is peeled too, which the
  receiver-resolution bench proved (kotlin.nonNullAssert VISIBLE-GAP ->
  RESOLVES). The comment now says so, and names the `!` gate rather than the
  language as the bound.
- test/helpers/temp-dir-pool.ts: its doc claimed four consumers; on THIS branch
  only `pdg-chained-receiver-callees` uses it (the other three convert on
  #2802). Corrected, and the byte-identical-to-#2802 intent recorded.
- inferred-field-receiver-matrix: adds the Dart shadowing assertion the header
  comment already CLAIMED to make but never did. First attempt was vacuous —
  `var s = Outer()` is a declaration, so it never produced the bare
  `assignment_expression` the guard inspects; removing the guard did not fail
  the row. Fixture corrected to `var s; s = Outer();`, and mutation-verified:
  guard present 35 pass, guard removed the row goes red.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(cache): move the SCHEMA_BUMP pin to 40

The pin at incremental-parse-cache.test.ts asserts the exact value on purpose —
it exists to catch two branches claiming one number, and it has earned that
eight times. Bumping the constant to 40 without moving the pin turned it red.

Found by the Codex (gpt-5.6-sol) review leg, which flagged it as a
deterministic committed-test failure. The Claude lanes could not have caught it:
they were dispatched before the bump landed.

The comment now records the 39 -> 40 movement and its reason, matching the
existing convention in that block.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dart): treat every binder as a field shadow, not just local declarations

Review P1, reproduced by two independent reviewers. `emitDartFieldAssignmentBindings`
binds a bare `r = Outer()` to the FIELD when the class declares `r` and the body
declares no local `r` — but the shadow set was built by walking for
`initialized_variable_definition` only. That is one binder form out of many, so a
formal PARAMETER named like a field slipped through:

    void reset(Alpha r) { r = Alpha(); }   // r is the PARAMETER

retyped the FIELD to `Alpha`, fabricating an edge AND destroying the correct
`Beta` binding the constructor had established. The mutation test shows exactly
that: the pre-fix result is not a missing edge but a WRONG one (`Other.inner#0`
instead of `Outer.inner#0`) — the failure mode compound-receiver.ts:519-537 calls
strictly worse than no edge.

The node types were chosen from real grammar output, not assumed. Two facts drove
the design: formal parameters live on the SIBLING `method_signature`, never inside
`function_body`, so no walk of the body could ever have seen them; and
`formal_parameter` carries a `name` field only when typed — untyped, `this.` and
`super.` forms do not. `collectDartBodyShadows` therefore walks the signature AND
the body, collecting formal/closure/local-function/named/optional params,
`this.`/`super.` constructor params, catch bindings, for-in variables, and both
local-declarator forms. A parameter shape whose name cannot be read contributes
nothing — declining to bind is the safe direction.

A 27-case binder sweep passes: 26 shadow shapes bind nothing, the no-binder
control still binds.

Four new matrix rows (param, closure param, catch, loop var) assert a surviving
POSITIVE target rather than an empty list — deliberately, because the pre-fix
value is a different non-empty target, so these rows cannot pass vacuously the way
an empty-assert row can. Mutation-verified: reverting captures.ts turns exactly
those four red and leaves every pre-existing row green.

SCHEMA_BUMP is already at 40 on this branch for the six-language capture change and
has not shipped, so it covers this too; re-check against origin/main before merge.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ruby): don't bind a class-object @ivar as an instance field

Review P1. The `@ivar = Foo.new` patterns added on this branch hoist to the
enclosing Class scope without asking WHOSE `self` owns the ivar. In Ruby an ivar
written in singleton context belongs to the class object, not to instances, so

    def self.build; @pool = Alpha.new; end
    class << self; def make; @cache = Alpha.new; end; end

bound `@pool`/`@cache` as INSTANCE fields, fabricating edges from instance methods
that read an ivar which is never assigned on an instance.

Three corrections came out of fixing it:

1. The detection premise was wrong. `def self.build` is NOT a `method` node with a
   `self` receiver — it is its own node type, `singleton_method`, and
   `childForFieldName('receiver')` returns NONE on it. Matching on a receiver field
   would have detected nothing, silently. Detection is by node type:
   `singleton_method` / `singleton_class`.

2. A THIRD form exists that the review did not name: a class-body-level
   `class C; @shared = Outer.new;` is the same defect (self is the class object),
   and is likewise new on this branch — before it, `left: (instance_variable)`
   matched nothing at all.

3. Dropping only the `@type-binding.ivar-field` marker is NOT sufficient, and the
   class-body case is what proves it: with the marker gone the binding falls back
   to its innermost scope, which at class-body level ALREADY IS the Class scope, so
   it still lands in the wrong place. The whole match is therefore discarded.

The check lives in `languages/ruby/captures.ts` because `Capture` carries only
`{name, range, text}` — no AST node — so `rubyBindingScopeFor` structurally cannot
ask whose `self` owns the ivar. All Ruby logic stays under `languages/ruby/`.
`method` alone is not a sufficient "instance" signal, since a `def` inside
`class << self` is reached through a `method` node first.

Cost relative to main is zero: a class-object ivar goes back to binding nothing,
exactly as before these patterns existed.

The three new rows are structurally two-sided, not just mutation-checked: each
empty row is paired with a non-empty `*-instance-ivar` row on the SAME fixture
class, so breaking the hoist entirely turns the partner red while an unconditional
hoist turns the empty row red. Mutation-verified in both directions.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(typescript,javascript): bind `this.field = new X()` only inside a class method

Review P0, the most serious finding of the tri-review and reproduced by two
independent reviewers. The `this.<field> = new X()` patterns added on this branch
were CONTEXT-FREE: they matched anywhere in the file, and `tsBindingScopeFor`
hoisted to the nearest enclosing Class without asking whose `this` that was. Since
the binding lands on the same Class scope with the same `constructor-inferred`
source as the field-initializer pattern, and pass4CollectTypeBindings prefers the
later match on `>=`, it OVERWROTE the class's real field type. Reproduced from a
non-arrow callback, an object-literal method, a static method, and module level.

Fixed STRUCTURALLY, in the query: both patterns are now nested under
`class_body -> method_definition -> body: (statement_block) -> (expression_statement)`,
which kills the callback, object-literal and top-level triggers with no runtime
code and mirrors JavaScript's `synthesizeConstructorFieldBindings` discipline.
TypeScript still accepts ANY method, not just `constructor`, so the setter case
this branch deliberately supports keeps working.

`static` needed one emit-side guard: it is an ANONYMOUS token on `method_definition`
with no field name, and tree-sitter patterns cannot negate an anonymous token
(checked against node-types.json), so `isStaticMethodThis` drops it in captures.ts.
`simple-hooks.ts` is comment-only — the unconditional Class hoist is now documented
as safe BECAUSE the marker's producers are bounded, with a note that widening them
means re-establishing that.

Also fixes a `.ts`/`.js` disagreement the narrowing itself created: JavaScript's
synthesis matched `method_definition` ANYWHERE, so an object literal containing a
method named `constructor` still typed the enclosing class's field. Measured on
identical source — JS emitted `p -> Alien`, narrowed TS emitted nothing — and
closed with a `node.parent?.type !== 'class_body'` guard in javascript/captures.ts.
The two languages must not disagree about the same source.

Deliberately NOT matched (a missing binding, never a wrong one — JS declines these
too): an assignment in a nested block, or inside an arrow where `this` genuinely IS
the instance.

Evidence the narrowing removed nothing legitimate: `bench/scope-capture --check`
passes with the TypeScript AND JavaScript fingerprints BYTE-IDENTICAL. The five new
matrix rows use an `Alien` class that also declares `inner()`, so a regression SWAPS
the target rather than emptying the set — they cannot pass vacuously. Mutation
test: reverting the source turns exactly those rows red (`+ "Alien.inner#0"`,
`- "Outer.inner#0"`).

SCHEMA_BUMP stays at 40 — this PR's existing bump covers the capture change being
narrowed, and the buggy variant never shipped outside this branch.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(resolution): consult the sibling callable label in the position key too

Review P1. This branch added a sibling Method<->Function retry to the qualified
keys in `resolveDefGraphId`, but not to the #2699 POSITION key or its fail-closed
guard, which both stayed scoped to `def.type`. Since the premise of the whole fix
is that Swift defs are `Method` while nodes are `Function`, the position lookup
missed and the fail-closed guard could NEVER FIRE for exactly the case the retry
serves — so a function-local `func helper` inside `Host.run` was deterministically
aliased onto the class method `Host.helper`, even with differing arity.

Two earlier reviewers REFUTED this by arguing the guard runs before `lookupTagged`.
That is true and irrelevant: the guard is scoped to `def.type`, so in the
label-split case it is unreachable. Recording it because two independent lanes
agreeing on a refutation is not proof.

`siblingCallableLabel(label)` is now the single definition, consulted by all three
key families:
  - position key: retried under the sibling label, gated on `posHit === undefined`
    so an AMBIGUOUS_POSITION tombstone still falls through to the name keys rather
    than being resolved by relabelling. Deliberately NOT dot-gated — a position key
    is not a name, so the aliasing risk the dot gate exists for does not apply.
  - fail-closed guard: mirrored unconditionally (it only ever returns undefined).
  - qualified retry: dot gate untouched.

Measured before -> after on a Swift fixture: `Host.helper#1 -> sink` (the local
body's call credited to the public 1-arg method) becomes
`Host.run.helper@8:8#2 -> sink`, with the local's own node no longer edgeless.

SCOPE CORRECTION to the P1 report: only the first consequence is a bridge defect.
The second — "`run`'s call to the local resolves to the method" — is NOT reachable
from ids.ts. Both defs carry qualifiedName `Host.helper` and label `Method`, and
the binding hands the target side the class-member def, so the scope walk in
free-call-fallback picks the member. No def->node mapping can change that; it is
pinned as an explicitly labelled KNOWN GAP rather than left implied.

Verification, on shared code so the full bar: resolvers+cfg 3170 passed / 1 skipped
/ 0 failed; `bench/receiver-resolution --check` OK; `bench/scope-capture --check`
PASS (15 languages, Swift fingerprint unchanged) — i.e. the bridge change altered
no capture output. The 3170 reconciles against the 3167 pre-existing at a5bf4c2da
plus exactly 3 new tests; 3167 differs from the older 3166 baseline because
0418b0aac added the matrix's only known-gap row, which emits one extra `it`.

Mutation test: with both arms reverted, 3 of the 5 new cases go red, each arm
pinned independently — the guard case registers no position key, the position case
registers no local-name key.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: apply cleanup-review findings across the receiver-typing change

Four parallel quality lanes (reuse, simplification, efficiency, altitude) over
`origin/main...HEAD`. Eleven fixes; both exact-match bench gates hold with every
capture fingerprint BYTE-IDENTICAL, so none of this changed what the analyser emits.

Reuse — stop re-rolling helpers that already exist:
- `walkToScope` moved out of the TypeScript provider into a language-neutral
  `utils/scope-tree-walk.ts`. Ruby and Dart had begun importing it FROM
  `languages/typescript/`, which made three unrelated providers depend on the TS
  module for a generic `Scope`/`ScopeTree` walk. Python's hand-rolled copy — the
  one this PR's new `self.x = Outer()` path routes through — is folded in, so all
  six languages now share one traversal.
- Swift stops string-parsing a type name. `swiftEnclosingTypeName` split on `<`
  and `.`; `swiftBaseTypeIdentifier` + `swiftQualifiedBaseTail` do it structurally
  and correctly skip the sibling `type_arguments` node, which the string form only
  guessed at. `findEnclosingTypeDeclaration` replaces the inlined ancestor walk.
- TypeScript uses the canonical `hasKeyword(method, 'static')`. The previous
  `child.type === 'static'` is the exact form `isStaticMember` documents as
  grammar-version-fragile: "`static` can appear as an unnamed token or as a
  keyword node depending on grammar version; check text."
- Both new test suites use `cleanupTempDirSync`, which exists because a pipeline
  test's open handle surfaces as EBUSY/EPERM on Windows and `force` does not
  suppress it. This repo shards Windows CI.

LATENT DEFECT, found by the reuse lane and fixed: `var a = X(), b = Y();` parses
as ONE `declaration` with two declarators, and the query matches it once per
declarator with the SAME node — so the first-descendant search handed every
declarator the FIRST one's initializer. `b` resolved as `X`. Now reads
`nameNode.nextNamedSibling`, which is both correct and free. Pinned by a
`multi-declarator-inferred-field` row ordered so the declarator under test is the
second; reverting the fix turns exactly that row red with the wrong edge.

Efficiency — measured, not asserted:
- Dart's shadow set was built eagerly for EVERY method body and discarded 87-100%
  of the time (a `this.`-prefixed write never reads it). Now lazy and memoised per
  body, gated on `fields.has()`. Semantics are unchanged: the set is body-wide, so
  deferring construction cannot change its contents.
  Worth recording WHY CI could never have caught this: `bench/scope-capture` gates
  the SCALING RATIO, and the work is linear — ratio stays 1.0 against a 1.5 budget
  while a constant-factor regression passes straight through.
- `isTransparentReceiverWrapper` crossed the `node.type` native getter twice on the
  common path. One hoisted read, and — since absent and ungated are distinguishable —
  one `get` replaces `has`+`get`.

Simplification:
- One `Map<string, string | null>` replaces the parallel Set + Map that both
  expressed "this wrapper is transparent", with `null` meaning unconditional.
- `ids.ts` computed `siblingCallableLabel` twice under two names. The three retry
  blocks are deliberately NOT collapsed — they use different key builders and
  materially different gates.
- Python's `interpret.ts` nesting was only a consequence of arm ORDER; swapping the
  arms is unconditionally equivalent (the two differ only when both markers are
  present, and both orders then yield `constructor-inferred`).
- One `isDirectConstruction` predicate replaces the construction-shape test that
  had been written four times in dart/captures.ts.

Deliberately NOT done, each needing a fingerprint rebaseline or new node ids:
unifying the six `@type-binding.*-field` markers into one canonical capture (it
would change Python's anchor semantics, which must be verified not assumed); a
Swift `labelOverride` mirroring Kotlin's four-line fix, which is the real cure for
the Method/Function split the bridge currently compensates for; generalising the
Swift optional-annotation pattern to `(type_annotation (_))` so the existing
strippers handle every wrapper; and merging the TS query with the JS walker, which
also carries a JSDoc branch no query can express.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(swift): regenerate the Swift captures golden for the optional-annotation pattern

CI caught what my local runs did not: `swift-captures-golden.test.ts` pins
`emitSwiftScopeCaptures` output across every `swift-*` fixture, and this branch
changes that output. It is a THIRD capture gate, separate from the two
exact-match benches already rebaselined here — `bench/scope-capture` hashes a
different corpus, so its Swift fingerprint moving did not imply this one, and
passing it was not evidence this was clean.

The drift is digest-only: 37 changed lines, 37 in each direction, no capture
entry added or removed. That is the expected shape for
`(type_annotation (optional_type (user_type …)))` making optional properties emit
an annotation binding they previously did not, plus the `@declaration.qualified_name`
now carried on Swift method declarations.

Regenerated with the mechanism the test itself prescribes (`UPDATE_GOLDEN=1`),
not by relaxing the assertion. Verified after: all Swift unit + resolver suites
green (4 files, 124 tests), and `bench/receiver-resolution --check` still exactly
matches its baseline.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: three more wrong-owner defects, found by a second review round

A second tri-review of this PR found THREE new P1 wrong-owner defects — every one
of them in code the FIRST round had already fixed. All three are the same root
shape: an incomplete ENUMERATION of binder or scope forms. That class has now
bitten this branch four times (formal parameters, then these), so two of the
three fixes below deliberately attack the class rather than the instance.

1. DART 3 PATTERN BINDERS (P1, reproduced by two independent lanes).
   `addDartBinderName` enumerated five binder node types, and every Dart 3
   pattern form parses into node types in NONE of them — so a pattern-bound local
   did not count as a shadow and its write retyped the CLASS FIELD:
       class Host {
         var session = Cache();
         void load() { final (session, count) = (Session(), 2); }
         void use() { session.ping(); }   // resolved Session.ping, not Cache.ping
       }
   The grammar hides every rule that would carry a binder (`_pattern_field`,
   `_list_pattern_element`, `_guarded_pattern`, …), inlining children onto the
   enclosing visible pattern node, so binders land as direct `identifier` children
   of just two leaf types. Covers all 10 pattern types that can hold one; the
   eight container types are defence, since the grammar demonstrably inlines
   identifiers onto containers already.
   THE COMPOUNDING PART: a grammar-derived coverage guard reads `nodeTypeInfo` and
   fails if the grammar declares a `*pattern*` type the fixtures do not exercise.
   A grammar bump adding an 11th type now turns the suite red instead of silently
   reopening this bug a third time.

2. RUBY BLOCK-RECEIVER `self` REBINDING (P1 here, both Claude lanes + Codex, which
   rated it P2 — the engines agreed the defect is real and disagreed on severity).
   `isRubyInstanceIvarWrite` enumerated `singleton_method`/`singleton_class` as the
   ways `self` gets rebound. A `def` inside a BLOCK attaches to the block's
   receiver, so `Struct.new(:x) do def warm; @a = Beta.new; end end`,
   `Class.new do … end`, `class_eval`, and `other.instance_eval { @a = … }` all
   published onto the nearest LEXICAL class.
   Deliberately NOT fixed by listing rebinding call names: that set is OPEN —
   `def helper(&blk) = Foo.class_eval(&blk)` rebinds a block it merely receives,
   and nothing in the block's own syntax reveals it. An allow-list of "safe"
   iterators would be the same defect one level down. The rule is structural:
   crossing ANY block boundary makes ownership unprovable, so discard. Complete by
   construction rather than by enumeration.
   ACCEPTED COST, asserted not hidden: `[1].each { @shared = X.new }` in an
   instance method really is the instance's `self`, and this drops it — that block
   is syntactically identical to the `instance_eval` one. It has its own row
   (`plain-block-self-ivar`) so the loss is visible rather than discovered later.

3. STATIC FIELD INITIALIZERS (P1, found by Codex/gpt-5.6-sol, corroborated).
   A `static` field initializer was captured as an ordinary instance binding, and
   since both land on one Class scope at the same `constructor-inferred` strength,
   the later wins the `>=` tie-break — so a static field retyped the instance
   field of the same name (`this.p.hit()` -> `Wrong.hit`). Unguarded in BOTH
   `javascript/query.ts` and `typescript/query.ts`; the existing
   `isStaticMethodThis` only ever covered the `this.x =` assignment form.
   Two things surfaced while fixing it: the TS `annotation` pattern collides
   identically and is PRE-EXISTING, not introduced here; and JS `static
   constructor(){}` had no guard where TS did — the .ts/.js divergence this PR's
   own comment claimed could not happen.
   Dart has no same-name twin (the language forbids it), but a static method's
   receiver-less write named a library-level variable and DISPLACED the
   constructor's binding. Fixed narrowly, with a counterweight row
   (`static-field-declaration-still-types-its-receiver`) that goes red if anyone
   widens the guard into "drop every static binding" — reading a static by bare
   name from an instance method is ordinary Dart and must keep working.
   ACCEPTED COST: `typeBindings` has one map per Class scope with no static/
   instance split, so a static field is dropped rather than recorded separately,
   losing typing on a TS/JS `Host.p.hit()` static receiver chain. Missed edge over
   wrong edge, per compound-receiver.ts:519-537.

Every new row asserts a SURVIVING POSITIVE target, never an empty set: the pre-fix
value in each case is a DIFFERENT non-empty target, so none can pass vacuously —
the trap this branch already fell into once. Mutation-verified per fix: reverting
each turns exactly its own rows red (17 Dart, 6 Ruby blocks, 5 static) with every
pre-existing row green.

Matrix 49 -> 80 tests. Siblings 510 passed. tsc clean. scope-capture PASS (15
languages, all ratios within gate).

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dart): mask a shadowed field on the READ side, not just the write side

The review critic refused to pass this PR while this was open, and it was right
to: this is the same wrong-owner shape as the three defects fixed in the previous
commit, except this one is introduced BY this PR rather than merely missed by it.

THE DEFECT. Typing an unannotated field from a constructor assignment
(`var conn; Host() { conn = Alpha(); }` binds `conn` on the CLASS scope) is this
PR's whole point. `emitDartFieldAssignmentBindings` correctly declines to WRITE
that binding when a member body rebinds the name — but the shadow set gated
writes ONLY. `collectDartBodyShadows` had exactly one call site, inside the
bare-name write branch. Nothing consulted it on the read side, so a bare-name
READ of a shadowing binder the resolver cannot type walked straight past the
local and hit the field binding this feature mints:

    class Host {
      var conn;
      Host() { conn = Alpha(); }
      void probe(List<Beta> xs) {
        for (final conn in xs) { conn.inner(); }   // conn is a Beta element
      }
    }
    // resolved Alpha.inner, not Beta.inner

Reproduced in SEVEN binder shapes, not the one the review reported: for-in
(`final` and `var`), untyped formal parameter, plain local `var`, catch binding,
closure parameter, and record pattern. Delete the constructor and the same read
emits NOTHING — which is what proves this PR introduced it. "No edge" became
"wrong edge", the one failure mode compound-receiver.ts:519-537 exists to prevent.

THE FIX uses `Scope.ownsReceivers` (#2701), the primitive that already exists for
exactly this, rather than inventing a mechanism. `scope/walkers.ts` consults
`typeBindings` FIRST at every scope and only then honours the mask, so a shadow
the resolver CAN type still wins — an annotated `void probe(Beta conn)` keeps
`Beta`, because `synthesizeDartSignatureBindings` anchors parameter bindings on
the same body node and they land on the same Function scope. The mask fires only
where the alternative was a fabricated type.

Plumbing follows TypeScript's `@receiver-owner.this` precedent: the marker rides
the same synthesized match as `@scope.function` and sits outside the `@scope.`
namespace so `anchorCaptureFor` cannot mistake it for the anchor. Dart differs
only in that its function scopes are synthesized in captures.ts rather than
declared in the .scm, so the names travel as capture TEXT — a `CaptureMatch`
carries no AST node, so the reader cannot re-derive them.

SCOPE, and the costs taken knowingly rather than hidden. The mask is
`shadows ∩ fields` and nothing wider. Masking every locally bound name would
also fix a library-level `var logger = Logger();` shadowed by a loop variable,
but it changes resolution for code this PR never touched. Three consequences are
documented on `dartShadowedFieldsCapture`, not buried: the wider case is left
open; an ANNOTATED field shadowed by a binder is masked too (correct Dart, but it
touches resolution predating #2807); and `mixin` bodies are reached, since the
grammar gives them a `class_body`.

PERFORMANCE, measured rather than asserted. The mask is emitted eagerly in Pass A,
where `collectDartBodyShadows` used to be lazy — the replaced comment recorded
87-100% of eagerly built sets being discarded, ~15% of Dart emission. Actual cost
on the scope-capture large corpus, median of 3: 405.6ms with the mask vs 390.0ms
without, ≈ +4%. Fingerprint and capture_groups are byte-identical across both
arms, so no corpus fixture emits a mask at all — that 4% is the cost of the CHECK
alone. Not visible to `bench/scope-capture`, which gates the scaling RATIO and is
blind to a linear constant factor; stated here because the gate cannot state it.
(3 samples per arm, blocked not interleaved — an estimate, not a rigorous number.)
A per-file memo keyed by node span makes both passes share one walk per body, so
the write side no longer pays a second one.

SCHEMA_BUMP 40 -> 41 with its exact-value pin, since capture emission changed.
Re-check against origin/main immediately before merge — main was 39 at commit time.

Mutation-verified both directions, which is the part that matters:
  - unwire `scopeOwnsReceivers`, rebuild -> exactly 2 rows red
    (`loop-var-read-does-not-see-the-field`, `pattern-read-does-not-see-the-field`),
    83/85 green.
  - over-widen the mask (drop the `shadows.has` test) -> 28 Dart rows red,
    including `unshadowed-read-in-a-shadowing-class-still-resolves`.
The three control rows stay green under the first mutation BY DESIGN — they guard
overreach, not the defect; the second mutation is what proves they are live. Pre/post
on the trigger row: `{Class:Alien, Alien.inner#0, Outer.inner#0}` -> `{Class:Alien,
Alien.inner#0}`, so no row can pass vacuously.

Matrix 80 -> 85 tests. Sweep 3220 passed (was 3215; exactly +5). tsc clean. All four
capture gates green: receiver-resolution OK, scope-capture PASS (15 languages, no
fingerprint moved, nothing rebaselined), callable-value-flow PASS, swift golden 9.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ts,js): let each language name its own class-field node type

CI caught a defect the whole review round missed. `grammar-literal-validation`:

    1 dead grammar literal(s) found:
      - node-type "field_definition" — languages/typescript/captures.ts:0
        — not valid in [typescript]

`isStaticClassFieldBinding` held BOTH spellings in one set —
`public_field_definition` (TypeScript) and `field_definition` (JavaScript) — so
that one predicate could serve both languages. But the predicate lives in
`typescript/captures.ts`, and the gate checks every literal against the grammar
of the FILE it appears in. `field_definition` is not a TypeScript node type.

The literal was NOT dead code: `javascript/captures.ts:42` imports the predicate
and calls it against real JS nodes, so the guard worked. The gate is still right
to fail it, and for exactly the reason this predicate's own docblock gives for
preferring `hasKeyword` over a node-type test — "a node-type test silently stops
firing on a grammar bump and every static field starts retyping its instance
twin again". A literal already dead in its own file is that failure shipped
pre-broken: nothing in the TypeScript file would ever have told us.

Each language now names its own node type and passes it in
(`TS_CLASS_FIELD_DEFINITION_TYPES` / `JS_CLASS_FIELD_DEFINITION_TYPES`), so every
literal is checked against the grammar it belongs to. The `hasKeyword` logic and
the static/instance reasoning stay shared and unchanged — only the node-type set
moves to the caller.

WHY THE LOCAL SWEEP DID NOT CATCH IT: I ran `test/integration/resolvers` and
`test/integration/cfg`. The gate is `test/integration/grammar-literal-validation.
test.ts`, in the parent directory. Scoping a sweep to the subdirectories a change
touches is precisely how a cross-cutting gate gets skipped.

grammar-literal-validation 4 passed. tsc clean. Full `test/integration` +
`test/unit/scope-resolution`: 6305 passed, 14 failed — all 14 in e2e/environment
suites (fts-extension-e2e 9, analyze-heap-oom-e2e, cli-e2e,
analyze-wal-checkpoint-failure, plus interproc-taint and parse-impl-env-reads,
which BOTH pass in isolation and fail only under 28-worker load). CI runs the
same files green.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(dart,ts): pin all seven read-side binder shapes; correct a wrong accepted-cost claim

Two review findings, one of which turned out to be a documentation defect rather
than the design defect it was filed as.

S8 — THE READ-SIDE FIX PINNED 2 OF THE 7 SHAPES IT REPORTED REPRODUCING.
`ab2f48c17` reported the wrong-edge defect reproducing in seven binder shapes and
landed rows for two. The stated mitigation was that all seven route through one
`collectDartBodyShadows` enumeration whose completeness the grammar-derived
coverage guard protects. That mitigation is NARROWER THAN CLAIMED: the guard
filters `nodeTypeInfo` on `type.includes('pattern')`, so it covers the pattern
family and NOT catch bindings, closure parameters, plain locals, or formal
parameters. Narrowing `addDartBinderName`'s catch arm would have turned no row red.

All seven were re-measured by unwiring `dartScopeOwnsReceivers` and rebuilding.
Every one gained the wrong edge `Outer.inner#0` — none had to be dropped as
non-reproducing. Five new rows: formal parameter, plain local `var`, catch
binding, closure parameter, for-in `var`.

Non-vacuity established structurally, not by assertion: the Dart AST was dumped
first to confirm each fixture produces the node `addDartBinderName` actually
inspects (`formal_parameter`, `initialized_variable_definition`,
`catch_parameters`, `for_loop_parts`). The catch row uses a bare `catch (zf)`
rather than `on Err catch` deliberately — an `on` clause names a type, which
would make the row measure type resolution instead of the mask.

S7 — THE ACCEPTED-COST COMMENT WAS WRONG, AND THAT IS THE FINDING.
It claimed dropping a static field's binding trades a wrong edge for a missed one.
Measured on a same-name twin, that is false:

    read                     with the drop      without it
    this.p  (instance twin)  Outer  correct     Alien  wrong
    Host.p  (static twin)    Outer  WRONG       Alien  correct
    Host.q  (static, no twin) none — missed     Alien  correct

The wrong edge did not disappear. It MOVED to the static read, which now picks up
the instance twin's type. Only the no-twin case is a genuine missed edge. The
trade is still right — `this.p` is far more common than `Host.p` — but it was
documented as safer than it is, and a reader deciding whether to revisit it was
being given the wrong picture.

NAMESPACING WAS EVALUATED AND DELIBERATELY NOT DONE. `Host.p.hit()` resolves
through `foldReceiverChain` in shared `compound-receiver.ts`, which explicitly
discards whether a chain's base was a class reference or a value (:519-527). The
class-constant bit exists only on the text-cascade path (`currentIsClassConstant`)
and is consumed solely by `isConstructionSelectorHop`; TS/JS take the fold, not
the cascade. `Scope.typeBindings` is `ReadonlyMap<string, TypeRef>` with no static
field. `ownsReceivers` cannot help — it is a suppressor that can only REMOVE a
binding, never route to a second one. A real fix needs `FoldState` to carry the
bit plus a key convention in shared code (an AGENTS.md:42 hook if not
language-neutral), it crosses the worker boundary so it needs a SCHEMA_BUMP, and
`compound-receiver.ts:826` iterates every binding for `fieldFallback` so a
namespaced key would leak straight back in as an ordinary field. Not a cheap or
safe change — and it would have been made with ZERO existing tests pinning
static-read behaviour.

So: smallest safe step instead. Two rows pin the measured behaviour
(`static-read-of-a-same-name-twin-picks-up-the-instance-type` asserts the positive
wrong target, not an empty set; `static-read-without-a-twin-loses-its-type` is a
known-gap), and the comment now says what actually happens. Anyone who revisits
this starts from measurements rather than from a claim.

No SCHEMA_BUMP: the `captures.ts` change is comment-only — verified, the diff has
no non-comment added lines.

Mutation red-rows 2/85 -> 7/90; each new row fails with a strictly larger set
(`+Outer.inner#0`), so none can pass vacuously. Overreach control still live:
dropping `shadows.has` turns 28 rows red including
`unshadowed-read-in-a-shadowing-class-still-resolves`.

Matrix 85 -> 92 tests. Sweep 3231 passed, 0 failed. tsc clean. All four gates
green, no fingerprint moved, nothing rebaselined.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(python): stop a dotted callee from fabricating a constructor type

S4 and S5 from the review round. They are ONE defect, not two, and the real one
is wider than the review described. Both live in a 32-line block THIS PR adds
(`@@ -116,0 +117,32 @@` — a pure addition), so neither is pre-existing.

THE DEFECT. `constructorCallTypeName` rejected only a callee rooted at the
receiver and returned every other dotted callee whole to `resolveTypeRef`, which
resolves dotted names through `QualifiedNameIndex` — and that index matches the
TRAILING SEGMENT against a class of that name even when the callee is a method on
an unrelated object:

    class Alpha:
        def ping(self): return 1
    class Factory:
        def Alpha(self): return "not an Alpha"    # a METHOD
    class Host:
        def __init__(self, f): self.svc = f.Alpha()   # svc is a str
        def run(self): return self.svc.ping()
    # measured: Host.run -> Alpha.ping, fabricated

WIDER THAN FILED: the review framed the trigger as a callee rooted at an
`__init__` PARAMETER. Measured, the root's binding form is irrelevant — a
module-level variable (`shared_factory.Alpha()`) fabricates identically. Any rule
written against what the root binds to would have fixed half the defect and left
the other half looking fixed. Both fabrications now have rows.

S5 IS A SYMPTOM, NOT A SECOND DEFECT. `self.conn = Outer()` then
`self.conn = Registry.get()` typed the field as `"Registry.get"` (resolving to
nothing, so the edge vanished) only because the dotted arm accepted
`Registry.get` as a constructor in the first place. Once dotted callees yield no
candidate, there is nothing weak left to displace with and `Outer` survives. So
`>=` is untouched and NO second mechanism was added: between two REAL
constructions last-write-wins is correct, and the existing `ReassignedField`
matrix row depends on it. Tightening the tie-break would have been the wrong fix
to a symptom.

THE FIX: accept a bare `identifier` callee only. Refusing ambiguous evidence at
CAPTURE time rather than resolving-then-rejecting is deliberate — the target-kind
route is not reachable from this file (`resolveTypeRef` already filters
`TYPE_KINDS`; the fabrication comes from a trailing-segment match in
`scope/walkers.ts`), and the root-alias route would collide with PR #2828, which
is rewriting exactly how an unaliased dotted namespace import resolves. This
change is orthogonal to #2828 by construction: it changes what is CAPTURED, never
how a name is looked up, and touches none of its files.

WHAT THE DOTTED ARM WAS ACTUALLY BUYING: nothing. The review (and this PR's own
docblock) justified it with `self.u = models.User()`. Measured, that shape emits
NO edge before or after this change — an instance field's binding lands in CLASS
scope, which never reaches the namespace split. The shape that really resolves is
the module-level local `u = models.User()`, which comes from `query.ts` and is
untouched here. The arm's entire measured contribution was fabrications, which is
what made the fix cheap.

#2828 COMPATIBILITY, checked not assumed: `import pkg.user` -> `self.u =
pkg.user.User()` resolves to nothing both before and after, so this cannot stop it
resolving. No test row pins that shape ON PURPOSE — asserting its current empty
state would plant a tripwire that goes red the moment #2828 lands. If #2828 also
teaches the FIELD path the namespace split, re-enabling dotted field callees
becomes a live option; the docblock says so, and says why redoing it capture-side
would re-open the fabrication.

SCHEMA_BUMP 41 -> 42 with its pin. This is parse-time capture emission: after the
fix `self.svc = f.Alpha()` emits no `@type-binding.constructor` capture at all, so
a v41 warm cache replays the pre-fix capture set for byte-unchanged files and
keeps serving the fabricated edge (GUARDRAILS.md:34). A within-PR re-bump, not a
collision fix — 40/41/42 are all this unmerged branch's, and `origin/main` is at
39. Re-check against origin/main immediately before merging.

Mutation-verified in BOTH directions, which is what shows the fix is placed at the
right width rather than merely working:
  - revert the fix     -> exactly 3 red: both S4 fabrication rows + the S5
                          displacement row (8 green)
  - reject EVERY callee -> exactly 3 red: the three positive-typing rows (8 green);
                          the S4 rows correctly stay green
The two mutations hit DISJOINT row sets — too loose and too tight each break a
different half.

No row asserts an empty set: the three "must not type" rows call `Alien.ping()` as
a witness so a regression SWAPS a target in rather than emptying. Non-vacuity is
asserted in the test itself — one guard checks every caller node is live, another
asserts the `Alpha` class / `Factory.Alpha` method name collision the fabrication
NEEDS is actually present, so the rows cannot rot into passing for the wrong reason.

Sweep 3268 passed, 0 failed. Python unit + python.test.ts 342 passed. tsc clean.
All four gates green — no bench cell moved, nothing rebaselined.

Refs #2807

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:31:25 +01:00
Gergő Magyar
911151e230
fix(resolution): resolve Go pointer-receiver calls, and report the program boundary instead of hedging (#2766) (#2782)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
2026-08-01 22:42:18 +01:00
Karl Lehenbauer
639eb04b31
fix(swift): preprocess indented conditional directives so class bodies survive parsing (#2771)
* fix(swift): preprocess indented conditional directives so class bodies survive parsing

* fix(swift): make conditional-directive blanking comment-, string- and brace-aware (#2771)

Addresses the review findings on PR #2771. The transform fired
unconditionally, which turned valid Swift into parse errors while missing the
most common shape it was written for.

- The blank/keep decision now consults `blockCommentDepth`, so `  #endif */` —
  the result of commenting out a conditional block — keeps its comment
  terminator. Previously `hasError` went raw=false -> preprocessed=true and the
  rest of the file was swallowed.
- The decision keys on the scanner's brace depth instead of indentation. A
  column-0 `#if` inside a class body is blanked (6 of 7 body shapes previously
  still lost the enclosing declaration) and an indented file-scope directive is
  not — matching what the doc comment already claimed. Bare-CR line endings,
  NBSP/ideographic indentation and a leading BOM are recognized too.
- A group is blanked only when every branch is brace-balanced. An `#if`/`#else`
  that splits a declaration header leaves one unmatched `{` once both branches
  survive, which collapsed five top-level nodes into one and gave unrelated
  types fabricated `NetworkClient.` qualified names. Such a group now degrades
  to the pre-fix behavior.
- Multiline strings honour `\"""` escapes, and a plain `"""` closes even when a
  `#` follows it, so the scanner no longer wedges in string state and silently
  stops blanking for the rest of the file.
- The pound run is counted once per position and skipped. It was quadratic:
  10.6s for one 64k-`#` line, well inside the 512 KB walker limit.
- Extended regex literals (`#/.../#`) no longer open a phantom block comment.
- Directive-free files return early, matching `stripUeMacros`.

Worker parity: `emitSwiftScopeCaptures` and `emitCppScopeCaptures` re-apply
their provider's `preprocessSource` on the parse-cache-miss path — Dart already
did this — and the embedding parse in `ensureAndParse` applies the hook as
well. Before this the worker and the scope-capture/embedding halves analyzed
different programs, turning a consistent degradation into cold-run/warm-run
non-determinism. A new parity test pins the equivalence for every provider that
defines the hook.

SCHEMA_BUMP 37 -> 38: this changes parse semantics, the chunk key hashes raw
on-disk bytes, and `preprocessSource` runs after the key is computed — so a
same-package-version warm cache would replay pre-fix Swift results verbatim,
including across `--force`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ingestion): apply preprocessSource once in the scope bridge (#2771)

Follow-up cleanup on the review fixes. The previous commit re-applied each
provider's `preprocessSource` inside `emitSwiftScopeCaptures` and
`emitCppScopeCaptures`, mirroring what Dart already did — three copies of the
same rule, and a contract that asked every future emitter to remember it.

`extractParsedFile` is the single funnel every `emitScopeCaptures` caller
passes through (parse worker, scope-resolution run, Vue script extraction), and
it already receives the provider. Applying the hook there on the cache-miss
path covers all three languages and every future one, names no language in
shared code, and drops Dart's unconditional transform on the cache-hit path.
Verified the three emitters use `sourceText` for nothing but the parse, so the
substitution is output-identical — which the parity test asserts directly.

Also from the cleanup pass:

- the parity test derives its language list from the provider registry, so a
  new provider adopting the hook fails until it adds a fixture
- `ensureAndParse` resolves the provider from the language it already computed,
  instead of a second extension table (`getProviderForFile`)
- the preprocessor returns `sourceText` unchanged when no group was blanked,
  which is the common case for files whose only directives are top-level
- `split(/(\r\n|\n|\r)/)` replaces the hand-rolled line splitter, and the
  per-group brace bookkeeping is two scalars instead of an array
- the hint regex is derived from the line regex so the two cannot drift
- unit assertions compare the WHOLE preprocessed file against the expected
  blanking, replacing per-line spot checks; the pipeline tests share one
  `runFixture` helper and `getNodesForFile` in the resolver test helpers
- `LanguageProvider.preprocessSource` documents the real call sites and says
  plainly that the set is not closed — `populateRangeBindings` still hands
  language helpers raw text

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-01 20:10:58 +00:00
MyShining
1147646518
feat(spring): model AOP transactions, caching, and security (#2783)
* feat(spring): model AOP advice and proxy behavior

* fix(spring): address AOP review findings

---------

Co-authored-by: Shining <xuenning@qiyi.com>
2026-08-01 17:22:12 +01:00
azizur100389
454d383416
fix(ingestion): join multi-line closure bindings on initializer startLine (#2735) (#2762)
* fix(ingestion): join multi-line closure bindings on initializer startLine

Graph-node captures sit on the outer binding wrapper while scope-resolution
anchors on the inner callable; the line-only position join missed when those
split across lines and fail-closed dropped the real CALLS edge (#2735).

* fix(ingestion): unwrap Ruby call+block for multi-line lambda joins

Cover Kotlin/Ruby/Dart multi-line closure CALLS in integration tests, and
dig Ruby's call/block field so do-end bindings join on the block start line.

* style(ingestion): format closure join changes

* fix(ingestion): make closure position join language agnostic
2026-07-31 11:58:47 +01:00
MyShining
de84ad6297
feat(spring): index @Bean factories and @Resource injection (#2740)
* feat(spring): index Bean factories and Resource injection

* fix(spring): address Bean and Resource review findings

* refactor(lbug): keep relation pair parsing in router

* test(lbug): preserve schema exports in WAL mocks

* test(cache): align schema bump pin

---------

Co-authored-by: Shining <xuenning@qiyi.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2026-07-31 10:33:42 +01:00
Gergő Magyar
27ab37c432
feat(resolution): type receiver chains from AST structure across all 14 languages (#2708) + epistemic lower-bound (#2744) (#2747) 2026-07-31 07:12:57 +01:00
Gergő Magyar
9c24e3459e
fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742) (#2745)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(rust): let the qualified-call filter see inline modules

The negative filter added in #2741 builds its set of known module names from
FILE PATHS, so an inline `mod x { … }` — which appears in no path — was absent
from it. Every module-qualified call into an inline module was therefore
rejected before any candidate channel ran, which is a hole in that optimisation
rather than in the resolution logic it guards.

The per-pass index now unions the file-derived names with inline module names
taken from the scope model: a `mod` declaration binds a `Namespace` def locally
in the declaring scope, and that binding is the only place an inline module's
name exists. Collected in the same walk that already builds the module → scope
map, so it costs no extra pass.

Found while fixing #2742, where a correctly resolved call into `mod inner { … }`
still could not reach its target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(scope-resolution): try the namespace-prefixed node key before the bare one

`resolveDefGraphId` looked up the plain `qualifiedName` key first and only
retried with the `namespacePrefix`-qualified key afterwards. For the defs that
carry a prefix the qualified name is a bare TAIL, so the plain key happily
matched a same-named item at a different namespace depth in the same file and
returned it before the more specific retry was ever reached.

The namespace-prefixed key is strictly the more specific of the two, so it is
now tried first. Where no such node exists the lookup falls through to exactly
the previous order, which keeps the #1982 behaviour this retry was added for.

Without this, a call into `mod inner { fn dispatch }` resolved to the correct
definition and then mapped it onto the crate-root `fn dispatch` node — the
self-loop #2742 describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742)

Node identity is `<label>:<file>:<qualifiedName>` and carried no module path, so
an inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
file collapsed onto `Function:<file>:dispatch`, first-wins. Resolution already
picked the right definition — the target simply was not representable, so a
correct resolution still rendered as a self-loop and `impact` reported the real
callee as unreached.

The mechanism already existed: `qualifyRustImplTargetByModScope` has walked
`mod_item` ancestors for impl targets since #1982. Generalised to
`qualifyByEnclosingModScope` and applied to free items, so
`mod inner { fn dispatch }` becomes `Function:<file>:inner.dispatch`. Keyed
purely on the `mod_item` node type, exactly as the impl qualifier already was,
so it is a no-op for every language whose grammar has no such node.

Two constraints found by tests rather than by reading, both now encoded:

  - The helper normalised `::` to `.` unconditionally. With no enclosing `mod`
    that rewrote a top-level `impl a::Inner` from `a::Inner` to `a.Inner` and
    moved its node id away from the one the HAS_METHOD owner edge emits,
    breaking the #1975 scoped-impl ownership. It now returns raw text untouched
    when there are no mod segments, which also makes the change strictly
    additive for every id that has no enclosing module.

  - Qualification is scoped to items with no enclosing class/impl. A method
    already carries its owner's name, and that owner's id is mod-scoped by the
    impl qualifier, so qualifying the method again breaks the same byte-for-byte
    agreement. Same-named methods on same-named types in sibling modules
    therefore still collapse — a narrower residual than the free-item case fixed
    here, and one belonging to the owner edge rather than to this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(storage): bump schema versions for the mod-qualified Rust node ids (#2742)

`INCREMENTAL_SCHEMA_VERSION` 24 -> 25 and `SCHEMA_BUMP` 31 -> 32.

Node ids change for every Rust item inside any `mod` block, and
`#[cfg(test)] mod tests` makes that close to every Rust repository. A pre-v25
index therefore holds ids an incremental top-up cannot reconcile — the old nodes
would simply be stranded — so the reuse gate has to force a full re-analyze. The
qualified name is computed in the parse worker, so a warm parse cache would
likewise replay the old unqualified ids and keep the collapse.

This branch originally claimed v24; #2708 took that number and merged first, so
it is renumbered to v25 here. That is exactly the collision the v29 note in
parse-cache.ts warns about, and re-checking against origin/main at rebase time
rather than at branch time is what caught it. #2708 did not touch `SCHEMA_BUMP`,
so 32 is free.

The version-pin test moves with the bump by design, including the new pre-v25
row in the reuse-gate table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(rust): stop mod-qualifying container ids while their owner edges stay bare (#2745 review)

#2742 re-keyed Rust node ids by the enclosing `mod` chain. The mint moved; the
owner-edge anchor did not. `findEnclosingClassInfo` mints a member's owner id
from the container's BARE `nameNode.text` and only follows a qualified shape when
the provider sets `classExtractor.qualifiedNodeId`, which Rust does not.

So every `struct` / `trait` / `enum` / `impl` declared directly inside a `mod`
got a node id that none of its member edges pointed at. Five lines of idiomatic
Rust were enough:

    pub mod engine { pub struct Config { pub retries: usize } }

    NODE     Struct:src/lib.rs:engine.Config
    DANGLING HAS_PROPERTY Struct:src/lib.rs:Config -> Property:src/lib.rs:Config.retries

The rows are discarded by the IGNORE_ERRORS COPY retry, so the struct silently
lost every field. A trait impl inside a `mod` additionally dropped its
METHOD_IMPLEMENTS edge outright.

The same gap put `impl a::Inner` inside a `mod` back on the #1975 rake that
`qualifyByEnclosingModScope`'s own docblock warns about. The impl-target branch
deliberately fires only for an UNSCOPED `type_identifier`; the new gate had no
such restriction and picked up the scoped targets that branch had just excluded,
minting `Impl:<file>:outer.a.Inner` against an anchor still reading
`Impl:<file>🅰️:Inner`.

The member side was already excluded via `!enclosingClassInfo`. This adds the
owner side, gated on `MEMBER_OWNER_NODE_TYPES` — derived from
`CLASS_CONTAINER_TYPES`, which is already the single source of "this node type
owns member edges" and already carries an INVARIANT note binding it to
`CONTAINER_TYPE_TO_LABEL`. A language adding a container therefore cannot gain a
mismatched id shape here without also failing that invariant. Keyed purely on
tree-sitter node types, so no language name enters shared ingestion.

`union_item` is listed too: its fields are captured as Property but it is not a
recognized owner, so they carry no HAS_PROPERTY edge and cannot dangle — it is
here so a union's id keeps the same shape as the struct beside it.

Containers still collapse across sibling modules, exactly as before this fix.
That residual belongs to the owner edge, and is not worked around here.

Regression tests use the UNFILTERED `findDanglingEdges(result)`. Every other
dangling assertion in `rust.test.ts` passes `['HAS_METHOD']`, which is precisely
why the HAS_PROPERTY breakage shipped with a green suite. They assert the NODE
id rather than only the edge's anchor, because the anchor was already bare while
the bug was live — an edge-only assertion passes in both builds. All four fail
when the new gate clause alone is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): resolve modules nested inside an inline mod (#2745 review)

The #2730 self-loop survived one `mod` deeper:

    pub mod outer {
        pub mod tools { pub fn dispatch() {} }
        pub fn dispatch() { tools::dispatch(); }
    }

    CALLS outer.dispatch -> outer.dispatch          <- the #2730 symptom
    NODE  outer.tools.dispatch                      <- correct target, unlinked

Two gates were blind to a nested inline module, so the hook refused and the shared
lexical tier bound the call to the enclosing same-name `dispatch`:

`knownModuleNames` was collected by walking `moduleScopeByFile`, which maps a file
to its ROOT `Module` scope only. A `mod` nested inside an inline `mod` binds in the
parent module's scope, so the walk saw depth-1 inline modules and missed every
nested one — `tools` never entered the set and the negative filter rejected the
qualifier before any candidate ran.

`declaresSubmodule` had the same root-only assumption, so even with the name known
the candidate `outer::tools` was never yielded.

Both now read the def index. Names come from every `Namespace` def; inline module
PATHS are derived from the members' `namespacePrefix` rather than from the `mod`
defs, because a `mod` def carries no nesting information of its own — inside
`mod outer { mod tools { … } }` the inner def is `qualifiedName: 'tools'` with NO
`namespacePrefix`, while every def within it is stamped `outer.tools`. A
`Namespace` scope also owns its OWN def rather than its children's, so the scope
tree cannot answer this either: the `mod outer` scope lists `outer`, never `tools`.

Restricted to non-empty prefixes, so this stays a DECLARATION check. Including
file-derived modules would let an undeclared or `cfg`-gated file on disk outrank a
real `use` binding — the regression #2741's review already fixed once. File-backed
submodules therefore keep going through the binding check.

A module with no defs at all is absent from the set, which is harmless: it has no
member for a qualified call to resolve to.

Cost is one pass over an already-resident def index, memoized per resolution pass
on the existing WeakMap — the same order of work as the binding walk it replaces,
and it subsumes it. `isLocalNamespaceBinding` was going to single-source the
duplicated "locally declared submodule" predicate the review flagged; deriving
paths from members removed the second copy outright instead.

Regression fixture covers depth 2 and depth 3, so the fix is depth-agnostic rather
than depth-2 special-cased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): let an imported type outrank a same-named module (#2745 review)

Widening the negative filter to inline `mod` names let a type-qualified call
through whenever a module happened to share the type's name. The
crate-root-relative candidate then captured it:

    // src/lib.rs
    pub mod Buffer { pub fn with_capacity() -> usize { 111 } }
    // src/b.rs
    use crate::c::Buffer;                        // the real target lives in c.rs
    pub fn call() -> usize { Buffer::with_capacity() }

    base: (no CALLS edge — unresolved)
    PR:   CALLS b::call -> Function:src/lib.rs:Buffer.with_capacity   <- fabricated

`ids.ts` states the doctrine this broke: a missing edge is the correct failure
direction for a graph whose consumers include `impact`; a fabricated caller is not.
The base produced the missing edge and the PR produced the fabricated one.

That third candidate is the loosest of the three — a guess at a crate-root-relative
path the caller never wrote, kept for 2015-edition style. In Rust 2018 a bare first
segment resolves in the CALLER's module, so a local binding for that segment
settles the question: it is now skipped when the head names anything non-module in
the caller's own module. Candidates 1 and 2 are untouched, and they run first, so
the legitimate `use crate::tools;` path is unaffected.

The binding lookup goes through `lookupBindingsAt`. A first attempt read
`Scope.bindings` directly and the guard never fired: a `use` binding is finalize
OUTPUT and absent from the scope's own local table, which is exactly the
imported-type case being guarded. Contract I8 in `contract/scope-resolver.ts`
requires that channel anyway.

The regression test asserts the forbidden TARGET rather than an empty edge set, and
separately asserts the module member still exists as a node — otherwise the test
would pass just as well if the call went unresolved for some unrelated reason, or
if the module node disappeared entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(scope-resolution): try the namespace-prefixed name on the TAGGED keys too (#2745 review)

`resolveDefGraphId` gained a namespace-prefixed retry for the plain qualified key
in this PR, but the five tagged keys above it — template constraints, parameter
types, parameter shape, arity, template arguments — kept composing from the bare
`qualifiedName`.

For a namespace- or `mod`-qualified def those keys are simply dead:
`node-lookup.ts` registers them under the QUALIFIED name (`inner.dispatch#0`)
while this side built `dispatch#0`. The keys exist to separate overloads, so a
mod-scoped overload set was relying on whichever later key happened to catch it.

Verified as a miss rather than a mis-hit before changing anything — an end-to-end
run with a crate-root decoy of the same name and arity binds correctly — so this
is hygiene, not a live bug. Worth doing while the code is open rather than leaving
five keys dead and the behaviour dependent on fallback order.

Both name forms now go through one `lookupTagged` helper, most specific first, so
a sixth tagged key cannot be added with the bare form only. That also removes the
five hand-repeated `qualifiedKey(...)` / `nodeLookup.get(...)` pairs.

Also pins the C++ `EXTENDS` retarget this PR's reorder produces.
`cpp-two-phase-dependent-base-cross-ns-deep` declares a global `Inner` decoy
alongside `ns:🅰️🅱️:Inner`; the base's `qualifiedName` is a bare `Inner` with the
path on `namespacePrefix`, so only the prefixed key separates them, and only if it
runs first. The improvement was riding unasserted in a Rust-scoped PR.

The captures golden covers every `rust-*` fixture, so the three fixtures added by
this review series drift it; regenerated with UPDATE_GOLDEN=1.

Verified: 785 tests across cpp / csharp / rust resolvers and the
callable-id-lockstep unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): keep a mod declared inside a fn from hoisting above the callable (#2745 review)

`fn wrapper() { mod helper { fn dispatch } }` minted
`Function:<file>:helper.wrapper.dispatch@2:8` — the mod segment composed OUTSIDE
the enclosing-callable prefix, inverting the real nesting.

Nothing dangled: the `@line:col` suffix already makes a function-local callable's
id unique, which is also why the mod segment adds no identity in this position. The
path simply read as a lie about the source. It is now skipped rather than
reordered — interleaving two qualifier passes to fix the order would be real
machinery for a shape whose ids are already unique.

Also folds in the three documentation and structure findings from the same review:

- The 4-clause gate is extracted to a named `qualifiesByEnclosingModScope`, matching
  the two conditions directly above it in the same function, which were already
  named consts.
- `qualifyByEnclosingModScope`'s docblock documented only the impl-target contract
  even though the generalized name has had a second, looser caller since #2742. It
  now states both, and says which gate belongs to which — that gap is what let the
  #1975 scoped-impl regression through in the first place.
- The "cheap rejection BEFORE any index work" comment was no longer true:
  `passIndexFor` walks the def index on its first call in a pass. Corrected rather
  than left to mislead the next reader into thinking the filter is free. What it
  still buys — skipping the per-site candidate search, the part that scales with
  the workspace — is stated instead.

Verified: 279 tests across the Rust resolver suite and the Rust scope-resolution
unit tests. Captures golden regenerated for the extended fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* test(storage): move main's SCHEMA_BUMP pin to 32 for the mod-qualified ids (#2745 review)

`#2736` added a pin asserting `SCHEMA_BUMP === 31` on main, which arrived on this
branch through the merge of main while `0062a5c2` had already bumped the constant
to 32. Neither side conflicted textually — the pin and the constant live in
different files — so the merge was clean and the test failed instead.

That is the pin working as designed: it exists so a bump cannot ride along
unnoticed, and this is the fifth time a SCHEMA_BUMP collision has been caught by a
guard rather than by review. Updated to 32 with the reason recorded inline.

`INCREMENTAL_SCHEMA_VERSION` needs no second bump: 25 was introduced by this
unmerged branch, so no released index carries it, and its own pin in
`call-summary-schema-version.test.ts` is already consistent.

Verified: 119 tests across the parse-cache, schema-version, incremental-orchestration
and the two identity suites that arrived with the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* test(bench): rebaseline the Rust capture fingerprint for the three new fixtures (#2745 review)

CI caught what I missed:

    [scope-capture --check] FAIL: rust: capture fingerprint drift
      (got 05acbaca..., expected 90fda086...)   fixture_count 202

`bench/scope-capture` fingerprints the whole `rust-*` fixture corpus, so the three
fixtures added by this review series drift it. I rebaselined the
`rust-captures-golden` snapshot and stopped there — a new fixture is a call site of
BOTH, and updating only one is how this reached CI red.

This is the same class as PR #2743's headline finding, from the other direction: an
id-shape change makes every synthetic corpus a call site, and the author fixed the
unit-test fixture and missed the bench. Here it is a fixture-count change rather
than an id-shape change, and the review that flagged the #2743 lead as "REFUTED,
bench/ has no Rust node-id corpus" was right about node ids and wrong about the
corpus fingerprint. Noted for the next author in the baseline entry itself.

Verified as pure corpus growth rather than a capture-logic shift: removing ONLY the
three new fixture directories and re-running reproduces the prior fingerprint
exactly (196 fixtures, capture_groups_fp 3432), and restoring them gives the new
one (202, 3556). `emitRustScopeCaptures` is untouched by this series. Scaling 1.022
local / 1.057 CI, well inside the 1.5 budget.

`bench/python-scope` globs `python-*` only and is unaffected; no other bench walks
the Rust corpus. `--check` now PASSes for all 15 languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:08:00 +01:00
azizur100389
bee3e82ab2
test(scope-resolution): guard closure identity invariants (#2748) 2026-07-30 05:34:21 +01:00
Gergő Magyar
95f87fc12a
perf(ingestion): Linux-kernel-scale analysis — worker-pool parse + finalize O(n²) + scope-resolution memory wall (#1983) (#2038)
* fix(ingestion): reduce parse-phase memory for huge repos (#1983)

Stop retaining full parse-cache chunks in RAM alongside the merged graph,
slim on-disk shards, defer worker ParsedFile emission for scope-resolver
languages, and add GITNEXUS_DEBUG_HEAP probes for OOM diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ingestion): address #2038 tri-review findings (parse-phase memory)

Resolves the confirmed review findings on PR #2038:

- P1: thread exportedTypeMap through the sequential parse path
  (processParsingSequential) so a no-worker run over a partially-warm
  cache no longer silently drops the sequential-miss files' exported
  types. Cache hits made exportedTypeMap.size > 0, suppressing the
  end-of-loop buildExportedTypeMapFromGraph rebuild, but the sequential
  path never populated the map. Regression test added (fails on the
  pre-fix tree, passes after) plus a fully-sequential differential oracle.
- P2: saveParseCache builds its on-disk index from hashes actually
  written/copied (writtenKeys), never a usedKeys hash whose shard write
  or copy was skipped — no more phantom index entries.
- P2: add a unit test asserting SCOPE_RESOLUTION_LANGUAGES stays in sync
  with SCOPE_RESOLVERS (asymmetric drift would lose a language's ParsedFile).
- Backfill cache coverage: loadParseCacheChunk missing/corrupt -> undefined,
  pruneCache onDiskKeys branch, slim preserves nodes, saveParseCache
  copy-evicted-shard round-trip.
- Cleanups: single-source heap-probe gating via isDebugHeapEnabled();
  hoist the per-chunk mkdir in persistParseCacheChunk behind a
  process-scoped Set; gate COBOL's unused worker-side ParsedFile
  extraction (graph nodes still come from cobolPhase) while keeping
  fileCount/progress unconditional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ingestion): remove dead worker-side ParsedFile extraction

After #2038 gated worker `ParsedFile` emission behind `!isScopeResolutionLanguage(language)`, and with all 16 SupportedLanguages registered in SCOPE_RESOLVERS, that gate was structurally always true — the worker already produced no ParsedFiles and scope-resolution re-extracts each file from source on the main thread (run.ts). Remove the now-dead machinery:

- Drop both worker `extractParsedFile` call-sites (tree-sitter processFileGroup + the standalone-provider branch) and the `result.parsedFiles.push`. The standalone branch keeps fileCount/onFileProcessed per file. `result.parsedFiles` stays declared but empty (field removal deferred).
- Remove the now-orphaned `scopeSourceKind` var + `ScopeCaptureSourceKind`/`extractParsedFile`/`isScopeResolutionLanguage` imports.
- Delete the consumerless `migrated-languages.ts` (isScopeResolutionLanguage + SCOPE_RESOLUTION_LANGUAGES) and its drift-guard test — parse-worker was their only importer. Also improves AGENTS.md "shared ingestion code must not name languages" compliance.

`extractParsedFile` and the scope-extractor-bridge stay (scope-resolution/run.ts + Vue resolver use them). Behavior-preserving: worker-sequential-parity passes before and after; tsc/eslint clean; no baseline/golden drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ingestion): worker-pool-only parsing; remove sequential parser (#1983)

Completes the #1983 huge-repo parse-OOM effort by making the worker pool
GitNexus's sole parse path.

Parallel serialization (the perf core): workers serialize their ParsedFiles to
a disk store in parallel and stream them back to scope-resolution, so the main
thread no longer re-parses every file (the tree-sitter native-memory leak that
caused the OOM). Adds chunk merge-pipelining + work-proportional chunk sizing so
the pool stays saturated.

Remove the sequential parser: `--workers 0`, `GITNEXUS_WORKER_POOL_SIZE=0`, and
`skipWorkers` now hard-error (no silent degrade — #1741); the small-repo
threshold no longer selects an in-process path; pool creation stays lazy /
cache-miss-gated so warm all-hit runs never spawn workers.

Worker-path parity fixes — removing sequential surfaced two pre-existing gaps
that tiny-fixture tests had masked by running below the worker threshold, both
fixed by carrying per-file metadata as DATA across the worker boundary (never
re-parsing on the main thread, preserving the OOM fix):
  - C++: templateConstraints wired into worker node identity (SFINAE overload
    disambiguation) + ADL / inline-namespace capture side-channel serialized
    onto the ParsedFile.
  - Kotlin: companion-scope side-channel serialized the same way (companion /
    static dispatch).

Validation: tsc + build clean; full suite green (10,190 pass — the only
deterministic failures were the now-fixed C++/Kotlin worker-path gaps; the 2
remaining full-run failures are pre-existing load flakiness, green in
isolation); cpp-pipeline benchmark stays linear on a 1-worker pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): wire C static-linkage side-channel + ADL O(1) collect + tri-review cleanups (#1983)

Follow-up to the worker-pool-only refactor, from a tri-review of the parse path.

- C static-linkage side-channel (P1): cProvider had no collect/applyCaptureSideChannel,
  so on the now-sole worker path C `static` file-local marks were lost across the worker
  boundary -> false cross-file CALLS edges + over-broad #include wildcard visibility on
  every C analysis (the Linux kernel is C). Mirror the C++/Kotlin wiring: serialize
  `staticNames` per file onto ParsedFile.captureSideChannel and restore it on the main
  thread (no re-parse). + a worker-path regression test (the existing c-static-isolation
  fixture passed vacuously — its collision resolves via #include before the global
  free-call fallback ever consults static-linkage).

- captureSideChannel `kind` discriminant: add `kind:'cpp'`/`kind:'c'` tags + guards
  (Kotlin already had one) now that C/C++/Kotlin share the single generic field.

- Perf: collectCppAdlSideChannel scanned the whole argInfoBySite/noAdlSites maps per file
  (O(F^2) per sub-batch, ~100M parseSiteKey calls at kernel scale). Add per-filePath
  lockstep indexes -> O(1) collect; serialized snapshot byte-identical.

- Cleanups: inline the one-line processParsingWithWorkers wrapper into processParsing;
  drop the always-empty WorkerExtractedData.calls/assignments/constructorBindings fields;
  remove the voided astCache param from processParsing; refresh stale "sequential
  fallback" JSDoc.

Validation: tsc + build clean; cpp 297/297, c 8/8 (incl. the new worker-path
static-linkage guard), typescript + parsedfile-store green; cpp ADL benchmark stays linear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): index C/C++ #include resolution in finalize (O(n²)→O(n))

Kernel-scale C/C++ analysis ground in finalizeScopeModel because three
per-#include operations each did a full O(F) scan with no index — the
finalize O(n²) that surfaced once the #1983 parse-phase OOM was fixed:

- expand{C,Cpp}WildcardNames: parsedFiles.find() per wildcard edge → O(R·F)
- resolveImportTarget: new Set(allFilePaths) rebuilt per #include
- resolveCImportTarget: suffix-match scanned all workspace paths

Each is replaced with a WeakMap-per-pass index keyed on the stable
parsedFiles/allFilePaths references that scope-resolution run.ts passes
once per pass:

- Map<ScopeId,ParsedFile> for wildcard expansion (c/static-linkage.ts +
  cpp/file-local-linkage.ts)
- memoized augmented header set (c/scope-resolver.ts + cpp/scope-resolver.ts)
- basename-bucketed suffix index in resolveCImportTarget (c/import-target.ts),
  shared by C and C++ since resolveCppImportTarget delegates to it

Collapses the C/C++ finalize from O(R·F) to O(R+F). Pure-perf, byte-identical
edge output: 962 targeted tests green (490 C + 472 C/C++ scope-resolution);
the basename index preserves the exact endsWith('/'+target) match and the
fewest-path-components-then-lexicographic tie-break.

The kernel's ~25-30k .h headers are classified C++, so both providers must
be fixed. Proven on the Linux kernel: the C finalize completed
(sr-post-finalize lang=c → sr-end lang=c), which the pre-fix run never
reached in 16+ min of grinding.

Build-independent follow-ups (separate from this finalize fix), documented
for later: emitFreeCallFallback same-name buckets (emit phase),
buildGraphNodeLookup + precount global setup, the ParsedFile store-load,
the dart/go/ruby expand-wildcards .find siblings, and the ~26GB
scope-resolution memory floor (full kernel completion needs >~40GB RAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bench): regenerate C scope-capture baseline for the #1983 c-static-linkage-worker fixture

bench/scope-capture/measure.mjs fingerprints emitCScopeCaptures over the
lang-resolution/c-* fixture corpus. The #1983 PR added the
c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — the
worker-path static-linkage side-channel test) but did not regenerate the C
baseline, so `--check` has been red on this branch (main, lacking the
fixture, still matches 0de009b).

Pure fixture-corpus drift — no c/captures.ts or query change branch-vs-main,
existing fixtures' captures byte-identical (c-captures.test.ts 45/45),
scaling stays linear (~0.97). Regenerated: 0de009b -> 39f3a83. Bench now
PASS (14 languages). Unrelated to the finalize O(n²) fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): lower kernel-scale resident memory floor + setup cost

Reduce the scope-resolution resident-memory floor and setup throughput on
huge repos (Linux kernel), the wall that remains after #1983 (parse OOM) and
the finalize O(n^2) fix (b71c77b8). Five units; all preserve byte-identical
edge output (C fixture 177n/255e + c/cpp/cross-file/php/static-linkage suites
green, 619 tests).

U1 (src/cli/analyze.ts): RAM-aware auto heap-cap. Replace the hardcoded
16384MB cap with computeHeapCapMb = max(16384, floor(0.75*effectiveRAM)),
where effectiveRAM = min(os.totalmem(), process.constrainedMemory()) with the
unconstrained-sentinel guard. Add --max-semi-space-size=128 on the respawn.
A user-supplied NODE_OPTIONS heap still wins (no re-exec). Verified: 23973MB
on a 31964MB box, 16384 floor on small machines, cgroup-aware, sentinel safe.

U2 (src/storage/parsedfile-store.ts, .../pipeline/phase.ts): export forceGc()
and call it at the per-language eviction boundary, so a finished language's
ParsedFiles are reclaimed before the next language's store-load instead of
collected lazily under the next pass's allocation pressure (which at cap>=RAM
degrades into swap-thrash). Measured on a real drivers/net/ethernet run:
C 2113->894MB and C++ 1754->1057MB reclaimed at the boundary (no fragmentation
defeat). Answers the plan's Open Question 1.

U3 (src/storage/parsedfile-store.ts): intern def objects by nodeId in the load
reviver so a SymbolDefinition's three serialized copies (localDefs /
scope.ownedDefs / scope.bindings[].def) collapse to one shared object on load.
Per-shard def pool (a def's copies are shard-local). Measured ~42% off the
def-object retained heap (3->1; 1.8M->600k distinct objects on 600k defs).

U4 (.../passes/free-call-fallback.ts): memoize pickUniqueGlobalCallable's
post-filter candidate list per (name, callerFilePath), only when no per-caller
visibility filter applies (the list is then a pure function of name+file), so
repeated free calls of one name from a file reuse the same-name-bucket scan
instead of re-walking a potentially huge bucket per site. The cached array is
read-only-consumed by the .filter()-based arity/overload narrowers. Exported
pickUniqueGlobalCallable + buildGlobalCallableIndex and added an equivalence
test (memoized == un-memoized reference for every (name, file, arity),
including warm-cache repeats and cross-file file-local exclusion).

U5 (.../pipeline/phase.ts): replace the O(L*F) per-language precount + repeated
scannedFiles.filter() with a single O(F) partition-by-language pass; bracket
buildGraphNodeLookup with scope-setup-nodeLookup heap probes so the long setup
is no longer silent.

Plan: docs/plans/2026-06-06-001-perf-kernel-scope-resolution-memory-plan.md
(U6 out-of-core global index deferred). Note: the kernel's full C++ pass floor
(~20k headers + the 8.8GB graph) likely still exceeds 24GB by itself, which is
why U6 remains the only unit that clears the wall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): match OOM-guidance e2e assertions to the U1 reworded hint

The analyze-heap-oom-e2e real-child-OOM test still asserted the pre-U1
wording ('...out of memory.' + a hardcoded 24576 cap). U1 reworded the hint
to mention the auto heap-cap and use a <MB> placeholder, so the three
toContain substrings no longer matched (the assertion at line 62 failed on
all platforms). Update them to the current message. The unit twin
(analyze-heap-respawn) was already updated in 85bfc216; this integration
test was missed by the targeted local run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(lbug): U6a — deterministic id-sorted graph output behind GITNEXUS_SORT_GRAPH_OUTPUT

First increment of U6 (out-of-core scope-resolution). Adds an optional
deterministic ordering of node + relationship CSV rows by their unique graph
id, behind GITNEXUS_SORT_GRAPH_OUTPUT (default OFF = today's graph-insertion
order, byte-identical — the iterator is returned untouched). With the flag ON
the CSV becomes a pure function of the node/edge SET rather than of emit order.

This is the structural enabler for the windowed/out-of-core resolve (U6b-U6d):
csv-generator.ts:518 currently iterates graph.iterRelationships() in insertion
order with NO terminal sort, so any deviation from parsedFiles-order emit would
change bytes. With U6a on, a windowed emit need only reproduce the same edge
SET, not the global insertion order — removing the single largest byte-identical
hazard from every later windowing step.

Verified: default off keeps the existing csv-pipeline suite byte-identical; on,
node rows are id-sorted and output is independent of graph insertion order
(set-build) with the same node/edge set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(storage): U6d foundation — disk-backed scope store + lazy ScopeTree

Adds scope-index-store.ts: persistScopeShards (per-file scope shards via the
proven mapReplacer + def-interning reviver) + DiskBackedScopeTree, a lazy
ScopeTree that serves getScope from a bounded LRU of decoded shards plus a small
resident skeleton (scopeId -> {shard, childIds, parent}). Exports
makeInterningReviver from parsedfile-store for reuse.

This is the contained, highest-risk mechanism of U6d (out-of-core scope
resolution): the emit passes reach the heavy per-Scope binding payload
(~17-20GB on the kernel) ONLY through scopeTree.getScope (a point lookup) and
getChildren — they never read parsed.scopes directly — so moving that payload to
disk behind getScope is transparent. Every consumer reads a Scope BY VALUE, so a
value-faithful disk round-trip is byte-identical to resolution.

Proven in isolation: DiskBackedScopeTree is value-identical to buildScopeTree
for getScope/getChildren/getParent/getAncestors/has/size across multiple files
and after LRU eviction, and preserves the def-identity collapse (ownedDefs[i]
=== binding.def). Nothing wires it yet (the resolution-pipeline integration is
the next increment) — zero production impact; default off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d integration — seal scopeTree to disk before emit (GITNEXUS_DISK_SCOPE_INDEX)

Wires the U6d out-of-core scope index into the live pipeline behind
GITNEXUS_DISK_SCOPE_INDEX (default OFF = byte-identical). When on:

- finalize-orchestrator builds a TransitionalScopeTree (validated, fully
  resident) instead of buildScopeTree, so finalize/propagate/resolve are
  unchanged.
- After resolve, before emit, run.ts seals it: persists the scopes to a
  file-sharded scope-index-store, swaps the model's scopeTree to disk-backed
  serving from the inside (the frozen bundle can't be reassigned, but the
  wrapper nulls its own resident backing), and drops the heavy Scope.bindings
  payload from all THREE holders — the model's tree (seal), the caller's
  preExtractedParsedFiles, and run.ts's own parsedFiles (scope-stripped copies
  for emit). Emit reads scopes only via scopeTree.getScope (a point lookup,
  now disk-backed + LRU) — verified it never reads parsed.scopes.

Purpose: lower the per-language resident PEAK (kernel C pass ~20→~12 GB by
moving the ~8-9 GB scope payload to disk) so the analysis fits on smaller-RAM
machines. At >=24 GB the full kernel already fits with U1-U5 (U2's 8.7 GB
inter-language forceGc reclaim keeps each pass under cap) — empirically
confirmed — so this is the sub-24 GB lever, not needed at 24 GB.

Byte-identical evidence: DiskBackedScopeTree/TransitionalScopeTree return
value-identical scopes vs buildScopeTree (getScope/getChildren/getParent/
getAncestors, across files + after LRU eviction + post-seal); emit reads only
getScope + referenceSites; flag-off (394 tests) and flag-on-resident (91 tests)
resolver suites stay green; an end-to-end A/B on a 212-file C+cpp+rust subset
produced identical 17,444 nodes / 31,343 edges with the seal firing per language
(c: 410→141 MB reclaimed). Kernel-scale peak-drop measurement pending the
in-flight verdict run freeing memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d — id-back workspaceIndex so the disk seal can reclaim scopes

The kernel run revealed the contained scopeTree seal didn't lower the heap:
WorkspaceResolutionIndex held Scope OBJECTS (classScopeByDefId / moduleScopeByFile),
built from every ParsedFile and live through emit, so the ~28k module + class
scopes stayed pinned past the seal (sr-seal-pre 17,583 -> sr-seal-post 17,771 MB,
no drop). It was the sole residual Scope-object holder (SemanticModel holds none).

Fix: classScopeByDefId / moduleScopeByFile become id-backed ScopeByKeyView
instances — a ReadonlyMap<K, Scope> facade over a K->ScopeId map + the scopeTree,
whose .get fetches via scopeTree.getScope(id). The index now pins only ids, so
once the tree seals to disk the scopes become collectible. Byte-identical: the
view returns the same Scope the resident tree holds (or a value-identical revived
one in disk mode), and iteration keeps the old insertion order. buildWorkspace
ResolutionIndex takes an optional scopeTree (live pipeline passes it); without it
(unit tests) the legacy direct Scope-object maps are returned unchanged.

Verified byte-identical: 733 tests across workspace-index / imported-return-types
/ c / cpp / cross-file / go / java. Kernel peak-drop re-measurement to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d — precompute exportedCallableByName (fix disk-getScope thrash)

The workspaceIndex id-backing freed the kernel scopes but exposed a throughput
collapse: findExportedDefByName's workspace fallback (walkers.ts:1019) scanned
EVERY module scope's bindings per unresolved free call, and under the U6d
disk-backed scopeTree each module-scope access faulted a shard in from disk —
lib ON went ~1min -> ~7.5min.

Fix: precompute the fallback result once into
WorkspaceResolutionIndex.exportedCallableByName (simpleName -> first module-local
callable def, first-file-wins — the exact semantics the scan returned), built
from the resident module-scope bindings at index-build time. findExportedDefByName
now does an O(1) lookup with zero disk reads.

Result: lib ON ~7.5min -> 21s (cache-warm), byte-identical 17,444/31,343; 758
tests green across workspace-index + c/cpp/cross-file/go/python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: rename cryptic U-unit codes to descriptive names in comments

The plan-unit shorthand (U3/U4/U6a/U6d/...) was meaningless in the code.
Renamed in comments + test descriptions (no behavior change, byte-identical):
  out-of-core scope index   (was U6)
  deterministic output      (was U6a)
  disk-backed scope seal    (was U6d)
  def-object interning      (was U3)
  free-call candidate cache (was U4)
Also renamed throughout the PR title/summary. Pushed commit messages keep
their original U-codes as historical record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): durable ParsedFile shards for warm-cache coverage (#2038)

On a warm re-analyze where every chunk is a parse-cache HIT, no parse worker
runs, the run-scoped ParsedFile store is cleared at parse start, and the cached
ParseWorkerResult carries no ParsedFiles (the worker writes them to the store
and empties them from the message). Scope-resolution then found an empty store
and fell back to main-thread extractParsedFile — re-opening the #1983
tree-sitter native-leak OOM the disk store closes (abhigyanpatwari review on
parse-cache.ts).

Fix: workers ALSO write their ParsedFiles to a durable, content-addressed store
(parsedfile-cache/) keyed by chunk hash, mirroring the parse cache's lifecycle
(version-gated by PARSE_CACHE_VERSION, pruned in lockstep to the surviving
keys). On a warm hit the chunk's durable shards are byte-COPIED into the
run-scoped store (no re-parse, no re-serialize -> byte-identical), so
scope-resolution streams them exactly as on a cold run. A coherence gate
re-dispatches the worker whenever a cached chunk's durable shards are missing
(migration / pruned / version-stale) -- never the main-thread extract.

- worker-pool/parse-worker: thread chunkHash through dispatch->job->flush
  (incl. split/requeue) so the worker tags its durable shard by content
- parsedfile-store: durable persist / restore / index / prune API (sibling
  dir, never cleared per run); content-addressing makes stale reuse impossible
- parse-impl: load durable index, gate the cache hit on durable coverage,
  restore on hit, dispatch chunkHash on miss
- run-analyze: prune+save the durable store to the parse cache's surviving keys
- saveParseCache returns its written keys (the durable keepKeys)

Verified on linux/lib: warm preExtractedHits = full coverage (520/207/1, zero
main-thread re-parse), byte-identical cold==warm (17,456n/31,353e), warm 8.5x
faster. New two-run + mixed-mode + coherence-gate regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): clear stale scope-index-store shards on each seal (#2038)

The disk-backed scope index writes sequential s<n>.json shards into a shared
<storagePath>/scope-index-store/ dir, with the index resetting per
persistScopeShards call. A seal that writes fewer shards than a previous one
(a later language with fewer files, or a re-run of a shrunken repo) left stale
tail shards on disk indefinitely -- never read by the disk-backed tree, but
multi-GB on kernel-scale repos.

Add clearScopeIndexStore() and clear at the start of persistScopeShards: the
previously sealed language has finished emit and been released before the next
seal runs, so its DiskBackedScopeTree never reads those shards again. Unit
tests: a stale prior-run shard is removed, a fewer-files re-seal leaves no tail
shards, and the helper is idempotent.

Addresses abhigyanpatwari review on run.ts (disk hygiene for the
GITNEXUS_DISK_SCOPE_INDEX path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:46:34 +01:00
henry201605
b565c7c990
feat(ingestion): resolve FastAPI include_router(prefix=...) cross-file routes (#1877)
* feat(ingestion): resolve FastAPI include_router(prefix=...) cross-file routes

FastAPI sub-route files declare paths via @router.<verb> while the entry
file mounts the router with app.include_router(<router>, prefix='/x').
Previously both the ingestion-layer Route graph nodes and the group-layer
ExtractedContract URLs lost the cross-file prefix, breaking provider <->
consumer matching.

Ingestion layer:
  - parse-worker emits routerIncludes / routerImports + decoratorReceiver
  - parsing-processor / parse-impl thread the new fields and aggregate
    prefixesByModule across chunks; decorator routes whose receiver is
    'router' are duplicated once per matching prefix
  - routes.ts joins prefix via normalizeExtractedRoutePath

Group layer:
  - HttpLanguagePlugin gains an optional prepareRepo() pre-pass and a
    repoContext arg to scan(); python.ts builds prefixesByModule and
    falls back to the bare path when no entry matches
  - http-route-extractor caches one repoContext per plugin

Tests:
  - 3 new http-route-extractor cases (attr / named-import / no-prefix)
  - ParseWorkerResult literals in 3 test files updated to the new shape

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ingestion,group): address PR #1877 review — relative imports, cross-package collisions, host names, ingestion tests

Follow-ups to the FastAPI `include_router(prefix=...)` cross-file fix
based on PR #1877's automated production-readiness review. Three
correctness gaps and one test coverage gap addressed:

1. Relative-import support in the worker regex (FINDING 2)
   `FROM_IMPORT_ROUTER_RE` now accepts module paths starting with a
   `.` (e.g. `from .calls import router as calls_router`). The
   previous `[A-Za-z_][\w.]*` rejected leading dots and silently
   dropped every relative-import Shape-B include — a real pattern
   from the PR description's own motivating example. The matching
   helpers now strip leading dots before keying so absolute and
   relative imports collapse to the same module key.

2. Cross-package same-name module collisions (FINDING 3)
   Two-tier module keying replaces the previous basename-only key:
     • short key — `users`            (file basename without `.py`)
     • long  key — `api/users`        (parent dir + stem)
   `prefixesByLongKey` is consulted first and only falls back to
   `prefixesByShortKey` when no long-key match is available. Both
   the ingestion pipeline (parse-impl.ts) and the group extractor
   (http-patterns/python.ts) carry the same scheme so the graph
   nodes and HTTP contracts agree on which prefix applies.

   New protocol field `ExtractedRouterModuleAlias` (parse-worker →
   parsing-processor → parse-impl) lets Shape-A
   `<host>.include_router(<mod>.router, prefix='/x')` calls promote
   to a long key when the same file imports `<mod>` via
   `from <pkg> import <mod>`. Without this, `api/users.py` and
   `admin/users.py` collided on the basename `users` and the admin
   file's routes inherited the `/users` prefix that was only meant
   for `api/users.py`.

3. Non-`app` host variable names (FINDING 4)
   The group-layer `INCLUDE_ROUTER_*_PATTERNS` queries pinned the
   host identifier to the literal `"app"` and dropped every
   `application = FastAPI()` / `api = FastAPI()` pattern — the
   constraint was redundant given that the call shape
   (`include_router` invoked with a router argument and a
   `prefix=` keyword) is already specific enough. The pin is
   removed; the ingestion regex was already unrestricted.

4. Ingestion-layer regression tests (FINDING 1)
   The previous PR added group-layer tests
   (`http-route-extractor.test.ts`) but zero in-tree tests for the
   ingestion path. Two new suites pin the
   worker → parse-impl → routes flow:

   - `test/unit/fastapi-router-bindings.test.ts` (23 cases):
     `extractFastAPIRouterBindings()` is split into a stand-alone
     module so it can be unit-tested without booting a worker
     thread, then pinned for regex shape, two-tier key emission,
     relative-import support, and negative cases.
   - `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases)
     plus `test/fixtures/fastapi-prefix-app/` — runs the full
     `runPipelineFromRepo()` against a realistic multi-package
     fixture (containing both `api/users.py` and `admin/users.py`)
     and inspects the resulting `Route` graph nodes for cross-file
     prefix joining and absence of cross-package bleed.

Verification

  - `npx tsc --noEmit`: pass
  - PR-touched test suites (6 files / 117 cases): all green
  - `npx prettier --check`: pass on touched files
  - `npx eslint`: 0 errors on touched files

Cache / compatibility

  The new `routerModuleAliases?` field on `ParseWorkerResult` and
  `routerModuleAliases` on `WorkerExtractedData` are optional /
  guarded with `?? []`, so historical parse-cache entries continue
  to load without forced re-scan.

Refs PR #1877.

* refactor(ingestion): move fastapi-router-bindings out of workers/ — pure module, not a worker

Addresses @magyargergo's `CHANGES_REQUESTED` review on PR #1877:

> Sorry I just found that we are introducing a new worker in the PR.

`gitnexus/src/core/ingestion/workers/fastapi-router-bindings.ts` was a
**pure-function module** — it never imported `worker_threads` or
`parentPort`, never spawned a worker, and was never registered as a
worker entry. It was placed in `workers/` purely because it was split
out of `workers/parse-worker.ts` to make its functions unit-testable
without booting a worker thread (parse-worker is itself the worker
entry and cannot be loaded from the main thread).

To remove the misleading directory placement:

  • The implementation moves to
    `gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts`,
    alongside the other framework-specific route extractors (`expo`,
    `nextjs`, `php`, `laravel`, `middleware`, `response-shapes`).
  • `workers/parse-worker.ts` keeps a thin re-export so the worker
    entry can keep using `extractFastAPIRouterBindings` directly. The
    re-export now carries an explicit comment stating that the imported
    file is **not** a worker and that the `workers/` directory
    deliberately hosts only true worker entries (`parse-worker.ts`,
    `worker-pool.ts`, `quarantine.ts`).
  • The new file's leading docstring opens with "NOT A WORKER" and
    explains why it exists where it does.
  • The unit test (`test/unit/fastapi-router-bindings.test.ts`) is
    updated to import from the new path.

No behaviour change. The function body, signatures, and exported types
are identical.

Verification

  • `npx tsc --noEmit`: pass
  • `npx tsc` (dist rebuild): pass
  • `test/unit/fastapi-router-bindings.test.ts` (23 cases): all green
  • `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases): all green
  • `test/unit/group/http-route-extractor.test.ts` (63 cases): all green
  • `npx prettier --check` on touched files: pass
  • `npx eslint` on touched files: 0 errors

Refs PR #1877.

* refactor(ingestion): drop parse-worker re-exports; consumers import router types directly from route-extractors

Addresses @magyargergo's two remaining review comments on PR #1877:

1. **`gitnexus/src/core/ingestion/workers/parse-worker.ts:247`** —
   "Can you please remove them and update the call sites?"

   The `export type { ExtractedRouterInclude, ExtractedRouterImport,
   ExtractedRouterModuleAlias } from '../route-extractors/...'` block
   in parse-worker.ts is gone. The remaining `import type {…}` is
   purely local — used only to type the corresponding fields on
   `ParseWorkerResult` below — and the leading comment now says so
   explicitly ("this file does NOT re-export them"). The
   `extractFastAPIRouterBindings` symbol is also no longer re-exported
   from parse-worker.ts; it's still imported here so the worker entry
   can call it per file, but downstream consumers must reach it via
   `route-extractors/fastapi-router-bindings` directly.

   Call sites updated:
     - `gitnexus/src/core/ingestion/parsing-processor.ts`
     - `gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts`

   Both files now `import type { ExtractedRouterInclude,
   ExtractedRouterImport, ExtractedRouterModuleAlias }` directly from
   `route-extractors/fastapi-router-bindings.js`. The worker types
   they still need (`ParseWorkerResult`, `ExtractedToolDef`, etc.)
   keep coming from `workers/parse-worker.js`.

   The unit + integration tests already imported from the new path,
   so no test changes were required.

2. **`gitnexus/src/core/ingestion/parsing-processor.ts:168`** —
   suggested simplification:

       for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item);
       for (const item of result.routerImports ?? []) allRouterImports.push(item);
       for (const item of result.routerModuleAliases ?? []) allRouterModuleAliases.push(item);

   Applied verbatim. Replaces the previous `if (result.…) for …`
   guards. The cache-compat semantics are unchanged — historical
   parse-cache entries that lack these fields still load cleanly,
   the new form just spells the fallback inline.

No behavior change, no tests touched, no public API change.

Verification

  • `npx tsc --noEmit`: pass
  • `npx tsc` (dist rebuild): pass
  • PR-touched test suites (6 files / 117 cases): all green
  • `npx prettier --check` on touched files: pass
  • `npx eslint` on touched files: 0 errors

Refs PR #1877.

* refactor(ingestion): hoist fastapi-router-bindings type imports to top of parse-worker.ts

Move the `import type { ExtractedRouterInclude, ExtractedRouterImport,
ExtractedRouterModuleAlias }` block to the top of the file with the
other type imports, and drop the comment that previously sat next to
ExtractedDecoratorRoute.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-28 19:04:19 +01:00
Gergő Magyar
99168be773
feat(ingestion): trace indirect call patterns — FastAPI Depends() and frontend HTTP consumers (#1852) 2026-05-28 05:33:30 +01:00
BlackOvOoo
263ca353a6
fix: shard parse cache persistence on large repos (#1580)
* fix: shard parse cache persistence on large repos

* fix(parse-cache): validate shard keys, docs, and sharded-cache tests

- Reject non-sha256-hex keys from index.json before path.join (path traversal).

- saveParseCache: skip invalid keys defensively; try/catch per-shard JSON.stringify.

- Clarify save comment (tmp dir + rename vs atomic).

- Tests: hex keys throughout, traversal keys, multi-shard, version-mismatch+legacy, second save, legacy removal.

- AGENTS.md / GUARDRAILS.md: document .gitnexus/parse-cache/ vs legacy parse-cache.json.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-16 07:19:06 +01:00
Abhigyan Patwari
4fa40e9881
feat(analyze): incremental indexing (parse cache + DB writeback + scope-res short-circuit) (#1479)
* docs: incremental indexing design spec

Captures the design agreed in brainstorming on 2026-05-10:
- Transitive importer closure with public-surface-change optimization
- Git-only change detection (non-git repos: full rebuild as today)
- New default behavior; --force opts out
- New hydratePhase + loadGraphFromLbug primitive
- Iterative closure expansion with parseCache reuse
- incrementalInProgress dirty flag for crash recovery

Prior art: PR #592 (zenprocess), PR #533 (davidbeesley),
PR #1146 (azeemshaik025) — referenced and credited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(communities): seed Leiden RNG for deterministic community detection

The vendored Leiden algorithm defaults to Math.random for tie-breaking
and randomized walks, which produces non-deterministic community
assignments and modularity values across runs on the same graph.

Pass a seeded mulberry32 RNG (LEIDEN_SEED=0xC0DE) so:
- The same graph always produces the same partition
- Modularity values are reproducible
- Equivalence tests for incremental indexing can compare community
  assignments byte-for-byte

This is foundational for the upcoming incremental-indexing feature
(see docs/superpowers/specs/2026-05-10-incremental-indexing-design.md)
where the correctness contract is incremental output ≡ full rebuild
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(incremental): change-detection, surface signatures, closure expansion

Three new modules supporting the incremental-indexing pipeline:

* core/incremental/git-diff.ts — getChangedFilesSinceCommit() unions
  'git diff lastCommit HEAD' (committed) with 'git status --porcelain'
  (dirty tree). Renames flattened to delete(orig) + add(new). Throws
  LastCommitMissingError when lastCommit is gone (caller falls back to
  full rebuild).

* core/incremental/surface.ts — extractSurfaceSignature() produces a
  stable hash of a file's publicly-visible symbols (functions, classes,
  methods, interfaces, types, heritage). Body-only edits → same hash.
  Signature/heritage changes → different hash. Drives the closure
  scoping optimization.

* core/incremental/closure.ts — computeImporterClosure() iterative
  fixpoint: parse each closure file, extract surface, query DB
  importers, expand. Uses a parseCache so each file is parsed once.
  Generic over TParseResult so closure logic is decoupled from the
  pipeline's parse representation.

32 unit tests across the three modules. Tests cover edge cases:
clean tree, dirty-only, mixed, renames, deletes, multi-hop cascade,
cycle termination, surface invariance, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(lbug): loadGraphFromLbug, queryImporters, deleteAllCommunitiesAndProcesses

Three new primitives in lbug-adapter.ts to support incremental indexing:

* loadGraphFromLbug(graph, unchangedFilePaths) — streams all nodes for
  files in the set across every hydratable node table (excludes
  Community/Process — graph-wide, regenerated downstream). Then loads
  edges where both endpoints belong to loaded nodes, excluding
  MEMBER_OF / STEP_IN_PROCESS edges (also graph-wide).
  FilePaths chunked at 200 per query to keep statement size bounded
  on huge repos. Endpoint-level join filters by source-side filePath
  in the query, target-side checked JS-side via the loadedNodeIds set.

* queryImporters(targetFilePath) — returns DISTINCT a.filePath where
  a -[IMPORTS]-> b and b.filePath = target. Powers closure expansion:
  when a changed file's surface signature changes, all its importers
  must be re-parsed.

* deleteAllCommunitiesAndProcesses() — drops Community/Process nodes
  (and their edges via DETACH DELETE) at the start of each incremental
  run so the communities/processes phases regenerate them from the
  fully-merged graph. Required for the 'Leiden runs on full graph'
  correctness invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(pipeline): hydrate phase + parse-filter for incremental indexing

Wires the incremental-indexing infrastructure into the phase-based
pipeline. Three coordinated changes:

* New hydratePhase (deps: structure) — loads node/edge state for files
  OUTSIDE ctx.options.filesToParse from the existing LadybugDB index.
  Runs before parse so the parse phase can produce a partial graph
  while downstream phases (mro, communities, processes) still see the
  full graph. No-op in full-rebuild mode (filesToParse unset).

* PipelineOptions.filesToParse: optional ReadonlySet<string>. When
  set, parse phase filters scanned files to this set; hydrate fills
  the complement. Set by runFullAnalysis when it detects an eligible
  incremental run; never set by callers directly.

* gitnexus-shared PipelinePhase enum: 'hydrate' added so progress
  callbacks can report the new phase distinctly from 'structure'.

Phase order: scan → structure → hydrate → markdown,cobol → parse
→ routes,tools,orm → crossFile → scopeResolution → mro → communities
→ processes. Communities (Leiden) still runs on the full graph,
satisfying the correctness invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): incremental orchestrator branch + meta schema

Wires incremental indexing into runFullAnalysis. Highlights:

* RepoMeta schema extended: schemaVersion, surfaceSignatures, and
  incrementalInProgress fields. INCREMENTAL_SCHEMA_VERSION = 1.

* core/incremental/file-hash.ts — v1 surface signature: SHA-256 of file
  content. v2 will switch to a true surface-only signature (defined in
  surface.ts) so body-only edits don't expand the closure. The plumbing
  is signature-agnostic so the swap is local.

* core/incremental/orchestrator.ts — eligibility check, closure
  computation (uses file-hash as the surface signal), dirty-flag
  management, subgraph extraction, signature merge.

* run-analyze.ts adds:
  - hasDirtyTree() check on the existing 'lastCommit==HEAD' early-exit
    so an uncommitted edit triggers re-index (was a coarse equality
    check before).
  - incremental branch: try incremental first; fall through to full
    rebuild on any setup failure or eligibility miss.
  - runIncrementalBranch() — opens existing DB, deletes closure-file
    rows + Community/Process, runs pipeline with filesToParse, writes
    only the changed-subgraph back, refreshes FTS, updates meta with
    new surfaceSignatures and clears the dirty flag.
  - Full-rebuild path now populates surfaceSignatures + schemaVersion
    in meta.json so the next run is eligible for incremental.

Crash recovery: incrementalInProgress is set BEFORE any DB mutation
and cleared on success by overwriting meta.json. A crash anywhere in
between leaves the flag set, and the next analyze run forces a full
rebuild (cheapest path back to a known-good index).

v1 limitation documented: body-only edits trigger 1-hop closure
expansion (content-hash signal). True surface-only optimization is
deferred to v2 — see design doc for the integration path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): drop invalid --no-renames=false from git diff

The flag --no-renames=false isn't valid git syntax (it's parsed as a
file path). Git's default rename detection is on; removing the flag
keeps that behavior.

Caught while running an end-to-end smoke test against a small fixture
repo: incremental setup failed with 'Command failed: git diff
--name-status -z --no-renames=false ...'. After the fix, the
incremental path runs cleanly: closure is computed, hydrate phase
loads unchanged-file state from DB, parse phase only re-parses files
in closure, and the writeback updates only changed nodes/edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert v1 incremental indexing (5 commits)

Reverts the v1 design that parsed only closure files into a fresh
graph and tried to hydrate the rest from DB. Real-repo equivalence
test failed: cross-file resolution operates on partial parse data
(closure files only), so CALLS edges that resolve through unchanged
files silently fall off. Diff against full rebuild on the same
edited state: -50 nodes, -425 edges, -5 communities, -48 processes.

Architecture pivot: switch to PR #533-style content-addressed parse
cache. Pipeline parses every file (cache-served when possible),
giving cross-file resolution full data, with DB writeback then
restricted to changed-file rows.

Reverts:
  d4b9de47 fix(incremental): drop invalid --no-renames=false
  f35f7634 feat(analyze): incremental orchestrator branch + meta schema
  bc039686 feat(pipeline): hydrate phase + parse-filter
  98bb893d feat(lbug): loadGraphFromLbug, queryImporters, ...
  aa8d7ae3 feat(incremental): change-detection, surface signatures, closure

Kept:
  d9e340b0 feat(communities): seed Leiden RNG (foundational)
  8235ca36 docs: incremental indexing design spec (will be revised)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): incremental DB writeback (Option B)

Equivalence-preserving incremental analyze. The pipeline still parses
every file (correctness invariant: cross-file resolution / scope
resolution / MRO / community detection all need full graph data); the
saving comes from selectively replacing only changed-file rows in
LadybugDB instead of wiping and reloading the whole graph.

How it works:

* On every analyze, we hash all source files (SHA-256 of content) and
  store the map in meta.json.fileHashes alongside schemaVersion.
* The next run loads the prior map and diffs:
  - changed: content hash differs → file's DB rows replaced.
  - added: not in prior map → file's DB rows inserted.
  - deleted: in prior map but not on disk → file's DB rows dropped.
* If the diff is non-empty AND no --force / no schema mismatch / no
  dirty flag, take the incremental path:
  - Set incrementalInProgress dirty flag (BEFORE any DB mutation).
  - Open existing DB (no wipe).
  - deleteNodesForFile() for each changed/added/deleted file.
  - deleteAllCommunitiesAndProcesses() — Leiden regenerates these.
  - extractChangedSubgraph() from the in-memory ctx.graph: nodes whose
    filePath is in the writable set + Community + Process + edges with
    at least one endpoint in the writable set (edges entirely between
    hydrated unchanged nodes are skipped — already in DB).
  - loadGraphToLbug() on the subgraph. Unchanged-file rows in DB
    untouched.
  - Recreate FTS indexes.
  - Update meta with new fileHashes; clear dirty flag.
* Otherwise full-rebuild path runs as before.

Crash recovery: incrementalInProgress is the dirty flag. Set before
destructive ops; cleared on success. Set on next-run startup → forces
full rebuild (cheapest path back to known-good).

Other changes:
* Dirty-tree gate on the existing 'lastCommit==HEAD' early-return:
  uncommitted edits no longer slip through as 'already up to date'.
* deleteAllCommunitiesAndProcesses helper in lbug-adapter.
* Skip the embedding cache+restore cycle when willTryIncremental is
  true — embeddings stay in DB; re-inserting them would PK-conflict.

End-to-end equivalence verified on this repo (993 files, 24K nodes):
incremental run produces byte-identical {nodes, edges, clusters,
flows} to a full rebuild from the same edited state.

Speedup is currently modest (~5% on this repo) because the parse
phase still runs in full. Parse-cache integration is a separate
follow-up that composes cleanly on top of this work.

See docs/superpowers/specs/2026-05-10-incremental-indexing-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(analyze): chunk-level parse cache for full incremental speedup

Composes with the incremental DB writeback (commit 27f3b49d) to deliver
the major-speedup half of incremental indexing. Previously, the parse
phase ran in full on every analyze; the speedup came purely from
selective DB rewriting. With this commit the parse phase also reuses
prior tree-sitter output for chunks whose contents haven't changed.

How it works:

* Cache layer (gitnexus/src/storage/parse-cache.ts):
  - File: <repo>/.gitnexus/parse-cache.json. Versioned, atomic write.
  - Key: chunk content hash = sha256(sorted(filePath:fileContentHash
    for each file in chunk)).
  - Value: ParseWorkerResult[] (raw worker output for the chunk,
    pre-merge).
  - Granularity: per chunk (~20MB byte-budget). A change to one file
    invalidates only its chunk — typically 1 of ~50 on a 1000-file
    repo (~98% cache hit ratio on a small edit).

* Worker contract (gitnexus/src/core/ingestion/parsing-processor.ts):
  - Extracted the chunk-result merge loop into a public
    mergeChunkResults() so the same logic applies to live worker
    output AND replayed cache entries.
  - processParsingWithWorkers / processParsing accept an optional
    outRawResults out-parameter that captures worker output before
    merging — used by parse-impl to populate the cache after a miss.

* Parse phase wiring (parse-impl.ts):
  - For each chunk, compute its content hash (after reading file
    contents). Cache hit → mergeChunkResults() on cached results,
    skip the worker dispatch entirely. Cache miss → run workers
    normally, capture raw results, store under the chunk hash.
  - Cache mutations happen in-place on the ParseCache passed via
    PipelineOptions.parseCache.

* Lifecycle (run-analyze.ts):
  - loadParseCache() before pipeline runs.
  - Cache passed via runPipelineFromRepo's PipelineOptions.
  - saveParseCache() after the pipeline + DB writeback succeed.

Equivalence verified on this repo (993 files, 24K nodes):

  Cold (no cache, full work):           141.1s
  Warm cache + 1-file edit, incremental: 63.6s  ← 55% speedup
  Warm cache + 1-file edit, --force:     71.6s  ← 49% speedup

All three runs produce byte-identical {nodes, edges, clusters,
flows}. The cache survives --force (content-addressed = always
correct), so even forced rebuilds get the parse-skip benefit.

Why chunk-level rather than per-file: workers process sub-batches and
emit aggregated ParseWorkerResults. Per-file granularity would require
restructuring the worker contract; chunk-level captures most of the
practical speedup with no worker-side changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(parse-impl): smaller default chunk budget (20MB→2MB) for cache granularity

The parse cache is keyed at chunk granularity. With the previous 20MB
budget, a typical mid-size repo (e.g. this worktree at 9MB total
parseable source) fits in a single chunk — meaning ANY file change
invalidates the whole chunk and re-parses every file.

2MB default produces ~5x more chunks on the same input, so a one-file
edit invalidates ~1/N of cached chunks instead of the whole thing.
Cold-run overhead from more chunks is <5% (one extra serialization
pass per chunk).

Override via GITNEXUS_CHUNK_BYTE_BUDGET env var for benchmarking.

Measured on this repo (~9MB / 887 parseable files):
  Cold (no cache):                    143s
  Warm cache, no source changes:        2s  (early-return)
  Warm cache + 1-file edit:            81s  (~43% off cold)

Speedup is bounded by the scopeResolution phase (~58s flat regardless
of parse cache) and by GitNexus's own auto-writes during analyze
(AGENTS.md / .claude/skills/ etc. mutate between runs and invalidate
chunks containing them). Both are addressable in follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): reuse worker-produced ParsedFile + stabilize chunk order

Two compounding optimizations that drop warm-cache analyze from
~134s to ~38s on a 1000-file repo (72% faster), and cold rebuild
from ~143s to ~86s (40% faster) by short-circuiting work that was
previously re-done.

1. SCOPE-RESOLUTION: REUSE WORKER PARSEDFILE

Previously, the scope-resolution phase re-parsed every file with
tree-sitter on the main thread (~58s on a 1000-file repo) because
worker-produced tree-sitter Trees can't cross the worker MessageChannel.

But the worker ALSO produces a  artifact via
, which structured-clones fine — and it's exactly
what scope-resolution would re-derive. Threading those ParsedFiles
through the parse phase () into
 ( map) lets scope-
resolution skip its extract loop on a per-file basis.

The fast path is bounded only by  per file (cheap
graph mutation). On this repo: scopeResolution went from 58s → 5s.

2. MAP-PRESERVING PARSE-CACHE SERIALIZATION

 is a
which JSON.stringify collapses to . The first attempt at threading
parsedFiles through the parse cache crashed at runtime with
"importerModule.typeBindings is not iterable" because cached entries
came back as plain objects.

Added a JSON replacer/reviver pair in parse-cache.ts that round-trips
Map and Set instances through tagged plain objects (). Symmetric: save uses replacer, load uses reviver.

3. STABLE CHUNK ORDERING

The byte-budget chunker walked files in filesystem-scan order, which
on Windows isn't guaranteed to be stable across runs. Even with
identical source content, two scans could place files in different
chunks, shifting chunk hashes and causing 100% parse-cache misses.

Added a deterministic alphabetical sort on  before
chunking. Chunk membership is now stable across runs, so a single-file
edit invalidates exactly one chunk, not all of them.

Measured on this repo (993 files, 24K nodes):
  Cold rebuild:                        86s  (was 143s)
  Warm cache, no source changes:        3s  (early-return)
  Warm cache + 1-file edit:            38s  (was 134s)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(incremental): update spec + AGENTS.md + GUARDRAILS.md for shipped design

- Rewrite docs/superpowers/specs/2026-05-10-incremental-indexing-design.md
  to describe the architecture that actually shipped (parse cache +
  incremental DB writeback + scope-resolution short-circuit), with the
  v1 hydrate-phase post-mortem preserved as historical context.
- AGENTS.md "Keeping the Index Fresh" section: note that incremental
  is the new default and --force is the explicit opt-out; mention
  the parse-cache file location and that it's safe to delete.
- GUARDRAILS.md Signs: add an "Index seems corrupt or incremental is
  misbehaving" entry pointing users to --force as the manual escape
  hatch (the dirty flag handles automatic recovery).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(incremental): bugbot review + CI test failures

Bugbot (PR #1479):
- Medium: pruneCache was exported but never called -> cache grew
  unbounded. Wire pruneCache into run-analyze before saveParseCache,
  using a transient usedKeys Set on ParseCache that the parse phase
  populates as it processes chunks.
- Low: willTryIncremental (pre-pipeline) and isIncremental
  (post-pipeline) could desync, silently dropping embeddings on
  mispredicted runs. Removed the prediction; the embedding cache
  now loads unconditionally when shouldLoadCache is true. The
  re-insert step gates on the actual isIncremental value to avoid
  PK-conflicts when the incremental-writeback path keeps DB rows.

CI test failures:
- cli-e2e #1169 + run-analyze.test.ts #1233: my dirty-tree gate on
  the lastCommit==HEAD early-return saw GitNexus's own auto-generated
  outputs (.claude/, .cursor/, AGENTS.md, CLAUDE.md) as dirty,
  perpetually defeating the up-to-date fast path. Extended the
  pathspec exclusion to cover all auto-gen outputs, not just
  .gitnexus/.
- ruby field-type disambig: my chunk-stability sort exposed a
  pre-existing order-dependency in Ruby cross-file resolution
  (`user.address.save -> Address#save` only resolves correctly when
  user.rb parses before address.rb in some configurations). Removed
  the sort. Filesystem ordering is stable enough in practice that
  the parse cache still hits the common case; the pre-existing
  fragility is left for a separate fix.
- pipeline-graph-golden: regenerated. Seeded Leiden RNG produces a
  partition different from the previous Math.random snapshot.
- staleness `parallel calls` was a CI timing flake; passes locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): re-insert cached embeddings on incremental path

Bugbot re-review caught: deleteNodesForFile cascades to the
CodeEmbedding table (DELETE WHERE e.nodeId STARTS WITH ...), so
changed-file embedding rows are wiped along with their nodes. The
previous fix gated re-insert on `!isIncremental`, which silently
dropped those embeddings — a regression versus the full-rebuild path's
"preserve embeddings by default" guarantee.

Remove the `!isIncremental` gate. The per-batch try/catch already
handles the unchanged-file PK-conflict case ("some may fail if node
was removed, that's fine") with the same semantics, so re-inserting
the full cached set on incremental works:

  - changed-file rows: deleted, then re-inserted from cache (preserved)
  - unchanged-file rows: still in DB, re-insert PK-conflicts and is
    silently ignored (existing rows are correct)

Cost: re-inserting ~24K embeddings on incremental when only a few
files changed — most are no-op conflicts. Bounded by batch size of
200; ~3-5s overhead. Worth it for correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): address Claude+Bugbot review findings + remove design doc

Addresses CHANGES_REQUESTED review on PR #1479:

1. Remove docs/superpowers/specs/2026-05-10-incremental-indexing-design.md
   per maintainer request.

2. BLOCKER (Claude Finding 1, Bugbot Round 3): Stale cross-file edges
   between unchanged files. extractChangedSubgraph excluded edges where
   both endpoints were unchanged-file nodes — when a barrel/re-export
   file changes, cross-file resolution may update CALLS edges between
   two unchanged files that would then be silently lost.

   Fix: 1-hop importer-closure expansion of the writable set in
   run-analyze.ts. Before deleting/rewriting rows, query DB for
   importers of every changed/deleted file and add them to the writable
   set. Their nodes get deleted+rewritten too, so cross-file's refined
   edges land in the DB. Re-added queryImporters to lbug-adapter.ts.

3. BLOCKER (Claude Finding 3): Parse cache key omitted parser version.
   After a GitNexus upgrade, the cache silently replays pre-upgrade
   ParseWorkerResults against the new schema → wrong CALLS/IMPORTS/
   scope edges with no visible signal.

   Fix: PARSE_CACHE_VERSION now embeds the gitnexus npm package
   version (read at module load via createRequire on package.json).
   Format: `${SCHEMA_BUMP}+${PKG_VERSION}` e.g. "1+1.6.4". Any release
   that bumps package.json automatically invalidates the on-disk cache.
   Mismatched versions fall through to an empty cache (next save
   overwrites with the new version baked in).

4. BLOCKER (Claude Finding 2): No automated tests for incremental
   behavior. Added 28 unit tests across 3 files:

     - incremental-file-hash.test.ts (10 tests)
       diffFileHashes classification, computeFileHash determinism,
       computeFileHashes batch / missing-file tolerance, sorted output.

     - incremental-parse-cache.test.ts (12 tests)
       computeChunkHash stability and order-independence, version
       prefix format, pruneCache, load/save round-trip on empty /
       missing / corrupt / version-mismatched files, AND a Map/Set
       round-trip test that pins the JSON replacer/reviver behaviour
       (without it, ParsedFile.scopes[*].typeBindings collapses to
       {} and downstream `.get()` / iteration throws).

     - incremental-subgraph-extract.test.ts (6 tests)
       writable-set node inclusion, Community/Process always kept,
       edge inclusion when at least one endpoint is writable, MEMBER_OF
       edges via graph-wide endpoints, empty subgraph case.

5. Medium (Claude Finding 6): AGENTS.md "Keeping the Index Fresh"
   said "only changed files are re-parsed." Imprecise — the pipeline
   parses every file every run; the cache skips tree-sitter for chunks
   whose contents haven't changed. Reworded to match the design doc.

Test plan still expects:
  [x] Typecheck clean
  [x] All 28 new unit tests pass
  [x] All previously-failing tests still pass on the rebased branch
  [x] Equivalence verified locally (incremental ≡ --force, byte-identical
      stats on this repo)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): round 3 review feedback — bounded BFS, atomic meta, integration test, docs

Addresses remaining findings on PR #1479 from Claude's re-review of
commit ad7bd31 + verifies the outstanding Bugbot HIGH severity.

1. F1 — Transitive importer expansion (Claude, was Medium-but-noted).
   Previous 1-hop importer expansion missed barrel re-export chains
   (A imports C, C re-exports B; when B changes, only C was pulled in
   — A was left with potentially-stale CALLS edges to refined targets).
   Replaced the single pass with a bounded BFS over the IMPORTS graph
   (depth ≤ 4). Catches nested barrel pyramids without ballooning into
   a near-full rebuild on monorepos with deep re-export trees. `--force`
   remains the escape hatch documented in GUARDRAILS.md for cases that
   exceed the bound.

2. F2 — Integration test for incremental orchestration (Claude, BLOCKER,
   DoD §2.7). The unit tests added in ad7bd31 covered `diffFileHashes`,
   `extractChangedSubgraph`, `computeChunkHash`, `pruneCache`, and the
   Map/Set JSON round-trip — but none of them exercised the real
   `runFullAnalysis` orchestration. Added gitnexus/test/unit/
   incremental-orchestration.test.ts with four end-to-end tests against
   a real git-initialized fixture repo + real LadybugDB:

     a. First run populates fileHashes + schemaVersion and clears
        incrementalInProgress on success.
     b. Second run on unchanged state takes the alreadyUpToDate fast
        path (early-return).
     c. Second run after a source edit takes the incremental path
        (not full rebuild) and rotates fileHashes for the touched file
        while keeping the dirty flag cleared.
     d. A pre-set incrementalInProgress flag forces a full rebuild
        that clears it (crash-recovery wire).

   These would catch any regression that wires `isIncremental` from a
   pre-pipeline prediction (the Bugbot finding from commit 5eb0597) or
   accidentally re-gates the embedding re-insert on `!isIncremental`
   (the Bugbot finding from commit 60c10f1).

3. F3 — GUARDRAILS.md docs accuracy (Claude, Low). Line 33 still said
   "only changed files are re-parsed" — AGENTS.md was already corrected
   in ad7bd31 but GUARDRAILS.md was missed. Reworded to match.

4. F5 — Atomic saveMeta (Claude, Medium; vvladescu-tb fork). The dirty
   flag (`incrementalInProgress`) travels through meta.json. A crash
   mid-write would leave a corrupt meta.json that `loadMeta` would
   silently treat as "no prior index", losing the flag and skipping
   recovery. Switched to tmp-file + rename matching saveParseCache.

5. Bugbot's "Subgraph edges reference nodes absent from subgraph"
   (HIGH severity). Verified as FALSE POSITIVE: `getNodeLabel` in
   lbug-adapter.ts derives labels from the node-ID string (parses
   the table prefix), not from the in-memory graph. The CSV
   generator writes (src_id, dst_id, type) rows without consulting
   node objects; `splitRelCsvByLabelPair` routes by ID-derived label;
   `COPY ... (from=X, to=Y)` resolves both endpoints against the live
   LadybugDB where unchanged-file nodes still exist. No fix needed.

All 213 tests pass locally (including the 4 new integration tests
and the previously-failing CI tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(incremental): address Bugbot round-4 findings (added-file shadow seed + dedupe)

Bugbot review on commit e23e4400 surfaced two new findings against the
incremental writeback in run-analyze.ts:

  HIGH — Incremental BFS misses importers of newly added files.
    queryImporters() reads the pre-pipeline DB. For a NEWLY ADDED
    file there are no IMPORTS rows pointing to it yet, so unchanged
    files whose pre-existing import statements now resolve to the
    newcomer keep stale CALLS edges pointing at the OLD resolution
    target.

  LOW — Deleted files double-counted in filesToDelete.
    hashDiff.deleted entries can reappear in writableFiles via the
    BFS expansion (queryImporters can return a now-deleted path),
    so deleteNodesForFile() ran twice for the same file.

Fixes:

  - Add gitnexus/src/core/incremental/shadow-candidates.ts: derive
    the pre-existing file paths whose JS/TS module-resolution claim
    an added file can steal. Pattern catalogue: same-basename/
    different-extension, bare-file-beats-directory-index, and
    directory-index-beats-bare-file. Emit both POSIX and Windows
    separators because the prior fileHashes map may have been
    written from either OS.

  - In run-analyze.ts, seed the BFS frontier with shadow candidates
    that exist in the prior meta.fileHashes. Their importers — found
    via queryImporters — get pulled into the writable set so their
    CALLS edges re-resolve against the new file.

  - Dedupe filesToDelete via Set to avoid the double-call.

Tests: gitnexus/test/unit/incremental-shadow-candidates.test.ts —
8 cases covering each shadow pattern, separator handling, .d.ts as
a single extension token, deduplication, and the no-self-shadow
invariant. All 40 incremental tests (file-hash, parse-cache,
subgraph-extract, shadow-candidates, orchestration) pass locally.

Note on the third Bugbot finding ("Subgraph edges reference nodes
absent from subgraph"): re-anchored from a prior review pass — the
code at subgraph-extract.ts:48 is unchanged. Already verified as a
false positive: getNodeLabel parses labels from ID strings, CSV
write is by ID, and COPY resolves against the live DB.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(incremental): exact-equality stats invariant + analyze ≡ analyze --force

Addresses the only remaining Claude production-readiness review finding
on PR #1479 (Low-Medium, test-quality only — Claude itself said it does
NOT block merge, but the central PR claim "incremental ≡ full rebuild"
deserves explicit CI coverage rather than implicit trust).

Changes to gitnexus/test/unit/incremental-orchestration.test.ts:

1) Tighten the existing "comment-only edit takes incremental path" test.
   - Replace toBeGreaterThan(0) bounds assertions on stats.files and
     stats.nodes with exact toBe(firstMeta) per-field equality across
     files / nodes / edges / communities / processes. DoD §2.7 calls
     out bounds-only assertions as masking regressions that drop half
     the graph; this swap closes that gap.
   - Rationale: a comment-only edit must change the file content hash
     (driving the incremental path) without changing any graph data.
     Therefore every stat MUST be identical to the first run. Anything
     else is a regression.

2) New test: incremental output is byte-equivalent to a full rebuild.
   - Run analyze → comment-only edit → analyze (incremental writeback)
     → analyze --force (full rebuild from same on-disk state).
   - Assert files / nodes / edges / communities / processes are exactly
     equal across the incremental and the --force passes.
   - This is the PR's central correctness contract, now proven by a
     test that exercises the real runtime path end-to-end against a
     real on-disk LadybugDB.

All 5 orchestration tests pass locally (52s), including the new
equivalence test — every stat field matches exactly between incremental
and --force on the mini-repo fixture.

tsc --noEmit clean.

* fix(incremental): F1 cross-file edge consistency + F4 stable chunk sort + unit coverage (#1511)

Patch addressing two of the still-open changes-requested findings on PR
#1479, rebased onto the current feat/incremental-indexing head. F3
(parser fingerprint in the cache key), F5 (atomic saveMeta), and F6
(AGENTS.md phrasing) were already handled on the branch, so the
corresponding parts of the original patch were dropped as redundant.

  F1 (Blocker) — Cross-file edges between unchanged files
    Adds `computeEffectiveWriteSet(graph, toWriteSet)` to
    subgraph-extract.ts: a single pass over the new graph's edges that
    pulls the unchanged-side file of every writable-boundary-crossing
    edge into the write set. run-analyze composes it ON TOP of the
    existing importer-BFS expansion and feeds the combined set to BOTH
    `deleteNodesForFile` and `extractChangedSubgraph`, so the delete
    cascade and the writeback subgraph cover identical files (asymmetry
    would leave stale rows or PK-conflict at COPY time). The BFS reads
    IMPORTS from the pre-pipeline DB (catches files that *stopped*
    importing a changed file); the edge walk reads the new graph
    (catches refined CALLS edges the pre-run DB couldn't predict, e.g.
    a barrel re-export shifting a symbol from B to D). `extractChangedSubgraph`
    stays a pure filter — all expansion is the orchestrator's job.

  F4 (Medium) — Restore alphabetical chunk sort
    `parseableScanned` is sorted before chunking. Filesystem-scan order
    isn't stable enough across runs/platforms (notably macOS APFS) to
    keep chunk hashes consistent, so the parse cache thrashes without
    it. The pre-existing Ruby cross-file resolution order-dependency the
    old comment cited is independent — the sort surfaces it but doesn't
    cause it; tracked separately rather than leaving the cache cold.

  Tests — incremental-subgraph-extract.test.ts
    Locks the F1 invariants: `extractChangedSubgraph` is a pure filter
    (includes only the set it's given, plus graph-wide nodes; edges
    fire on one writable endpoint), and `computeEffectiveWriteSet`
    covers the barrel-re-export scenario, the symmetric edge-into-
    changed-file case, the no-boundary-crossed no-op, graph-wide-node
    edges, and input-immutability. Supersedes the prior
    extractChangedSubgraph-only test file on the branch.

Co-authored-by: Val Vladescu <vvladescu-tb@users.noreply.github.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(call-processor): register properties in pre-pass to fix order-dependent field type disambiguation + regenerate golden snapshot

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2d66666f-861c-432e-a4b0-11f2aefca98a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(call-processor): port worker-path property enrichment into the sequential pre-pass

Copilot's pre-pass in 8184439 fixed the Ruby attr_accessor order-dependence,
but it copied the OLD in-loop registration logic, not the canonical worker
path in parse-worker.ts. That left the sequential and worker paths emitting
non-identical Property nodes/symbols for the same source — silently breaking
the `incremental ≡ --force` invariant the moment a repo crosses the worker
threshold between runs.

Two concrete divergences are closed here:

  * Node id: worker keys Property as `${file}:${className}.${propName}`
    (qualified). Pre-pass was using `${file}:${propName}` (unqualified).
    Same source produced different graph ids depending on which path ran.

  * Field metadata: worker enriches each routed property with
    `provider.fieldExtractor` + `getFieldInfo`, falling back to
    `routedFieldInfo.type` for `declaredType` when the routing payload
    lacks one (e.g. types discovered from `@address = Address.new`
    ctor assignments rather than YARD `@return [Type]`), and propagates
    `visibility` / `isStatic` / `isReadonly`. Pre-pass did none of this,
    so on the sequential path `resolveFieldAccessType` failed to walk
    chains where the type only came from the FieldExtractor.

The pre-pass now mirrors parse-worker.ts:1803-1898 verbatim, with one
deliberate difference: the FieldInfo cache is scoped to a single
`processCalls` invocation rather than module-level (the worker process
is short-lived; the main thread is not, and a module-level cache would
leak state between analyze runs).

Also drops the now-stale "Defer resolution: Ruby attr_accessor properties
are registered during this same loop" comment on `pendingWrites.push` —
the rationale is no longer accurate after Copilot's pre-pass, but the
deferral is still needed so write-access tracking sees inference that
completes during the main loop. Comment updated to reflect that.

Verification:
  * `tsc --noEmit`: 0 errors
  * test/unit (call-processor, call-routing, field-extraction, ruby-self-call): 224 passing
  * test/integration (ruby, ruby-sequential-mixin, pipeline-graph-golden): 137 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(call-processor): key fieldInfoCache by filePath:startIndex, not raw byte offset

Claude's review of 255bdf6 caught a real collision in the FieldInfoCache I
added: keying by `classNode.startIndex` alone is a per-file byte offset, so
two files that both begin with a class at byte 0 — extremely common in Ruby /
Python, where files frequently open with `class Foo`, `module Foo` — collide
on the same cache entry. The second file's `getFieldInfo` then returns the
first file's FieldInfo map, producing wrong `declaredType` / `visibility` /
`isReadonly` on its properties.

Same shape as the bug that already exists in parse-worker.ts:377 (also keyed
by `classNode.startIndex` in a module-level map, persistent across files
processed by the same worker). Fixing the symmetric pre-existing leak in
parse-worker.ts is a separate, scoped follow-up — left out of this commit to
keep the fix minimal and reviewable.

Cache map and key are now both string-typed. Composite key
`${context.filePath}:${classNode.startIndex}` keeps the within-file hit rate
(one FieldExtractor.extract() per class regardless of how many
`attr_accessor` lines it has) while eliminating cross-file aliasing.

Verification on the patched HEAD:
  * `tsc --noEmit`: 0 errors
  * test/unit (call-processor, call-routing, field-extraction, ruby-self-call): 224 passing
  * test/integration (ruby, ruby-sequential-mixin, pipeline-graph-golden): 137 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Val Vladescu <val.vladescu@thirdbridge.com>
Co-authored-by: Val Vladescu <vvladescu-tb@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-12 13:14:56 +01:00