Commit graph

5 commits

Author SHA1 Message Date
azizur100389
bee3e82ab2
test(scope-resolution): guard closure identity invariants (#2748) 2026-07-30 05:34:21 +01:00
Gergő Magyar
0ce7880290
fix(scope-resolution): a closure binding is a call SOURCE in every language, and function-local values carry their own identity (closes #2699) (#2718)
* test(scope-resolution): audit the consumers of file-scoped node ids (#2699 part A)

#2699 item 4 — "audit consumers that assume file-scoped ids" — after #2695/#2714
gave function-local CALLABLES position-bearing ids. Tests and findings only; no
production change. That split is deliberate: `impact` reports
`resolveDefGraphId` at CRITICAL with 23 DIRECT dependents across 7 modules
(every language MRO builder, both Spring attachers, C++ member lookup,
tryEmitEdge, emitReferencesViaLookup, buildGraphTargetIndex, emitFreeCallFallback,
emitReceiverBoundCalls, preEmitInheritanceEdges, emitDetectedInterfaceImplementations,
phpEmitUnresolvedReceiverEdges, emitRubyMixinEdges, emitRustTraitImplEdges,
emitDartHeritageEdges), so changing that key chain is its own change, not a
rider on an audit.

A2 — detect_changes: CONCERN RESOLVED, now pinned. The worry was that an id
containing `@row:col` re-keys whenever a declaration MOVES, making every edit
look like symbol churn. It cannot: `local-backend.ts` maps diff hunks to
symbols by LINE-RANGE OVERLAP (`n.startLine`/`n.endLine`) and merely REPORTS
`n.id`. Node identity never participates in the match. New structural test
asserts the WHERE clause never gains `n.id =` or `n.id IN`, keeps the one
legitimate id-shaped predicate (the `BasicBlock:` prefix exclusion, #2082 U7),
and confirms the id is returned rather than matched. Structural in the same
idiom as `detect-changes-worktree.test.ts`, and labelled as not proving runtime
behaviour.

A1 — ANSWERED, and the answer is that #2699 is NOT fully closed by items 1-3.
The fail-closed guard is gated on `isOverloadableCallable`
(Function | Method | Constructor), so a function-local VALUE never reaches it.
Measured on a fixture: a top-level `const handler` and a function-local
`const handler` still produce ONE node, `Const:v.ts:handler`. That is the
residual half of the issue's original complaint. Pinned as a KNOWN LIMIT with
its reason (widening identity to values re-keys ~14,700 build-time nodes to
change ~800 persisted ones — the decision recorded in `parse-worker.ts`), and
deliberately NOT fixed here.

A3 — id-persisting consumers, classified:
  - detect_changes ................ SAFE (position-keyed; pinned by A2)
  - MCP impact/context/trace ...... SAFE (resolve by name/uid at query time)
  - bench fingerprints ............ SAFE (digest capture shape, not node ids)
  - rust-captures golden .......... SAFE (digests captures, not ids)
  - cfg pipeline-pdg snapshot ..... AT RISK by design — pins exact edge ids, so
    it trips whenever attribution changes. That is the gate working; #2714
    already exercised it.
  - wiki / group-contract links ... NOT id-keyed on locals (locals are never
    cross-file addressable, per the document-scoped contract of item 2).

Verified: tsc clean; 14/14 across the two touched files; `detect_changes`
reports 0 changed symbols (tests only).

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

* fix(php): a closure binding is a call SOURCE, not only a TARGET (#2699 part B, S1)

A call made inside a closure binding was attributed to the ENCLOSING scope, so
the closure was a call TARGET but never a call SOURCE: impact(handler,
direction:"downstream") reported nothing even though the closure calls out.

Root cause, probe-measured rather than inferred. Instrumenting
pickCallerCallableDef (graph-bridge/ids.ts) to log every rejection reason shows
the closure's own scope EXISTS and its range DOES contain the call site, but its
ownedDefs is EMPTY, so the ":94" owned-callable filter drops it and attribution
falls through to the ":97" enclosing-scope fallback.

The reason is one missing query rule. javascript/query.ts pairs the binding name
with the closure via @declaration.function anchored on the INNER arrow node, so
anchor.range equals the @scope.function range and pass2AttachDeclarations
attaches the declaration to the CLOSURE's scope. No other language had that
rule — PHP, Rust, Kotlin, Ruby and Dart all captured named function
declarations only. That single omission is the entire empty-ownedDefs cause.

This ports the rule to PHP with the same anchor discipline (@declaration.function
on the inner anonymous_function / arrow_function, NOT on the
assignment_expression wrapper). PHP needs nothing else: it already declares
(anonymous_function) and (arrow_function) as @scope.function, so the rule alone
completes it.

Measured on a fixture: `$handler = function ($x) { return target($x); }` inside
outer() now emits

  Function:src/a.php:outer.$handler@3:2 -> Function:src/a.php:target

where it previously emitted `outer -> target`.

The pinned test in closure-binding-labels.test.ts asserted the OLD, wrong
behaviour by design ("to catch that asymmetry changing in EITHER direction"), so
it is INVERTED here rather than deleted, per its own instruction. Its block
comment is corrected to record the measured root cause, including that Kotlin
and Ruby will need BOTH this rule AND a relaxed kind gate (their lambda_literal
/ do_block is @scope.block deliberately, #1757), and that Dart has no closure
scope at all.

Verification: closure-binding-labels 50/50; PHP resolver suites 221/221
(php, php-coverage, php-response-shapes). detect_changes {staged}: 1 changed
symbol (PHP_SCOPE_QUERY), 0 affected processes, risk LOW.

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

* fix(rust): emit a node for a closure binding and make it a call SOURCE (#2699 part B, S3)

Rust was the one exception to #2687's "a closure bound to a name is a Function
node in every language": `let handler = || target(1);` produced NO graph node at
all, so the closure could be neither a call target nor a call source.

Needed BOTH query channels, which is the finding worth recording. Porting only
the scope-resolution rule (as S1 did for PHP) changed nothing measurable here,
because there was no node to attribute anything to:

  - languages/rust/query.ts — closure-binding declaration, @declaration.function
    on the INNER closure_expression so anchor.range aligns with the existing
    (closure_expression) @scope.function. This is what gives the closure's own
    scope a callable in ownedDefs, which is what stops pickCallerCallableDef
    falling through to the enclosing fn.
  - tree-sitter-queries.ts — @definition.function on the OUTER let_declaration.
    This emits the Function NODE that Rust never had.

Note the deliberate anchor asymmetry between the two channels: the graph-node
channel anchors the WRAPPER (matching the existing
(lexical_declaration (variable_declarator ... (arrow_function))) rule), while
the scope-resolution channel anchors the INNER closure (to align with
@scope.function). Getting these backwards silently produces either no node or
an unattributable one, so both sites carry a comment saying so.

Measured on a fixture — `let handler = || target(1);` inside outer():

  Function:src/a.rs:outer                CALLS  Function:src/a.rs:outer.handler@2:4
  Function:src/a.rs:outer.handler@2:4    CALLS  Function:src/a.rs:target

Previously the whole binding was absent and the call read as `outer -> target`.
The rule also covers `move` closures: the closure_expression node spans the
`move` keyword.

Verification: closure-binding-labels 50/50; rust.test.ts 192/192;
rust-coverage, rust-f70, rust-scope all pass; rust-captures-golden passes
UNCHANGED, so no golden regeneration was required. detect_changes {staged}:
2 changed symbols (RUST_SCOPE_QUERY, RUST_QUERIES), 0 affected processes,
risk LOW.

One caveat on the suite runs: this host times out `beforeAll` hooks at the
default 60s under load — rust.test.ts needed --hookTimeout=600000 to complete,
and a concurrent second vitest run starves worker startup entirely (every test
fails at ~5001ms). Both are host artifacts, not signal.

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

* fix(kotlin,ruby): a closure binding is a call SOURCE, via a Block-scope callable boundary (#2699 part B, S2)

Kotlin and Ruby anchor a closure on a Block-kind scope — Kotlin lambda_literal
and Ruby do_block/block are @scope.block DELIBERATELY (#1757 smart casts), so
they must not be re-kinded. pickCallerCallableDef gated its child-scope walk on
kind === 'Function', so a closure there could never become a call SOURCE.

Both halves are required; neither alone changes anything:

1. kotlin/query.ts and ruby/query.ts gain the closure-binding declaration rule,
   with @declaration.function on the INNER lambda_literal / block so its range
   aligns with the @scope.block range (the anchor discipline documented in
   javascript/query.ts). Without this the closure scope owns no callable def.

2. pickCallerCallableDef accepts a Block-kind child as a callable boundary when
   the scope IS that callable's body. Without this the kind gate still rejects.

The alignment test in (2) is the part worth scrutiny. Relaxing the kind gate to
accept ANY Block owning a callable would be a real regression: a nested
`fun foo()` declared inside a block is owned by that block, so a call made at
BLOCK level — outside foo — would be misattributed to foo. Comparing the def's
declaration position against the scope's start position discriminates them: for
a closure the declaration and the scope sit on the SAME node, so the positions
match; for a nested function the block starts at `{` while the def starts at the
declaration, so they do not. Existing Function-kind behaviour is untouched, so
every already-working language is unaffected by construction.

The comparison is base-safe: scope-extractor.ts builds a def id as
`def:<filePath>#<startLine>:<startCol>:<type>:<name>` from the same Range a
scope carries, so both sides share one coordinate base. This is called out in
the helper's docblock because `defStartLine` nearby documents its own output as
1-based, which invites a wrong "fix" (#2377 is exactly this class of hazard).

Ruby's call forms are restricted to lambda/proc by name: an unrestricted
(call block: (block)) would match ANY method call taking a block, so
`mapped = items.map { |i| ... }` would wrongly declare `mapped` a callable.
Verified against the parser: 3 matches (->, lambda, proc), map excluded.
Separate #eq? patterns rather than one #match? alternation, which is a known
hazard on this tree-sitter line.

Measured on fixtures:

  Kotlin  Function:src/A.kt:outer.handler@2:4  CALLS  Function:src/A.kt:target
  Ruby    Function:src/a.rb:outer.handler@4:2  CALLS  Method:src/a.rb:target#1

previously `outer -> target` and `outer#0 -> target#1`.

The pinned Kotlin test asserted the old behaviour by design and is INVERTED, not
deleted. Ruby had NO pinned case, so a new one is added rather than inverted.
The describe title no longer claimed something false ("not yet a call SOURCE"
now holds only for Dart) and was retitled.

Verification: closure-binding-labels 51/51; kotlin.test.ts, kotlin-coverage,
ruby.test.ts, ruby-scope, ruby-namespaced all pass (478 passed / 1 expected
inversion before the test was flipped). impact on pickCallerCallableDef:
CRITICAL, 191 impacted, ONE d=1 (resolveCallerGraphId) — the return contract is
unchanged, so that dependent is unaffected. detect_changes {staged}: 5 changed
symbols, 2 affected processes (both EmitReferencesViaLookup, one of them the new
ScopeIsCallableBody step), risk medium.

Dart remains the last failing language: dart/query.ts declares no
@scope.function at all, and dart/captures.ts synthesizes one only from a
declaration WITH a body node, which an expression-bodied closure lacks.

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

* fix(dart): give a closure binding a scope and a distinct identity (#2699 part B, S4)

Dart was the last language where a closure binding could not be a call SOURCE,
and fixing only that would have made the graph WORSE, not better. This lands
both halves together for that reason.

## The attribution half

A Dart closure had no scope at all. `dart/query.ts` declares no
@scope.function anywhere — Dart's function scopes are SYNTHESIZED in
`dart/captures.ts` from `declNode` + `findFunctionBody(declNode)`, and
`findFunctionBody` looked only at the next named SIBLING for a `function_body`.
A closure literal carries its body as a CHILD (`function_expression_body`), so
it matched nothing and no scope was produced.

`query.ts` gains the closure-binding declaration rule and `findFunctionBody`
understands the child form. Deliberately NO @scope.function is added to the
query: it would collide at identical range with the synthesized one, and
duplicate scope ids make `buildScopeTree` throw, which DROPS THE WHOLE FILE.

## The identity half, and why it is not optional

With attribution alone, two same-named closures in one file both keyed to the
bare `Function:a.dart:handler`. One node then appeared to call BOTH targets —
a CALLS edge present nowhere in the source. That is worse than the missing edge
it replaced, so S4 could not ship without this.

Root cause is not Dart-specific. `enclosingCallablePrefix` derives a SEMANTIC
relation — what encloses this callable — by SYNTACTIC ancestor walk. Dart parses
`int outer() { … }` as `function_signature` followed by `function_body` as
SIBLINGS, so the enclosing callable is never an ancestor of code inside it and
no membership set can fix that; the walk looks in the wrong direction.

This is what SCIP and real compilers avoid by construction. SCIP keeps a local
symbol opaque (`local <id>` — no name, no position, no chain) and models
containment as a SEPARATE `enclosing_symbol` field; its spec says the local/global
choice should follow ACCESSIBILITY, not the ability to name an enclosure. Dart's
own analyzer answers this from `Element.enclosingElement` in the element model,
never from AST ancestry. clang uses `name@offset` for a function-local; Kythe
uses a document-scoped VName plus a `childof` edge. Identity is positional and
opaque; enclosure is a relation.

`findSplitBodyCallableAncestor` is the narrow fix at that seam: a fallback used
ONLY when the ancestor walk finds nothing, recovering the callable from the
body's preceding sibling.

The sibling must be a BARE SIGNATURE, and that restriction is load-bearing —
"any preceding callable sibling" is WRONG and was caught regressing PHP during
this work. In `<?php function target($x) {…} $handler = function ($x) {…};` the
closure is at FILE level, so the ancestor walk correctly finds nothing, the
fallback runs, and an unrestricted version mis-qualified the file-level
`$handler` as `target.$handler`. A preceding sibling is only an ENCLOSING
callable when it cannot hold its own body.

`SPLIT_SIGNATURE_NODE_TYPES` is exactly that set and is DERIVED, not listed:
`LOCAL_SCOPE_BODY_NODE_TYPES` is already `FUNCTION_NODE_TYPES` minus the bare
signature types, so the difference between them IS the split-signature set
(`function_signature`, `method_signature` — verified at runtime). PHP's
`function_definition` carries a body and is in both, so it is excluded. No
language is named in shared code, and any future split-grammar language is
covered for free.

## Verification

Full resolver sweep — the gate that caught #2714's Rust regression — 2926
passed / 1 skipped / 0 failed across 51 files. closure-binding-labels 52/52;
dart.test.ts, dart-coverage, callable-id-lockstep, function-local-identity,
caller-identity-regression all pass (156/156 across 6 files).
impact on `enclosingCallablePrefix`: LOW, 5 impacted, 3 d=1 all inside
parse-worker. detect_changes {staged}: 5 changed symbols, 0 affected processes,
risk LOW.

Three existing Dart expectations FLIPPED rather than being deleted: Dart locals
now carry the same enclosing-callable + position identity every other language
got in #2695, so `local.dart:handler` became `local.dart:caller.handler@1:2`.
A new test pins the actual defect — two same-named closures staying DISTINCT
nodes — because the qualification assertions alone would not fail if the
fabricated edge returned.

One note for future work: an id-shape assertion here carries a call-site suffix
on indirect invocations (`…handler@3:2:5:9`) but not on direct calls. That is
the callable-value-flow pass keying its edge by invocation position, not part of
the node id.

Part B is now complete: PHP (S1), Rust (S3), Kotlin + Ruby (S2), Dart (S4).

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

* fix(scope-resolution): close every deferred item on #2699 (A1 values, twin-list guard, schema bumps)

Clears the limitations this PR had been carrying rather than leaving them as
follow-ups.

## A1 — function-local VALUES now carry their own identity

This was #2699's ORIGINAL complaint and the one a callable-only gate could never
reach: a top-level `const handler` and a function-local `const handler`
collapsed onto ONE `Const:v.ts:handler`. #2695 restricted position-qualified
identity to Function|Method|Constructor because the collision that produced
wrong CALLS edges was between callables, and widening churned ids for symbols
the pruner mostly deletes. The churn is real and is accepted here deliberately.

Widening needed THREE gates aligned, not one:
  - id-building     — `parse-worker.ts` nestedCallablePrefix
  - resolution      — `ids.ts` position key
  - registration    — `node-lookup.ts` position-key registration

Missing the third would register no position key for values, so every lookup
misses and falls through silently. That is the #2714 failure mode: the caller
attaches to a node that does not exist and the edge is DROPPED, which looks like
"zero dangling edges" from outside. All three now route through ONE predicate,
`isPositionQualifiedLocalLabel`, rather than repeating the label set a third
time.

Only LOCALS move. The prefix comes from `enclosingCallablePrefix`, which returns
undefined when nothing encloses the declaration, so top-level and class-member
ids are untouched — verified by the full resolver sweep, where a leak onto class
members would have broken assertions in every language. `Property` is included
on purpose: a class field stays unqualified because the prefix walk boundaries
on class-likes, while an object-literal property inside a function is genuinely
local and would otherwise keep the old collision.

Measured: `Const:v.ts:handler` + `Const:v.ts:run.handler@3:2`, two distinct
nodes. The KNOWN LIMIT test is FLIPPED per its own former instruction ("this
test should be updated as part of it rather than deleted").

## Schema bumps — required by Part B, not just by A1

INCREMENTAL_SCHEMA_VERSION 20 -> 21, parse-cache SCHEMA_BUMP 27 -> 29.

SCHEMA_BUMP is 29, not 28, and that is the point of re-checking it against
origin/main at MERGE time rather than branch time. This branch cut at 27 and
bumped to 28; #2415 also bumped 27 -> 28 and merged first. The automated
main-merge onto this branch surfaced the collision — leaving it at 28 would have
shipped this whole change with NO parse-cache invalidation, so every warm cache
keeps replaying the pre-fix captures and ids. This is the third instance of that
collision recorded in parse-cache.ts (#2632/#2653 hit it at v21, and
#2653/#2654 hit INCREMENTAL_SCHEMA_VERSION the same way).

Part B already changed emitted node ids AND edges on files that did not
themselves change (Dart locals re-keyed, Rust gained a node it never emitted,
five languages gained closure-source attribution). A v20 index topped up
incrementally keeps serving the old attribution, and a warm parse cache replays
the old captures and ids verbatim. Shipping S1-S4 without these would have let
every existing index silently keep the pre-fix graph.

## Twin-list drift guard — the sixth instance in this family

`IMPLICIT_RECEIVERS` (gitnexus-shared lookup-core.ts) and `THIS_RECEIVERS`
(type-env.ts) spell the same concept in two packages, and nothing enforced
agreement — `$this` was added to the shared list in #2714 only because it was
already in the other. New structural test asserts set equality plus the ONE
deliberate asymmetry (`Me`, Visual Basic spelling, absent from the shared list
because no SupportedLanguages entry uses it) in BOTH directions, so re-adding it
there or dropping it here each fail loudly.

Structural rather than value-imported: both constants are module-private, and
exporting them purely to be testable would widen two public surfaces to satisfy
a test.

## Two false comments corrected

  - `lookup-core.ts` said "see the drift guard noted in #2714", implying a guard
    existed when it was only a deferred follow-up. It exists now, and the
    comment points at it.
  - `callable-id-lockstep.test.ts` claimed its regex "fails if any site
    reconstructs the id". It matches ONE template spelling; a hand-rolled
    concatenation still slips past. Now stated as a tripwire for the known
    shape, not a proof.

## Skill learnings

Four entries appended to eval/workflow_bench/learnings.jsonl from this run: the
v9fs safe-writer failure, backticks silently terminating a query template
literal (hit three times), a module-level TDZ const that passes tsc and then
presents as N file failures with ZERO failing assertions, and concurrent vitest
runs starving worker startup so a whole suite fails at ~5001ms.

## Verification

Full resolver sweep 2926 passed / 1 skipped / 0 failed (51 files) — identical to
pre-A1, which is the evidence that only locals moved. All EIGHT bench gates PASS
with fingerprints UNCHANGED, so no regeneration was needed. function-local-identity,
callable-id-lockstep, receiver-twin-list-drift and closure-binding-labels 71/71.
tsc --noEmit clean.

detect_changes {staged}: 9 changed symbols, 14 affected processes, risk HIGH —
expected, and the reason the sweep above is the gate rather than a targeted list.
Every affected process routes through `resolveDefGraphId`, the key chain Part A
measured at CRITICAL with 23 direct dependents.

Deliberately NOT done: the SCIP end state (opaque `local <id>` plus an explicit
enclosure EDGE instead of containment encoded in the id string). It is a design
direction, not a limitation of this work, and it is INCOMPATIBLE with A1 — A1
widens chain-encoded identity, that removes chain encoding entirely. Bundling
both would re-key every local twice. Written up in the research notes.

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

* test: update two assertions the #2699 changes correctly invalidated

Both failed on CI at da3d8397 and are fixed here. Neither is a behaviour
regression; both pinned values that this PR deliberately changed.

1. this-boundary.test.ts — "a Kotlin lambda still sees the receiver"

The `this.m()` edge still exists and `this` still resolves to the enclosing
receiver, which is the ONLY property this test exists to guard (its own comment
said so: "what matters here is only that the `this.m()` edge still exists at
all"). Only the SOURCE moved, from `run` to the lambda:

  Method:K.kt:K.run#0       -> Method:K.kt:K.run.f@2:16
  Method:K.kt:K.run.f@2:16  -> Method:K.kt:K.m#0

The comment justifying the old expectation is now false and is corrected rather
than left: it said the lambda "is not its own caller anchor" because Kotlin
scopes `lambda_literal` as a BLOCK. Kotlin still scopes it as a block (#1757 is
unchanged) — what changed in S2 is that a Block-kind scope is accepted as a
caller anchor when the scope IS the callable's body.

2. call-summary-schema-version.test.ts — INCREMENTAL_SCHEMA_VERSION pin

Moves 20 -> 21 with the bump, which is the point of pinning it: a change that
alters emitted ids or edges without bumping would otherwise ship silently.

Also adds the missing reuse-gate case. `passesReuseGate(20)` now asserts FALSE —
a v20 index predates closure bindings becoming call SOURCES, the Rust node for
`let f = || …`, the Dart closure scope + enclosing-callable identity, and
position-qualified function-local values. Topping such an index up incrementally
keeps serving the old attribution, including the Dart case where two same-named
closures collapsed onto one node and asserted a CALLS edge present nowhere in
the source.

Why CI found these and local verification did not: the verification set was
`test/integration/resolvers/` plus a hand-picked list, and both failures sat
outside it — one integration test about `this` (which a caller-attribution
change obviously touches) and one unit test pinning the exact constant that was
bumped. Grepping for the changed constant, and for tests asserting closure
attribution, would have found both. All 8 suites that reference the schema
constants were then run: 177/177, no third pin.

Verification: this-boundary + call-summary-schema-version 17/17;
the 8 schema-referencing suites 177/177. detect_changes {staged}: 0 changed
symbols, 0 affected processes, risk LOW (assertion-only edits).

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

* fix(scope-resolution): resolve every finding from the multi-engine review of #2699 part B

The first cut of Part B shipped four P1 defects. A two-engine review (Claude
swarm + ce personas; Codex gpt-5.6-sol swarm + ce + adversarial) found all four,
three of them because an independent engine disagreed with the authoring one.
Each is fixed here and pinned in test/integration/closure-review-findings.test.ts.

## P1-1 — a multi-line closure binding fabricated a CALLS edge

The worst of the four, because it reintroduced the exact defect class #2699
exists to remove. The two query channels anchor on DIFFERENT nodes by design
(graph-node on the outer wrapper, scope-resolution on the inner closure) and the
bridge joins them on line only. Same line, the join matches. Split across lines:

    $multi =
        function ($x) { return target($x); };

the join missed, `resolveDefGraphId` failed closed, and `resolveCallerGraphId`
then CLIMBED to the parent scope — emitting `outer -> target` although `outer`
calls nothing, while the real `outer.$multi` node sat with zero outgoing edges.

`resolveCallerGraphId` now fails closed at the owning callable instead of
climbing. If we have identified the callable that owns a call site and cannot
name its graph node, crediting an ancestor is not graceful degradation — it
invents a relationship. A missing edge is the correct failure direction for a
graph whose consumers include `impact`.

Getting there took two attempts, worth recording: the first guard keyed on the
def's qualifiedName carrying the `@line:col` local marker, but that suffix is
added by parse-worker for GRAPH NODE ids and scope-resolution defs do not have
it, so the guard never fired. `pickCallerCallableDef` now reports whether the
callable came from a child scope, and the fail-closed applies to the owning
callable either way.

## P1-2 — TS constructor parameter properties were re-keyed as locals

A REGRESSION against the base, not merely an incomplete fix. Admitting
`Property` to the position-qualified set made the enclosing-callable walk reach
the constructor's `method_definition` THROUGH the parameter list — a
LOCAL_SCOPE_BODY hit that lands before any class boundary — so
`constructor(private readonly port: Port)` produced
`Property:svc.ts:Service.constructor.port@2:14` instead of `Service.port`. That
silently empties the slot `impact`, `rename` and FTS address while the class
still asserts HAS_PROPERTY against it, and it is the Angular/NestJS DI idiom.
A real instance exists in this repo at src/core/group/service.ts:304.

parse-worker.ts already computed the correct exemption (`isFunctionLocalProperty`,
lines 2245-2257) two lines above; the new ternary discarded it. Now reused, so
the owner-edge decision and the id decision cannot disagree.

## P1-3 — Dart top-level and `final` closures were never call sources

The rule matched only `initialized_variable_definition`, Dart's FUNCTION-LOCAL
shape. A top-level `var` is `initialized_identifier` and a top-level
`final`/`const` is `static_final_declaration`; the second declarator of
`var f = ..., g = ...` is also `initialized_identifier`. None got a declaration
capture, so `findFunctionBody` never synthesized their scope.
dart/captures.ts ALREADY listed all three in bindingNodeTypes for callable-flow
— the declaration rule simply did not mirror it. It does now.

## P1-4 — Ruby `do ... end` and `Proc.new` closures were uncovered

`do ... end` is the dominant MULTI-LINE Ruby style and produces `(do_block)`;
all three patterns matched `(block)` only. The scope channel already covered
both, so these closures got a Block scope owning nothing and their calls fell
through to the enclosing method. The PR's own Ruby test used the brace form, so
it passed.

Fixing it needed BOTH channels — tree-sitter-queries.ts had no graph-node rule
for the `(call)` forms either, exactly as Rust did. Verified: brace, do/end and
Proc.new are now all sources.

## Also from the review

- The split-signature fallback could fire on VALID TypeScript: a
  `declare namespace` containing a bodyless overload made the next declaration's
  `export_statement` a sibling of a `function_signature`, so `send` became
  `internalHelper.send@2:9`. The fallback now requires the matched node to be
  the signature's BODY (a body holds statements; a declaration wrapper holds
  another signature), which separates the two without naming a grammar.
- `isCallableDef` re-spelled `Function | Method | Constructor` in the same file
  that imports `isOverloadableCallable` and calls it three times — a NEW twin
  list, in the PR whose headline is a twin-list drift guard. It now delegates.
- A partial edit had left a self-contradictory comment in parse-worker.ts
  ("Restricted to CALLABLE labels: the / Applies to VALUES as well as callables").
- eval/workflow_bench/learnings.jsonl carried a "skill": "gitnexus-plan" entry,
  but that skill's SKILL.md:347 states feedback is chat-only and forbids
  appending learnings during a planning task. Dropped; the three gitnexus-work
  entries are sanctioned and stay.
- Ruby's lambda/proc patterns tested the method NAME only, so `MyMod.lambda { }`
  was captured as a closure binding. `!receiver` now constrains them.
- Rust's closure work (S3) had ZERO test coverage anywhere — verified once by a
  throwaway fixture and never pinned. Now covered.

## Verification

Full resolver sweep plus the identity/closure suites: 3017 passed / 1 skipped /
0 failed across 58 files (up from 2997 — the new tests). This is the gate that
mattered for P1-1: failing closed instead of climbing could have silently
deleted real edges in any language, and ~2900 resolver assertions say it did
not. All EIGHT bench fingerprint gates PASS with fingerprints UNCHANGED.
tsc --noEmit clean.

detect_changes {staged}: 14 changed symbols, 18 affected processes, risk
CRITICAL — expected, since P1-1 changes the fallthrough of `resolveCallerGraphId`,
the key chain Part A measured at CRITICAL with 23 direct dependents.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:25:19 +01:00
Gergő Magyar
e307286d52
fix(scope-resolution): a named receiver's member never resolves lexically, + two #2695 follow-ups (#2714)
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): a named receiver's member never resolves lexically (#2699)

`lookupCore` Step 1 walked the lexical scope chain for every lookup, including
explicit-receiver property reads. So `options.baseUrl` could bind to an
unrelated function-local `const baseUrl` in the same file, and
`config.extractVisibility(node)` to the enclosing class's own method.

This is the residual half of the defect JS/TS block scopes narrowed in #2695.
Blocks moved nested-block locals off the chain of a reference outside the
block, which removed 114 false edges; a local declared directly in the function
body stayed on it, and no amount of extra scopes reaches that case. Fixed at
the cause instead: `recv.name` names a member of whatever `recv` denotes, so a
binding of the bare tail name in an enclosing scope is never the right answer.
Steps 2 and 3 (receiver type / owner members) are the legitimate routes.

`this` and `self` are EXEMPT, and that exemption was measured, not assumed.
Skipping Step 1 for every explicit receiver removed 711 edges on a 762-file
corpus — but 2 of those were genuine: `self.srcIx` and `self.streamedAt(...)`
after `const self = this`, reaching their own class's members through the
class-body scope. For a self-receiver the members and the lexical chain
legitimately overlap; for a named receiver they never do. Exempting the self
names keeps both true edges and still removes 709 false ones, adding none.

The removals were classified by reading source at the site, not by pattern-
matching ids — an "is the target a member of the source's owner?" heuristic
labelled 43 of them plausible and every one I then read was false:

    language = config.language;          -> the class's own `language`
    dirMap.get(...) / exactMap.get(...)  -> a sibling object-literal `get`
    return config.extractVisibility(n);  -> the class's own method (self-edge)
    writer.close();                      -> GraphEmitSink.close

Residual, deliberately kept: a `this.x` read can still bind lexically to a
same-named local. That is the price of the two true self-alias edges above.

`INCREMENTAL_SCHEMA_VERSION` 19 -> 20: a v19 index holds these false
CALLS/ACCESSES on every unchanged file and would keep serving them through the
reuse gate.

Test confirmed discriminating: it fails with the guard reverted.

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

* fix(typescript,javascript): a generator expression binding is a Function node (#2693)

`const g = function* () {}` matched none of the closure-binding definition
rules — they covered `arrow_function` and `function_expression` only — so the
binding emitted a `Const` node. `buildGraphTargetIndex` admits callable nodes
only, so `g()` resolved to nothing.

Same defect shape as the `var` case #2693 already fixed: a different grammar
node for the same construct, and the resulting graph node was not callable.

Adds the four variable-binding shapes in both languages: `const`/`let` and
`var`, each plain and exported. Purely additive — no existing pattern is
reordered or rewritten, because the #2687 pre-scan dedup is order-dependent
and collapsing the value/callable pair depends on which match wins.

Deliberately NOT covered, and the query comment says so: a generator in an
object-literal pair or a HOC wrapper still falls through anonymous. Those are
rarer, and each additional pattern is another chance to disturb the dedup.

`SCHEMA_BUMP` 26 -> 27: definition captures are parse-time, so a warm parse
cache would replay the old ones verbatim — `--force` does not clear it.

Two tests confirmed discriminating (they fail with the patterns reverted), plus
a guard that the already-working generator DECLARATION form is unaffected,
since it shares the emit path these were inserted beside.

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

* fix(ingestion): keep caller attribution in lockstep with definition ids (#2699)

The definition phase appends `localIdentity` to a nested callable's own name
segment (`run.save@3:2`); `findEnclosingFunctionId` did not, so the two phases
derived different ids for the same callable. The failure mode is silent — the
caller id names a node that does not exist, so the edge is dropped rather than
reported — which is why the parse-worker docblock calls this pair a lockstep
guarantee and asks that both phases derive the prefix from one place.

The condition is now byte-identical to the definition phase's
(`nestedPrefix !== undefined`), so the two cannot diverge again.

Scope of the claim, stated plainly: no reproducing case was found, and this
changes nothing measurable on a 762-file TypeScript corpus. TS/JS resolve
callers through `resolveCallerGraphId` in the graph bridge, not this path;
`findEnclosingFunctionId` serves the `callExtractor` languages, and the
corpus does not exercise a nested callable there. The review that raised it
(P3) observed zero dangling edges, and "zero dangling" is also what silently
dropped edges look like — so this closes a documented contract rather than a
demonstrated bug, and carries no test of its own.

Rides the `SCHEMA_BUMP` 26 -> 27 in the preceding commit: caller attribution
runs in the worker, so a warm parse cache would replay the old ids.

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

* docs(test): correct the block-scope header that this PR made false (#2699)

Review finding (MEDIUM). The file header still described `lookupCore` Step 1 as
walking the lexical chain for EVERY lookup, and called the function-body-local
case "unchanged and still mis-resolves ... pre-existing and tracked
separately". Commit 59b892ca in this same PR falsified both, and the describe
block added ~80 lines lower in this same file asserts the opposite — a reader
scoping future work from the header would have concluded the case was still
open.

Rewritten to state what the code does: Step 1 is skipped for a NAMED explicit
receiver, the function-body case is fixed here, and the surviving residual is
that a `this`/`self` read can still bind lexically to a same-named local —
with the reason those two names are exempt (they keep the genuine
`const self = this; self.member` reads that Step 1 resolves correctly).

Also corrects a PRE-EXISTING staleness inherited from #2695 in the same
paragraph block: "the genuine bare read of that same local must still emit its
edge" describes a test that no longer exists, because TypeScript emits no
`@reference.read` for bare identifiers at all. Fixed here rather than left
adjacent to a freshly corrected sentence.

Comments only — `detect_changes` reports 0 changed symbols across 1 file.

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

* refactor(ingestion): give the nested-callable id rule one definition (#2699)

Review finding (LOW): the lockstep change in this PR shipped without a test.
The plan called for a unit test asserting the two id-derivation phases agree.
Two things changed that plan during execution, both recorded here.

FIRST — there are THREE phases, not two. Re-verifying the plan's assumption
(`grep -n localIdentity`) found a third call site: the worker-path node-id
derivation in `processFileGroup` (parse-worker.ts:2316), whose own comment
already acknowledged the coupling. `impact` on `localIdentity` corroborates:
three direct dependents, all in the Workers module. So the invariant three
phases must agree on is now ONE function, `nestedCallableQualifiedName`, and
divergence requires deleting a call rather than editing a duplicated
expression.

SECOND — the planned `_forTest` alias seam does not work for this module.
`parse-worker.ts` posts a `ready` message to `parentPort` at module scope, so
value-importing it from a unit test throws before any test runs; the existing
unit tests that reference it use `import type` only, which erases. The rules
therefore move to a new pure module, `workers/callable-id.ts`. That is what
makes them testable at all, rather than merely commented.

Pure refactor — no id changes. Verified by the suites that assert exact node
ids (`Function:svc.ts:run.save@7:2`, `Function:c.php:run.$save@3:2`): 74/74
green, and `detect_changes` reports only the three expected symbols and the
two `processFileGroup` flows `impact` predicted.

The test pins both halves: the rule's contract, and a structural assertion
that no site has re-inlined `${prefix}.${localIdentity(...)}` — the unit
assertions alone would still pass if a fourth phase spelled the rule out by
hand, which is exactly how the divergence arose.

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

* fix(scope-resolution): give PHP's `$this` the same self-receiver exemption (#2699)

Review finding (LOW). The Step-1 skip added in this PR exempts `this`/`self`,
but the receiver name arrives as the reference node's RAW SOURCE TEXT —
`extractExplicitReceiver` returns `cap.text` verbatim — so PHP's `$this->x`
presents as the string "$this" and matched neither entry. PHP was the one
supported language whose self-receiver got no exemption at all.

Measured, and the measurement is why this is framed as consistency rather
than a bug fix:

  - Corpus delta ZERO. 762-file TypeScript corpus, CALLS+ACCESSES set diff:
    13179 -> 13179, added 0, removed 0. So no INCREMENTAL_SCHEMA_VERSION bump
    (stays 20), per the plan's decision rule.
  - No PHP shape found that DISCRIMINATES. Both the simple `$this->prop` /
    `$this->helper()` shapes and a closure reading `$this->…` inside a method
    that also declares a same-named local produce byte-identical edge sets
    with `$this` present and absent — Step 2 resolves the receiver's type
    first. The added test is therefore labelled a COMPANION INVARIANT, exactly
    as the `this.baseUrl` case beside it is, and does not claim to prove the
    fix.

It is still worth making: the exemption is protective, and the 709-removed /
0-true-lost measurement that justified the narrow guard was TypeScript-only,
so PHP's safety was never established by evidence. This closes that by
construction.

Two corrections to what the plan assumed, both found by checking:

  - The plan (and my first draft of this comment) claimed the codebase had no
    precedent for handling a sigil'd receiver name. FALSE: `THIS_RECEIVERS` in
    `core/ingestion/type-env.ts:244` has always listed `$this`, and it is the
    ingestion-side twin of this very list. The precedent does not merely
    exist, it validates the approach chosen here — list the spelling as data,
    do not strip sigils.
  - That twin also lists `Me`. Deliberately NOT mirrored: no entry in
    `SupportedLanguages` is Visual Basic, so it could only ever exempt a
    variable that happens to be called `Me`.

The two lists are otherwise the same set with nothing enforcing it — a fifth
instance of the twin-list drift class this PR keeps meeting. A drift guard is
the right fix and is out of scope here; noted for follow-up.

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

* fix(rust): resolve `Self` in scope-resolution type bindings (#2699)

CI regression, caught by `tests / ubuntu / coverage` on 13d5e738 and traced to
the named-receiver Step-1 skip earlier in this PR (59b892ca), not to the three
commits above it — verified by reverting those three and reproducing the
failure unchanged.

`test/integration/resolvers/rust.test.ts > resolves fresh.validate() inside
impl User via Self {} inference` failed: 192/192 on main, 191/192 on this
branch. The fixture calls `fresh.validate()` where `let fresh = Self { .. }`
inside `impl User` — a genuine call to `User::validate`, and a TRUE edge that
the skip deleted.

Root cause is a twin-channel disagreement, not the skip:

  - `type-extractors/rust.ts:142` substitutes `Self` -> the enclosing impl
    type into the TYPE-ENV channel via `findEnclosingImplType`.
  - `languages/rust/interpret.ts` recorded `@type-binding.type` verbatim, so
    the SCOPE-RESOLUTION channel bound `fresh: Self` — a type that does not
    exist, leaving the receiver's type unknown and Step 2 unable to resolve.

`main` passed only because Step 1 still walked the lexical chain for named
receivers: the impl scope binds `validate` by name, so the call resolved BY
ACCIDENT. Stopping that walk turned a latent gap into a lost edge. The fix
closes the gap rather than restoring the accident — `Self` is now substituted
at capture-emit time in `languages/rust/captures.ts`, where the impl node is
reachable, reusing the `findEnclosingImpl` + `syntheticCapture` idiom already
in that file.

CORRECTION to this PR's central claim. "709 removed / 0 added / 0 true edges
lost" was measured on a 762-file TYPESCRIPT corpus and stated without that
qualifier. Rust lost one true edge. The measurement stands for TypeScript; it
did not generalise, and the PR body is being updated to say so.

Scope of the breakage, measured rather than assumed: 1 failure in 2927 tests
across all 51 resolver files. Every other language — Go, Java, C#, Kotlin,
Swift, Python, PHP, Ruby, Dart, C++ — passes, which is why this is a targeted
fix and not a revert of the skip.

Re-baselined `bench/scope-capture` for RUST ONLY (655aed01 -> 7f1240b3); the
other 14 language fingerprints are byte-identical. The drift is the intended
output change and the reason is recorded in the baseline entry, per that
file's own "explain, never re-baseline to make CI green" rule.

Verified: rust resolvers 192/192; all 51 resolver files 2926 passed / 1
skipped / 0 failed; the 8 targeted suites 96/96; all 8 CI bench gates PASS;
`tsc --noEmit` clean; `detect_changes` reports one touched symbol
(`emitRustScopeCaptures`) and no affected flows.

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

* test(golden): refresh the Rust capture golden and the C# PDG snapshot (#2699)

The two committed artifacts CI flagged after 5f55fe46. They drifted for
OPPOSITE reasons, so each was inspected before regenerating rather than
refreshed on sight.

RUST GOLDEN — drifted because 5f55fe46 CORRECTS the output. A `Self` type
binding now records the enclosing impl's type instead of the literal `Self`,
in both the `let x = Self { .. }` and `fn new() -> Self` forms. Blast radius
verified exact: 5 fixtures drifted, all 5 contain `Self`, and every
`Self`-bearing rust fixture is among them (rust-self-struct-literal,
rust-constructor-type-inference, rust-default-constructor,
rust-method-enrichment, rust-scoped-multi-file).

C# PDG SNAPSHOT — drifted because the named-receiver Step-1 skip (59b892ca)
REMOVED A FALSE EDGE. CALLS 7 -> 6, and the edge that went is:

    Demo.Resolve.Parse@142:12#1 -> Demo.Resolve.Parse@142:12#1

a self-call, from `int Parse(string v) => int.Parse(v);`. `int.Parse(v)` is
System.Int32.Parse; the lexical chain was binding it to the enclosing local
function that happens to also be called `Parse`. Same defect class as
`writer.close()` -> GraphEmitSink.close. The snapshot's own comment says it
exists so "a future refactor that silently rewires the C-family graph trips
this gate" — it tripped correctly, and the rewiring is an improvement.

Both failures were PRE-EXISTING on this PR from 59b892ca, not from the three
commits above it — verified by reverting those and reproducing unchanged. They
went unseen because this PR's CI was never watched after its first push.

Verified after regeneration, WITHOUT update flags so they must genuinely pass:
rust-captures-golden 9/9; pipeline-pdg 31/31. The snapshot diff is 3 lines,
all inside the C# entry — no other language's snapshot moved. `detect_changes`
reports 0 changed symbols (test artifacts only).

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 17:56:38 +01:00
Gergő Magyar
4906daf27b
fix(scope-resolution): resolve calls through a closure-valued binding across languages (#2693) (#2695)
* fix(scope-resolution): resolve calls through a closure-valued binding (#2693)

`val f = { }; f()` emitted no CALLS edge in Kotlin or Swift, so `impact` on
such a symbol under-reported to zero — the same false all-clear as #2687.

The cause was not, as first suspected, that these languages fail to feed
`callable-value-flow`. They do: `synthesizeCallableFlowCaptures` is called
from 15 language capture modules, and Kotlin already resolves reassignment
through the pass (`var f = ::a; if (c) f = ::b; f(1)` reaches both targets).
Their captures are already exactly right — the seed names the binding as its
own callable, per the anonymous-callable convention in
callable-flow-captures.ts.

They died one layer later, at the `buildGraphTargetIndex` gate:

    if (!isCallable(def) && providerTarget?.(def) !== true) continue;

`isCallable` is Function/Method/Constructor, but the scope-resolution layer
declares a closure binding with its VALUE label (Kotlin/Swift `Property`),
and `isCallableValueTarget` is implemented by exactly one provider — COBOL.
So the binding never entered `graphTargets`; `lexicalCallableLookup` then
returned `shadowed: true` with no targets, which also suppressed the
workspace-wide fallback, and the seed resolved to nothing.

Only the graph knows a value binding holds a callable — since #2687 it emits
a single `Function` node for one. So value bindings now resolve their graph
id first and are admitted on the label of the node they actually reach.

This is self-limiting: a genuine constant keeps its own Const/Property node,
so `resolveDefGraphId`'s qualified key hits before the label-agnostic
`simpleKey` fallback can reach a same-named callable. Only a binding whose
own value node was replaced by a callable one gets through.

No scope kind changes — Kotlin's `lambda_literal` stays `@scope.block`, so
#1757 smart-cast semantics are untouched by construction. The fix is
language-neutral: it discriminates on the graph node label, never on a
language name.

Dart is fixed separately; its root cause is independent.

* fix(dart): resolve calls through a closure-valued binding (#2693)

Dart needed more than the shared gate fix: neither of its closure-binding
forms could resolve, for two different reasons, and the plan's one-line
diagnosis turned out to be incomplete.

TOP-LEVEL `var f = (x) => x;`
  A graph Function node already existed (#2687), but no `@declaration.*`
  matched the binding, so scope resolution had no SymbolDefinition to attach
  a flow seed to. Adding the declaration exposed a second problem: Dart's
  `initialized_identifier` is FIELDLESS, so the shared field-based assignment
  fallback (`left`/`name`/`value`/…) decomposed nothing and the binding still
  emitted no flow captures at all. Kotlin's fieldless `assignment` node hit
  exactly this and took the same remedy — a provider `extractAssignment`.

FUNCTION-LOCAL `void m() { var f = (x) => x; }`
  Locals parse as `initialized_variable_definition`, which the top-level
  graph-node rules are deliberately anchored under (program) to avoid, so a
  local closure had no graph node at all — nothing for the widened
  `buildGraphTargetIndex` gate to admit.

Both new rules are restricted to a `function_expression` value. Declaring
every Dart variable would mint defs and nodes repo-wide for no resolution
benefit; ordinary locals stay unindexed exactly as before. The top-level
declaration reuses the (program) anchor the graph-node query already relies
on, so class-body fields — which share `initialized_identifier_list` and are
already `@declaration.property` — are never matched twice.

Also drops the now-false note in tree-sitter-queries.ts claiming `f()` does
not resolve for Dart. That node is now the evidence that makes it resolve.

* docs(scope-resolution): document the callable-flow capture contract (#2693)

The module is 1200+ lines behind a nine-line docblock, and the only worked
example was C. Both root causes fixed in this series were "the contract was
discoverable only by reading the emitter":

  - the anonymous-callable convention (a seed whose source is a closure takes
    its DESTINATION's name) is what makes closure bindings resolvable at all,
    and is the reason the widened target gate is correct;
  - a fieldless binding node silently decomposes to nothing under the shared
    assignment fallback, which cost Kotlin one debugging cycle in #2522 and
    Dart another here;
  - captures alone are never enough — the bound name also needs a
    `@declaration.*` or there is no cell to key the seed on.

Records the cell/site model, both traps, and points at the fullest and
smallest worked examples.

Bumps INCREMENTAL_SCHEMA_VERSION 15 → 16 and the parse-cache SCHEMA_BUMP
22 → 23: this series emits NEW CALLS edges and new Dart Function nodes, and
the incremental write set only covers changed files, so an existing index
would keep reporting a zero blast radius for exactly the symbols the fix is
about.

* perf(scope-resolution): pre-filter value bindings in the callable target index (#2693)

Widening the `buildGraphTargetIndex` gate to consider VALUE bindings put the
hot loop on a much larger def population — value bindings outnumber callables
in real source — and the naive version paid full price per binding. Measured
on a synthetic 800-file corpus (8 value bindings per file, 1 of them a closure
binding), the widening cost 2.50-2.82x the pre-#2693 callable-only build.

Two wastes, both provable rather than guessed:

1. `definitionAnchorKey` ran for every def, including value bindings. The
   anchor index is keyed by callable LABEL and the key is built from
   `def.type`, so a value def can never hit it — and the key costs a regex
   per def.

2. Every value binding paid the whole `resolveDefGraphId` key chain only to be
   rejected. It need not: every qualified key that function tries embeds
   `def.type`, so for a VALUE def those can only ever reach a value-labelled
   node. Its one route to a callable is the label-agnostic
   `simpleKey(filePath, simpleName)` fallback, which by construction requires
   a callable node with the SAME file and simple name. So a value binding with
   no such node cannot resolve to a callable, and one Set lookup decides it.

That set is derived in the graph walk the anchor index already performs, so it
costs no extra pass.

  large_ms            7.79-8.37  ->  4.90-5.02   (1.61x faster)
  widening_overhead   2.50-2.82  ->  1.45-1.50

The resolved target-set fingerprint is byte-identical across both, which is
the point: this is a cost change, not a behaviour change.

Adds bench/callable-value-flow/ (fingerprint + scaling + widening-overhead
gates) and wires it into ci-tests.yml beside the other build-free benches. The
overhead budget of 1.9 sits between the measured with-filter and without-filter
bands, so it cannot be met if the pre-filter is removed. Timings use the MIN of
15 warmed reps, not the median: the same build reported 1.65 idle and 2.03
under load, and a median-based gate would have to be loosened past the point of
detecting the regression it exists to catch.

`buildGraphTargetIndex` is exported for the bench; it is pure and not part of
the pass's public contract.

* test(scope-resolution): assert the declaration route does not double-emit (#2693)

Go, Python, C++ and TS/JS already resolved a closure-binding call through
their `@declaration.function` capture. The widened `buildGraphTargetIndex`
gate gives the same call a SECOND possible route, so each must still produce
exactly one edge.

`tryEmitEdge` dedups by key, but a collapsed key and a site-anchored key are
DIFFERENT keys — a real double-emit would show up as two ids for one call
site, not be silently collapsed. Asserting on edge ids rather than target ids
is what makes that visible.

* fix(scope-resolution): join value bindings to their callable node by POSITION (#2693)

Review found the first cut of this series minted FALSE CALLS edges. Admitting a
value binding whose *resolved* graph node is callable let `resolveDefGraphId`
fall through to its label-agnostic, first-write-wins
`simpleKey(filePath, simpleName)` and bind the name to ANY same-named callable
in the file.

The safety argument in the previous commit — "a genuine constant keeps its own
Const/Property node, so the qualified key hits first" — silently assumed
`def.type === node.label`. It does not hold:

  - TypeScript declares `const` as `Variable` but emits a `Const` NODE, so the
    qualified key misses even though the value node exists;
  - Rust `let` bindings get no graph node at all, so the fallback is the only
    route.

Reproduced, all previously emitting a fabricated caller:

  const save = (x: number) => x * 2;   // next to an unrelated Svc.save
      -> Method:svc.ts:Svc.save#1      // Svc never instantiated
  const handler = other;               // shadowing a top-level handler
      -> Function:app.ts:handler       // unreachable from here
  let handler = cb;                    // Rust
      -> Function:main.rs:handler

Worse in Dart, where the same collision INVERTED the feature: the only edge went
to the class method and the closure's own node got none. The result was also
declaration-order dependent — two files differing only in declaration order got
different CALLS sets — and it propagated through argument-to-formal binding into
functions whose source never mentions the name.

A closure binding IS its callable node: same file, same line, same name. An
aliasing local is not. So the join is positional now — a file/line/name index
built in the graph walk `byAnchor` already performs — and value bindings never
run the key chain at all. That is both correct and cheaper:

  large_ms            4.90-5.02  ->  4.37-4.63
  widening_overhead   1.45-1.50  ->  1.43-1.58   (name-match design: 2.50-2.82)

with a byte-identical target-set fingerprint on the bench corpus.

Also from review:

  - `Static` dropped from VALUE_BINDING_DEF_TYPES: `normalizeNodeLabel` has no
    `static` case, so no def can carry that type — it was an entry no fixture
    could ever exercise. The remaining set now documents why it deliberately
    does NOT reuse `isOwnableValueLabel`, which is contracted to a different
    consumer.
  - Dart `final`/`const` top-level closures (static_final_declaration_list) and
    every declarator after the first in a multi-name local now resolve; both
    parse into shapes the earlier rules never reached.
  - The bench source carried a literal NUL byte, so git recorded it as BINARY
    and the only artifact pinning the target set was unreviewable in the PR
    diff. It is written as an escape now. Its corpus also modelled `startLine`
    as 1-based where graph nodes are 0-based, which would have stopped it
    exercising the value-binding path at all.
  - `call-summary-schema-version.test.ts` asserted `passesReuseGate(15)` is
    true; the 15 to 16 bump made that false and the test RED. It now pins 16 as
    current and 15 as rejected, matching the pattern every prior bump followed.
  - The v23 parse-cache comment is at the top of the list, not mid-list.

Tests: the five collision cases above are new regression tests, each confirmed
failing against the previous commit. Also added Kotlin class-body closures (the
only case exercising the Method arm), Dart top-level `final`, Dart multi-name
locals, and a warm-parse-cache replay for Kotlin and Dart — the #2693 captures
are replayed verbatim, so a serialization change would surface only on a SECOND
analyze and every other test here runs cold. The previous negative tests were
vacuous: they paired names that did not collide (`maxSize` vs `size`), so the
pre-filter rejected them before the guard they were named after could run.

* docs(storage): fix the schema-version changelog blocks (#2693)

Two problems, one mine and one not.

MINE: the `INCREMENTAL_SCHEMA_VERSION` block is ASCENDING (v2 … v15), and I
inserted v16 above v15 rather than at the end — I had just moved the parse-cache
entry to the top of ITS block, which is descending, and applied the same habit
to a list ordered the other way. Moved to the end; both blocks are now
internally consistent.

NOT MINE: the parse-cache block carries TWO v21 entries, with v20 wedged between
them. Tracing it: #2632 (Spring DI facts) bumped 20 -> 21 and merged first;
#2653 (Java JLS local-class identities) had branched at 20, also bumped to 21,
and merged second — so it shipped with NO invalidation of its own. An index
already stamped 21 by the first change was treated as current by the second and
kept serving stale local-class identities from the warm cache.

Numbers left alone: both genuinely shipped as 21, and renumbering them now would
misstate what users' indexes actually contain. Instead the entry says so
explicitly, and points at the process fix — re-check the constant against
origin/main immediately before merging, not just when the branch is cut. The
identical collision hit INCREMENTAL_SCHEMA_VERSION in #2653/#2654, so this is a
recurring failure mode of concurrent PRs, not a one-off typo.

Comment-only; no constant changes value.

* feat(scope-resolution): resolve closure bindings in Ruby, Java, C#, PHP and JS/TS var (#2693)

Ruby, Java, C# and PHP already emitted correct callable-flow seeds and invokes.
What they lacked was the #2687 piece — a CALLABLE graph node at the binding,
which is what buildGraphTargetIndex joins to by position. PHP additionally had
no scope declaration for the bound name, so the flow pass had nothing to attach
its seed to.

  ruby    handler = ->(x) { x }        handler.call(1)   -> Function:a.rb:handler
  java    Function<..> handler = x->x  handler.apply(1)  -> Function:A.java:A.handler
  csharp  Func<int,int> handler = ...  handler(1)        -> Function:A.cs:A.handler
  php     $handler = fn($x) => $x      $handler(1)       -> Function:a.php:handler

Ruby and Java invoke through the callable-object protocol; C# and PHP call the
binding directly. Locals work in all four, and a binding whose name collides
with a same-named method resolves to the CLOSURE, not the method.

Two things the sweep caught:

JAVA TWIN. Anchoring the rule on the inner variable_declarator produced BOTH a
Function and a Property node — the exact double-indexing #2687 removed. The
parse-worker dedup keys on (definition node, name), and Java's value rule
anchors on field_declaration, so the keys never matched. Re-anchored on
field_declaration / local_variable_declaration.

JS/TS `var`. `var f = (x) => x` kept a Variable label while const/let got
Function, because `var` is a different grammar node (variable_declaration vs
lexical_declaration) that no closure rule covered. A call through the binding
still resolved via the declaration route, so the CALLS edge pointed at a
NON-callable node. Now consistent across const/let/var.

That last one flipped an existing assertion in const-function-twin.test.ts,
which expected `Variable` for a var-bound function-expression. Its comment
explained why — "var has no matching @definition.function pattern, so nothing
claims the name" — i.e. it documented the gap rather than defending it. The
property it was really protecting (an UNCLAIMED value node survives) now has
its own case with a non-function initializer, and the var-closure case asserts
the collapse to one node, which is also the twin guard for the new rule.

Known limits, both pre-existing and both failing safe:

  - A PHP local closure whose name collides with a top-level function gets no
    edge: both want id Function:<file>:<name>, so the closure never gets its own
    node. This is the file-scoped node-identity convention — TypeScript, Python
    and Dart collapse identically at base.
  - TS/JS class-field arrows stay Property (Kotlin's equivalent emits Method).
    They already resolve; changing the label risks the HAS_PROPERTY ownership
    regression #2687 hit once.

The invalidation constants already bumped in this PR (INCREMENTAL_SCHEMA_VERSION
16, SCHEMA_BUMP 23) cover these additional languages; their notes now say so.

Tests: one case per newly-resolving language plus the PHP anonymous-function
form and the JS var form, in closure-binding-labels.test.ts. The file now spins
a worker pool per test across a dozen languages, so its timeout is raised
file-wide — a case that takes ~7s alone was exceeding the 30s default under
that contention.

* fix(ingestion): class-field closures are callable members in TS/JS (#2693)

A CALLS edge must target a callable node. `class A { handler = (x) => x }` emitted
a Property, so calling it produced `CALLS -> Property:A.ts:A.handler` — an edge
pointing at something the graph says is not callable. Same defect class as the
JS/TS `var` binding fixed in the previous commit, and the last place a closure
binding still carried a value label.

Kotlin already models its class-body closure as Method + HAS_METHOD; TS/JS now
match, so all three agree:

  class-field closure   -> Method   + HAS_METHOD    (CALLS target is callable)
  plain class field     -> Property + HAS_PROPERTY  (unchanged, no CALLS)

Anchored on public_field_definition / field_definition — the same nodes the
property rules use — so the parse-worker dedup collapses the pair rather than
leaving a Method/Property twin, the failure the Java rule hit in the previous
commit.

ON MATCHING THE COMPILERS. This deliberately diverges from tsc and SCIP. The
TypeScript compiler classes `handler = () => {}` as a PropertyDeclaration
("a property declaration independently from what it's assigned to"), and SCIP
gives it a `.` term descriptor, the same suffix as any field — both call it a
property, and Kotlin's compiler likewise treats `val f = { }` as a property with
a function type. The divergence is intentional: GitNexus's Function/Method label
does not mean "tsc SymbolFlags", it means "this node can be the target of a
CALLS edge", which is the convention #2687 set for closure bindings in every
language. Modelling it the compiler's way would mean either dropping call
resolution for these members or emitting a separate node for the lambda and
flowing the property to it — the two-node shape #2687 removed. Recorded here so
the next reader does not "fix" it back.

Tests: TS and JS class-field arrows resolve to their Method node, plus a guard
that a NON-closure class field stays a Property — the closure rule must key on
the initializer, not on the field syntax.

* fix(php): keep the $ sigil on closure-binding nodes so locals stop colliding (#2693)

A PHP local closure whose name matched a file-level function got NO edge at all:

    function save($x) { return $x; }
    function run() {
      $save = fn($x) => $x * 2;
      return $save(1);              // no CALLS edge
    }

Both minted the id Function:<file>:save, so the closure's node was swallowed by
the function's and the positional join found nothing at the binding's line.

The fix is PHP's own semantics rather than a change to node identity across the
graph. PHP holds variables and functions in SEPARATE namespaces — $save and
save() cannot collide in the language — and the sigil is what separates them.
Dropping it was the bug. The node rule now captures the whole variable_name, so
the closure is Function:<file>:$save and the function stays Function:<file>:save.
languages/php/query.ts already keeps the sigil on property declarations for the
same reason, so this makes the two consistent.

The positional join normalises a leading $/@ on both sides, matching what the
scope layer and the callable-flow synthesizer already do, so the binding still
matches its own declaration while its NODE stays distinct.

    local closure + same-named function -> Function:c.php:$save   (the closure)
    calling the real function           -> Function:f.php:save    (unchanged)
    plain $max = 10                     -> no node, no edge       (unchanged)

WHAT THIS DOES NOT FIX. The general problem is wider than PHP: GitNexus node ids
are file-scoped, so a function-local symbol and a file-level one with the same
name collapse in TypeScript, Python and Dart too, and Java/C# only escape by
qualifying on the enclosing CLASS (so two same-named locals in different methods
still collide). SCIP solves it with a separate `local <id>` keyspace that is
document-scoped and never globally addressable. That is issue #2699 — it changes
persisted ids for every function-local symbol and needs its own invalidation, so
it is not bundled here. PHP is fixed on its own merits: the sigil belongs in the
identity regardless of how locals are eventually scoped.

* test(scope-resolution): pin the closure-binding caller-attribution limit (#2693)

Review of this PR found the new callable nodes are call TARGETS but never call
SOURCES: a call made INSIDE a closure binding is attributed to the enclosing
scope, so `impact(handler, direction:"downstream")` reports nothing even though
the closure calls out. Consistent across Kotlin, Dart, Ruby and PHP; TS/JS free
bindings are the exception because their arrow carries a @scope.function whose
range matches.

Not fixed here — pinned, so the boundary is visible instead of surprising, and
so a change in EITHER direction fails a test.

The cause is precise: `pickCallerCallableDef` (graph-bridge/ids.ts) finds the
caller by walking CHILD scopes whose range contains the call site, gated on
`child.kind === 'Function'`. A closure literal is a BLOCK scope in these
languages (Kotlin deliberately, #1757 smart casts), AND the binding's def is
owned by the enclosing scope rather than by the closure's scope — so neither
half of the link exists. Fixing it needs "callable boundary" decoupled from
scope `kind` plus an association between the closure scope and its binding.
That is a change to the caller anchor used by every call in the repo, which is
not something to land at the tail of this PR.

Also adds a unit suite for `buildGraphTargetIndex` itself, covering what the
integration tier cannot isolate: a binding is admitted only on POSITIONAL
evidence, a name-only match is rejected, a non-callable node at that position is
rejected, an ambiguous position claimed by two callables is rejected, and the
PHP dollar sigil normalises across the join while still not matching a
same-named function on another line. That last one closes the review's LOW —
the node/declaration name asymmetry now has an executable contract rather than
resting on a comment.

* docs(test): correct the per-language cause of the attribution limit (#2693)

The comment on the pinned attribution tests claimed "a closure literal is a
BLOCK scope in these languages". That is true for Kotlin (lambda_literal
@scope.block, #1757) and Ruby (do_block/block @scope.block) and FALSE for PHP:
anonymous_function and arrow_function are already @scope.function
(php/query.ts:61-62). Dart is a third case again — it has no scope over a
closure literal at all.

So the four languages fail at three different points, not one:

  Kotlin, Ruby  fail the `child.kind === 'Function'` gate
  PHP           passes that gate; its closure scope owns no callable def,
                because the binding's def belongs to the enclosing scope
  Dart          has no child scope for the walk to consider

Worth correcting carefully rather than tidying: a follow-up plan re-stated this
comment instead of re-deriving it, and inherited the misdiagnosis — it proposed
"relax the kind gate" as required for all four, which is a no-op for PHP and
unreachable for Dart. A review caught it. The comment now states each language's
actual blocker and says why the distinction matters.

Comment-only; the three pinned tests are unchanged and still pass.

* fix(scope-resolution): an ordinary JS/TS `function` binds its own `this` (#2701)

`this.m()` inside a nested `function` resolved to the lexically enclosing
class, so it emitted a CALLS edge that does not exist at runtime — including
the exact `forEach(function () { this.m(); })` shape arrow functions were
introduced to avoid:

    class D {
      m() {}
      build() { const h = function () { this.m(); }; return h; }
    }
    // CALLS: Function:D.ts:D.h -> Method:D.ts:D.m#0      FALSE

ECMA-262 gives an arrow `[[ThisMode]] = lexical`: it has no `this` binding in
its environment record, so the lookup passes through to the enclosing
environment. Every other function form binds `this` at call time. `tsc` draws
the same line by resolving `this` through `getThisContainer` with
`includeArrowFunctions = false`. That one rule is the whole fix.

Languages declare it; shared code never learns a language. The query files —
the one place that already names grammar nodes — tag every non-arrow function
form with `@receiver-owner.this`, which becomes `Scope.ownsReceivers`. A
receiver walk that reaches such a scope without finding the name stops there
instead of borrowing an enclosing scope's binding. Every other language leaves
the field unset and is bit-for-bit unchanged; a Kotlin lambda, which DOES
capture the enclosing `this`, still resolves (pinned as a test).

THREE GATES, ALL LOAD-BEARING. The false edge survived each one alone, which
is why the tests assert on the emitted edge rather than any single walk:

  1. `Scope.ownsReceivers` stops BOTH receiver-type walks — `findReceiver
     TypeBinding` here and its twin `lookupReceiverType` in gitnexus-shared's
     `lookup-core`, which was resolving the receiver independently.
  2. `LanguageTypeConfig.thisBoundaryNodeTypes` stops the type-env AST walk
     that infers a receiver's type during capture.
  3. `isReceiverOwnedButUnbound` makes `receiver-bound-calls` SUPPRESS the
     site. Without it the member still resolved by NAME through `lookupCore`'s
     lexical chain — the class-body scope binds `m` two scopes up — merely at
     lower confidence. An owned-but-unbound receiver is a definitive negative,
     not a miss, so it must not reach a receiver-blind fallback.

Also fixed: `function*(){}` as an expression was not a `@scope.function` at
all, so `this` inside one read as the enclosing method's.

WHAT THIS GIVES UP. The fix REMOVES edges, and some were correct:
`.bind(this)`, `.call(this)` and `forEach(fn, thisArg)` do make `this` the
instance at runtime. Their correctness is fixed at the CALL SITE, which no
scope-level rule can see, so the choice is between losing them and keeping
every detached-callback false positive. All three are pinned as tests
asserting the empty result, so changing the trade later is deliberate.
`this` in a static method also stops resolving to the INSTANCE member — that
edge was wrong in the other direction.

INVALIDATION. Both constants move, and the parse-cache one is not optional:
`ownsReceivers` lives on the cached `Scope`, and a warm cache replays scopes
without it — verified by probe that `--force` alone does NOT re-derive it, so
the fix silently did nothing until SCHEMA_BUMP moved. INCREMENTAL_SCHEMA_
VERSION 16 -> 17 (the incremental write set covers only changed files, so
unchanged TS/JS files would keep their fabricated `this` edges);
SCHEMA_BUMP 23 -> 24.

Verified against a built index, not by reading: all three false edges from the
issue gone, every correct edge kept, same result in JavaScript through its
separate grammar. 64 tests green across the new suite plus the closure-binding
and schema-version suites. The full suite's 36 failures are pre-existing
load-flakes — confirmed by A/B: `skip-git-cli` fails FOUR tests on a clean
HEAD versus three with this change, and `pipeline-pdg-streaming` passes in
isolation either way.

Refs #2701

* fix(ingestion): give function-local callables their own identity (#2699)

Graph node ids were file-scoped, so a local callable and a same-named
file-level one collapsed onto ONE node. That is a wrong answer, not a missing
one — the local call was attributed to the file-level symbol:

    export function save(x) { return x; }
    export function run()   { const save = x => x * 2; return save(1); }
    export function other() { const save = x => x * 3; return save(2); }

    // ONE node Function:a.ts:save, and BOTH run and other pointed at it, so
    // `impact` on the top-level save reported two callers that never call it.

A local's identity is now its enclosing-callable chain plus its own position —
`run.save@2:2`. The chain is for humans reading `impact`; the position is what
makes it correct. Names alone cannot express what ECMAScript actually
specifies, and the gap is the language's, not the grammar's: an environment
record is created per function AND per block, so an anonymous function has no
name to contribute and sibling blocks hold distinct bindings under the same
name. One positional rule settles both, with no conditionals and no
"disambiguate only when it looks ambiguous" heuristic — the ambiguity-flag
class of bug that bit #2514. SCIP reaches the same place with its
document-scoped `local <id>` keyspace.

Top-level functions and class methods are NOT locals and keep their ids
byte-for-byte. That is the bound on the churn: this touches only symbols that
are unreachable from outside their own document anyway.

RESOLUTION JOINS BY POSITION, NOT BY NAME. `resolveDefGraphId` matches a def
to its node on (file, label, line, simple name). A def and its node are the
same construct, so this needs no scope chain at all — which is the point:
re-deriving the chain in the resolver would be a second implementation that
could silently disagree with the first. A genuine tie (two callables on one
line) stores an AMBIGUOUS_POSITION tombstone and falls through to the existing
name keys rather than picking by source order. Without this the node ids were
already correct and calls STILL resolved to the file-level symbol — the fix is
only half a fix without it.

JS/TS GAIN BLOCK SCOPES. They emitted no `@scope.block` at all, so the
resolver could not tell two `const pick` in sibling branches apart. Giving
them distinct ids made that visible as DUPLICATE edges — each call resolving
to BOTH — which is worse than the collapse it replaced. `(statement_block)
@scope.block` supplies the missing environment record. The other half of the
ECMAScript rule was already implemented and waiting: `tsBindingScopeFor`
hoists `var` past blocks to the enclosing Function/Module while `let`/`const`
bind innermost, and its docblock already claimed "the innermost default covers
these" for block scopes that did not exist. All 82 scope-resolution test files
pass with blocks on.

Verified by probe, per case: two locals in different functions, a local inside
an ANONYMOUS function (`outer.fn@1:9.save@2:4`), sibling blocks resolving to
their own binding, `var` still hoisting out of its block, a nested named
`function` vs a file-level one, PHP composing with the `$` sigil from #2693,
and Python. Top-level/method ids unchanged, asserted directly.

Every assertion is on the EDGE, not on node existence. Ids are built twice and
independently — definition phase and caller attribution — and a one-character
disagreement makes the caller attach to a node that does not exist and the
edge vanish, with nothing thrown and no test failing. An edge assertion can
only pass if both phases agree.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 17 -> 18 and SCHEMA_BUMP 24 -> 25:
persisted node ids change for every function-local callable, and the cached
scope tree lacks block scopes. A top-up would leave unchanged files on the old
ids while changed files emit the new ones, splitting each symbol in two.

Bench fingerprint unchanged and both timing budgets pass. The one full-suite
failure (incremental-orchestration) passes in isolation — its log shows stale
init locks and WAL reclaim, i.e. LadybugDB contention under the parallel run.

Refs #2699

* perf(ingestion): emit block scopes only where they bind something (#2699)

Block scopes make `let`/`const` in sibling blocks distinct bindings, which is
what stopped a call in one branch resolving to both. Emitted naively — one
scope per `statement_block` — they also cost ~10% of analyze wall time, because
every scope-chain walk in every function then steps through levels that bind
nothing.

Two emit-side filters keep the semantics and drop the waste:

  1. A block that IS a function body duplicates the enclosing Function scope.
     Nothing can be declared between a function and its own body, so a binding
     in either resolves identically — the inner scope is pure depth.
  2. A block that declares no `let`/`const`/`class`/`function` binds nothing,
     so it is transparent: a lookup finds nothing in it and walks to the
     parent. `var` is deliberately excluded from that list — it hoists past the
     block to the function, so a block containing only `var` still binds
     nothing.

MEASURED, on a 762-file / 228k-line TypeScript corpus (gitnexus/src), min of 6
warmed reps with the cold first rep discarded:

    block scopes emitted   19,389  ->  5,331     (-72%)
    total scopes           35,942  ->  21,884    (-39%)
    analyze wall time      +9.8%   ->  +1.6-2.5% vs pre-#2699
    peak RSS (whole tree)  2398MB  ->  2434MB    (+1.5%, inside run-to-run noise)

The filters themselves are free: scope emission over the same corpus measured
12.6s naive vs 12.5s filtered.

Wall-clock on a shared runner has a ±10% spread run to run, which is wider than
the effect being optimised, so the durable gate added here counts scopes
instead. `bench/scope-emission/measure.mjs --check` asserts an EXACT scope set
over a synthetic corpus that mixes the shapes the filters discriminate between
— function/method/arrow bodies, non-declaring if/else/for/while/try, blocks
that declare `const`, and a `var`-only block. Baseline is 2 block scopes per
module: only the two `if`/`else` branches that declare `const chosen`. If the
filters regress that number jumps immediately, in a way wall-clock CI could
never resolve from noise. Wired into the existing benchmarks job.

Behaviour is unchanged: 86 scope-resolution and identity test files, 1371
tests, all green — including the sibling-block case this could plausibly have
broken — and the callable-value-flow fingerprint is untouched.

Refs #2699

* test(bench): re-baseline the TS/JS scope-capture fingerprints for #2701

`bench/scope-capture` fingerprints the full capture set per language, and
#2701 added a `@receiver-owner.this` marker to every non-arrow function form
so a scope that BINDS its own `this` can terminate the receiver walk. That is
a capture-set change, so the TypeScript and JavaScript fingerprints moved and
the benchmarks job has been failing since that commit — I pushed it without
checking CI.

A fingerprint is a correctness gate, so this does not simply adopt the new
value. Verified first by diffing the capture-name HISTOGRAM over the same
fixture corpus against 1d308817 (the commit before #2701), which says what a
fingerprint cannot: WHICH names moved.

    typescript   @receiver-owner.this   0 -> 143
    javascript   @receiver-owner.this   0 -> 32

Nothing else. Every other capture count is byte-identical, so no existing
capture shifted and the drift is entirely the intended marker. Both languages'
scaling ratios stay well inside their 1.5 budgets (0.976 / 1.025).

Note `@scope.block` does not appear in the delta: the #2699 filters suppress a
block that is a function body or that declares no binding, and no fixture in
this corpus has a block that binds. Block-scope emission is guarded separately
by `bench/scope-emission`, whose synthetic corpus exercises exactly those
shapes.

Refs #2701

* fix(ingestion): stop the callable-prefix walk at class bodies, not only declarations (#2699)

An anonymous class owns its members, but `CLASS_CONTAINER_TYPES` lists only class
DECLARATION nodes — and a Java anonymous class has none. It is

    object_creation_expression > class_body > method_declaration

so `enclosingCallablePrefix` sailed straight through the anonymous body, reached the
enclosing method, and re-keyed the member as a function-local of that method:

    Method:src/Worker.java:Worker$1.run#0
    -> Method:src/Worker.java:Worker.makeHandler.run@7:12#0

That destroys the javac-compatible JLS identity #2550/#2555/#2562 exist to provide, and
broke four existing Java tests that this PR never touched — anonymous-class instance
identity, local-type identity, and enum-constant-body chaining.

The design was right; the boundary was blind. `CALLABLE_PREFIX_BOUNDARY_TYPES` adds the
body and anonymous-construction forms (`class_body`, `interface_body`,
`annotation_type_body`, `enum_body`, `enum_body_declarations`, `enum_constant`,
`object_creation_expression`, `object_literal`,
`anonymous_object_creation_expression`). Over-inclusion is the SAFE direction here: an
extra boundary only suppresses the nesting prefix, falling back to the pre-#2699 class
qualification.

This also falsifies the claim in the #2699 commit that "top-level functions and class
methods keep their ids byte-for-byte" — an anonymous-class method IS a class method, and
its id did change. The claim was true only for the shapes that were tested.

Also removes the dead `NO_QUALIFIED_NAME` constant, which contained a literal NUL byte.
That byte made `file(1)` report the source as `data` and made plain `grep` return zero
matches for ANY pattern in the whole 2,928-line file — which is why several greps during
development came back mysteriously empty. Two other files carry NULs; they are
pre-existing and out of scope here.

INVALIDATION. INCREMENTAL_SCHEMA_VERSION 18 -> 19 and SCHEMA_BUMP 25 -> 26. This is not
defensive: an index stamped v18 holds the WRONG Java ids, and without the bump it passes
the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate and keeps them on every unchanged file.

Found by the PR #2695 tri-review (review 4782134453) — independently by a Claude
adversarial AST probe, by Codex's swarm, and by CI (`tests / ubuntu / coverage 2/3`).
Verified: `resolvers/java.test.ts` 247/247 (was 243/247), plus this-boundary,
function-local-identity and the schema-version suites.

Refs #2699

* fix(scope-resolution): fail closed when a function-local shadows a same-named callable (#2699)

The #2699 positional join failed OPEN. On a position miss `resolveDefGraphId` fell
through to the label-agnostic, first-write-wins `simpleKey(filePath, simpleName)`, which
aliases a def onto whichever same-named callable was registered first — the exact
fabricated-caller mechanism this PR's own #2693 work already shipped once as a P0.

It misses because the two id phases anchor on different nodes BY DESIGN:
`tree-sitter-queries.ts` anchors the graph node on the outer `lexical_declaration`, while
`languages/typescript/query.ts` anchors the scope def on the inner `arrow_function` so
`anchor.range` lines up with `@scope.function` for auto-hoist. Split the declaration
across lines and those land on different LINES:

    export function run()   { const pick =
        (x) => x * 2; return pick(1); }
    export function other() { const pick =
        (x) => x * 3; return pick(2); }

    before:  run   -> run.pick@1:2      correct
             other -> other.pick@6:2    correct
             other -> run.pick@1:2      FABRICATED — other() never calls run's pick

Every fixture in function-local-identity.test.ts kept the declaration and its initializer
on ONE line, where the anchors coincide. That is why the suite stayed green while the bug
shipped, and the new test deliberately splits them.

WHY NOT A BLANKET FAIL-CLOSED. A position miss is not always a collision: it also happens
where the anchors legitimately differ, e.g. a Vue SFC, whose graph nodes carry
`+ lineOffset` while scope extraction does not. Failing closed on every miss would delete
correct edges there. So the guard is keyed on evidence that the collision is REAL —
`localNameKey` records that a function-local of this simple name exists in the file
(local-identity nodes are recognisable by the `@<row>:<col>` on their last name segment).
Only then is a miss treated as ambiguity. Files with no such local keep their previous
fallback behaviour byte-for-byte.

A missing edge is the correct failure direction here: `impact` can recover from an absent
caller, but a fabricated one silently corrupts the answer.

WHY NOT UNIFY THE ANCHORS. Considered and rejected: the split is deliberate and
load-bearing for auto-hoist across every language (the `rangesEqual(anchor.range,
innermost.range)` rule), so unifying it would fight that discipline far outside this fix.

The regression test was verified to DISCRIMINATE: with the guard disabled it fails on
exactly the fabricated edge (`+ "Function:m.ts:other -> Function:m.ts:run.pick@1:2"`).

impact(resolveDefGraphId, upstream) is CRITICAL — 62 impacted, 23 direct, 6 flows — which
is precisely why the guard is gated rather than broad. detect_changes: HIGH, 8 affected
processes, all in EmitReceiverBoundCalls / EmitRubyMixinEdges. Verified: 85 test files /
1364 tests green, including every scope-resolution unit.

Found by the PR #2695 tri-review (review 4782134453): raised by Codex's adversarial leg,
mechanism source-confirmed during synthesis, then reproduced end-to-end.

Refs #2699

* fix(typescript): stop the enclosing-type walk at nodes that rebind `this` (#2701)

`findEnclosingType` walked `node.parent` to the top of the file with no boundary, so it
happily synthesized a `this` binding from a type that does not own the member:

    class A { outer() { const o = { inner() { return this.x; } }; return o; } }

`this` inside `o.inner` is `o`, never `A` — but the walk reached `A` and bound to it, so
every `this.…` in such a method resolved against the wrong type. Only the module-level
object literal escaped, because there was no enclosing class to reach. Applies to
JavaScript too: `languages/javascript/captures.ts` calls the same function.

Boundary set: object literals and the function forms that rebind `this` at call time.
Arrows are deliberately absent — they inherit `this` lexically, which is what makes a
class-field arrow `m = () => this.x` resolve.

WHY THE MARKER WAS NOT ALSO REMOVED FROM METHOD FORMS.

The review argued `@receiver-owner.this` over-suppresses: `synthesizeTsReceiverBinding`
returns null for static members, object-literal methods and anonymous class expressions,
so those scopes are "owned but unbound" and get suppressed, losing edges the base
resolved. Removing the marker from the method forms was tried and MEASURED, and the
result does not support shipping it:

    marker removed, probe of all five shapes:
      static -> static            RESTORED (true)
      object literal (module)     RESTORED (true)
      anonymous class expression  RESTORED (true)
      static -> INSTANCE          FALSE EDGE returned
      object literal in a class   FALSE EDGE (Nested.outer.inner -> Nested.x)

The last one is the point: this fix stops the false *synthesis*, but removing the marker
re-enables receiver-blind *name* resolution in `lookupCore`'s lexical chain, which
recreates the same wrong edge by another route. The restored edges and the false ones
come from the SAME mechanism — a name walk — so they cannot be separated by toggling the
marker. The real trade is 2 genuinely-new true edges for 2 false ones, not the 3-for-1
the plan assumed.

Corpus evidence (762 real TypeScript files, edge SETS not counts, cold cache both arms):

    baseline vs marker-removed:  net 0, REMOVED 0, ADDED 0

Neither the gains nor the losses occur in production code. Given a 1:1 true/false ratio
on synthetic shapes and zero effect on real ones, the marker stays: for a graph feeding
`impact`, a fabricated caller is worse than an absent one — the same principle applied in
the fail-closed positional join. The three shapes remain UNRESOLVED rather than wrongly
resolved; resolving them properly needs a typed binding for object literals, anonymous
classes and static contexts, which is a feature, not this fix.

Measured with an edge-SET diff harness, after both ce-doc-review passes established that
an edge COUNT cannot decide this (it conflates edges gained with edges lost, so a
near-zero net reads as "no regression"). The harness also had to wipe the index each arm
— a warm parse cache initially reported an unchanged edge set across a real behavioural
change, the same trap documented in the v24 SCHEMA_BUMP note.

detect_changes: low risk, 3 symbols, no affected processes. 85 files / 1365 tests green.

Refs #2701

* docs(test): correct the false "three load-bearing gates" claim (#2701)

The header of `this-boundary.test.ts` asserted that all three gates were
independently load-bearing because "the false edge survived removing any one of
them alone". That was true DURING development, measured incrementally, and was
carried into the shipped comment without being re-tested against the finished
code. It is false: gate 3 (`isReceiverOwnedButUnbound` in `receiver-bound-calls`)
runs FIRST and marks the site in `handledSites`, which `emitReferencesViaLookup`
then skips — so for an explicit `this` receiver it subsumes gate 1. Removing
gate 1's `ownsReceivers` check in `gitnexus-shared/.../lookup-core.ts` leaves all
10 tests in the file passing; verified by experiment.

The gate is RETAINED, and the review's recommendation to delete it as "dead" is
rejected on evidence. `receiver-bound-calls` only suppresses EXPLICIT receivers
(`if (site.explicitReceiver === undefined) continue;`), whereas `lookup-core`'s
gate is also reached for IMPLICIT ones through `IMPLICIT_RECEIVERS` in
`resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a method
goes down that path. The experiment shows the gate is UNTESTED, not unreachable;
those are different claims and only the first is supported. Deleting it on the
strength of a green test run would have removed live code, which is the same
reasoning error the corrected comment is about.

This is a documentation-only change: no behaviour, no test expectations. The
correction is recorded in place rather than silently rewritten, because the way
the claim came to be wrong — measured on an intermediate tree, then asserted
about the final one — is the reusable lesson.

Refs #2701

* test(scope-resolution): pin the block-scope ACCESSES delta as false-edge removal (#2699)

The tri-review flagged that enabling `(statement_block) @scope.block` for
JS/TS drops 114 `ACCESSES -> Const` edges corpus-wide with `added: 0`,
undocumented and untested. That was recorded as a suspected regression.

It is not one. All 274 emitting reference sites behind those 114 edges were
classified by re-reading the source at the site: 269 are member reads, the 5
others are classifier artifacts (the name recurs earlier on the line, as in
`a.b.declLine` for `b`) and are member reads too. No edge was
bare-identifier-only. Every dropped edge was a property read
(`options.baseUrl`) mis-resolving to an unrelated function-local `const` of
the same name in the same file.

The cause is not block-specific: `lookupCore` Step 1 walks the lexical chain
for every lookup, including explicit-receiver property reads. Block scopes do
not fix that, they narrow it, by moving the local off the chain of any
reference outside its block. A local declared directly in the function body
still hijacks the read; that is pre-existing and left alone here.

Two tests. The first discriminates: it fails with the block capture removed
(the false edge reappears) and passes with it. The second is a companion
invariant, identical in both arms, so that "the edge went away" cannot be
satisfied by a change that dropped Block-kind bindings outright.

Fixture notes, both of which defeated earlier attempts at this edge class:
`pruneLocalSymbols` deletes ~94% of function-local value symbols, so the
`const` under test must be kept via `keepLocalValueSymbols`; and the member
read must sit outside the block, since inside it the block is on the
reference's own chain and the false edge appears in both arms.

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

* docs(ingestion): correct the SCIP citation on the function-local id (#2699)

The comment justified the positional, name-bearing local id (`fn@12:9`) as
"same reasoning as SCIP's document-scoped `local <id>` keyspace". SCIP is the
wrong citation for this key shape: its `local <id>` is a per-document counter,
and the spec states that locals do not encode the name.

SCIP remains prior art for the document-scoped keyspace itself, which is the
part the argument actually leans on, so the reference is corrected rather than
dropped. clang's USR for a function-local (`name@offset`) and Kythe's C++
indexer are the accurate citations for a positional, name-bearing key.

Comment only, no behavior change.

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

* fix(typescript,javascript): sync the function node-type lists, and test it (#2701)

Four hand-maintained lists answer "which node types are function-like":

  1. `query.ts` — the `@scope.function` / `@receiver-owner.this` patterns
  2. `captures.ts` — `FUNCTION_NODE_TYPES` (callable-flow synthesis + the
     body-block filter)
  3. `receiver-binding.ts` — `THIS_REBINDING_BOUNDARY_TYPES`
  4. `type-extractors/typescript.ts` — `THIS_BOUNDARY_NODE_TYPES`, whose
     docstring already claimed it was "kept in sync with `@receiver-owner.this`"
     with nothing enforcing it

`generator_function` (the EXPRESSION form, `const g = function* () {}`) was
added to both queries for #2701 and is present in lists 3 and 4, but was
missing from both `FUNCTION_NODE_TYPES`. Added.

That gap changes no graph output today, and the commit does not claim
otherwise. Measured on `const g = function* (x) { yield x; }; g(1)`: node and
edge sets are byte-identical with and without the entry. The `this` boundary
was already correct via the query marker — `this-boundary.test.ts` has a
passing generator case. A generator-expression binding still emits a `Const`
node rather than a `Function` one, so its call resolves to nothing either way;
that label comes from the definition rules, and closing it is a separate change
NOT made here.

So the entry is list consistency and the test is the real deliverable. It
asserts lists 1 and 2 EQUAL, and lists 3 and 4 as subsets of the query markers
with an explicit allowlist — the method forms bind their own `this` but the
class is their `this`-owner, so neither walk may stop there. Verified
discriminating: removing the `generator_function` entry fails both equality
assertions.

The lists are exported for the test; no other production surface changes.

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

* test(bench): gate scope emission per language, not TypeScript-only (#2699)

The scope-emission gate ran the TypeScript emitter only, so a JavaScript-only
regression shipped green. The two filters it guards are implemented twice —
`FUNCTION_BODY_OWNER_TYPES` in `typescript/captures.ts` and
`JS_FUNCTION_BODY_OWNER_TYPES` in `javascript/captures.ts`, each with its own
`blockDeclaresBinding` and `BLOCK_BINDING_CHILD_TYPES` — so covering one said
nothing about the other.

Adds a structurally parallel JavaScript corpus (the same shapes with the
TS-only syntax removed) and splits `baselines.json` per language. `--check`
now also fails when a baselined language is not measured, which is how a gate
goes quietly green.

Verified the new arm bites: disabling the JS body-block filter alone takes
JavaScript from 400 to 600 block scopes and fails `--check`, while TypeScript
stays green — the exact regression the old gate would have passed.

The two languages happen to agree exactly on this corpus (2 blocks per module,
2200 scopes). That is recorded as a measured result, not an invariant: each
language is still gated against its own baseline. TypeScript's numbers are
unchanged.

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

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:52:18 +01:00
Gergő Magyar
89bbdcf566
fix(ingestion): stop double-indexing const X = () => {} as Function + edgeless Const twin (#2687) (#2691)
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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
2026-07-25 16:56:17 +01:00