pr-verify 0.1.0__tar.gz

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.
@@ -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,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,107 @@
1
+ # PR Verify
2
+
3
+ Deterministic trust for agent-authored PRs. Not another AI reviewer —
4
+ **verification, not opinion.** Agents now write more code than seniors can
5
+ review; this tells a senior exactly how deep to review, from observable
6
+ facts: which lines an agent wrote, what could break, and whether policy
7
+ gates pass.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ git clone <repo> && cd pr-verify
13
+ pip install . # provides the `pr-verify` console script
14
+ ```
15
+
16
+ Or run without installing: `python3 -m pr_verify` from the repo root.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ cd your-repo
22
+ python3 -m pr_verify init # write an example .pr-verify.yaml
23
+ python3 -m pr_verify check --base main
24
+ ```
25
+
26
+ ## What it reports
27
+
28
+ **Provenance** — every added line labeled agent/human via `git blame` +
29
+ commit-author analysis (configurable markers: claude, codex, cursor,
30
+ copilot, bot…). Per file: `agent` / `mixed` / `human`, with the detected
31
+ agent identities.
32
+
33
+ **Blast radius** — reverse dependency graph: for each changed file, the
34
+ transitive set of in-repo dependents that could break. Python via AST;
35
+ JS/TS/Go/Rust/Java/Ruby/PHP via heuristic fallback.
36
+
37
+ **Policy gates** — commands from `.pr-verify.yaml` (tests, typecheck,
38
+ lint), run with timeout, pass/fail captured. The config is read from the
39
+ base ref (never the worktree under review) and gates run **without** a
40
+ shell — `;`, `&&`, `$()`, pipes and the rest are literal arguments, never
41
+ interpreted. A failing gate fails the verification (nonzero exit).
42
+
43
+ **Risk score** — deterministic 0–100 from six public factors:
44
+
45
+ | Factor | Max | What it measures |
46
+ |---|---|---|
47
+ | agent_share | 40 | % of added lines agent-authored |
48
+ | blast | 25 | log-scaled transitive dependents |
49
+ | sensitive | 20 | changed files under sensitive globs (auth, crypto, payments, migrations…); deleted files match like touched ones |
50
+ | gates | 10 | any policy gate failed |
51
+ | no_tests | 5 | source changed with no test changes |
52
+ | 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 |
53
+
54
+ Bands: 0–29 low · 30–69 medium · 70–100 high. The formula is documented so
55
+ teams can argue with it and tune it — a black-box score is just another
56
+ opinion.
57
+
58
+ ## CI
59
+
60
+ ```bash
61
+ python3 -m pr_verify check --base origin/main --format json --fail-on high
62
+ ```
63
+
64
+ `--fail-on high` (default) exits 1 when score ≥ `risk_threshold` (70).
65
+ `--fail-on any-risk` / `--fail-on never` available. A GitHub Action is
66
+ included at `.github/workflows/pr-verify.yml`.
67
+
68
+ ## Example output
69
+
70
+ ```
71
+ ════════════════════════════════════════════════════════
72
+ PR VERIFY risk 57/100 (MEDIUM)
73
+ ════════════════════════════════════════════════════════
74
+
75
+ provenance: 9/13 added lines agent-authored
76
+ agent app/auth.py (4/4 agent) [claude-bot]
77
+ mixed app/core.py (4/8 agent) [claude-bot]
78
+ agent app/views.py (1/1 agent) [claude-bot]
79
+
80
+ blast radius:
81
+ app/core.py: 1 downstream dependent(s)
82
+ direct: app/views.py
83
+
84
+ risk factors:
85
+ 28 pts agent_share 9/13 added lines agent-authored (69%)
86
+ 4 pts blast largest blast radius: 1 downstream dependent(s)
87
+ 20 pts sensitive sensitive files touched: app/auth.py
88
+ 0 pts gates no gates configured
89
+ 5 pts no_tests source changed with no test changes
90
+
91
+ ✓ score 57 < threshold 70
92
+ ════════════════════════════════════════════════════════
93
+ ```
94
+
95
+ ## Honest limits
96
+
97
+ - Provenance depends on commit authorship — squashed or rebased PRs lose
98
+ per-commit attribution (tune `agent_markers` for your workflow).
99
+ Uncommitted working-tree changes are included in `check`, but with no
100
+ commit to blame they are counted as human-written, never agent-written.
101
+ - Non-Python blast-radius extraction is heuristic.
102
+ - The risk formula is a starting point, not a verdict — tune the threshold
103
+ per team.
104
+
105
+ ## Tests
106
+
107
+ 55/55 pass: `python3 -m unittest discover -s tests`
File without changes
@@ -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
@@ -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())