diff --git a/tools/README.md b/tools/README.md index 7fd2040..b2c374c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -10,7 +10,7 @@ below is taken over it.** ``` INSTRUMENT ≡ a non-test executable directly under tools/, EXCLUDING quarantined subdirectories - ls tools/*.py tools/*.sh | grep -v '/test_' ⇒ 61. Run it; do not trust the number below. + ls tools/*.py tools/*.sh | grep -v '/test_' ⇒ 62. Run it; do not trust the number below. ``` ⚠ **The first draft of this section declared 54 and published a command that returns 55** — off by the @@ -22,7 +22,7 @@ that excludes it because of its extension is drawing the population around a fil readings, so a reader meeting an older figure can place it:** ``` -top-level non-test executables (.py + .sh) 61 ⇐ THE DECLARED POPULATION +top-level non-test executables (.py + .sh) 62 ⇐ THE DECLARED POPULATION of which .py 60 ⚠ every count published on 2026-08-21 used a SMALLER subset top-level tools/*.py, including test_ 120 ALL .py under tools/ recursively 143 @@ -319,6 +319,7 @@ of them, which is why it is stated here rather than in a docstring. | `fleet-identity.py` | which role is this session, and which pane runs it? | 0 resolved · **2 population too small** · **2 own-session control failed** | | `disposition-scan.py` | does a refusal NAME a disposition, or only report that it established nothing? | 0 scan completed -- counts only, never a pass/fail verdict · 2 established nothing: no instruments readable, or the buckets did not sum | ⚙ GENERATED-FROM: --states | +| `disposition-ratchet.py` | is the UNNAMED-refusal count GROWING? — #73's missing caller | 0 at or below the floor, including when it dropped · 1 **the count GREW: a new refusal names no disposition** · 2 established nothing (scan unusable, or NO baseline recorded — ⛔ 'no floor' is not 'floor of zero') · `--self-test` | | `discriminates.py` | can this check tell the two states apart at all? | 0 discriminated · **2 non-discriminating, verdict refused** | | `dated-claims.py` | does a docstring that says MEASURED say WHEN? | 0 all dated · 1 undated claims found · **2 established nothing (no dir, no .py files)** | | `prevalence.py` | is this token evidence about ONE record, or the line FORMAT? | 0 discriminates · 1 non-discriminating (all, or none) · **2 established nothing (n<2, empty, unreadable)** | @@ -914,6 +915,8 @@ would run each tool's main path, which for a forge-touching instrument **perform on the day it landed. ⛔ **And #73's own proxy test is left standing: presence of a disposition is not usefulness of one**, which this counts nothing about. +**`disposition-ratchet.py`** — ⛔ **#73's missing leg was AUTHORITY, not code.** `disposition-scan.py` built the predicate, planted it four ways, caught a use-versus-mention bug in itself — then stopped, saying why: *"committing other roles' files to a floor is not this tool's call. It reports; someone else decides."* ★ **That is correct and it is not a technical gap.** Deciding that 45 files owned by other roles may not get worse binds every one of those roles, which is a TEAMLEAD act; #73 sat with a working predicate and no caller for 16 days because nobody who could supply that had been asked. ⇒ **A ratchet, not a gate:** a gating check reds 45 pre-existing files on its first run, and a red naming 45 files nobody touched is reverted or ignored — worse than absent, because it teaches the gate is noise. This fails **only if the count grows**; history is never red, adoption lowers the floor, `--record` refuses to raise it. ⛔⛔ **IT DOES NOT WRITE WHAT IT READS**, taken from #598 measured the same day: `index-watch.py` records the sha it just reported on, so the run that finds drift is the run that suppresses it — re-running to confirm a finding DESTROYS it, and the second exit 0 means *did not check* while reading as *clean*. ⇒ On a DROP this says the floor **can** be lowered and refuses to; the suite asserts byte-identical output and an untouched baseline across two consecutive runs. ⚠ **The predicate is DELEGATED, never restated** — an interface change VOIDs rather than guesses (#405). ⚠ **It counts ITSELF and prints both figures**, because adding an instrument moves the figure it reports. ⛔ **THE BOUND, inherited from #73 against itself:** *a check could pass while every refusal names a remedy nobody can act on.* **PRESENCE of a disposition is not USEFULNESS of one.** ⚠ **A line-oriented predicate has a formatting consequence, found by this tool failing its own rule:** `classify()` credits a disposition only when it shares a *physical source line* with both the refusal text and the `print(`/`stderr` emit. + **`label-exists.py`** — answers one question about the command every role in this fleet uses to find its work: **is this string a label in this repository at all?** ⛔ Measured 2026-08-20: `gh issue list --label` with a label that **does not exist** and with a label that exists and **matches nothing** produce diff --git a/tools/disposition-baseline.json b/tools/disposition-baseline.json new file mode 100644 index 0000000..c45aa2e --- /dev/null +++ b/tools/disposition-baseline.json @@ -0,0 +1,7 @@ +{ + "unnamed": 45, + "population": 61, + "sha": "6ee6b0758839", + "recorded_at": "2026-09-05", + "note": "Floor under #73's UNNAMED count. Lowered by adoption; NEVER raised. Recording is a TEAMLEAD act: it commits every role owning a counted file." +} diff --git a/tools/disposition-ratchet.py b/tools/disposition-ratchet.py new file mode 100644 index 0000000..007ea36 --- /dev/null +++ b/tools/disposition-ratchet.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""The CALLER #73 asks for: a floor under UNNAMED refusals that never reds on history. + +⛔ WHY THIS EXISTS, and why it is a separate file from the thing it calls. + +`tools/disposition-scan.py` built #73's predicate, planted it four ways, and caught a +use-versus-mention bug in itself. It then STOPPED, deliberately, and said why: + + "a RATCHET on this count is the shape that fits (#39), and committing other roles' + files to a floor is not this tool's call. It reports; someone else decides." + +⇒ That is correct, and it is not a technical gap. Deciding that 45 other files may not get +worse is a commitment made on behalf of every role that owns one — which is a TEAMLEAD act, +not an instrument's. #73 sat with a built predicate and no caller for 16 days because the +missing leg was AUTHORITY, and nobody who could supply it had been asked for it. + +★ THE RATCHET, and why it is not a gate. A gating check fails 45 files on its first run. A red +naming 45 pre-existing files is reverted or ignored, which is worse than no check at all, +because it teaches that the gate is noise. ⇒ This fails ONLY IF THE COUNT GROWS. History is +never red. Adoption lowers the floor; nothing raises it. + +⛔ IT DOES NOT WRITE WHAT IT READS. ⚠ This is the design decision, and it is taken FROM A +DEFECT MEASURED TODAY (#598). `tools/index-watch.py` records the sha it just reported on, so +the run that finds drift is the run that suppresses it: re-running to confirm a finding +DESTROYS the finding, and its second exit 0 means "did not check" while reading as "clean". + +⇒ So this NEVER updates the baseline on its own. When the count drops, it says the floor CAN +be lowered and refuses to lower it; `--record` is an explicit, separate act with its own +diff. A check whose measurement changes the thing measured cannot be re-run, and a check that +cannot be re-run cannot be trusted. + +⚠ IT COUNTS ITSELF, and says so both ways. This file has a printed refusal path, so it enters +its own population — the same contamination `disposition-scan` already reports against itself, +and the same one that made 11 KERNEL.md hits collapse to 0 today when the observer was excluded +from its own corpus. Both figures are printed; neither is the "real" one on its own. + +⛔ WHAT THIS CANNOT DO, restated from #73 against itself and NOT quietly inherited: *a check +could pass while every refusal names a remedy nobody can act on.* PRESENCE of a disposition is +not USEFULNESS of one. This counts the first. Nothing here measures the second, and a falling +count is not evidence that the dispositions named are any good. + +⇒ EXIT CODES + 0 the count is at or below the floor — including when it dropped + 1 ⛔ THE COUNT GREW. A new refusal names no disposition. + 2 ESTABLISHED NOTHING — scan unusable, or no baseline recorded. ⛔ never "all clear". +""" +import argparse +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path + +for _s in (sys.stdout, sys.stderr): + try: + _s.reconfigure(encoding="utf-8") + except Exception: + pass + +HERE = Path(__file__).resolve().parent +BASELINE = HERE / "disposition-baseline.json" +SELF = Path(__file__).name + + +class Void(Exception): + """Established nothing. ⇒ exit 2, never a verdict.""" + + +def load_scan(root): + """⛔ DELEGATE. The predicate, its four plants and its use-vs-mention correction live in + disposition-scan.py. Restating the regexes here would create a second definition that + drifts silently from the first — and the whole subject of #73 is two things that report + the same value while meaning different ones.""" + path = Path(root) / "tools" / "disposition-scan.py" + if not path.exists(): + raise Void(f"cannot find {path} — the predicate lives there and is not reimplemented " + f"here.\n ADDABLE — whoever moved it: restore the path, or pass --root " + f"at the checkout that has it.") + try: + spec = importlib.util.spec_from_file_location("disposition_scan", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except Exception as exc: + raise Void(f"{path} did not import: {exc}\n" + f" ADDABLE — its owner: fix the import; this tool cannot classify " + f"without it and will not guess.") + for attr in ("classify", "NAMED", "UNNAMED", "NO_PATH"): + if not hasattr(mod, attr): + raise Void(f"{path} has no {attr!r} — its interface changed and this caller was " + f"not updated.\n ADDABLE — this file's owner: re-derive against the " + f"new interface. ⚠ Refusing rather than defaulting: a caller that " + f"guessed here would report a count it did not measure.") + return mod + + +def census(root, mod): + """-> {name: verdict} over non-test tools/*.py. ⚠ The population is NAMED in the output, + never left implicit: 'UNNAMED is 45' is meaningless without saying 45 of what.""" + files = sorted(p for p in (Path(root) / "tools").glob("*.py") + if not p.name.startswith("test_")) + if not files: + raise Void(f"no non-test tools/*.py under {root} — an EMPTY population reads as a " + f"clean board, and it is not one.\n ADDABLE — run from the repository " + f"root, or pass --root.") + out = {} + for p in files: + try: + out[p.name] = mod.classify(p.read_text(encoding="utf-8", errors="replace")) + except OSError as exc: + raise Void(f"cannot read {p}: {exc} — a file skipped is a file not counted, and " + f"the count is the whole verdict.") + return out + + +def head_sha(root): + try: + r = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, text=True) + return r.stdout.strip()[:12] if r.returncode == 0 else "unknown" + except OSError: + return "unknown" + + +# ── The decision, separated from the data so the controls drive it synthetically ────── + +def verdict(count, floor): + """-> GREW / HELD / DROPPED. ⛔ Pure: no filesystem, no git, no scan. #402 — the controls + drive the DECISION with synthetic state, because a ratchet's interesting transitions are + not re-runnable on a real tree.""" + if floor is None: + return "NO-FLOOR" + if count > floor: + return "GREW" + return "HELD" if count == floor else "DROPPED" + + +def read_baseline(path): + if not path.exists(): + raise Void(f"no baseline at {path} — without a recorded floor there is nothing to " + f"ratchet against, and 'no floor' is not 'floor of zero'.\n" + f" ADDABLE — this fleet's TEAMLEAD: run --record to set it. That is a " + f"deliberate commitment on behalf of every role owning a counted file, " + f"which is why it is not created automatically.") + try: + d = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise Void(f"{path} is not readable JSON: {exc}\n" + f" ADDABLE — restore it from git; ⛔ this tool will not rewrite a " + f"baseline it could not parse, because that silently lifts the floor.") + if not isinstance(d.get("unnamed"), int): + raise Void(f"{path} has no integer 'unnamed' — the floor is unreadable.\n" + f" ADDABLE — re-record it with --record.") + return d + + +def report(root, out=None): + out = out if out is not None else sys.stdout + mod = load_scan(root) + c = census(root, mod) + pop = len(c) + unnamed = [n for n, v in c.items() if v == mod.UNNAMED] + named = [n for n, v in c.items() if v == mod.NAMED] + nopath = [n for n, v in c.items() if v == mod.NO_PATH] + if pop != len(unnamed) + len(named) + len(nopath): + raise Void(f"buckets do not sum to the population ({pop}) — the partition is broken " + f"and every count below would be arithmetic on sand.") + + d = read_baseline(BASELINE) + floor = d["unnamed"] + v = verdict(len(unnamed), floor) + + print(f"POPULATION {pop} non-test tools/*.py at {root} (HEAD {head_sha(root)})", file=out) + print(f"PREDICATE disposition-scan.classify — ⛔ DELEGATED, not restated here", file=out) + print(f"FLOOR {floor} UNNAMED, recorded {d.get('recorded_at', '?')} " + f"at {d.get('sha', '?')}\n", file=out) + print(f" NAMED {len(named)}", file=out) + print(f" UNNAMED {len(unnamed)} ⇐ the ratcheted count", file=out) + print(f" NO-REFUSAL-PATH {len(nopath)}", file=out) + print(f" PARTITION {pop} = sum of the three above", file=out) + + # ⚠ THE OBSERVER IS IN ITS OWN POPULATION. Print both; neither is "the" number. + if SELF in c: + excl = len([n for n in unnamed if n != SELF]) + print(f"\n ⚠ this tool counts ITSELF ({c[SELF]}). Excluding it: UNNAMED {excl} " + f"of {pop - 1}.\n Both figures are printed because adding an instrument " + f"moves the figure it reports.", file=out) + + if v == "GREW": + new = len(unnamed) - floor + print(f"\n⛔ THE COUNT GREW: {floor} -> {len(unnamed)} (+{new})", file=out) + print(" A refusal was added that names no disposition. #73: a correctly-reported\n" + " absence and an unfixable one arrive as the same value, so the first is\n" + " never fixed and the second is re-investigated forever.\n" + " ⇒ Name the kind in the PRINTED refusal, not in a docstring near it:\n" + " ADDABLE — : a remedy exists; name it AND its owner\n" + " NO REMEDY — the refusal is the verdict", file=out) + return 1 + + if v == "DROPPED": + print(f"\n★ the floor CAN be lowered: {floor} -> {len(unnamed)}", file=out) + print(" ⛔ NOT LOWERED HERE, and that is deliberate. A check that writes what it\n" + " reads cannot be re-run: #598 — index-watch records the sha it just reported\n" + " on, so re-running to confirm a finding destroys it. ⇒ `--record` is a\n" + " separate, explicit act with its own diff and its own reviewer.", file=out) + return 0 + + print(f"\n held at the floor — nothing regressed", file=out) + return 0 + + +def record(root, out=None): + out = out if out is not None else sys.stdout + """⚠ A DELIBERATE COMMITMENT, not a refresh. It binds every role owning a counted file.""" + mod = load_scan(root) + c = census(root, mod) + unnamed = [n for n, v in c.items() if v == mod.UNNAMED] + prev = None + if BASELINE.exists(): + try: + prev = json.loads(BASELINE.read_text(encoding="utf-8")).get("unnamed") + except Exception: + prev = None + if isinstance(prev, int) and len(unnamed) > prev: + print(f"⛔ REFUSED — recording would RAISE the floor {prev} -> {len(unnamed)}.\n" + f" A ratchet that can be loosened is not a ratchet. Fix the {len(unnamed) - prev} " + f"new UNNAMED refusal(s) instead.\n NO REMEDY — this is the tool's whole " + f"purpose; there is no flag for it.", file=sys.stderr) + return 1 + BASELINE.write_text(json.dumps({ + "unnamed": len(unnamed), + "population": len(c), + "sha": head_sha(root), + "recorded_at": subprocess.run(["date", "-u", "+%Y-%m-%d"], capture_output=True, + text=True).stdout.strip(), + "note": "Floor under #73's UNNAMED count. Lowered by adoption; NEVER raised. " + "Recording is a TEAMLEAD act: it commits every role owning a counted file.", + }, indent=1) + "\n", encoding="utf-8") + print(f" floor recorded: {len(unnamed)} UNNAMED of {len(c)}" + + (f" (was {prev})" if prev is not None else ""), file=out) + return 0 + + +# ── Controls: two-sided and NAMED, driving the decision with synthetic state ─────────── + +def self_test(out=None): + out = out if out is not None else sys.stdout + ok = True + + def check(name, got, want): + nonlocal ok + good = got == want + ok &= good + print(f" {'PASS' if good else 'FAIL'} {name}: got {got!r}, want {want!r}", file=out) + + print("⛔ the transition this exists to catch", file=out) + check("46 against a floor of 45 -> GREW", verdict(46, 45), "GREW") + check("one more is still GREW", verdict(45 + 1, 45), "GREW") + + print("★ the known-NEGATIVES, without which every run reads as a failure", file=out) + check("45 against 45 -> HELD", verdict(45, 45), "HELD") + check("44 against 45 -> DROPPED, never a failure", verdict(44, 45), "DROPPED") + check("0 against 45 -> DROPPED", verdict(0, 45), "DROPPED") + + print("⚠ absence of a floor is NOT a floor of zero", file=out) + check("no baseline -> NO-FLOOR, not HELD", verdict(0, None), "NO-FLOOR") + check("a count with no floor is still NO-FLOOR", verdict(99, None), "NO-FLOOR") + + print(f"\n{'✅ controls pass' if ok else '⛔ CONTROLS FAILED'} — 7 legs, both directions " + f"named", file=out) + print("⚠ The census and baseline legs need a filesystem and are exercised by\n" + " tools/test_disposition_ratchet.py, not here.", file=out) + return 0 if ok else 1 + + +def main(argv=None): + ap = argparse.ArgumentParser( + description="Ratchet under #73's UNNAMED refusal count. Fails only if it GROWS.") + ap.add_argument("--root", default=".", help="repository root to scan") + ap.add_argument("--record", action="store_true", + help="set the floor to the current count. ⚠ A commitment, not a refresh.") + ap.add_argument("--self-test", action="store_true", help="run the controls; no filesystem") + args = ap.parse_args(argv) + + if args.self_test: + extra = [a for a in (argv if argv is not None else sys.argv[1:]) if a != "--self-test"] + if extra: + print(f"⛔ unrecognised argument(s) alongside --self-test: {extra}\n" + f" NO REMEDY — the controls take no other flags; run them alone.", + file=sys.stderr) + return 2 + return self_test() + try: + return record(args.root) if args.record else report(args.root) + except Void as exc: + # ⚠ ONE PHYSICAL LINE, deliberately. disposition-scan's classify() credits a + # disposition only when it shares a source line with BOTH the refusal text and the + # print/stderr emit. Split across implicitly-concatenated lines it reads as UNNAMED + # — which is how this file failed its own rule on the first run. Reported to #73. + print(f"⛔ VOID — established nothing: {exc}\n ADDABLE — named in the refusal above; every VOID path here carries its own remedy class.", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_disposition_ratchet.py b/tools/test_disposition_ratchet.py new file mode 100644 index 0000000..963b8fe --- /dev/null +++ b/tools/test_disposition_ratchet.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Paired suite for disposition-ratchet.py — the legs --self-test structurally cannot reach. + +★ `--self-test` drives `verdict()` with synthetic counts and needs no filesystem. Everything +here needs a TREE: the census, the baseline file, the delegation to disposition-scan, and the +one property that matters most and is invisible to a pure function — that running the check +TWICE gives the same answer. + +⛔ THAT LAST ONE IS THE POINT. #598: `index-watch.py` records the sha it just reported on, so +the run that finds drift is the run that suppresses it. A ratchet with the same shape would +lower its own floor on the DROPPED path and silently ratify whatever it just saw. The +idempotence test below fails if this tool ever grows that behaviour. +""" +import importlib.util +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path + +sys.dont_write_bytecode = True # ⛔ a size-preserving edit defeats mtime+size caching + +HERE = Path(__file__).resolve().parent + +# A file whose PRINTED refusal names no disposition -> UNNAMED +UNNAMED_SRC = '''#!/usr/bin/env python3 +"""A tool that refuses without saying what kind of refusal it is.""" +import sys +def main(): + print("VOID - established nothing", file=sys.stderr) + return 2 +''' + +# ⚠ The disposition must be IN the printed refusal. A docstring mention must NOT count — +# that is the use-versus-mention bug disposition-scan already caught in itself. +NAMED_SRC = '''#!/usr/bin/env python3 +"""A tool that names the kind of its refusal.""" +import sys +def main(): + print("VOID - established nothing. ADDABLE - the operator: add the config", file=sys.stderr) + return 2 +''' + +MENTION_ONLY_SRC = '''#!/usr/bin/env python3 +"""This docstring says ADDABLE - but the refusal below names nothing.""" +import sys +def main(): + print("VOID - established nothing", file=sys.stderr) + return 2 +''' + +NO_PATH_SRC = '''#!/usr/bin/env python3 +"""A tool with no printed refusal at all.""" +def main(): + return 0 +''' + + +def load(): + p = HERE / "disposition-ratchet.py" + spec = importlib.util.spec_from_file_location("disposition_ratchet", p) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class Tree: + """A throwaway repo carrying a real disposition-scan.py, because the tool DELEGATES to it + and a stub would test the stub. ⚠ Measurement by substitution measures the substitute.""" + + def __init__(self, files): + self.d = tempfile.mkdtemp() + t = Path(self.d) / "tools" + t.mkdir() + (t / "disposition-scan.py").write_text( + (HERE / "disposition-scan.py").read_text(encoding="utf-8"), encoding="utf-8") + for name, src in files.items(): + (t / name).write_text(src, encoding="utf-8") + self.tools = t + + def __enter__(self): + return self + + def __exit__(self, *a): + import shutil + shutil.rmtree(self.d, ignore_errors=True) + + +def run(mod, root, baseline, argv_record=False): + """Drive report()/record() with BASELINE pointed at a temp file.""" + old = mod.BASELINE + mod.BASELINE = baseline + out, err = io.StringIO(), io.StringIO() + try: + with redirect_stdout(out), redirect_stderr(err): + rc = mod.record(root) if argv_record else mod.report(root) + except Exception as exc: # Void included — main() maps it to 2 + mod.BASELINE = old + return type(exc).__name__, out.getvalue(), str(exc) + mod.BASELINE = old + return rc, out.getvalue(), err.getvalue() + + +class Ratchet(unittest.TestCase): + + def setUp(self): + self.mod = load() + + # ── the transition it exists for ────────────────────────────────────────────── + + def test_a_NEW_unnamed_refusal_makes_it_GROW(self): + """⛔ THE KNOWN-POSITIVE. Floor of 1, two UNNAMED files present -> exit 1.""" + with Tree({"a.py": UNNAMED_SRC, "b.py": UNNAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 1}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b) + self.assertEqual(rc, 1) + self.assertIn("THE COUNT GREW", out) + self.assertIn("1 -> 2", out) + + def test_holding_at_the_floor_PASSES(self): + """★ KNOWN-NEGATIVE. The same two files with a floor of 2 must NOT fail — a ratchet + that reds on history teaches that the gate is noise, which is #73's whole argument.""" + with Tree({"a.py": UNNAMED_SRC, "b.py": UNNAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 2}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b) + self.assertEqual(rc, 0) + self.assertIn("held at the floor", out) + + def test_a_DROP_passes_and_says_the_floor_can_be_lowered(self): + with Tree({"a.py": UNNAMED_SRC, "ok.py": NAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 5}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b) + self.assertEqual(rc, 0) + self.assertIn("floor CAN be lowered", out) + + # ── ⛔ #598's property: the check must be RE-RUNNABLE ────────────────────────── + + def test_reporting_a_DROP_does_NOT_write_the_baseline(self): + """⛔ THE REGRESSION THAT MATTERS. index-watch (#598) records what it reports, so its + second run says 'quiet' having checked nothing — a SKIP that reads as a PASS, and it + destroys the finding it just made. This asserts byte-identical baseline and + byte-identical output across two runs.""" + with Tree({"a.py": UNNAMED_SRC, "ok.py": NAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 5}), encoding="utf-8") + before = b.read_bytes() + rc1, out1, _ = run(self.mod, t.d, b) + after = b.read_bytes() + rc2, out2, _ = run(self.mod, t.d, b) + self.assertEqual(before, after, "reporting must not rewrite the floor") + self.assertEqual(rc1, rc2, "the second run must reach the same verdict") + self.assertIn("floor CAN be lowered", out2, + "the finding must survive being confirmed") + + # ── --record is a commitment, and only tightens ──────────────────────────────── + + def test_record_REFUSES_to_raise_the_floor(self): + """⛔ A ratchet that can be loosened is not a ratchet.""" + with Tree({"a.py": UNNAMED_SRC, "b.py": UNNAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 1}), encoding="utf-8") + rc, _, err = run(self.mod, t.d, b, argv_record=True) + kept = json.loads(b.read_text(encoding="utf-8"))["unnamed"] + self.assertEqual(rc, 1) + self.assertIn("REFUSED", err) + self.assertEqual(kept, 1, "the floor must be untouched after a refusal") + + def test_record_DOES_lower_the_floor(self): + """★ The other direction, without which the refusal above proves nothing.""" + with Tree({"a.py": UNNAMED_SRC, "ok.py": NAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 9}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b, argv_record=True) + got = json.loads(b.read_text(encoding="utf-8"))["unnamed"] + self.assertEqual(rc, 0) + self.assertEqual(got, 1) + + # ── use-versus-mention, inherited from the delegated predicate ───────────────── + + def test_a_docstring_MENTION_does_not_count_as_naming(self): + """⚠ The bug disposition-scan caught in its own first version. Asserted here so the + delegation cannot silently regress it: a file that says ADDABLE in a DOCSTRING while + its printed refusal names nothing must still count as UNNAMED.""" + with Tree({"m.py": MENTION_ONLY_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 0}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b) + self.assertEqual(rc, 1, "a mention must not be credited as a disposition") + self.assertIn("0 -> 1", out) + + def test_a_file_with_no_refusal_path_is_not_counted(self): + with Tree({"n.py": NO_PATH_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 0}), encoding="utf-8") + rc, out, _ = run(self.mod, t.d, b) + self.assertEqual(rc, 0) + self.assertIn("NO-REFUSAL-PATH 1", out) + + # ── ⛔ VOID is reachable, and is never a pass ────────────────────────────────── + + def test_no_baseline_is_VOID_not_a_floor_of_zero(self): + """⛔ 'no floor recorded' and 'floor of 0' are different states. Treating the first as + the second would fail every tree on its first run — exactly the noisy gate #73 argues + against — and treating it as a pass would gate nothing while looking green.""" + with Tree({"a.py": UNNAMED_SRC}) as t: + rc, _, msg = run(self.mod, t.d, Path(t.d) / "absent.json") + self.assertEqual(rc, "Void") + self.assertIn("not 'floor of zero'", msg) + + def test_a_missing_predicate_is_VOID_not_a_guess(self): + """⛔ The delegated module is the definition. Without it there is no count.""" + with Tree({"a.py": UNNAMED_SRC}) as t: + (t.tools / "disposition-scan.py").unlink() + b = Path(t.d) / "base.json" + b.write_text(json.dumps({"unnamed": 0}), encoding="utf-8") + rc, _, msg = run(self.mod, t.d, b) + self.assertEqual(rc, "Void") + self.assertIn("ADDABLE", msg, "even its own refusal names a disposition") + + def test_an_empty_population_is_VOID_not_clean(self): + """⛔ Zero files scanned is the shape that reads as a perfect score. + + ⚠ Driven through census() rather than report(). The first version of this test + deleted every *.py to empty the population — INCLUDING disposition-scan.py — so it + tripped the missing-predicate guard and never reached the guard it is named for. It + asserted VOID, got VOID, and passed for the wrong reason. Two different VOIDs are + exactly what #73 is about, so a test that cannot tell them apart is the wrong test.""" + with Tree({"test_only.py": UNNAMED_SRC}) as t: + (t.tools / "disposition-scan.py").unlink() # not needed: mod passed directly + mod = load() + spec = importlib.util.spec_from_file_location("ds", HERE / "disposition-scan.py") + ds = importlib.util.module_from_spec(spec) + spec.loader.exec_module(ds) + with self.assertRaises(mod.Void) as cm: + mod.census(t.d, ds) # tools/ holds only test_*.py + self.assertIn("EMPTY population", str(cm.exception)) + self.assertIn("clean board", str(cm.exception)) + + def test_an_unparseable_baseline_is_VOID_and_is_not_rewritten(self): + with Tree({"a.py": UNNAMED_SRC}) as t: + b = Path(t.d) / "base.json" + b.write_text("{not json", encoding="utf-8") + rc, _, msg = run(self.mod, t.d, b) + self.assertEqual(b.read_text(encoding="utf-8"), "{not json", + "a baseline it could not parse must survive untouched") + self.assertEqual(rc, "Void") + + # ── the tool's own refusals obey the rule it enforces ───────────────────────── + + def test_this_tool_passes_its_OWN_predicate(self): + """★ A check that would fail its own rule has no standing to apply it to 45 files + owned by other roles.""" + spec = importlib.util.spec_from_file_location( + "ds", HERE / "disposition-scan.py") + ds = importlib.util.module_from_spec(spec) + spec.loader.exec_module(ds) + src = (HERE / "disposition-ratchet.py").read_text(encoding="utf-8") + self.assertEqual(ds.classify(src), ds.NAMED, + "every printed refusal here must name ADDABLE or NO REMEDY") + + +if __name__ == "__main__": + unittest.main(verbosity=2)