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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. 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
+ )