diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc11c4e5..01aed81c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Interrupted extension installs and changes recover their last consistent configuration and graph, while conflicting user edits produce repair instructions instead of being overwritten. +- 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) - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) - Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869) diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index 95cd83a0b..92fb266f0 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -12,16 +12,20 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { CodeGraph } from '../src'; import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; // Make the built-in exports configurable so HOME can point at a real temp // 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 000000000..e1cae470b --- /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 3f5d92193..4729cb60d 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, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection } from '../directory'; +import { getCodeGraphDir, isInitialized, hasSchemalessDb, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection } 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 3ada4c88c..2ac4b340a 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -87,8 +87,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); @@ -97,7 +108,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 */ } + } } /** @@ -731,11 +800,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}`); }