Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ 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.
- `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.

### 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.
- 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.
- 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
Expand Down
4 changes: 3 additions & 1 deletion __tests__/adaptive-explore-sizing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import * as os from 'os';
import { ToolHandler } from '../src/mcp/tools';
import CodeGraph from '../src/index';

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

/** Return the `#### <path> ...` section for a file basename, header through the
* line before the next `###`/`####` header (or end of output). */
Expand Down
124 changes: 124 additions & 0 deletions __tests__/closure-collection-synthesizer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 3 additions & 2 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,12 @@ describe('Shared MCP daemon (issue #411)', () => {
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
// Despite the mismatched daemon, the client still gets an initialize
// response — the proxy refuses to attach and falls back to direct mode.
// response — the proxy answers the handshake locally and, refusing to
// attach across the version mismatch, serves the session in-process.
const resp = await waitFor(() => findResponse(server.stdout, 1), 10000);
expect(resp.result.serverInfo.name).toBe('codegraph');
await waitFor(
() => server.stderr.some((l) => l.includes('falling back to direct mode')),
() => server.stderr.some((l) => l.includes('serving this session in-process')),
6000,
);
} finally {
Expand Down
Loading