repofail 0.2.4__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {repofail-0.2.4 → repofail-0.2.7}/.gitignore +3 -0
  2. {repofail-0.2.4/homebrew-tap → repofail-0.2.7}/Formula/repofail.rb +2 -2
  3. repofail-0.2.7/LICENSE +21 -0
  4. repofail-0.2.4/README.md → repofail-0.2.7/PKG-INFO +55 -9
  5. repofail-0.2.4/PKG-INFO → repofail-0.2.7/README.md +35 -27
  6. repofail-0.2.7/docs/logo.png +0 -0
  7. {repofail-0.2.4 → repofail-0.2.7}/pyproject.toml +2 -1
  8. {repofail-0.2.4 → repofail-0.2.7}/repofail/cli.py +79 -2
  9. {repofail-0.2.4 → repofail-0.2.7}/repofail/fleet.py +99 -2
  10. repofail-0.2.7/repofail/lock.py +78 -0
  11. {repofail-0.2.4 → repofail-0.2.7}/repofail/scanner/parsers.py +15 -1
  12. repofail-0.2.4/.github/actions/repofail/README.md +0 -45
  13. repofail-0.2.4/.github/actions/repofail/action.yml +0 -73
  14. repofail-0.2.4/Formula/repofail.rb +0 -66
  15. repofail-0.2.4/docs/QUICKSTART.md +0 -42
  16. repofail-0.2.4/docs/logo.png +0 -0
  17. repofail-0.2.4/install.sh +0 -21
  18. {repofail-0.2.4 → repofail-0.2.7}/.github/workflows/publish.yml +0 -0
  19. {repofail-0.2.4 → repofail-0.2.7}/.github/workflows/repofail.yml +0 -0
  20. {repofail-0.2.4 → repofail-0.2.7}/CONTRIBUTING.md +0 -0
  21. {repofail-0.2.4 → repofail-0.2.7}/RULES.md +0 -0
  22. {repofail-0.2.4 → repofail-0.2.7}/contract.json +0 -0
  23. {repofail-0.2.4 → repofail-0.2.7}/docs/assets/demo-autogpt.png +0 -0
  24. {repofail-0.2.4 → repofail-0.2.7}/docs/assets/repofail-logo.png +0 -0
  25. {repofail-0.2.4 → repofail-0.2.7}/docs/screenshots/high_failure.png +0 -0
  26. {repofail-0.2.4 → repofail-0.2.7}/docs/screenshots/nodefail.gif +0 -0
  27. {repofail-0.2.4 → repofail-0.2.7}/repofail/__init__.py +0 -0
  28. {repofail-0.2.4 → repofail-0.2.7}/repofail/contract.py +0 -0
  29. {repofail-0.2.4 → repofail-0.2.7}/repofail/engine.py +0 -0
  30. {repofail-0.2.4 → repofail-0.2.7}/repofail/format.py +0 -0
  31. {repofail-0.2.4 → repofail-0.2.7}/repofail/models.py +0 -0
  32. {repofail-0.2.4 → repofail-0.2.7}/repofail/risk.py +0 -0
  33. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/__init__.py +0 -0
  34. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/abi_wheel_mismatch.py +0 -0
  35. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/apple_silicon.py +0 -0
  36. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/base.py +0 -0
  37. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/docker_only.py +0 -0
  38. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/gpu_memory.py +0 -0
  39. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/info_signals.py +0 -0
  40. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/lock_file_missing.py +0 -0
  41. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/ml_niche.py +0 -0
  42. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/native_toolchain.py +0 -0
  43. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/node_engine.py +0 -0
  44. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/node_eol.py +0 -0
  45. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/node_windows.py +0 -0
  46. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/port_collision.py +0 -0
  47. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/python_eol.py +0 -0
  48. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/python_version.py +0 -0
  49. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/registry.py +0 -0
  50. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/spec_drift.py +0 -0
  51. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/system_libs.py +0 -0
  52. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/torch_cuda.py +0 -0
  53. {repofail-0.2.4 → repofail-0.2.7}/repofail/rules/yaml_loader.py +0 -0
  54. {repofail-0.2.4 → repofail-0.2.7}/repofail/scanner/__init__.py +0 -0
  55. {repofail-0.2.4 → repofail-0.2.7}/repofail/scanner/ast_scan.py +0 -0
  56. {repofail-0.2.4 → repofail-0.2.7}/repofail/scanner/host.py +0 -0
  57. {repofail-0.2.4 → repofail-0.2.7}/repofail/scanner/repo.py +0 -0
  58. {repofail-0.2.4 → repofail-0.2.7}/repofail/telemetry.py +0 -0
  59. {repofail-0.2.4 → repofail-0.2.7}/tests/__init__.py +0 -0
  60. {repofail-0.2.4 → repofail-0.2.7}/tests/test_cli_stage2.py +0 -0
  61. {repofail-0.2.4 → repofail-0.2.7}/tests/test_contract.py +0 -0
  62. {repofail-0.2.4 → repofail-0.2.7}/tests/test_fleet.py +0 -0
  63. {repofail-0.2.4 → repofail-0.2.7}/tests/test_integration.py +0 -0
  64. {repofail-0.2.4 → repofail-0.2.7}/tests/test_ml_rules.py +0 -0
  65. {repofail-0.2.4 → repofail-0.2.7}/tests/test_rules.py +0 -0
  66. {repofail-0.2.4 → repofail-0.2.7}/tests/test_scanner.py +0 -0
  67. {repofail-0.2.4 → repofail-0.2.7}/tests/test_telemetry.py +0 -0
