pr-verify 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pr_verify/__init__.py ADDED
File without changes
pr_verify/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,179 @@
1
+ """Blast radius: who depends on the changed files?
2
+
3
+ Builds a module import graph for the repo (Python via AST, other languages
4
+ via heuristic regex), inverts it, and for each changed file reports the
5
+ transitive set of dependents — the code that could break if the change is
6
+ wrong. Standalone: no dependency on sibling packages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import dataclasses
13
+ import os
14
+ import re
15
+
16
+ PY_EXTS = {".py"}
17
+ OTHER_EXTS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
18
+ ".go", ".rs", ".java", ".rb", ".php"}
19
+
20
+ _GENERIC_IMPORT_RES = [
21
+ re.compile(r'^\s*import\s+(?:type\s+)?[{*]?[^"\']*?from\s+["\']([^"\']+)["\']'),
22
+ re.compile(r'^\s*import\s+["\']([^"\']+)["\']'),
23
+ re.compile(r'require\(\s*["\']([^"\']+)["\']\s*\)'),
24
+ re.compile(r'^\s*use\s+([a-zA-Z0-9_:]+)(?:::|\s*[{;])'),
25
+ ]
26
+
27
+
28
+ def _imports_of(path: str, content: str) -> list[str]:
29
+ ext = os.path.splitext(path)[1].lower()
30
+ if ext in PY_EXTS:
31
+ try:
32
+ tree = ast.parse(content)
33
+ except SyntaxError:
34
+ return []
35
+ mods = []
36
+ for node in ast.walk(tree):
37
+ if isinstance(node, ast.Import):
38
+ mods.extend(a.name for a in node.names)
39
+ elif isinstance(node, ast.ImportFrom):
40
+ if node.level:
41
+ mods.append("<local>")
42
+ elif node.module:
43
+ mods.append(node.module)
44
+ return mods
45
+ if ext in OTHER_EXTS:
46
+ mods = []
47
+ for line in content.splitlines():
48
+ for rx in _GENERIC_IMPORT_RES:
49
+ m = rx.search(line)
50
+ if m:
51
+ mods.append(m.group(1))
52
+ break
53
+ return mods
54
+ return []
55
+
56
+
57
+ def _module_candidates(rel: str) -> list[str]:
58
+ """Dotted forms a file might be imported as: a/b.py -> a.b, b."""
59
+ base = rel.replace(os.sep, "/")
60
+ if base.endswith("/__init__.py"):
61
+ dotted = base[: -len("/__init__.py")].replace("/", ".")
62
+ elif base.endswith(".py"):
63
+ dotted = base[: -len(".py")].replace("/", ".")
64
+ else:
65
+ stem = base.rsplit(".", 1)[0].replace("/", ".")
66
+ return [stem, stem.split(".")[-1]]
67
+ parts = dotted.split(".")
68
+ return [dotted, parts[-1]] if len(parts) > 1 else [dotted]
69
+
70
+
71
+ @dataclasses.dataclass
72
+ class BlastRadius:
73
+ file: str
74
+ direct: list[str] # files directly importing it
75
+ transitive: list[str] # all downstream dependents
76
+ external_only: bool = False # changed file imported by nothing in-repo
77
+
78
+ @property
79
+ def size(self) -> int:
80
+ return len(self.transitive)
81
+
82
+
83
+ def _iter_files(root: str) -> list[str]:
84
+ skip = {".git", "node_modules", "__pycache__", ".venv", "venv",
85
+ "dist", "build", ".tox", "vendor"}
86
+ out = []
87
+ for dirpath, dirnames, filenames in os.walk(root):
88
+ dirnames[:] = [d for d in dirnames if d not in skip]
89
+ for fn in filenames:
90
+ if os.path.splitext(fn)[1].lower() in (PY_EXTS | OTHER_EXTS):
91
+ out.append(os.path.join(dirpath, fn))
92
+ return sorted(out)
93
+
94
+
95
+ def build_graph(root: str) -> dict[str, set[str]]:
96
+ """rel path -> set of rel paths it imports (in-repo only)."""
97
+ files = _iter_files(root)
98
+ rels = [os.path.relpath(p, root).replace(os.sep, "/") for p in files]
99
+ # index: dotted name -> rel
100
+ index: dict[str, str] = {}
101
+ for rel in rels:
102
+ for cand in _module_candidates(rel):
103
+ index.setdefault(cand, rel)
104
+ graph: dict[str, set[str]] = {rel: set() for rel in rels}
105
+ for path, rel in zip(files, rels):
106
+ try:
107
+ with open(path, encoding="utf-8", errors="replace") as f:
108
+ content = f.read()
109
+ except OSError:
110
+ continue
111
+ for mod in _imports_of(path, content):
112
+ target = index.get(mod)
113
+ if target and target != rel:
114
+ graph[rel].add(target)
115
+ return graph
116
+
117
+
118
+ def dependents_of_modules(root: str, module_names: set[str]) -> list[str]:
119
+ """rel paths in the current worktree importing any of module_names.
120
+
121
+ Used for changed files absent from the worktree (e.g. deleted files):
122
+ the normal graph can't resolve them, so match the raw imported module
123
+ strings instead. Over-approximates on bare-name matches (e.g. "core");
124
+ for a trust tool, flagging a possibly-broken dependent beats silence.
125
+ """
126
+ if not module_names:
127
+ return []
128
+ out = []
129
+ for path in _iter_files(root):
130
+ rel = os.path.relpath(path, root).replace(os.sep, "/")
131
+ try:
132
+ with open(path, encoding="utf-8", errors="replace") as f:
133
+ content = f.read()
134
+ except OSError:
135
+ continue
136
+ if set(_imports_of(path, content)) & module_names:
137
+ out.append(rel)
138
+ return sorted(out)
139
+
140
+
141
+ def _transitive_dependents(reverse: dict[str, set[str]],
142
+ direct: list[str], rel: str) -> list[str]:
143
+ seen = set(direct)
144
+ stack = list(direct)
145
+ while stack:
146
+ cur = stack.pop()
147
+ for nxt in reverse.get(cur, ()):
148
+ if nxt not in seen:
149
+ seen.add(nxt)
150
+ stack.append(nxt)
151
+ seen.discard(rel)
152
+ return sorted(seen)
153
+
154
+
155
+ def analyze(root: str, changed: list[str]) -> dict[str, BlastRadius]:
156
+ graph = build_graph(root)
157
+ # invert
158
+ reverse: dict[str, set[str]] = {rel: set() for rel in graph}
159
+ for src, deps in graph.items():
160
+ for dep in deps:
161
+ reverse[dep].add(src)
162
+ result: dict[str, BlastRadius] = {}
163
+ for rel in changed:
164
+ if rel in reverse:
165
+ direct = sorted(reverse[rel])
166
+ transitive = _transitive_dependents(reverse, direct, rel)
167
+ result[rel] = BlastRadius(file=rel, direct=direct,
168
+ transitive=transitive)
169
+ continue
170
+ # Not in the worktree graph (deleted or unknown file): fall back
171
+ # to matching the file's module names against the raw imports of
172
+ # every file still in the worktree, so deleted imports still show
173
+ # their (now broken) dependents.
174
+ direct = dependents_of_modules(root, set(_module_candidates(rel)))
175
+ transitive = _transitive_dependents(reverse, direct, rel)
176
+ result[rel] = BlastRadius(file=rel, direct=direct,
177
+ transitive=transitive,
178
+ external_only=not direct)
179
+ return result
pr_verify/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """PR Verify: deterministic trust for agent-authored PRs.
2
+
3
+ Not another AI reviewer — verification, not opinion. For a diff range it
4
+ reports: which lines an agent wrote (provenance), what could break
5
+ (blast radius), whether policy gates pass, and a deterministic risk score
6
+ that tells a senior how deep to review.
7
+
8
+ pr-verify check --base main
9
+ pr-verify check --base origin/main --format json --fail-on high
10
+ pr-verify init # write an example .pr-verify.yaml
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+
21
+ from . import blastradius as br_mod
22
+ from . import config as config_mod
23
+ from . import gates as gates_mod
24
+ from . import provenance as prov_mod
25
+ from . import risk as risk_mod
26
+
27
+ VERSION = "0.1.0"
28
+
29
+
30
+ def _ensure_repo(root: str) -> None:
31
+ p = subprocess.run(["git", "-C", root, "rev-parse", "--git-dir"],
32
+ capture_output=True, timeout=15)
33
+ if p.returncode != 0:
34
+ print("error: not a git repo", file=sys.stderr)
35
+ sys.exit(2)
36
+
37
+
38
+ def cmd_init(args) -> int:
39
+ path = os.path.join(args.root, config_mod.CONFIG_FILE)
40
+ if os.path.exists(path) and not args.force:
41
+ print(f"{config_mod.CONFIG_FILE} exists (use --force).")
42
+ return 2
43
+ with open(path, "w") as f:
44
+ f.write(config_mod.example_config())
45
+ print(f"wrote {config_mod.CONFIG_FILE} — tune markers, paths, gates.")
46
+ return 0
47
+
48
+
49
+ def _report_human(prov, blast, gate_results, breakdown, threshold) -> None:
50
+ total_added = sum(f.added for f in prov.values())
51
+ agent_added = sum(f.agent_added for f in prov.values())
52
+ print("═" * 56)
53
+ print(f" PR VERIFY risk {breakdown.score}/100 "
54
+ f"({breakdown.band.upper()})")
55
+ print("═" * 56)
56
+ print(f"\n provenance: {agent_added}/{total_added} added lines "
57
+ f"agent-authored")
58
+ for rel in sorted(prov):
59
+ f = prov[rel]
60
+ if f.deleted:
61
+ who = ", ".join(f.deleted_by) or "unknown"
62
+ print(f" deleted {rel} (deleted by {who})")
63
+ elif f.added:
64
+ who = ",".join(sorted(f.agents)) if f.agents else "human"
65
+ print(f" {f.label():6} {rel} "
66
+ f"({f.agent_added}/{f.added} agent) [{who}]")
67
+ print("\n blast radius:")
68
+ ranked = sorted(blast.values(), key=lambda b: b.size, reverse=True)[:8]
69
+ if not ranked:
70
+ print(" no files changed")
71
+ for b in ranked:
72
+ print(f" {b.file}: {b.size} downstream dependent(s)")
73
+ if b.direct:
74
+ print(f" direct: {', '.join(b.direct[:5])}"
75
+ + (" …" if len(b.direct) > 5 else ""))
76
+ print("\n gates:")
77
+ if not gate_results:
78
+ print(" none configured")
79
+ for g in gate_results:
80
+ mark = "PASS" if g.passed else ("TIMEOUT" if g.timed_out else "FAIL")
81
+ print(f" [{mark}] {g.command}")
82
+ print("\n risk factors:")
83
+ for name, fac in breakdown.factors.items():
84
+ print(f" {fac['points']:>3} pts {name:12} {fac['detail']}")
85
+ print()
86
+ if breakdown.requires_review:
87
+ print(f" ✗ score {breakdown.score} >= threshold {threshold}: "
88
+ "needs senior review")
89
+ else:
90
+ print(f" ✓ score {breakdown.score} < threshold {threshold}")
91
+ print("═" * 56)
92
+
93
+
94
+ def cmd_check(args) -> int:
95
+ root = args.root
96
+ _ensure_repo(root)
97
+ cfg = config_mod.load(root, args.base)
98
+
99
+ # Default head (HEAD) also verifies uncommitted working-tree changes:
100
+ # snapshot them into a synthetic commit so diff/blame see everything.
101
+ # An explicit --head means "committed range only".
102
+ head = args.head
103
+ if head == "HEAD":
104
+ snap = prov_mod.snapshot_worktree(root)
105
+ if snap:
106
+ head = snap
107
+ prov = prov_mod.analyze(root, args.base, head,
108
+ cfg["agent_markers"])
109
+ if not prov:
110
+ print(f"no added lines in {args.base}..{args.head} — nothing to verify")
111
+ return 0
112
+ blast = br_mod.analyze(root, list(prov.keys()))
113
+ gate_results = (gates_mod.run_gates(root, cfg["gates"], cfg["timeout"])
114
+ if not args.skip_gates else [])
115
+ breakdown = risk_mod.score(prov, blast, gate_results,
116
+ cfg["sensitive_paths"],
117
+ cfg["risk_threshold"])
118
+
119
+ if args.format == "json":
120
+ out = {
121
+ "tool": "pr-verify", "version": VERSION,
122
+ "base": args.base, "head": args.head,
123
+ "risk": breakdown.as_dict(),
124
+ "files": {
125
+ rel: {"added": f.added, "agent_added": f.agent_added,
126
+ "label": f.label(), "agents": sorted(f.agents),
127
+ "deleted": f.deleted, "deleted_by": f.deleted_by}
128
+ for rel, f in prov.items()},
129
+ "blast_radius": {
130
+ rel: {"direct": b.direct, "transitive": b.transitive}
131
+ for rel, b in blast.items()},
132
+ "gates": [g.as_dict() for g in gate_results],
133
+ }
134
+ print(json.dumps(out, indent=2))
135
+ else:
136
+ _report_human(prov, blast, gate_results, breakdown,
137
+ cfg["risk_threshold"])
138
+
139
+ if any(not g.passed for g in gate_results):
140
+ # A failing policy gate is a failed verification, independent of
141
+ # --fail-on: the tool cannot vouch for this diff.
142
+ return 1
143
+ if args.fail_on == "high" and breakdown.requires_review:
144
+ return 1
145
+ if args.fail_on == "any-risk" and breakdown.score > 0:
146
+ return 1
147
+ return 0
148
+
149
+
150
+ def build_parser() -> argparse.ArgumentParser:
151
+ p = argparse.ArgumentParser(prog="pr-verify", description=__doc__)
152
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
153
+ p.add_argument("--root", default=os.getcwd())
154
+ sub = p.add_subparsers(dest="cmd", required=True)
155
+
156
+ i = sub.add_parser("init", help="Write example .pr-verify.yaml.")
157
+ i.add_argument("--force", action="store_true")
158
+
159
+ c = sub.add_parser("check", help="Verify a diff range.")
160
+ c.add_argument("--base", default="main",
161
+ help="Base ref (default: main).")
162
+ c.add_argument("--head", default="HEAD",
163
+ help="Head ref (default: HEAD, uncommitted "
164
+ "working-tree changes included).")
165
+ c.add_argument("--format", choices=["human", "json"], default="human")
166
+ c.add_argument("--fail-on", choices=["high", "any-risk", "never"],
167
+ default="high")
168
+ c.add_argument("--skip-gates", action="store_true")
169
+ return p
170
+
171
+
172
+ def main(argv=None) -> int:
173
+ args = build_parser().parse_args(argv)
174
+ return {"init": cmd_init, "check": cmd_check}[args.cmd](args)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
pr_verify/config.py ADDED
@@ -0,0 +1,83 @@
1
+ """Config: .pr-verify.yaml — markers, sensitive paths, gates, thresholds.
2
+
3
+ Security: the config is read from the BASE REF (the trusted side of the
4
+ diff), never from the worktree. A hostile PR must not be able to ship its
5
+ own gate config and get it executed on the reviewer's/CI machine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+
13
+ from . import miniyaml
14
+
15
+ CONFIG_FILE = ".pr-verify.yaml"
16
+
17
+ DEFAULTS = {
18
+ # Substrings matched (case-insensitive) against commit author name/email
19
+ # and message to classify a commit as agent-authored.
20
+ "agent_markers": [
21
+ "claude", "codex", "cursor", "copilot", "bot",
22
+ "co-authored-by: claude", "aider", "cody",
23
+ ],
24
+ # Globs (repo-relative) for security-sensitive areas.
25
+ "sensitive_paths": [
26
+ "**/auth*/**", "**/*auth*.py",
27
+ "**/crypto*/**", "**/*crypt*.py",
28
+ "**/payment*/**", "**/billing*/**",
29
+ "**/migrations/**", "**/migrate/**",
30
+ "**/*secret*", "**/*password*", "**/*token*", "**/*key*.py",
31
+ ],
32
+ # Policy gates: commands run from the repo root, WITHOUT a shell
33
+ # (each command is split with shlex into an argv list). Keep them to
34
+ # plain executables with arguments — no pipes, redirects, or $VARs.
35
+ "gates": [],
36
+ # "gates": ["python -m pytest -x -q"],
37
+ "risk_threshold": 70, # >= this => exit 1 (needs senior review)
38
+ "timeout": 300, # seconds per gate
39
+ }
40
+
41
+
42
+ def _read_base_config(root: str, base: str) -> dict:
43
+ """Return the parsed config file as committed at <base>, or {}."""
44
+ p = subprocess.run(
45
+ ["git", "-C", root, "show", f"{base}:{CONFIG_FILE}"],
46
+ capture_output=True, text=True, timeout=15)
47
+ if p.returncode != 0:
48
+ return {}
49
+ try:
50
+ return miniyaml.safe_load(p.stdout) or {}
51
+ except ValueError as e:
52
+ raise RuntimeError(
53
+ f"invalid {CONFIG_FILE} at {base}: {e} "
54
+ f"(supported: plain mappings/lists/scalars, no anchors or "
55
+ f"multi-line scalars)") from e
56
+
57
+
58
+ def load(root: str, base: str) -> dict:
59
+ cfg = dict(DEFAULTS)
60
+ for key, val in _read_base_config(root, base).items():
61
+ cfg[key] = val
62
+ # normalize marker matching inputs
63
+ cfg["agent_markers"] = [m.lower() for m in cfg.get("agent_markers", [])]
64
+ return cfg
65
+
66
+
67
+ def example_config() -> str:
68
+ return """# PR Verify config. All fields optional; shown with defaults.
69
+ agent_markers:
70
+ - claude
71
+ - codex
72
+ - cursor
73
+ - copilot
74
+ - bot
75
+ sensitive_paths:
76
+ - "**/auth*/**"
77
+ - "**/migrations/**"
78
+ - "**/*secret*"
79
+ gates:
80
+ - "python -m pytest -x -q"
81
+ risk_threshold: 70
82
+ timeout: 300
83
+ """
pr_verify/gates.py ADDED
@@ -0,0 +1,63 @@
1
+ """Policy gates: run configured commands, capture pass/fail.
2
+
3
+ Security: commands are executed WITHOUT a shell. Each gate string is
4
+ split with shlex into an argv list and run directly, so shell
5
+ metacharacters (;, &&, $(), backticks, pipes) are passed as literal
6
+ arguments and never interpreted. Gate config itself is loaded from the
7
+ base ref (see config.py), never from the worktree under review.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import shlex
14
+ import subprocess
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class GateResult:
19
+ command: str
20
+ passed: bool
21
+ returncode: int
22
+ output_tail: str
23
+ timed_out: bool = False
24
+
25
+ def as_dict(self) -> dict:
26
+ return dataclasses.asdict(self)
27
+
28
+
29
+ def run_gates(root: str, commands: list[str],
30
+ timeout: int = 300) -> list[GateResult]:
31
+ results: list[GateResult] = []
32
+ for cmd in commands:
33
+ if not cmd or not cmd.strip():
34
+ continue
35
+ argv = shlex.split(cmd)
36
+ if not argv:
37
+ continue
38
+ try:
39
+ p = subprocess.run(argv, shell=False, cwd=root,
40
+ capture_output=True, text=True,
41
+ timeout=timeout)
42
+ tail = (p.stdout + p.stderr)[-2000:]
43
+ results.append(GateResult(
44
+ command=cmd, passed=p.returncode == 0,
45
+ returncode=p.returncode, output_tail=tail))
46
+ except subprocess.TimeoutExpired as e:
47
+ out = ""
48
+ if e.stdout:
49
+ out += e.stdout.decode(errors="replace") \
50
+ if isinstance(e.stdout, bytes) else e.stdout
51
+ if e.stderr:
52
+ err = e.stderr.decode(errors="replace") \
53
+ if isinstance(e.stderr, bytes) else e.stderr
54
+ out += err
55
+ results.append(GateResult(
56
+ command=cmd, passed=False, returncode=-1,
57
+ output_tail=out[-2000:], timed_out=True))
58
+ except (OSError, ValueError) as e:
59
+ # argv[0] not found / not executable: a failed gate, not a crash.
60
+ results.append(GateResult(
61
+ command=cmd, passed=False, returncode=127,
62
+ output_tail=f"gate could not run: {e}"[-2000:]))
63
+ return results
pr_verify/miniyaml.py ADDED
@@ -0,0 +1,273 @@
1
+ """Minimal YAML subset parser (stdlib only).
2
+
3
+ Handles the subset these tools need:
4
+ - block mappings and lists, nested by indentation
5
+ - inline-map list items ("- key: value")
6
+ - flow-style lists of scalars ("[a, b]")
7
+ - scalars: quoted strings, ints, floats, bools, null
8
+ - "#" comments (outside quotes)
9
+
10
+ SHARED COPY: the canonical version lives here in adr_drift/miniyaml.py.
11
+ pr_verify and promptregress carry verbatim copies (see their headers).
12
+ Do not fork — fix the canonical copy and re-copy.
13
+
14
+ NOT supported (raises ValueError): anchors, aliases, tags, multi-line
15
+ (|, >) scalars, flow maps, duplicate keys. Configs using them get a
16
+ clear error naming the feature and the line number.
17
+
18
+ Strictness notes:
19
+ - Every ValueError carries a line number: "miniyaml: <what> (line N)".
20
+ - Unclosed flow lists ("a: [x"), embedded mapping indicators
21
+ ("a: b: c"), unterminated quotes, and nested flow collections are
22
+ rejected instead of silently misparsed.
23
+ - Nesting deeper than MAX_DEPTH (50) raises ValueError, never
24
+ RecursionError.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ _KEY_RE = re.compile(r"^([A-Za-z0-9_.\-/ ]+):(?:\s|$)")
32
+
33
+ #: Deepest block-nesting level accepted. Deeper input raises ValueError
34
+ #: (the parser's error contract) instead of RecursionError.
35
+ MAX_DEPTH = 50
36
+
37
+
38
+ def _err(msg: str, lineno: int) -> ValueError:
39
+ return ValueError(f"miniyaml: {msg} (line {lineno})")
40
+
41
+
42
+ def _strip_comment(line: str) -> str:
43
+ in_s = in_d = False
44
+ for i, ch in enumerate(line):
45
+ if ch == "'" and not in_d:
46
+ in_s = not in_s
47
+ elif ch == '"' and not in_s:
48
+ in_d = not in_d
49
+ elif ch == "#" and not in_s and not in_d:
50
+ if i == 0 or line[i - 1] in " \t":
51
+ return line[:i]
52
+ return line
53
+
54
+
55
+ def _scalar(s: str):
56
+ s = s.strip()
57
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
58
+ inner = s[1:-1]
59
+ if s[0] == '"':
60
+ inner = inner.replace('\\"', '"').replace("\\\\", "\\")
61
+ return inner
62
+ low = s.lower()
63
+ if low in ("null", "~", "none", ""):
64
+ return None
65
+ if low in ("true", "yes", "on"):
66
+ return True
67
+ if low in ("false", "no", "off"):
68
+ return False
69
+ try:
70
+ return int(s)
71
+ except ValueError:
72
+ pass
73
+ try:
74
+ return float(s)
75
+ except ValueError:
76
+ pass
77
+ return s
78
+
79
+
80
+ def _check_no_mapping_indicator(v: str, lineno: int):
81
+ """Reject ':' inside a plain scalar ("a: b: c") — PyYAML rejects it too.
82
+
83
+ v is an unquoted scalar here; stray quote chars are tracked so a
84
+ colon inside e.g. "it's: fine" does not false-positive.
85
+ """
86
+ in_s = in_d = False
87
+ for i, ch in enumerate(v):
88
+ if ch == "'" and not in_d:
89
+ in_s = not in_s
90
+ elif ch == '"' and not in_s:
91
+ in_d = not in_d
92
+ elif ch == ":" and not in_s and not in_d:
93
+ nxt = v[i + 1] if i + 1 < len(v) else ""
94
+ if nxt in ("", " ", "\t"):
95
+ raise _err(
96
+ "unexpected ':' in scalar "
97
+ f"(nested mappings need their own lines): {v!r}",
98
+ lineno,
99
+ )
100
+
101
+
102
+ def _check_value(val: str, lineno: int, in_flow: bool = False):
103
+ """Reject unsupported/malformed value shapes before _scalar() sees them.
104
+
105
+ Anchors (&), aliases (*), and tags (!) are named explicitly so a
106
+ config using them fails loudly instead of silently dropping content.
107
+ """
108
+ v = val.strip()
109
+ if not v:
110
+ return
111
+ c0 = v[0]
112
+ if c0 == "&":
113
+ raise _err("YAML anchors (&) are not supported", lineno)
114
+ if c0 == "*":
115
+ raise _err("YAML aliases (*) are not supported", lineno)
116
+ if c0 == "!":
117
+ raise _err("YAML tags (!) are not supported", lineno)
118
+ if c0 in ("|", ">"):
119
+ raise _err("multi-line (|, >) scalars are not supported", lineno)
120
+ if c0 in "\"'":
121
+ # Quoted scalars are opaque — nothing else to check.
122
+ if len(v) < 2 or v[-1] != c0:
123
+ raise _err(f"unterminated quoted string: {v!r}", lineno)
124
+ return
125
+ if c0 == "{":
126
+ raise _err("flow-style maps ({...}) are not supported", lineno)
127
+ if c0 == "[":
128
+ if in_flow:
129
+ raise _err("nested flow collections are not supported", lineno)
130
+ if not v.endswith("]"):
131
+ raise _err(f"unclosed flow list: {v!r}", lineno)
132
+ return # balanced "[...]"; items are checked per-item in _flow_list
133
+ if in_flow and any(ch in v for ch in "[]{}"):
134
+ raise _err("nested flow collections are not supported", lineno)
135
+ _check_no_mapping_indicator(v, lineno)
136
+
137
+
138
+ def _flow_list(s: str, lineno: int) -> list:
139
+ """Parse [a, b, "c"] — scalars only."""
140
+ inner = s.strip()[1:-1].strip()
141
+ if not inner:
142
+ return []
143
+ raw_items, cur, in_s, in_d = [], "", False, False
144
+ for ch in inner:
145
+ if ch == "'" and not in_d:
146
+ in_s = not in_s
147
+ cur += ch
148
+ elif ch == '"' and not in_s:
149
+ in_d = not in_d
150
+ cur += ch
151
+ elif ch == "," and not in_s and not in_d:
152
+ raw_items.append(cur)
153
+ cur = ""
154
+ else:
155
+ cur += ch
156
+ if in_s or in_d:
157
+ raise _err(f"unterminated quote in flow list: {s}", lineno)
158
+ raw_items.append(cur)
159
+ out = []
160
+ for item in raw_items:
161
+ _check_value(item, lineno, in_flow=True)
162
+ out.append(_scalar(item))
163
+ return out
164
+
165
+
166
+ def safe_load(text: str):
167
+ # items: (line number, indent, content)
168
+ items = []
169
+ for lineno, raw in enumerate(text.splitlines(), 1):
170
+ line = _strip_comment(raw).rstrip()
171
+ if not line.strip():
172
+ continue
173
+ if "\t" in line[: len(line) - len(line.lstrip())]:
174
+ raise _err("tabs not allowed for indentation", lineno)
175
+ indent = len(line) - len(line.lstrip(" "))
176
+ content = line.strip()
177
+ if content in ("---", "..."):
178
+ continue
179
+ items.append((lineno, indent, content))
180
+ if not items:
181
+ return None
182
+ val, pos = _parse_block(items, 0, items[0][1], 0)
183
+ if pos != len(items):
184
+ lineno, _, content = items[pos]
185
+ raise _err(f"unexpected content: {content!r}", lineno)
186
+ return val
187
+
188
+
189
+ def _parse_block(items, pos, indent, depth):
190
+ if depth > MAX_DEPTH:
191
+ raise _err(
192
+ f"maximum nesting depth ({MAX_DEPTH}) exceeded", items[pos][0]
193
+ )
194
+ first = items[pos][2]
195
+ if first == "-" or first.startswith("- "):
196
+ return _parse_list(items, pos, indent, depth)
197
+ return _parse_map(items, pos, indent, depth)
198
+
199
+
200
+ def _parse_map(items, pos, indent, depth):
201
+ d: dict = {}
202
+ while pos < len(items):
203
+ lineno, ind, line = items[pos]
204
+ if ind < indent:
205
+ break
206
+ if ind > indent:
207
+ raise _err(f"bad indentation: {line!r}", lineno)
208
+ if line == "-" or line.startswith("- "):
209
+ raise _err(f"list item inside mapping: {line!r}", lineno)
210
+ if line[0] in "&*!":
211
+ # Anchor/alias/tag where a key was expected — name it.
212
+ _check_value(line, lineno)
213
+ m = _KEY_RE.match(line)
214
+ if not m:
215
+ raise _err(f"expected 'key: value', got: {line!r}", lineno)
216
+ key = m.group(1).strip()
217
+ rest = line[m.end():].strip()
218
+ pos += 1
219
+ if key in d:
220
+ raise _err(f"duplicate key: {key!r}", lineno)
221
+ if rest:
222
+ _check_value(rest, lineno)
223
+ if rest.startswith("["):
224
+ d[key] = _flow_list(rest, lineno)
225
+ else:
226
+ d[key] = _scalar(rest)
227
+ else:
228
+ if pos < len(items) and items[pos][1] > indent:
229
+ val, pos = _parse_block(items, pos, items[pos][1], depth + 1)
230
+ d[key] = val
231
+ else:
232
+ d[key] = None
233
+ return d, pos
234
+
235
+
236
+ def _parse_list(items, pos, indent, depth):
237
+ lst: list = []
238
+ while pos < len(items):
239
+ lineno, ind, line = items[pos]
240
+ if ind < indent:
241
+ break
242
+ if ind > indent:
243
+ raise _err(f"bad indentation: {line!r}", lineno)
244
+ if not (line == "-" or line.startswith("- ")):
245
+ break # end of list; parent map continues
246
+ content = line[1:].strip()
247
+ pos += 1
248
+ if not content:
249
+ if pos < len(items) and items[pos][1] > indent:
250
+ val, pos = _parse_block(items, pos, items[pos][1], depth + 1)
251
+ lst.append(val)
252
+ else:
253
+ lst.append(None)
254
+ continue
255
+ if _KEY_RE.match(content):
256
+ sub = [(lineno, indent + 2, content)]
257
+ while pos < len(items) and items[pos][1] > indent:
258
+ sub.append(items[pos])
259
+ pos += 1
260
+ val, _ = _parse_map(sub, 0, indent + 2, depth + 1)
261
+ lst.append(val)
262
+ elif content.startswith("[") and content.endswith("]"):
263
+ _check_value(content, lineno)
264
+ lst.append(_flow_list(content, lineno))
265
+ else:
266
+ _check_value(content, lineno)
267
+ lst.append(_scalar(content))
268
+ return lst, pos
269
+
270
+
271
+ # alias mirroring the PyYAML call sites being replaced
272
+ def load(text: str):
273
+ return safe_load(text)
@@ -0,0 +1,225 @@
1
+ """Provenance: which added lines were written by an agent vs a human.
2
+
3
+ Method (deterministic, no LLM):
4
+ 1. `git diff -U0 base..head` -> added lines per file (hunks).
5
+ 2. `git blame --porcelain <head> -- <file>` -> commit per line.
6
+ 3. `git log` for those commits -> author name/email + subject/body.
7
+ 4. A commit is agent-authored if any agent_marker substring appears in
8
+ the author name, author email, subject, or body (case-insensitive).
9
+
10
+ Deleted files are reported too: `git diff --diff-filter=D` finds them and
11
+ `git log --diff-filter=D` identifies the deleting commit(s), so a deletion
12
+ can never slip through as "nothing to verify". For a deleted file the
13
+ blast-radius scan matches the file's module names against the imports of
14
+ the files still in the worktree.
15
+
16
+ snapshot_worktree() commits the current index+worktree (including
17
+ untracked files) to a synthetic, ref-less commit so `check` also sees
18
+ uncommitted changes. Uncommitted lines are attributed to a neutral
19
+ "pr-verify (worktree)" identity — i.e. counted as human, never as agent —
20
+ because there is no commit authorship to analyze.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import dataclasses
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import tempfile
30
+
31
+
32
+ def _git(root: str, *args: str, env: dict | None = None) -> str:
33
+ p = subprocess.run(["git", "-C", root, *args], capture_output=True,
34
+ text=True, timeout=60, env=env)
35
+ if p.returncode != 0:
36
+ raise RuntimeError(f"git {' '.join(args)} failed: {p.stderr.strip()[:200]}")
37
+ return p.stdout
38
+
39
+
40
+ def snapshot_worktree(root: str) -> str | None:
41
+ """Synthetic commit of index+worktree (untracked included).
42
+
43
+ Returns the new commit sha, or None when there is nothing to snapshot
44
+ (no HEAD yet, or any git plumbing failure — callers fall back to the
45
+ plain head ref). Touches no refs and no user-visible index: staging
46
+ happens in a temporary GIT_INDEX_FILE that is removed afterwards.
47
+ """
48
+ try:
49
+ head = _git(root, "rev-parse", "--verify", "HEAD").strip()
50
+ except RuntimeError:
51
+ return None
52
+ env = dict(os.environ)
53
+ env.update({
54
+ "GIT_AUTHOR_NAME": "pr-verify (worktree)",
55
+ "GIT_AUTHOR_EMAIL": "pr-verify@local",
56
+ "GIT_COMMITTER_NAME": "pr-verify (worktree)",
57
+ "GIT_COMMITTER_EMAIL": "pr-verify@local",
58
+ })
59
+ fd, idx = tempfile.mkstemp(prefix="pr-verify-index-")
60
+ os.close(fd)
61
+ env["GIT_INDEX_FILE"] = idx
62
+ try:
63
+ for args in (["read-tree", "HEAD"], ["add", "-A"]):
64
+ p = subprocess.run(["git", "-C", root, *args], env=env,
65
+ capture_output=True, timeout=60)
66
+ if p.returncode != 0:
67
+ return None
68
+ p = subprocess.run(["git", "-C", root, "write-tree"], env=env,
69
+ capture_output=True, text=True, timeout=60)
70
+ if p.returncode != 0:
71
+ return None
72
+ tree = p.stdout.strip()
73
+ p = subprocess.run(
74
+ ["git", "-C", root, "commit-tree", tree,
75
+ "-p", head, "-m", "pr-verify: uncommitted worktree snapshot"],
76
+ env=env, capture_output=True, text=True, timeout=60)
77
+ if p.returncode != 0:
78
+ return None
79
+ return p.stdout.strip()
80
+ finally:
81
+ try:
82
+ os.unlink(idx)
83
+ except OSError:
84
+ pass
85
+
86
+
87
+ @dataclasses.dataclass
88
+ class FileProvenance:
89
+ file: str
90
+ added: int = 0
91
+ agent_added: int = 0
92
+ human_added: int = 0
93
+ agents: set = dataclasses.field(default_factory=set) # author names
94
+ deleted: bool = False
95
+ deleted_by: list = dataclasses.field(default_factory=list) # author names
96
+
97
+ @property
98
+ def agent_share(self) -> float:
99
+ return self.agent_added / self.added if self.added else 0.0
100
+
101
+ def label(self) -> str:
102
+ if self.deleted:
103
+ return "deleted"
104
+ if self.added == 0:
105
+ return "untouched"
106
+ if self.agent_added == self.added:
107
+ return "agent"
108
+ if self.agent_added == 0:
109
+ return "human"
110
+ return "mixed"
111
+
112
+
113
+ _DIFF_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
114
+
115
+
116
+ def added_lines(root: str, base: str, head: str = "HEAD") -> dict[str, list[int]]:
117
+ """Repo-relative file -> sorted list of added line numbers (at head)."""
118
+ out = _git(root, "diff", "-U0", f"{base}..{head}", "--")
119
+ result: dict[str, list[int]] = {}
120
+ cur_file: str | None = None
121
+ new_line = 0
122
+ for line in out.splitlines():
123
+ if line.startswith("+++ b/"):
124
+ cur_file = line[6:]
125
+ result.setdefault(cur_file, [])
126
+ elif line.startswith("@@"):
127
+ m = _DIFF_HUNK_RE.match(line)
128
+ if m and cur_file:
129
+ new_line = int(m.group(1))
130
+ elif cur_file and new_line:
131
+ if line.startswith("+") and not line.startswith("+++"):
132
+ result[cur_file].append(new_line)
133
+ new_line += 1
134
+ elif line.startswith("-") and not line.startswith("---"):
135
+ pass # removed line: doesn't advance new-file numbering
136
+ else:
137
+ new_line += 1
138
+ return {f: sorted(ls) for f, ls in result.items() if ls}
139
+
140
+
141
+ def deleted_files(root: str, base: str, head: str = "HEAD") -> list[str]:
142
+ """Repo-relative paths deleted between base and head."""
143
+ out = _git(root, "diff", "--name-only", "--diff-filter=D",
144
+ f"{base}..{head}", "--")
145
+ return sorted(p for p in out.splitlines() if p)
146
+
147
+
148
+ def _deletion_commits(root: str, base: str, head: str,
149
+ rel: str) -> list[str]:
150
+ """Commit shas in base..head that deleted rel (oldest first)."""
151
+ out = _git(root, "log", "--format=%H", "--diff-filter=D", "--reverse",
152
+ f"{base}..{head}", "--", rel)
153
+ return [ln for ln in out.splitlines() if ln]
154
+
155
+
156
+ def _blame_authors(root: str, head: str, rel: str) -> dict[int, str]:
157
+ """Line number -> commit sha, for <head> version of file."""
158
+ out = _git(root, "blame", "--porcelain", head, "--", rel)
159
+ authors: dict[int, str] = {}
160
+ sha = ""
161
+ lineno = 0
162
+ for line in out.splitlines():
163
+ if re.match(r"^[0-9a-f]{40} \d+ \d+ \d+$", line):
164
+ parts = line.split()
165
+ sha, lineno = parts[0], int(parts[2])
166
+ elif line.startswith("\t"):
167
+ authors[lineno] = sha
168
+ lineno += 1 # each content line advances within the hunk
169
+ return authors
170
+
171
+
172
+ def _commit_text(root: str, shas: set[str]) -> dict[str, tuple[str, str]]:
173
+ """sha -> (author name, 'name email subject body' lowercased)."""
174
+ infos: dict[str, tuple[str, str]] = {}
175
+ for sha in shas:
176
+ out = _git(root, "log", "-1", "--format=%an%x00%ae%x00%s%x00%b", sha)
177
+ parts = out.split("\x00")
178
+ name = parts[0] if parts else sha[:7]
179
+ infos[sha] = (name, " ".join(parts).lower())
180
+ return infos
181
+
182
+
183
+ def analyze(root: str, base: str, head: str = "HEAD",
184
+ agent_markers: list[str] | None = None) -> dict[str, FileProvenance]:
185
+ agent_markers = [m.lower() for m in (agent_markers or [])]
186
+ added = added_lines(root, base, head)
187
+ deleted = deleted_files(root, base, head)
188
+ result: dict[str, FileProvenance] = {}
189
+ # Batch: collect all shas first, then one log per sha.
190
+ blame_maps: dict[str, dict[int, str]] = {}
191
+ del_shas: dict[str, list[str]] = {}
192
+ all_shas: set[str] = set()
193
+ for rel in added:
194
+ bm = _blame_authors(root, head, rel)
195
+ blame_maps[rel] = bm
196
+ all_shas.update(bm[ln] for ln in added[rel] if ln in bm)
197
+ for rel in deleted:
198
+ shas = _deletion_commits(root, base, head, rel)
199
+ del_shas[rel] = shas
200
+ all_shas.update(shas)
201
+ texts = _commit_text(root, all_shas)
202
+ agent_shas = {s for s, (_name, t) in texts.items()
203
+ if any(m in t for m in agent_markers)}
204
+
205
+ for rel, linenos in added.items():
206
+ fp = FileProvenance(file=rel)
207
+ bm = blame_maps[rel]
208
+ for ln in linenos:
209
+ fp.added += 1
210
+ sha = bm.get(ln)
211
+ if sha in agent_shas:
212
+ fp.agent_added += 1
213
+ fp.agents.add(texts[sha][0] or sha[:7])
214
+ else:
215
+ fp.human_added += 1
216
+ result[rel] = fp
217
+ for rel in deleted:
218
+ fp = FileProvenance(file=rel, deleted=True)
219
+ for sha in del_shas[rel]:
220
+ name = texts.get(sha, (sha[:7], ""))[0] or sha[:7]
221
+ fp.deleted_by.append(name)
222
+ if sha in agent_shas:
223
+ fp.agents.add(name)
224
+ result[rel] = fp
225
+ return result
pr_verify/risk.py ADDED
@@ -0,0 +1,160 @@
1
+ """Risk scoring: deterministic, documented, no LLM.
2
+
3
+ Score 0-100 from six observable factors. The formula is public so teams
4
+ can argue with it and tune it — a black-box score is just another opinion.
5
+
6
+ agent_share 40 pts % of added lines written by an agent (provenance)
7
+ blast 25 pts log-scaled transitive dependents: 25 * min(1, log10(1+n)/2)
8
+ (n=9 -> ~12.5, n=99 -> 25)
9
+ sensitive 20 pts any changed file under a sensitive_path glob
10
+ (deleted files match like touched files)
11
+ gates 10 pts any policy gate failed or timed out (0 if no gates)
12
+ no_tests 5 pts src changed but no test file changed
13
+ deletion 50 pts agent-attributed deletions: 45 if any deleted file
14
+ matches a sensitive_path glob (deleting sensitive
15
+ code, not touching it), else 10 for any agent
16
+ deletion of tracked files; plus blast-radius-style
17
+ points for the deleted files' transitive dependents
18
+ (25 * min(1, log10(1+n)/2)), capped at 50.
19
+ A pure-deletion PR previously capped at ~50 and could
20
+ never trip the high threshold; deleting auth.py is at
21
+ least as scary as rewriting it.
22
+
23
+ Bands: 0-29 low · 30-69 medium · 70-100 high (needs senior review).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import fnmatch
30
+ import math
31
+ import os
32
+
33
+ from . import blastradius as br_mod
34
+ from . import gates as gates_mod
35
+ from . import provenance as prov_mod
36
+
37
+
38
+ @dataclasses.dataclass
39
+ class RiskBreakdown:
40
+ score: int
41
+ band: str
42
+ factors: dict # name -> {"points": int, "detail": str}
43
+ requires_review: bool
44
+
45
+ def as_dict(self) -> dict:
46
+ return {"score": self.score, "band": self.band,
47
+ "factors": self.factors,
48
+ "requires_review": self.requires_review}
49
+
50
+
51
+ def _is_test_file(rel: str) -> bool:
52
+ low = rel.lower()
53
+ return ("test" in low.split("/")[-1] or "/tests/" in low
54
+ or low.startswith("tests/"))
55
+
56
+
57
+ def _sensitive_candidates(f: str) -> list[str]:
58
+ # Globs are matched against the full repo-relative path AND the
59
+ # bare filename, so a pattern like "*auth*" catches auth.py at the
60
+ # repo root or nested anywhere — not just under matched directories.
61
+ base = os.path.basename(f)
62
+ return [f, "/" + f, base, "/" + base]
63
+
64
+
65
+ def _sensitive_hits(files: list[str], sensitive_paths: list[str]) -> list[str]:
66
+ return sorted({f for f in files
67
+ if any(fnmatch.fnmatchcase(c, p)
68
+ for c in _sensitive_candidates(f)
69
+ for p in sensitive_paths)})
70
+
71
+
72
+ def _blast_points(n: int, cap: int) -> int:
73
+ """Log-scaled dependents -> points, shared by blast and deletion."""
74
+ return round(cap * min(1.0, math.log10(1 + n) / 2)) if n else 0
75
+
76
+
77
+ def score(prov: dict[str, prov_mod.FileProvenance],
78
+ blast: dict[str, br_mod.BlastRadius],
79
+ gate_results: list[gates_mod.GateResult],
80
+ sensitive_paths: list[str],
81
+ threshold: int) -> RiskBreakdown:
82
+ factors: dict[str, dict] = {}
83
+
84
+ total_added = sum(f.added for f in prov.values())
85
+ agent_added = sum(f.agent_added for f in prov.values())
86
+ share = agent_added / total_added if total_added else 0.0
87
+ pts = round(share * 40)
88
+ factors["agent_share"] = {
89
+ "points": pts,
90
+ "detail": f"{agent_added}/{total_added} added lines agent-authored "
91
+ f"({share:.0%})"}
92
+
93
+ max_n = max((b.size for b in blast.values()), default=0)
94
+ pts = _blast_points(max_n, 25)
95
+ factors["blast"] = {
96
+ "points": pts,
97
+ "detail": f"largest blast radius: {max_n} downstream dependent(s)"}
98
+
99
+ # Deleted files match the sensitive globs like touched files do —
100
+ # deleting auth.py is not a way to dodge the sensitive factor.
101
+ sens_hits = _sensitive_hits(list(prov), sensitive_paths)
102
+ pts = 20 if sens_hits else 0
103
+ factors["sensitive"] = {
104
+ "points": pts,
105
+ "detail": f"sensitive files touched: {', '.join(sens_hits) or 'none'}"}
106
+
107
+ failed = [g.command for g in gate_results
108
+ if not g.passed]
109
+ pts = 10 if failed else 0
110
+ factors["gates"] = {
111
+ "points": pts,
112
+ "detail": (f"failed gates: {', '.join(failed)}"
113
+ if failed else
114
+ f"{len(gate_results)} gate(s) passed"
115
+ if gate_results else "no gates configured")}
116
+
117
+ src_changed = any(not _is_test_file(f) for f in prov)
118
+ tests_changed = any(_is_test_file(f) for f in prov)
119
+ pts = 5 if (src_changed and not tests_changed) else 0
120
+ factors["no_tests"] = {
121
+ "points": pts,
122
+ "detail": ("source changed with no test changes"
123
+ if pts else "tests touched or no source changes")}
124
+
125
+ # Deletions: pure-deletion PRs contribute ~0 to agent_share (no added
126
+ # lines), so a deletion factor makes sure they can still trip the
127
+ # threshold. Only agent-attributed deletions count — a human deleting
128
+ # a file is their own review problem, not an agent-trust problem.
129
+ # Deleting sensitive code is the worst case (45): unlike a touch, the
130
+ # file can no longer be fixed in place, only restored. Otherwise an
131
+ # agent deleting tracked files at all is worth 10, plus
132
+ # blast-radius-style points for the deleted files' dependents (a
133
+ # deleted import breaks its importers just like a bad edit would).
134
+ agent_deleted = [f for f in prov.values()
135
+ if f.deleted and f.agents]
136
+ if agent_deleted:
137
+ del_names = [f.file for f in agent_deleted]
138
+ max_dep = max((blast[f].size for f in del_names if f in blast),
139
+ default=0)
140
+ pts = _blast_points(max_dep, 25)
141
+ if _sensitive_hits(del_names, sensitive_paths):
142
+ pts += 45
143
+ else:
144
+ pts += 10
145
+ pts = min(50, pts)
146
+ factors["deletion"] = {
147
+ "points": pts,
148
+ "detail": (f"agent-deleted: {', '.join(del_names)}; "
149
+ f"sensitive: "
150
+ f"{', '.join(_sensitive_hits(del_names, sensitive_paths)) or 'none'}; "
151
+ f"largest deleted-file dependents: {max_dep}")}
152
+ else:
153
+ factors["deletion"] = {
154
+ "points": 0,
155
+ "detail": "no agent-attributed deletions"}
156
+
157
+ total = min(100, sum(f["points"] for f in factors.values()))
158
+ band = "low" if total < 30 else "medium" if total < 70 else "high"
159
+ return RiskBreakdown(score=total, band=band, factors=factors,
160
+ requires_review=total >= threshold)
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: pr-verify
3
+ Version: 0.1.0
4
+ Summary: Deterministic trust scoring for agent-authored PRs.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/pr-verify
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/pr-verify/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # PR Verify
15
+
16
+ Deterministic trust for agent-authored PRs. Not another AI reviewer —
17
+ **verification, not opinion.** Agents now write more code than seniors can
18
+ review; this tells a senior exactly how deep to review, from observable
19
+ facts: which lines an agent wrote, what could break, and whether policy
20
+ gates pass.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ git clone <repo> && cd pr-verify
26
+ pip install . # provides the `pr-verify` console script
27
+ ```
28
+
29
+ Or run without installing: `python3 -m pr_verify` from the repo root.
30
+
31
+ ## Quick start
32
+
33
+ ```bash
34
+ cd your-repo
35
+ python3 -m pr_verify init # write an example .pr-verify.yaml
36
+ python3 -m pr_verify check --base main
37
+ ```
38
+
39
+ ## What it reports
40
+
41
+ **Provenance** — every added line labeled agent/human via `git blame` +
42
+ commit-author analysis (configurable markers: claude, codex, cursor,
43
+ copilot, bot…). Per file: `agent` / `mixed` / `human`, with the detected
44
+ agent identities.
45
+
46
+ **Blast radius** — reverse dependency graph: for each changed file, the
47
+ transitive set of in-repo dependents that could break. Python via AST;
48
+ JS/TS/Go/Rust/Java/Ruby/PHP via heuristic fallback.
49
+
50
+ **Policy gates** — commands from `.pr-verify.yaml` (tests, typecheck,
51
+ lint), run with timeout, pass/fail captured. The config is read from the
52
+ base ref (never the worktree under review) and gates run **without** a
53
+ shell — `;`, `&&`, `$()`, pipes and the rest are literal arguments, never
54
+ interpreted. A failing gate fails the verification (nonzero exit).
55
+
56
+ **Risk score** — deterministic 0–100 from six public factors:
57
+
58
+ | Factor | Max | What it measures |
59
+ |---|---|---|
60
+ | agent_share | 40 | % of added lines agent-authored |
61
+ | blast | 25 | log-scaled transitive dependents |
62
+ | sensitive | 20 | changed files under sensitive globs (auth, crypto, payments, migrations…); deleted files match like touched ones |
63
+ | gates | 10 | any policy gate failed |
64
+ | no_tests | 5 | source changed with no test changes |
65
+ | deletion | 50 | agent-attributed deletions: 45 if any deleted file is sensitive, else 10 for deleting tracked files at all, plus log-scaled points for the deleted files' dependents — pure-deletion PRs can trip the threshold too |
66
+
67
+ Bands: 0–29 low · 30–69 medium · 70–100 high. The formula is documented so
68
+ teams can argue with it and tune it — a black-box score is just another
69
+ opinion.
70
+
71
+ ## CI
72
+
73
+ ```bash
74
+ python3 -m pr_verify check --base origin/main --format json --fail-on high
75
+ ```
76
+
77
+ `--fail-on high` (default) exits 1 when score ≥ `risk_threshold` (70).
78
+ `--fail-on any-risk` / `--fail-on never` available. A GitHub Action is
79
+ included at `.github/workflows/pr-verify.yml`.
80
+
81
+ ## Example output
82
+
83
+ ```
84
+ ════════════════════════════════════════════════════════
85
+ PR VERIFY risk 57/100 (MEDIUM)
86
+ ════════════════════════════════════════════════════════
87
+
88
+ provenance: 9/13 added lines agent-authored
89
+ agent app/auth.py (4/4 agent) [claude-bot]
90
+ mixed app/core.py (4/8 agent) [claude-bot]
91
+ agent app/views.py (1/1 agent) [claude-bot]
92
+
93
+ blast radius:
94
+ app/core.py: 1 downstream dependent(s)
95
+ direct: app/views.py
96
+
97
+ risk factors:
98
+ 28 pts agent_share 9/13 added lines agent-authored (69%)
99
+ 4 pts blast largest blast radius: 1 downstream dependent(s)
100
+ 20 pts sensitive sensitive files touched: app/auth.py
101
+ 0 pts gates no gates configured
102
+ 5 pts no_tests source changed with no test changes
103
+
104
+ ✓ score 57 < threshold 70
105
+ ════════════════════════════════════════════════════════
106
+ ```
107
+
108
+ ## Honest limits
109
+
110
+ - Provenance depends on commit authorship — squashed or rebased PRs lose
111
+ per-commit attribution (tune `agent_markers` for your workflow).
112
+ Uncommitted working-tree changes are included in `check`, but with no
113
+ commit to blame they are counted as human-written, never agent-written.
114
+ - Non-Python blast-radius extraction is heuristic.
115
+ - The risk formula is a starting point, not a verdict — tune the threshold
116
+ per team.
117
+
118
+ ## Tests
119
+
120
+ 55/55 pass: `python3 -m unittest discover -s tests`
@@ -0,0 +1,15 @@
1
+ pr_verify/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pr_verify/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
3
+ pr_verify/blastradius.py,sha256=xfl8mbqeHzYs-bnMuJ2Llgy5llMYMHx5wNK_VA01VAU,6413
4
+ pr_verify/cli.py,sha256=F7L6DIOCvkIxnb7zboEgK6qXY7L9kPQ9LDPYfgvNog4,6693
5
+ pr_verify/config.py,sha256=6mT4lBBtg5F8N9MZUT4_bWjzGVxNuhHUqm_aPO5PuhA,2617
6
+ pr_verify/gates.py,sha256=P_mDVGt-3Rq-ZwoFsAZHS9leN2iCq-uiLM72jAUBQ50,2239
7
+ pr_verify/miniyaml.py,sha256=bDpXNkei59wFIOd3Jf7Z_gqVXHI1RpqtUxpRT3a1yCU,9170
8
+ pr_verify/provenance.py,sha256=A8ltrU3I9JDWczhhzPAXw5TXfDR5kY4guqi9Io769qI,8523
9
+ pr_verify/risk.py,sha256=BL2k-OiyPtUNOp511YG2cs28LavLWuHKiYM9mY4zvoY,6653
10
+ pr_verify-0.1.0.dist-info/licenses/LICENSE,sha256=f-qFLkEfvZidBgKv2YINOOKS7Trk_gj1c6Cy718D8F0,1079
11
+ pr_verify-0.1.0.dist-info/METADATA,sha256=5NWE48uF6pBzDDqKesQqG6P6zp6zTO_PWjbJeq6PKj4,4735
12
+ pr_verify-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ pr_verify-0.1.0.dist-info/entry_points.txt,sha256=BpWIv-124hHwJfd4L0KyzfN77DzvpcQEs9TvNGY5qIs,49
14
+ pr_verify-0.1.0.dist-info/top_level.txt,sha256=N78V88hw0XwT2_QsRW-woqa9APQ95XoNjfwQctre4vc,10
15
+ pr_verify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pr-verify = pr_verify.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pr_verify