stackfix 0.1.0__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.
Files changed (44) hide show
  1. cloudgym/__init__.py +3 -0
  2. cloudgym/benchmark/__init__.py +0 -0
  3. cloudgym/benchmark/dataset.py +188 -0
  4. cloudgym/benchmark/evaluator.py +275 -0
  5. cloudgym/cli.py +61 -0
  6. cloudgym/fixer/__init__.py +1 -0
  7. cloudgym/fixer/cli.py +521 -0
  8. cloudgym/fixer/detector.py +81 -0
  9. cloudgym/fixer/formatter.py +55 -0
  10. cloudgym/fixer/lambda_handler.py +126 -0
  11. cloudgym/fixer/repairer.py +237 -0
  12. cloudgym/generator/__init__.py +0 -0
  13. cloudgym/generator/formatter.py +142 -0
  14. cloudgym/generator/pipeline.py +271 -0
  15. cloudgym/inverter/__init__.py +0 -0
  16. cloudgym/inverter/_cf_injectors.py +705 -0
  17. cloudgym/inverter/_cf_utils.py +202 -0
  18. cloudgym/inverter/_hcl_utils.py +182 -0
  19. cloudgym/inverter/_tf_injectors.py +641 -0
  20. cloudgym/inverter/_yaml_cf.py +84 -0
  21. cloudgym/inverter/agentic.py +90 -0
  22. cloudgym/inverter/engine.py +258 -0
  23. cloudgym/inverter/programmatic.py +95 -0
  24. cloudgym/scraper/__init__.py +0 -0
  25. cloudgym/scraper/aws_samples.py +159 -0
  26. cloudgym/scraper/github.py +238 -0
  27. cloudgym/scraper/registry.py +165 -0
  28. cloudgym/scraper/validator.py +116 -0
  29. cloudgym/taxonomy/__init__.py +10 -0
  30. cloudgym/taxonomy/base.py +102 -0
  31. cloudgym/taxonomy/cloudformation.py +258 -0
  32. cloudgym/taxonomy/terraform.py +274 -0
  33. cloudgym/utils/__init__.py +0 -0
  34. cloudgym/utils/config.py +57 -0
  35. cloudgym/utils/ollama.py +66 -0
  36. cloudgym/validator/__init__.py +0 -0
  37. cloudgym/validator/cloudformation.py +55 -0
  38. cloudgym/validator/opentofu.py +103 -0
  39. cloudgym/validator/terraform.py +115 -0
  40. stackfix-0.1.0.dist-info/METADATA +182 -0
  41. stackfix-0.1.0.dist-info/RECORD +44 -0
  42. stackfix-0.1.0.dist-info/WHEEL +4 -0
  43. stackfix-0.1.0.dist-info/entry_points.txt +3 -0
  44. stackfix-0.1.0.dist-info/licenses/LICENSE +21 -0
