182 lines
6.6 KiB
Python
182 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
# Ledger — independent cross-derivation + comparison gate for the
|
|
# check-in census.
|
|
#
|
|
# Re-derives the census from the raw check-in markdown with a SECOND, naive
|
|
# implementation (plain string splitting, no section-regex) and compares it
|
|
# field-for-field against the persisted tables produced by
|
|
# extract_checkin_census_2026_10_28.py. Exit code IS the verdict:
|
|
# 0 = census matches the raw files on every row and headline invariant
|
|
# 1 = mismatch found
|
|
#
|
|
# A re-run of `python3 wizard-work/ledger/2026-10-28-checkin-census/CHECK`
|
|
# re-derives everything from the current tree, so the check stays green only
|
|
# while the census honestly reflects `checkins/`.
|
|
import csv
|
|
import datetime
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
BASE = Path("/root/troupe-standup")
|
|
CHECKS = BASE / "checkins"
|
|
OUT = BASE / "wizard-work/ledger/2026-10-28-checkin-census"
|
|
EXTRACTOR = OUT / "extract_checkin_census_2026_10_28.py"
|
|
TSV = OUT / "checkin-census-2026-10-28.tsv"
|
|
JSON = OUT / "checkin-census-2026-10-28.json"
|
|
MD = OUT / "checkin-census-2026-10-28.md"
|
|
EXCLUDE = {"README.md"}
|
|
|
|
|
|
def naive_parse(path):
|
|
"""Second, independent implementation to cross-derive the census.
|
|
|
|
Uses plain string-finding on the raw text (no section regex, no line
|
|
splitting). Bounds the `## Did` section the same way the extractor does
|
|
(up to the next `## ` heading) and reports did_items / did_chars on that
|
|
exact slice, so both implementations must agree on every row.
|
|
"""
|
|
import re as _re
|
|
name = path.name
|
|
body = name[:-3]
|
|
m = _re.match(r"^(\d{4}-\d{2}-\d{2})-(.+)$", body)
|
|
if not m:
|
|
return None
|
|
date_str, signer = m.group(1), m.group(2)
|
|
try:
|
|
datetime.date.fromisoformat(date_str)
|
|
except ValueError:
|
|
return None
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
t = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
def section(name):
|
|
# A heading may sit at file start (no preceding newline).
|
|
if t.startswith(name):
|
|
idx = 0
|
|
else:
|
|
idx = t.find("\n" + name)
|
|
if idx == -1:
|
|
return ""
|
|
idx += 1 # "\n" at idx; "##" one char later
|
|
nxt = t.find("\n## ", idx)
|
|
return t[idx:nxt if nxt != -1 else len(t)]
|
|
|
|
has_done = t.find("\n## Did") != -1 or t.startswith("## Did")
|
|
has_blocked = t.find("\n## Blocked") != -1 or t.startswith("## Blocked")
|
|
has_next = t.find("\n## Next") != -1 or t.startswith("## Next")
|
|
has_ask = t.find("\n## Ask") != -1 or t.startswith("## Ask")
|
|
did = section("## Did")
|
|
|
|
did_items = 0
|
|
for line in did.split("\n"):
|
|
s2 = line.strip()
|
|
if s2.startswith("### "):
|
|
rest = s2[len("### "):]
|
|
if len(rest) > 1 and rest[0].isdigit() and rest[1] == ".":
|
|
did_items += 1
|
|
did_chars = len(did)
|
|
return {"date": date_str, "signer": signer, "file": name,
|
|
"has_done": has_done, "has_blocked": has_blocked,
|
|
"has_next": has_next, "has_ask": has_ask,
|
|
"did_items": did_items, "did_chars": did_chars}
|
|
|
|
|
|
def rederive():
|
|
rows = []
|
|
for path in sorted(CHECKS.iterdir()):
|
|
if path.name in EXCLUDE or not path.is_file():
|
|
continue
|
|
r = naive_parse(path)
|
|
if r is not None:
|
|
rows.append(r)
|
|
return rows
|
|
|
|
|
|
def load_tsv():
|
|
with open(TSV, encoding="utf-8", newline="") as f:
|
|
return list(csv.DictReader(f, delimiter="\t"))
|
|
|
|
|
|
def load_json():
|
|
return json.loads(JSON.read_text(encoding="utf-8"))
|
|
|
|
|
|
def same(a, b):
|
|
return a == b
|
|
|
|
|
|
def main():
|
|
errors = []
|
|
# 1) The extractor must run clean.
|
|
proc = subprocess.run([sys.executable, str(EXTRACTOR), str(CHECKS), str(OUT)],
|
|
capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
print("CHECK FAIL: extractor exited {!r}\nstd{}out{}end{}".format(
|
|
proc.returncode, "out:", proc.stdout, "end:", proc.stderr))
|
|
return 1
|
|
|
|
# 2) Independent re-derivation vs persisted TSV.
|
|
expect = rederive()
|
|
actual = load_tsv()
|
|
if len(expect) != len(actual):
|
|
errors.append("row count: re-derived {} != persisted {}".format(len(expect), len(actual)))
|
|
for i in range(max(len(expect), len(actual))):
|
|
e = expect[i] if i < len(expect) else None
|
|
a = actual[i] if i < len(actual) else None
|
|
if e is None or a is None:
|
|
errors.append("row {} side mismatch".format(i))
|
|
continue
|
|
if e["date"] != a["date"]:
|
|
errors.append("row {} date {} != {}".format(i, e["date"], a["date"]))
|
|
if e["signer"] != a["signer"]:
|
|
errors.append("row {} signer {} != {}".format(i, e["signer"], a["signer"]))
|
|
if e["has_done"] != (a["has_done"] == "True"):
|
|
errors.append("row {} has_done mismatch".format(i))
|
|
if e["did_items"] != int(a["did_items"]):
|
|
errors.append("row {} did_items {} != {}".format(i, e["did_items"], a["did_items"]))
|
|
if e["did_chars"] != int(a["did_chars"]):
|
|
errors.append("row {} did_chars mismatch".format(i))
|
|
|
|
# 3) Headline invariants from the JSON.
|
|
d = load_json()
|
|
h = d.get("headline", {})
|
|
if h.get("files") != len(expect):
|
|
errors.append("headline.files {} != re-derived {}".format(h.get("files"), len(expect)))
|
|
if h.get("skipped_non_dated") != 1:
|
|
errors.append("headline.skipped != 1")
|
|
signers = {r["signer"] for r in expect}
|
|
if h.get("signers") != len(signers):
|
|
errors.append("headline.signers {} != re-derived {}".format(h.get("signers"), len(signers)))
|
|
|
|
# Re-derived latest per signer must match JSON's latest_per_signer.
|
|
latest = {}
|
|
for r in expect:
|
|
cur = latest.get(r["signer"])
|
|
if cur is None or r["date"] > cur["date"]:
|
|
latest[r["signer"]] = {"signer": r["signer"], "date": r["date"], "file": r["file"]}
|
|
j_latest = {x["signer"]: x for x in h.get("latest_per_signer", [])}
|
|
if set(latest) != set(j_latest):
|
|
errors.append("latest signers mismatch: {} != {}".format(set(latest), set(j_latest)))
|
|
for s, rec in latest.items():
|
|
if s not in j_latest:
|
|
continue
|
|
if j_latest[s]["latest_date"] != rec["date"] or j_latest[s]["file"] != rec["file"]:
|
|
errors.append("latest {} mismatch: json {} vs re-derived {}".format(
|
|
s, j_latest[s], rec))
|
|
|
|
if errors:
|
|
print("CHECK FAIL:")
|
|
for e in errors:
|
|
print(" -", e)
|
|
return 1
|
|
print("CHECK PASS: {} dated check-ins, {} signers, 1 non-dated skipped, "
|
|
"all rows + headline re-derive clean".format(len(expect), len(signers)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|