From 588784d7ea04e3195e31103cb3aba17a7a000c01 Mon Sep 17 00:00:00 2001 From: Volt Date: Sun, 18 Oct 2026 00:19:25 +0000 Subject: [PATCH] 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 --- tools/checkin_check.py | 579 ++++++++++++++++++++++++++++++++++++++++ tools/selftest_sweep.py | 14 +- 2 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 tools/checkin_check.py diff --git a/tools/checkin_check.py b/tools/checkin_check.py new file mode 100644 index 0000000..8d2128a --- /dev/null +++ b/tools/checkin_check.py @@ -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-.md`." + `WIZARDS-TABLE.md` -- "The churn contract", item 1: + "One check-in in `checkins/YYYY-MM-DD-.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 `-.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 `-.md`, where + 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 other `.""" + 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 ` -> 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 -.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 -.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 \n" + "Zephyrus Sparkborn \n") == {"volt", "zephyrus"}, + str(roster_from_mailmap("Volt volt \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 /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()) diff --git a/tools/selftest_sweep.py b/tools/selftest_sweep.py index 94cca61..67412a2 100644 --- a/tools/selftest_sweep.py +++ b/tools/selftest_sweep.py @@ -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",