Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion testgraph/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion testgraph/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 44 additions & 10 deletions testgraph/integrity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""
import hashlib
import os
import sqlite3

from . import db as dbmod

Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion testgraph/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading