Przeglądaj źródła

feat(mcp): closure-collection dispatch synthesizer + trace endpoint relevance

Three dynamic-dispatch / disambiguation improvements, validated on Alamofire
(the README's weakest repo, n=8): WITH-arm tool calls 12→8 median, read
variance collapsed 0–12 → 1–4.

- trace endpoint relevance (handleTrace nodeRelevance): overloaded names hit
  protocol/delegate-stub flooding (Swift/Java/C#/Go) — `trace request→task`
  resolved to an empty EventMonitor.request(){} stub over the real
  Session.request because shared-dir-prefix favored two unrelated Source/Features/
  stubs. Penalize ≤1-line stubs + test symbols so the substantive definition
  wins; path-proximity (cosmos EndBlocker) unaffected. THE fix — the meltdowns
  were all the trace-collision flounder. Control-safe (excalidraw/okhttp/gin).

- closure-collection synthesizer (closureCollectionEdges): Swift deferred-
  validation pattern — validators.write{$0.append(v)} … didCompleteTask
  validators.forEach{$0()}. Element-invoke $0(/it( is the precision gate →
  9 edges on Alamofire, 0 on every non-closure-collection control. Sufficiency
  proven (forced codegraph-only: 3/3 build/send/validate correct).

- explore synth-links (buildFlowFromNamedSymbols): surfaces synthesized
  dynamic-dispatch edges incident to any NAMED symbol even when the other end
  isn't named ("didCompleteTask runs validators" when only `validate` named).
  Bounded: cap 6, pathIds-guarded.

Full suite 1090 pass (only pre-existing npm-shim network fails). CHANGELOG +
coverage-playbook (§3c/3d + Swift row) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 3 tygodni temu
rodzic
commit
e86d573006

+ 3 - 0
CHANGELOG.md

@@ -14,9 +14,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - `codegraph init` now builds the initial index by default — you no longer need the `-i`/`--index` flag (it's still accepted, so existing commands and scripts keep working). (#483)
 - Go: Gin middleware chains now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request reaches the middleware and route handlers registered via `.Use()` / `.GET()` instead of dead-ending where the framework dispatches the chain dynamically.
 - `codegraph_explore` now sizes its response to the *answer* instead of the file count: it shows the mechanism and the exact methods you asked about in full — even when they're buried deep in a large file — while collapsing the redundant interchangeable implementations of an interface (an HTTP interceptor chain, a query-compiler family) down to signatures. Fewer tokens for a more complete answer, so on the flows that used to occasionally cost more than plain grep/read it's now clearly cheaper — and the win holds across small, medium, and large codebases. Distinct, non-interchangeable code is shown in full as before. Disable with `CODEGRAPH_ADAPTIVE_EXPLORE=0`.
+- Swift deferred-validation flows (and similar "handler array" patterns) now connect end-to-end in `codegraph_trace` and `codegraph_explore` — following a request's lifecycle reaches the validators registered with `.validate { … }` instead of dead-ending where the framework runs them by iterating a stored list of closures. Any pattern where closures are appended to a collection and later invoked by looping over it is now traced.
+- `codegraph_explore` now spells out the dynamic-dispatch relationships of the symbols you ask about — e.g. "the closures registered here are run by `didCompleteTask`" — so the indirect hops you'd otherwise grep to reconstruct are listed alongside the call flow.
 
 ### Fixes
 
+- `codegraph_trace` now resolves an overloaded symbol name to its real implementation instead of an empty protocol/delegate stub. Tracing a flow through a heavily-overloaded API (common in Swift, Java, C#, and Go) could land on an unrelated no-op method that happened to share the name and report "no path"; it now picks the substantive definition the flow actually runs through.
 - Indexing a project that contains only config-style files (YAML, Twig, or `.properties`) no longer misleadingly reports "No files found to index" — these files are tracked at the file level and are now counted as indexed. Thanks @luojiyin1987 (#357).
 
 ## [0.9.7] - 2026-05-28

+ 124 - 0
__tests__/closure-collection-synthesizer.test.ts

@@ -0,0 +1,124 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+
+/**
+ * End-to-end synthesizer test for closure-collection dynamic dispatch.
+ *
+ * A method appends a closure to a collection property; another method iterates
+ * that property *invoking each element* (`coll.forEach { $0() }`) — a dynamic
+ * dispatch tree-sitter can't resolve, so a flow into the dispatcher dead-ends
+ * before the registered closures. This is Alamofire's request-validation shape:
+ * `DataRequest.validate` does `validators.write { $0.append(validator) }`, the
+ * base `Request.didCompleteTask` runs `validators.forEach { $0() }`.
+ *
+ * Verify the synthesizer (1) links the dispatcher → each same-named registrar
+ * across files/classes, (2) handles both the Swift `prop.write { $0.append }`
+ * and the direct `prop.append(...)` registrar forms, (3) surfaces the wiring
+ * site, and (4) does NOT fire on a `.forEach` that doesn't invoke its element
+ * (the closure-invoke is the precision gate — a plain collection is skipped).
+ */
+describe('closure-collection synthesizer', () => {
+  let dir: string;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'closure-coll-fixture-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('links dispatcher → registrars across files, both append forms, and skips non-invoked collections', async () => {
+    // Base class: the dispatchers (iterate-and-invoke) + a non-closure control.
+    fs.writeFileSync(
+      path.join(dir, 'Request.swift'),
+      `class Request {
+    var validators: [() -> Void] = []
+    var handlers: [() -> Void] = []
+    var names: [String] = []
+
+    func didCompleteTask() {
+        let validators = validators
+        validators.forEach { $0() }
+    }
+
+    func runHandlers() {
+        handlers.forEach { $0() }
+    }
+
+    func printNames() {
+        names.forEach { print($0) }
+    }
+}
+`
+    );
+
+    // Subclass: the registrars (append a closure) in a DIFFERENT file/class.
+    fs.writeFileSync(
+      path.join(dir, 'DataRequest.swift'),
+      `class DataRequest: Request {
+    func validate(_ validation: @escaping () -> Void) -> Self {
+        let validator: () -> Void = { validation() }
+        validators.write { $0.append(validator) }
+        return self
+    }
+
+    func onEvent(_ handler: @escaping () -> Void) {
+        handlers.append(handler)
+    }
+
+    func addName(_ n: String) {
+        names.append(n)
+    }
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+
+    const db = (cg as any).db.db;
+    const rows = db
+      .prepare(
+        `SELECT s.name source_name, s.kind source_kind, t.name target_name,
+                json_extract(e.metadata,'$.field') field,
+                json_extract(e.metadata,'$.registeredAt') registeredAt
+         FROM edges e
+         JOIN nodes s ON s.id = e.source
+         JOIN nodes t ON t.id = e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy') = 'closure-collection'`
+      )
+      .all();
+    cg.close?.();
+
+    expect(rows.length).toBeGreaterThan(0);
+
+    // Every edge originates from a dispatcher method and is a real `calls` hop.
+    expect(rows.every((r: any) => r.source_kind === 'method')).toBe(true);
+
+    // The validators flow: didCompleteTask → validate, captured via the Swift
+    // Protected `prop.write { $0.append }` form, wiring site surfaced.
+    const validatorsEdge = rows.find(
+      (r: any) => r.field === 'validators' && r.target_name === 'validate'
+    );
+    expect(validatorsEdge).toBeTruthy();
+    expect(validatorsEdge.source_name).toBe('didCompleteTask');
+    expect(validatorsEdge.registeredAt).toMatch(/DataRequest\.swift:\d+/);
+
+    // The handlers flow: runHandlers → onEvent, via the direct `prop.append`
+    // form — proves both registrar shapes are covered.
+    const handlersEdge = rows.find(
+      (r: any) => r.field === 'handlers' && r.target_name === 'onEvent'
+    );
+    expect(handlersEdge).toBeTruthy();
+    expect(handlersEdge.source_name).toBe('runHandlers');
+
+    // Precision gate: `names.forEach { print($0) }` does NOT invoke its element,
+    // so `names` is not a closure collection — no edge, and addName is never a target.
+    expect(rows.some((r: any) => r.field === 'names')).toBe(false);
+    expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false);
+  });
+});

+ 47 - 0
docs/design/dynamic-dispatch-coverage-playbook.md

@@ -43,6 +43,7 @@ Static tree-sitter extraction captures explicit calls (`foo()`, `this.bar()`). I
 | 2 | **Field-backed observer** | `onUpdate(cb)` + `for(cb of cbs)cb()` | callback synthesizer (whole-graph pass) | medium |
 | 3 | **String-keyed EventEmitter** | `on('e',fn)` / `emit('e')` | callback synthesizer (event-keyed) | medium |
 | 4 | **Inline callback handler** | `on('e', function h(){})` / `() => {}` | extraction (named) + synthesizer link-through-body (anon) | named: cheap · anon: hard |
+| 5 | **Closure-collection dispatch** | Swift `validators.write{$0.append(v)}` … `validators.forEach{$0()}` | callback synthesizer (`closureCollectionEdges`, element-invoke gated) | medium |
 
 Key distinction driving the mechanism choice:
 - **A named ref exists** to resolve (`_iterable_class` is an attribute name) → **resolver**.
@@ -78,6 +79,51 @@ Key distinction driving the mechanism choice:
   extracts named nested functions).
 - **Result:** `trace(mutateElement, triggerRender)` → 3 hops; express `use → onmount`.
 
+### 3c. Alamofire deferred validation — closure-collection dispatch (Swift)
+- **Hole:** `DataRequest.validate(_:)` builds a closure and `validators.write { $0.append(validator) }`;
+  the base `Request.didCompleteTask` runs them via `validators.forEach { $0() }`. Append and
+  dispatch live in *different files and classes* (a subclass appends, the base iterates) and the
+  field is a Swift `Protected<[@Sendable () -> Void]>` — so neither same-file pairing nor the
+  name-based registrar match (`onX`/`subscribe`/…) reaches it. `trace(didCompleteTask, validate)`
+  returned no path; the agent grepped `validators` and read three files to reconstruct it.
+- **Fix:** `closureCollectionEdges` (callback-synthesizer.ts). A **dispatcher** iterates a collection
+  *invoking each element* (`coll.forEach { $0() }` / `{ it() }`); a **registrar** appends a closure to
+  the same-named field (`.append`/`.add`/`.push`/`.insert`, incl. Swift `.write { $0.append }`). The
+  element-invoke (`$0(` / `it(`) is the precision **gate** — it proves the collection holds closures —
+  so a repo with no closure-collection dispatch yields **0 edges** regardless of how many `.append`
+  sites it has. Pairs dispatcher → registrar globally by field name (cross-file/class required),
+  fan-out-capped. Surfaced two ways: inline in `trace`, and as a "Dynamic-dispatch links among your
+  symbols" section in `codegraph_explore` (`buildFlowFromNamedSymbols`) so the relationship shows even
+  when the agent named only `validate`, not the `didCompleteTask` that drains the list.
+- **Files:** `src/resolution/callback-synthesizer.ts` (`closureCollectionEdges`),
+  `src/mcp/tools.ts` (`synthEdgeNote` closure-collection case + the explore synth-links section).
+- **Result:** `trace(didCompleteTask, validate)` connects with the closure-collection hop + the
+  `validators.write { $0.append }` wiring site inlined. 9 precise edges on Alamofire
+  (`validators`/`streams`/`finishHandlers`/`requestsToRetry`), **0 on every non-Swift control**.
+  Forced codegraph-only (Read+Grep+Bash blocked): 3/3 runs answer build/send/validate correctly.
+
+### 3d. Insight — an "adoption floor" can hide a trace-endpoint bug (Alamofire)
+Alamofire (110 files) was the README's weakest repo and was written off as the "small-repo floor"
+(native grep is cheap, so the agent reads anyway). It wasn't. Reading the **transcripts** — every
+`Read`'s `file_path`+offset and the assistant text right before it — surfaced the agent's own words:
+*"the trace collided with same-named symbols (44 `request`s, 8 `task`s), let me read by line."*
+`codegraph_trace`'s endpoint disambiguation (`scorePair`, shared-dir-prefix only) was resolving an
+overloaded name to an **empty delegate/protocol stub** — `request` → `EventMonitor.request(){}`
+(a 1-line no-op) over the real `Session.request`, because two unrelated `Source/Features/` stubs
+shared a deeper dir prefix than the correct `Source/Core/` pair. Garbage trace → manual reading,
+sometimes a spiral (12 reads / 11 greps in one run). **Fix:** a `nodeRelevance` term in `handleTrace`
+pair scoring that penalizes empty stubs (≤1 body line) and test-file symbols; among real methods it's
+flat, so path-proximity (cosmos `EndBlocker`) is unaffected. Result (n=8): WITH-arm tool calls
+12 → 8 median, and the read **variance collapsed** (0–12 → 1–4 — the meltdowns *were* the
+trace-collision flounder). General bug: protocol/delegate-stub flooding hits Swift/Java/C#/Go.
+
+**Methodology lesson:** when the agent reads on a small repo, don't conclude "adoption floor" — diff
+*what it read* against what the tool returned *immediately before*. A read of content the tool already
+gave = adoption; a read after the tool returned the **wrong thing** (stub endpoints, collided names) =
+a fixable bug. The transcript reasoning, not the median, tells you which. The forced codegraph-only
+hook (block Read+Grep+Glob+Bash-search) is the variance-free way to confirm sufficiency separately
+from adoption.
+
 ---
 
 ## 4. The repeatable methodology (run this per language/framework)
@@ -188,6 +234,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started.
 | Java | MyBatis (XML mappers) | DAO interface method → `<select\|insert\|update\|delete id="X">` SQL | R (XML extract) + S (Java↔XML synthesizer) | ✅ **XML mapper as first-class language** (#389) — `src/extraction/mybatis-extractor.ts` parses files containing `<mapper namespace="...">`; emits one method-shaped node per statement qualified `<namespace>::<id>` + `<sql id="X">` fragments + `<include refid>` references. Non-mapper XML (pom, log4j) → file node only. `mybatisJavaXmlEdges` synthesizer indexes Java methods by `<ClassName>::<methodName>` and joins to XML qualified names by suffix-match — ambiguous simple-name collisions dropped (precision over recall). mall-tiny S **6/6 custom-SQL mapper methods bridge** to their XML statements; full enterprise chain `trace(controller.action → mapper.method-xml)` connects across controller / service-iface / impl / mapper / XML. 🔬 cross-mapper `<include>` via unqualified refid; MyBatis Plus dynamic methods (`BaseMapper<T>` CRUD inherited from framework, not in project); annotation-driven mappers (`@Select("SELECT ...")` on Java methods — the SQL lives in the annotation, not XML) |
 | Kotlin | Spring Boot / Jetpack Compose | request → @RestController → service; @Composable → child | R + X | ✅ **Spring Boot Kotlin** — the Spring resolver was `['java']`-only with a Java-syntax method regex (`public X name()`); extended to `.kt` + Kotlin `fun name(` handler matching (petclinic-kotlin **0→18, 18/18**; class-prefix joins; DI controller→repo resolves — `showOwner ← GET /owners/{ownerId}` → `OwnerRepository.findById`). **Compose composition already static** (@Composable→child are plain function calls — Jetcaster `PodcastInformation→HtmlTextContainer`). Java Spring unchanged (realworld 19/19). 🔬 Ktor `routing { get("/x"){…} }` lambda handlers (anonymous) + Compose recomposition (implicit `mutableStateOf`, no setState gate) + coroutines/Flow |
 | Swift | Vapor | request → route → controller | R + X | ✅ **was 0 routes on every real app** — the extractor required an `app/router/routes` receiver + a `"path"` literal, but real Vapor routes on grouped builders (`let todos = routes.grouped("todos"); todos.get(use: index)`) with NO path arg. Rewrote: any receiver, optional/non-string path segments, `.grouped`/`.group{}` prefix tracking, `use:` discriminator. vapor-template S **0→3 (3/3**, nested `/todos/:todoID`), SteamPress M **0→27 (27/27)**, SwiftPackageIndex-Server L **0→14 (14/14** handler resolution). 🔬 typed-route enums (SPI `SiteURL.x.pathComponents` — path label only, handler still resolves) + closure handlers `app.get("x"){ }` (anonymous) |
+| Swift | Alamofire / closure-collection | request → build → send → **validate** (deferred closures) | S | ✅ **closure-collection dispatch synthesizer** (`closureCollectionEdges`): the Swift deferred-handler pattern `DataRequest.validate` `validators.write{$0.append(v)}` … base `Request.didCompleteTask` `validators.forEach{$0()}` (append + dispatch in different files/classes, field is `Protected<[() -> Void]>`). The element-invoke `$0(`/`it(` is the precision gate → **9 edges on Alamofire** (validators/streams/finishHandlers/requestsToRetry), **0 on every non-closure-collection control**. Surfaced inline in `trace` + as an explore "Dynamic-dispatch links" section (so it shows when the agent named only `validate`, not the `didCompleteTask` that drains the list). Forced codegraph-only: **3/3** build/send/validate correct. + **trace endpoint relevance** (`nodeRelevance`): overloaded `request`/`task` (44/8 defs, mostly empty `EventMonitor` delegate stubs) now resolve to the real `Session.request`, not a 1-line no-op — **WITH-arm tool calls 12→8 median, read variance 0–12→1–4** (the meltdowns were all the trace-collision flounder); control-safe (excalidraw/okhttp/gin traces intact, gin A/B 0 reads). 🔬 god-file explore body-trim (Session.swift > per-file cap drops the *named* `Session.request` body — wants relevance-first selection, not a bigger cap) |
 | C# | ASP.NET Core | request → [Http*] action → DI service → EF | X | ✅ **feature-folder detection** (realworld 0→19 — was undetected) + **bare `[HttpGet]` + class `[Route]` prefix** (eShopOnWeb 9→33 / jellyfin L) — co-located so no claimsReference needed. 🔬 EF Core LINQ/DbSet (metaprogramming frontier) |
 | Ruby | Rails / Sinatra | request → routes.rb → Controller#action → model | R | ✅ **RESTful `resources`/`resource` routing → controller#action** (realworld S 16 / spree M / forem L), pluralization + only/except + claimsReference; explicit routes fixed to precise `controller#action` too. 🔬 ActiveRecord dynamic finders (`Article.find_by_slug`) — metaprogramming frontier |
 | PHP | Laravel | request → route → controller → Eloquent | R | ✅ **precise `Route::get([Ctrl::class,'m'])` / `'Ctrl@m'` → Ctrl@method** (realworld S / firefly M / bookstack L) — was resolving the bare method name to the WRONG controller (every `index`→ArticleController); Route::resource→controller. 🔬 Eloquent dynamic finders/relationships (metaprogramming frontier) |

+ 77 - 9
src/mcp/tools.ts

@@ -1586,10 +1586,28 @@ export class ToolHandler {
       - (isLessCanonicalPath(b) ? LESS_CANONICAL_PENALTY : 0);
     const fromCands = fromMatches.nodes;
     const toCands = toMatches.nodes;
+    // Candidate relevance: an overloaded name (Alamofire has 44 `request`s, most
+    // of them EMPTY EventMonitor protocol-conformance stubs `func request(…){}`)
+    // floods the pool with no-op decls. Shared-dir-prefix alone then MISLEADS —
+    // two unrelated `Source/Features/` delegate stubs outscore the real
+    // `Source/Core/Session.request` × `Source/Core/…task` pair the agent meant,
+    // so trace resolves to stubs, finds no path, and the agent reads by line.
+    // Penalize empty stubs and test-file symbols so a substantive entry point
+    // wins; among real methods this is ~flat, so path-proximity still decides
+    // (cosmos EndBlocker disambiguation is unaffected — none of its candidates
+    // are stubs/tests).
+    const isTestPath = (p: string): boolean => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
+    const nodeRelevance = (n: Node): number => {
+      const bodyLines = Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
+      let s = Math.min(bodyLines, 20);     // a substantive body is more likely the meant symbol
+      if (bodyLines <= 1) s -= 40;          // empty/one-line stub (protocol no-op, decl-only) — almost never the trace endpoint
+      if (isTestPath(n.filePath)) s -= 150; // a Source/ symbol is meant over a Tests/ same-named one
+      return s;
+    };
     const pairs: Array<{ f: Node; t: Node; score: number }> = [];
     for (const f of fromCands) {
       for (const t of toCands) {
-        pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) });
+        pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) + nodeRelevance(f) + nodeRelevance(t) });
       }
     }
     // Sort by shared prefix desc, then by FTS order (already encoded in the
