mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
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>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<typeof import('os')>() }));
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
+76
-7
@@ -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<string, { mtimeMs: number; size: number; ok: boolean }>();
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user