Files
LithosAnanake/v4/tools/mkpost.py
T
rajamesandClaude Opus 5.5 2930349bbf feat(v4.0.0): POST passes and is part of the boot; U* and U/MOD
The boot is now nucleus, forth79.4th, POST, prompt, on both products.

- forth79.4th: U* and U/MOD, the capsule's first colon definitions.  They
  are in the FORTH-79 Required Word Set and neither v3 nor v4 had them.
- post79.4th: 550 cases, 126 of the 130 required words.  443 are v3's with
  v3's result.  The rest follow three rulings (2026-10-05): address-
  dependent cases are checked for count, not value; where v3 departs from
  FORTH-79 the standard's result is expected; words v3 has no case for get
  cases written by hand.  v4/tools/post79_rules.py holds each exception
  with its reason and docs/v4.0.0/POST79.md lists them all.
- every case starts from an empty stack, DECIMAL and FORTH DEFINITIONS
- the boot requires POST's tally line with fail=0

Verified: tests=550 pass=550 fail=0 and identical PARITY lines on hosted
amd64, aarch64 and riscv64 (make -C v4 hosted-check) and on bare metal,
clean qemu with STARFORTH_V4=1, on the same three (logs/20261005-1619xx,
-1621xx, -1625xx).  A U/MOD broken on purpose fails five cases and stops
the boot.  make -C v4 test passes.

