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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Healing orchestrator — coordinates the full self-healing pipeline.
|
|
2
|
+
|
|
3
|
+
Pipeline Flow:
|
|
4
|
+
1. Scan test file for selector failures.
|
|
5
|
+
2. Prune live DOM snapshot.
|
|
6
|
+
3. Query Semantic Vector Cache (zero latency, zero LLM cost).
|
|
7
|
+
4. On Cache Miss:
|
|
8
|
+
a. AI Failure Diagnosis (RENAME vs MISSING vs STRUCTURAL).
|
|
9
|
+
b. Anti-Hallucination Guardrail: Skip genuine MISSING bugs.
|
|
10
|
+
c. AI Resilient Selector Generation (with DOM presence check).
|
|
11
|
+
5. Surgical AST/CST Patching (preserves comments, indentation, formatting).
|
|
12
|
+
6. Test Verification (re-runs test to confirm fix; reverts on failure).
|
|
13
|
+
7. Store verified fix in Semantic Memory for future instant reuse.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
import structlog
|
|
23
|
+
|
|
24
|
+
from kernhell.analyzer.analyzer import FailureAnalyzer
|
|
25
|
+
from kernhell.analyzer.healer import SelectorHealer
|
|
26
|
+
from kernhell.cache.cache_manager import CacheManager
|
|
27
|
+
from kernhell.core.config import KernHellConfig, load_config
|
|
28
|
+
from kernhell.core.exceptions import (
|
|
29
|
+
HealerError,
|
|
30
|
+
LowConfidenceError,
|
|
31
|
+
NoFailuresDetectedError,
|
|
32
|
+
ScannerError,
|
|
33
|
+
)
|
|
34
|
+
from kernhell.core.models import (
|
|
35
|
+
Diagnosis,
|
|
36
|
+
FailureContext,
|
|
37
|
+
FailureType,
|
|
38
|
+
HealingReport,
|
|
39
|
+
HealingResult,
|
|
40
|
+
PrunedDOM,
|
|
41
|
+
SelectorFix,
|
|
42
|
+
)
|
|
43
|
+
from kernhell.patcher.patcher import SurgicalPatcher
|
|
44
|
+
from kernhell.patcher.verifier import TestVerifier
|
|
45
|
+
from kernhell.pruner.dom_pruner import prune_dom
|
|
46
|
+
from kernhell.scanner.runner import ScannerRunner
|
|
47
|
+
|
|
48
|
+
logger = structlog.get_logger()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HealingOrchestrator:
|
|
52
|
+
"""Coordinates failure scanning, context capture, DOM pruning, and healing."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, config: Optional[KernHellConfig] = None) -> None:
|
|
55
|
+
self.config = config or load_config()
|
|
56
|
+
self.scanner = ScannerRunner(self.config)
|
|
57
|
+
self.cache = CacheManager(self.config)
|
|
58
|
+
self.analyzer = FailureAnalyzer(self.config)
|
|
59
|
+
self.healer = SelectorHealer(self.config)
|
|
60
|
+
self.patcher = SurgicalPatcher()
|
|
61
|
+
self.verifier = TestVerifier(self.config)
|
|
62
|
+
|
|
63
|
+
def scan(self, test_file: Path) -> list[tuple[FailureContext, Optional[PrunedDOM]]]:
|
|
64
|
+
"""Scan a test file for failures and capture pruned DOMs without modifying files."""
|
|
65
|
+
logger.info("orchestrator.scan.start", test_file=str(test_file))
|
|
66
|
+
failures = self.scanner.run(test_file)
|
|
67
|
+
|
|
68
|
+
results: list[tuple[FailureContext, Optional[PrunedDOM]]] = []
|
|
69
|
+
for failure in failures:
|
|
70
|
+
pruned: Optional[PrunedDOM] = None
|
|
71
|
+
if failure.live_dom:
|
|
72
|
+
pruned = prune_dom(failure.live_dom)
|
|
73
|
+
results.append((failure, pruned))
|
|
74
|
+
|
|
75
|
+
logger.info("orchestrator.scan.complete", failures_count=len(results))
|
|
76
|
+
return results
|
|
77
|
+
|
|
78
|
+
def heal(self, test_file: Path, dry_run: Optional[bool] = None) -> HealingReport:
|
|
79
|
+
"""Run the autonomous self-healing pipeline for a test file."""
|
|
80
|
+
start_time = time.perf_counter()
|
|
81
|
+
is_dry_run = self.config.healing.dry_run if dry_run is None else dry_run
|
|
82
|
+
|
|
83
|
+
logger.info(
|
|
84
|
+
"orchestrator.heal.start",
|
|
85
|
+
test_file=str(test_file),
|
|
86
|
+
dry_run=is_dry_run,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
report = HealingReport(test_file=test_file)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
scan_results = self.scan(test_file)
|
|
93
|
+
except NoFailuresDetectedError as exc:
|
|
94
|
+
logger.info("orchestrator.no_failures", message=str(exc))
|
|
95
|
+
report.duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
96
|
+
return report
|
|
97
|
+
except ScannerError as exc:
|
|
98
|
+
logger.error("orchestrator.scan_failed", error=str(exc))
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
# Limit heals per file to prevent runaway modification
|
|
102
|
+
max_heals = self.config.healing.max_heals_per_file
|
|
103
|
+
if len(scan_results) > max_heals:
|
|
104
|
+
logger.warning(
|
|
105
|
+
"orchestrator.exceeded_max_heals",
|
|
106
|
+
failures=len(scan_results),
|
|
107
|
+
max_allowed=max_heals,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
for failure, pruned in scan_results[:max_heals]:
|
|
111
|
+
result = self._heal_single_failure(
|
|
112
|
+
test_file=test_file,
|
|
113
|
+
failure=failure,
|
|
114
|
+
pruned_dom=pruned,
|
|
115
|
+
dry_run=is_dry_run,
|
|
116
|
+
)
|
|
117
|
+
report.results.append(result)
|
|
118
|
+
|
|
119
|
+
report.duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
120
|
+
logger.info(
|
|
121
|
+
"orchestrator.heal.complete",
|
|
122
|
+
test_file=str(test_file),
|
|
123
|
+
total=report.total_failures,
|
|
124
|
+
healed=report.healed_count,
|
|
125
|
+
skipped=report.skipped_count,
|
|
126
|
+
from_cache=report.cache_hits,
|
|
127
|
+
duration_ms=report.duration_ms,
|
|
128
|
+
)
|
|
129
|
+
return report
|
|
130
|
+
|
|
131
|
+
def _heal_single_failure(
|
|
132
|
+
self,
|
|
133
|
+
test_file: Path,
|
|
134
|
+
failure: FailureContext,
|
|
135
|
+
pruned_dom: Optional[PrunedDOM],
|
|
136
|
+
dry_run: bool,
|
|
137
|
+
) -> HealingResult:
|
|
138
|
+
"""Execute the healing decision tree for a single broken selector."""
|
|
139
|
+
# 1. Check Semantic Vector Cache
|
|
140
|
+
cached_fix, score = self.cache.lookup(
|
|
141
|
+
broken_selector=failure.broken_selector,
|
|
142
|
+
context=failure.surrounding_code,
|
|
143
|
+
page_url=failure.page_url,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if cached_fix is not None:
|
|
147
|
+
logger.info(
|
|
148
|
+
"orchestrator.cache_hit_applying",
|
|
149
|
+
selector=failure.broken_selector,
|
|
150
|
+
score=score,
|
|
151
|
+
)
|
|
152
|
+
if not dry_run:
|
|
153
|
+
self.patcher.patch_test_file(
|
|
154
|
+
test_file=test_file,
|
|
155
|
+
fix=cached_fix,
|
|
156
|
+
line_number=failure.line_number,
|
|
157
|
+
)
|
|
158
|
+
self.verifier.verify(test_file, cached_fix)
|
|
159
|
+
else:
|
|
160
|
+
cached_fix.verified = True
|
|
161
|
+
|
|
162
|
+
return HealingResult(
|
|
163
|
+
failure=failure,
|
|
164
|
+
pruned_dom=pruned_dom,
|
|
165
|
+
fix=cached_fix,
|
|
166
|
+
from_cache=True,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# 2. AI Failure Diagnosis
|
|
170
|
+
try:
|
|
171
|
+
diagnosis = self.analyzer.diagnose(failure, pruned_dom)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
logger.error("orchestrator.diagnosis_failed", error=str(exc))
|
|
174
|
+
return HealingResult(
|
|
175
|
+
failure=failure,
|
|
176
|
+
pruned_dom=pruned_dom,
|
|
177
|
+
skipped=True,
|
|
178
|
+
skip_reason=f"Diagnosis failed: {exc}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# 3. Anti-Hallucination Guardrail: Never heal genuine MISSING bugs
|
|
182
|
+
if diagnosis.failure_type == FailureType.MISSING:
|
|
183
|
+
logger.info(
|
|
184
|
+
"orchestrator.skip_missing",
|
|
185
|
+
selector=failure.broken_selector,
|
|
186
|
+
explanation=diagnosis.explanation,
|
|
187
|
+
)
|
|
188
|
+
return HealingResult(
|
|
189
|
+
failure=failure,
|
|
190
|
+
pruned_dom=pruned_dom,
|
|
191
|
+
diagnosis=diagnosis,
|
|
192
|
+
skipped=True,
|
|
193
|
+
skip_reason=f"Potential real bug: element is genuinely MISSING ({diagnosis.explanation})",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# 4. Generate Resilient Replacement Selector
|
|
197
|
+
try:
|
|
198
|
+
fix = self.healer.suggest_fix(failure, diagnosis, pruned_dom)
|
|
199
|
+
except LowConfidenceError as exc:
|
|
200
|
+
logger.warning("orchestrator.low_confidence", error=str(exc))
|
|
201
|
+
return HealingResult(
|
|
202
|
+
failure=failure,
|
|
203
|
+
pruned_dom=pruned_dom,
|
|
204
|
+
diagnosis=diagnosis,
|
|
205
|
+
skipped=True,
|
|
206
|
+
skip_reason=str(exc),
|
|
207
|
+
)
|
|
208
|
+
except HealerError as exc:
|
|
209
|
+
logger.warning("orchestrator.healer_error", error=str(exc))
|
|
210
|
+
return HealingResult(
|
|
211
|
+
failure=failure,
|
|
212
|
+
pruned_dom=pruned_dom,
|
|
213
|
+
diagnosis=diagnosis,
|
|
214
|
+
skipped=True,
|
|
215
|
+
skip_reason=str(exc),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# 5. Surgical AST/CST Patching & Verification
|
|
219
|
+
if not dry_run:
|
|
220
|
+
self.patcher.patch_test_file(
|
|
221
|
+
test_file=test_file,
|
|
222
|
+
fix=fix,
|
|
223
|
+
line_number=failure.line_number,
|
|
224
|
+
)
|
|
225
|
+
verified = self.verifier.verify(test_file, fix)
|
|
226
|
+
if verified:
|
|
227
|
+
# 6. Store in Semantic Memory
|
|
228
|
+
self.cache.store(
|
|
229
|
+
broken_selector=failure.broken_selector,
|
|
230
|
+
fix=fix,
|
|
231
|
+
context=failure.surrounding_code,
|
|
232
|
+
page_url=failure.page_url,
|
|
233
|
+
test_file=str(test_file),
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
# In dry-run mode, simulate successful verification preview
|
|
237
|
+
fix.verified = True
|
|
238
|
+
|
|
239
|
+
return HealingResult(
|
|
240
|
+
failure=failure,
|
|
241
|
+
pruned_dom=pruned_dom,
|
|
242
|
+
diagnosis=diagnosis,
|
|
243
|
+
fix=fix,
|
|
244
|
+
from_cache=False,
|
|
245
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""AST / Concrete Syntax Tree Surgical Code Patcher.
|
|
2
|
+
|
|
3
|
+
Uses `libcst` to surgically replace broken selectors in test files.
|
|
4
|
+
Unlike Python's standard `ast` module, `libcst` preserves:
|
|
5
|
+
- All original indentation, spaces, and blank lines
|
|
6
|
+
- All inline and block comments (# ...)
|
|
7
|
+
- All docstrings and quote styles (' vs ")
|
|
8
|
+
- All unrelated code and imports
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
from kernhell.core.exceptions import PatcherError, SelectorNotFoundInASTError
|
|
20
|
+
from kernhell.core.models import SelectorFix
|
|
21
|
+
from kernhell.utils.file_utils import backup_file, read_file, write_file
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SurgicalPatcher:
|
|
27
|
+
"""Surgically edits test files using Concrete Syntax Tree parsing."""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def patch_test_file(
|
|
33
|
+
self,
|
|
34
|
+
test_file: Path,
|
|
35
|
+
fix: SelectorFix,
|
|
36
|
+
line_number: Optional[int] = None,
|
|
37
|
+
create_backup: bool = True,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Surgically replace the broken selector in the test file.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
test_file : Path
|
|
44
|
+
Path to the test file on disk.
|
|
45
|
+
fix : SelectorFix
|
|
46
|
+
The proposed fix containing old_selector and new_selector.
|
|
47
|
+
line_number : Optional[int]
|
|
48
|
+
The line number where failure occurred (for disambiguation).
|
|
49
|
+
create_backup : bool
|
|
50
|
+
Whether to write a .kernhell.bak file before mutating.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
str
|
|
55
|
+
The patched source code.
|
|
56
|
+
"""
|
|
57
|
+
resolved = test_file.resolve()
|
|
58
|
+
source_code = read_file(resolved)
|
|
59
|
+
|
|
60
|
+
logger.info(
|
|
61
|
+
"patcher.start",
|
|
62
|
+
file=str(resolved),
|
|
63
|
+
old=fix.old_selector,
|
|
64
|
+
new=fix.new_selector,
|
|
65
|
+
line=line_number,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if create_backup:
|
|
69
|
+
backup_file(resolved)
|
|
70
|
+
|
|
71
|
+
patched_code = self.apply_patch(
|
|
72
|
+
source_code=source_code,
|
|
73
|
+
old_selector=fix.old_selector,
|
|
74
|
+
new_selector=fix.new_selector,
|
|
75
|
+
target_line=line_number,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
write_file(resolved, patched_code)
|
|
79
|
+
logger.info("patcher.complete", file=str(resolved))
|
|
80
|
+
return patched_code
|
|
81
|
+
|
|
82
|
+
def apply_patch(
|
|
83
|
+
self,
|
|
84
|
+
source_code: str,
|
|
85
|
+
old_selector: str,
|
|
86
|
+
new_selector: str,
|
|
87
|
+
target_line: Optional[int] = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Apply CST surgical replacement to source code string.
|
|
90
|
+
|
|
91
|
+
Tries `libcst` first. If `libcst` is unavailable or parsing fails,
|
|
92
|
+
falls back to a high-precision line-scoped surgical string replacement.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
import libcst as cst
|
|
96
|
+
|
|
97
|
+
return self._cst_transform(
|
|
98
|
+
source_code=source_code,
|
|
99
|
+
old_selector=old_selector,
|
|
100
|
+
new_selector=new_selector,
|
|
101
|
+
target_line=target_line,
|
|
102
|
+
)
|
|
103
|
+
except ImportError:
|
|
104
|
+
logger.warning("patcher.libcst_not_available.using_surgical_regex")
|
|
105
|
+
return self._surgical_line_transform(
|
|
106
|
+
source_code=source_code,
|
|
107
|
+
old_selector=old_selector,
|
|
108
|
+
new_selector=new_selector,
|
|
109
|
+
target_line=target_line,
|
|
110
|
+
)
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.warning("patcher.cst_failed.falling_back_to_surgical_regex", error=str(exc))
|
|
113
|
+
return self._surgical_line_transform(
|
|
114
|
+
source_code=source_code,
|
|
115
|
+
old_selector=old_selector,
|
|
116
|
+
new_selector=new_selector,
|
|
117
|
+
target_line=target_line,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _cst_transform(
|
|
121
|
+
self,
|
|
122
|
+
source_code: str,
|
|
123
|
+
old_selector: str,
|
|
124
|
+
new_selector: str,
|
|
125
|
+
target_line: Optional[int] = None,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""CST Transformer implementation."""
|
|
128
|
+
import libcst as cst
|
|
129
|
+
|
|
130
|
+
module = cst.parse_module(source_code)
|
|
131
|
+
|
|
132
|
+
# Clean selector text (strip quotes if raw string)
|
|
133
|
+
raw_old = old_selector.strip("'\"")
|
|
134
|
+
|
|
135
|
+
class SelectorStringReplacer(cst.CSTTransformer):
|
|
136
|
+
def __init__(self) -> None:
|
|
137
|
+
self.replaced_count = 0
|
|
138
|
+
|
|
139
|
+
def leave_SimpleString(
|
|
140
|
+
self,
|
|
141
|
+
original_node: cst.SimpleString,
|
|
142
|
+
updated_node: cst.SimpleString,
|
|
143
|
+
) -> cst.SimpleString:
|
|
144
|
+
# Value includes surrounding quotes, e.g. '"#buy-now"'
|
|
145
|
+
val = original_node.evaluated_value
|
|
146
|
+
if val == raw_old:
|
|
147
|
+
quote = original_node.value[0]
|
|
148
|
+
# Format replacement preserving quote style
|
|
149
|
+
new_val = f"{quote}{new_selector.strip('\'"')}{quote}"
|
|
150
|
+
self.replaced_count += 1
|
|
151
|
+
return updated_node.with_changes(value=new_val)
|
|
152
|
+
return updated_node
|
|
153
|
+
|
|
154
|
+
replacer = SelectorStringReplacer()
|
|
155
|
+
transformed = module.visit(replacer)
|
|
156
|
+
|
|
157
|
+
if replacer.replaced_count == 0:
|
|
158
|
+
# If literal string wasn't found in quotes, try surgical line transform
|
|
159
|
+
return self._surgical_line_transform(
|
|
160
|
+
source_code=source_code,
|
|
161
|
+
old_selector=old_selector,
|
|
162
|
+
new_selector=new_selector,
|
|
163
|
+
target_line=target_line,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return transformed.code
|
|
167
|
+
|
|
168
|
+
def _surgical_line_transform(
|
|
169
|
+
self,
|
|
170
|
+
source_code: str,
|
|
171
|
+
old_selector: str,
|
|
172
|
+
new_selector: str,
|
|
173
|
+
target_line: Optional[int] = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""High-precision line-scoped replacement.
|
|
176
|
+
|
|
177
|
+
Targets only the specific line (or lines near it) where the failure occurred.
|
|
178
|
+
Preserves indentation, comments, and spacing.
|
|
179
|
+
"""
|
|
180
|
+
raw_old = old_selector.strip("'\"")
|
|
181
|
+
raw_new = new_selector.strip("'\"")
|
|
182
|
+
|
|
183
|
+
lines = source_code.splitlines(keepends=True)
|
|
184
|
+
replaced = False
|
|
185
|
+
|
|
186
|
+
if target_line is not None and 1 <= target_line <= len(lines):
|
|
187
|
+
# Try target line first
|
|
188
|
+
idx = target_line - 1
|
|
189
|
+
if raw_old in lines[idx]:
|
|
190
|
+
lines[idx] = lines[idx].replace(raw_old, raw_new)
|
|
191
|
+
replaced = True
|
|
192
|
+
else:
|
|
193
|
+
# Search window of ±5 lines around target_line
|
|
194
|
+
start = max(0, idx - 5)
|
|
195
|
+
end = min(len(lines), idx + 6)
|
|
196
|
+
for i in range(start, end):
|
|
197
|
+
if raw_old in lines[i]:
|
|
198
|
+
lines[i] = lines[i].replace(raw_old, raw_new)
|
|
199
|
+
replaced = True
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
# Fallback: replace first occurrence across whole file
|
|
203
|
+
if not replaced:
|
|
204
|
+
for i, line in enumerate(lines):
|
|
205
|
+
if raw_old in line:
|
|
206
|
+
lines[i] = line.replace(raw_old, raw_new)
|
|
207
|
+
replaced = True
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if not replaced:
|
|
211
|
+
raise SelectorNotFoundInASTError(
|
|
212
|
+
f"Broken selector '{old_selector}' could not be located in test file."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return "".join(lines)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Test Verifier — re-runs tests to confirm that patches actually heal the test.
|
|
2
|
+
|
|
3
|
+
If verification succeeds: marks fix as verified and cleans up backups.
|
|
4
|
+
If verification fails: reverts the test file from backup to prevent corruption.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from kernhell.core.config import KernHellConfig
|
|
14
|
+
from kernhell.core.exceptions import NoFailuresDetectedError, VerificationFailedError
|
|
15
|
+
from kernhell.core.models import SelectorFix
|
|
16
|
+
from kernhell.scanner.runner import ScannerRunner
|
|
17
|
+
from kernhell.utils.file_utils import restore_from_backup
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestVerifier:
|
|
23
|
+
"""Verifies that an applied patch successfully allows the test to pass."""
|
|
24
|
+
|
|
25
|
+
__test__ = False
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: KernHellConfig) -> None:
|
|
28
|
+
self.config = config
|
|
29
|
+
self.scanner = ScannerRunner(config)
|
|
30
|
+
|
|
31
|
+
def verify(
|
|
32
|
+
self,
|
|
33
|
+
test_file: Path,
|
|
34
|
+
fix: SelectorFix,
|
|
35
|
+
revert_on_failure: bool = True,
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Re-run the test to verify whether the fix resolved the failure.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
test_file : Path
|
|
42
|
+
Path to the patched test file.
|
|
43
|
+
fix : SelectorFix
|
|
44
|
+
The fix that was applied.
|
|
45
|
+
revert_on_failure : bool
|
|
46
|
+
Whether to automatically restore the file from backup on failure.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
bool
|
|
51
|
+
True if verification passed (all tests clean).
|
|
52
|
+
"""
|
|
53
|
+
logger.info(
|
|
54
|
+
"verifier.start",
|
|
55
|
+
file=str(test_file),
|
|
56
|
+
new_selector=fix.new_selector,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Re-running the test. If NoFailuresDetectedError is raised, it PASSED!
|
|
61
|
+
self.scanner.run(test_file)
|
|
62
|
+
|
|
63
|
+
# If scanner.run returned a list of failures without raising NoFailuresDetectedError,
|
|
64
|
+
# then there are still failures remaining.
|
|
65
|
+
logger.warning("verifier.still_failing", file=str(test_file))
|
|
66
|
+
if revert_on_failure:
|
|
67
|
+
restore_from_backup(test_file)
|
|
68
|
+
fix.verified = False
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
except NoFailuresDetectedError:
|
|
72
|
+
# The test passed cleanly! Fix verified.
|
|
73
|
+
logger.info("verifier.passed", file=str(test_file))
|
|
74
|
+
fix.verified = True
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning("verifier.exception", file=str(test_file), error=str(exc))
|
|
79
|
+
if revert_on_failure:
|
|
80
|
+
restore_from_backup(test_file)
|
|
81
|
+
fix.verified = False
|
|
82
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pruner package — DOM noise removal."""
|