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,240 @@
1
+ """DOM Pruner — strips noise from raw HTML for LLM consumption.
2
+
3
+ The live DOM captured by Playwright is typically 200–500 KB of raw HTML.
4
+ Sending all of that to an LLM is wasteful, slow, and often exceeds context
5
+ limits. The pruner retains only **semantically meaningful** elements and
6
+ attributes, cutting token usage by 80–90%.
7
+
8
+ **What we keep:**
9
+ - Interactive elements: ``<button>``, ``<a>``, ``<input>``, ``<select>``,
10
+ ``<textarea>``, ``<form>``, ``<label>``
11
+ - Semantic landmarks: ``<header>``, ``<nav>``, ``<main>``, ``<footer>``,
12
+ ``<section>``, ``<article>``, ``<aside>``
13
+ - Content elements: ``<h1>``–``<h6>``, ``<p>``, ``<li>``, ``<td>``, ``<th>``
14
+ - Important attributes: ``id``, ``class``, ``role``, ``aria-*``,
15
+ ``data-testid``, ``data-test``, ``name``, ``type``, ``href``, ``value``,
16
+ ``placeholder``, ``title``, ``alt``
17
+ - Visible text content (trimmed)
18
+
19
+ **What we strip:**
20
+ - ``<script>``, ``<style>``, ``<svg>``, ``<noscript>``, ``<link>``, ``<meta>``
21
+ - Inline ``style`` attributes
22
+ - ``data:`` URIs and base64 content
23
+ - Empty/whitespace-only text nodes
24
+ - Deeply nested non-semantic ``<div>``/``<span>`` wrappers (flattened)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ import structlog
32
+ from selectolax.lexbor import LexborHTMLParser, LexborNode
33
+
34
+ from kernhell.core.models import PrunedDOM
35
+
36
+ logger = structlog.get_logger()
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Configuration
40
+ # ---------------------------------------------------------------------------
41
+
42
+ # Tags to remove entirely (including children)
43
+ _STRIP_TAGS: frozenset[str] = frozenset({
44
+ "script", "style", "svg", "noscript", "link", "meta",
45
+ "iframe", "object", "embed", "template", "picture",
46
+ })
47
+
48
+ # Tags whose children we keep but the tag itself may be meaningful
49
+ _SEMANTIC_TAGS: frozenset[str] = frozenset({
50
+ # Interactive
51
+ "button", "a", "input", "select", "textarea", "form", "label", "option",
52
+ # Landmarks
53
+ "header", "nav", "main", "footer", "section", "article", "aside", "dialog",
54
+ # Content
55
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "ul", "ol",
56
+ "table", "thead", "tbody", "tr", "td", "th",
57
+ "img", "figure", "figcaption", "details", "summary",
58
+ # Structure (kept when they have semantic attributes)
59
+ "div", "span",
60
+ })
61
+
62
+ # Attributes worth preserving
63
+ _KEEP_ATTRS: frozenset[str] = frozenset({
64
+ "id", "class", "role", "name", "type", "href", "value",
65
+ "placeholder", "title", "alt", "for", "action", "method",
66
+ "data-testid", "data-test", "data-cy",
67
+ })
68
+
69
+ # Prefix-based attribute preservation (aria-*)
70
+ _KEEP_ATTR_PREFIXES: tuple[str, ...] = ("aria-", "data-testid", "data-test", "data-cy")
71
+
72
+ # Regex for base64 / data URIs
73
+ _DATA_URI_PATTERN = re.compile(r"data:[^;]+;base64,[A-Za-z0-9+/=]+")
74
+
75
+ # Maximum text length to keep per element
76
+ _MAX_TEXT_LENGTH = 120
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Pruner Implementation
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def _should_keep_attr(name: str) -> bool:
85
+ """Determine if an HTML attribute should be preserved."""
86
+ if name in _KEEP_ATTRS:
87
+ return True
88
+ return any(name.startswith(prefix) for prefix in _KEEP_ATTR_PREFIXES)
89
+
90
+
91
+ def _clean_attr_value(value: str) -> str:
92
+ """Clean an attribute value — strip data URIs, excessive whitespace."""
93
+ if not value:
94
+ return value
95
+ # Remove base64 data URIs
96
+ value = _DATA_URI_PATTERN.sub("[data-uri]", value)
97
+ # Collapse whitespace
98
+ value = " ".join(value.split())
99
+ # Truncate extremely long values (e.g. inline SVG paths)
100
+ if len(value) > 200:
101
+ value = value[:200] + "…"
102
+ return value
103
+
104
+
105
+ def _has_semantic_value(node: LexborNode) -> bool:
106
+ """Check if a div/span has attributes that make it semantically meaningful.
107
+
108
+ Naked ``<div>`` and ``<span>`` wrappers with no id, class, role, or
109
+ aria attributes are noise — we flatten them (keep children, drop the tag).
110
+ """
111
+ if node.tag in ("div", "span"):
112
+ attrs = node.attributes
113
+ if not attrs:
114
+ return False
115
+ return any(_should_keep_attr(name) for name in attrs)
116
+ return True
117
+
118
+
119
+ def _prune_node(node: LexborNode) -> str:
120
+ """Recursively prune a DOM node, returning cleaned HTML."""
121
+ tag = node.tag
122
+
123
+ # Skip stripped tags entirely
124
+ if tag and tag.lower() in _STRIP_TAGS:
125
+ return ""
126
+
127
+ # Handle text nodes
128
+ if tag is None or tag == "-text":
129
+ text = (node.text(deep=False) or "").strip()
130
+ if not text:
131
+ return ""
132
+ # Truncate long text
133
+ if len(text) > _MAX_TEXT_LENGTH:
134
+ text = text[:_MAX_TEXT_LENGTH] + "…"
135
+ return text
136
+
137
+ # For non-semantic div/span: flatten (emit children only)
138
+ if tag.lower() in ("div", "span") and not _has_semantic_value(node):
139
+ children_html = ""
140
+ child = node.child
141
+ while child is not None:
142
+ children_html += _prune_node(child)
143
+ child = child.next
144
+ return children_html
145
+
146
+ # Build the pruned tag with only meaningful attributes
147
+ attrs_str = ""
148
+ raw_attrs = node.attributes
149
+ if raw_attrs:
150
+ kept_attrs: list[str] = []
151
+ for name, value in raw_attrs.items():
152
+ if _should_keep_attr(name):
153
+ clean_value = _clean_attr_value(value or "")
154
+ if name == "style":
155
+ continue # Always strip inline styles
156
+ kept_attrs.append(f'{name}="{clean_value}"')
157
+ if kept_attrs:
158
+ attrs_str = " " + " ".join(kept_attrs)
159
+
160
+ # Recurse into children
161
+ children_html = ""
162
+ child = node.child
163
+ while child is not None:
164
+ children_html += _prune_node(child)
165
+ child = child.next
166
+
167
+ # Self-closing tags
168
+ if tag.lower() in ("input", "img", "br", "hr"):
169
+ return f"<{tag}{attrs_str}/>"
170
+
171
+ # Get direct text content
172
+ text = (node.text(deep=False) or "").strip()
173
+ if text and len(text) > _MAX_TEXT_LENGTH:
174
+ text = text[:_MAX_TEXT_LENGTH] + "…"
175
+
176
+ inner = text + children_html
177
+ if not inner.strip() and tag.lower() not in _SEMANTIC_TAGS:
178
+ return ""
179
+
180
+ return f"<{tag}{attrs_str}>{inner}</{tag}>"
181
+
182
+
183
+ def prune_dom(raw_html: str) -> PrunedDOM:
184
+ """Prune raw HTML to a semantic skeleton for LLM consumption.
185
+
186
+ Parameters
187
+ ----------
188
+ raw_html:
189
+ The full ``page.content()`` HTML captured by Playwright.
190
+
191
+ Returns
192
+ -------
193
+ PrunedDOM
194
+ The pruned HTML along with size reduction metrics.
195
+
196
+ Examples
197
+ --------
198
+ >>> result = prune_dom("<html><body><script>...</script><button id='buy'>Buy</button></body></html>")
199
+ >>> "script" not in result.html
200
+ True
201
+ >>> "buy" in result.html.lower()
202
+ True
203
+ """
204
+ original_size = len(raw_html.encode("utf-8"))
205
+ logger.debug("pruner.start", original_size=original_size)
206
+
207
+ if not raw_html.strip():
208
+ return PrunedDOM(html="", original_size=0, pruned_size=0)
209
+
210
+ parser = LexborHTMLParser(raw_html)
211
+ body = parser.body
212
+
213
+ if body is None:
214
+ # Fallback: try the root
215
+ body = parser.root
216
+ if body is None:
217
+ logger.warning("pruner.no_body")
218
+ return PrunedDOM(html="", original_size=original_size, pruned_size=0)
219
+
220
+ pruned_html = _prune_node(body)
221
+
222
+ # Clean up excessive whitespace and empty lines in the result
223
+ pruned_html = re.sub(r"\n{3,}", "\n\n", pruned_html)
224
+ pruned_html = pruned_html.strip()
225
+
226
+ pruned_size = len(pruned_html.encode("utf-8"))
227
+
228
+ result = PrunedDOM(
229
+ html=pruned_html,
230
+ original_size=original_size,
231
+ pruned_size=pruned_size,
232
+ )
233
+
234
+ logger.info(
235
+ "pruner.done",
236
+ original_size=original_size,
237
+ pruned_size=pruned_size,
238
+ reduction_pct=result.reduction_pct,
239
+ )
240
+ return result
@@ -0,0 +1,5 @@
1
+ """Reporter package — terminal and export formats."""
2
+
3
+ from kernhell.reporter.reporter import HealingReporter
4
+
5
+ __all__ = ["HealingReporter"]
@@ -0,0 +1,124 @@
1
+ """Reporter module — produces beautiful terminal summaries and machine-readable JSON exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from rich import box
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+
15
+ from kernhell.core.models import HealingReport, HealingResult
16
+
17
+ console = Console(stderr=True)
18
+
19
+
20
+ class HealingReporter:
21
+ """Renders healing run results to terminal and files."""
22
+
23
+ @staticmethod
24
+ def render_diff(old_code: str, new_code: str, filename: str = "") -> None:
25
+ """Display a syntax-highlighted diff of changes."""
26
+ import difflib
27
+
28
+ diff_lines = list(
29
+ difflib.unified_diff(
30
+ old_code.splitlines(),
31
+ new_code.splitlines(),
32
+ fromfile=f"a/{filename}" if filename else "original",
33
+ tofile=f"b/{filename}" if filename else "healed",
34
+ lineterm="",
35
+ )
36
+ )
37
+ if not diff_lines:
38
+ return
39
+
40
+ diff_text = "\n".join(diff_lines)
41
+ syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
42
+ panel = Panel(
43
+ syntax,
44
+ title="[bold green]Surgical AST Diff (Comments & Spacing Preserved)[/bold green]",
45
+ border_style="green",
46
+ box=box.ROUNDED,
47
+ )
48
+ console.print(panel)
49
+
50
+ @staticmethod
51
+ def print_summary(report: HealingReport) -> None:
52
+ """Print full healing summary table to terminal."""
53
+ table = Table(
54
+ title=f"KernHell Healing Report: {report.test_file.name}",
55
+ box=box.ROUNDED,
56
+ show_lines=True,
57
+ )
58
+ table.add_column("#", style="dim", width=4)
59
+ table.add_column("Original Selector", style="red")
60
+ table.add_column("Healed Selector", style="bold green")
61
+ table.add_column("Strategy", style="cyan")
62
+ table.add_column("Confidence", justify="right")
63
+ table.add_column("Source", style="yellow")
64
+ table.add_column("Verified", justify="center")
65
+
66
+ for i, res in enumerate(report.results, start=1):
67
+ if res.fix:
68
+ source = "Semantic Cache" if res.from_cache else "AI Healer"
69
+ verified = "✅ YES" if res.fix.verified else "❌ NO"
70
+ conf_str = f"{int(res.fix.confidence * 100)}%"
71
+ table.add_row(
72
+ str(i),
73
+ res.failure.broken_selector,
74
+ res.fix.new_selector,
75
+ res.fix.strategy.value,
76
+ conf_str,
77
+ source,
78
+ verified,
79
+ )
80
+ else:
81
+ table.add_row(
82
+ str(i),
83
+ res.failure.broken_selector,
84
+ "[yellow](SKIPPED)[/yellow]",
85
+ "-",
86
+ "-",
87
+ "-",
88
+ f"[dim]{res.skip_reason[:30]}...[/dim]" if res.skip_reason else "-",
89
+ )
90
+
91
+ console.print(table)
92
+ console.print()
93
+
94
+ @staticmethod
95
+ def export_json(report: HealingReport, output_path: Optional[Path] = None) -> str:
96
+ """Export healing report to structured JSON."""
97
+ data = {
98
+ "test_file": str(report.test_file),
99
+ "total_failures": report.total_failures,
100
+ "healed_count": report.healed_count,
101
+ "skipped_count": report.skipped_count,
102
+ "cache_hits": report.cache_hits,
103
+ "llm_calls": report.llm_calls,
104
+ "verification_passed": report.verification_passed,
105
+ "duration_ms": report.duration_ms,
106
+ "results": [
107
+ {
108
+ "broken_selector": r.failure.broken_selector,
109
+ "line_number": r.failure.line_number,
110
+ "healed_selector": r.fix.new_selector if r.fix else None,
111
+ "strategy": r.fix.strategy.value if r.fix else None,
112
+ "confidence": r.fix.confidence if r.fix else None,
113
+ "from_cache": r.from_cache,
114
+ "skipped": r.skipped,
115
+ "skip_reason": r.skip_reason,
116
+ "verified": r.fix.verified if r.fix else False,
117
+ }
118
+ for r in report.results
119
+ ],
120
+ }
121
+ json_str = json.dumps(data, indent=2)
122
+ if output_path:
123
+ output_path.write_text(json_str, encoding="utf-8")
124
+ return json_str
@@ -0,0 +1 @@
1
+ """Scanner package — test execution and failure capture."""
@@ -0,0 +1,276 @@
1
+ """Playwright error output parser.
2
+
3
+ Parses structured error output from Playwright/pytest to extract:
4
+ - The broken selector string
5
+ - The line number in the test file where the failure occurred
6
+ - The type of selector (CSS, XPath, text, role, etc.)
7
+ - The error type (timeout, not found, strict mode, etc.)
8
+
9
+ This parser handles the standard Playwright error format from both
10
+ ``pytest-playwright`` and raw Playwright Python output.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import structlog
20
+
21
+ from kernhell.core.models import ErrorType, FailureContext, SelectorType
22
+ from kernhell.utils.file_utils import extract_surrounding_lines, read_file
23
+
24
+ logger = structlog.get_logger()
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Regex patterns for Playwright error parsing
28
+ # ---------------------------------------------------------------------------
29
+
30
+ # Matches: "locator.click: Timeout 5000ms exceeded."
31
+ _TIMEOUT_PATTERN = re.compile(
32
+ r"(locator\.\w+|page\.\w+):\s*Timeout\s+\d+ms\s+exceeded",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+ # Matches: "waiting for locator("button#buy-now")" or 'locator("#buy-now")'
37
+ _LOCATOR_PATTERN = re.compile(
38
+ r"""locator\(\s*["'](.+?)["']\s*\)""",
39
+ re.IGNORECASE,
40
+ )
41
+
42
+ # Matches: page.click("#buy-now") or page.fill("input.email", ...)
43
+ _PAGE_ACTION_PATTERN = re.compile(
44
+ r"""page\.(?:click|fill|type|check|uncheck|select_option|hover|dblclick|focus)\(\s*["'](.+?)["']""",
45
+ re.IGNORECASE,
46
+ )
47
+
48
+ # Matches: page.get_by_role("button", name="Buy Now")
49
+ _GET_BY_ROLE_PATTERN = re.compile(
50
+ r"""page\.get_by_role\(\s*["'](\w+)["'](?:\s*,\s*name\s*=\s*["'](.+?)["'])?\s*\)""",
51
+ re.IGNORECASE,
52
+ )
53
+
54
+ # Matches: page.get_by_text("Buy Now")
55
+ _GET_BY_TEXT_PATTERN = re.compile(
56
+ r"""page\.get_by_text\(\s*["'](.+?)["']\s*\)""",
57
+ re.IGNORECASE,
58
+ )
59
+
60
+ # Matches: page.get_by_test_id("checkout-btn")
61
+ _GET_BY_TESTID_PATTERN = re.compile(
62
+ r"""page\.get_by_test_id\(\s*["'](.+?)["']\s*\)""",
63
+ re.IGNORECASE,
64
+ )
65
+
66
+ # Matches: page.get_by_label("Email")
67
+ _GET_BY_LABEL_PATTERN = re.compile(
68
+ r"""page\.get_by_label\(\s*["'](.+?)["']\s*\)""",
69
+ re.IGNORECASE,
70
+ )
71
+
72
+ # Matches: page.get_by_placeholder("Enter email")
73
+ _GET_BY_PLACEHOLDER_PATTERN = re.compile(
74
+ r"""page\.get_by_placeholder\(\s*["'](.+?)["']\s*\)""",
75
+ re.IGNORECASE,
76
+ )
77
+
78
+ # Matches pytest traceback lines like: "tests/test_checkout.py:15: TimeoutError"
79
+ _PYTEST_LOCATION_PATTERN = re.compile(
80
+ r"^(.+\.py):(\d+):\s*(TimeoutError|Error|AssertionError)",
81
+ re.MULTILINE,
82
+ )
83
+
84
+ # Matches: "strict mode violation" errors
85
+ _STRICT_MODE_PATTERN = re.compile(
86
+ r"strict\s+mode\s+violation",
87
+ re.IGNORECASE,
88
+ )
89
+
90
+
91
+ @dataclass
92
+ class ParsedFailure:
93
+ """Intermediate result from parsing a single Playwright error block."""
94
+
95
+ selector: str = ""
96
+ selector_type: SelectorType = SelectorType.UNKNOWN
97
+ error_type: ErrorType = ErrorType.OTHER
98
+ line_number: int = 0
99
+ error_message: str = ""
100
+
101
+
102
+ def classify_selector(selector: str) -> SelectorType:
103
+ """Determine the type of a Playwright selector string.
104
+
105
+ Examples
106
+ --------
107
+ >>> classify_selector("#buy-now")
108
+ SelectorType.CSS
109
+ >>> classify_selector("//div[@class='cart']")
110
+ SelectorType.XPATH
111
+ >>> classify_selector("text=Buy Now")
112
+ SelectorType.TEXT
113
+ """
114
+ s = selector.strip()
115
+ if s.startswith("//") or s.startswith("(//"):
116
+ return SelectorType.XPATH
117
+ if s.startswith("text=") or s.startswith('"') or s.startswith("'"):
118
+ return SelectorType.TEXT
119
+ if s.startswith("role="):
120
+ return SelectorType.ROLE
121
+ if s.startswith("data-testid=") or s.startswith("[data-testid"):
122
+ return SelectorType.TEST_ID
123
+ # Default to CSS for #id, .class, tag, [attr] patterns
124
+ return SelectorType.CSS
125
+
126
+
127
+ def _detect_error_type(text: str) -> ErrorType:
128
+ """Classify the Playwright error type from the error output."""
129
+ if _STRICT_MODE_PATTERN.search(text):
130
+ return ErrorType.STRICT_MODE
131
+ if _TIMEOUT_PATTERN.search(text):
132
+ return ErrorType.TIMEOUT
133
+ if "waiting for" in text.lower() or "not found" in text.lower():
134
+ return ErrorType.NOT_FOUND
135
+ if "navigation" in text.lower():
136
+ return ErrorType.NAVIGATION
137
+ return ErrorType.OTHER
138
+
139
+
140
+ def _extract_selector(text: str) -> tuple[str, SelectorType]:
141
+ """Extract the broken selector and its type from error output.
142
+
143
+ Tries multiple patterns in priority order and returns the first match.
144
+ """
145
+ # Try get_by_role first (most semantic)
146
+ m = _GET_BY_ROLE_PATTERN.search(text)
147
+ if m:
148
+ role = m.group(1)
149
+ name = m.group(2) or ""
150
+ selector = f'get_by_role("{role}", name="{name}")' if name else f'get_by_role("{role}")'
151
+ return selector, SelectorType.ROLE
152
+
153
+ # get_by_text
154
+ m = _GET_BY_TEXT_PATTERN.search(text)
155
+ if m:
156
+ return f'get_by_text("{m.group(1)}")', SelectorType.TEXT
157
+
158
+ # get_by_test_id
159
+ m = _GET_BY_TESTID_PATTERN.search(text)
160
+ if m:
161
+ return f'get_by_test_id("{m.group(1)}")', SelectorType.TEST_ID
162
+
163
+ # get_by_label
164
+ m = _GET_BY_LABEL_PATTERN.search(text)
165
+ if m:
166
+ return f'get_by_label("{m.group(1)}")', SelectorType.LABEL
167
+
168
+ # get_by_placeholder
169
+ m = _GET_BY_PLACEHOLDER_PATTERN.search(text)
170
+ if m:
171
+ return f'get_by_placeholder("{m.group(1)}")', SelectorType.PLACEHOLDER
172
+
173
+ # locator("selector")
174
+ m = _LOCATOR_PATTERN.search(text)
175
+ if m:
176
+ sel = m.group(1)
177
+ return sel, classify_selector(sel)
178
+
179
+ # page.click("selector") etc.
180
+ m = _PAGE_ACTION_PATTERN.search(text)
181
+ if m:
182
+ sel = m.group(1)
183
+ return sel, classify_selector(sel)
184
+
185
+ return "", SelectorType.UNKNOWN
186
+
187
+
188
+ def _extract_line_number(error_text: str, test_file: Path) -> int:
189
+ """Extract the failing line number from pytest traceback output."""
190
+ filename = test_file.name
191
+ for m in _PYTEST_LOCATION_PATTERN.finditer(error_text):
192
+ if filename in m.group(1):
193
+ return int(m.group(2))
194
+
195
+ # Fallback: look for any ":<number>:" pattern near the test filename
196
+ fallback = re.search(
197
+ rf"{re.escape(filename)}:(\d+)",
198
+ error_text,
199
+ )
200
+ if fallback:
201
+ return int(fallback.group(1))
202
+
203
+ return 0
204
+
205
+
206
+ def parse_playwright_errors(
207
+ error_output: str,
208
+ test_file: Path,
209
+ ) -> list[FailureContext]:
210
+ """Parse Playwright/pytest error output into structured FailureContexts.
211
+
212
+ Parameters
213
+ ----------
214
+ error_output:
215
+ The combined stdout+stderr from a failed ``pytest`` run.
216
+ test_file:
217
+ Path to the test file that was executed.
218
+
219
+ Returns
220
+ -------
221
+ list[FailureContext]
222
+ One context per detected selector failure. May be empty if no
223
+ Playwright selector errors are found in the output.
224
+ """
225
+ logger.debug("parser.start", test_file=str(test_file), output_len=len(error_output))
226
+
227
+ failures: list[FailureContext] = []
228
+
229
+ # Split on "FAILED" or "ERROR" markers to handle multiple failures.
230
+ # Playwright test output typically has sections separated by "_ _ _" or "==="
231
+ sections = re.split(r"_{3,}|={3,}", error_output)
232
+
233
+ # Also try parsing the whole output as a single failure
234
+ if len(sections) <= 1:
235
+ sections = [error_output]
236
+
237
+ seen_selectors: set[str] = set()
238
+
239
+ for section in sections:
240
+ selector, sel_type = _extract_selector(section)
241
+ if not selector or selector in seen_selectors:
242
+ continue
243
+
244
+ seen_selectors.add(selector)
245
+ error_type = _detect_error_type(section)
246
+ line_number = _extract_line_number(section, test_file)
247
+
248
+ # Read surrounding code context if we have a valid line number
249
+ surrounding_code = ""
250
+ if line_number > 0 and test_file.is_file():
251
+ try:
252
+ content = read_file(test_file)
253
+ surrounding_code = extract_surrounding_lines(content, line_number)
254
+ except Exception:
255
+ logger.warning("parser.surrounding_code.failed", line=line_number)
256
+
257
+ ctx = FailureContext(
258
+ test_file=test_file,
259
+ line_number=line_number,
260
+ broken_selector=selector,
261
+ selector_type=sel_type,
262
+ error_message=section.strip()[:500], # Cap at 500 chars
263
+ error_type=error_type,
264
+ surrounding_code=surrounding_code,
265
+ )
266
+ failures.append(ctx)
267
+ logger.info(
268
+ "parser.failure_found",
269
+ selector=selector,
270
+ selector_type=sel_type.value,
271
+ line=line_number,
272
+ error_type=error_type.value,
273
+ )
274
+
275
+ logger.debug("parser.done", failures_found=len(failures))
276
+ return failures