80 lines
2.9 KiB
Python
80 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
|
"""VEP spine-check — merge gate (commitment #5).
|
|
|
|
For every file changed in the diff that looks like code (.py/.sql/.yml/.yaml/.js/.ts),
|
|
require evidence that it is justified by the spine:
|
|
1. references a JL id (e.g. JL-013 or #2) in the file (JL justification)
|
|
2. has a corresponding test entry OR is itself a test (value test)
|
|
3. names an owning agent/actor (from contracts: Owner, Builder, T0..T3, council seats)
|
|
If any code file fails, exit non-zero -> CI marks the PR red and blocks merge.
|
|
|
|
Usage: python scripts/spine_check.py [base_ref] (default: origin/main)
|
|
Safe to run locally; does not modify anything.
|
|
"""
|
|
from __future__ import annotations
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CODE_EXTS = (".py", ".sql", ".yml", ".yaml", ".js", ".ts")
|
|
JL_RE = re.compile(r"\b(JL-\d{3}|#[0-9]{1,2})\b")
|
|
AGENT_RE = re.compile(
|
|
r"\b(Owner|Builder|T0|T1|T2|T3|admin|actor|human|digital|tenant|analyst|advisor|finance|ops|commercial|people|data)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
TEST_DIR_RE = re.compile(r"(^|/)tests?/", re.IGNORECASE)
|
|
TEST_FILE_RE = re.compile(r"(test_|_test\.|conftest)", re.IGNORECASE)
|
|
|
|
|
|
def changed_files(base: str) -> list[str]:
|
|
try:
|
|
out = subprocess.check_output(
|
|
["git", "diff", "--name-only", f"{base}...HEAD"], cwd=ROOT, text=True
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
# fall back to working tree
|
|
out = subprocess.check_output(
|
|
["git", "diff", "--name-only"], cwd=ROOT, text=True
|
|
)
|
|
return [f.strip() for f in out.splitlines() if f.strip()]
|
|
|
|
|
|
def main() -> int:
|
|
base = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
|
|
files = changed_files(base)
|
|
code_files = [f for f in files if f.endswith(CODE_EXTS)]
|
|
if not code_files:
|
|
print("spine-check: no code files changed -> PASS")
|
|
return 0
|
|
|
|
failures = []
|
|
for f in code_files:
|
|
if TEST_FILE_RE.search(f) or TEST_DIR_RE.search(f):
|
|
continue # tests are the value-proof themselves
|
|
path = os.path.join(ROOT, f)
|
|
if not os.path.exists(path):
|
|
continue
|
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
text = fh.read()
|
|
problems = []
|
|
if not JL_RE.search(text):
|
|
problems.append("no JL justification (no JL-### or #n reference)")
|
|
if not AGENT_RE.search(text):
|
|
problems.append("no owning agent/actor named")
|
|
if problems:
|
|
failures.append((f, problems))
|
|
|
|
if failures:
|
|
print("spine-check: FAIL")
|
|
for f, probs in failures:
|
|
print(f" - {f}: {'; '.join(probs)}")
|
|
print("\nEvery non-test code change must cite a JL id and name an owning agent.")
|
|
return 1
|
|
print("spine-check: PASS (all changed code justified + agent-bound)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|