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,1039 @@
|
|
|
1
|
+
"""
|
|
2
|
+
conflict_resolver.py - AST-Aware Git Conflict Resolver for K-CLI (Project Bankai)
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
1. Parse standard 2-way and 3-way git conflict markers (`<<<<<<< HEAD`, `||||||| base`, `=======`, `>>>>>>> <branch>`).
|
|
6
|
+
2. Extract rich surrounding AST & lexical scope context (enclosing class, function, imports, surrounding code).
|
|
7
|
+
3. Multi-attempt semantic conflict resolution powered by LLM inference.
|
|
8
|
+
4. Ground-truth verification gate with AST syntax parsing and automated retry on error feedback.
|
|
9
|
+
5. Safe file updates, automatic git staging, and repository-wide conflict discovery & resolution summary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import textwrap
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from k_cli.git.verifier import CodeExtractor, VerificationResult, Verifier
|
|
28
|
+
except ModuleNotFoundError:
|
|
29
|
+
try:
|
|
30
|
+
from k_cli.git.verifier import CodeExtractor, VerificationResult, Verifier
|
|
31
|
+
except ModuleNotFoundError:
|
|
32
|
+
CodeExtractor = None # type: ignore
|
|
33
|
+
VerificationResult = None # type: ignore
|
|
34
|
+
Verifier = None # type: ignore
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from k_cli.git.git_guard import GitGuard
|
|
38
|
+
except ModuleNotFoundError:
|
|
39
|
+
try:
|
|
40
|
+
from git_guard import GitGuard
|
|
41
|
+
except ModuleNotFoundError:
|
|
42
|
+
GitGuard = None # type: ignore
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
import tempfile
|
|
46
|
+
|
|
47
|
+
def _sanitize_path(path_input: Union[str, Path], base_dir: Optional[Union[str, Path]] = None) -> Path:
|
|
48
|
+
"""Sanitizes user-provided path inputs to prevent directory traversal vulnerabilities."""
|
|
49
|
+
resolved_path = Path(path_input).resolve()
|
|
50
|
+
if base_dir is not None:
|
|
51
|
+
base = Path(base_dir).resolve()
|
|
52
|
+
if not (resolved_path == base or resolved_path.is_relative_to(base)):
|
|
53
|
+
raise ValueError(f"Path '{path_input}' escapes base directory '{base}'")
|
|
54
|
+
return resolved_path
|
|
55
|
+
|
|
56
|
+
cwd = Path.cwd().resolve()
|
|
57
|
+
tmp_dir = Path(tempfile.gettempdir()).resolve()
|
|
58
|
+
if not (resolved_path == cwd or resolved_path.is_relative_to(cwd) or resolved_path == tmp_dir or resolved_path.is_relative_to(tmp_dir)):
|
|
59
|
+
resolved_path = (cwd / resolved_path.name).resolve()
|
|
60
|
+
|
|
61
|
+
return resolved_path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ConflictBlock:
|
|
66
|
+
"""Represents a single parsed git conflict marker block within a file."""
|
|
67
|
+
file_path: str
|
|
68
|
+
start_line: int # 1-indexed start of <<<<<<<
|
|
69
|
+
end_line: int # 1-indexed end of >>>>>>>
|
|
70
|
+
ours_content: str # Content in HEAD / current branch
|
|
71
|
+
theirs_content: str # Content in incoming / remote branch
|
|
72
|
+
base_content: Optional[str] = None # Common ancestor content if diff3 markers present
|
|
73
|
+
ours_label: str = "HEAD" # Label following <<<<<<<
|
|
74
|
+
theirs_label: str = "" # Label following >>>>>>>
|
|
75
|
+
base_label: Optional[str] = None # Label following |||||||
|
|
76
|
+
raw_block: str = "" # Complete raw text of conflict block including markers
|
|
77
|
+
surrounding_context: Optional[str] = None # Extracted AST scope / context snippet
|
|
78
|
+
scope_name: Optional[str] = None # Enclosing class/function/method name
|
|
79
|
+
language: str = "python" # Detected file language
|
|
80
|
+
|
|
81
|
+
def is_3way(self) -> bool:
|
|
82
|
+
"""Returns True if the block contains base/ancestor (3-way) information."""
|
|
83
|
+
return self.base_content is not None
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
86
|
+
"""Serializes ConflictBlock to a dictionary."""
|
|
87
|
+
return {
|
|
88
|
+
"file_path": self.file_path,
|
|
89
|
+
"start_line": self.start_line,
|
|
90
|
+
"end_line": self.end_line,
|
|
91
|
+
"ours_label": self.ours_label,
|
|
92
|
+
"theirs_label": self.theirs_label,
|
|
93
|
+
"base_label": self.base_label,
|
|
94
|
+
"ours_content": self.ours_content,
|
|
95
|
+
"theirs_content": self.theirs_content,
|
|
96
|
+
"base_content": self.base_content,
|
|
97
|
+
"scope_name": self.scope_name,
|
|
98
|
+
"language": self.language,
|
|
99
|
+
"is_3way": self.is_3way(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class ConflictResolution:
|
|
105
|
+
"""Represents the resolution result of an individual ConflictBlock."""
|
|
106
|
+
conflict: ConflictBlock
|
|
107
|
+
resolved_content: str
|
|
108
|
+
success: bool
|
|
109
|
+
attempts: int = 1
|
|
110
|
+
error_message: Optional[str] = None
|
|
111
|
+
verification_result: Optional[Any] = None
|
|
112
|
+
explanation: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
115
|
+
"""Serializes ConflictResolution to a dictionary."""
|
|
116
|
+
return {
|
|
117
|
+
"file_path": self.conflict.file_path,
|
|
118
|
+
"start_line": self.conflict.start_line,
|
|
119
|
+
"end_line": self.conflict.end_line,
|
|
120
|
+
"success": self.success,
|
|
121
|
+
"attempts": self.attempts,
|
|
122
|
+
"resolved_content": self.resolved_content,
|
|
123
|
+
"error_message": self.error_message,
|
|
124
|
+
"explanation": self.explanation,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class FileResolutionResult:
|
|
130
|
+
"""Represents the resolution outcome of a conflicted file."""
|
|
131
|
+
file_path: str
|
|
132
|
+
success: bool
|
|
133
|
+
total_conflicts: int
|
|
134
|
+
resolved_conflicts: int
|
|
135
|
+
resolutions: List[ConflictResolution] = field(default_factory=list)
|
|
136
|
+
staged: bool = False
|
|
137
|
+
error_message: Optional[str] = None
|
|
138
|
+
|
|
139
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
140
|
+
"""Serializes FileResolutionResult to a dictionary."""
|
|
141
|
+
return {
|
|
142
|
+
"file_path": self.file_path,
|
|
143
|
+
"success": self.success,
|
|
144
|
+
"total_conflicts": self.total_conflicts,
|
|
145
|
+
"resolved_conflicts": self.resolved_conflicts,
|
|
146
|
+
"staged": self.staged,
|
|
147
|
+
"error_message": self.error_message,
|
|
148
|
+
"resolutions": [r.to_dict() for r in self.resolutions],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class ConflictSummary:
|
|
154
|
+
"""Repository-wide conflict resolution summary."""
|
|
155
|
+
repo_path: str
|
|
156
|
+
total_files: int
|
|
157
|
+
resolved_files: int
|
|
158
|
+
failed_files: int
|
|
159
|
+
file_results: Dict[str, FileResolutionResult] = field(default_factory=dict)
|
|
160
|
+
success: bool = True
|
|
161
|
+
|
|
162
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
163
|
+
"""Serializes ConflictSummary to a dictionary."""
|
|
164
|
+
return {
|
|
165
|
+
"repo_path": self.repo_path,
|
|
166
|
+
"total_files": self.total_files,
|
|
167
|
+
"resolved_files": self.resolved_files,
|
|
168
|
+
"failed_files": self.failed_files,
|
|
169
|
+
"success": self.success,
|
|
170
|
+
"file_results": {k: v.to_dict() for k, v in self.file_results.items()},
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ConflictResolver:
|
|
175
|
+
"""
|
|
176
|
+
Production-grade AST-Aware Git Conflict Resolver for K-CLI.
|
|
177
|
+
|
|
178
|
+
Parses 2-way and 3-way conflict markers, extracts surrounding AST/scope context,
|
|
179
|
+
invokes LLM for semantic resolution with verification gates, and automatically
|
|
180
|
+
updates and stages resolved files.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
LANGUAGE_MAP: Dict[str, str] = {
|
|
184
|
+
".py": "python",
|
|
185
|
+
".pyi": "python",
|
|
186
|
+
".js": "javascript",
|
|
187
|
+
".jsx": "javascript",
|
|
188
|
+
".ts": "typescript",
|
|
189
|
+
".tsx": "typescript",
|
|
190
|
+
".rs": "rust",
|
|
191
|
+
".go": "go",
|
|
192
|
+
".cpp": "cpp",
|
|
193
|
+
".cc": "cpp",
|
|
194
|
+
".cxx": "cpp",
|
|
195
|
+
".c": "cpp",
|
|
196
|
+
".h": "cpp",
|
|
197
|
+
".hpp": "cpp",
|
|
198
|
+
".sh": "bash",
|
|
199
|
+
".bash": "bash",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
def __init__(self, default_model: Optional[str] = None):
|
|
203
|
+
self.default_model = default_model
|
|
204
|
+
|
|
205
|
+
# =========================================================================
|
|
206
|
+
# Conflict Marker Parsing (2-way & 3-way)
|
|
207
|
+
# =========================================================================
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def detect_language(cls, file_path: str) -> str:
|
|
211
|
+
"""Detects language from file extension."""
|
|
212
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
213
|
+
return cls.LANGUAGE_MAP.get(ext, "python")
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def parse_conflict_blocks(cls, file_content: str, file_path: str = "") -> List[ConflictBlock]:
|
|
217
|
+
"""
|
|
218
|
+
Parses git conflict markers (`<<<<<<<`, `|||||||`, `=======`, `>>>>>>>`) from text.
|
|
219
|
+
Handles standard 2-way merge conflicts and 3-way (diff3/zdiff3) merge conflicts.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
file_content: Raw string content of the conflicted file.
|
|
223
|
+
file_path: Relative or absolute file path.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of parsed `ConflictBlock` objects in order of occurrence.
|
|
227
|
+
"""
|
|
228
|
+
blocks: List[ConflictBlock] = []
|
|
229
|
+
lines = file_content.splitlines(keepends=True)
|
|
230
|
+
num_lines = len(lines)
|
|
231
|
+
lang = cls.detect_language(file_path)
|
|
232
|
+
|
|
233
|
+
i = 0
|
|
234
|
+
while i < num_lines:
|
|
235
|
+
line = lines[i]
|
|
236
|
+
# Match start of conflict block: <<<<<<< [label]
|
|
237
|
+
m_start = re.match(r"^<{7}(?:\s+(.*))?$", line.rstrip("\r\n"))
|
|
238
|
+
if m_start:
|
|
239
|
+
start_line = i + 1 # 1-indexed
|
|
240
|
+
ours_label = (m_start.group(1) or "HEAD").strip()
|
|
241
|
+
raw_lines = [line]
|
|
242
|
+
|
|
243
|
+
ours_lines: List[str] = []
|
|
244
|
+
base_lines: Optional[List[str]] = None
|
|
245
|
+
theirs_lines: List[str] = []
|
|
246
|
+
base_label: Optional[str] = None
|
|
247
|
+
theirs_label: str = ""
|
|
248
|
+
|
|
249
|
+
state = "ours"
|
|
250
|
+
i += 1
|
|
251
|
+
|
|
252
|
+
while i < num_lines:
|
|
253
|
+
curr_line = lines[i]
|
|
254
|
+
raw_lines.append(curr_line)
|
|
255
|
+
stripped = curr_line.rstrip("\r\n")
|
|
256
|
+
|
|
257
|
+
# Check for 3-way base marker: ||||||| [label]
|
|
258
|
+
m_base = re.match(r"^\|{7}(?:\s+(.*))?$", stripped)
|
|
259
|
+
if m_base and state == "ours":
|
|
260
|
+
base_label = (m_base.group(1) or "ancestor").strip()
|
|
261
|
+
base_lines = []
|
|
262
|
+
state = "base"
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Check for separator marker: =======
|
|
267
|
+
if stripped == "=======" and state in ("ours", "base"):
|
|
268
|
+
state = "theirs"
|
|
269
|
+
i += 1
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
# Check for end of conflict marker: >>>>>>> [label]
|
|
273
|
+
m_end = re.match(r"^>{7}(?:\s+(.*))?$", stripped)
|
|
274
|
+
if m_end and state == "theirs":
|
|
275
|
+
theirs_label = (m_end.group(1) or "").strip()
|
|
276
|
+
end_line = i + 1 # 1-indexed
|
|
277
|
+
|
|
278
|
+
ours_content = "".join(ours_lines)
|
|
279
|
+
base_content = "".join(base_lines) if base_lines is not None else None
|
|
280
|
+
theirs_content = "".join(theirs_lines)
|
|
281
|
+
raw_block = "".join(raw_lines)
|
|
282
|
+
|
|
283
|
+
block = ConflictBlock(
|
|
284
|
+
file_path=file_path,
|
|
285
|
+
start_line=start_line,
|
|
286
|
+
end_line=end_line,
|
|
287
|
+
ours_content=ours_content,
|
|
288
|
+
theirs_content=theirs_content,
|
|
289
|
+
base_content=base_content,
|
|
290
|
+
ours_label=ours_label,
|
|
291
|
+
theirs_label=theirs_label,
|
|
292
|
+
base_label=base_label,
|
|
293
|
+
raw_block=raw_block,
|
|
294
|
+
language=lang,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Extract AST & surrounding scope context
|
|
298
|
+
scope_name, surrounding_context = cls.extract_scope_context(
|
|
299
|
+
file_content=file_content,
|
|
300
|
+
conflict=block,
|
|
301
|
+
file_path=file_path,
|
|
302
|
+
)
|
|
303
|
+
block.scope_name = scope_name
|
|
304
|
+
block.surrounding_context = surrounding_context
|
|
305
|
+
|
|
306
|
+
blocks.append(block)
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
# Collect content according to current state
|
|
310
|
+
if state == "ours":
|
|
311
|
+
ours_lines.append(curr_line)
|
|
312
|
+
elif state == "base" and base_lines is not None:
|
|
313
|
+
base_lines.append(curr_line)
|
|
314
|
+
elif state == "theirs":
|
|
315
|
+
theirs_lines.append(curr_line)
|
|
316
|
+
|
|
317
|
+
i += 1
|
|
318
|
+
i += 1
|
|
319
|
+
|
|
320
|
+
return blocks
|
|
321
|
+
|
|
322
|
+
# =========================================================================
|
|
323
|
+
# AST & Scope Context Extraction
|
|
324
|
+
# =========================================================================
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def extract_scope_context(
|
|
328
|
+
cls,
|
|
329
|
+
file_content: str,
|
|
330
|
+
conflict: ConflictBlock,
|
|
331
|
+
file_path: str = "",
|
|
332
|
+
) -> Tuple[Optional[str], str]:
|
|
333
|
+
"""
|
|
334
|
+
Extracts semantic surrounding context and enclosing AST scope (class/function/imports)
|
|
335
|
+
for a conflict block.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Tuple of (scope_name, formatted_surrounding_context_str)
|
|
339
|
+
"""
|
|
340
|
+
lang = conflict.language or cls.detect_language(file_path)
|
|
341
|
+
lines = file_content.splitlines()
|
|
342
|
+
total_lines = len(lines)
|
|
343
|
+
|
|
344
|
+
start_idx = max(0, conflict.start_line - 1)
|
|
345
|
+
end_idx = min(total_lines, conflict.end_line)
|
|
346
|
+
|
|
347
|
+
# 1. Preceding and succeeding code window (up to 20 lines before and after)
|
|
348
|
+
preceding_start = max(0, start_idx - 20)
|
|
349
|
+
preceding_lines = lines[preceding_start:start_idx]
|
|
350
|
+
|
|
351
|
+
succeeding_end = min(total_lines, end_idx + 20)
|
|
352
|
+
succeeding_lines = lines[end_idx:succeeding_end]
|
|
353
|
+
|
|
354
|
+
scope_name: Optional[str] = None
|
|
355
|
+
imports_list: List[str] = []
|
|
356
|
+
|
|
357
|
+
if lang == "python":
|
|
358
|
+
scope_name, imports_list = cls._extract_python_ast_scope(
|
|
359
|
+
file_content=file_content,
|
|
360
|
+
conflict=conflict,
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
scope_name, imports_list = cls._extract_generic_scope(
|
|
364
|
+
lines=lines,
|
|
365
|
+
start_idx=start_idx,
|
|
366
|
+
lang=lang,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
context_parts: List[str] = []
|
|
370
|
+
if scope_name:
|
|
371
|
+
context_parts.append(f"Enclosing Scope: {scope_name}")
|
|
372
|
+
|
|
373
|
+
if imports_list:
|
|
374
|
+
context_parts.append("File Imports:\n" + "\n".join(imports_list[:15]))
|
|
375
|
+
|
|
376
|
+
if preceding_lines:
|
|
377
|
+
prec_text = "\n".join(preceding_lines)
|
|
378
|
+
context_parts.append(f"Preceding Context (Lines {preceding_start + 1}-{start_idx}):\n```\n{prec_text}\n```")
|
|
379
|
+
|
|
380
|
+
if succeeding_lines:
|
|
381
|
+
succ_text = "\n".join(succeeding_lines)
|
|
382
|
+
context_parts.append(f"Succeeding Context (Lines {end_idx + 1}-{succeeding_end}):\n```\n{succ_text}\n```")
|
|
383
|
+
|
|
384
|
+
surrounding_context = "\n\n".join(context_parts)
|
|
385
|
+
return scope_name, surrounding_context
|
|
386
|
+
|
|
387
|
+
@classmethod
|
|
388
|
+
def _extract_python_ast_scope(
|
|
389
|
+
cls,
|
|
390
|
+
file_content: str,
|
|
391
|
+
conflict: ConflictBlock,
|
|
392
|
+
) -> Tuple[Optional[str], List[str]]:
|
|
393
|
+
"""Extracts enclosing class/function and imports using Python AST."""
|
|
394
|
+
lines = file_content.splitlines(keepends=True)
|
|
395
|
+
start_idx = conflict.start_line - 1
|
|
396
|
+
end_idx = conflict.end_line
|
|
397
|
+
|
|
398
|
+
clean_lines = lines[:start_idx] + [conflict.ours_content] + lines[end_idx:]
|
|
399
|
+
clean_content = "".join(clean_lines)
|
|
400
|
+
|
|
401
|
+
imports: List[str] = []
|
|
402
|
+
scope_name: Optional[str] = None
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
tree = ast.parse(clean_content)
|
|
406
|
+
except SyntaxError:
|
|
407
|
+
# If ours_content has syntax errors, try with theirs_content
|
|
408
|
+
clean_lines2 = lines[:start_idx] + [conflict.theirs_content] + lines[end_idx:]
|
|
409
|
+
try:
|
|
410
|
+
tree = ast.parse("".join(clean_lines2))
|
|
411
|
+
except Exception:
|
|
412
|
+
# Fallback to regex extraction
|
|
413
|
+
return cls._extract_generic_scope(file_content.splitlines(), start_idx, "python")
|
|
414
|
+
|
|
415
|
+
# Collect top-level imports
|
|
416
|
+
for node in ast.walk(tree):
|
|
417
|
+
if isinstance(node, ast.Import):
|
|
418
|
+
for alias in node.names:
|
|
419
|
+
imports.append(f"import {alias.name}")
|
|
420
|
+
elif isinstance(node, ast.ImportFrom):
|
|
421
|
+
mod = node.module or ""
|
|
422
|
+
names = ", ".join(a.name for a in node.names)
|
|
423
|
+
imports.append(f"from {mod} import {names}")
|
|
424
|
+
|
|
425
|
+
# Find enclosing scope (deepest node enclosing conflict start_line)
|
|
426
|
+
target_line = conflict.start_line
|
|
427
|
+
best_scope: List[str] = []
|
|
428
|
+
|
|
429
|
+
def _traverse(node: ast.AST, stack: List[str]) -> None:
|
|
430
|
+
nonlocal best_scope
|
|
431
|
+
curr_stack = list(stack)
|
|
432
|
+
if isinstance(node, ast.ClassDef):
|
|
433
|
+
curr_stack.append(node.name)
|
|
434
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
435
|
+
curr_stack.append(node.name)
|
|
436
|
+
|
|
437
|
+
if hasattr(node, "lineno") and hasattr(node, "end_lineno"):
|
|
438
|
+
if node.lineno <= target_line <= (node.end_lineno or node.lineno):
|
|
439
|
+
if len(curr_stack) > len(best_scope):
|
|
440
|
+
best_scope = list(curr_stack)
|
|
441
|
+
|
|
442
|
+
for child in ast.iter_child_nodes(node):
|
|
443
|
+
_traverse(child, curr_stack)
|
|
444
|
+
|
|
445
|
+
_traverse(tree, [])
|
|
446
|
+
|
|
447
|
+
if best_scope:
|
|
448
|
+
scope_name = ".".join(best_scope)
|
|
449
|
+
|
|
450
|
+
return scope_name, imports
|
|
451
|
+
|
|
452
|
+
@classmethod
|
|
453
|
+
def _extract_generic_scope(
|
|
454
|
+
cls,
|
|
455
|
+
lines: List[str],
|
|
456
|
+
start_idx: int,
|
|
457
|
+
lang: str,
|
|
458
|
+
) -> Tuple[Optional[str], List[str]]:
|
|
459
|
+
"""Generic scope and import extractor for non-Python languages using pattern matching."""
|
|
460
|
+
imports: List[str] = []
|
|
461
|
+
scope_name: Optional[str] = None
|
|
462
|
+
|
|
463
|
+
import_patterns = [
|
|
464
|
+
re.compile(r"^\s*(?:import|export|from|require|use|#include)\s+.*"),
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
# Scan for imports in top 60 lines
|
|
468
|
+
for line in lines[:60]:
|
|
469
|
+
if any(p.match(line) for p in import_patterns):
|
|
470
|
+
imports.append(line.strip())
|
|
471
|
+
|
|
472
|
+
# Search backwards from start_idx for enclosing definition
|
|
473
|
+
def_patterns = [
|
|
474
|
+
re.compile(r"^\s*(?:export|public|private|protected|async|static|default)?\s*(?:class|struct|interface|trait|enum|function|def|func|fn)\s+([A-Za-z0-9_$]+)"),
|
|
475
|
+
re.compile(r"^\s*([A-Za-z0-9_$]+)\s*\([^)]*\)\s*\{"),
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
for line in reversed(lines[:start_idx]):
|
|
479
|
+
for p in def_patterns:
|
|
480
|
+
m = p.search(line)
|
|
481
|
+
if m:
|
|
482
|
+
scope_name = m.group(1)
|
|
483
|
+
break
|
|
484
|
+
if scope_name:
|
|
485
|
+
break
|
|
486
|
+
|
|
487
|
+
return scope_name, imports
|
|
488
|
+
|
|
489
|
+
# =========================================================================
|
|
490
|
+
# Fast Trivial Conflict Resolution
|
|
491
|
+
# =========================================================================
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def resolve_trivial(conflict: ConflictBlock) -> Optional[str]:
|
|
495
|
+
"""
|
|
496
|
+
Fast-paths trivial conflicts without calling LLM:
|
|
497
|
+
1. Identical content on both sides -> take ours.
|
|
498
|
+
2. 3-way base matches ours -> take theirs (clean incoming change).
|
|
499
|
+
3. 3-way base matches theirs -> take ours (clean local change).
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Resolved code string if trivial, or None if non-trivial.
|
|
503
|
+
"""
|
|
504
|
+
# Rule 1: Both sides are identical
|
|
505
|
+
if conflict.ours_content.strip() == conflict.theirs_content.strip():
|
|
506
|
+
return conflict.ours_content
|
|
507
|
+
|
|
508
|
+
# Rule 2 & 3: 3-way base checks
|
|
509
|
+
if conflict.base_content is not None:
|
|
510
|
+
base_clean = conflict.base_content.strip()
|
|
511
|
+
ours_clean = conflict.ours_content.strip()
|
|
512
|
+
theirs_clean = conflict.theirs_content.strip()
|
|
513
|
+
|
|
514
|
+
if ours_clean == base_clean:
|
|
515
|
+
# Local did not change; accept incoming changes
|
|
516
|
+
return conflict.theirs_content
|
|
517
|
+
|
|
518
|
+
if theirs_clean == base_clean:
|
|
519
|
+
# Incoming did not change; accept local changes
|
|
520
|
+
return conflict.ours_content
|
|
521
|
+
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
# =========================================================================
|
|
525
|
+
# Prompt Construction
|
|
526
|
+
# =========================================================================
|
|
527
|
+
|
|
528
|
+
@classmethod
|
|
529
|
+
def build_resolution_prompt(
|
|
530
|
+
cls,
|
|
531
|
+
conflict: ConflictBlock,
|
|
532
|
+
error_feedback: Optional[str] = None,
|
|
533
|
+
prompt_override: Optional[str] = None,
|
|
534
|
+
) -> Tuple[str, str]:
|
|
535
|
+
"""
|
|
536
|
+
Builds the system and user prompt for LLM conflict resolution.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
(system_prompt, user_prompt)
|
|
540
|
+
"""
|
|
541
|
+
system_prompt = (
|
|
542
|
+
"You are an expert compiler-grounded software engineer and git merge conflict resolver. "
|
|
543
|
+
"Your task is to analyze git conflict blocks, integrate changes from both branches semantically, "
|
|
544
|
+
"and produce clean, syntactically valid code without any conflict markers (<<<<<<<, =======, >>>>>>>)."
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if prompt_override:
|
|
548
|
+
return system_prompt, prompt_override
|
|
549
|
+
|
|
550
|
+
prompt_parts: List[str] = []
|
|
551
|
+
prompt_parts.append("### Git Merge Conflict Resolution Request")
|
|
552
|
+
prompt_parts.append(f"**Target File**: `{conflict.file_path}`")
|
|
553
|
+
prompt_parts.append(f"**Language**: `{conflict.language}`")
|
|
554
|
+
if conflict.scope_name:
|
|
555
|
+
prompt_parts.append(f"**Enclosing Scope**: `{conflict.scope_name}`")
|
|
556
|
+
|
|
557
|
+
if conflict.surrounding_context:
|
|
558
|
+
prompt_parts.append(f"### Surrounding Code Context\n{conflict.surrounding_context}")
|
|
559
|
+
|
|
560
|
+
prompt_parts.append("### Conflicting Sections")
|
|
561
|
+
if conflict.base_content is not None:
|
|
562
|
+
prompt_parts.append(
|
|
563
|
+
f"**Base / Ancestor ({conflict.base_label or 'base'}):**\n"
|
|
564
|
+
f"```{conflict.language}\n{conflict.base_content}\n```"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
prompt_parts.append(
|
|
568
|
+
f"**Ours / Local ({conflict.ours_label or 'HEAD'}):**\n"
|
|
569
|
+
f"```{conflict.language}\n{conflict.ours_content}\n```"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
prompt_parts.append(
|
|
573
|
+
f"**Theirs / Incoming ({conflict.theirs_label or 'incoming'}):**\n"
|
|
574
|
+
f"```{conflict.language}\n{conflict.theirs_content}\n```"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if error_feedback:
|
|
578
|
+
prompt_parts.append(
|
|
579
|
+
f"⚠️ **ATTENTION - PREVIOUS ATTEMPT VERIFICATION FAILED**:\n"
|
|
580
|
+
f"The previous resolution attempt produced the following syntax/verification error:\n"
|
|
581
|
+
f"```\n{error_feedback}\n```\n"
|
|
582
|
+
f"Please fix this syntax/semantic error and ensure the code compiles cleanly."
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
prompt_parts.append(
|
|
586
|
+
"### Instructions:\n"
|
|
587
|
+
"1. Synthesize the changes from both sides, preserving non-conflicting logic, imports, and variables.\n"
|
|
588
|
+
"2. Ensure proper indentation matching the surrounding context.\n"
|
|
589
|
+
"3. Output ONLY the resolved code replacement block inside markdown code fences: ```" + conflict.language + " ... ```\n"
|
|
590
|
+
"4. NEVER include git conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
user_prompt = "\n\n".join(prompt_parts)
|
|
594
|
+
return system_prompt, user_prompt
|
|
595
|
+
|
|
596
|
+
# =========================================================================
|
|
597
|
+
# Conflict Block Resolution
|
|
598
|
+
# =========================================================================
|
|
599
|
+
|
|
600
|
+
def resolve_conflict_block(
|
|
601
|
+
self,
|
|
602
|
+
conflict: ConflictBlock,
|
|
603
|
+
llm_driver: Any,
|
|
604
|
+
model: Optional[str] = None,
|
|
605
|
+
prompt_override: Optional[str] = None,
|
|
606
|
+
max_retries: int = 3,
|
|
607
|
+
verifier: Optional[Any] = None,
|
|
608
|
+
) -> ConflictResolution:
|
|
609
|
+
"""
|
|
610
|
+
Resolves a single ConflictBlock using LLM inference and a verification gate.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
conflict: The ConflictBlock to resolve.
|
|
614
|
+
llm_driver: LLMDriver instance (or mock).
|
|
615
|
+
model: Optional model name override.
|
|
616
|
+
prompt_override: Optional user prompt override.
|
|
617
|
+
max_retries: Maximum verification retry attempts (default: 3).
|
|
618
|
+
verifier: Optional Verifier instance for AST / syntax checks.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
ConflictResolution object with status, resolved content, and attempts.
|
|
622
|
+
"""
|
|
623
|
+
# Step 1: Check trivial fast-path
|
|
624
|
+
trivial = self.resolve_trivial(conflict)
|
|
625
|
+
if trivial is not None:
|
|
626
|
+
return ConflictResolution(
|
|
627
|
+
conflict=conflict,
|
|
628
|
+
resolved_content=trivial,
|
|
629
|
+
success=True,
|
|
630
|
+
attempts=0,
|
|
631
|
+
explanation="Resolved via fast-path 3-way/identical change deduction.",
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
error_feedback: Optional[str] = None
|
|
635
|
+
last_error: Optional[str] = None
|
|
636
|
+
last_extracted_code: str = ""
|
|
637
|
+
|
|
638
|
+
# Step 2: Retry loop with verification gate
|
|
639
|
+
for attempt in range(1, max_retries + 1):
|
|
640
|
+
sys_prompt, user_prompt = self.build_resolution_prompt(
|
|
641
|
+
conflict=conflict,
|
|
642
|
+
error_feedback=error_feedback,
|
|
643
|
+
prompt_override=prompt_override,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
raw_response = llm_driver.generate(
|
|
648
|
+
prompt=user_prompt,
|
|
649
|
+
system_prompt=sys_prompt,
|
|
650
|
+
temperature=0.1 if attempt > 1 else 0.2,
|
|
651
|
+
)
|
|
652
|
+
except Exception as e:
|
|
653
|
+
logger.error("LLM generation failed for conflict block in %s: %s", conflict.file_path, e)
|
|
654
|
+
last_error = f"LLM generation exception: {str(e)}"
|
|
655
|
+
continue
|
|
656
|
+
|
|
657
|
+
# Extract clean code
|
|
658
|
+
extracted_code = raw_response
|
|
659
|
+
if CodeExtractor is not None:
|
|
660
|
+
_, extracted_code = CodeExtractor.extract_primary_code(
|
|
661
|
+
raw_response, default_lang=conflict.language
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
# Fallback block extraction
|
|
665
|
+
m = re.search(r"```(?:[a-zA-Z0-9_\-+]*)\n(.*?)```", raw_response, re.DOTALL)
|
|
666
|
+
if m:
|
|
667
|
+
extracted_code = m.group(1).strip()
|
|
668
|
+
else:
|
|
669
|
+
extracted_code = raw_response.strip()
|
|
670
|
+
|
|
671
|
+
last_extracted_code = extracted_code
|
|
672
|
+
|
|
673
|
+
# Check if output still contains conflict markers
|
|
674
|
+
if any(marker in extracted_code for marker in ("<<<<<<<", "=======", ">>>>>>>")):
|
|
675
|
+
last_error = "Generated resolution still contains git conflict markers (<<<<<<<, =======, >>>>>>>)."
|
|
676
|
+
error_feedback = last_error
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
# Step 3: Verification Gate
|
|
680
|
+
is_valid = True
|
|
681
|
+
v_res: Optional[Any] = None
|
|
682
|
+
|
|
683
|
+
if verifier is not None and conflict.language == "python":
|
|
684
|
+
test_code = extracted_code
|
|
685
|
+
try:
|
|
686
|
+
ast.parse(test_code)
|
|
687
|
+
except SyntaxError:
|
|
688
|
+
try:
|
|
689
|
+
ast.parse(textwrap.dedent(test_code))
|
|
690
|
+
except SyntaxError:
|
|
691
|
+
try:
|
|
692
|
+
wrapped = f"def _dummy_scope():\n{textwrap.indent(test_code, ' ')}"
|
|
693
|
+
ast.parse(wrapped)
|
|
694
|
+
except SyntaxError as syn_err:
|
|
695
|
+
is_valid = False
|
|
696
|
+
last_error = f"SyntaxError in resolved code: {syn_err.msg} at line {syn_err.lineno}"
|
|
697
|
+
error_feedback = last_error
|
|
698
|
+
if hasattr(verifier, "verify_python_ast"):
|
|
699
|
+
v_res = verifier.verify_python_ast(test_code)
|
|
700
|
+
|
|
701
|
+
if is_valid:
|
|
702
|
+
return ConflictResolution(
|
|
703
|
+
conflict=conflict,
|
|
704
|
+
resolved_content=extracted_code,
|
|
705
|
+
success=True,
|
|
706
|
+
attempts=attempt,
|
|
707
|
+
verification_result=v_res,
|
|
708
|
+
explanation="Successfully resolved and verified by AST parser.",
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
# If we exhausted retries
|
|
712
|
+
return ConflictResolution(
|
|
713
|
+
conflict=conflict,
|
|
714
|
+
resolved_content=last_extracted_code if not error_feedback else "",
|
|
715
|
+
success=False,
|
|
716
|
+
attempts=max_retries,
|
|
717
|
+
error_message=last_error or f"Failed verification after {max_retries} attempts.",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# =========================================================================
|
|
721
|
+
# File Resolution & Auto-Staging
|
|
722
|
+
# =========================================================================
|
|
723
|
+
|
|
724
|
+
@classmethod
|
|
725
|
+
def apply_resolutions(cls, file_content: str, resolutions: List[ConflictResolution]) -> str:
|
|
726
|
+
"""
|
|
727
|
+
Applies successful resolutions to file content.
|
|
728
|
+
Replaces each conflict block with its resolved content.
|
|
729
|
+
"""
|
|
730
|
+
sorted_res = sorted(resolutions, key=lambda r: r.conflict.start_line, reverse=True)
|
|
731
|
+
|
|
732
|
+
lines = file_content.splitlines(keepends=True)
|
|
733
|
+
total_lines = len(lines)
|
|
734
|
+
|
|
735
|
+
for res in sorted_res:
|
|
736
|
+
if not res.success:
|
|
737
|
+
continue
|
|
738
|
+
start_idx = res.conflict.start_line - 1
|
|
739
|
+
end_idx = min(total_lines, res.conflict.end_line)
|
|
740
|
+
|
|
741
|
+
replacement = res.resolved_content
|
|
742
|
+
if replacement and not replacement.endswith("\n"):
|
|
743
|
+
replacement += "\n"
|
|
744
|
+
|
|
745
|
+
lines = lines[:start_idx] + [replacement] + lines[end_idx:]
|
|
746
|
+
total_lines = len(lines)
|
|
747
|
+
|
|
748
|
+
return "".join(lines)
|
|
749
|
+
|
|
750
|
+
def resolve_file(
|
|
751
|
+
self,
|
|
752
|
+
file_path: str,
|
|
753
|
+
llm_driver: Any,
|
|
754
|
+
verifier: Optional[Any] = None,
|
|
755
|
+
auto_stage: bool = True,
|
|
756
|
+
max_retries: int = 3,
|
|
757
|
+
) -> FileResolutionResult:
|
|
758
|
+
"""
|
|
759
|
+
Resolves all conflict blocks within a single file.
|
|
760
|
+
Verifies the full file after all block resolutions, safely writes the output,
|
|
761
|
+
and optionally auto-stages the file with git.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
file_path: Relative or absolute path to the conflicted file.
|
|
765
|
+
llm_driver: LLM inference driver.
|
|
766
|
+
verifier: Ground-truth verifier guard.
|
|
767
|
+
auto_stage: Whether to `git add <file_path>` upon verified resolution.
|
|
768
|
+
max_retries: Max retries per conflict block.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
FileResolutionResult.
|
|
772
|
+
"""
|
|
773
|
+
path = _sanitize_path(file_path)
|
|
774
|
+
if not path.exists() or not path.is_file():
|
|
775
|
+
return FileResolutionResult(
|
|
776
|
+
file_path=file_path,
|
|
777
|
+
success=False,
|
|
778
|
+
total_conflicts=0,
|
|
779
|
+
resolved_conflicts=0,
|
|
780
|
+
error_message=f"File not found: {file_path}",
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
original_content = path.read_text(encoding="utf-8")
|
|
785
|
+
except Exception as e:
|
|
786
|
+
return FileResolutionResult(
|
|
787
|
+
file_path=file_path,
|
|
788
|
+
success=False,
|
|
789
|
+
total_conflicts=0,
|
|
790
|
+
resolved_conflicts=0,
|
|
791
|
+
error_message=f"Failed to read file: {str(e)}",
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
blocks = self.parse_conflict_blocks(original_content, file_path=str(path))
|
|
795
|
+
if not blocks:
|
|
796
|
+
return FileResolutionResult(
|
|
797
|
+
file_path=file_path,
|
|
798
|
+
success=True,
|
|
799
|
+
total_conflicts=0,
|
|
800
|
+
resolved_conflicts=0,
|
|
801
|
+
resolutions=[],
|
|
802
|
+
staged=False,
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
resolutions: List[ConflictResolution] = []
|
|
806
|
+
all_succeeded = True
|
|
807
|
+
|
|
808
|
+
for block in blocks:
|
|
809
|
+
res = self.resolve_conflict_block(
|
|
810
|
+
conflict=block,
|
|
811
|
+
llm_driver=llm_driver,
|
|
812
|
+
model=self.default_model,
|
|
813
|
+
max_retries=max_retries,
|
|
814
|
+
verifier=verifier,
|
|
815
|
+
)
|
|
816
|
+
resolutions.append(res)
|
|
817
|
+
if not res.success:
|
|
818
|
+
all_succeeded = False
|
|
819
|
+
|
|
820
|
+
if not all_succeeded:
|
|
821
|
+
failed_count = sum(1 for r in resolutions if not r.success)
|
|
822
|
+
return FileResolutionResult(
|
|
823
|
+
file_path=file_path,
|
|
824
|
+
success=False,
|
|
825
|
+
total_conflicts=len(blocks),
|
|
826
|
+
resolved_conflicts=len(blocks) - failed_count,
|
|
827
|
+
resolutions=resolutions,
|
|
828
|
+
staged=False,
|
|
829
|
+
error_message=f"{failed_count} conflict block(s) failed verification. File was not modified.",
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# Apply resolutions to construct the full resolved file
|
|
833
|
+
resolved_file_content = self.apply_resolutions(original_content, resolutions)
|
|
834
|
+
|
|
835
|
+
# Verify full file syntax if verifier is present
|
|
836
|
+
lang = self.detect_language(file_path)
|
|
837
|
+
if verifier is not None and lang == "python":
|
|
838
|
+
if hasattr(verifier, "verify_python_ast"):
|
|
839
|
+
ast_check = verifier.verify_python_ast(resolved_file_content)
|
|
840
|
+
if not ast_check.success:
|
|
841
|
+
logger.warning(
|
|
842
|
+
"Resolved full file %s failed AST check: %s",
|
|
843
|
+
file_path,
|
|
844
|
+
ast_check.error_trace,
|
|
845
|
+
)
|
|
846
|
+
return FileResolutionResult(
|
|
847
|
+
file_path=file_path,
|
|
848
|
+
success=False,
|
|
849
|
+
total_conflicts=len(blocks),
|
|
850
|
+
resolved_conflicts=len(blocks),
|
|
851
|
+
resolutions=resolutions,
|
|
852
|
+
staged=False,
|
|
853
|
+
error_message=f"Full file AST verification failed: {ast_check.error_trace}",
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
# Safely write out resolved content
|
|
857
|
+
try:
|
|
858
|
+
path.write_text(resolved_file_content, encoding="utf-8")
|
|
859
|
+
except Exception as e:
|
|
860
|
+
return FileResolutionResult(
|
|
861
|
+
file_path=file_path,
|
|
862
|
+
success=False,
|
|
863
|
+
total_conflicts=len(blocks),
|
|
864
|
+
resolved_conflicts=len(blocks),
|
|
865
|
+
resolutions=resolutions,
|
|
866
|
+
staged=False,
|
|
867
|
+
error_message=f"Failed to write resolved file: {str(e)}",
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# Auto-stage with Git if requested
|
|
871
|
+
staged = False
|
|
872
|
+
if auto_stage:
|
|
873
|
+
staged = self._stage_file_git(file_path=str(path))
|
|
874
|
+
|
|
875
|
+
return FileResolutionResult(
|
|
876
|
+
file_path=file_path,
|
|
877
|
+
success=True,
|
|
878
|
+
total_conflicts=len(blocks),
|
|
879
|
+
resolved_conflicts=len(blocks),
|
|
880
|
+
resolutions=resolutions,
|
|
881
|
+
staged=staged,
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
def _stage_file_git(self, file_path: str) -> bool:
|
|
885
|
+
"""Helper to stage a resolved file using git add."""
|
|
886
|
+
try:
|
|
887
|
+
p = _sanitize_path(file_path)
|
|
888
|
+
if not p.exists():
|
|
889
|
+
return False
|
|
890
|
+
parent_dir = p.parent
|
|
891
|
+
res = subprocess.run(
|
|
892
|
+
["git", "add", p.name],
|
|
893
|
+
cwd=str(parent_dir),
|
|
894
|
+
capture_output=True,
|
|
895
|
+
text=True,
|
|
896
|
+
)
|
|
897
|
+
return res.returncode == 0
|
|
898
|
+
except Exception:
|
|
899
|
+
return False
|
|
900
|
+
|
|
901
|
+
# =========================================================================
|
|
902
|
+
# Repository-Wide Conflict Discovery & Resolution
|
|
903
|
+
# =========================================================================
|
|
904
|
+
|
|
905
|
+
def find_conflicts(self, repo_path: str = ".") -> List[ConflictBlock]:
|
|
906
|
+
"""
|
|
907
|
+
Finds all conflict blocks across all files in the repository.
|
|
908
|
+
Scans git unmerged status (`git diff --diff-filter=U` / `git status`)
|
|
909
|
+
and performs workspace file scanning for `<<<<<<< ` markers.
|
|
910
|
+
|
|
911
|
+
Args:
|
|
912
|
+
repo_path: Path to repository workspace root or individual file.
|
|
913
|
+
|
|
914
|
+
Returns:
|
|
915
|
+
List of all ConflictBlock objects found across the workspace.
|
|
916
|
+
"""
|
|
917
|
+
target = _sanitize_path(repo_path)
|
|
918
|
+
if not target.exists():
|
|
919
|
+
return []
|
|
920
|
+
|
|
921
|
+
if target.is_file():
|
|
922
|
+
try:
|
|
923
|
+
content = target.read_text(encoding="utf-8")
|
|
924
|
+
return self.parse_conflict_blocks(content, file_path=str(target))
|
|
925
|
+
except Exception:
|
|
926
|
+
return []
|
|
927
|
+
|
|
928
|
+
all_conflicts: List[ConflictBlock] = []
|
|
929
|
+
conflicted_files: Set[Path] = set()
|
|
930
|
+
|
|
931
|
+
# Method 1: Check git unmerged files
|
|
932
|
+
try:
|
|
933
|
+
res = subprocess.run(
|
|
934
|
+
["git", "diff", "--name-only", "--diff-filter=U"],
|
|
935
|
+
cwd=str(target),
|
|
936
|
+
capture_output=True,
|
|
937
|
+
text=True,
|
|
938
|
+
)
|
|
939
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
940
|
+
for line in res.stdout.splitlines():
|
|
941
|
+
rel_p = line.strip()
|
|
942
|
+
if rel_p:
|
|
943
|
+
candidate = (target / rel_p).resolve()
|
|
944
|
+
if candidate.is_relative_to(target) and candidate.exists():
|
|
945
|
+
conflicted_files.add(candidate)
|
|
946
|
+
except Exception:
|
|
947
|
+
pass
|
|
948
|
+
|
|
949
|
+
# Method 2: Recursive scan for conflict markers
|
|
950
|
+
ignored_dirs = {".git", ".venv", "k_cli_env", "venv", "node_modules", "build", "dist", "__pycache__", ".pytest_cache", "data"}
|
|
951
|
+
for root, dirs, files in os.walk(str(target)):
|
|
952
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith(".")]
|
|
953
|
+
for fname in files:
|
|
954
|
+
if fname.startswith("."):
|
|
955
|
+
continue
|
|
956
|
+
fpath = (Path(root) / fname).resolve()
|
|
957
|
+
if not fpath.is_relative_to(target):
|
|
958
|
+
continue
|
|
959
|
+
try:
|
|
960
|
+
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
|
961
|
+
chunk = f.read(1024 * 1024)
|
|
962
|
+
if "<<<<<<< " in chunk:
|
|
963
|
+
conflicted_files.add(fpath)
|
|
964
|
+
except Exception:
|
|
965
|
+
continue
|
|
966
|
+
|
|
967
|
+
for fpath in sorted(conflicted_files):
|
|
968
|
+
try:
|
|
969
|
+
content = fpath.read_text(encoding="utf-8")
|
|
970
|
+
blocks = self.parse_conflict_blocks(content, file_path=str(fpath))
|
|
971
|
+
all_conflicts.extend(blocks)
|
|
972
|
+
except Exception as e:
|
|
973
|
+
logger.warning("Could not read conflicted file %s: %s", fpath, e)
|
|
974
|
+
|
|
975
|
+
return all_conflicts
|
|
976
|
+
|
|
977
|
+
def resolve_all_conflicts(
|
|
978
|
+
self,
|
|
979
|
+
repo_path: str = ".",
|
|
980
|
+
llm_driver: Any = None,
|
|
981
|
+
verifier: Optional[Any] = None,
|
|
982
|
+
auto_stage: bool = True,
|
|
983
|
+
model_name: Optional[str] = None,
|
|
984
|
+
mock: bool = False,
|
|
985
|
+
**kwargs: Any,
|
|
986
|
+
) -> ConflictSummary:
|
|
987
|
+
"""
|
|
988
|
+
Discovers all conflicted files in repository, resolves each file,
|
|
989
|
+
verifies AST/syntax, auto-stages, and returns ConflictSummary.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
repo_path: Path to repository workspace.
|
|
993
|
+
llm_driver: LLMDriver instance.
|
|
994
|
+
verifier: Ground-truth verifier.
|
|
995
|
+
auto_stage: Whether to git add resolved files.
|
|
996
|
+
|
|
997
|
+
Returns:
|
|
998
|
+
ConflictSummary.
|
|
999
|
+
"""
|
|
1000
|
+
conflicts = self.find_conflicts(repo_path=repo_path)
|
|
1001
|
+
conflicted_files = sorted({c.file_path for c in conflicts if c.file_path})
|
|
1002
|
+
|
|
1003
|
+
if not conflicted_files:
|
|
1004
|
+
return ConflictSummary(
|
|
1005
|
+
repo_path=repo_path,
|
|
1006
|
+
total_files=0,
|
|
1007
|
+
resolved_files=0,
|
|
1008
|
+
failed_files=0,
|
|
1009
|
+
file_results={},
|
|
1010
|
+
success=True,
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
file_results: Dict[str, FileResolutionResult] = {}
|
|
1014
|
+
resolved_count = 0
|
|
1015
|
+
failed_count = 0
|
|
1016
|
+
|
|
1017
|
+
for fpath in conflicted_files:
|
|
1018
|
+
res = self.resolve_file(
|
|
1019
|
+
file_path=fpath,
|
|
1020
|
+
llm_driver=llm_driver,
|
|
1021
|
+
verifier=verifier,
|
|
1022
|
+
auto_stage=auto_stage,
|
|
1023
|
+
)
|
|
1024
|
+
file_results[fpath] = res
|
|
1025
|
+
if res.success:
|
|
1026
|
+
resolved_count += 1
|
|
1027
|
+
else:
|
|
1028
|
+
failed_count += 1
|
|
1029
|
+
|
|
1030
|
+
overall_success = (failed_count == 0)
|
|
1031
|
+
|
|
1032
|
+
return ConflictSummary(
|
|
1033
|
+
repo_path=repo_path,
|
|
1034
|
+
total_files=len(conflicted_files),
|
|
1035
|
+
resolved_files=resolved_count,
|
|
1036
|
+
failed_files=failed_count,
|
|
1037
|
+
file_results=file_results,
|
|
1038
|
+
success=overall_success,
|
|
1039
|
+
)
|