cloudgym/fixer/cli.py ADDED
@@ -0,0 +1,521 @@
1
+ """stackfix CLI: AI-powered Infrastructure-as-Code repair tool.
2
+
3
+ Usage:
4
+ stackfix check main.tf # validate and show errors
5
+ stackfix repair main.tf # validate, fix, show diff
6
+ stackfix repair main.tf --apply # validate, fix, write in place
7
+ stackfix repair main.tf --apply -o fixed.tf # write to different file
8
+ stackfix repair *.tf # fix multiple files
9
+ cat broken.tf | stackfix repair - # stdin/stdout mode
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ import click
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.table import Table
22
+
23
+ from cloudgym.fixer.detector import IaCFormat, detect_format, validate_file_sync
24
+ from cloudgym.fixer.formatter import colorized_diff, unified_diff, write_repair
25
+
26
+ console = Console()
27
+ stderr_console = Console(stderr=True)
28
+
29
+ # Lazy-loaded repairer (avoids model load until needed)
30
+ _repairer = None
31
+
32
+
33
+ def _get_repairer(backend: str, model: str | None, adapter: str | None):
34
+ """Get or create the repairer instance."""
35
+ global _repairer
36
+ if _repairer is not None:
37
+ return _repairer
38
+
39
+ if backend == "mlx":
40
+ from cloudgym.fixer.repairer import MLXRepairer, DEFAULT_BASE_MODEL, DEFAULT_ADAPTER_PATH
41
+
42
+ _repairer = MLXRepairer(
43
+ base_model=model or DEFAULT_BASE_MODEL,
44
+ adapter_path=adapter or DEFAULT_ADAPTER_PATH,
45
+ )
46
+ elif backend == "gguf":
47
+ from cloudgym.fixer.repairer import GGUFRepairer
48
+
49
+ if not model:
50
+ raise click.ClickException(
51
+ "GGUF backend requires --model pointing to a .gguf file. "
52
+ "Export one with: python scripts/export_gguf.py 0.5b"
53
+ )
54
+ _repairer = GGUFRepairer(model_path=model)
55
+ elif backend == "ollama":
56
+ from cloudgym.fixer.repairer import OllamaRepairer
57
+
58
+ _repairer = OllamaRepairer(model=model or "qwen2.5-coder:3b")
59
+ else:
60
+ raise click.ClickException(f"Unknown backend: {backend}")
61
+
62
+ return _repairer
63
+
64
+
65
+ @click.group()
66
+ @click.version_option(version="0.1.0", prog_name="stackfix")
67
+ def cli():
68
+ """AI-powered Infrastructure-as-Code repair.
69
+
70
+ Validates Terraform, CloudFormation, and OpenTofu configs,
71
+ then uses a fine-tuned local model to fix errors.
72
+ """
73
+
74
+
75
+ @cli.command()
76
+ @click.argument("files", nargs=-1, required=True, type=click.Path())
77
+ @click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
78
+ default=None, help="Override auto-detected format")
79
+ def check(files: tuple[str, ...], fmt: str | None):
80
+ """Validate IaC files and report errors.
81
+
82
+ Examples:
83
+ stackfix check main.tf
84
+ stackfix check *.yaml
85
+ stackfix check --format cloudformation template.yaml
86
+ """
87
+ iac_fmt = IaCFormat(fmt) if fmt else None
88
+ any_errors = False
89
+
90
+ for file_str in files:
91
+ path = Path(file_str)
92
+ if not path.exists():
93
+ console.print(f"[red]File not found: {path}[/red]")
94
+ any_errors = True
95
+ continue
96
+
97
+ detected_fmt, result = validate_file_sync(path, iac_fmt)
98
+
99
+ if result.valid:
100
+ console.print(f"[green]PASS[/green] {path} ({detected_fmt.value})")
101
+ else:
102
+ any_errors = True
103
+ console.print(f"[red]FAIL[/red] {path} ({detected_fmt.value})")
104
+ for err in result.errors:
105
+ console.print(f" [red]error:[/red] {err}")
106
+ for warn in result.warnings:
107
+ console.print(f" [yellow]warning:[/yellow] {warn}")
108
+
109
+ sys.exit(1 if any_errors else 0)
110
+
111
+
112
+ @cli.command()
113
+ @click.argument("files", nargs=-1, required=True, type=click.Path(allow_dash=True))
114
+ @click.option("--apply", is_flag=True, help="Write fixes in place (otherwise just show diff)")
115
+ @click.option("-o", "--output", type=click.Path(), help="Write fixed output to this file")
116
+ @click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
117
+ default=None, help="Override auto-detected format")
118
+ @click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx",
119
+ help="Model backend (default: mlx)")
120
+ @click.option("--model", default=None, help="Override base model")
121
+ @click.option("--adapter", default=None, help="Override adapter path")
122
+ @click.option("--no-verify", is_flag=True, help="Skip post-repair validation")
123
+ @click.option("--diff/--no-diff", default=True, help="Show diff output (default: on)")
124
+ @click.option("--color/--no-color", default=True, help="Colorize diff output")
125
+ def repair(
126
+ files: tuple[str, ...],
127
+ apply: bool,
128
+ output: str | None,
129
+ fmt: str | None,
130
+ backend: str,
131
+ model: str | None,
132
+ adapter: str | None,
133
+ no_verify: bool,
134
+ diff: bool,
135
+ color: bool,
136
+ ):
137
+ """Validate and repair IaC files using a fine-tuned AI model.
138
+
139
+ By default shows a diff of proposed changes. Use --apply to write fixes.
140
+
141
+ Examples:
142
+ stackfix repair main.tf # show diff
143
+ stackfix repair main.tf --apply # fix in place
144
+ stackfix repair main.tf -o fixed.tf # write to new file
145
+ stackfix repair --backend ollama main.tf # use Ollama
146
+ cat broken.tf | stackfix repair - # stdin/stdout
147
+ """
148
+ iac_fmt = IaCFormat(fmt) if fmt else None
149
+ stdin_mode = len(files) == 1 and files[0] == "-"
150
+
151
+ if stdin_mode:
152
+ _repair_stdin(iac_fmt, backend, model, adapter, no_verify, output)
153
+ return
154
+
155
+ any_failed = False
156
+ for file_str in files:
157
+ path = Path(file_str)
158
+ if not path.exists():
159
+ console.print(f"[red]File not found: {path}[/red]")
160
+ any_failed = True
161
+ continue
162
+
163
+ success = _repair_file(
164
+ path, iac_fmt, backend, model, adapter,
165
+ apply, output, no_verify, diff, color,
166
+ )
167
+ if not success:
168
+ any_failed = True
169
+
170
+ sys.exit(1 if any_failed else 0)
171
+
172
+
173
+ def _repair_file(
174
+ path: Path,
175
+ fmt: IaCFormat | None,
176
+ backend: str,
177
+ model: str | None,
178
+ adapter: str | None,
179
+ apply: bool,
180
+ output: str | None,
181
+ no_verify: bool,
182
+ show_diff: bool,
183
+ color: bool,
184
+ ) -> bool:
185
+ """Repair a single file. Returns True if successful."""
186
+ original = path.read_text()
187
+
188
+ # Step 1: Validate
189
+ detected_fmt, result = validate_file_sync(path, fmt)
190
+
191
+ if result.valid:
192
+ console.print(f"[green]PASS[/green] {path} — no errors to fix")
193
+ return True
194
+
195
+ console.print(f"[yellow]FIXING[/yellow] {path} ({detected_fmt.value}) — {len(result.errors)} error(s)")
196
+ for err in result.errors:
197
+ console.print(f" [dim]{err}[/dim]")
198
+
199
+ # Step 2: Repair
200
+ repairer = _get_repairer(backend, model, adapter)
201
+ with console.status("[bold]Generating fix...", spinner="dots"):
202
+ repaired = repairer.repair(original, result.errors)
203
+
204
+ if not repaired or repaired.strip() == original.strip():
205
+ console.print(f" [yellow]No changes generated[/yellow]")
206
+ return False
207
+
208
+ # Step 3: Post-repair validation
209
+ if not no_verify:
210
+ verified = _verify_repair(repaired, detected_fmt, path.name)
211
+ if not verified:
212
+ console.print(f" [red]Fix did not pass validation — not applying[/red]")
213
+ if show_diff:
214
+ _show_diff(original, repaired, path.name, color)
215
+ return False
216
+
217
+ # Step 4: Show diff
218
+ if show_diff:
219
+ _show_diff(original, repaired, path.name, color)
220
+
221
+ # Step 5: Apply
222
+ if apply:
223
+ out_path = Path(output) if output else path
224
+ write_repair(out_path, repaired)
225
+ console.print(f" [green]Fixed and saved to {out_path}[/green]")
226
+ elif output:
227
+ write_repair(Path(output), repaired)
228
+ console.print(f" [green]Fixed output written to {output}[/green]")
229
+ elif not apply:
230
+ console.print(f" [dim]Use --apply to write fix, or -o FILE to save elsewhere[/dim]")
231
+
232
+ return True
233
+
234
+
235
+ def _repair_stdin(
236
+ fmt: IaCFormat | None,
237
+ backend: str,
238
+ model: str | None,
239
+ adapter: str | None,
240
+ no_verify: bool,
241
+ output: str | None,
242
+ ):
243
+ """Repair from stdin, output to stdout or file."""
244
+ original = sys.stdin.read()
245
+
246
+ # Write to temp file for validation
247
+ suffix = ".tf" if fmt != IaCFormat.CLOUDFORMATION else ".yaml"
248
+ with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
249
+ f.write(original)
250
+ tmp_path = Path(f.name)
251
+
252
+ try:
253
+ detected_fmt, result = validate_file_sync(tmp_path, fmt)
254
+ finally:
255
+ tmp_path.unlink(missing_ok=True)
256
+
257
+ if result.valid:
258
+ # Pass through unchanged
259
+ sys.stdout.write(original)
260
+ return
261
+
262
+ stderr_console.print(f"[yellow]Fixing stdin[/yellow] ({detected_fmt.value}) — {len(result.errors)} error(s)")
263
+
264
+ repairer = _get_repairer(backend, model, adapter)
265
+ repaired = repairer.repair(original, result.errors)
266
+
267
+ if output:
268
+ write_repair(Path(output), repaired)
269
+ stderr_console.print(f"[green]Written to {output}[/green]")
270
+ else:
271
+ sys.stdout.write(repaired)
272
+
273
+
274
+ def _verify_repair(repaired: str, fmt: IaCFormat, filename: str) -> bool:
275
+ """Validate repaired content to confirm the fix works."""
276
+ suffix = ".tf" if fmt != IaCFormat.CLOUDFORMATION else ".yaml"
277
+ with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as f:
278
+ f.write(repaired)
279
+ tmp_path = Path(f.name)
280
+
281
+ try:
282
+ _, result = validate_file_sync(tmp_path, fmt)
283
+ if result.valid:
284
+ console.print(f" [green]Verified: fix passes validation[/green]")
285
+ else:
286
+ console.print(f" [red]Verification failed: {len(result.errors)} error(s) remain[/red]")
287
+ for err in result.errors:
288
+ console.print(f" [dim]{err}[/dim]")
289
+ return result.valid
290
+ finally:
291
+ tmp_path.unlink(missing_ok=True)
292
+
293
+
294
+ def _show_diff(original: str, repaired: str, filename: str, color: bool):
295
+ """Display diff between original and repaired."""
296
+ if color:
297
+ diff_text = colorized_diff(original, repaired, filename)
298
+ if diff_text:
299
+ console.print(Panel(diff_text, title="Proposed Fix", border_style="blue"))
300
+ else:
301
+ diff_text = unified_diff(original, repaired, filename)
302
+ if diff_text:
303
+ click.echo(diff_text)
304
+
305
+
306
+ @cli.command(name="pre-commit")
307
+ @click.argument("files", nargs=-1, type=click.Path(exists=True))
308
+ @click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
309
+ @click.option("--model", default=None)
310
+ @click.option("--adapter", default=None)
311
+ def pre_commit(files: tuple[str, ...], backend: str, model: str | None, adapter: str | None):
312
+ """Pre-commit hook: validate and auto-fix staged IaC files.
313
+
314
+ Exits 0 if all files are valid (or were successfully fixed).
315
+ Exits 1 if any file has unfixable errors.
316
+
317
+ Usage in .pre-commit-config.yaml:
318
+ - repo: local
319
+ hooks:
320
+ - id: stackfix
321
+ name: stackfix
322
+ entry: stackfix pre-commit
323
+ language: python
324
+ types_or: [terraform, yaml]
325
+ """
326
+ if not files:
327
+ sys.exit(0)
328
+
329
+ iac_files = [
330
+ Path(f) for f in files
331
+ if f.endswith((".tf", ".yaml", ".yml", ".json"))
332
+ ]
333
+
334
+ if not iac_files:
335
+ sys.exit(0)
336
+
337
+ any_failed = False
338
+ any_fixed = False
339
+
340
+ for path in iac_files:
341
+ detected_fmt, result = validate_file_sync(path)
342
+
343
+ if result.valid:
344
+ continue
345
+
346
+ console.print(f"[yellow]stackfix:[/yellow] {path} has {len(result.errors)} error(s), attempting fix...")
347
+
348
+ repairer = _get_repairer(backend, model, adapter)
349
+ original = path.read_text()
350
+ repaired = repairer.repair(original, result.errors)
351
+
352
+ if not repaired or repaired.strip() == original.strip():
353
+ console.print(f" [red]Could not fix {path}[/red]")
354
+ any_failed = True
355
+ continue
356
+
357
+ # Verify the fix
358
+ verified = _verify_repair(repaired, detected_fmt, path.name)
359
+ if verified:
360
+ write_repair(path, repaired)
361
+ console.print(f" [green]Fixed {path}[/green]")
362
+ any_fixed = True
363
+ else:
364
+ console.print(f" [red]Fix for {path} did not pass validation[/red]")
365
+ any_failed = True
366
+
367
+ if any_fixed:
368
+ console.print("[yellow]stackfix: Files were modified. Please re-stage and commit.[/yellow]")
369
+ sys.exit(1) # Signal to pre-commit that files changed
370
+
371
+ sys.exit(1 if any_failed else 0)
372
+
373
+
374
+ @cli.command()
375
+ @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True))
376
+ @click.option("--format", "fmt", type=click.Choice(["terraform", "cloudformation", "opentofu"]),
377
+ default=None, help="Override auto-detected format")
378
+ @click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
379
+ @click.option("--model", default=None)
380
+ @click.option("--adapter", default=None)
381
+ def discuss(files: tuple[str, ...], fmt: str | None, backend: str, model: str | None, adapter: str | None):
382
+ """Explain IaC errors in plain language with fix guidance.
383
+
384
+ Uses the fine-tuned model to analyze errors, explain what's wrong,
385
+ why it matters, and how to fix it. Great for learning and code review.
386
+
387
+ Examples:
388
+ stackfix discuss main.tf
389
+ stackfix discuss --backend ollama template.yaml
390
+ """
391
+ iac_fmt = IaCFormat(fmt) if fmt else None
392
+
393
+ for file_str in files:
394
+ path = Path(file_str)
395
+ config = path.read_text()
396
+ detected_fmt, result = validate_file_sync(path, iac_fmt)
397
+
398
+ if result.valid and not result.warnings:
399
+ console.print(f"[green]PASS[/green] {path} — no issues to discuss")
400
+ continue
401
+
402
+ errors = result.errors + result.warnings
403
+ console.print(f"\n[bold]{path}[/bold] ({detected_fmt.value})")
404
+ console.print(f"[dim]{len(result.errors)} error(s), {len(result.warnings)} warning(s)[/dim]\n")
405
+
406
+ repairer = _get_repairer(backend, model, adapter)
407
+ with console.status("[bold]Analyzing...", spinner="dots"):
408
+ explanation = repairer.discuss(config, errors)
409
+
410
+ console.print(Panel(explanation, title="Analysis", border_style="blue"))
411
+
412
+
413
+ @cli.command(name="git-diff")
414
+ @click.option("--backend", type=click.Choice(["mlx", "gguf", "ollama"]), default="mlx")
415
+ @click.option("--model", default=None)
416
+ @click.option("--adapter", default=None)
417
+ @click.option("--apply", is_flag=True, help="Auto-fix and stage repaired files")
418
+ def git_diff(backend: str, model: str | None, adapter: str | None, apply: bool):
419
+ """Check and repair IaC files changed in the current git diff.
420
+
421
+ Scans staged and unstaged IaC file changes, validates them,
422
+ and offers AI-powered fixes. Ideal for CI or local git workflows.
423
+
424
+ Examples:
425
+ stackfix git-diff # check changed IaC files
426
+ stackfix git-diff --apply # fix and re-stage
427
+ """
428
+ import subprocess
429
+
430
+ # Get list of changed IaC files (staged + unstaged)
431
+ try:
432
+ result = subprocess.run(
433
+ ["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD"],
434
+ capture_output=True, text=True, check=True,
435
+ )
436
+ changed = result.stdout.strip().splitlines()
437
+ except subprocess.CalledProcessError:
438
+ # No HEAD yet (initial commit) — check staged files
439
+ try:
440
+ result = subprocess.run(
441
+ ["git", "diff", "--name-only", "--cached"],
442
+ capture_output=True, text=True, check=True,
443
+ )
444
+ changed = result.stdout.strip().splitlines()
445
+ except subprocess.CalledProcessError:
446
+ console.print("[red]Not a git repository or git not available[/red]")
447
+ sys.exit(1)
448
+
449
+ # Also include unstaged changes
450
+ try:
451
+ result = subprocess.run(
452
+ ["git", "diff", "--name-only", "--diff-filter=ACMR"],
453
+ capture_output=True, text=True, check=True,
454
+ )
455
+ changed += result.stdout.strip().splitlines()
456
+ except subprocess.CalledProcessError:
457
+ pass
458
+
459
+ # Deduplicate and filter to IaC files
460
+ iac_files = sorted(set(
461
+ f for f in changed
462
+ if f.endswith((".tf", ".yaml", ".yml", ".json"))
463
+ and Path(f).exists()
464
+ ))
465
+
466
+ if not iac_files:
467
+ console.print("[green]No changed IaC files to check[/green]")
468
+ sys.exit(0)
469
+
470
+ console.print(f"[bold]Checking {len(iac_files)} changed IaC file(s)...[/bold]")
471
+
472
+ any_errors = False
473
+ any_fixed = False
474
+
475
+ for file_str in iac_files:
476
+ path = Path(file_str)
477
+ detected_fmt, result = validate_file_sync(path)
478
+
479
+ if result.valid:
480
+ console.print(f" [green]PASS[/green] {path}")
481
+ continue
482
+
483
+ console.print(f" [red]FAIL[/red] {path} — {len(result.errors)} error(s)")
484
+ for err in result.errors:
485
+ console.print(f" [dim]{err}[/dim]")
486
+
487
+ if apply:
488
+ repairer = _get_repairer(backend, model, adapter)
489
+ original = path.read_text()
490
+ with console.status(f" Fixing {path}...", spinner="dots"):
491
+ repaired = repairer.repair(original, result.errors)
492
+
493
+ if repaired and repaired.strip() != original.strip():
494
+ verified = _verify_repair(repaired, detected_fmt, path.name)
495
+ if verified:
496
+ write_repair(path, repaired)
497
+ subprocess.run(["git", "add", str(path)], check=True)
498
+ console.print(f" [green]Fixed and staged[/green]")
499
+ any_fixed = True
500
+ else:
501
+ any_errors = True
502
+ else:
503
+ any_errors = True
504
+ else:
505
+ any_errors = True
506
+
507
+ if any_fixed:
508
+ console.print(f"\n[green]{sum(1 for _ in iac_files)} file(s) checked, fixes applied and staged[/green]")
509
+ elif any_errors:
510
+ console.print(f"\n[yellow]Use --apply to auto-fix errors[/yellow]")
511
+
512
+ sys.exit(1 if any_errors and not apply else 0)
513
+
514
+
515
+ def main():
516
+ """Entry point."""
517
+ cli()
518
+
519
+
520
+ if __name__ == "__main__":
521
+ main()
@@ -0,0 +1,81 @@
1
+ """Detect IaC format and validate configurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from pathlib import Path
9
+
10
+ from cloudgym.validator.terraform import ValidationResult
11
+
12
+
13
+ class IaCFormat(Enum):
14
+ TERRAFORM = "terraform"
15
+ CLOUDFORMATION = "cloudformation"
16
+ OPENTOFU = "opentofu"
17
+
18
+
19
+ @dataclass
20
+ class DetectionResult:
21
+ """Result of detecting the IaC format of a file."""
22
+
23
+ format: IaCFormat
24
+ confidence: float
25
+ path: Path
26
+
27
+
28
+ def detect_format(path: Path) -> DetectionResult:
29
+ """Auto-detect whether a file is Terraform, CloudFormation, or OpenTofu."""
30
+ suffix = path.suffix.lower()
31
+ name = path.name.lower()
32
+ content = path.read_text(errors="replace") if path.is_file() else ""
33
+
34
+ # Terraform: .tf files
35
+ if suffix == ".tf":
36
+ return DetectionResult(IaCFormat.TERRAFORM, 1.0, path)
37
+
38
+ # CloudFormation: .yaml/.yml/.json with AWSTemplateFormatVersion
39
+ if suffix in (".yaml", ".yml", ".json"):
40
+ if "AWSTemplateFormatVersion" in content:
41
+ return DetectionResult(IaCFormat.CLOUDFORMATION, 1.0, path)
42
+ # Check for common CF patterns
43
+ if "Type: AWS::" in content or '"Type": "AWS::' in content:
44
+ return DetectionResult(IaCFormat.CLOUDFORMATION, 0.9, path)
45
+
46
+ # HCL files that aren't .tf (e.g., .hcl)
47
+ if suffix == ".hcl":
48
+ return DetectionResult(IaCFormat.TERRAFORM, 0.8, path)
49
+
50
+ # Check content patterns as fallback
51
+ if "resource " in content and "{" in content and "provider " in content:
52
+ return DetectionResult(IaCFormat.TERRAFORM, 0.6, path)
53
+ if "AWSTemplateFormatVersion" in content:
54
+ return DetectionResult(IaCFormat.CLOUDFORMATION, 0.9, path)
55
+
56
+ # Default to terraform for unknown
57
+ return DetectionResult(IaCFormat.TERRAFORM, 0.3, path)
58
+
59
+
60
+ async def validate_file(path: Path, fmt: IaCFormat | None = None) -> tuple[IaCFormat, ValidationResult]:
61
+ """Validate a file, auto-detecting format if not specified."""
62
+ if fmt is None:
63
+ detection = detect_format(path)
64
+ fmt = detection.format
65
+
66
+ if fmt == IaCFormat.CLOUDFORMATION:
67
+ from cloudgym.validator import cloudformation
68
+ result = await cloudformation.validate(path)
69
+ elif fmt == IaCFormat.OPENTOFU:
70
+ from cloudgym.validator import opentofu
71
+ result = await opentofu.validate(path)
72
+ else:
73
+ from cloudgym.validator import terraform
74
+ result = await terraform.validate(path)
75
+
76
+ return fmt, result
77
+
78
+
79
+ def validate_file_sync(path: Path, fmt: IaCFormat | None = None) -> tuple[IaCFormat, ValidationResult]:
80
+ """Synchronous wrapper for validate_file."""
81
+ return asyncio.run(validate_file(path, fmt))
@@ -0,0 +1,55 @@
1
+ """Diff and output formatting for repair results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from pathlib import Path
7
+
8
+
9
+ def unified_diff(original: str, repaired: str, filename: str = "config") -> str:
10
+ """Generate a unified diff between original and repaired config."""
11
+ original_lines = original.splitlines(keepends=True)
12
+ repaired_lines = repaired.splitlines(keepends=True)
13
+
14
+ diff = difflib.unified_diff(
15
+ original_lines,
16
+ repaired_lines,
17
+ fromfile=f"a/{filename}",
18
+ tofile=f"b/{filename}",
19
+ lineterm="",
20
+ )
21
+ return "".join(diff)
22
+
23
+
24
+ def colorized_diff(original: str, repaired: str, filename: str = "config") -> str:
25
+ """Generate a colorized diff using Rich markup."""
26
+ original_lines = original.splitlines(keepends=True)
27
+ repaired_lines = repaired.splitlines(keepends=True)
28
+
29
+ diff = difflib.unified_diff(
30
+ original_lines,
31
+ repaired_lines,
32
+ fromfile=f"a/{filename}",
33
+ tofile=f"b/{filename}",
34
+ )
35
+
36
+ colored_lines = []
37
+ for line in diff:
38
+ line = line.rstrip("\n")
39
+ if line.startswith("+++") or line.startswith("---"):
40
+ colored_lines.append(f"[bold]{line}[/bold]")
41
+ elif line.startswith("@@"):
42
+ colored_lines.append(f"[cyan]{line}[/cyan]")
43
+ elif line.startswith("+"):
44
+ colored_lines.append(f"[green]{line}[/green]")
45
+ elif line.startswith("-"):
46
+ colored_lines.append(f"[red]{line}[/red]")
47
+ else:
48
+ colored_lines.append(line)
49
+
50
+ return "\n".join(colored_lines)
51
+
52
+
53
+ def write_repair(path: Path, content: str) -> None:
54
+ """Write repaired content to file."""
55
+ path.write_text(content)