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.
This commit is contained in:
Gergo Magyar 2026-07-25 17:11:11 +00:00
parent 18757c048d
commit 5fee2016d2
4 changed files with 59 additions and 4 deletions

View file

@ -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';

View file

@ -63,6 +63,8 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// method injection sites plus bean-name and @Primary provider metadata.
// v20: Java/Kotlin capture side-channels persist package and class-annotation
// facts for shared Spring Bean resolution.
// v23: Dart closure bindings emit Function nodes for function-local closures
// and flow captures for top-level ones (#2693).
// v21: Java local class/enum/record/interface captures use javac-compatible,
// source-type-relative JLS 13.1 identities and declaration-to-block scopes
// (#2562).
@ -70,7 +72,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// 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 = 23;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -467,13 +467,19 @@ export interface RepoMeta {
* instance owner is outside the caller's enclosing class/MRO (#2563). The
* incremental write set would otherwise retain those stale CALLS edges on
* every unchanged C# and Kotlin file; force a full re-analyze instead.
* v16: calls through a closure-valued binding (`val f = { }; f()`) now resolve
* in Kotlin, Swift and Dart (#2693). These are NEW `CALLS` edges, and Dart also
* gains `Function` nodes for function-local closures. 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.
*
* v15: `const X = <arrow | function-expression>` no longer emits an edgeless
* `Const:<file>:X` twin beside its `Function` node (#2687). The incremental
* 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.
*/
export const INCREMENTAL_SCHEMA_VERSION = 15;
export const INCREMENTAL_SCHEMA_VERSION = 16;
export interface IndexedRepo {
repoPath: string;

View file

@ -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 16 (closure-binding call resolution, #2693)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(16);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {