breakproof 0.1.1__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.
breakproof/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Breakproof: fail the PR only when you actually broke something."""
2
+
3
+ __version__ = "0.1.1"
breakproof/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """breakproof CLI. Reads a lot, writes only what you point it at.
2
+
3
+ Exit codes: 0 pass, 1 fail (something broke), 2 you broke the invocation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from breakproof import __version__
14
+ from breakproof.extract import scan_path
15
+ from breakproof.prgate import build_gate, render_gate_markdown
16
+ from breakproof.radar import build_api_diff, render_radar_markdown
17
+
18
+ MAX_CONTRACT_BYTES = 50_000_000
19
+
20
+
21
+ def _force_utf8() -> None:
22
+ for stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ if hasattr(stream, "reconfigure"):
25
+ stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ _force_utf8()
31
+
32
+
33
+ def _load_contract(source: str) -> dict:
34
+ if not isinstance(source, str) or not source:
35
+ raise ValueError("empty contract source")
36
+ if os.path.isdir(source):
37
+ return scan_path(source)
38
+ try:
39
+ if os.path.getsize(source) > MAX_CONTRACT_BYTES:
40
+ raise ValueError(f"contract file too large: {source}")
41
+ except OSError as exc:
42
+ raise ValueError(f"cannot read contract: {exc}") from exc
43
+ try:
44
+ with open(source, "r", encoding="utf-8") as fh:
45
+ data = json.load(fh)
46
+ except (OSError, ValueError) as exc:
47
+ raise ValueError(f"bad contract JSON: {exc}") from exc
48
+ if not isinstance(data, dict) or not isinstance(data.get("endpoints"), list):
49
+ return {"endpoints": [], "edges": []}
50
+ edges = data.get("edges") if isinstance(data.get("edges"), list) else []
51
+ return {"endpoints": data["endpoints"], "edges": edges}
52
+
53
+
54
+ def _write_file(path: str, content: str) -> None:
55
+ """Write exactly one file the user named. Nothing else. Ever."""
56
+ parent = os.path.dirname(os.path.abspath(path))
57
+ os.makedirs(parent, exist_ok=True)
58
+ with open(path, "w", encoding="utf-8") as fh:
59
+ fh.write(content)
60
+
61
+
62
+ def _cmd_scan(args) -> int:
63
+ try:
64
+ contract = scan_path(args.path, service=args.service or "")
65
+ except ValueError as exc:
66
+ print(f"breakproof: {exc}", file=sys.stderr)
67
+ return 2
68
+ payload = json.dumps(contract, indent=2, sort_keys=True) + "\n"
69
+ if args.out:
70
+ try:
71
+ _write_file(args.out, payload)
72
+ except OSError as exc:
73
+ print(f"breakproof: cannot write {args.out}: {exc}", file=sys.stderr)
74
+ return 2
75
+ else:
76
+ try:
77
+ print(payload, end="")
78
+ except BrokenPipeError:
79
+ return 0
80
+ print(f"endpoints={len(contract['endpoints'])}", file=sys.stderr)
81
+ return 0
82
+
83
+
84
+ def _cmd_diff(args) -> int:
85
+ base_src = args.base or args.base_dir
86
+ head_src = args.head or args.head_dir
87
+ if not base_src or not head_src:
88
+ print("breakproof diff: need --base/--base-dir and --head/--head-dir",
89
+ file=sys.stderr)
90
+ return 2
91
+ try:
92
+ base = _load_contract(base_src)
93
+ head = _load_contract(head_src)
94
+ except ValueError as exc:
95
+ print(f"breakproof: {exc}", file=sys.stderr)
96
+ return 2
97
+ gate = build_gate(base, head, base_ref=args.base_ref or "",
98
+ head_ref=args.head_ref or "")
99
+ md = render_gate_markdown(gate)
100
+ try:
101
+ if args.markdown:
102
+ _write_file(args.markdown, md + "\n")
103
+ else:
104
+ print(md)
105
+ except (OSError, BrokenPipeError) as exc:
106
+ if isinstance(exc, BrokenPipeError):
107
+ return 0
108
+ print(f"breakproof: cannot write {args.markdown}: {exc}", file=sys.stderr)
109
+ return 2
110
+ if args.out_gate:
111
+ try:
112
+ _write_file(args.out_gate,
113
+ json.dumps(gate, indent=2, sort_keys=True) + "\n")
114
+ except OSError as exc:
115
+ print(f"breakproof: cannot write {args.out_gate}: {exc}", file=sys.stderr)
116
+ return 2
117
+ if args.radar_markdown:
118
+ try:
119
+ _write_file(args.radar_markdown,
120
+ render_radar_markdown(gate.get("diff", {}),
121
+ args.head_ref or "") + "\n")
122
+ except OSError as exc:
123
+ print(f"breakproof: cannot write {args.radar_markdown}: {exc}",
124
+ file=sys.stderr)
125
+ return 2
126
+ verdict = gate.get("verdict", "pass")
127
+ print(f"verdict={verdict}", file=sys.stderr)
128
+ return 1 if verdict == "fail" else 0
129
+
130
+
131
+ def build_parser() -> argparse.ArgumentParser:
132
+ p = argparse.ArgumentParser(
133
+ prog="breakproof",
134
+ description="Fail the PR only when an API contract vanished. No spec needed.")
135
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
136
+ sub = p.add_subparsers(dest="cmd", required=True)
137
+
138
+ s = sub.add_parser("scan", help="Read a directory, print its contracts.")
139
+ s.add_argument("path", help="Directory to read. Only read, promise.")
140
+ s.add_argument("--out", default="", help="Write contract JSON here, else stdout.")
141
+ s.add_argument("--service", default="", help="Label every endpoint with this service.")
142
+ s.set_defaults(func=_cmd_scan)
143
+
144
+ d = sub.add_parser("diff", help="Base vs head. Removals fail, additions don't.")
145
+ d.add_argument("--base", default="", help="Base contract JSON, or nothing.")
146
+ d.add_argument("--head", default="", help="Head contract JSON, or nothing.")
147
+ d.add_argument("--base-dir", default="", help="Scan this dir as base instead.")
148
+ d.add_argument("--head-dir", default="", help="Scan this dir as head instead.")
149
+ d.add_argument("--base-ref", default="")
150
+ d.add_argument("--head-ref", default="")
151
+ d.add_argument("--out-gate", default="")
152
+ d.add_argument("--markdown", default="")
153
+ d.add_argument("--radar-markdown", default="")
154
+ d.set_defaults(func=_cmd_diff)
155
+ return p
156
+
157
+
158
+ def main(argv=None) -> int:
159
+ try:
160
+ args = build_parser().parse_args(argv)
161
+ return int(args.func(args) or 0)
162
+ except BrokenPipeError:
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ raise SystemExit(main())
breakproof/evidence.py ADDED
@@ -0,0 +1,94 @@
1
+ """Finding types. Dumb data, no opinions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Dict, List, Optional
8
+
9
+
10
+ class Verdict(str, Enum):
11
+ REQUIRED = "REQUIRED"
12
+ NECESSARY = "NECESSARY"
13
+ UNNECESSARY = "UNNECESSARY"
14
+ DEAD = "DEAD"
15
+ UNREACHABLE = "UNREACHABLE"
16
+ MISSING = "MISSING"
17
+ INCORRECT = "INCORRECT"
18
+ REDUNDANT = "REDUNDANT"
19
+ RISKY = "RISKY"
20
+ EXPENSIVE = "EXPENSIVE"
21
+ REGRESSION = "REGRESSION"
22
+ UNPROVEN = "UNPROVEN"
23
+ UNKNOWN = "UNKNOWN"
24
+
25
+
26
+ @dataclass
27
+ class Evidence:
28
+ kind: str
29
+ summary: str
30
+ detail: Dict = field(default_factory=dict)
31
+
32
+ def as_dict(self) -> Dict:
33
+ return {"kind": self.kind, "summary": self.summary, "detail": self.detail}
34
+
35
+
36
+ @dataclass
37
+ class Finding:
38
+ id: str
39
+ title: str
40
+ verdict: Verdict
41
+ severity: str
42
+ location: Optional[Dict] = None
43
+ requirement_id: Optional[str] = None
44
+ claim: str = ""
45
+ static_evidence: List[Evidence] = field(default_factory=list)
46
+ runtime_evidence: List[Evidence] = field(default_factory=list)
47
+ counterfactual: Optional[Evidence] = None
48
+ confidence: float = 0.0
49
+
50
+ def add(self, ev: Evidence) -> None:
51
+ if ev.kind == "runtime":
52
+ self.runtime_evidence.append(ev)
53
+ elif ev.kind == "counterfactual":
54
+ self.counterfactual = ev
55
+ else:
56
+ self.static_evidence.append(ev)
57
+ self._recompute_confidence()
58
+
59
+ def _recompute_confidence(self) -> None:
60
+ score = 0.0
61
+ if self.static_evidence:
62
+ score += 0.4
63
+ if self.runtime_evidence:
64
+ score += 0.3
65
+ if self.counterfactual is not None:
66
+ score += 0.3
67
+ self.confidence = min(1.0, score)
68
+
69
+ def as_dict(self) -> Dict:
70
+ return {
71
+ "id": self.id, "title": self.title, "verdict": self.verdict.value,
72
+ "severity": self.severity, "location": self.location,
73
+ "requirement_id": self.requirement_id, "claim": self.claim,
74
+ "static_evidence": [e.as_dict() for e in self.static_evidence],
75
+ "runtime_evidence": [e.as_dict() for e in self.runtime_evidence],
76
+ "counterfactual": self.counterfactual.as_dict() if self.counterfactual else None,
77
+ "confidence": round(self.confidence, 3),
78
+ }
79
+
80
+
81
+ class EvidenceEngine:
82
+ """Mints findings. That's it."""
83
+
84
+ def new(self, *, id: str, title: str, verdict: Verdict, severity: str,
85
+ location: Optional[Dict] = None, requirement_id: Optional[str] = None,
86
+ claim: str = "") -> Finding:
87
+ return Finding(id=id, title=title, verdict=verdict, severity=severity,
88
+ location=location, requirement_id=requirement_id, claim=claim)
89
+
90
+ def finalize(self, f: Finding) -> Finding:
91
+ strong = f.verdict in (Verdict.NECESSARY, Verdict.REGRESSION)
92
+ if strong and f.confidence < 0.6:
93
+ f.verdict = Verdict.UNPROVEN
94
+ return f
breakproof/extract.py ADDED
@@ -0,0 +1,171 @@
1
+ """Find HTTP routes in a directory. Read-only; wouldn't delete your files if you paid it.
2
+
3
+ Covers common Flask/FastAPI/Express/Go patterns. Anything exotic is reported
4
+ as nothing rather than guessed at — a missed route is an honest gap, a fake
5
+ route is a lie.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import bisect
11
+ import os
12
+ import re
13
+
14
+ MAX_FILE_BYTES = 1_000_000
15
+ MAX_FILES = 50_000
16
+ MAX_ENDPOINTS = 20_000
17
+ MAX_PATH_LEN = 2048
18
+
19
+ SCAN_EXTS = (".py", ".js", ".jsx", ".ts", ".tsx", ".go")
20
+ SKIP_DIRS = frozenset({
21
+ ".git", "node_modules", "dist", "build", "__pycache__",
22
+ ".venv", "venv", ".tox", "vendor",
23
+ })
24
+
25
+ _METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS")
26
+
27
+ _PY_DECORATOR = re.compile(
28
+ r"""@(?:[\w.]+\.)?(get|post|put|delete|patch|head|options)\s*\(\s*["']([^"']+)["']""",
29
+ re.IGNORECASE,
30
+ )
31
+ _PY_ROUTE = re.compile(
32
+ r"""@(?:[\w.]+\.)?route\s*\(\s*["']([^"']+)["']\s*(?:,\s*methods\s*=\s*\[([^\]]{0,512})\])?""",
33
+ re.IGNORECASE,
34
+ )
35
+ _PY_METHOD = re.compile(r"""["'](\w{3,7})["']""")
36
+ _JS_ROUTE = re.compile(
37
+ r"""(?:app|router|server|api)\s*\.\s*(get|post|put|delete|patch|head|options)\s*\(\s*["'`]([^"'`]+)["'`]""",
38
+ re.IGNORECASE,
39
+ )
40
+ _GO_HANDLE = re.compile(r"""HandleFunc\s*\(\s*["']([^"']+)["']""")
41
+ _GO_GIN = re.compile(
42
+ r"""\.\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(\s*["']([^"']+)["']""",
43
+ )
44
+
45
+
46
+ def _service_for(relpath: str) -> str:
47
+ head = (relpath or "").replace("\\", "/").split("/", 1)[0]
48
+ return head or "app"
49
+
50
+
51
+ def _line_offsets(text: str) -> list:
52
+ offsets, total = [0], 0
53
+ for line in text.splitlines(keepends=True):
54
+ total += len(line)
55
+ offsets.append(total)
56
+ return offsets
57
+
58
+
59
+ def _line_of(offsets: list, pos: int) -> int:
60
+ return bisect.bisect_right(offsets, pos)
61
+
62
+
63
+ def _valid_route(path: str) -> bool:
64
+ return bool(path) and path.startswith("/") and len(path) <= MAX_PATH_LEN and " " not in path
65
+
66
+
67
+ def extract_endpoints_from_text(text, lang: str) -> list:
68
+ """Pull (method, path, line) triples out of one file's text."""
69
+ if not isinstance(text, str) or not text or not isinstance(lang, str):
70
+ return []
71
+ offsets = _line_offsets(text)
72
+ found = []
73
+
74
+ def line_at(pos: int) -> int:
75
+ return _line_of(offsets, pos)
76
+
77
+ if lang == "py":
78
+ for m in _PY_DECORATOR.finditer(text):
79
+ if _valid_route(m.group(2)):
80
+ found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
81
+ for m in _PY_ROUTE.finditer(text):
82
+ if not _valid_route(m.group(1)):
83
+ continue
84
+ methods = _PY_METHOD.findall(m.group(2) or "GET")
85
+ methods = [x.upper() for x in methods if x.upper() in _METHODS] or ["GET"]
86
+ for meth in methods:
87
+ found.append((meth, m.group(1), line_at(m.start())))
88
+ elif lang in ("js", "ts"):
89
+ for m in _JS_ROUTE.finditer(text):
90
+ if _valid_route(m.group(2)):
91
+ found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
92
+ elif lang == "go":
93
+ for m in _GO_HANDLE.finditer(text):
94
+ if _valid_route(m.group(1)):
95
+ found.append(("GET", m.group(1), line_at(m.start())))
96
+ for m in _GO_GIN.finditer(text):
97
+ if _valid_route(m.group(2)):
98
+ found.append((m.group(1).upper(), m.group(2), line_at(m.start())))
99
+ return found
100
+
101
+
102
+ def _lang_of(filename: str) -> str:
103
+ ext = os.path.splitext(filename.lower())[1]
104
+ if ext == ".py":
105
+ return "py"
106
+ if ext == ".go":
107
+ return "go"
108
+ if ext in (".js", ".jsx"):
109
+ return "js"
110
+ return "ts"
111
+
112
+
113
+ def _iter_files(root: str) -> list:
114
+ """Collect candidate files. Skips symlinks — cute trick, not today."""
115
+ out = []
116
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
117
+ dirnames[:] = sorted(
118
+ d for d in dirnames
119
+ if d not in SKIP_DIRS and not d.startswith(".")
120
+ and not os.path.islink(os.path.join(dirpath, d))
121
+ )
122
+ for fn in sorted(filenames):
123
+ if not fn.endswith(SCAN_EXTS):
124
+ continue
125
+ full = os.path.join(dirpath, fn)
126
+ if os.path.islink(full):
127
+ continue
128
+ out.append(full)
129
+ if len(out) >= MAX_FILES:
130
+ return out
131
+ return out
132
+
133
+
134
+ def scan_path(root: str, service: str = "") -> dict:
135
+ """Scan a directory into a contract. Never writes, never deletes, never executes."""
136
+ if not isinstance(root, str) or not root:
137
+ raise ValueError("scan root must be a non-empty path")
138
+ if not os.path.isdir(root):
139
+ raise ValueError(f"not a directory: {root}")
140
+ endpoints, seen = [], set()
141
+ for path in _iter_files(root):
142
+ try:
143
+ if os.path.getsize(path) > MAX_FILE_BYTES:
144
+ continue
145
+ except OSError:
146
+ continue
147
+ try:
148
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
149
+ text = fh.read()
150
+ except (OSError, ValueError):
151
+ continue
152
+ try:
153
+ rel = os.path.relpath(path, root).replace("\\", "/")
154
+ except ValueError:
155
+ continue
156
+ if rel.startswith(".."):
157
+ continue
158
+ svc = service or _service_for(rel)
159
+ for method, route, line in extract_endpoints_from_text(text, _lang_of(path)):
160
+ key = (method, route, rel, line)
161
+ if key in seen:
162
+ continue
163
+ seen.add(key)
164
+ endpoints.append({"method": method, "path": route, "protocol": "http",
165
+ "service": svc, "handler": "", "file": rel, "line": line})
166
+ if len(endpoints) >= MAX_ENDPOINTS:
167
+ break
168
+ if len(endpoints) >= MAX_ENDPOINTS:
169
+ break
170
+ endpoints.sort(key=lambda e: (e["method"], e["path"], e["file"], str(e["line"])))
171
+ return {"endpoints": endpoints, "edges": []}
breakproof/prgate.py ADDED
@@ -0,0 +1,90 @@
1
+ """Gate a PR: fail on removals, new CVEs, new secrets. Everything else passes.
2
+
3
+ Additions never fail. Yes, really.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict
9
+
10
+ from breakproof.radar import build_api_diff
11
+
12
+
13
+ def affected_cves(statements) -> set:
14
+ return {str(s.get("cve") or "").upper()
15
+ for s in (statements or []) if isinstance(s, dict)
16
+ and (s.get("state") or "") == "affected" and s.get("cve")}
17
+
18
+
19
+ def secret_sites(compliance_controls) -> set:
20
+ out = set()
21
+ for c in compliance_controls or []:
22
+ if not isinstance(c, dict) or c.get("status") != "fail":
23
+ continue
24
+ if c.get("id") not in ("C-SECRETS", "C-CFG-SECRETS"):
25
+ continue
26
+ for e in c.get("evidence") or []:
27
+ if isinstance(e, dict) and e.get("file"):
28
+ out.add((str(e.get("file")), str(e.get("line", ""))))
29
+ return out
30
+
31
+
32
+ def build_gate(base_contract=None, head_contract=None, base_affected=None,
33
+ head_affected=None, base_secrets=None, head_secrets=None,
34
+ base_ref: str = "", head_ref: str = "") -> Dict:
35
+ """PR verdict from frozen contracts + advisory/secret sets. Deterministic."""
36
+ diff = build_api_diff(
37
+ base_contract if isinstance(base_contract, dict) else {},
38
+ head_contract if isinstance(head_contract, dict) else {})
39
+ new_cves = sorted(set(head_affected or ()) - set(base_affected or ()))
40
+ new_secrets = sorted(set(head_secrets or ()) - set(base_secrets or ()))
41
+ breaking = (diff.get("summary") or {}).get("breaking", 0)
42
+ failed = bool(breaking or new_cves or new_secrets)
43
+ return {
44
+ "verdict": "fail" if failed else "pass",
45
+ "base_ref": base_ref or "",
46
+ "head_ref": head_ref or "",
47
+ "diff": diff,
48
+ "new_cves": [{"cve": c} for c in new_cves],
49
+ "new_secrets": [{"file": f, "line": ln} for f, ln in new_secrets],
50
+ "summary": {"breaking": breaking,
51
+ "removed": (diff.get("summary") or {}).get("removed", 0),
52
+ "new_cves": len(new_cves),
53
+ "new_secrets": len(new_secrets)},
54
+ }
55
+
56
+
57
+ def render_gate_markdown(gate: Dict | None) -> str:
58
+ """CI-readable verdict rendering."""
59
+ g = gate if isinstance(gate, dict) else {}
60
+ s = g.get("summary", {}) or {}
61
+ verdict = str(g.get("verdict", "pass")).upper()
62
+ lines = [
63
+ f"# PR Merge Gate: {verdict}",
64
+ "",
65
+ f"Base: `{g.get('base_ref', '')}` → Head: `{g.get('head_ref', '')}`",
66
+ f"Breaking contracts: {s.get('breaking', 0)} · "
67
+ f"New CVEs: {s.get('new_cves', 0)} · "
68
+ f"New secret sites: {s.get('new_secrets', 0)}",
69
+ "",
70
+ "## Removed contracts (breaking)",
71
+ ]
72
+ for rec in (g.get("diff") or {}).get("removed", []) or []:
73
+ callers = ", ".join(c.get("service", "") for c in rec.get("callers", [])[:6])
74
+ lines.append(f"- **{rec.get('method')} {rec.get('path')}** "
75
+ f"(was: {rec.get('service', '?')}; callers: {callers or 'none observed'})")
76
+ if not ((g.get("diff") or {}).get("removed", []) or []):
77
+ lines.append("- None.")
78
+ lines.append("")
79
+ lines.append("## New reachable CVEs")
80
+ for item in g.get("new_cves", []) or []:
81
+ lines.append(f"- {item.get('cve')}")
82
+ if not (g.get("new_cves", []) or []):
83
+ lines.append("- None.")
84
+ lines.append("")
85
+ lines.append("## New secret sites")
86
+ for item in g.get("new_secrets", []) or []:
87
+ lines.append(f"- {item.get('file')}:{item.get('line')}")
88
+ if not (g.get("new_secrets", []) or []):
89
+ lines.append("- None.")
90
+ return "\n".join(lines)
breakproof/radar.py ADDED
@@ -0,0 +1,223 @@
1
+ """Diff two frozen endpoint contracts. Removed routes break; added ones don't.
2
+
3
+ That's the whole philosophy. Everything else is bookkeeping.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict, List
9
+
10
+ from breakproof.evidence import Evidence, EvidenceEngine, Finding, Verdict
11
+
12
+ # "X consumes Y's API" edges. Deploy wiring and imports don't count —
13
+ # refactors aren't breakages, no matter how scary the diff looks.
14
+ CALLER_KINDS = ("calls", "publish", "subscribe", "event")
15
+
16
+ MAX_BREAK_FINDINGS = 25
17
+ MAX_WATCH_ROWS = 50
18
+
19
+
20
+ def normalize_path(path: str) -> str:
21
+ path = (path or "").strip() or "/"
22
+ if not path.startswith("/"):
23
+ path = "/" + path
24
+ if len(path) > 1:
25
+ path = path.rstrip("/")
26
+ return path
27
+
28
+
29
+ def break_key(method: str, path: str) -> str:
30
+ """Stable cross-scan identity for one contract (also the finding id)."""
31
+ return f"API-{(method or 'GET').upper()}-{normalize_path(path)}"
32
+
33
+
34
+ def normalize_endpoints(endpoints) -> Dict[str, Dict]:
35
+ """Index endpoint records by contract identity (deterministic)."""
36
+ out: Dict[str, Dict] = {}
37
+ for ep in endpoints or []:
38
+ if not isinstance(ep, dict):
39
+ continue
40
+ method = str(ep.get("method", "GET") or "GET").upper()
41
+ proto = str(ep.get("protocol", "http") or "http").lower()
42
+ path = normalize_path(ep.get("path", ""))
43
+ key = break_key(method, path)
44
+ if key not in out:
45
+ out[key] = {"key": key, "protocol": proto, "method": method,
46
+ "path": path,
47
+ "service": str(ep.get("service", "") or ""),
48
+ "handler": str(ep.get("handler", "") or ""),
49
+ "file": str(ep.get("file", "") or ""),
50
+ "line": ep.get("line", "") or ""}
51
+ return out
52
+
53
+
54
+ def api_contract(svc_summary) -> Dict:
55
+ """Trimmed contract snapshot (endpoints + call edges)."""
56
+ summary = svc_summary if isinstance(svc_summary, dict) else {}
57
+ indexed = normalize_endpoints(summary.get("endpoints"))
58
+ endpoints = [indexed[key] for key in sorted(indexed)]
59
+ edges = []
60
+ for e in summary.get("edges", []) or []:
61
+ if not isinstance(e, dict):
62
+ continue
63
+ edges.append({"src": str(e.get("src") or e.get("from") or ""),
64
+ "dst": str(e.get("dst") or e.get("to") or ""),
65
+ "kind": str(e.get("kind") or e.get("type") or ""),
66
+ "file": str(e.get("file", "") or ""),
67
+ "line": e.get("line", "") or ""})
68
+ return {"endpoints": endpoints, "edges": edges}
69
+
70
+
71
+ def _callers(prev_edges, owner: str) -> List[Dict]:
72
+ if not owner:
73
+ return []
74
+ out = []
75
+ for e in prev_edges or []:
76
+ if e.get("kind") not in CALLER_KINDS or e.get("dst") != owner:
77
+ continue
78
+ out.append({"service": e.get("src", ""),
79
+ "file": e.get("file", ""), "line": e.get("line", ""),
80
+ "via": e.get("kind", "calls")})
81
+ out.sort(key=lambda c: (c["service"], str(c["file"]), str(c["line"])))
82
+ return out
83
+
84
+
85
+ def build_api_diff(prev_contract=None, curr_contract=None) -> Dict:
86
+ """Diff two frozen contracts. Pure, deterministic, sorted."""
87
+ prev = prev_contract if isinstance(prev_contract, dict) else {}
88
+ curr = curr_contract if isinstance(curr_contract, dict) else {}
89
+ prev_eps = normalize_endpoints(prev.get("endpoints"))
90
+ curr_eps = normalize_endpoints(curr.get("endpoints"))
91
+ prev_edges = [e for e in (prev.get("edges") or []) if isinstance(e, dict)]
92
+ curr_triples = {(e.get("src"), e.get("dst"), e.get("kind"))
93
+ for e in (curr.get("edges") or []) if isinstance(e, dict)}
94
+
95
+ removed, added = [], []
96
+ for key in sorted(set(prev_eps) - set(curr_eps)):
97
+ rec = dict(prev_eps[key])
98
+ rec["callers"] = _callers(prev_edges, rec["service"])
99
+ removed.append(rec)
100
+ for key in sorted(set(curr_eps) - set(prev_eps)):
101
+ added.append(dict(curr_eps[key]))
102
+
103
+ changed = []
104
+ for proto, path in sorted({(r["protocol"], r["path"]) for r in
105
+ list(prev_eps.values()) + list(curr_eps.values())}):
106
+ before = sorted(v["method"] for v in prev_eps.values()
107
+ if v["protocol"] == proto and v["path"] == path)
108
+ after = sorted(v["method"] for v in curr_eps.values()
109
+ if v["protocol"] == proto and v["path"] == path)
110
+ dropped = [m for m in before if m not in after]
111
+ gained = [m for m in after if m not in before]
112
+ if dropped or gained:
113
+ still = [m for m in before if m in after]
114
+ if still:
115
+ owner = next((v["service"] for v in curr_eps.values()
116
+ if v["protocol"] == proto and v["path"] == path), "")
117
+ changed.append({"protocol": proto, "path": path,
118
+ "removed_methods": dropped,
119
+ "added_methods": gained,
120
+ "breaking": bool(dropped),
121
+ "service": owner,
122
+ "callers": _callers(prev_edges, owner)})
123
+
124
+ watch = []
125
+ for e in prev_edges:
126
+ if e.get("kind") not in CALLER_KINDS:
127
+ continue
128
+ if (e.get("src"), e.get("dst"), e.get("kind")) not in curr_triples:
129
+ watch.append({"src": e.get("src", ""), "dst": e.get("dst", ""),
130
+ "kind": e.get("kind", ""), "file": e.get("file", ""),
131
+ "line": e.get("line", "")})
132
+ watch.sort(key=lambda w: (w["src"], w["dst"], w["kind"]))
133
+ watch = watch[:MAX_WATCH_ROWS]
134
+
135
+ return {"removed": removed, "added": added, "changed": changed,
136
+ "watch": watch,
137
+ "summary": {"removed": len(removed), "added": len(added),
138
+ "changed": len(changed), "watch": len(watch),
139
+ "breaking": len(removed)}}
140
+
141
+
142
+ def api_break_findings(diff: Dict | None, prev_job_id: str,
143
+ ev: EvidenceEngine) -> List[Finding]:
144
+ """One RISKY/high finding per removed endpoint (never double-filed)."""
145
+ diff = diff if isinstance(diff, dict) else {}
146
+ prev_job = (prev_job_id or "")[:12]
147
+ targets = []
148
+ for rec in diff.get("removed", []) or []:
149
+ if isinstance(rec, dict):
150
+ targets.append((rec.get("key") or break_key(rec.get("method"), rec.get("path")),
151
+ rec))
152
+ targets.sort(key=lambda t: t[0])
153
+ findings: List[Finding] = []
154
+ for key, rec in targets[:MAX_BREAK_FINDINGS]:
155
+ callers = rec.get("callers") or []
156
+ first = next((c for c in callers if c.get("file")), None)
157
+ location = ({"file": first["file"], "line": first.get("line") or 0,
158
+ "col": 0, "end_line": 0, "end_col": 0}
159
+ if first else {})
160
+ caller_note = (f"Affected callers: "
161
+ + ", ".join(f"{c['service']} ({c.get('file', '?')}"
162
+ f"{':' + str(c['line']) if c.get('line') else ''})"
163
+ for c in callers[:8]) if callers
164
+ else "No in-repo callers observed — external consumers may still break.")
165
+ f = ev.new(
166
+ id=key,
167
+ title=f"Breaking API change: {rec.get('method')} {rec.get('path')} vanished",
168
+ verdict=Verdict.RISKY, severity="high", location=location,
169
+ claim=(f"Contract {rec.get('method')} {rec.get('path')} served by "
170
+ f"'{rec.get('service') or 'unknown service'}' in scan {prev_job} "
171
+ f"is gone. {caller_note}"))
172
+ f.add(Evidence("static", "Route present in previous frozen scan, absent now.",
173
+ {"contract": key, "previous_job": prev_job_id,
174
+ "owner": rec.get("service", ""),
175
+ "callers": callers[:20]}))
176
+ f.add(Evidence("counterfactual", "Restore the route or migrate every caller, then re-scan.",
177
+ {"fix": f"restore {rec.get('method')} {rec.get('path')}"}))
178
+ findings.append(ev.finalize(f))
179
+ return findings
180
+
181
+
182
+ def render_radar_markdown(diff: Dict | None, job_id: str = "") -> str:
183
+ """Shareable contract-diff rendering (findings only for breakage)."""
184
+ d = diff if isinstance(diff, dict) else {}
185
+ s = d.get("summary", {}) or {}
186
+ lines = [
187
+ "# API Change Radar",
188
+ "",
189
+ f"Scan: `{(job_id or '')[:12]}` — breaking changes: {s.get('breaking', 0)} "
190
+ f"(removed {s.get('removed', 0)}, method-breaks "
191
+ f"{sum(1 for c in d.get('changed', []) or [] if c.get('breaking'))}, "
192
+ f"added {s.get('added', 0)})",
193
+ "",
194
+ "## Removed contracts (breaking)",
195
+ ]
196
+ for rec in d.get("removed", []) or []:
197
+ callers = ", ".join(c["service"] for c in rec.get("callers", [])[:6]) or "none observed"
198
+ lines.append(f"- **{rec.get('method')} {rec.get('path')}** "
199
+ f"(was: {rec.get('service', '?')}; callers: {callers})")
200
+ if not (d.get("removed", []) or []):
201
+ lines.append("- None.")
202
+ lines.append("")
203
+ lines.append("## Changed method-sets")
204
+ for ch in d.get("changed", []) or []:
205
+ mark = "BREAKING" if ch.get("breaking") else "compatible"
206
+ lines.append(f"- {ch.get('path')} [{mark}]: "
207
+ f"dropped {', '.join(ch.get('removed_methods', [])) or '—'}; "
208
+ f"gained {', '.join(ch.get('added_methods', [])) or '—'}")
209
+ if not (d.get("changed", []) or []):
210
+ lines.append("- None.")
211
+ lines.append("")
212
+ lines.append("## Added contracts (informational)")
213
+ for rec in (d.get("added", []) or [])[:20]:
214
+ lines.append(f"- {rec.get('method')} {rec.get('path')} ({rec.get('service', '?')})")
215
+ if not (d.get("added", []) or []):
216
+ lines.append("- None.")
217
+ lines.append("")
218
+ lines.append("## Watch: vanished consumption (informational, not flagged)")
219
+ for w in (d.get("watch", []) or [])[:20]:
220
+ lines.append(f"- {w.get('src')} no longer {w.get('kind')} {w.get('dst')}")
221
+ if not (d.get("watch", []) or []):
222
+ lines.append("- None.")
223
+ return "\n".join(lines)
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: breakproof
3
+ Version: 0.1.1
4
+ Summary: No-spec API break gate: FAIL only on removed contracts with caller proof
5
+ Author-email: AuditScan <team@auditscan.sh>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://auditscan.sh
8
+ Project-URL: Repository, https://github.com/auditscan-sh/breakproof
9
+ Project-URL: Issues, https://github.com/auditscan-sh/breakproof/issues
10
+ Keywords: api,breaking-changes,contract-testing,ci,merge-gate
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7.4; extra == "test"
22
+ Dynamic: license-file
23
+
24
+ # Breakproof — merge without fear.
25
+
26
+ Your PR deleted an endpoint someone still calls. Breakproof tells you before your users do. No OpenAPI spec, no config novel, no noise. Removed contracts fail. Everything else passes.
27
+
28
+ ```bash
29
+ pipx install breakproof
30
+ breakproof scan ./app --out base.json
31
+ breakproof diff --base base.json --head-dir ./pr-branch
32
+ # verdict=fail, exit 1, and a Markdown receipt of exactly what vanished
33
+ ```
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pipx install breakproof # CLI, isolated. The good default.
39
+ pip install breakproof # library + CLI in your venv.
40
+ uvx breakproof --help # no install at all, just vibes.
41
+ ```
42
+
43
+ Python 3.10+. Zero dependencies. No network, no telemetry, no account. It works on a plane.
44
+
45
+ ## CLI
46
+
47
+ ```bash
48
+ # Freeze a directory into a contract
49
+ breakproof scan ./app --out contract.json
50
+ breakproof scan ./app --service payments --out contract.json
51
+
52
+ # Diff two frozen contracts
53
+ breakproof diff --base base.json --head head.json
54
+
55
+ # Diff two directories directly (the 10-second demo)
56
+ breakproof diff --base-dir ./main --head-dir ./pr
57
+
58
+ # Save the receipts instead of printing them
59
+ breakproof diff --base base.json --head head.json \
60
+ --out-gate gate.json --markdown gate.md --radar-markdown radar.md
61
+ ```
62
+
63
+ Exit codes: `0` pass, `1` fail (something vanished), `2` you invoked it wrong.
64
+
65
+ Contract JSON looks like this. You can hand-write it, generate it, or freeze it in CI — Breakproof doesn't care where it came from:
66
+
67
+ ```json
68
+ {
69
+ "endpoints": [
70
+ {"method": "GET", "path": "/users", "protocol": "http",
71
+ "service": "api", "handler": "", "file": "app.py", "line": 12}
72
+ ],
73
+ "edges": []
74
+ }
75
+ ```
76
+
77
+ ## Library (yes, it's importable)
78
+
79
+ Same gate your CI runs, inside your own Python:
80
+
81
+ ```python
82
+ from breakproof.extract import scan_path
83
+ from breakproof.prgate import build_gate, render_gate_markdown
84
+
85
+ base = scan_path("./main")
86
+ head = scan_path("./pr")
87
+ gate = build_gate(base, head, base_ref="main", head_ref="pr")
88
+
89
+ print(gate["verdict"]) # "pass" | "fail"
90
+ print(render_gate_markdown(gate)) # paste it into a PR comment
91
+ ```
92
+
93
+ Lower level, if you already have contracts:
94
+
95
+ ```python
96
+ from breakproof.radar import build_api_diff
97
+
98
+ diff = build_api_diff(base, head)
99
+ print(diff["summary"]) # {"removed": 1, "added": 0, ..., "breaking": 1}
100
+ ```
101
+
102
+ ## CI
103
+
104
+ ```yaml
105
+ - uses: auditscan-sh/breakproof@v1
106
+ with:
107
+ base-dir: ./base
108
+ head-dir: .
109
+ ```
110
+
111
+ Posts the verdict to the run summary. Set `fail-on-breaking: "false"` for advisory mode (comments, never red).
112
+
113
+ ## Why not a spec diff?
114
+
115
+ Spec tools need an `openapi.yaml` you have to write and keep honest, then flag everything including stuff nobody calls. Breakproof reads your code, freezes what it serves, and fails only on removals — plus new CVEs or secret sites if you hand it those lists as JSON. Nothing to maintain, nothing to triage, nothing to argue about in review.
116
+
117
+ ## Honest limits
118
+
119
+ v0.1 recognizes common Flask, FastAPI, Express, Fiber, Go `net/http`, and Gin route patterns. Anything exotic reports zero endpoints instead of inventing some — run `breakproof scan` and eyeball the count before you gate on it. Standalone caller lists read `none observed`; pair it with a scanner that knows your consumers to name names.
120
+
121
+ ## Safety
122
+
123
+ Read-only by design. It never deletes, moves, edits, or executes your code — it reads the directories you point it at and writes exactly the files you name with `--out` / `--markdown`. Symlinks skipped, files over 1 MB skipped, scans cap at 50k files / 20k endpoints.
124
+
125
+ ## License
126
+
127
+ MIT. Break things responsibly.
128
+
129
+ Built by [AuditScan](https://auditscan.sh).
@@ -0,0 +1,12 @@
1
+ breakproof/__init__.py,sha256=spb2RNi-CVmM4d_eFz_zF2AQESjqd83ZhUMORlZ9WJo,93
2
+ breakproof/cli.py,sha256=tpJkrHDSeVFpex-G4nYrPj4VmrN1OMCb6ZT9dlS0s7M,5988
3
+ breakproof/evidence.py,sha256=uFHDi093nfP1t0bwQN9MRChbPedPnVfltFc4zcXsmZQ,2986
4
+ breakproof/extract.py,sha256=ciZLr89ufnwCljHidLOiZCaFPu9SDNxtQTTcQ5XaeeA,5905
5
+ breakproof/prgate.py,sha256=FVKd1jRQyCv11gJfPhYWDuH89ITAzzpk58BeUycE4Kw,3641
6
+ breakproof/radar.py,sha256=v2IBqQwy7PIo_Vp7ZHsFCpYppAxWQfZfTr8E098pzRg,10217
7
+ breakproof-0.1.1.dist-info/licenses/LICENSE,sha256=r0pBrXvK5C-IVj5haUtL2SbIycn4SwPT-1128FOB0cw,1066
8
+ breakproof-0.1.1.dist-info/METADATA,sha256=1uI8b5QzkVpYUhdXBl4EyFrMVz4OQjtQvAa0mav0cQ8,4489
9
+ breakproof-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ breakproof-0.1.1.dist-info/entry_points.txt,sha256=GKA5t3_Vxf-yMX61Q0SVNDnVCmhiXKRiP0cHrd80iXo,51
11
+ breakproof-0.1.1.dist-info/top_level.txt,sha256=ggAr7geCjJNnmc30ixrjFpoVUsHSyLj8mam-Lh6192Q,11
12
+ breakproof-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ breakproof = breakproof.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AuditScan
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
+ breakproof