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:
1 parent
700013bce8
commit
82cea5a55f
4 files changed
+549
-9
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user