#!/usr/bin/env python3
"""Audit the promoted tier against every item of `PROMPTS.md`'s checklist.
`PROMPTS.md` lists eleven conditions a project must satisfy before it is
promoted. This checks all of them, for every promoted project, and reports
what is left. It is a checker, not a fixer: it never edits a project.
1. `make` prints **Byte-identical ROM verified.** (only with --build)
2. no raw `$2000-$4017` hardware literal in an operand
3. no raw `$XXXX` jump/branch target
4. every `ZpXX` reference is a slot `locals.inc` documents
5. no generic placeholder name
6. every neutral `ZpXX` slot carries a recorded reason
7. the README carries a power-on flow analysis and a data-table inventory
8. `apply_symbols.py` leaves no resolvable operand numeric
9. `CHANGELOG.md` carries the project's milestone rows
10. every data block in the PRG has an inventory row
11. every routine header describes its own routine
Usage:
python3 _tools/audit_promoted.py [--build] [--project
]
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MAPPERS = ("NROM", "CNROM", "UNROM", "MAPPER8")
# an instruction line, with its operand and its audit comment
INSN = re.compile(
r"^ (?P[A-Za-z@][A-Za-z0-9_]*)\s+(?P\S*)\s*; "
r"CPU:\$(?P[0-9A-F]{4,6}) PRG:(?P[0-9A-F]{4,6})\s+"
r"(?P(?:[0-9A-F]{2} ?)+)\s*$")
# a data block header
BLOCK = re.compile(
r"^; ---- data \$([0-9A-F]{4})-\$([0-9A-F]{4}) \((\d+) bytes\) ----$")
# a raw hardware address in an operand. The NES hardware is the PPU's
# eight registers at $2000-$2007, the four test registers at $4000-$4003
# and the four I/O registers at $4014-$4017; $2008-$3FFF is ordinary RAM
# and an absolute address there is not a hardware literal.
HW = re.compile(r"\$(200[0-7]|400[0-3]|401[4-7])\b")
# a raw jump/branch target
RAWTGT = re.compile(r"^\$(?:jsr|jmp)\s+\$[0-9A-F]{4}\s*;")
RAWBR = re.compile(r"^\s*b(?:cc|bcs|beq|bmi|bne|bpl|bvc|bvs)\s+\$[0-9A-F]{4}\s*;")
# the names that are placeholders rather than names
PLACEHOLDER = re.compile(
r"^(?:Actor|Object|Entity|DataTable\d*|Buffer|Sprite\d*|"
r"LookupTable_[0-9A-F]{4}|RomData_[0-9A-F]{4}|"
r"Label_[0-9A-F]{4}|UnnamedData|UnreachedData\d*)$")
# a name that is only the address it sits at
ADDRESS_DERIVED = re.compile(
r"^(?:[A-Za-z][A-Za-z0-9]*_)?(?:SubC|Sub|Label)_?[0-9A-F]{4}$")
# a branch and a jump, with their operand
BRANCH = re.compile(r"^\s*b(?:cc|bcs|beq|bmi|bne|bpl|bvc|bvs)\s+(\S+)\s*;")
JUMP = re.compile(r"^\s*(?:jsr|jmp)\s+(\S+)\s*;")
# a header that talks about the work rather than the code
PASSREF = re.compile(
r"Role not established|Undetermined in Pass|Contextual control-flow entry|"
r"Address-derived routine entry|Pass 1 reachable entry|"
r"will be refined in Pass|be refined during later passes|"
r"semantic naming will be refined")
# an inventory row, in either the table form or the bullet form
INVROW = re.compile(r"^(?:\|\s*`?\$[0-9A-F]{4}|-\s*`?\$[0-9A-F]{4})")
INVHDR = re.compile(r"^#{1,4} .*data[- ]table inventory", re.I | re.M)
def sources(proj):
"""Every source file of a project, in the order they are assembled."""
d = ROOT / proj
out = []
for f in ("prg.s", "prg.6.s", "prg.07.s", "prg.0.s", "prg.1.s"):
p = d / f
if p.is_file():
out.append((f, p.read_text().split("\n")))
return out
def hardware_names(proj):
"""The hardware addresses `symbols.inc` gives a name to."""
p = ROOT / proj / "symbols.inc"
if not p.is_file():
return set()
out = set()
for m in re.finditer(r"^([A-Z][A-Z0-9_]*)\s*=\s*\$([0-9A-F]{4})", p.read_text(), re.M):
out.add(int(m.group(2), 16))
return out
def audit(proj, build):
"""Return {check number: [findings]} for one project."""
d = ROOT / proj
res = {}
def bad(n, msg):
res.setdefault(n, []).append(msg)
src = sources(proj)
text = "\n".join("\n".join(t) for _f, t in src)
syms = hardware_names(proj)
# 2. raw hardware literals
for f, lines in src:
for i, line in enumerate(lines, 1):
m = INSN.match(line)
if not m:
continue
for h in HW.findall(m.group("op")):
a = int(h, 16)
if a not in syms:
bad(2, f"{f}:{i}: ${h} has no name in symbols.inc")
else:
bad(2, f"{f}:{i}: ${h} is a raw literal, not the "
f"symbols.inc name")
# 3. raw jump and branch targets
for f, lines in src:
for i, line in enumerate(lines, 1):
if RAWTGT.match(line) or RAWBR.match(line):
bad(3, f"{f}:{i}: {line.strip()[:60]}")
# 4. a ZpXX reference that locals.inc does not document
loc = (d / "locals.inc").read_text() if (d / "locals.inc").is_file() else ""
documented = set(re.findall(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=", loc, re.M))
used = set()
for f, lines in src:
for i, line in enumerate(lines, 1):
m = INSN.match(line)
if not m:
continue
for z in re.findall(r"\bZp[0-9A-F]{2}\b", m.group("op")):
used.add(z)
if z not in documented:
bad(4, f"{f}:{i}: {z} is not in locals.inc")
# 5. placeholder names. A branch target must carry a role name; an
# address-derived name on a `jsr`/`jmp` entry point is the form
# `PROMPTS.md` accepts when the dump does not reveal a better one,
# so it is counted rather than failed.
brt = set()
jt = set()
for f, lines in src:
for line in lines:
m = BRANCH.match(line)
if m:
brt.add(m.group(1))
m = JUMP.match(line)
if m:
jt.add(m.group(1))
for f, lines in src:
for i, line in enumerate(lines, 1):
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*$", line)
if not m:
continue
name = m.group(1)
if name in brt and ADDRESS_DERIVED.match(name):
bad(5, f"{f}:{i}: {name} is a branch target and has no role name")
elif name not in brt and name in jt and ADDRESS_DERIVED.match(name):
res.setdefault("5b", []).append(f"{f}: {name}")
elif PLACEHOLDER.match(name):
bad(5, f"{f}:{i}: {name} is a placeholder name")
# 6. a neutral ZpXX slot without a recorded reason
for i, line in enumerate(loc.split("\n"), 1):
m = re.match(r"^(Zp[0-9A-F]{2})\s*=\s*\$([0-9A-F]{2})\s*(.*)$", line)
if m and not m.group(3).strip():
bad(6, f"locals.inc:{i}: {m.group(1)} has no reason recorded")
# 7. the README's two sections
rd = (d / "README.md").read_text() if (d / "README.md").is_file() else ""
if "## Power-on flow analysis" not in rd:
bad(7, "README.md has no power-on flow analysis")
if not INVHDR.search(rd):
bad(7, "README.md has no data-table inventory")
# 10. every data block has an inventory row. A row counts when it
# holds a $XXXX address in any of its cells - the address may be
# the first column or the second.
blocks = 0
for f, lines in src:
blocks += sum(1 for l in lines if BLOCK.match(l))
if INVHDR.search(rd):
rows = 0
for l in rd.split("\n"):
if not l.lstrip().startswith(("|", "-")):
continue
if re.search(r"\$[0-9A-F]{4}", l):
rows += 1
if rows < blocks:
bad(10, f"{blocks} data block(s) in the source, {rows} inventory row(s)")
# 11. a header that refers to the work rather than the code
for f, lines in src:
for i, line in enumerate(lines, 1):
if PASSREF.search(line):
bad(11, f"{f}:{i}: {line.strip()[:60]}")
# 8. `apply_symbols.py` leaves no resolvable operand numeric - or the
# project records why the one that is left has to stay
r = subprocess.run([sys.executable, str(ROOT / "_tools" / "apply_symbols.py"),
proj, "--report"], capture_output=True, text=True,
cwd=str(ROOT))
rd = (d / "README.md").read_text() if (d / "README.md").is_file() else ""
for line in r.stdout.split("\n"):
m = re.search(r"multi-alias residue left numeric: (\d+)", line)
if m and int(m.group(1)):
for detail in r.stdout.split("\n"):
if "candidates=" in detail:
addr = detail.split(": ")[1].split()[0]
if addr in rd and ("residue" in rd.lower()
or "numeric" in rd.lower()):
continue # the project says why it stays
bad(8, detail.strip()[:76])
# 9. the changelog carries the project's milestone rows
cl = ROOT / "CHANGELOG.md"
if cl.is_file():
text = cl.read_text()
name = proj.split("/", 1)[1]
if f"### {name}" not in text and f"`{name}`" not in text:
bad(9, "CHANGELOG.md carries no row for this project")
if build:
r = subprocess.run(["make"], cwd=d, capture_output=True, text=True)
if "Byte-identical ROM verified." not in r.stdout:
bad(1, r.stdout.strip().split("\n")[-1][:70] if r.stdout else "no output")
return res
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--build", action="store_true",
help="run `make` in every project (slow)")
ap.add_argument("--project", action="append", default=[],
help="audit only this project (repeatable)")
args = ap.parse_args()
projects = []
for base in MAPPERS:
for d in sorted((ROOT / base).iterdir()):
if d.is_dir() and (d / "prg.s").is_file():
projects.append(f"{base}/{d.name}")
if args.project:
projects = [p for p in projects if p in args.project]
tally = {}
res_info = {}
for proj in projects:
res = audit(proj, args.build)
info = res.pop("5b", None)
if info:
res_info[proj] = len(info)
if not res:
print(f"ok {proj}")
else:
print(f"FAIL {proj}")
for n in sorted(res):
tally.setdefault(n, 0)
tally[n] += len(res[n])
for msg in res[n][:4]:
print(f" [{n}] {msg}")
if len(res[n]) > 4:
print(f" [{n}] ... and {len(res[n]) - 4} more")
print()
print(f"{len(projects)} project(s) audited")
for n in sorted(tally):
print(f" check {n}: {tally[n]} finding(s)")
for n in sorted(res_info):
print(f" check {n}: {res_info[n]} address-derived entry point(s) - "
f"the form PROMPTS.md accepts when the dump reveals no better name")
return 1 if tally else 0
if __name__ == "__main__":
sys.exit(main())