Files
op-packages/smart-srun/scripts/doc_context.py
T

146 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Print local guidance and suggested checks selected by changed paths."""
import argparse
import fnmatch
import json
import os
from pathlib import Path, PurePosixPath
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ".github/review-context.json"
def git(root, *args):
result = subprocess.run(["git", "-C", str(root), *args], capture_output=True)
if result.returncode:
raise ValueError(result.stderr.decode(errors="replace").strip() or "Git command failed")
return result.stdout
def local_path(value):
if not isinstance(value, str) or not value.strip():
raise ValueError("Expected a nonempty repository-relative path")
value = value.replace("\\", "/")
path = PurePosixPath(value)
if path.is_absolute() or ".." in path.parts or ":" in value:
raise ValueError(f"Not a repository-relative path: {value}")
return path.as_posix()
def string_list(value, label):
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
raise ValueError(f"{label} must be a list of nonempty strings")
return value
def load_rules(root):
data = json.loads((root / CONFIG).read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("rules"), list):
raise ValueError(f"{CONFIG}: expected version 1 and a rules list")
seen = set()
for rule in data["rules"]:
if not isinstance(rule, dict) or set(rule) - {"id", "any", "all", "docs", "skills", "checks"}:
raise ValueError(f"{CONFIG}: invalid rule fields")
name = rule.get("id")
if not isinstance(name, str) or not name.strip() or name in seen:
raise ValueError(f"{CONFIG}: rule IDs must be nonempty and unique")
seen.add(name)
for key in ("any", "all", "docs", "skills"):
for value in string_list(rule.get(key, []), f"{name}.{key}"):
local_path(value)
if not rule.get("any") and not rule.get("all"):
raise ValueError(f"{name}: at least one any/all glob is required")
checks = rule.get("checks", [])
if not isinstance(checks, list):
raise ValueError(f"{name}.checks must be a list")
for check in checks:
if not isinstance(check, dict) or set(check) != {"command", "targets"}:
raise ValueError(f"{name}: each check needs command and targets")
if not isinstance(check["command"], str) or not check["command"].strip():
raise ValueError(f"{name}: empty check command")
if not string_list(check["targets"], f"{name}.targets"):
raise ValueError(f"{name}: check targets cannot be empty")
for target in check["targets"]:
local_path(target)
return data["rules"]
def diff_paths(data):
"""Git -z emits separate status/path tokens; renames include both paths."""
tokens = data.split(b"\0")
paths = set()
index = 0
while index < len(tokens) and tokens[index]:
status = tokens[index]
count = 2 if status[:1] in (b"R", b"C") else 1
index += 1
if index + count > len(tokens) or any(not value for value in tokens[index:index + count]):
raise ValueError("Incomplete Git name-status output")
paths.update(os.fsdecode(value) for value in tokens[index:index + count])
index += count
return paths
def changed_paths(root, base=None):
paths = set()
if base is not None:
# Resolve an argument as a revision, never as a Git option or shell text.
commit = git(root, "rev-parse", "--verify", "--end-of-options", base + "^{commit}").decode().strip()
ancestor = git(root, "merge-base", commit, "HEAD").decode().strip()
paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", ancestor, "HEAD", "--")))
for args in (("--cached",), ()):
paths.update(diff_paths(git(root, "diff", "--name-status", "-z", "--find-renames", *args, "--")))
paths.update(os.fsdecode(value) for value in git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\0") if value)
return sorted(paths)
def matching_rules(rules, paths):
def hit(pattern):
return any(fnmatch.fnmatchcase(path, pattern) for path in paths)
return [rule for rule in rules
if (not rule.get("any") or any(hit(pattern) for pattern in rule["any"]))
and all(hit(pattern) for pattern in rule.get("all", []))]
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=ROOT)
source = parser.add_mutually_exclusive_group()
source.add_argument("--files", nargs="+", help="Explicit repository-relative paths, including deleted paths")
source.add_argument("--base", help="Compare merge-base(REF, HEAD)..HEAD and include local changes")
args = parser.parse_args(argv)
try:
rules = load_rules(args.root)
paths = sorted({local_path(path) for path in args.files}) if args.files else changed_paths(args.root, args.base)
if not paths:
print("No changed files; no additional guidance selected.")
return 0
print("Changed paths:")
for path in paths:
print(" " + path)
selected = matching_rules(rules, paths)
if not selected:
print("No matching rules; start with AGENTS.md.")
return 0
print("\nMatching rules: " + ", ".join(rule["id"] for rule in selected))
for label, key in (("Read local guidance", "docs"), ("Load skills when needed", "skills")):
values = dict.fromkeys(value for rule in selected for value in rule.get(key, []))
if values:
print("\n" + label + ":")
for value in values:
print(" " + value)
print("\nSuggested checks (printed only; not executed):")
for command in dict.fromkeys(check["command"] for rule in selected for check in rule.get("checks", [])):
print(" " + command)
return 0
except (OSError, ValueError) as error:
print(f"doc-context: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())