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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Heurix: Deterministic Codebase Scanner & Automated YAML Synthesizer.
3
+ """
4
+
5
+ __version__ = "0.1.0"
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