#!/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())