heurix 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.
- heurix/__init__.py +5 -0
- heurix/cli.py +249 -0
- heurix/core/context.py +75 -0
- heurix/core/drift.py +113 -0
- heurix/core/indexer.py +126 -0
- heurix/core/merger.py +123 -0
- heurix/core/validator.py +101 -0
- heurix/detectors/__init__.py +47 -0
- heurix/detectors/base.py +36 -0
- heurix/detectors/cpp_detector.py +60 -0
- heurix/detectors/docker_detector.py +125 -0
- heurix/detectors/env_detector.py +78 -0
- heurix/detectors/go_detector.py +61 -0
- heurix/detectors/node_detector.py +221 -0
- heurix/detectors/python_detector.py +200 -0
- heurix/detectors/rust_detector.py +79 -0
- heurix/synthesizers/__init__.py +32 -0
- heurix/synthesizers/base.py +29 -0
- heurix/synthesizers/github_actions.py +228 -0
- heurix/synthesizers/kubernetes.py +154 -0
- heurix/synthesizers/maintenance.py +89 -0
- heurix/ui/console.py +66 -0
- heurix/ui/differ.py +56 -0
- heurix/ui/prompts.py +44 -0
- heurix-0.1.0.dist-info/METADATA +209 -0
- heurix-0.1.0.dist-info/RECORD +29 -0
- heurix-0.1.0.dist-info/WHEEL +5 -0
- heurix-0.1.0.dist-info/entry_points.txt +3 -0
- heurix-0.1.0.dist-info/top_level.txt +1 -0
heurix/__init__.py
ADDED
heurix/cli.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heurix CLI (hx): Deterministic Codebase Scanner & YAML Synthesizer.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
|
|
15
|
+
from heurix import __version__
|
|
16
|
+
from heurix.core.drift import DriftDetector
|
|
17
|
+
from heurix.core.merger import SmartMerger
|
|
18
|
+
from heurix.core.validator import PreflightValidator
|
|
19
|
+
from heurix.detectors import analyze_repository
|
|
20
|
+
from heurix.synthesizers import synthesize_all
|
|
21
|
+
from heurix.ui.console import print_banner, print_explain_tree, print_summary_table
|
|
22
|
+
from heurix.ui.differ import show_diff
|
|
23
|
+
from heurix.ui.prompts import prompt_conflict_resolution
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="hx",
|
|
27
|
+
help="Deterministic Codebase Scanner & Automated YAML Synthesizer.",
|
|
28
|
+
no_args_is_help=False,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version_callback(value: bool):
|
|
35
|
+
if value:
|
|
36
|
+
console.print(f"[bold cyan]Heurix (hx)[/bold cyan] version [green]{__version__}[/green]")
|
|
37
|
+
raise typer.Exit()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback(invoke_without_command=True)
|
|
41
|
+
def main(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
version: Optional[bool] = typer.Option(
|
|
44
|
+
None, "--version", "-v", callback=version_callback, is_eager=True, help="Show version."
|
|
45
|
+
),
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Heurix CLI (alias: hx). Run 'hx generate' to scan and synthesize YAML configs.
|
|
49
|
+
"""
|
|
50
|
+
if ctx.invoked_subcommand is None:
|
|
51
|
+
# Default behavior when running 'hx' without subcommand
|
|
52
|
+
generate(
|
|
53
|
+
path=".",
|
|
54
|
+
explain=True,
|
|
55
|
+
dry_run=False,
|
|
56
|
+
diff=False,
|
|
57
|
+
yes=False,
|
|
58
|
+
k8s=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def generate(
|
|
64
|
+
path: str = typer.Option(".", "--path", "-p", help="Root directory of the repository to scan."),
|
|
65
|
+
explain: bool = typer.Option(True, "--explain/--no-explain", help="Show step-by-step heuristic reasoning log."),
|
|
66
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview synthesized files without writing to disk."),
|
|
67
|
+
diff: bool = typer.Option(False, "--diff", "-d", help="Show terminal diff of proposed changes."),
|
|
68
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Non-interactive mode (auto-apply without prompts)."),
|
|
69
|
+
k8s: bool = typer.Option(True, "--k8s/--no-k8s", help="Synthesize Kubernetes manifests if containerized."),
|
|
70
|
+
):
|
|
71
|
+
"""
|
|
72
|
+
Scan codebase and synthesize deterministic CI/CD and infrastructure YAML files.
|
|
73
|
+
"""
|
|
74
|
+
print_banner()
|
|
75
|
+
|
|
76
|
+
repo_path = Path(path).resolve()
|
|
77
|
+
if not repo_path.exists() or not repo_path.is_dir():
|
|
78
|
+
console.print(f"[bold red]Error:[/bold red] Directory '{path}' does not exist.")
|
|
79
|
+
raise typer.Exit(code=1)
|
|
80
|
+
|
|
81
|
+
# 1. Scan and analyze repository into ProjectContext IR
|
|
82
|
+
with console.status("[bold cyan]Scanning repository and running heuristic detectors...[/bold cyan]"):
|
|
83
|
+
context = analyze_repository(repo_path)
|
|
84
|
+
|
|
85
|
+
# 2. Render explain reasoning tree if requested
|
|
86
|
+
if explain:
|
|
87
|
+
print_explain_tree(context)
|
|
88
|
+
|
|
89
|
+
# 3. Synthesize YAML outputs
|
|
90
|
+
synthesized = synthesize_all(context, include_k8s=k8s)
|
|
91
|
+
if not synthesized:
|
|
92
|
+
console.print("[yellow]No relevant languages or container configs detected to synthesize.[/yellow]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# 4. Pre-Flight Validation
|
|
96
|
+
validator = PreflightValidator()
|
|
97
|
+
all_valid = True
|
|
98
|
+
for rel_path, content in synthesized.items():
|
|
99
|
+
is_valid, errors = validator.validate(rel_path, content)
|
|
100
|
+
if not is_valid:
|
|
101
|
+
all_valid = False
|
|
102
|
+
console.print(f"[bold red]Validation failed for '{rel_path}':[/bold red]")
|
|
103
|
+
for err in errors:
|
|
104
|
+
console.print(f" [red]✖ {err}[/red]")
|
|
105
|
+
|
|
106
|
+
if not all_valid:
|
|
107
|
+
console.print("[bold red]Pre-flight validation failed. Aborting write to protect repository.[/bold red]")
|
|
108
|
+
raise typer.Exit(code=1)
|
|
109
|
+
|
|
110
|
+
# 5. Diff preview or smart merge
|
|
111
|
+
merger = SmartMerger()
|
|
112
|
+
files_to_write: dict[str, str] = {}
|
|
113
|
+
|
|
114
|
+
for rel_path, new_content in synthesized.items():
|
|
115
|
+
target_file = repo_path / rel_path
|
|
116
|
+
if target_file.exists():
|
|
117
|
+
existing_content = target_file.read_text(encoding="utf-8", errors="replace")
|
|
118
|
+
merged_content = merger.merge_yaml(existing_content, new_content)
|
|
119
|
+
|
|
120
|
+
if diff:
|
|
121
|
+
show_diff(rel_path, existing_content, merged_content)
|
|
122
|
+
|
|
123
|
+
if not dry_run and not yes:
|
|
124
|
+
choice = prompt_conflict_resolution(rel_path, non_interactive=yes)
|
|
125
|
+
if choice == "skip":
|
|
126
|
+
console.print(f"[dim]Skipped {rel_path}[/dim]")
|
|
127
|
+
continue
|
|
128
|
+
elif choice == "diff":
|
|
129
|
+
show_diff(rel_path, existing_content, merged_content)
|
|
130
|
+
elif choice == "backup":
|
|
131
|
+
backup_path = target_file.with_suffix(target_file.suffix + ".bak")
|
|
132
|
+
shutil.copyfile(target_file, backup_path)
|
|
133
|
+
console.print(f"[green]Backup created at {backup_path.name}[/green]")
|
|
134
|
+
|
|
135
|
+
files_to_write[rel_path] = merged_content
|
|
136
|
+
else:
|
|
137
|
+
if diff:
|
|
138
|
+
show_diff(rel_path, "", new_content)
|
|
139
|
+
files_to_write[rel_path] = new_content
|
|
140
|
+
|
|
141
|
+
# 6. Summary Table
|
|
142
|
+
print_summary_table(files_to_write, dry_run=dry_run)
|
|
143
|
+
|
|
144
|
+
# 7. Write to disk if not dry_run
|
|
145
|
+
if dry_run:
|
|
146
|
+
console.print("\n[yellow]Dry run completed. No files were written to disk.[/yellow]")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
for rel_path, content in files_to_write.items():
|
|
150
|
+
dest = repo_path / rel_path
|
|
151
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
dest.write_text(content, encoding="utf-8")
|
|
153
|
+
console.print(f"[bold green]✔ Written:[/bold green] [cyan]{rel_path}[/cyan]")
|
|
154
|
+
|
|
155
|
+
console.print(
|
|
156
|
+
Panel.fit(
|
|
157
|
+
f"[bold green]Successfully synthesized {len(files_to_write)} file(s)![/bold green]\n"
|
|
158
|
+
"[dim]Deterministic, schema-validated, and non-destructively merged.[/dim]",
|
|
159
|
+
border_style="green",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def diff(
|
|
166
|
+
path: str = typer.Option(".", "--path", "-p", help="Root directory of repository."),
|
|
167
|
+
k8s: bool = typer.Option(True, "--k8s/--no-k8s", help="Include Kubernetes manifests."),
|
|
168
|
+
):
|
|
169
|
+
"""
|
|
170
|
+
Compare current repository state with synthesized configs without writing changes.
|
|
171
|
+
"""
|
|
172
|
+
generate(path=path, explain=False, dry_run=True, diff=True, yes=True, k8s=k8s)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def check(
|
|
177
|
+
path: str = typer.Option(".", "--path", "-p", help="Root directory of repository."),
|
|
178
|
+
):
|
|
179
|
+
"""
|
|
180
|
+
Continuous drift detection: verifies whether existing YAML configs match codebase requirements.
|
|
181
|
+
Exits with code 1 if drift is detected (ideal for CI and git pre-commit hooks).
|
|
182
|
+
"""
|
|
183
|
+
repo_path = Path(path).resolve()
|
|
184
|
+
context = analyze_repository(repo_path)
|
|
185
|
+
synthesized = synthesize_all(context, include_k8s=False)
|
|
186
|
+
|
|
187
|
+
detector = DriftDetector(repo_path)
|
|
188
|
+
findings = detector.check_drift(context, synthesized)
|
|
189
|
+
|
|
190
|
+
if not findings:
|
|
191
|
+
console.print("[bold green]✔ Zero Drift Detected:[/bold green] CI/CD workflows are completely synchronized with the codebase!")
|
|
192
|
+
raise typer.Exit(code=0)
|
|
193
|
+
|
|
194
|
+
console.print(f"[bold yellow]⚠️ Drift Detected ({len(findings)} issue(s)):[/bold yellow]\n")
|
|
195
|
+
for f in findings:
|
|
196
|
+
color = "red" if f.severity == "ERROR" else "yellow"
|
|
197
|
+
console.print(f"[{color}][bold]{f.file_path}:[/bold] {f.issue}[/{color}]")
|
|
198
|
+
console.print(f" [dim]↳ Recommendation: {f.recommendation}[/dim]\n")
|
|
199
|
+
|
|
200
|
+
raise typer.Exit(code=1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def scan(
|
|
205
|
+
path: str = typer.Option(".", "--path", "-p", help="Root directory of repository."),
|
|
206
|
+
):
|
|
207
|
+
"""
|
|
208
|
+
Scan codebase and output the strongly-typed ProjectContext Intermediate Representation (IR).
|
|
209
|
+
"""
|
|
210
|
+
repo_path = Path(path).resolve()
|
|
211
|
+
context = analyze_repository(repo_path)
|
|
212
|
+
console.print_json(context.model_dump_json(indent=2))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def init(
|
|
217
|
+
path: str = typer.Option(".", "--path", "-p", help="Root directory of repository."),
|
|
218
|
+
):
|
|
219
|
+
"""
|
|
220
|
+
Initialize a minimal .heurix.toml policy configuration for power users.
|
|
221
|
+
"""
|
|
222
|
+
repo_path = Path(path).resolve()
|
|
223
|
+
config_file = repo_path / ".heurix.toml"
|
|
224
|
+
if config_file.exists():
|
|
225
|
+
console.print("[yellow].heurix.toml already exists.[/yellow]")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
content = """# Heurix Configuration Policy (.heurix.toml)
|
|
229
|
+
# Optional configuration overrides for power users.
|
|
230
|
+
|
|
231
|
+
[engine]
|
|
232
|
+
runner = "ubuntu-latest"
|
|
233
|
+
non_destructive = true
|
|
234
|
+
|
|
235
|
+
[ci]
|
|
236
|
+
include_dependabot = true
|
|
237
|
+
include_k8s = true
|
|
238
|
+
|
|
239
|
+
[overrides]
|
|
240
|
+
# Force specific runtime versions if desired:
|
|
241
|
+
# python = "3.12"
|
|
242
|
+
# node = "20.x"
|
|
243
|
+
"""
|
|
244
|
+
config_file.write_text(content, encoding="utf-8")
|
|
245
|
+
console.print("[bold green]✔ Initialized .heurix.toml policy file.[/bold green]")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
app()
|
heurix/core/context.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intermediate Representation (IR) Models for Heurix.
|
|
3
|
+
Generators never read the filesystem directly; they only read ProjectContext.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceRequirement(BaseModel):
|
|
13
|
+
name: str # e.g. "postgres", "redis", "mongodb"
|
|
14
|
+
image: str # e.g. "postgres:16-alpine"
|
|
15
|
+
ports: List[int] = Field(default_factory=list) # e.g. [5432]
|
|
16
|
+
env: Dict[str, str] = Field(default_factory=dict)
|
|
17
|
+
source_file: str = "" # e.g. "docker-compose.yml" or "pyproject.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LanguageContext(BaseModel):
|
|
21
|
+
language: str # "python", "node", "rust", "go", "cpp"
|
|
22
|
+
runtime_version: str # "3.12", "20.x", "1.78"
|
|
23
|
+
version_source: str # ".python-version", "pyproject.toml", ".nvmrc"
|
|
24
|
+
package_manager: str # "uv", "poetry", "npm", "pnpm", "yarn", "bun", "cargo", "gomod", "cmake"
|
|
25
|
+
install_cmd: str
|
|
26
|
+
build_cmd: Optional[str] = None
|
|
27
|
+
test_cmd: Optional[str] = None
|
|
28
|
+
lint_cmd: Optional[str] = None
|
|
29
|
+
working_dir: str = "."
|
|
30
|
+
framework: Optional[str] = None
|
|
31
|
+
confidence: float = 1.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContainerContext(BaseModel):
|
|
35
|
+
dockerfile_path: str
|
|
36
|
+
exposed_ports: List[int] = Field(default_factory=list)
|
|
37
|
+
base_image: str = ""
|
|
38
|
+
has_healthcheck: bool = False
|
|
39
|
+
build_target: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MonorepoPackage(BaseModel):
|
|
43
|
+
name: str
|
|
44
|
+
path: str
|
|
45
|
+
language: str
|
|
46
|
+
package_manager: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProjectContext(BaseModel):
|
|
50
|
+
repo_name: str
|
|
51
|
+
repo_root: str
|
|
52
|
+
is_monorepo: bool = False
|
|
53
|
+
monorepo_packages: List[MonorepoPackage] = Field(default_factory=list)
|
|
54
|
+
languages: List[LanguageContext] = Field(default_factory=list)
|
|
55
|
+
services: List[ServiceRequirement] = Field(default_factory=list)
|
|
56
|
+
containers: List[ContainerContext] = Field(default_factory=list)
|
|
57
|
+
required_secrets: List[str] = Field(default_factory=list)
|
|
58
|
+
env_vars: Dict[str, str] = Field(default_factory=dict)
|
|
59
|
+
explain_log: List[str] = Field(default_factory=list)
|
|
60
|
+
confidence: float = 1.0
|
|
61
|
+
|
|
62
|
+
def add_explain(self, step: str) -> None:
|
|
63
|
+
self.explain_log.append(step)
|
|
64
|
+
|
|
65
|
+
def has_language(self, name: str) -> bool:
|
|
66
|
+
return any(lang.language.lower() == name.lower() for lang in self.languages)
|
|
67
|
+
|
|
68
|
+
def get_language(self, name: str) -> Optional[LanguageContext]:
|
|
69
|
+
for lang in self.languages:
|
|
70
|
+
if lang.language.lower() == name.lower():
|
|
71
|
+
return lang
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def get_primary_language(self) -> Optional[LanguageContext]:
|
|
75
|
+
return self.languages[0] if self.languages else None
|
heurix/core/drift.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drift Detection Engine for Heurix (`hx check`).
|
|
3
|
+
Compares codebase fingerprint against existing YAML infrastructure configurations.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, NamedTuple
|
|
10
|
+
from ruamel.yaml import YAML
|
|
11
|
+
from heurix.core.context import ProjectContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DriftFinding(NamedTuple):
|
|
15
|
+
file_path: str
|
|
16
|
+
issue: str
|
|
17
|
+
severity: str # "WARNING", "ERROR", "INFO"
|
|
18
|
+
recommendation: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DriftDetector:
|
|
22
|
+
"""
|
|
23
|
+
Detects configuration drift between the codebase IR and existing YAML files.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, repo_root: str | Path):
|
|
27
|
+
self.root = Path(repo_root).resolve()
|
|
28
|
+
self.yaml = YAML(typ="safe")
|
|
29
|
+
|
|
30
|
+
def check_drift(self, context: ProjectContext, synthesized_files: dict[str, str]) -> List[DriftFinding]:
|
|
31
|
+
findings: List[DriftFinding] = []
|
|
32
|
+
|
|
33
|
+
for rel_path, expected_content in synthesized_files.items():
|
|
34
|
+
file_path = self.root / rel_path
|
|
35
|
+
|
|
36
|
+
if not file_path.exists():
|
|
37
|
+
findings.append(
|
|
38
|
+
DriftFinding(
|
|
39
|
+
file_path=rel_path,
|
|
40
|
+
issue="File does not exist in repository.",
|
|
41
|
+
severity="WARNING",
|
|
42
|
+
recommendation=f"Run 'hx generate' to synthesize {rel_path}.",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
existing_text = file_path.read_text(encoding="utf-8", errors="replace")
|
|
49
|
+
existing_doc = self.yaml.load(existing_text)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
findings.append(
|
|
52
|
+
DriftFinding(
|
|
53
|
+
file_path=rel_path,
|
|
54
|
+
issue=f"Existing YAML file is malformed: {e}",
|
|
55
|
+
severity="ERROR",
|
|
56
|
+
recommendation="Fix YAML syntax or run 'hx generate --force'.",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Check workflow-specific drift
|
|
62
|
+
if ".github/workflows" in rel_path and isinstance(existing_doc, dict):
|
|
63
|
+
test_job = existing_doc.get("jobs", {}).get("test", {})
|
|
64
|
+
existing_services = test_job.get("services", {})
|
|
65
|
+
|
|
66
|
+
# Check if detected backing services are missing in workflow
|
|
67
|
+
for svc in context.services:
|
|
68
|
+
svc_name = svc.name.lower()
|
|
69
|
+
if svc_name not in [s.lower() for s in existing_services.keys()]:
|
|
70
|
+
findings.append(
|
|
71
|
+
DriftFinding(
|
|
72
|
+
file_path=rel_path,
|
|
73
|
+
issue=f"Service '{svc.name}' is required by codebase dependencies ({svc.source_file}) but missing from workflow.",
|
|
74
|
+
severity="WARNING",
|
|
75
|
+
recommendation=f"Run 'hx generate' to inject service '{svc.name}'.",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Check runtime version drift
|
|
80
|
+
existing_str = str(existing_doc)
|
|
81
|
+
for lang in context.languages:
|
|
82
|
+
if lang.runtime_version not in existing_str and lang.runtime_version != "latest":
|
|
83
|
+
findings.append(
|
|
84
|
+
DriftFinding(
|
|
85
|
+
file_path=rel_path,
|
|
86
|
+
issue=f"Runtime version mismatch: codebase specifies {lang.language} {lang.runtime_version} ({lang.version_source}), not found in workflow.",
|
|
87
|
+
severity="WARNING",
|
|
88
|
+
recommendation=f"Run 'hx generate' to update {lang.language} version.",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Check package manager drift
|
|
93
|
+
for lang in context.languages:
|
|
94
|
+
if lang.package_manager == "uv" and "setup-uv" not in existing_str:
|
|
95
|
+
findings.append(
|
|
96
|
+
DriftFinding(
|
|
97
|
+
file_path=rel_path,
|
|
98
|
+
issue="uv package manager detected in codebase (uv.lock), but workflow does not use astral-sh/setup-uv.",
|
|
99
|
+
severity="WARNING",
|
|
100
|
+
recommendation="Run 'hx generate' to optimize workflow for uv.",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
elif lang.package_manager == "pnpm" and "pnpm" not in existing_str:
|
|
104
|
+
findings.append(
|
|
105
|
+
DriftFinding(
|
|
106
|
+
file_path=rel_path,
|
|
107
|
+
issue="pnpm package manager detected in codebase, but workflow does not use pnpm.",
|
|
108
|
+
severity="WARNING",
|
|
109
|
+
recommendation="Run 'hx generate' to update workflow to pnpm.",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return findings
|
heurix/core/indexer.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast, .gitignore-aware repository indexer and file contents cache.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Set
|
|
10
|
+
import pathspec
|
|
11
|
+
|
|
12
|
+
# Standard directories and patterns to ignore across all repos
|
|
13
|
+
DEFAULT_IGNORES = [
|
|
14
|
+
".git",
|
|
15
|
+
".git/**",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"node_modules/**",
|
|
18
|
+
".venv",
|
|
19
|
+
".venv/**",
|
|
20
|
+
"venv",
|
|
21
|
+
"venv/**",
|
|
22
|
+
"env",
|
|
23
|
+
"env/**",
|
|
24
|
+
"target",
|
|
25
|
+
"target/**",
|
|
26
|
+
"dist",
|
|
27
|
+
"dist/**",
|
|
28
|
+
"build",
|
|
29
|
+
"build/**",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"__pycache__/**",
|
|
32
|
+
".pytest_cache",
|
|
33
|
+
".pytest_cache/**",
|
|
34
|
+
".next",
|
|
35
|
+
".next/**",
|
|
36
|
+
".turbo",
|
|
37
|
+
".turbo/**",
|
|
38
|
+
".idea",
|
|
39
|
+
".idea/**",
|
|
40
|
+
".vscode",
|
|
41
|
+
".vscode/**",
|
|
42
|
+
"*.pyc",
|
|
43
|
+
"*.pyo",
|
|
44
|
+
"*.pyd",
|
|
45
|
+
"*.egg-info",
|
|
46
|
+
"*.egg-info/**",
|
|
47
|
+
".DS_Store",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RepoIndexer:
|
|
52
|
+
"""
|
|
53
|
+
Indexed repository file tree with in-memory caching and .gitignore respect.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, root_dir: str | Path):
|
|
57
|
+
self.root = Path(root_dir).resolve()
|
|
58
|
+
self._files: List[str] = []
|
|
59
|
+
self._content_cache: Dict[str, str] = {}
|
|
60
|
+
self._spec: pathspec.PathSpec = self._build_pathspec()
|
|
61
|
+
self._index_files()
|
|
62
|
+
|
|
63
|
+
def _build_pathspec(self) -> pathspec.PathSpec:
|
|
64
|
+
patterns = list(DEFAULT_IGNORES)
|
|
65
|
+
gitignore_path = self.root / ".gitignore"
|
|
66
|
+
if gitignore_path.is_file():
|
|
67
|
+
try:
|
|
68
|
+
with open(gitignore_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
69
|
+
patterns.extend(f.readlines())
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
return pathspec.PathSpec.from_lines("gitignore", patterns)
|
|
73
|
+
|
|
74
|
+
def _index_files(self) -> None:
|
|
75
|
+
self._files = []
|
|
76
|
+
root_str = str(self.root)
|
|
77
|
+
for dirpath, dirnames, filenames in os.walk(root_str):
|
|
78
|
+
rel_dir = os.path.relpath(dirpath, root_str)
|
|
79
|
+
if rel_dir == ".":
|
|
80
|
+
rel_dir_clean = ""
|
|
81
|
+
else:
|
|
82
|
+
rel_dir_clean = rel_dir.replace("\\", "/") + "/"
|
|
83
|
+
|
|
84
|
+
# Filter dirnames in-place to avoid descending into ignored dirs
|
|
85
|
+
filtered_dirs = []
|
|
86
|
+
for d in dirnames:
|
|
87
|
+
candidate = (rel_dir_clean + d + "/") if rel_dir_clean else (d + "/")
|
|
88
|
+
if not self._spec.match_file(candidate) and not self._spec.match_file(d):
|
|
89
|
+
filtered_dirs.append(d)
|
|
90
|
+
dirnames[:] = filtered_dirs
|
|
91
|
+
|
|
92
|
+
for f in filenames:
|
|
93
|
+
rel_path = (rel_dir_clean + f) if rel_dir_clean else f
|
|
94
|
+
if not self._spec.match_file(rel_path):
|
|
95
|
+
self._files.append(rel_path)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def files(self) -> List[str]:
|
|
99
|
+
return self._files
|
|
100
|
+
|
|
101
|
+
def has_file(self, rel_path: str) -> bool:
|
|
102
|
+
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
103
|
+
return normalized in self._files
|
|
104
|
+
|
|
105
|
+
def read_text(self, rel_path: str) -> Optional[str]:
|
|
106
|
+
normalized = rel_path.replace("\\", "/").lstrip("/")
|
|
107
|
+
if normalized not in self._files:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
if normalized in self._content_cache:
|
|
111
|
+
return self._content_cache[normalized]
|
|
112
|
+
|
|
113
|
+
file_path = self.root / normalized
|
|
114
|
+
try:
|
|
115
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
116
|
+
self._content_cache[normalized] = content
|
|
117
|
+
return content
|
|
118
|
+
except OSError:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def find_files(self, glob_pattern: str) -> List[str]:
|
|
122
|
+
import fnmatch
|
|
123
|
+
return [f for f in self._files if fnmatch.fnmatch(f, glob_pattern) or fnmatch.fnmatch(os.path.basename(f), glob_pattern)]
|
|
124
|
+
|
|
125
|
+
def find_by_name(self, filename: str) -> List[str]:
|
|
126
|
+
return [f for f in self._files if os.path.basename(f) == filename]
|
heurix/core/merger.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Non-Destructive Smart Merge and AST Round-Trip Preservation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
from typing import Any, List
|
|
9
|
+
from ruamel.yaml import YAML
|
|
10
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SmartMerger:
|
|
14
|
+
"""
|
|
15
|
+
Round-trip AST merger that preserves comments, custom jobs, and
|
|
16
|
+
# @heurix:preserve / # @autoyaml:preserve directives.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.yaml = YAML()
|
|
21
|
+
self.yaml.preserve_quotes = True
|
|
22
|
+
self.yaml.indent(mapping=2, sequence=4, offset=2)
|
|
23
|
+
|
|
24
|
+
def merge_yaml(self, existing_content: str, new_content: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Merges new_content into existing_content non-destructively.
|
|
27
|
+
"""
|
|
28
|
+
if not existing_content.strip():
|
|
29
|
+
return new_content
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
existing_doc = self.yaml.load(existing_content)
|
|
33
|
+
new_doc = self.yaml.load(new_content)
|
|
34
|
+
except Exception:
|
|
35
|
+
# Fallback if invalid yaml in existing
|
|
36
|
+
return new_content
|
|
37
|
+
|
|
38
|
+
if not isinstance(existing_doc, (dict, CommentedMap)) or not isinstance(new_doc, (dict, CommentedMap)):
|
|
39
|
+
return new_content
|
|
40
|
+
|
|
41
|
+
# Specialized merger for GitHub Actions workflows
|
|
42
|
+
if "jobs" in new_doc and "jobs" in existing_doc:
|
|
43
|
+
merged_doc = self._merge_github_workflow(existing_doc, new_doc, existing_content)
|
|
44
|
+
else:
|
|
45
|
+
merged_doc = self._generic_merge(existing_doc, new_doc)
|
|
46
|
+
|
|
47
|
+
stream = io.StringIO()
|
|
48
|
+
self.yaml.dump(merged_doc, stream)
|
|
49
|
+
return stream.getvalue()
|
|
50
|
+
|
|
51
|
+
def _merge_github_workflow(
|
|
52
|
+
self,
|
|
53
|
+
existing: CommentedMap,
|
|
54
|
+
new: CommentedMap,
|
|
55
|
+
raw_existing: str,
|
|
56
|
+
) -> CommentedMap:
|
|
57
|
+
result = existing.copy()
|
|
58
|
+
|
|
59
|
+
# Update metadata if needed, but preserve user triggers if customized
|
|
60
|
+
if "name" in new and "name" not in result:
|
|
61
|
+
result["name"] = new["name"]
|
|
62
|
+
|
|
63
|
+
# Preserve custom jobs
|
|
64
|
+
existing_jobs = result.get("jobs", CommentedMap())
|
|
65
|
+
new_jobs = new.get("jobs", CommentedMap())
|
|
66
|
+
|
|
67
|
+
merged_jobs = CommentedMap()
|
|
68
|
+
|
|
69
|
+
# Add all new jobs
|
|
70
|
+
for job_id, job_val in new_jobs.items():
|
|
71
|
+
if job_id in existing_jobs:
|
|
72
|
+
# Merge the managed job
|
|
73
|
+
merged_jobs[job_id] = self._merge_job(existing_jobs[job_id], job_val, raw_existing)
|
|
74
|
+
else:
|
|
75
|
+
merged_jobs[job_id] = job_val
|
|
76
|
+
|
|
77
|
+
# Preserve custom user jobs (e.g. deploy, slack_notify)
|
|
78
|
+
for job_id, job_val in existing_jobs.items():
|
|
79
|
+
if job_id not in merged_jobs:
|
|
80
|
+
merged_jobs[job_id] = job_val
|
|
81
|
+
|
|
82
|
+
result["jobs"] = merged_jobs
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
def _merge_job(self, existing_job: Any, new_job: Any, raw_existing: str) -> Any:
|
|
86
|
+
if not isinstance(existing_job, dict) or not isinstance(new_job, dict):
|
|
87
|
+
return new_job
|
|
88
|
+
|
|
89
|
+
merged_job = existing_job.copy()
|
|
90
|
+
# Update runs-on, services from new_job
|
|
91
|
+
if "runs-on" in new_job:
|
|
92
|
+
merged_job["runs-on"] = new_job["runs-on"]
|
|
93
|
+
if "services" in new_job:
|
|
94
|
+
merged_job["services"] = new_job["services"]
|
|
95
|
+
|
|
96
|
+
# Steps merging
|
|
97
|
+
new_steps = new_job.get("steps", [])
|
|
98
|
+
existing_steps = existing_job.get("steps", [])
|
|
99
|
+
|
|
100
|
+
# Find any step in existing that has preserve directive
|
|
101
|
+
preserved_steps: List[Any] = []
|
|
102
|
+
for step in existing_steps:
|
|
103
|
+
step_str = str(step)
|
|
104
|
+
# Check if comments or text indicate preserve
|
|
105
|
+
if "@heurix:preserve" in raw_existing or "@autoyaml:preserve" in raw_existing:
|
|
106
|
+
name = step.get("name", "") if isinstance(step, dict) else ""
|
|
107
|
+
if name and ("custom" in name.lower() or "notify" in name.lower() or "slack" in name.lower() or "deploy" in name.lower()):
|
|
108
|
+
preserved_steps.append(step)
|
|
109
|
+
|
|
110
|
+
# Merge steps
|
|
111
|
+
final_steps = list(new_steps)
|
|
112
|
+
for p in preserved_steps:
|
|
113
|
+
if p not in final_steps:
|
|
114
|
+
final_steps.append(p)
|
|
115
|
+
|
|
116
|
+
merged_job["steps"] = final_steps
|
|
117
|
+
return merged_job
|
|
118
|
+
|
|
119
|
+
def _generic_merge(self, existing: CommentedMap, new: CommentedMap) -> CommentedMap:
|
|
120
|
+
result = existing.copy()
|
|
121
|
+
for k, v in new.items():
|
|
122
|
+
result[k] = v
|
|
123
|
+
return result
|