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 +0 -0
- pr_verify/__main__.py +4 -0
- pr_verify/blastradius.py +179 -0
- pr_verify/cli.py +178 -0
- pr_verify/config.py +83 -0
- pr_verify/gates.py +63 -0
- pr_verify/miniyaml.py +273 -0
- pr_verify/provenance.py +225 -0
- pr_verify/risk.py +160 -0
- pr_verify-0.1.0.dist-info/METADATA +120 -0
- pr_verify-0.1.0.dist-info/RECORD +15 -0
- pr_verify-0.1.0.dist-info/WHEEL +5 -0
- pr_verify-0.1.0.dist-info/entry_points.txt +2 -0
- pr_verify-0.1.0.dist-info/licenses/LICENSE +21 -0
- pr_verify-0.1.0.dist-info/top_level.txt +1 -0
pr_verify/__init__.py
ADDED
|
File without changes
|
pr_verify/__main__.py
ADDED
pr_verify/blastradius.py
ADDED
|
@@ -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)
|
pr_verify/provenance.py
ADDED
|
@@ -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,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
|