@@ -20,3 +20,6 @@ secrets.yaml
20
20
  .credentials
21
21
  *.pem
22
22
  .DS_Store
23
+
24
+ # Source logo (cropped version committed as docs/logo.png)
25
+ repofail-logo-cropped-upscaled.png
@@ -12,8 +12,8 @@ class Repofail < Formula
12
12
 
13
13
  desc "Deterministic runtime compatibility analyzer"
14
14
  homepage "https://github.com/jayvenn21/repofail"
15
- url "https://github.com/jayvenn21/repofail/archive/refs/tags/v0.2.4.tar.gz"
16
- sha256 "d5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed"
15
+ url "https://github.com/jayvenn21/repofail/archive/refs/tags/v0.2.5.tar.gz"
16
+ sha256 "ac1791fcfd24fd9a70df44423f02ac6f70df5df53ee2f2fca7d518a6b639fa86"
17
17
  license "MIT"
18
18
 
19
19
  # App supports Python 3.10+. We use 3.12 for current Homebrew; change to
repofail-0.2.7/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jayanth Vennamreddy
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.
@@ -1,20 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: repofail
3
+ Version: 0.2.7
4
+ Summary: Predict why a repository will fail on your machine before you run it.
5
+ Author: Jayanth
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: Implementation :: CPython
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: pyyaml>=6.0
15
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
16
+ Requires-Dist: typer>=0.9.0
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
1
21
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/jayvenn21/repofail/main/docs/logo.png" width="180" alt="repofail logo">
22
+ <img src="docs/logo.png" width="65%" alt="repofail logo">
3
23
  </p>
4
24
 
5
- <h1 align="center">repofail</h1>
6
-
7
25
  <p align="center">
8
26
  Deterministic runtime compatibility analyzer
9
27
  </p>
10
28
 
11
29
  <p align="center">
12
- <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/v/repofail?color=orange" alt="PyPI"></a>
13
- <img src="https://img.shields.io/pypi/pyversions/repofail" alt="Python">
14
- <img src="https://img.shields.io/pypi/dm/repofail" alt="Downloads">
15
- <img src="https://img.shields.io/badge/ci-passing-brightgreen">
16
- <img src="https://img.shields.io/badge/runtime-validated-success">
17
- <img src="https://img.shields.io/badge/rules-20+-informational">
30
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/v/repofail?cacheSeconds=60" alt="PyPI"></a>
31
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/pyversions/repofail?cacheSeconds=60" alt="Python"></a>
32
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/dm/repofail?cacheSeconds=60" alt="Downloads"></a>
33
+ <a href="https://github.com/jayvenn21/repofail/actions"><img src="https://img.shields.io/github/actions/workflow/status/jayvenn21/repofail/repofail.yml?branch=main" alt="CI"></a>
34
+ <img src="https://img.shields.io/badge/runtime-validated-brightgreen" alt="Runtime">
35
+ <img src="https://img.shields.io/badge/rules-20%2B-blue" alt="Rules">
36
+ <a href="https://github.com/jayvenn21/repofail/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="License"></a>
18
37
  </p>
19
38
 
20
39
  <p align="center">
@@ -215,6 +234,27 @@ The action installs repofail, runs a compatibility check, comments the Markdown
215
234
 
216
235
  Exits 1 if HIGH rules fire. Use `--fail-on MEDIUM` to be stricter.
217
236
 
237
+ **Option C — Lock + verify (enforcement)**
238
+
239
+ Pin the runtime once, then fail CI on drift:
240
+
241
+ ```bash
242
+ repofail lock # generates repofail.lock.json (python, node, arch, os, cuda, docker_base)
243
+ repofail verify # exit 1 if host doesn't match lock
244
+ ```
245
+
246
+ Commit `repofail.lock.json`. In CI, run `repofail verify` so builds only pass on the locked environment.
247
+
248
+ **Fleet compliance**
249
+
250
+ Scan many repos and get violations, most common drift, and risk clusters:
251
+
252
+ ```bash
253
+ repofail fleet scan ~/org --policy org.policy.yaml
254
+ ```
255
+
256
+ Policy YAML (optional): `fail_on: HIGH`, `max_repos: 500`, `max_depth: 4`. With `fail_on: HIGH`, exit code is 1 if any repo has a HIGH finding.
257
+
218
258
  ## Contracts
219
259
 
220
260
  ```bash
@@ -331,3 +371,9 @@ pytest tests/ -v
331
371
  ```
332
372
 
333
373
  **Quick checks:** `bash -n install.sh` (syntax). The GitHub Action runs on every PR in this repo (see [.github/workflows/repofail.yml](.github/workflows/repofail.yml)); to test the reusable action, use it in another repo’s workflow on a PR.
