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.
- kernhell/__init__.py +8 -0
- kernhell/__main__.py +6 -0
- kernhell/analyzer/analyzer.py +178 -0
- kernhell/analyzer/healer.py +249 -0
- kernhell/analyzer/prompts.py +89 -0
- kernhell/api/__init__.py +5 -0
- kernhell/api/server.py +367 -0
- kernhell/cache/__init__.py +13 -0
- kernhell/cache/cache_manager.py +221 -0
- kernhell/cache/embedder.py +104 -0
- kernhell/cache/schemas.py +42 -0
- kernhell/cli/__init__.py +1 -0
- kernhell/cli/app.py +62 -0
- kernhell/cli/commands/__init__.py +1 -0
- kernhell/cli/commands/cache.py +96 -0
- kernhell/cli/commands/config.py +111 -0
- kernhell/cli/commands/dashboard.py +55 -0
- kernhell/cli/commands/heal.py +141 -0
- kernhell/cli/commands/scan.py +87 -0
- kernhell/cli/ui.py +176 -0
- kernhell/core/__init__.py +1 -0
- kernhell/core/config.py +219 -0
- kernhell/core/exceptions.py +104 -0
- kernhell/core/models.py +174 -0
- kernhell/core/orchestrator.py +245 -0
- kernhell/patcher/patcher.py +215 -0
- kernhell/patcher/verifier.py +82 -0
- kernhell/pruner/__init__.py +1 -0
- kernhell/pruner/dom_pruner.py +240 -0
- kernhell/reporter/__init__.py +5 -0
- kernhell/reporter/reporter.py +124 -0
- kernhell/scanner/__init__.py +1 -0
- kernhell/scanner/parser.py +276 -0
- kernhell/scanner/runner.py +242 -0
- kernhell/utils/__init__.py +1 -0
- kernhell/utils/file_utils.py +117 -0
- kernhell/utils/logger.py +78 -0
- kernhell-0.1.0.dist-info/METADATA +245 -0
- kernhell-0.1.0.dist-info/RECORD +42 -0
- kernhell-0.1.0.dist-info/WHEEL +4 -0
- kernhell-0.1.0.dist-info/entry_points.txt +2 -0
- 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."""
|
kernhell/core/config.py
ADDED
|
@@ -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."""
|
kernhell/core/models.py
ADDED
|
@@ -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)
|