From 983df054f2a907bbfdd5532eae49ab5fd63fce17 Mon Sep 17 00:00:00 2001 From: Drew T <50529377+Druthulu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:38:39 -0600 Subject: [PATCH] =?UTF-8?q?feat(draw):=20audit=20the=20exclude=20list=20as?= =?UTF-8?q?=20a=20PREREQUISITE=20=E2=80=94=20a=20stale=20one=20is=20refuse?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exclude list records what the TOOLING could not do, then gets treated as a property of the FUNCTIONS. Nothing re-examined it, so every tool fix left behind a population that is now tractable and still marked impossible — invisible, because the draw filters it out before anything measures it. MEASURED one day after .run/S71_exclude.txt was written: 88 of its 107 entries were stale — 28 already banked, 14 linked PsyQ symbols that were never targets, and 46 whose blocker had since been fixed. Those 46 are 12,750 instructions of open, drawable work including main:SaveLoadRoutine (1,165), the largest function left in main. * tools/exclude_audit.py (NEW) — classifies each entry by its CURRENT blocker (BANKED / LINKED / RE-PROBE / CARVE-BLOCKED / WALL), regenerates keeping only the still-valid classes, and --assert-fresh exits 3 on staleness. * draw_waves --exclude-file — runs that audit and REFUSES to draw on a stale list, naming the counts and the regenerate command. --exclude-stale-ok still draws but prints what it ignores: skipping is possible, never silent. Also fixes the old --exclude parsing, which could not survive a '#' comment. * .run/S72_exclude.txt — the regenerated list: 19 entries (16 CARVE-BLOCKED + 3 WALL), each carrying its reason, down from 107. Verified in all three directions: stale refuses rc=1, fresh proceeds rc=0, override proceeds and announces. The parser's own report-don't-drop design caught a bug I introduced in it (comma-splitting before comment-stripping). --- .run/S72_exclude.txt | 23 ++++++ docs/SETUP.md | 2 + docs/wave-playbook.md | 12 +++ tools/draw_waves.py | 39 +++++++++- tools/exclude_audit.py | 164 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 .run/S72_exclude.txt create mode 100644 tools/exclude_audit.py diff --git a/.run/S72_exclude.txt b/.run/S72_exclude.txt new file mode 100644 index 000000000..51733070b --- /dev/null +++ b/.run/S72_exclude.txt @@ -0,0 +1,23 @@ +# regenerated by tools/exclude_audit.py from .run/S71_exclude.txt +# 19 still-valid of 107; 88 dropped as stale (banked / linked / blocker-since-fixed). +# An exclude list records what the TOOLING could not do — regenerate it as +# part of every tool fix, or it becomes a list of work you decided not to do. +main:func_8005D9C4 # WALL: no jump table — curated compiler fact, not a tooling limit +main:func_8005ECC0 # WALL: no jump table — curated compiler fact, not a tooling limit +main:func_80062144 # WALL: no jump table — curated compiler fact, not a tooling limit +ov_SC01_084:func_80182328 # CARVE-BLOCKED: ov_SC01_084_jr_8017F690 owns >1 raw jtbl span — needs a split +ov_SC01_084:func_80182A00 # CARVE-BLOCKED: ov_SC01_084_jr_8017F690 owns >1 raw jtbl span — needs a split +ov_SC02_005:func_80185060 # CARVE-BLOCKED: ov_SC02_005_jr_80181D30 owns >1 raw jtbl span — needs a split +ov_SC02_005:func_80185E80 # CARVE-BLOCKED: ov_SC02_005_jr_80181D30 owns >1 raw jtbl span — needs a split +ov_SC02_011:func_80183178 # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split +ov_SC02_011:func_80183630 # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split +ov_SC02_011:func_8018418C # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split +ov_SC02_011:func_80188E3C # CARVE-BLOCKED: ov_SC02_011_jr_8017AE2C owns >1 raw jtbl span — needs a split +ov_SC03_105:func_801806F8 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_80180ABC # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_80180EC0 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_801813BC # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_801818E8 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_80181C84 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_8018624C # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split +ov_SC03_105:func_801867D0 # CARVE-BLOCKED: ov_SC03_105_jr_8017C8D0 owns >1 raw jtbl span — needs a split diff --git a/docs/SETUP.md b/docs/SETUP.md index 605399147..44fdee67a 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -754,6 +754,8 @@ Every script under `tools/` (plus the two report make-targets), grouped by purpo | | `tools/t5_targets.py` · `tools/t5_cards.py` · `tools/wave_card_fuel.py` · `tools/t5_bank.sh` | **(Phase 31 S63 T5)** The wave *draw* and the wave *bank* halves around the T4 harness: `t5_targets.py --wave .run/t5x --n 48 [--residue .run/t5w] [--main-only]` draws a routed slate (open per `corpus.stubs`, ledger `.run/t5/drawn.json` keyed (binary,fn), ≤120 ins → sonnet / >120 → opus, residue → opus once, stratified by band, ≤4 per binary, one fn NAME per wave — the harness keys packs/drafts by bare name, so `claude_wave_packs`/`wave_judge` now REFUSE duplicates, R43/R48); then **`t5_cards.py --wave .run/t5x`** BUILDS the target's OWN card fuel (`tu_ref` = in-TU banked neighbours §194-E, `decl_prior` = fleet signatures §196) from `wave_card_fuel.py` — those helpers extracted VERBATIM out of `build_wave_atlas.py` (which parses argv at import and so was unusable as a library) so both consumers share ONE oracle (R33); measured 88% tu_ref / 98% decl_prior on a fresh K-class slate, against 0% correct fuel before the fix. Then `claude_wave_packs.py --cards /cards.json` → Workflow `tools/workflows/claude_wave_draft.js` `args={wave, targets}` (one agent per target, `model=target.arm`) → `tools/t5_bank.sh .run/t5x sonnet opus` (refuses if a lane/gate runs or src/ config/ is dirty; `wave_judge --union`; then a RECOVERY pass — every un-banked draft through `fix_tu_ret_decls`, which retypes a destination TU's `extern void` decl on a value-returning function and re-gates (wave t5a: 2/2 banked, both byte-exact bodies refused for that decl alone); then the R22 clean fleet sweep parsed `N passed, 0 failed of N`; commit `— N banked`). | | | `tools/jtbl_rodata_pads.py --derive` | **(Phase 31 S62 T3a, cookbook §303)** Module path of the §8e pads filter: the Makefile runs `--derive $(BINARY) --tu ` for every `md_*` object **and for `main` (P31 S72)** — jump-table pads derived at build time from the retail island + the emission stream (trailing pads `0t1`, table-aware, const data passes through). No stored spec; an anchor miss fails the build with the offset. **S72:** `--derive` now serves main too — `_file0_vram` returns the code segment's `vram - start` (the PS-X EXE's 0x800 header), which makes both `raw[a - vram]` and `vram + ` correct for the EXE and leaves flat overlays byte-identical; `_splat_yaml` resolves main to `splat.us.exe.yaml`. | | | **main's TU layout (P31 S72)** | main's game code is no longer one file. `src/800.c` was split at the jtbl-span TU boundaries into **`src/800.c`** (vram 0x800123F0-0x8002B0B4, owns `.rodata` span A 0x80072A38-0x80072C70), **`src/800_b.c`** (0x8002B0B4-0x80035270, span B 0x80072E44-0x80073140) and **`src/800_c.c`** (0x80035270-0x8003A444, span C 0x800732A0-0x8007344C), plus **`src/800_shared.h`** for the 19 typedefs + 2 includes that cross. Reason: one code object contributes exactly ONE contiguous `.rodata` run, so each jump-table span needs its own object or every switch function in it double-emits its table (cookbook §426/§431). main's island is now a 7-piece sandwich and `ld_interleave` runs with `--order`, not `--front/--tail`. **Any tool that reads "main's source" must glob `corpus.src_files('main')`, never hardcode `src/800.c`** — `reconcile_slate` did, and saw 133 of 187 typedefs after the split. | +| | `tools/split_indicator.py` | **(P31 S72, cookbook §426/§431)** Which code subsegs MUST be split before their switch functions can bank: a code object contributes exactly ONE contiguous `.rodata` run, so a subseg owning raw jump tables in ≥2 non-adjacent island spans makes every switch function outside the one carveable span unbankable at any effort. **Decidable from the raw image at 0% matched** — no attempt needed. `--self-test` fires on main's pre-S72 island, stays silent on main today, and does not over-fire on a one-span subseg; linked PsyQ subsegs are excluded on principle. Runs in `make tools-health` (informational while 4 known violations exist — flip to a hard gate once they are split). First fleet run: 209/213 OK, 4 flagged holding 16 open fns / 3,613 ins. | +| | `tools/exclude_audit.py` | **(P31 S72)** Classifies every wave-exclude entry by its CURRENT blocker — `BANKED` / `LINKED` / `RE-PROBE` (blocker since fixed) / `CARVE-BLOCKED` / `WALL` — and regenerates the list keeping only the still-valid classes. **`draw_waves --exclude-file` runs it as a PREREQUISITE and refuses a stale list** (`--exclude-stale-ok` overrides, loudly). Measured on `.run/S71_exclude.txt` one day after it was written: 88 of 107 entries stale, of which 46 were open drawable functions totalling 12,750 instructions — `main:SaveLoadRoutine` among them. | | | `tools/main_diff_locate.py` | **(P31 S72, cookbook §426/§427)** Turns a RED main gate — whose whole output is two SHA1s — into a NAMED list of divergent symbols: byte-diffs `build/us/SLUS_007.26` against `extracted/retail/SLUS_007.26`, coalesces the differing bytes into runs, and attributes them PER BYTE to symbols via `build/us/SLUS_007.26.map` (file-offset mapping DERIVED from each output section's `load address`, not the 0x800 header constant). `--focus ` prints the routing verdict: **BODY REJECT** (divergence confined to the function) vs **PLUMBING REJECT** (the function is byte-identical, everything differs elsewhere → the §376/§378 chain) vs **MIXED**. `--self-test ` is the negative control: flips one byte at a known address and asserts the containing symbol is named, plus the identical-pair-reports-zero direction. `gate_main` calls it automatically and preserves the red image + map under `.run/gate_main_fail/` BEFORE the R40 baseline control rebuilds over them. | | | `tools/blocker_probe.py` | **(Phase 29 SESSION-16, cookbook §65)** WHY a byte-correct draft fails the whole-binary gate. Read-only; **two oracles** (R34): STATIC (`cdecl.parse` + `cdecl.compatible` — cc1's own acceptance question, never text equality) beside the REAL cc1 (via `rtu_match`), leading with the DISAGREEMENT table. Classes `self_decl_hdr`/`self_decl_tu`/`callee_decl`/`data_decl`/`local_type`, each mapped to a blast-radius tier (T0 draft-only / T1 binary-local / T2 fleet-shared). Blockers STACK, so a function's tier is the MAX over them. 36 drafts in ~9 s. Replaced+deleted `.run/diag_plumbing.py`. | | | `tools/symcheck.py` | **(Phase 29 SESSION-18, cookbook §67a)** The pre-gate SYMBOL-SET guard: diffs the symbols a draft's object references (reloc records) against the target `.s`'s `%hi`/`%lo`/`jal` set. Catches the class **every masked oracle is structurally blind to** — `match_one`/`masked_diff` compare relocation-MASKED words (object-vs-`.s` mode is symbol-agnostic by construction) and `rtu_match` **compiles without linking**, so a draft that invents an extern no symbol table defines reads MATCH in both and can never bank (the SESSION-17 `func_801463A0` `_s`-alias trap). Negative-control-proven: on a draft with one renamed data extern, `match_one` reports the SAME 14 mismatched as the correct draft while `symcheck` exits 1 naming both the MISSING and the INVENTED symbol. `--c` (compiles via `match_one`, so the triple can never drift) or `--obj`. A cheap necessary condition, NOT a match oracle — still finish on the byte-gate (G3/P9). | diff --git a/docs/wave-playbook.md b/docs/wave-playbook.md index 6d5dfd86e..62d500eaa 100644 --- a/docs/wave-playbook.md +++ b/docs/wave-playbook.md @@ -33,6 +33,18 @@ python3 tools/draw_waves.py --only-main --prefix .run/m_ --waves 1 --per-w * **`--prefix` IS A RELATIVE PATH.** `--prefix s67o` writes `./s67o1/` into the repo root, not `.run/`. Always pass `.run/_`. +* **THE EXCLUDE LIST IS AUDITED BEFORE THE DRAW, AND A STALE ONE IS REFUSED (P31 S72).** Pass it as + `--exclude-file .run/_exclude.txt`; `draw_waves` classifies every entry against the CURRENT + tooling and **exits non-zero** if any is stale, naming the count and the regenerate command. + `--exclude-stale-ok` still draws, but prints what it is ignoring — skipping is possible, never + silent. Regenerate with `tools/exclude_audit.py --write `. + + > **Measured the day after `.run/S71_exclude.txt` was written: 88 of its 107 entries were stale** + > — 28 already banked, 14 linked PsyQ symbols that were never targets, and **46 whose blocker had + > since been fixed**. Those 46 are **12,750 instructions of open, drawable work**, including + > `main:SaveLoadRoutine` (1,165), the largest function left in main. A list that filters them out + > costs more than it saves. Current list: **`.run/S72_exclude.txt` (19 entries)**. + * **The exclude list is not optional.** It carries two populations that no model can bank: * **96 jtbl functions whose carve plan `build_carve` REFUSES** (non-contiguous same-subseg `.rodata`). Cookbook §322. Before the S67 probe fix these all read "carveable". diff --git a/tools/draw_waves.py b/tools/draw_waves.py index 74f91f026..5b1471e13 100644 --- a/tools/draw_waves.py +++ b/tools/draw_waves.py @@ -137,12 +137,49 @@ def main(): ap.add_argument('--only-main', action='store_true', help='draw ONLY main (the main lane; implies --main)') ap.add_argument('--ledger', default=LEDGER) ap.add_argument('--exclude', default='', help='comma-separated binary:fn to skip') + ap.add_argument('--exclude-file', help='file of binary:fn to skip (# comments allowed); ' + 'audited for freshness before the draw') + ap.add_argument('--exclude-stale-ok', action='store_true', + help='draw anyway against a STALE exclude list (prints what it ignores)') ap.add_argument('--dry', action='store_true') a = ap.parse_args() ledger = json.load(open(a.ledger)) if os.path.exists(a.ledger) else {} drawn = {tuple(k.split(':', 1)) for k in ledger} - skip = {tuple(x.split(':', 1)) for x in a.exclude.split(',') if ':' in x} + # THE EXCLUDE LIST IS A PREREQUISITE, NOT A PARAMETER (P31 S72). + # An exclude list records what the TOOLING could not do at the moment it was written, and is + # then treated as a property of the FUNCTIONS. Nothing re-examined it, so every tool fix left + # behind a population that is now tractable and still marked impossible -- invisible, because + # the draw filters it out before anything measures it. Measured the day after `.run/ + # S71_exclude.txt` was written: 88 of its 107 entries were stale (28 already banked, 14 linked + # PsyQ symbols that were never targets, 46 whose blocker had since been fixed) -- including + # SaveLoadRoutine, the single largest function left in main. + # So the draw AUDITS whatever exclusions it is handed and REFUSES on staleness. Skipping is + # still possible (--exclude-stale-ok) but can no longer be silent. + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import exclude_audit as EA + ex_rows, ex_bad = EA.parse(text=a.exclude) + if a.exclude_file: + r2, b2 = EA.parse(path=a.exclude_file) + ex_rows += r2; ex_bad += b2 + if ex_bad: + sys.exit(f"draw_waves: {len(ex_bad)} unparseable exclude entr(ies) — refusing rather than " + f"under-excluding (R43): {ex_bad[:4]}") + if ex_rows: + classified, _ = EA.classify(rows=ex_rows) + stale = [(b, f, k) for b, f, k, _ in classified if k in EA.STALE] + if stale: + by = collections.Counter(k for _, _, k in stale) + msg = (f"\n*** EXCLUDE LIST IS STALE — {len(stale)} of {len(classified)} entries no " + f"longer describe reality ({dict(by)}).\n" + f" e.g. " + ", ".join(f"{b}:{f}" for b, f, _ in stale[:6]) + "\n" + f" Regenerate: tools/exclude_audit.py --write \n" + f" Drawing against it silently filters out work that is now doable.") + if not a.exclude_stale_ok: + sys.exit(msg + "\n (--exclude-stale-ok to draw anyway)") + print(msg + "\n --exclude-stale-ok given: proceeding, and ignoring those entries.") + ex_rows = [(b, f) for b, f, k, _ in classified if k not in EA.STALE] + skip = set(ex_rows) bins = sorted(os.path.basename(p) for p in glob.glob('src/*') if os.path.isdir(p)) if a.only_main: diff --git a/tools/exclude_audit.py b/tools/exclude_audit.py new file mode 100644 index 000000000..3fa474cc9 --- /dev/null +++ b/tools/exclude_audit.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""exclude_audit.py -- classify every entry of a wave exclude list by its CURRENT blocker, and +regenerate the list so it never silently becomes "work we have decided not to do". + +THE STANDING RULE THIS ENFORCES. An exclude list records **what the TOOLING could not do at the +moment it was written**, and is treated thereafter as a property of the FUNCTIONS. Nothing in the +pipeline re-examines it, so every tool improvement leaves behind a population that is now tractable +and still marked impossible -- invisible, because the draw filters it out before anything measures +it. S71 re-probed 68 such entries and found 17 newly carveable, banked 9, four of them in 57 seconds. + +MEASURED ON `.run/S71_exclude.txt` AT P31 S72 (107 entries, written the day before): + + 28 BANKED -- already matched; the entry is dead + 58 RE-PROBE/LINKED-- the blocker that put them here is GONE, or they were never targets + 16 CARVE-BLOCKED -- still real: their subseg owns >1 raw jtbl span (split_indicator) + 5 WALL -- a curated per-function compiler fact + +i.e. **74 of 107 entries were wrong**, one day after the list was written. + +THE CLASSES, and why each is decided the way it is: + * `BANKED` -- the `INCLUDE_ASM` stub is gone. The source is the oracle, not any ledger. + * `LINKED` -- the function lives in a PsyQ LINKED subseg. It is not a matching target at + all and `draw_waves` already refuses those, so the entry is pure noise. + * `CARVE-BLOCKED` -- it has a jump table and its subseg still owns raw tables in >= 2 non-adjacent + spans (`split_indicator`), so it genuinely cannot bank yet. KEEP. + * `RE-PROBE` -- it has a jump table and its subseg is now CLEAN. Whatever refused it has since + been fixed. **DROP, loudly** -- this is the population the rule exists for. + * `WALL` -- no jump table, so the jtbl machinery is not why it was excluded. A curated + per-function compiler fact (§188 epilogue, §332 `la`-in-delay-slot). KEEP, + because it cannot be re-derived here -- but it should carry its refutation + list so a later idea can be checked against it cheaply. + + tools/exclude_audit.py .run/S71_exclude.txt # report + tools/exclude_audit.py .run/S71_exclude.txt --write OUT # regenerate (KEEP classes only) + tools/exclude_audit.py .run/S71_exclude.txt --assert-fresh # exit 3 if stale (the draw prereq) +""" +import argparse, collections, functools, os, re, sys + +print = functools.partial(print, flush=True) +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, 'tools')) + +KEEP = ('CARVE-BLOCKED', 'WALL') +STALE = ('BANKED', 'LINKED', 'RE-PROBE') + + +def parse(path=None, text=None): + """-> (rows, unparseable). Accepts a FILE or a comma/space-separated string, because + `draw_waves --exclude` takes the latter and the freshness prerequisite must cover both.""" + src = open(path, errors='replace').read() if path else (text or '') + rows, bad = [], [] + # STRIP COMMENTS PER LINE **BEFORE** SPLITTING ON COMMAS, never after. The regenerated list + # carries a `# CLASS: reason` note on every entry and prose in its header, and those contain + # commas -- splitting first turned `# ...compiler fact, not a tooling limit` into a second + # "entry" reading `not a tooling limit`. Caught only because this function REPORTS what it + # cannot parse instead of dropping it (R32); a silent parser would have quietly under-excluded. + for line in src.splitlines(): + body = line.split('#', 1)[0] + for chunk in body.split(','): + s = chunk.strip() + if not s: + continue + if ':' not in s: + bad.append(s); continue + b, _, f = s.partition(':') + rows.append((b.strip(), f.strip())) + return rows, bad + + +def classify(path=None, rows=None): + """-> ([(binary, fn, klass, note)], unparseable). Unparseable lines are reported, not dropped.""" + import corpus, progress, split_indicator as SI + if rows is None: + rows, bad = parse(path=path) + else: + bad = [] + + by = collections.defaultdict(list) + for b, f in rows: + by[b].append(f) + + blocked, linked_of, stubs_of = {}, {}, {} + for b in by: + try: + _st, lines = SI.check(b) + blocked[b] = {m.group(1) for l in lines + for m in [re.search(r'subseg `([^`]+)`', l)] if m} + except Exception: + blocked[b] = set() + try: + progress.set_binary(b); linked_of[b] = set(progress.linked_subsegs()) + except Exception: + linked_of[b] = set() + try: + stubs_of[b] = {x.symbol: x for x in corpus.stubs(b).values()} + except Exception: + stubs_of[b] = None + + out = [] + for b, f in rows: + stubs = stubs_of.get(b) + if stubs is None: + out.append((b, f, 'WALL', 'binary not readable — kept, unverified')); continue + st = stubs.get(f) + if st is None: + out.append((b, f, 'BANKED', 'no INCLUDE_ASM stub in the source')); continue + sub = os.path.basename(os.path.dirname(st.asm_path)) + if sub in linked_of.get(b, ()): + out.append((b, f, 'LINKED', f'subseg {sub} is linked PsyQ — never a target')); continue + txt = open(st.asm_path, errors='replace').read() + # §401: a jtbl function jumps through a register that is NOT $ra; `jr $ra` ends every function + if re.search(r'\bjr\s+\$(?!ra\b)\w+', txt): + if sub in blocked.get(b, ()): + out.append((b, f, 'CARVE-BLOCKED', f'{sub} owns >1 raw jtbl span — needs a split')) + else: + out.append((b, f, 'RE-PROBE', f'{sub} is clean now — the blocker is gone')) + else: + out.append((b, f, 'WALL', 'no jump table — curated compiler fact, not a tooling limit')) + return out, bad + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('path') + ap.add_argument('--write', metavar='OUT', help='regenerate, keeping only still-valid entries') + ap.add_argument('--assert-fresh', action='store_true', + help='exit 3 if any entry is stale (the draw-time prerequisite)') + a = ap.parse_args() + os.chdir(REPO) + rows, bad = classify(a.path) + cnt = collections.Counter(k for _, _, k, _ in rows) + print(f"{a.path}: {len(rows)} entries") + for k, v in cnt.most_common(): + print(f" {v:4d} {k}{' (STALE)' if k in STALE else ''}") + if bad: + print(f" !! {len(bad)} unparseable line(s), kept out of the count: {bad[:3]}") + stale = [r for r in rows if r[2] in STALE] + for k in STALE: + ex = [f"{b}:{f}" for b, f, kk, _ in rows if kk == k] + if ex: + print(f"\n {k} ({len(ex)}): " + ", ".join(ex[:8]) + (" …" if len(ex) > 8 else "")) + + if a.write: + keep = [r for r in rows if r[2] in KEEP] + with open(a.write, 'w') as fh: + fh.write(f"# regenerated by tools/exclude_audit.py from {a.path}\n" + f"# {len(keep)} still-valid of {len(rows)}; " + f"{len(stale)} dropped as stale (banked / linked / blocker-since-fixed).\n" + f"# An exclude list records what the TOOLING could not do — regenerate it as\n" + f"# part of every tool fix, or it becomes a list of work you decided not to do.\n") + for b, f, k, note in keep: + fh.write(f"{b}:{f} # {k}: {note}\n") + print(f"\nwrote {a.write}: {len(keep)} kept, {len(stale)} dropped") + + if a.assert_fresh and stale: + print(f"\n*** STALE — {len(stale)} of {len(rows)} entries no longer describe reality. " + f"Regenerate with:\n tools/exclude_audit.py {a.path} --write \n" + f"Drawing against this list would silently filter out work that is now doable.") + return 3 + return 0 + + +if __name__ == '__main__': + sys.exit(main())