374
+
375
+ ---
376
+
377
+ ## License
378
+
379
+ MIT — see [LICENSE](LICENSE).
@@ -1,38 +1,19 @@
1
- Metadata-Version: 2.4
2
- Name: repofail
3
- Version: 0.2.4
4
- Summary: Predict why a repository will fail on your machine before you run it.
5
- Author: Jayanth
6
- Classifier: Programming Language :: Python :: 3
7
- Classifier: Programming Language :: Python :: 3.10
8
- Classifier: Programming Language :: Python :: 3.11
9
- Classifier: Programming Language :: Python :: 3.12
10
- Classifier: Programming Language :: Python :: 3.13
11
- Requires-Python: >=3.10
12
- Requires-Dist: pyyaml>=6.0
13
- Requires-Dist: tomli>=2.0; python_version < '3.11'
14
- Requires-Dist: typer>=0.9.0
15
- Provides-Extra: dev
16
- Requires-Dist: pytest>=7.0; extra == 'dev'
17
- Description-Content-Type: text/markdown
18
-
19
1
  <p align="center">
20
- <img src="https://raw.githubusercontent.com/jayvenn21/repofail/main/docs/logo.png" width="180" alt="repofail logo">
2
+ <img src="docs/logo.png" width="65%" alt="repofail logo">
21
3
  </p>
22
4
 
23
- <h1 align="center">repofail</h1>
24
-
25
5
  <p align="center">
26
6
  Deterministic runtime compatibility analyzer
27
7
  </p>
28
8
 
29
9
  <p align="center">
30
- <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/v/repofail?color=orange" alt="PyPI"></a>
31
- <img src="https://img.shields.io/pypi/pyversions/repofail" alt="Python">
32
- <img src="https://img.shields.io/pypi/dm/repofail" alt="Downloads">
33
- <img src="https://img.shields.io/badge/ci-passing-brightgreen">
34
- <img src="https://img.shields.io/badge/runtime-validated-success">
35
- <img src="https://img.shields.io/badge/rules-20+-informational">
10
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/v/repofail?cacheSeconds=60" alt="PyPI"></a>
11
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/pyversions/repofail?cacheSeconds=60" alt="Python"></a>
12
+ <a href="https://pypi.org/project/repofail/"><img src="https://img.shields.io/pypi/dm/repofail?cacheSeconds=60" alt="Downloads"></a>
13
+ <a href="https://github.com/jayvenn21/repofail/actions"><img src="https://img.shields.io/github/actions/workflow/status/jayvenn21/repofail/repofail.yml?branch=main" alt="CI"></a>
14
+ <img src="https://img.shields.io/badge/runtime-validated-brightgreen" alt="Runtime">
15
+ <img src="https://img.shields.io/badge/rules-20%2B-blue" alt="Rules">
16
+ <a href="https://github.com/jayvenn21/repofail/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-lightgrey" alt="License"></a>
36
17
  </p>
37
18
 
38
19
  <p align="center">
@@ -233,6 +214,27 @@ The action installs repofail, runs a compatibility check, comments the Markdown
233
214
 
234
215
  Exits 1 if HIGH rules fire. Use `--fail-on MEDIUM` to be stricter.
235
216
 
217
+ **Option C — Lock + verify (enforcement)**
218
+
219
+ Pin the runtime once, then fail CI on drift:
220
+
221
+ ```bash
222
+ repofail lock # generates repofail.lock.json (python, node, arch, os, cuda, docker_base)
223
+ repofail verify # exit 1 if host doesn't match lock
224
+ ```
225
+
226
+ Commit `repofail.lock.json`. In CI, run `repofail verify` so builds only pass on the locked environment.
227
+
228
+ **Fleet compliance**
229
+
230
+ Scan many repos and get violations, most common drift, and risk clusters:
231
+
232
+ ```bash
233
+ repofail fleet scan ~/org --policy org.policy.yaml
234
+ ```
235
+
236
+ Policy YAML (optional): `fail_on: HIGH`, `max_repos: 500`, `max_depth: 4`. With `fail_on: HIGH`, exit code is 1 if any repo has a HIGH finding.
237
+
236
238
  ## Contracts
237
239
 
238
240
  ```bash
@@ -349,3 +351,9 @@ pytest tests/ -v
349
351
  ```
350
352
 
351
353
  **Quick checks:** `bash -n install.sh` (syntax). The GitHub Action runs on every PR in this repo (see [.github/workflows/repofail.yml](.github/workflows/repofail.yml)); to test the reusable action, use it in another repo’s workflow on a PR.
354
+
355
+ ---
356
+
357
+ ## License
358
+
359
+ MIT — see [LICENSE](LICENSE).
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "repofail"
7
- version = "0.2.4"
7
+ version = "0.2.7"
8
8
  description = "Predict why a repository will fail on your machine before you run it."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -20,6 +20,7 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
22
  "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: Implementation :: CPython",
23
24
  ]
24
25
 
25
26
  [project.scripts]
@@ -1,5 +1,6 @@
1
1
  """CLI entry point — scan repo, run rules, output clearly."""
2
2
 
3
+ import json
3
4
  import sys
4
5
  from pathlib import Path
5
6
  from typing import Optional
@@ -13,7 +14,7 @@ def _err(msg: str) -> None:
13
14
  raise click.BadParameter(msg)
14
15
 
