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)

This commit is contained in:
volt committed 2026-10-19 14:30:49 +00:00
1 parent 700013bce8
commit 82cea5a55f
4 files changed
+549 -9

No files matched your search

+13
View File
@@ -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`. **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
**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-<recipient>-<N>.md`, where `<recipient>` 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 <missing>`) and malformed (`--dir <file-that-is-not-utf8>`, 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
+9 -9
View File
@@ -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 | | 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 | | 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 | | 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 ## 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 | | Q50 | 6990 | 6990 | 46 | Q49 | BULLET+BOLD | BOLD-LEAD |
| Q51 | 7106 | 7106 | 37 | Q49 | BULLET+BOLD | BOLD-LEAD | | Q51 | 7106 | 7106 | 37 | Q49 | BULLET+BOLD | BOLD-LEAD |
| Q52 | 7167 | 7167 | 90 | 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 | | 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`. **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", "form": "BOLD-LEAD",
"id": "Q53", "id": "Q53",
"line": 7292, "line": 7292,
"refs": 126, "refs": 127,
"section": "Q49", "section": "Q49",
"shape": "BULLET+BOLD" "shape": "BULLET+BOLD"
}, },
@@ -236,9 +236,9 @@ The block below is the parse this document renders, byte for byte. `--check` com
"end_line": 8, "end_line": 8,
"line": 1 "line": 1
}, },
"queue_bytes": 1292031, "queue_bytes": 1298235,
"queue_lines": 10134, "queue_lines": 10147,
"queue_sha256": "ae3b05cb5f244a69d9a44c5887598592d28f798d1d1f9fc72f02b27e56d99bf9", "queue_sha256": "13913398b5b33743d0603c865dcdca2b79f9b74ceb71dbe2c64ef56ef47522fd",
"sections": [ "sections": [
{ {
"bytes": 3139, "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" "title": "The runtime-resolved root's live write path: name it, reproduce it, then close it"
}, },
{ {
"bytes": 399770, "bytes": 405974,
"end_line": 10134, "end_line": 10147,
"id": "Q45", "id": "Q45",
"line": 7594, "line": 7594,
"lines": 2541, "lines": 2554,
"role": "opening", "role": "opening",
"role_detail": "", "role_detail": "",
"status": null, "status": null,
+522
View File
@@ -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-<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())
+5
View File
@@ -166,6 +166,7 @@ READ_PROBES = (
("queue_claim_check.py", ["--queue", "{nope}/q.md"]), ("queue_claim_check.py", ["--queue", "{nope}/q.md"]),
("queue_index.py", ["--queue", "{nope}/q.md", "--check", "{nope}/i.md"]), ("queue_index.py", ["--queue", "{nope}/q.md", "--check", "{nope}/i.md"]),
("queue_append_audit.py", ["--repo", "{nope}"]), ("queue_append_audit.py", ["--repo", "{nope}"]),
("reply_check.py", ["--dir", "{nope}"]),
("selftest_sweep.py", ["--repo", "{nope}"]), ("selftest_sweep.py", ["--repo", "{nope}"]),
("verify.py", ["--repo", "{nope}"]) ("verify.py", ["--repo", "{nope}"])
) )
@@ -220,6 +221,10 @@ MALFORMED_PROBES = (
2, "UNVERIFIABLE"), 2, "UNVERIFIABLE"),
("queue_append_audit.py", "not-a-repo", ["--repo", "{notrepo}"], ("queue_append_audit.py", "not-a-repo", ["--repo", "{notrepo}"],
2, "not a git repository"), 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}"], ("selftest_sweep.py", "not-a-repo", ["--repo", "{notrepo}"],
2, "no tools discovered"), 2, "no tools discovered"),
# verify.py's own two legs are the repo it is asked to grade; a directory # verify.py's own two legs are the repo it is asked to grade; a directory