@@ -1843,6 +1861,14 @@ export class ToolHandler {
         registeredAt,
       };
     }
+    if (m?.synthesizedBy === 'closure-collection') {
+      const field = m.field ? `\`${String(m.field)}\`` : 'a collection';
+      return {
+        label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`,
+        compact: `dynamic: runs ${field} handlers${at}`,
+        registeredAt,
+      };
+    }
     return null;
   }
 
@@ -2001,20 +2027,62 @@ export class ToolHandler {
         chain.reverse();
         if (!best || chain.length > best.length) best = chain;
       }
-      if (!best || best.length < 3) return EMPTY;
-      const out = ['## Flow (call path among the symbols you queried)', ''];
-      for (let i = 0; i < best.length; i++) {
-        const step = best[i]!;
-        if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}`); }
-        out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
+      const hasMain = !!best && best.length >= 3;
+      const pathIds = new Set((best ?? []).map((s) => s.node.id));
+
+      // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
+      // symbol — the indirect hops an agent would otherwise grep/Read to
+      // reconstruct ("where do the appended `validators` actually run?"). The
+      // synth edge IS that answer, so surface it even when the OTHER end wasn't
+      // named (e.g. the agent names `validate` but not the `didCompleteTask`
+      // that drains the collection). On-topic by construction: only heuristic
+      // edges touching a symbol the agent named; skipped when the hop already
+      // shows in the main chain.
+      const synthLines: string[] = [];
+      const synthSeen = new Set<string>();
+      for (const n of named.values()) {
+        if (synthLines.length >= 6) break;
+        for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
+          if (synthLines.length >= 6) break;
+          if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
+          if (pathIds.has(edge.source) && pathIds.has(edge.target)) continue; // already in the main chain
+          const src = edge.source === n.id ? n : other;
+          const tgt = edge.source === n.id ? other : n;
+          const key = `${src.name}>${tgt.name}`;
+          if (synthSeen.has(key)) continue;
+          synthSeen.add(key);
+          const note = this.synthEdgeNote(edge);
+          synthLines.push(`- ${src.name} → ${tgt.name}   [${note ? note.compact : edge.kind}]`);
+        }
+      }
+
+      if (!hasMain && synthLines.length === 0) return EMPTY;
+      const out: string[] = [];
+      if (hasMain) {
+        out.push('## Flow (call path among the symbols you queried)', '');
+        for (let i = 0; i < best!.length; i++) {
+          const step = best![i]!;
+          if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`   ↓ ${sy ? sy.compact : step.edge.kind}`); }
+          out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
+        }
+        out.push('');
+      }
+      if (synthLines.length) {
+        out.push(
+          '## Dynamic-dispatch links among your symbols',
+          '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
+          '',
+          ...synthLines,
+          ''
+        );
       }
