from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
FOLLOW_UP_PHRASES = (
"follow up",
"follow-up",
"followup",
"next PR",
"part 2",
"rest in",
)
ISSUE_REF = re.compile(r"#\d+")
COAUTHOR = re.compile(r"(?im)^\s*co-authored-by\s*:")
CONVENTIONAL = re.compile(
r"^(feat|fix|refactor|docs|test|tests|chore|perf|style|build|ci|revert"
r"|release|deps)(\([^)]*\))?!?: \S.*$"
)
EXEMPT_SUBJECTS = ("Merge ", "Revert ")
TITLE_HEAD = re.compile(r"^(?P<head>[^:]{1,60}):\s")
VALID_TITLE_HEAD = re.compile(
r"^(feat|fix|refactor|docs|test|tests|chore|perf|style|build|ci|revert"
r"|release|deps)(\([^)]*\))?!?$"
)
EXPECTED_SECTIONS = {
"workflow": {
"sync_before_commit": bool,
"child_issues_not_child_prs": bool,
"one_pr_per_problem": bool,
"single_sanity_ci": bool,
"merge_at_completion": bool,
},
"prs": {
"max_files_warn": int,
"no_stub": bool,
},
"commits": {
"no_coauthor_trailers": bool,
"conventional_titles": bool,
},
"titles": {
"issues_conventional": bool,
"prs_conventional": bool,
},
}
def repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def load_config(path: Path) -> dict:
if not path.is_file():
return fail_and_exit(f"config: {path} not found")
try:
cfg = tomllib.loads(path.read_text(encoding="utf-8"))
except Exception as e: return fail_and_exit(f"config: {path} does not parse: {e}")
problems = []
for section, keys in EXPECTED_SECTIONS.items():
block = cfg.get(section)
if not isinstance(block, dict):
problems.append(f"config: missing section [{section}]")
continue
for key, typ in keys.items():
if key not in block:
problems.append(f"config: missing [{section}].{key}")
elif not isinstance(block[key], typ) or isinstance(block[key], bool) and typ is int:
problems.append(f"config: [{section}].{key} has wrong type")
if problems:
for p in problems:
print(f"FAIL: {p}")
sys.exit(1)
return cfg
def fail_and_exit(msg: str):
print(f"FAIL: {msg}")
sys.exit(1)
def check_title(title: str, kind: str, cfg: dict) -> list[str]:
key = "issues_conventional" if kind == "issue" else "prs_conventional"
if not cfg.get("titles", {}).get(key, False):
return []
title = title.strip()
if not title:
return [f"titles.{key}: the {kind} title is empty"]
if CONVENTIONAL.match(title):
return []
head = TITLE_HEAD.match(title)
if head and not VALID_TITLE_HEAD.match(head.group("head").strip()):
return [
f"titles.{key}: bare area prefix {head.group('head')!r} in the "
f"{kind} title. Use type(scope): subject, e.g. "
f"'fix(tui): what is wrong'. A bare area prefix is not the "
f"convention (CONTRIBUTING.md, Issue Titles)."
]
return []
def run_checks(cfg: dict, body: str, subjects: list[str], messages: str, files_count: int):
fails: list[str] = []
warns: list[str] = []
if cfg["workflow"]["child_issues_not_child_prs"]:
lowered = body.lower()
hit = [p for p in FOLLOW_UP_PHRASES if p.lower() in lowered]
if hit and not ISSUE_REF.search(body):
fails.append(
"child_issues_not_child_prs: PR body announces follow-up work "
f"({', '.join(repr(p) for p in hit)}) with zero issue refs. "
"Follow-ups become CHILD ISSUES (#NNN), not sibling PRs."
)
cap = cfg["prs"]["max_files_warn"]
if files_count > cap:
warns.append(
f"max_files_warn: diff touches {files_count} files (> {cap}). "
"One PR = one change. Double-check nothing unrelated rode along."
)
if cfg["commits"]["no_coauthor_trailers"]:
hits = COAUTHOR.findall(messages)
if hits:
fails.append(
f"no_coauthor_trailers: {len(hits)} commit(s) carry a "
"Co-authored-by trailer. Sole author, no exceptions."
)
if cfg["commits"]["conventional_titles"]:
for subject in subjects:
if not subject or subject.startswith(EXEMPT_SUBJECTS):
continue
if not CONVENTIONAL.match(subject):
fails.append(
f"conventional_titles: subject does not match "
f"type(scope)!: subject -> {subject!r}"
)
return fails, warns
def verdict(fails: list[str], warns: list[str], label: str = "") -> int:
tag = f"[{label}] " if label else ""
for w in warns:
print(f"WARN: {tag}{w}")
if not fails:
print(f"PASS: {tag}policy-lint found no drift")
return 0
for f in fails:
print(f"FAIL: {tag}{f}")
return 1
def fixture_files(tests_dir: Path) -> list[Path]:
return sorted(tests_dir.glob("*.json"))
def run_fixture(path: Path, cfg: dict) -> int:
data = json.loads(path.read_text(encoding="utf-8"))
fails, warns = run_checks(
cfg,
data.get("body", ""),
data.get("subjects", []),
data.get("messages", ""),
int(data.get("files_count", 0)),
)
if "title" in data:
fails = fails + check_title(
data["title"], data.get("title_kind", "issue"), cfg
)
return verdict(fails, warns, label=data.get("name", path.stem))
def main() -> int:
expected = _expected_exit_from_argv(sys.argv[1:])
code = _run()
if expected is None:
return code
if code == expected:
print(f"EXPECT-EXIT PASS: exit {code}, as declared")
return 0
print(f"EXPECT-EXIT FAIL: exit {code}, expected {expected}")
return 1
def _expected_exit_from_argv(argv: list[str]) -> int | None:
for i, arg in enumerate(argv):
if arg == "--expect-exit" and i + 1 < len(argv):
return int(argv[i + 1])
if arg.startswith("--expect-exit="):
return int(arg.split("=", 1)[1])
return None
def _run() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--config", type=Path, default=None)
parser.add_argument("--self-test", action="store_true")
parser.add_argument("--fixture", type=Path, default=None)
parser.add_argument(
"--expect-exit",
type=int,
default=None,
help=(
"assert the run's exit code equals this value and exit 0 when it "
"does; used to prove a negative case (a fixture that must FAIL) "
"from a CI gate that only reads exit 0 as success"
),
)
parser.add_argument("--body-file", type=Path)
parser.add_argument("--subjects-file", type=Path)
parser.add_argument("--messages-file", type=Path)
parser.add_argument("--files-count", type=int, default=0)
parser.add_argument("--title-file", type=Path)
parser.add_argument(
"--title-kind",
choices=("issue", "pr"),
default="issue",
help="which title rule to apply in --title-file mode",
)
args = parser.parse_args()
cfg = load_config(args.config or repo_root() / "governance.toml")
if args.self_test:
tests_dir = repo_root() / "scripts" / "tests" / "policy-lint"
fixtures = fixture_files(tests_dir)
if not fixtures:
print("FAIL: no fixtures under scripts/tests/policy-lint/")
return 1
bad = 0
for path in fixtures:
data = json.loads(path.read_text(encoding="utf-8"))
got = run_fixture(path, cfg)
expected = int(data.get("expected_exit", 0))
mark = "ok" if got == expected else "MISMATCH"
if got != expected:
bad += 1
print(f" fixture {path.name}: exit {got} (expected {expected}) {mark}")
if bad:
print(f"SELF-TEST FAIL: {bad}/{len(fixtures)} fixtures mismatched")
return 1
print(f"SELF-TEST PASS: {len(fixtures)}/{len(fixtures)} fixtures match their teeth")
return 0
if args.fixture:
return run_fixture(args.fixture, cfg)
if args.body_file or args.subjects_file or args.messages_file:
missing = [
name
for name, val in (
("--body-file", args.body_file),
("--subjects-file", args.subjects_file),
("--messages-file", args.messages_file),
)
if val is None
]
if missing:
print(f"FAIL: PR mode needs all inputs; missing {', '.join(missing)}")
return 1
body = args.body_file.read_text(encoding="utf-8")
subjects = [
line.strip()
for line in args.subjects_file.read_text(encoding="utf-8").splitlines()
if line.strip()
]
messages = args.messages_file.read_text(encoding="utf-8")
return verdict(*run_checks(cfg, body, subjects, messages, args.files_count))
if args.title_file:
title = args.title_file.read_text(encoding="utf-8")
return verdict(check_title(title, args.title_kind, cfg), [], label=args.title_kind)
print("PASS: governance.toml schema validated; no PR context, checks skipped")
return 0
if __name__ == "__main__":
sys.exit(main())