diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8642f246..7332feb9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,7 @@ 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) - 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) 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..10ae7b7689 --- /dev/null +++ b/__tests__/schemaless-db-not-initialized.test.ts @@ -0,0 +1,237 @@ +/** + * 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() })); + +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(); + }); + + // 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 }); + } + } + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index e9da3a6e6f..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, 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,17 @@ 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.`); + } 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..0a08df9521 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -122,8 +122,21 @@ 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 `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); @@ -132,7 +145,88 @@ 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, 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'); + let st: fs.Stats; + try { st = fs.statSync(dbPath); } catch { return false; } + if (!st.isFile() || isInitialized(projectRoot)) return false; + return st.size === 0 || probeSchema(dbPath) === 'no-schema'; +} + +/** + * `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 && probeSchema(dbPath) === 'not-sqlite'; +} + +/** 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 probe = probeSchema(dbPath); + const ok = probe === 'schema' || probe === 'unknown'; + schemaProbeCache.set(dbPath, { mtimeMs: st.mtimeMs, size: st.size, ok }); + return ok; +} + +/** + * 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 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 ? '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 */ } + } } /** @@ -806,11 +900,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}`); }