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 ADDED
@@ -0,0 +1,3 @@
1
+ """repofail — Failure-oriented repo introspection."""
2
+
3
+ __version__ = "0.1.0"
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