CI Pipeline / lint (push) Failing after 45s
CI Pipeline / unit-tests (push) Failing after 17s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Failing after 23s
CI Pipeline / unit-tests (pull_request) Failing after 16s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 3s
CI Pipeline / type-check (pull_request) Successful in 55s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
Spec gate (headless) / spec-gate (pull_request) Successful in 5s
82 lines
3.0 KiB
Python
Executable File
82 lines
3.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Enforce the bug-fix discharge contract (docs/bugfix-contract.md) (#147).
|
|
|
|
A PR that claims discharges must carry, for every `Discharge:` finding line in
|
|
its body, at least one ADDED test hunk in the named test file. Structural check:
|
|
the pipeline cannot read intent, but it can verify the shape of the claim.
|
|
|
|
Convention (PR body):
|
|
Discharge: F1 -> tests/test_foo.py
|
|
Discharge: F2 -> tests/test_bar.py::test_case
|
|
|
|
Exit 0 when no Discharge lines exist (not a fix PR) or every line is satisfied.
|
|
Exit 1 with the unsatisfied finding ids otherwise. Silent fixes (fix commits
|
|
with no discharge claim AND no test hunks) are a reviewer question, not a
|
|
script verdict — see the contract's "Why".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
DISCHARGE_RE = re.compile(r"^\s*Discharge:\s*(?P<id>\S+)\s*->\s*(?P<file>\S+)", re.MULTILINE)
|
|
TEST_PATH_RE = re.compile(r"(tests|src/rune)/.*?test.*\.py$")
|
|
|
|
|
|
def added_test_hunks(diff: str, test_file: str) -> bool:
|
|
"""True if the diff adds lines inside the named test file."""
|
|
current_file: str | None = None
|
|
for line in diff.splitlines():
|
|
if line.startswith("+++ b/"):
|
|
current_file = line[6:].strip()
|
|
elif line.startswith("+") and not line.startswith("+++"):
|
|
if current_file and (current_file == test_file or current_file.endswith("/" + test_file)):
|
|
if TEST_PATH_RE.search(current_file):
|
|
return True
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
pr_body_path = os.environ.get("PR_BODY_FILE")
|
|
if not pr_body_path or not os.path.exists(pr_body_path):
|
|
print("check_discharge_contract: no PR body provided — nothing to check (exit 0)")
|
|
return 0
|
|
body = open(pr_body_path, encoding="utf-8").read()
|
|
claims = DISCHARGE_RE.findall(body)
|
|
if not claims:
|
|
print("check_discharge_contract: no Discharge claims in PR body (exit 0)")
|
|
return 0
|
|
|
|
base = os.environ.get("PR_BASE_SHA")
|
|
head = os.environ.get("PR_HEAD_SHA") or "HEAD"
|
|
if not base:
|
|
print("check_discharge_contract: PR_BASE_SHA missing — cannot diff (exit 1)")
|
|
return 1
|
|
diff = subprocess.run(
|
|
["git", "diff", base, head, "--", "tests/", "src/rune/"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout
|
|
|
|
unsatisfied = []
|
|
for fid, test_file in claims:
|
|
if not added_test_hunks(diff, test_file):
|
|
unsatisfied.append(f"{fid} -> {test_file}")
|
|
|
|
if unsatisfied:
|
|
print("check_discharge_contract: DISCHARGE CLAIMS WITHOUT RED TESTS (contract docs/bugfix-contract.md):")
|
|
for u in unsatisfied:
|
|
print(f" - {u}")
|
|
print("Every finding needs a failing test hunk in the named file BEFORE the fix. See docs/bugfix-contract.md.")
|
|
return 1
|
|
print(f"check_discharge_contract: {len(claims)} discharge claim(s) verified against added test hunks")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|