#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check-test-record.py — catalog gate: every ladder is well-formed and agrees with its compose. python3 scripts/check-test-record.py # every template python3 scripts/check-test-record.py navidrome romm # only these python3 scripts/check-test-record.py --root DIR ... # another checkout (decoy tests) `09-update-architecture.md` §3 decision 13 (the test decides, not the tag) and §6.4 part 4. This is the STATIC half: no git history, no network — so it runs in the pre-push hook AND in CI, whose clone is shallow (the R-452 gap that skips every history gate there). Its twin `check-test-record-move.py` is the half that needs history: an image MOVE must add a proven entry. WHAT IT CHECKS, per template that carries `update_ladder:` (format and field rules: ladder.py): 1. every line of the block is a one-line JSON entry, and every entry is well-formed (verdict proven|unrecorded only; a sha256 digest per `to` service; the memory watch's peak on any entry not backfilled; marks.memory_tight agrees with the peak); 2. the ladder is CONTINUOUS — each entry's `from` is the previous entry's `to`; 3. the NEWEST entry's `to` is EXACTLY the compose's current image per service. This is the fact that makes the rule hold without history: a compose moved without a new entry no longer matches its ladder's head, whoever pushed it and however. A template WITHOUT a ladder passes here — it has never been moved since the gate existed, and its first move is refused by the twin unless that move brings the first entry. Exit 0 clean · 1 convicted · 2 inconclusive (nothing to read). """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ladder # noqa: E402 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def check_app(app_dir): """List of problem sentences for one template directory ([] = clean or no ladder).""" fy = os.path.join(app_dir, ".felhom.yml") comp = os.path.join(app_dir, "docker-compose.yml") if not os.path.exists(fy) or not os.path.exists(comp): return [] entries, _raws, errors = ladder.parse(open(fy, encoding="utf-8").read()) if not entries and not errors: return [] problems = list(errors) for i, e in enumerate(entries): for p in ladder.check_entry(e): problems.append("entry %d: %s" % (i + 1, p)) for i in range(1, len(entries)): if entries[i].get("from") != entries[i - 1].get("to"): problems.append("entry %d's `from` is not entry %d's `to` — the ladder has a gap" % (i + 1, i)) if entries: current = ladder.images_in(open(comp, encoding="utf-8").read()) head = entries[-1].get("to") if head != current: diff = sorted(set(current.items()) ^ set((head or {}).items())) problems.append("the compose's images are not the ladder's newest step — an image moved " "without a test record, or the record names other refs: %s" % diff) return problems def main(argv): root = ROOT if "--root" in argv: i = argv.index("--root") root = argv[i + 1] argv = argv[:i] + argv[i + 2:] only = [a for a in argv if not a.startswith("-")] tdir = os.path.join(root, "templates") apps = sorted(only) if only else sorted(os.listdir(tdir)) seen = with_ladder = 0 convicted = [] for app in apps: d = os.path.join(tdir, app) if not os.path.isdir(d): print("test-record: no such template %r" % app) return 2 seen += 1 text = open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read() if os.path.exists( os.path.join(d, ".felhom.yml")) else "" if "update_ladder:" in text: with_ladder += 1 probs = check_app(d) if probs: convicted.append(app) for p in probs: print("FAIL %s: %s" % (app, p)) if seen == 0: print("TEST-RECORD GATE INCONCLUSIVE: no template read") return 2 print("test-record gate — %d template(s) read, %d carry a ladder, %d convicted" % (seen, with_ladder, len(convicted))) return 1 if convicted else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))