"""You altered the shape of something — here is everything that reads it (#4215). WHY THIS EXISTS Milestone 419's most repeated miss, five of seven: acting on the thing in hand without reading the contract around it. Lesson #4207 says it in words — "widening a tuple is an interface change to every unpack site, and the compiler will not tell you" — and was written by its author HOURS before a structurally identical mistake, having been surfaced twice in the turns between. Text delivered at the moment of acting is too weak a carrier for a reflex that has to change what the act IS. This looks it up instead. Rule 33 one scope down: its checks are between layers ("every parameter the caller sends is read by the handler under the same name"), and the same question exists between a definition and its callers. THREE KINDS OF EXPOSED NAME, because a contract breaks three ways that look nothing alike in source: sym what is defined a rename or a removal arg its parameter names arity and order key quoted keys of dict literals the shape of what it RETURNS The third is here because of the miss that produced this step, and it is the one a signature-watcher would have missed: `get_writepath_config` gained one dict key, three arms read that dict inside a fail-open `except`, every one silently became a no-op, and ten tests went red at once with nothing pointing at the cause. No signature changed. WHAT IS PINNED: which edits speak and which stay silent, and that both gates are load-bearing. NOT pinned: the wording, which is prose. A FIXTURE REPO, NEVER THIS ONE. Asserting against Scribe's own files would make the test a description of today's tree, failing the next time someone renames something (rule 115's reasoning, one floor down). """ import shutil import subprocess from pathlib import Path import pytest DEFS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_defs.sh" HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_prior_art.sh" def _need(*tools): for t in tools: if shutil.which(t) is None: pytest.skip(f"hook runtime tool {t!r} not installed") def run(script: str) -> str: _need("bash", "awk", "git", "grep", "sed") r = subprocess.run( ["bash", "-c", f'set -uo pipefail\n. "{DEFS}"\n{script}'], capture_output=True, text=True, ) assert r.returncode == 0, f"exit {r.returncode}: {r.stderr}" return r.stdout @pytest.fixture() def repo(tmp_path): """A small git repo: a definition with a reader, and one without.""" _need("git") # `other` lives in lib.py BESIDE widget, and nothing references it. That # placement is the point of the no-readers case: putting it in its own # file would leave that file as its reader, since only the file being # edited is excluded — which is how the first version of this fixture # quietly tested the opposite of what it claimed. (tmp_path / "lib.py").write_text( "def widget(size, colour):\n" ' return {"size": size, "colour": colour}\n\n' "def other():\n return 1\n" ) (tmp_path / "caller.py").write_text( "from lib import widget\n\n" "def render():\n" ' return widget(1, "red")["size"]\n' ) (tmp_path / "unrelated.py").write_text("def gizmo():\n return 2\n") subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) return tmp_path def q(text: str) -> str: """A multi-line blob as one bash word, newlines intact. ANSI-C quoting (`$'...'`) and NOT Python's `repr`, which is what the first version of this file used. Inside ordinary single quotes bash leaves `\\n` as two characters and `printf %s` does not interpret it either, so every fixture arrived as a SINGLE line. The extractor is line-oriented, so the cases still passed — the test would have gone green while exercising input no editor could produce. Escaping is explicit rather than delegated to `repr`, whose quote character depends on the content. """ esc = (text.replace("\\", "\\\\").replace("'", "\\'") .replace("\n", "\\n").replace("\t", "\\t")) return "$'" + esc + "'" def block(repo, subject, old, new, ledger="") -> str: return run( f'scribe_contract_block "{repo}" "lib.py" "{subject}" ' f'{q(old)} {q(new)} "{ledger}"' ) # ── scribe_exposed: the three kinds ─────────────────────────────────────── def exposed(text: str) -> set[str]: out = run(f'printf %s {q(text)} | scribe_exposed') return {ln for ln in out.split("\n") if ln.strip()} def test_a_definitions_name_is_exposed(): assert "sym\twidget" in exposed("def widget(size):\n pass\n") def test_parameter_names_are_exposed(): got = exposed("def widget(size, colour):\n pass\n") assert "arg\tsize" in got and "arg\tcolour" in got def test_self_and_cls_are_not_part_of_the_contract(): """Nothing a caller passes, so naming them would report a change on every method that gains a keyword argument.""" got = exposed("def method(self, size):\n pass\n") assert "arg\tself" not in got assert "arg\tsize" in got def test_a_type_annotation_and_a_default_are_stripped_from_a_parameter(): got = exposed('def f(user_id: int, days: int = 30, *, flag=False):\n pass\n') assert {"arg\tuser_id", "arg\tdays", "arg\tflag"} <= got def test_a_quoted_dict_key_is_exposed(): """THE CASE A SIGNATURE-WATCHER MISSES, and the one that produced this step — see the module docstring.""" assert 'key\tcheckpoint_threshold' in exposed(' "checkpoint_threshold": floor,\n') def test_reading_a_dict_key_does_not_count_as_defining_one(): """Anchored on the writing position (`"k":`), so `cfg["k"]` says nothing. Without this every consumer of a config would report a contract change the moment it read one.""" assert exposed('x = cfg["tool_rule_threshold"]\ny = d.get("other")\n') == set() def test_a_dunder_is_never_exposed(): """Every class defines __init__, so it would be a guaranteed false positive on every class edit — and noise is what teaches a reader to skip the block.""" assert "sym\t__init__" not in exposed(" def __init__(self, x):\n pass\n") # ── The check: what speaks, and what stays quiet ────────────────────────── def test_a_gained_dict_key_names_the_files_that_read_it(repo): out = block( repo, "widget", 'def widget(size, colour):\n return {"size": size}\n', 'def widget(size, colour):\n return {"size": size, "weight": 1}\n', ) assert "widget" in out assert "gained" in out and "weight" in out assert "caller.py" in out assert "unrelated.py" not in out, "only files that reference the subject" def test_a_gained_parameter_names_the_files_that_read_it(repo): out = block(repo, "widget", "def widget(size):\n", "def widget(size, colour):\n") assert "colour" in out and "caller.py" in out def test_a_rename_reports_both_halves(repo): out = block(repo, "widget", "def widget(size):\n", "def gadget(size):\n") assert "lost" in out and "widget" in out assert "gained" in out and "gadget" in out def test_a_body_only_edit_says_nothing(repo): """THE GATE THAT DECIDES WHETHER THIS IS USABLE. Most edits are bodies; a check that spoke on all of them would be skipped by the third turn.""" out = block( repo, "widget", "def widget(size, colour):\n total = size\n return total\n", "def widget(size, colour):\n total = size + 1\n return total\n", ) assert out.strip() == "" def test_a_definition_nothing_references_says_nothing(repo): """THE SECOND GATE, and it is not redundant. A shape change is necessary but not sufficient — a definition with no readers has no contract to break, and on any real repo this is what keeps the check quiet.""" out = block(repo, "other", "def other():\n", "def other(x):\n") assert out.strip() == "" def test_the_same_subject_is_named_once_per_session(repo, tmp_path): """Getting a change right takes several passes over the same definition, and re-asking the same contract question at each one punishes exactly the rhythm that gets it right.""" ledger = tmp_path / "sid.contract.ids" first = block(repo, "widget", "def widget(size):\n", "def widget(size, colour):\n", ledger=str(ledger)) assert first.strip() second = block(repo, "widget", "def widget(size):\n", "def widget(size, colour):\n", ledger=str(ledger)) assert second.strip() == "" def test_a_missing_old_text_says_nothing(repo): """A Write that creates a file has no `before`, so there is no contract to have changed. Silence, never a report against an empty string.""" assert block(repo, "widget", "", "def widget(size, colour):\n").strip() == "" # ── The hook that carries it ────────────────────────────────────────────── def test_the_write_hook_still_cannot_stop_a_write(): """THE RECORDED DECISION THIS ARM MUST NOT ERODE. The operator's position is that a recall aid may not stand in the way of a write, and this arm is a nudge like the others — it names files, it does not gate. Asserted here as well as in test_write_path_trigger.py because this is the change most likely to tempt someone into making it a gate: it reports something that may already be broken. """ code = [ln for ln in HOOK.read_text().splitlines() if not ln.lstrip().startswith("#")] assert not any("permissionDecision" in ln for ln in code) assert not any("scribe_json_deny" in ln for ln in code) def test_the_hook_asks_the_contract_question_first(): """Order is the strength of the claim: this arm says something may already be BROKEN, the local arm says a copy exists, the recorded arms say something resembles this. A reader who reads one line should read that one.""" code = [ln for ln in HOOK.read_text().splitlines() if not ln.lstrip().startswith("#")] combined = next(i for i, ln in enumerate(code) if ln.startswith("combined=")) assert "contract_context" in code[combined] def test_the_hook_is_still_shell_valid(): _need("bash") subprocess.run(["bash", "-n", str(HOOK)], check=True) subprocess.run(["bash", "-n", str(DEFS)], check=True)