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
@@ -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."""