Files
BFM-decomp/tools/kit_lint.py
T
Drew T 9235800fb7 tools+docs(phase-33.5): task 11 — kit part 2: the firewall pack (templates/gitignore.decomp extracted byte-for-byte from the wiki fence — gitignore_template_check now runs in tools-health; firewall.txt with purge:/glob:/required:/pending:/fixture: rules; audit_public.template.py generalised from the repo's audit with its sources in the config, refusing zero sources; firewall-fixture/ = 16 synthetic bytes + sha1, the planted negative control), no-rom.template.yml, the docs/.run READMEs, ops-setup.decomp.md, bootstrap.template.sh (skeleton), CLAUDE.decomp-overlay.md (the four fail-safes + session-start extras), pa-overlays.md (7 fenced blocks: DIGEST, the 🛑 checkpoint block, the PhaseEnd narrative axis, effort rows, cookbook entry shape + triage table, wave-playbook skeleton, settings/mcp), the LICENSE/NOTICE/README/CONTRIBUTING skeletons, .clang-format + make-format.snippet.mk; tools/MANIFEST.md (325 tool files by ladder phase from one read-only survey, coverage 325/325, as Phase-N tasks); tools/kit_lint.py (fence-aware leak grep, the PLACEHOLDERS set-diff, in-memory compile / bash -n / JSON+YAML, the gitignore diff, TODO counts, coverage; --selftest = the R39 control) wired into tools-health; decomp-architect/README.md in doc_links DEFAULT; SETUP row; PLACEHOLDERS Used-in cells reconciled; make tools-health OK on this tree (detached run, .run/P33.5/tools_health_t11.log); story-timeline regenerated by the report step; log + checkpoint (NEXT = task 12, Max)
2026-09-07 19:22:51 -06:00

179 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""kit_lint.py — the day-one decomp kit (decomp-architect/) stays free of the source project's names, paths, addresses and
rule numbers, honours its own placeholder contract, and ships scripts that parse (P33.5 task 11; in tools-health).
tools/kit_lint.py # the whole package: every file under decomp-architect/
tools/kit_lint.py --paths a b … # an explicit file list (the checks that make sense per file)
tools/kit_lint.py --selftest # the R39 control: a planted leak line and a planted unlisted placeholder MUST fail
Six checks, each printed with its denominator (R41), exit 1 on any finding, exit 2 when the package is absent (R43):
1. LEAK — no line outside a ```calibration fence and off a `provenance:` line matches the pattern
`SLUS|Musashi|BFM|Druthulu|func_80|ov_SC|/home/musashi|/mnt/z|172\\.17\\.|\\bR[0-9]{1,2}\\b|§[0-9]+` (the kit refers to rules
by G-id, to kernels by DK-id, to sections by "step N"; source-project figures live only inside calibration fences).
The fence-aware form matters: `grep -v calibration` drops only the lines containing that WORD, not the fence contents.
2. PLACEHOLDERS — the set of `{{NAME}}` tokens used anywhere under the package equals the set listed (backticked) in
templates/PLACEHOLDERS.md; a used-but-unlisted or listed-but-unused placeholder fails (the contract tasks 11/12 honour).
3. SYNTAX — every `*.sh` passes `bash -n`; every `*.py` compiles (the `compile` builtin, in memory — no bytecode is written into the kit); every `*.yml`/`*.json` block parses
(JSON only for files whose whole content is JSON).
4. GITIGNORE — templates/gitignore.decomp equals the ROM-firewall wiki page's fence (delegated to
tools/gitignore_template_check.py; rc 0 required).
5. TODO counts — `TODO(platform)` and `TODO(phase-N)` occurrences, informational, with the file count as denominator
(the README says how many remain).
6. COVERAGE — the number of files scanned is printed; zero files is a failure, never a pass.
Controls (R39): `--selftest` writes a leak line, a fenced (allowed) line, a provenance (allowed) line and an unlisted
placeholder into a scratch file under .run/ and asserts checks 1 and 2 catch exactly the two planted defects.
"""
import json
import pathlib
import re
import subprocess
import sys
REPO = pathlib.Path(__file__).resolve().parent.parent
KIT = REPO / "decomp-architect"
PLACEHOLDERS_MD = KIT / "templates" / "PLACEHOLDERS.md"
LEAK_RE = re.compile(r"SLUS|Musashi|BFM|Druthulu|func_80|ov_SC|/home/musashi|/mnt/z|172\.17\.|\bR[0-9]{1,2}\b|§[0-9]+")
PH_RE = re.compile(r"\{\{[A-Z_0-9]+\}\}")
PH_LISTED_RE = re.compile(r"`(\{\{[A-Z_0-9]+\}\})`")
def kit_files():
return sorted(p for p in KIT.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
def leak_lines(text):
"""(lineno, line) for every leak outside a calibration fence and off a provenance line."""
out, in_cal = [], False
for i, line in enumerate(text.splitlines(), 1):
if line.startswith("```"):
if in_cal:
in_cal = False
continue
if line.strip().startswith("```calibration"):
in_cal = True
continue
if in_cal or "provenance:" in line:
continue
if LEAK_RE.search(line):
out.append((i, line))
return out
def placeholders_used(files):
used = {}
for p in files:
for m in PH_RE.findall(p.read_text(encoding="utf-8", errors="replace")):
used.setdefault(m, set()).add(p)
return used
def placeholders_listed():
if not PLACEHOLDERS_MD.exists():
sys.exit(f"kit_lint: {PLACEHOLDERS_MD.relative_to(REPO)} missing — the placeholder contract cannot be checked (R43)")
return set(PH_LISTED_RE.findall(PLACEHOLDERS_MD.read_text(encoding="utf-8")))
def check_syntax(files):
bad = []
for p in files:
try:
if p.suffix == ".sh":
r = subprocess.run(["bash", "-n", str(p)], capture_output=True, text=True)
if r.returncode:
bad.append((p, r.stderr.strip().splitlines()[-1] if r.stderr.strip() else "bash -n failed"))
elif p.suffix == ".py":
compile(p.read_text(encoding="utf-8"), str(p), "exec") # in memory: never writes bytecode into the kit
elif p.suffix == ".json":
json.loads(p.read_text(encoding="utf-8"))
elif p.suffix in (".yml", ".yaml"):
try:
import yaml # noqa: F401
except ImportError:
continue
yaml.safe_load(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 — the message is the finding
bad.append((p, str(e).splitlines()[0]))
return bad
def run_checks(files, do_gitignore=True):
findings = 0
n = len(files)
if n == 0:
print("kit_lint: 0 files to check — refusing to pass on an empty package (R43)")
return 2
# 1 LEAK
leaks = 0
for p in files:
for ln, line in leak_lines(p.read_text(encoding="utf-8", errors="replace")):
leaks += 1
print(f" LEAK {p.relative_to(REPO)}:{ln}: {line.strip()[:110]}")
print(f"kit_lint: leak check — {leaks} finding(s) over {n} files (calibration fences + provenance lines exempt)")
findings += leaks
# 2 PLACEHOLDERS
used = placeholders_used(files)
listed = placeholders_listed()
extra, unused = sorted(set(used) - listed), sorted(listed - set(used))
for ph in extra:
print(f" PLACEHOLDER used but not listed in PLACEHOLDERS.md: {ph} ({', '.join(str(q.relative_to(REPO)) for q in sorted(used[ph])[:3])})")
for ph in unused:
print(f" PLACEHOLDER listed but used nowhere: {ph}")
print(f"kit_lint: placeholders — used {len(used)}, listed {len(listed)}, unlisted {len(extra)}, unused {len(unused)}")
findings += len(extra) + len(unused)
# 3 SYNTAX
bad = check_syntax(files)
for p, why in bad:
print(f" SYNTAX {p.relative_to(REPO)}: {why}")
n_scripts = sum(1 for p in files if p.suffix in (".sh", ".py", ".json", ".yml", ".yaml"))
print(f"kit_lint: syntax — {len(bad)} failure(s) over {n_scripts} script/config files")
findings += len(bad)
# 4 GITIGNORE
if do_gitignore:
r = subprocess.run([sys.executable, str(REPO / "tools" / "gitignore_template_check.py")], capture_output=True, text=True)
print(f"kit_lint: gitignore template — {(r.stdout.strip().splitlines() or ['(no output)'])[-1]}")
if r.returncode:
findings += 1
# 5 TODO counts (informational)
text = "\n".join(p.read_text(encoding="utf-8", errors="replace") for p in files)
print(f"kit_lint: TODO(platform) {text.count('TODO(platform)')}, TODO(phase-N) {len(re.findall(r'TODO\(phase-\d+\)', text))} over {n} files")
return 1 if findings else 0
def selftest():
scratch = REPO / ".run" / "kit_lint_selftest"
scratch.mkdir(parents=True, exist_ok=True)
f = scratch / "planted.md"
f.write_text(
"# planted control\n"
"a leak line that names the source project: BFM and its rule R32\n" # MUST be caught
"```calibration\nsource project: BFM 218/218, R22 after every batch\n```\n" # allowed
"provenance: BFM R32 (Phase 26)\n" # allowed
"an unlisted placeholder {{NOT_IN_THE_CONTRACT}}\n", # MUST be caught
encoding="utf-8")
leaks = leak_lines(f.read_text(encoding="utf-8"))
used = placeholders_used([f])
extra = set(used) - placeholders_listed()
ok = len(leaks) == 1 and leaks[0][0] == 2 and extra == {"{{NOT_IN_THE_CONTRACT}}"}
print(f"kit_lint --selftest: leak lines caught {len(leaks)} (expected 1, at line 2: {leaks[0][0] if leaks else '-'}), "
f"unlisted placeholders caught {sorted(extra)} (expected ['{{{{NOT_IN_THE_CONTRACT}}}}']) → {'OK' if ok else 'FAIL'}")
f.unlink()
return 0 if ok else 1
def main(argv):
if "--selftest" in argv:
return selftest()
if not KIT.is_dir():
print(f"kit_lint: {KIT.relative_to(REPO)} does not exist — nothing to lint (R43)")
return 2
if "--paths" in argv:
files = [pathlib.Path(a).resolve() for a in argv[argv.index("--paths") + 1:]]
return run_checks(files, do_gitignore=False)
rc = run_checks(kit_files())
print("kit_lint: OK" if rc == 0 else f"kit_lint: FAIL (rc {rc})")
return rc
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))