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
2 changes: 1 addition & 1 deletion .claude/skills/codegraph-build-validation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npx vitest run __tests__/installer-targets.test.ts
npx vitest run __tests__/extraction.test.ts -t "TypeScript"
```

Under Bun the suite runs via `__tests__/bun-homedir.setup.ts` (an `os.homedir` shim for oven-sh/bun#29244) plus three test-file adaptations linked to their upstream issues (#42891, #42893, #25498). `npm run test:bun` still fails 19 tests that pass on Node: tests that replace modules or `fs` functions (Bun's `syncBuiltinESMExports` is a no-op and a `vi.mock` of a module loaded through the lazy `require('./extraction')` appears not to apply), one that empties `PATH` (Bun still finds `git`), and a V8-only `gc` test. Do not read those as regressions; a new Bun-only failure is one outside that list.
Under Bun the suite runs via `__tests__/bun-homedir.setup.ts` (an `os.homedir` shim for oven-sh/bun#29244) plus three test-file adaptations linked to their upstream issues (#42891, #42893, #25498). `npm run test:bun` skips tests that need a runtime capability Bun lacks (patching a builtin so the library sees it, an emptied `PATH` blocking a spawn); `__tests__/helpers/runtime-capabilities.ts` measures each one at run time and `__tests__/runtime-capabilities.test.ts` fails if it goes missing anywhere but Bun. Gate a new test of that kind with the helper rather than `process.versions.bun`. Under Bun, spell a `vi.mock` path as an absolute path (`${__dirname}/…`): a relative one resolves against the project root (oven-sh/bun#43685).

`copy-assets` (called from `build`) copies `src/db/schema.sql` into `dist/`. **Any new SQL asset must be copied or it won't ship.**

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ Both builds rebuild the links inferred from events, callbacks and function point
| `codegraph status` | 173 ms | 117 ms | 86 ms |
| `codegraph explore "<query>"` | 224 ms | 121 ms | 99 ms |

**Node or Bun.** The fork produces the same node and edge counts on both, and the full test suite passed on Node.js 24.21.0, Node.js 26.10.0 and Bun 1.4.2 at `48903f5` (`npm run test:bun`; one test is skipped under Bun for [oven-sh/bun#42891](https://github.com/oven-sh/bun/issues/42891)). Since then the suite has grown: at `2bf24ef4` plus the directory-order fix, `npm run test:bun` fails 19 of 8,780 tests that pass on Node (writer-lock, watcher-startup and grammar-load-failure tests that replace modules or `fs` functions, a test that empties `PATH`, and a V8-only garbage-collection test); none is a known product defect. On Bun, peak index memory is 8–27% lower than on Node, the idle MCP server holds 37–41% less memory, and commands start faster. Index and sync times range from 6% slower to 15% faster than Node's. Bun has two costs. Eight concurrent explores on n8n take 12% longer; contention between worker threads reading through Bun's SQLite is the suspected cause ([oven-sh/bun#44084](https://github.com/oven-sh/bun/issues/44084), [#44187](https://github.com/oven-sh/bun/issues/44187)). Idle CPU is two to four times Node's, though still under 1% of one core.
**Node or Bun.** The fork produces the same node and edge counts on both, and the full test suite passed on Node.js 24.21.0, Node.js 26.10.0 and Bun 1.4.2 at `48903f5` (`npm run test:bun`; one test is skipped under Bun for [oven-sh/bun#42891](https://github.com/oven-sh/bun/issues/42891)). Since then the suite has grown, and the Bun run skips 14 more tests that need what Bun does not provide: patching a builtin so the library sees it (`syncBuiltinESMExports` is a no-op, [oven-sh/bun#42616](https://github.com/oven-sh/bun/pull/42616)) and an emptied `PATH` blocking a spawn ([oven-sh/bun#44948](https://github.com/oven-sh/bun/issues/44948)). Each skip is measured at run time, so the test returns when Bun supports it. On Bun, peak index memory is 8–27% lower than on Node, the idle MCP server holds 37–41% less memory, and commands start faster. Index and sync times range from 6% slower to 15% faster than Node's. Bun has two costs. Eight concurrent explores on n8n take 12% longer; contention between worker threads reading through Bun's SQLite is the suspected cause ([oven-sh/bun#44084](https://github.com/oven-sh/bun/issues/44084), [#44187](https://github.com/oven-sh/bun/issues/44187)). Idle CPU is two to four times Node's, though still under 1% of one core.

Measured separately:

Expand Down
5 changes: 4 additions & 1 deletion __tests__/grammar-load-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import type { Language } from '../src/types';

const { failing, throwing } = vi.hoisted(() => ({ failing: new Set<string>(), throwing: new Set<string>() }));

vi.mock('../src/extraction/parse-tree', async (importOriginal) => {
// Spelled as an absolute path: vitest finds the test file that called `vi.mock` from a
// stack frame reading `at Object.mock`, which Bun prints as `at mock`, so under Bun a
// relative path resolves against the project root (oven-sh/bun#43685).
vi.mock(`${__dirname}/../src/extraction/parse-tree`, async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/extraction/parse-tree')>();
const { KernelUnavailableError } = await import('../src/extraction/kernel/loader');
return {
Expand Down
42 changes: 42 additions & 0 deletions __tests__/helpers/runtime-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Behaviors the engine suites rely on that Bun's Node compatibility does not
* provide. Each is measured, not assumed from the runtime name, so a test
* comes back on its own once the runtime supports it; `runtime-capabilities.test.ts`
* fails if a capability goes missing anywhere but Bun.
*/
import * as fsNamespace from 'node:fs';
import fsDefault from 'node:fs';
import { spawnSync } from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';

/**
* Replacing a method on a builtin's CommonJS object and calling
* `syncBuiltinESMExports()` reaches code that imports the builtin by name or as
* a namespace. Bun's `syncBuiltinESMExports` is a no-op (a fix is open as
* oven-sh/bun#42616), so spies on `fs` and `child_process` never reach the
* library under test there.
*/
export const builtinPatchingReachesImporters = (() => {
const original = fsDefault.accessSync;
const stub = (() => undefined) as typeof fsDefault.accessSync;
fsDefault.accessSync = stub;
try {
syncBuiltinESMExports();
return fsNamespace.accessSync === stub;
} finally {
fsDefault.accessSync = original;
syncBuiltinESMExports();
}
})();

/** A child spawned by bare name fails with ENOENT once `process.env.PATH` is emptied. Bun treats an empty PATH as unset and still finds the binary (oven-sh/bun#44948). */
export const emptyPathBlocksSpawn = (() => {
const saved = process.env.PATH;
process.env.PATH = '';
try {
return (spawnSync('git', ['--version']).error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
} finally {
if (saved === undefined) delete process.env.PATH;
else process.env.PATH = saved;
}
})();
27 changes: 25 additions & 2 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import { CodeGraphPackageVersion } from '../src/mcp/version';
import { once } from 'events';
import { recordSpawns, removeSpawnLog, settleLosingCandidates } from './daemon-candidates';

// Vite strips the `node:` prefix from an import of this builtin; require keeps it.
const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite');

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

interface SpawnedServer {
Expand All @@ -53,6 +56,26 @@ interface SpawnedServer {
stderr: string[];
}

/**
* The database state this test asserts did not change. Node (and Windows, where
* a read-only connection cannot recover a killed writer's WAL) compares the raw
* file bytes. Under Bun a checkpoint sometimes folds that WAL's frames into the
* main file without changing any row, so Bun compares the indexed content
* through a read-only connection.
*/
function snapshotDatabase(root: string): Buffer | string {
const dbPath = path.join(root, '.codegraph', 'codegraph.db');
if (!process.versions.bun) return fs.readFileSync(dbPath);
const db = new DatabaseSync(dbPath, { readOnly: true });
try {
const files = db.prepare('SELECT path, content_hash, node_count FROM files ORDER BY path').all();
const nodes = db.prepare('SELECT * FROM nodes ORDER BY id').all();
return JSON.stringify({ files, nodes });
} finally {
db.close();
}
}

function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}, args: string[] = []): SpawnedServer {
// Record the daemon candidates this launcher spawns, for the teardown.
const recorder = recordSpawns(cwd);
Expand Down Expand Up @@ -637,7 +660,7 @@ describe('Shared MCP daemon (issue #411)', () => {

// Make the index stale by changing the source, without opening a new
// SQLite writer against the database of the daemon we just killed.
const before = fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'));
const before = snapshotDatabase(realRoot);
fs.writeFileSync(path.join(realRoot, 'app.ts'), 'export function changedSymbol() {}\n');
const second = spawnServer(tempDir, env);
servers.push(second);
Expand Down Expand Up @@ -675,7 +698,7 @@ describe('Shared MCP daemon (issue #411)', () => {
expect(fs.readFileSync(daemonPath, 'utf8')).toBe(staleDaemonLock);
second.child.stdin.end();
await waitFor(() => second.child.exitCode !== null, 5000);
expect(fs.readFileSync(path.join(realRoot, '.codegraph', 'codegraph.db'))).toEqual(before);
expect(snapshotDatabase(realRoot)).toEqual(before);
expect(fs.readFileSync(writerPath, 'utf8')).toBe(staleWriterLock);
}, 50000);

Expand Down
3 changes: 2 additions & 1 deletion __tests__/mcp-nested-repo-projectpath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools';
import { emptyPathBlocksSpawn } from './helpers/runtime-capabilities';

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

Expand Down Expand Up @@ -183,7 +184,7 @@ describe('projectPath inside a nested git repository the ancestor index excludes
expect(text(res)).toContain('nested-only.ts');
});

it('fails open to the ancestor index when git cannot be run', async () => {
it.skipIf(!emptyPathBlocksSpawn)('fails open to the ancestor index when git cannot be run', async () => {
handler = new ToolHandler(cg);
const savedPath = process.env.PATH;
process.env.PATH = '';
Expand Down
15 changes: 15 additions & 0 deletions __tests__/runtime-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { builtinPatchingReachesImporters, emptyPathBlocksSpawn } from './helpers/runtime-capabilities';

// Tests that need these capabilities skip themselves where they are missing. This
// keeps that skip to Bun: on Node a missing capability would otherwise silently
// turn a whole group of tests off.
describe('runtime capabilities the suites skip on', () => {
it('lets a patched builtin reach its importers, except on Bun', () => {
expect(builtinPatchingReachesImporters || !!process.versions.bun).toBe(true);
});

it('lets an emptied PATH block a spawn by name, except on Bun', () => {
expect(emptyPathBlocksSpawn || !!process.versions.bun).toBe(true);
});
});
9 changes: 5 additions & 4 deletions __tests__/runtime-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as daemonRegistry from '../src/mcp/daemon-registry';
import { getTelemetry } from '../src/telemetry';
import * as updateCheck from '../src/upgrade/update-check';
import { getWriterPidPath } from '../src/mcp/writer-lock';
import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities';

let root: string;
let server: net.Server | null = null;
Expand Down Expand Up @@ -362,7 +363,7 @@ describe('ordinary watcher startup', () => {
},
);

it('initializes an exact child index and refreshes the same daemon after a new edit', async () => {
it.skipIf(!builtinPatchingReachesImporters)('initializes an exact child index and refreshes the same daemon after a new edit', async () => {
vi.stubEnv('CODEGRAPH_WATCH_DEBOUNCE_MS', '60000');
vi.stubEnv('CODEGRAPH_QUERY_POOL_SIZE', '0');
await initialize();
Expand Down Expand Up @@ -626,7 +627,7 @@ describe('ordinary watcher startup', () => {
finally { reader.close(); }
});

it('converges racing ordinary starters on one real watcher without a promotion lease', async () => {
it.skipIf(!builtinPatchingReachesImporters)('converges racing ordinary starters on one real watcher without a promotion lease', async () => {
await initialize();
const original = childProcess.spawn;
const spawned: ChildProcess[] = [];
Expand All @@ -647,7 +648,7 @@ describe('ordinary watcher startup', () => {
await checkRuntimeReady(root, winner, 1000, { requireWatcher: true });
});

it.each(['promotion', 'older', 'invalid-writer'].flatMap(event => [true, false].map(initialized => ({ event, initialized }))))(
it.skipIf(!builtinPatchingReachesImporters).each(['promotion', 'older', 'invalid-writer'].flatMap(event => [true, false].map(initialized => ({ event, initialized }))))(
'preserves a $event owner after precheck before election (initialized=$initialized)', async ({ event, initialized }) => {
if (initialized) await initialize();
const original = childProcess.spawn;
Expand Down Expand Up @@ -677,7 +678,7 @@ describe('ordinary watcher startup', () => {
},
);

it('times out with a disabled real watcher without terminating a possibly shared daemon', async () => {
it.skipIf(!builtinPatchingReachesImporters)('times out with a disabled real watcher without terminating a possibly shared daemon', async () => {
await initialize();
const original = childProcess.spawn;
spySpawn().mockImplementation(((command: string, args: string[], spawnOptions: childProcess.SpawnOptions) => {
Expand Down
3 changes: 2 additions & 1 deletion __tests__/sync-resynthesis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as path from 'path';
import * as os from 'os';
import { syncBuiltinESMExports } from 'node:module';
import CodeGraph from '../src/index';
import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities';

describe('synthesized edges on incremental sync', () => {
let dir: string;
Expand Down Expand Up @@ -151,7 +152,7 @@ describe('synthesized edges on incremental sync', () => {
expect(callees('loadRepo')).toContain('GET https://api.github.com/repos/${…}');
});

it('rejects an unreadable changed file and indexes it when a later refresh can read it', async () => {
it.skipIf(!builtinPatchingReachesImporters)('rejects an unreadable changed file and indexes it when a later refresh can read it', async () => {
const source = path.join(dir, 'src/handlers.ts');
write('src/handlers.ts', 'export function readableAfterRetry() {}\n');
const mutableFs = require('fs') as typeof fs;
Expand Down
13 changes: 10 additions & 3 deletions __tests__/worker-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('terminating a worker once it has started', () => {
});

describe('collecting garbage before a worker exits', () => {
it('runs a full collection in a real worker, and leaves no global gc behind', async () => {
it('runs a full collection in a real worker (V8), and leaves no global gc behind', async () => {
const teardown = path.resolve(__dirname, '../dist/worker-teardown.js');
const run = (code: string) => new Promise<unknown>((resolve, reject) => {
const w = new Worker(code, { eval: true });
Expand All @@ -73,8 +73,15 @@ describe('collecting garbage before a worker exits', () => {
const after = process.memoryUsage().heapUsed;
require('worker_threads').parentPort.postMessage({ collected, freed: before - after, gc: typeof globalThis.gc });
`) as { collected: boolean; freed: number; gc: string };
expect(ran.collected).toBe(true);
expect(ran.freed).toBeGreaterThan(0);
if (process.versions.bun) {
// JavaScriptCore has no V8 `--expose-gc` flag to reach at runtime, so the
// collector is unavailable and the worker exits without one. The crash it
// guards against is V8's concurrent marker on Windows.
expect(ran.collected).toBe(false);
} else {
expect(ran.collected).toBe(true);
expect(ran.freed).toBeGreaterThan(0);
}
expect(ran.gc).toBe('undefined');
// A worker created afterwards doesn't get a global gc either.
expect(await run(`require('worker_threads').parentPort.postMessage(typeof globalThis.gc)`)).toBe('undefined');
Expand Down
3 changes: 2 additions & 1 deletion __tests__/writer-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
tryAcquireWriterLock,
writerLockHeldMessage,
} from '../src/mcp/writer-lock';
import { builtinPatchingReachesImporters } from './helpers/runtime-capabilities';

describe('writer lock (#1740)', () => {
let dir: string;
Expand Down Expand Up @@ -73,7 +74,7 @@ describe('writer lock (#1740)', () => {
expect(fs.readFileSync(file, 'utf8')).toBe('uncertain');
});

it.each(['uncertain', 'invalid-pid', 'same-pid-successor'])(
it.skipIf(!builtinPatchingReachesImporters).each(['uncertain', 'invalid-pid', 'same-pid-successor'])(
'preserves a %s record arriving during stale-writer cleanup', (successor) => {
const root = makeProject();
const file = getWriterPidPath(root);
Expand Down
2 changes: 1 addition & 1 deletion src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let warnedBadDirName = false;

/**
* A directory's entries in name order. Node's `readdirSync` returns them sorted
* (libuv sorts); Bun returns the filesystem's own order. Indexing, resolution
* (libuv sorts); Bun returns the filesystem's own order (oven-sh/bun#44947). Indexing, resolution
* and equal-score tie-breaks follow this order, so every walk that feeds them
* reads through here and gets the same order on both runtimes.
*/
Expand Down
Loading