Files
troupe-standup/tools/reply_check.py
T

523 lines
22 KiB
Python

#!/usr/bin/env python3
"""reply_check.py -- does every reply file's own header agree with its own name?
WHY THIS EXISTS
`replies/README.md` states the naming contract in one sentence: a reply is
`YYYY-MM-DD-to-<recipient>-<N>.md`, where `<recipient>` is who the reply
ADDRESSES. The header inside the file is the copy a reader actually reads,
and nothing in this archive has ever compared the two.
The failure this grades is the archive's own most-recurring defect, logged in
its ledger as a class rather than an accident: "the identifiers printed for
Zephyrus's newest file are the 11th's ... Filename and *Blocked on* text were
right, the identifiers wrong" (`citation_resolve_check.py`'s docstring quotes
it at length). A reply filed to one party whose header addresses another puts
a whole thread under the wrong name, and the file is still readable, still
append-only, and still green to every instrument this repo owns -- none of
them reads `replies/`.
WHAT IT MEASURES (read-only; the only write path is --json)
Every `*.md` under --dir except `README.md` is one ROW. The filename gives
(date, recipient, number); the first `# ` heading in the file gives what the
file says about itself. Five classes are graded:
NAME_MALFORMED the filename is neither the numbered contract form nor
the one unnumbered legacy form -- the row is skipped
by the header rules, because there is no name to check
it against.
BAD_DATE the date half is not a real calendar date.
NO_HEADER no `# ` heading in the first --head-lines lines.
UNREADABLE the file is not UTF-8 text, so no heading can be read
from it at all. FAULT: this archive's files are text.
HEADER_RECIPIENT the heading names a recipient (`to <name>`) that is not
the filename's recipient. FAULT: the two copies of the
addressee disagree, and the reader follows the heading.
HEADER_NUMBER the heading says `Reply <N>` and N is not the
filename's N. FAULT: the same file would be cited by
two different numbers.
DUPLICATE_NUMBER two files in one recipient's series carry the same N.
FAULT: "the next free integer in that recipient's own
series" cannot produce the same integer twice.
Three more are counted and printed, and are ADVISORY by construction -- each
is a stated consequence of history that predates the naming rule, not a
defect this tool can attribute to a writer:
LEGACY_UNNUMBERED the one legacy form (no `-<N>`). Its number is implicit
in its position, so numbering rules skip it.
NO_RECIPIENT_IN_HEADING a heading that names no addressee at all (the
early "`# Reply 14 -- Q31 ...`" house style). Nothing
contradicts the filename; the heading is silent.
NO_NUMBER_IN_HEADING a heading that names the addressee but not
"Reply N" (the "`# 2026-10-07 -- to zephyrus (...)`"
style).
HOLE a gap in one recipient's number series. Advisory
on purpose: a hole is indistinguishable, from the
bytes, between a lost reply and a number spent in the
other direction of the same correspondence. The
archive has a measured case of each shape of confusion
(see the report that introduced this tool), so the
tool names the hole and does not grade it.
Recipient matching is by prefix on a `-` boundary, so a heading that says
`to zephyrus-sparkborn` matches the filename's `zephyrus`; a heading that
says `to merlin` does not match `to-timmy`.
EXIT CONTRACT (the repo's, shared by every tool in tools/)
0 ran clean: no FAULT class fired
1 ran and graded at least one fault
2 could not grade (missing directory, or a path that is not a directory)
SELFTEST
`--selftest` grades 23 cells, building every fixture in a fresh temporary
directory: each fault class fires on a fixture that carries it and only the
classes that belong there (a wrong addressee, a wrong number, a duplicate
number, an impossible date, a missing heading, a malformed name, non-UTF-8
bytes), the advisory classes stay advisory and exit 0 (legacy form, hole,
heading that names nobody), the prefix match accepts a hyphenated handle,
README.md is not a row, and both rc 2 paths (missing directory, file where a
directory belongs) answer 2. A rule that stops firing and a rule that fires
everywhere both fail a cell.
"""
import argparse
import contextlib
import datetime
import io
import json
import os
import re
import sys
import tempfile
NAME_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-to-([a-z][a-z0-9]*)-(\d{2,})\.md$")
LEGACY_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-to-([a-z][a-z0-9]*)\.md$")
HEADING_TO = re.compile(r"\bto\s+([A-Za-z][A-Za-z0-9-]*)")
HEADING_ARROW = re.compile(r"(?:→|->)\s*([A-Za-z][A-Za-z0-9-]*)")
HEADING_NUM = re.compile(r"\breply\s+(\d{1,4})\b", re.IGNORECASE)
ROSTER_HANDLE_RE = re.compile(r"^[a-z][a-z0-9]*$")
FAULTS = ("NAME_MALFORMED", "BAD_DATE", "NO_HEADER", "UNREADABLE",
"HEADER_RECIPIENT", "HEADER_NUMBER", "DUPLICATE_NUMBER")
ADVISORIES = ("LEGACY_UNNUMBERED", "NO_RECIPIENT_IN_HEADING",
"NO_NUMBER_IN_HEADING", "HOLE")
def _real_date(y, m, d):
try:
datetime.date(int(y), int(m), int(d))
return True
except ValueError:
return False
def roster_from_table(text):
"""Handles from WIZARDS-TABLE.md's `| Wizard | ... |` rows: first token, lowered."""
out, in_table = set(), 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):
"""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
first = s.split("<")[0].split()
if first and ROSTER_HANDLE_RE.match(first[0].lower()):
out.add(first[0].lower())
return out
def load_roster(repo, override):
"""(handles, source). The roster exists only to tell a person's name from an
ordinary word after `to` -- `to this host` must not read as an addressee."""
if override:
hs = {h.strip().lower() for h in override.split(",") if h.strip()}
if hs:
return hs, "--roster"
out, sources = set(), []
for fname, fn in (("WIZARDS-TABLE.md", roster_from_table),
(".mailmap", roster_from_mailmap)):
path = os.path.join(repo, fname)
if not os.path.exists(path):
continue
try:
with open(path, encoding="utf-8") as fh:
got = fn(fh.read())
except (OSError, UnicodeDecodeError):
continue
if got:
out |= got
sources.append(fname)
return out, "+".join(sources) or "(none)"
def _recipient_matches(named, wanted):
"""Prefix match on a '-' boundary, both directions (a heading may be
narrower than the filename or fuller than it)."""
named = named.lower()
wanted = wanted.lower()
return (named == wanted or named.startswith(wanted + "-")
or wanted.startswith(named + "-"))
def _known(cand, roster):
return any(_recipient_matches(cand, handle) for handle in roster)
def heading_addressee(heading, roster):
"""The person a heading says the file is addressed to, or None.
Two forms are read: `to <name>`, and the arrow form `<author> → <name>`
(whose addressee is the arrow's target). A candidate counts only if it is a
roster handle -- otherwise `to this host` would read as an addressee named
"this", which is how a naive matcher invents a contradiction.
"""
for m in HEADING_ARROW.finditer(heading):
cand = m.group(1).lower()
if _known(cand, roster):
return cand
for m in HEADING_TO.finditer(heading):
cand = m.group(1).lower()
if _known(cand, roster):
return cand
return None
def read_heading(path, head_lines):
"""The file's first `# ` heading inside the first `head_lines` lines, or
None. Blank lines are skipped; a non-blank line before any heading ends the
search (a file that opens with prose has no heading where one belongs).
False means the file could not be decoded as UTF-8 at all."""
try:
with open(path, encoding="utf-8") as fh:
for i, line in enumerate(fh):
if i >= head_lines:
return None
s = line.strip()
if not s:
continue
return s if s.startswith("# ") else None
except UnicodeDecodeError:
return False
except OSError:
return None
return None
def scan(dirpath, head_lines=10, roster=()):
"""(rows, faults, advisories, series, holes) for one replies directory.
rows: one dict per file, in sorted filename order, each with `file`,
`date`, `recipient`, `number` (None for the legacy form), `heading`, and
`classes` (a list). Nothing is written.
"""
rows = []
for name in sorted(os.listdir(dirpath)):
if not name.endswith(".md") or name == "README.md":
continue
path = os.path.join(dirpath, name)
if not os.path.isfile(path):
continue
row = {"file": name, "date": None, "recipient": None, "number": None,
"heading": None, "classes": []}
m = NAME_RE.match(name)
legacy = False
if m:
y, mo, d, row["recipient"], num = m.groups()
row["number"] = int(num)
else:
m = LEGACY_RE.match(name)
if not m:
row["classes"].append("NAME_MALFORMED")
rows.append(row)
continue
y, mo, d, row["recipient"] = m.groups()
legacy = True
row["date"] = "%s-%s-%s" % (y, mo, d)
if not _real_date(y, mo, d):
row["classes"].append("BAD_DATE")
if legacy:
row["classes"].append("LEGACY_UNNUMBERED")
heading = read_heading(path, head_lines)
unreadable = heading is False
row["heading"] = heading if isinstance(heading, str) else None
if unreadable:
row["classes"].append("UNREADABLE")
elif heading is None:
row["classes"].append("NO_HEADER")
else:
named = heading_addressee(heading, roster)
if named is None:
row["classes"].append("NO_RECIPIENT_IN_HEADING")
elif not _recipient_matches(named, row["recipient"]):
row["classes"].append("HEADER_RECIPIENT")
if row["number"] is not None:
hnum = HEADING_NUM.search(heading)
if hnum is not None:
if int(hnum.group(1)) != row["number"]:
row["classes"].append("HEADER_NUMBER")
elif not re.search(r"(?<!\d)%d(?!\d)" % row["number"], heading):
# No "Reply N", and the filename's N is not in the heading
# in any form: the heading is silent on the number.
row["classes"].append("NO_NUMBER_IN_HEADING")
rows.append(row)
series = {}
for row in rows:
if row["number"] is None or "NAME_MALFORMED" in row["classes"]:
continue
series.setdefault(row["recipient"], []).append(row["number"])
holes = {}
for who, nums in series.items():
dup = sorted({n for n in nums if nums.count(n) > 1})
for n in dup:
for row in rows:
if row["number"] == n and row["recipient"] == who:
row["classes"].append("DUPLICATE_NUMBER")
missing = [n for n in range(min(nums), max(nums) + 1) if n not in nums]
if missing:
holes[who] = missing
faults = [r for r in rows if any(c in FAULTS for c in r["classes"])]
advisories = [r for r in rows if any(c in ADVISORIES for c in r["classes"])]
return rows, faults, advisories, series, holes
def report(path, rows, faults, advisories, series, holes, roster_source, as_json=None):
counts = {}
for row in rows:
for c in row["classes"]:
counts[c] = counts.get(c, 0) + 1
print("reply_check -- %d file(s) under %s (roster: %s)"
% (len(rows), path, roster_source))
for cls in FAULTS + ADVISORIES:
n = counts.get(cls, 0)
if n:
print(" %-24s %d" % (cls, n))
for row in rows:
if cls in row["classes"]:
extra = ""
if row["heading"]:
extra = " | heading: %s" % row["heading"][:96]
print(" %s%s" % (row["file"], extra))
else:
print(" %-24s 0" % cls)
print("series: %d recipient(s), %d numbered file(s), %d hole(s)%s"
% (len(series), sum(len(v) for v in series.values()), len(holes),
(": " + ", ".join("%s %s" % (k, v) for k, v in sorted(holes.items())))
if holes else ""))
print("faults: %d advisories: %d" % (len(faults), len(advisories)))
if as_json:
with open(as_json, "w", encoding="utf-8") as fh:
json.dump({"path": path, "files": len(rows), "counts": counts,
"holes": holes, "rows": rows}, fh, indent=2, sort_keys=True)
fh.write("\n")
print("json: %s" % as_json)
return 1 if faults else 0
def _write(d, name, text):
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
fh.write(text)
def selftest():
"""Fixture cells. Each cell builds a directory, runs scan()/main(), and
asserts the class list AND the exit code -- both directions, so a rule that
stops firing and a rule that fires everywhere both fail."""
cells = []
ROSTER = {"volt", "merlin", "timmy", "zephyrus"}
ROSTER_ARG = "volt,merlin,timmy,zephyrus"
def cell(label, ok):
cells.append((label, bool(ok)))
def classes(d, name):
rows, _, _, _, _ = scan(d, roster=ROSTER)
for row in rows:
if row["file"] == name:
return row["classes"]
return None
def rc(argv):
# The fixtures' own reports are noise here; the cell is the verdict.
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
try:
return run(argv + ["--roster", ROSTER_ARG])
except SystemExit as e: # pragma: no cover - defensive
return e.code
root = tempfile.mkdtemp(prefix="reply-selftest-")
# 1 clean numbered file, heading agrees in name and number
d = os.path.join(root, "clean")
os.makedirs(d)
_write(d, "2026-10-01-to-zephyrus-02.md", "# Reply 2 -- to Zephyrus\n\nbody\n")
_write(d, "README.md", "not a reply\n")
cell("clean file has no class", classes(d, "2026-10-01-to-zephyrus-02.md") == [])
cell("README.md is not a row", len(scan(d)[0]) == 1)
cell("clean run exits 0", rc(["--dir", d]) == 0)
# 2 header names the other party -- the money rule
d = os.path.join(root, "wrongparty")
os.makedirs(d)
_write(d, "2026-10-02-to-timmy-58.md",
"# Reply 58 -- to Merlin (Helm lane), Run 33\n\nbody\n")
cell("wrong addressee is HEADER_RECIPIENT",
classes(d, "2026-10-02-to-timmy-58.md") == ["HEADER_RECIPIENT"])
cell("wrong addressee exits 1", rc(["--dir", d]) == 1)
# 3 heading's number disagrees with the filename's
d = os.path.join(root, "wrongnum")
os.makedirs(d)
_write(d, "2026-10-02-to-merlin-07.md", "# Reply 6 -- to Merlin\n\nbody\n")
cell("wrong number is HEADER_NUMBER",
classes(d, "2026-10-02-to-merlin-07.md") == ["HEADER_NUMBER"])
cell("wrong number exits 1", rc(["--dir", d]) == 1)
# 4 a hyphenated handle in the heading still matches its prefix
d = os.path.join(root, "handle")
os.makedirs(d)
_write(d, "2026-10-03-to-zephyrus-58.md",
"# 2026-10-03 -- to zephyrus-sparkborn (reply 58)\n\nbody\n")
cell("hyphenated handle matches", classes(d, "2026-10-03-to-zephyrus-58.md") == [])
# 5 duplicate number within one recipient's series
d = os.path.join(root, "dup")
os.makedirs(d)
_write(d, "2026-10-04-to-volt-03.md", "# Reply 3 -- to Volt\n")
_write(d, "2026-10-05-to-volt-03.md", "# Reply 3 -- to Volt\n")
got = classes(d, "2026-10-05-to-volt-03.md")
cell("duplicate number is DUPLICATE_NUMBER", got == ["DUPLICATE_NUMBER"])
cell("duplicate exits 1", rc(["--dir", d]) == 1)
# 6 a hole is named, never graded
d = os.path.join(root, "hole")
os.makedirs(d)
_write(d, "2026-10-04-to-volt-03.md", "# Reply 3 -- to Volt\n")
_write(d, "2026-10-05-to-volt-05.md", "# Reply 5 -- to Volt\n")
_, faults, _, _, holes = scan(d)
cell("hole is reported with its number", holes == {"volt": [4]})
cell("hole is not a fault", not faults and rc(["--dir", d]) == 0)
# 7 the one legacy unnumbered form is advisory, not a fault
d = os.path.join(root, "legacy")
os.makedirs(d)
_write(d, "2026-09-24-to-zephyrus.md", "# 2026-09-24 -- to zephyrus\n\nbody\n")
cell("legacy form is advisory",
classes(d, "2026-09-24-to-zephyrus.md") == ["LEGACY_UNNUMBERED"])
cell("legacy form exits 0", rc(["--dir", d]) == 0)
# 8 a real-calendar check, not a shape check
d = os.path.join(root, "baddate")
os.makedirs(d)
_write(d, "2026-02-30-to-volt-04.md", "# Reply 4 -- to Volt\n")
cell("impossible date is BAD_DATE",
classes(d, "2026-02-30-to-volt-04.md") == ["BAD_DATE"])
# 9 no heading where one belongs
d = os.path.join(root, "noheader")
os.makedirs(d)
_write(d, "2026-10-06-to-volt-06.md", "Reply 6 -- to Volt\n\nbody\n")
cell("prose before any heading is NO_HEADER",
classes(d, "2026-10-06-to-volt-06.md") == ["NO_HEADER"])
# 10 a name that is neither form
d = os.path.join(root, "badname")
os.makedirs(d)
_write(d, "2026-10-06-to-volt-6.md", "# Reply 6 -- to Volt\n")
cell("unpadded number is NAME_MALFORMED",
classes(d, "2026-10-06-to-volt-6.md") == ["NAME_MALFORMED"])
cell("NAME_MALFORMED exits 1", rc(["--dir", d]) == 1)
# 11 an early heading that names nobody: advisory, never a fault
d = os.path.join(root, "norecipient")
os.makedirs(d)
_write(d, "2026-10-11-to-merlin-14.md",
"# Reply 14 -- Q31 (Run 6) picked up from this host\n")
cell("heading naming nobody is advisory",
classes(d, "2026-10-11-to-merlin-14.md") == ["NO_RECIPIENT_IN_HEADING"])
cell("advisory exits 0", rc(["--dir", d]) == 0)
# 12 bytes that are not UTF-8: a fault with the decoding named, never a
# silent "no heading"
d = os.path.join(root, "binary")
os.makedirs(d)
with open(os.path.join(d, "2026-10-07-to-volt-07.md"), "wb") as fh:
fh.write(b"# Reply 7 \xff\xfe to Volt\n")
cell("non-UTF-8 bytes are UNREADABLE",
classes(d, "2026-10-07-to-volt-07.md") == ["UNREADABLE"])
# 13 the read contract: a path that cannot be read is rc 2, never 0/1
cell("missing directory exits 2",
rc(["--dir", os.path.join(root, "nope")]) == 2)
_write(root, "afile", "not a directory\n")
cell("a file where a directory belongs exits 2",
rc(["--dir", os.path.join(root, "afile")]) == 2)
failed = [label for label, ok in cells if not ok]
print("selftest: %d cells, %d passed, %d failed" %
(len(cells), len(cells) - len(failed), len(failed)))
for label in failed:
print(" FAILED: %s" % label)
return 1 if failed else 0
def run(argv):
ap = argparse.ArgumentParser(
description="grade every reply file's heading against its own filename")
ap.add_argument("--repo", default=".",
help="the repository the roster is read from (default: .)")
ap.add_argument("--dir", default=None,
help="the replies directory (default: <repo>/replies)")
ap.add_argument("--roster", default=None,
help="comma-separated handles, overriding WIZARDS-TABLE.md and .mailmap")
ap.add_argument("--head-lines", type=int, default=10,
help="how many leading lines may hold the heading (default 10)")
ap.add_argument("--json", help="write the full row report here")
args = ap.parse_args(argv)
dirpath = args.dir if args.dir is not None else os.path.join(args.repo, "replies")
if not os.path.exists(dirpath):
print("reply_check: cannot read replies: %s: no such directory" % dirpath,
file=sys.stderr)
return 2
if not os.path.isdir(dirpath):
print("reply_check: cannot read replies: %s: not a directory" % dirpath,
file=sys.stderr)
return 2
roster, source = load_roster(args.repo, args.roster)
rows, faults, advisories, series, holes = scan(dirpath, args.head_lines, roster)
return report(dirpath, rows, faults, advisories, series, holes, source, args.json)
def main():
argv = sys.argv[1:]
if "--selftest" in argv:
return selftest()
return run(argv)
if __name__ == "__main__":
sys.exit(main())