diff --git a/testgraph/db.py b/testgraph/db.py index e6ea641..7990f4d 100644 --- a/testgraph/db.py +++ b/testgraph/db.py @@ -5,6 +5,8 @@ not a transitive symbol closure (verified on honeyslate 2026-07-17). """ import collections +import os +import pathlib import sqlite3 # Edge kinds that carry a behavior-reachability signal, walked in REVERSE @@ -84,8 +86,73 @@ def weak_edge_reason(cap): return CAP_REASONS.get(cap) +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): - conn = sqlite3.connect(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} — check the path, or build one " + f"with `codegraph init` in the repo" + ) + uri = pathlib.Path(db_path).resolve().as_uri() + "?mode=ro" + 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 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() + 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.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..d9f29b7 100644 --- a/testgraph/integrity.py +++ b/testgraph/integrity.py @@ -12,6 +12,7 @@ """ import hashlib import os +import sqlite3 from . import db as dbmod @@ -163,17 +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 Exception: - pass # older schema without status column + columns = {r[1] for r in conn.execute("PRAGMA table_info(unresolved_refs)")} + if not columns: + missing = "no unresolved_refs table" + elif "status" not in columns: + missing = "no unresolved_refs.status column" + else: + missing = None + 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/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..81770c7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -404,6 +404,145 @@ 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 _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() + + 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)) + + 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): + 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, [])): + 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 +601,113 @@ 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) + + # 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_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", {}) + 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 + + 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`