cognify-code 0.2.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.
- ai_code_assistant/__init__.py +14 -0
- ai_code_assistant/agent/__init__.py +63 -0
- ai_code_assistant/agent/code_agent.py +461 -0
- ai_code_assistant/agent/code_generator.py +388 -0
- ai_code_assistant/agent/code_reviewer.py +365 -0
- ai_code_assistant/agent/diff_engine.py +308 -0
- ai_code_assistant/agent/file_manager.py +300 -0
- ai_code_assistant/agent/intent_classifier.py +284 -0
- ai_code_assistant/chat/__init__.py +11 -0
- ai_code_assistant/chat/agent_session.py +156 -0
- ai_code_assistant/chat/session.py +165 -0
- ai_code_assistant/cli.py +1571 -0
- ai_code_assistant/config.py +149 -0
- ai_code_assistant/editor/__init__.py +8 -0
- ai_code_assistant/editor/diff_handler.py +270 -0
- ai_code_assistant/editor/file_editor.py +350 -0
- ai_code_assistant/editor/prompts.py +146 -0
- ai_code_assistant/generator/__init__.py +7 -0
- ai_code_assistant/generator/code_gen.py +265 -0
- ai_code_assistant/generator/prompts.py +114 -0
- ai_code_assistant/git/__init__.py +6 -0
- ai_code_assistant/git/commit_generator.py +130 -0
- ai_code_assistant/git/manager.py +203 -0
- ai_code_assistant/llm.py +111 -0
- ai_code_assistant/providers/__init__.py +23 -0
- ai_code_assistant/providers/base.py +124 -0
- ai_code_assistant/providers/cerebras.py +97 -0
- ai_code_assistant/providers/factory.py +148 -0
- ai_code_assistant/providers/google.py +103 -0
- ai_code_assistant/providers/groq.py +111 -0
- ai_code_assistant/providers/ollama.py +86 -0
- ai_code_assistant/providers/openai.py +114 -0
- ai_code_assistant/providers/openrouter.py +130 -0
- ai_code_assistant/py.typed +0 -0
- ai_code_assistant/refactor/__init__.py +20 -0
- ai_code_assistant/refactor/analyzer.py +189 -0
- ai_code_assistant/refactor/change_plan.py +172 -0
- ai_code_assistant/refactor/multi_file_editor.py +346 -0
- ai_code_assistant/refactor/prompts.py +175 -0
- ai_code_assistant/retrieval/__init__.py +19 -0
- ai_code_assistant/retrieval/chunker.py +215 -0
- ai_code_assistant/retrieval/indexer.py +236 -0
- ai_code_assistant/retrieval/search.py +239 -0
- ai_code_assistant/reviewer/__init__.py +7 -0
- ai_code_assistant/reviewer/analyzer.py +278 -0
- ai_code_assistant/reviewer/prompts.py +113 -0
- ai_code_assistant/utils/__init__.py +18 -0
- ai_code_assistant/utils/file_handler.py +155 -0
- ai_code_assistant/utils/formatters.py +259 -0
- cognify_code-0.2.0.dist-info/METADATA +383 -0
- cognify_code-0.2.0.dist-info/RECORD +55 -0
- cognify_code-0.2.0.dist-info/WHEEL +5 -0
- cognify_code-0.2.0.dist-info/entry_points.txt +3 -0
- cognify_code-0.2.0.dist-info/licenses/LICENSE +22 -0
- cognify_code-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Code Reviewer for AI-powered code analysis."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from ai_code_assistant.agent.file_manager import FileContextManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IssueSeverity(Enum):
|
|
11
|
+
"""Severity levels for code issues."""
|
|
12
|
+
CRITICAL = "critical"
|
|
13
|
+
HIGH = "high"
|
|
14
|
+
MEDIUM = "medium"
|
|
15
|
+
LOW = "low"
|
|
16
|
+
INFO = "info"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IssueCategory(Enum):
|
|
20
|
+
"""Categories of code issues."""
|
|
21
|
+
SECURITY = "security"
|
|
22
|
+
BUG = "bug"
|
|
23
|
+
PERFORMANCE = "performance"
|
|
24
|
+
STYLE = "style"
|
|
25
|
+
MAINTAINABILITY = "maintainability"
|
|
26
|
+
BEST_PRACTICE = "best_practice"
|
|
27
|
+
ERROR_HANDLING = "error_handling"
|
|
28
|
+
DOCUMENTATION = "documentation"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CodeIssue:
|
|
33
|
+
"""Represents a code issue found during review."""
|
|
34
|
+
severity: IssueSeverity
|
|
35
|
+
category: IssueCategory
|
|
36
|
+
message: str
|
|
37
|
+
line_number: Optional[int] = None
|
|
38
|
+
line_content: str = ""
|
|
39
|
+
suggestion: str = ""
|
|
40
|
+
fixed_code: str = ""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def severity_icon(self) -> str:
|
|
44
|
+
icons = {
|
|
45
|
+
IssueSeverity.CRITICAL: "🔴",
|
|
46
|
+
IssueSeverity.HIGH: "🟠",
|
|
47
|
+
IssueSeverity.MEDIUM: "��",
|
|
48
|
+
IssueSeverity.LOW: "🔵",
|
|
49
|
+
IssueSeverity.INFO: "⚪",
|
|
50
|
+
}
|
|
51
|
+
return icons.get(self.severity, "⚪")
|
|
52
|
+
|
|
53
|
+
def format(self) -> str:
|
|
54
|
+
"""Format issue for display."""
|
|
55
|
+
lines = [
|
|
56
|
+
f"{self.severity_icon} [{self.severity.value.upper()}] {self.message}"
|
|
57
|
+
]
|
|
58
|
+
if self.line_number:
|
|
59
|
+
lines.append(f" Line {self.line_number}: {self.line_content[:60]}")
|
|
60
|
+
if self.suggestion:
|
|
61
|
+
lines.append(f" → {self.suggestion}")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ReviewResult:
|
|
67
|
+
"""Result of a code review."""
|
|
68
|
+
file_path: str
|
|
69
|
+
issues: List[CodeIssue] = field(default_factory=list)
|
|
70
|
+
summary: str = ""
|
|
71
|
+
score: int = 100 # 0-100 quality score
|
|
72
|
+
reviewed_lines: int = 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def critical_count(self) -> int:
|
|
76
|
+
return sum(1 for i in self.issues if i.severity == IssueSeverity.CRITICAL)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def high_count(self) -> int:
|
|
80
|
+
return sum(1 for i in self.issues if i.severity == IssueSeverity.HIGH)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def medium_count(self) -> int:
|
|
84
|
+
return sum(1 for i in self.issues if i.severity == IssueSeverity.MEDIUM)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def low_count(self) -> int:
|
|
88
|
+
return sum(1 for i in self.issues if i.severity == IssueSeverity.LOW)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_critical_issues(self) -> bool:
|
|
92
|
+
return self.critical_count > 0
|
|
93
|
+
|
|
94
|
+
def format_summary(self) -> str:
|
|
95
|
+
"""Format review summary."""
|
|
96
|
+
lines = [
|
|
97
|
+
f"📋 Code Review: {self.file_path}",
|
|
98
|
+
f" Score: {self.score}/100",
|
|
99
|
+
f" Issues: 🔴{self.critical_count} 🟠{self.high_count} 🟡{self.medium_count} 🔵{self.low_count}",
|
|
100
|
+
"",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
if self.summary:
|
|
104
|
+
lines.append(f"Summary: {self.summary}")
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
REVIEW_PROMPT = '''You are an expert code reviewer. Analyze the following code and identify issues.
|
|
111
|
+
|
|
112
|
+
## Code to Review
|
|
113
|
+
File: {file_path}
|
|
114
|
+
Language: {language}
|
|
115
|
+
|
|
116
|
+
```{language}
|
|
117
|
+
{code}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Review Focus Areas
|
|
121
|
+
1. **Security**: SQL injection, XSS, authentication issues, secrets exposure
|
|
122
|
+
2. **Bugs**: Logic errors, null references, race conditions, edge cases
|
|
123
|
+
3. **Performance**: Inefficient algorithms, memory leaks, N+1 queries
|
|
124
|
+
4. **Error Handling**: Missing try/catch, unhandled exceptions
|
|
125
|
+
5. **Best Practices**: Code style, naming conventions, SOLID principles
|
|
126
|
+
6. **Maintainability**: Code complexity, duplication, documentation
|
|
127
|
+
|
|
128
|
+
## Output Format
|
|
129
|
+
For each issue found, provide:
|
|
130
|
+
- SEVERITY: critical/high/medium/low/info
|
|
131
|
+
- CATEGORY: security/bug/performance/style/maintainability/best_practice/error_handling/documentation
|
|
132
|
+
- LINE: line number (if applicable)
|
|
133
|
+
- MESSAGE: brief description of the issue
|
|
134
|
+
- SUGGESTION: how to fix it
|
|
135
|
+
- FIXED_CODE: corrected code snippet (if applicable)
|
|
136
|
+
|
|
137
|
+
Format each issue as:
|
|
138
|
+
```
|
|
139
|
+
ISSUE:
|
|
140
|
+
SEVERITY: <severity>
|
|
141
|
+
CATEGORY: <category>
|
|
142
|
+
LINE: <number or "N/A">
|
|
143
|
+
MESSAGE: <description>
|
|
144
|
+
SUGGESTION: <fix recommendation>
|
|
145
|
+
FIXED_CODE:
|
|
146
|
+
<code if applicable>
|
|
147
|
+
END_ISSUE
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
After all issues, provide:
|
|
151
|
+
```
|
|
152
|
+
SUMMARY: <overall assessment>
|
|
153
|
+
SCORE: <0-100>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If no issues found, respond with:
|
|
157
|
+
```
|
|
158
|
+
SUMMARY: Code looks good! No significant issues found.
|
|
159
|
+
SCORE: 95
|
|
160
|
+
```
|
|
161
|
+
'''
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class CodeReviewer:
|
|
165
|
+
"""Reviews code for issues and suggests improvements."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, llm_manager, file_manager: Optional[FileContextManager] = None):
|
|
168
|
+
self.llm = llm_manager
|
|
169
|
+
self.file_manager = file_manager or FileContextManager()
|
|
170
|
+
|
|
171
|
+
def review_file(self, file_path: str,
|
|
172
|
+
focus_areas: Optional[List[IssueCategory]] = None) -> ReviewResult:
|
|
173
|
+
"""Review a file for issues."""
|
|
174
|
+
content = self.file_manager.read_file(file_path)
|
|
175
|
+
|
|
176
|
+
if not content:
|
|
177
|
+
raise ValueError(f"Cannot read file: {file_path}")
|
|
178
|
+
|
|
179
|
+
return self.review_code(content, file_path, focus_areas)
|
|
180
|
+
|
|
181
|
+
def review_code(self, code: str, file_path: str = "code",
|
|
182
|
+
focus_areas: Optional[List[IssueCategory]] = None) -> ReviewResult:
|
|
183
|
+
"""Review code content for issues."""
|
|
184
|
+
# Detect language
|
|
185
|
+
language = self._detect_language(file_path)
|
|
186
|
+
|
|
187
|
+
# Build prompt
|
|
188
|
+
prompt = REVIEW_PROMPT.format(
|
|
189
|
+
file_path=file_path,
|
|
190
|
+
language=language,
|
|
191
|
+
code=code[:8000], # Limit code size
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Get review from LLM
|
|
195
|
+
response = self.llm.invoke(prompt)
|
|
196
|
+
|
|
197
|
+
# Parse response
|
|
198
|
+
result = self._parse_review_response(response, file_path, code)
|
|
199
|
+
|
|
200
|
+
# Filter by focus areas if specified
|
|
201
|
+
if focus_areas:
|
|
202
|
+
result.issues = [i for i in result.issues if i.category in focus_areas]
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
def quick_review(self, file_path: str) -> str:
|
|
207
|
+
"""Get a quick review summary."""
|
|
208
|
+
result = self.review_file(file_path)
|
|
209
|
+
return result.format_summary()
|
|
210
|
+
|
|
211
|
+
def security_review(self, file_path: str) -> ReviewResult:
|
|
212
|
+
"""Focus on security issues only."""
|
|
213
|
+
return self.review_file(file_path, focus_areas=[IssueCategory.SECURITY])
|
|
214
|
+
|
|
215
|
+
def get_fix_suggestions(self, file_path: str) -> List[dict]:
|
|
216
|
+
"""Get actionable fix suggestions for a file."""
|
|
217
|
+
result = self.review_file(file_path)
|
|
218
|
+
|
|
219
|
+
suggestions = []
|
|
220
|
+
for issue in result.issues:
|
|
221
|
+
if issue.fixed_code:
|
|
222
|
+
suggestions.append({
|
|
223
|
+
"line": issue.line_number,
|
|
224
|
+
"original": issue.line_content,
|
|
225
|
+
"fixed": issue.fixed_code,
|
|
226
|
+
"reason": issue.message,
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
return suggestions
|
|
230
|
+
|
|
231
|
+
def _detect_language(self, file_path: str) -> str:
|
|
232
|
+
"""Detect language from file extension."""
|
|
233
|
+
ext_map = {
|
|
234
|
+
".py": "python",
|
|
235
|
+
".js": "javascript",
|
|
236
|
+
".ts": "typescript",
|
|
237
|
+
".jsx": "javascript",
|
|
238
|
+
".tsx": "typescript",
|
|
239
|
+
".java": "java",
|
|
240
|
+
".go": "go",
|
|
241
|
+
".rs": "rust",
|
|
242
|
+
".rb": "ruby",
|
|
243
|
+
".php": "php",
|
|
244
|
+
".c": "c",
|
|
245
|
+
".cpp": "cpp",
|
|
246
|
+
".cs": "csharp",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for ext, lang in ext_map.items():
|
|
250
|
+
if file_path.endswith(ext):
|
|
251
|
+
return lang
|
|
252
|
+
|
|
253
|
+
return "text"
|
|
254
|
+
|
|
255
|
+
def _parse_review_response(self, response: str, file_path: str, code: str) -> ReviewResult:
|
|
256
|
+
"""Parse LLM response into ReviewResult."""
|
|
257
|
+
result = ReviewResult(
|
|
258
|
+
file_path=file_path,
|
|
259
|
+
reviewed_lines=code.count("\n") + 1,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Parse issues
|
|
263
|
+
import re
|
|
264
|
+
|
|
265
|
+
issue_pattern = r"ISSUE:\s*\n(.*?)END_ISSUE"
|
|
266
|
+
issue_matches = re.findall(issue_pattern, response, re.DOTALL)
|
|
267
|
+
|
|
268
|
+
for issue_text in issue_matches:
|
|
269
|
+
issue = self._parse_issue(issue_text, code)
|
|
270
|
+
if issue:
|
|
271
|
+
result.issues.append(issue)
|
|
272
|
+
|
|
273
|
+
# Parse summary and score
|
|
274
|
+
summary_match = re.search(r"SUMMARY:\s*(.+?)(?:\n|$)", response)
|
|
275
|
+
if summary_match:
|
|
276
|
+
result.summary = summary_match.group(1).strip()
|
|
277
|
+
|
|
278
|
+
score_match = re.search(r"SCORE:\s*(\d+)", response)
|
|
279
|
+
if score_match:
|
|
280
|
+
result.score = min(100, max(0, int(score_match.group(1))))
|
|
281
|
+
else:
|
|
282
|
+
# Calculate score based on issues
|
|
283
|
+
result.score = self._calculate_score(result.issues)
|
|
284
|
+
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
def _parse_issue(self, issue_text: str, code: str) -> Optional[CodeIssue]:
|
|
288
|
+
"""Parse a single issue from text."""
|
|
289
|
+
import re
|
|
290
|
+
|
|
291
|
+
# Extract fields
|
|
292
|
+
severity_match = re.search(r"SEVERITY:\s*(\w+)", issue_text, re.IGNORECASE)
|
|
293
|
+
category_match = re.search(r"CATEGORY:\s*(\w+)", issue_text, re.IGNORECASE)
|
|
294
|
+
line_match = re.search(r"LINE:\s*(\d+|N/A)", issue_text, re.IGNORECASE)
|
|
295
|
+
message_match = re.search(r"MESSAGE:\s*(.+?)(?:\n|SUGGESTION)", issue_text, re.DOTALL)
|
|
296
|
+
suggestion_match = re.search(r"SUGGESTION:\s*(.+?)(?:\n|FIXED_CODE|$)", issue_text, re.DOTALL)
|
|
297
|
+
fixed_match = re.search(r"FIXED_CODE:\s*\n?(.*?)(?:END_ISSUE|$)", issue_text, re.DOTALL)
|
|
298
|
+
|
|
299
|
+
if not message_match:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
# Map severity
|
|
303
|
+
severity_map = {
|
|
304
|
+
"critical": IssueSeverity.CRITICAL,
|
|
305
|
+
"high": IssueSeverity.HIGH,
|
|
306
|
+
"medium": IssueSeverity.MEDIUM,
|
|
307
|
+
"low": IssueSeverity.LOW,
|
|
308
|
+
"info": IssueSeverity.INFO,
|
|
309
|
+
}
|
|
310
|
+
severity = IssueSeverity.MEDIUM
|
|
311
|
+
if severity_match:
|
|
312
|
+
severity = severity_map.get(severity_match.group(1).lower(), IssueSeverity.MEDIUM)
|
|
313
|
+
|
|
314
|
+
# Map category
|
|
315
|
+
category_map = {
|
|
316
|
+
"security": IssueCategory.SECURITY,
|
|
317
|
+
"bug": IssueCategory.BUG,
|
|
318
|
+
"performance": IssueCategory.PERFORMANCE,
|
|
319
|
+
"style": IssueCategory.STYLE,
|
|
320
|
+
"maintainability": IssueCategory.MAINTAINABILITY,
|
|
321
|
+
"best_practice": IssueCategory.BEST_PRACTICE,
|
|
322
|
+
"error_handling": IssueCategory.ERROR_HANDLING,
|
|
323
|
+
"documentation": IssueCategory.DOCUMENTATION,
|
|
324
|
+
}
|
|
325
|
+
category = IssueCategory.BEST_PRACTICE
|
|
326
|
+
if category_match:
|
|
327
|
+
category = category_map.get(category_match.group(1).lower(), IssueCategory.BEST_PRACTICE)
|
|
328
|
+
|
|
329
|
+
# Get line number and content
|
|
330
|
+
line_number = None
|
|
331
|
+
line_content = ""
|
|
332
|
+
if line_match and line_match.group(1) != "N/A":
|
|
333
|
+
try:
|
|
334
|
+
line_number = int(line_match.group(1))
|
|
335
|
+
code_lines = code.split("\n")
|
|
336
|
+
if 0 < line_number <= len(code_lines):
|
|
337
|
+
line_content = code_lines[line_number - 1].strip()
|
|
338
|
+
except ValueError:
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
return CodeIssue(
|
|
342
|
+
severity=severity,
|
|
343
|
+
category=category,
|
|
344
|
+
message=message_match.group(1).strip() if message_match else "",
|
|
345
|
+
line_number=line_number,
|
|
346
|
+
line_content=line_content,
|
|
347
|
+
suggestion=suggestion_match.group(1).strip() if suggestion_match else "",
|
|
348
|
+
fixed_code=fixed_match.group(1).strip() if fixed_match else "",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _calculate_score(self, issues: List[CodeIssue]) -> int:
|
|
352
|
+
"""Calculate quality score based on issues."""
|
|
353
|
+
score = 100
|
|
354
|
+
|
|
355
|
+
for issue in issues:
|
|
356
|
+
if issue.severity == IssueSeverity.CRITICAL:
|
|
357
|
+
score -= 25
|
|
358
|
+
elif issue.severity == IssueSeverity.HIGH:
|
|
359
|
+
score -= 15
|
|
360
|
+
elif issue.severity == IssueSeverity.MEDIUM:
|
|
361
|
+
score -= 8
|
|
362
|
+
elif issue.severity == IssueSeverity.LOW:
|
|
363
|
+
score -= 3
|
|
364
|
+
|
|
365
|
+
return max(0, score)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Diff Engine for showing and applying code changes."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChangeType(Enum):
|
|
10
|
+
"""Type of file change."""
|
|
11
|
+
CREATE = "create"
|
|
12
|
+
MODIFY = "modify"
|
|
13
|
+
DELETE = "delete"
|
|
14
|
+
RENAME = "rename"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FileDiff:
|
|
19
|
+
"""Represents a diff for a single file."""
|
|
20
|
+
file_path: str
|
|
21
|
+
change_type: ChangeType
|
|
22
|
+
original_content: str = ""
|
|
23
|
+
new_content: str = ""
|
|
24
|
+
diff_lines: List[str] = field(default_factory=list)
|
|
25
|
+
additions: int = 0
|
|
26
|
+
deletions: int = 0
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def has_changes(self) -> bool:
|
|
30
|
+
return self.original_content != self.new_content
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def summary(self) -> str:
|
|
34
|
+
if self.change_type == ChangeType.CREATE:
|
|
35
|
+
return f"📄 {self.file_path} (new file, +{self.additions} lines)"
|
|
36
|
+
elif self.change_type == ChangeType.DELETE:
|
|
37
|
+
return f"🗑️ {self.file_path} (deleted, -{self.deletions} lines)"
|
|
38
|
+
else:
|
|
39
|
+
return f"📝 {self.file_path} (+{self.additions}/-{self.deletions})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ChangeSet:
|
|
44
|
+
"""A set of file changes to apply."""
|
|
45
|
+
description: str
|
|
46
|
+
diffs: List[FileDiff] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def total_additions(self) -> int:
|
|
50
|
+
return sum(d.additions for d in self.diffs)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_deletions(self) -> int:
|
|
54
|
+
return sum(d.deletions for d in self.diffs)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def files_changed(self) -> int:
|
|
58
|
+
return len(self.diffs)
|
|
59
|
+
|
|
60
|
+
def add_file_change(self, file_path: str, new_content: str,
|
|
61
|
+
original_content: str = "") -> FileDiff:
|
|
62
|
+
"""Add a file change to the set."""
|
|
63
|
+
if not original_content and new_content:
|
|
64
|
+
change_type = ChangeType.CREATE
|
|
65
|
+
elif original_content and not new_content:
|
|
66
|
+
change_type = ChangeType.DELETE
|
|
67
|
+
else:
|
|
68
|
+
change_type = ChangeType.MODIFY
|
|
69
|
+
|
|
70
|
+
diff = FileDiff(
|
|
71
|
+
file_path=file_path,
|
|
72
|
+
change_type=change_type,
|
|
73
|
+
original_content=original_content,
|
|
74
|
+
new_content=new_content,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.diffs.append(diff)
|
|
78
|
+
return diff
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DiffEngine:
|
|
82
|
+
"""Engine for creating and applying diffs."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, file_manager=None):
|
|
85
|
+
self.file_manager = file_manager
|
|
86
|
+
|
|
87
|
+
def create_diff(self, original: str, modified: str,
|
|
88
|
+
file_path: str = "file") -> FileDiff:
|
|
89
|
+
"""Create a diff between original and modified content."""
|
|
90
|
+
original_lines = original.splitlines(keepends=True)
|
|
91
|
+
modified_lines = modified.splitlines(keepends=True)
|
|
92
|
+
|
|
93
|
+
# Generate unified diff
|
|
94
|
+
diff_lines = list(difflib.unified_diff(
|
|
95
|
+
original_lines,
|
|
96
|
+
modified_lines,
|
|
97
|
+
fromfile=f"a/{file_path}",
|
|
98
|
+
tofile=f"b/{file_path}",
|
|
99
|
+
lineterm="",
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
# Count additions and deletions
|
|
103
|
+
additions = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
|
|
104
|
+
deletions = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
|
|
105
|
+
|
|
106
|
+
# Determine change type
|
|
107
|
+
if not original and modified:
|
|
108
|
+
change_type = ChangeType.CREATE
|
|
109
|
+
elif original and not modified:
|
|
110
|
+
change_type = ChangeType.DELETE
|
|
111
|
+
else:
|
|
112
|
+
change_type = ChangeType.MODIFY
|
|
113
|
+
|
|
114
|
+
return FileDiff(
|
|
115
|
+
file_path=file_path,
|
|
116
|
+
change_type=change_type,
|
|
117
|
+
original_content=original,
|
|
118
|
+
new_content=modified,
|
|
119
|
+
diff_lines=diff_lines,
|
|
120
|
+
additions=additions,
|
|
121
|
+
deletions=deletions,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def create_file_diff(self, file_path: str, new_content: str) -> FileDiff:
|
|
125
|
+
"""Create a diff for a file, reading original from disk."""
|
|
126
|
+
original = ""
|
|
127
|
+
if self.file_manager:
|
|
128
|
+
original = self.file_manager.read_file(file_path) or ""
|
|
129
|
+
|
|
130
|
+
return self.create_diff(original, new_content, file_path)
|
|
131
|
+
|
|
132
|
+
def format_diff(self, diff: FileDiff, color: bool = True,
|
|
133
|
+
context_lines: int = 3) -> str:
|
|
134
|
+
"""Format a diff for display."""
|
|
135
|
+
if not diff.diff_lines:
|
|
136
|
+
# Generate diff lines if not present
|
|
137
|
+
diff = self.create_diff(
|
|
138
|
+
diff.original_content,
|
|
139
|
+
diff.new_content,
|
|
140
|
+
diff.file_path
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
lines = []
|
|
144
|
+
|
|
145
|
+
# Header
|
|
146
|
+
if diff.change_type == ChangeType.CREATE:
|
|
147
|
+
header = f"📄 New file: {diff.file_path}"
|
|
148
|
+
elif diff.change_type == ChangeType.DELETE:
|
|
149
|
+
header = f"🗑️ Delete: {diff.file_path}"
|
|
150
|
+
else:
|
|
151
|
+
header = f"📝 Modified: {diff.file_path}"
|
|
152
|
+
|
|
153
|
+
lines.append(header)
|
|
154
|
+
lines.append("─" * 60)
|
|
155
|
+
|
|
156
|
+
# Diff content
|
|
157
|
+
for line in diff.diff_lines:
|
|
158
|
+
if color:
|
|
159
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
160
|
+
lines.append(f"[green]{line}[/green]")
|
|
161
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
162
|
+
lines.append(f"[red]{line}[/red]")
|
|
163
|
+
elif line.startswith("@@"):
|
|
164
|
+
lines.append(f"[cyan]{line}[/cyan]")
|
|
165
|
+
else:
|
|
166
|
+
lines.append(line)
|
|
167
|
+
else:
|
|
168
|
+
lines.append(line)
|
|
169
|
+
|
|
170
|
+
# Summary
|
|
171
|
+
lines.append("─" * 60)
|
|
172
|
+
lines.append(f"+{diff.additions} additions, -{diff.deletions} deletions")
|
|
173
|
+
|
|
174
|
+
return "\n".join(lines)
|
|
175
|
+
|
|
176
|
+
def format_diff_simple(self, diff: FileDiff) -> str:
|
|
177
|
+
"""Format diff in a simple, readable way."""
|
|
178
|
+
lines = []
|
|
179
|
+
|
|
180
|
+
if diff.change_type == ChangeType.CREATE:
|
|
181
|
+
lines.append(f"📄 {diff.file_path} (new file)")
|
|
182
|
+
lines.append("┌" + "─" * 58 + "┐")
|
|
183
|
+
for i, line in enumerate(diff.new_content.split("\n")[:30]):
|
|
184
|
+
lines.append(f"│ {line[:56]:<56} │")
|
|
185
|
+
if diff.new_content.count("\n") > 30:
|
|
186
|
+
lines.append(f"│ {'... (truncated)':<56} │")
|
|
187
|
+
lines.append("└" + "─" * 58 + "┘")
|
|
188
|
+
else:
|
|
189
|
+
lines.append(f"📝 {diff.file_path}")
|
|
190
|
+
lines.append("─" * 60)
|
|
191
|
+
|
|
192
|
+
# Show only changed sections
|
|
193
|
+
for line in diff.diff_lines[:50]:
|
|
194
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
195
|
+
lines.append(f"[green]+ {line[1:]}[/green]")
|
|
196
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
197
|
+
lines.append(f"[red]- {line[1:]}[/red]")
|
|
198
|
+
elif line.startswith("@@"):
|
|
199
|
+
lines.append(f"[dim]{line}[/dim]")
|
|
200
|
+
|
|
201
|
+
if len(diff.diff_lines) > 50:
|
|
202
|
+
lines.append("[dim]... (more changes)[/dim]")
|
|
203
|
+
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
def format_changeset(self, changeset: ChangeSet, detailed: bool = False) -> str:
|
|
207
|
+
"""Format a complete changeset for display."""
|
|
208
|
+
lines = [
|
|
209
|
+
f"📦 {changeset.description}",
|
|
210
|
+
f" {changeset.files_changed} file(s), "
|
|
211
|
+
f"+{changeset.total_additions}/-{changeset.total_deletions}",
|
|
212
|
+
"",
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
for diff in changeset.diffs:
|
|
216
|
+
if detailed:
|
|
217
|
+
lines.append(self.format_diff_simple(diff))
|
|
218
|
+
else:
|
|
219
|
+
lines.append(f" {diff.summary}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
def apply_diff(self, diff: FileDiff) -> bool:
|
|
225
|
+
"""Apply a diff to the filesystem."""
|
|
226
|
+
if not self.file_manager:
|
|
227
|
+
raise RuntimeError("No file manager configured")
|
|
228
|
+
|
|
229
|
+
if diff.change_type == ChangeType.DELETE:
|
|
230
|
+
return self.file_manager.delete_file(diff.file_path)
|
|
231
|
+
else:
|
|
232
|
+
return self.file_manager.write_file(diff.file_path, diff.new_content)
|
|
233
|
+
|
|
234
|
+
def apply_changeset(self, changeset: ChangeSet) -> Tuple[int, int]:
|
|
235
|
+
"""Apply all changes in a changeset.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Tuple of (successful, failed) counts
|
|
239
|
+
"""
|
|
240
|
+
successful = 0
|
|
241
|
+
failed = 0
|
|
242
|
+
|
|
243
|
+
for diff in changeset.diffs:
|
|
244
|
+
try:
|
|
245
|
+
if self.apply_diff(diff):
|
|
246
|
+
successful += 1
|
|
247
|
+
else:
|
|
248
|
+
failed += 1
|
|
249
|
+
except Exception:
|
|
250
|
+
failed += 1
|
|
251
|
+
|
|
252
|
+
return successful, failed
|
|
253
|
+
|
|
254
|
+
def preview_changes(self, changeset: ChangeSet) -> str:
|
|
255
|
+
"""Generate a preview of changes to be applied."""
|
|
256
|
+
lines = [
|
|
257
|
+
"┌" + "─" * 58 + "┐",
|
|
258
|
+
f"│ {'CHANGE PREVIEW':^56} │",
|
|
259
|
+
"├" + "─" * 58 + "┤",
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
for diff in changeset.diffs:
|
|
263
|
+
icon = {
|
|
264
|
+
ChangeType.CREATE: "📄",
|
|
265
|
+
ChangeType.MODIFY: "📝",
|
|
266
|
+
ChangeType.DELETE: "🗑️ ",
|
|
267
|
+
}.get(diff.change_type, "📋")
|
|
268
|
+
|
|
269
|
+
action = {
|
|
270
|
+
ChangeType.CREATE: "CREATE",
|
|
271
|
+
ChangeType.MODIFY: "MODIFY",
|
|
272
|
+
ChangeType.DELETE: "DELETE",
|
|
273
|
+
}.get(diff.change_type, "CHANGE")
|
|
274
|
+
|
|
275
|
+
path_display = diff.file_path[:40]
|
|
276
|
+
if len(diff.file_path) > 40:
|
|
277
|
+
path_display = "..." + diff.file_path[-37:]
|
|
278
|
+
|
|
279
|
+
lines.append(f"│ {icon} {action:<8} {path_display:<44} │")
|
|
280
|
+
lines.append(f"│ {'+' + str(diff.additions):<6} {'-' + str(diff.deletions):<6} {'':38} │")
|
|
281
|
+
|
|
282
|
+
lines.append("├" + "─" * 58 + "┤")
|
|
283
|
+
lines.append(f"│ Total: {changeset.files_changed} files, "
|
|
284
|
+
f"+{changeset.total_additions}/-{changeset.total_deletions}{'':20} │")
|
|
285
|
+
lines.append("└" + "─" * 58 + "┘")
|
|
286
|
+
|
|
287
|
+
return "\n".join(lines)
|
|
288
|
+
|
|
289
|
+
def get_inline_diff(self, original: str, modified: str) -> List[Tuple[str, str]]:
|
|
290
|
+
"""Get inline diff showing word-level changes.
|
|
291
|
+
|
|
292
|
+
Returns list of (type, text) tuples where type is 'equal', 'insert', or 'delete'.
|
|
293
|
+
"""
|
|
294
|
+
matcher = difflib.SequenceMatcher(None, original.split(), modified.split())
|
|
295
|
+
result = []
|
|
296
|
+
|
|
297
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
298
|
+
if tag == "equal":
|
|
299
|
+
result.append(("equal", " ".join(original.split()[i1:i2])))
|
|
300
|
+
elif tag == "replace":
|
|
301
|
+
result.append(("delete", " ".join(original.split()[i1:i2])))
|
|
302
|
+
result.append(("insert", " ".join(modified.split()[j1:j2])))
|
|
303
|
+
elif tag == "delete":
|
|
304
|
+
result.append(("delete", " ".join(original.split()[i1:i2])))
|
|
305
|
+
elif tag == "insert":
|
|
306
|
+
result.append(("insert", " ".join(modified.split()[j1:j2])))
|
|
307
|
+
|
|
308
|
+
return result
|