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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Fixed a v1.5.0 regression where a perfectly valid file could be permanently recorded as having 0 symbols, with no error reported. When a file's first parse attempt was interrupted — a parsing worker crash or timeout, most likely on slow or heavily loaded machines — the automatic retry stored an empty result for any language on the native extraction path, so the file's functions and classes silently vanished from search, callers, and impact until the file was next edited. Retries now store the file's real symbols, and a file already recorded as symbol-free is detected and repaired automatically by the next sync or re-index after upgrading. Thanks @Baiae for the report. (#1541)
- When indexing has to fall back to parsing a file with its comment lines stripped — a last-resort recovery after repeated parser crashes — the file is now flagged with a visible warning instead of being reported as cleanly indexed. The recovered result can be incomplete, and reporting success made a fresh index quietly disagree with a later re-parse of the same unchanged file. Thanks @jeremypetz for the precise init-versus-sync symbol accounting that exposed this. (#1565)
- Syncing a large batch of changed files no longer crashes with "Maximum call stack size exceeded" partway through. The crash aborted reference resolution after the files' symbols were already stored, leaving the graph with far fewer connections than a fresh index would have — and it hit exactly the scenarios that re-parse many files at once, including the automatic repair above. Thanks @netbrah for pinpointing the failure. (#1558)
- A background daemon left behind by an out-of-memory kill or force-kill can no longer block every future session when the operating system reuses its process ID: daemon management now verifies a recorded process is really a CodeGraph daemon before trusting or signaling it, and `codegraph unlock` clears stale daemon artifacts as well as the indexing lock. Thanks @hcg1023 for the report and @danusha2345 for the fix. (#1553)
- Data-only C/C++ headers near the file-size limit no longer hold a parser worker for several minutes before timing out; the default large-file parse budget is now bounded, while an explicitly configured larger timeout is still honored. (#1555)
- Reopening an index after a crash during bulk loading now restores every dropped database index, and a successful recovery sync marks the index complete instead of leaving it permanently flagged as interrupted. (#1556)
- Files skipped because they are too large or repeatedly fail to parse are now recorded with the reason, so unchanged rejected files are no longer rediscovered and retried on every status check and sync — and a later successful parse of such a file replaces the record with its real symbols. Thanks @netbrah for the exceptional failure analysis behind this batch, and @danusha2345 for the fixes. (#1557)
- C/C++ function-pointer analysis now bounds its compiled-pattern caches, so very large repositories can no longer exhaust the JavaScript engine's regular-expression code space during indexing. (#1559)
- JSX rendering analysis now runs only on JavaScript-family files, so JSX-looking strings in C/C++ (or any other language) no longer create impossible call edges — in pure-C projects and in mixed-language monorepos alike. (#1560)

## [1.5.0] - 2026-07-21

Expand Down
102 changes: 102 additions & 0 deletions __tests__/cli-unlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { execFile, execFileSync } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getDaemonPidPath, getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';

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

function runCodegraph(args: string[], cwd: string): string {
return execFileSync(process.execPath, [BIN, ...args], {
cwd,
encoding: 'utf8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
}

function runCodegraphAsync(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[BIN, ...args],
{ cwd, encoding: 'utf8', env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' } },
(error, stdout, stderr) => {
if (error) reject(new Error(`${error.message}\n${stderr}`));
else resolve(stdout);
},
);
});
}

describe('codegraph unlock — daemon artifact recovery (#1553)', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unlock-'));
const cg = CodeGraph.initSync(tempDir);
cg.close();
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});

it('removes indexing and phantom-daemon artifacts, then permits indexing', () => {
const graphDir = path.join(tempDir, '.codegraph');
const pidPath = getDaemonPidPath(tempDir);
const socketPath = getDaemonSocketPath(tempDir);
fs.writeFileSync(path.join(graphDir, 'codegraph.lock'), 'stale\n');
fs.writeFileSync(pidPath, JSON.stringify({
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath,
startedAt: Date.now() - 60_000,
}));
if (process.platform !== 'win32') fs.writeFileSync(socketPath, 'stale\n');

const output = runCodegraph(['unlock', tempDir], tempDir);

expect(output).toContain('Removed stale lock artifacts');
expect(fs.existsSync(path.join(graphDir, 'codegraph.lock'))).toBe(false);
expect(fs.existsSync(pidPath)).toBe(false);
if (process.platform !== 'win32') expect(fs.existsSync(socketPath)).toBe(false);
expect(() => process.kill(process.pid, 0)).not.toThrow();
expect(() => runCodegraph(['index', '--quiet', tempDir], tempDir)).not.toThrow();
});

it('preserves artifacts when the recorded live daemon answers the socket hello', async () => {
const pidPath = getDaemonPidPath(tempDir);
const socketPath = getDaemonSocketPath(tempDir);
const server = net.createServer((socket) => {
socket.end(JSON.stringify({
codegraph: CodeGraphPackageVersion,
pid: process.pid,
socketPath,
protocol: 1,
}) + '\n');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
fs.writeFileSync(pidPath, JSON.stringify({
pid: process.pid,
version: CodeGraphPackageVersion,
socketPath,
startedAt: Date.now(),
}));

try {
const output = await runCodegraphAsync(['unlock', tempDir], tempDir);
expect(output).toContain('No stale lock files found');
expect(fs.existsSync(pidPath)).toBe(true);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
55 changes: 55 additions & 0 deletions __tests__/daemon-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import {
Expand All @@ -9,8 +10,11 @@ import {
registerDaemon,
deregisterDaemon,
listDaemons,
listVerifiedDaemons,
stopDaemonAt,
type DaemonRecord,
} from '../src/mcp/daemon-registry';
import { encodeLockInfo, getDaemonPidPath } from '../src/mcp/daemon-paths';

/** A pid that's guaranteed dead: spawn a trivial process, let it exit, reap it. */
async function deadPid(): Promise<number> {
Expand Down Expand Up @@ -100,4 +104,55 @@ describe('daemon-registry', () => {
const live = listDaemons();
expect(live.map((d) => d.root)).toEqual(['/proj/new', '/proj/old']);
});

it('keeps a registry entry whose socket hello matches its PID and version', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'verified-'));
const socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\cg-reg-${process.pid}-${Date.now()}`
: path.join(tmpHome, 'verified.sock');
const server = net.createServer((socket) => {
socket.end(JSON.stringify({
protocol: 1,
pid: process.pid,
codegraph: '1.5.0',
socketPath,
}) + '\n');
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, resolve);
});
try {
registerDaemon({ root, pid: process.pid, version: '1.5.0', socketPath, startedAt: 1 });
expect((await listVerifiedDaemons()).map((d) => d.root)).toEqual([root]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it('never signals a reused live PID when no matching daemon answers (#1553)', async () => {
const root = fs.mkdtempSync(path.join(tmpHome, 'project-'));
const pidPath = getDaemonPidPath(root);
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
fs.writeFileSync(pidPath, encodeLockInfo({
pid: process.pid,
version: '1.5.0',
socketPath: path.join(root, '.codegraph', 'missing.sock'),
startedAt: Date.now() - 60_000,
}));

registerDaemon({
root,
pid: process.pid,
version: '1.5.0',
socketPath: path.join(root, '.codegraph', 'missing.sock'),
startedAt: Date.now() - 60_000,
});

expect(await listVerifiedDaemons()).toEqual([]);
const result = await stopDaemonAt(root);
expect(result).toMatchObject({ pid: process.pid, outcome: 'not-running' });
expect(isProcessAlive(process.pid)).toBe(true);
expect(fs.existsSync(pidPath)).toBe(false);
});
});
35 changes: 35 additions & 0 deletions __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,41 @@ describe('CodeGraph Foundation', () => {
cg.close();
});

it('restores every secondary index after a crash inside bulk parse load (#1556)', () => {
const dbPath = getDatabasePath(tempDir);
const first = DatabaseConnection.initialize(dbPath);
const before = (first.getDb()
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
.all() as Array<{ name: string }>).map((r) => r.name);
first.beginBulkParseLoad();
first.close();

const reopened = DatabaseConnection.open(dbPath);
const after = (reopened.getDb()
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name")
.all() as Array<{ name: string }>).map((r) => r.name);
reopened.close();

expect(after).toEqual(before);
});

it('skips secondary-index DDL when the schema is already healthy', () => {
const dbPath = getDatabasePath(tempDir);
const connection = DatabaseConnection.initialize(dbPath);
const db = connection.getDb();
const originalExec = db.exec.bind(db);
let execCalls = 0;
db.exec = (sql: string) => {
execCalls++;
originalExec(sql);
};

(connection as any).healBulkSecondaryIndexes();
connection.close();

expect(execCalls).toBe(0);
});

it('should return correct database size', () => {
const cg = CodeGraph.initSync(tempDir);
const stats = cg.getStats();
Expand Down
143 changes: 143 additions & 0 deletions __tests__/large-corpus-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import CodeGraph from '../src/index';
import { QueryBuilder } from '../src/db/queries';

describe('large-corpus regression fixes', () => {
it('collects a dense unresolved-reference chunk without spreading it onto the V8 stack (#1558)', () => {
const row = {
id: 1,
from_node_id: 'source',
reference_name: 'target',
reference_kind: 'calls',
line: 1,
col: 1,
candidates: null,
file_path: 'dense.c',
language: 'c',
status: 'pending',
name_tail: 'target',
};
const denseRows = new Array(200_000).fill(row);
const db = { prepare: () => ({ all: () => denseRows }) };
const queries = new QueryBuilder(db as any);
expect(queries.getUnresolvedReferencesByFiles(['dense.c'])).toHaveLength(200_000);
});

it('records an oversized file during a fresh index so sync does not retry it (#1557)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-skipped-file-'));
try {
fs.writeFileSync(path.join(dir, 'oversized.py'), 'value = 1\n'.repeat(120_000));
const cg = await CodeGraph.init(dir, { silent: true });
const indexed = await cg.indexAll();
expect(indexed.filesSkipped).toBe(1);
expect(cg.getFiles().find((f) => f.path === 'oversized.py')?.errors?.[0]?.code).toBe('size_exceeded');
const synced = await cg.sync();
expect(synced.filesAdded).toBe(0);
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('records an oversized file through the single-file indexing path (#1557)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-single-skipped-file-'));
try {
fs.writeFileSync(path.join(dir, 'oversized.py'), 'value = 1\n'.repeat(120_000));
const cg = await CodeGraph.init(dir, { silent: true });
const indexed = await cg.indexFiles(['oversized.py']);
expect(indexed.filesSkipped).toBe(1);
expect(cg.getFiles().find((f) => f.path === 'oversized.py')?.errors?.[0]?.code).toBe('size_exceeded');
const synced = await cg.sync();
expect(synced.filesAdded).toBe(0);
expect(synced.filesModified).toBe(0);
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});

describe('JSX synthesis language boundary (#1560)', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-gate-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

it('does not create jsx-render edges from JSX-looking text in a C-only project', async () => {
fs.writeFileSync(
path.join(dir, 'only.c'),
'void Foo(void) {}\nvoid parent(void) { const char *s = "<Foo/>"; }\n'
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const rows = (cg as any).db.db.prepare(
"SELECT count(*) AS c FROM edges WHERE json_extract(metadata, '$.synthesizedBy') = 'jsx-render'"
).get() as { c: number };
cg.close();
expect(rows.c).toBe(0);
});

it('runs for JavaScript while excluding C parents in the same project', async () => {
fs.writeFileSync(
path.join(dir, 'native.c'),
'void Widget(void) {}\nvoid native_parent(void) { const char *s = "<Widget/>"; }\n'
);
fs.writeFileSync(
path.join(dir, 'ui.jsx'),
'export function Widget() { return <span/>; }\nexport function App() { return <Widget/>; }\n'
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const rows = (cg as any).db.db.prepare(`
SELECT source.file_path AS source_file, target.name AS target_name
FROM edges e
JOIN nodes source ON source.id = e.source
JOIN nodes target ON target.id = e.target
WHERE json_extract(e.metadata, '$.synthesizedBy') = 'jsx-render'
`).all() as Array<{ source_file: string; target_name: string }>;
cg.close();
expect(rows).toContainEqual({ source_file: 'ui.jsx', target_name: 'Widget' });
expect(rows.some((row) => row.source_file === 'native.c')).toBe(false);
});
});

describe('failure markers vs later real results (#1557 × #1541)', () => {
it('a failure marker never blocks storing a later successful parse of the same bytes', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-marker-override-'));
try {
const rel = 'flaky.py';
const content = 'def real_fn():\n return 1\n\nclass RealClass:\n def m(self):\n return 2\n';
fs.writeFileSync(path.join(dir, rel), content);
const cg = await CodeGraph.init(dir, { silent: true });
const { initGrammars, loadGrammarsForLanguages } = await import('../src/extraction/grammars');
await initGrammars();
await loadGrammarsForLanguages(['python']);
const orch = (cg as any).orchestrator;
const stats = fs.statSync(path.join(dir, rel));

// What recordParseFailure persists when a parse worker dies: a marker
// row under the SAME content hash the retry will store with.
await orch.storeExtractionResult(rel, content, 'python', stats, {
nodes: [], edges: [], unresolvedReferences: [],
errors: [{ message: 'Worker exited with code 1', filePath: rel, severity: 'error', code: 'parse_error' }],
durationMs: 0,
});
expect(cg.getFile(rel)?.nodeCount).toBe(0);

// The retry pass succeeds with identical bytes — the marker must be
// replaced, not treated as "no changes".
const { extractFromSource } = await import('../src/extraction/tree-sitter');
const real = extractFromSource(rel, content, 'python');
expect(real.nodes.length).toBeGreaterThan(0);
await orch.storeExtractionResult(rel, content, 'python', stats, real);

expect(cg.getFile(rel)?.nodeCount).toBe(real.nodes.length);
expect(cg.getNodesInFile(rel).map((n: { name: string }) => n.name)).toContain('real_fn');
cg.close();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
Loading