Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.10] - 2026-05-19

### Fixed
- **MCP**: tools no longer silently fail to appear in clients on slow
filesystems (Docker Desktop VirtioFS on macOS, WSL2). The `initialize`
handshake was blocking on opening the SQLite database and bootstrapping
the tree-sitter WASM runtime, which on slow I/O could exceed Claude
Code's ~30s handshake timeout — leaving the codegraph process alive but
unresponsive and no tools visible. The handshake now returns immediately
and defers project open to the background; tool calls wait on the
in-flight init rather than racing it with a second open. Closes
[#172](https://github.com/colbymchenry/codegraph/issues/172). Thanks to
[@sashanclrp](https://github.com/sashanclrp) for the original report and
detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
decisive wire capture that isolated the actual root cause.
- **CLI**: terminal output no longer mojibakes on Windows PowerShell /
cmd.exe during `codegraph index` and `codegraph sync`. The shimmer
progress renderer writes from a worker thread via `fs.writeSync(1, …)`
to keep the animation smooth while the main thread is busy in SQLite,
which bypasses Node's TTY-aware UTF-8→codepage conversion — so glyphs
like `│ ◆ —` were emitted as raw UTF-8 bytes and reinterpreted as the
console's OEM codepage (CP437, CP936, …), producing strings like
`鋍?[0m 鉒?[0m Scanning files 鈥?N found`. CodeGraph now picks an ASCII
glyph set on Windows by default (`| * -` instead of `│ ◆ —`); set
`CODEGRAPH_UNICODE=1` to opt back into the Unicode glyphs (e.g. on
pwsh 7 with UTF-8 codepage), or `CODEGRAPH_ASCII=1` on any platform to
force ASCII (useful for log collectors / non-TTY pipelines). Closes
[#168](https://github.com/colbymchenry/codegraph/issues/168). Thanks to
[@starkleek](https://github.com/starkleek) for the report and to
[@Bortlesboat](https://github.com/Bortlesboat) for the initial PR.

[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10

## [0.7.8] - 2026-05-17

### Fixed
Expand Down
170 changes: 170 additions & 0 deletions __tests__/glyphs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Glyph fallback / Unicode-support detection.
*
* Pinned because the matrix is small and the consequence of regression
* is highly visible: shimmer-worker output on Windows mojibakes when
* UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection
* + ASCII fallback is the contract that prevents this.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
supportsUnicode,
getGlyphs,
UNICODE_GLYPHS,
ASCII_GLYPHS,
_resetGlyphsCache,
} from '../src/ui/glyphs';

function withEnv(patch: Record<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
const savedPlatform = process.platform;
for (const key of Object.keys(patch)) {
saved[key] = process.env[key];
if (patch[key] === undefined) delete process.env[key];
else process.env[key] = patch[key];
}
_resetGlyphsCache();
try {
fn();
} finally {
for (const key of Object.keys(saved)) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
Object.defineProperty(process, 'platform', { value: savedPlatform });
_resetGlyphsCache();
}
}

function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value });
}

describe('supportsUnicode', () => {
let originalPlatform: NodeJS.Platform;

beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});

it('returns false on Windows by default (mojibake-prone consoles)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(false);
});
});

it('returns true on macOS by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(true);
});
});

it('returns true on Linux by default', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(true);
});
});

it('returns false on Linux kernel console (TERM=linux)', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => {
setPlatform('linux');
expect(supportsUnicode()).toBe(false);
});
});

it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => {
withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => {
setPlatform('win32');
expect(supportsUnicode()).toBe(true);
});
});

it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});

it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => {
withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => {
setPlatform('darwin');
expect(supportsUnicode()).toBe(false);
});
});
});

describe('getGlyphs', () => {
let originalPlatform: NodeJS.Platform;

beforeEach(() => {
originalPlatform = process.platform;
_resetGlyphsCache();
});

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
_resetGlyphsCache();
});

it('returns ASCII glyphs on Windows', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('win32');
const g = getGlyphs();
expect(g).toBe(ASCII_GLYPHS);
expect(g.ok).toBe('[OK]');
expect(g.rail).toBe('|');
expect(g.phaseDone).toBe('*');
expect(g.dash).toBe('-');
});
});

it('returns Unicode glyphs on macOS', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
const g = getGlyphs();
expect(g).toBe(UNICODE_GLYPHS);
expect(g.ok).toBe('✓');
expect(g.rail).toBe('│');
expect(g.phaseDone).toBe('◆');
expect(g.dash).toBe('—');
});
});

it('caches the result so repeated calls return the same object', () => {
withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
setPlatform('darwin');
expect(getGlyphs()).toBe(getGlyphs());
});
});
});

