Files
codegraph/__tests__/schemaless-db-not-initialized.test.ts
Colby Mchenry ee97a57a9b fix(directory): a schema-less codegraph.db does not make a project initialized (#1895) (#2083)
* fix(directory): a schema-less codegraph.db does not make a project initialized (#1895)

isInitialized() accepted any existing .codegraph/codegraph.db, so an empty or
table-less file in an ancestor directory (an interrupted init, a stray touch,
a never-populated ~/.codegraph/) captured the upward walk of
findNearestCodeGraphRoot / resolveProjectPath / resolveServerRoot for every
project beneath it: the real index of a sub-project became unreachable, the
sub-project down-scan never ran, and commands either operated on the broken
file or failed opening it instead of showing the not-initialized guidance.

An initialized project is now one whose db carries the schema: the probe is
gated by file size (>= the 100-byte SQLite header), then the header magic,
then a read-only node:sqlite open with a single sqlite_master lookup for the
`nodes` table, memoized per path + mtime + size so hot callers (the prompt
hook, MCP root resolution on every call) never reopen an unchanged db. The
handle is closed immediately so the probe never holds the db on Windows; a
locked/busy db counts as live.

createDirectory() uses the same predicate, so `codegraph init` in the
directory with the broken file repairs it in place (the schema is applied
with CREATE ... IF NOT EXISTS) instead of refusing with "Already
initialized"; the CLI says what it found. Nothing is deleted automatically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(directory): a database we cannot open read-only still counts as initialized

The schema probe failed CLOSED: a real WAL codegraph.db in a directory the
process cannot write (a read-only checkout, a mount, another user's tree)
made the read-only node:sqlite open throw — SQLite must create `-shm` to
open a WAL database — and isInitialized() reported the project as not
initialized, where main's file-exists check had been right.

The probe now fails OPEN. Once the size gate and the `SQLite format 3\0`
header have passed, the file is a SQLite database; only a SUCCESSFUL
sqlite_master query proving the `nodes` table absent may return false. Any
open/prepare error (locked, busy, cantopen, readonly, disk I/O) returns
true, the pre-existing behaviour for a database we cannot inspect. No error
strings are matched. The per path + mtime + size memo is unchanged, and
schema-less/empty files still return false, so `codegraph init` still
repairs them and never touches a real db.

Test: a WAL db copied into a `.codegraph/` made read-only with chmod stays
initialized — skipped on win32 and when running as root (root ignores mode
bits).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(init): refuse a codegraph.db that is not SQLite instead of promising a rebuild

Review found that a `.codegraph/codegraph.db` holding bytes that are not
SQLite at all counted as "schema-less", so `init` printed "rebuilding it"
and then failed with SQLite's "file is not a database". Only an empty file
or a SQLite database without the codegraph tables can be rebuilt in place;
`hasSchemalessDb` now means exactly that, and the new `hasForeignDbFile`
names the other case. `init` stops on it with exit 1, says which file it
is and to move or delete it, and never touches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(directory): ask SQLite what codegraph.db is instead of reading its header ourselves (#1895)

The header check opened and closed a descriptor of our own on
`codegraph.db`. Closing any descriptor on a database file drops every
POSIX lock the process holds on it, including those of a connection it
already has open (sqlite.org/howtocorrupt.html §2.2.1) — and the MCP
server resolves projects through isInitialized on every call while it
holds the index as its writer. mcp-projectpath-lifecycle's takeover tests
caught it: after a real owner exits, the taking-over engine's next read
failed with "disk I/O error" (2 of 21 fail with the header read; 21/21
on main and with this change).

One read-only SQLite probe now answers all three questions: the schema
is there, SQLite opened it and the schema is not (repairable), or SQLite
refused it as not a database (SQLITE_NOTADB). Anything else — locked,
busy, no `-shm` possible — still fails open as initialized. SQLite's own
connections share one lock table per file, so opening and closing a
second one leaves the first one's locks alone. A test pins that
isInitialized and both init helpers never open the file themselves while
this process holds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): keep the #1895 entry clear of #1917's, so the carries merge in any order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 03:01:50 +00:00

238 lines
10 KiB
TypeScript

/**
* A schema-less `codegraph.db` does not make a project initialized (#1895).
*
* `isInitialized()` used to accept any existing `.codegraph/codegraph.db`, so
* an empty or table-less file in an ANCESTOR directory (an interrupted `init`,
* a stray `touch`, a never-populated `$HOME/.codegraph/`) captured the upward
* resolution of every project beneath it: the CLI and the MCP server operated
* on the broken file instead of the project's own index, or instead of the
* "not initialized" guidance. An initialized project is now one whose db
* carries the codegraph schema.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { DatabaseSync } = require('node:sqlite');
import { CodeGraph } from '../src';
import { isInitialized, findNearestCodeGraphRoot, resolveServerRoot, planFrontload, hasSchemalessDb, hasForeignDbFile } from '../src/directory';
import { ToolHandler } from '../src/mcp/tools';
// A configurable `fs`, so a test can watch which files the probe opens.
vi.mock('fs', async (importOriginal) => ({ ...await importOriginal<typeof import('fs')>() }));
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
function run(cwd: string, args: string[]) {
const r = spawnSync(process.execPath, [BIN, ...args], {
cwd,
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
});
return { status: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') };
}
/** A `.codegraph/codegraph.db` that exists but is not a codegraph index. */
function plantBrokenDb(dir: string, kind: 'empty' | 'no-tables' | 'garbage'): string {
const cgDir = path.join(dir, '.codegraph');
fs.mkdirSync(cgDir, { recursive: true });
const dbPath = path.join(cgDir, 'codegraph.db');
if (kind === 'empty') {
fs.writeFileSync(dbPath, '');
} else if (kind === 'garbage') {
fs.writeFileSync(dbPath, 'x'.repeat(4096));
} else {
// A valid SQLite file with zero tables — what an interrupted init leaves.
const db = new DatabaseSync(dbPath);
db.exec('CREATE TABLE t(x); DROP TABLE t;');
db.close();
}
return dbPath;
}
async function indexProject(dir: string): Promise<void> {
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'src', 'a.py'), 'def alpha(x):\n return beta(x) + 1\n\ndef beta(x):\n return x * 2\n');
const cg = CodeGraph.initSync(dir);
await cg.indexAll();
cg.close();
}
describe('a schema-less codegraph.db is not an initialized project (#1895)', () => {
let tmp: string;
let parent: string;
let child: string;
beforeEach(() => {
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1895-')));
parent = path.join(tmp, 'parent');
child = path.join(parent, 'child');
fs.mkdirSync(child, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it.each(['empty', 'no-tables', 'garbage'] as const)('isInitialized() rejects a %s codegraph.db', (kind) => {
plantBrokenDb(parent, kind);
expect(isInitialized(parent)).toBe(false);
expect(findNearestCodeGraphRoot(child)).toBeNull();
expect(resolveServerRoot(child).root).toBeNull();
expect(planFrontload(child, 'how does alpha call beta').exploreRoot).toBeNull();
});
// Root ignores mode bits and Windows has no chmod-based directory permissions.
const canDenyWrite = process.platform !== 'win32' && process.getuid?.() !== 0;
it.runIf(canDenyWrite)('fails OPEN: a WAL db in a directory we cannot open read-only is still initialized', async () => {
// SQLite needs to create `-shm` to open a WAL database; with the directory
// non-writable the read-only open throws. That is a database we cannot
// inspect, not a schema-less one — it must keep resolving (#1913 review).
await indexProject(parent);
const src = path.join(parent, '.codegraph', 'codegraph.db');
const other = path.join(tmp, 'other');
fs.mkdirSync(path.join(other, '.codegraph'), { recursive: true });
fs.copyFileSync(src, path.join(other, '.codegraph', 'codegraph.db'));
const dir = path.join(other, '.codegraph');
fs.chmodSync(dir, 0o555);
try {
expect(isInitialized(other)).toBe(true);
expect(findNearestCodeGraphRoot(path.join(other, 'sub'))).toBe(other);
} finally {
fs.chmodSync(dir, 0o755);
}
});
it('never opens the database through a descriptor of its own while this process holds it', async () => {
// Closing any descriptor on a database file drops every POSIX lock the
// process holds on it, including an open connection's (the MCP server
// resolves projects through isInitialized on every call, as the writer).
await indexProject(parent);
const live = await CodeGraph.open(parent);
const opened: string[] = [];
const real = fs.openSync;
const spy = vi.spyOn(fs, 'openSync').mockImplementation(((file: fs.PathLike, ...rest: unknown[]) => {
opened.push(String(file));
return (real as (...args: unknown[]) => number)(file, ...rest);
}) as typeof fs.openSync);
try {
expect(isInitialized(parent)).toBe(true);
expect(hasSchemalessDb(parent)).toBe(false);
expect(hasForeignDbFile(parent)).toBe(false);
expect(live.getNodesByKind('function').map((n) => n.name).sort()).toEqual(['alpha', 'beta']);
} finally {
spy.mockRestore();
live.close();
}
expect(opened.filter((f) => path.basename(f).startsWith('codegraph.db'))).toEqual([]);
});
it('a real index still resolves upward from a subdirectory', async () => {
await indexProject(parent);
expect(isInitialized(parent)).toBe(true);
expect(findNearestCodeGraphRoot(child)).toBe(parent);
const r = run(child, ['status']);
expect(r.status).toBe(0);
expect(r.out).toContain(parent);
});
it('the cache follows the file: a db that gains its schema flips to initialized', async () => {
plantBrokenDb(parent, 'no-tables');
expect(isInitialized(parent)).toBe(false);
await indexProject(parent); // repairs the file in place
expect(isInitialized(parent)).toBe(true);
});
it('(a) an empty db in the parent does not hijack the initialized child', async () => {
plantBrokenDb(parent, 'empty');
await indexProject(child);
expect(findNearestCodeGraphRoot(child)).toBe(child);
expect(resolveServerRoot(child).root).toBe(child);
const status = run(child, ['status']);
expect(status.status).toBe(0);
expect(status.out).toContain(child);
expect(status.out).not.toMatch(/SQLITE_|not a database|no such table/i);
const explore = run(child, ['explore', 'alpha']);
expect(explore.status).toBe(0);
expect(explore.out).toContain('alpha');
});
it('(a) a workspace container adopts its indexed sub-project even under a schema-less ancestor', async () => {
// The issue's exact reproduction: ws/ has .git and no index, ws/sub is
// indexed, and an ancestor of ws carries a zero-table db.
plantBrokenDb(tmp, 'no-tables');
const ws = path.join(tmp, 'ws');
const sub = path.join(ws, 'sub');
fs.mkdirSync(path.join(ws, '.git'), { recursive: true });
await indexProject(sub);
fs.writeFileSync(path.join(sub, 'pyproject.toml'), '[project]\nname = "sub"\nversion = "0.1"\n');
const res = resolveServerRoot(ws);
expect(res.root).toBe(sub);
expect(res.viaSubScan).toBe(true);
});
it('(b) CLI: parent with a schema-less db, child not initialized → not-initialized guidance, never a SQLite error', () => {
plantBrokenDb(parent, 'no-tables');
const r = run(child, ['status']);
expect(r.out).toMatch(/not initialized/i);
expect(r.out).toContain('codegraph init');
expect(r.out).not.toMatch(/SQLITE_|not a database|no such table/i);
});
it('(b) MCP: the handler returns success-shaped not-indexed guidance for the child', async () => {
plantBrokenDb(parent, 'no-tables');
const handler = new ToolHandler(null);
const res = await handler.execute('codegraph_explore', { query: 'alpha', projectPath: child });
expect(res.isError).toBeUndefined();
const text = res.content.map((c: any) => c.text ?? '').join('\n');
expect(text).toMatch(/isn't indexed|codegraph init/i);
expect(text).not.toMatch(/SQLITE_|not a database|no such table/i);
});
it('(d) codegraph init in the directory with the broken file repairs it', () => {
const dbPath = plantBrokenDb(parent, 'no-tables');
fs.writeFileSync(path.join(parent, 'a.py'), 'def alpha():\n return 1\n');
const r = run(parent, ['init', '--yes']);
expect(r.status).toBe(0);
expect(r.out).toContain('without the codegraph schema');
expect(r.out).not.toContain('Already initialized');
expect(isInitialized(parent)).toBe(true);
const db = new DatabaseSync(dbPath, { readOnly: true });
const row = db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get() as { c: number };
db.close();
expect(row.c).toBe(1);
});
it('(d) an empty file is repaired too; a file that is not SQLite is refused with guidance and left alone', () => {
plantBrokenDb(parent, 'empty');
fs.writeFileSync(path.join(parent, 'a.py'), 'def alpha():\n return 1\n');
const empty = run(parent, ['init', '--yes']);
expect(empty.status).toBe(0);
expect(empty.out).toContain('without the codegraph schema');
expect(isInitialized(parent)).toBe(true);
for (const bytes of ['x'.repeat(4096), 'hello']) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1895-foreign-'));
try {
const cgDir = path.join(dir, '.codegraph');
fs.mkdirSync(cgDir);
const dbPath = path.join(cgDir, 'codegraph.db');
fs.writeFileSync(dbPath, bytes);
fs.writeFileSync(path.join(dir, 'a.py'), 'def alpha():\n return 1\n');
const r = run(dir, ['init', '--yes']);
expect(r.status).toBe(1);
expect(r.out).toContain('is not a SQLite database');
expect(r.out).not.toContain('rebuilding');
expect(r.out).not.toContain('file is not a database');
// Nothing is deleted or rewritten behind the user's back.
expect(fs.readFileSync(dbPath, 'utf-8')).toBe(bytes);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
});