Files
SplitBit-Emulator/Tests/docs.sh
T
AnachronautandClaude Opus 5 7a55cfe151 Say that an option's file is one the assembler writes, and check we said it
-S was added without a row in the Assembler Manual, and the usage it
printed listed a bare "-S <file>" with no long name and no statement of
what the file is for. That is not merely incomplete, it is misleading:
"-S <file>" reads just as naturally as "dump the symbols of <file>", and
asking for it that way hands the source to -S, leaves nothing positional
behind it, and is answered with "No source file specified" on a command
line that plainly names a source. The error described the hole the
mistake left and hid the mistake.

So the usage now prints the long names, says outright that every <file>
is a path it writes and the source is the last argument on its own, and
ends with a whole example command. When the source is missing and a
file-taking option was given, the error says which options take a path
to write. The manual gains the -S row it never had, a warning in the
same words, and a sentence on what a symbol dump is for.

Documenting it twice is how it went wrong once, so docs.sh now settles
both against getopt's own option table: every option the assembler takes
has a row in the manual and a line in its own usage. Verified with
break.sh against the manual row and the usage line separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-05 09:40:21 -04:00

830 lines
44 KiB
Bash
Executable File

#!/usr/bin/env bash
# Checks the manuals against the code, and the repository against its own rules.
#
# Documentation goes stale quietly. An instruction added without a table row, or a count
# in a heading that nobody updated, is wrong in a way nothing notices until somebody
# trusts it. Everything here is a claim the manuals make that can be settled by looking
# at the source, so it is settled every time the tests run.
#
# Written by Anachronaut
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" || exit 1
python3 - <<'PY'
import glob
import re
import sys
problems = []
def read(path):
return open(path).read()
pm = read("SplitBit Programming Manual.md")
am = read("SplitBit Assembler Manual.md")
# The third manual. CosmOS is a system that runs ON SplitBit rather than part of it, so
# what it offers a program is documented with it and checked here alongside the other two.
cr = read("Programs/CosmOS/README.md")
asmc = read("Source/Assembler/assembly.c")
util = read("Source/Assembler/Assm-util.c")
# ---- Every tracked file is plain ASCII ----
#
# A standing rule of this repository, and nothing enforced it, so it drifted: 39 em dashes
# and an ellipsis had collected in the two manuals, all of them typed by something that
# helpfully substituted a nicer character.
#
# GIT LS-FILES IS READ NUL SEPARATED, and that is not fussiness. The obvious shell version
# of this check - looping over $(git ls-files) - splits on whitespace, so it looked for a
# file called "SplitBit" and reported the repository clean while both manuals had drifted.
# A check that cannot see the files with spaces in their names is worse than no check.
import subprocess
tracked = subprocess.run(["git", "ls-files", "-z"], capture_output=True).stdout
for name in tracked.split(b"\0"):
if not name:
continue
path = name.decode()
try:
text = open(path, encoding="utf-8").read()
except (UnicodeDecodeError, OSError):
continue
for number, line in enumerate(text.split("\n"), 1):
odd = sorted({c for c in line if ord(c) > 127})
if odd:
problems.append("%s line %d is not plain ASCII: %s"
% (path, number, ", ".join("%r (U+%04X)" % (c, ord(c)) for c in odd)))
break
# ---- Every link in the documents goes somewhere ----
#
# A link that does not resolve is the same kind of wrong as a stale claim: it looks like
# information and is not, and nobody notices until a stranger clicks it. The manuals have
# SPACES IN THEIR NAMES, so a link to one carries %20 and has to be unquoted before it can
# be looked for - which is the sort of thing that would otherwise be got wrong once and
# then reported as fine.
import os
import urllib.parse
for name in tracked.split(b"\0"):
if not name or not name.endswith(b".md"):
continue
path = name.decode()
here = os.path.dirname(path)
for match in re.finditer(r"\[[^\]]*\]\(([^)]+)\)", read(path)):
target = match.group(1)
if target.startswith(("http://", "https://", "#", "mailto:")):
continue
# A raw space ends the link early in most renderers, so the file existing is not
# enough - the manuals have spaces in their names and must carry %20.
if " " in target:
problems.append("%s links to \"%s\", which has a space in it: most renderers"
" stop at the space. Write it as %%20."
% (path, target))
continue
wanted = urllib.parse.unquote(target.split("#")[0])
if not os.path.exists(os.path.normpath(os.path.join(here, wanted))):
problems.append("%s links to %s, and there is nothing there" % (path, target))
# ---- Every instruction has a row, and every row is an instruction ----
#
# A mnemonic begins with a letter, which is what keeps the offset and size columns of the
# other tables in these manuals out of it.
documented = {(int(m.group(1), 16), m.group(2))
for m in re.finditer(r'^\|\s*([0-9A-F]{2})\s*\|\s*([A-Z][A-Z0-9]*)\s*\|', pm, re.M)}
implemented = {(int(m.group(1), 16), m.group(2))
for m in re.finditer(r'\{0x([0-9A-Fa-f]{2}),\s*"([A-Z0-9]+)"\}', asmc)}
for opcode, name in sorted(implemented - documented):
problems.append("%s (0x%02X) is implemented and not in the manual" % (name, opcode))
for opcode, name in sorted(documented - implemented):
problems.append("%s (0x%02X) is in the manual and not implemented" % (name, opcode))
# ---- The counts in the group headings ----
body = asmc[asmc.index("Instruction instruction_set[]"):asmc.index("int num_instructions")]
actual = {}
group = None
for line in body.split("\n"):
heading = re.match(r'\s*// (.+?) Operations:', line)
if heading:
group = heading.group(1)
actual.setdefault(group, 0)
if re.search(r'\{0x[0-9A-Fa-f]{2},', line) and group:
actual[group] += 1
for m in re.finditer(r'^### (.+?) Operations: (\d+) Instructions?$', pm, re.M):
name, claimed = m.group(1), int(m.group(2))
# The manual's headings are wordier than the source's comments, so match on the start.
match = [v for k, v in actual.items() if name.startswith(k)]
if not match:
problems.append("the manual has a group called \"%s\" that the source does not" % name)
elif match[0] != claimed:
problems.append("the manual says %s has %d instructions, and it has %d"
% (name, claimed, match[0]))
# ---- How many instructions carry a Data Pointer selector ----
#
# The manual says this as a word rather than a figure, and it is the sort of number that
# goes stale quietly: adding an instruction that takes a selector leaves the sentence
# looking perfectly reasonable and wrong. dataPointerOperands is the list, so it is the
# one to believe.
# Past twenty the number is two words, the way this manual writes every other one, so the
# pattern has to allow a second - and the count going past twenty is exactly the sort of
# thing that would otherwise turn "the manual is wrong" into "the manual has stopped
# saying it", which reads as a different kind of problem.
words = {12: "Twelve", 13: "Thirteen", 14: "Fourteen", 15: "Fifteen", 16: "Sixteen",
17: "Seventeen", 18: "Eighteen", 19: "Nineteen", 20: "Twenty",
21: "Twenty one", 22: "Twenty two", 23: "Twenty three", 24: "Twenty four",
25: "Twenty five", 26: "Twenty six"}
selectors = asmc[asmc.index("int dataPointerOperands"):asmc.index("int getOpcode")]
taking = len(re.findall(r'^\s*case 0x[0-9A-Fa-f]{2}:', selectors, re.M))
said = re.search(r'^([A-Z][a-z]+(?: [a-z]+)?) instructions work through a Data Pointer\.',
pm, re.M)
if not said:
problems.append("the manual no longer says how many instructions take a Data Pointer")
elif said.group(1) != words.get(taking):
problems.append("the manual says %s instructions work through a Data Pointer, and %d do"
% (said.group(1).lower(), taking))
# ---- Every device class in the header has a row in the Devices table ----
#
# The table says which ports a device answers on and what class it reports. Adding a
# device, or widening one from a single port to a block, leaves the table looking perfectly
# reasonable and describing a machine that no longer exists. The classes are the part that
# can be checked against the source without teaching this script how ports are laid out:
# every class the header defines except DEVICE_NONE is something a program can find on the
# bus, so every one of them has to be findable in the manual too.
ioh = read("Source/Emulator/io.h")
classes = {name: int(value, 16)
for name, value in re.findall(r'^#define (DEVICE_[A-Z_]+)\s+(0x[0-9A-Fa-f]{2})$',
ioh, re.M)
if name not in ("DEVICE_NONE",)}
if "## Devices:" not in pm:
problems.append("the Programming Manual has lost its Devices table")
else:
table = pm.split("## Devices:")[1].split("\n## ")[0]
listed = {int(m, 16) for m in re.findall(r'\|\s*(0x[0-9A-Fa-f]{2})\s*\|\s*$', table, re.M)}
for name, value in sorted(classes.items(), key=lambda pair: pair[1]):
if value not in listed:
problems.append("%s (0x%02X) is a device class and has no row in the Devices"
" table" % (name, value))
# ---- Every option the assembler takes is written down in both places ----
#
# An option added to getopt is an option nobody knows about until it is said twice: in the
# manual's table, and in the usage the assembler prints when it is asked for help or refuses
# a command line. -S arrived with neither, and the damage was worse than an undocumented
# switch. The usage listed a bare "-S <file>" with no long name and no statement that the
# file is one it WRITES, so it read as "dump the symbols of <file>" - which hands the source
# to -S, leaves nothing positional behind it, and gets answered with "No source file
# specified" on a command line that plainly names one.
#
# The option table is the source of truth because it is what getopt_long is actually given.
assembler = read("Source/Assembler/Assembler.c")
options = re.findall(r'^\s*\{"([a-z]+)",\s*\w+,\s*0,\s*\'(\w)\'\s*\},', assembler, re.M)
if not options:
problems.append("could not find the assembler's option table")
elif "void printUsage" not in assembler:
problems.append("the assembler has lost its printUsage")
elif "## Running the Assembler:" not in am:
problems.append("the Assembler Manual has lost its options table")
else:
usage = assembler.split("void printUsage")[1].split("\n}")[0]
section = am.split("## Running the Assembler:")[1].split("\n## ")[0]
# Only the table rows count, so a switch merely mentioned in the prose below it does not
# pass for a documented one.
rows = re.findall(r'^\| (-\w, --[a-z]+)', section, re.M)
for longName, shortName in options:
spelled = "-%s, --%s" % (shortName, longName)
if spelled not in rows:
problems.append("the assembler takes %s and the Assembler Manual's option table"
" has no row for it" % spelled)
if spelled not in usage:
problems.append("the assembler takes %s and its own usage message does not name"
" it" % spelled)
# ---- The vector ranges the manuals quote are the ones the assembler uses ----
#
# Both manuals print the boundary between numbers a program may pin and numbers the
# assembler hands out. Those are two constants in one header, and moving them without
# touching the manuals would leave every programmer reading a range that no longer exists
# and being refused a number the manual said was theirs.
header = read("Source/Assembler/assembly.h")
ranges = {name: int(value)
for name, value in re.findall(r'^#define (VECTOR_FIRST_[A-Z]+)\s+(\d+)$',
header, re.M)}
if set(ranges) != {"VECTOR_FIRST_PINNED", "VECTOR_FIRST_AUTO"}:
problems.append("the vector range constants are not the two this check knows about: %s"
% ", ".join(sorted(ranges)) if ranges else "none found")
else:
pinnedFrom = ranges["VECTOR_FIRST_PINNED"]
autoFrom = ranges["VECTOR_FIRST_AUTO"]
said = "%d to %d" % (pinnedFrom, autoFrom - 1)
for manual, text in [("Programming Manual", pm), ("Assembler Manual", am)]:
if said not in text:
problems.append("the %s does not say the pinned vectors are %s"
% (manual, said))
if "%d and up" % autoFrom not in pm:
problems.append("the Programming Manual does not say the automatic vectors start"
" at %d" % autoFrom)
if "from vector %d upwards" % autoFrom not in am:
problems.append("the Assembler Manual does not say the automatic vectors start"
" at %d" % autoFrom)
# ---- The loadable header table matches the offsets the assembler writes ----
#
# The Programming Manual prints the header field by field, which is the description two
# implementations work from. sbex.h is where the offsets actually are, so a field moved
# there and not here would leave the manual describing a format nobody writes.
sbex = read("Source/Assembler/sbex.h")
offsets = {name: int(value)
for name, value in re.findall(r'^#define (SBEX_[A-Z_]+_AT)\s+(\d+)$', sbex, re.M)}
if "## Loading A Program From A Disk:" not in am:
problems.append("the Assembler Manual has lost its loadable program section")
else:
loading = am.split("## Loading A Program From A Disk:")[1].split("\n## ")[0]
listed = [int(m) for m in re.findall(r'^\| (\d+) \| \d* \|', loading, re.M)]
for name, offset in sorted(offsets.items(), key=lambda pair: pair[1]):
if offset not in listed:
problems.append("%s is at offset %d and the header table has no row for it"
% (name, offset))
# Spelled as a word, the way these manuals write small numbers in prose.
asWord = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five"}
for version in ("SBEX_VERSION", "SBEX_VERSION_VECTORS"):
number = re.search(r'^#define %s\s+(\d+)$' % version, sbex, re.M)
if not number:
problems.append("%s is gone from sbex.h" % version)
continue
said = asWord.get(int(number.group(1)))
if said is None or said not in loading.lower():
problems.append("the loadable program section does not mention version %s (%s)"
% (number.group(1), said))
# ---- Every console status bit is described ----
#
# The status port is read by writing a mask and testing it, so a program can only use a bit
# it has been told the number of. Adding one and forgetting to write it down leaves a bit
# that works and that nobody can discover. The section names them as "bit N", so that is
# what is looked for.
status = {name: int(value, 16)
for name, value in re.findall(r'^#define (CONSOLE_STATUS_[A-Z]+)\s+(0x[0-9A-Fa-f]{2})$',
ioh, re.M)}
if "## The Console:" not in pm:
problems.append("the Programming Manual has lost its \"The Console\" section")
else:
console = pm.split("## The Console:")[1].split("\n## ")[0]
for name, value in sorted(status.items(), key=lambda pair: pair[1]):
bit = value.bit_length() - 1
if "bit %d" % bit not in console:
problems.append("%s is bit %d of the console status port and The Console does"
" not mention it" % (name, bit))
# ---- Every service the system offers has a row ----
#
# services.asm is the one place the numbers are written, and both the system and every
# program include it. A service added there and not here is one nothing can find out about
# except by reading the source of the operating system.
# DECLARING A SERVICE AND IMPLEMENTING ONE ARE DIFFERENT THINGS, and the manual should
# describe the second. services.asm names them and fixes their numbers, which is what lets a
# number be pinned before anything answers to it; cosmos.asm is where a name gets a handler.
# A row for a service nothing implements would be describing a call that faults, and a
# missing row for one that works is a service nobody can find out about.
services = read("Programs/CosmOS/Source/services.asm")
named = set(re.findall(r'^\s{2}(os[A-Za-z]+)\s+0d\d+', services, re.M))
system = read("Programs/CosmOS/Source/cosmos.asm")
vectors = system.split("#Vectors")[-1] if "#Vectors" in system else ""
implemented = {name for name in re.findall(r'^\s{2}(os[A-Za-z]+)\s+[a-zA-Z]', vectors, re.M)
if name in named}
if not named:
problems.append("no services could be found in services.asm")
elif "## What A Program May Ask The System For:" not in cr:
problems.append("the CosmOS README has lost its services section")
else:
section = cr.split("## What A Program May Ask The System For:")[1].split("\n## ")[0]
documented = set(re.findall(r'^\| (os[A-Za-z]+) \|', section, re.M))
for name in sorted(implemented - documented):
problems.append("%s is a service the system implements and has no row in the"
" services table" % name)
for name in sorted(documented - implemented):
problems.append("the services table describes %s, which nothing implements: calling"
" it would dispatch through an empty vector and fault" % name)
# ---- Every program the manual describes is really there ----
#
# The table names what the shell can load. A program renamed or removed leaves a row
# describing something nobody can run, which is the same kind of quiet wrongness as a
# routine that no longer exists. The other direction is deliberately not checked: the ported
# programs are covered in the prose rather than given a row each.
import os
if "## Included Applications:" not in cr:
problems.append("the CosmOS README has lost its list of applications")
else:
listed = cr.split("## Included Applications:")[1].split("\n### ")[0]
# After the separator, so the table's own heading row is not mistaken for a program.
listed = listed.split("| --- |")[-1]
for name in re.findall(r'^\| ([A-Z][A-Za-z0-9-]*) \|', listed, re.M):
if not os.path.exists("Programs/CosmOS/Apps/%s.asm" % name):
problems.append("the CosmOS README describes an application called %s, and"
" there is no Programs/CosmOS/Apps/%s.asm" % (name, name))
# ---- The monitor's instruction table is the assembler's ----
#
# The monitor disassembles, so it needs the same 64 instructions with the same names and the
# same lengths. A disassembler that disagreed about a length would not print one line wrong,
# it would lose its place and print everything after it wrong, which is the worst way for a
# tool like that to fail: confidently. So the table is generated from assembly.c by
# Tests/instructiontable.py, and what is in the monitor is checked against it here.
import subprocess
generated = subprocess.run([sys.executable, "Tests/instructiontable.py"],
capture_output=True, text=True)
if generated.returncode != 0:
problems.append("the instruction table generator would not run")
else:
wanted = [line.rstrip() for line in generated.stdout.splitlines() if line.strip()]
# TWO copies now, and both are checked. The monitor has one and the assembler that
# runs on the machine has another, because they are separate programs and there is no
# linker to let them share: the monitor's lives in the system's data at an address
# that moves every rebuild. Duplication is the cost of having no libraries, and a
# check on every copy is what keeps the cost to bytes rather than to correctness.
copies = [("the system", "Programs/CosmOS/Source/cosmos.asm", "\nInstructions:\n"),
("the native assembler", "Programs/CosmOS/Assembler/table.asm",
"\nAsmInstructions:\n")]
for who, path, marker in copies:
text = read(path)
if marker not in text:
problems.append("%s has lost its instruction table" % who)
continue
block = text.split(marker)[1]
have = []
for line in block.splitlines():
if not line.strip() or not line.startswith(" 0x"):
break
have.append(line.rstrip())
if have != wanted:
problems.append("%s's instruction table is not what the assembler's"
" instruction set generates: %d entries against %d, first"
" difference at %s"
% (who, len(have), len(wanted),
next((a or b for a, b in zip(have + [None] * len(wanted),
wanted + [None] * len(have))
if a != b), "the end")))
# ---- And the lengths that table implies are the ones the manual prints ----
#
# The generator works out how long each instruction is from rules written in it; the manual
# says so in a column somebody typed. They are independent accounts of the same fact, which
# is exactly the pair worth checking against each other.
sys.path.insert(0, "Tests")
import instructiontable
lengthOf = {0: 1, 1: 3, 2: 2, 3: 2, 4: 3, 5: 4, 6: 3}
printed = {}
for m in re.finditer(r'^\|\s*[0-9A-F]{2}\s*\|\s*([A-Z][A-Z0-9]*)\s*\|\s*(\d+)\s*\|', pm, re.M):
printed[m.group(1)] = int(m.group(2))
for opcode, name in instructiontable.table():
implied = lengthOf[instructiontable.shapeOf(opcode)]
if name in printed and printed[name] != implied:
problems.append("the manual says %s is %d bytes and the disassembler will read it"
" as %d" % (name, printed[name], implied))
# ---- Every directive the assembler knows is written down ----
for directive in sorted(set(re.findall(r'"(#[A-Za-z]+)"', util))):
if directive not in am:
problems.append("%s is a directive and is not in the Assembler Manual" % directive)
# ---- Every routine the manual promises exists ----
#
# The first column of the table in each of these sections names something the library has
# to define. A routine renamed in the source and not in the manual is caught here, which
# is what keeps the tables a description rather than a memory.
for heading, library in [("## The Filesystem Library:", "Programs/CosmOS/Source/sbfs.asm"),
("## The Console Library:", "Programs/CosmOS/Source/console.asm")]:
if heading not in cr:
problems.append("the CosmOS README has lost its \"%s\" section"
% heading.strip("# :"))
continue
section = cr.split(heading)[1].split("\n## ")[0]
defined = set(re.findall(r'^([a-zA-Z][A-Za-z0-9]*):', read(library), re.M))
for name in re.findall(r'^\| ([a-z][A-Za-z0-9]*) \|', section, re.M):
if name not in defined:
problems.append("the manual lists %s, which %s does not define" % (name, library))
# ---- The worked example still assembles to the bytes the manual prints ----
#
# The hello world program and the hex dump beside it are two claims about the same thing,
# and nothing but this keeps them agreeing.
import os
import subprocess
import tempfile
# BOTH ANCHORS ARE CHECKED BEFORE THEY ARE USED. Every other heading this script splits on
# says what it could not find; these two were the exception, and a rename here produced an
# IndexError and a traceback instead of a sentence. That is a worse answer than a stale
# manual, because whoever reads it learns nothing about which heading moved.
# THE TWO HALVES ARE IN DIFFERENT MANUALS NOW, and that makes this a better check than it
# was. The program belongs with the machine, where it arrives just after the instruction
# list; the hex dump belongs with the boot image format it is an example of, which is the
# assembler's business. So this settles three things against each other at once: what the
# Programming Manual prints, what the Assembler Manual prints, and what the assembler does.
exampleAnchor = "### Example Program: Hello World"
dumpAnchor = "assembled and dumped as hex:"
if exampleAnchor not in pm:
problems.append("the Programming Manual has lost its \"Example Program: Hello World\""
" heading, so the worked example cannot be found")
elif dumpAnchor not in am:
problems.append("the Assembler Manual no longer says \"%s\" before the hex dump, so"
" there is nothing to compare the worked example against" % dumpAnchor)
else:
source = pm.split(exampleAnchor)[1].split("```")[1]
claimed = am.split(dumpAnchor)[1].split("```")[1].split()
with tempfile.TemporaryDirectory() as work:
asm = os.path.join(work, "hello.asm")
binary = os.path.join(work, "hello.bin")
open(asm, "w").write(source)
built = subprocess.run(["./Assembler", asm, "-o", binary], capture_output=True)
if built.returncode != 0:
problems.append("the hello world program in the manual no longer assembles")
else:
actual = ["%02x" % b for b in open(binary, "rb").read()]
if [c.lower() for c in claimed] != actual:
problems.append("the hex dump in the manual is not what that program"
" assembles to now: it prints %d bytes and the assembler"
" makes %d" % (len(claimed), len(actual)))
# ---- The Assembler Manual's worked programs still assemble ----
for heading in ["## An Example SplitBit Assembly Program:",
"## An Example Using More Than One Data Pointer:",
"## An Example Using Interrupts:"]:
if heading not in am:
problems.append("the Assembler Manual has lost its \"%s\" section" % heading.strip("# :"))
continue
example = am.split(heading)[1].split("```")[1]
with tempfile.TemporaryDirectory() as work:
asm = os.path.join(work, "example.asm")
open(asm, "w").write(example)
built = subprocess.run(["./Assembler", "-I", "Programs/Libraries",
"-I", "Programs/CosmOS/Source", asm,
"-o", os.path.join(work, "example.bin")],
capture_output=True)
if built.returncode != 0:
problems.append("the example under \"%s\" no longer assembles"
% heading.strip("# :"))
# ---- No manual still says the filesystem is flat ----
#
# It was flat, and every manual said so in passing, and those sentences went on being true
# for as long as nobody looked. SBFS grew directories over five separate pieces of work and
# the last thing left claiming otherwise was one line in the Assembler Manual explaining
# why an include had nowhere else to look.
#
# A phrase rather than a section, because that is the shape this kind of staleness takes:
# not a chapter that is wrong, one clause inside a paragraph that is otherwise right.
for name, text in (("the Programming Manual", pm), ("the Assembler Manual", am),
("the README", open("README.md").read()),
("the CosmOS README", open("Programs/CosmOS/README.md").read())):
for claim in ("SBFS is flat", "the filesystem is flat", "flat filesystem",
"SBFS has no directories"):
if claim.lower() in text.lower():
problems.append("%s still says \"%s\", and it has not been true since"
" directories arrived" % (name, claim))
# ---- The shell's words are a table as well as a chain of comparisons ----
#
# The dispatch is a run of "is the line this name" tests, which is fine to execute and
# impossible to WALK - so completing a half typed command needs the names as data too, and
# they are: fifteen strings packed end to end from ShellNames, each ending in the zero that
# says where the next begins.
#
# THE TWO CAN DISAGREE AND THE WAY THEY DO IS QUIET. A command added to the dispatch and not
# to the run simply never completes, which nobody would think to test by hand; something put
# BETWEEN the strings ends the walk early and takes the rest of the commands with it. So this
# reads both and compares them, and reads the count as well, because a run of strings does
# not say where it stops.
cosmosSource = open("Programs/CosmOS/Source/cosmos.asm").read()
# The dispatch, up to the point where the monitor's single letters begin - those are one
# character each and there is nothing to complete about them.
dispatchEnd = cosmosSource.find("SETD.0 Mode")
dispatched = re.findall(r"SETD\.1 (\w+Name)\b", cosmosSource[:dispatchEnd])
# The run, which ends at the first thing that is not a label and a string.
runAt = cosmosSource.find("ShellNames:")
packed = []
if runAt < 0:
problems.append("cosmos.asm has no ShellNames run for the shell's own words")
else:
lines = cosmosSource[runAt:].split("\n")[1:]
while len(lines) >= 2 and re.fullmatch(r"(\w+Name):", lines[0]) \
and re.fullmatch(r'"[^"]*"', lines[1]):
packed.append(lines[0][:-1])
lines = lines[2:]
stated = re.search(r"ShellNameCount:\s*\n\s*0d(\d+)", cosmosSource)
if not stated:
problems.append("cosmos.asm no longer says how many shell names there are")
elif int(stated.group(1)) != len(packed):
problems.append("cosmos.asm says there are %s shell names and the run holds %d"
% (stated.group(1), len(packed)))
if runAt >= 0 and sorted(packed) != sorted(dispatched):
missing = sorted(set(dispatched) - set(packed))
extra = sorted(set(packed) - set(dispatched))
problems.append("the shell's dispatch and its packed names disagree:%s%s"
% ("".join(" %s is dispatched and not in the run;" % n for n in missing),
"".join(" %s is in the run and not dispatched;" % n for n in extra)))
# ---- CosmOS fits in the half of the machine it says it does ----
#
# The memory map in the CosmOS README is a CONVENTION. Nothing in the assembler, the
# loader or the machine enforces it: an application says where it goes with #Base, and
# CosmOS puts it there. So when CosmOS grew past the address applications are based at,
# nothing said so - the next program loaded simply landed on top of the shell's own code,
# and what broke was whichever part of the shell that program happened to cover, at
# whatever later moment somebody used it.
#
# That is why this reads the numbers out of the table rather than being told them: the
# table is the specification, and a table nothing checks is the thing that goes stale.
readme = open("Programs/CosmOS/README.md").read()
row = re.compile(r"\|\s*(Program|Data) Memory\s*\|\s*`0x0000` through `0x([0-9A-Fa-f]{4})`"
r"\s*\|\s*`0x([0-9A-Fa-f]{4})` and above\s*\|")
table = {kind: (int(top, 16) + 1, int(base, 16)) for kind, top, base in row.findall(readme)}
limits = {kind: room for kind, (room, base) in table.items()}
bases = {kind: base for kind, (room, base) in table.items()}
if len(table) != 2:
problems.append("the CosmOS README no longer states a memory map this can check")
else:
built = subprocess.run(["./Assembler", "-I", "Programs/CosmOS/Source",
"Programs/CosmOS/Source/cosmos.asm", "-o", os.devnull],
capture_output=True, text=True)
sizes = dict(re.findall(r"(Program|Data) Segment size: (\d+)", built.stdout))
if len(sizes) != 2:
problems.append("could not measure the CosmOS segments")
else:
for kind in ("Program", "Data"):
used, room = int(sizes[kind]), limits[kind]
if used > room:
problems.append(
"CosmOS's %s Segment is %d bytes and the map gives it %d - it now"
" reaches into the %d bytes an application is loaded at, and loading"
" one will overwrite it" % (kind, used, room, used - room))
# ---- And the table against itself ----
#
# The check above reads the CosmOS column and never the one beside it, so it passed a
# Data row that gave the system 0x0000-0x3FFF and an application 0x2000 and above -
# two halves of one sentence contradicting each other in print. A number checked
# against the code and not against the number next to it is still unchecked.
for kind in ("Program", "Data"):
room, base = table[kind]
if base != room:
problems.append(
"the CosmOS README gives the system %s Memory up to 0x%04X and puts an"
" application at 0x%04X - the two columns of that row disagree"
% (kind, room - 1, base))
# ---- And the example under it ----
#
# The minimal application in the same section is what somebody copies, so it is the
# part of the map most worth being right. It went stale across the doubling while the
# table above it was corrected.
# ---- Found by its section, not by being first ----
#
# This took the first asm block in the file, which was the minimal application right up
# until somebody documented a program with an assembly example above it - and then this
# said the minimal application had no #Base, about a block that was never claiming to be
# one. The example lives under System Services; that is what identifies it.
services = readme.split("### System Services:", 1)
example = (re.search(r"```asm\n(.*?)```", services[1], re.S)
if len(services) > 1 else None)
if not example:
problems.append("the CosmOS README no longer shows a minimal application")
else:
shown = dict(zip(("Program", "Data"),
re.findall(r"#Base 0x([0-9A-Fa-f]{4})", example.group(1))))
for kind in ("Program", "Data"):
if kind not in shown:
problems.append("the minimal CosmOS application shows no %s #Base" % kind)
elif int(shown[kind], 16) != bases[kind]:
problems.append(
"the minimal CosmOS application is based at 0x%s in %s Memory and the"
" map above it says 0x%04X" % (shown[kind].upper(), kind, bases[kind]))
# ---- And the copy in the source ----
#
# cosmos.asm opens with the same map in its own words, because somebody reading the
# system reads that before they read the README. Three copies of one fact, so all
# three are compared.
header = open("Programs/CosmOS/Source/cosmos.asm").read()[:4096]
stated = dict(re.findall(
r"(Program|Data) Memory\s+0x0000 - 0x([0-9A-Fa-f]{4})\s+the system", header))
for kind in ("Program", "Data"):
if kind not in stated:
problems.append("cosmos.asm no longer opens with a %s Memory map" % kind)
elif int(stated[kind], 16) + 1 != limits[kind]:
problems.append(
"cosmos.asm says the system keeps below 0x%s in %s Memory and the CosmOS"
" README says 0x%04X" % (stated[kind].upper(), kind, limits[kind] - 1))
# ---- The assembler's scratch map sits above what it says it sits above ----
#
# scratch.asm is a MAP rather than a set of declarations: the buffers are not reserved,
# they are addresses written in a comment, because reserving them would put 22K of zeroes
# in the file and the assembler could not load itself. Nothing enforces a word of it.
#
# So it carries two claims about the machine around it, and both have gone stale once. It
# said the system keeps below 0x1000 for a while after the system's half of Data Memory
# was doubled - twelve lines above the paragraph explaining the doubling. And the map
# starts at 0x4000 on the grounds that the assembler's own data ends well before there,
# which was measured on the day and is not measured again by anything.
scratch = open("Programs/CosmOS/Assembler/scratch.asm").read()
floor = re.search(r"the system keeps\s*;?\s*below 0x([0-9A-Fa-f]{4})", scratch)
if not floor:
problems.append("scratch.asm no longer says what the system keeps below")
elif "Data" in limits and int(floor.group(1), 16) + 1 != limits["Data"]:
problems.append(
"the assembler's scratch map says the system keeps below 0x%s and the CosmOS"
" README says 0x%04X" % (floor.group(1).upper(), limits["Data"] - 1))
first = re.search(r"^;\s+0x([0-9A-Fa-f]{4})\s+\d+\s+the label index", scratch, re.M)
if not first:
problems.append("scratch.asm no longer states where its buffers begin")
else:
built = subprocess.run(["./Assembler", "-I", "Programs/CosmOS/Source",
"-I", "Programs/CosmOS/Assembler",
"Programs/CosmOS/Assembler/Asm.asm", "-o", os.devnull],
capture_output=True, text=True)
# A loadable program reports "Data: N bytes at 0xAAAA"; a boot image says it another
# way. The assembler is the former, and the address matters as much as the size.
said = re.search(r"Data:\s*(\d+) bytes at 0x([0-9A-Fa-f]{4})", built.stdout)
if not said:
problems.append("could not measure the native assembler's data")
else:
ends = int(said.group(2), 16) + int(said.group(1))
if ends > int(first.group(1), 16):
problems.append(
"the native assembler's data reaches 0x%04X and its scratch map begins at"
" 0x%s - the buffers are on top of the variables"
% (ends - 1, first.group(1).upper()))
# ---- The Test Manual against the suite it describes ----
#
# The suite documents itself, and a document about the suite goes stale the same way every
# other one does. Two claims in the README went stale at once before this check existed and
# neither was noticed: it said FIVE more scripts run alongside run.sh when there were six,
# and "all three tools" when there were four. Both are the kind of number that is written
# once, is true for months, and is then quietly wrong - which is the entire subject of this
# file.
#
# The bullets now live in the Test Manual rather than the README, so that is what is read.
# makedisks.sh is not counted, because it builds the images rather than checking anything,
# and break.sh is not counted for the same reason turned round: it checks that a check works,
# is run by hand at the moment a check is written, and is not part of what "make test" means.
# Both are still described in the manual - what they are excluded from is the COUNT of the
# suite, not from being documented, and the check below enforces that.
# run.sh is counted, because the manual describes it alongside the rest.
rootReadme = open("README.md").read()
manual = open("SplitBit Test Manual.md").read()
# THE MANUAL IS WRAPPED, so a number and the noun it counts are regularly on two different
# lines. Every pattern below runs against a copy with its whitespace flattened.
flat = re.sub(r"\s+", " ", manual)
notSuite = ("makedisks.sh", "break.sh")
scripts = sorted(os.path.basename(p) for p in glob.glob("Tests/*.sh")
if os.path.basename(p) not in notSuite)
# Spelled out, because that is how the documents say them. Kept a few ahead of the count so
# that adding a script fails on the number being wrong rather than on the word being unknown,
# which is a much less helpful thing to be told.
words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9,
"ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14}
said = re.search(r"It is ([a-z]+) scripts making", flat)
if not said:
problems.append("the Test Manual no longer says how many scripts the suite is")
elif words.get(said.group(1)) != len(scripts):
problems.append("the Test Manual says the suite is %s scripts, and there are %d: %s"
% (said.group(1), len(scripts), ", ".join(scripts)))
for name in scripts:
if ("`Tests/%s`" % name) not in manual:
problems.append("Tests/%s runs in the suite and the Test Manual does not say what"
" it is for" % name)
# ---- And the two that are not in the suite are still described ----
#
# Being left out of the COUNT is not the same as being left out of the manual, and the gap
# between those two is exactly where a script goes undocumented for months. A tool nobody has
# written down is a tool nobody uses, which for break.sh would be a particular waste: it
# exists because the technique it automates was got wrong by hand twice.
for name in notSuite:
if ("`Tests/%s`" % name) not in manual:
problems.append("Tests/%s is a tool the suite does not count, and the Test Manual"
" does not say what it is for" % name)
# ---- The shape of the manifest, which the manual states outright ----
#
# Five numbers in one sentence, all of them countable from the file they describe. This is
# the most quotable thing in the manual and the least likely to be recounted by hand.
modes = {}
total = 0
for line in open("Tests/manifest"):
line = line.strip()
if not line or line.startswith("#"):
continue
total += 1
fields = line.split("|")
if len(fields) > 2:
modes[fields[2].strip()] = modes.get(fields[2].strip(), 0) + 1
said = re.search(r"(\d+) tests, of which (\d+) run, (\d+) only assemble,"
r" (\d+) are expected to fail to assemble, and (\d+) boot from ROM", flat)
if not said:
problems.append("the Test Manual no longer states the shape of the manifest")
else:
for index, (what, count) in enumerate((("tests", total),
("run tests", modes.get("run", 0)),
("assemble-only tests", modes.get("assemble", 0)),
("xfail tests", modes.get("xfail", 0)),
("rom tests", modes.get("rom", 0)))):
if int(said.group(index + 1)) != count:
problems.append("the Test Manual says there are %s %s, and there are %d"
% (said.group(index + 1), what, count))
said = re.search(r"The (\d+) `xfail` tests", flat)
if said and int(said.group(1)) != modes.get("xfail", 0):
problems.append("the Test Manual says %s xfail tests in one place and %d in another"
% (said.group(1), modes.get("xfail", 0)))
# ---- And the fixtures and the baseline ----
disks = len(re.findall(r'format "\$DISKS/', open("Tests/makedisks.sh").read()))
said = re.search(r"builds (\d+) images with SplitDisk", flat)
if not said:
problems.append("the Test Manual no longer says how many fixture disks are built")
elif int(said.group(1)) != disks:
problems.append("the Test Manual says %s fixture disks are built, and makedisks.sh"
" builds %d" % (said.group(1), disks))
pairs = sum(1 for line in open("Tests/lint-baseline.txt") if line.strip())
said = re.search(r"(\d+) file-and-rule pairs", flat)
if not said:
problems.append("the Test Manual no longer says how large the lint baseline is")
elif int(said.group(1)) != pairs:
problems.append("the Test Manual says the lint baseline holds %s file and rule pairs,"
" and it holds %d" % (said.group(1), pairs))
# ---- And the tool count is the number of things the makefile builds ----
#
# Claimed in both documents, so both are read.
makefile = open("makefile").read()
# TOOLS rather than the all target, which now depends on whether Raylib is installed.
# What "the tools" means should be a fact in one place, not read off a conditional.
built = re.search(r"^TOOLS = (.*)$", makefile, re.M)
if not built:
problems.append("the makefile no longer has a TOOLS list this can count")
else:
tools = len(built.group(1).split())
for where, text in (("README", rootReadme), ("Test Manual", manual)):
for said in re.findall(r"(?:build|rebuild)s? (?:all )?(?:the )?([a-z]+) tools",
text, re.I):
if words.get(said.lower()) != tools:
problems.append("the %s says the %s tools and the makefile builds %d"
% (where, said, tools))
# ---- The sizes the CosmOS README quotes for its own programs ----
#
# It said Files was 645 bytes in two places and Edit 1,983. They were 665 and 1,996: the
# programs kept being improved and the sentences about how small they are did not move.
# These are the most quotable numbers in the document and the least likely to be rechecked
# by hand, so they are measured.
#
# A claim is "<something naming an app> ... in N bytes" on one line. Only lines that name
# an app are looked at, so ordinary prose about bytes is left alone.
apps = {}
for source in glob.glob("Programs/CosmOS/Apps/*.asm"):
apps[os.path.basename(source)[:-4]] = source
for line in readme.split("\n"):
said = re.search(r"\b(?:in|to) ([\d,]+) bytes", line)
if not said:
continue
named = [name for name in apps
if re.search(r"(?:`|\| )%s(?:\.asm)?\b" % re.escape(name), line)]
if len(named) != 1:
continue
built = subprocess.run(["./Assembler", "-I", "Programs/CosmOS/Source",
apps[named[0]], "-o", os.devnull],
capture_output=True, text=True)
real = re.search(r"Total size: (\d+) bytes", built.stdout)
if not real:
problems.append("could not measure %s, which the CosmOS README quotes a size for"
% named[0])
elif int(said.group(1).replace(",", "")) != int(real.group(1)):
problems.append("the CosmOS README says %s is %s bytes and it is %s"
% (named[0], said.group(1), real.group(1)))
if problems:
print("The manuals and the code disagree:")
for p in problems:
print(" " + p)
sys.exit(1)
print("The manuals agree with the code.")
PY