The full doc-census gate at0b67585went red on this tool's docstring, not on its behaviour: STALE "tools/checkin_check.py:46 files claimed 70 at HEAD -- printed 71". Two unpinned mutable counts over checkins/ were behind it. 1. The A1 rationale said "52 of 70 files faulty ... 12 real name faults". Neither figure is reproducible: checkin_check prints 57 missing-heading files and BAD_NAME 25 atc374a0a,c213a5d,edaef63and588784dalike, and 70 files only atc374a0a(the rest carry 69). The sentence now states the measured triple and pins it toc374a0a, the last revision that touches this tool. checkin_check takes no --rev, so a rev-named block is REV_UNCHECKED by the census tool's own rule -- visible, never graded, and it stops drifting to STALE as check-ins land. 2. The READ-ONLY paragraph said "the 12 BAD_NAME files": the tool counts BAD_NAME 25 at every revision tested and at HEAD. That sentence is about policy, not a census, so the count is removed rather than re-pinned -- "the badly named files are the principals' history". Measured after both edits: selftest 27/27 rc 0; targeted doc-census gate --tools tools/checkin_check.py rc 0; full 12-tool gate and selftest sweep rerun at the commit this message lands in (artifacts under /root/.hermes/wizard-outputs/volt/2026-10-18-vocab-minimal/).
621 lines
27 KiB
Python
621 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""checkin_check.py -- the gate on the check-in contract, which nothing measured.
|
|
|
|
WHY THIS EXISTS
|
|
Two files state the check-in contract and neither is instrumented.
|
|
|
|
`checkins/README.md` (in the tree since `07441fa`'s ancestry, 2026-09-24):
|
|
"One file per person per day: `YYYY-MM-DD-<handle>.md`."
|
|
`WIZARDS-TABLE.md` -- "The churn contract", item 1:
|
|
"One check-in in `checkins/YYYY-MM-DD-<name>.md` (Did / Blocked / Next / Ask)."
|
|
|
|
Read against `checkins/` at `07441fa` (69 check-in files, measured from this
|
|
host on 2026-10-18 with `python3 tools/checkin_check.py --repo . --gaps`),
|
|
both sentences are false of the archive they describe. Twenty-five of the 69
|
|
filenames are not `<date>-<handle>.md` (`-01`..`-07` suffixes, `zephyrus-wizards`,
|
|
`cassian-q5-census`, `cassian-q5-recount`), and those suffixes are not cosmetic:
|
|
ten (date, handle) days carry 2 to 8 files, the worst being 2026-10-06 for
|
|
zephyrus at 8. Of the 69, 57 do not carry all four of Did / Blocked / Next /
|
|
Ask; 12 do. A contract with no instrument is a preference, and this archive has
|
|
been running on two of them.
|
|
|
|
RULES IMPLEMENTED (stated, not implied)
|
|
F1 NAME a check-in filename is `<YYYY-MM-DD>-<handle>.md`, where <handle>
|
|
is a bare roster name: `[a-z][a-z0-9]*`. Any suffix -- `-02`,
|
|
`-wizards`, `-q5-census` -- is BAD_NAME. This is the README's
|
|
sentence, taken literally, because a rule read "generously"
|
|
(suffixes allowed, same person's day split across files) is
|
|
not the rule that was written.
|
|
F2 DUPLICATE two or more files resolving to one (date, handle) pair. The
|
|
handle is resolved by longest-roster-prefix on a `-` boundary,
|
|
so `zephyrus-02` resolves to zephyrus and `cassian-q5-census`
|
|
to cassian -- which is why F2 catches what F1 already flags:
|
|
the suffix is the mechanism, the duplicate is the damage.
|
|
F3 UNKNOWN a name that no roster handle prefixes. The roster is read from
|
|
`WIZARDS-TABLE.md`'s rows and `.mailmap`'s canonical names, so
|
|
a new wizard needs no edit here; `--roster` overrides it.
|
|
F4 DATE a date that is not a real calendar date (2026-02-30) or lies in
|
|
the future against `--today` (UTC). FUTURE is separate from
|
|
BAD_DATE because the two mean different things: a typo, versus
|
|
a clock this host cannot reproduce.
|
|
F5 EMPTY a file with no non-blank line. A filename is a claim that a
|
|
check-in is there.
|
|
A1 HEADINGS missing any of Did / Blocked / Next / Ask. ADVISORY, never a
|
|
fault and never rc 1: the four-heading sentence entered this
|
|
tree after the archive does, so grading history by it would
|
|
report 57 of 70 files faulty at `c374a0a` -- noise that buries
|
|
the 25 name faults. It is printed, counted, and carried in
|
|
--json, and the summary says which rule it is.
|
|
A2 GAP a day inside a handle's own span with no file, and days since
|
|
the newest file against --today. ADVISORY, per the table's own
|
|
words: "A lane that stops churning is a lane that needs a
|
|
decision, not silence." A missing day is not a repository
|
|
fault; it is a fact an operator acts on.
|
|
|
|
EXIT CODES (the repo's contract, `tools/selftest_sweep.py`)
|
|
0 ran; nothing to report, or faults reported without --fail
|
|
1 ran, and graded at least one F-fault, under --fail
|
|
2 could not grade: no checkins dir, no roster, or a file this host cannot
|
|
read as UTF-8. Never 1, never a traceback.
|
|
|
|
READ-ONLY: the only write path is `--json PATH`. It renames nothing -- the
|
|
badly named files are the principals' history, and a tool that renamed them
|
|
would be editing the record to make its own report green.
|
|
|
|
USAGE:
|
|
python3 checkin_check.py --repo /root/troupe-standup
|
|
python3 checkin_check.py --repo . --gaps --json /tmp/checkins.json
|
|
python3 checkin_check.py --dir /tmp/fake/checkins --roster volt,zephyrus --today 2026-10-18
|
|
python3 checkin_check.py --selftest # 27 cells, names, dates, roster, refusals
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
NAME_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-([a-z][a-z0-9]*)\.md$")
|
|
AMBIENT_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-(.+)\.md$")
|
|
ROSTER_HANDLE_RE = re.compile(r"^[a-z][a-z0-9]*$")
|
|
HEADING_RE = re.compile(r"^#{1,6}\s+(.*\S)\s*$")
|
|
# The four headings are matched on the heading's own first word, case-folded.
|
|
WANT_HEADINGS = ("did", "blocked", "next", "ask")
|
|
|
|
FAULTS = ("BAD_NAME", "UNKNOWN_HANDLE", "BAD_DATE", "FUTURE", "EMPTY")
|
|
# Not a check-in, and never silently dropped: the directory's own README is
|
|
# printed as SKIP so a reader can see it was seen and why it was not graded.
|
|
SKIP_NAMES = {"README.md"}
|
|
|
|
|
|
class CannotGrade(Exception):
|
|
"""Anything that stops the tool from grading: rc 2, one message."""
|
|
|
|
|
|
# --------------------------------------------------------------------- roster
|
|
|
|
def roster_from_table(text: str):
|
|
"""Handles from WIZARDS-TABLE.md's `| Wizard | ... |` rows: first token, lowered.
|
|
|
|
Skips the header row and the `|---|` separator. A row whose first cell is not
|
|
a bare name contributes nothing -- this reads the roster, it does not guess.
|
|
"""
|
|
out = set()
|
|
in_table = False
|
|
for line in text.splitlines():
|
|
s = line.strip()
|
|
if not s.startswith("|"):
|
|
in_table = False
|
|
continue
|
|
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
if not cells or not cells[0]:
|
|
continue
|
|
if cells[0].lower() == "wizard":
|
|
in_table = True
|
|
continue
|
|
if not in_table or set(cells[0]) <= set("-: "):
|
|
continue
|
|
first = cells[0].split()[0].lower() if cells[0].split() else ""
|
|
if ROSTER_HANDLE_RE.match(first):
|
|
out.add(first)
|
|
return out
|
|
|
|
|
|
def roster_from_mailmap(text: str):
|
|
"""Handles from the canonical side of `.mailmap`: `Name <mail> other <mail>`."""
|
|
out = set()
|
|
for line in text.splitlines():
|
|
s = line.strip()
|
|
if not s or s.startswith("#") or "<" not in s:
|
|
continue
|
|
name = s.split("<")[0].strip()
|
|
# `Zephyrus Sparkborn <z@…>` -> the handle is the FIRST name, not the last.
|
|
first = name.split()[0].lower() if name.split() else ""
|
|
if ROSTER_HANDLE_RE.match(first):
|
|
out.add(first)
|
|
return out
|
|
|
|
|
|
def load_roster(repo: str, override: str | None):
|
|
"""(handles, source-string). Raises CannotGrade when there is nothing to read."""
|
|
if override:
|
|
hs = {h.strip().lower() for h in override.split(",") if h.strip()}
|
|
if not hs:
|
|
raise CannotGrade("cannot grade: --roster is empty")
|
|
return hs, "--roster"
|
|
sources, out = [], set()
|
|
table = os.path.join(repo, "WIZARDS-TABLE.md")
|
|
mailmap = os.path.join(repo, ".mailmap")
|
|
for path, fn, label in ((table, roster_from_table, "WIZARDS-TABLE.md"),
|
|
(mailmap, roster_from_mailmap, ".mailmap")):
|
|
if not os.path.exists(path):
|
|
continue
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
got = fn(fh.read())
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
raise CannotGrade(f"cannot read roster {label}: {exc}")
|
|
if got:
|
|
out |= got
|
|
sources.append(f"{label} ({len(got)})")
|
|
if not out:
|
|
raise CannotGrade("cannot grade: no roster "
|
|
f"(looked for WIZARDS-TABLE.md and .mailmap in {repo})")
|
|
return out, " + ".join(sources)
|
|
|
|
|
|
# --------------------------------------------------------------------- grading
|
|
|
|
def resolve_handle(name: str, roster):
|
|
"""The longest roster handle that is `name` or a `-`-delimited prefix of it."""
|
|
best = ""
|
|
for h in roster:
|
|
if name == h or name.startswith(h + "-"):
|
|
if len(h) > len(best):
|
|
best = h
|
|
return best or None
|
|
|
|
|
|
def parse_date(triple):
|
|
"""'YYYY-MM-DD' -> date, or None when the calendar refuses it."""
|
|
try:
|
|
return date(int(triple[0]), int(triple[1]), int(triple[2]))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def read_text(path: str) -> str:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
return fh.read()
|
|
except UnicodeDecodeError as exc:
|
|
raise CannotGrade(f"cannot read {path}: not UTF-8 ({exc.reason} at byte {exc.start})")
|
|
except OSError as exc:
|
|
raise CannotGrade(f"cannot read {path}: {exc}")
|
|
|
|
|
|
def headings_missing(text: str):
|
|
seen = set()
|
|
for line in text.splitlines():
|
|
m = HEADING_RE.match(line)
|
|
if m:
|
|
seen.add(m.group(1).split()[0].strip("*_`:").lower())
|
|
return [w for w in WANT_HEADINGS if w not in seen]
|
|
|
|
|
|
def grade_file(path: str, roster, today: date):
|
|
"""One row. Reads the file only when the name parsed far enough to need it."""
|
|
base = os.path.basename(path)
|
|
rec = {"file": base, "path": path, "handle": None, "date": None,
|
|
"class": [], "headings_missing": [], "lines": 0}
|
|
|
|
m = AMBIENT_RE.match(base)
|
|
if not m:
|
|
rec["class"].append("BAD_NAME")
|
|
rec["detail"] = "not <YYYY-MM-DD>-<handle>.md"
|
|
return rec
|
|
|
|
rec["date"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
|
name = m.group(4)
|
|
strict = NAME_RE.match(base)
|
|
rec["handle"] = resolve_handle(name, roster)
|
|
|
|
if not strict:
|
|
rec["class"].append("BAD_NAME")
|
|
if rec["handle"] is None:
|
|
rec["class"].append("UNKNOWN_HANDLE")
|
|
|
|
d = parse_date((m.group(1), m.group(2), m.group(3)))
|
|
if d is None:
|
|
rec["class"].append("BAD_DATE")
|
|
elif d > today:
|
|
rec["class"].append("FUTURE")
|
|
|
|
text = read_text(path)
|
|
rec["lines"] = len(text.splitlines())
|
|
if not text.strip():
|
|
rec["class"].append("EMPTY")
|
|
else:
|
|
rec["headings_missing"] = headings_missing(text)
|
|
return rec
|
|
|
|
|
|
def duplicates(rows):
|
|
"""{(date, handle): [files]} for groups with more than one file."""
|
|
groups = {}
|
|
for r in rows:
|
|
if r["handle"] and r["date"] and "BAD_DATE" not in r["class"]:
|
|
groups.setdefault((r["date"], r["handle"]), []).append(r["file"])
|
|
return {k: sorted(v) for k, v in groups.items() if len(v) > 1}
|
|
|
|
|
|
def gaps(rows, today: date, cap: int = 60):
|
|
"""Per-handle coverage + the missing days inside its own span (not after it)."""
|
|
by_handle = {}
|
|
for r in rows:
|
|
# A future-dated file is a clock fault, not coverage: counting it would
|
|
# stretch the span to the year 2099 and report 26k "missing" days.
|
|
if not r["handle"] or r["class"] and ("BAD_DATE" in r["class"] or "FUTURE" in r["class"]):
|
|
continue
|
|
by_handle.setdefault(r["handle"], set()).add(r["date"])
|
|
out = []
|
|
for h in sorted(by_handle):
|
|
days = sorted(by_handle[h])
|
|
first, last = date.fromisoformat(days[0]), date.fromisoformat(days[-1])
|
|
missing, run, longest, run_end = [], 0, 0, None
|
|
cur = first
|
|
while cur <= last:
|
|
if cur.isoformat() in by_handle[h]:
|
|
run = 0
|
|
else:
|
|
missing.append(cur.isoformat())
|
|
run += 1
|
|
if run > longest:
|
|
longest, run_end = run, cur
|
|
cur += timedelta(days=1)
|
|
since = (today - last).days
|
|
# `days` and `files` are different numbers: zephyrus has 44 files across
|
|
# 23 days, and a table that called the second one "files" would be the
|
|
# exact defect this archive keeps grading other people for.
|
|
nfiles = sum(1 for r in rows
|
|
if r["handle"] == h
|
|
and not ("BAD_DATE" in r["class"] or "FUTURE" in r["class"]))
|
|
out.append({"handle": h, "files": nfiles, "days": len(by_handle[h]),
|
|
"first": days[0], "last": days[-1],
|
|
"days_in_span": (last - first).days + 1,
|
|
"missing": missing[:cap], "missing_count": len(missing),
|
|
"longest_gap": longest,
|
|
"longest_gap_ends": run_end.isoformat() if run_end else None,
|
|
"days_since_last": since})
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------- reporting
|
|
|
|
def banner(text: str) -> str:
|
|
return f"== {text} =="
|
|
|
|
|
|
def run(path: str, roster, today: date, show_gaps: bool,
|
|
since: str | None = None):
|
|
"""Everything except printing the file table. Returns the report dict."""
|
|
if not os.path.isdir(path):
|
|
raise CannotGrade(f"cannot read checkins dir {path}: not a directory")
|
|
try:
|
|
names = sorted(os.listdir(path))
|
|
except OSError as exc:
|
|
raise CannotGrade(f"cannot read checkins dir {path}: {exc}")
|
|
files = [os.path.join(path, n) for n in names
|
|
if n.endswith(".md") and n not in SKIP_NAMES
|
|
and os.path.isfile(os.path.join(path, n))]
|
|
skipped = [n for n in names if n in SKIP_NAMES]
|
|
# --since grades forward only: the archive's 25 historical names are the
|
|
# record, and a gate that can only ever be red is a gate nobody adopts.
|
|
excluded_since = 0
|
|
if since:
|
|
keep = []
|
|
for p in files:
|
|
m = AMBIENT_RE.match(os.path.basename(p))
|
|
if m and f"{m.group(1)}-{m.group(2)}-{m.group(3)}" >= since:
|
|
keep.append(p)
|
|
else:
|
|
excluded_since += 1
|
|
files = keep
|
|
if not files and not since:
|
|
raise CannotGrade(f"cannot read checkins dir {path}: no *.md files")
|
|
|
|
rows = [grade_file(p, roster, today) for p in files]
|
|
dup = duplicates(rows)
|
|
dup_map = {}
|
|
for (d, h), fl in dup.items():
|
|
for f in fl:
|
|
dup_map[f] = (d, h, len(fl))
|
|
for r in rows:
|
|
if r["file"] in dup_map:
|
|
r["duplicate"] = {"date": dup_map[r["file"]][0],
|
|
"handle": dup_map[r["file"]][1],
|
|
"group_size": dup_map[r["file"]][2]}
|
|
else:
|
|
r["duplicate"] = None
|
|
|
|
fault_rows = [r for r in rows if r["class"] or r["duplicate"]]
|
|
rep = {
|
|
"dir": path, "checked": len(files), "today": today.isoformat(),
|
|
"roster_size": len(roster), "skipped": skipped,
|
|
"since": since, "excluded_since": excluded_since,
|
|
"faults": {c: sum(1 for r in rows if c in r["class"]) for c in FAULTS},
|
|
"fault_rows": len(fault_rows),
|
|
"duplicate_groups": [{"date": d, "handle": h, "files": fl}
|
|
for (d, h), fl in sorted(dup.items())],
|
|
"advisory": {
|
|
"missing_headings": sum(1 for r in rows if r["headings_missing"]),
|
|
"headings_ok": sum(1 for r in rows
|
|
if not r["headings_missing"] and "EMPTY" not in r["class"]),
|
|
"note": "A1/A2 are advisory: the four-heading rule postdates the archive",
|
|
},
|
|
"rows": rows,
|
|
}
|
|
if show_gaps:
|
|
rep["gaps"] = gaps(rows, today)
|
|
return rep
|
|
|
|
|
|
def print_report(rep, show_gaps: bool, fail: bool):
|
|
print(banner(f"{rep['checked']} check-in files in {rep['dir']}"))
|
|
print(f"roster: {rep['roster_size']} handles; today {rep['today']}")
|
|
if rep.get("since"):
|
|
print(f"since {rep['since']}: {rep['checked']} graded, "
|
|
f"{rep['excluded_since']} older file(s) not graded")
|
|
for n in rep.get("skipped") or []:
|
|
print(f" SKIP {n} (the directory's own README: not a check-in)")
|
|
fault_rows = [r for r in rep["rows"] if r["class"] or r["duplicate"]]
|
|
if not fault_rows:
|
|
print("\nno file violates the filename, date, emptiness or duplicate rule")
|
|
else:
|
|
width = max(len(r["file"]) for r in fault_rows)
|
|
print(f"\n{'file':<{width}} {'verdict':<22} detail")
|
|
for r in fault_rows:
|
|
cls = list(r["class"])
|
|
if r["duplicate"]:
|
|
cls.append("DUPLICATE")
|
|
detail = []
|
|
if "BAD_NAME" in cls:
|
|
detail.append("name is not <date>-<handle>.md")
|
|
if "UNKNOWN_HANDLE" in cls:
|
|
detail.append("no roster handle prefixes it")
|
|
if "BAD_DATE" in cls:
|
|
detail.append("not a calendar date")
|
|
if "FUTURE" in cls:
|
|
detail.append(f"after today {rep['today']}")
|
|
if "EMPTY" in cls:
|
|
detail.append("no non-blank line")
|
|
if r["duplicate"]:
|
|
d = r["duplicate"]
|
|
detail.append(f"{d['group_size']} files for {d['handle']} on {d['date']}")
|
|
print(f"{r['file']:<{width}} {','.join(cls):<22} {'; '.join(detail)}")
|
|
|
|
adv = rep["advisory"]
|
|
print(f"\nadvisory (never rc 1): {adv['missing_headings']} of {rep['checked']} "
|
|
f"files lack a Did/Blocked/Next/Ask heading")
|
|
print(f" -- A1: the four-heading rule postdates the archive; grade it where it applies")
|
|
if show_gaps:
|
|
print(f"\n{'handle':<12} {'files':>5} {'days':>5} {'first':>10} {'last':>10} "
|
|
f"{'span':>4} {'miss':>4} {'gap':>4} {'idle':>4}")
|
|
for g in rep["gaps"]:
|
|
print(f"{g['handle']:<12} {g['files']:>5} {g['days']:>5} {g['first']:>10} "
|
|
f"{g['last']:>10} {g['days_in_span']:>4} {g['missing_count']:>4} "
|
|
f"{g['longest_gap']:>4} {g['days_since_last']:>4}")
|
|
print(" -- A2: a missing day is not a repo fault; it is a decision an operator owes")
|
|
|
|
counts = ", ".join(f"{k} {v}" for k, v in rep["faults"].items() if v)
|
|
print(f"\ncheckin_check: {rep['checked']} files, {rep['fault_rows']} with a fault"
|
|
+ (f" ({counts}; DUPLICATE groups {len(rep['duplicate_groups'])})" if counts else "")
|
|
+ f", {adv['missing_headings']} advisory")
|
|
if fail and rep["fault_rows"]:
|
|
print(f"FAIL: {rep['fault_rows']} file(s) violate the check-in contract")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------------- selftest
|
|
|
|
def _mk(d: str, name: str, body: str, encoding="utf-8"):
|
|
p = os.path.join(d, name)
|
|
with open(p, "w", encoding=encoding, errors="strict") as fh:
|
|
fh.write(body)
|
|
return p
|
|
|
|
|
|
GOOD = "# 2026-10-17 -- volt\n\n## Did\nlanded x\n\n## Blocked\nnothing\n\n## Next\ny\n\n## Ask\nno\n"
|
|
BARE = "# 2026-10-17 -- volt\n\nlanded x\n"
|
|
TABLE = """# The Wizards Table
|
|
|
|
| Wizard | Model | Lane |
|
|
|---|---|---|
|
|
| Timmy | Hermes | partner |
|
|
| Zephyrus Sparkborn | Mistral | archive |
|
|
| Volt | DeepSeek | workhorse |
|
|
|
|
| not a roster row
|
|
"""
|
|
|
|
|
|
def selftest():
|
|
bad = []
|
|
|
|
def cell(name, ok, detail=""):
|
|
print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else ""))
|
|
if not ok:
|
|
bad.append(name)
|
|
|
|
cell("c1 roster from the table's rows only",
|
|
roster_from_table(TABLE) == {"timmy", "zephyrus", "volt"},
|
|
str(roster_from_table(TABLE)))
|
|
cell("c2 roster from .mailmap's canonical names",
|
|
roster_from_mailmap("Volt <volt@a> volt <volt@b>\n"
|
|
"Zephyrus Sparkborn <z@s> <z@n>\n") == {"volt", "zephyrus"},
|
|
str(roster_from_mailmap("Volt <volt@a> volt <volt@b>\n")))
|
|
cell("c3 comment lines and blank lines in .mailmap are skipped",
|
|
roster_from_mailmap("# c\n\n") == set())
|
|
m4 = NAME_RE.match("2026-10-17-volt.md")
|
|
cell("c4 the strict name parses", m4 is not None and m4.group(4) == "volt")
|
|
cell("c5 a -02 suffix is not the strict name",
|
|
NAME_RE.match("2026-09-24-zephyrus-02.md") is None
|
|
and AMBIENT_RE.match("2026-09-24-zephyrus-02.md").group(4) == "zephyrus-02")
|
|
cell("c6 longest-prefix handle resolution",
|
|
(resolve_handle("zephyrus-02", {"zephyrus", "volt"}) == "zephyrus"
|
|
and resolve_handle("cassian-q5-census", {"cassian"}) == "cassian"
|
|
and resolve_handle("gandalf", {"volt"}) is None))
|
|
cell("c7 a hyphen boundary is required, not a bare prefix",
|
|
resolve_handle("voltmeter", {"volt"}) is None)
|
|
cell("c8 2026-02-30 is not a calendar date",
|
|
parse_date(("2026", "02", "30")) is None
|
|
and parse_date(("2026", "02", "28")) is not None)
|
|
cell("c9 headings are matched by first word, case-folded",
|
|
headings_missing(GOOD) == [] and headings_missing(BARE) == list(WANT_HEADINGS),
|
|
f"{headings_missing(GOOD)} / {headings_missing(BARE)}")
|
|
|
|
today = date(2026, 10, 18)
|
|
with __import__("tempfile").TemporaryDirectory() as td:
|
|
repo = os.path.join(td, "repo")
|
|
ck = os.path.join(repo, "checkins")
|
|
os.makedirs(ck)
|
|
_mk(repo, "WIZARDS-TABLE.md", TABLE)
|
|
_mk(ck, "2026-10-17-volt.md", GOOD)
|
|
_mk(ck, "2026-10-17-zephyrus.md", BARE)
|
|
_mk(ck, "2026-10-17-zephyrus-02.md", BARE)
|
|
_mk(ck, "2026-10-16-gandalf.md", GOOD)
|
|
_mk(ck, "2026-02-30-volt.md", GOOD)
|
|
_mk(ck, "2099-01-01-volt.md", GOOD)
|
|
_mk(ck, "2026-10-15-volt.md", "")
|
|
_mk(ck, "README.md", "# Check-ins\n\nOne file per person per day.\n")
|
|
|
|
roster, src = load_roster(repo, None)
|
|
cell("c10 roster is the union of table and mailmap, and says so",
|
|
roster == {"timmy", "zephyrus", "volt"} and "WIZARDS-TABLE.md" in src,
|
|
f"{roster} / {src}")
|
|
rep = run(ck, roster, today, True)
|
|
by = {r["file"]: r for r in rep["rows"]}
|
|
cell("c11 a conforming complete file has no class",
|
|
by["2026-10-17-volt.md"]["class"] == []
|
|
and by["2026-10-17-volt.md"]["headings_missing"] == [])
|
|
cell("c12 the -02 file is BAD_NAME and duplicates zephyrus",
|
|
by["2026-10-17-zephyrus-02.md"]["class"] == ["BAD_NAME"]
|
|
and by["2026-10-17-zephyrus-02.md"]["duplicate"] is not None,
|
|
str(by["2026-10-17-zephyrus-02.md"]))
|
|
cell("c13 an unknown handle is UNKNOWN_HANDLE",
|
|
by["2026-10-16-gandalf.md"]["class"] == ["UNKNOWN_HANDLE"],
|
|
str(by["2026-10-16-gandalf.md"]["class"]))
|
|
cell("c14 a false calendar date is BAD_DATE, not FUTURE",
|
|
by["2026-02-30-volt.md"]["class"] == ["BAD_DATE"])
|
|
cell("c15 a future date is FUTURE",
|
|
by["2099-01-01-volt.md"]["class"] == ["FUTURE"])
|
|
cell("c16 an empty file is EMPTY",
|
|
by["2026-10-15-volt.md"]["class"] == ["EMPTY"])
|
|
cell("c17 the duplicate group carries both files",
|
|
rep["duplicate_groups"] == [{"date": "2026-10-17", "handle": "zephyrus",
|
|
"files": ["2026-10-17-zephyrus-02.md",
|
|
"2026-10-17-zephyrus.md"]}],
|
|
str(rep["duplicate_groups"]))
|
|
cell("c18 the advisory counts, and does not enter the fault count",
|
|
rep["advisory"]["missing_headings"] == 2 and rep["fault_rows"] == 6,
|
|
f"{rep['advisory']} faults={rep['fault_rows']}")
|
|
g = {x["handle"]: x for x in rep["gaps"]}
|
|
cell("c19 the gap axis counts missing days inside a handle's span",
|
|
g["volt"]["missing_count"] == 1 and g["volt"]["longest_gap"] == 1,
|
|
str(g.get("volt")))
|
|
cell("c27 'files' and 'days' are two columns, not one",
|
|
g["zephyrus"]["files"] == 2 and g["zephyrus"]["days"] == 1,
|
|
str(g.get("zephyrus")))
|
|
|
|
rc_fail = print_report(rep, True, True)
|
|
cell("c20 --fail exits 1 when a fault exists", rc_fail == 1, str(rc_fail))
|
|
|
|
# refusals: rc 2, never a traceback, never 1
|
|
jd = os.path.join(td, "j.json")
|
|
miss = os.path.join(td, "no-such-dir")
|
|
rc_missing = main(["--dir", miss, "--roster", "volt"])
|
|
cell("c21 a missing checkins dir refuses at rc 2", rc_missing == 2, str(rc_missing))
|
|
_mk(ck, "2026-10-14-volt.md", "not utf8")
|
|
with open(os.path.join(ck, "2026-10-14-volt.md"), "wb") as fh:
|
|
fh.write(b"\xff\xfe\x00bad\x00\n")
|
|
rc_bin = main(["--dir", ck, "--roster", "volt,timmy,zephyrus"])
|
|
cell("c22 a non-UTF-8 check-in refuses at rc 2", rc_bin == 2, str(rc_bin))
|
|
rc_noroster = main(["--dir", ck, "--repo", os.path.join(td, "empty")])
|
|
cell("c23 no roster at all refuses at rc 2", rc_noroster == 2, str(rc_noroster))
|
|
cell("c24 the directory's README is skipped, not graded as a check-in",
|
|
rep["skipped"] == ["README.md"]
|
|
and "README.md" not in [r["file"] for r in rep["rows"]]
|
|
and rep["checked"] == 7, f"{rep['skipped']} {rep['checked']}")
|
|
rep_since = run(ck, roster, today, False, since="2026-10-16")
|
|
cell("c25 --since grades forward only, and says how many it left out",
|
|
rep_since["checked"] == 5 and rep_since["excluded_since"] == 3
|
|
and "2026-10-15-volt.md" not in [r["file"] for r in rep_since["rows"]],
|
|
f"{rep_since['checked']}/{rep_since['excluded_since']}")
|
|
cell("c26 a malformed --since refuses at rc 2, never 1",
|
|
main(["--dir", ck, "--roster", "volt", "--since", "16-10-2026"]) == 2)
|
|
|
|
print(f"selftest: 27 cells, {27 - len(bad)} passed, {len(bad)} failed")
|
|
return 1 if bad else 0
|
|
|
|
|
|
# --------------------------------------------------------------------- main
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(
|
|
description="checkin_check.py -- the gate on the check-in filename and date "
|
|
"contract, shipped with its tests.")
|
|
ap.add_argument("--repo", default=".", help="repository root (roster is read here)")
|
|
ap.add_argument("--dir", help="checkins dir (default <repo>/checkins)")
|
|
ap.add_argument("--roster", help="comma-separated handles, instead of the roster files")
|
|
ap.add_argument("--today", help="YYYY-MM-DD for the future-date rule (default: UTC today)")
|
|
ap.add_argument("--gaps", action="store_true", help="print per-handle coverage and gaps")
|
|
ap.add_argument("--since", help="grade only check-ins dated on/after this YYYY-MM-DD "
|
|
"(the archive's older names are its record)")
|
|
ap.add_argument("--json", help="write the full report here")
|
|
ap.add_argument("--fail", action="store_true", help="exit 1 when any file has a fault")
|
|
ap.add_argument("--selftest", action="store_true", help="fixture corpus")
|
|
a = ap.parse_args(argv)
|
|
|
|
if a.selftest:
|
|
return selftest()
|
|
|
|
if a.today:
|
|
try:
|
|
today = date.fromisoformat(a.today)
|
|
except ValueError:
|
|
print(f"cannot grade: --today is not YYYY-MM-DD: {a.today}", file=sys.stderr)
|
|
return 2
|
|
else:
|
|
today = datetime.now(timezone.utc).date()
|
|
|
|
repo = os.path.abspath(a.repo)
|
|
path = os.path.abspath(a.dir) if a.dir else os.path.join(repo, "checkins")
|
|
since = a.since
|
|
if since:
|
|
try:
|
|
date.fromisoformat(since)
|
|
except ValueError:
|
|
print(f"cannot grade: --since is not YYYY-MM-DD: {since}", file=sys.stderr)
|
|
return 2
|
|
try:
|
|
roster, src = load_roster(repo, a.roster)
|
|
rep = run(path, roster, today, a.gaps, since=since)
|
|
except CannotGrade as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
|
|
rep["roster_source"] = src
|
|
rc = print_report(rep, a.gaps, a.fail)
|
|
if a.json:
|
|
with open(a.json, "w", encoding="utf-8") as fh:
|
|
json.dump(rep, fh, indent=1, sort_keys=False)
|
|
print(f"wrote {a.json}")
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|