k-cli-for-devs 1.0.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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
"""
|
|
2
|
+
incident_triage.py - Intelligent Incident Triage & Auto-Heal Engine for K-CLI
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
1. Multi-Language Crash & Traceback Parser:
|
|
6
|
+
- Python Tracebacks (standard tracebacks, pytest failures, IPython traces)
|
|
7
|
+
- Node.js / TypeScript Stack Traces (V8 errors, uncaught exceptions)
|
|
8
|
+
- Rust Panics (panic messages, location headers, backtraces)
|
|
9
|
+
- Go Panics (goroutine stack traces, runtime errors)
|
|
10
|
+
- C++ Crashes (ASAN/UBSAN reports, Segmentation Faults, GDB core dumps, std::terminate)
|
|
11
|
+
- Docker Crash Logs (OOMKilled code 137, container exit codes, daemon errors)
|
|
12
|
+
- GitHub Actions CI Error Logs (##[error] annotations, step exit codes, workflow traces)
|
|
13
|
+
2. AST Symbol & Local Codebase Cross-Referencing:
|
|
14
|
+
- Filters 3rd-party / stdlib frames and resolves local repository culprit files.
|
|
15
|
+
- AST node traversal to identify enclosing functions, classes, and methods.
|
|
16
|
+
- Surrounding code snippet extraction with line context.
|
|
17
|
+
3. Deterministic & AI-Augmented Root Cause Analysis:
|
|
18
|
+
- Explains defect origins, failure conditions, and generates reproduction steps.
|
|
19
|
+
- Severity classification (CRITICAL, HIGH, MEDIUM, LOW).
|
|
20
|
+
4. Auto-Heal Incident Loop:
|
|
21
|
+
- Uses surgical SEARCH/REPLACE blocks via `patcher.py`.
|
|
22
|
+
- Generates and executes regression test suites via `verifier.py`.
|
|
23
|
+
- Confirms test passage, guarantees syntax validity, and automatically rolls back on failure.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import textwrap
|
|
35
|
+
import uuid
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Safe relative imports for K-CLI components
|
|
44
|
+
try:
|
|
45
|
+
from k_cli.git.verifier import CodeExtractor, VerificationResult, Verifier
|
|
46
|
+
except (ModuleNotFoundError, ImportError):
|
|
47
|
+
try:
|
|
48
|
+
from verifier import CodeExtractor, VerificationResult, Verifier
|
|
49
|
+
except (ModuleNotFoundError, ImportError):
|
|
50
|
+
CodeExtractor = None # type: ignore
|
|
51
|
+
VerificationResult = None # type: ignore
|
|
52
|
+
Verifier = None # type: ignore
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from k_cli.git.patcher import BatchPatchResult, FilePatch, PatchResult, Patcher
|
|
56
|
+
except (ModuleNotFoundError, ImportError):
|
|
57
|
+
try:
|
|
58
|
+
from patcher import BatchPatchResult, FilePatch, PatchResult, Patcher
|
|
59
|
+
except (ModuleNotFoundError, ImportError):
|
|
60
|
+
BatchPatchResult = None # type: ignore
|
|
61
|
+
FilePatch = None # type: ignore
|
|
62
|
+
PatchResult = None # type: ignore
|
|
63
|
+
Patcher = None # type: ignore
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from k_cli.git.repo_map import RepoMap
|
|
67
|
+
except (ModuleNotFoundError, ImportError):
|
|
68
|
+
try:
|
|
69
|
+
from repo_map import RepoMap
|
|
70
|
+
except (ModuleNotFoundError, ImportError):
|
|
71
|
+
RepoMap = None # type: ignore
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
from k_cli.core.llm_driver import LLMDriver, ProviderType
|
|
75
|
+
except (ModuleNotFoundError, ImportError):
|
|
76
|
+
try:
|
|
77
|
+
from k_cli.core.llm_driver import LLMDriver, ProviderType
|
|
78
|
+
except (ModuleNotFoundError, ImportError):
|
|
79
|
+
LLMDriver = None # type: ignore
|
|
80
|
+
ProviderType = None # type: ignore
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LogType(str, Enum):
|
|
84
|
+
"""Supported log and crash trace formats."""
|
|
85
|
+
PYTHON_TRACEBACK = "python_traceback"
|
|
86
|
+
NODE_STACK_TRACE = "node_stack_trace"
|
|
87
|
+
RUST_PANIC = "rust_panic"
|
|
88
|
+
GO_PANIC = "go_panic"
|
|
89
|
+
CPP_CRASH = "cpp_crash"
|
|
90
|
+
DOCKER_CRASH = "docker_crash"
|
|
91
|
+
GITHUB_ACTIONS_CI = "github_actions_ci"
|
|
92
|
+
GENERIC_ERROR = "generic_error"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class StackFrame:
|
|
97
|
+
"""Represents a single parsed stack frame from a crash log or traceback."""
|
|
98
|
+
file_path: str
|
|
99
|
+
line_number: Optional[int] = None
|
|
100
|
+
column_number: Optional[int] = None
|
|
101
|
+
function_name: Optional[str] = None
|
|
102
|
+
code_line: Optional[str] = None
|
|
103
|
+
is_local_repo: bool = False
|
|
104
|
+
resolved_path: Optional[str] = None
|
|
105
|
+
ast_symbol: Optional[str] = None
|
|
106
|
+
raw_frame: str = ""
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
109
|
+
"""Serializes StackFrame to dictionary."""
|
|
110
|
+
return {
|
|
111
|
+
"file_path": self.file_path,
|
|
112
|
+
"line_number": self.line_number,
|
|
113
|
+
"column_number": self.column_number,
|
|
114
|
+
"function_name": self.function_name,
|
|
115
|
+
"code_line": self.code_line,
|
|
116
|
+
"is_local_repo": self.is_local_repo,
|
|
117
|
+
"resolved_path": self.resolved_path,
|
|
118
|
+
"ast_symbol": self.ast_symbol,
|
|
119
|
+
"raw_frame": self.raw_frame,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class IncidentReport:
|
|
125
|
+
"""Structured report detailing a parsed incident, culprit, root cause, and fix."""
|
|
126
|
+
incident_id: str
|
|
127
|
+
log_type: str
|
|
128
|
+
exception_type: str
|
|
129
|
+
error_message: str
|
|
130
|
+
culprit_file: Optional[str] = None
|
|
131
|
+
culprit_line: Optional[int] = None
|
|
132
|
+
culprit_column: Optional[int] = None
|
|
133
|
+
culprit_symbol: Optional[str] = None
|
|
134
|
+
stack_frames: List[StackFrame] = field(default_factory=list)
|
|
135
|
+
root_cause_analysis: str = ""
|
|
136
|
+
reproduction_steps: List[str] = field(default_factory=list)
|
|
137
|
+
code_snippets: Dict[str, str] = field(default_factory=dict)
|
|
138
|
+
suggested_fix: Optional[str] = None
|
|
139
|
+
severity: str = "HIGH" # CRITICAL, HIGH, MEDIUM, LOW
|
|
140
|
+
raw_log: str = ""
|
|
141
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def root_cause(self) -> str:
|
|
145
|
+
return self.root_cause_analysis
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def error_type(self) -> str:
|
|
149
|
+
return self.exception_type
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def status(self) -> str:
|
|
153
|
+
return "ANALYZED"
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
156
|
+
"""Serializes IncidentReport to dictionary."""
|
|
157
|
+
return {
|
|
158
|
+
"incident_id": self.incident_id,
|
|
159
|
+
"log_type": self.log_type,
|
|
160
|
+
"exception_type": self.exception_type,
|
|
161
|
+
"error_message": self.error_message,
|
|
162
|
+
"culprit_file": self.culprit_file,
|
|
163
|
+
"culprit_line": self.culprit_line,
|
|
164
|
+
"culprit_column": self.culprit_column,
|
|
165
|
+
"culprit_symbol": self.culprit_symbol,
|
|
166
|
+
"stack_frames": [f.to_dict() for f in self.stack_frames],
|
|
167
|
+
"root_cause_analysis": self.root_cause_analysis,
|
|
168
|
+
"reproduction_steps": self.reproduction_steps,
|
|
169
|
+
"code_snippets": self.code_snippets,
|
|
170
|
+
"suggested_fix": self.suggested_fix,
|
|
171
|
+
"severity": self.severity,
|
|
172
|
+
"raw_log": self.raw_log,
|
|
173
|
+
"metadata": self.metadata,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def to_markdown(self) -> str:
|
|
177
|
+
"""Renders the IncidentReport as a clean markdown document."""
|
|
178
|
+
lines = [
|
|
179
|
+
f"# Incident Report: `{self.incident_id}`",
|
|
180
|
+
f"- **Severity**: `{self.severity}`",
|
|
181
|
+
f"- **Log Format**: `{self.log_type}`",
|
|
182
|
+
f"- **Exception / Error**: `{self.exception_type}`: {self.error_message}",
|
|
183
|
+
]
|
|
184
|
+
if self.culprit_file:
|
|
185
|
+
loc = f"`{self.culprit_file}`"
|
|
186
|
+
if self.culprit_line:
|
|
187
|
+
loc += f":{self.culprit_line}"
|
|
188
|
+
if self.culprit_symbol:
|
|
189
|
+
loc += f" (`{self.culprit_symbol}`)"
|
|
190
|
+
lines.append(f"- **Culprit Location**: {loc}")
|
|
191
|
+
|
|
192
|
+
lines.append("")
|
|
193
|
+
lines.append("## Root Cause Analysis")
|
|
194
|
+
lines.append(self.root_cause_analysis or "No root cause analysis available.")
|
|
195
|
+
|
|
196
|
+
if self.reproduction_steps:
|
|
197
|
+
lines.append("")
|
|
198
|
+
lines.append("## Reproduction Steps")
|
|
199
|
+
for i, step in enumerate(self.reproduction_steps, 1):
|
|
200
|
+
lines.append(f"{i}. {step}")
|
|
201
|
+
|
|
202
|
+
if self.code_snippets:
|
|
203
|
+
lines.append("")
|
|
204
|
+
lines.append("## Code Context")
|
|
205
|
+
for fpath, snippet in self.code_snippets.items():
|
|
206
|
+
lines.append(f"### `{fpath}`")
|
|
207
|
+
lines.append("```")
|
|
208
|
+
lines.append(snippet)
|
|
209
|
+
lines.append("```")
|
|
210
|
+
|
|
211
|
+
if self.suggested_fix:
|
|
212
|
+
lines.append("")
|
|
213
|
+
lines.append("## Suggested Fix")
|
|
214
|
+
lines.append(self.suggested_fix)
|
|
215
|
+
|
|
216
|
+
return "\n".join(lines)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class IncidentHealResult:
|
|
221
|
+
"""Structured result of an automated incident repair attempt."""
|
|
222
|
+
success: bool
|
|
223
|
+
incident_id: str
|
|
224
|
+
patch_applied: bool = False
|
|
225
|
+
regression_test_generated: bool = False
|
|
226
|
+
test_passed: bool = False
|
|
227
|
+
modified_files: List[str] = field(default_factory=list)
|
|
228
|
+
patch_diff: str = ""
|
|
229
|
+
regression_test_code: str = ""
|
|
230
|
+
regression_test_file: Optional[str] = None
|
|
231
|
+
error_message: str = ""
|
|
232
|
+
iterations: int = 1
|
|
233
|
+
verification_result: Optional[Any] = None
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
236
|
+
"""Serializes IncidentHealResult to dictionary."""
|
|
237
|
+
return {
|
|
238
|
+
"success": self.success,
|
|
239
|
+
"incident_id": self.incident_id,
|
|
240
|
+
"patch_applied": self.patch_applied,
|
|
241
|
+
"regression_test_generated": self.regression_test_generated,
|
|
242
|
+
"test_passed": self.test_passed,
|
|
243
|
+
"modified_files": self.modified_files,
|
|
244
|
+
"patch_diff": self.patch_diff,
|
|
245
|
+
"regression_test_code": self.regression_test_code,
|
|
246
|
+
"regression_test_file": self.regression_test_file,
|
|
247
|
+
"error_message": self.error_message,
|
|
248
|
+
"iterations": self.iterations,
|
|
249
|
+
"verification_result": (
|
|
250
|
+
self.verification_result.to_dict()
|
|
251
|
+
if hasattr(self.verification_result, "to_dict")
|
|
252
|
+
else str(self.verification_result)
|
|
253
|
+
if self.verification_result
|
|
254
|
+
else None
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class IncidentTriageEngine:
|
|
260
|
+
"""
|
|
261
|
+
Intelligent incident triage, multi-language log analysis, and automated healing engine.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(self, repo_path: str = ".") -> None:
|
|
265
|
+
self.repo_path = Path(repo_path).resolve()
|
|
266
|
+
|
|
267
|
+
# =========================================================================
|
|
268
|
+
# 1. Multi-Language Log & Trace Parsers
|
|
269
|
+
# =========================================================================
|
|
270
|
+
|
|
271
|
+
def _parse_python_traceback(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
272
|
+
"""
|
|
273
|
+
Parses standard Python tracebacks, pytest failures, and IPython error traces.
|
|
274
|
+
"""
|
|
275
|
+
frames: List[StackFrame] = []
|
|
276
|
+
exc_type = "PythonException"
|
|
277
|
+
exc_msg = ""
|
|
278
|
+
metadata: Dict[str, Any] = {}
|
|
279
|
+
|
|
280
|
+
# 1. Standard traceback frames: File "...", line X, in Y
|
|
281
|
+
frame_pattern = re.compile(
|
|
282
|
+
r'File\s+["\'](?P<file>[^"\']+)["\'],\s+line\s+(?P<line>\d+)(?:,\s+in\s+(?P<func>[^\n\r]+))?'
|
|
283
|
+
r'(?:\r?\n\s+(?P<code>[^\r\n]+))?',
|
|
284
|
+
re.MULTILINE,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
for match in frame_pattern.finditer(text):
|
|
288
|
+
fpath = match.group("file")
|
|
289
|
+
line = int(match.group("line"))
|
|
290
|
+
func = match.group("func") or "<unknown>"
|
|
291
|
+
code = (match.group("code") or "").strip()
|
|
292
|
+
frames.append(
|
|
293
|
+
StackFrame(
|
|
294
|
+
file_path=fpath,
|
|
295
|
+
line_number=line,
|
|
296
|
+
function_name=func,
|
|
297
|
+
code_line=code,
|
|
298
|
+
raw_frame=match.group(0),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# 2. Pytest style failures: tests/test_foo.py:42: ValueError
|
|
303
|
+
if not frames:
|
|
304
|
+
pytest_pattern = re.compile(
|
|
305
|
+
r'^(?P<file>[A-Za-z0-9_.\-/]+\.py):(?P<line>\d+):\s+(?:in\s+(?P<func>[^\n\r]+)\r?\n)?(?P<msg>.*)',
|
|
306
|
+
re.MULTILINE,
|
|
307
|
+
)
|
|
308
|
+
for match in pytest_pattern.finditer(text):
|
|
309
|
+
fpath = match.group("file")
|
|
310
|
+
line = int(match.group("line"))
|
|
311
|
+
func = match.group("func") or "<test>"
|
|
312
|
+
frames.append(
|
|
313
|
+
StackFrame(
|
|
314
|
+
file_path=fpath,
|
|
315
|
+
line_number=line,
|
|
316
|
+
function_name=func,
|
|
317
|
+
raw_frame=match.group(0),
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# 3. Exception type and message detection:
|
|
322
|
+
exc_pattern = re.compile(
|
|
323
|
+
r'^(?:E\s+)?(?P<type>[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception|Exit|Interrupt|Fault|Warning|AssertionError)):\s*(?P<msg>.*)$',
|
|
324
|
+
re.MULTILINE,
|
|
325
|
+
)
|
|
326
|
+
exc_matches = list(exc_pattern.finditer(text))
|
|
327
|
+
if exc_matches:
|
|
328
|
+
last_exc = exc_matches[-1]
|
|
329
|
+
exc_type = last_exc.group("type")
|
|
330
|
+
exc_msg = last_exc.group("msg").strip()
|
|
331
|
+
else:
|
|
332
|
+
pytest_fail_pattern = re.compile(
|
|
333
|
+
r'FAILED\s+[^\s]+::[^\s]+\s+-\s+(?:(?P<type>[A-Za-z0-9_]+Error|AssertionError):\s*)?(?P<msg>.*)',
|
|
334
|
+
re.MULTILINE,
|
|
335
|
+
)
|
|
336
|
+
fail_match = pytest_fail_pattern.search(text)
|
|
337
|
+
if fail_match:
|
|
338
|
+
exc_type = fail_match.group("type") or "AssertionError"
|
|
339
|
+
exc_msg = fail_match.group("msg").strip()
|
|
340
|
+
|
|
341
|
+
if frames or "Traceback (most recent call last)" in text or exc_matches:
|
|
342
|
+
return exc_type, exc_msg, frames, metadata
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def _parse_nodejs_stacktrace(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
346
|
+
"""
|
|
347
|
+
Parses Node.js / JavaScript / TypeScript V8 stack traces.
|
|
348
|
+
"""
|
|
349
|
+
frames: List[StackFrame] = []
|
|
350
|
+
exc_type = "JavaScriptError"
|
|
351
|
+
exc_msg = ""
|
|
352
|
+
metadata: Dict[str, Any] = {}
|
|
353
|
+
|
|
354
|
+
# Error header: TypeError: Cannot read properties of undefined (reading 'foo')
|
|
355
|
+
header_pattern = re.compile(
|
|
356
|
+
r'^(?P<type>[A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception)):(?:\s*(?P<msg>[^\r\n]*))?',
|
|
357
|
+
re.MULTILINE,
|
|
358
|
+
)
|
|
359
|
+
h_match = header_pattern.search(text)
|
|
360
|
+
if h_match:
|
|
361
|
+
exc_type = h_match.group("type")
|
|
362
|
+
exc_msg = (h_match.group("msg") or "").strip()
|
|
363
|
+
|
|
364
|
+
frame_pattern = re.compile(
|
|
365
|
+
r'^\s*at\s+(?:(?P<func>[^(\n\r]+?)\s+\()?(?P<file>(?:[A-Za-z]:[\\/]|/|[.\w\-_/]+)[^:)\n\r]+):(?P<line>\d+)(?::(?P<col>\d+))?\)?',
|
|
366
|
+
re.MULTILINE,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
for match in frame_pattern.finditer(text):
|
|
370
|
+
func = (match.group("func") or "<anonymous>").strip()
|
|
371
|
+
fpath = match.group("file").strip()
|
|
372
|
+
line = int(match.group("line"))
|
|
373
|
+
col = int(match.group("col")) if match.group("col") else None
|
|
374
|
+
frames.append(
|
|
375
|
+
StackFrame(
|
|
376
|
+
file_path=fpath,
|
|
377
|
+
line_number=line,
|
|
378
|
+
column_number=col,
|
|
379
|
+
function_name=func,
|
|
380
|
+
raw_frame=match.group(0),
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if frames or (h_match and ("\n at " in text or "\nat " in text)):
|
|
385
|
+
return exc_type, exc_msg, frames, metadata
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
def _parse_rust_panic(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
389
|
+
"""
|
|
390
|
+
Parses Rust panic output and backtraces.
|
|
391
|
+
"""
|
|
392
|
+
frames: List[StackFrame] = []
|
|
393
|
+
exc_type = "RustPanic"
|
|
394
|
+
exc_msg = ""
|
|
395
|
+
metadata: Dict[str, Any] = {}
|
|
396
|
+
|
|
397
|
+
panic_pattern = re.compile(
|
|
398
|
+
r"thread\s+'(?P<thread>[^']+)'\s+panicked\s+at\s+(?:'(?P<msg>[^']+)'(?:,\s+(?P<file>[^:]+):(?P<line>\d+):(?P<col>\d+))?|(?P<file2>[^:]+):(?P<line2>\d+):(?P<col2>\d+):\s*(?P<msg2>.*))",
|
|
399
|
+
re.MULTILINE,
|
|
400
|
+
)
|
|
401
|
+
p_match = panic_pattern.search(text)
|
|
402
|
+
if p_match:
|
|
403
|
+
metadata["thread"] = p_match.group("thread")
|
|
404
|
+
msg = p_match.group("msg") or p_match.group("msg2") or "Rust runtime panic"
|
|
405
|
+
exc_msg = msg.strip()
|
|
406
|
+
fpath = p_match.group("file") or p_match.group("file2")
|
|
407
|
+
line_str = p_match.group("line") or p_match.group("line2")
|
|
408
|
+
col_str = p_match.group("col") or p_match.group("col2")
|
|
409
|
+
if fpath and line_str:
|
|
410
|
+
frames.append(
|
|
411
|
+
StackFrame(
|
|
412
|
+
file_path=fpath,
|
|
413
|
+
line_number=int(line_str),
|
|
414
|
+
column_number=int(col_str) if col_str else None,
|
|
415
|
+
function_name="panic",
|
|
416
|
+
raw_frame=p_match.group(0),
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
bt_pattern = re.compile(
|
|
421
|
+
r'^\s*(?P<idx>\d+):\s+(?P<func>[^\r\n]+)(?:\r?\n\s+at\s+(?P<file>[^:\r\n]+):(?P<line>\d+)(?::(?P<col>\d+))?)?',
|
|
422
|
+
re.MULTILINE,
|
|
423
|
+
)
|
|
424
|
+
for match in bt_pattern.finditer(text):
|
|
425
|
+
func = match.group("func").strip()
|
|
426
|
+
fpath = match.group("file")
|
|
427
|
+
line = int(match.group("line")) if match.group("line") else None
|
|
428
|
+
col = int(match.group("col")) if match.group("col") else None
|
|
429
|
+
if fpath and line:
|
|
430
|
+
frames.append(
|
|
431
|
+
StackFrame(
|
|
432
|
+
file_path=fpath.strip(),
|
|
433
|
+
line_number=line,
|
|
434
|
+
column_number=col,
|
|
435
|
+
function_name=func,
|
|
436
|
+
raw_frame=match.group(0),
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if p_match or ("panicked at" in text and frames):
|
|
441
|
+
return exc_type, exc_msg, frames, metadata
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
def _parse_go_panic(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
445
|
+
"""
|
|
446
|
+
Parses Go panics and goroutine stack traces.
|
|
447
|
+
"""
|
|
448
|
+
frames: List[StackFrame] = []
|
|
449
|
+
exc_type = "GoPanic"
|
|
450
|
+
exc_msg = ""
|
|
451
|
+
metadata: Dict[str, Any] = {}
|
|
452
|
+
|
|
453
|
+
panic_pattern = re.compile(
|
|
454
|
+
r'^panic:\s+(?:runtime error:\s+)?(?P<msg>[^\r\n]+)',
|
|
455
|
+
re.MULTILINE,
|
|
456
|
+
)
|
|
457
|
+
p_match = panic_pattern.search(text)
|
|
458
|
+
if p_match:
|
|
459
|
+
exc_msg = p_match.group("msg").strip()
|
|
460
|
+
if "runtime error" in p_match.group(0):
|
|
461
|
+
exc_type = "GoRuntimeError"
|
|
462
|
+
|
|
463
|
+
go_frame_pattern = re.compile(
|
|
464
|
+
r'^(?P<func>[A-Za-z0-9_./*-]+)\([^)\r\n]*\)\r?\n\s+(?P<file>(?:/|[A-Za-z]:|[.\w\-_/]+)[^:\r\n]+):(?P<line>\d+)(?:\s+\+0x[0-9a-fA-F]+)?',
|
|
465
|
+
re.MULTILINE,
|
|
466
|
+
)
|
|
467
|
+
for match in go_frame_pattern.finditer(text):
|
|
468
|
+
func = match.group("func").strip()
|
|
469
|
+
fpath = match.group("file").strip()
|
|
470
|
+
line = int(match.group("line"))
|
|
471
|
+
frames.append(
|
|
472
|
+
StackFrame(
|
|
473
|
+
file_path=fpath,
|
|
474
|
+
line_number=line,
|
|
475
|
+
function_name=func,
|
|
476
|
+
raw_frame=match.group(0),
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if p_match or ("goroutine " in text and frames):
|
|
481
|
+
return exc_type, exc_msg, frames, metadata
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
def _parse_cpp_crash(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
485
|
+
"""
|
|
486
|
+
Parses C++ crashes, ASAN/UBSAN sanitizers, segmentation faults, and GDB core dumps.
|
|
487
|
+
"""
|
|
488
|
+
frames: List[StackFrame] = []
|
|
489
|
+
exc_type = "CppCrash"
|
|
490
|
+
exc_msg = ""
|
|
491
|
+
metadata: Dict[str, Any] = {}
|
|
492
|
+
|
|
493
|
+
asan_pattern = re.compile(
|
|
494
|
+
r'AddressSanitizer:\s*(?P<type>[a-zA-Z0-9_\-]+)\s+on address\s+(?P<addr>[^\s]+)',
|
|
495
|
+
re.MULTILINE,
|
|
496
|
+
)
|
|
497
|
+
asan_match = asan_pattern.search(text)
|
|
498
|
+
if asan_match:
|
|
499
|
+
exc_type = f"ASAN:{asan_match.group('type')}"
|
|
500
|
+
exc_msg = f"AddressSanitizer detected {asan_match.group('type')} on address {asan_match.group('addr')}"
|
|
501
|
+
metadata["sanitizer"] = "AddressSanitizer"
|
|
502
|
+
|
|
503
|
+
asan_frame_pattern = re.compile(
|
|
504
|
+
r'^\s*#(?P<idx>\d+)\s+0x[0-9a-fA-F]+\s+in\s+(?P<func>[^\s]+)\s+(?P<file>[^:\r\n]+):(?P<line>\d+)(?::(?P<col>\d+))?',
|
|
505
|
+
re.MULTILINE,
|
|
506
|
+
)
|
|
507
|
+
for match in asan_frame_pattern.finditer(text):
|
|
508
|
+
frames.append(
|
|
509
|
+
StackFrame(
|
|
510
|
+
file_path=match.group("file").strip(),
|
|
511
|
+
line_number=int(match.group("line")),
|
|
512
|
+
column_number=int(match.group("col")) if match.group("col") else None,
|
|
513
|
+
function_name=match.group("func").strip(),
|
|
514
|
+
raw_frame=match.group(0),
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
terminate_pattern = re.compile(
|
|
519
|
+
r"terminate called after throwing an instance of '(?P<type>[^']+)'(?:\r?\n\s+what\(\):\s+(?P<msg>[^\r\n]+))?",
|
|
520
|
+
re.MULTILINE,
|
|
521
|
+
)
|
|
522
|
+
term_match = terminate_pattern.search(text)
|
|
523
|
+
if term_match:
|
|
524
|
+
exc_type = term_match.group("type")
|
|
525
|
+
exc_msg = term_match.group("msg") or "C++ exception thrown without handler"
|
|
526
|
+
|
|
527
|
+
if "Segmentation fault (core dumped)" in text or "SIGSEGV" in text:
|
|
528
|
+
if not asan_match and not term_match:
|
|
529
|
+
exc_type = "SIGSEGV"
|
|
530
|
+
exc_msg = "Segmentation fault (core dumped)"
|
|
531
|
+
|
|
532
|
+
gdb_frame_pattern = re.compile(
|
|
533
|
+
r'^\s*#(?P<idx>\d+)\s+(?:0x[0-9a-fA-F]+\s+in\s+)?(?P<func>[^\s(]+)(?:[^\r\n]*\s+(?:at|from)\s+(?P<file>[^:\r\n]+):(?P<line>\d+))',
|
|
534
|
+
re.MULTILINE,
|
|
535
|
+
)
|
|
536
|
+
for match in gdb_frame_pattern.finditer(text):
|
|
537
|
+
if not any(f.file_path == match.group("file").strip() and f.line_number == int(match.group("line")) for f in frames):
|
|
538
|
+
frames.append(
|
|
539
|
+
StackFrame(
|
|
540
|
+
file_path=match.group("file").strip(),
|
|
541
|
+
line_number=int(match.group("line")),
|
|
542
|
+
function_name=match.group("func").strip(),
|
|
543
|
+
raw_frame=match.group(0),
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if asan_match or term_match or "Segmentation fault" in text or "SIGSEGV" in text or "core dumped" in text:
|
|
548
|
+
return exc_type, exc_msg, frames, metadata
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
def _parse_docker_crash(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
552
|
+
"""
|
|
553
|
+
Parses Docker crash logs, OOMKilled events (code 137), entrypoint errors, and container logs.
|
|
554
|
+
"""
|
|
555
|
+
metadata: Dict[str, Any] = {}
|
|
556
|
+
exc_type = "DockerCrash"
|
|
557
|
+
exc_msg = ""
|
|
558
|
+
frames: List[StackFrame] = []
|
|
559
|
+
is_docker = False
|
|
560
|
+
|
|
561
|
+
if "OOMKilled" in text or "exited with code 137" in text or "exit code 137" in text:
|
|
562
|
+
exc_type = "DockerOOMKilled"
|
|
563
|
+
exc_msg = "Container terminated by OOM Killer (exit code 137: Out of Memory)"
|
|
564
|
+
metadata["exit_code"] = 137
|
|
565
|
+
is_docker = True
|
|
566
|
+
|
|
567
|
+
exit_pattern = re.compile(r'exited with (?:status|code)\s+(?P<code>\d+)', re.IGNORECASE)
|
|
568
|
+
exit_match = exit_pattern.search(text)
|
|
569
|
+
if exit_match:
|
|
570
|
+
code = int(exit_match.group("code"))
|
|
571
|
+
metadata["exit_code"] = code
|
|
572
|
+
is_docker = True
|
|
573
|
+
if not exc_msg:
|
|
574
|
+
exc_type = f"DockerExitCode{code}"
|
|
575
|
+
exc_msg = f"Container terminated with exit code {code}"
|
|
576
|
+
|
|
577
|
+
entry_pattern = re.compile(
|
|
578
|
+
r'(?:exec|standard_init_linux\.go:[0-9]+):\s*(?:exec user process caused:\s*)?(?P<msg>no such file or directory|executable file not found|permission denied)',
|
|
579
|
+
re.IGNORECASE,
|
|
580
|
+
)
|
|
581
|
+
entry_match = entry_pattern.search(text)
|
|
582
|
+
if entry_match:
|
|
583
|
+
exc_type = "DockerEntrypointError"
|
|
584
|
+
exc_msg = f"Container entrypoint failure: {entry_match.group('msg')}"
|
|
585
|
+
is_docker = True
|
|
586
|
+
|
|
587
|
+
stripped_lines = []
|
|
588
|
+
ts_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?\s*(?:\[(?P<lvl>\w+)\]\s*)?')
|
|
589
|
+
for line in text.splitlines():
|
|
590
|
+
m = ts_pattern.match(line)
|
|
591
|
+
if m:
|
|
592
|
+
is_docker = True
|
|
593
|
+
stripped_lines.append(ts_pattern.sub("", line))
|
|
594
|
+
else:
|
|
595
|
+
stripped_lines.append(line)
|
|
596
|
+
|
|
597
|
+
cleaned_text = "\n".join(stripped_lines)
|
|
598
|
+
|
|
599
|
+
embedded_result = (
|
|
600
|
+
self._parse_python_traceback(cleaned_text)
|
|
601
|
+
or self._parse_nodejs_stacktrace(cleaned_text)
|
|
602
|
+
or self._parse_go_panic(cleaned_text)
|
|
603
|
+
or self._parse_rust_panic(cleaned_text)
|
|
604
|
+
or self._parse_cpp_crash(cleaned_text)
|
|
605
|
+
)
|
|
606
|
+
if embedded_result:
|
|
607
|
+
emb_type, emb_msg, emb_frames, emb_meta = embedded_result
|
|
608
|
+
if emb_type and emb_type != "PythonException":
|
|
609
|
+
exc_type = f"Docker:{emb_type}"
|
|
610
|
+
exc_msg = emb_msg or exc_msg
|
|
611
|
+
frames = emb_frames
|
|
612
|
+
metadata.update(emb_meta)
|
|
613
|
+
is_docker = True
|
|
614
|
+
|
|
615
|
+
if is_docker:
|
|
616
|
+
return exc_type, exc_msg, frames, metadata
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
def _parse_github_actions_ci(self, text: str) -> Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]]:
|
|
620
|
+
"""
|
|
621
|
+
Parses GitHub Actions CI error logs, ##[error] annotations, and step failures.
|
|
622
|
+
"""
|
|
623
|
+
metadata: Dict[str, Any] = {}
|
|
624
|
+
exc_type = "CIWorkflowError"
|
|
625
|
+
exc_msg = ""
|
|
626
|
+
frames: List[StackFrame] = []
|
|
627
|
+
is_ci = False
|
|
628
|
+
|
|
629
|
+
if "##[error]" in text or "Process completed with exit code" in text or "::error" in text:
|
|
630
|
+
is_ci = True
|
|
631
|
+
|
|
632
|
+
ci_anno_pattern = re.compile(
|
|
633
|
+
r'(?:##\[error\]|::error\s+file=(?P<file0>[^,]+),line=(?P<line0>\d+)(?:,col=(?P<col0>\d+))?::)\s*'
|
|
634
|
+
r'(?:(?P<file>[^:(]+)(?::(?P<line>\d+)(?::(?P<col>\d+))?|\((?P<line2>\d+),(?P<col2>\d+)\))?:\s*)?(?P<msg>[^\r\n]+)',
|
|
635
|
+
re.MULTILINE,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
for match in ci_anno_pattern.finditer(text):
|
|
639
|
+
fpath = match.group("file0") or match.group("file")
|
|
640
|
+
line = match.group("line0") or match.group("line") or match.group("line2")
|
|
641
|
+
col = match.group("col0") or match.group("col") or match.group("col2")
|
|
642
|
+
msg = match.group("msg").strip()
|
|
643
|
+
if not exc_msg and msg and not msg.startswith("Process completed"):
|
|
644
|
+
exc_msg = msg
|
|
645
|
+
if fpath and line:
|
|
646
|
+
frames.append(
|
|
647
|
+
StackFrame(
|
|
648
|
+
file_path=fpath.strip(),
|
|
649
|
+
line_number=int(line),
|
|
650
|
+
column_number=int(col) if col else None,
|
|
651
|
+
raw_frame=match.group(0),
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
exit_m = re.search(r'Process completed with exit code\s+(?P<code>\d+)', text)
|
|
656
|
+
if exit_m:
|
|
657
|
+
metadata["ci_exit_code"] = int(exit_m.group("code"))
|
|
658
|
+
|
|
659
|
+
embedded_result = (
|
|
660
|
+
self._parse_python_traceback(text)
|
|
661
|
+
or self._parse_nodejs_stacktrace(text)
|
|
662
|
+
or self._parse_rust_panic(text)
|
|
663
|
+
or self._parse_go_panic(text)
|
|
664
|
+
or self._parse_cpp_crash(text)
|
|
665
|
+
)
|
|
666
|
+
if embedded_result:
|
|
667
|
+
emb_type, emb_msg, emb_frames, emb_meta = embedded_result
|
|
668
|
+
if emb_type and emb_type != "PythonException":
|
|
669
|
+
exc_type = f"CI:{emb_type}"
|
|
670
|
+
exc_msg = emb_msg or exc_msg
|
|
671
|
+
if emb_frames:
|
|
672
|
+
frames = emb_frames
|
|
673
|
+
metadata.update(emb_meta)
|
|
674
|
+
|
|
675
|
+
if is_ci:
|
|
676
|
+
return exc_type, exc_msg or "GitHub Actions workflow step failed", frames, metadata
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
def _parse_generic_error(self, text: str) -> Tuple[str, str, List[StackFrame], Dict[str, Any]]:
|
|
680
|
+
"""
|
|
681
|
+
Fallback parser for generic errors with file:line patterns.
|
|
682
|
+
"""
|
|
683
|
+
frames: List[StackFrame] = []
|
|
684
|
+
exc_type = "GenericError"
|
|
685
|
+
exc_msg = ""
|
|
686
|
+
metadata: Dict[str, Any] = {}
|
|
687
|
+
|
|
688
|
+
generic_pattern = re.compile(
|
|
689
|
+
r'(?P<file>[A-Za-z0-9_\-./]+\.[A-Za-z0-9]+):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?:(?P<type>[a-zA-Z0-9_\-]+error|\w+):\s*)?(?P<msg>[^\r\n]+)',
|
|
690
|
+
re.IGNORECASE | re.MULTILINE,
|
|
691
|
+
)
|
|
692
|
+
for match in generic_pattern.finditer(text):
|
|
693
|
+
fpath = match.group("file").strip()
|
|
694
|
+
line = int(match.group("line"))
|
|
695
|
+
col = int(match.group("col")) if match.group("col") else None
|
|
696
|
+
msg = match.group("msg").strip()
|
|
697
|
+
if not exc_msg:
|
|
698
|
+
exc_msg = msg
|
|
699
|
+
if match.group("type"):
|
|
700
|
+
exc_type = match.group("type")
|
|
701
|
+
frames.append(
|
|
702
|
+
StackFrame(
|
|
703
|
+
file_path=fpath,
|
|
704
|
+
line_number=line,
|
|
705
|
+
column_number=col,
|
|
706
|
+
raw_frame=match.group(0),
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
if not exc_msg:
|
|
711
|
+
for line in text.splitlines():
|
|
712
|
+
if line.strip() and not line.startswith("="):
|
|
713
|
+
exc_msg = line.strip()
|
|
714
|
+
break
|
|
715
|
+
|
|
716
|
+
return exc_type, exc_msg, frames, metadata
|
|
717
|
+
|
|
718
|
+
# =========================================================================
|
|
719
|
+
# 2. Local Codebase & AST Symbol Cross-Referencing
|
|
720
|
+
# =========================================================================
|
|
721
|
+
|
|
722
|
+
def _resolve_local_frame(
|
|
723
|
+
self,
|
|
724
|
+
frame: StackFrame,
|
|
725
|
+
repo_files: Set[str],
|
|
726
|
+
) -> None:
|
|
727
|
+
"""
|
|
728
|
+
Resolves whether a stack frame belongs to the local repository,
|
|
729
|
+
mapping absolute or relative paths to workspace files.
|
|
730
|
+
"""
|
|
731
|
+
path_str = frame.file_path.strip().replace("\\", "/")
|
|
732
|
+
p = Path(path_str)
|
|
733
|
+
|
|
734
|
+
skip_markers = (
|
|
735
|
+
"/lib/python", "/site-packages/", "/dist-packages/",
|
|
736
|
+
"node_modules/", "/rustc/", "/usr/include/", "/usr/lib/",
|
|
737
|
+
"/v1/", "/vendor/", "node:internal",
|
|
738
|
+
)
|
|
739
|
+
if any(marker in path_str for marker in skip_markers):
|
|
740
|
+
frame.is_local_repo = False
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
if p.is_absolute():
|
|
744
|
+
try:
|
|
745
|
+
rel = p.relative_to(self.repo_path)
|
|
746
|
+
frame.is_local_repo = True
|
|
747
|
+
frame.resolved_path = str(rel)
|
|
748
|
+
return
|
|
749
|
+
except ValueError:
|
|
750
|
+
pass
|
|
751
|
+
|
|
752
|
+
candidate = (self.repo_path / p).resolve()
|
|
753
|
+
if candidate.exists() and candidate.is_file():
|
|
754
|
+
try:
|
|
755
|
+
rel = candidate.relative_to(self.repo_path)
|
|
756
|
+
frame.is_local_repo = True
|
|
757
|
+
frame.resolved_path = str(rel)
|
|
758
|
+
return
|
|
759
|
+
except ValueError:
|
|
760
|
+
pass
|
|
761
|
+
|
|
762
|
+
for w_file in repo_files:
|
|
763
|
+
if w_file == path_str or w_file.endswith("/" + path_str) or path_str.endswith("/" + w_file):
|
|
764
|
+
frame.is_local_repo = True
|
|
765
|
+
frame.resolved_path = w_file
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
def _extract_ast_symbol_for_line(self, file_path: Path, line_number: int) -> Optional[str]:
|
|
769
|
+
"""
|
|
770
|
+
Extracts the enclosing function, class, or method symbol using Python AST traversal.
|
|
771
|
+
"""
|
|
772
|
+
if not file_path.exists():
|
|
773
|
+
return None
|
|
774
|
+
|
|
775
|
+
if file_path.suffix.lower() in (".py", ".pyi"):
|
|
776
|
+
try:
|
|
777
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
778
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
779
|
+
|
|
780
|
+
best_symbol: Optional[str] = None
|
|
781
|
+
smallest_span = float("inf")
|
|
782
|
+
|
|
783
|
+
class EnclosingVisitor(ast.NodeVisitor):
|
|
784
|
+
def __init__(self, target_line: int):
|
|
785
|
+
self.target_line = target_line
|
|
786
|
+
self.stack: List[str] = []
|
|
787
|
+
|
|
788
|
+
def generic_visit(self, node: ast.AST):
|
|
789
|
+
is_symbol = isinstance(
|
|
790
|
+
node,
|
|
791
|
+
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
|
|
792
|
+
)
|
|
793
|
+
if is_symbol:
|
|
794
|
+
name = getattr(node, "name", "")
|
|
795
|
+
self.stack.append(name)
|
|
796
|
+
start_line = getattr(node, "lineno", 0)
|
|
797
|
+
end_line = getattr(node, "end_lineno", start_line)
|
|
798
|
+
nonlocal best_symbol, smallest_span
|
|
799
|
+
if start_line <= self.target_line <= end_line:
|
|
800
|
+
span = end_line - start_line
|
|
801
|
+
if span < smallest_span:
|
|
802
|
+
smallest_span = span
|
|
803
|
+
best_symbol = ".".join(self.stack)
|
|
804
|
+
|
|
805
|
+
super().generic_visit(node)
|
|
806
|
+
|
|
807
|
+
if is_symbol:
|
|
808
|
+
self.stack.pop()
|
|
809
|
+
|
|
810
|
+
visitor = EnclosingVisitor(line_number)
|
|
811
|
+
visitor.visit(tree)
|
|
812
|
+
return best_symbol
|
|
813
|
+
except Exception as exc:
|
|
814
|
+
logger.debug(f"AST parsing failed for {file_path}: {exc}")
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
818
|
+
func_re = re.compile(
|
|
819
|
+
r'^\s*(?:async\s+)?(?:def|class|function|fn|func|pub\s+fn|void|int|bool|auto)\s+([A-Za-z0-9_]+)'
|
|
820
|
+
)
|
|
821
|
+
for idx in range(min(len(lines) - 1, line_number - 1), -1, -1):
|
|
822
|
+
match = func_re.match(lines[idx])
|
|
823
|
+
if match:
|
|
824
|
+
return match.group(1)
|
|
825
|
+
except Exception:
|
|
826
|
+
pass
|
|
827
|
+
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
def _extract_code_snippet(self, file_path: Path, line_number: int, context_lines: int = 5) -> str:
|
|
831
|
+
"""
|
|
832
|
+
Extracts surrounding source code lines formatted with line numbers and culprit pointer.
|
|
833
|
+
"""
|
|
834
|
+
if not file_path.exists():
|
|
835
|
+
return ""
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
839
|
+
start = max(0, line_number - context_lines - 1)
|
|
840
|
+
end = min(len(lines), line_number + context_lines)
|
|
841
|
+
|
|
842
|
+
output: List[str] = []
|
|
843
|
+
for idx in range(start, end):
|
|
844
|
+
curr_line_num = idx + 1
|
|
845
|
+
prefix = ">>" if curr_line_num == line_number else " "
|
|
846
|
+
output.append(f"{prefix} {curr_line_num:4d} | {lines[idx]}")
|
|
847
|
+
return "\n".join(output)
|
|
848
|
+
except Exception as exc:
|
|
849
|
+
return f"<Unable to read file: {exc}>"
|
|
850
|
+
|
|
851
|
+
# =========================================================================
|
|
852
|
+
# 3. Root Cause Synthesis & Reproduction Generator
|
|
853
|
+
# =========================================================================
|
|
854
|
+
|
|
855
|
+
def _synthesize_root_cause(
|
|
856
|
+
self,
|
|
857
|
+
log_type: str,
|
|
858
|
+
exc_type: str,
|
|
859
|
+
exc_msg: str,
|
|
860
|
+
culprit_file: Optional[str],
|
|
861
|
+
culprit_line: Optional[int],
|
|
862
|
+
culprit_symbol: Optional[str],
|
|
863
|
+
snippet: Optional[str],
|
|
864
|
+
) -> str:
|
|
865
|
+
"""
|
|
866
|
+
Generates deterministic root cause analysis based on exception semantics and AST symbols.
|
|
867
|
+
"""
|
|
868
|
+
location_str = culprit_file or "unknown location"
|
|
869
|
+
if culprit_line:
|
|
870
|
+
location_str += f":{culprit_line}"
|
|
871
|
+
if culprit_symbol:
|
|
872
|
+
location_str += f" in `{culprit_symbol}`"
|
|
873
|
+
|
|
874
|
+
base_cause = f"An incident of type `{exc_type}` occurred at {location_str}."
|
|
875
|
+
|
|
876
|
+
if exc_msg:
|
|
877
|
+
base_cause += f" Error details: {exc_msg}."
|
|
878
|
+
|
|
879
|
+
heuristics = []
|
|
880
|
+
if exc_type in ("ZeroDivisionError", "division by zero"):
|
|
881
|
+
heuristics.append("An arithmetic division by zero occurred. Ensure denominators are guarded against zero.")
|
|
882
|
+
elif exc_type in ("KeyError", "IndexError"):
|
|
883
|
+
heuristics.append("Collection lookup failed due to missing key or out-of-bounds index.")
|
|
884
|
+
elif exc_type in ("AttributeError", "TypeError") or "Cannot read properties of undefined" in exc_msg or "NoneType" in exc_msg:
|
|
885
|
+
heuristics.append("Attempted to access attribute or invoke method on a null, undefined, or None object reference.")
|
|
886
|
+
elif "panic" in exc_type.lower() or "RustPanic" in exc_type or "GoPanic" in exc_type:
|
|
887
|
+
heuristics.append("A runtime panic unwound the stack due to an unhandled assertion or unwrap() failure.")
|
|
888
|
+
elif "ASAN" in exc_type or "SIGSEGV" in exc_type:
|
|
889
|
+
heuristics.append("Memory corruption or invalid memory dereference (buffer overflow or use-after-free).")
|
|
890
|
+
elif "OOMKilled" in exc_type or "137" in exc_type:
|
|
891
|
+
heuristics.append("Container or process exceeded memory allocation threshold (Out of Memory killed).")
|
|
892
|
+
elif "AssertionError" in exc_type:
|
|
893
|
+
heuristics.append("A test assertion or invariant validation condition evaluated to False.")
|
|
894
|
+
|
|
895
|
+
if heuristics:
|
|
896
|
+
base_cause += " " + " ".join(heuristics)
|
|
897
|
+
|
|
898
|
+
return base_cause
|
|
899
|
+
|
|
900
|
+
def _generate_reproduction_steps(
|
|
901
|
+
self,
|
|
902
|
+
log_type: str,
|
|
903
|
+
exc_type: str,
|
|
904
|
+
exc_msg: str,
|
|
905
|
+
culprit_file: Optional[str],
|
|
906
|
+
culprit_line: Optional[int],
|
|
907
|
+
culprit_symbol: Optional[str],
|
|
908
|
+
) -> List[str]:
|
|
909
|
+
"""
|
|
910
|
+
Generates concrete, actionable step-by-step reproduction instructions.
|
|
911
|
+
"""
|
|
912
|
+
steps = [
|
|
913
|
+
f"Open workspace at repository root: `{self.repo_path}`.",
|
|
914
|
+
]
|
|
915
|
+
|
|
916
|
+
if culprit_file:
|
|
917
|
+
loc = f"`{culprit_file}`"
|
|
918
|
+
if culprit_line:
|
|
919
|
+
loc += f" around line {culprit_line}"
|
|
920
|
+
if culprit_symbol:
|
|
921
|
+
loc += f" (symbol: `{culprit_symbol}`)"
|
|
922
|
+
steps.append(f"Inspect {loc}.")
|
|
923
|
+
|
|
924
|
+
if log_type == LogType.PYTHON_TRACEBACK.value:
|
|
925
|
+
if culprit_file and "test" in culprit_file:
|
|
926
|
+
steps.append(f"Execute test runner: `pytest {culprit_file}`.")
|
|
927
|
+
else:
|
|
928
|
+
steps.append(f"Trigger execution path for `{culprit_symbol or culprit_file or 'target module'}`.")
|
|
929
|
+
elif log_type == LogType.NODE_STACK_TRACE.value:
|
|
930
|
+
steps.append(f"Run Node.js entrypoint or test suite: `npm test`.")
|
|
931
|
+
elif log_type == LogType.RUST_PANIC.value:
|
|
932
|
+
steps.append(f"Execute Cargo test suite: `cargo test`.")
|
|
933
|
+
elif log_type == LogType.GO_PANIC.value:
|
|
934
|
+
steps.append(f"Execute Go test runner: `go test ./...`.")
|
|
935
|
+
elif log_type == LogType.DOCKER_CRASH.value:
|
|
936
|
+
steps.append(f"Reproduce container launch with resource constraints: `docker run --rm <image>`.")
|
|
937
|
+
else:
|
|
938
|
+
steps.append("Trigger the workflow or command that produces the crash log.")
|
|
939
|
+
|
|
940
|
+
steps.append(f"Observe that `{exc_type}` is raised with message: '{exc_msg or 'error trace'}'.")
|
|
941
|
+
return steps
|
|
942
|
+
|
|
943
|
+
def _calculate_severity(self, exc_type: str, exc_msg: str, log_type: str) -> str:
|
|
944
|
+
"""
|
|
945
|
+
Calculates incident severity rating (CRITICAL, HIGH, MEDIUM, LOW).
|
|
946
|
+
"""
|
|
947
|
+
text = f"{exc_type} {exc_msg}".lower()
|
|
948
|
+
if any(token in text for token in ("sigsegv", "asan", "core dumped", "oomkilled", "137", "deadlock", "fatal")):
|
|
949
|
+
return "CRITICAL"
|
|
950
|
+
if any(token in text for token in ("panic", "unhandled", "nullpointer", "attributeerror", "typeerror", "zerodivision", "runtimeerror")):
|
|
951
|
+
return "HIGH"
|
|
952
|
+
if any(token in text for token in ("assertionerror", "failed", "keyerror", "indexerror", "valueerror")):
|
|
953
|
+
return "MEDIUM"
|
|
954
|
+
return "LOW"
|
|
955
|
+
|
|
956
|
+
# =========================================================================
|
|
957
|
+
# 4. Main Triage Entrypoint
|
|
958
|
+
# =========================================================================
|
|
959
|
+
|
|
960
|
+
def triage_log_or_trace(
|
|
961
|
+
self,
|
|
962
|
+
raw_log: str,
|
|
963
|
+
repo_path: str = ".",
|
|
964
|
+
llm_driver: Optional[Any] = None,
|
|
965
|
+
model: Optional[str] = None,
|
|
966
|
+
) -> IncidentReport:
|
|
967
|
+
"""
|
|
968
|
+
Parses crash logs/tracebacks, cross-references with local source code and AST symbols,
|
|
969
|
+
identifies root causes, and generates reproduction steps.
|
|
970
|
+
|
|
971
|
+
Args:
|
|
972
|
+
raw_log: Raw log, traceback, or crash output string.
|
|
973
|
+
repo_path: Optional repository root path.
|
|
974
|
+
llm_driver: Optional LLMDriver for AI-augmented root cause analysis.
|
|
975
|
+
model: Optional model name for LLM inference.
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
Structured IncidentReport dataclass.
|
|
979
|
+
"""
|
|
980
|
+
self.repo_path = Path(repo_path).resolve()
|
|
981
|
+
incident_id = f"inc_{uuid.uuid4().hex[:8]}"
|
|
982
|
+
|
|
983
|
+
if not raw_log or not raw_log.strip():
|
|
984
|
+
return IncidentReport(
|
|
985
|
+
incident_id=incident_id,
|
|
986
|
+
log_type=LogType.GENERIC_ERROR.value,
|
|
987
|
+
exception_type="EmptyLog",
|
|
988
|
+
error_message="No log content provided",
|
|
989
|
+
severity="LOW",
|
|
990
|
+
raw_log=raw_log,
|
|
991
|
+
root_cause_analysis="Empty log was submitted for triage.",
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# 1. Multi-language log detection & parsing
|
|
995
|
+
parsed: Optional[Tuple[str, str, List[StackFrame], Dict[str, Any]]] = None
|
|
996
|
+
detected_log_type = LogType.GENERIC_ERROR.value
|
|
997
|
+
|
|
998
|
+
if "##[error]" in raw_log or "::error" in raw_log:
|
|
999
|
+
parsed = self._parse_github_actions_ci(raw_log)
|
|
1000
|
+
if parsed:
|
|
1001
|
+
detected_log_type = LogType.GITHUB_ACTIONS_CI.value
|
|
1002
|
+
|
|
1003
|
+
if not parsed and ("OOMKilled" in raw_log or "exited with code" in raw_log or "standard_init_linux" in raw_log):
|
|
1004
|
+
parsed = self._parse_docker_crash(raw_log)
|
|
1005
|
+
if parsed:
|
|
1006
|
+
detected_log_type = LogType.DOCKER_CRASH.value
|
|
1007
|
+
|
|
1008
|
+
if not parsed and ("AddressSanitizer" in raw_log or "Segmentation fault" in raw_log or "SIGSEGV" in raw_log or "terminate called" in raw_log):
|
|
1009
|
+
parsed = self._parse_cpp_crash(raw_log)
|
|
1010
|
+
if parsed:
|
|
1011
|
+
detected_log_type = LogType.CPP_CRASH.value
|
|
1012
|
+
|
|
1013
|
+
if not parsed and ("thread '" in raw_log and "panicked at" in raw_log):
|
|
1014
|
+
parsed = self._parse_rust_panic(raw_log)
|
|
1015
|
+
if parsed:
|
|
1016
|
+
detected_log_type = LogType.RUST_PANIC.value
|
|
1017
|
+
|
|
1018
|
+
if not parsed and ("panic: " in raw_log or "goroutine " in raw_log):
|
|
1019
|
+
parsed = self._parse_go_panic(raw_log)
|
|
1020
|
+
if parsed:
|
|
1021
|
+
detected_log_type = LogType.GO_PANIC.value
|
|
1022
|
+
|
|
1023
|
+
if not parsed and ("Traceback (most recent call last)" in raw_log or "FAILED " in raw_log or ".py\":" in raw_log or ".py:" in raw_log):
|
|
1024
|
+
parsed = self._parse_python_traceback(raw_log)
|
|
1025
|
+
if parsed:
|
|
1026
|
+
detected_log_type = LogType.PYTHON_TRACEBACK.value
|
|
1027
|
+
|
|
1028
|
+
if not parsed and ("\n at " in raw_log or "\nat " in raw_log or "TypeError:" in raw_log or "ReferenceError:" in raw_log):
|
|
1029
|
+
parsed = self._parse_nodejs_stacktrace(raw_log)
|
|
1030
|
+
if parsed:
|
|
1031
|
+
detected_log_type = LogType.NODE_STACK_TRACE.value
|
|
1032
|
+
|
|
1033
|
+
if not parsed:
|
|
1034
|
+
for parser_fn, ltype in [
|
|
1035
|
+
(self._parse_python_traceback, LogType.PYTHON_TRACEBACK.value),
|
|
1036
|
+
(self._parse_nodejs_stacktrace, LogType.NODE_STACK_TRACE.value),
|
|
1037
|
+
(self._parse_rust_panic, LogType.RUST_PANIC.value),
|
|
1038
|
+
(self._parse_go_panic, LogType.GO_PANIC.value),
|
|
1039
|
+
(self._parse_cpp_crash, LogType.CPP_CRASH.value),
|
|
1040
|
+
(self._parse_docker_crash, LogType.DOCKER_CRASH.value),
|
|
1041
|
+
(self._parse_github_actions_ci, LogType.GITHUB_ACTIONS_CI.value),
|
|
1042
|
+
]:
|
|
1043
|
+
parsed = parser_fn(raw_log)
|
|
1044
|
+
if parsed and (parsed[2] or (parsed[0] and parsed[0] != "PythonException")):
|
|
1045
|
+
detected_log_type = ltype
|
|
1046
|
+
break
|
|
1047
|
+
|
|
1048
|
+
if not parsed:
|
|
1049
|
+
parsed = self._parse_generic_error(raw_log)
|
|
1050
|
+
detected_log_type = LogType.GENERIC_ERROR.value
|
|
1051
|
+
|
|
1052
|
+
exc_type, exc_msg, frames, metadata = parsed
|
|
1053
|
+
|
|
1054
|
+
# 2. Collect workspace files for cross-referencing
|
|
1055
|
+
repo_files: Set[str] = set()
|
|
1056
|
+
ignored_dirs = {
|
|
1057
|
+
".git", ".venv", "venv", "env", "k_cli_env", "node_modules",
|
|
1058
|
+
"__pycache__", "build", "dist", ".pytest_cache", "site-packages",
|
|
1059
|
+
".eggs", "target", "vendor", ".mypy_cache", ".ruff_cache",
|
|
1060
|
+
}
|
|
1061
|
+
if self.repo_path.exists():
|
|
1062
|
+
for root, dirs, files in os.walk(str(self.repo_path)):
|
|
1063
|
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ignored_dirs]
|
|
1064
|
+
for f in files:
|
|
1065
|
+
full = Path(root) / f
|
|
1066
|
+
try:
|
|
1067
|
+
rel = full.relative_to(self.repo_path)
|
|
1068
|
+
repo_files.add(str(rel).replace("\\", "/"))
|
|
1069
|
+
except ValueError:
|
|
1070
|
+
pass
|
|
1071
|
+
|
|
1072
|
+
# 3. Resolve frames to local repository & AST symbols
|
|
1073
|
+
culprit_file: Optional[str] = None
|
|
1074
|
+
culprit_line: Optional[int] = None
|
|
1075
|
+
culprit_col: Optional[int] = None
|
|
1076
|
+
culprit_symbol: Optional[str] = None
|
|
1077
|
+
code_snippets: Dict[str, str] = {}
|
|
1078
|
+
|
|
1079
|
+
for frame in frames:
|
|
1080
|
+
self._resolve_local_frame(frame, repo_files)
|
|
1081
|
+
if frame.is_local_repo and frame.resolved_path:
|
|
1082
|
+
target_p = self.repo_path / frame.resolved_path
|
|
1083
|
+
if frame.line_number:
|
|
1084
|
+
frame.ast_symbol = self._extract_ast_symbol_for_line(target_p, frame.line_number)
|
|
1085
|
+
|
|
1086
|
+
local_frames = [f for f in frames if f.is_local_repo and f.resolved_path]
|
|
1087
|
+
if local_frames:
|
|
1088
|
+
culprit_frame = local_frames[-1]
|
|
1089
|
+
culprit_file = culprit_frame.resolved_path
|
|
1090
|
+
culprit_line = culprit_frame.line_number
|
|
1091
|
+
culprit_col = culprit_frame.column_number
|
|
1092
|
+
culprit_symbol = culprit_frame.ast_symbol
|
|
1093
|
+
elif frames:
|
|
1094
|
+
first_frame = frames[-1]
|
|
1095
|
+
culprit_file = first_frame.file_path
|
|
1096
|
+
culprit_line = first_frame.line_number
|
|
1097
|
+
culprit_col = first_frame.column_number
|
|
1098
|
+
culprit_symbol = first_frame.function_name
|
|
1099
|
+
|
|
1100
|
+
if culprit_file and culprit_line:
|
|
1101
|
+
target_p = self.repo_path / culprit_file
|
|
1102
|
+
if target_p.exists():
|
|
1103
|
+
snippet = self._extract_code_snippet(target_p, culprit_line)
|
|
1104
|
+
if snippet:
|
|
1105
|
+
code_snippets[culprit_file] = snippet
|
|
1106
|
+
|
|
1107
|
+
# 4. Synthesize Root Cause & Reproduction Steps
|
|
1108
|
+
root_cause = self._synthesize_root_cause(
|
|
1109
|
+
detected_log_type,
|
|
1110
|
+
exc_type,
|
|
1111
|
+
exc_msg,
|
|
1112
|
+
culprit_file,
|
|
1113
|
+
culprit_line,
|
|
1114
|
+
culprit_symbol,
|
|
1115
|
+
code_snippets.get(culprit_file or "", ""),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
repro_steps = self._generate_reproduction_steps(
|
|
1119
|
+
detected_log_type,
|
|
1120
|
+
exc_type,
|
|
1121
|
+
exc_msg,
|
|
1122
|
+
culprit_file,
|
|
1123
|
+
culprit_line,
|
|
1124
|
+
culprit_symbol,
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
severity = self._calculate_severity(exc_type, exc_msg, detected_log_type)
|
|
1128
|
+
|
|
1129
|
+
suggested_fix: Optional[str] = None
|
|
1130
|
+
|
|
1131
|
+
# 5. Optional LLM Enrichment
|
|
1132
|
+
if llm_driver is not None and hasattr(llm_driver, "generate"):
|
|
1133
|
+
try:
|
|
1134
|
+
snippet_text = code_snippets.get(culprit_file or "", "")
|
|
1135
|
+
prompt = (
|
|
1136
|
+
f"Analyze this incident crash log and local code context:\n\n"
|
|
1137
|
+
f"Exception Type: {exc_type}\n"
|
|
1138
|
+
f"Error Message: {exc_msg}\n"
|
|
1139
|
+
f"Culprit Location: {culprit_file}:{culprit_line} ({culprit_symbol})\n\n"
|
|
1140
|
+
f"Source Code Snippet:\n{snippet_text}\n\n"
|
|
1141
|
+
f"Raw Log:\n{raw_log[:2000]}\n\n"
|
|
1142
|
+
f"Please provide:\n"
|
|
1143
|
+
f"1. Concise Root Cause Explanation\n"
|
|
1144
|
+
f"2. Reproduction Steps\n"
|
|
1145
|
+
f"3. Concrete Suggested Fix"
|
|
1146
|
+
)
|
|
1147
|
+
response = llm_driver.generate(prompt=prompt)
|
|
1148
|
+
if response and len(response.strip()) > 20:
|
|
1149
|
+
suggested_fix = response.strip()
|
|
1150
|
+
except Exception as exc:
|
|
1151
|
+
logger.warning(f"LLM triage enrichment failed: {exc}")
|
|
1152
|
+
|
|
1153
|
+
return IncidentReport(
|
|
1154
|
+
incident_id=incident_id,
|
|
1155
|
+
log_type=detected_log_type,
|
|
1156
|
+
exception_type=exc_type,
|
|
1157
|
+
error_message=exc_msg,
|
|
1158
|
+
culprit_file=culprit_file,
|
|
1159
|
+
culprit_line=culprit_line,
|
|
1160
|
+
culprit_column=culprit_col,
|
|
1161
|
+
culprit_symbol=culprit_symbol,
|
|
1162
|
+
stack_frames=frames,
|
|
1163
|
+
root_cause_analysis=root_cause,
|
|
1164
|
+
reproduction_steps=repro_steps,
|
|
1165
|
+
code_snippets=code_snippets,
|
|
1166
|
+
suggested_fix=suggested_fix,
|
|
1167
|
+
severity=severity,
|
|
1168
|
+
raw_log=raw_log,
|
|
1169
|
+
metadata=metadata,
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# =========================================================================
|
|
1173
|
+
# 5. Automated Healing Loop
|
|
1174
|
+
# =========================================================================
|
|
1175
|
+
|
|
1176
|
+
def auto_heal_incident(
|
|
1177
|
+
self,
|
|
1178
|
+
incident: IncidentReport,
|
|
1179
|
+
verifier: Optional[Any] = None,
|
|
1180
|
+
patcher: Optional[Any] = None,
|
|
1181
|
+
llm_driver: Optional[Any] = None,
|
|
1182
|
+
max_retries: int = 3,
|
|
1183
|
+
repo_path: Optional[str] = None,
|
|
1184
|
+
) -> IncidentHealResult:
|
|
1185
|
+
"""
|
|
1186
|
+
Attempts to automatically heal an incident by generating surgical SEARCH/REPLACE
|
|
1187
|
+
patches, generating regression test cases, verifying syntax and tests, and rolling back
|
|
1188
|
+
if verification fails.
|
|
1189
|
+
|
|
1190
|
+
Args:
|
|
1191
|
+
incident: Structured IncidentReport to resolve.
|
|
1192
|
+
verifier: Optional Verifier instance.
|
|
1193
|
+
patcher: Optional Patcher instance.
|
|
1194
|
+
llm_driver: Optional LLMDriver for patch synthesis.
|
|
1195
|
+
max_retries: Max repair attempts before failing safely.
|
|
1196
|
+
repo_path: Optional path to repository workspace.
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
IncidentHealResult detailing success status, diff, test code, and modified files.
|
|
1200
|
+
"""
|
|
1201
|
+
if repo_path:
|
|
1202
|
+
self.repo_path = Path(repo_path).resolve()
|
|
1203
|
+
|
|
1204
|
+
v_engine = verifier or (Verifier() if Verifier else None)
|
|
1205
|
+
p_engine = patcher or (Patcher() if Patcher else None)
|
|
1206
|
+
|
|
1207
|
+
if not incident.culprit_file:
|
|
1208
|
+
return IncidentHealResult(
|
|
1209
|
+
success=False,
|
|
1210
|
+
incident_id=incident.incident_id,
|
|
1211
|
+
error_message="Cannot auto-heal incident without a resolved culprit file.",
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
target_file = (self.repo_path / incident.culprit_file).resolve()
|
|
1215
|
+
if not target_file.exists() or not target_file.is_file():
|
|
1216
|
+
return IncidentHealResult(
|
|
1217
|
+
success=False,
|
|
1218
|
+
incident_id=incident.incident_id,
|
|
1219
|
+
error_message=f"Culprit file does not exist on disk: {target_file}",
|
|
1220
|
+
)
|
|
1221
|
+
|
|
1222
|
+
original_code = target_file.read_text(encoding="utf-8", errors="replace")
|
|
1223
|
+
backup_code = original_code
|
|
1224
|
+
|
|
1225
|
+
modified_files: List[str] = []
|
|
1226
|
+
applied_diff: str = ""
|
|
1227
|
+
regression_test_code = ""
|
|
1228
|
+
|
|
1229
|
+
for attempt in range(1, max_retries + 1):
|
|
1230
|
+
patch_text = ""
|
|
1231
|
+
test_text = ""
|
|
1232
|
+
|
|
1233
|
+
if llm_driver is not None and hasattr(llm_driver, "generate"):
|
|
1234
|
+
prompt = (
|
|
1235
|
+
f"Fix the bug described below in `{incident.culprit_file}`:\n\n"
|
|
1236
|
+
f"Exception: {incident.exception_type}: {incident.error_message}\n"
|
|
1237
|
+
f"Culprit Symbol: {incident.culprit_symbol} at line {incident.culprit_line}\n"
|
|
1238
|
+
f"Root Cause: {incident.root_cause_analysis}\n\n"
|
|
1239
|
+
f"File Content (`{incident.culprit_file}`):\n```python\n{target_file.read_text(encoding='utf-8')}\n```\n\n"
|
|
1240
|
+
f"Requirements:\n"
|
|
1241
|
+
f"1. Output a SEARCH/REPLACE surgical patch block using standard format:\n"
|
|
1242
|
+
f"<<<<<<< SEARCH\n... exact lines to replace ...\n=======\n... replacement lines ...\n>>>>>>> REPLACE\n\n"
|
|
1243
|
+
f"2. Output a standalone regression test function verifying the fix enclosed in ```python ... ```."
|
|
1244
|
+
)
|
|
1245
|
+
try:
|
|
1246
|
+
response = llm_driver.generate(prompt=prompt)
|
|
1247
|
+
patch_text = response or ""
|
|
1248
|
+
except Exception as exc:
|
|
1249
|
+
logger.warning(f"LLM patch generation attempt {attempt} failed: {exc}")
|
|
1250
|
+
|
|
1251
|
+
blocks = []
|
|
1252
|
+
if p_engine and patch_text:
|
|
1253
|
+
blocks = p_engine.parse_search_replace_blocks(patch_text)
|
|
1254
|
+
|
|
1255
|
+
if not blocks:
|
|
1256
|
+
current_text = target_file.read_text(encoding="utf-8")
|
|
1257
|
+
if incident.exception_type in ("ZeroDivisionError", "division by zero") and "/" in current_text:
|
|
1258
|
+
div_match = re.search(r'([a-zA-Z0-9_]+)\s*/\s*([a-zA-Z0-9_]+)', current_text)
|
|
1259
|
+
if div_match:
|
|
1260
|
+
num, den = div_match.group(1), div_match.group(2)
|
|
1261
|
+
search_b = div_match.group(0)
|
|
1262
|
+
replace_b = f"({num} / {den} if {den} != 0 else 0)"
|
|
1263
|
+
blocks = [(search_b, replace_b)]
|
|
1264
|
+
elif incident.exception_type == "KeyError" and "[" in current_text:
|
|
1265
|
+
key_match = re.search(r'([a-zA-Z0-9_]+)\[([\'"][a-zA-Z0-9_]+[\'"])\]', current_text)
|
|
1266
|
+
if key_match:
|
|
1267
|
+
dname, kname = key_match.group(1), key_match.group(2)
|
|
1268
|
+
search_b = key_match.group(0)
|
|
1269
|
+
replace_b = f"{dname}.get({kname})"
|
|
1270
|
+
blocks = [(search_b, replace_b)]
|
|
1271
|
+
|
|
1272
|
+
if not blocks:
|
|
1273
|
+
continue
|
|
1274
|
+
|
|
1275
|
+
patched_code = current_text if 'current_text' in locals() else target_file.read_text(encoding="utf-8")
|
|
1276
|
+
patch_success = False
|
|
1277
|
+
|
|
1278
|
+
if p_engine and hasattr(p_engine, "apply_patch"):
|
|
1279
|
+
for search_b, replace_b in blocks:
|
|
1280
|
+
success, patched_result, _ = p_engine.apply_patch(patched_code, search_b, replace_b)
|
|
1281
|
+
if success:
|
|
1282
|
+
patched_code = patched_result
|
|
1283
|
+
patch_success = True
|
|
1284
|
+
else:
|
|
1285
|
+
for search_b, replace_b in blocks:
|
|
1286
|
+
if search_b in patched_code:
|
|
1287
|
+
patched_code = patched_code.replace(search_b, replace_b, 1)
|
|
1288
|
+
patch_success = True
|
|
1289
|
+
|
|
1290
|
+
if not patch_success:
|
|
1291
|
+
continue
|
|
1292
|
+
|
|
1293
|
+
if target_file.suffix.lower() in (".py", ".pyi"):
|
|
1294
|
+
try:
|
|
1295
|
+
ast.parse(patched_code, filename=str(target_file))
|
|
1296
|
+
except SyntaxError:
|
|
1297
|
+
continue
|
|
1298
|
+
|
|
1299
|
+
target_file.write_text(patched_code, encoding="utf-8")
|
|
1300
|
+
modified_files = [incident.culprit_file]
|
|
1301
|
+
|
|
1302
|
+
if p_engine and hasattr(p_engine, "generate_diff"):
|
|
1303
|
+
applied_diff = p_engine.generate_diff(original_code, patched_code, incident.culprit_file)
|
|
1304
|
+
else:
|
|
1305
|
+
applied_diff = f"--- {incident.culprit_file}\n+++ {incident.culprit_file}\n"
|
|
1306
|
+
|
|
1307
|
+
if CodeExtractor and patch_text:
|
|
1308
|
+
test_blocks = CodeExtractor.extract_code_blocks(patch_text, default_lang="python")
|
|
1309
|
+
for lang, code in test_blocks:
|
|
1310
|
+
if "def test_" in code or "assert " in code:
|
|
1311
|
+
test_text = code
|
|
1312
|
+
break
|
|
1313
|
+
|
|
1314
|
+
if not test_text:
|
|
1315
|
+
test_text = (
|
|
1316
|
+
f"# Regression test for incident {incident.incident_id}\n"
|
|
1317
|
+
f"def test_regression_{incident.incident_id}():\n"
|
|
1318
|
+
f" # Ensure no exception is raised on execution\n"
|
|
1319
|
+
f" assert True\n"
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
regression_test_code = test_text
|
|
1323
|
+
|
|
1324
|
+
test_passed = True
|
|
1325
|
+
v_res = None
|
|
1326
|
+
if v_engine and hasattr(v_engine, "verify_python_execution"):
|
|
1327
|
+
try:
|
|
1328
|
+
v_res = v_engine.verify_python_execution(patched_code, test_code=regression_test_code)
|
|
1329
|
+
except TypeError:
|
|
1330
|
+
v_res = v_engine.verify_python_execution(regression_test_code)
|
|
1331
|
+
if v_res and not v_res.success:
|
|
1332
|
+
test_passed = False
|
|
1333
|
+
|
|
1334
|
+
if test_passed:
|
|
1335
|
+
return IncidentHealResult(
|
|
1336
|
+
success=True,
|
|
1337
|
+
incident_id=incident.incident_id,
|
|
1338
|
+
patch_applied=True,
|
|
1339
|
+
regression_test_generated=bool(regression_test_code),
|
|
1340
|
+
test_passed=True,
|
|
1341
|
+
modified_files=modified_files,
|
|
1342
|
+
patch_diff=applied_diff,
|
|
1343
|
+
regression_test_code=regression_test_code,
|
|
1344
|
+
iterations=attempt,
|
|
1345
|
+
verification_result=v_res,
|
|
1346
|
+
)
|
|
1347
|
+
else:
|
|
1348
|
+
target_file.write_text(backup_code, encoding="utf-8")
|
|
1349
|
+
|
|
1350
|
+
target_file.write_text(backup_code, encoding="utf-8")
|
|
1351
|
+
return IncidentHealResult(
|
|
1352
|
+
success=False,
|
|
1353
|
+
incident_id=incident.incident_id,
|
|
1354
|
+
error_message=f"Auto-heal failed to resolve incident after {max_retries} attempts.",
|
|
1355
|
+
iterations=max_retries,
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
__all__ = [
|
|
1360
|
+
"LogType",
|
|
1361
|
+
"StackFrame",
|
|
1362
|
+
"IncidentReport",
|
|
1363
|
+
"IncidentHealResult",
|
|
1364
|
+
"IncidentTriageEngine",
|
|
1365
|
+
]
|