#!/usr/bin/env python3 """ check-systems-db-stamp — verify that server/data/systems.db is up to date. Reads the meta table from systems.db and checks that the stored SHA-1 of each generator's source file(s) matches the current file content on disk. Exit codes: 0 — DB is stamped and all generator SHAs match current sources 1 — DB is stale, has an unknown generator, or references a missing source file 2 — DB does not have a meta table (treat as unstamped — run make regen-db) Usage (called by .config/hooks/pre-push): tooling/check-systems-db-stamp Usage (interactive): tooling/check-systems-db-stamp --verbose Decision refs: #855 (generator versioning), #857 (pre-push hook) """ import re import sqlite3 import sys from pathlib import Path # semver pattern: MAJOR.MINOR.PATCH (no pre-release or build metadata) _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") REPO_ROOT = Path(__file__).resolve().parent.parent DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" # The generator-source registry and the SHA helper live in the shared module # tooling/generator_sources.py (T-1067) — the single source of truth, also # imported by the importer's stamp writer and consumed by /pr-process via # `python3 tooling/generator_sources.py --list`. sys.path.insert(0, str(Path(__file__).resolve().parent)) from generator_sources import GENERATOR_SOURCES, file_sha1 # noqa: E402 def check(verbose: bool = False) -> int: """Return exit code: 0 = fresh, 1 = stale, 2 = no meta table.""" if not DB_PATH.exists(): if verbose: print(f"check-systems-db-stamp: {DB_PATH} not found — skipping check") return 0 try: conn = sqlite3.connect(str(DB_PATH)) rows = conn.execute( "SELECT generator_name, schema_version, generator_sha FROM meta" ).fetchall() conn.close() except sqlite3.OperationalError: # meta table does not exist if verbose: print("check-systems-db-stamp: no meta table — systems.db has not been stamped") print(" Run: make regen-db") return 2 if not rows: if verbose: print("check-systems-db-stamp: meta table is empty — systems.db has not been stamped") print(" Run: make regen-db") return 2 stale: list[str] = [] unknown: list[str] = [] bad_version: list[str] = [] seen_versions: dict[str, str] = {} # generator_name -> schema_version for generator_name, schema_version, stored_sha in rows: seen_versions[generator_name] = schema_version # Validate schema_version is a semver string (#888). # Old DBs may still carry a SHA-1 hex (40-char) — flag them as stale # so the user knows to run make regen-db rather than getting a silent pass. if not _SEMVER_RE.match(schema_version or ""): bad_version.append( f"{generator_name}: schema_version='{schema_version}' " f"(expected semver like '1.0.0' — run make regen-db)" ) sources = GENERATOR_SOURCES.get(generator_name) if sources is None: # Unknown generator — fail closed (T6). A future branch adding a # new generator without registering it here must update this map # before the check will pass, preventing the "silent no-op" trap. unknown.append(generator_name) continue try: current_sha = file_sha1(*sources) except FileNotFoundError as exc: # Source file moved/deleted — explicit failure instead of # silent empty-hash (H2). print( f"check-systems-db-stamp: BROKEN — {generator_name}: {exc}", file=sys.stderr, ) return 1 if current_sha != stored_sha: stale.append(generator_name) if verbose: print( f"check-systems-db-stamp: STALE — {generator_name}" f"\n stored: {stored_sha}" f"\n current: {current_sha}" ) if bad_version: for msg in bad_version: print(f"check-systems-db-stamp: BAD schema_version — {msg}", file=sys.stderr) return 1 # All generators must agree on the same schema_version (#888 defense-in-depth). # If they differ, the DB was partially regenerated with different source trees. unique_versions = set(seen_versions.values()) if len(unique_versions) > 1: print( "check-systems-db-stamp: CONFLICT — generators disagree on schema_version:", file=sys.stderr, ) for gen, ver in sorted(seen_versions.items()): print(f" {gen}: {ver}", file=sys.stderr) print(" Run: make regen-db", file=sys.stderr) return 1 if unknown: print( "check-systems-db-stamp: UNKNOWN generator(s) in meta table: " f"{unknown}", file=sys.stderr, ) print( " Update GENERATOR_SOURCES in tooling/generator_sources.py to " "register them before pushing.", file=sys.stderr, ) return 1 if stale: if not verbose: print( "systems.db is stale — run `make regen-db` before pushing.", file=sys.stderr, ) print(f" Stale generators: {stale}", file=sys.stderr) return 1 if verbose: print(f"check-systems-db-stamp: OK — {len(rows)} generator(s) up to date") return 0 def main() -> None: verbose = "--verbose" in sys.argv or "-v" in sys.argv sys.exit(check(verbose=verbose)) if __name__ == "__main__": main()