#!/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. 4. every entry but the newest carries its OWN definition at `steps/.yml`, whose images per service are EXACTLY that entry's `to` (`09` §6.4 part 5: the box pins that file, one step per press). The name alone is not the fact: the file's own `image:` lines are read. 4b. ... and its own `.felhom.yml` at `steps/.felhom.yml` (R-664), with a healthcheck when the template has one. 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)) for i, e in enumerate(entries[:-1]): to = e.get("to") if not isinstance(to, dict) or not to: continue # already convicted by check_entry sp = os.path.join(app_dir, ladder.step_file(to)) if not os.path.isfile(sp): problems.append("entry %d of %d has no definition at %s — the box climbs one step at a time " "and needs this step's own compose file" % (i + 1, len(entries), ladder.step_file(to))) continue got = ladder.images_in(open(sp, encoding="utf-8").read()) if got != to: problems.append("%s names %s, but entry %d's `to` is %s" % (ladder.step_file(to), got, i + 1, to)) # rule 4b (R-664, 2026-09-24): the step's own .felhom.yml — the box judges the step with it. smp = os.path.join(app_dir, ladder.step_meta_file(to)) if not os.path.isfile(smp): problems.append("entry %d of %d has no %s — the box would judge the step with the head's probe (R-664)" % (i + 1, len(entries), ladder.step_meta_file(to))) elif "healthcheck:" not in open(smp, encoding="utf-8").read() and "healthcheck:" in open(os.path.join(app_dir, ".felhom.yml"), encoding="utf-8").read(): problems.append("%s carries no healthcheck: while the template does — not a real step file" % ladder.step_meta_file(to)) 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:]))