describe('Glyph sets', () => {
it('ASCII and Unicode sets cover the same keys', () => {
expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort());
});

it('ASCII glyphs are all 7-bit ASCII', () => {
for (const [key, value] of Object.entries(ASCII_GLYPHS)) {
const flat = Array.isArray(value) ? value.join('') : value;
for (let i = 0; i < flat.length; i++) {
const codepoint = flat.charCodeAt(i);
expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128);
}
}
});

it('ASCII spinner has the same frame count as the Unicode spinner', () => {
expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length);
});
});
149 changes: 149 additions & 0 deletions __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* MCP `initialize` handshake regression tests.
*
* Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2),
* the MCP server was blocking the initialize response on CodeGraph.open() and
* Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than
* Claude Code's ~30s handshake timeout. The child process stayed alive and
* had received the request, but never sent a response, so tools never
* appeared in the client. The fix sends the initialize response before
* kicking off the heavy init in the background. These tests guard the
* contract that initialize is fast regardless of how much work init does.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
}) as ChildProcessWithoutNullStreams;
}

function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) {
const msg = JSON.stringify({
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'test', version: '0.0.0' },
rootUri: `file://${projectPath}`,
},
});
child.stdin.write(msg + '\n');
}

/**
* Collect stdout lines and stderr text from the child, tagging each piece
* with a monotonic sequence number. Lets us assert ordering between the
* JSON-RPC response (stdout) and side-effect logs (stderr).
*/
function tagStreams(child: ChildProcessWithoutNullStreams) {
const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = [];
let seq = 0;
let stdoutBuf = '';
let stderrBuf = '';
child.stdout.on('data', (chunk) => {
stdoutBuf += chunk.toString('utf8');
let idx;
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
const line = stdoutBuf.slice(0, idx);
stdoutBuf = stdoutBuf.slice(idx + 1);
events.push({ seq: seq++, stream: 'stdout', text: line });
}
});
child.stderr.on('data', (chunk) => {
stderrBuf += chunk.toString('utf8');
let idx;
while ((idx = stderrBuf.indexOf('\n')) !== -1) {
const line = stderrBuf.slice(0, idx);
stderrBuf = stderrBuf.slice(idx + 1);
events.push({ seq: seq++, stream: 'stderr', text: line });
}
});
return events;
}

function waitFor<T>(
events: ReadonlyArray<{ seq: number; stream: string; text: string }>,
predicate: (e: { seq: number; stream: string; text: string }) => boolean,
timeoutMs: number,
): Promise<{ seq: number; stream: string; text: string }> {
return new Promise((resolve, reject) => {
const started = Date.now();
const tick = () => {
const hit = events.find(predicate);
if (hit) return resolve(hit);
if (Date.now() - started > timeoutMs) {
return reject(new Error(`Timed out waiting for predicate. Events: ${JSON.stringify(events)}`));
}
setTimeout(tick, 20);
};
tick();
});
}

describe('MCP initialize handshake (issue #172)', () => {
let tempDir: string;
let child: ChildProcessWithoutNullStreams | null = null;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-'));
});

afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
child = null;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('responds to initialize quickly when no .codegraph exists in cwd', async () => {
child = spawnServer(tempDir);
const events = tagStreams(child);
sendInitialize(child, tempDir);
const response = await waitFor(events, (e) => e.stream === 'stdout', 5000);
const json = JSON.parse(response.text);
expect(json.jsonrpc).toBe('2.0');
expect(json.id).toBe(0);
expect(json.result.protocolVersion).toBeDefined();
expect(json.result.capabilities.tools).toBeDefined();
}, 10000);

it('sends initialize response BEFORE tryInitializeDefault finishes', async () => {
// Seed a real .codegraph so the server's tryInitializeDefault path runs
// its full body: CodeGraph.open() (which awaits initGrammars()) and then
// startWatching() (which logs "File watcher active" to stderr). On any
// platform, that stderr log is observable evidence that tryInitializeDefault
// has completed. The contract we're protecting: the JSON-RPC response on
// stdout must arrive BEFORE that stderr log. If a future change re-awaits
// tryInitializeDefault before sendResult, this ordering inverts and the
// test fails — regardless of how fast the local filesystem is.
const cg = await CodeGraph.init(tempDir);
cg.close();

child = spawnServer(tempDir);
const events = tagStreams(child);
sendInitialize(child, tempDir);

const response = await waitFor(events, (e) => e.stream === 'stdout', 10000);
const watcherLog = await waitFor(
events,
(e) => e.stream === 'stderr' && e.text.includes('File watcher active'),
10000,
);
expect(response.seq).toBeLessThan(watcherLog.seq);
const json = JSON.parse(response.text);
expect(json.id).toBe(0);
expect(json.result.serverInfo.name).toBe('codegraph');
}, 20000);
});
Loading