Skip to content

Commit 3a1ddf4

Browse files
authored
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).
1 parent b026e64 commit 3a1ddf4

12 files changed

Lines changed: 756 additions & 92 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1414
- `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)
1515
- 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.
1616
- `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`.
17+
- 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.
18+
- `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.
19+
- `codegraph_explore` answers multi-phase questions that span a large "god file" far more completely. For a flow like "build, send, and validate a request" — where one big file holds the build chain and the validate logic lives in others — it now keeps every method *on the flow path* in full, collapses the file's off-path methods to one-line signatures, and guarantees each phase's defining file is shown (instead of truncating at a fixed size and dropping whichever phase came last, which sent you to read it by hand). Incidental files that merely name-drop the flow are still trimmed, so the response stays focused on the code that answers the question.
1720

1821
### Fixes
1922

23+
- `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.
24+
- CodeGraph's MCP server now answers an agent's opening handshake the instant it launches instead of blocking while the index loads, so a fresh session's very first tool call no longer occasionally races a server that's still warming up and falls back to grep/read. The first question in a new session now reliably goes through CodeGraph.
2025
- 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).
2126

2227
## [0.9.7] - 2026-05-28

__tests__/adaptive-explore-sizing.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import * as os from 'os';
3131
import { ToolHandler } from '../src/mcp/tools';
3232
import CodeGraph from '../src/index';
3333

34-
const SKELETON_MARK = '· skeleton (signatures only; Read for a full body)';
34+
// Stable marker — assert the `· skeleton` tag, not its exact trailing wording
35+
// (the steer-to-explore phrasing changed when the Read invitation was removed).
36+
const SKELETON_MARK = '· skeleton (signatures only';
3537

3638
/** Return the `#### <path> ...` section for a file basename, header through the
3739
* line before the next `###`/`####` header (or end of output). */
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import * as os from 'node:os';
5+
import { CodeGraph } from '../src';
6+
7+
/**
8+
* End-to-end synthesizer test for closure-collection dynamic dispatch.
9+
*
10+
* A method appends a closure to a collection property; another method iterates
11+
* that property *invoking each element* (`coll.forEach { $0() }`) — a dynamic
12+
* dispatch tree-sitter can't resolve, so a flow into the dispatcher dead-ends
13+
* before the registered closures. This is Alamofire's request-validation shape:
14+
* `DataRequest.validate` does `validators.write { $0.append(validator) }`, the
15+
* base `Request.didCompleteTask` runs `validators.forEach { $0() }`.
16+
*
17+
* Verify the synthesizer (1) links the dispatcher → each same-named registrar
18+
* across files/classes, (2) handles both the Swift `prop.write { $0.append }`
19+
* and the direct `prop.append(...)` registrar forms, (3) surfaces the wiring
20+
* site, and (4) does NOT fire on a `.forEach` that doesn't invoke its element
21+
* (the closure-invoke is the precision gate — a plain collection is skipped).
22+
*/
23+
describe('closure-collection synthesizer', () => {
24+
let dir: string;
25+
26+
beforeEach(() => {
27+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'closure-coll-fixture-'));
28+
});
29+
30+
afterEach(() => {
31+
fs.rmSync(dir, { recursive: true, force: true });
32+
});
33+
34+
it('links dispatcher → registrars across files, both append forms, and skips non-invoked collections', async () => {
35+
// Base class: the dispatchers (iterate-and-invoke) + a non-closure control.
36+
fs.writeFileSync(
37+
path.join(dir, 'Request.swift'),
38+
`class Request {
39+
var validators: [() -> Void] = []
40+
var handlers: [() -> Void] = []
41+
var names: [String] = []
42+
43+
func didCompleteTask() {
44+
let validators = validators
45+
validators.forEach { $0() }
46+
}
47+
48+
func runHandlers() {
49+
handlers.forEach { $0() }
50+
}
51+
52+
func printNames() {
53+
names.forEach { print($0) }
54+
}
55+
}
56+
`
57+
);
58+
59+
// Subclass: the registrars (append a closure) in a DIFFERENT file/class.
60+
fs.writeFileSync(
61+
path.join(dir, 'DataRequest.swift'),
62+
`class DataRequest: Request {
63+
func validate(_ validation: @escaping () -> Void) -> Self {
64+
let validator: () -> Void = { validation() }
65+
validators.write { $0.append(validator) }
66+
return self
67+
}
68+
69+
func onEvent(_ handler: @escaping () -> Void) {
70+
handlers.append(handler)
71+
}
72+
73+
func addName(_ n: String) {
74+
names.append(n)
75+
}
76+
}
77+
`
78+
);
79+
80+
const cg = await CodeGraph.init(dir, { silent: true });
81+
await cg.indexAll();
82+
83+
const db = (cg as any).db.db;
84+
const rows = db
85+
.prepare(
86+
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
87+
json_extract(e.metadata,'$.field') field,
88+
json_extract(e.metadata,'$.registeredAt') registeredAt
89+
FROM edges e
90+
JOIN nodes s ON s.id = e.source
91+
JOIN nodes t ON t.id = e.target
92+
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'closure-collection'`
93+
)
94+
.all();
95+
cg.close?.();
96+
97+
expect(rows.length).toBeGreaterThan(0);
98+
99+
// Every edge originates from a dispatcher method and is a real `calls` hop.
100+
expect(rows.every((r: any) => r.source_kind === 'method')).toBe(true);
101+
102+
// The validators flow: didCompleteTask → validate, captured via the Swift
103+
// Protected `prop.write { $0.append }` form, wiring site surfaced.
104+
const validatorsEdge = rows.find(
105+
(r: any) => r.field === 'validators' && r.target_name === 'validate'
106+
);
107+
expect(validatorsEdge).toBeTruthy();
108+
expect(validatorsEdge.source_name).toBe('didCompleteTask');
109+
expect(validatorsEdge.registeredAt).toMatch(/DataRequest\.swift:\d+/);
110+
111+
// The handlers flow: runHandlers → onEvent, via the direct `prop.append`
112+
// form — proves both registrar shapes are covered.
113+
const handlersEdge = rows.find(
114+
(r: any) => r.field === 'handlers' && r.target_name === 'onEvent'
115+
);
116+
expect(handlersEdge).toBeTruthy();
117+
expect(handlersEdge.source_name).toBe('runHandlers');
118+
119+
// Precision gate: `names.forEach { print($0) }` does NOT invoke its element,
120+
// so `names` is not a closure collection — no edge, and addName is never a target.
121+
expect(rows.some((r: any) => r.field === 'names')).toBe(false);
122+
expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false);
123+
});
124+
});

__tests__/mcp-daemon.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,11 +346,12 @@ describe('Shared MCP daemon (issue #411)', () => {
346346
servers.push(server);
347347
sendInitialize(server.child, `file://${tempDir}`, 1);
348348
// Despite the mismatched daemon, the client still gets an initialize
349-
// response — the proxy refuses to attach and falls back to direct mode.
349+
// response — the proxy answers the handshake locally and, refusing to
350+
// attach across the version mismatch, serves the session in-process.
350351
const resp = await waitFor(() => findResponse(server.stdout, 1), 10000);
351352
expect(resp.result.serverInfo.name).toBe('codegraph');
352353
await waitFor(
353-
() => server.stderr.some((l) => l.includes('falling back to direct mode')),
354+
() => server.stderr.some((l) => l.includes('serving this session in-process')),
354355
6000,
355356
);
356357
} finally {

0 commit comments

Comments
 (0)