feat(mcp): trace relevance + closure-collection + god-file rendering + cold-start handshake (#580)
Trace endpoint relevance (overloaded names resolve to the real implementation instead of an empty protocol/delegate stub), Swift closure-collection synthesizer, multi-phase god-file explore rendering, and serve --mcp cold-start handshake sped ~811ms→~90ms (proxy answers initialize/tools-list locally). Full suite green (1090 pass).
This commit is contained in:
+13
-3
@@ -10,10 +10,20 @@
|
||||
* inotify watch set — that's the entire point of issue #411.
|
||||
*/
|
||||
|
||||
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
||||
import type CodeGraph from '../index';
|
||||
import { findNearestCodeGraphRoot } from '../directory';
|
||||
import { watchDisabledReason } from '../sync';
|
||||
import { ToolHandler } from './tools';
|
||||
|
||||
// Lazy-load the heavy CodeGraph chain (sqlite + query/graph/context layers) OFF
|
||||
// the MCP startup path. It's only needed once a tool actually opens a project —
|
||||
// not to answer initialize/tools-list — so deferring it lets `serve --mcp` (and
|
||||
// the daemon it spawns) bind + register tools in ~Node-startup time instead of
|
||||
// ~800ms, closing the "No such tool available" cold-start race that made headless
|
||||
// agents flounder. require() is sync + cached on the CommonJS build.
|
||||
const loadCodeGraph = (): typeof import('../index').default =>
|
||||
(require('../index') as typeof import('../index')).default;
|
||||
|
||||
export interface MCPEngineOptions {
|
||||
/**
|
||||
* Whether to start the file watcher when initializing. Daemon and direct
|
||||
@@ -118,7 +128,7 @@ export class MCPEngine {
|
||||
try { this.cg.close(); } catch { /* ignore */ }
|
||||
this.cg = null;
|
||||
}
|
||||
this.cg = CodeGraph.openSync(resolvedRoot);
|
||||
this.cg = loadCodeGraph().openSync(resolvedRoot);
|
||||
this.projectPath = resolvedRoot;
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
@@ -154,7 +164,7 @@ export class MCPEngine {
|
||||
|
||||
this.projectPath = resolvedRoot;
|
||||
try {
|
||||
this.cg = await CodeGraph.open(resolvedRoot);
|
||||
this.cg = await loadCodeGraph().open(resolvedRoot);
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
this.catchUpSync();
|
||||
|
||||
+43
-40
@@ -37,8 +37,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn, StdioOptions } from 'child_process';
|
||||
import { findNearestCodeGraphRoot } from '../index';
|
||||
import { getCodeGraphDir } from '../directory';
|
||||
import { findNearestCodeGraphRoot, getCodeGraphDir } from '../directory';
|
||||
import { StdioTransport } from './transport';
|
||||
import { MCPEngine } from './engine';
|
||||
import { MCPSession } from './session';
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
isProcessAlive,
|
||||
tryAcquireDaemonLock,
|
||||
} from './daemon';
|
||||
import { runProxy } from './proxy';
|
||||
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
|
||||
import { getDaemonSocketPath } from './daemon-paths';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
|
||||
@@ -82,8 +81,14 @@ const TAKEOVER_RETRY_DELAY_MS = 100;
|
||||
* process startup. 60 × 100ms = 6s of headroom for a cold/slow box; on the
|
||||
* common path the socket appears within a few rounds.
|
||||
*/
|
||||
const DAEMON_CONNECT_MAX_RETRIES = 60;
|
||||
const DAEMON_CONNECT_RETRY_DELAY_MS = 100;
|
||||
// Poll finely (25ms) so the proxy attaches the instant the freshly-spawned
|
||||
// daemon binds, instead of waiting up to a coarse 100ms after — shaves the
|
||||
// cold-start handshake (the window the headless agent races). Same ~6s total
|
||||
// give-up budget (240 × 25ms), just finer granularity; socket-connect probes
|
||||
// are cheap. Paired with deferring the CodeGraph load (engine.ts) off the bind
|
||||
// path, this narrows the "No such tool available" race window.
|
||||
const DAEMON_CONNECT_MAX_RETRIES = 240;
|
||||
const DAEMON_CONNECT_RETRY_DELAY_MS = 25;
|
||||
|
||||
/**
|
||||
* Resolve the PPID watchdog poll interval from an env override. A value of
|
||||
@@ -258,21 +263,20 @@ export class MCPServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const mode = await this.connectOrSpawnDaemon(root);
|
||||
if (mode === 'fallback') {
|
||||
return this.startDirect('daemon unavailable; fallback to direct');
|
||||
}
|
||||
// 'proxy': connectOrSpawnDaemon ran the stdio↔socket pipe to completion
|
||||
// (it only returns once the host disconnected). The process is now
|
||||
// expected to terminate naturally — the proxy installed its own watchdog.
|
||||
// Answer the MCP handshake LOCALLY (instant tool registration — no waiting
|
||||
// ~600ms for the daemon to spawn+bind, which produced the cold-start race)
|
||||
// and forward tool CALLS to the shared daemon, connected in the background.
|
||||
// Runs until the host disconnects; the proxy installs its own watchdog and
|
||||
// falls back to an in-process engine if the daemon never comes up.
|
||||
this.mode = 'proxy';
|
||||
await this.runProxyWithLocalHandshake(root);
|
||||
return;
|
||||
} catch (err) {
|
||||
// Belt-and-braces: if anything throws inside the daemon machinery,
|
||||
// never wedge the user — fall back to a working direct-mode session.
|
||||
// Belt-and-braces: a throw during proxy SETUP (before the client was served)
|
||||
// is still safe to recover from with a direct-mode session.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[CodeGraph MCP] Daemon path failed (${msg}); falling back to direct mode.\n`);
|
||||
return this.startDirect('daemon path threw');
|
||||
process.stderr.write(`[CodeGraph MCP] Proxy path failed (${msg}); falling back to direct mode.\n`);
|
||||
return this.startDirect('proxy path threw');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,32 +380,31 @@ export class MCPServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Become a proxy to the shared daemon, spawning the daemon first if none is
|
||||
* reachable. Returns 'proxy' once the proxied session has run to completion
|
||||
* (the host disconnected), or 'fallback' if the caller should run in-process.
|
||||
* Proxy mode (the common case). Serve the MCP handshake LOCALLY for instant
|
||||
* tool registration, forwarding tool calls to the shared daemon — which is
|
||||
* connected in the background (probed, then spawned + polled if absent) so the
|
||||
* handshake never waits ~600ms on it. Runs until the host disconnects; the
|
||||
* proxy falls back to an in-process engine if the daemon never binds, so this
|
||||
* never wedges a session.
|
||||
*/
|
||||
private async connectOrSpawnDaemon(root: string): Promise<'proxy' | 'fallback'> {
|
||||
private async runProxyWithLocalHandshake(root: string): Promise<void> {
|
||||
const socketPath = getDaemonSocketPath(root);
|
||||
|
||||
// Fast path: a daemon may already be listening. On success runProxy pipes
|
||||
// stdio until the host disconnects, so a 'proxied' outcome means this
|
||||
// process has finished its entire job.
|
||||
let probe = await runProxy(socketPath);
|
||||
if (probe.outcome === 'proxied') return 'proxy';
|
||||
if (probe.reason === 'version mismatch') return 'fallback';
|
||||
|
||||
// No reachable daemon — spawn one (detached) and wait for it to bind.
|
||||
spawnDetachedDaemon(root);
|
||||
|
||||
for (let attempt = 0; attempt < DAEMON_CONNECT_MAX_RETRIES; attempt++) {
|
||||
await sleep(DAEMON_CONNECT_RETRY_DELAY_MS);
|
||||
probe = await runProxy(socketPath);
|
||||
if (probe.outcome === 'proxied') return 'proxy';
|
||||
if (probe.reason === 'version mismatch') return 'fallback';
|
||||
}
|
||||
|
||||
// Daemon never came up in time — run in-process so the user is never blocked.
|
||||
return 'fallback';
|
||||
const getDaemonSocket = async () => {
|
||||
// Fast path: a daemon may already be listening.
|
||||
const probe = await connectWithHello(socketPath);
|
||||
if (probe === 'version-mismatch') return null; // definitive — serve in-process, don't poll for 6s
|
||||
if (probe) return probe;
|
||||
// None reachable — spawn one (detached) and poll for its bind.
|
||||
spawnDetachedDaemon(root);
|
||||
for (let attempt = 0; attempt < DAEMON_CONNECT_MAX_RETRIES; attempt++) {
|
||||
await sleep(DAEMON_CONNECT_RETRY_DELAY_MS);
|
||||
const s = await connectWithHello(socketPath);
|
||||
if (s === 'version-mismatch') return null;
|
||||
if (s) return s;
|
||||
}
|
||||
return null; // never bound — the proxy serves this session in-process
|
||||
};
|
||||
await runLocalHandshakeProxy({ getDaemonSocket, makeEngine: () => new MCPEngine(), root });
|
||||
}
|
||||
|
||||
/** Standard SIGINT/SIGTERM handlers that route to our `stop()` (direct mode). */
|
||||
|
||||
@@ -23,6 +23,10 @@ import * as net from 'net';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
import { DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
|
||||
import { CodeGraphPackageVersion } from './version';
|
||||
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
|
||||
import { SERVER_INSTRUCTIONS } from './server-instructions';
|
||||
import { getStaticTools } from './tools';
|
||||
import type { MCPEngine } from './engine';
|
||||
|
||||
/** Default poll cadence for the PPID watchdog (same as the direct server). */
|
||||
const DEFAULT_PPID_POLL_MS = 5000;
|
||||
@@ -96,6 +100,198 @@ export async function runProxy(
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a daemon at `socketPath` and verify its hello (exact version match).
|
||||
* Returns the live socket (hello already consumed) or null if unreachable / stale
|
||||
* / version-mismatched. Unlike {@link runProxy} it does NOT pipe — the caller
|
||||
* owns the socket. Used by the local-handshake proxy's background connect.
|
||||
*/
|
||||
export async function connectWithHello(
|
||||
socketPath: string,
|
||||
expectedVersion: string = CodeGraphPackageVersion,
|
||||
): Promise<net.Socket | 'version-mismatch' | null> {
|
||||
if (process.platform !== 'win32' && !fs.existsSync(socketPath)) return null;
|
||||
const socket = net.createConnection(socketPath);
|
||||
socket.setEncoding('utf8');
|
||||
const hello = await readHelloLine(socket).catch(() => null);
|
||||
if (!hello) {
|
||||
socket.destroy();
|
||||
return null; // no daemon yet — caller should keep polling
|
||||
}
|
||||
if (hello.codegraph !== expectedVersion) {
|
||||
// A daemon IS up but it's the wrong version — definitive, not a "not yet".
|
||||
// Don't poll; the caller serves in-process so we never run stale-vs-new.
|
||||
process.stderr.write(
|
||||
`[CodeGraph MCP] Found a daemon on ${socketPath} but version (${hello.codegraph}) ` +
|
||||
`differs from ours (${expectedVersion}); serving this session in-process.\n`
|
||||
);
|
||||
socket.destroy();
|
||||
return 'version-mismatch';
|
||||
}
|
||||
process.stderr.write(
|
||||
`[CodeGraph MCP] Attached to shared daemon on ${socketPath} (pid ${hello.pid}, v${hello.codegraph}).\n`
|
||||
);
|
||||
return socket;
|
||||
}
|
||||
|
||||
type JsonRpc = Record<string, unknown>;
|
||||
|
||||
/** Dependencies the local-handshake proxy needs, injected by MCPServer (which
|
||||
* owns the daemon-spawn machinery and the engine factory). */
|
||||
export interface LocalHandshakeDeps {
|
||||
/** Probe → spawn → retry → hello-verify; resolves a connected daemon socket,
|
||||
* or null when the daemon path is genuinely unavailable (→ in-process fallback). */
|
||||
getDaemonSocket(): Promise<net.Socket | null>;
|
||||
/** Lazily create an in-process engine — used ONLY if the daemon never comes up,
|
||||
* preserving the "a broken daemon never wedges a session" guarantee. */
|
||||
makeEngine(): MCPEngine;
|
||||
/** Project root for the fallback engine's lazy init. */
|
||||
root: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-handshake proxy (the cold-start fix).
|
||||
*
|
||||
* Answers `initialize` + `tools/list` from STATIC constants the instant the
|
||||
* client asks — tools register in ~process-startup time instead of waiting
|
||||
* ~600ms for the daemon to spawn+bind, which is what produced the "No such tool
|
||||
* available" race that made headless agents flail into grep/Read. Tool CALLS are
|
||||
* forwarded to the shared daemon (connected in the background); the daemon's
|
||||
* response to the forwarded `initialize` is suppressed (the client already got
|
||||
* the local one). If the daemon never comes up (version mismatch / spawn fail),
|
||||
* a lazily-created in-process engine serves the calls — so the handshake speedup
|
||||
* never costs the old fall-back-to-direct robustness.
|
||||
*/
|
||||
export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<void> {
|
||||
let daemonStatus: 'connecting' | 'ready' | 'failed' = 'connecting';
|
||||
let daemonSocket: net.Socket | null = null;
|
||||
let clientInitId: unknown = undefined; // suppress the daemon's reply to the forwarded initialize
|
||||
const pending: string[] = []; // client lines buffered until the daemon resolves
|
||||
let engine: MCPEngine | null = null;
|
||||
let engineReady: Promise<void> | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const writeClient = (obj: JsonRpc | string): void => {
|
||||
try { process.stdout.write((typeof obj === 'string' ? obj : JSON.stringify(obj)) + '\n'); } catch { /* host gone */ }
|
||||
};
|
||||
const shutdown = (): void => {
|
||||
if (shuttingDown) return; shuttingDown = true;
|
||||
try { daemonSocket?.destroy(); } catch { /* ignore */ }
|
||||
try { engine?.stop(); } catch { /* ignore */ }
|
||||
process.exit(0);
|
||||
};
|
||||
const ensureEngine = (): Promise<void> => {
|
||||
if (!engine) engine = deps.makeEngine();
|
||||
if (!engineReady) engineReady = engine.ensureInitialized(deps.root).catch(() => { /* degraded */ });
|
||||
return engineReady;
|
||||
};
|
||||
// Daemon-unavailable fallback: serve a client message in-process.
|
||||
const handleLocally = async (line: string): Promise<void> => {
|
||||
let msg: JsonRpc; try { msg = JSON.parse(line) as JsonRpc; } catch { return; }
|
||||
const id = msg.id;
|
||||
if (msg.method === 'tools/call' && id !== undefined) {
|
||||
try {
|
||||
await ensureEngine();
|
||||
const params = (msg.params || {}) as { name: string; arguments?: Record<string, unknown> };
|
||||
const result = await engine!.getToolHandler().execute(params.name, params.arguments || {});
|
||||
writeClient({ jsonrpc: '2.0', id, result });
|
||||
} catch (err) {
|
||||
writeClient({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } });
|
||||
}
|
||||
} else if (msg.method === 'ping' && id !== undefined) {
|
||||
writeClient({ jsonrpc: '2.0', id, result: {} });
|
||||
}
|
||||
// initialize already answered locally; notifications (initialized) need no reply.
|
||||
};
|
||||
const routeToDaemon = (line: string): void => {
|
||||
if (daemonStatus === 'ready' && daemonSocket) {
|
||||
try { daemonSocket.write(line.endsWith('\n') ? line : line + '\n'); } catch { /* close path */ }
|
||||
} else if (daemonStatus === 'failed') {
|
||||
void handleLocally(line);
|
||||
} else {
|
||||
pending.push(line);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- client (stdin) ----
|
||||
let stdinBuf = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk: string) => {
|
||||
stdinBuf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = stdinBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdinBuf.slice(0, idx).trim();
|
||||
stdinBuf = stdinBuf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg: JsonRpc; try { msg = JSON.parse(line) as JsonRpc; } catch { routeToDaemon(line); continue; }
|
||||
if (msg.method === 'initialize') {
|
||||
clientInitId = msg.id;
|
||||
writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS } });
|
||||
routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
|
||||
} else if (msg.method === 'tools/list') {
|
||||
writeClient({ jsonrpc: '2.0', id: msg.id, result: { tools: getStaticTools() } });
|
||||
} else {
|
||||
routeToDaemon(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', shutdown);
|
||||
process.stdin.on('close', shutdown);
|
||||
startPpidWatchdogNoSocket(shutdown);
|
||||
|
||||
// ---- daemon connection (background) ----
|
||||
let socket: net.Socket | null = null;
|
||||
try { socket = await deps.getDaemonSocket(); } catch { socket = null; }
|
||||
|
||||
if (socket && !shuttingDown) {
|
||||
daemonSocket = socket;
|
||||
daemonStatus = 'ready';
|
||||
let sockBuf = '';
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('data', (chunk: string) => {
|
||||
sockBuf += chunk;
|
||||
let idx: number;
|
||||
while ((idx = sockBuf.indexOf('\n')) !== -1) {
|
||||
const line = sockBuf.slice(0, idx);
|
||||
sockBuf = sockBuf.slice(idx + 1);
|
||||
if (!line.trim()) continue;
|
||||
if (clientInitId !== undefined) {
|
||||
try { const m = JSON.parse(line) as JsonRpc; if (m.id === clientInitId && ('result' in m || 'error' in m)) continue; } catch { /* relay */ }
|
||||
}
|
||||
writeClient(line);
|
||||
}
|
||||
});
|
||||
socket.on('close', shutdown);
|
||||
socket.on('error', shutdown);
|
||||
for (const line of pending) { try { socket.write(line + '\n'); } catch { /* ignore */ } }
|
||||
pending.length = 0;
|
||||
} else if (!shuttingDown) {
|
||||
daemonStatus = 'failed';
|
||||
process.stderr.write('[CodeGraph MCP] Shared daemon unavailable; serving this session in-process (degraded).\n');
|
||||
const buffered = pending.splice(0);
|
||||
for (const line of buffered) await handleLocally(line);
|
||||
}
|
||||
|
||||
await new Promise<void>(() => { /* stdin keeps the loop alive; exit via shutdown() */ });
|
||||
}
|
||||
|
||||
/** PPID watchdog for the local-handshake proxy — same #277 logic as
|
||||
* {@link startPpidWatchdog} but with no socket to close (the caller's shutdown
|
||||
* handles teardown). */
|
||||
function startPpidWatchdogNoSocket(onDeath: () => void): void {
|
||||
const pollMs = parsePollMs(process.env.CODEGRAPH_PPID_POLL_MS);
|
||||
if (pollMs <= 0) return;
|
||||
const originalPpid = process.ppid;
|
||||
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
|
||||
const timer = setInterval(() => {
|
||||
if (process.ppid !== originalPpid || (hostPpid !== null && !isProcessAliveLocal(hostPpid))) {
|
||||
process.stderr.write('[CodeGraph MCP] Parent process exited; shutting down.\n');
|
||||
onDeath();
|
||||
}
|
||||
}, pollMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one CRLF/LF-terminated JSON line from the socket, parse it as the
|
||||
* daemon hello, and return it. Bounded to {@link MAX_HELLO_LINE_BYTES} so a
|
||||
|
||||
+4
-2
@@ -23,13 +23,15 @@ import { CodeGraphPackageVersion } from './version';
|
||||
* MCP Server Info — kept on the session because some clients log it. The
|
||||
* version tracks the real package version (was a hard-coded '0.1.0').
|
||||
*/
|
||||
const SERVER_INFO = {
|
||||
// Exported so the proxy can answer `initialize` locally with the IDENTICAL
|
||||
// payload the daemon would send — no drift between the two handshake paths.
|
||||
export const SERVER_INFO = {
|
||||
name: 'codegraph',
|
||||
version: CodeGraphPackageVersion,
|
||||
};
|
||||
|
||||
/** MCP Protocol Version (latest the server claims). */
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
export const PROTOCOL_VERSION = '2024-11-05';
|
||||
|
||||
/**
|
||||
* How long to wait for the client's `roots/list` response before giving up
|
||||
|
||||
+206
-39
@@ -4,7 +4,15 @@
|
||||
* Defines the tools exposed by the CodeGraph MCP server.
|
||||
*/
|
||||
|
||||
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
|
||||
import type CodeGraph from '../index';
|
||||
import { findNearestCodeGraphRoot } from '../directory';
|
||||
// Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
|
||||
// helper in engine.ts. ToolHandler must load to answer tools/list (static
|
||||
// schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
|
||||
// CodeGraph is pulled in only when a tool actually opens a project. require() is
|
||||
// sync + cached (CommonJS build).
|
||||
const loadCodeGraph = (): typeof import('../index').default =>
|
||||
(require('../index') as typeof import('../index')).default;
|
||||
import {
|
||||
detectWorktreeIndexMismatch,
|
||||
worktreeMismatchWarning,
|
||||
@@ -622,6 +630,19 @@ export const tools: ToolDefinition[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
|
||||
* proxy answers `tools/list` with before any project is open. Mirrors
|
||||
* `ToolHandler.getTools()` in the no-CodeGraph case (the dynamic per-repo budget
|
||||
* note in a description only adds once `cg` is loaded; the schemas are static).
|
||||
*/
|
||||
export function getStaticTools(): ToolDefinition[] {
|
||||
const raw = process.env.CODEGRAPH_MCP_TOOLS;
|
||||
if (!raw || !raw.trim()) return tools;
|
||||
const allow = new Set(raw.split(',').map(s => s.trim().replace(/^codegraph_/, '')).filter(Boolean));
|
||||
return allow.size ? tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool handler that executes tools against a CodeGraph instance
|
||||
*
|
||||
@@ -841,7 +862,7 @@ export class ToolHandler {
|
||||
}
|
||||
|
||||
// Open and cache under both paths
|
||||
const cg = CodeGraph.openSync(resolvedRoot);
|
||||
const cg = loadCodeGraph().openSync(resolvedRoot);
|
||||
this.projectCache.set(resolvedRoot, cg);
|
||||
if (projectPath !== resolvedRoot) {
|
||||
this.projectCache.set(projectPath, cg);
|
||||
@@ -1586,10 +1607,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 +1882,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 +2048,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}]`);
|
||||
}
|
||||
}
|
||||
out.push('', '> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
|
||||
|
||||
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.', '');
|
||||
// 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;
|
||||
}
|
||||
@@ -2096,9 +2185,42 @@ export class ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Named-symbol seeding: findRelevantContext is an FTS/text rank, so a query
|
||||
// that's a BAG of symbol names skewed toward one phase (Alamofire: 5 build
|
||||
// terms, each a high-frequency name, vs 3 validate terms) lets the
|
||||
// lower-frequency names fall below the search cut — their definitions, and
|
||||
// whole files (Validation.swift), never get gathered, so they can never
|
||||
// render and the agent Reads them. Resolve EACH named token to its
|
||||
// substantive definition (skip empty stubs + test files, same relevance the
|
||||
// trace endpoint picker uses) and inject it as an entry, so every symbol the
|
||||
// agent explicitly named is in the subgraph and its file is scored.
|
||||
const namedSeedIds = new Set<string>();
|
||||
{
|
||||
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
|
||||
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
||||
const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
|
||||
const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
|
||||
const tokens = [...new Set(
|
||||
query.split(/[\s,()[\]]+/)
|
||||
.map((t) => t.replace(FILE_EXT, '').trim())
|
||||
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
|
||||
)].slice(0, 16);
|
||||
for (const t of tokens) {
|
||||
const cands = this.findAllSymbols(cg, t).nodes
|
||||
.filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
|
||||
.sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
|
||||
// A specific name (<=3 defs) injects all its defs; an overloaded name
|
||||
// (`request` = 44, mostly stubs) injects only the single most substantive
|
||||
// one, so the build-overload flood doesn't crowd the subgraph.
|
||||
for (const n of cands.slice(0, cands.length <= 3 ? cands.length : 1)) {
|
||||
if (!subgraph.nodes.has(n.id)) { subgraph.nodes.set(n.id, n); namedSeedIds.add(n.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Group nodes by file, score by relevance
|
||||
const fileGroups = new Map<string, { nodes: Node[]; score: number }>();
|
||||
const entryNodeIds = new Set(subgraph.roots);
|
||||
const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]);
|
||||
|
||||
// Build a set of nodes directly connected to entry points (depth 1)
|
||||
const connectedToEntry = new Set<string>();
|
||||
@@ -2113,8 +2235,15 @@ export class ToolHandler {
|
||||
|
||||
const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
|
||||
group.nodes.push(node);
|
||||
// Score: entry point nodes worth 10, directly connected worth 3, others worth 1
|
||||
if (entryNodeIds.has(node.id)) {
|
||||
// Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
|
||||
// injected) is worth far more than a mere reference — its file is where the
|
||||
// answer lives. Without this, an incidental file that name-drops the flow
|
||||
// (Combine.swift references request/task → score 23 from connected nodes)
|
||||
// outranks the file that DEFINES a named symbol (Validation.swift's
|
||||
// `validate` → 10) and steals its render slot. Definition ≫ reference.
|
||||
if (namedSeedIds.has(node.id)) {
|
||||
group.score += 50;
|
||||
} else if (entryNodeIds.has(node.id)) {
|
||||
group.score += 10;
|
||||
} else if (connectedToEntry.has(node.id)) {
|
||||
group.score += 3;
|
||||
@@ -2315,7 +2444,15 @@ export class ToolHandler {
|
||||
|
||||
for (const [filePath, group] of sortedFiles) {
|
||||
if (filesIncluded >= maxFiles) break;
|
||||
if (totalChars > budget.maxOutputChars * 0.9) break;
|
||||
// A file DEFINES a named/spine symbol (the answer) vs merely references the
|
||||
// flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
|
||||
// for necessary ones, which render even past the cap (bounded by maxFiles).
|
||||
// Without this `continue` (was an unconditional `break`), the loop stopped
|
||||
// after the build + validators-exec files and never reached the ranked-in
|
||||
// validate-logic file (Alamofire's Validation.swift).
|
||||
const fileNecessary = group.nodes.some(n =>
|
||||
entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
|
||||
if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9) continue;
|
||||
|
||||
const absPath = validatePathWithinRoot(projectRoot, filePath);
|
||||
if (!absPath || !existsSync(absPath)) continue;
|
||||
@@ -2351,11 +2488,25 @@ export class ToolHandler {
|
||||
const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
|
||||
const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
|
||||
const spared = spareNamed && !fileDefinesSuper;
|
||||
const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
|
||||
const hasSpineNode = group.nodes.some(n => flow.pathNodeIds.has(n.id));
|
||||
// On-spine god-file: the flow path runs THROUGH this file, but it also holds
|
||||
// many OTHER named methods, and rendering all of them in full blows the
|
||||
// per-file budget and starves the other flow files (Alamofire: the agent
|
||||
// names ~7 Session.swift methods — the build spine PLUS off-path
|
||||
// task/didCompleteTask — far past the whole response budget). Engage the
|
||||
// per-symbol view to keep the SPINE full and collapse the off-path named
|
||||
// methods to signatures. Only when there IS off-path content to shed —
|
||||
// otherwise the spine is irreducible (a sequential flow has no redundancy),
|
||||
// so leave it to the normal full render.
|
||||
const namedBodyChars = group.nodes
|
||||
.filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
|
||||
.reduce((s, n) => s + fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length, 0);
|
||||
const onSpineGodFile = hasSpineNode
|
||||
&& namedBodyChars > budget.maxCharsPerFile
|
||||
&& group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
|
||||
if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
|
||||
&& !group.nodes.some(n => flow.pathNodeIds.has(n.id))
|
||||
&& isPolymorphicSibling(group.nodes)
|
||||
&& !spared) {
|
||||
const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
|
||||
&& (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
|
||||
const syms = group.nodes
|
||||
.filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
|
||||
.sort((a, b) => a.startLine - b.startLine);
|
||||
@@ -2375,7 +2526,9 @@ export class ToolHandler {
|
||||
let bodyChars = 0;
|
||||
for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
|
||||
const sz = fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length;
|
||||
if (bodyChars + sz > bodyCap && bodyIds.size > 0) continue;
|
||||
// Spine methods (prio 0) ALWAYS get a full body — the cap governs the
|
||||
// off-path extras (unique-named, family base), never the flow path itself.
|
||||
if (prio(n) > 0 && bodyChars + sz > bodyCap && bodyIds.size > 0) continue;
|
||||
bodyIds.add(n.id);
|
||||
bodyChars += sz;
|
||||
}
|
||||
@@ -2410,9 +2563,15 @@ export class ToolHandler {
|
||||
if (skel.length > 0) {
|
||||
const names = [...new Set(group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export').map(n => n.name))]
|
||||
.slice(0, budget.maxSymbolsInFileHeader).join(', ');
|
||||
// Steer the agent to codegraph_explore for an elided body — NEVER to
|
||||
// Read. The old "Read for more" / "Read for a full body" tags invited
|
||||
// a Read of the very file just skeletonized; on a central, wanted file
|
||||
// (Session.swift, DataRequest.swift) that fired an over-investigation
|
||||
// spiral (the agent Read the skeletonized file, then kept digging).
|
||||
// CLAUDE.md: explore output must never tell the agent to Read.
|
||||
const tag = bodyIds.size > 0
|
||||
? 'focused (the methods you named in full, the rest as signatures; Read for more)'
|
||||
: 'skeleton (signatures only; Read for a full body)';
|
||||
? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
|
||||
: 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
|
||||
lines.push(`#### ${filePath} — ${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
|
||||
totalChars += skel.join('\n').length + 120;
|
||||
filesIncluded++;
|
||||
@@ -2658,22 +2817,21 @@ export class ToolHandler {
|
||||
: headerSymbols.join(', ');
|
||||
const fileHeader = `#### ${filePath} — ${headerSuffix}`;
|
||||
|
||||
// Respect the total output cap on a file-by-file basis.
|
||||
if (totalChars + fileSection.length + 200 > budget.maxOutputChars) {
|
||||
// The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
|
||||
// the agent named (or that's on the flow spine) renders even when the
|
||||
// nominal total is used up — it's the answer, and the set is bounded by
|
||||
// maxFiles AND by true-spine/named-seeding having already trimmed each file
|
||||
// to its necessary content. A file that merely REFERENCES the flow
|
||||
// (Combine.swift name-drops request/task) is incidental → still capped, so
|
||||
// freed budget never leaks into noise. This is the last god-file layer:
|
||||
// build (Session, true-spined) + validators-exec (Request) + validate
|
||||
// (DataRequest/Validation) all render, instead of the cap dropping whichever
|
||||
// phase the file order happened to put last.
|
||||
if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
|
||||
const remaining = budget.maxOutputChars - totalChars - 200;
|
||||
if (remaining < 500) break;
|
||||
const trimmed = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
|
||||
|
||||
lines.push(fileHeader);
|
||||
lines.push('');
|
||||
lines.push('```' + lang);
|
||||
lines.push(trimmed);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
totalChars += trimmed.length + 200;
|
||||
filesIncluded++;
|
||||
if (remaining < 500) continue; // incidental file, no room — skip it, keep scanning for necessary ones
|
||||
fileSection = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
|
||||
anyFileTrimmed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
lines.push(fileHeader);
|
||||
@@ -2740,11 +2898,20 @@ export class ToolHandler {
|
||||
// maxOutputChars (observed 30k against a 28k tier cap). A fat explore
|
||||
// payload persists in the agent's context and is re-read as cache-input
|
||||
// on every subsequent turn, so the overrun is paid many times over.
|
||||
// Final ceiling. The render loop is now the authority on WHAT to emit — it
|
||||
// renders necessary files (named/spine) even past maxOutputChars and caps
|
||||
// only incidental ones, all bounded by maxFiles + per-file true-spine — so
|
||||
// this is a SAFETY ceiling above that necessary content, not a hard cut
|
||||
// through it. Cutting at a flat maxOutputChars here undid the whole point:
|
||||
// Alamofire's loop assembles build+validators-exec+validate (~15K) and a 13K
|
||||
// slice dropped the validate phase the agent then Read. Allow necessary
|
||||
// overflow up to 1.5× (still bounds a pathological monolith).
|
||||
const output = flow.text + lines.join('\n');
|
||||
if (output.length > budget.maxOutputChars) {
|
||||
const cut = output.slice(0, budget.maxOutputChars);
|
||||
const hardCeiling = Math.round(budget.maxOutputChars * 1.5);
|
||||
if (output.length > hardCeiling) {
|
||||
const cut = output.slice(0, hardCeiling);
|
||||
const lastNewline = cut.lastIndexOf('\n');
|
||||
const safe = lastNewline > budget.maxOutputChars * 0.8 ? cut.slice(0, lastNewline) : cut;
|
||||
const safe = lastNewline > hardCeiling * 0.8 ? cut.slice(0, lastNewline) : cut;
|
||||
return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)');
|
||||
}
|
||||
return this.textResult(output);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user