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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-09-23 14:41:33 +00:00
committed by Forge
co-authored by Claude Fable 5.1
parent 986005228b
commit 24041f38b9
2 changed files with 34 additions and 5 deletions
@@ -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);
+13 -5
View File
@@ -99,7 +99,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);
@@ -151,6 +153,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');
@@ -159,10 +169,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 */ }