15
16
  # Subcommands (short names so "repofail gen" works)
16
- _SUBCOMMANDS = {"gen", "s", "a", "sim", "check"}
17
+ _SUBCOMMANDS = {"gen", "s", "a", "sim", "check", "lock", "verify", "fleet"}
17
18
 
18
19
 
19
20
  def _preprocess_argv():
@@ -45,7 +46,9 @@ def _preprocess_argv():
45
46
  from .scanner import scan_repo, inspect_host
46
47
  from .engine import run_rules
47
48
  from .contract import generate_contract, validate_contract, EnvironmentContract
49
+ from .lock import generate_lock, verify_lock, LOCK_FILENAME
48
50
  from .telemetry import save_report, get_stats
51
+ from .fleet import audit, fleet_scan
49
52
  from .rules.base import Severity
50
53
  from .rules.registry import RULE_INFO
51
54
  from .risk import estimate_success_probability
@@ -53,6 +56,49 @@ from .format import format_human
53
56
 
54
57
  app = typer.Typer(help="Predict why a repository will fail on your machine.")
55
58
 
59
+ fleet_app = typer.Typer(help="Fleet-wide compliance and scanning.")
60
+
61
+
62
+ @fleet_app.command("scan")
63
+ def fleet_scan_cmd(
64
+ path: Path = typer.Argument(Path("."), exists=True, file_okay=False, dir_okay=True, resolve_path=True, help="Root dir to scan (e.g. ~/org)"),
65
+ policy: Optional[Path] = typer.Option(None, "--policy", "-P", path_type=Path, help="Policy YAML (fail_on, max_repos, max_depth)"),
66
+ json_out: bool = typer.Option(False, "--json", "-j", help="Output JSON"),
67
+ ) -> None:
68
+ """Scan all repos under path; report violations, most common drift, risk clusters."""
69
+ if not path.exists() or not path.is_dir():
70
+ _err(f"Directory not found: {path}")
71
+ summary = fleet_scan(path, policy_path=policy)
72
+ if json_out:
73
+ typer.echo(json.dumps(summary, indent=2))
74
+ if summary.get("violations", 0) > 0 and summary.get("policy", {}).get("fail_on") == "HIGH":
75
+ high_count = sum(1 for r in summary.get("repos", []) if r.get("has_high"))
76
+ if high_count > 0:
77
+ raise typer.Exit(1)
78
+ return
79
+ total = summary["total_repos_scanned"]
80
+ violations = summary["violations"]
81
+ typer.echo(f"Total repos scanned: {total}")
82
+ typer.echo(f"Violations: {violations}")
83
+ drift = summary.get("most_common_drift") or {}
84
+ if drift:
85
+ typer.echo("Most common drift:")
86
+ for rule_id, count in list(drift.items())[:8]:
87
+ typer.echo(f" {rule_id}: {count}")
88
+ clusters = summary.get("risk_clusters") or []
89
+ if clusters:
90
+ typer.echo("Risk cluster:")
91
+ for c in clusters[:6]:
92
+ typer.echo(f" {c['category']}: {c['count']} finding(s)")
93
+ if summary.get("policy", {}).get("fail_on") == "HIGH":
94
+ high_count = sum(1 for r in summary.get("repos", []) if r.get("has_high"))
95
+ if high_count > 0:
96
+ typer.echo(f"\n{high_count} repo(s) with HIGH severity (policy fail_on=HIGH).", err=True)
97
+ raise typer.Exit(1)
98
+
99
+
100
+ app.add_typer(fleet_app, name="fleet")
101
+
56
102
 
57
103
  @app.callback(invoke_without_command=True)