-      out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
+      out.push('> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
       // namedNodeIds = every callable the agent explicitly named (a superset of
       // the spine). A file holding one is something the agent asked to SEE, so it
       // must keep full source even if it's an off-spine polymorphic sibling — the
       // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
       // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
-      return { text: out.join('\n'), pathNodeIds: new Set(best.map((s) => s.node.id)), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
+      return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
     } catch {
       return EMPTY;
     }

+ 90 - 0
src/resolution/callback-synthesizer.ts

@@ -47,6 +47,19 @@ const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/
 // Captures the destructure body + the called composable; only `use*` calls qualify.
 const VUE_DESTRUCTURE_RE = /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(\w+)\s*\(/g;
 
+// Closure-collection dynamic dispatch (language-agnostic, Swift-first). A method
+// appends a closure to a collection property; another method iterates that
+// property *invoking each element* (`coll.forEach { $0() }` / `{ it() }`). The
+// element-invoke (`$0(` / `it(`) PROVES the collection holds closures, so pairing
+// a dispatcher to same-named registrars (`.append`/`.add`/`.push`/`.insert`,
+// incl. Swift `prop.write { $0.append }`) is high-precision. Cross-file/class by
+// design: Alamofire appends in `DataRequest.validate` but iterates in the base
+// `Request.didCompleteTask` — neither same-file nor same-class pairing reaches it.
+const CC_DISPATCH_RE = /(\w+)\.forEach\s*\{\s*(?:\$0|it)\s*\(/g;
+const CC_APPEND_WRITE_RE = /(\w+)\.write\s*\{\s*\$0(?:\.(\w+))?\.(?:append|add|push|insert)\s*\(/g;
+const CC_APPEND_DIRECT_RE = /(\w+)\.(?:append|add|push|insert)\s*\(/g;
+const CC_FANOUT_CAP = 8; // skip a field name with more dispatchers/registrars than this (too generic to pair confidently)
+
 function kebabToPascal(s: string): string {
   return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
 }
@@ -143,6 +156,81 @@ function fieldChannelEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[
   return edges;
 }
 
+/**
+ * Closure-collection dispatch: dispatcher iterates a closure-collection property
+ * invoking each element; registrar appends a closure to the same-named property.
+ * Emits dispatcher → registrar so a flow reaches the registration site (where the
+ * appended closure's body — and its callers — live). High-precision: the
+ * dispatcher's element-invoke is the gate (a `.forEach` that does NOT invoke its
+ * element is ignored), so a repo with no closure-collection dispatch yields zero
+ * edges regardless of how many `.append`/`.push` sites it has.
+ *
+ * Pairs globally by field name (cross-file/class is required — see Alamofire's
+ * base-class `Request.didCompleteTask` iterating `validators` appended by the
+ * subclass `DataRequest.validate`), bounded by a fan-out cap so a generic field
+ * name shared across unrelated classes can't fan out into noise.
+ */
+function closureCollectionEdges(queries: QueryBuilder, ctx: ResolutionContext): Edge[] {
+  const candidates = [...queries.getNodesByKind('method'), ...queries.getNodesByKind('function')];
+  const dispatchers = new Map<string, Array<{ node: Node; line: number }>>(); // field → dispatcher methods + forEach line
+  const registrars = new Map<string, Array<{ node: Node; line: number }>>();   // field → registrar methods + append line
+
+  const addReg = (field: string | undefined, node: Node, absLine: number) => {
+    if (!field || /^\d+$/.test(field)) return; // `$0.append` mis-captures the `0`; the write-RE owns that field
+    const arr = registrars.get(field) ?? [];
+    if (!arr.some((r) => r.node.id === node.id)) arr.push({ node, line: absLine });
+    registrars.set(field, arr);
+  };
+
+  for (const m of candidates) {
+    const content = ctx.readFile(m.filePath);
+    const src = content && sliceLines(content, m.startLine, m.endLine);
+    if (!src) continue;
+    const hasForEach = src.includes('.forEach');
+    const hasAppend = src.includes('.append(') || src.includes('.add(') || src.includes('.push(') || src.includes('.insert(');
+    if (!hasForEach && !hasAppend) continue;
+    const lineAt = (idx: number) => (m.startLine ?? 1) + src.slice(0, idx).split('\n').length - 1;
+
+    if (hasForEach) {
+      CC_DISPATCH_RE.lastIndex = 0;
+      let d: RegExpExecArray | null;
+      while ((d = CC_DISPATCH_RE.exec(src))) {
+        const arr = dispatchers.get(d[1]!) ?? [];
+        if (!arr.some((n) => n.node.id === m.id)) arr.push({ node: m, line: lineAt(d.index) });
+        dispatchers.set(d[1]!, arr);
+      }
+    }
+    if (hasAppend) {
+      CC_APPEND_WRITE_RE.lastIndex = 0;
+      let w: RegExpExecArray | null;
+      while ((w = CC_APPEND_WRITE_RE.exec(src))) addReg(w[2] || w[1], m, lineAt(w.index)); // nested `$0.streams` else the `.write` receiver
+      CC_APPEND_DIRECT_RE.lastIndex = 0;
+      let a: RegExpExecArray | null;
+      while ((a = CC_APPEND_DIRECT_RE.exec(src))) addReg(a[1], m, lineAt(a.index));
+    }
+  }
+
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+  for (const [field, disps] of dispatchers) {
+    const regs = registrars.get(field);
+    if (!regs || regs.length === 0) continue;
+    if (disps.length > CC_FANOUT_CAP || regs.length > CC_FANOUT_CAP) continue; // generic field — can't pair confidently
+    for (const disp of disps) for (const reg of regs) {
+      if (disp.node.id === reg.node.id) continue;
+      const key = `${disp.node.id}>${reg.node.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: disp.node.id, target: reg.node.id, kind: 'calls', line: disp.line,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'closure-collection', field, registeredAt: `${reg.node.filePath}:${reg.line}` },
+      });
+    }
+  }
+  return edges;
+}
+
 /** Phase 2: string-keyed EventEmitter channels (on('e', fn) ↔ emit('e')). */
 function eventEmitterEdges(ctx: ResolutionContext): Edge[] {
   const emitsByEvent = new Map<string, Set<string>>();          // event → dispatcher node ids
@@ -1093,6 +1181,7 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
   const fieldEdges = fieldChannelEdges(queries, ctx);
+  const closureCollEdges = closureCollectionEdges(queries, ctx);
   const emitterEdges = eventEmitterEdges(ctx);
   const renderEdges = reactRenderEdges(queries, ctx);
   const jsxEdges = reactJsxChildEdges(ctx);
@@ -1110,6 +1199,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const seen = new Set<string>();
   for (const e of [
     ...fieldEdges,
+    ...closureCollEdges,
     ...emitterEdges,
     ...renderEdges,
     ...jsxEdges,