repofail 0.1.2__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.
- repofail/__init__.py +3 -0
- repofail/cli.py +401 -0
- repofail/contract.py +121 -0
- repofail/engine.py +68 -0
- repofail/fleet.py +116 -0
- repofail/format.py +306 -0
- repofail/models.py +75 -0
- repofail/risk.py +97 -0
- repofail/rules/__init__.py +5 -0
- repofail/rules/abi_wheel_mismatch.py +80 -0
- repofail/rules/apple_silicon.py +95 -0
- repofail/rules/base.py +35 -0
- repofail/rules/docker_only.py +37 -0
- repofail/rules/gpu_memory.py +31 -0
- repofail/rules/info_signals.py +142 -0
- repofail/rules/lock_file_missing.py +28 -0
- repofail/rules/ml_niche.py +96 -0
- repofail/rules/native_toolchain.py +53 -0
- repofail/rules/node_engine.py +80 -0
- repofail/rules/node_eol.py +50 -0
- repofail/rules/node_windows.py +21 -0
- repofail/rules/port_collision.py +28 -0
- repofail/rules/python_eol.py +52 -0
- repofail/rules/python_version.py +75 -0
- repofail/rules/registry.py +167 -0
- repofail/rules/spec_drift.py +82 -0
- repofail/rules/system_libs.py +24 -0
- repofail/rules/torch_cuda.py +73 -0
- repofail/rules/yaml_loader.py +90 -0
- repofail/scanner/__init__.py +6 -0
- repofail/scanner/ast_scan.py +208 -0
- repofail/scanner/host.py +156 -0
- repofail/scanner/parsers.py +384 -0
- repofail/scanner/repo.py +298 -0
- repofail/telemetry.py +93 -0
- repofail-0.1.2.dist-info/METADATA +244 -0
- repofail-0.1.2.dist-info/RECORD +39 -0
- repofail-0.1.2.dist-info/WHEEL +4 -0
- repofail-0.1.2.dist-info/entry_points.txt +2 -0
repofail/fleet.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Stage 5 — Fleet/enterprise: audit, simulate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .models import HostProfile, RepoProfile
|
|
10
|
+
from .scanner import scan_repo, inspect_host
|
|
11
|
+
from .engine import run_rules
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SKIP_AUDIT_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", ".tox", "build", "dist", "eggs"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_repo(p: Path) -> bool:
|
|
18
|
+
"""Check if path looks like a repo (has .git or deps file)."""
|
|
19
|
+
return (
|
|
20
|
+
(p / ".git").exists()
|
|
21
|
+
or (p / "pyproject.toml").exists()
|
|
22
|
+
or (p / "requirements.txt").exists()
|
|
23
|
+
or (p / "package.json").exists()
|
|
24
|
+
or (p / "Cargo.toml").exists()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _find_repos(base_path: Path, max_depth: int = 4, max_repos: int = 50) -> list[Path]:
|
|
29
|
+
"""Find repo roots recursively (nested repos)."""
|
|
30
|
+
found: list[Path] = []
|
|
31
|
+
base = Path(base_path).resolve()
|
|
32
|
+
|
|
33
|
+
def walk(d: Path, depth: int) -> None:
|
|
34
|
+
if depth > max_depth or len(found) >= max_repos:
|
|
35
|
+
return
|
|
36
|
+
if not d.is_dir():
|
|
37
|
+
return
|
|
38
|
+
if d.name.startswith(".") or d.name in SKIP_AUDIT_DIRS:
|
|
39
|
+
return
|
|
40
|
+
try:
|
|
41
|
+
rel = d.relative_to(base) if d != base else Path(".")
|
|
42
|
+
if any(part in SKIP_AUDIT_DIRS for part in rel.parts):
|
|
43
|
+
return
|
|
44
|
+
except ValueError:
|
|
45
|
+
return
|
|
46
|
+
if _is_repo(d):
|
|
47
|
+
found.append(d)
|
|
48
|
+
return # Don't descend — this dir is the repo root
|
|
49
|
+
for child in sorted(d.iterdir()):
|
|
50
|
+
if child.is_dir():
|
|
51
|
+
walk(child, depth + 1)
|
|
52
|
+
|
|
53
|
+
for child in sorted(base.iterdir()):
|
|
54
|
+
if child.is_dir():
|
|
55
|
+
walk(child, 1)
|
|
56
|
+
if _is_repo(base):
|
|
57
|
+
found.insert(0, base)
|
|
58
|
+
return list(dict.fromkeys(found)) # dedupe
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def audit(base_path: Path) -> list[dict]:
|
|
62
|
+
"""Scan all repo-like subdirs (including nested), return aggregated report."""
|
|
63
|
+
base_path = Path(base_path).resolve()
|
|
64
|
+
if not base_path.is_dir():
|
|
65
|
+
return []
|
|
66
|
+
dirs = _find_repos(base_path)
|
|
67
|
+
results = []
|
|
68
|
+
host = inspect_host()
|
|
69
|
+
for d in dirs:
|
|
70
|
+
try:
|
|
71
|
+
repo = scan_repo(d)
|
|
72
|
+
rule_results = run_rules(repo, host)
|
|
73
|
+
high_count = sum(1 for r in rule_results if r.severity.value == "HIGH")
|
|
74
|
+
med_count = sum(1 for r in rule_results if r.severity.value in ("MEDIUM", "LOW"))
|
|
75
|
+
results.append({
|
|
76
|
+
"path": str(d),
|
|
77
|
+
"name": repo.name or d.name,
|
|
78
|
+
"rule_count": len(rule_results),
|
|
79
|
+
"rules": [r.rule_id for r in rule_results],
|
|
80
|
+
"has_high": high_count > 0,
|
|
81
|
+
"high_count": high_count,
|
|
82
|
+
"medium_count": med_count,
|
|
83
|
+
"score": __import__("repofail.risk", fromlist=["estimate_success_probability"]).estimate_success_probability(rule_results),
|
|
84
|
+
})
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def host_from_dict(data: dict) -> HostProfile:
|
|
91
|
+
"""Build HostProfile from dict (e.g. from JSON file)."""
|
|
92
|
+
return HostProfile(
|
|
93
|
+
os=data.get("os", "linux"),
|
|
94
|
+
arch=data.get("arch", "x86_64"),
|
|
95
|
+
cuda_available=data.get("cuda_available", False),
|
|
96
|
+
cuda_version=data.get("cuda_version"),
|
|
97
|
+
python_version=data.get("python_version"),
|
|
98
|
+
node_version=data.get("node_version"),
|
|
99
|
+
rust_version=data.get("rust_version"),
|
|
100
|
+
has_compiler=data.get("has_compiler", True),
|
|
101
|
+
has_metal=data.get("has_metal", False),
|
|
102
|
+
has_libgl=data.get("has_libgl", False),
|
|
103
|
+
has_ffmpeg=data.get("has_ffmpeg", False),
|
|
104
|
+
ram_gb=data.get("ram_gb"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def simulate(repo_path: Path, host_path: Path) -> tuple[RepoProfile, HostProfile, list]:
|
|
109
|
+
"""Run rules against repo with a target host profile from file."""
|
|
110
|
+
repo = scan_repo(repo_path)
|
|
111
|
+
data = json.loads(host_path.read_text())
|
|
112
|
+
if "host" in data:
|
|
113
|
+
data = data["host"]
|
|
114
|
+
host = host_from_dict(data)
|
|
115
|
+
results = run_rules(repo, host)
|
|
116
|
+
return repo, host, results
|
repofail/format.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Terminal output formatting — box layout, colors, width control."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .rules.base import RuleResult, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fix_lines_for_rule(r: RuleResult) -> List[str]:
|
|
12
|
+
"""Actionable fix commands for HIGH rules."""
|
|
13
|
+
try:
|
|
14
|
+
from .rules.registry import RULE_FIX_COMMANDS
|
|
15
|
+
return RULE_FIX_COMMANDS.get(r.rule_id, [])
|
|
16
|
+
except ImportError:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _executive_summary(results: List[RuleResult], hard: List[RuleResult]) -> str | None:
|
|
21
|
+
"""One-line summary: primary blocker or no blockers. Screenshot-ready."""
|
|
22
|
+
if not hard:
|
|
23
|
+
return "No deterministic blockers detected."
|
|
24
|
+
r = hard[0]
|
|
25
|
+
ev = r.evidence or {}
|
|
26
|
+
if r.rule_id == "node_engine_mismatch":
|
|
27
|
+
eng = ev.get("engines_node", "?")
|
|
28
|
+
host = ev.get("host_node", "?")
|
|
29
|
+
return f"Primary blocker: Node {eng} required, host is Node {host}."
|
|
30
|
+
if r.rule_id == "lock_file_missing":
|
|
31
|
+
return "Primary blocker: No lock file — npm ci will fail."
|
|
32
|
+
if r.rule_id == "torch_cuda_mismatch":
|
|
33
|
+
return "Primary blocker: Hard-coded CUDA path, host has no GPU."
|
|
34
|
+
if r.rule_id == "spec_drift":
|
|
35
|
+
vers = ev.get("versions", [])
|
|
36
|
+
return f"Primary blocker: Spec drift — {len(vers)} distinct Python targets across configs."
|
|
37
|
+
if r.rule_id == "python_version_mismatch":
|
|
38
|
+
return f"Primary blocker: Host Python outside requires-python range."
|
|
39
|
+
return f"Primary blocker: {r.message}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_width() -> int:
|
|
43
|
+
try:
|
|
44
|
+
return min(72, shutil.get_terminal_size((72, 24)).columns)
|
|
45
|
+
except OSError:
|
|
46
|
+
return 72
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wrap(text: str, indent: int = 0, width: int = 72) -> List[str]:
|
|
50
|
+
"""Wrap text to width, first line has indent, following lines +2."""
|
|
51
|
+
prefix = " " * indent
|
|
52
|
+
extra = " "
|
|
53
|
+
lines = []
|
|
54
|
+
rest = text
|
|
55
|
+
first = True
|
|
56
|
+
while rest:
|
|
57
|
+
max_len = width - (indent if first else indent + len(extra))
|
|
58
|
+
if len(rest) <= max_len:
|
|
59
|
+
lines.append(prefix + rest)
|
|
60
|
+
break
|
|
61
|
+
break_at = rest.rfind(" ", 0, max_len + 1)
|
|
62
|
+
if break_at <= 0:
|
|
63
|
+
break_at = max_len
|
|
64
|
+
chunk = rest[:break_at].strip()
|
|
65
|
+
rest = rest[break_at:].strip()
|
|
66
|
+
lines.append(prefix + chunk)
|
|
67
|
+
prefix = " " * indent + extra
|
|
68
|
+
first = False
|
|
69
|
+
return lines
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _score_color(score: int) -> str:
|
|
73
|
+
if score >= 90:
|
|
74
|
+
return "green"
|
|
75
|
+
if score >= 70:
|
|
76
|
+
return "yellow"
|
|
77
|
+
return "red"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _group_by_severity(results: List[RuleResult]) -> tuple[List[RuleResult], List[RuleResult], List[RuleResult]]:
|
|
81
|
+
"""Split into: hard_failures (HIGH), runtime_risks (MEDIUM/LOW), observations (INFO)."""
|
|
82
|
+
hard = [r for r in results if r.severity == Severity.HIGH]
|
|
83
|
+
risks = [r for r in results if r.severity in (Severity.MEDIUM, Severity.LOW)]
|
|
84
|
+
obs = [r for r in results if r.severity == Severity.INFO]
|
|
85
|
+
return hard, risks, obs
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _bullet_text(r: RuleResult, verbose: bool, bullet: str = "○") -> str:
|
|
89
|
+
"""One-line bullet for a result. ● = hard failure, ○ = risk/observation."""
|
|
90
|
+
base = r.message.rstrip(".")
|
|
91
|
+
if verbose and r.rule_id:
|
|
92
|
+
base = f"{base} [{r.rule_id}]"
|
|
93
|
+
return f"{bullet} {base}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _evidence_to_lines(r: RuleResult) -> List[str]:
|
|
97
|
+
"""Convert evidence dict to human-readable lines for HARD FAILURES."""
|
|
98
|
+
ev = r.evidence or {}
|
|
99
|
+
lines: List[str] = []
|
|
100
|
+
|
|
101
|
+
if r.rule_id == "torch_cuda_mismatch":
|
|
102
|
+
for s in ev.get("repo_cuda_usage", [])[:5]:
|
|
103
|
+
lines.append(f" {s}")
|
|
104
|
+
if not ev.get("has_is_available_guard") and ev.get("cuda_mandatory"):
|
|
105
|
+
lines.append(" No torch.cuda.is_available() guard detected")
|
|
106
|
+
if ev.get("host_cuda") is False:
|
|
107
|
+
lines.append(" Host has no CUDA device")
|
|
108
|
+
if ev.get("determinism") is not None:
|
|
109
|
+
lines.append(f" Determinism: {ev['determinism']} (code-level execution path)")
|
|
110
|
+
if ev.get("breakage_likelihood"):
|
|
111
|
+
lines.append(f" Breakage likelihood: {ev['breakage_likelihood']}")
|
|
112
|
+
if ev.get("likely_error"):
|
|
113
|
+
lines.append(f" Likely error: {ev['likely_error']}")
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
if r.rule_id == "apple_silicon_wheels":
|
|
117
|
+
if ev.get("docker_platform"):
|
|
118
|
+
lines.append(" Dockerfile uses --platform=linux/amd64")
|
|
119
|
+
if ev.get("host"):
|
|
120
|
+
lines.append(f" Host: {ev['host']}")
|
|
121
|
+
if ev.get("determinism") is not None:
|
|
122
|
+
lines.append(f" Determinism: {ev['determinism']}")
|
|
123
|
+
if ev.get("likely_error"):
|
|
124
|
+
lines.append(f" Likely error: {ev['likely_error']}")
|
|
125
|
+
return lines
|
|
126
|
+
|
|
127
|
+
if r.rule_id == "node_engine_mismatch":
|
|
128
|
+
eng = ev.get("engines_node")
|
|
129
|
+
host = ev.get("host_node")
|
|
130
|
+
if eng:
|
|
131
|
+
lines.append(f" package.json requires: node {eng}")
|
|
132
|
+
if host:
|
|
133
|
+
lines.append(f" Host: node {host}")
|
|
134
|
+
if ev.get("determinism") is not None:
|
|
135
|
+
lines.append(f" Determinism: {ev['determinism']} (spec violation)")
|
|
136
|
+
if ev.get("likely_error"):
|
|
137
|
+
lines.append(f" Likely error: {ev['likely_error']}")
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
if r.rule_id == "abi_wheel_mismatch":
|
|
141
|
+
lines.append(f" Detected: {ev.get('host_os_arch', 'macOS arm64')}, Python {ev.get('host_python', '?')}")
|
|
142
|
+
for p in ev.get("problematic_packages", [])[:3]:
|
|
143
|
+
lines.append(f" Dependency: {p}")
|
|
144
|
+
if ev.get("breakage_likelihood"):
|
|
145
|
+
lines.append(f" Breakage likelihood: {ev['breakage_likelihood']}")
|
|
146
|
+
if ev.get("likely_error"):
|
|
147
|
+
lines.append(f" Likely error: {ev['likely_error']}")
|
|
148
|
+
return lines
|
|
149
|
+
|
|
150
|
+
if r.rule_id == "lock_file_missing":
|
|
151
|
+
lines.append(" package.json has dependencies")
|
|
152
|
+
lines.append(" No package-lock.json or yarn.lock found")
|
|
153
|
+
fail = ev.get("expected_failure")
|
|
154
|
+
if fail:
|
|
155
|
+
lines.append(f" {fail}")
|
|
156
|
+
return lines
|
|
157
|
+
|
|
158
|
+
if r.rule_id == "node_eol":
|
|
159
|
+
lines.append(f" engines.node: {ev.get('engines_node', '?')}")
|
|
160
|
+
lines.append(f" Node {ev.get('eol_major', '?')} is end-of-life")
|
|
161
|
+
return lines
|
|
162
|
+
|
|
163
|
+
if r.rule_id == "spec_drift":
|
|
164
|
+
vers = ev.get("versions", [])
|
|
165
|
+
lines.append(f" Drift entropy: {ev.get('drift_entropy', len(vers))} distinct interpreter targets")
|
|
166
|
+
lines.append(f" Versions found: {', '.join(vers)}")
|
|
167
|
+
for s in ev.get("sources", [])[:4]:
|
|
168
|
+
lines.append(f" {s}")
|
|
169
|
+
lines.append(" CI and local runtime definitions diverge.")
|
|
170
|
+
if ev.get("likely_error"):
|
|
171
|
+
lines.append(f" Likely error: {ev['likely_error']}")
|
|
172
|
+
return lines
|
|
173
|
+
|
|
174
|
+
if r.rule_id == "python_eol":
|
|
175
|
+
lines.append(f" requires-python: {ev.get('requires_python', '?')}")
|
|
176
|
+
lines.append(f" Python {ev.get('eol_version', '?')} is end-of-life")
|
|
177
|
+
return lines
|
|
178
|
+
|
|
179
|
+
# Generic: requires_python, host_python, etc.
|
|
180
|
+
labels = {
|
|
181
|
+
"requires_python": "requires-python",
|
|
182
|
+
"host_python": "Host Python",
|
|
183
|
+
"engines_node": "engines.node",
|
|
184
|
+
"host_node": "Host Node",
|
|
185
|
+
"problematic_packages": "Packages",
|
|
186
|
+
"port": "Port",
|
|
187
|
+
}
|
|
188
|
+
for k, v in ev.items():
|
|
189
|
+
if v is None or k in ("host_cuda", "cuda_mandatory", "has_is_available_guard", "repo_cuda_usage", "versions", "sources"):
|
|
190
|
+
continue
|
|
191
|
+
if isinstance(v, list):
|
|
192
|
+
v = ", ".join(str(x) for x in v[:5])
|
|
193
|
+
label = labels.get(k, k.replace("_", " ").title())
|
|
194
|
+
lines.append(f" {label}: {v}")
|
|
195
|
+
return lines[:6] # Cap generic evidence
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _explanation_summary(results: List[RuleResult]) -> str | None:
|
|
199
|
+
"""Multi-line summary: deterministic vs structural. Feels analytical."""
|
|
200
|
+
if not results:
|
|
201
|
+
return None
|
|
202
|
+
hard, risks, obs = _group_by_severity(results)
|
|
203
|
+
parts = []
|
|
204
|
+
if hard:
|
|
205
|
+
n = len(hard)
|
|
206
|
+
parts.append(f"{n} deterministic violation{'s' if n != 1 else ''} detected.")
|
|
207
|
+
if risks:
|
|
208
|
+
parts.append(f"{len(risks)} runtime risk(s) detected.")
|
|
209
|
+
if obs:
|
|
210
|
+
parts.append(f"{len(obs)} structural profile note(s).")
|
|
211
|
+
return " ".join(parts) if parts else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _score_context(results: List[RuleResult], prob: int) -> str:
|
|
215
|
+
"""Short context for score line, e.g. '(5 informational)'."""
|
|
216
|
+
if not results:
|
|
217
|
+
return ""
|
|
218
|
+
by_sev = {}
|
|
219
|
+
for r in results:
|
|
220
|
+
by_sev[r.severity] = by_sev.get(r.severity, 0) + 1
|
|
221
|
+
parts = []
|
|
222
|
+
for sev in [Severity.INFO, Severity.LOW, Severity.MEDIUM, Severity.HIGH]:
|
|
223
|
+
n = by_sev.get(sev, 0)
|
|
224
|
+
if n:
|
|
225
|
+
label = "informational" if sev == Severity.INFO else sev.value.lower()
|
|
226
|
+
parts.append(f"{n} {label}")
|
|
227
|
+
return " (" + ", ".join(parts) + ")" if parts else ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def format_human(
|
|
231
|
+
repo_name: str,
|
|
232
|
+
prob: int,
|
|
233
|
+
results: List[RuleResult],
|
|
234
|
+
verbose: bool = False,
|
|
235
|
+
confidence: str = "high",
|
|
236
|
+
low_confidence_rules: List[str] | None = None,
|
|
237
|
+
) -> str:
|
|
238
|
+
"""Build the human terminal output as a single string."""
|
|
239
|
+
width = _get_width()
|
|
240
|
+
lines = []
|
|
241
|
+
|
|
242
|
+
lines.append("┌" + "─" * (width - 2) + "┐")
|
|
243
|
+
lines.append(" repofail · environment risk")
|
|
244
|
+
lines.append("─" * width)
|
|
245
|
+
|
|
246
|
+
ctx = _score_context(results, prob)
|
|
247
|
+
if verbose and confidence != "high":
|
|
248
|
+
ctx = f"{ctx} (confidence: {confidence})".strip() or f"(confidence: {confidence})"
|
|
249
|
+
if verbose and low_confidence_rules:
|
|
250
|
+
ctx = f"{ctx} — low-confidence: {', '.join(low_confidence_rules[:3])}".strip()
|
|
251
|
+
fatal_note = ""
|
|
252
|
+
hard, _, _ = _group_by_severity(results)
|
|
253
|
+
if prob <= 15 and hard:
|
|
254
|
+
fatal_note = " — fatal deterministic violations present"
|
|
255
|
+
score_str = f" Score {prob}%{fatal_note}{ctx}"
|
|
256
|
+
score_color = _score_color(prob)
|
|
257
|
+
lines.append(click.style(score_str, fg=score_color))
|
|
258
|
+
summary = _explanation_summary(results)
|
|
259
|
+
if summary:
|
|
260
|
+
lines.append(click.style(f" {summary}", dim=True))
|
|
261
|
+
exec_sum = _executive_summary(results, hard)
|
|
262
|
+
if exec_sum:
|
|
263
|
+
lines.append(click.style(f" Summary {exec_sum}", dim=True))
|
|
264
|
+
if hard:
|
|
265
|
+
lines.append(click.style(" Execution likelihood: low unless constraints are resolved.", dim=True))
|
|
266
|
+
lines.append("─" * width)
|
|
267
|
+
|
|
268
|
+
if results:
|
|
269
|
+
hard, risks, obs = _group_by_severity(results)
|
|
270
|
+
if hard:
|
|
271
|
+
lines.append(" HARD FAILURES")
|
|
272
|
+
for r in hard:
|
|
273
|
+
bullet = _bullet_text(r, verbose, bullet="●")
|
|
274
|
+
for ln in _wrap(bullet, indent=2, width=width):
|
|
275
|
+
lines.append(click.style(ln, fg="red"))
|
|
276
|
+
ev_lines = _evidence_to_lines(r)
|
|
277
|
+
for el in ev_lines:
|
|
278
|
+
lines.append(click.style(el, fg="red", dim=True))
|
|
279
|
+
fix_lines = _fix_lines_for_rule(r)
|
|
280
|
+
if fix_lines:
|
|
281
|
+
lines.append(click.style(" Suggested fix:", dim=True))
|
|
282
|
+
for fl in fix_lines:
|
|
283
|
+
lines.append(click.style(f" {fl}", dim=True))
|
|
284
|
+
if risks:
|
|
285
|
+
lines.append(" RUNTIME RISKS")
|
|
286
|
+
for r in risks:
|
|
287
|
+
bullet = _bullet_text(r, verbose, bullet="○")
|
|
288
|
+
for ln in _wrap(bullet, indent=2, width=width):
|
|
289
|
+
lines.append(click.style(ln, dim=True))
|
|
290
|
+
if obs:
|
|
291
|
+
lines.append(" STRUCTURAL PROFILE")
|
|
292
|
+
for r in obs:
|
|
293
|
+
bullet = _bullet_text(r, verbose, bullet="○")
|
|
294
|
+
for ln in _wrap(bullet, indent=2, width=width):
|
|
295
|
+
lines.append(click.style(ln, dim=True))
|
|
296
|
+
else:
|
|
297
|
+
lines.append(" No high-confidence incompatibilities detected.")
|
|
298
|
+
|
|
299
|
+
lines.append("─" * width)
|
|
300
|
+
footer = " Run with --json for machine output · --ci for exit codes"
|
|
301
|
+
if len(footer) > width:
|
|
302
|
+
footer = " --json · --ci · --help"
|
|
303
|
+
lines.append(click.style(footer, dim=True))
|
|
304
|
+
lines.append("└" + "─" * (width - 2) + "┘")
|
|
305
|
+
|
|
306
|
+
return "\n".join(lines)
|
repofail/models.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Structured profiles for repo and host."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class HostProfile:
|
|
9
|
+
"""Structured output from host inspection."""
|
|
10
|
+
|
|
11
|
+
os: str # "macos", "linux", "windows"
|
|
12
|
+
arch: str # "arm64", "x86_64"
|
|
13
|
+
cuda_available: bool = False
|
|
14
|
+
cuda_version: Optional[str] = None
|
|
15
|
+
python_version: Optional[str] = None
|
|
16
|
+
node_version: Optional[str] = None
|
|
17
|
+
rust_version: Optional[str] = None
|
|
18
|
+
has_compiler: bool = False # gcc/clang for native builds
|
|
19
|
+
has_metal: bool = False # macOS Metal / MLX
|
|
20
|
+
has_libgl: bool = False
|
|
21
|
+
has_ffmpeg: bool = False
|
|
22
|
+
ram_gb: Optional[float] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RepoProfile:
|
|
27
|
+
"""Structured output from repo scanning."""
|
|
28
|
+
|
|
29
|
+
path: str
|
|
30
|
+
name: str = ""
|
|
31
|
+
|
|
32
|
+
# Python
|
|
33
|
+
python_version: Optional[str] = None # e.g. ">=3.10,<3.12"
|
|
34
|
+
has_requirements_txt: bool = False
|
|
35
|
+
has_pyproject: bool = False
|
|
36
|
+
has_setup_py: bool = False
|
|
37
|
+
|
|
38
|
+
# ML / GPU
|
|
39
|
+
uses_torch: bool = False
|
|
40
|
+
uses_tensorflow: bool = False
|
|
41
|
+
requires_cuda: bool = False # explicit CUDA in code/config
|
|
42
|
+
cuda_optional: bool = True # has CPU fallback
|
|
43
|
+
cuda_mandatory_packages: list[str] = field(default_factory=list) # bitsandbytes, flash-attn, etc.
|
|
44
|
+
frameworks: list[str] = field(default_factory=list) # PEFT, diffusers, etc.
|
|
45
|
+
|
|
46
|
+
# Other ecosystems
|
|
47
|
+
has_package_json: bool = False
|
|
48
|
+
node_engine_spec: str | None = None # engines.node from package.json, e.g. ">=18"
|
|
49
|
+
node_lock_file_missing: bool = False # package.json has deps but no package-lock.json/yarn.lock
|
|
50
|
+
node_native_modules: list[str] = field(default_factory=list)
|
|
51
|
+
has_cargo_toml: bool = False
|
|
52
|
+
rust_system_libs: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
# System libs (detected from deps)
|
|
55
|
+
requires_libgl: bool = False
|
|
56
|
+
requires_ffmpeg: bool = False
|
|
57
|
+
|
|
58
|
+
# Infra
|
|
59
|
+
has_dockerfile: bool = False
|
|
60
|
+
dockerfile_has_cuda: bool = False # FROM nvidia/cuda or cuda in Dockerfile
|
|
61
|
+
has_devcontainer: bool = False # .devcontainer/ or devcontainer.json
|
|
62
|
+
docker_platform_amd64: bool = False # FROM --platform=linux/amd64
|
|
63
|
+
required_ports: list[int] = field(default_factory=list) # from docker-compose, .env
|
|
64
|
+
github_workflows: list[str] = field(default_factory=list)
|
|
65
|
+
os_specific: bool = False
|
|
66
|
+
|
|
67
|
+
# For rule output — where CUDA usage was found
|
|
68
|
+
cuda_files: list[str] = field(default_factory=list)
|
|
69
|
+
cuda_usages: list[dict] = field(default_factory=list) # [{"file": str, "line": int, "kind": str}]
|
|
70
|
+
|
|
71
|
+
# Monorepo: discovered subprojects (path rel to repo, type, key fields)
|
|
72
|
+
subprojects: list[dict] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
# Raw data for rule engine
|
|
75
|
+
raw: dict = field(default_factory=dict)
|
repofail/risk.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Probabilistic failure risk estimation — deterministic, explainable."""
|
|
2
|
+
|
|
3
|
+
from .rules.base import RuleResult, Severity
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Confidence levels (rule-driven, defensible):
|
|
7
|
+
# HIGH = Deterministic spec violation or hardcoded mismatch (direct read from config/code)
|
|
8
|
+
# MEDIUM = Structural inference (monorepo, multiple subprojects, context-dependent)
|
|
9
|
+
# LOW = Heuristic / incomplete signals (minimal evidence, guess from layout)
|
|
10
|
+
#
|
|
11
|
+
# Per-rule confidence multiplies severity penalty: high=1.0, medium=0.75, low=0.5
|
|
12
|
+
|
|
13
|
+
# Severity weights: reduction in success probability per finding
|
|
14
|
+
# 0% reserved for: CUDA hardcoded, engine mismatch, python version violation, docker amd64 on arm64
|
|
15
|
+
SEVERITY_WEIGHTS = {
|
|
16
|
+
Severity.HIGH: 45,
|
|
17
|
+
Severity.MEDIUM: 20,
|
|
18
|
+
Severity.LOW: 7,
|
|
19
|
+
Severity.INFO: 5,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Per-rule weight override (calibrated: deterministic = higher, probabilistic = lower)
|
|
23
|
+
RULE_WEIGHTS = {
|
|
24
|
+
"node_engine_mismatch": 50,
|
|
25
|
+
"lock_file_missing": 40,
|
|
26
|
+
"spec_drift": 25,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Per-rule determinism (1.0 = will break, 0.6 = probabilistic)
|
|
30
|
+
RULE_DETERMINISM = {
|
|
31
|
+
"spec_drift": 0.6,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Confidence multiplier: reduces penalty when evidence is heuristic
|
|
35
|
+
# high=1.0 (full penalty), medium=0.75, low=0.5
|
|
36
|
+
CONFIDENCE_MULTIPLIER = {"high": 1.0, "medium": 0.75, "low": 0.5}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_confidence(results: list[RuleResult]) -> tuple[str, list[str]]:
|
|
40
|
+
"""
|
|
41
|
+
Per-run confidence from results.
|
|
42
|
+
High if at least one HIGH or many deterministic rules.
|
|
43
|
+
Medium if mixed heuristics. Low if minimal evidence.
|
|
44
|
+
Returns (aggregate, low_confidence_rule_ids).
|
|
45
|
+
"""
|
|
46
|
+
if not results:
|
|
47
|
+
return "high", []
|
|
48
|
+
has_high = any(r.severity == Severity.HIGH for r in results)
|
|
49
|
+
high_conf_count = sum(1 for r in results if getattr(r, "confidence", "high") == "high")
|
|
50
|
+
if has_high or high_conf_count >= len(results) * 0.8:
|
|
51
|
+
agg = "high"
|
|
52
|
+
elif len(results) >= 3 or high_conf_count >= 1:
|
|
53
|
+
agg = "medium"
|
|
54
|
+
else:
|
|
55
|
+
agg = "low"
|
|
56
|
+
low = [r.rule_id for r in results if getattr(r, "confidence", "high") == "low"]
|
|
57
|
+
return agg, low
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Cap total penalty so score doesn't flatten across 3 vs 7 HIGH
|
|
61
|
+
MAX_PENALTY = 90
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _all_high_rules_deterministic(results: list[RuleResult]) -> bool:
|
|
65
|
+
"""True if every HIGH rule has determinism = 1.0. Else floor at 10%."""
|
|
66
|
+
from .rules.base import Severity
|
|
67
|
+
high_rules = [r for r in results if r.severity == Severity.HIGH]
|
|
68
|
+
if not high_rules:
|
|
69
|
+
return True
|
|
70
|
+
for r in high_rules:
|
|
71
|
+
det = RULE_DETERMINISM.get(r.rule_id, 1.0)
|
|
72
|
+
if det < 1.0:
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def estimate_success_probability(results: list[RuleResult]) -> int:
|
|
78
|
+
"""
|
|
79
|
+
Estimate run success probability (0-100) from rule results.
|
|
80
|
+
penalty = weight × confidence × determinism.
|
|
81
|
+
If ALL HIGH rules are determinism=1.0 → allow 0% (rare, powerful).
|
|
82
|
+
If ANY HIGH has determinism<1.0 (e.g. spec_drift) → cap penalty, floor at 10%.
|
|
83
|
+
"""
|
|
84
|
+
if not results:
|
|
85
|
+
return 100
|
|
86
|
+
total_penalty = 0
|
|
87
|
+
for r in results:
|
|
88
|
+
weight = RULE_WEIGHTS.get(r.rule_id) or SEVERITY_WEIGHTS.get(r.severity, 0)
|
|
89
|
+
conf = CONFIDENCE_MULTIPLIER.get(getattr(r, "confidence", "high"), 1.0)
|
|
90
|
+
det = RULE_DETERMINISM.get(r.rule_id, 1.0)
|
|
91
|
+
total_penalty += weight * conf * det
|
|
92
|
+
if not _all_high_rules_deterministic(results):
|
|
93
|
+
total_penalty = min(total_penalty, MAX_PENALTY)
|
|
94
|
+
raw = round(100 - total_penalty)
|
|
95
|
+
if not _all_high_rules_deterministic(results):
|
|
96
|
+
raw = max(10, raw)
|
|
97
|
+
return max(0, min(100, raw))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Rule: Python ABI / wheel mismatch — arm64 + Python 3.12 + packages that lag wheels."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..models import HostProfile, RepoProfile
|
|
6
|
+
from .base import RuleResult, Severity
|
|
7
|
+
|
|
8
|
+
# Packages with unstable binary wheel availability on arm64 + Python 3.12
|
|
9
|
+
# Likely: Symbol not found, undefined symbol, or build-from-source fallback
|
|
10
|
+
ARM64_PY312_UNSTABLE = {
|
|
11
|
+
"bitsandbytes",
|
|
12
|
+
"torchvision",
|
|
13
|
+
"opencv-python",
|
|
14
|
+
"opencv-contrib-python",
|
|
15
|
+
"opencv",
|
|
16
|
+
"xformers",
|
|
17
|
+
"pytorch3d",
|
|
18
|
+
"triton",
|
|
19
|
+
"onnxruntime-gpu",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_python_minor(ver: str | None) -> tuple[int, int] | None:
|
|
24
|
+
"""'3.12.1' -> (3, 12). None if unparseable."""
|
|
25
|
+
if not ver:
|
|
26
|
+
return None
|
|
27
|
+
m = re.search(r"(\d+)\.(\d+)", str(ver))
|
|
28
|
+
return (int(m.group(1)), int(m.group(2))) if m else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_repo_packages(repo: RepoProfile) -> set[str]:
|
|
32
|
+
"""Extract package names from repo."""
|
|
33
|
+
packages: set[str] = set()
|
|
34
|
+
raw = repo.raw or {}
|
|
35
|
+
if "requirements" in raw:
|
|
36
|
+
for p in raw["requirements"].get("packages", []):
|
|
37
|
+
packages.add(str(p).lower().replace("_", "-"))
|
|
38
|
+
if "pyproject" in raw:
|
|
39
|
+
for p in raw["pyproject"].get("packages", []):
|
|
40
|
+
packages.add(str(p).lower().replace("_", "-"))
|
|
41
|
+
return packages
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check(repo: RepoProfile, host: HostProfile) -> RuleResult | None:
|
|
45
|
+
"""
|
|
46
|
+
Trigger when: macOS arm64 + Python >= 3.12 + deps that lag arm64 3.12 wheels.
|
|
47
|
+
HIGH: deterministic pip install or import failure risk.
|
|
48
|
+
"""
|
|
49
|
+
if host.os != "macos" or host.arch != "arm64":
|
|
50
|
+
return None
|
|
51
|
+
py = _parse_python_minor(host.python_version)
|
|
52
|
+
if not py or py < (3, 12):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
packages = _get_repo_packages(repo)
|
|
56
|
+
found = [p for p in packages if any(u in p for u in ARM64_PY312_UNSTABLE)]
|
|
57
|
+
if not found:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
return RuleResult(
|
|
61
|
+
rule_id="abi_wheel_mismatch",
|
|
62
|
+
severity=Severity.HIGH,
|
|
63
|
+
message="Binary wheel availability unstable (arm64 + Python 3.12).",
|
|
64
|
+
reason=(
|
|
65
|
+
f"macOS arm64, Python 3.12+, dependency: {found[0]}. "
|
|
66
|
+
"Likely to hit: Symbol not found, undefined symbol, or build-from-source fallback."
|
|
67
|
+
),
|
|
68
|
+
host_summary=f"macOS arm64, Python {host.python_version}",
|
|
69
|
+
evidence={
|
|
70
|
+
"host_os_arch": "macOS arm64",
|
|
71
|
+
"host_python": host.python_version,
|
|
72
|
+
"problematic_packages": found[:5],
|
|
73
|
+
"expected_failure": "Symbol not found, undefined symbol, or build-from-source fallback",
|
|
74
|
+
"determinism": 1.0,
|
|
75
|
+
"breakage_likelihood": "~90%",
|
|
76
|
+
"likely_error": "pip install: No matching distribution / import: undefined symbol",
|
|
77
|
+
},
|
|
78
|
+
category="architecture_mismatch",
|
|
79
|
+
confidence="high",
|
|
80
|
+
)
|