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>
This commit is contained in:
Gergő Magyar
2026-07-27 07:52:18 +01:00
committed by GitHub
co-authored by Claude Opus 5 Gergo Magyar Gergo Magyar
parent 24584297d2
commit 4906daf27b
42 changed files with 3218 additions and 79 deletions
+24
View File
@@ -488,6 +488,30 @@ jobs:
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: Callable-value-flow target-index guards (#2693)
# Build-free: asserts buildGraphTargetIndex resolves an unchanged target
# set (fingerprint), stays linear in def count, and that the #2693
# widened gate — which now considers VALUE bindings, a population that
# outnumbers callables in real source — stays within its measured
# overhead of the pre-#2693 callable-only cost. The overhead budget also
# guards the DESIGN: value bindings are joined to their callable node by
# position, never by name through resolveDefGraphId, whose label-agnostic
# simpleKey fallback would alias a binding onto any same-named callable.
run: node --import tsx bench/callable-value-flow/measure.mjs --check
working-directory: gitnexus
- name: Scope-emission guards (#2699)
# Build-free: asserts the JS/TS scope set is unchanged. Block scopes are
# what make `let`/`const` in sibling blocks distinct bindings, but a
# scope per `statement_block` triples the count and deepens every
# scope-chain walk in every function for no semantic gain. Two emit-side
# filters drop the waste — function-body blocks (the Function scope
# already covers them) and blocks that declare nothing — and this gate
# fails if either regresses. Counts are exact, so it catches a change
# wall-clock CI could never resolve from noise.
run: node --import tsx bench/scope-emission/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1)
# Build-free: asserts collectFunctionCfgs output is unchanged
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
@@ -326,6 +326,12 @@ function lookupReceiverType(
// intentionally do NOT re-implement a simple-name fallback here.
return undefined;
}
// The scope binds this receiver itself but carries no type for it — a
// JS/TS ordinary `function` whose `this` is bound at call time, not the
// enclosing instance (#2701). Stop rather than borrowing an enclosing
// scope's binding; see `Scope.ownsReceivers`. Mirrors the same gate in
// the ingestion-side twin of this walk, `findReceiverTypeBinding`.
if (scope.ownsReceivers?.has(receiverName) === true) return undefined;
currentId = scope.parent;
}
return undefined;
@@ -414,6 +414,20 @@ export interface Scope {
/** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */
readonly typeBindings: ReadonlyMap<string, TypeRef>;
/** Receiver names this scope BINDS rather than inherits — `this`, `self`, … (#2701).
*
* A receiver walk (`findReceiverTypeBinding`) that reaches such a scope
* without finding the name in `typeBindings` stops here and reports the
* receiver unresolved, instead of continuing up and borrowing an enclosing
* scope's binding. In JavaScript/TypeScript an ordinary `function` binds its
* own `this` (ECMA-262 `[[ThisMode]]`) while an arrow inherits one, so
* `this.m()` inside a nested `function` must NOT reach the enclosing class.
*
* Left unset by every language whose closures capture the receiver
* lexically, which is nearly all of them — the walk is unchanged there.
* Populated from `LanguageProvider.scopeOwnsReceivers`. */
readonly ownsReceivers?: ReadonlySet<string>;
}
// ─── §2.6 Resolution + ResolutionEvidence ───────────────────────────────────
@@ -0,0 +1,8 @@
{
"_comment": "Baselines for bench/callable-value-flow/measure.mjs --check (#2693). `fingerprint` is an order-independent sha256 over every (defNodeId -> graphId) pair buildGraphTargetIndex resolves on the synthetic corpus; it is a CORRECTNESS gate, so drift means the callable-value target set moved and must be explained, never re-baselined to make CI green. The two budgets are timing gates and carry deliberate headroom for shared CI runners.",
"fingerprint": "70bebf6a26ff6fc9f231a0933678274b44c4883ddab5e719a61a9c77d6223e51",
"scaling_budget": 1.6,
"_scaling_note": "(t_large/t_small)/(800/250). ~1.0 is linear; measured 1.14-1.16. The index build is one pass over defs plus map lookups, so a jump toward 3.x means someone made the per-def work depend on corpus size (e.g. a scan inside the loop).",
"widening_overhead_budget": 1.9,
"_widening_overhead_note": "large_ms / callable_only_ms — how much more the #2693 widened gate costs than the pre-#2693 callable-only population on the SAME corpus. Measured 1.43-1.58 with the positional join (value bindings are matched against a file/line/name index built in the existing graph walk and never run the resolveDefGraphId key chain); a name-only match that fell through to resolveDefGraphId measured 2.50-2.82. The budget sits between the two bands, so it cannot be met by reverting to the slower — and incorrect — name-match design."
}
@@ -0,0 +1,241 @@
/**
* Build-free throughput + identity bench for `buildGraphTargetIndex`, the
* callable-value-flow target index (issue #2693).
*
* #2693 widened this function's gate: before it, only Function/Method/
* Constructor defs were considered; now VALUE bindings (Const/Property/Static/
* Variable) are considered too, because a closure bound to a name declares as a
* value but emits a callable graph node (#2687). Value bindings usually
* OUTNUMBER callables in real source, so the widening puts the hot loop's cost
* on a much larger def population — this bench exists to keep that honest.
*
* Value bindings are joined to their callable node POSITIONALLY
* (`file\0line\0name`); they never run the `resolveDefGraphId` key chain,
* whose label-agnostic `simpleKey` fallback would alias a binding onto any
* same-named callable in the file.
*
* For a synthetic corpus at two scales it reports:
* - elapsed_ms_small / elapsed_ms_large (fastest of REPS, see `fastest`) + a scaling ratio
* `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, ~3.x quadratic;
* - `callable_only_ms_large`, the same corpus with the PRE-#2693 def
* population, so the cost the widening actually added stays visible as
* `widening_overhead` rather than being folded into one opaque number;
* - an order-independent sha256 fingerprint over every (defNodeId → graphId)
* pair the index resolves, as the correctness gate. A fingerprint change
* means the set of callable-value targets moved — that is a behaviour
* change, never a performance one.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --import tsx bench/callable-value-flow/measure.mjs`). Static `.ts`
* imports work; a top-level `await import()` breaks tsx's lexer.
*
* Without args: prints one JSON object per scale plus the summary.
* With `--check`: asserts the fingerprint == the committed baseline AND both
* the scaling ratio and the widening overhead are within their recorded
* budgets; exits non-zero on drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { createKnowledgeGraph } from '../../src/core/graph/graph.ts';
import { buildGraphNodeLookup } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts';
import { buildGraphTargetIndex } from '../../src/core/ingestion/scope-resolution/passes/callable-value-flow.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
/**
* Deterministic synthetic corpus — no randomness, so the fingerprint is stable.
*
* Per file: 2 free functions, 1 class with 2 methods, and 8 value bindings. Of
* those 8, ONE is a closure binding: it declares as a value but its only graph
* node is a `Function` (exactly what #2687 emits, and the sole case the widened
* gate is meant to admit). The other 7 keep their own value node, so they must
* be REJECTED — they are the population whose cost the widening added.
*
* The 7:1 reject:admit ratio is the point: the loop must reject seven bindings
* cheaply for every one it admits. The closure binding's callable node sits at
* the SAME line as its def, which is what the positional join keys on; the
* seven others have their own value node at their own line and must not be
* admitted by any name coincidence.
*/
function buildCorpus(fileCount) {
const graph = createKnowledgeGraph();
const defs = new Map();
// `line` is 1-based (the convention definition ids use); graph nodes store a
// 0-BASED startLine, and the positional join in buildGraphTargetIndex is what
// reconciles the two. Modelling that off by one here would silently stop the
// bench from exercising the value-binding path at all.
const addNode = (label, filePath, qualifiedName, line) => {
const id = `${label}:${filePath}:${qualifiedName}`;
graph.addNode({
id,
label,
properties: {
filePath,
name: qualifiedName.split('.').pop(),
qualifiedName,
startLine: line - 1,
},
});
return id;
};
const addDef = (type, filePath, qualifiedName, line) => {
const nodeId = `${filePath}#${line}:0:${qualifiedName}`;
defs.set(nodeId, { nodeId, type, filePath, qualifiedName });
};
for (let f = 0; f < fileCount; f++) {
const filePath = `src/module${f}/file${f}.ts`;
let line = 1;
for (let i = 0; i < 2; i++, line++) {
addNode('Function', filePath, `fn${i}`, line);
addDef('Function', filePath, `fn${i}`, line);
}
addNode('Class', filePath, `Cls`, line);
for (let i = 0; i < 2; i++, line++) {
addNode('Method', filePath, `Cls.m${i}`, line);
addDef('Method', filePath, `Cls.m${i}`, line);
}
// 1 closure binding: value def, callable node, NO value node.
addNode('Function', filePath, `handler`, line);
addDef('Const', filePath, `handler`, line);
line++;
// 7 ordinary value bindings: value def AND its own value node → rejected.
const valueLabels = [
'Const',
'Variable',
'Property',
'Static',
'Const',
'Variable',
'Property',
];
for (let i = 0; i < valueLabels.length; i++, line++) {
const label = valueLabels[i];
addNode(label, filePath, `value${i}`, line);
addDef(label, filePath, `value${i}`, line);
}
}
return { graph, scopes: { defs: { byId: defs } }, nodeLookup: buildGraphNodeLookup(graph) };
}
/** Only the pre-#2693 def population, for the overhead comparison. */
function callableOnlyScopes(scopes) {
const byId = new Map();
for (const [id, def] of scopes.defs.byId) {
if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') {
byId.set(id, def);
}
}
return { defs: { byId } };
}
/**
* MIN, not median. Both scales are timed in one process, and every source of
* error here is additive — scheduler preemption, GC, a noisy neighbour on a
* shared CI runner. The fastest observed run is the closest estimate of the
* uncontended cost, so the derived ratios stay comparable across machines
* instead of tracking whatever else the box was doing. (Measured directly: the
* same build reported an overhead of 1.65 idle and 2.03 while a test shard was
* running — a median-based gate would have to be loosened until it could no
* longer detect the regression it exists to catch.)
*/
function fastest(values) {
return Math.min(...values);
}
function timeIndex(scopes, nodeLookup, graph) {
// Warm up before timing: the first calls carry JIT compilation of the whole
// resolve chain, and the widened and callable-only runs would otherwise be
// measured at different optimisation tiers — which alone moved the reported
// overhead by ~30%.
for (let w = 0; w < WARMUP; w++) buildGraphTargetIndex(scopes, nodeLookup, undefined, graph);
const samples = [];
let last;
for (let r = 0; r < REPS; r++) {
const t0 = performance.now();
last = buildGraphTargetIndex(scopes, nodeLookup, undefined, graph);
samples.push(performance.now() - t0);
}
return { ms: fastest(samples), result: last };
}
function fingerprint(targets) {
const lines = [...targets.entries()].map(([defId, t]) => `${defId}\u0000${t.id}`).sort();
return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
}
const scales = {};
for (const [name, fileCount] of [
['small', SMALL],
['large', LARGE],
]) {
const { graph, scopes, nodeLookup } = buildCorpus(fileCount);
const widened = timeIndex(scopes, nodeLookup, graph);
const callableOnly = timeIndex(callableOnlyScopes(scopes), nodeLookup, graph);
scales[name] = {
files: fileCount,
defs: scopes.defs.byId.size,
ms: widened.ms,
callable_only_ms: callableOnly.ms,
targets: widened.result.size,
callable_only_targets: callableOnly.result.size,
fingerprint: fingerprint(widened.result),
};
}
const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL);
// How much slower the widened gate is than the pre-#2693 one on the same
// corpus. 1.0 = free; 2.0 = the widening doubled the index build.
const wideningOverhead = scales.large.ms / scales.large.callable_only_ms;
const report = {
small: scales.small,
large: scales.large,
scaling_ratio: Number(scalingRatio.toFixed(3)),
widening_overhead: Number(wideningOverhead.toFixed(3)),
fingerprint: scales.large.fingerprint,
};
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const failures = [];
if (report.fingerprint !== baseline.fingerprint) {
failures.push(
`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — the resolved ` +
`callable-value target set CHANGED. This is a behaviour change, not a perf one.`,
);
}
if (report.scaling_ratio > baseline.scaling_budget) {
failures.push(`scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget}`);
}
if (report.widening_overhead > baseline.widening_overhead_budget) {
failures.push(
`widening overhead ${report.widening_overhead} > budget ${baseline.widening_overhead_budget}`,
);
}
console.log(JSON.stringify(report, null, 2));
if (failures.length > 0) {
console.error(`[callable-value-flow --check] FAIL\n - ${failures.join('\n - ')}`);
process.exit(1);
}
console.log('[callable-value-flow --check] PASS');
+6 -4
View File
@@ -111,7 +111,7 @@
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054."
},
"typescript": {
"fingerprint": "3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4",
"fingerprint": "281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
@@ -119,10 +119,11 @@
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.",
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5."
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.",
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699."
},
"javascript": {
"fingerprint": "f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c",
"fingerprint": "90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
@@ -130,7 +131,8 @@
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (extends Base); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5."
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354."
},
"kotlin": {
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
@@ -0,0 +1,21 @@
{
"_comment": "Baselines for bench/scope-emission/measure.mjs --check (#2699), one entry per language. `scopes` is an EXACT count over a synthetic corpus fixed in measure.mjs — a correctness gate, not a timing one, so drift means the emitted scope set moved and must be explained, never re-baselined to make CI green. The two emit-side filters this guards (function-body blocks, and blocks that declare no binding) cut block scopes 19389 -> 5331 on a 762-file TypeScript corpus and took the block-scope overhead from ~+10% to ~+2% of analyze wall time. `@scope.block` = 400 is 2 per module: only the two `if`/`else` branches that declare `const chosen`. If that number jumps, the filters regressed and every scope-chain walk in every function got deeper. BOTH languages are baselined because the filters 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 — so a TypeScript-only gate would let a JavaScript-only regression ship green. The two agree exactly on this corpus; that is a measured result, not an invariant the gate depends on. `emit_ms_budget` carries deliberate headroom for shared CI runners and exists to catch an order-of-magnitude regression, not a few percent.",
"typescript": {
"scopes": {
"@scope.block": 400,
"@scope.class": 200,
"@scope.function": 1400,
"@scope.module": 200
},
"emit_ms_budget": 1500
},
"javascript": {
"scopes": {
"@scope.block": 400,
"@scope.class": 200,
"@scope.function": 1400,
"@scope.module": 200
},
"emit_ms_budget": 1500
}
}
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env node
/**
* Scope-emission bench (#2699).
*
* JavaScript/TypeScript gained block scopes so that `let`/`const` in sibling
* blocks are distinct bindings. Emitted naively — one scope per
* `statement_block` — that TRIPLED the block-scope count and 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;
* 2. a block that declares no `let`/`const`/`class`/`function` binds nothing,
* so it is transparent to every lookup.
*
* This bench guards that. It counts scope captures over a synthetic corpus
* whose shape is fixed in this file, so the numbers are exact and independent
* of the machine — unlike wall-clock analyze, where a 2% effect sits well
* inside the noise of a shared runner (measured: ±10% run to run).
*
* BOTH languages are measured. The filters 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 its own `BLOCK_BINDING_CHILD_TYPES` — so a
* TypeScript-only bench would let a JavaScript-only regression ship green.
*
* On this corpus the two currently agree exactly (2 blocks per module, 2200
* scopes). That is a measured result, not a required invariant: the fixtures
* are structurally parallel and the TS-only syntax they drop carries no extra
* scopes. Each language is still gated against its OWN baseline, because the
* filters are separate code and nothing enforces that the counts stay equal.
*
* Usage:
* node bench/scope-emission/measure.mjs # print measurements
* node bench/scope-emission/measure.mjs --check # gate against baselines
*
* Build-free: imports the TypeScript sources through tsx, like the other
* benches here.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const { emitTsScopeCaptures } =
await import('../../src/core/ingestion/languages/typescript/captures.ts');
const { emitJsScopeCaptures } =
await import('../../src/core/ingestion/languages/javascript/captures.ts');
/**
* One synthetic TypeScript module, parameterised by index so names stay
* distinct.
*
* Deliberately mixes the shapes the filters discriminate between:
* - function/method/arrow bodies → block scope must be SUPPRESSED
* - `if`/`else`/`for`/`while`/`try` → suppressed when they declare nothing
* - blocks declaring `let`/`const` → block scope REQUIRED (shadowing)
* - a block declaring only `var` → suppressed (`var` hoists past it)
*/
const tsModuleSource = (i) => `
export class Svc${i} {
private total = 0;
run(xs: number[]): number {
for (const x of xs) {
if (x > 0) {
this.total += x;
} else {
this.total -= x;
}
}
while (this.total > 100) {
this.total = this.total / 2;
}
try {
this.total = Math.round(this.total);
} catch {
this.total = 0;
}
return this.total;
}
pick(flag: boolean): number {
if (flag) {
const chosen = (n: number) => n * 2;
return chosen(1);
} else {
const chosen = (n: number) => n * 3;
return chosen(2);
}
}
hoisted(flag: boolean): number {
if (flag) { var v = 1; }
return v ?? 0;
}
}
export function free${i}(): number {
const inner = (n: number) => n + 1;
return inner(1);
}
`;
/** The same shapes with the TypeScript-only syntax removed. Kept structurally
* parallel to `tsModuleSource` on purpose: when the two languages' block
* counts diverge, the cause is the emitter, not the fixture. */
const jsModuleSource = (i) => `
export class Svc${i} {
total = 0;
run(xs) {
for (const x of xs) {
if (x > 0) {
this.total += x;
} else {
this.total -= x;
}
}
while (this.total > 100) {
this.total = this.total / 2;
}
try {
this.total = Math.round(this.total);
} catch {
this.total = 0;
}
return this.total;
}
pick(flag) {
if (flag) {
const chosen = (n) => n * 2;
return chosen(1);
} else {
const chosen = (n) => n * 3;
return chosen(2);
}
}
hoisted(flag) {
if (flag) { var v = 1; }
return v ?? 0;
}
}
export function free${i}() {
const inner = (n) => n + 1;
return inner(1);
}
`;
const CORPUS_MODULES = 200;
const REPS = 7;
const LANGUAGES = [
{ name: 'typescript', ext: 'ts', emit: emitTsScopeCaptures, moduleSource: tsModuleSource },
{ name: 'javascript', ext: 'js', emit: emitJsScopeCaptures, moduleSource: jsModuleSource },
];
const measure = ({ ext, emit, moduleSource }) => {
const corpus = Array.from({ length: CORPUS_MODULES }, (_, i) => ({
path: `bench/mod${i}.${ext}`,
source: moduleSource(i),
}));
const tally = () => {
const counts = new Map();
for (const { path, source } of corpus) {
for (const match of emit(source, path)) {
for (const key of Object.keys(match)) {
if (key.startsWith('@scope.')) counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
}
return counts;
};
// Warm the parser + query caches so the timing reflects steady state.
tally();
let bestMs = Infinity;
let counts;
for (let r = 0; r < REPS; r++) {
const t0 = process.hrtime.bigint();
counts = tally();
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
if (ms < bestMs) bestMs = ms;
}
const scopes = Object.fromEntries([...counts.entries()].sort());
return {
modules: CORPUS_MODULES,
scopes,
total_scopes: Object.values(scopes).reduce((a, b) => a + b, 0),
emit_min_ms: Number(bestMs.toFixed(2)),
blocks_per_module: Number(((scopes['@scope.block'] ?? 0) / CORPUS_MODULES).toFixed(3)),
};
};
const result = Object.fromEntries(LANGUAGES.map((lang) => [lang.name, measure(lang)]));
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
}
const baselines = JSON.parse(readFileSync(join(HERE, 'baselines.json'), 'utf8'));
const failures = [];
for (const { name } of LANGUAGES) {
const expected = baselines[name];
const actual = result[name];
if (expected === undefined) {
failures.push(`${name}: no baseline entry — add one rather than skipping the language`);
continue;
}
// Scope counts are EXACT — a synthetic corpus and a deterministic emitter. A
// mismatch means the emitted scope set moved and must be explained, never
// re-baselined to make CI green.
for (const [key, want] of Object.entries(expected.scopes)) {
const got = actual.scopes[key] ?? 0;
if (got !== want) failures.push(`${name} ${key}: expected ${want}, got ${got}`);
}
for (const key of Object.keys(actual.scopes)) {
if (!(key in expected.scopes)) {
failures.push(`${name}: unexpected capture ${key}: ${actual.scopes[key]}`);
}
}
// Timing carries deliberate headroom for shared CI runners; it exists to
// catch an order-of-magnitude regression, not to police a few percent.
if (actual.emit_min_ms > expected.emit_ms_budget) {
failures.push(
`${name} emit_min_ms ${actual.emit_min_ms} exceeds budget ${expected.emit_ms_budget}`,
);
}
}
// A language present in baselines but not measured means the bench stopped
// covering it — the exact way a gate goes quietly green.
for (const name of Object.keys(baselines)) {
if (name.startsWith('_')) continue;
if (!(name in result)) failures.push(`${name}: baselined but not measured`);
}
console.log(JSON.stringify(result, null, 2));
if (failures.length > 0) {
console.error('[scope-emission --check] FAIL');
for (const f of failures) console.error(` - ${f}`);
process.exit(1);
}
console.log('[scope-emission --check] PASS');
@@ -458,6 +458,20 @@ interface LanguageProviderConfig {
*/
readonly resolveScopeKind?: (captures: CaptureMatch) => ScopeKind | null;
/**
* Report the receiver names this scope BINDS rather than inherits — see
* `Scope.ownsReceivers` (#2701).
*
* Called once per `@scope.*` capture during scope-tree construction.
* Return the shared frozen set for a scope that starts a fresh receiver
* (a JS/TS ordinary `function`, whose `this` is bound at call time), and
* `undefined` for one that inherits it (an arrow function, and every
* closure form in languages that capture the receiver lexically).
*
* Default: undefined everywhere — the receiver walk is unchanged.
*/
readonly scopeOwnsReceivers?: (captures: CaptureMatch) => ReadonlySet<string> | undefined;
/**
* Override where a declaration's name becomes visible. By default the name
* is bound in the innermost enclosing scope; return a different `ScopeId`
@@ -58,9 +58,35 @@ const DART_CALLABLE_CAPTURE_OPTIONS = {
callNodeTypes: new Set(['selector']),
parameterListNodeTypes: new Set(['formal_parameter_list', 'arguments']),
parameterNodeTypes: new Set(['formal_parameter']),
bindingNodeTypes: new Set(['initialized_variable_definition']),
// `initialized_identifier` covers TOP-LEVEL `var` bindings and the second and
// later declarators of a multi-name local; `static_final_declaration` covers
// top-level `final`/`const`, which parse into a different list node entirely.
// Dart wraps only the FIRST local declarator in `initialized_variable_
// definition`, so without the other two a top-level `var f = (x) => x;`, a
// `final f = …`, and the `g` of `var f = …, g = …;` all emitted no flow
// captures at all and never resolved (#2693).
bindingNodeTypes: new Set([
'initialized_variable_definition',
'initialized_identifier',
'static_final_declaration',
]),
assignmentNodeTypes: new Set(['assignment_expression']),
identifierNodeTypes: new Set(['identifier', 'type_identifier']),
// `initialized_identifier` and `static_final_declaration` are FIELDLESS, so
// the shared field-based fallback (`left`/`name`/`value`/…) decomposes
// nothing and those bindings produced no flow facts at all — the same shape
// as Kotlin's fieldless `assignment` node. Positional: first named child is
// the bound name, last is the initializer.
// `initialized_variable_definition` carries real `name:` / `value:` fields,
// so it is left to the shared path by returning undefined.
extractAssignment: (node: SyntaxNode) => {
if (node.type !== 'initialized_identifier' && node.type !== 'static_final_declaration') {
return undefined;
}
const named = node.namedChildren.filter((child): child is SyntaxNode => child !== null);
if (named.length < 2) return undefined;
return { destination: named[0]!, source: named[named.length - 1]! };
},
lexicalFunctionOwner: (node: SyntaxNode) => dartLexicalFunctionOwner(node),
isCallNode: (node: SyntaxNode) => node.namedChild(0)?.type === 'argument_part',
extractCallCallee: (node: SyntaxNode) => dartCallableCallee(node) ?? undefined,
@@ -126,6 +126,37 @@ const DART_SCOPE_QUERY = `
(initialized_identifier
. (identifier) @declaration.name))) @declaration.property
; ── Declarations — closure bindings (#2693) ──────────────────────────────────
; \`var f = (x) => x;\` binds a callable. Without a declaration the binding has
; no SymbolDefinition, so callable-value-flow has nothing to attach its seed to
; and \`f()\` stays unresolved even though the graph emits a Function node for it.
;
; Restricted to a function_expression value on purpose: declaring every Dart
; variable would mint defs repo-wide for no resolution benefit. The top-level
; rule is anchored under (program) — the same disambiguation the graph-node
; query uses — so class-body fields, which reuse initialized_identifier_list
; and are already @declaration.property, are never matched twice.
(program
(initialized_identifier_list
(initialized_identifier
(identifier) @declaration.name
(function_expression))) @declaration.variable)
(program
(static_final_declaration_list
(static_final_declaration
(identifier) @declaration.name
(function_expression))) @declaration.variable)
(initialized_variable_definition
name: (identifier) @declaration.name
value: (function_expression)) @declaration.variable
; Second and later declarators of \`var f = .., g = ..;\` are nested
; initialized_identifier children of the same initialized_variable_definition,
; which the field-based rule above only reaches for the first name.
(initialized_variable_definition
(initialized_identifier
(identifier) @declaration.name
(function_expression)) @declaration.variable)
; ── Imports / re-exports ─────────────────────────────────────────────────────
(import_or_export
(library_import
@@ -50,14 +50,44 @@ import {
/** JS function-like node types that may carry a synthesized `this` binding.
* Kept in sync with the `@scope.function` patterns in `query.ts`. */
const FUNCTION_NODE_TYPES = [
export const FUNCTION_NODE_TYPES = [
'method_definition',
'arrow_function',
'function_expression',
'function_declaration',
'generator_function_declaration',
// The EXPRESSION form (`const g = function* () {}`) — see the matching note
// in `typescript/captures.ts`.
'generator_function',
] as const;
/** Nodes whose `statement_block` child is their BODY, not a nested block. */
const JS_FUNCTION_BODY_OWNER_TYPES: ReadonlySet<string> = new Set(FUNCTION_NODE_TYPES);
/** Direct-child node types that create a BINDING in their enclosing block.
* `variable_declaration` (`var`) is deliberately absent: it hoists past the
* block to the function, so a block containing only `var` binds nothing. */
const BLOCK_BINDING_CHILD_TYPES: ReadonlySet<string> = new Set([
'lexical_declaration',
'class_declaration',
'function_declaration',
'generator_function_declaration',
]);
/** True when `block` directly declares a name, i.e. it is a real environment
* record rather than punctuation. A block that binds nothing is transparent to
* every scope-chain walk — a lookup finds nothing in it and continues to the
* parent — so emitting a scope for it costs tree size and walk depth and buys
* exactly nothing. Only DIRECT children count: a declaration in a nested block
* belongs to that block, which gets its own scope by the same rule. */
const blockDeclaresBinding = (block: SyntaxNode): boolean => {
for (let i = 0; i < block.namedChildCount; i++) {
const child = block.namedChild(i);
if (child !== null && BLOCK_BINDING_CHILD_TYPES.has(child.type)) return true;
}
return false;
};
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
@@ -810,6 +840,17 @@ export function emitJsScopeCaptures(
}
// Filter @reference.read.member false-positives.
// See the matching filter in typescript/captures.ts: a `statement_block`
// that IS a function body duplicates the enclosing Function scope, and
// keeping it puts a redundant level inside every function for every
// scope-chain walk to step through (~6% of analyze wall time, measured).
if (grouped['@scope.block'] !== undefined) {
const blockNode = groupedNodes['@scope.block'];
const parentType = blockNode?.parent?.type;
if (parentType !== undefined && JS_FUNCTION_BODY_OWNER_TYPES.has(parentType)) continue;
if (blockNode === undefined || !blockDeclaresBinding(blockNode)) continue;
}
if (grouped['@reference.read.member'] !== undefined) {
const anchor = grouped['@reference.read.member'];
const memberNode =
@@ -60,18 +60,23 @@ function isJsxFile(filePath: string): boolean {
return filePath.endsWith('.jsx');
}
const JAVASCRIPT_SCOPE_QUERY = `
export const JAVASCRIPT_SCOPE_QUERY = `
;; Scopes — module / class-likes / function-likes
(program) @scope.module
(class_declaration) @scope.class
(class) @scope.class
(function_declaration) @scope.function
(generator_function_declaration) @scope.function
(function_expression) @scope.function
;; \`@receiver-owner.this\` — see the matching block in typescript/query.ts
;; (#2701). Every function form except \`arrow_function\` binds its own \`this\`.
(function_declaration) @scope.function @receiver-owner.this
(generator_function_declaration) @scope.function @receiver-owner.this
(function_expression) @scope.function @receiver-owner.this
;; \`function*(){}\` as an EXPRESSION. Absent from this list before #2701, so it
;; was not a scope at all and \`this\` inside one read as the enclosing method's.
(generator_function) @scope.function @receiver-owner.this
(arrow_function) @scope.function
(method_definition) @scope.function
(method_definition) @scope.function @receiver-owner.this
;; Object literals get their own scope boundary -- see the matching
;; comment in typescript/query.ts (#2545/#2551). Prevents a
@@ -80,6 +85,16 @@ const JAVASCRIPT_SCOPE_QUERY = `
;; sibling properties from seeing each other as bare identifiers.
(object) @scope.object
;; Statement blocks are BINDING scopes (#2699). ECMAScript gives every block its
;; own environment record, so \`let\`/\`const\`/\`class\`/\`function\` declared in
;; sibling blocks of one function are DIFFERENT bindings — without this the
;; resolver sees both as function-level and a call in one branch resolves to
;; both. \`tsBindingScopeFor\` already implements the other half of the rule:
;; \`var\` hoists past blocks to the enclosing Function/Module, \`let\`/\`const\`
;; take the innermost scope, which is now the block.
(statement_block) @scope.block
;; Declarations — classes
(class_declaration
name: (identifier) @declaration.name) @declaration.class
@@ -107,6 +107,20 @@ const PHP_SCOPE_QUERY = `
(property_element
name: (variable_name) @declaration.name)) @declaration.variable
;; ── Declarations — closure bindings (#2693) ───────────────────────────────
;; A dollar-name bound to a closure (fn(x) => x, or function(x){...}) IS a
;; callable. PHP emitted the callable-flow seed and invoke for it already, but
;; nothing DECLARED the name, so the flow pass had no SymbolDefinition to attach
;; the seed to and the call stayed unresolved.
;; Restricted to a closure value: declaring every PHP assignment would mint defs
;; repo-wide for no resolution benefit.
(assignment_expression
left: (variable_name (name) @declaration.name)
right: (arrow_function)) @declaration.variable
(assignment_expression
left: (variable_name (name) @declaration.name)
right: (anonymous_function)) @declaration.variable
;; ── Imports — namespace_use_declaration ───────────────────────────────────
;;
;; Captures ALL forms: plain, alias, function/const qualifiers, and grouped.
@@ -7,7 +7,7 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import type { CaptureMatch, NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createClassExtractor } from '../class-extractors/generic.js';
@@ -307,6 +307,18 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
'valueOf',
]);
/**
* `this` is the only receiver keyword JavaScript and TypeScript bind, and it
* is bound by every function form except an arrow (#2701). The query files
* tag those forms with `@receiver-owner.this`; this hook just reads the tag,
* so the node-type list stays in the one place that already names grammar
* nodes. See `Scope.ownsReceivers` for what the marker does to the walk.
*/
const TS_OWNED_RECEIVERS: ReadonlySet<string> = new Set(['this']);
const tsScopeOwnsReceivers = (match: CaptureMatch): ReadonlySet<string> | undefined =>
match['@receiver-owner.this'] === undefined ? undefined : TS_OWNED_RECEIVERS;
export const typescriptProvider = defineLanguage({
id: SupportedLanguages.TypeScript,
extensions: ['.ts', '.tsx'],
@@ -335,6 +347,7 @@ export const typescriptProvider = defineLanguage({
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
scopeOwnsReceivers: tsScopeOwnsReceivers,
exportChecker: tsExportChecker,
importResolver: createImportResolver(typescriptImportConfig),
callExtractor: createCallExtractor(typescriptCallConfig),
@@ -402,6 +415,7 @@ export const javascriptProvider = defineLanguage({
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: JAVASCRIPT_QUERIES,
typeConfig: typescriptConfig,
scopeOwnsReceivers: tsScopeOwnsReceivers,
exportChecker: tsExportChecker,
importResolver: createImportResolver(javascriptImportConfig),
callExtractor: createCallExtractor(javascriptCallConfig),
@@ -50,7 +50,7 @@ import {
/** tree-sitter-typescript node types for function-like scopes that may
* carry a synthesized `this` binding. Kept in sync with the
* `@scope.function` patterns in `query.ts`. */
const FUNCTION_NODE_TYPES = [
export const FUNCTION_NODE_TYPES = [
'method_definition',
'method_signature',
'abstract_method_signature',
@@ -58,9 +58,50 @@ const FUNCTION_NODE_TYPES = [
'function_expression',
'function_declaration',
'generator_function_declaration',
// The EXPRESSION form (`const g = function* () {}`). Both queries capture it
// as `@scope.function`, and both `this`-boundary lists already carry it, but
// this list did not — so callable-flow synthesis and the body-block filter
// treated a generator expression as a non-function.
//
// Measured: adding it changes no graph output today. A generator-expression
// binding still emits a `Const` node rather than a `Function` one, so the
// call never resolves either way — that label comes from the definition
// rules, not from here, and closing it is a separate change. This entry is
// list consistency, enforced by
// `test/unit/ts-js-function-node-type-lists.test.ts`.
'generator_function',
'function_signature',
] as const;
/** Nodes whose `statement_block` child is their BODY, not a nested block.
* Such a block duplicates the enclosing Function scope — see the emit-side
* filter in `emitTsScopeCaptures`. */
const FUNCTION_BODY_OWNER_TYPES: ReadonlySet<string> = new Set(FUNCTION_NODE_TYPES);
/** Direct-child node types that create a BINDING in their enclosing block.
* `variable_declaration` (`var`) is deliberately absent: it hoists past the
* block to the function, so a block containing only `var` binds nothing. */
const BLOCK_BINDING_CHILD_TYPES: ReadonlySet<string> = new Set([
'lexical_declaration',
'class_declaration',
'function_declaration',
'generator_function_declaration',
]);
/** True when `block` directly declares a name, i.e. it is a real environment
* record rather than punctuation. A block that binds nothing is transparent to
* every scope-chain walk — a lookup finds nothing in it and continues to the
* parent — so emitting a scope for it costs tree size and walk depth and buys
* exactly nothing. Only DIRECT children count: a declaration in a nested block
* belongs to that block, which gets its own scope by the same rule. */
const blockDeclaresBinding = (block: SyntaxNode): boolean => {
for (let i = 0; i < block.namedChildCount; i++) {
const child = block.namedChild(i);
if (child !== null && BLOCK_BINDING_CHILD_TYPES.has(child.type)) return true;
}
return false;
};
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
@@ -266,6 +307,23 @@ export function emitTsScopeCaptures(
continue;
}
// A `statement_block` that IS a function body adds nothing: the enclosing
// Function scope already provides that environment record, so emitting one
// here just puts a redundant level inside EVERY function for every
// scope-chain walk to step through. Measured on a 762-file TypeScript
// corpus, keeping them cost ~6% of total analyze wall time; dropping them
// keeps the block scopes that matter (if/else/for/while/try/bare blocks,
// where `let`/`const` genuinely shadow) at no measurable cost.
//
// Semantically safe: nothing can be declared between a function and its
// own body, so a binding in either resolves identically.
if (grouped['@scope.block'] !== undefined) {
const blockNode = groupedNodes['@scope.block'];
const parentType = blockNode?.parent?.type;
if (parentType !== undefined && FUNCTION_BODY_OWNER_TYPES.has(parentType)) continue;
if (blockNode === undefined || !blockDeclaresBinding(blockNode)) continue;
}
// Filter out `@reference.read.member` matches whose AST parent tells
// us they are actually calls / writes / constructor invocations. The
// tree-sitter pattern is context-free and matches every member_expression;
@@ -78,7 +78,7 @@ function isTsxFile(filePath: string): boolean {
return filePath.endsWith('.tsx');
}
const TYPESCRIPT_SCOPE_QUERY = `
export const TYPESCRIPT_SCOPE_QUERY = `
;; Scopes — module / namespace / class-likes / function-likes
(program) @scope.module
@@ -94,14 +94,26 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; class expressions omit it); ScopeExtractor tolerates missing names.
(class) @scope.class
(function_declaration) @scope.function
(generator_function_declaration) @scope.function
(function_signature) @scope.function
(method_definition) @scope.function
(method_signature) @scope.function
(abstract_method_signature) @scope.function
;; \`@receiver-owner.this\` marks a scope that BINDS its own \`this\` rather
;; than inheriting one (#2701) — see \`Scope.ownsReceivers\`. Every function
;; form except \`arrow_function\` carries it: ECMA-262 gives an arrow
;; \`[[ThisMode]] = lexical\` (no \`this\` in its environment record, so the
;; lookup passes through), while every other form binds \`this\` at call time.
;; \`method_definition\` is marked too and is unaffected — it also carries a
;; synthesized \`this\` typeBinding, which the walk consults first.
;; The marker rides on the same node as \`@scope.function\`; it is outside the
;; \`@scope.\` namespace so \`anchorCaptureFor\` cannot mistake it for the anchor.
(function_declaration) @scope.function @receiver-owner.this
(generator_function_declaration) @scope.function @receiver-owner.this
(function_signature) @scope.function @receiver-owner.this
(method_definition) @scope.function @receiver-owner.this
(method_signature) @scope.function @receiver-owner.this
(abstract_method_signature) @scope.function @receiver-owner.this
(arrow_function) @scope.function
(function_expression) @scope.function
(function_expression) @scope.function @receiver-owner.this
;; \`function*(){}\` as an EXPRESSION. Absent from this list before #2701, so it
;; was not a scope at all and \`this\` inside one read as the enclosing method's.
(generator_function) @scope.function @receiver-owner.this
;; Object literals (the { ... } value expression, NOT object_type or
;; object_pattern) get their own scope boundary. Without it, a
@@ -120,6 +132,16 @@ const TYPESCRIPT_SCOPE_QUERY = `
;; (#2551).
(object) @scope.object
;; Statement blocks are BINDING scopes (#2699). ECMAScript gives every block its
;; own environment record, so \`let\`/\`const\`/\`class\`/\`function\` declared in
;; sibling blocks of one function are DIFFERENT bindings — without this the
;; resolver sees both as function-level and a call in one branch resolves to
;; both. \`tsBindingScopeFor\` already implements the other half of the rule:
;; \`var\` hoists past blocks to the enclosing Function/Module, \`let\`/\`const\`
;; take the innermost scope, which is now the block.
(statement_block) @scope.block
;; Type aliases that contain an object_type are structurally class-like —
;; they define a shape with named members. Emit @scope.class so the
;; field-extractor's type-alias-with-object-type handling (in
@@ -155,9 +155,36 @@ function isStaticMember(memberNode: SyntaxNode): boolean {
return false;
}
/**
* Nodes that REBIND `this`, so the walk for an enclosing type must stop at them.
*
* Without these the walk ran to the top of the file and happily synthesized a binding
* from a type that does not own the member. An object-literal method nested in a class
* bound `this` to the CLASS:
*
* class A { outer() { const o = { inner() { return this.x; } }; return o; } }
*
* `this` inside `o.inner` is `o`, never `A` — 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.
*
* An arrow is deliberately absent: it inherits `this` lexically, so the walk SHOULD pass
* through it (that is what makes a class-field arrow `m = () => this.x` resolve).
*/
export const THIS_REBINDING_BOUNDARY_TYPES: ReadonlySet<string> = new Set([
'object', // object literal — `this` is the literal, not any enclosing type
'function_declaration',
'function_expression',
'generator_function',
'generator_function_declaration',
]);
function findEnclosingType(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
// Boundary before the type check: a rebinding node between the member and a type
// means the type does not own this `this`.
if (THIS_REBINDING_BOUNDARY_TYPES.has(cur.type)) return null;
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
cur = cur.parent;
}
+28 -2
View File
@@ -101,6 +101,7 @@ import { extractTemplateArguments } from './utils/template-arguments.js';
export type ScopeExtractorHooks = Pick<
LanguageProvider,
| 'resolveScopeKind'
| 'scopeOwnsReceivers'
| 'bindingScopeFor'
| 'interpretImport'
| 'interpretTypeBinding'
@@ -137,7 +138,18 @@ export function extract(
for (let i = 0; i < scopeDrafts.length; i++) {
const d = scopeDrafts[i];
if (d.parent === null && d.kind !== 'Module') {
scopeDrafts[i] = makeDraft(d.id, moduleScope.id, d.kind, d.range, d.filePath);
// `ownsReceivers` must be carried across: it is decided from the scope's
// own capture in pass 1 and re-parenting does not change what the scope
// binds. Dropping it here would silently un-mark every function scope in
// a file whose root parsed as ERROR (the only way a scope is orphaned).
scopeDrafts[i] = makeDraft(
d.id,
moduleScope.id,
d.kind,
d.range,
d.filePath,
d.ownsReceivers,
);
}
}
const scopes = scopeDrafts.map(draftToScope);
@@ -301,6 +313,8 @@ interface ScopeDraft {
readonly ownedDefs: SymbolDefinition[];
readonly imports: ImportEdge[];
readonly typeBindings: Map<string, TypeRef>;
/** See `Scope.ownsReceivers` — set once at pass 1, never mutated. */
readonly ownsReceivers?: ReadonlySet<string>;
}
function ensureModuleScope(
@@ -356,6 +370,7 @@ function draftToScope(draft: ScopeDraft): Scope {
ownedDefs: Object.freeze(draft.ownedDefs.slice()),
imports: Object.freeze(draft.imports.slice()),
typeBindings: new Map(draft.typeBindings),
ownsReceivers: draft.ownsReceivers,
};
}
@@ -424,7 +439,16 @@ function pass1BuildScopes(
}
const parent = stack.length > 0 ? stack[stack.length - 1]!.id : null;
drafts.push(makeDraft(cand.id, parent, cand.kind, cand.range, filePath));
drafts.push(
makeDraft(
cand.id,
parent,
cand.kind,
cand.range,
filePath,
provider.scopeOwnsReceivers?.(cand.match),
),
);
stack.push(cand);
}
@@ -468,6 +492,7 @@ function makeDraft(
kind: ScopeKind,
range: Range,
filePath: string,
ownsReceivers?: ReadonlySet<string>,
): ScopeDraft {
return {
id,
@@ -479,6 +504,7 @@ function makeDraft(
ownedDefs: [],
imports: [],
typeBindings: new Map(),
ownsReceivers,
};
}
@@ -20,7 +20,14 @@
import type { NodeLabel, ParameterTypeClass, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import {
AMBIGUOUS_POSITION,
localNameKey,
positionKey,
qualifiedKey,
simpleKey,
type GraphNodeLookup,
} from '../graph-bridge/node-lookup.js';
import { isOverloadableCallable } from '../../utils/callable-labels.js';
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
import { parameterShapeIdTag } from '../../utils/method-props.js';
@@ -109,9 +116,38 @@ function pickCallerCallableDef(
* resolution working for languages that don't yet synthesize
* qualifiers).
*/
/**
* Extract the 1-based declaration line from a scope-resolution def id.
* Shape: `def:<filePath>#<line>:<col>:<...>`; `undefined` when it doesn't match.
*/
function defStartLine(nodeId: string | undefined): number | undefined {
if (nodeId === undefined) return undefined;
const m = nodeId.match(/#(\d+):(\d+):/);
if (m === null) return undefined;
const line = Number(m[1]);
return Number.isFinite(line) ? line : undefined;
}
/**
* Trailing segment of a dotted qualified name (`Outer.inner` -> `inner`),
* with any function-local `@line:col` identity suffix stripped
* (`run.pick@5:10` -> `pick`).
*
* The graph node's `name` property is the bare source name, so the position
* key must compare against that — the position it carries is already the
* disambiguator, and leaving the suffix on would make every local miss.
*/
function simpleNameOf(qualifiedName: string): string {
const dot = qualifiedName.lastIndexOf('.');
const tail = dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
return tail.replace(/@\d+:\d+$/, '');
}
export function resolveDefGraphId(
filePath: string,
def: {
/** Scope-resolution def id — carries the declaration position (#2699). */
nodeId?: string;
qualifiedName?: string;
type?: NodeLabel;
parameterTypes?: readonly string[];
@@ -127,6 +163,31 @@ export function resolveDefGraphId(
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
if (def.type !== undefined) {
// Position key FIRST (#2699). A def and its graph node are the same
// construct, so they share a source line — the only evidence that
// separates a function-local declaration from a same-named file-level one
// without either side having to model the scope chain. Node ids are
// 0-based, def ids 1-based. An `AMBIGUOUS_POSITION` tombstone (two
// callables on one line) falls through to the name-based keys below.
const line = defStartLine(def.nodeId);
if (line !== undefined && isOverloadableCallable(def.type)) {
const simple = simpleNameOf(qn);
const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple));
if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit;
// FAIL CLOSED when a function-local of this name exists in the file (#2699
// follow-up). Falling through to the name keys would end at the label-agnostic,
// first-write-wins `simpleKey` below and alias this def onto whichever same-named
// callable was registered first — reproducibly minting a FALSE edge for a
// multiline `const pick =` (the declaration and its initializer land on different
// lines, so the position join misses). A missing edge is the correct failure
// direction for a graph whose consumers include `impact`; a fabricated caller is
// not. Gated on `localNameKey` so this ONLY fires where the collision is real —
// a file with no such local keeps its previous fallback behaviour, which is what
// preserves legitimate anchor differences such as a Vue SFC's `lineOffset`.
if (nodeLookup.get(localNameKey(filePath, def.type, simple)) !== undefined) {
return undefined;
}
}
// SFINAE / `requires`-clause disambiguation (issue #1579) — try the
// constraint-fingerprinted key FIRST. Two function-template overloads
// with identical `parameterTypes` but mutually-exclusive SFINAE
@@ -67,6 +67,59 @@ export function simpleKey(filePath: string, name: string): string {
return `${filePath}::${name}`;
}
/**
* Position key: `(filePath, label, 0-based startLine, simple name)` (#2699).
*
* The strongest evidence there is, and the only one that needs no name
* qualification at all — a definition and its graph node are the same
* construct, so they share a source position. That makes it correct for
* exactly the cases a name-based key cannot express: a function-local
* declaration shadowing a file-level one, a local inside an ANONYMOUS
* function (no name to qualify with), and two same-named declarations in
* sibling blocks. ECMAScript gives each of those its own environment record;
* position is what distinguishes them without having to model the chain.
*
* Registered only for callable labels, and only when the (line, name) pair is
* unique in the file — a genuine tie (overloads declared on one line) stores
* the `AMBIGUOUS_POSITION` tombstone so the caller falls through to the
* name-based keys rather than picking by source order.
*/
export function positionKey(
filePath: string,
label: NodeLabel,
startLine: number,
name: string,
): string {
return `<p>:${filePath}::${label}::${startLine}::${name}`;
}
/**
* Key recording that a FUNCTION-LOCAL callable with this simple name exists in the
* file (#2699 follow-up).
*
* `resolveDefGraphId`'s last resort is a label-agnostic, first-write-wins
* `simpleKey(filePath, simpleName)`. That is safe while at most one callable in a file
* carries a given simple name — but #2699 deliberately creates function-locals that
* share a name with a file-level callable, and the local's graph node is keyed by
* position (`run.pick@1:2`) while the scope def is not. When the position join misses —
* the two id phases anchor on different nodes, so a multiline `const pick =` puts the
* declaration and its initializer on different lines — the simple-name fallback aliases
* the local onto whichever same-named callable was registered FIRST and mints a
* fabricated edge. That is the exact failure class #2693 already shipped once.
*
* This lets the resolver fail CLOSED for precisely that case and only that case: if a
* local of this name exists, a position miss is a genuine ambiguity rather than a lookup
* gap, so emitting no edge is correct. Files with no such local are untouched, which
* keeps legitimate anchor differences (e.g. a Vue SFC `lineOffset`) resolving through the
* name keys exactly as before.
*/
export function localNameKey(filePath: string, label: NodeLabel, name: string): string {
return `<l>:${filePath}::${label}::${name}`;
}
/** Tombstone for a position claimed by two nodes — see `positionKey`. */
export const AMBIGUOUS_POSITION = '';
export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
const lookup = new Map<string, string>();
for (const node of graph.iterNodes()) {
@@ -79,6 +132,21 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
// Position key (#2699) — see `positionKey`. Second write on a key marks it
// ambiguous rather than letting source order decide.
const startLine = (props as { startLine?: number }).startLine;
if (startLine !== undefined && isOverloadableCallable(node.label)) {
const posK = positionKey(props.filePath, node.label, startLine, props.name);
lookup.set(posK, lookup.has(posK) ? AMBIGUOUS_POSITION : node.id);
// A local-identity node carries `@<row>:<col>` on its last name segment. Record
// that a local of this simple name exists, so the resolver can fail closed on a
// position miss instead of aliasing through the simple-name fallback.
const qualForLocal = parseQualifiedFromId(node.id, node.label, props.filePath);
if (qualForLocal !== undefined && /@\d+:\d+$/.test(qualForLocal)) {
lookup.set(localNameKey(props.filePath, node.label, props.name), node.id);
}
}
// Primary key: fully-qualified name + label, in a separate
// keyspace from simple names. Class nodes carry `qualifiedName`
// in their properties (set by the parsing processor).
@@ -10,6 +10,7 @@ import type {
CallableFlowInvokeSite,
CallableFlowOperand,
CallableFlowSite,
NodeLabel,
ParsedFile,
ScopeId,
SymbolDefinition,
@@ -19,7 +20,11 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { CalleeIdAccumulator } from '../graph-bridge/callee-id-sink.js';
import { tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import {
resolveCallerGraphId,
resolveDefGraphId,
simpleQualifiedName,
} from '../graph-bridge/ids.js';
import { resolveInheritanceBaseInScope } from '../scope/walkers.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
@@ -738,28 +743,105 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
return { emitted, resolvedInvokes, ambiguousInvokes, unmatchedInvokes, iterations };
}
function buildGraphTargetIndex(
/**
* Pure — exported for `bench/callable-value-flow/measure.mjs`, which guards its
* scaling ratio and result fingerprint. Not part of the pass's public contract.
*/
export function buildGraphTargetIndex(
scopes: ScopeResolutionIndexes,
nodeLookup: GraphNodeLookup,
providerTarget: ((def: SymbolDefinition) => boolean) | undefined,
graph: KnowledgeGraph,
): ReadonlyMap<string, Target> {
const out = new Map<string, Target>();
const byAnchor = buildGraphCallableAnchorIndex(graph);
const { byAnchor, callableByPosition } = buildGraphCallableIndexes(graph);
for (const def of scopes.defs.byId.values()) {
if (!isCallable(def) && providerTarget?.(def) !== true) continue;
const callableDef = isCallable(def) || providerTarget?.(def) === true;
if (!callableDef) {
// #2693: a closure bound to a name (`val f = { }`) is a callable the def
// type cannot see — the scope layer declares it with its VALUE label
// (Kotlin/Swift `Property`, Dart `Variable`) while #2687 makes the graph
// emit a single callable node for it. Only the graph knows.
//
// The join MUST be positional. Resolving such a def through
// `resolveDefGraphId` is unsafe: every qualified key it builds embeds
// `def.type`, so for a value def they can only ever hit a value-labelled
// node — and when the def's own node is absent (Rust `let`) or carries a
// DIFFERENT label than the def's type (TypeScript declares `const` as
// `Variable` but emits a `Const` node), the chain falls through to the
// label-agnostic, first-write-wins `simpleKey(filePath, simpleName)`.
// That aliases the binding onto ANY same-named callable in the file —
// `const save = cb` next to an unrelated `Svc.save` mints a CALLS edge to
// the method, and the result depends on declaration order.
//
// A closure binding IS its callable node: same file, same line, same
// name. An aliasing local is not. So look the node up by position and
// admit only an exact hit.
const positionalKey = valueBindingPositionKey(def);
const positionalId =
positionalKey === undefined ? undefined : callableByPosition.get(positionalKey);
// `''` marks an ambiguous position (two callables claiming one
// file/line/name) — undecidable, so admit neither.
if (positionalId === undefined || positionalId === '') continue;
out.set(def.nodeId, { id: positionalId, def });
continue;
}
const anchorKey = definitionAnchorKey(def);
const anchored = anchorKey === undefined ? undefined : byAnchor.get(anchorKey);
const id =
anchored?.length === 1 ? anchored[0] : resolveDefGraphId(def.filePath, def, nodeLookup);
if (id === undefined) continue;
// Overloads can intentionally share one graph node ID. Index by the
// definition identity so contextual signature narrowing still sees the
// complete overload set before a selected target collapses to graph ID.
if (id !== undefined) out.set(def.nodeId, { id, def });
out.set(def.nodeId, { id, def });
}
return out;
}
/**
* Leading sigils are part of a name in some grammars and stripped in others:
* PHP keeps `$` on a variable_name node (deliberately — it is what separates
* PHP's variable and function namespaces, so `$save` does not collide with
* `save()`), while the scope layer and the callable-flow synthesizer both
* normalise it away. The positional join has to see both sides the same way.
*/
const withoutSigil = (name: string): string => name.replace(/^[$@]+/, '');
/**
* `file\0line\0name` for a value binding, matching the callable-node key built
* in `buildGraphCallableIndexes`. Definition lines come from the def id and are
* 1-based; graph `startLine` is 0-based, which is the `+ 1` there.
*/
function valueBindingPositionKey(def: SymbolDefinition): string | undefined {
if (!VALUE_BINDING_DEF_TYPES.has(def.type)) return undefined;
const line = def.nodeId.match(/#(\d+):(\d+):/)?.[1];
const name = simpleQualifiedName(def);
if (line === undefined || name === undefined) return undefined;
return `${def.filePath}\0${line}\0${withoutSigil(name)}`;
}
/**
* Value-binding labels whose initializer can be a callable. Admitted ONLY on
* positional graph-node evidence (see `buildGraphTargetIndex`), never on the def
* type alone.
*
* Deliberately NOT `isOwnableValueLabel` (scope/walkers.ts), which lists the
* same labels for the value-receiver bridge: that predicate is contracted to
* `reconcileOwnership`, and coupling the two would let a label added for
* ownership silently widen call-target admission. Two lists, two reasons —
* changing either means checking the other.
*
* `Static` is excluded: `normalizeNodeLabel` (scope-extractor.ts) has no
* `static` case, so no scope-resolution def can carry that type. Including it
* added an entry no fixture could ever exercise.
*/
const VALUE_BINDING_DEF_TYPES: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
'Const',
'Property',
'Variable',
]);
interface CanonicalCallableTargets {
/** Definition identity remains the key; declaration keys may point at the definition target. */
readonly targets: ReadonlyMap<string, Target>;
@@ -874,10 +956,24 @@ function declarationSignatureCompatible(
return typeof declarationConst !== 'boolean' || declarationConst === definitionConst;
}
function buildGraphCallableAnchorIndex(
graph: KnowledgeGraph,
): ReadonlyMap<string, readonly string[]> {
const out = new Map<string, string[]>();
interface GraphCallableIndexes {
/** Callable graph nodes by `file\0label\0line\0name` — the definition anchor. */
readonly byAnchor: ReadonlyMap<string, readonly string[]>;
/**
* Callable graph nodes by `file\0line\0name` — the same anchor WITHOUT the
* label, because a value binding's def type never matches its callable node's
* label (that is the whole point of #2693). Value = the node id, or `''` when
* two callables claim one position and the join is undecidable.
*
* Derived in the same walk as `byAnchor`: a second pass over the graph for
* the same nodes would double the cost of the largest loop in this pass.
*/
readonly callableByPosition: ReadonlyMap<string, string>;
}
function buildGraphCallableIndexes(graph: KnowledgeGraph): GraphCallableIndexes {
const byAnchor = new Map<string, string[]>();
const callableByPosition = new Map<string, string>();
for (const node of graph.iterNodes()) {
if (node.label !== 'Function' && node.label !== 'Method' && node.label !== 'Constructor') {
continue;
@@ -892,12 +988,18 @@ function buildGraphCallableAnchorIndex(
) {
continue;
}
const key = `${filePath}\0${node.label}\0${zeroBasedLine + 1}\0${name}`;
const bucket = out.get(key);
if (bucket === undefined) out.set(key, [node.id]);
const oneBasedLine = zeroBasedLine + 1;
const positionKey = `${filePath}\0${oneBasedLine}\0${withoutSigil(name)}`;
const existing = callableByPosition.get(positionKey);
// First wins would be order-dependent; mark the collision instead so an
// ambiguous position admits nothing rather than something arbitrary.
callableByPosition.set(positionKey, existing === undefined ? node.id : '');
const key = `${filePath}\0${node.label}\0${oneBasedLine}\0${name}`;
const bucket = byAnchor.get(key);
if (bucket === undefined) byAnchor.set(key, [node.id]);
else bucket.push(node.id);
}
return out;
return { byAnchor, callableByPosition };
}
function definitionAnchorKey(def: SymbolDefinition): string | undefined {
@@ -55,6 +55,7 @@ import { collectNamespaceTargets } from '../scope/namespace-targets.js';
import {
findClassBindingInScope,
findEnclosingClassDef,
isReceiverOwnedButUnbound,
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
@@ -270,6 +271,28 @@ export function emitReceiverBoundCalls(
const memberName = site.name;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
// ── owned-but-unbound receiver ───────────────────────────────
// The language declared this scope REBINDS the receiver and gave
// it no type — a JS/TS ordinary `function`, whose `this` comes
// from the call site (#2701). No enclosing type can be its type,
// so this is a definitive negative, not a miss: suppress the site
// instead of letting the receiver-blind lexical fallback in
// `lookupCore` match the enclosing class's member by name.
// No-op for every language that leaves `Scope.ownsReceivers` unset.
if (isReceiverOwnedButUnbound(site.inScope, receiverName, scopes)) {
options.recordResolutionOutcome?.({
kind: 'suppressed',
phase: 'receiver-bound-calls',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'receiver-owned-but-unbound',
candidateIds: [],
});
handledSites.add(siteKey);
continue;
}
// ── super branch ─────────────────────────────────────────────
// Languages with caller-context-dependent super classification
// (C++) define `isSuperReceiverInContext`; we prefer it. Simple
@@ -8,7 +8,11 @@ export type ResolutionSuppressionReason =
| 'selected-callable-deleted'
| 'overload-ambiguous'
| 'overload-ambiguous-normalization'
| 'free-call-instance-ownership';
| 'free-call-instance-ownership'
/** #2701 — the receiver is rebound by its own scope and has no type
* there (a JS/TS ordinary `function`'s `this`), so no enclosing type
* can be its type. See `isReceiverOwnedButUnbound`. */
| 'receiver-owned-but-unbound';
export type ResolutionOutcome =
| {
@@ -179,7 +179,54 @@ export function isClassLike(t: string): boolean {
* Walk the scope chain from `startScope` looking for a typeBinding
* named `receiverName`. Returns the TypeRef or undefined if no binding
* exists in the chain.
*
* A scope that declares `ownsReceivers.has(receiverName)` terminates the
* walk with `undefined` (#2701): it binds that receiver itself, so an
* enclosing scope's binding is not visible through it. The check runs
* AFTER this scope's own `typeBindings`, so a scope that both owns and
* binds the receiver — a class method, which is where `this` is bound TO
* the class — still resolves normally. The namespace/global fallbacks
* below are also skipped: they answer "which type is named X", which is a
* different question from "what is this scope's receiver", and reaching
* them for an owned-but-unbound receiver is how a static method or a
* detached callback acquires a fabricated one.
*/
/**
* True when `receiverName` is DEFINITIVELY unresolvable at `startScope`:
* a scope on the chain declares it owns that receiver (`Scope.ownsReceivers`)
* and carries no type binding for it (#2701).
*
* This is a stronger statement than `findReceiverTypeBinding` returning
* `undefined`, which only means "no type found" — an ordinary miss that later
* passes are free to resolve by other means. Here the language has said the
* receiver is REBOUND at this scope, so no enclosing type can be its type:
* `this.m()` inside a nested JS/TS `function` is a call on whatever the
* function is invoked with, which the graph does not model. A member call
* whose receiver is unresolvable in this sense must be suppressed rather
* than left to the receiver-blind lexical fallback in `lookupCore`, which
* would find the enclosing class's member by name alone.
*
* Returns false for every language that leaves `ownsReceivers` unset.
*/
export function isReceiverOwnedButUnbound(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
): boolean {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return false;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return false;
if (scope.typeBindings.has(receiverName)) return false;
if (scope.ownsReceivers?.has(receiverName) === true) return true;
currentId = scope.parent;
}
return false;
}
export function findReceiverTypeBinding(
startScope: ScopeId,
receiverName: string,
@@ -195,6 +242,7 @@ export function findReceiverTypeBinding(
if (scope === undefined) return undefined;
const typeRef = scope.typeBindings.get(receiverName);
if (typeRef !== undefined) return typeRef;
if (scope.ownsReceivers?.has(receiverName) === true) return undefined;
if (scope.kind === 'Module') moduleScopeId = currentId;
currentId = scope.parent;
}
@@ -71,6 +71,33 @@ export const TYPESCRIPT_QUERIES = `
name: (identifier) @name
value: (function_expression)))) @definition.function
; \`var\` closure bindings (#2693). The lexical rules above cover const/let;
; \`var\` is a different grammar node, so \`var f = (x) => x\` kept a Variable
; label while const/let got Function — and the CALLS edge that resolved through
; the declaration route therefore pointed at a NON-callable node. Same construct,
; same binding semantics for this purpose, so same label.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (arrow_function))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (function_expression))) @definition.function
(export_statement
declaration: (variable_declaration
(variable_declarator
name: (identifier) @name
value: (arrow_function)))) @definition.function
(export_statement
declaration: (variable_declaration
(variable_declarator
name: (identifier) @name
value: (function_expression)))) @definition.function
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
; The pair's key field carries the meaningful name. Without these patterns,
; calls inside the arrow are attributed to the file (issue #1166), and the
@@ -315,6 +342,25 @@ export const TYPESCRIPT_QUERIES = `
(public_field_definition
name: (private_property_identifier) @name) @definition.property
; Closure-valued class fields (#2693): \`handler = (x) => x\` is a CALLABLE
; member, so it emits Method like every other closure binding rather than a
; Property that CALLS edges would point at — a call target must be callable.
; Kotlin already models its class-body closure this way (Method + HAS_METHOD).
;
; Note this diverges from tsc's SymbolFlags and SCIP's descriptor, which both
; class an arrow-initialized field as a PROPERTY/term. That is deliberate: the
; label here means "is a call target", not "is a tsc symbol kind", and #2687 set
; that convention for closure bindings in every language. Anchored on
; public_field_definition — the same node the property rules use — so the
; parse-worker dedup collapses the pair (callable ranks highest).
(public_field_definition
name: (property_identifier) @name
value: (arrow_function)) @definition.method
(public_field_definition
name: (property_identifier) @name
value: (function_expression)) @definition.method
; Constructor parameter properties: constructor(public address: Address)
(required_parameter
(accessibility_modifier)
@@ -409,6 +455,33 @@ export const JAVASCRIPT_QUERIES = `
name: (identifier) @name
value: (function_expression)))) @definition.function
; \`var\` closure bindings (#2693). The lexical rules above cover const/let;
; \`var\` is a different grammar node, so \`var f = (x) => x\` kept a Variable
; label while const/let got Function — and the CALLS edge that resolved through
; the declaration route therefore pointed at a NON-callable node. Same construct,
; same binding semantics for this purpose, so same label.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (arrow_function))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (function_expression))) @definition.function
(export_statement
declaration: (variable_declaration
(variable_declarator
name: (identifier) @name
value: (arrow_function)))) @definition.function
(export_statement
declaration: (variable_declaration
(variable_declarator
name: (identifier) @name
value: (function_expression)))) @definition.function
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
; See TYPESCRIPT_QUERIES for rationale (issue #1166).
(pair
@@ -613,6 +686,16 @@ export const JAVASCRIPT_QUERIES = `
(field_definition
property: (property_identifier) @name) @definition.property
; Closure-valued class fields (#2693) — see the TypeScript block for why these
; are Method rather than Property.
(field_definition
property: (property_identifier) @name
value: (arrow_function)) @definition.method
(field_definition
property: (property_identifier) @name
value: (function_expression)) @definition.method
; Write access: obj.field = value
(assignment_expression
left: (member_expression
@@ -800,6 +883,27 @@ export const JAVA_QUERIES = `
object: (_) @assignment.receiver
field: (identifier) @assignment.property)
right: (_)) @assignment
; ── Closure bindings (#2693) ────────────────────────────────────────────────
; A name bound to a closure literal IS a callable, so it emits Function rather
; than a value label — matching TS/JS and the languages #2687 already covered.
; The callable node is what callable-value-flow joins the binding to (by file,
; line and name), which is what makes handler.apply(1) resolve. Overlap with the value
; rules above is collapsed by the parse-worker dedup, which ranks callable
; highest (#2687).
; Anchored on field_declaration / local_variable_declaration — the SAME nodes
; the value rules above use — so the parse-worker dedup (keyed by definition
; node + name) actually collapses the pair. Anchoring on the inner
; variable_declarator instead produced a Function AND a Property twin, the exact
; double-indexing #2687 removed.
(field_declaration
declarator: (variable_declarator
name: (identifier) @name
value: (lambda_expression))) @definition.function
(local_variable_declaration
declarator: (variable_declarator
name: (identifier) @name
value: (lambda_expression))) @definition.function
`;
// C queries - works with tree-sitter-c
@@ -1152,6 +1256,17 @@ export const CSHARP_QUERIES = `
expression: (_) @assignment.receiver
name: (identifier) @assignment.property)
right: (_)) @assignment
; ── Closure bindings (#2693) ────────────────────────────────────────────────
; A name bound to a closure literal IS a callable, so it emits Function rather
; than a value label — matching TS/JS and the languages #2687 already covered.
; The callable node is what callable-value-flow joins the binding to (by file,
; line and name), which is what makes handler(1) resolve. Overlap with the value
; rules above is collapsed by the parse-worker dedup, which ranks callable
; highest (#2687).
(variable_declarator
(identifier) @name
(lambda_expression)) @definition.function
`;
// Rust queries - works with tree-sitter-rust
@@ -1306,6 +1421,28 @@ export const PHP_QUERIES = `
scope: (_) @assignment.receiver
name: (variable_name (name) @assignment.property))
right: (_)) @assignment
; ── Closure bindings (#2693) ────────────────────────────────────────────────
; A name bound to a closure literal IS a callable, so it emits Function rather
; than a value label — matching TS/JS and the languages #2687 already covered.
; The callable node is what callable-value-flow joins the binding to (by file,
; line and name), which is what makes $handler(1) resolve. Overlap with the value
; rules above is collapsed by the parse-worker dedup, which ranks callable
; highest (#2687).
; Captures the whole variable_name, so the node keeps PHP's \`$\` sigil. That is
; not cosmetic: PHP holds variables and functions in SEPARATE namespaces, so
; \`$save\` and \`save()\` can never collide in the language — but dropping the
; sigil made both mint the id Function:<file>:save, and the local closure was
; swallowed by the function's node (no node, therefore no edge). The property
; rules in languages/php/query.ts already keep the sigil for the same reason.
; The positional join normalises leading sigils, so the binding still matches
; its own declaration.
(assignment_expression
left: (variable_name) @name
right: (arrow_function)) @definition.function
(assignment_expression
left: (variable_name) @name
right: (anonymous_function)) @definition.function
`;
// Ruby queries - works with tree-sitter-ruby
@@ -1374,6 +1511,17 @@ export const RUBY_QUERIES = `
receiver: (_) @assignment.receiver
method: (identifier) @assignment.property)
right: (_)) @assignment
; ── Closure bindings (#2693) ────────────────────────────────────────────────
; A name bound to a closure literal IS a callable, so it emits Function rather
; than a value label — matching TS/JS and the languages #2687 already covered.
; The callable node is what callable-value-flow joins the binding to (by file,
; line and name), which is what makes handler.call(1) resolve. Overlap with the value
; rules above is collapsed by the parse-worker dedup, which ranks callable
; highest (#2687).
(assignment
left: (identifier) @name
right: (lambda)) @definition.function
`;
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
@@ -1719,15 +1867,47 @@ export const DART_QUERIES = `
(initialized_identifier
(identifier) @name)) @definition.variable)
; Closure bindings: \`var f = (x) => x;\` binds a CALLABLE, so it emits Function
; rather than Variable, matching TS/JS. This aligns the LABEL only — call
; resolution runs off the scope-resolution query, which still models the binding
; as a value, so \`f()\` does not resolve here yet. Overlap with the pattern
; above is collapsed by the parse-worker dedup (#2687).
; rather than Variable, matching TS/JS. Overlap with the pattern above is
; collapsed by the parse-worker dedup (#2687). Since #2693 this node is also
; what makes \`f()\` resolve: the scope-resolution query declares the binding as
; a value, and callable-value-flow admits it as a call target precisely because
; the node it resolves to is a Function.
(program
(initialized_identifier_list
(initialized_identifier
(identifier) @name
(function_expression))) @definition.function)
; ── Top-level final/const closure bindings (#2693) ──────────────────────────
; \`final handler = (x) => x;\` parses as a static_final_declaration_list, not an
; initialized_identifier_list, so the rules above never reach it — \`final\` is
; the idiomatic top-level binding keyword and was the one closure form getting
; neither the callable label nor resolution.
(program
(static_final_declaration_list
(static_final_declaration
(identifier) @name
(function_expression))) @definition.function)
; ── Function-local closure bindings (#2693) ─────────────────────────────────
; \`void m() { var f = (x) => x; }\` — locals parse as initialized_variable_
; definition, which the top-level rules above never reach, so a local closure
; had no graph node at all and \`f()\` could not resolve. Restricted to a
; function_expression value: ordinary locals stay unindexed, as before.
(initialized_variable_definition
name: (identifier) @name
value: (function_expression)) @definition.function
; Second and later declarators of a multi-name local (\`var f = .., g = ..;\`)
; are initialized_identifier children NESTED INSIDE the same
; initialized_variable_definition, which the \`name:\`/\`value:\` field rule above
; only reaches for the FIRST name — so \`g\` silently had no node. Anchored on the
; inner node so each name gets its own range; the top-level form lives under
; initialized_identifier_list instead, so these never double-match.
(initialized_variable_definition
(initialized_identifier
(identifier) @name
(function_expression)) @definition.function)
(program
(static_final_declaration_list
(static_final_declaration
+18 -5
View File
@@ -156,10 +156,11 @@ const lookupInEnv = (
filePath?: string,
) => { funcName: string | null; label: NodeLabel } | null,
filePath?: string,
thisBoundaryNodeTypes?: ReadonlySet<string>,
): string | undefined => {
// Self/this receiver: resolve to enclosing class name via AST walk
if (varName === 'self' || varName === 'this' || varName === '$this') {
return findEnclosingClassName(callNode);
return findEnclosingClassName(callNode, thisBoundaryNodeTypes);
}
// Super/base/parent receiver: resolve to the parent class name via AST walk.
@@ -215,10 +216,17 @@ const enclosingParentClassNameCache = new Map<SyntaxNode, string | undefined>();
* Used to resolve `self`/`this` receivers to their containing type.
* Memoized per-file: cache is cleared at buildTypeEnv entry.
*/
const findEnclosingClassName = (node: SyntaxNode): string | undefined => {
const findEnclosingClassName = (
node: SyntaxNode,
thisBoundaryNodeTypes?: ReadonlySet<string>,
): string | undefined => {
if (enclosingClassNameCache.has(node)) return enclosingClassNameCache.get(node);
let current = node.parent;
while (current) {
if (thisBoundaryNodeTypes?.has(current.type) === true) {
enclosingClassNameCache.set(node, undefined);
return undefined;
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
const nameNode = current.childForFieldName('name') ?? findTypeIdentifierChild(current);
if (nameNode) {
@@ -241,10 +249,14 @@ const THIS_RECEIVERS = new Set(['this', 'self', '$this', 'Me']);
* or when the receiver is not a this-keyword. Properties are readonly in the
* discriminated union, so a new object is returned when substitution occurs.
*/
const substituteThisReceiver = (item: PendingAssignment, node: SyntaxNode): PendingAssignment => {
const substituteThisReceiver = (
item: PendingAssignment,
node: SyntaxNode,
thisBoundaryNodeTypes?: ReadonlySet<string>,
): PendingAssignment => {
if (item.kind !== 'fieldAccess' && item.kind !== 'methodCallResult') return item;
if (!THIS_RECEIVERS.has(item.receiver)) return item;
const className = findEnclosingClassName(node);
const className = findEnclosingClassName(node, thisBoundaryNodeTypes);
if (!className) return item;
return { ...item, receiver: className };
};
@@ -1218,7 +1230,7 @@ export const buildTypeEnv = (
const items = Array.isArray(pending) ? pending : [pending];
for (const item of items) {
// Substitute this/self/$this/Me receivers with enclosing class name
const resolved = substituteThisReceiver(item, node);
const resolved = substituteThisReceiver(item, node, config.thisBoundaryNodeTypes);
pendingItems.push({ scope, ...resolved });
}
}
@@ -1298,6 +1310,7 @@ export const buildTypeEnv = (
options?.enclosingFunctionFinder,
extractFuncNameHook,
options?.filePath,
config.thisBoundaryNodeTypes,
),
constructorBindings: bindings,
fileScope: () => env.get(FILE_SCOPE) ?? emptyFileScope(),
@@ -142,6 +142,18 @@ export interface LanguageTypeConfig {
readonly allowPatternBindingOverwrite?: boolean;
/** Node types that represent typed declarations for this language */
declarationNodeTypes: ReadonlySet<string>;
/** Function node types that OWN their `this` receiver, terminating the
* upward AST walk that resolves `this`/`self`/`$this` to an enclosing
* class. The type-env twin of `Scope.ownsReceivers` (#2701): the scope
* layer gates receiver-type LOOKUP, this gates receiver-type INFERENCE
* during capture, and a call is only suppressed when both agree.
*
* Most languages leave it unset — their closures capture the enclosing
* `this` lexically (Kotlin lambdas, Go func literals, C# lambdas, Dart
* function expressions, PHP closures auto-bound since 5.4, Python's
* `self` closed over by a nested `def`) — so the walk is unchanged
* there. JavaScript/TypeScript are the exception. */
thisBoundaryNodeTypes?: ReadonlySet<string>;
/** Optional: language-specific way to find a declaration's type-annotation node.
* Prefer providing this for grammars where the type is wrapped (e.g., C#, Kotlin, Swift). */
getDeclarationTypeNode?: DeclarationTypeNodeLocator;
@@ -694,9 +694,36 @@ const inferTsLiteralType: LiteralTypeInferrer = (node) => {
}
};
/**
* Ordinary functions own their `this`; arrows do not (#2701).
*
* 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, so
* `this.m()` inside one does NOT reach the enclosing class. That is exactly
* the distinction `tsc` draws by resolving `this` through `getThisContainer`
* with `includeArrowFunctions = false`.
*
* `method_definition` is deliberately absent: it is the construct that binds
* `this` TO the enclosing class, so the walk must pass through it and stop at
* the class. (Unlike the scope-layer marker in `typescript/query.ts`, which
* can list it because a method's own `this` typeBinding is consulted first.)
*
* Kept in sync with `@receiver-owner.this` in `languages/typescript/query.ts`
* and `languages/javascript/query.ts` — the two layers must agree or a call
* suppressed by one is re-introduced by the other.
*/
export const THIS_BOUNDARY_NODE_TYPES: ReadonlySet<string> = new Set([
'function_declaration',
'function_expression',
'generator_function',
'generator_function_declaration',
]);
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
thisBoundaryNodeTypes: THIS_BOUNDARY_NODE_TYPES,
patternBindingNodeTypes: new Set(['binary_expression']),
extractDeclaration,
extractParameter,
@@ -6,6 +6,53 @@
* callable signatures, protocol invocation names). The central extractor
* never sees a parser node and shared ingestion code never branches on a
* language name.
*
* ## The cell/site model
*
* A *cell* is a named storage location that may hold a callable — a variable,
* parameter, field or pointer, canonicalized to a binding key by the scope
* tree. A *site* is one observed fact about cells, emitted here as a capture
* match and consumed by `passes/callable-value-flow.ts`, which runs them to a
* fixpoint. The site kinds (`CallableFlowSite`) are:
*
* - `seed` — a cell acquires a named callable: `f = target`. Carries
* `@callable-flow.target-name`, the name the pass resolves against.
* - `copy` / `alias` — a cell takes another cell's contents, so targets flow
* between them.
* - `address` / `store` / `load` — indirection through a pointer cell.
* - `formal` — a parameter cell of a known function, by index.
* - `argument` — a callable passed at a call site, binding to that `formal`.
* - `invoke` — a call THROUGH a cell (`f()`), the site that ultimately becomes
* a `CALLS` edge once the cell's target set is known.
*
* ## The anonymous-callable convention
*
* A closure literal has no name to resolve against, so a `seed` whose source
* is an anonymous callable takes its **destination's** name as
* `@callable-flow.target-name` — `val f = { }` seeds "the cell `f` holds the
* callable named `f`". That is deliberately self-referential and only resolves
* because the binding itself is a callable target: `buildGraphTargetIndex`
* admits it on the label of the graph node it resolves to, which #2687 makes a
* `Function` for exactly this construct. Languages whose closure binding does
* NOT emit a callable graph node get no resolution from the convention alone
* (#2693).
*
* ## Adding a language
*
* Supply `CallableFlowCaptureOptions` from `<lang>/captures.ts` and call
* `synthesizeCallableFlowCaptures`. Two recurring traps:
*
* - A **fieldless** assignment/binding node decomposes to nothing under the
* shared `left`/`name`/`value` fallback in `assignmentParts`. Supply
* `extractAssignment` (Kotlin's `assignment`, Dart's
* `initialized_identifier`). Returning `undefined` falls back to the shared
* path, so one callback can handle the odd node and leave the rest alone.
* - A binding needs a `SymbolDefinition` for the pass to attach to. Captures
* alone are not enough: without a `@declaration.*` for the bound name, the
* seed has no cell to key on.
*
* `c/captures.ts` is the fullest worked example (pointers, signatures,
* overload selection); `dart/captures.ts` the smallest interesting one.
*/
import type { CaptureMatch, ParameterTypeClass } from 'gitnexus-shared';
@@ -741,6 +741,165 @@ function getMethodInfo(
// Enclosing function detection (for call extraction) — cached
// ============================================================================
/**
* Qualified-name prefix naming the enclosing CALLABLE chain of `node`, or
* `undefined` when nothing callable encloses it (#2699).
*
* Graph node ids are file-scoped, so before this a function-local callable and
* a file-level one with the same name collapsed onto a single node: a
* top-level `save()` and `run() { const save = … }` both keyed
* `Function:<file>:save`, and `run`'s call to its OWN local was attributed to
* the top-level function — a wrong edge, not a missing one, so `impact` on
* `save` reported a caller that never calls it. Qualifying the local as
* `run.save` separates them, mirroring how class members already qualify as
* `Class.member` (and SCIP's document-scoped `local <id>` keyspace).
*
* **This pair is the lockstep guarantee.** The definition phase and the
* caller-attribution phase (`findEnclosingFunctionId`) each build ids
* independently, and an id they compute differently is not a test failure —
* it is a caller silently attaching to a node that does not exist, and the
* edge vanishing. Both phases therefore derive the nesting prefix from THIS
* function and nothing else. Keep it that way: any per-phase variation here
* fails silently.
*
* Only a callable that is genuinely nested inside another callable gains a
* prefix. Top-level functions and ordinary class methods hit the `null` branch
* and keep their existing ids byte-for-byte, which is what bounds the id churn
* this change forces.
*
* `localIdentity` below completes it. The name chain alone is not enough, and
* the gap is the language's, not the grammar's: ECMAScript creates an
* environment record per function AND per block, so sibling blocks in one
* function hold genuinely different bindings —
*
* function outer(a) {
* if (a) { const pick = …; return pick(1); } // one binding
* else { const pick = …; return pick(2); } // a DIFFERENT binding
* }
*
* — and both are `outer.pick` by name. Putting a block token in the qualifier
* would tag every local inside any `if`, the common case, and buy nothing over
* putting the position on the declaration itself: a declaration's own position
* is unique across every environment record it could belong to, without the
* qualifier having to enumerate them. One rule, no conditionals, O(1).
*
* Applied ONLY to locals. Top-level functions and class methods keep their
* bare/class-qualified ids, which is what keeps this off the symbols other
* files, saved queries and stored references actually address.
*/
const localIdentity = (node: SyntaxNode, name: string): string =>
`${name}@${node.startPosition.row}:${node.startPosition.column}`;
/**
* Boundary for the enclosing-callable walk (#2699).
*
* `CLASS_CONTAINER_TYPES` lists class DECLARATIONS only. A class can also own
* members without any declaration node — Java anonymous classes
* (`object_creation_expression > class_body`), enum-constant bodies, and
* interface/annotation bodies — and those owners must still stop the walk, or a
* member of one gets re-keyed as a function-local of the surrounding method.
*
* (The dead `NO_QUALIFIED_NAME` sentinel that used to sit below this — which also
* contained a literal NUL byte — was removed; the cache is two-state: absent =
* not yet computed, any string = computed.)
*/
const CALLABLE_PREFIX_BOUNDARY_TYPES: ReadonlySet<string> = new Set<string>([
...CLASS_CONTAINER_TYPES,
// Class bodies (Java, JS/TS, Kotlin) — the owner when the declaration is
// anonymous or the grammar nests members under a body node.
'class_body',
'interface_body',
'annotation_type_body',
'enum_body',
'enum_body_declarations',
'enum_constant',
// Anonymous-class construction sites.
'object_creation_expression', // Java: new Runnable() { ... }
'object_literal', // Kotlin: object : Runnable { ... }
'anonymous_object_creation_expression', // C#
]);
const enclosingCallablePrefix = (
node: SyntaxNode,
filePath: string,
provider: LanguageProvider,
): string | undefined => {
// Boundary on class-likes: a method's owner is its CLASS, not whatever
// function that class happens to sit inside. `CLASS_CONTAINER_TYPES` alone is
// NOT enough for that — it lists only DECLARATION nodes, and an anonymous or
// body-form class has none. A Java anonymous class is
// `object_creation_expression > class_body > method_declaration` with no
// `class_declaration` anywhere, so the walk sailed straight through it to the
// enclosing method and re-keyed `Worker$1.run` as `Worker.makeHandler.run@7:12`,
// destroying the javac-compatible JLS identity of #2550/#2555/#2562 (4 existing
// Java tests). Adding the body/anonymous forms restores the boundary.
//
// Over-inclusion here is the SAFE direction: an extra boundary only suppresses
// the nesting prefix, which falls back to the pre-#2699 class qualification.
const fnNode = findAncestorBeforeBoundary(
node,
LOCAL_SCOPE_BODY_NODE_TYPES,
CALLABLE_PREFIX_BOUNDARY_TYPES,
);
if (fnNode === null) return undefined;
return callableOwnQualifiedName(fnNode, filePath, provider);
};
/**
* A callable node's own qualified name, including its enclosing-callable chain.
* Mutually recursive with `enclosingCallablePrefix`; recursion depth is source
* nesting depth and every level is memoized, so a file costs O(callables).
*
* An ANONYMOUS callable still gets a name — its own source position
* (`fn@12:9`). ECMAScript creates an environment record for EVERY function
* whether or not it has a name, so the `save` in
* `outer() { (function () { const save = … })() }` is a genuinely distinct
* binding from a file-level `save`. Name-only qualification cannot express
* that; position can. It is unique by construction (two functions cannot start
* at the same offset) and deterministic across reparses of the same source.
* Same reasoning as clang's USR for a function-local (`name@offset`) and
* Kythe's C++ indexer: a local is not addressable from outside its document,
* so its identity only has to be unique within it, and source position is the
* cheapest thing that is. NOT SCIP — SCIP's `local <id>` is a per-document
* counter and the spec is explicit that locals do not encode the name, so it
* is prior art for the document-scoped keyspace but not for this key shape.
*/
const callableOwnQualifiedName = (
fnNode: SyntaxNode,
filePath: string,
provider: LanguageProvider,
): string => {
const cached = callableQualifiedNameCache.get(fnNode);
if (cached !== undefined) return cached;
const efnResult = provider.methodExtractor?.extractFunctionName?.(fnNode, filePath);
// An anonymous callable has no name of its own, so it IS its position —
// `localIdentity` supplies the same suffix the local branch below appends,
// and the two must not stack.
const ownName = efnResult?.funcName ?? genericFuncName(fnNode) ?? null;
const prefix = enclosingCallablePrefix(fnNode, filePath, provider);
const classInfo =
prefix === undefined
? cachedFindEnclosingClassInfo(fnNode, filePath, provider.resolveEnclosingOwner)
: null;
const owner = prefix ?? classInfo?.className;
const localName = localIdentity(fnNode, ownName ?? 'fn');
const result =
prefix !== undefined
? `${prefix}.${localName}`
: ownName === null
? localName
: owner
? `${owner}.${ownName}`
: ownName;
callableQualifiedNameCache.set(fnNode, result);
return result;
};
/** Sentinel distinguishing "computed, anonymous" from "not yet computed". */
const callableQualifiedNameCache = new WeakMap<SyntaxNode, string>();
/** Walk up AST to find enclosing function, return its generateId or null for top-level.
* Applies provider.labelOverride so the label matches the definition phase (single source of truth). */
const findEnclosingFunctionId = (
@@ -781,7 +940,13 @@ const findEnclosingFunctionId = (
language: encLang,
})
: null;
const ownerName = classInfo?.className ?? standaloneMethodInfo?.receiverType ?? undefined;
// A nested callable is qualified by its enclosing callable (#2699) and
// wins over the class/receiver owner: a closure inside a method belongs
// to the METHOD, not directly to the class, and a Go receiver method can
// never itself be nested inside another callable.
const nestedPrefix = enclosingCallablePrefix(current, filePath, provider);
const ownerName =
nestedPrefix ?? classInfo?.className ?? standaloneMethodInfo?.receiverType ?? undefined;
const qualifiedName = ownerName ? `${ownerName}.${funcName}` : funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
@@ -840,8 +1005,18 @@ const findEnclosingFunctionId = (
filePath,
provider.resolveEnclosingOwner,
);
const qualifiedName = classInfo
? `${classInfo.className}.${customResult.funcName}`
// Same nesting rule as the generic branch above (#2699). Anchored on
// `sigNode`-equivalent (`current.previousSibling ?? current`) so Dart,
// whose body is a SIBLING of the signature, walks from the same node
// the class lookup already uses.
const nestedPrefix2 = enclosingCallablePrefix(
current.previousSibling ?? current,
filePath,
provider,
);
const customOwner = nestedPrefix2 ?? classInfo?.className;
const qualifiedName = customOwner
? `${customOwner}.${customResult.funcName}`
: customResult.funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// When same-arity collisions exist, also append ~type1,type2.
@@ -2115,6 +2290,19 @@ const processFileGroup = (
// #1982: LOCKSTEP with parsing-processor.ts — a Rust inherent-impl with an
// UNSCOPED bare target is keyed by the enclosing `mod_item` scope so the
// worker-path Impl node id matches the sequential path and the owner walk.
// #2699: a callable nested inside another callable is qualified by the
// enclosing callable, so a function-local closure stops colliding with a
// same-named file-level function. Restricted to CALLABLE labels: the
// collision that produced wrong CALLS edges is between callables, and
// widening it to every function-local Variable/Property would churn ids
// for symbols the local-symbol pruner mostly deletes anyway.
// Same helper as the caller-attribution phase — see `enclosingCallablePrefix`.
const nestedCallablePrefix =
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') &&
definitionNode
? enclosingCallablePrefix(definitionNode, file.path, provider)
: undefined;
const rustImplQualifiedName =
nodeLabel === 'Impl' &&
definitionNode?.type === 'impl_item' &&
@@ -2131,9 +2319,11 @@ const processFileGroup = (
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
: nestedCallablePrefix !== undefined && definitionNode
? `${nestedCallablePrefix}.${localIdentity(definitionNode, nodeName)}`
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// Extract method metadata BEFORE generating node ID — parameterCount is needed
// to disambiguate overloaded methods via #<arity> suffix in the ID.
+33 -6
View File
@@ -55,22 +55,49 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// the main thread (the #1983 OOM). Because the two stores share this version,
// any future change to the `ParsedFile` serialization shape MUST bump
// SCHEMA_BUMP so both invalidate in lockstep.
// v26: the enclosing-callable walk stops at class bodies and anonymous-class
// construction sites (#2699 follow-up); a v25 cache replays worker results carrying the
// wrong Java anonymous-class ids. Cached results are replayed verbatim — including
// across `--force` — so without this bump a warm cache keeps serving them.
// v25: function-local callables are qualified by their enclosing-callable chain
// plus their own position, and JS/TS gain block scopes (#2699). Both the node
// ids AND the scope tree in a cached worker result are therefore stale. Cached
// results are replayed verbatim — including across `--force` — so without this
// bump a warm cache keeps serving the colliding ids and the block-less scopes.
// v24: function scopes carry `Scope.ownsReceivers`, marking the JS/TS forms
// that bind their own `this` (#2701). The flag lives on the cached `Scope`, so
// without this bump a warm cache replays scopes that lack it and every `this`
// inside an ordinary `function` keeps resolving to the enclosing class —
// verified by probe: `--force` alone does NOT re-derive it.
// v23: closure bindings emit callable nodes in Dart, Ruby, Java, C# and PHP
// (plus JS/TS `var`), and Dart/PHP gain the scope declarations and flow
// captures their forms were missing (#2693). Cached worker results are replayed
// verbatim, so without this bump a warm cache keeps serving the old labels.
// v22: `const X = <arrow | function-expression>` emits one `Function` node
// instead of a `Function` plus an edgeless `Const` twin (#2687). Cached worker
// results are replayed verbatim — including across `--force` — so without this
// bump a warm cache keeps serving the old two-node set.
// v21: Java/Kotlin Spring DI facts persist constructor, field/property, and
// method injection sites plus bean-name and @Primary provider metadata.
// v21: TWO changes share this number — a collision, not a typo. #2632
// (Java/Kotlin Spring DI facts: constructor, field/property and method
// injection sites plus bean-name and @Primary provider metadata) bumped 20 -> 21
// and merged first; #2653 (Java local class/enum/record/interface captures using
// javac-compatible, source-type-relative JLS 13.1 identities and
// declaration-to-block scopes, #2562) had branched at 20, bumped to 21 as well,
// 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. Harmless
// now (anything below the current value is rejected), and left as-is because
// both genuinely shipped as 21 — renumbering would misstate history. Read this
// as the reason to re-check SCHEMA_BUMP against origin/main immediately before
// merging, not just when the branch is cut; the same collision hit
// INCREMENTAL_SCHEMA_VERSION in #2653/#2654.
// v20: Java/Kotlin capture side-channels persist package and class-annotation
// facts for shared Spring Bean resolution.
// v21: Java local class/enum/record/interface captures use javac-compatible,
// source-type-relative JLS 13.1 identities and declaration-to-block scopes
// (#2562).
// v19: Java enum constant bodies emit E$N Class nodes; anonymous naming uses
// JLS 13.1 immediate-host chains (#2555).
// v18: Worker$N anonymous bodies. v17: callable-value-flow operand identity.
// v16: direct callee identity.
const SCHEMA_BUMP = 22;
const SCHEMA_BUMP = 26;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from
+32 -1
View File
@@ -487,8 +487,39 @@ export interface RepoMeta {
* write set only covers changed files, so every unchanged TS/JS file would
* keep its twin and `impact`/`context` would stay ambiguous on those names;
* force a full re-analyze instead.
* v16: calls through a closure-valued binding (`val f = { }; f()`) now resolve
* in Kotlin, Swift, Dart, Ruby, Java, C# and PHP (#2693). These are NEW `CALLS`
* edges, and those languages also gain callable graph nodes for closure
* bindings that previously carried a value label or no node at all (including
* JS/TS `var f = () => {}`). The incremental write set only covers changed
* files, so unchanged files would keep reporting a zero blast radius for those
* symbols; force a full re-analyze instead.
* v17: `this` inside a JS/TS ordinary `function` no longer resolves to the
* lexically enclosing class (#2701). This REMOVES `CALLS`/`ACCESSES` edges —
* including ones that are correct at runtime via `.bind(this)`, `.call`, or a
* `forEach` thisArg, which the graph does not model. The incremental write set
* only covers changed files, so every unchanged TS/JS file would keep its
* fabricated `this` edges; force a full re-analyze instead.
* v18: function-local callables carry their enclosing-callable chain plus their
* own position, so a local closure no longer shares a node id with a same-named
* file-level function (#2699) — `Function:f.ts:save` ->
* `Function:f.ts:run.save@2:2`. JavaScript/TypeScript also gain block scopes
* (`statement_block`), without which two `const` of one name in sibling blocks
* stay indistinguishable to the resolver and each call resolves to BOTH. This
* CHANGES PERSISTED NODE IDS for every function-local callable and changes
* which node a local call resolves to. An incremental top-up would leave
* unchanged files pointing at the old ids while changed files emit the new
* ones, splitting each symbol in two; force a full re-analyze instead.
* v19: the enclosing-callable walk now stops at class BODIES and anonymous-class
* construction sites, not only at class DECLARATIONS (#2699 follow-up). v18 shipped
* with `CLASS_CONTAINER_TYPES` as the only boundary, which lists no node for a Java
* anonymous class (`object_creation_expression > class_body`), so the walk reached the
* enclosing method and re-keyed `Worker$1.run` as `Worker.makeHandler.run@7:12` —
* destroying the javac-compatible JLS identity of #2550/#2555/#2562. An index stamped
* v18 therefore holds WRONG Java ids, and without this bump it passes the reuse gate
* and keeps them on every unchanged file; force a full re-analyze instead.
*/
export const INCREMENTAL_SCHEMA_VERSION = 15;
export const INCREMENTAL_SCHEMA_VERSION = 19;
export interface IndexedRepo {
repoPath: string;
@@ -0,0 +1,116 @@
/**
* #2699 — JS/TS `statement_block` scopes, and the false ACCESSES edges they
* remove.
*
* Enabling `(statement_block) @scope.block` for TS/JS dropped 114 ACCESSES
* edges across a 762-file corpus with `added: 0`. That looked like a
* regression, so it was measured rather than assumed: all 274 emitting
* reference sites behind those 114 edges were classified by re-reading the
* source at the site. Every one of the 114 had at least one site of the form
* `receiver.name`, and none was bare-identifier-only. (269 sites classified as
* member reads outright; the 5 remaining were classifier artifacts — the name
* also occurred earlier on the line, as in `a.b.declLine` for `b` — and are
* member reads too.) So 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, so
* `options.baseUrl` can bind to a local `baseUrl`. Block scopes do not fix that
* — they narrow it, by moving the local off the chain of any reference outside
* its block. The remaining case (a local declared directly in the function
* body) is unchanged and still mis-resolves; that is pre-existing and tracked
* separately.
*
* So these tests pin the direction of the change in BOTH directions: the
* property read must not reach the block-local, and the genuine bare read of
* that same local must still emit its edge. Deleting the block-scope capture
* fails the first; over-suppressing (dropping block bindings instead of
* scoping them) fails the second.
*/
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
vi.setConfig({ testTimeout: 90_000 });
/** `ACCESSES` edges in a one-file repo, as `source -> target` id pairs. */
const accessEdgesFor = async (filename: string, source: string): Promise<string[]> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-block-scope-'));
try {
fs.writeFileSync(path.join(dir, filename), source, 'utf-8');
const result = await runPipelineFromRepo(dir, () => {}, {
workerPoolSize: 1,
workerUrlForTest: DIST_WORKER_URL,
// `pruneLocalSymbols` drops inert function-local value symbols — ~94% of
// them on a real corpus — so in a two-line fixture the `const` under test
// is deleted before any edge can name it, and both arms return []. That
// is why earlier synthetic attempts at this edge class all read as "no
// difference". Keeping them is what makes the fixture discriminate.
keepLocalValueSymbols: true,
});
return result.graph.relationships
.filter((rel) => rel.type === 'ACCESSES')
.map((rel) => `${rel.sourceId} -> ${rel.targetId}`)
.sort();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
};
// The member read sits OUTSIDE the block on purpose. Inside it, the block is on
// the reference's own lexical chain and the property would bind to the local in
// either arm — so an inside-the-block fixture cannot discriminate.
const SHADOWED = [
'export function pickBaseUrl(options: { baseUrl?: string }, fallback: string): string {',
' if (fallback.length > 0) {',
' const baseUrl = fallback.trim();',
' return baseUrl;',
' }',
' return options.baseUrl ?? fallback;',
'}',
'',
].join('\n');
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
describeIfWorkerBuilt('block scopes keep a property read off a same-named block local', () => {
it('TypeScript: `options.baseUrl` does not ACCESS the block-local `const baseUrl`', async () => {
const edges = await accessEdgesFor('pick.ts', SHADOWED);
expect(edges.filter((e) => e.endsWith('baseUrl') && e.includes('pickBaseUrl'))).toEqual([]);
});
it('TypeScript: a real property read still resolves past a same-named block local', async () => {
// Companion invariant, not a discriminating regression test: this edge is
// identical in both arms. It exists because the test above only proves an
// edge went away, which a change that dropped Block-kind bindings entirely
// would also satisfy. Asserting the surviving edge SET — exactly one, and
// pointing at the class property rather than the block local — is what
// separates "correctly scoped" from "deleted".
const edges = await accessEdgesFor(
'box.ts',
[
'export class Box {',
" baseUrl = 'https://example.com';",
' pick(fallback: string): string {',
' if (fallback.length > 0) {',
' const baseUrl = fallback.trim();',
' return baseUrl;',
' }',
' return this.baseUrl;',
' }',
'}',
'',
].join('\n'),
);
// Matched on the target rather than the whole id: the method node carries
// an overload index (`Box.pick#1`) that is orthogonal to what this pins.
expect(edges).toHaveLength(1);
expect(edges[0]).toContain('-> Property:box.ts:Box.baseUrl');
});
});
@@ -13,16 +13,18 @@
* these rely on the #2687 pre-scan collapsing the pair; a regression there
* would surface here as a twin rather than a wrong label.
*
* The label alone does not make `f()` resolve — free-call resolution runs off
* the per-language scope-resolution queries. Go, Python and C++ now also carry a
* The label alone does not make `f()` resolve. Go, Python and C++ carry a
* `@declaration.function` capture anchored on the inner closure literal, so
* calls resolve there too (asserted in the second describe). Kotlin, Swift and
* Dart still lack a `@scope.function` whose range matches the closure literal —
* Kotlin deliberately scopes `lambda_literal` as a BLOCK (#1757) — and an
* unaligned declaration anchor mis-attributes callers, so those three keep the
* label fix only.
* free-call resolution finds the def directly. Kotlin, Swift and Dart cannot
* take that route — they lack a `@scope.function` whose range matches the
* closure literal (Kotlin deliberately scopes `lambda_literal` as a BLOCK,
* #1757) and an unaligned declaration anchor mis-attributes callers.
*
* #2693 resolves those three through `callable-value-flow` instead: the graph
* node this file asserts IS the evidence that admits the binding as a callable
* target, so a regression in the labels above now also breaks call resolution.
*/
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -30,6 +32,12 @@ import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
import { parseFilesWithWorkers } from '../helpers/worker-parse.js';
// Every test here spins its own worker pool (see the note above), and the file
// now covers a dozen languages across four describes. Under that contention a
// single case can exceed the 30s default even though it takes ~7s alone, so the
// budget is raised file-wide rather than per-test.
vi.setConfig({ testTimeout: 90_000 });
const labelsFor = async (path: string, content: string, name: string): Promise<string[]> => {
const { graph } = await parseFilesWithWorkers([{ path, content }]);
return graph.nodes
@@ -108,6 +116,14 @@ describe('closure bindings emit a single Function node in every language', () =>
]);
});
it('TypeScript: a NON-closure class field stays a Property', async () => {
// The closure rule must key on the initializer, not the field syntax —
// otherwise every class field would become a callable member.
expect(
await labelsFor('src/plain.ts', 'export class A {\n address = "x";\n}\n', 'address'),
).toEqual(['Property']);
});
it('Python: an annotated attribute stays a Property, not a Variable', async () => {
// Regression guard. Python matches BOTH `@definition.property` (annotated)
// and `@definition.variable` (bare assignment) on the same statement at the
@@ -156,9 +172,18 @@ const callTargetsFor = async (filename: string, source: string): Promise<string[
};
describeIfWorkerBuilt('calls to a closure binding resolve to its Function node', () => {
// The label change alone is not enough: each language also needs a
// `@declaration.function` anchored on the inner closure literal, so the def is
// owned by the closure's own scope and free-call resolution can find it.
// Two independent routes reach the same outcome.
//
// Go, Python and C++ take the DECLARATION route: a `@declaration.function`
// anchored on the inner closure literal, so the def is owned by the closure's
// own scope and free-call resolution finds it directly.
//
// Kotlin, Swift and Dart cannot — an unaligned declaration anchor
// mis-attributes callers, and Kotlin scopes `lambda_literal` as a BLOCK on
// purpose (#1757, smart casts). They take the CALLABLE-VALUE-FLOW route
// instead (#2693): their capture layer already emits a `seed` naming the
// binding as its own callable, and `buildGraphTargetIndex` admits the
// binding because the graph node #2687 created for it is a `Function`.
it('Go: Handler(1) resolves', async () => {
const targets = await callTargetsFor(
@@ -186,4 +211,430 @@ describeIfWorkerBuilt('calls to a closure binding resolve to its Function node',
expect(targets).toContain('Function:main.cpp:handler');
});
it('Kotlin: handler(1) resolves', async () => {
const targets = await callTargetsFor(
'App.kt',
'val handler = { x: Int -> x }\n\nfun caller(): Int {\n return handler(1)\n}\n',
);
expect(targets).toEqual(['Function:App.kt:handler']);
});
it('Swift: handler(1) resolves', async () => {
const targets = await callTargetsFor(
'App.swift',
'let handler = { (x: Int) -> Int in return x }\n\nfunc caller() -> Int {\n return handler(1)\n}\n',
);
expect(targets).toEqual(['Function:App.swift:handler']);
});
it('Dart: a top-level closure binding resolves', async () => {
// The top-level form parses as `initialized_identifier`; the function-local
// form as `initialized_variable_definition`. Only the latter was in Dart's
// `bindingNodeTypes`, so the top-level binding emitted no flow captures.
const targets = await callTargetsFor(
'app.dart',
'var handler = (int x) => x;\n\nint caller() {\n return handler(1);\n}\n',
);
expect(targets).toEqual(['Function:app.dart:handler']);
});
it('Dart: a function-local closure binding resolves', async () => {
const targets = await callTargetsFor(
'local.dart',
'int caller() {\n var handler = (int x) => x;\n return handler(1);\n}\n',
);
expect(targets).toEqual(['Function:local.dart:handler']);
});
it('Dart: a top-level `final` closure binding resolves', async () => {
// `final` is the idiomatic top-level binding keyword and parses as a
// static_final_declaration_list, not an initialized_identifier_list, so it
// reaches neither the #2687 label rule nor the #2693 flow captures unless
// both are taught about it.
const targets = await callTargetsFor(
'final.dart',
'final handler = (int x) => x;\n\nint caller() {\n return handler(1);\n}\n',
);
expect(targets).toEqual(['Function:final.dart:handler']);
});
it('Dart: every declarator of a multi-name local closure resolves', async () => {
// Dart wraps only the FIRST declarator in initialized_variable_definition;
// `g` is a nested initialized_identifier, so a rule keyed on the `name:`
// field alone silently drops it.
const targets = await callTargetsFor(
'multi.dart',
'int caller() {\n var f = (int x) => x, g = (int y) => y;\n return f(1) + g(2);\n}\n',
);
expect(targets).toEqual(['Function:multi.dart:f', 'Function:multi.dart:g']);
});
it('Kotlin: a class-body closure property resolves to its Method node', async () => {
// The class-body form is the common real-world shape and is the ONLY case
// that exercises the `Method` arm of the callable-label check — narrowing
// that check to `Function` would delete this silently.
const targets = await callTargetsFor(
'Box.kt',
'class Box {\n val handler = { x: Int -> x }\n fun caller(): Int {\n return handler(1)\n }\n}\n',
);
expect(targets).toEqual(['Method:Box.kt:Box.handler']);
});
});
/** Every CALLS edge id in a one-file repo, for the duplicate-shape assertions. */
const callEdgeIdsFor = async (filename: string, source: string): Promise<string[]> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-closure-edges-'));
try {
fs.writeFileSync(path.join(dir, filename), source, 'utf-8');
const result = await runPipelineFromRepo(dir, () => {}, {
workerPoolSize: 1,
workerUrlForTest: DIST_WORKER_URL,
});
return result.graph.relationships
.filter((rel) => rel.type === 'CALLS')
.map((rel) => rel.id)
.sort();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
};
describeIfWorkerBuilt('the declaration route does not double-emit (#2693)', () => {
// Go, Python and C++ already resolved these calls through their
// `@declaration.function` capture. Widening `buildGraphTargetIndex` gives the
// same call a SECOND possible route, so each must still produce exactly one
// edge — `tryEmitEdge` dedups by key, and a collapsed and a site-anchored key
// are different keys, so a genuine regression here shows up as two ids.
it('TypeScript: one CALLS edge for one call site', async () => {
expect(
await callEdgeIdsFor(
'app.ts',
'const handler = (x: number) => x;\n\nexport function caller(): number {\n return handler(1);\n}\n',
),
).toHaveLength(1);
});
it('Go: one CALLS edge for one call site', async () => {
expect(
await callEdgeIdsFor(
'main.go',
'package main\n\nvar Handler = func(x int) int { return x }\n\nfunc Caller() int { return Handler(1) }\n',
),
).toHaveLength(1);
});
it('Python: one CALLS edge for one call site', async () => {
expect(
await callEdgeIdsFor(
'app.py',
'handler = lambda x: x\n\ndef caller():\n return handler(1)\n',
),
).toHaveLength(1);
});
it('C++: one CALLS edge for one call site', async () => {
expect(
await callEdgeIdsFor(
'main.cpp',
'auto handler = [](int x) { return x; };\n\nint caller() { return handler(1); }\n',
),
).toHaveLength(1);
});
});
describeIfWorkerBuilt('closure bindings resolve in the remaining languages (#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.
// Ruby and Java invoke through the callable-object protocol (`.call` /
// `.apply`); C# and PHP call the binding directly.
it('Ruby: handler.call(1) resolves', async () => {
const targets = await callTargetsFor(
'a.rb',
'handler = ->(x) { x }\n\ndef caller\n handler.call(1)\nend\n',
);
expect(targets).toEqual(['Function:a.rb:handler']);
});
it('Java: handler.apply(1) resolves to ONE node, not a Function/Property twin', async () => {
// The rule is anchored on field_declaration — the same node the value rule
// uses — so the parse-worker dedup collapses the pair. Anchoring on the
// inner variable_declarator produced both a Function and a Property node.
const targets = await callTargetsFor(
'A.java',
'import java.util.function.Function;\n' +
'class A {\n' +
' static Function<Integer,Integer> handler = x -> x;\n' +
' int caller() { return handler.apply(1); }\n' +
'}\n',
);
expect(targets).toEqual(['Function:A.java:A.handler']);
});
it('C#: handler(1) resolves', async () => {
const targets = await callTargetsFor(
'A.cs',
'using System;\nclass A {\n' +
' static Func<int,int> handler = x => x;\n' +
' int Caller() { return handler(1); }\n}\n',
);
expect(targets).toEqual(['Function:A.cs:A.handler']);
});
it('PHP: $handler(1) resolves', async () => {
const targets = await callTargetsFor(
'a.php',
'<?php\n$handler = fn($x) => $x;\n' +
'function caller() {\n global $handler;\n return $handler(1);\n}\n',
);
expect(targets).toEqual(['Function:a.php:$handler']);
});
it('PHP: an anonymous function binding resolves too', async () => {
const targets = await callTargetsFor(
'b.php',
'<?php\n$handler = function ($x) { return $x; };\n' +
'function caller() {\n global $handler;\n return $handler(1);\n}\n',
);
expect(targets).toEqual(['Function:b.php:$handler']);
});
it('TypeScript: a class-field arrow is a callable member, like Kotlin', async () => {
// A CALLS edge must target a CALLABLE node. This field used to emit
// Property, so the edge pointed at a non-callable — the same defect class
// as the `var` case below. Kotlin already modelled its class-body closure
// as Method + HAS_METHOD.
//
// This diverges from tsc (PropertyDeclaration) and SCIP (a `.` term), both
// of which class an arrow-initialised field as a property. Deliberate: the
// label means "is a call target" here, not "is a tsc symbol kind".
const targets = await callTargetsFor(
'Box.ts',
'export class Box {\n handler = (x: number) => x;\n caller(): number { return this.handler(1); }\n}\n',
);
expect(targets).toEqual(['Method:Box.ts:Box.handler']);
});
it('JavaScript: a class-field arrow is a callable member', async () => {
const targets = await callTargetsFor(
'C.js',
'export class C {\n handler = (x) => x;\n caller() { return this.handler(1); }\n}\n',
);
expect(targets).toEqual(['Method:C.js:C.handler']);
});
it('PHP: a local closure sharing a name with a function resolves to the CLOSURE', async () => {
// PHP keeps variables and functions in SEPARATE namespaces, so `$save` and
// `save()` cannot collide in the language. Dropping the `$` made both mint
// Function:<file>:save, so the closure was swallowed by the function's node
// and the call got NO edge at all. Keeping the sigil restores PHP's own
// separation; the positional join normalises it when matching.
//
// #2699 then added the enclosing-callable qualifier, so the id is
// `run.$save`. The two fixes are independent and both still needed: the
// sigil separates the VARIABLE namespace from the function one, the
// qualifier separates this function's local from any other scope's.
const targets = await callTargetsFor(
'c.php',
'<?php\nfunction save($x) { return $x; }\n' +
'function run() {\n $save = fn($x) => $x * 2;\n return $save(1);\n}\n',
);
expect(targets).toEqual(['Function:c.php:run.$save@3:2']);
});
it('PHP: calling the real function still resolves to the function', async () => {
const targets = await callTargetsFor(
'f.php',
'<?php\nfunction save($x) { return $x; }\nfunction run() { return save(1); }\n',
);
expect(targets).toEqual(['Function:f.php:save']);
});
it('JavaScript: a `var` closure binding is a Function, like const/let', async () => {
// `var` is a different grammar node than const/let, so it kept a Variable
// label — and the CALLS edge that resolved through the declaration route
// pointed at a NON-callable node.
const targets = await callTargetsFor(
'c.js',
'var handler = (x) => x;\n\nexport function caller() { return handler(1); }\n',
);
expect(targets).toEqual(['Function:c.js:handler']);
});
});
describeIfWorkerBuilt('a closure binding is a call TARGET, not yet a call SOURCE', () => {
// Known limit, pinned deliberately so it is visible rather than surprising.
//
// A call made INSIDE a closure binding is attributed to the ENCLOSING scope,
// not to the binding's own node — so `impact(handler, direction:"downstream")`
// reports nothing even though the closure calls `target`.
//
// Cause: `pickCallerCallableDef` (graph-bridge/ids.ts) finds the caller by
// walking CHILD scopes whose range contains the call site, gated on
// `child.kind === 'Function'`, and then requires that child to OWN a
// callable def. The languages here fail at different points, which is worth
// stating precisely because an earlier version of this comment claimed one
// shared cause and that error propagated into a follow-up plan:
//
// - Kotlin (`lambda_literal` @scope.block, deliberately — #1757 smart
// casts) and Ruby (`do_block`/`block` @scope.block) fail the KIND gate.
// - PHP does NOT: `anonymous_function`/`arrow_function` are already
// @scope.function (php/query.ts:61-62). It fails only the second half —
// the `$handler` def is owned by the enclosing scope, so the closure's
// own scope owns no callable def.
// - Dart has no scope over a closure literal at all, so there is no child
// scope for the walk to consider.
//
// So a fix needs per-language work, not one switch: a callable-boundary
// signal independent of scope `kind` (Kotlin/Ruby), an association from a
// closure scope to its binding's def (PHP), and a scope that does not exist
// yet (Dart). See #2699.
//
// TS/JS free bindings are the exception: their arrow has a `@scope.function`
// with a matching range, so the closure IS the anchor there. These tests exist
// to catch that asymmetry changing in EITHER direction.
it('Kotlin: a call inside the closure is attributed to the file, not the binding', async () => {
const targets = await callEdgeIdsFor(
'A.kt',
'fun target(x: Int): Int = x\n\nval handler = { x: Int -> target(x) }\n',
);
expect(targets).toEqual(['rel:CALLS:File:A.kt->Function:A.kt:target']);
});
it('PHP: a call inside the closure is attributed to the file, not the binding', async () => {
const targets = await callEdgeIdsFor(
'a.php',
'<?php\nfunction target($x) { return $x; }\n' +
'$handler = function ($x) { return target($x); };\n',
);
expect(targets).toEqual(['rel:CALLS:File:a.php->Function:a.php:target']);
});
it('JavaScript: a free arrow binding IS the caller anchor', async () => {
// The counter-case: an aligned @scope.function makes the closure the anchor.
const targets = await callEdgeIdsFor(
'c.js',
'export function target(x) { return x; }\nvar handler = (x) => target(x);\n',
);
expect(targets).toEqual(['rel:CALLS:Function:c.js:handler->Function:c.js:target']);
});
});
describeIfWorkerBuilt('a value binding is never aliased onto a same-named callable', () => {
// These are the regression tests for the defect the first cut of #2693
// shipped. Admitting a value binding on a same-file NAME match 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 — a fabricated caller, chosen by declaration order.
//
// The join is positional now: a closure binding IS its callable node (same
// file, same line, same name); an aliasing local is not. Every case below
// pairs a value binding with a same-named callable, which is precisely the
// collision the previous fixtures never created — they used DIFFERENT names
// (`maxSize` vs `size`), so the pre-filter rejected them before the guard
// they were named after could run, and deleting that guard changed nothing.
it('TypeScript: a local aliasing a parameter does not call the same-named top-level function', async () => {
const targets = await callTargetsFor(
'alias.ts',
'export function handler(): number {\n return 1;\n}\n\n' +
'export function caller(cb: () => number): number {\n const handler = cb;\n return handler();\n}\n',
);
expect(targets).toEqual([]);
});
it('TypeScript: a local closure does not call a same-named class method', async () => {
// `Svc` is never instantiated. The local arrow has its own Function node,
// which is the only legitimate target.
const targets = await callTargetsFor(
'svc.ts',
'export class Svc {\n save(x: number): number {\n return x;\n }\n}\n\n' +
'export function run(): number {\n const save = (x: number): number => x * 2;\n return save(1);\n}\n',
);
// `run.save` — the local carries its enclosing function, so it can no
// longer be confused with a file-level `save` (#2699).
expect(targets).toEqual(['Function:svc.ts:run.save@7:2']);
});
it('TypeScript: a shadowing local does not also call the shadowed function', async () => {
// `caller` invokes `other` through the shadowing binding; the outer
// `handler` is unreachable from it.
const targets = await callTargetsFor(
'shadow.ts',
'export function handler(x: number): number {\n return x;\n}\n' +
'export function other(x: number): number {\n return x * 2;\n}\n\n' +
'export function caller(): number {\n const handler = other;\n return handler(1);\n}\n',
);
expect(targets).toEqual(['Function:shadow.ts:other']);
});
it('Rust: a let binding does not call the same-named function', async () => {
// Rust `let` bindings get no graph node at all, so the simple-name
// fallback was the ONLY route — this is the shape with no value node to
// claim the qualified key first.
const targets = await callTargetsFor(
'main.rs',
'fn handler() -> i32 {\n 1\n}\n\n' +
'fn caller(cb: fn() -> i32) -> i32 {\n let handler = cb;\n handler()\n}\n',
);
expect(targets).toEqual([]);
});
it('Dart: a local closure does not call a same-named class method', async () => {
// Before the positional join this emitted the WRONG edge and lost the
// right one: the only target was `Svc.save`, while the closure's own node
// got nothing.
const targets = await callTargetsFor(
'svc.dart',
'class Svc {\n int save(int x) => x;\n}\n\n' +
'int run() {\n var save = (int x) => x * 2;\n return save(1);\n}\n',
);
expect(targets).toEqual(['Function:svc.dart:save']);
});
it('Kotlin: a genuine constant mints no CALLS', async () => {
const targets = await callTargetsFor(
'Consts.kt',
'val maxSize = 10\n\nfun size(): Int {\n return maxSize\n}\n',
);
expect(targets).toEqual([]);
});
it('Kotlin: a property initialised from a call is not itself callable', async () => {
const targets = await callTargetsFor(
'Made.kt',
'fun make(): Int = 1\n\nval made = make()\n\nfun caller(): Int {\n return made\n}\n',
);
expect(targets).toEqual(['Function:Made.kt:make']);
});
});
@@ -15,8 +15,10 @@
* the twin was emitted first and never suppressed.
*
* The over-suppression guards below matter as much as the twin assertions: a
* genuine non-callable `const`, an object-literal service (#1718), a `var`
* binding, and the non-function initializers must all keep their value nodes.
* genuine non-callable `const`, an object-literal service (#1718), a plain
* `var` value, and the non-function initializers must all keep their value
* nodes. (A `var` bound to a CLOSURE is a twin case, not a guard case, since
* #2693 — see the pair of `var` tests.)
*
* Mirrors the sibling suppression case in `c-cpp-typedef-legacy-parse.test.ts`.
*/
@@ -113,11 +115,24 @@ describe('#2687 export-const function twin', () => {
expect(labelsOf(nodes, 'ternary')).toEqual(['Const']);
});
it('keeps the Variable node for a var-bound function-expression', async () => {
// `var` has no matching `@definition.function` pattern, so nothing claims
// the name and the value node must survive untouched.
it('collapses a var-bound function-expression to one Function node', async () => {
// `var` originally had no `@definition.function` pattern, so the value node
// survived unclaimed and this asserted `Variable`. That was a gap, not a
// decision: a call through the binding still resolved via the declaration
// route, so the CALLS edge pointed at a NON-callable node. `var` now claims
// the name like const/let (#2693), and the dedup collapses the pair to ONE
// node — a twin here would mean the rule is anchored on a different node
// than the value rule.
const nodes = await parseNodes('src/var.ts', 'var legacy = function () {\n return 3;\n};\n');
expect(labelsOf(nodes, 'legacy')).toEqual(['Function']);
});
it('keeps the Variable node for a var-bound NON-function initializer', async () => {
// The property the previous case used to cover: when nothing claims the
// name, the value node must survive untouched.
const nodes = await parseNodes('src/varvalue.ts', 'var legacy = 3;\n');
expect(labelsOf(nodes, 'legacy')).toEqual(['Variable']);
});
@@ -0,0 +1,283 @@
/**
* #2699 — a function-local callable gets its own graph node instead of
* collapsing onto a same-named file-level one.
*
* Graph node ids are file-scoped, so before this a top-level `save()` and a
* local `const save = …` inside `run()` both keyed `Function:<file>:save`. That
* is a WRONG answer, not merely a missing one: `run`'s call to its own local
* was attributed to the top-level function, so `impact` on `save` reported a
* caller that never calls it.
*
* A local's identity is its enclosing-callable chain plus its own position —
* `run.save@2:2`. The chain is for humans reading `impact` output; the position
* is what makes it correct. ECMAScript creates an environment record per
* function AND per block, so a name alone cannot separate sibling blocks, and
* an anonymous function has no name to contribute at all. Position settles both
* (SCIP reaches the same place with its document-scoped `local <id>`).
* Top-level functions and class methods are NOT locals and keep their existing
* ids — that is the bound on how far this churn reaches.
*
* THE SILENT FAILURE THIS GUARDS. Node ids are built twice and independently:
* once by the definition phase and once by the caller-attribution phase
* (`findEnclosingFunctionId`). If those two disagree by a single character the
* caller attaches to a node that does not exist and the edge simply vanishes —
* nothing throws, and a test that only checked "the node exists" would still
* pass. Every assertion here is therefore on the EDGE, whose source and target
* are produced by the two different phases: it can only pass if both agree.
*/
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
vi.setConfig({ testTimeout: 90_000 });
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
const analyze = async (
filename: string,
source: string,
): Promise<{ readonly calls: string[]; readonly nodes: string[] }> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-local-identity-'));
try {
fs.writeFileSync(path.join(dir, filename), source, 'utf-8');
const result = await runPipelineFromRepo(dir, () => {}, {
workerPoolSize: 1,
workerUrlForTest: DIST_WORKER_URL,
});
return {
calls: result.graph.relationships
.filter((rel) => rel.type === 'CALLS')
.map((rel) => `${rel.sourceId} -> ${rel.targetId}`)
.sort(),
nodes: result.graph.nodes
.filter((node) => node.properties.name?.toString().includes('save') === true)
.map((node) => node.id)
.sort(),
};
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
};
describeIfWorkerBuilt('a function-local callable does not collide with a file-level one', () => {
it('TypeScript: two locals and a top-level function are three distinct nodes', async () => {
const { calls, nodes } = await analyze(
'a.ts',
[
'export function save(x: number): number { return x; }',
'export function run(): number {',
' const save = (x: number): number => x * 2;',
' return save(1);',
'}',
'export function other(): number {',
' const save = (x: number): number => x * 3;',
' return save(2);',
'}',
].join('\n'),
);
// Three nodes, not one. `other`'s local is distinct from `run`'s: qualifying
// by the enclosing FUNCTION (not the file) is what separates two locals that
// share a name in different functions.
expect(nodes).toEqual([
'Function:a.ts:other.save@6:2',
'Function:a.ts:run.save@2:2',
'Function:a.ts:save',
]);
// Each function calls its OWN local. The top-level `save` has no callers —
// before this it collected both, and `impact` reported callers that do not
// exist in the source.
expect(calls).toEqual([
'Function:a.ts:other -> Function:a.ts:other.save@6:2',
'Function:a.ts:run -> Function:a.ts:run.save@2:2',
]);
});
it('Python: the same collision, via a lambda binding', async () => {
const { calls } = await analyze(
'c.py',
[
'def save(x):',
' return x',
'',
'def run():',
' save = lambda x: x * 2',
' return save(1)',
].join('\n'),
);
expect(calls).toEqual(['Function:c.py:run -> Function:c.py:run.save@4:4']);
});
it('PHP: the enclosing-callable qualifier composes with the `$` sigil', async () => {
// Two independent separations, both needed. The sigil (#2693) keeps PHP's
// variable namespace apart from its function namespace; the qualifier
// (#2699) keeps this function's local apart from any other scope's.
const { calls } = await analyze(
'b.php',
[
'<?php',
'function save($x) { return $x; }',
'function run() {',
' $save = fn($x) => $x * 2;',
' return $save(1);',
'}',
].join('\n'),
);
expect(calls).toEqual(['Function:b.php:run -> Function:b.php:run.$save@3:2']);
});
it('a closure inside a METHOD is qualified by the method, not just the class', async () => {
// Before this, both closures qualified as `S.h` and collapsed — the class
// was the only qualifier, so two methods' locals still collided.
const { calls } = await analyze(
's.ts',
[
'export class S {',
' first(): number { const h = (): number => 1; return h(); }',
' second(): number { const h = (): number => 2; return h(); }',
'}',
].join('\n'),
);
expect(calls).toEqual([
'Method:s.ts:S.first#0 -> Function:s.ts:S.first.h@1:20',
'Method:s.ts:S.second#0 -> Function:s.ts:S.second.h@2:21',
]);
});
it('an ANONYMOUS enclosing callable is named by its position', async () => {
// An anonymous function has no name to qualify with, but ECMAScript still
// gives it an environment record, so its `save` is a genuinely different
// binding from the file-level one. The anonymous link becomes `fn@1:9` —
// unique by construction, since two functions cannot start at one offset.
const { calls } = await analyze(
'anon.ts',
[
'export function outer() {',
' return function () {',
' const save = (x: number) => x * 2;',
' return save(1);',
' };',
'}',
'export function save(x: number) { return x; }',
].join('\n'),
);
expect(calls).toEqual(['Function:anon.ts:outer -> Function:anon.ts:outer.fn@1:9.save@2:4']);
});
it('sibling BLOCKS hold different bindings, and each call reaches its own', async () => {
// `let`/`const` are block-scoped, so these are two bindings, not one name
// declared twice. Two things had to be true for this to work, and the first
// without the second is worse than neither: giving them distinct ids made
// the collapse visible as DUPLICATE edges (each call resolving to both),
// because JS/TS emitted no block scopes at all and the resolver could not
// tell the branches apart. `(statement_block) @scope.block` supplies the
// missing environment record — `tsBindingScopeFor` already implemented the
// other half of the ECMAScript rule, hoisting `var` past blocks while
// `let`/`const` bind innermost.
const { calls } = await analyze(
'blocks.ts',
[
'export function outer(a: boolean): number {',
' if (a) {',
' const pick = (x: number) => x * 2;',
' return pick(1);',
' } else {',
' const pick = (x: number) => x * 3;',
' return pick(2);',
' }',
'}',
].join('\n'),
);
// Exactly two edges: one per call, each to the binding in its OWN branch.
expect(calls).toEqual([
'Function:blocks.ts:outer -> Function:blocks.ts:outer.pick@2:4',
'Function:blocks.ts:outer -> Function:blocks.ts:outer.pick@5:4',
]);
});
it('`var` still hoists past blocks to the function, per the spec', async () => {
// The other half of block scoping: a `var` declared in a block belongs to
// the FUNCTION environment record. If block scopes had captured `var` too,
// this would silently become two bindings.
const { calls } = await analyze(
'v.js',
[
'function outer(a) {',
' if (a) { var pick = (x) => x * 2; }',
' return pick(1);',
'}',
'module.exports = { outer };',
].join('\n'),
);
expect(calls).toEqual(['Function:v.js:outer -> Function:v.js:outer.pick@1:11']);
});
it('a MULTILINE local declaration does not alias onto a same-named sibling local', async () => {
// The two id phases anchor on different nodes ON PURPOSE: the graph node anchors on
// the outer `lexical_declaration`, the scope def on the inner `arrow_function` (so
// `anchor.range` lines up with `@scope.function` for auto-hoist). Splitting the
// declaration across lines therefore puts them on different LINES and the position
// join misses.
//
// Before the fix that miss fell through to the label-agnostic, first-write-wins
// `simpleKey`, which aliased `other`'s local onto `run`'s and emitted a FABRICATED
// edge `other -> run.pick@1:2`. Every other fixture in this file keeps the
// declaration and its initializer on ONE line, where the anchors coincide — which is
// exactly why the suite was green while the bug shipped.
//
// Correct behaviour is to fail CLOSED: two edges, each to its own binding, and no
// third edge. A missing edge is recoverable; a fabricated caller silently corrupts
// `impact`.
const { calls } = await analyze(
'm.ts',
[
'export function run(): number {',
' const pick =',
' (x: number): number => x * 2;',
' return pick(1);',
'}',
'export function other(): number {',
' const pick =',
' (x: number): number => x * 3;',
' return pick(2);',
'}',
].join('\n'),
);
expect(calls).toEqual([
'Function:m.ts:other -> Function:m.ts:other.pick@6:2',
'Function:m.ts:run -> Function:m.ts:run.pick@1:2',
]);
});
it('leaves top-level functions and ordinary methods unqualified', async () => {
// The bound on id churn: only a callable nested inside another callable
// gains a prefix. If this ever fails, the change is rewriting far more ids
// than it intends to.
const { calls } = await analyze(
't.ts',
[
'export function helper(): number { return 1; }',
'export class T {',
' m(): number { return helper(); }',
'}',
'export function top(): number { return helper(); }',
].join('\n'),
);
expect(calls).toEqual([
'Function:t.ts:top -> Function:t.ts:helper',
'Method:t.ts:T.m#0 -> Function:t.ts:helper',
]);
});
});
@@ -1399,6 +1399,66 @@ invoke(assigned);
}
}, 120_000);
it('replays closure-binding resolution from the durable warm parse cache (#2693)', async () => {
// The #2693 captures are provider-synthesized (Dart's fieldless
// `initialized_identifier` seed) and the function-local closure `Function`
// node is minted at parse time — both are replayed VERBATIM from the parse
// cache, so a serialization change would surface only on the SECOND
// analyze. Every other test in this PR runs cold and would stay green.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-closure-warm-repo-'));
const storage = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-closure-warm-store-'));
try {
fs.writeFileSync(
path.join(root, 'app.dart'),
'var handler = (int x) => x;\n\nint caller() {\n return handler(1);\n}\n',
'utf8',
);
fs.writeFileSync(
path.join(root, 'App.kt'),
'val handler = { x: Int -> x }\n\nfun caller(): Int {\n return handler(1)\n}\n',
'utf8',
);
const coldCache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storage,
onDiskKeys: new Set(),
};
const cold = await runPipelineFromRepo(root, () => {}, {
skipGraphPhases: true,
parseCache: coldCache,
});
const savedKeys = await saveParseCache(storage, coldCache);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storage),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warmCache = await loadParseCache(storage);
const warm = await runPipelineFromRepo(root, () => {}, {
skipGraphPhases: true,
parseCache: warmCache,
});
const project = (result: Awaited<ReturnType<typeof runSource>>) =>
getRelationships(result, 'CALLS')
.map(
(edge) =>
`${edge.sourceFilePath}:${edge.source}->${edge.targetFilePath}:${edge.target}`,
)
.sort();
expect(project(warm)).toEqual(project(cold));
expect(project(warm)).toEqual([
'App.kt:caller->App.kt:handler',
'app.dart:caller->app.dart:handler',
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(storage, { recursive: true, force: true });
}
}, 120_000);
it('keeps normal/PDG targets identical and stamps calleeIds at the indirect invocation', async () => {
const source = `
function target(): void {}
@@ -0,0 +1,243 @@
/**
* #2701 — `this` inside an ordinary JS/TS `function` is NOT the enclosing
* instance, so `this.m()` there must not emit a `CALLS` edge to the enclosing
* class's member. Only an arrow inherits `this`.
*
* 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`.
*
* The fix spans three layers. An earlier version of this comment claimed all
* three were independently load-bearing because "the false edge survived
* removing any one of them alone". That was measured DURING development and is
* FALSE for the shipped code — it was carried into the final commit without
* being re-tested. Corrected:
*
* 1. `Scope.ownsReceivers`, set from the `@receiver-owner.this` query marker,
* stops both receiver-type walks (`findReceiverTypeBinding` in ingestion,
* `lookupReceiverType` in gitnexus-shared's `lookup-core`).
* 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. This is the one that decides the outcome for the fixtures below:
* without it the member still resolved by NAME through `lookupCore`'s
* lexical chain — the class-body scope binds `m`, two scopes up.
*
* Layer 3 runs FIRST (`emitReceiverBoundCalls` marks the site in `handledSites`,
* which `emitReferencesViaLookup` then skips), so it SUBSUMES layer 1 for an
* explicit `this` receiver. Removing layer 1's gate in `lookup-core.ts` leaves
* every test in this file passing — verified by experiment.
*
* That gate is nonetheless RETAINED, and deleting it would be a mistake: the
* `receiver-bound-calls` suppression only covers EXPLICIT receivers
* (`if (site.explicitReceiver === undefined) continue;`), while `lookup-core`'s
* gate is also reached for IMPLICIT ones via `IMPLICIT_RECEIVERS` in
* `resolveReceiverOwner` — a bare `m()` inside a nested `function` inside a
* method. The experiment above establishes that gate is UNTESTED, not that it is
* unreachable. It needs a test for the implicit-receiver path; until then, do
* not treat its removability as demonstrated.
*
* WHAT THIS DELIBERATELY GIVES UP. `.bind(this)`, `.call(this)` and
* `forEach(fn, thisArg)` DO make `this` the instance at runtime; their edges
* were correct and are now dropped. Their correctness is fixed at the call
* site, which a scope-level rule cannot see, so the choice is between losing
* them and keeping every detached-callback false positive. The pinned cases at
* the bottom record that trade so a future change to it is deliberate.
*/
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
vi.setConfig({ testTimeout: 90_000 });
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
/** `CALLS` edges in a one-file repo as sorted `source -> target` strings. */
const callEdgesFor = async (filename: string, source: string): Promise<string[]> => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-this-boundary-'));
try {
fs.writeFileSync(path.join(dir, filename), source, 'utf-8');
const result = await runPipelineFromRepo(dir, () => {}, {
workerPoolSize: 1,
workerUrlForTest: DIST_WORKER_URL,
});
return result.graph.relationships
.filter((rel) => rel.type === 'CALLS')
.map((rel) => `${rel.sourceId} -> ${rel.targetId}`)
.sort();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
};
describeIfWorkerBuilt('an arrow inherits `this`; every other function form binds it', () => {
it('TypeScript: a nested `function` emits no edge, its arrow twin does', async () => {
// The two methods differ ONLY in arrow vs `function`, so any edge
// difference between them is the boundary and nothing else.
expect(
await callEdgesFor(
'c.ts',
[
'export class C {',
' m(): void {}',
' viaArrow(): void { const good = () => { this.m(); }; good(); }',
' viaFn(): void { const bad = function () { this.m(); }; bad(); }',
'}',
].join('\n'),
),
// The closure ids carry their enclosing METHOD (`C.viaArrow.good`), not
// just the class — #2699. Note both phases agree on that name: the caller
// edge and the definition it points at were built independently.
).toEqual([
'Function:c.ts:C.viaArrow.good@2:21 -> Method:c.ts:C.m#0',
'Method:c.ts:C.viaArrow#0 -> Function:c.ts:C.viaArrow.good@2:21',
'Method:c.ts:C.viaFn#0 -> Function:c.ts:C.viaFn.bad@3:18',
]);
});
it('JavaScript: the same boundary, via the JavaScript grammar', async () => {
// JS and TS have separate query files; a marker added to one only would
// pass the TypeScript case above and silently leave JavaScript broken.
expect(
await callEdgesFor(
'h.js',
[
'class H {',
' m() {}',
' viaArrow() { const good = () => { this.m(); }; return good; }',
' viaFn() { const bad = function () { this.m(); }; return bad; }',
' direct() { this.m(); }',
'}',
'module.exports = { H };',
].join('\n'),
),
).toEqual([
'Function:h.js:H.viaArrow.good@2:15 -> Method:h.js:H.m#0',
'Method:h.js:H.direct#0 -> Method:h.js:H.m#0',
]);
});
it('a callback `function` passed to forEach does not reach the enclosing class', async () => {
// The original motivating shape: the bug arrow functions were introduced
// to avoid. `run` must have NO outgoing call to `m`.
expect(
await callEdgesFor(
'f.ts',
[
'export class F {',
' m(): void {}',
' run(xs: number[]): void { xs.forEach(function () { this.m(); }); }',
'}',
].join('\n'),
),
).toEqual([]);
});
it('a plain `this.m()` in a method still resolves', async () => {
// The boundary must not swallow the ordinary case: a `method_definition`
// carries the marker too, but its own synthesized `this` binding is
// consulted first.
expect(
await callEdgesFor(
'd.ts',
['export class D {', ' m(): void {}', ' direct(): void { this.m(); }', '}'].join('\n'),
),
).toEqual(['Method:d.ts:D.direct#0 -> Method:d.ts:D.m#0']);
});
it('a class-field arrow still resolves', async () => {
// `m = () => {}` is lexically bound to the instance, so it keeps its edge.
// The caller is the class itself: a field initializer has no method scope.
expect(
await callEdgesFor(
'g.ts',
['export class G {', ' m(): void {}', ' field = (): void => { this.m(); };', '}'].join(
'\n',
),
),
).toEqual(['Class:g.ts:G -> Method:g.ts:G.m#0']);
});
it('a generator `function` is a boundary too', async () => {
expect(
await callEdgesFor(
'gen.ts',
[
'export class Gen {',
' m(): void {}',
' run() { return function* () { this.m(); }; }',
'}',
].join('\n'),
),
).toEqual([]);
});
it('leaves other languages untouched: a Kotlin lambda still sees the receiver', async () => {
// `ownsReceivers` is unset for every language but JS/TS, so a Kotlin lambda
// — which DOES capture the enclosing `this` — must keep resolving. This is
// the guard against the boundary leaking into shared code.
expect(
await callEdgesFor(
'K.kt',
['class K {', ' fun m() {}', ' fun run() { val f = { this.m() }; f() }', '}'].join(
'\n',
),
),
// Attributed to `run`, not to `f`: Kotlin scopes `lambda_literal` as a
// BLOCK (#1757), so the lambda is not its own caller anchor. What matters
// here is only that the `this.m()` edge still exists at all.
).toContain('Method:K.kt:K.run#0 -> Method:K.kt:K.m#0');
});
});
describeIfWorkerBuilt('receiver rebinding is not modelled — pinned, not endorsed', () => {
// Each of these is CORRECT at runtime and produces no edge. The rebinding
// happens at the call site, which a scope-level rule cannot see; modelling it
// needs call-site receiver tracking, which is a separate concern. Pinned so
// that a future change here is a decision rather than a surprise.
it('`.bind(this)` loses its edge', async () => {
expect(
await callEdgesFor(
'b.ts',
[
'export class B {',
' m(): void {}',
' run() { return function (this: B) { this.m(); }.bind(this); }',
'}',
].join('\n'),
),
).toEqual([]);
});
it('a forEach `thisArg` loses its edge', async () => {
expect(
await callEdgesFor(
't.ts',
[
'export class T {',
' m(): void {}',
' run(xs: number[]): void { xs.forEach(function (this: T) { this.m(); }, this); }',
'}',
].join('\n'),
),
).toEqual([]);
});
it('`this` in a static method no longer reaches the instance member', async () => {
// `this` in a static context is the constructor, not an instance, so an
// edge to the INSTANCE method `m` was wrong in the other direction. It was
// previously emitted by the same lexical-name fallback this change closes.
expect(
await callEdgesFor(
's.ts',
['export class S {', ' m(): void {}', ' static go(): void { this.m(); }', '}'].join('\n'),
),
).toEqual([]);
});
});
@@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 15 (const-arrow twin removal, #2687)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(15);
it('INCREMENTAL_SCHEMA_VERSION is bumped to 19 (class-body boundary fix, #2699)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(19);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@@ -133,7 +133,24 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// every unchanged TS/JS file, and the incremental write set never touches
// those files → must NOT reuse.
expect(passesReuseGate(14)).toBe(false);
// A pre-v16 (v15) index predates #2693: calls through a closure-valued
// binding do not resolve in Kotlin/Swift/Dart, and the incremental write
// set never revisits unchanged files, so those symbols would keep reporting
// a zero blast radius → must NOT reuse.
expect(passesReuseGate(15)).toBe(false);
// A pre-v17 (v16) index predates #2701: `this` inside an ordinary JS/TS
// `function` still resolves to the enclosing class, so every unchanged
// TS/JS file keeps its fabricated `this` edges → must NOT reuse.
expect(passesReuseGate(16)).toBe(false);
// A pre-v18 (v17) index predates #2699: a function-local callable still
// shares a node id with a same-named file-level one, and the incremental
// write set would mix old and new ids → must NOT reuse.
expect(passesReuseGate(17)).toBe(false);
// A pre-v19 (v18) index holds the WRONG Java anonymous-class ids — v18 bounded the
// enclosing-callable walk on class DECLARATIONS only, so `Worker$1.run` was re-keyed
// as `Worker.makeHandler.run@7:12`. Reusing it would keep those on unchanged files.
expect(passesReuseGate(18)).toBe(false);
// A current-version stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(15)).toBe(true);
expect(passesReuseGate(19)).toBe(true);
});
});
@@ -0,0 +1,117 @@
/**
* #2693 — `buildGraphTargetIndex` admits a VALUE binding as a call target only
* on POSITIONAL evidence: the callable graph node at the binding's own file,
* line and name.
*
* The first cut of #2693 admitted a value binding whose *resolved* node was
* callable, which let `resolveDefGraphId` fall through to its label-agnostic,
* first-write-wins `simpleKey(filePath, simpleName)` and alias the binding onto
* ANY same-named callable in the file — a fabricated caller, chosen by
* declaration order. These tests pin the property that replaced it, at the unit
* level where the integration suite cannot isolate it.
*/
import { describe, expect, it } from 'vitest';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from '../../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import { buildGraphTargetIndex } from '../../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js';
interface StubNode {
readonly id: string;
readonly label: string;
readonly properties: { filePath: string; name: string; startLine: number };
}
/** Minimal graph — `buildGraphTargetIndex` reads only `iterNodes`/`getNode`. */
const graphOf = (nodes: readonly StubNode[]): KnowledgeGraph => {
const byId = new Map(nodes.map((node) => [node.id, node]));
return {
iterNodes: () => nodes[Symbol.iterator](),
getNode: (id: string) => byId.get(id),
} as unknown as KnowledgeGraph;
};
/** `line` is 1-based, matching the definition-id convention. */
const def = (type: string, filePath: string, qualifiedName: string, line: number) => ({
nodeId: `${filePath}#${line}:0:${qualifiedName}`,
type,
filePath,
qualifiedName,
});
const scopesOf = (defs: readonly ReturnType<typeof def>[]): ScopeResolutionIndexes =>
({
defs: { byId: new Map(defs.map((d) => [d.nodeId, d])) },
}) as unknown as ScopeResolutionIndexes;
/** Graph nodes store a 0-BASED startLine; defs are 1-based. */
const node = (label: string, filePath: string, name: string, line: number): StubNode => ({
id: `${label}:${filePath}:${name}`,
label,
properties: { filePath, name, startLine: line - 1 },
});
const targetsFor = (nodes: readonly StubNode[], defs: readonly ReturnType<typeof def>[]) =>
[
...buildGraphTargetIndex(
scopesOf(defs),
new Map() as GraphNodeLookup,
undefined,
graphOf(nodes),
),
]
.map(([defId, target]) => `${defId} => ${target.id}`)
.sort();
describe('buildGraphTargetIndex — value bindings join by position', () => {
it('admits a value binding whose callable node sits at its own line', () => {
// The #2687 closure-binding shape: the value def and the Function node are
// the same construct, so they share file, line and name.
expect(
targetsFor([node('Function', 'a.kt', 'handler', 3)], [def('Property', 'a.kt', 'handler', 3)]),
).toEqual(['a.kt#3:0:handler => Function:a.kt:handler']);
});
it('REJECTS a value binding that merely shares a name with a callable elsewhere', () => {
// `const save = …` on line 7 beside an unrelated `save` callable on line 2.
// A name-only match admitted this and minted a fabricated caller.
expect(
targetsFor([node('Function', 'a.ts', 'save', 2)], [def('Variable', 'a.ts', 'save', 7)]),
).toEqual([]);
});
it('REJECTS a value binding whose node at that position is NOT callable', () => {
expect(
targetsFor([node('Const', 'a.ts', 'CONFIG', 4)], [def('Const', 'a.ts', 'CONFIG', 4)]),
).toEqual([]);
});
it('REJECTS an ambiguous position claimed by two callables', () => {
// Admitting either would be an arbitrary, order-dependent choice.
expect(
targetsFor(
[node('Function', 'a.ts', 'dup', 5), node('Method', 'a.ts', 'dup', 5)],
[def('Variable', 'a.ts', 'dup', 5)],
),
).toEqual([]);
});
it('normalises the PHP dollar sigil across the join', () => {
// The PHP node keeps the sigil so `$save` cannot collide with the function
// `save()` — PHP holds the two in separate namespaces — while the scope
// declaration drops it. The join must still match them, and must NOT match
// the same-named function on another line.
expect(
targetsFor(
[node('Function', 'a.php', '$save', 5), node('Function', 'a.php', 'save', 2)],
[def('Variable', 'a.php', 'save', 5)],
),
).toEqual(['a.php#5:0:save => Function:a.php:$save']);
});
it('keeps ordinary callable defs, which never take the positional path', () => {
expect(
targetsFor([node('Function', 'a.ts', 'fn', 1)], [def('Function', 'a.ts', 'fn', 1)]),
).toEqual(['a.ts#1:0:fn => Function:a.ts:fn']);
});
});
@@ -0,0 +1,151 @@
/**
* #2701 / #2699 follow-up — the TS/JS function node-type lists must agree with
* the queries that produce them.
*
* Four lists describe "which node types are function-like", maintained by hand
* in four files:
*
* 1. `query.ts` — the `@scope.function` / `@receiver-owner.this`
* patterns (what becomes a scope, and which scopes bind
* their own `this`)
* 2. `captures.ts` — `FUNCTION_NODE_TYPES`, which feeds `functionNodeTypes`
* into callable-flow capture synthesis AND the
* body-block filter
* 3. `receiver-binding.ts` — `THIS_REBINDING_BOUNDARY_TYPES`, where the
* enclosing-type walk stops
* 4. `type-extractors/typescript.ts` — `THIS_BOUNDARY_NODE_TYPES`, where the
* type-env AST walk stops. Its own docstring already
* claims it is "kept in sync with `@receiver-owner.this`
* in query.ts" — this test is what makes that true.
*
* Drift between them is silent — it produces wrong edges, not errors. Real
* instance: `generator_function` (the EXPRESSION form, `const g = function* ()
* {}`) was added to both queries for #2701 and is present in both `this`-
* boundary lists, but was missing from both `FUNCTION_NODE_TYPES`.
*
* That particular gap was measured to change no graph output today — the
* `this` boundary was already correct via the query marker, and a
* generator-expression binding emits a `Const` node, so its call does not
* resolve either way. So this test is not backfilling a live bug; it is
* removing the class of bug, which the four-way hand-sync otherwise makes a
* matter of vigilance. It fails on that drift, which is the point.
*
* Lists 1 and 2 are asserted EQUAL. Lists 3 and 4 are asserted as subsets with
* an explicit allowlist, because they encode a different question: a
* `method_definition` binds its own `this` (so it is marked in the query) but
* the class IS its `this`-owner (so neither walk may stop there).
*/
import { describe, expect, it } from 'vitest';
import { TYPESCRIPT_SCOPE_QUERY } from '../../src/core/ingestion/languages/typescript/query.js';
import { JAVASCRIPT_SCOPE_QUERY } from '../../src/core/ingestion/languages/javascript/query.js';
import { FUNCTION_NODE_TYPES as TS_FUNCTION_NODE_TYPES } from '../../src/core/ingestion/languages/typescript/captures.js';
import { FUNCTION_NODE_TYPES as JS_FUNCTION_NODE_TYPES } from '../../src/core/ingestion/languages/javascript/captures.js';
import { THIS_REBINDING_BOUNDARY_TYPES } from '../../src/core/ingestion/languages/typescript/receiver-binding.js';
import { THIS_BOUNDARY_NODE_TYPES } from '../../src/core/ingestion/type-extractors/typescript.js';
/**
* Node types of every single-line `(node_type) @capture …` pattern in a query
* that carries `capture`.
*
* Only single-line patterns are matched. A multi-line `@scope.function` pattern
* would be missed — but it would then be missing from the extracted set while
* still present in `FUNCTION_NODE_TYPES`, so the equality assertions below fail
* loudly rather than silently under-checking. Comment lines start with `;;` and
* cannot match the leading `(`.
*/
const nodeTypesCapturedAs = (query: string, capture: string): Set<string> => {
const found = new Set<string>();
const pattern = /^\((\w+)\)((?:[ \t]+@[\w.-]+)+)[ \t]*$/gm;
for (const match of query.matchAll(pattern)) {
const captures = match[2]!.trim().split(/\s+/);
if (captures.includes(capture)) found.add(match[1]!);
}
return found;
};
const sorted = (types: Iterable<string>): string[] => [...types].sort();
describe('the @scope.function patterns and FUNCTION_NODE_TYPES agree', () => {
it('TypeScript', () => {
const fromQuery = nodeTypesCapturedAs(TYPESCRIPT_SCOPE_QUERY, '@scope.function');
expect(fromQuery.size).toBeGreaterThan(0);
expect(sorted(fromQuery)).toEqual(sorted(TS_FUNCTION_NODE_TYPES));
});
it('JavaScript', () => {
const fromQuery = nodeTypesCapturedAs(JAVASCRIPT_SCOPE_QUERY, '@scope.function');
expect(fromQuery.size).toBeGreaterThan(0);
expect(sorted(fromQuery)).toEqual(sorted(JS_FUNCTION_NODE_TYPES));
});
});
describe('the @receiver-owner.this markers and THIS_REBINDING_BOUNDARY_TYPES agree', () => {
// `object` is a boundary but never a function scope: an object literal
// rebinds `this` to itself, and it is captured as `@scope.object`.
const NOT_A_FUNCTION_SCOPE = new Set(['object']);
// Marked in the query (they bind their own `this`) but deliberately NOT walk
// boundaries: for a method the enclosing class IS the `this`-owner, so
// stopping there would delete every correct `this.x` resolution. The
// signature forms carry no body at all.
const OWNS_THIS_BUT_CLASS_IS_THE_OWNER = [
'abstract_method_signature',
'function_signature',
'method_definition',
'method_signature',
];
it('TypeScript: every walk boundary is a query-marked this-owner', () => {
const marked = nodeTypesCapturedAs(TYPESCRIPT_SCOPE_QUERY, '@receiver-owner.this');
const boundaries = [...THIS_REBINDING_BOUNDARY_TYPES].filter(
(type) => !NOT_A_FUNCTION_SCOPE.has(type),
);
expect(marked.size).toBeGreaterThan(0);
expect(boundaries.filter((type) => !marked.has(type))).toEqual([]);
});
it('TypeScript: the markers that are NOT boundaries are exactly the method forms', () => {
const marked = nodeTypesCapturedAs(TYPESCRIPT_SCOPE_QUERY, '@receiver-owner.this');
expect(sorted([...marked].filter((type) => !THIS_REBINDING_BOUNDARY_TYPES.has(type)))).toEqual(
OWNS_THIS_BUT_CLASS_IS_THE_OWNER,
);
});
it('JavaScript: every walk boundary is a query-marked this-owner', () => {
// `receiver-binding.ts` is shared — `javascript/captures.ts` imports
// `synthesizeTsReceiverBinding` — so the same boundary set governs JS.
const marked = nodeTypesCapturedAs(JAVASCRIPT_SCOPE_QUERY, '@receiver-owner.this');
const boundaries = [...THIS_REBINDING_BOUNDARY_TYPES].filter(
(type) => !NOT_A_FUNCTION_SCOPE.has(type),
);
expect(marked.size).toBeGreaterThan(0);
expect(boundaries.filter((type) => !marked.has(type))).toEqual([]);
});
it('the type-env walk boundary matches the enclosing-type walk boundary', () => {
// Two walks, two lists, one rule. They differ only by `object`: the
// enclosing-type walk must stop at an object literal (`this` is the
// literal), while the type-env walk never reaches one — it is not a
// function scope.
const marked = nodeTypesCapturedAs(TYPESCRIPT_SCOPE_QUERY, '@receiver-owner.this');
expect(sorted(THIS_BOUNDARY_NODE_TYPES)).toEqual(
sorted([...THIS_REBINDING_BOUNDARY_TYPES].filter((type) => !NOT_A_FUNCTION_SCOPE.has(type))),
);
expect([...THIS_BOUNDARY_NODE_TYPES].filter((type) => !marked.has(type))).toEqual([]);
});
it('an arrow is marked in neither — it inherits `this` lexically', () => {
const tsMarked = nodeTypesCapturedAs(TYPESCRIPT_SCOPE_QUERY, '@receiver-owner.this');
const jsMarked = nodeTypesCapturedAs(JAVASCRIPT_SCOPE_QUERY, '@receiver-owner.this');
expect(tsMarked.has('arrow_function')).toBe(false);
expect(jsMarked.has('arrow_function')).toBe(false);
expect(THIS_REBINDING_BOUNDARY_TYPES.has('arrow_function')).toBe(false);
});
});