kernhell 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.

Potentially problematic release.


This version of kernhell might be problematic. Click here for more details.

Files changed (42) hide show
  1. kernhell/__init__.py +8 -0
  2. kernhell/__main__.py +6 -0
  3. kernhell/analyzer/analyzer.py +178 -0
  4. kernhell/analyzer/healer.py +249 -0
  5. kernhell/analyzer/prompts.py +89 -0
  6. kernhell/api/__init__.py +5 -0
  7. kernhell/api/server.py +367 -0
  8. kernhell/cache/__init__.py +13 -0
  9. kernhell/cache/cache_manager.py +221 -0
  10. kernhell/cache/embedder.py +104 -0
  11. kernhell/cache/schemas.py +42 -0
  12. kernhell/cli/__init__.py +1 -0
  13. kernhell/cli/app.py +62 -0
  14. kernhell/cli/commands/__init__.py +1 -0
  15. kernhell/cli/commands/cache.py +96 -0
  16. kernhell/cli/commands/config.py +111 -0
  17. kernhell/cli/commands/dashboard.py +55 -0
  18. kernhell/cli/commands/heal.py +141 -0
  19. kernhell/cli/commands/scan.py +87 -0
  20. kernhell/cli/ui.py +176 -0
  21. kernhell/core/__init__.py +1 -0
  22. kernhell/core/config.py +219 -0
  23. kernhell/core/exceptions.py +104 -0
  24. kernhell/core/models.py +174 -0
  25. kernhell/core/orchestrator.py +245 -0
  26. kernhell/patcher/patcher.py +215 -0
  27. kernhell/patcher/verifier.py +82 -0
  28. kernhell/pruner/__init__.py +1 -0
  29. kernhell/pruner/dom_pruner.py +240 -0
  30. kernhell/reporter/__init__.py +5 -0
  31. kernhell/reporter/reporter.py +124 -0
  32. kernhell/scanner/__init__.py +1 -0
  33. kernhell/scanner/parser.py +276 -0
  34. kernhell/scanner/runner.py +242 -0
  35. kernhell/utils/__init__.py +1 -0
  36. kernhell/utils/file_utils.py +117 -0
  37. kernhell/utils/logger.py +78 -0
  38. kernhell-0.1.0.dist-info/METADATA +245 -0
  39. kernhell-0.1.0.dist-info/RECORD +42 -0
  40. kernhell-0.1.0.dist-info/WHEEL +4 -0
  41. kernhell-0.1.0.dist-info/entry_points.txt +2 -0
  42. kernhell-0.1.0.dist-info/licenses/LICENSE +21 -0
