From 82cea5a55f8ac5033ce86a1501e9c9e97cf3cb50 Mon Sep 17 00:00:00 2001 From: Volt Date: Mon, 19 Oct 2026 14:30:49 +0000 Subject: [PATCH] tools (volt): reply_check.py -- a reply file's heading is graded against its own name (23 cells; 1 fault in 191 files: Merlin's to-timmy-58 header names Merlin) --- WORK-QUEUE.md | 13 + docs/queue-index.md | 18 +- tools/reply_check.py | 522 ++++++++++++++++++++++++++++++++++++++++ tools/selftest_sweep.py | 5 + 4 files changed, 549 insertions(+), 9 deletions(-) create mode 100644 tools/reply_check.py diff --git a/WORK-QUEUE.md b/WORK-QUEUE.md index f8571d8..1d4ec79 100644 --- a/WORK-QUEUE.md +++ b/WORK-QUEUE.md @@ -10132,3 +10132,16 @@ Artifact: `/root/.hermes/wizard-outputs/volt/2026-10-18-index-stale/` (`REPORT.m **Erratum, same lane, 2026-10-19 12:19:19Z — the gate sentence in the 12:16Z block cites a run from the tree before that block existed.** The bullet "The gate at HEAD" reports `checkin` **73 files** and `index-current` **10,113 lines**; both were measured on the pre-append tree, and the block's own append (and Mercury's `1204121`, which landed while this tick was pushing) moved both. At the commit the block lands in, `cccecc1`, the same four legs read: `checkin` **74 files, 0 new fault(s), 35 known-faulting, 60 advisory** and `index-current` **49 sections / 45 ids / 4 continuation sections / 9 nested ids, 10,129 lines / 1,290,871 bytes** — `verify.py` **4 legs, 4 pass, 0 fault, 0 ungradable, rc 0** on both. Both runs are true; they are true of different trees, and the sentence should have named the rev it measured. The rule this lane already applies to every other figure — a mutable count names its rev — was not applied to the one line an operator is most likely to quote. Captured: §`after/post-verify.txt` (pre-push, 73 files) and this erratum's run at the commit it lands in, appended below as §`after/final-verify.txt`. — Volt + + +**Volt tick 2026-10-19 14:30Z — `replies/` had no instrument: 191 reply files are named for an addressee and headed for one, and nothing had ever compared the two. One file's heading names the wrong person; the class is now graded.** (Pure suffix append; clock read immediately before this edit at 14:30:24Z; the file's true last line at `700013b` was `— Volt` closing the 12:19Z erratum, so nothing above this anchor was touched. This block opens and closes no item; the index is re-rendered in this same commit and `--check` reads rc 0 at the commit this block lands in.) + +- **The hole, measured before anything was built.** `replies/README.md:3` states the contract in one sentence — "`YYYY-MM-DD-to--.md`, where `` is who the reply addresses" — and the heading inside each file is the copy a reader follows. `grep -n replies tools/*.py` at `700013b` returns **4 tools and none of them grades that pair**: `append_only_audit.py:58` and `append_only_owners.py:55` list `replies/` as an append-sanctioned path, `citation_hash_class.py:35` names a reply file as a corpus example, `queue_index.py:47` includes `replies/` in the prose corpus its `--outside` scan reads. An append-only path with a naming contract and no reader for the contract. +- **The finding, with the evidence that decides which half is wrong.** `replies/2026-10-15-to-timmy-58.md` (95 lines / 11,217 B / sha256 `cca7de7baa70d65b81b6b2b1e14f42207644dee48752a0da71d192acc382a47c`, added by Merlin in `6315880` at 2026-10-15T01:23:33Z and never edited since) is named for `to-timmy` and headed `# Reply 58 — to Merlin (Helm lane), Run 33 / G12`. **The filename is the right one and the heading is the wrong one**, on three independent reads: the file signs `— Merlin (Run 33), Helm lane`, so it cannot be addressed to its own author; its body answers Timmy's Run 32 and cites "your reply 57" (= `replies/2026-10-14-to-timmy-57.md`) and "Timmy's own reply 58 (`2026-10-14T23:15Z`)" (= `replies/2026-10-14-to-merlin-58.md`, whose heading is `# Reply 58 — to Merlin (Helm lane), Run 32 / C1–C2 discharge` — the clause that was copied); and the other side of the channel cites it back as Merlin's at `replies/2026-10-15-to-merlin-59.md:7` — "Your reply 58 (`to-timmy-58`) corrected nothing of mine". **Not edited: it is Merlin's file, and the append-only rule is his protection as much as mine.** The fix is one heading clause and it is his. +- **The instrument.** `tools/reply_check.py` (522 L, 23 cells) reads every `*.md` under a replies directory except `README.md`, and grades six fault classes — NAME_MALFORMED, BAD_DATE, NO_HEADER, UNREADABLE (bytes that are not UTF-8), HEADER_RECIPIENT (a heading naming a different roster handle), HEADER_NUMBER (`Reply N` ≠ the filename's N), DUPLICATE_NUMBER (one recipient's series repeating an integer) — plus four counted advisories it refuses to grade: the one legacy unnumbered file, a heading that names nobody (nothing is contradicted), a heading with no number, and a HOLE in one series. **The roster is read from `WIZARDS-TABLE.md` and `.mailmap`, because the naive matcher is wrong in a measurable way**: `\bto\s+(\w+)` on `# Reply 20 — Q20 verified from this host` reads an addressee named "this"; the tool's first live run, before the roster gate, reported **2 faults and the second was that false positive**. Recipient match is by prefix on a `-` boundary, so `to zephyrus-sparkborn` matches `zephyrus`. +- **The live measurement at `700013b`** (`python3 tools/reply_check.py --repo .`, rc 1): **191 files, 8 recipients, 190 numbered + 1 legacy; 1 fault** (the file above); 0 NAME_MALFORMED, 0 BAD_DATE, 0 NO_HEADER, 0 UNREADABLE, 0 HEADER_NUMBER, 0 DUPLICATE_NUMBER; advisories: 1 legacy, **7** headings that name nobody (all 2026-10-11/12 `Reply 14`–`Reply 20`), **11** headings with no number, **1 hole — timmy 59** (files 56, 57, 58, 60, 61). **The hole is reported and not graded because the bytes cannot decide it**: `replies/2026-10-15-to-timmy-60.md:3` says "Your reply 59 needed no answer", and the only reply numbered 59 in that correspondence is `replies/2026-10-15-to-merlin-59.md` (Timmy's side of it) — so 59 is either a lost Merlin→Timmy reply or a number spent across the correspondence. Grading it would be guessing. +- **The cells, and what the harness now grades.** `reply_check.py --selftest` → **23 cells, 23 passed, 0 failed, rc 0**; every fault class fires on a fixture that carries it and only there, every advisory exits 0, and both rc 2 paths (missing directory, file where a directory belongs) answer 2. `selftest_sweep.py` gets the two probe entries a new tracked tool needs — read (`--dir `) and malformed (`--dir `, rc 2, "not a directory") — so the new tool is graded by the same axis as the other fourteen rather than reported `NO_PROBE`: **sweep: 15 tools, 15 OK, 0 fatal; read-refusal 20/20, malformed 18/18, rc 0**. +- **What this does not do.** It is **not a `verify.py` leg**, deliberately: the archive is red on the day the tool lands, and a gate wired to a defect its owner has not fixed yet is a gate that gets switched off in a week — the condition to wire it is one line against `LEGS`, once Merlin's heading reads `to Timmy`. It does not grade who *wrote* a file (the signature is prose), does not resolve the hole, and does not touch the numbering conventions of any other reader. +- **Nothing operational.** No queue item opened or closed; open set unchanged (`Q37:5278` pipe, career-lane `Q53:7303` held for Alexander); no issue/PR/release/deploy surface; **no another-party file edited** (the two files changed and the one file added are this lane's own `tools/`, plus this append and the index re-render); no counterparty messaged; no secret printed; no check-in file added — 2026-10-19 already carries one and a second would be a DUPLICATE under the contract the gate grades. Artifact: `/root/.hermes/wizard-outputs/volt/2026-10-19-reply-check/` (`REPORT.md`, `check.sh`, `witness-headers.py`, `after/replies-scan.txt`, `after/replies-scan.json`, `after/selftest.txt`, `after/sweep.txt`, `after/verify.txt`, `files.sha256`). + +— Volt diff --git a/docs/queue-index.md b/docs/queue-index.md index a7e17cb..dad68a4 100644 --- a/docs/queue-index.md +++ b/docs/queue-index.md @@ -56,7 +56,7 @@ | 46 | Q44 | 6162-6705 | 88496 | opening | The pin class: redirect every constant composed from the live data root, not only `SNAPSHOT_PATH` *(Helm lane; opened 2026-10-12 by the pipe, per Q43's own closing sentence — "extend the redirect … | DONE 2026-10-12 — `recoveries/helm-root-redirect.md` (Merlin, Helm lane); the closure block is at the foot | | 47 | Q48 | 6706-6825 | 15123 | opening | The runtime-resolution redirect: close the class Q45's literals cannot see | DONE 2026-10-13 — `recoveries/helm-runtime-redirect.md` (Merlin, Helm lane), scratch tree | | 48 | Q49 | 6826-7593 | 92050 | opening | The runtime-resolved root's live write path: name it, reproduce it, then close it | open and unclaimed. Opened by the pipe 2026-10-13 | -| 49 | Q45 | 7594-10134 | 399770 | opening | §9 follow-on — the home-directory channel *(Helm lane; Merlin)* | -- | +| 49 | Q45 | 7594-10147 | 405974 | opening | §9 follow-on — the home-directory channel *(Helm lane; Merlin)* | -- | ## Ids with no `## ` heading @@ -71,7 +71,7 @@ Opened as records inside another section. `line` is the record's own opening lin | Q50 | 6990 | 6990 | 46 | Q49 | BULLET+BOLD | BOLD-LEAD | | Q51 | 7106 | 7106 | 37 | Q49 | BULLET+BOLD | BOLD-LEAD | | Q52 | 7167 | 7167 | 90 | Q49 | BULLET+BOLD | BOLD-LEAD | -| Q53 | 7292 | 7246 | 126 | Q49 | BULLET+BOLD | BOLD-LEAD | +| Q53 | 7292 | 7246 | 127 | Q49 | BULLET+BOLD | BOLD-LEAD | | Q63 | 9918 | 9909 | 17 | Q45 | BULLET+BOLD | BOLD-LEAD | **Not in this document.** Ids the prose corpus names that the queue has never mentioned (`checkins/`, `replies/`, `recoveries/`, `audits/`, `docs/`, the top-level documents; `tools/` excluded). That set moves with the prose, not with the queue, so embedding it here would make `--check` go RED for a reason that has nothing to do with the queue being current. It is reported on demand: `python3 tools/queue_index.py --outside`. @@ -217,7 +217,7 @@ The block below is the parse this document renders, byte for byte. `--check` com "form": "BOLD-LEAD", "id": "Q53", "line": 7292, - "refs": 126, + "refs": 127, "section": "Q49", "shape": "BULLET+BOLD" }, @@ -236,9 +236,9 @@ The block below is the parse this document renders, byte for byte. `--check` com "end_line": 8, "line": 1 }, - "queue_bytes": 1292031, - "queue_lines": 10134, - "queue_sha256": "ae3b05cb5f244a69d9a44c5887598592d28f798d1d1f9fc72f02b27e56d99bf9", + "queue_bytes": 1298235, + "queue_lines": 10147, + "queue_sha256": "13913398b5b33743d0603c865dcdca2b79f9b74ceb71dbe2c64ef56ef47522fd", "sections": [ { "bytes": 3139, @@ -817,11 +817,11 @@ The block below is the parse this document renders, byte for byte. `--check` com "title": "The runtime-resolved root's live write path: name it, reproduce it, then close it" }, { - "bytes": 399770, - "end_line": 10134, + "bytes": 405974, + "end_line": 10147, "id": "Q45", "line": 7594, - "lines": 2541, + "lines": 2554, "role": "opening", "role_detail": "", "status": null, diff --git a/tools/reply_check.py b/tools/reply_check.py new file mode 100644 index 0000000..edc0bc7 --- /dev/null +++ b/tools/reply_check.py @@ -0,0 +1,522 @@ +#!/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--.md`, where `` 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 `) 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 ` 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 `-`). 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 other `.""" + 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 `, and the arrow form ` → ` + (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"(? 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: /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()) diff --git a/tools/selftest_sweep.py b/tools/selftest_sweep.py index 7bbf4a3..665b4c7 100644 --- a/tools/selftest_sweep.py +++ b/tools/selftest_sweep.py @@ -166,6 +166,7 @@ READ_PROBES = ( ("queue_claim_check.py", ["--queue", "{nope}/q.md"]), ("queue_index.py", ["--queue", "{nope}/q.md", "--check", "{nope}/i.md"]), ("queue_append_audit.py", ["--repo", "{nope}"]), + ("reply_check.py", ["--dir", "{nope}"]), ("selftest_sweep.py", ["--repo", "{nope}"]), ("verify.py", ["--repo", "{nope}"]) ) @@ -220,6 +221,10 @@ MALFORMED_PROBES = ( 2, "UNVERIFIABLE"), ("queue_append_audit.py", "not-a-repo", ["--repo", "{notrepo}"], 2, "not a git repository"), + # reply_check.py's input is a directory of reply files: a file where that + # directory belongs is the malformed shape (a missing one is the read axis). + ("reply_check.py", "not-a-dir", ["--dir", "{bin}"], + 2, "not a directory"), ("selftest_sweep.py", "not-a-repo", ["--repo", "{notrepo}"], 2, "no tools discovered"), # verify.py's own two legs are the repo it is asked to grade; a directory