Files
app-catalog-felhom.eu/scripts/check-test-record.py
T
admin cf7cf8456f
gates / gates (push) Successful in 2s
steps/<key>.felhom.yml: gate rule 4b, writer, backfill (8) (R-664)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 11:45:34 +02:00

123 lines
6.1 KiB
Python

#!/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/<step_key(to)>.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/<step_key(to)>.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:]))