From bfac5c500b1ed3bfe105570fd2d410f6c3360cc8 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Tue, 22 Sep 2026 17:58:21 +0300 Subject: [PATCH 1/5] 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 --- CHANGELOG.md | 1 + __tests__/frontload-hook.test.ts | 9 +- .../schemaless-db-not-initialized.test.ts | 160 ++++++++++++++++++ src/bin/codegraph.ts | 5 +- src/directory.ts | 83 ++++++++- 5 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 __tests__/schemaless-db-not-initialized.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8642f246..b9f70f439a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing - On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773) +- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". (#1895) - File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project. - Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship. diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index 6868e4c754..3e81e6090e 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -19,10 +19,13 @@ import { planFrontload, isTaskNotification, findIndexedSubprojectRoots, unsafeIn // fixture without changing the process environment or the user's home files. vi.mock('os', async (importOriginal) => ({ ...await importOriginal() })); -/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */ +/** + * Make `dir` indexed. isInitialized needs `.codegraph/codegraph.db` WITH the + * codegraph schema — an empty file no longer counts (#1895). + */ function mkIndexed(dir: string): string { - fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true }); - fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), ''); + fs.mkdirSync(dir, { recursive: true }); + CodeGraph.initSync(dir).close(); return dir; } /** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */ diff --git a/__tests__/schemaless-db-not-initialized.test.ts b/__tests__/schemaless-db-not-initialized.test.ts new file mode 100644 index 0000000000..e1cae470b4 --- /dev/null +++ b/__tests__/schemaless-db-not-initialized.test.ts @@ -0,0 +1,160 @@ +/** + * 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 } 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 } from '../src/directory'; +import { ToolHandler } from '../src/mcp/tools'; + +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 { + 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(); + }); + + 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); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e9da3a6e6f..656ad4889f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -41,7 +41,7 @@ try { import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; -import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; +import { getCodeGraphDir, isInitialized, hasSchemalessDb, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; import { extractProseCandidates } from '../search/identifier-segments'; import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; import { createShimmerProgress } from '../ui/shimmer-progress'; @@ -697,6 +697,9 @@ async function runInit( return; } + if (hasSchemalessDb(projectPath)) { + clack.log.warn(`Found a codegraph.db without the codegraph schema in ${getCodeGraphDir(projectPath)} (left by an interrupted init?) — rebuilding it.`); + } const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); const cg = await CodeGraph.init(projectPath, { index: false }); clack.log.success(`Initialized in ${projectPath}`); diff --git a/src/directory.ts b/src/directory.ts index 1d758dbe03..cfbbc69768 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -122,8 +122,19 @@ export function getCodeGraphDir(projectRoot: string): string { } /** - * Check if a project has been initialized with CodeGraph - * Requires both .codegraph/ directory AND codegraph.db to exist + * Check if a project has been initialized with CodeGraph. + * + * Requires `.codegraph/codegraph.db` to exist AND to carry the codegraph + * schema. A file that merely exists — empty, or a SQLite database with no + * tables, as an interrupted `init` or a stray `touch` leaves behind — used to + * count as initialized, so one such file in an ANCESTOR directory (worst + * case: `$HOME`) captured the upward resolution of every project beneath it + * and made their real indexes unreachable (#1895). + * + * The probe is cheap and gated so hot callers (the prompt hook, MCP root + * resolution on every call) pay one small read: file size, then the SQLite + * header magic, then a read-only open with a single `sqlite_master` lookup — + * memoized per path + mtime + size so an unchanged db is never reopened. */ export function isInitialized(projectRoot: string): boolean { const codegraphDir = getCodeGraphDir(projectRoot); @@ -132,7 +143,65 @@ export function isInitialized(projectRoot: string): boolean { } // Must have codegraph.db, not just .codegraph folder const dbPath = path.join(codegraphDir, 'codegraph.db'); - return fs.existsSync(dbPath); + let st: fs.Stats; + try { + st = fs.statSync(dbPath); + } catch { + return false; + } + return hasCodeGraphSchema(dbPath, st); +} + +/** `codegraph.db` exists at `projectRoot` but does not carry the schema (#1895). */ +export function hasSchemalessDb(projectRoot: string): boolean { + const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db'); + return fs.existsSync(dbPath) && !isInitialized(projectRoot); +} + +const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1'); +/** A SQLite file header is 100 bytes; anything shorter cannot be a database. */ +const SQLITE_HEADER_SIZE = 100; +const schemaProbeCache = new Map(); + +function hasCodeGraphSchema(dbPath: string, st: fs.Stats): boolean { + if (!st.isFile() || st.size < SQLITE_HEADER_SIZE) return false; + const cached = schemaProbeCache.get(dbPath); + if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.ok; + const ok = readsAsSqlite(dbPath) && hasNodesTable(dbPath); + schemaProbeCache.set(dbPath, { mtimeMs: st.mtimeMs, size: st.size, ok }); + return ok; +} + +function readsAsSqlite(dbPath: string): boolean { + let fd: number | null = null; + try { + fd = fs.openSync(dbPath, 'r'); + const head = Buffer.alloc(SQLITE_MAGIC.length); + const n = fs.readSync(fd, head, 0, head.length, 0); + return n === head.length && head.equals(SQLITE_MAGIC); + } catch { + return false; + } finally { + if (fd !== null) fs.closeSync(fd); + } +} + +function hasNodesTable(dbPath: string): boolean { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require('node:sqlite'); + let db: any = null; + try { + db = new DatabaseSync(dbPath, { readOnly: true }); + const row = db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get(); + return row !== undefined; + } catch (err) { + // A db another process holds an exclusive lock on is a live db, not a + // broken one; only an unreadable or schema-less file is "not initialized". + return /locked|busy/i.test(String((err as Error)?.message ?? '')); + } finally { + // Never hold the handle: Windows file locking would block the owner. + try { db?.close(); } catch { /* already closed */ } + } } /** @@ -806,11 +875,11 @@ function ensureGitignore(gitignorePath: string): boolean { */ export function createDirectory(projectRoot: string): void { const codegraphDir = getCodeGraphDir(projectRoot); - const dbPath = path.join(codegraphDir, 'codegraph.db'); - // Only throw if CodeGraph is actually initialized (db exists) - // .codegraph/ folder alone is fine - if (fs.existsSync(dbPath)) { + // Only throw if CodeGraph is actually initialized (db with a schema). + // .codegraph/ folder alone — or a schema-less codegraph.db left by an + // interrupted init (#1895) — is fine: initialize() adds the schema to it. + if (isInitialized(projectRoot)) { throw new Error(`CodeGraph already initialized in ${projectRoot}`); } From 30f73687153bbcc5e60db85c4ee2d95f4769ece0 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Tue, 22 Sep 2026 18:02:11 +0300 Subject: [PATCH 2/5] fix(directory): a database we cannot open read-only still counts as initialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../schemaless-db-not-initialized.test.ts | 21 +++++++++++++++++++ src/directory.ts | 18 +++++++++++----- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/__tests__/schemaless-db-not-initialized.test.ts b/__tests__/schemaless-db-not-initialized.test.ts index e1cae470b4..dc960934b8 100644 --- a/__tests__/schemaless-db-not-initialized.test.ts +++ b/__tests__/schemaless-db-not-initialized.test.ts @@ -80,6 +80,27 @@ describe('a schema-less codegraph.db is not an initialized project (#1895)', () 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('a real index still resolves upward from a subdirectory', async () => { await indexProject(parent); expect(isInitialized(parent)).toBe(true); diff --git a/src/directory.ts b/src/directory.ts index cfbbc69768..c26d8a5318 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -134,7 +134,9 @@ export function getCodeGraphDir(projectRoot: string): string { * The probe is cheap and gated so hot callers (the prompt hook, MCP root * resolution on every call) pay one small read: file size, then the SQLite * header magic, then a read-only open with a single `sqlite_master` lookup — - * memoized per path + mtime + size so an unchanged db is never reopened. + * memoized per path + mtime + size so an unchanged db is never reopened. A + * database that passes the header check but cannot be opened counts as + * initialized (see hasNodesTable) — only a proven-absent schema says no. */ export function isInitialized(projectRoot: string): boolean { const codegraphDir = getCodeGraphDir(projectRoot); @@ -186,6 +188,14 @@ function readsAsSqlite(dbPath: string): boolean { } } +/** + * Fails OPEN. Once the size gate and the header magic have passed, the file IS + * a SQLite database; only a SUCCESSFUL `sqlite_master` query that proves the + * `nodes` table is absent may say "not initialized". Any open/prepare error — + * locked, busy, a WAL db in a directory we cannot create `-shm` in (read-only + * checkout, mount, another user's tree), disk I/O — returns true: the + * pre-existing behaviour for a database we cannot inspect. + */ function hasNodesTable(dbPath: string): boolean { // eslint-disable-next-line @typescript-eslint/no-require-imports const { DatabaseSync } = require('node:sqlite'); @@ -194,10 +204,8 @@ function hasNodesTable(dbPath: string): boolean { db = new DatabaseSync(dbPath, { readOnly: true }); const row = db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get(); return row !== undefined; - } catch (err) { - // A db another process holds an exclusive lock on is a live db, not a - // broken one; only an unreadable or schema-less file is "not initialized". - return /locked|busy/i.test(String((err as Error)?.message ?? '')); + } catch { + return true; } finally { // Never hold the handle: Windows file locking would block the owner. try { db?.close(); } catch { /* already closed */ } From cb7d0f80eb36cb563d2b015589c6a7cbdb5870c7 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Tue, 22 Sep 2026 20:28:20 +0300 Subject: [PATCH 3/5] 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 --- .../schemaless-db-not-initialized.test.ts | 29 +++++++++++++++++++ src/bin/codegraph.ts | 10 ++++++- src/directory.ts | 25 ++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/__tests__/schemaless-db-not-initialized.test.ts b/__tests__/schemaless-db-not-initialized.test.ts index dc960934b8..484f496160 100644 --- a/__tests__/schemaless-db-not-initialized.test.ts +++ b/__tests__/schemaless-db-not-initialized.test.ts @@ -178,4 +178,33 @@ describe('a schema-less codegraph.db is not an initialized project (#1895)', () 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 }); + } + } + }); }); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 656ad4889f..cfe1cecfd0 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -41,7 +41,7 @@ try { import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; -import { getCodeGraphDir, isInitialized, hasSchemalessDb, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; +import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; import { extractProseCandidates } from '../search/identifier-segments'; import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; import { createShimmerProgress } from '../ui/shimmer-progress'; @@ -697,6 +697,14 @@ async function runInit( return; } + if (hasForeignDbFile(projectPath)) { + const dbFile = path.join(getCodeGraphDir(projectPath), 'codegraph.db'); + clack.log.error(`${dbFile} is not a SQLite database, so it cannot be rebuilt in place.`); + clack.log.info('Move or delete that file, then run "codegraph init" again.'); + clack.outro(''); + process.exitCode = 1; + return; + } if (hasSchemalessDb(projectPath)) { clack.log.warn(`Found a codegraph.db without the codegraph schema in ${getCodeGraphDir(projectPath)} (left by an interrupted init?) — rebuilding it.`); } diff --git a/src/directory.ts b/src/directory.ts index c26d8a5318..c3412accc5 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -154,10 +154,31 @@ export function isInitialized(projectRoot: string): boolean { return hasCodeGraphSchema(dbPath, st); } -/** `codegraph.db` exists at `projectRoot` but does not carry the schema (#1895). */ +/** + * `codegraph.db` exists at `projectRoot` but does not carry the schema, and + * `init` can add it in place: an empty file, or a SQLite database without the + * codegraph tables (#1895). A file that is not SQLite at all is NOT this case — + * see {@link hasForeignDbFile}. + */ export function hasSchemalessDb(projectRoot: string): boolean { const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db'); - return fs.existsSync(dbPath) && !isInitialized(projectRoot); + let st: fs.Stats; + try { st = fs.statSync(dbPath); } catch { return false; } + if (!st.isFile() || isInitialized(projectRoot)) return false; + return st.size === 0 || readsAsSqlite(dbPath); +} + +/** + * `codegraph.db` exists at `projectRoot` and is not a SQLite database (no + * header magic): SQLite refuses to open it, so `init` cannot rebuild it in + * place. The caller must say so rather than promise a repair; nothing here + * deletes the file. + */ +export function hasForeignDbFile(projectRoot: string): boolean { + const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db'); + let st: fs.Stats; + try { st = fs.statSync(dbPath); } catch { return false; } + return st.isFile() && st.size > 0 && !readsAsSqlite(dbPath); } const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1'); From bcbac2f39b7ceb11232083a653b09ea1fca88e22 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 28 Sep 2026 20:09:19 -0500 Subject: [PATCH 4/5] fix(directory): ask SQLite what codegraph.db is instead of reading its header ourselves (#1895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 +- .../schemaless-db-not-initialized.test.ts | 31 ++++++++- src/directory.ts | 64 +++++++++---------- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f70f439a..50bbb6b010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,8 +172,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### MCP / indexing - On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773) -- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". (#1895) - File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964) +- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". Thanks @danusha2345. (#1895) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project. - Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship. - Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829) diff --git a/__tests__/schemaless-db-not-initialized.test.ts b/__tests__/schemaless-db-not-initialized.test.ts index 484f496160..10ae7b7689 100644 --- a/__tests__/schemaless-db-not-initialized.test.ts +++ b/__tests__/schemaless-db-not-initialized.test.ts @@ -9,7 +9,7 @@ * "not initialized" guidance. An initialized project is now one whose db * carries the codegraph schema. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; @@ -17,9 +17,12 @@ 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 } from '../src/directory'; +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() })); + const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function run(cwd: string, args: string[]) { @@ -101,6 +104,30 @@ describe('a schema-less codegraph.db is not an initialized project (#1895)', () } }); + 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); diff --git a/src/directory.ts b/src/directory.ts index c3412accc5..0a08df9521 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -132,11 +132,11 @@ export function getCodeGraphDir(projectRoot: string): string { * and made their real indexes unreachable (#1895). * * The probe is cheap and gated so hot callers (the prompt hook, MCP root - * resolution on every call) pay one small read: file size, then the SQLite - * header magic, then a read-only open with a single `sqlite_master` lookup — - * memoized per path + mtime + size so an unchanged db is never reopened. A - * database that passes the header check but cannot be opened counts as - * initialized (see hasNodesTable) — only a proven-absent schema says no. + * resolution on every call) pay one `stat`: file size, then a read-only + * SQLite open with a single `sqlite_master` lookup — memoized per path + + * mtime + size so an unchanged db is never reopened. A database that cannot + * be inspected counts as initialized (see probeSchema) — only a proven-absent + * schema, or a file SQLite refuses as not a database, says no. */ export function isInitialized(projectRoot: string): boolean { const codegraphDir = getCodeGraphDir(projectRoot); @@ -165,7 +165,7 @@ export function hasSchemalessDb(projectRoot: string): boolean { let st: fs.Stats; try { st = fs.statSync(dbPath); } catch { return false; } if (!st.isFile() || isInitialized(projectRoot)) return false; - return st.size === 0 || readsAsSqlite(dbPath); + return st.size === 0 || probeSchema(dbPath) === 'no-schema'; } /** @@ -178,55 +178,51 @@ export function hasForeignDbFile(projectRoot: string): boolean { const dbPath = path.join(getCodeGraphDir(projectRoot), 'codegraph.db'); let st: fs.Stats; try { st = fs.statSync(dbPath); } catch { return false; } - return st.isFile() && st.size > 0 && !readsAsSqlite(dbPath); + return st.isFile() && st.size > 0 && probeSchema(dbPath) === 'not-sqlite'; } -const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1'); -/** A SQLite file header is 100 bytes; anything shorter cannot be a database. */ +/** A SQLite file header is 100 bytes; anything shorter cannot hold a schema. */ const SQLITE_HEADER_SIZE = 100; +/** SQLITE_NOTADB: SQLite read the file and it is not a database. */ +const SQLITE_NOTADB = 26; const schemaProbeCache = new Map(); function hasCodeGraphSchema(dbPath: string, st: fs.Stats): boolean { if (!st.isFile() || st.size < SQLITE_HEADER_SIZE) return false; const cached = schemaProbeCache.get(dbPath); if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.ok; - const ok = readsAsSqlite(dbPath) && hasNodesTable(dbPath); + const probe = probeSchema(dbPath); + const ok = probe === 'schema' || probe === 'unknown'; schemaProbeCache.set(dbPath, { mtimeMs: st.mtimeMs, size: st.size, ok }); return ok; } -function readsAsSqlite(dbPath: string): boolean { - let fd: number | null = null; - try { - fd = fs.openSync(dbPath, 'r'); - const head = Buffer.alloc(SQLITE_MAGIC.length); - const n = fs.readSync(fd, head, 0, head.length, 0); - return n === head.length && head.equals(SQLITE_MAGIC); - } catch { - return false; - } finally { - if (fd !== null) fs.closeSync(fd); - } -} - /** - * Fails OPEN. Once the size gate and the header magic have passed, the file IS - * a SQLite database; only a SUCCESSFUL `sqlite_master` query that proves the - * `nodes` table is absent may say "not initialized". Any open/prepare error — - * locked, busy, a WAL db in a directory we cannot create `-shm` in (read-only - * checkout, mount, another user's tree), disk I/O — returns true: the - * pre-existing behaviour for a database we cannot inspect. + * What `codegraph.db` holds, asked of SQLite itself through a read-only + * connection: the codegraph schema, a database without it, not a database at + * all (SQLITE_NOTADB), or `unknown` — locked, busy, a WAL db in a directory we + * cannot create `-shm` in (read-only checkout, mount, another user's tree), + * disk I/O. Callers treat `unknown` as initialized: the pre-existing behaviour + * for a database we cannot inspect. + * + * The file is never read through a descriptor of our own, not even for its + * 16-byte header. Closing ANY descriptor on a database file drops every POSIX + * lock this 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. SQLite's own connections share one lock table per file, so a + * second connection opened and closed here leaves the first one's locks alone. */ -function hasNodesTable(dbPath: string): boolean { +function probeSchema(dbPath: string): 'schema' | 'no-schema' | 'not-sqlite' | 'unknown' { // eslint-disable-next-line @typescript-eslint/no-require-imports const { DatabaseSync } = require('node:sqlite'); let db: any = null; try { db = new DatabaseSync(dbPath, { readOnly: true }); const row = db.prepare("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'nodes'").get(); - return row !== undefined; - } catch { - return true; + return row !== undefined ? 'schema' : 'no-schema'; + } catch (error) { + return (error as { errcode?: number })?.errcode === SQLITE_NOTADB ? 'not-sqlite' : 'unknown'; } finally { // Never hold the handle: Windows file locking would block the owner. try { db?.close(); } catch { /* already closed */ } From 938ae8daa205bc0eb1ba39c0a68e4ec1b97674b2 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 28 Sep 2026 20:40:28 -0500 Subject: [PATCH 5/5] docs(changelog): keep the #1895 entry clear of #1917's, so the carries merge in any order Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50bbb6b010..7332feb9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,8 +173,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773) - File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964) -- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". Thanks @danusha2345. (#1895) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project. +- An empty or table-less `codegraph.db` left behind in a parent directory (by an interrupted `codegraph init`, or a never-populated `~/.codegraph/`) no longer counts as an initialized project, so it can no longer hide the real index of every project beneath it. Only a database that actually carries the codegraph schema is treated as initialized, and `codegraph init` in the directory with the broken file now repairs it instead of refusing with "Already initialized". Thanks @danusha2345. (#1895) - Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship. - Status now detects committed but unindexed changes and restored edits without scanning every source file; thanks @inth3shadows. (#1829)