58
104
  def main(
@@ -310,7 +356,6 @@ def audit_cmd(
310
356
  json_out: bool = typer.Option(False, "-j", help="JSON output"),
311
357
  ) -> None:
312
358
  """Scan all repos in directory — fleet-wide compatibility check."""
313
- from .fleet import audit
314
359
  if not path.exists() or not path.is_dir():
315
360
  _err(f"Directory not found: {path}\nUse a path that exists, e.g. repofail a .")
316
361
  results = audit(path)
@@ -361,6 +406,38 @@ def sim_cmd(
361
406
  raise typer.Exit(1)
362
407
 
363
408
 
409
+ @app.command("lock")
410
+ def lock_cmd(
411
+ path: Path = typer.Option(Path("."), "--path", "-p", exists=True, file_okay=False, dir_okay=True, resolve_path=True, help="Repo path"),
412
+ output: Optional[Path] = typer.Option(None, "-o", help="Output file (default: repofail.lock.json in repo)"),
413
+ ) -> None:
414
+ """Generate repofail.lock.json from current host (and repo Dockerfile base). CI can then use repofail verify."""
415
+ try:
416
+ lock = generate_lock(path)
417
+ except NotADirectoryError as e:
418
+ _err(str(e))
419
+ out = output or path / LOCK_FILENAME
420
+ out.write_text(json.dumps(lock, indent=2))
421
+ typer.echo(f"Wrote {out}", err=True)
422
+
423
+
424
+ @app.command("verify")
425
+ def verify_cmd(
426
+ path: Path = typer.Option(Path("."), "--path", "-p", exists=True, file_okay=False, dir_okay=True, resolve_path=True, help="Repo path"),
427
+ lock_file: Optional[Path] = typer.Option(None, "-f", help="Lock file path (default: repofail.lock.json in repo)"),
428
+ ) -> None:
429
+ """Verify current host matches repofail.lock.json. Exit 1 on drift (for CI)."""
430
+ lock_path = lock_file or path / LOCK_FILENAME
431
+ failures = verify_lock(lock_path)
432
+ if not failures:
433
+ typer.echo("OK: Host matches lock.")
434
+ return
435
+ typer.echo("Runtime drift detected:", err=True)
436
+ for field, expected, actual in failures:
437
+ typer.echo(f" {field}: expected {expected}, host {actual}", err=True)
438
+ raise typer.Exit(1)
439
+
440
+
364
441
  @app.command("check")
365
442
  def check_cmd(
366
443
  contract_path: Path = typer.Argument(..., exists=True, path_type=Path, help="Contract JSON path"),
@@ -1,18 +1,43 @@
1
- """Stage 5 — Fleet/enterprise: audit, simulate."""
1
+ """Stage 5 — Fleet/enterprise: audit, simulate, fleet scan."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from dataclasses import asdict
6
+ from collections import Counter
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
10
  from .models import HostProfile, RepoProfile
10
11
  from .scanner import scan_repo, inspect_host
11
12
  from .engine import run_rules
12
13
 
14
+ try:
15
+ import yaml
16
+ except ImportError:
17
+ yaml = None # type: ignore[assignment]
13
18
 
14
19
  SKIP_AUDIT_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules", ".tox", "build", "dist", "eggs"}
15
20
 
21
+ # Rule ID -> short category for risk clusters
22
+ RULE_CATEGORIES: dict[str, str] = {
23
+ "torch_cuda_mismatch": "ML/CUDA",
24
+ "python_version_mismatch": "Python",
25
+ "python_eol": "Python",
26
+ "spec_drift": "Spec drift",
27
+ "abi_wheel_mismatch": "ML/ARM",
28
+ "apple_silicon_wheels": "ML/ARM",
29
+ "node_engine_mismatch": "Node",
30
+ "node_eol": "Node",
31
+ "node_lock_file_missing": "Node",
32
+ "native_toolchain_missing": "Native build",
33
+ "gpu_memory_risk": "ML/RAM",
34
+ "node_native_windows": "Node/Windows",
35
+ "missing_system_libs": "System libs",
36
+ "docker_only": "Docker",
37
+ "lock_file_missing": "Lock file",
38
+ }
39
+ DEFAULT_CATEGORY = "Other"
40
+
16
41
 
17
42
  def _is_repo(p: Path) -> bool:
18
43
  """Check if path looks like a repo (has .git or deps file)."""
@@ -25,6 +50,21 @@ def _is_repo(p: Path) -> bool:
25
50
  )
26
51
 
27
52
 
53
+ def _load_policy(policy_path: Path | None) -> dict[str, Any]:
54
+ """Load optional policy YAML: fail_on (HIGH/MEDIUM/LOW), max_repos, max_depth."""
55
+ if not policy_path or not policy_path.exists() or not yaml:
56
+ return {}
57
+ try:
58
+ data = yaml.safe_load(policy_path.read_text()) or {}
59
+ return {
60
+ "fail_on": data.get("fail_on", "HIGH"),
61
+ "max_repos": data.get("max_repos", 500),
62
+ "max_depth": data.get("max_depth", 4),
63
+ }
64
+ except Exception:
65
+ return {}
66
+
67
+
28
68
  def _find_repos(base_path: Path, max_depth: int = 4, max_repos: int = 50) -> list[Path]:
29
69
  """Find repo roots recursively (nested repos)."""
30
70
  found: list[Path] = []
@@ -87,6 +127,63 @@ def audit(base_path: Path) -> list[dict]:
87
127
  return results
88
128
 
89
129
 
130
+ def fleet_scan(
131
+ base_path: Path,
132
+ policy_path: Path | None = None,
133
+ ) -> dict[str, Any]:
134
+ """
135
+ Scan all repos under base_path; optionally apply policy.
136
+ Returns: total_repos, violations (count), repos (list), by_rule (most common drift), risk_clusters.
137
+ """
138
+ base_path = Path(base_path).resolve()
139
+ policy = _load_policy(policy_path)
140
+ max_repos = int(policy.get("max_repos", 500))
141
+ max_depth = int(policy.get("max_depth", 4))
142
+
143
+ dirs = _find_repos(base_path, max_depth=max_depth, max_repos=max_repos)
144
+ host = inspect_host()
145
+ repos: list[dict[str, Any]] = []
146
+ rule_counter: Counter[str] = Counter()
147
+ category_counter: Counter[str] = Counter()
148
+
149
+ for d in dirs:
150
+ try:
151
+ repo = scan_repo(d)
152
+ rule_results = run_rules(repo, host)
153
+ rule_ids = [r.rule_id for r in rule_results]
154
+ for rid in rule_ids:
155
+ rule_counter[rid] += 1
156
+ category_counter[RULE_CATEGORIES.get(rid, DEFAULT_CATEGORY)] += 1
157
+ high_count = sum(1 for r in rule_results if r.severity.value == "HIGH")
158
+ med_count = sum(1 for r in rule_results if r.severity.value in ("MEDIUM", "LOW"))
159
+ score = __import__("repofail.risk", fromlist=["estimate_success_probability"]).estimate_success_probability(rule_results)
160
+ repos.append({
161
+ "path": str(d),
162
+ "name": repo.name or d.name,
163
+ "rule_count": len(rule_results),
164
+ "rules": rule_ids,
165
+ "has_high": high_count > 0,
166
+ "high_count": high_count,
167
+ "medium_count": med_count,
168
+ "score": score,
169
+ })
170
+ except Exception:
171
+ pass
172
+
173
+ violations = sum(1 for r in repos if r["rule_count"] > 0)
174
+ by_rule = dict(rule_counter.most_common(15))
175
+ risk_clusters = [{"category": k, "count": v} for k, v in category_counter.most_common(10)]
176
+
177
+ return {
178
+ "total_repos_scanned": len(repos),
179
+ "violations": violations,
180
+ "repos": repos,
181
+ "most_common_drift": by_rule,
182
+ "risk_clusters": risk_clusters,
183
+ "policy": policy,
184
+ }
185
+
186
+
90
187
  def host_from_dict(data: dict) -> HostProfile:
91
188
  """Build HostProfile from dict (e.g. from JSON file)."""
92
189
  return HostProfile(
@@ -0,0 +1,78 @@
1
+ """Runtime lock — pin host + optional docker base for CI enforcement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from . import __version__
10
+ from .scanner import inspect_host, scan_repo
11
+
12
+
13
+ LOCK_FILENAME = "repofail.lock.json"
14
+
15
+
16
+ def generate_lock(repo_path: Path) -> dict[str, Any]:
17
+ """Build lock dict from current host and repo (docker base if present)."""
18
+ host = inspect_host()
19
+ lock: dict[str, Any] = {
20
+ "repofail_lock": "1",
21
+ "generated_by": f"repofail {__version__}",
22
+ "python": host.python_version,
23
+ "node": host.node_version,
24
+ "cuda": host.cuda_version if host.cuda_available else None,
25
+ "arch": host.arch,
26
+ "os": host.os,
27
+ "docker_base": None,
28
+ }
29
+ try:
30
+ repo = scan_repo(repo_path)
31
+ if repo.raw.get("dockerfile"):
32
+ # First Dockerfile parsed may have base_image
33
+ df = repo.raw["dockerfile"]
34
+ if isinstance(df, dict) and df.get("base_image"):
35
+ lock["docker_base"] = df["base_image"]
36
+ # Also check first Dockerfile on disk if not in merged raw
37
+ if lock["docker_base"] is None and repo.has_dockerfile:
38
+ for p in (repo_path / "Dockerfile",):
39
+ if p.exists():
40
+ from .scanner.parsers import parse_dockerfile
41
+ data = parse_dockerfile(p)
42
+ if data.get("base_image"):
43
+ lock["docker_base"] = data["base_image"]
44
+ break
45
+ except Exception:
46
+ pass
47
+ return lock
48
+
49
+
50
+ def verify_lock(lock_path: Path) -> list[tuple[str, str, str]]:
51
+ """
52
+ Compare current host to lock. Returns list of (field, expected, actual) for mismatches.
53
+ """
54
+ if not lock_path.exists():
55
+ return [("_lock", "file exists", str(lock_path) + " not found")]
56
+ data = json.loads(lock_path.read_text())
57
+ host = inspect_host()
58
+
59
+ failures: list[tuple[str, str, str]] = []
60
+
61
+ def _cmp(field: str, expected: Any, actual: Any) -> None:
62
+ if expected is None:
63
+ return
64
+ if expected != actual:
65
+ failures.append((field, str(expected), str(actual) if actual is not None else "missing"))
66
+
67
+ _cmp("python", data.get("python"), host.python_version)
68
+ _cmp("node", data.get("node"), host.node_version)
69
+ _cmp("arch", data.get("arch"), host.arch)
70
+ _cmp("os", data.get("os"), host.os)
71
+
72
+ if data.get("cuda") is not None:
73
+ if not host.cuda_available:
74
+ failures.append(("cuda", str(data.get("cuda")), "not available"))
75
+ elif host.cuda_version and data.get("cuda") != host.cuda_version:
76
+ failures.append(("cuda", str(data.get("cuda")), str(host.cuda_version)))
77
+
78
+ return failures
@@ -255,13 +255,27 @@ def parse_cargo_toml(path: Path) -> dict[str, Any]:
255
255
 
256
256
  def parse_dockerfile(path: Path) -> dict[str, Any]:
257
257
  """Parse Dockerfile for base image, platform, Python."""
258
- result: dict[str, Any] = {"has_cuda": False, "python_version": None, "platform_amd64": False}
258
+ result: dict[str, Any] = {
259
+ "has_cuda": False,
260
+ "python_version": None,
261
+ "platform_amd64": False,
262
+ "base_image": None,
263
+ }
259
264
  if not path.exists():
260
265
  return result
261
266
 
262
267
  content = path.read_text(errors="replace")
263
268
  result["has_cuda"] = "cuda" in content.lower() or "nvidia" in content.lower()
264
269
  for line in content.splitlines():
270
+ stripped = line.strip()
271
+ if stripped.upper().startswith("FROM "):
272
+ # FROM image[:tag] [AS name]; skip --platform= and ARG
273
+ rest = stripped[5:].strip()
274
+ if rest and not result.get("base_image"):
275
+ base = rest.split()[0] if rest.split() else rest
276
+ if "AS " in base.upper():
277
+ base = base.upper().split(" AS ")[0].strip()
278
+ result["base_image"] = base
265
279
  if "python:" in line.lower() or "python=" in line.lower():
266
280
  m = re.search(r"python[:\s]*([\d.]+)", line, re.I)
267
281
  if m:
@@ -1,45 +0,0 @@
1
- # repofail GitHub Action
2
-
3
- Run repofail on every PR: get a Markdown report as a comment and fail CI when compatibility violations exceed the threshold.
4
-
5
- ## Usage
6
-
7
- In your repo, create `.github/workflows/repofail.yml`:
8
-
9
- ```yaml
10
- name: repofail
11
- on:
12
- pull_request:
13
- branches: [main, master]
14
- jobs:
15
- check:
16
- runs-on: ubuntu-latest
17
- steps:
18
- - uses: actions/checkout@v4
19
- - uses: jayvenn21/repofail/.github/actions/repofail@main
20
- with:
21
- path: .
22
- fail_on: HIGH
23
- comment: 'true'
24
- upload_artifact: 'true'
25
- pr_number: ${{ github.event.pull_request.number }}
26
- ```
27
-
28
- ## Inputs
29
-
30
- | Input | Default | Description |
31
- |-------|---------|-------------|
32
- | `path` | `.` | Repository path to scan |
33
- | `fail_on` | `HIGH` | Fail CI when severity is HIGH, MEDIUM, or LOW |
34
- | `comment` | `true` | Comment on PR with Markdown report |
35
- | `upload_artifact` | `true` | Upload JSON report as workflow artifact |
36
- | `pr_number` | (empty) | Set to `${{ github.event.pull_request.number }}` for PR comments |
37
-
38
- ## Behavior
39
-
40
- 1. Installs repofail from PyPI
41
- 2. Runs `repofail -m` and saves the report
42
- 3. Runs `repofail -j` for machine-readable output
43
- 4. Comments on the PR with the Markdown report (if `comment: true` and `pr_number` set)
44
- 5. Uploads `repofail.json` as an artifact (if `upload_artifact: true`)
45
- 6. Runs `repofail --ci` and fails the job if violations meet or exceed `fail_on`
@@ -1,73 +0,0 @@
1
- name: repofail
2
- description: Deterministic runtime compatibility check — fail CI or comment on PR with report
3
- inputs:
4
- path:
5
- description: Repository path to scan (default .)
6
- required: false
7
- default: .
8
- fail_on:
9
- description: Severity threshold to fail CI (HIGH, MEDIUM, LOW)
10
- required: false
11
- default: HIGH
12
- comment:
13
- description: Comment on PR with Markdown report
14
- required: false
15
- default: 'true'
16
- upload_artifact:
17
- description: Upload JSON report as artifact
18
- required: false
19
- default: 'true'
20
- pr_number:
21
- description: Pull request number (for commenting). Set to ${{ github.event.pull_request.number }}
22
- required: false
23
- default: ''
24
- outputs:
25
- markdown_file:
26
- description: Path to Markdown report file (repofail-report.md)
27
- runs:
28
- using: composite
29
- steps:
30
- - name: Install repofail
31
- shell: bash
32
- run: pip install repofail
33
-
34
- - name: Run repofail (Markdown)
35
- id: md
36
- shell: bash
37
- run: |
38
- repofail -p "${{ inputs.path }}" -m > repofail-report.md || true
39
- echo "markdown_file=repofail-report.md" >> $GITHUB_OUTPUT
40
-
41
- - name: Run repofail (JSON)
42
- if: inputs.upload_artifact == 'true'
43
- shell: bash
44
- run: repofail -p "${{ inputs.path }}" -j > repofail.json || true
45
-
46
- - name: Comment on PR with report
47
- if: inputs.comment == 'true' && inputs.pr_number != ''
48
- uses: actions/github-script@v7
49
- with:
50
- github-token: ${{ secrets.GITHUB_TOKEN }}
51
- script: |
52
- const fs = require('fs');
53
- const body = fs.existsSync('repofail-report.md')
54
- ? fs.readFileSync('repofail-report.md', 'utf8')
55
- : 'repofail report not available.';
56
- const comment = `## repofail · compatibility report\n\n\`\`\`\n${body}\n\`\`\``;
57
- await github.rest.issues.createComment({
58
- issue_number: ${{ inputs.pr_number }},
59
- owner: context.repo.owner,
60
- repo: context.repo.repo,
61
- body: comment,
62
- });
63
-
64
- - name: Upload repofail JSON artifact
65
- if: inputs.upload_artifact == 'true' && always()
66
- uses: actions/upload-artifact@v4
67
- with:
68
- name: repofail-report
69
- path: repofail.json
70
-
71
- - name: Run repofail (CI — fail if threshold exceeded)
72
- shell: bash
73
- run: repofail -p "${{ inputs.path }}" --ci --fail-on "${{ inputs.fail_on }}"
@@ -1,66 +0,0 @@
1
- # Homebrew formula for repofail
2
- # To use: copy to your tap repo (e.g. homebrew-tap/Formula/repofail.rb)
3
- # Then: brew tap jayvenn21/tap && brew install jayvenn21/tap/repofail
4
- #
5
- # Homebrew uses --no-deps when installing the main package, so all Python
6
- # dependencies must be declared as resources below.
7
- # If SHA256 fails after pushing a new tag, get the correct hash with:
8
- # curl -sL https://github.com/jayvenn21/repofail/archive/refs/tags/vX.Y.Z.tar.gz | shasum -a 256
9
-
10
- class Repofail < Formula
11
- include Language::Python::Virtualenv
12
-
13
- desc "Deterministic runtime compatibility analyzer"
14
- homepage "https://github.com/jayvenn21/repofail"
15
- url "https://github.com/jayvenn21/repofail/archive/refs/tags/v0.2.4.tar.gz"
16
- sha256 "d5558cd419c8d46bdc958064cb97f963d1ea793866414c025906ec15033512ed"
17
- license "MIT"
18
-
19
- # App supports Python 3.10+. We use 3.12 for current Homebrew; change to
20
- # python@3.10 or python@3.11 if you need an older interpreter.
21
- depends_on "libyaml"
22
- depends_on "python@3.12"
23
-
24
- resource "click" do
25
- url "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz"
26
- sha256 "12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"
27
- end
28
-
29
- resource "typer" do
30
- url "https://files.pythonhosted.org/packages/ac/0a/d55af35db5f50f486e3eda0ada747eed773859e2699d3ce570b682a9b70a/typer-0.12.3.tar.gz"
31
- sha256 "49e73131481d804288ef62598d97a1ceef3058905aa536a1134f90891ba35482"
32
- end
33
-
34
- resource "rich" do
35
- url "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz"
36
- sha256 "439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"
37
- end
38
-
39
- resource "shellingham" do
40
- url "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz"
41
- sha256 "8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"
42
- end
43
-
44
- resource "typing-extensions" do
45
- url "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz"
46
- sha256 "1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
47
- end
48
-
49
- resource "tomli" do
50
- url "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz"
51
- sha256 "d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"
52
- end
53
-
54
- resource "pyyaml" do
55
- url "https://github.com/yaml/pyyaml/archive/refs/tags/6.0.1.tar.gz"
56
- sha256 "57314c984aaa84318eed00cf5a8365afc49f87954969e295efe2ba99f3b21f7a"
57
- end
58
-
59
- def install
60
- virtualenv_install_with_resources
61
- end
62
-
63
- test do
64
- assert_match "repofail", shell_output("#{bin}/repofail --help", 0)
65
- end
66
- end
@@ -1,42 +0,0 @@
1
- # repofail — 30-second quickstart
2
-
3
- ## What it does
4
-
5
- Answers one question: **Will this repository run on this machine?**
6
-
7
- It does **not** install your project. It reads configs and your environment, then reports compatibility issues (Node version, Python range, CUDA, lock file, spec drift, etc.) with a single score and clear evidence.
8
-
9
- ## Install
10
-
11
- ```bash
12
- pip install repofail
13
- ```
14
-
15
- Or: `pipx install repofail` | [install.sh](https://raw.githubusercontent.com/jayvenn21/repofail/main/install.sh) | Homebrew (see README).
16
-
17
- ## Run
18
-
19
- ```bash
20
- cd /path/to/any/repo
21
- repofail
22
- ```
23
-
24
- You get:
25
-
26
- - A **compatibility score** (0–100%)
27
- - **HARD FAILURES** (deterministic blockers) with evidence and suggested fixes
28
- - Optional: **RUNTIME RISKS**, **STRUCTURAL PROFILE**
29
-
30
- ## CI
31
-
32
- ```bash
33
- repofail --ci
34
- ```
35
-
36
- Exits 1 if HIGH (or configured) severity violations are found. Use in GitHub Actions or any CI.
37
-
38
- ## Next
39
-
40
- - `repofail -e list` — list all rules
41
- - `repofail -e spec_drift` — explain a rule
42
- - [README](https://github.com/jayvenn21/repofail#readme) — full docs, FAQ, when not to use
Binary file
repofail-0.2.4/install.sh DELETED
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Install repofail — deterministic runtime compatibility analyzer
3
- # Usage: curl -sSL https://raw.githubusercontent.com/jayvenn21/repofail/main/install.sh | bash
4
-
5
- set -e
6
-
7
- if command -v pipx &>/dev/null; then
8
- echo "Installing repofail via pipx (isolated)..."
9
- pipx install repofail
10
- elif command -v pip3 &>/dev/null; then
11
- echo "Installing repofail via pip..."
12
- pip3 install repofail
13
- elif command -v pip &>/dev/null; then
14
- echo "Installing repofail via pip..."
15
- pip install repofail
16
- else
17
- echo "Error: need pip or pipx. Install Python 3.10+ and run: pip install repofail"
18
- exit 1
19
- fi
20
-
21
- echo "Done. Run: repofail --help"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes