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/__init__.py
ADDED
repofail/cli.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""CLI entry point — scan repo, run rules, output clearly."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _err(msg: str) -> None:
|
|
11
|
+
"""Raise a styled error (red box) — used for all CLI errors."""
|
|
12
|
+
raise click.BadParameter(msg)
|
|
13
|
+
|
|
14
|
+
# Subcommands (short names so "repofail gen" works)
|
|
15
|
+
_SUBCOMMANDS = {"gen", "s", "a", "sim", "check"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _preprocess_argv():
|
|
19
|
+
"""Fix argv so: (1) repofail . /path works via -p, (2) repofail . --json works."""
|
|
20
|
+
argv = sys.argv[1:]
|
|
21
|
+
if not argv:
|
|
22
|
+
return
|
|
23
|
+
first = argv[0]
|
|
24
|
+
if first in _SUBCOMMANDS or first.startswith("-"):
|
|
25
|
+
return
|
|
26
|
+
# First token is path-like (not a subcommand). Convert to -p and optionally reorder options.
|
|
27
|
+
path_tokens = [first]
|
|
28
|
+
opt_tokens = ["-p", first]
|
|
29
|
+
i = 1
|
|
30
|
+
while i < len(argv):
|
|
31
|
+
t = argv[i]
|
|
32
|
+
if t.startswith("-"):
|
|
33
|
+
opt_tokens.append(t)
|
|
34
|
+
i += 1
|
|
35
|
+
if "=" not in t and i < len(argv) and not argv[i].startswith("-") and t in ("--explain", "-e", "--path", "-p", "--fail-on"):
|
|
36
|
+
opt_tokens.append(argv[i])
|
|
37
|
+
i += 1
|
|
38
|
+
else:
|
|
39
|
+
path_tokens.append(t)
|
|
40
|
+
opt_tokens.extend(["-p", t])
|
|
41
|
+
i += 1
|
|
42
|
+
sys.argv[1:] = opt_tokens
|
|
43
|
+
|
|
44
|
+
from .scanner import scan_repo, inspect_host
|
|
45
|
+
from .engine import run_rules
|
|
46
|
+
from .contract import generate_contract, validate_contract, EnvironmentContract
|
|
47
|
+
from .telemetry import save_report, get_stats
|
|
48
|
+
from .rules.base import Severity
|
|
49
|
+
from .rules.registry import RULE_INFO, RULE_SUGGESTIONS
|
|
50
|
+
from .risk import estimate_success_probability
|
|
51
|
+
from .format import format_human
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(help="Predict why a repository will fail on your machine.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.callback(invoke_without_command=True)
|
|
57
|
+
def main(
|
|
58
|
+
ctx: typer.Context,
|
|
59
|
+
json_out: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
60
|
+
markdown_out: bool = typer.Option(False, "--markdown", "-m", help="Output as Markdown"),
|
|
61
|
+
ci: bool = typer.Option(False, "--ci", help="CI mode: exit 1 if HIGH rules fire"),
|
|
62
|
+
fail_on: str = typer.Option("HIGH", "--fail-on", help="In CI mode: fail on this severity or higher (HIGH/MEDIUM/LOW)"),
|
|
63
|
+
explain: str = typer.Option(None, "--explain", "-e", help="Explain a rule by ID and exit"),
|
|
64
|
+
report: bool = typer.Option(False, "--report", "-r", help="Save failure report locally (opt-in telemetry)"),
|
|
65
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Include rule IDs and low-confidence hints"),
|
|
66
|
+
path: Path = typer.Option(Path("."), "--path", "-p", exists=True, file_okay=False, dir_okay=True, resolve_path=True, help="Repo path (default: .)"),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Scan a repository and report detected incompatibilities."""
|
|
69
|
+
if ctx.invoked_subcommand is not None:
|
|
70
|
+
return
|
|
71
|
+
if explain:
|
|
72
|
+
_print_explain(explain)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
scan_path = path
|
|
76
|
+
try:
|
|
77
|
+
repo_profile = scan_repo(scan_path)
|
|
78
|
+
host_profile = inspect_host()
|
|
79
|
+
results = run_rules(repo_profile, host_profile)
|
|
80
|
+
except NotADirectoryError as e:
|
|
81
|
+
_err(str(e))
|
|
82
|
+
|
|
83
|
+
if json_out:
|
|
84
|
+
_print_json(repo_profile, host_profile, results, verbose)
|
|
85
|
+
elif markdown_out:
|
|
86
|
+
_print_markdown(repo_profile, host_profile, results)
|
|
87
|
+
else:
|
|
88
|
+
_print_human(repo_profile, host_profile, results, verbose)
|
|
89
|
+
|
|
90
|
+
if report and results:
|
|
91
|
+
saved = save_report(repo_profile.name or repo_profile.path, results, host_profile)
|
|
92
|
+
if saved:
|
|
93
|
+
typer.echo(f"Report saved: {saved}", err=True)
|
|
94
|
+
|
|
95
|
+
if ci:
|
|
96
|
+
_ci_exit(results, fail_on)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _host_summary(host) -> str:
|
|
100
|
+
"""Build host summary string."""
|
|
101
|
+
parts = [f"{host.os} {host.arch}"]
|
|
102
|
+
if host.cuda_available:
|
|
103
|
+
parts.append(f"CUDA {host.cuda_version or 'available'}")
|
|
104
|
+
else:
|
|
105
|
+
parts.append("no NVIDIA GPU")
|
|
106
|
+
if getattr(host, "has_metal", False) and host.os == "macos":
|
|
107
|
+
parts.append("Metal (MLX) available")
|
|
108
|
+
if host.ram_gb is not None:
|
|
109
|
+
parts.append(f"{host.ram_gb:.0f} GB RAM")
|
|
110
|
+
return ", ".join(parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _stack_items(repo_profile) -> list[str]:
|
|
114
|
+
"""Build detected stack items; never return empty."""
|
|
115
|
+
items = []
|
|
116
|
+
if repo_profile.python_version:
|
|
117
|
+
items.append(f"Python {repo_profile.python_version}")
|
|
118
|
+
elif repo_profile.has_pyproject or repo_profile.has_requirements_txt:
|
|
119
|
+
items.append("Python project")
|
|
120
|
+
if repo_profile.uses_torch:
|
|
121
|
+
items.append("PyTorch")
|
|
122
|
+
if repo_profile.uses_tensorflow:
|
|
123
|
+
items.append("TensorFlow")
|
|
124
|
+
if repo_profile.frameworks:
|
|
125
|
+
items.extend(repo_profile.frameworks)
|
|
126
|
+
cuda_note = "required" if repo_profile.requires_cuda else "optional"
|
|
127
|
+
if repo_profile.uses_torch or repo_profile.uses_tensorflow:
|
|
128
|
+
items.append(f"CUDA {cuda_note} backend")
|
|
129
|
+
if repo_profile.has_package_json and "Node project" not in items:
|
|
130
|
+
items.append("Node project")
|
|
131
|
+
if repo_profile.has_cargo_toml and "Rust project" not in items:
|
|
132
|
+
items.append("Rust project")
|
|
133
|
+
if repo_profile.has_dockerfile:
|
|
134
|
+
items.append("Dockerized")
|
|
135
|
+
# Only show subprojects when they add info (nested paths or multiple)
|
|
136
|
+
show_subprojects = any(sp.get("path", ".") != "." for sp in repo_profile.subprojects) or len(repo_profile.subprojects) > 1
|
|
137
|
+
if show_subprojects:
|
|
138
|
+
for sp in repo_profile.subprojects[:8]:
|
|
139
|
+
path, ptype = sp.get("path", "."), sp.get("type", "?")
|
|
140
|
+
prefix = "./" if path != "." else ""
|
|
141
|
+
extra = f" ({sp.get('python_version', '')})" if sp.get("python_version") else ""
|
|
142
|
+
items.append(f"{ptype} at {prefix}{path}{extra}".rstrip())
|
|
143
|
+
if not items:
|
|
144
|
+
items.append("(no config files detected)")
|
|
145
|
+
return items
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _print_human(repo_profile, host_profile, results, verbose: bool = False) -> None:
|
|
149
|
+
"""Human-readable output — box layout, score, findings, footer."""
|
|
150
|
+
from .risk import run_confidence
|
|
151
|
+
|
|
152
|
+
prob = estimate_success_probability(results)
|
|
153
|
+
run_conf, low_conf_rules = run_confidence(results)
|
|
154
|
+
repo_name = repo_profile.name or str(repo_profile.path)
|
|
155
|
+
text = format_human(
|
|
156
|
+
repo_name, prob, results,
|
|
157
|
+
verbose=verbose,
|
|
158
|
+
confidence=run_conf,
|
|
159
|
+
low_confidence_rules=low_conf_rules if verbose else None,
|
|
160
|
+
)
|
|
161
|
+
typer.echo(text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _print_explain(rule_id: str) -> None:
|
|
165
|
+
"""Print rule description and exit."""
|
|
166
|
+
if rule_id == "list" or rule_id == "rules":
|
|
167
|
+
typer.echo("Available rules:")
|
|
168
|
+
for rid in RULE_INFO:
|
|
169
|
+
typer.echo(f" {rid}")
|
|
170
|
+
typer.echo("\nUse: repofail --explain <rule_id>")
|
|
171
|
+
return
|
|
172
|
+
info = RULE_INFO.get(rule_id)
|
|
173
|
+
if not info:
|
|
174
|
+
_err(f"Unknown rule: {rule_id}\nAvailable: {', '.join(RULE_INFO.keys())}")
|
|
175
|
+
typer.echo(f"Rule: {rule_id}")
|
|
176
|
+
typer.echo(f"Severity: {info['severity']}")
|
|
177
|
+
typer.echo(f"Description: {info['description']}")
|
|
178
|
+
typer.echo(f"When: {info['when']}")
|
|
179
|
+
typer.echo(f"Fix: {info['fix']}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ci_exit(results, fail_on: str) -> None:
|
|
183
|
+
"""Exit 1 if any result meets or exceeds fail_on severity. INFO never fails."""
|
|
184
|
+
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 4} # INFO never triggers
|
|
185
|
+
threshold = order.get(fail_on.upper(), 0)
|
|
186
|
+
for r in results:
|
|
187
|
+
if order.get(r.severity.value, 3) <= threshold:
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _print_markdown(repo_profile, host_profile, results) -> None:
|
|
192
|
+
"""Markdown output for docs/PRs."""
|
|
193
|
+
prob = estimate_success_probability(results)
|
|
194
|
+
typer.echo(f"# repofail: {repo_profile.name or repo_profile.path}")
|
|
195
|
+
typer.echo()
|
|
196
|
+
typer.echo(f"**Estimated run success probability: {prob}%**")
|
|
197
|
+
typer.echo()
|
|
198
|
+
typer.echo("## Detected stack")
|
|
199
|
+
for item in _stack_items(repo_profile):
|
|
200
|
+
typer.echo(f"- {item}")
|
|
201
|
+
typer.echo()
|
|
202
|
+
typer.echo("## Host")
|
|
203
|
+
typer.echo(f"- {_host_summary(host_profile)}")
|
|
204
|
+
typer.echo()
|
|
205
|
+
if not results:
|
|
206
|
+
typer.echo("## Result")
|
|
207
|
+
typer.echo("No high-confidence incompatibilities detected.")
|
|
208
|
+
return
|
|
209
|
+
typer.echo("## Risk analysis")
|
|
210
|
+
for r in results:
|
|
211
|
+
typer.echo(f"\n### [{r.severity.value}] {r.message}")
|
|
212
|
+
typer.echo(f"- **Reason:** {r.reason}")
|
|
213
|
+
if r.host_summary:
|
|
214
|
+
typer.echo(f"- **Host:** {r.host_summary}")
|
|
215
|
+
typer.echo(f"\n---\n{len(results)} potential runtime mismatch(es) detected.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _print_json(repo_profile, host_profile, results, verbose: bool = False) -> None:
|
|
219
|
+
"""JSON output for piping/CI."""
|
|
220
|
+
import json
|
|
221
|
+
from dataclasses import asdict
|
|
222
|
+
|
|
223
|
+
from .risk import run_confidence
|
|
224
|
+
|
|
225
|
+
prob = estimate_success_probability(results)
|
|
226
|
+
run_conf, low_conf_rules = run_confidence(results)
|
|
227
|
+
|
|
228
|
+
output = {
|
|
229
|
+
"estimated_success_probability": prob,
|
|
230
|
+
"confidence": run_conf,
|
|
231
|
+
"repo": {
|
|
232
|
+
"name": repo_profile.name,
|
|
233
|
+
"path": repo_profile.path,
|
|
234
|
+
"python_version": repo_profile.python_version,
|
|
235
|
+
"uses_torch": repo_profile.uses_torch,
|
|
236
|
+
"requires_cuda": repo_profile.requires_cuda,
|
|
237
|
+
"has_pyproject": repo_profile.has_pyproject,
|
|
238
|
+
"has_requirements_txt": repo_profile.has_requirements_txt,
|
|
239
|
+
"has_dockerfile": repo_profile.has_dockerfile,
|
|
240
|
+
"has_package_json": repo_profile.has_package_json,
|
|
241
|
+
"subprojects": repo_profile.subprojects,
|
|
242
|
+
},
|
|
243
|
+
"host": asdict(host_profile),
|
|
244
|
+
"results": [
|
|
245
|
+
{
|
|
246
|
+
"rule_id": r.rule_id,
|
|
247
|
+
"severity": r.severity.value,
|
|
248
|
+
"message": r.message,
|
|
249
|
+
"reason": r.reason,
|
|
250
|
+
"host_summary": r.host_summary,
|
|
251
|
+
"confidence": getattr(r, "confidence", "high"),
|
|
252
|
+
**({"category": r.category} if getattr(r, "category", "") else {}),
|
|
253
|
+
**({"evidence": r.evidence} if getattr(r, "evidence", None) else {}),
|
|
254
|
+
}
|
|
255
|
+
for r in results
|
|
256
|
+
],
|
|
257
|
+
}
|
|
258
|
+
if low_conf_rules:
|
|
259
|
+
output["low_confidence_rules"] = low_conf_rules
|
|
260
|
+
typer.echo(json.dumps(output, indent=2))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.command("gen")
|
|
264
|
+
def gen_cmd(
|
|
265
|
+
path: Path = typer.Argument(Path("."), exists=True, file_okay=False, dir_okay=True, resolve_path=True, help="Repo path"),
|
|
266
|
+
output: Path | None = typer.Option(None, "-o", help="Output file"),
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Generate an environment contract from the repository."""
|
|
269
|
+
try:
|
|
270
|
+
repo_profile = scan_repo(path)
|
|
271
|
+
except NotADirectoryError as e:
|
|
272
|
+
_err(str(e))
|
|
273
|
+
contract = generate_contract(repo_profile)
|
|
274
|
+
text = contract.to_json()
|
|
275
|
+
if output:
|
|
276
|
+
output.write_text(text)
|
|
277
|
+
typer.echo(f"Wrote {output}", err=True)
|
|
278
|
+
else:
|
|
279
|
+
typer.echo(text)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.command("s")
|
|
283
|
+
def stats_cmd(
|
|
284
|
+
json_out: bool = typer.Option(False, "-j", help="JSON output"),
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Show local failure stats from opt-in reports."""
|
|
287
|
+
stats = get_stats()
|
|
288
|
+
if json_out:
|
|
289
|
+
import json
|
|
290
|
+
typer.echo(json.dumps(stats, indent=2))
|
|
291
|
+
return
|
|
292
|
+
if stats["total_runs"] == 0:
|
|
293
|
+
typer.echo("No reports yet. Run with --report when rules fire to contribute.")
|
|
294
|
+
return
|
|
295
|
+
typer.echo(f"Total runs with failures: {stats['total_runs']}")
|
|
296
|
+
typer.echo()
|
|
297
|
+
typer.echo("By rule:")
|
|
298
|
+
for rule_id, count in sorted(stats["by_rule"].items(), key=lambda x: -x[1]):
|
|
299
|
+
typer.echo(f" {rule_id}: {count}")
|
|
300
|
+
typer.echo()
|
|
301
|
+
typer.echo("By host:")
|
|
302
|
+
for host, count in sorted(stats["by_host"].items(), key=lambda x: -x[1]):
|
|
303
|
+
typer.echo(f" {host}: {count}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command("a")
|
|
307
|
+
def audit_cmd(
|
|
308
|
+
path: Path = typer.Argument(Path("."), file_okay=False, dir_okay=True, help="Dir of repos"),
|
|
309
|
+
json_out: bool = typer.Option(False, "-j", help="JSON output"),
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Scan all repos in directory — fleet-wide compatibility check."""
|
|
312
|
+
from .fleet import audit
|
|
313
|
+
if not path.exists() or not path.is_dir():
|
|
314
|
+
_err(f"Directory not found: {path}\nUse a path that exists, e.g. repofail a .")
|
|
315
|
+
results = audit(path)
|
|
316
|
+
if json_out:
|
|
317
|
+
import json
|
|
318
|
+
typer.echo(json.dumps(results, indent=2))
|
|
319
|
+
return
|
|
320
|
+
if not results:
|
|
321
|
+
typer.echo("No repos found.")
|
|
322
|
+
return
|
|
323
|
+
high = sum(1 for r in results if r["has_high"])
|
|
324
|
+
medium = sum(1 for r in results if r.get("rule_count", 0) > 0 and not r["has_high"])
|
|
325
|
+
clean = len(results) - high - medium
|
|
326
|
+
typer.echo(f"Found {len(results)} repos. {high} high-risk. {medium} medium. {clean} clean.")
|
|
327
|
+
typer.echo()
|
|
328
|
+
for r in results:
|
|
329
|
+
status = "HIGH" if r["has_high"] else ("MEDIUM" if r.get("rule_count", 0) > 0 else "OK")
|
|
330
|
+
rules_preview = ", ".join(r["rules"][:5]) or "none"
|
|
331
|
+
if len(r.get("rules", [])) > 5:
|
|
332
|
+
rules_preview += " ..."
|
|
333
|
+
typer.echo(f" [{status}] {r['name']}: {r['rule_count']} issue(s) — {rules_preview}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.command("sim")
|
|
337
|
+
def sim_cmd(
|
|
338
|
+
repo_path: Path = typer.Argument(Path("."), file_okay=False, help="Repo to check"),
|
|
339
|
+
host_file: Path = typer.Option(..., "-H", help="Host JSON (from repofail -j)"),
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Simulate: would this repo work on target host? (pre-deployment check)."""
|
|
342
|
+
from .fleet import simulate
|
|
343
|
+
if not host_file.exists():
|
|
344
|
+
_err(f"Host file not found: {host_file}\nCreate one with: repofail -j > host.json")
|
|
345
|
+
if not repo_path.exists() or not repo_path.is_dir():
|
|
346
|
+
_err(f"Repo path not found: {repo_path}")
|
|
347
|
+
try:
|
|
348
|
+
repo, host, results = simulate(repo_path, host_file)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
_err(str(e))
|
|
351
|
+
typer.echo(f"Repo: {repo.name or repo_path}")
|
|
352
|
+
typer.echo(f"Target host: {host.os} {host.arch}" + (" CUDA" if host.cuda_available else " no-CUDA"))
|
|
353
|
+
typer.echo()
|
|
354
|
+
if not results:
|
|
355
|
+
typer.echo("OK: No incompatibilities for target host.")
|
|
356
|
+
return
|
|
357
|
+
typer.echo(f"{len(results)} issue(s) on target host:")
|
|
358
|
+
for r in results:
|
|
359
|
+
typer.echo(f" [{r.severity.value}] {r.rule_id}: {r.message}")
|
|
360
|
+
raise typer.Exit(1)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command("check")
|
|
364
|
+
def check_cmd(
|
|
365
|
+
contract_path: Path = typer.Argument(..., exists=True, path_type=Path, help="Contract JSON path"),
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Validate current host against an environment contract."""
|
|
368
|
+
import json
|
|
369
|
+
data = json.loads(contract_path.read_text())
|
|
370
|
+
requires = data.get("requires", {})
|
|
371
|
+
contract = EnvironmentContract(
|
|
372
|
+
repo=data.get("repo", ""),
|
|
373
|
+
version=str(data.get("repofail_contract", "1")),
|
|
374
|
+
requires=requires,
|
|
375
|
+
optional=data.get("optional", {}),
|
|
376
|
+
)
|
|
377
|
+
host = inspect_host()
|
|
378
|
+
host_data = {
|
|
379
|
+
"python_version": host.python_version,
|
|
380
|
+
"cuda_available": host.cuda_available,
|
|
381
|
+
"has_compiler": host.has_compiler,
|
|
382
|
+
"ram_gb": host.ram_gb,
|
|
383
|
+
"node_version": host.node_version,
|
|
384
|
+
"rust_version": host.rust_version,
|
|
385
|
+
}
|
|
386
|
+
failures = validate_contract(contract, host_data)
|
|
387
|
+
if not failures:
|
|
388
|
+
typer.echo("OK: Host satisfies contract.")
|
|
389
|
+
return
|
|
390
|
+
lines = ["Contract violations:"] + [f" {req}: {reason}" for req, reason in failures]
|
|
391
|
+
_err("\n".join(lines))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _main() -> None:
|
|
395
|
+
"""Entry point: preprocess argv (repofail . -> repofail -p .), then run app."""
|
|
396
|
+
_preprocess_argv()
|
|
397
|
+
app()
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
_main()
|
repofail/contract.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Environment contract — versioned runtime expectations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .models import RepoProfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class EnvironmentContract:
|
|
15
|
+
"""Versioned runtime expectations for a repository."""
|
|
16
|
+
|
|
17
|
+
repo: str = ""
|
|
18
|
+
version: str = "1"
|
|
19
|
+
requires: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
optional: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
return {
|
|
24
|
+
"repofail_contract": self.version,
|
|
25
|
+
"generated_by": f"repofail {__version__}",
|
|
26
|
+
"repo": self.repo,
|
|
27
|
+
"requires": self.requires,
|
|
28
|
+
"optional": self.optional,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def to_json(self, indent: int = 2) -> str:
|
|
32
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_contract(repo: RepoProfile) -> EnvironmentContract:
|
|
36
|
+
"""Build an environment contract from a RepoProfile."""
|
|
37
|
+
requires: dict[str, Any] = {}
|
|
38
|
+
optional: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
if repo.python_version:
|
|
41
|
+
requires["python"] = repo.python_version
|
|
42
|
+
|
|
43
|
+
if repo.uses_torch or repo.uses_tensorflow:
|
|
44
|
+
if repo.requires_cuda:
|
|
45
|
+
requires["cuda"] = True
|
|
46
|
+
else:
|
|
47
|
+
optional["cuda"] = True
|
|
48
|
+
|
|
49
|
+
has_native = bool(repo.node_native_modules or repo.has_cargo_toml or repo.rust_system_libs)
|
|
50
|
+
if has_native:
|
|
51
|
+
requires["compiler"] = True
|
|
52
|
+
|
|
53
|
+
if repo.has_package_json:
|
|
54
|
+
requires["node"] = True
|
|
55
|
+
|
|
56
|
+
if repo.has_cargo_toml:
|
|
57
|
+
requires["rust"] = True
|
|
58
|
+
|
|
59
|
+
if repo.uses_torch and repo.frameworks and ("Diffusers" in repo.frameworks or "Transformers" in repo.frameworks):
|
|
60
|
+
optional["ram_gb"] = 16
|
|
61
|
+
|
|
62
|
+
return EnvironmentContract(
|
|
63
|
+
repo=repo.name or repo.path,
|
|
64
|
+
requires=requires,
|
|
65
|
+
optional=optional,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_contract(contract: EnvironmentContract, host_data: dict) -> list[tuple[str, str]]:
|
|
70
|
+
"""
|
|
71
|
+
Validate host against contract. Returns list of (requirement, reason) for failures.
|
|
72
|
+
host_data: dict with python_version, cuda_available, has_compiler, has_node, has_rust, ram_gb
|
|
73
|
+
"""
|
|
74
|
+
failures: list[tuple[str, str]] = []
|
|
75
|
+
|
|
76
|
+
for key, value in contract.requires.items():
|
|
77
|
+
if key == "python":
|
|
78
|
+
host_py = host_data.get("python_version")
|
|
79
|
+
if host_py and not _python_satisfies(host_py, str(value)):
|
|
80
|
+
failures.append((key, f"Host Python {host_py} does not satisfy {value}"))
|
|
81
|
+
elif key == "cuda" and value is True:
|
|
82
|
+
if not host_data.get("cuda_available", False):
|
|
83
|
+
failures.append((key, "CUDA required but not available"))
|
|
84
|
+
elif key == "compiler" and value is True:
|
|
85
|
+
if not host_data.get("has_compiler", False):
|
|
86
|
+
failures.append((key, "Compiler required for native builds"))
|
|
87
|
+
elif key == "node" and value is True:
|
|
88
|
+
if not host_data.get("node_version"):
|
|
89
|
+
failures.append((key, "Node required but not found"))
|
|
90
|
+
elif key == "rust" and value is True:
|
|
91
|
+
if not host_data.get("rust_version"):
|
|
92
|
+
failures.append((key, "Rust required but not found"))
|
|
93
|
+
|
|
94
|
+
return failures
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _python_satisfies(version: str, spec: str) -> bool:
|
|
98
|
+
"""Simple check: version satisfies requires-python spec."""
|
|
99
|
+
import re
|
|
100
|
+
try:
|
|
101
|
+
v = tuple(int(x) for x in version.split(".")[:3])
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
return True
|
|
104
|
+
for part in re.split(r"[, ]+", spec):
|
|
105
|
+
part = part.strip()
|
|
106
|
+
if not part:
|
|
107
|
+
continue
|
|
108
|
+
m = re.match(r"(>=|<=|>|<)\s*(\d+)\.(\d+)(?:\.(\d+))?", part)
|
|
109
|
+
if not m:
|
|
110
|
+
continue
|
|
111
|
+
op, ma, mi, pa = m.groups()
|
|
112
|
+
other = (int(ma), int(mi), int(pa) if pa else 0)
|
|
113
|
+
if op == ">=" and v < other:
|
|
114
|
+
return False
|
|
115
|
+
if op == "<=" and v > other:
|
|
116
|
+
return False
|
|
117
|
+
if op == ">" and v <= other:
|
|
118
|
+
return False
|
|
119
|
+
if op == "<" and v >= other:
|
|
120
|
+
return False
|
|
121
|
+
return True
|
repofail/engine.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Rule engine — runs five rules against repo + host profiles."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .models import HostProfile, RepoProfile
|
|
6
|
+
from .rules.base import RuleResult, Severity
|
|
7
|
+
from .rules import (
|
|
8
|
+
abi_wheel_mismatch,
|
|
9
|
+
torch_cuda,
|
|
10
|
+
python_version,
|
|
11
|
+
python_eol,
|
|
12
|
+
spec_drift,
|
|
13
|
+
apple_silicon,
|
|
14
|
+
native_toolchain,
|
|
15
|
+
gpu_memory,
|
|
16
|
+
node_windows,
|
|
17
|
+
node_engine,
|
|
18
|
+
node_eol,
|
|
19
|
+
lock_file_missing,
|
|
20
|
+
system_libs,
|
|
21
|
+
ml_niche,
|
|
22
|
+
info_signals,
|
|
23
|
+
port_collision,
|
|
24
|
+
docker_only,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_rules(repo: RepoProfile, host: HostProfile) -> list[RuleResult]:
|
|
29
|
+
"""Run all built-in and YAML rules, return any that fire."""
|
|
30
|
+
checks = [
|
|
31
|
+
torch_cuda.check,
|
|
32
|
+
python_version.check,
|
|
33
|
+
python_eol.check,
|
|
34
|
+
spec_drift.check,
|
|
35
|
+
abi_wheel_mismatch.check,
|
|
36
|
+
apple_silicon.check,
|
|
37
|
+
native_toolchain.check,
|
|
38
|
+
gpu_memory.check,
|
|
39
|
+
node_windows.check,
|
|
40
|
+
node_engine.check,
|
|
41
|
+
node_eol.check,
|
|
42
|
+
lock_file_missing.check,
|
|
43
|
+
system_libs.check,
|
|
44
|
+
port_collision.check,
|
|
45
|
+
docker_only.check,
|
|
46
|
+
ml_niche.check_lora_mlx_scaling,
|
|
47
|
+
ml_niche.check_torchao_incompatible,
|
|
48
|
+
]
|
|
49
|
+
checks.extend(info_signals.CHECKS)
|
|
50
|
+
results: list[RuleResult] = []
|
|
51
|
+
for check_fn in checks:
|
|
52
|
+
try:
|
|
53
|
+
r = check_fn(repo, host)
|
|
54
|
+
if r is not None:
|
|
55
|
+
results.append(r)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
# Load and run YAML rules from repo
|
|
59
|
+
try:
|
|
60
|
+
from .rules.yaml_loader import run_yaml_rules
|
|
61
|
+
yaml_results = run_yaml_rules(repo, host, Path(repo.path))
|
|
62
|
+
results.extend(yaml_results)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
# Sort by severity: HIGH first, INFO last
|
|
66
|
+
order = {Severity.HIGH: 0, Severity.MEDIUM: 1, Severity.LOW: 2, Severity.INFO: 3}
|
|
67
|
+
results.sort(key=lambda x: order.get(x.severity, 4))
|
|
68
|
+
return results
|