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.
- pr_verify-0.1.0/LICENSE +21 -0
- pr_verify-0.1.0/PKG-INFO +120 -0
- pr_verify-0.1.0/README.md +107 -0
- pr_verify-0.1.0/pr_verify/__init__.py +0 -0
- pr_verify-0.1.0/pr_verify/__main__.py +4 -0
- pr_verify-0.1.0/pr_verify/blastradius.py +179 -0
- pr_verify-0.1.0/pr_verify/cli.py +178 -0
- pr_verify-0.1.0/pr_verify/config.py +83 -0
- pr_verify-0.1.0/pr_verify/gates.py +63 -0
- pr_verify-0.1.0/pr_verify/miniyaml.py +273 -0
- pr_verify-0.1.0/pr_verify/provenance.py +225 -0
- pr_verify-0.1.0/pr_verify/risk.py +160 -0
- pr_verify-0.1.0/pr_verify.egg-info/PKG-INFO +120 -0
- pr_verify-0.1.0/pr_verify.egg-info/SOURCES.txt +20 -0
- pr_verify-0.1.0/pr_verify.egg-info/dependency_links.txt +1 -0
- pr_verify-0.1.0/pr_verify.egg-info/entry_points.txt +2 -0
- pr_verify-0.1.0/pr_verify.egg-info/top_level.txt +1 -0
- pr_verify-0.1.0/pyproject.toml +20 -0
- pr_verify-0.1.0/setup.cfg +4 -0
- pr_verify-0.1.0/tests/test_cli.py +237 -0
- pr_verify-0.1.0/tests/test_miniyaml.py +106 -0
- pr_verify-0.1.0/tests/test_units.py +244 -0
pr_verify-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pr_verify-0.1.0/PKG-INFO
ADDED
|
@@ -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,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())
|