From d31c50d1b6b9d896f911e60079330c7274ccdd76 Mon Sep 17 00:00:00 2001 From: Jonathan Borduas Date: Sun, 6 Sep 2026 01:08:48 +0100 Subject: [PATCH] =?UTF-8?q?#73:=20the=20caller=20=E2=80=94=20a=20ratchet?= =?UTF-8?q?=20under=20UNNAMED=20refusals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disposition-scan.py built #73's predicate, planted it four ways, caught a use-versus-mention bug in itself — then stopped, and said exactly why: "committing other roles' files to a floor is not this tool's call. It reports; someone else decides." ⇒ Right, and not a technical gap. Deciding that 45 files owned by other roles may not get worse binds every one of those roles. It is a TEAMLEAD act. #73 sat with a working predicate and no caller for 16 days because the missing leg was AUTHORITY, and nobody who could supply it had been asked. ★ A RATCHET, NOT A GATE. A gating check reds 45 pre-existing files on its first run; a red naming 45 files nobody touched gets reverted or ignored, which is worse than absent because it teaches the gate is noise. This fails ONLY IF THE COUNT GROWS. 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. ⇒ 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. ⚠ THREE THINGS THIS FILE GOT WRONG FIRST, all caught by its own suite: 1 `def report(root, out=sys.stdout)` binds the default AT DEFINITION TIME, so redirect_stdout never reached it. Eight tests failed on empty output. 2 It FAILED ITS OWN RULE — classify() credits a disposition only on the same PHYSICAL SOURCE LINE as the refusal and the emit; every refusal here named ADDABLE across concatenated lines and scored UNNAMED. 3 The empty-population test deleted disposition-scan.py too, so it tripped the missing-predicate guard and PASSED FOR THE WRONG REASON. Two different VOIDs is #73's own subject. ⛔ THE BOUND, from #73 against itself: a check could pass while every refusal names a remedy nobody can act on. PRESENCE is not USEFULNESS. This counts the first only. ⚠ REBUILT ON A CLEAN BASE. The previous branch was cut before #597 was SQUASH merged, so a rebase replayed commits main holds only as one squashed commit and conflicted; `git merge-tree` exit 1 and GitHub mergeable=false agreed. ⇒ Rather than resolve a conflict against a base that no longer exists, this is cut from current origin/main and the README edits re-applied to ITS copy — carrying the old copy forward would have REVERTED #600's leg-5 fix silently. check-tools-index 0 · test_index_watch 0 · test_disposition_ratchet 13/13 · test_merge_guard 26/26. All four READ from the runs, not assumed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DTMf4EvaTEnDKXY47efRZZ --- tools/README.md | 7 +- tools/disposition-baseline.json | 7 + tools/disposition-ratchet.py | 305 ++++++++++++++++++++++++++++++ tools/test_disposition_ratchet.py | 270 ++++++++++++++++++++++++++ 4 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 tools/disposition-baseline.json create mode 100644 tools/disposition-ratchet.py create mode 100644 tools/test_disposition_ratchet.py 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)