Three minor releases at once. Byte-identical surface: only WORKFLOW.md actually changed (+128/-2); CLAUDE.md and the four templates are untouched. AGENTS.md takes the new upstream prefix (+22/-2) and keeps our project section unchanged. The two declared-extended files were reconciled by hand, because nothing compares them: validate.py gains upstream's check_vendored_portable, and schema.yaml gains vendored, project_section_marker, the judged link field and the ledger and verdict types. gen_status.py needed nothing — upstream did not touch it. Recorded in the new HERKUNFT.md, including that this reconciliation has no contradictor and will be forgotten next time. Newly adopted: check_harvest.py and judge.py, plus docs/ledger/ and docs/verdict/ with their templates. Deliberately not adopted: check_locked.py — pruefe_sperrliste.py has done that job here since 2026-08-20, and two tools for one rule is maintenance without gain. The v0.1.1 baseline stays where it is; docs/sources is immutable.
352 lines
13 KiB
Python
352 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""check_locked.py — contradict an edit to an artifact the rules call binding.
|
|
|
|
`AGENTS.md` has said since v0.1.0 that accepted decision records are
|
|
"binding; never edited, only superseded", and that `docs/sources/` holds
|
|
immutable originals. Both rules were prose, and prose is the category of
|
|
rule the second field report found broken without exception: an accepted
|
|
record was amended, committed and pushed, and the violation was caught by
|
|
chance rather than by anything that refused. The generated index carries
|
|
the same kind of prohibition and was never once violated, because a script
|
|
contradicts it. This is that script for the other two.
|
|
|
|
Locked:
|
|
* every file under `docs/sources/` — modification, deletion or rename.
|
|
Adding a new source is not an edit and stays permitted.
|
|
* every ADR under `docs/adr/` that carried `status: accepted` *before*
|
|
the range. A newly added ADR is not locked, whatever status it
|
|
arrives with.
|
|
|
|
Permitted, because the ADR template prescribes exactly this edit: setting
|
|
`status:` and `superseded_by:` on an accepted record when a newer one
|
|
replaces it. Nothing else about the file may change in that commit.
|
|
|
|
Scope is the range it is given, and only that. Checking history would
|
|
report violations nobody can undo, which paints the pipeline permanently
|
|
red — and a permanently red check reports nothing. That failure class is
|
|
issue 0022's subject; walking into it here would be the same mistake with
|
|
a different subject.
|
|
|
|
Fails closed (exit 1, never "clean"):
|
|
* a revision range git cannot resolve
|
|
* a blob git cannot produce
|
|
|
|
⚠️ This sees one push. A violation that reaches the default branch by some
|
|
path this never runs on stays unseen; that is a boundary, not an oversight.
|
|
|
|
Usage:
|
|
python scripts/check_locked.py [--range <a>..<b>]
|
|
python scripts/check_locked.py --selftest
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import difflib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
LOCKED_DIR = "docs/sources/"
|
|
ADR_DIR = "docs/adr/"
|
|
PERMITTED_FIELD = re.compile(r"^(status|superseded_by):", re.I)
|
|
STATUS_ACCEPTED = re.compile(r"^status:\s*accepted\s*$", re.I | re.M)
|
|
|
|
|
|
class LockError(RuntimeError):
|
|
"""The check could not answer the question. Never a pass."""
|
|
|
|
|
|
def git(root: Path, *args: str) -> str:
|
|
"""Run git, or raise. A failure is never an empty result."""
|
|
proc = subprocess.run(
|
|
["git", "-C", str(root), *args],
|
|
capture_output=True, text=True,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise LockError(
|
|
f"git {' '.join(args)} failed: {proc.stderr.strip() or 'no output'}"
|
|
)
|
|
return proc.stdout
|
|
|
|
|
|
def split_range(rev_range: str) -> tuple[str, str]:
|
|
"""'a..b' -> ('a', 'b'). An empty right side means HEAD."""
|
|
if ".." not in rev_range:
|
|
raise LockError(f"not a revision range: {rev_range!r} (expected a..b)")
|
|
base, _, head = rev_range.partition("..")
|
|
base, head = base.strip(), head.strip() or "HEAD"
|
|
if not base:
|
|
raise LockError(f"range has no base: {rev_range!r}")
|
|
return base, head
|
|
|
|
|
|
def changed_paths(root: Path, base: str, head: str) -> list[tuple[str, str]]:
|
|
"""[(status, path)] for the range. Status is git's A/M/D/R letter."""
|
|
out = git(root, "diff", "--name-status", "--no-renames", base, head)
|
|
entries: list[tuple[str, str]] = []
|
|
for line in out.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("\t")
|
|
entries.append((parts[0][0], parts[-1]))
|
|
return entries
|
|
|
|
|
|
def blob(root: Path, rev: str, path: str) -> str | None:
|
|
"""File content at a revision, or None if it did not exist there."""
|
|
proc = subprocess.run(
|
|
["git", "-C", str(root), "show", f"{rev}:{path}"],
|
|
capture_output=True, text=True,
|
|
)
|
|
if proc.returncode != 0:
|
|
if "exists on disk, but not in" in proc.stderr or "does not exist" in proc.stderr:
|
|
return None
|
|
if "path" in proc.stderr and "not in" in proc.stderr:
|
|
return None
|
|
return None
|
|
return proc.stdout
|
|
|
|
|
|
def was_accepted(root: Path, base: str, path: str) -> bool:
|
|
"""Did this ADR carry `status: accepted` before the range?"""
|
|
before = blob(root, base, path)
|
|
return before is not None and bool(STATUS_ACCEPTED.search(before))
|
|
|
|
|
|
def only_supersede_fields(root: Path, base: str, head: str, path: str) -> bool:
|
|
"""True if the change touches nothing but status/superseded_by."""
|
|
before = blob(root, base, path)
|
|
after = blob(root, head, path)
|
|
if before is None or after is None:
|
|
return False
|
|
diff = difflib.unified_diff(
|
|
before.splitlines(), after.splitlines(), n=0, lineterm=""
|
|
)
|
|
touched = [
|
|
line[1:].strip()
|
|
for line in diff
|
|
if line[:1] in "+-" and not line.startswith(("+++", "---"))
|
|
]
|
|
if not touched:
|
|
return False
|
|
return all(PERMITTED_FIELD.match(line) for line in touched)
|
|
|
|
|
|
def check(root: Path, base: str, head: str) -> list[str]:
|
|
findings: list[str] = []
|
|
for state, path in changed_paths(root, base, head):
|
|
if path.startswith(LOCKED_DIR):
|
|
if state != "A":
|
|
findings.append(
|
|
f"{path}: immutable source, {_verb(state)} in this range"
|
|
)
|
|
continue
|
|
if path.startswith(ADR_DIR):
|
|
if not was_accepted(root, base, path):
|
|
continue
|
|
if state == "D":
|
|
findings.append(f"{path}: accepted decision record, deleted")
|
|
elif only_supersede_fields(root, base, head, path):
|
|
continue
|
|
else:
|
|
findings.append(
|
|
f"{path}: accepted decision record, edited — supersede it "
|
|
f"with a new record instead"
|
|
)
|
|
return findings
|
|
|
|
|
|
def _verb(state: str) -> str:
|
|
return {"M": "modified", "D": "deleted", "R": "renamed"}.get(state, "changed")
|
|
|
|
|
|
def report(findings: list[str]) -> None:
|
|
if not findings:
|
|
print("check_locked: no findings")
|
|
return
|
|
print(f"check_locked: {len(findings)} finding(s)\n")
|
|
for f in findings:
|
|
print(f" {f}")
|
|
print(
|
|
"\nAn accepted record is superseded, never edited; a source under "
|
|
f"{LOCKED_DIR} is never changed at all."
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Positive control. A gate that has only ever been seen green is a
|
|
# hypothesis — WORKFLOW.md, Gate 4.
|
|
# --------------------------------------------------------------------------
|
|
|
|
def selftest() -> int:
|
|
passed = 0
|
|
failed: list[str] = []
|
|
|
|
def check_that(name: str, condition: bool) -> None:
|
|
nonlocal passed
|
|
if condition:
|
|
passed += 1
|
|
print(f" ok {passed + len(failed)} {name}")
|
|
else:
|
|
failed.append(name)
|
|
print(f" FAIL {passed + len(failed)} {name}")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
run = lambda *a: git(root, *a)
|
|
git(root, "init", "-q", ".")
|
|
run("config", "user.email", "selftest@invalid")
|
|
run("config", "user.name", "selftest")
|
|
|
|
adr = root / ADR_DIR
|
|
adr.mkdir(parents=True)
|
|
(adr / "0001-locked.md").write_text(
|
|
"---\ntype: adr\nid: \"0001\"\nstatus: accepted\n"
|
|
"superseded_by: null\n---\n\n# ADR-0001\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
src = root / LOCKED_DIR
|
|
src.mkdir(parents=True)
|
|
(src / "digest.md").write_text("original\n", encoding="utf-8")
|
|
(root / "README.md").write_text("free\n", encoding="utf-8")
|
|
run("add", "-A")
|
|
run("commit", "-qm", "base")
|
|
base = run("rev-parse", "HEAD").strip()
|
|
|
|
# 1 — an accepted record, edited
|
|
(adr / "0001-locked.md").write_text(
|
|
"---\ntype: adr\nid: \"0001\"\nstatus: accepted\n"
|
|
"superseded_by: null\n---\n\n# ADR-0001\n\nBody, amended.\n",
|
|
encoding="utf-8",
|
|
)
|
|
run("add", "-A"); run("commit", "-qm", "amend an accepted adr")
|
|
check_that("an edit to an accepted record is reported",
|
|
len(check(root, base, "HEAD")) == 1)
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 2 — an immutable source, edited
|
|
(src / "digest.md").write_text("rewritten\n", encoding="utf-8")
|
|
run("add", "-A"); run("commit", "-qm", "edit a source")
|
|
check_that("an edit under the immutable sources is reported",
|
|
len(check(root, base, "HEAD")) == 1)
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 3 — negative control
|
|
(root / "README.md").write_text("free, changed\n", encoding="utf-8")
|
|
run("add", "-A"); run("commit", "-qm", "ordinary change")
|
|
check_that("an ordinary change is not reported",
|
|
check(root, base, "HEAD") == [])
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 4 — the one edit the template prescribes
|
|
(adr / "0001-locked.md").write_text(
|
|
"---\ntype: adr\nid: \"0001\"\nstatus: superseded\n"
|
|
"superseded_by: docs/adr/0002-newer.md\n---\n\n# ADR-0001\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
run("add", "-A"); run("commit", "-qm", "supersede")
|
|
check_that("setting status and superseded_by stays permitted",
|
|
check(root, base, "HEAD") == [])
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 5 — a new record may arrive accepted
|
|
(adr / "0002-newer.md").write_text(
|
|
"---\ntype: adr\nid: \"0002\"\nstatus: accepted\n---\n\n# ADR-0002\n",
|
|
encoding="utf-8",
|
|
)
|
|
run("add", "-A"); run("commit", "-qm", "add an accepted adr")
|
|
check_that("a newly added record is not locked",
|
|
check(root, base, "HEAD") == [])
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 6 — a new source may be added
|
|
(src / "second.md").write_text("new digest\n", encoding="utf-8")
|
|
run("add", "-A"); run("commit", "-qm", "add a source")
|
|
check_that("adding a new source is not an edit",
|
|
check(root, base, "HEAD") == [])
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 7 — a source that is deleted
|
|
(src / "digest.md").unlink()
|
|
run("add", "-A"); run("commit", "-qm", "delete a source")
|
|
check_that("deleting an immutable source is reported",
|
|
len(check(root, base, "HEAD")) == 1)
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 8 — fails closed on a range git cannot resolve
|
|
check_that("an unresolvable range raises instead of reporting nothing",
|
|
_raises(lambda: check(root, "no-such-ref", "HEAD")))
|
|
|
|
# 9 — fails closed on a malformed range, through main()
|
|
check_that("main() rejects a range that is not a..b",
|
|
main(["--range", "HEAD"], root=root) == 1)
|
|
|
|
# 10 — end to end through main(): a real violation exits 1
|
|
(adr / "0001-locked.md").write_text(
|
|
"---\ntype: adr\nid: \"0001\"\nstatus: accepted\n"
|
|
"superseded_by: null\n---\n\n# ADR-0001\n\nBody, amended again.\n",
|
|
encoding="utf-8",
|
|
)
|
|
run("add", "-A"); run("commit", "-qm", "amend again")
|
|
check_that("main() exits 1 on a real violation",
|
|
main(["--range", f"{base}..HEAD"], root=root) == 1)
|
|
run("reset", "-q", "--hard", base)
|
|
|
|
# 11 — end to end through main(): a clean range exits 0
|
|
(root / "README.md").write_text("free, again\n", encoding="utf-8")
|
|
run("add", "-A"); run("commit", "-qm", "ordinary")
|
|
check_that("main() exits 0 on a clean range",
|
|
main(["--range", f"{base}..HEAD"], root=root) == 0)
|
|
|
|
total = passed + len(failed)
|
|
print()
|
|
if failed:
|
|
print(f"selftest: {len(failed)} of {total} assertions FAILED")
|
|
for name in failed:
|
|
print(f" - {name}")
|
|
return 1
|
|
print(f"selftest: {total} assertions passed")
|
|
return 0
|
|
|
|
|
|
def _raises(fn) -> bool:
|
|
try:
|
|
fn()
|
|
except LockError:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
return False
|
|
|
|
|
|
def main(argv: list[str] | None = None, *, root: Path | None = None) -> int:
|
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
if "--selftest" in args:
|
|
return selftest()
|
|
|
|
rev_range = "origin/main..HEAD"
|
|
if "--range" in args:
|
|
i = args.index("--range")
|
|
if i + 1 >= len(args):
|
|
print("check_locked: --range needs a value", file=sys.stderr)
|
|
return 1
|
|
rev_range = args[i + 1]
|
|
|
|
repo = root or Path(os.environ.get("CHECK_LOCKED_ROOT", ".")).resolve()
|
|
try:
|
|
base, head = split_range(rev_range)
|
|
findings = check(repo, base, head)
|
|
except LockError as exc:
|
|
print(f"check_locked: {exc}", file=sys.stderr)
|
|
print("unchecked is not passed.", file=sys.stderr)
|
|
return 1
|
|
|
|
report(findings)
|
|
return 1 if findings else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|