tools (volt): the check-in contract gets an instrument -- 25 of 69 names violate README, 10 days carry 2-8 files, 57 of 69 heading-incomplete
This commit is contained in:
1 parent
07441fa557
commit
588784d7ea
2 files changed
+591
-2
No files matched your search
@@ -0,0 +1,579 @@
|
||||
#!/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 52 of 70 files faulty -- noise that buries the 12 real
|
||||
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
|
||||
12 BAD_NAME 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 # 24 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")
|
||||
ADVISORY = ("MISSING_HEADINGS", "GAP")
|
||||
# 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
|
||||
out.append({"handle": h, "files": 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(repo: str, path: str, roster, today: date, show_gaps: bool, quiet=False):
|
||||
"""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]
|
||||
if not files:
|
||||
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,
|
||||
"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']}")
|
||||
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} {'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['first']:>10} {g['last']:>10} "
|
||||
f"{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(repo, 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")))
|
||||
|
||||
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']}")
|
||||
|
||||
print(f"selftest: 24 cells, {24 - 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("--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")
|
||||
try:
|
||||
roster, src = load_roster(repo, a.roster)
|
||||
rep = run(repo, path, roster, today, a.gaps)
|
||||
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())
|
||||
+12
-2
@@ -124,12 +124,16 @@ READ_AXIS_UNIT = 2
|
||||
FATAL_READ = ("TRACEBACK", "RC_NOT_2", "SILENT", "NO_TOOL")
|
||||
NO_PROBE = "NO_PROBE" # advisory: a tool with no probe defined
|
||||
|
||||
# {nope} = a path that does not exist; {bin} = a file that is not UTF-8.
|
||||
# {nope} = a path that does not exist; {bin} = a file that is not UTF-8;
|
||||
# {bindir} = a directory holding one non-UTF-8 *.md (added 2026-10-18 with
|
||||
# checkin_check.py, whose input is a directory of UTF-8 files -- a {bin} file
|
||||
# would only have proven it refuses a non-directory).
|
||||
READ_PROBES = (
|
||||
("append_only_audit.py", ["--repo", "{nope}"]),
|
||||
("append_only_owners.py", ["{nope}/audit.json"]),
|
||||
("census_row_delta.py", ["{nope}/a.json", "{nope}/b.json"]),
|
||||
("check_lint.py", ["--log", "{nope}/log.txt"]),
|
||||
("checkin_check.py", ["--dir", "{nope}", "--roster", "volt"]),
|
||||
("citation_hash_class.py", ["--repo", "{nope}"]),
|
||||
("citation_resolve_check.py", ["--repo", "{nope}"]),
|
||||
("doc_census_check.py", ["--repo", "{nope}", "--no-run"]),
|
||||
@@ -141,6 +145,7 @@ READ_PROBES = (
|
||||
NOT_UTF8_PROBES = (
|
||||
("append_only_owners.py", ["{bin}"]),
|
||||
("census_row_delta.py", ["{bin}", "{bin}"]),
|
||||
("checkin_check.py", ["--dir", "{bindir}", "--roster", "volt"]),
|
||||
("queue_claim_check.py", ["--queue", "{bin}"]),
|
||||
("queue_index.py", ["--queue", "{bin}", "--check", "{bin}"]),
|
||||
)
|
||||
@@ -377,8 +382,13 @@ def run_read_probe(path: str, argv, timeout: int):
|
||||
binpath = os.path.join(td, "not-utf8.bin")
|
||||
with open(binpath, "wb") as fh:
|
||||
fh.write(b"\xff\xfe\x00binary\x00\n")
|
||||
bindir = os.path.join(td, "not-utf8-dir")
|
||||
os.makedirs(bindir, exist_ok=True)
|
||||
with open(os.path.join(bindir, "2026-01-01-volt.md"), "wb") as fh:
|
||||
fh.write(b"\xff\xfe\x00binary\x00\n")
|
||||
nope = os.path.join(td, "there-is-no-such-path")
|
||||
real = [a.replace("{nope}", nope).replace("{bin}", binpath) for a in argv]
|
||||
real = [a.replace("{nope}", nope).replace("{bin}", binpath)
|
||||
.replace("{bindir}", bindir) for a in argv]
|
||||
try:
|
||||
p = subprocess.run([sys.executable, path] + real, cwd=td,
|
||||
capture_output=True, text=True, errors="replace",
|
||||
|
||||
Reference in new issue
Block a user