kernhell/cli/ui.py ADDED
@@ -0,0 +1,176 @@
1
+ """Rich console UI helpers for KernHell CLI output.
2
+
3
+ Provides reusable display components for the terminal:
4
+ - Branded banner with version
5
+ - Failure context panels
6
+ - Healing result tables
7
+ - Progress spinners
8
+ - Diff display for patched selectors
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+ from rich.syntax import Syntax
18
+ from rich import box
19
+
20
+ from kernhell import __version__
21
+ from kernhell.core.models import FailureContext, HealingReport, PrunedDOM
22
+
23
+ console = Console(stderr=True)
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Banner
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _BANNER = r"""
31
+ ██╗ ██╗███████╗██████╗ ███╗ ██╗██╗ ██╗███████╗██╗ ██╗
32
+ ██║ ██╔╝██╔════╝██╔══██╗████╗ ██║██║ ██║██╔════╝██║ ██║
33
+ █████╔╝ █████╗ ██████╔╝██╔██╗ ██║███████║█████╗ ██║ ██║
34
+ ██╔═██╗ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══██║██╔══╝ ██║ ██║
35
+ ██║ ██╗███████╗██║ ██║██║ ╚████║██║ ██║███████╗███████╗███████╗
36
+ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝
37
+ """
38
+
39
+
40
+ def print_banner() -> None:
41
+ """Display the KernHell ASCII banner with version."""
42
+ banner_text = Text(_BANNER, style="bold red")
43
+ tagline = Text(
44
+ " The Heart of Your System Where Innovation Meets Precision",
45
+ style="italic bright_black",
46
+ )
47
+ version = Text(f" v{__version__}", style="dim")
48
+
49
+ console.print(banner_text, highlight=False)
50
+ console.print(tagline)
51
+ console.print(version)
52
+ console.print()
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Failure Display
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def print_failure_context(ctx: FailureContext, index: int = 1) -> None:
61
+ """Display a single failure context as a Rich panel."""
62
+ table = Table(show_header=False, box=None, padding=(0, 2))
63
+ table.add_column("Key", style="bold cyan", width=16)
64
+ table.add_column("Value")
65
+
66
+ table.add_row("Selector", Text(ctx.broken_selector, style="bold red"))
67
+ table.add_row("Type", ctx.selector_type.value)
68
+ table.add_row("Error", ctx.error_type.value)
69
+ table.add_row("Line", str(ctx.line_number) if ctx.line_number else "unknown")
70
+ table.add_row("File", str(ctx.test_file.name))
71
+
72
+ if ctx.page_url:
73
+ table.add_row("URL", ctx.page_url)
74
+
75
+ panel = Panel(
76
+ table,
77
+ title=f"[bold red]💀 Failure #{index}[/bold red]",
78
+ border_style="red",
79
+ box=box.ROUNDED,
80
+ )
81
+ console.print(panel)
82
+
83
+ # Show surrounding code if available
84
+ if ctx.surrounding_code:
85
+ syntax = Syntax(
86
+ ctx.surrounding_code,
87
+ "python",
88
+ theme="monokai",
89
+ line_numbers=False,
90
+ word_wrap=True,
91
+ )
92
+ console.print(
93
+ Panel(syntax, title="[dim]Surrounding Code[/dim]", border_style="dim")
94
+ )
95
+
96
+
97
+ def print_pruning_stats(pruned: PrunedDOM) -> None:
98
+ """Display DOM pruning statistics."""
99
+ console.print(
100
+ f" 🔪 DOM Pruned: "
101
+ f"[dim]{pruned.original_size:,}[/dim] → "
102
+ f"[bold green]{pruned.pruned_size:,}[/bold green] bytes "
103
+ f"([bold green]-{pruned.reduction_pct}%[/bold green])"
104
+ )
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Scan Summary
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def print_scan_summary(failures: list[FailureContext]) -> None:
113
+ """Display a summary table of all detected failures."""
114
+ if not failures:
115
+ console.print(
116
+ Panel(
117
+ "[bold green]✅ All tests passed — nothing to heal![/bold green]",
118
+ border_style="green",
119
+ )
120
+ )
121
+ return
122
+
123
+ table = Table(
124
+ title="🔍 Detected Selector Failures",
125
+ box=box.SIMPLE_HEAVY,
126
+ show_lines=True,
127
+ )
128
+ table.add_column("#", style="dim", width=4)
129
+ table.add_column("Selector", style="bold red")
130
+ table.add_column("Type", style="cyan")
131
+ table.add_column("Line", style="yellow", justify="right")
132
+ table.add_column("Error", style="dim")
133
+
134
+ for i, f in enumerate(failures, 1):
135
+ table.add_row(
136
+ str(i),
137
+ f.broken_selector,
138
+ f.selector_type.value,
139
+ str(f.line_number) if f.line_number else "?",
140
+ f.error_type.value,
141
+ )
142
+
143
+ console.print(table)
144
+ console.print(
145
+ f"\n [bold]{len(failures)}[/bold] broken selector(s) found.\n"
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Healing Report (stub — expanded in Weekend 2)
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def print_healing_report(report: HealingReport) -> None:
155
+ """Display the final healing report."""
156
+ table = Table(
157
+ title="🔥 KernHell Healing Report",
158
+ box=box.DOUBLE_EDGE,
159
+ )
160
+ table.add_column("Metric", style="bold")
161
+ table.add_column("Value", justify="right")
162
+
163
+ table.add_row("Total Failures", str(report.total_failures))
164
+ table.add_row("Healed", f"[bold green]{report.healed_count}[/bold green]")
165
+ table.add_row("Skipped", f"[yellow]{report.skipped_count}[/yellow]")
166
+ table.add_row("From Cache", str(report.cache_hits))
167
+ table.add_row("LLM Calls", str(report.llm_calls))
168
+ table.add_row("Duration", f"{report.duration_ms}ms")
169
+ table.add_row(
170
+ "Status",
171
+ "[bold green]✅ PASSED[/bold green]"
172
+ if report.verification_passed
173
+ else "[bold red]❌ FAILED[/bold red]",
174
+ )
175
+
176
+ console.print(table)
@@ -0,0 +1 @@
1
+ """Core package — models, config, orchestration, and exceptions."""
@@ -0,0 +1,219 @@
1
+ """KernHell configuration management.
2
+
3
+ Loads settings from (in priority order):
4
+ 1. Environment variables (KERNHELL_LLM__PROVIDER, etc.)
5
+ 2. .kernhellrc.toml in the current directory
6
+ 3. ~/.kernhellrc.toml in the user's home directory
7
+ 4. Built-in defaults
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from pydantic import BaseModel, Field
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib
21
+ else:
22
+ import tomli as tomllib # type: ignore[import-untyped]
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Config Sub-Sections
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ class LLMConfig(BaseModel):
31
+ """LLM provider settings."""
32
+
33
+ provider: str = "gemini"
34
+ model: str = "gemini-1.5-flash"
35
+ api_key: Optional[str] = None
36
+ temperature: float = 0.1
37
+ max_tokens: int = 2048
38
+
39
+ def get_resolved_api_key(self) -> Optional[str]:
40
+ """Resolve API key from explicit config or standard provider environment variables."""
41
+ if self.api_key:
42
+ return self.api_key
43
+
44
+ import os
45
+
46
+ prov = self.provider.lower()
47
+ if prov == "gemini":
48
+ return os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
49
+ elif prov == "groq":
50
+ return os.environ.get("GROQ_API_KEY")
51
+ elif prov == "openai":
52
+ return os.environ.get("OPENAI_API_KEY")
53
+ elif prov == "anthropic":
54
+ return os.environ.get("ANTHROPIC_API_KEY")
55
+ return None
56
+
57
+ def get_litellm_model_name(self) -> str:
58
+ """Format the model name appropriately for litellm."""
59
+ prov = self.provider.lower()
60
+ model = self.model.strip()
61
+ if prov == "openai":
62
+ return model
63
+ elif prov == "gemini":
64
+ return model if model.startswith("gemini/") else f"gemini/{model}"
65
+ return model if model.startswith(f"{prov}/") else f"{prov}/{model}"
66
+
67
+
68
+ class HealingConfig(BaseModel):
69
+ """Healing behavior settings."""
70
+
71
+ confidence_threshold: float = 0.85
72
+ max_heals_per_file: int = 5
73
+ dry_run: bool = False
74
+ auto_commit: bool = False
75
+
76
+
77
+ class CacheConfig(BaseModel):
78
+ """Semantic vector cache settings."""
79
+
80
+ enabled: bool = True
81
+ similarity_threshold: float = 0.92
82
+ cache_dir: str = "~/.kernhell/cache"
83
+ embedding_model: str = "all-MiniLM-L6-v2"
84
+
85
+
86
+ class ScannerConfig(BaseModel):
87
+ """Playwright scanner settings."""
88
+
89
+ timeout_ms: int = 5000
90
+ capture_screenshot: bool = True
91
+ browser: str = "chromium"
92
+ headless: bool = True
93
+
94
+
95
+ class LoggingConfig(BaseModel):
96
+ """Logging settings."""
97
+
98
+ level: str = "INFO"
99
+ json_output: bool = False
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Root Config
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ class KernHellConfig(BaseSettings):
108
+ """Root configuration for KernHell.
109
+
110
+ Merges TOML file settings with environment variable overrides.
111
+ Env vars use the prefix ``KERNHELL_`` with double-underscore nesting
112
+ (e.g. ``KERNHELL_LLM__PROVIDER=openai``).
113
+ """
114
+
115
+ model_config = SettingsConfigDict(
116
+ env_prefix="KERNHELL_",
117
+ env_nested_delimiter="__",
118
+ case_sensitive=False,
119
+ )
120
+
121
+ llm: LLMConfig = Field(default_factory=LLMConfig)
122
+ healing: HealingConfig = Field(default_factory=HealingConfig)
123
+ cache: CacheConfig = Field(default_factory=CacheConfig)
124
+ scanner: ScannerConfig = Field(default_factory=ScannerConfig)
125
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Config Loading
130
+ # ---------------------------------------------------------------------------
131
+
132
+ _CONFIG_FILENAMES = [".kernhellrc.toml", "kernhell.toml"]
133
+
134
+
135
+ def _find_config_file() -> Optional[Path]:
136
+ """Search current dir, then home dir for a config file."""
137
+ for directory in [Path.cwd(), Path.home()]:
138
+ for name in _CONFIG_FILENAMES:
139
+ candidate = directory / name
140
+ if candidate.is_file():
141
+ return candidate
142
+ return None
143
+
144
+
145
+ def _extract_env_overrides() -> dict:
146
+ """Extract and nest environment variables starting with KERNHELL_."""
147
+ import os
148
+
149
+ overrides: dict = {}
150
+ prefix = "KERNHELL_"
151
+ for key, value in os.environ.items():
152
+ if key.upper().startswith(prefix):
153
+ remainder = key[len(prefix):].lower()
154
+ parts = remainder.split("__")
155
+ d = overrides
156
+ for p in parts[:-1]:
157
+ d = d.setdefault(p, {})
158
+ d[parts[-1]] = value
159
+ return overrides
160
+
161
+
162
+ def _deep_merge(base: dict, overlay: dict) -> dict:
163
+ """Recursively merge overlay dict on top of base dict."""
164
+ merged = dict(base)
165
+ for k, v in overlay.items():
166
+ if k in merged and isinstance(merged[k], dict) and isinstance(v, dict):
167
+ merged[k] = _deep_merge(merged[k], v)
168
+ else:
169
+ merged[k] = v
170
+ return merged
171
+
172
+
173
+ def _load_env_file() -> None:
174
+ """Load key-value pairs from .env file into os.environ if present."""
175
+ import os
176
+
177
+ for env_file in [Path.cwd() / ".env", Path.home() / ".kernhell" / ".env"]:
178
+ if env_file.is_file():
179
+ try:
180
+ for line in env_file.read_text(encoding="utf-8").splitlines():
181
+ line = line.strip()
182
+ if line and not line.startswith("#") and "=" in line:
183
+ k, v = line.split("=", 1)
184
+ k = k.strip()
185
+ v = v.strip().strip("'\"")
186
+ if k and k not in os.environ:
187
+ os.environ[k] = v
188
+ except Exception:
189
+ pass
190
+
191
+
192
+ def load_config(config_path: Optional[Path] = None) -> KernHellConfig:
193
+ """Load and return the merged KernHell configuration.
194
+
195
+ Parameters
196
+ ----------
197
+ config_path:
198
+ Explicit path to a TOML config file. When *None*, the function
199
+ searches the current directory and then ``$HOME`` for
200
+ ``.kernhellrc.toml``.
201
+
202
+ Returns
203
+ -------
204
+ KernHellConfig
205
+ Fully resolved configuration with TOML → env-var priority.
206
+ """
207
+ _load_env_file()
208
+ file_settings: dict = {}
209
+
210
+ path = config_path or _find_config_file()
211
+ if path and path.is_file():
212
+ with open(path, "rb") as f:
213
+ file_settings = tomllib.load(f)
214
+
215
+ # Environment variables have highest priority over file settings
216
+ env_overrides = _extract_env_overrides()
217
+ merged_settings = _deep_merge(file_settings, env_overrides)
218
+
219
+ return KernHellConfig(**merged_settings)
@@ -0,0 +1,104 @@
1
+ """KernHell custom exception hierarchy.
2
+
3
+ Every module raises typed exceptions so the Orchestrator can decide
4
+ whether to retry, skip, or abort cleanly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class KernHellError(Exception):
11
+ """Base exception for all KernHell errors."""
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Scanner Errors
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class ScannerError(KernHellError):
20
+ """Raised when the Scanner fails to execute or parse a test run."""
21
+
22
+
23
+ class PlaywrightNotInstalledError(ScannerError):
24
+ """Playwright or its browsers are not installed."""
25
+
26
+
27
+ class TestFileNotFoundError(ScannerError):
28
+ """The specified test file does not exist."""
29
+
30
+
31
+ class NoFailuresDetectedError(ScannerError):
32
+ """The test run completed without any selector failures — nothing to heal."""
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Pruner Errors
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class PrunerError(KernHellError):
41
+ """Raised when DOM pruning fails (e.g. malformed HTML)."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Analyzer / Healer Errors
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class AnalyzerError(KernHellError):
50
+ """Raised when the LLM analysis step fails."""
51
+
52
+
53
+ class HealerError(KernHellError):
54
+ """Raised when the LLM cannot produce a valid selector fix."""
55
+
56
+
57
+ class LLMConnectionError(AnalyzerError):
58
+ """Cannot reach the configured LLM provider."""
59
+
60
+
61
+ class LowConfidenceError(HealerError):
62
+ """The proposed fix has a confidence score below the threshold."""
63
+
64
+ def __init__(self, confidence: float, threshold: float) -> None:
65
+ self.confidence = confidence
66
+ self.threshold = threshold
67
+ super().__init__(
68
+ f"Confidence {confidence:.2f} is below threshold {threshold:.2f}"
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Patcher Errors
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ class PatcherError(KernHellError):
78
+ """Raised when AST patching fails."""
79
+
80
+
81
+ class SelectorNotFoundInASTError(PatcherError):
82
+ """The broken selector string was not found in the test file's AST."""
83
+
84
+
85
+ class VerificationFailedError(PatcherError):
86
+ """The patched test still fails after applying the fix."""
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Cache Errors
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class CacheError(KernHellError):
95
+ """Raised when the semantic cache encounters an error."""
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Config Errors
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ class ConfigError(KernHellError):
104
+ """Raised when configuration is invalid or missing."""
@@ -0,0 +1,174 @@
1
+ """Core data models for the KernHell healing pipeline.
2
+
3
+ Every module communicates through these typed Pydantic models.
4
+ This enforces clean boundaries and makes serialization/validation automatic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import enum
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Enums
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class SelectorType(str, enum.Enum):
23
+ """Classification of the Playwright selector strategy that was used."""
24
+
25
+ CSS = "css"
26
+ XPATH = "xpath"
27
+ TEXT = "text"
28
+ ROLE = "role"
29
+ TEST_ID = "test_id"
30
+ LABEL = "label"
31
+ PLACEHOLDER = "placeholder"
32
+ UNKNOWN = "unknown"
33
+
34
+
35
+ class ErrorType(str, enum.Enum):
36
+ """Category of Playwright failure."""
37
+
38
+ TIMEOUT = "timeout"
39
+ NOT_FOUND = "not_found"
40
+ STRICT_MODE = "strict_mode"
41
+ NAVIGATION = "navigation"
42
+ OTHER = "other"
43
+
44
+
45
+ class FailureType(str, enum.Enum):
46
+ """Diagnosis of *why* the selector failed."""
47
+
48
+ RENAME = "rename" # Element exists, selector changed
49
+ MISSING = "missing" # Element genuinely absent (real bug)
50
+ STRUCTURAL = "structural" # DOM structure significantly changed
51
+ UNKNOWN = "unknown"
52
+
53
+
54
+ class SelectorStrategy(str, enum.Enum):
55
+ """Strategy the healer used to find the replacement selector."""
56
+
57
+ ROLE_MATCH = "role_match"
58
+ TEXT_MATCH = "text_match"
59
+ ARIA_MATCH = "aria_match"
60
+ TEST_ID_MATCH = "test_id_match"
61
+ DATA_ATTR = "data_attr"
62
+ CSS_FALLBACK = "css_fallback"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Pipeline Data Models
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class FailureContext(BaseModel):
71
+ """Everything captured at the moment a Playwright selector fails.
72
+
73
+ Produced by the Scanner, consumed by the Pruner and Analyzer.
74
+ """
75
+
76
+ test_file: Path
77
+ line_number: int
78
+ broken_selector: str
79
+ selector_type: SelectorType = SelectorType.UNKNOWN
80
+ error_message: str
81
+ error_type: ErrorType = ErrorType.TIMEOUT
82
+ live_dom: str = ""
83
+ page_url: str = ""
84
+ screenshot_path: Optional[Path] = None
85
+ surrounding_code: str = ""
86
+
87
+ # Metadata
88
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
89
+
90
+
91
+ class PrunedDOM(BaseModel):
92
+ """The DOM after noise removal by the DOMPruner.
93
+
94
+ Carries both the pruned HTML and reduction stats for reporting.
95
+ """
96
+
97
+ html: str
98
+ original_size: int
99
+ pruned_size: int
100
+
101
+ @property
102
+ def reduction_pct(self) -> float:
103
+ """Percentage of bytes removed by pruning."""
104
+ if self.original_size == 0:
105
+ return 0.0
106
+ return round((1 - self.pruned_size / self.original_size) * 100, 1)
107
+
108
+
109
+ class Diagnosis(BaseModel):
110
+ """LLM classification of the failure — produced by the Analyzer.
111
+
112
+ RENAME → safe to heal. MISSING → potential real bug, skip.
113
+ """
114
+
115
+ failure_type: FailureType
116
+ explanation: str
117
+ confidence: float = Field(ge=0.0, le=1.0)
118
+
119
+
120
+ class SelectorFix(BaseModel):
121
+ """A proposed replacement selector — produced by the Healer."""
122
+
123
+ old_selector: str
124
+ new_selector: str
125
+ strategy: SelectorStrategy = SelectorStrategy.CSS_FALLBACK
126
+ confidence: float = Field(ge=0.0, le=1.0)
127
+ reasoning: str = ""
128
+
129
+ # Populated after Verifier confirms the fix works
130
+ verified: bool = False
131
+
132
+
133
+ class HealingResult(BaseModel):
134
+ """Outcome of healing a single broken selector."""
135
+
136
+ failure: FailureContext
137
+ pruned_dom: Optional[PrunedDOM] = None
138
+ diagnosis: Optional[Diagnosis] = None
139
+ fix: Optional[SelectorFix] = None
140
+ from_cache: bool = False
141
+ skipped: bool = False
142
+ skip_reason: str = ""
143
+
144
+
145
+ class HealingReport(BaseModel):
146
+ """Aggregate report for a full healing run (one or more test files)."""
147
+
148
+ test_file: Path
149
+ results: list[HealingResult] = []
150
+ duration_ms: int = 0
151
+
152
+ @property
153
+ def total_failures(self) -> int:
154
+ return len(self.results)
155
+
156
+ @property
157
+ def healed_count(self) -> int:
158
+ return sum(1 for r in self.results if r.fix and r.fix.verified)
159
+
160
+ @property
161
+ def skipped_count(self) -> int:
162
+ return sum(1 for r in self.results if r.skipped)
163
+
164
+ @property
165
+ def cache_hits(self) -> int:
166
+ return sum(1 for r in self.results if r.from_cache)
167
+
168
+ @property
169
+ def llm_calls(self) -> int:
170
+ return sum(1 for r in self.results if not r.from_cache and r.fix is not None)
171
+
172
+ @property
173
+ def verification_passed(self) -> bool:
174
+ return all(r.fix.verified for r in self.results if r.fix is not None)