From 9a87f92c3227a9e35e65e53f45582412de1aa9e1 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Fri, 16 Oct 2026 08:53:59 -0400 Subject: [PATCH 1/4] fix: refuse a missing index and stop hiding pending-refs check failures (#111, #110) #111: sqlite3.connect creates a missing file, so a mistyped --db left an empty database behind and failed later with `no such table: files`. db.connect now raises IndexMissing; select and export print "no CodeGraph index at " and exit 2, creating nothing. #110: the pending-refs check sat under `except Exception: pass`, so a missing table or a locked database disarmed the mid-resolution guard silently. It now catches sqlite3.Error only and adds a warning, which turns an empty NONE into UNKNOWN. Non-sqlite errors propagate. Closes #111 Closes #110 --- testgraph/db.py | 13 +++++++ testgraph/export.py | 6 ++- testgraph/integrity.py | 20 +++++++++- testgraph/select.py | 6 ++- tests/test_core.py | 84 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 4 deletions(-) diff --git a/testgraph/db.py b/testgraph/db.py index e6ea641..4913cf8 100644 --- a/testgraph/db.py +++ b/testgraph/db.py @@ -5,6 +5,7 @@ not a transitive symbol closure (verified on honeyslate 2026-07-17). """ import collections +import os import sqlite3 # Edge kinds that carry a behavior-reachability signal, walked in REVERSE @@ -84,7 +85,19 @@ def weak_edge_reason(cap): return CAP_REASONS.get(cap) +class IndexMissing(FileNotFoundError): + """No CodeGraph index at the given path.""" + + def connect(db_path): + # sqlite3.connect CREATES a missing file, so a mistyped --db used to leave + # an empty database behind and fail later with `no such table: files` + # (issue #111). testgraph reads an index and never builds one. + if db_path != ":memory:" and not os.path.exists(db_path): + raise IndexMissing( + f"no CodeGraph index at {db_path} — testgraph reads an existing " + f"index and does not build one. Run `codegraph init` in the repo." + ) conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn diff --git a/testgraph/export.py b/testgraph/export.py index 1ccf03e..7ac301a 100644 --- a/testgraph/export.py +++ b/testgraph/export.py @@ -339,7 +339,11 @@ def main(argv=None): return 2 db_path = args.db or os.path.join(args.repo, ".codegraph", "codegraph.db") - conn = dbmod.connect(db_path) + try: + conn = dbmod.connect(db_path) + except dbmod.IndexMissing as e: + print(str(e), file=sys.stderr) + return 2 try: registry = reg.load(registry_path) diff --git a/testgraph/integrity.py b/testgraph/integrity.py index cbdbdb6..b6efae7 100644 --- a/testgraph/integrity.py +++ b/testgraph/integrity.py @@ -12,6 +12,7 @@ """ import hashlib import os +import sqlite3 from . import db as dbmod @@ -172,8 +173,23 @@ def check(conn, repo_root, spot_checks, pending_max=0, schema_pin=None, f"{pending} unresolved refs still pending (> {pending_max}) — " f"index is mid-resolution" ) - except Exception: - pass # older schema without status column + except sqlite3.Error as e: + # This used to be `except Exception: pass`, justified as "older schema + # without status column" — but it also swallowed a missing table and a + # locked database, so the mid-resolution guard stopped running with no + # trace (issue #110). Every miss is now a warning, so `select` reads an + # empty answer as UNKNOWN rather than as a clean bill. + if "no such column" in str(e) or "no such table" in str(e): + warnings.append( + f"pending-refs check skipped: the index has no " + f"unresolved_refs.status ({e}) — cannot tell whether it is " + f"mid-resolution" + ) + else: + warnings.append( + f"pending-refs check failed ({type(e).__name__}: {e}) — cannot " + f"tell whether the index is mid-resolution" + ) # 2. freshness: any tracked source newer than its index row. WARN not BLOCK # — repo-wide staleness costs precision, and blocking on it would refuse diff --git a/testgraph/select.py b/testgraph/select.py index dce7b09..70a17f7 100644 --- a/testgraph/select.py +++ b/testgraph/select.py @@ -687,7 +687,11 @@ def main(argv=None): if registry_path is None: print(reg.not_found_message(args.repo), file=sys.stderr) return 2 - result = select(args.repo, args.base, args.head, db_path, registry_path) + try: + result = select(args.repo, args.base, args.head, db_path, registry_path) + except dbmod.IndexMissing as e: + print(str(e), file=sys.stderr) + return 2 if args.json: print(json.dumps(result, indent=2)) else: diff --git a/tests/test_core.py b/tests/test_core.py index aaea380..17f4d51 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -404,6 +404,36 @@ def test_a_confident_journey_carries_no_weak_reason(self): self.assertIsNone(dbmod.weak_edge_reason(caps["function:mid_b"])) +class MissingIndexTests(unittest.TestCase): + """#111: sqlite3.connect creates a missing file, so a mistyped --db left an + empty database behind and failed later with `no such table: files`.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.tmp) + self.db = os.path.join(self.tmp, "typo.db") + + def test_connect_refuses_and_creates_nothing(self): + with self.assertRaises(dbmod.IndexMissing) as cm: + dbmod.connect(self.db) + self.assertIn("no CodeGraph index at", str(cm.exception)) + self.assertFalse(os.path.exists(self.db)) + + def test_memory_database_still_opens(self): + dbmod.connect(":memory:").close() + + def test_clis_exit_2_with_a_message(self): + registry = os.path.join(self.tmp, "r.json") + for main, extra in ((sel.main, []), (exp.main, [])): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--repo", self.tmp, "--db", self.db, + "--registry", registry] + extra) + self.assertEqual(rc, 2, main.__module__) + self.assertIn("no CodeGraph index at", err.getvalue()) + self.assertFalse(os.path.exists(self.db), main.__module__) + + class IntegrityTests(unittest.TestCase): def setUp(self): self.conn = build_fixture() @@ -462,6 +492,60 @@ def test_pending_refs_block(self): blocking, _ = integrity.check(self.conn, "/nonexistent", {}) self.assertTrue(any("pending" in b for b in blocking)) + # --- pending-refs check that cannot run (#110) ------------------------- + # + # It used to sit under `except Exception: pass`, so a missing table or a + # locked database disarmed the mid-resolution guard without a word. Each + # miss must now surface as a warning, which `select` turns into UNKNOWN. + + def test_missing_unresolved_refs_table_warns(self): + self.conn.execute("DROP TABLE unresolved_refs") + blocking, warnings = integrity.check(self.conn, "/nonexistent", {}) + self.assertTrue(any("pending-refs check skipped" in w for w in warnings), + warnings) + self.assertFalse(any("pending" in b for b in blocking)) + + def test_missing_status_column_warns(self): + self.conn.execute("DROP TABLE unresolved_refs") + self.conn.execute("CREATE TABLE unresolved_refs(id INTEGER PRIMARY KEY)") + _, warnings = integrity.check(self.conn, "/nonexistent", {}) + self.assertTrue(any("pending-refs check skipped" in w for w in warnings), + warnings) + + def test_locked_database_on_pending_check_warns(self): + real = self.conn + + class LockedOnPending: + def __getattr__(self, name): + return getattr(real, name) + + def execute(self, sql, *args): + if "unresolved_refs" in sql: + raise sqlite3.OperationalError("database is locked") + return real.execute(sql, *args) + + _, warnings = integrity.check(LockedOnPending(), "/nonexistent", {}) + self.assertTrue(any("pending-refs check failed" in w + and "database is locked" in w for w in warnings), + warnings) + + def test_non_sqlite_error_on_pending_check_propagates(self): + """Only sqlite errors mean "the check could not run". A bug in testgraph + itself must crash, not be reworded as a warning.""" + real = self.conn + + class Broken: + def __getattr__(self, name): + return getattr(real, name) + + def execute(self, sql, *args): + if "unresolved_refs" in sql: + raise TypeError("bug") + return real.execute(sql, *args) + + with self.assertRaises(TypeError): + integrity.check(Broken(), "/nonexistent", {}) + # --- stale edges (#85) ------------------------------------------------- # # The gap every other check in this class is blind to. `codegraph sync` From 7f235f8ac51d094c8bbe89cd9d28c94a58bd1b87 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Fri, 16 Oct 2026 10:02:24 -0400 Subject: [PATCH 2/4] fix: open the index read-only and block a missing pending-refs table at a matching pin Review round 1 on #123: - connect opens with mode=ro and refuses a path that is not a file or has no `files` table, so the 0-byte file the old bug left behind, a directory, or any non-index sqlite file is refused with exit 2, and nothing can create one. - a missing unresolved_refs table or status column BLOCKS when the schema pin matches; it warns only when the schema is unpinned or differs. - messages: "check the path" before "codegraph init"; a missing table and a missing column are worded apart. --- testgraph/db.py | 37 +++++++++++++++++---- testgraph/integrity.py | 33 ++++++++++++++----- tests/test_core.py | 75 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/testgraph/db.py b/testgraph/db.py index 4913cf8..1fb4087 100644 --- a/testgraph/db.py +++ b/testgraph/db.py @@ -6,6 +6,7 @@ """ import collections import os +import pathlib import sqlite3 # Edge kinds that carry a behavior-reachability signal, walked in REVERSE @@ -90,15 +91,37 @@ class IndexMissing(FileNotFoundError): def connect(db_path): - # sqlite3.connect CREATES a missing file, so a mistyped --db used to leave - # an empty database behind and fail later with `no such table: files` - # (issue #111). testgraph reads an index and never builds one. - if db_path != ":memory:" and not os.path.exists(db_path): + """Open a CodeGraph index READ-ONLY, or raise IndexMissing. + + sqlite3.connect creates a missing file, so a mistyped --db used to leave an + empty database behind and fail later with `no such table: files` (#111). + `mode=ro` means no path through here can create or write one; checking for + `files` also refuses the 0-byte file that bug left behind, and any sqlite + file that is not an index. Temp tables, which the closure walk uses, live + in the temp schema and still work.""" + if db_path == ":memory:": + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + if not os.path.isfile(db_path): raise IndexMissing( - f"no CodeGraph index at {db_path} — testgraph reads an existing " - f"index and does not build one. Run `codegraph init` in the repo." + f"no CodeGraph index at {db_path} — check the path, or build one " + f"with `codegraph init` in the repo" + ) + uri = pathlib.Path(db_path).resolve().as_uri() + "?mode=ro" + conn = sqlite3.connect(uri, uri=True) + try: + has_files = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'files'" + ).fetchone() + except sqlite3.DatabaseError: + has_files = None + if not has_files: + conn.close() + raise IndexMissing( + f"{db_path} is not a CodeGraph index (no `files` table) — check the " + f"path, or build one with `codegraph init` in the repo" ) - conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn diff --git a/testgraph/integrity.py b/testgraph/integrity.py index b6efae7..4badd91 100644 --- a/testgraph/integrity.py +++ b/testgraph/integrity.py @@ -177,18 +177,35 @@ def check(conn, repo_root, spot_checks, pending_max=0, schema_pin=None, # This used to be `except Exception: pass`, justified as "older schema # without status column" — but it also swallowed a missing table and a # locked database, so the mid-resolution guard stopped running with no - # trace (issue #110). Every miss is now a warning, so `select` reads an - # empty answer as UNKNOWN rather than as a clean bill. - if "no such column" in str(e) or "no such table" in str(e): + # trace (issue #110). + # + # A missing table or column is an older schema only when the schema is + # unpinned or differs from the pin. When the pin MATCHES, the index + # claims a layout that has them, so it is broken: block. A warning would + # not do — it only demotes an EMPTY answer to UNKNOWN, and a non-empty + # one would still read as confident. + msg = str(e) + if "no such table" in msg: + missing = "no unresolved_refs table" + elif "no such column" in msg: + missing = "no unresolved_refs.status column" + else: + missing = None + if missing and schema_pin is not None and found == schema_pin: + blocking.append( + f"index has {missing} although its schema matches the pin " + f"({schema_pin}) — the index is incomplete; rebuild it with " + f"`codegraph init`" + ) + elif missing: warnings.append( - f"pending-refs check skipped: the index has no " - f"unresolved_refs.status ({e}) — cannot tell whether it is " - f"mid-resolution" + f"pending-refs check skipped: index has {missing} ({msg}) — " + f"cannot tell whether it is mid-resolution" ) else: warnings.append( - f"pending-refs check failed ({type(e).__name__}: {e}) — cannot " - f"tell whether the index is mid-resolution" + f"pending-refs check failed ({type(e).__name__}: {msg}) — " + f"cannot tell whether the index is mid-resolution" ) # 2. freshness: any tracked source newer than its index row. WARN not BLOCK diff --git a/tests/test_core.py b/tests/test_core.py index 17f4d51..6ad7373 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -419,6 +419,56 @@ def test_connect_refuses_and_creates_nothing(self): self.assertIn("no CodeGraph index at", str(cm.exception)) self.assertFalse(os.path.exists(self.db)) + def _sqlite_file(self, script): + path = os.path.join(self.tmp, "other.db") + c = sqlite3.connect(path) + c.executescript(script) + c.commit() + c.close() + return path + + def test_empty_file_is_refused(self): + """The 0-byte file the old bug left behind passes an exists() check.""" + open(self.db, "w").close() + with self.assertRaises(dbmod.IndexMissing) as cm: + dbmod.connect(self.db) + self.assertIn("not a CodeGraph index", str(cm.exception)) + + def test_non_codegraph_sqlite_file_is_refused(self): + path = self._sqlite_file("CREATE TABLE t(x);") + with self.assertRaises(dbmod.IndexMissing): + dbmod.connect(path) + + def test_directory_is_refused(self): + with self.assertRaises(dbmod.IndexMissing): + dbmod.connect(self.tmp) + + def test_connection_is_read_only(self): + """testgraph reads an index and never builds one. Temp tables, which + the closure walk uses, must still work.""" + path = self._sqlite_file("CREATE TABLE files(path TEXT);") + conn = dbmod.connect(path) + self.addCleanup(conn.close) + conn.execute("CREATE TEMP TABLE _scratch(id TEXT)") + with self.assertRaises(sqlite3.OperationalError): + conn.execute("CREATE TABLE main.written(x)") + + def test_message_points_at_the_path_before_a_rebuild(self): + """A mistyped --db must not read as "rebuild your index".""" + with self.assertRaises(dbmod.IndexMissing) as cm: + dbmod.connect(self.db) + self.assertIn("check the path", str(cm.exception)) + + def test_cli_refuses_an_empty_file_with_exit_2(self): + open(self.db, "w").close() + registry = os.path.join(self.tmp, "r.json") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = sel.main(["--repo", self.tmp, "--db", self.db, + "--registry", registry]) + self.assertEqual(rc, 2) + self.assertIn("not a CodeGraph index", err.getvalue()) + def test_memory_database_still_opens(self): dbmod.connect(":memory:").close() @@ -512,6 +562,31 @@ def test_missing_status_column_warns(self): self.assertTrue(any("pending-refs check skipped" in w for w in warnings), warnings) + # A missing table or column only means "older schema" when the schema is + # unpinned or differs. When the pin MATCHES, the index claims a layout that + # has the table, so its absence is a broken index: block, because a warning + # only demotes an EMPTY answer and a non-empty one would stay confident. + + def test_missing_unresolved_refs_table_blocks_when_schema_pin_matches(self): + self.conn.execute("DROP TABLE unresolved_refs") + blocking, _ = integrity.check(self.conn, "/nonexistent", {}, schema_pin=8) + self.assertTrue(any("no unresolved_refs table" in b for b in blocking), + blocking) + + def test_missing_status_column_blocks_when_schema_pin_matches(self): + self.conn.execute("DROP TABLE unresolved_refs") + self.conn.execute("CREATE TABLE unresolved_refs(id INTEGER PRIMARY KEY)") + blocking, _ = integrity.check(self.conn, "/nonexistent", {}, schema_pin=8) + self.assertTrue(any("no unresolved_refs.status column" in b + for b in blocking), blocking) + + def test_missing_table_and_missing_column_are_worded_apart(self): + self.conn.execute("DROP TABLE unresolved_refs") + _, warnings = integrity.check(self.conn, "/nonexistent", {}) + self.assertTrue(any("no unresolved_refs table" in w for w in warnings), + warnings) + self.assertFalse(any(".status" in w for w in warnings), warnings) + def test_locked_database_on_pending_check_warns(self): real = self.conn From a5486cf3a401a1c6cc608a94b5e46e5bf3ac0042 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Thu, 29 Oct 2026 20:25:38 -0400 Subject: [PATCH 3/4] fix: call a locked index unreadable, not "not an index"; advise `codegraph index` Round-2 review of #123: - R2-2/R2-4: an OperationalError opening or probing the index (lock, permissions, file gone since the isfile check) now raises IndexUnreadable, a subclass of IndexMissing so the CLIs still exit 2. Only "file is not a database" or a missing `files` table reads as "not a CodeGraph index". - R2-1: the pin-matches block message advised `codegraph init`, a no-op on an existing index; it now says `codegraph index`. - R2-5/R2-8: the pending-refs check reads the layout from PRAGMA table_info instead of sqlite's error text; the skip warning no longer repeats itself. - R2-7: WAL fixture for the read-only open. --- testgraph/db.py | 24 ++++++++++++- testgraph/integrity.py | 77 +++++++++++++++++++++--------------------- tests/test_core.py | 70 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 39 deletions(-) diff --git a/testgraph/db.py b/testgraph/db.py index 1fb4087..2f34b55 100644 --- a/testgraph/db.py +++ b/testgraph/db.py @@ -90,6 +90,17 @@ class IndexMissing(FileNotFoundError): """No CodeGraph index at the given path.""" +class IndexUnreadable(IndexMissing): + """An index file exists but cannot be read now (locked, permissions, a + hot journal a read-only open cannot roll back). A subclass so every caller + that exits 2 on IndexMissing covers it; the message must not say "not an + index", which would send the user to rebuild a healthy one.""" + + +# sqlite's busy wait on open, in seconds (sqlite3.connect's default). +BUSY_TIMEOUT_S = 5.0 + + def connect(db_path): """Open a CodeGraph index READ-ONLY, or raise IndexMissing. @@ -109,12 +120,23 @@ def connect(db_path): f"with `codegraph init` in the repo" ) uri = pathlib.Path(db_path).resolve().as_uri() + "?mode=ro" - conn = sqlite3.connect(uri, uri=True) + conn = None try: + conn = sqlite3.connect(uri, uri=True, timeout=BUSY_TIMEOUT_S) has_files = conn.execute( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'files'" ).fetchone() + except sqlite3.OperationalError as e: + # Locked, unreadable, or deleted since the isfile check: a file that + # may well be an index, so do not call it "not an index". + if conn is not None: + conn.close() + raise IndexUnreadable( + f"cannot read the CodeGraph index at {db_path} ({e}) — retry once " + f"no other process holds it, or check its permissions" + ) from e except sqlite3.DatabaseError: + # "file is not a database": not sqlite at all. has_files = None if not has_files: conn.close() diff --git a/testgraph/integrity.py b/testgraph/integrity.py index 4badd91..d9f29b7 100644 --- a/testgraph/integrity.py +++ b/testgraph/integrity.py @@ -164,49 +164,50 @@ def check(conn, repo_root, spot_checks, pending_max=0, schema_pin=None, # 1. pending unresolved refs (terminal 'failed' refs are external stdlib — # ignored; only non-terminal 'pending' indicates an incomplete index). + # + # This used to be `except Exception: pass`, justified as "older schema + # without status column" — but it also swallowed a missing table and a + # locked database, so the mid-resolution guard stopped running with no + # trace (issue #110). The layout is read from the schema, not inferred + # from sqlite's error wording. try: - pending = conn.execute( - "SELECT count(*) FROM unresolved_refs WHERE status = 'pending'" - ).fetchone()[0] - if pending > pending_max: - blocking.append( - f"{pending} unresolved refs still pending (> {pending_max}) — " - f"index is mid-resolution" - ) - except sqlite3.Error as e: - # This used to be `except Exception: pass`, justified as "older schema - # without status column" — but it also swallowed a missing table and a - # locked database, so the mid-resolution guard stopped running with no - # trace (issue #110). - # - # A missing table or column is an older schema only when the schema is - # unpinned or differs from the pin. When the pin MATCHES, the index - # claims a layout that has them, so it is broken: block. A warning would - # not do — it only demotes an EMPTY answer to UNKNOWN, and a non-empty - # one would still read as confident. - msg = str(e) - if "no such table" in msg: + columns = {r[1] for r in conn.execute("PRAGMA table_info(unresolved_refs)")} + if not columns: missing = "no unresolved_refs table" - elif "no such column" in msg: + elif "status" not in columns: missing = "no unresolved_refs.status column" else: missing = None - if missing and schema_pin is not None and found == schema_pin: - blocking.append( - f"index has {missing} although its schema matches the pin " - f"({schema_pin}) — the index is incomplete; rebuild it with " - f"`codegraph init`" - ) - elif missing: - warnings.append( - f"pending-refs check skipped: index has {missing} ({msg}) — " - f"cannot tell whether it is mid-resolution" - ) - else: - warnings.append( - f"pending-refs check failed ({type(e).__name__}: {msg}) — " - f"cannot tell whether the index is mid-resolution" - ) + pending = conn.execute( + "SELECT count(*) FROM unresolved_refs WHERE status = 'pending'" + ).fetchone()[0] + if pending > pending_max: + blocking.append( + f"{pending} unresolved refs still pending (> {pending_max}) — " + f"index is mid-resolution" + ) + except sqlite3.Error as e: + missing = None + warnings.append( + f"pending-refs check failed ({type(e).__name__}: {e}) — " + f"cannot tell whether the index is mid-resolution" + ) + # A missing table or column is an older schema only when the schema is + # unpinned or differs from the pin. When the pin MATCHES, the index claims + # a layout that has them, so it is broken: block. A warning would not do — + # it only demotes an EMPTY answer to UNKNOWN, and a non-empty one would + # still read as confident. + if missing and schema_pin is not None and found == schema_pin: + blocking.append( + f"index has {missing} although its schema matches the pin " + f"({schema_pin}) — the index is incomplete; rebuild it with " + f"`codegraph index` in the repo" + ) + elif missing: + warnings.append( + f"pending-refs check skipped: index has {missing} — cannot tell " + f"whether it is mid-resolution" + ) # 2. freshness: any tracked source newer than its index row. WARN not BLOCK # — repo-wide staleness costs precision, and blocking on it would refuse diff --git a/tests/test_core.py b/tests/test_core.py index 6ad7373..826c550 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -472,6 +472,48 @@ def test_cli_refuses_an_empty_file_with_exit_2(self): def test_memory_database_still_opens(self): dbmod.connect(":memory:").close() + def test_wal_index_opens_read_only(self): + """codegraph writes its index in WAL mode; a read-only open must still + read it.""" + path = self._sqlite_file( + "PRAGMA journal_mode=WAL; CREATE TABLE files(path TEXT);") + conn = dbmod.connect(path) + self.addCleanup(conn.close) + self.assertEqual(conn.execute("SELECT count(*) FROM files").fetchone()[0], 0) + + # --- an index that exists but cannot be read (#123 round 2) ------------- + # + # Only "file is not a database" or a missing `files` table means "not an + # index". A lock or a permission error is a real index testgraph cannot + # read right now; calling it "not a CodeGraph index ... build one" sends + # the user to rebuild a healthy index. + + def test_locked_index_is_not_called_not_an_index(self): + path = self._sqlite_file("CREATE TABLE files(path TEXT);") + holder = sqlite3.connect(path) + self.addCleanup(holder.close) + holder.execute("BEGIN EXCLUSIVE") + with unittest.mock.patch.object(dbmod, "BUSY_TIMEOUT_S", 0.1): + with self.assertRaises(dbmod.IndexUnreadable) as cm: + dbmod.connect(path) + self.assertNotIn("not a CodeGraph index", str(cm.exception)) + self.assertIn("locked", str(cm.exception)) + + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, + "root reads a chmod-0 file") + def test_unreadable_file_exits_2_not_a_traceback(self): + path = self._sqlite_file("CREATE TABLE files(path TEXT);") + os.chmod(path, 0) + self.addCleanup(os.chmod, path, 0o600) + registry = os.path.join(self.tmp, "r.json") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = sel.main(["--repo", self.tmp, "--db", path, + "--registry", registry]) + self.assertEqual(rc, 2) + self.assertIn("cannot read", err.getvalue()) + self.assertNotIn("not a CodeGraph index", err.getvalue()) + def test_clis_exit_2_with_a_message(self): registry = os.path.join(self.tmp, "r.json") for main, extra in ((sel.main, []), (exp.main, [])): @@ -580,6 +622,34 @@ def test_missing_status_column_blocks_when_schema_pin_matches(self): self.assertTrue(any("no unresolved_refs.status column" in b for b in blocking), blocking) + def test_block_message_advises_a_command_that_rebuilds(self): + """`codegraph init` is a no-op on an existing index (it prints "Use + codegraph index"), so advising it leaves the index broken.""" + self.conn.execute("DROP TABLE unresolved_refs") + blocking, _ = integrity.check(self.conn, "/nonexistent", {}, schema_pin=8) + msg = next(b for b in blocking if "unresolved_refs" in b) + self.assertIn("codegraph index", msg) + self.assertNotIn("codegraph init", msg) + + def test_missing_table_is_detected_from_the_schema_not_error_text(self): + """Block vs warn must not hinge on sqlite's English error wording: an + error on the count query of a table that EXISTS is a failed check.""" + real = self.conn + + class Odd: + def __getattr__(self, name): + return getattr(real, name) + + def execute(self, sql, *args): + if sql.lstrip().upper().startswith("SELECT COUNT(*) FROM UNRESOLVED_REFS"): + raise sqlite3.OperationalError("no such table: unresolved_refs") + return real.execute(sql, *args) + + blocking, warnings = integrity.check(Odd(), "/nonexistent", {}, schema_pin=8) + self.assertFalse(any("unresolved_refs" in b for b in blocking), blocking) + self.assertTrue(any("pending-refs check failed" in w for w in warnings), + warnings) + def test_missing_table_and_missing_column_are_worded_apart(self): self.conn.execute("DROP TABLE unresolved_refs") _, warnings = integrity.check(self.conn, "/nonexistent", {}) From da47f2dbf088d7b2c7c3818d3d97b0c413097854 Mon Sep 17 00:00:00 2001 From: Eric Minish Date: Thu, 29 Oct 2026 20:34:29 -0400 Subject: [PATCH 4/4] fix: a corrupt index is damaged, not "not an index" Round-3 review of #123 (R3-1): `database disk image is malformed` is a plain DatabaseError, so it fell through to "not a CodeGraph index ... codegraph init", and init does nothing on an existing file. Only SQLITE_NOTADB now means not-an-index; any other DatabaseError raises IndexUnreadable advising `codegraph index`. --- testgraph/db.py | 13 +++++++++++-- tests/test_core.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/testgraph/db.py b/testgraph/db.py index 2f34b55..7990f4d 100644 --- a/testgraph/db.py +++ b/testgraph/db.py @@ -135,8 +135,17 @@ def connect(db_path): f"cannot read the CodeGraph index at {db_path} ({e}) — retry once " f"no other process holds it, or check its permissions" ) from e - except sqlite3.DatabaseError: - # "file is not a database": not sqlite at all. + except sqlite3.DatabaseError as e: + if conn is not None: + conn.close() + # Only SQLITE_NOTADB means "not sqlite at all". Anything else here + # (SQLITE_CORRUPT: "database disk image is malformed") is a damaged + # index, which `codegraph init` leaves alone; only a rebuild helps. + if e.sqlite_errorcode != sqlite3.SQLITE_NOTADB: + raise IndexUnreadable( + f"cannot read the CodeGraph index at {db_path} ({e}) — it looks " + f"damaged; rebuild it with `codegraph index` in the repo" + ) from e has_files = None if not has_files: conn.close() diff --git a/tests/test_core.py b/tests/test_core.py index 826c550..81770c7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -499,6 +499,23 @@ def test_locked_index_is_not_called_not_an_index(self): self.assertNotIn("not a CodeGraph index", str(cm.exception)) self.assertIn("locked", str(cm.exception)) + def test_corrupt_index_is_not_called_not_an_index(self): + """A damaged real index raises "database disk image is malformed", a + plain DatabaseError. `codegraph init` does nothing on it; only a + rebuild helps.""" + c = sqlite3.connect(self.db) + c.execute("CREATE TABLE files(path TEXT)") + c.commit() + c.close() + with open(self.db, "r+b") as f: + f.seek(100) + f.write(b"\xff" * 300) + with self.assertRaises(dbmod.IndexUnreadable) as cm: + dbmod.connect(self.db) + self.assertNotIn("not a CodeGraph index", str(cm.exception)) + self.assertIn("codegraph index", str(cm.exception)) + self.assertNotIn("codegraph init", str(cm.exception)) + @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root reads a chmod-0 file") def test_unreadable_file_exits_2_not_a_traceback(self):