Commit graph

3 commits

Author SHA1 Message Date
azizur100389
454d383416
fix(ingestion): join multi-line closure bindings on initializer startLine (#2735) (#2762)
* fix(ingestion): join multi-line closure bindings on initializer startLine

Graph-node captures sit on the outer binding wrapper while scope-resolution
anchors on the inner callable; the line-only position join missed when those
split across lines and fail-closed dropped the real CALLS edge (#2735).

* fix(ingestion): unwrap Ruby call+block for multi-line lambda joins

Cover Kotlin/Ruby/Dart multi-line closure CALLS in integration tests, and
dig Ruby's call/block field so do-end bindings join on the block start line.

* style(ingestion): format closure join changes

* fix(ingestion): make closure position join language agnostic
2026-07-31 11:58:47 +01:00
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