Not shown: all words but those two are still assembled, so POST has so far
tested the assembled words.  Nothing was typed at a bare-metal prompt.
Open: PAD 42 OVER ! faults on v4 (D-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:28:01 -04:00

572 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""mkpost.py -- write capsules/v4/post79.4th from v3's POST cases.
v4/tools/mkpost.py [--v3 PATH] [--report FILE]
A development tool, run when the cases change; its output is committed and
reviewed like any source. docs/v4.0.0/NUCLEUS.md section 6.
What it does:
1. Reads v3's case tables, v3/src/test_runner/modules/*.c, in the order
v3's own POST runs them. A case is a word, a name, a line of FORTH and
whether an error is expected.
2. Keeps the cases for words of the FORTH-79 Required Word Set.
3. Runs every kept case, in order, in one session of the hosted v3 binary,
and records what v3 did: an error or not, the data stack, and what was
printed. v3 is the reference.
4. Writes the harness and one case after another as a .4th capsule:
T{ DUP.basic name the case, start clean
T| 5 DUP . . a line of the case, its output captured
-> 5 5 }T 0 0 T= v3's stack; v3's output: length, checksum
or, for a case v3 ended with an error, ->ERR in place of the last
line.
A case is left out, and listed in the report, when: its word reads the
keyboard; it forgets a word of the system or damages it on purpose; v3 could
not finish it in one
session; or one of its lines cannot be cut to fit a 64-character block
line.
"""
import argparse
import os
import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import post79_rules as rules # noqa: E402
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
MODULE_DIR = os.path.join(ROOT, "v3", "src", "test_runner", "modules")
OUTPUT = os.path.join(ROOT, "capsules", "v4", "post79.4th")
FIRST_BLOCK = 7000
LINE_MAX = 64
# v3's POST order (v3/src/test_runner/test_runner.c, test_modules[]), the
# modules that test FORTH-79 words.
MODULES = [
"stack_words_test.c", "return_stack_words_test.c", "memory_words_test.c",
"arithmetic_words_test.c", "logical_words_test.c",
"mixed_arithmetic_words_test.c", "double_words_test.c",
"format_words_test.c", "string_words_test.c", "io_words_test.c",
"block_words_test.c", "dictionary_words_test.c",
"dictionary_manipulation_words_test.c", "vocabulary_words_test.c",
"system_words_test.c", "defining_words_tests.c", "control_words_test.c",
]
# The FORTH-79 Standard's Required Word Set, by the standard's own groups.
# Written from the standard's word list; check it against the document
# before relying on it for a claim of compliance.
REQUIRED = """
DUP DROP SWAP OVER ROT PICK ROLL ?DUP DEPTH >R R> R@
< = > 0< 0= 0> D< U< NOT
+ - * / MOD /MOD */ */MOD 1+ 1- 2+ 2- MAX MIN ABS NEGATE D+ DNEGATE
U* U/MOD AND OR XOR
@ ! C@ C! ? +! MOVE CMOVE FILL
DO LOOP +LOOP I J LEAVE IF ELSE THEN BEGIN UNTIL WHILE REPEAT EXIT EXECUTE
CR EMIT SPACE SPACES TYPE COUNT -TRAILING KEY EXPECT QUERY WORD
BASE DECIMAL . U. CONVERT <# # #S HOLD SIGN #>
LIST LOAD SCR BLOCK UPDATE BUFFER SAVE-BUFFERS EMPTY-BUFFERS
: ; VARIABLE CONSTANT VOCABULARY CREATE DOES>
CONTEXT CURRENT FORTH DEFINITIONS ' FIND FORGET
, ALLOT ." IMMEDIATE LITERAL STATE [ ] COMPILE [COMPILE]
( >IN ABORT QUIT HERE PAD BLK 79-STANDARD
""".split()
# Words whose cases wait for the keyboard, which a boot cannot type at.
KEYBOARD = {"KEY", "EXPECT", "QUERY"}
# ---- the harness: blocks FIRST_BLOCK up, FORTH-79 and the two nucleus hooks ----
HARNESS = r"""
( post79.4th -- POST for the FORTH-79 Required Word Set on v4. )
( Generated by v4/tools/mkpost.py from v3's POST cases: do not )
( edit. docs/v4.0.0/NUCLEUS.md section 6. )
VARIABLE T# VARIABLE TPASS VARIABLE TFAIL VARIABLE TOPEN
VARIABLE TBAD VARIABLE TWANT VARIABLE TLEN VARIABLE TSUM
VARIABLE TDEP
CREATE TBUF 64 ALLOT CREATE TSTK 16 ALLOT CREATE TNAME 8 ALLOT
0 T# ! 0 TPASS ! 0 TFAIL ! 0 TOPEN !
: TB ( -- baddr ) TBUF 4 * ;
: TN ( -- baddr ) TNAME 4 * ;
( T-EMIT is given every character a case prints: the first 255 )
( are kept, all are counted and summed. )
: T-EMIT ( c -- )
255 AND DUP TSUM @ 31 * + 16777215 AND TSUM !
TLEN @ 255 < IF TB TLEN @ + C! ELSE DROP THEN 1 TLEN +! ;
=====
: T-CLEAR ( i*x -- ) BEGIN DEPTH WHILE DROP REPEAT ;
: T. ( n -- ) 0 <# #S #> TYPE ;
( what a failing case printed, and the stack it left )
: T-SHOW ( -- )
." out<" TB TLEN @ 255 MIN TYPE ." > stack<"
TDEP @ BEGIN DUP WHILE 1- DUP TSTK + @ . REPEAT DROP ." >" ;
( the verdict on the case that is open, if one is )
: T-CLOSE ( -- )
TOPEN @ IF
(CATCH) @ -1 = TWANT @ = 0= IF 1 TBAD ! THEN
TBAD @ IF 1 TFAIL +!
." POST FAIL: " TN COUNT TYPE T-SHOW CR
ELSE 1 TPASS +! THEN
0 TOPEN ! 0 (CATCH) !
THEN ;
=====
( T{ name begin a case: clean stack, decimal, FORTH )
: T{ ( i*x -- ) T-CLOSE T-CLEAR
DECIMAL [COMPILE] FORTH DEFINITIONS
BL WORD COUNT 31 MIN DUP TN C! TN 1+ SWAP CMOVE
1 T# +! 1 TOPEN ! 0 TBAD ! 0 TWANT ! 0 TLEN ! 0 TSUM !
0 TDEP ! 1 (CATCH) ! ;
( T| ... a line of the case, what it prints captured )
: T| ' T-EMIT (EMIT-HOOK) ! ; IMMEDIATE
: T-END ( -- ) 0 (EMIT-HOOK) ! DECIMAL ;
( -> ... the case is over: set its stack aside, top first )
: -> ( i*x -- ) T-END 0
BEGIN DEPTH 1 > WHILE
DUP 16 < IF SWAP OVER TSTK + ! ELSE SWAP DROP 1 TBAD ! THEN
1+ REPEAT
16 MIN TDEP ! ;
=====
( ->ERR the case is over, and must have ended in an error )
: ->ERR ( i*x -- ) T-END -1 TWANT ! T-CLEAR ;
( ... }T the stack the case must have left, in order )
: }T ( i*x -- )
DEPTH TDEP @ = 0= IF 1 TBAD ! T-CLEAR EXIT THEN
0 BEGIN DEPTH 1 > WHILE
SWAP OVER TSTK + @ = 0= IF 1 TBAD ! THEN 1+ REPEAT DROP ;
( n }TD only how many values the case must have left )
: }TD ( n -- ) TDEP @ = 0= IF 1 TBAD ! THEN ;
( len sum T= what the case must have printed )
: T= ( len sum -- ) TSUM @ = 0= IF 1 TBAD ! THEN
TLEN @ = 0= IF 1 TBAD ! THEN ;
=====
( the tally; a failure is an error, which ends the boot )
: T-REPORT ( -- ) T-CLOSE 0 (CATCH) ! DECIMAL
." PARITY:V4_POST tests=" T# @ T. ." pass=" TPASS @ T.
." fail=" TFAIL @ T. CR
TFAIL @ IF -1 NODE-ERROR ! THEN ;
"""
# ---- reading v3's case tables ----------------------------------------------
TOKEN = re.compile(r'''
\s+ | /\*.*?\*/ | //[^\n]* |
(?P<str>"(?:\\.|[^"\\])*") |
(?P<punct>[{}(),;=\[\]&*]) |
(?P<word>[A-Za-z0-9_.+\-]+)
''', re.S | re.X)
ESCAPES = {"n": "\n", "t": "\t", "\\": "\\", '"': '"', "r": "\r", "0": "\0"}
def unescape(lit):
out, i, body = [], 0, lit[1:-1]
while i < len(body):
if body[i] == "\\" and i + 1 < len(body):
out.append(ESCAPES.get(body[i + 1], body[i + 1]))
i += 2
else:
out.append(body[i])
i += 1
return "".join(out)
def tokens(text):
pos, out = 0, []
while pos < len(text):
m = TOKEN.match(text, pos)
if not m:
pos += 1
continue
pos = m.end()
if m.group("str"):
value = unescape(m.group("str"))
if out and out[-1][0] == "str": # adjacent literals join
out[-1] = ("str", out[-1][1] + value)
else:
out.append(("str", value))
elif m.group("punct"):
out.append(("punct", m.group("punct")))
elif m.group("word"):
out.append(("word", m.group("word")))
return out
def parse_braces(toks, i):
"""toks[i] is '{'; returns (nested list, index after the matching '}')."""
items = []
i += 1
while toks[i] != ("punct", "}"):
if toks[i] == ("punct", "{"):
sub, i = parse_braces(toks, i)
items.append(sub)
elif toks[i] == ("punct", ","):
i += 1
else:
items.append(toks[i][1] if toks[i][0] != "str" else ("s", toks[i][1]))
i += 1
return items, i + 1
def read_module(path):
"""Yields (word, case name, input, should_error) for every implemented case."""
toks = tokens(open(path, encoding="utf-8", errors="replace").read())
for i, t in enumerate(toks):
if t != ("word", "WordTestSuite"):
continue
j = i
while j < len(toks) and toks[j] != ("punct", "=") and toks[j] != ("punct", ";") and toks[j] != ("punct", "("):
j += 1
if j >= len(toks) or toks[j] != ("punct", "="):
continue
suites, _ = parse_braces(toks, j + 1)
for suite in suites:
if not isinstance(suite, list) or not suite or not isinstance(suite[0], tuple):
continue
word = suite[0][1]
for case in suite[1]:
if not isinstance(case, list) or len(case) < 6 or not isinstance(case[0], tuple):
continue
name, text = case[0][1], case[1][1]
should_error, implemented = case[4] != "0", case[5] != "0"
if implemented:
yield word, name, text, should_error
# ---- cutting a case's line to block lines ------------------------------------
STRING_OPENERS = {'."', 'S"', 'ABORT"', '.('}
def atoms(text):
"""The line as pieces that must each stay on one line: a word, or a word
that takes text together with its text. None if the line has a new-line
inside such a text."""
out, i, n = [], 0, len(text)
while i < n:
if text[i].isspace():
i += 1
continue
j = i
while j < n and not text[j].isspace():
j += 1
word = text[i:j]
upper = word.upper()
if upper in STRING_OPENERS or upper == "(":
close = ")" if upper in ("(", ".(") else '"'
k = text.find(close, j + 1 if j < n else j)
if k < 0:
k = n - 1
piece = text[i:k + 1]
if "\n" in piece:
return None
out.append(piece)
i = k + 1
elif word == "\\":
break # the rest of the line is a comment
else:
out.append(word)
i = j
return out
def pack(prefix, pieces, width=LINE_MAX):
"""Pieces into lines no longer than `width`, each begun with `prefix`
(which may be empty). None if a piece cannot fit on a line."""
head = [prefix] if prefix else []
lines, current = [], []
for piece in pieces:
if len(" ".join(head + [piece])) > width:
return None
if current and len(" ".join(head + current + [piece])) > width:
lines.append(current)
current = []
current.append(piece)
if current:
lines.append(current)
return [" ".join(head + line) for line in lines]
# A store to the search order, which v3's C test runner survives and a
# running FORTH system does not.
DAMAGES = re.compile(r"\b(CONTEXT|CURRENT)\s+!")
def forgets_system_word(text):
"""True if the line FORGETs a word it did not itself define. In a plain
v3 session that takes the rest of the system with it, and v4 refuses."""
words = text.split()
for i, word in enumerate(words[:-1]):
if word.upper() == "FORGET":
target = words[i + 1]
made = any(words[k] == target and k > 0 and words[k - 1].upper() in
(":", "CREATE", "VARIABLE", "CONSTANT", "VOCABULARY") for k in range(i))
if not made:
return True
return False
# ---- what v3 does --------------------------------------------------------------
ANSI = re.compile(r"\x1b\[[0-9;]*m")
def run_v3(v3, cases):
"""Runs the cases in one session. Returns a list, one entry per case:
None if v3 did not get through it, else (error, stack, output)."""
script = [": T| ; IMMEDIATE", ": TCLR BEGIN DEPTH WHILE DROP REPEAT ;"]
for n, case in enumerate(cases):
script.append('TCLR DECIMAL FORTH DEFINITIONS ." [[B%d]]"' % n)
script.extend(case["lines"])
script.append('." [[E%d]]" BASE @ DECIMAL .S BASE ! ." [[S%d]]"' % (n, n))
script.append("BYE")
done = subprocess.run([v3, "--log-none"], input=("\n".join(script) + "\n").encode("latin-1"),
capture_output=True, timeout=600)
text = ANSI.sub("", done.stdout.decode("latin-1")) # a byte is a character
results = []
for n, case in enumerate(cases):
begin, mid, end = "[[B%d]] ok\nok> " % n, "ok> [[E%d]]" % n, "[[S%d]]" % n
b = text.find(begin)
e = text.find(mid, b) if b >= 0 else -1
s = text.find(end, e) if e >= 0 else -1
if b < 0 or e < 0 or s < 0:
results.append(None)
continue
body = text[b + len(begin):e]
# one "ok> " and one " ok" or " ERROR" for every line of the case
chunks = body.split("ok> ")
output, error, ok = "", False, True
for chunk in chunks:
if chunk.endswith(" ERROR\n"):
output += chunk[:-len(" ERROR\n")]
error = True
elif chunk.endswith(" ok\n"):
output += chunk[:-len(" ok\n")]
else:
ok = False
m = re.match(r"<(\d+)>((?: -?\d+)*) \n", text[e + len(mid):s])
if not ok or not m:
results.append(None)
continue
stack = [int(v) for v in m.group(2).split()]
results.append((error, stack[:-1], output)) # the last value is BASE
return results
def checksum(output):
total = 0
for ch in output.encode("latin-1", errors="replace"):
total = (total * 31 + ch) & 0xFFFFFF
return total
# ---- writing the capsule -------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--v3", default=os.path.join(ROOT, "lfs", "amd64", "starforth"))
ap.add_argument("--report", default=None)
ap.add_argument("--dump", default=None, help="write what v3 did with every case")
args = ap.parse_args()
required, left_out, cases, seen = set(REQUIRED), [], [], {}
for module in MODULES:
for word, name, text, should_error in read_module(os.path.join(MODULE_DIR, module)):
if word not in required:
continue
label = ("%s.%s" % (word, name))[:31]
seen[label] = seen.get(label, 0) + 1
if seen[label] > 1:
label = (label[:28] + "~%d" % seen[label])[:31]
if word in KEYBOARD:
left_out.append((label, "reads the keyboard"))
continue
if forgets_system_word(text):
left_out.append((label, "forgets a word of the system"))
continue
if DAMAGES.search(text.upper()):
left_out.append((label, "damages the system on purpose"))
continue
pieces = atoms(text)
lines = pack("T|", pieces) if pieces is not None else None
if not lines:
left_out.append((label, "a line cannot be cut to fit a block line"))
continue
cases.append({"label": label, "word": word, "text": text, "lines": lines, "should_error": should_error})
# v3 is run until every case left is one it gets through
while True:
results = run_v3(args.v3, cases)
bad = [i for i, r in enumerate(results) if r is None]
if not bad:
break
first = bad[0]
left_out.append((cases[first]["label"], "v3 did not get through it in one session"))
del cases[first]
blocks, current = [], []
def flush():
if current:
blocks.append(list(current))
del current[:]
for part in HARNESS.strip("\n").split("=====\n"):
current.extend(part.strip("\n").split("\n"))
flush()
disagreements, applied = [], {"address": 0, "standard": 0, "extra": 0}
def expectation(error, stack, output, mode="full"):
"""The lines that end a case."""
if error:
return ["->ERR"]
if mode == "depth":
return ["-> %d }TD" % len(stack)]
if mode == "depth+output":
return pack("", ["->", str(len(stack)), "}TD", str(len(output)), str(checksum(output)), "T="])
return pack("", ["->"] + [str(v) for v in stack] + ["}T", str(len(output)), str(checksum(output)), "T="])
def emit(label, lines, ending):
block = ["T{ " + label] + lines + ending
if len(current) + len(block) > 16:
flush()
current.extend(block)
dump = []
for case, (error, stack, output) in zip(cases, results):
label = case["label"]
dump.append("%s\n input: %s\n v3: %s stack=%s out=%r\n"
% (label, " ".join(case["text"].split()), "ERROR" if error else "ok", stack, output))
if error != case["should_error"]:
disagreements.append((label, "v3's table says %s, v3 %s"
% ("error" if case["should_error"] else "no error",
"raised one" if error else "raised none")))
if label in rules.STANDARD:
rule = rules.STANDARD[label]
if rule is None: # the case is dropped
applied["standard"] += 1
left_out.append((label, rules.DROPPED[label]))
continue
lines = pack("T|", atoms(rule.get("text", case["text"])))
assert lines, label
if "depth" in rule:
emit(label, lines, ["-> %d }TD" % rule["depth"]])
else:
emit(label, lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", "")))
applied["standard"] += 1
elif label in rules.ADDRESS:
emit(label, case["lines"], expectation(error, stack, output, rules.ADDRESS[label]))
applied["address"] += 1
else:
emit(label, case["lines"], expectation(error, stack, output))
for rule in rules.EXTRA:
lines = pack("T|", atoms(rule["text"]))
assert lines, rule
emit(rule["label"], lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", "")))
applied["extra"] += 1
unknown = [k for k in list(rules.STANDARD) + list(rules.ADDRESS) if k not in {c["label"] for c in cases}]
assert not unknown, "rules for cases that do not exist: %s" % unknown
if args.dump:
open(args.dump, "w").write("\n".join(dump))
flush()
blocks.append(["T-REPORT", "FORGET T# DECIMAL"])
with open(OUTPUT, "w", encoding="ascii") as out:
for k, block in enumerate(blocks):
assert len(block) <= 16, block
out.write("Block %d\n" % (FIRST_BLOCK + k))
for line in block:
assert len(line) <= LINE_MAX, line
out.write(line + "\n")
tested = sorted({c["word"] for c in cases} | {r["label"].rsplit(".", 1)[0] for r in rules.EXTRA})
untested = [w for w in REQUIRED if w not in set(tested)]
dropped = sum(1 for v in rules.STANDARD.values() if v is None)
total = len(cases) - dropped + len(rules.EXTRA)
report = ["POST cases: %d, in %d blocks (%d to %d)" % (total, len(blocks), FIRST_BLOCK, FIRST_BLOCK + len(blocks) - 1),
" from v3 unchanged: %d" % (len(cases) - applied["address"] - applied["standard"]),
" from v3, address-dependent (post79_rules.ADDRESS): %d" % applied["address"],
" rewritten or dropped for FORTH-79 (post79_rules.STANDARD): %d, of which dropped %d" % (applied["standard"], dropped),
" written by hand (post79_rules.EXTRA): %d" % applied["extra"],
"Required words with at least one case: %d of %d" % (len(tested), len(REQUIRED)),
"Required words with no case: " + " ".join(untested), "",
"Left out (%d):" % len(left_out)]
report += [" %-32s %s" % item for item in left_out]
report += ["", "v3 did not do what its own table expects (%d):" % len(disagreements)]
report += [" %-32s %s" % item for item in disagreements]
text = "\n".join(report) + "\n"
write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped)
if args.report:
open(args.report, "w").write(text)
sys.stdout.write(text)
DOC = os.path.join(ROOT, "docs", "v4.0.0", "POST79.md")
def code(text):
return "`" + " ".join(text.split()).replace("|", "\\|") + "`"
def expects(rule):
if rule.get("error"):
return "an error"
if "depth" in rule:
return "%d value(s) left; output not compared" % rule["depth"]
return "stack `%s`, prints %s" % (" ".join(str(v) for v in rule.get("stack", [])) or "empty",
"`%s`" % rule["out"].replace("\n", "\\n") if rule.get("out") else "nothing")
def write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped):
by_label = {c["label"]: (c, r) for c, r in zip(cases, results)}
out = ["# POST for the FORTH-79 Required Word Set: where it departs from v3", "",
"Written by `v4/tools/mkpost.py` from `v4/tools/post79_rules.py`; do not edit.",
"Design: `NUCLEUS.md` section 6. The capsule is `capsules/v4/post79.4th`.", "",
"POST runs %d cases. %d are v3's, with what the hosted v3 binary did as the" % (total, len(cases) - applied["address"] - applied["standard"]),
"expected result. This file lists every other case, and why.", "",
"## 1. Rewritten for FORTH-79 (%d)" % (applied["standard"] - dropped), "",
"v3 departs from the standard, or the case is written for v3's machine. v4",
"follows the standard (standing ruling), so the expected result is the",
"standard's and not v3's.", "",
"| Case | v3's line | What v3 did | As POST runs it | Must do | Why |", "|---|---|---|---|---|---|"]
for label, rule in rules.STANDARD.items():
if rule is None:
continue
case, (error, stack, output) = by_label[label]
did = "error" if error else "stack `%s`, printed `%s`" % (" ".join(map(str, stack)) or "empty", output.replace("\n", "\\n")[:40])
ran = code(rule["text"]) if "text" in rule else "unchanged"
out.append("| `%s` | %s | %s | %s | %s | %s |" % (label, code(case["text"]), did, ran, expects(rule), rule["why"]))
out += ["", "## 2. Address-dependent (%d)" % applied["address"], "",
"The case leaves or prints a memory address, which is a different number on",
"the two machines. The number of values left is checked, and what is",
"printed unless an address is printed; the values are not.", "",
"| Case | Line | Checked |", "|---|---|---|"]
for label, mode in rules.ADDRESS.items():
out.append("| `%s` | %s | %s |" % (label, code(by_label[label][0]["text"]),
"values left, and output" if mode == "depth+output" else "values left only"))
out += ["", "## 3. Written by hand (%d)" % len(rules.EXTRA), "",
"For required words v3 has no case for. The expected results are the",
"standard's; no v3 run stands behind them.", "",
"| Case | Line | Must do |", "|---|---|---|"]
for rule in rules.EXTRA:
out.append("| `%s` | %s | %s |" % (rule["label"], code(rule["text"]), expects(rule)))
out += ["", "## 4. v3 cases left out (%d)" % len(left_out), "", "| Case | Why |", "|---|---|"]
out += ["| `%s` | %s |" % item for item in left_out]
out += ["", "## 5. Required words POST does not test", "",
" ".join("`%s`" % w for w in untested) + ".", "",
"`KEY`, `EXPECT` and `QUERY` wait for the keyboard, which a boot cannot type",
"at. `QUIT` returns to the terminal without `ok`, which the capsule loader",
"takes for a refused line.", "",
"## 6. Where v3 did not do what its own table expects (%d)" % len(disagreements), "",
"POST expects what v3 did, unless section 1 says otherwise.", "", "| Case | |", "|---|---|"]
out += ["| `%s` | %s |" % item for item in disagreements]
open(DOC, "w").write("\n".join(out) + "\n")
if __name__ == "__main__":
main()