test record: an image move must carry its proof (09 decision 13, part 4)
gates / gates (push) Successful in 1s
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
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user