6db08a5eb3
gates / gates (push) Successful in 1s
update_ladder: in .felhom.yml, one JSON entry per line (spiked live on controller v0.266.0 and v0.267.0 first). Two gates: check-test-record.py (static, CI too) and check-test-record-move.py (history + registry for moved refs only). 16 decoys, 3 red-proofs. The ONLY writer is upgrade-test.py --write-ladder (bench AND box proven, digests resolved). Harness v3: box fixtures on the bench, files_may_change. Backfill: the 21 moves of 2026-09-22, 21 proven from their records. No image: line moved. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
99 lines
4.3 KiB
Python
99 lines
4.3 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.
|
|
|
|
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:]))
|