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