semantic-diff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- semantic_diff/__init__.py +0 -0
- semantic_diff/analyzers/__init__.py +0 -0
- semantic_diff/analyzers/llm_analyzer.py +413 -0
- semantic_diff/cli.py +86 -0
- semantic_diff/formatters/__init__.py +0 -0
- semantic_diff/formatters/console_formatter.py +165 -0
- semantic_diff/models.py +86 -0
- semantic_diff/parsers/__init__.py +0 -0
- semantic_diff/parsers/git_parser.py +208 -0
- semantic_diff-0.1.0.dist-info/METADATA +131 -0
- semantic_diff-0.1.0.dist-info/RECORD +14 -0
- semantic_diff-0.1.0.dist-info/WHEEL +5 -0
- semantic_diff-0.1.0.dist-info/entry_points.txt +2 -0
- semantic_diff-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM-based semantic analyzer using Claude
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import random
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
import anthropic
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from semantic_diff.models import (
|
|
15
|
+
FileChange, SemanticAnalysis, Intent, ImpactMap, Impact,
|
|
16
|
+
RiskAssessment, Risk, ReviewQuestion, RiskLevel
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LLMAnalyzer:
|
|
23
|
+
"""Analyzes code changes using Claude for semantic understanding"""
|
|
24
|
+
|
|
25
|
+
ANALYSIS_PROMPT = """You are a senior code reviewer analyzing a git commit. Your task is to provide semantic analysis that goes beyond what a simple diff shows.
|
|
26
|
+
|
|
27
|
+
## Commit Information
|
|
28
|
+
- **Hash:** {commit_hash}
|
|
29
|
+
- **Message:** {commit_message}
|
|
30
|
+
- **Author:** {author}
|
|
31
|
+
- **Date:** {date}
|
|
32
|
+
|
|
33
|
+
## Project Context
|
|
34
|
+
{project_context}
|
|
35
|
+
|
|
36
|
+
## Files Changed
|
|
37
|
+
{files_summary}
|
|
38
|
+
|
|
39
|
+
## Detailed Diffs
|
|
40
|
+
{diffs}
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
Analyze this commit and provide a structured response in the following JSON format:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{{
|
|
48
|
+
"intent": {{
|
|
49
|
+
"summary": "One sentence describing WHAT the developer was trying to accomplish (not what changed, but WHY)",
|
|
50
|
+
"reasoning": "2-3 sentences explaining your reasoning",
|
|
51
|
+
"confidence": 0.0-1.0
|
|
52
|
+
}},
|
|
53
|
+
"impact_map": {{
|
|
54
|
+
"direct_impacts": [
|
|
55
|
+
{{"area": "affected area", "description": "how it's affected", "severity": "low|medium|high|critical"}}
|
|
56
|
+
],
|
|
57
|
+
"indirect_impacts": [
|
|
58
|
+
{{"area": "indirectly affected area", "description": "potential ripple effects", "severity": "low|medium|high|critical"}}
|
|
59
|
+
],
|
|
60
|
+
"affected_components": ["list", "of", "components"]
|
|
61
|
+
}},
|
|
62
|
+
"risk_assessment": {{
|
|
63
|
+
"overall_risk": "low|medium|high|critical",
|
|
64
|
+
"risks": [
|
|
65
|
+
{{
|
|
66
|
+
"description": "specific risk",
|
|
67
|
+
"severity": "low|medium|high|critical",
|
|
68
|
+
"mitigation": "how to mitigate",
|
|
69
|
+
"edge_cases": ["edge case 1", "edge case 2"]
|
|
70
|
+
}}
|
|
71
|
+
],
|
|
72
|
+
"breaking_changes": true/false,
|
|
73
|
+
"requires_migration": true/false
|
|
74
|
+
}},
|
|
75
|
+
"review_questions": [
|
|
76
|
+
{{
|
|
77
|
+
"question": "Question for the author",
|
|
78
|
+
"context": "Why this question matters",
|
|
79
|
+
"priority": "low|medium|high|critical"
|
|
80
|
+
}}
|
|
81
|
+
]
|
|
82
|
+
}}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Focus on:
|
|
86
|
+
1. **Intent**: What problem is being solved? What's the motivation?
|
|
87
|
+
2. **Impact**: What else in the system might be affected? Consider imports, API consumers, tests.
|
|
88
|
+
3. **Risk**: What could break? What edge cases exist? Is this backwards compatible?
|
|
89
|
+
4. **Questions**: What would you ask the author in a code review?
|
|
90
|
+
|
|
91
|
+
Be specific and actionable. Avoid generic observations."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, model: Optional[str] = None):
|
|
94
|
+
load_dotenv()
|
|
95
|
+
|
|
96
|
+
self.api_key = os.getenv('ANTHROPIC_API_KEY')
|
|
97
|
+
if not self.api_key:
|
|
98
|
+
raise ValueError("ANTHROPIC_API_KEY not found in environment")
|
|
99
|
+
|
|
100
|
+
self.model = model or os.getenv('SEMANTIC_DIFF_MODEL', 'claude-sonnet-4-5-20250929')
|
|
101
|
+
self.client = anthropic.Anthropic(api_key=self.api_key)
|
|
102
|
+
self.last_usage = None
|
|
103
|
+
|
|
104
|
+
def _format_files_summary(self, files: List[FileChange]) -> str:
|
|
105
|
+
"""Format file changes for the prompt"""
|
|
106
|
+
lines = []
|
|
107
|
+
for f in files:
|
|
108
|
+
lang = f"[{f.language}]" if f.language else ""
|
|
109
|
+
lines.append(f"- {f.path} ({f.change_type}) +{f.additions}/-{f.deletions} {lang}")
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
def _format_diffs(self, files: List[FileChange], max_total_chars: int = 15000) -> str:
|
|
113
|
+
"""Format diffs for the prompt, respecting token limits"""
|
|
114
|
+
diffs = []
|
|
115
|
+
total_chars = 0
|
|
116
|
+
|
|
117
|
+
for f in files:
|
|
118
|
+
if total_chars >= max_total_chars:
|
|
119
|
+
diffs.append(f"\n... (truncated - {len(files) - len(diffs)} more files)")
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
header = f"\n### {f.path} ({f.change_type})\n```{f.language or 'diff'}\n"
|
|
123
|
+
content = f.diff_content
|
|
124
|
+
footer = "\n```\n"
|
|
125
|
+
|
|
126
|
+
# Truncate individual file if too long
|
|
127
|
+
available = max_total_chars - total_chars - len(header) - len(footer)
|
|
128
|
+
if len(content) > available:
|
|
129
|
+
content = content[:available] + "\n... (truncated)"
|
|
130
|
+
|
|
131
|
+
diff_block = header + content + footer
|
|
132
|
+
diffs.append(diff_block)
|
|
133
|
+
total_chars += len(diff_block)
|
|
134
|
+
|
|
135
|
+
return "\n".join(diffs)
|
|
136
|
+
|
|
137
|
+
def _format_project_context(self, context: dict) -> str:
|
|
138
|
+
"""Format project context for the prompt"""
|
|
139
|
+
lines = [
|
|
140
|
+
f"- **Languages:** {', '.join(context.get('languages', ['unknown']))}",
|
|
141
|
+
f"- **Package Manager:** {context.get('package_manager', 'unknown')}",
|
|
142
|
+
f"- **Has Tests:** {'Yes' if context.get('has_tests') else 'No'}",
|
|
143
|
+
f"- **Has CI:** {'Yes' if context.get('has_ci') else 'No'}",
|
|
144
|
+
f"- **Root Files:** {', '.join(context.get('root_files', [])[:10])}",
|
|
145
|
+
f"- **Directories:** {', '.join(context.get('directories', [])[:10])}",
|
|
146
|
+
]
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
def _parse_response(self, response_text: str) -> dict:
|
|
150
|
+
"""Extract JSON from Claude's response"""
|
|
151
|
+
# Try to find JSON block
|
|
152
|
+
if "```json" in response_text:
|
|
153
|
+
start = response_text.find("```json") + 7
|
|
154
|
+
end = response_text.find("```", start)
|
|
155
|
+
json_str = response_text[start:end].strip()
|
|
156
|
+
elif "```" in response_text:
|
|
157
|
+
start = response_text.find("```") + 3
|
|
158
|
+
end = response_text.find("```", start)
|
|
159
|
+
json_str = response_text[start:end].strip()
|
|
160
|
+
else:
|
|
161
|
+
# Try to parse the whole response as JSON
|
|
162
|
+
json_str = response_text.strip()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(json_str)
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
raise ValueError(f"Failed to parse LLM response as JSON: {e}\nResponse: {response_text[:500]}")
|
|
168
|
+
|
|
169
|
+
def _call_api_with_retry(
|
|
170
|
+
self,
|
|
171
|
+
prompt: str,
|
|
172
|
+
max_retries: Optional[int] = None,
|
|
173
|
+
base_delay: float = 1.0,
|
|
174
|
+
max_total_wait: Optional[float] = None
|
|
175
|
+
):
|
|
176
|
+
"""
|
|
177
|
+
Call Claude API with exponential backoff + jitter retry.
|
|
178
|
+
Handles rate limits, timeouts, and transient errors.
|
|
179
|
+
Respects Retry-After headers when present.
|
|
180
|
+
"""
|
|
181
|
+
if max_retries is None:
|
|
182
|
+
max_retries = int(os.getenv('SEMANTIC_DIFF_MAX_RETRIES', '3'))
|
|
183
|
+
if max_total_wait is None:
|
|
184
|
+
max_total_wait = float(os.getenv('SEMANTIC_DIFF_MAX_WAIT', '30.0'))
|
|
185
|
+
|
|
186
|
+
last_exception = None
|
|
187
|
+
total_waited = 0.0
|
|
188
|
+
|
|
189
|
+
for attempt in range(max_retries):
|
|
190
|
+
try:
|
|
191
|
+
response = self.client.messages.create(
|
|
192
|
+
model=self.model,
|
|
193
|
+
max_tokens=4096,
|
|
194
|
+
messages=[
|
|
195
|
+
{"role": "user", "content": prompt}
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
return response
|
|
199
|
+
except anthropic.RateLimitError as e:
|
|
200
|
+
last_exception = e
|
|
201
|
+
# Check for Retry-After header (can be seconds or HTTP-date)
|
|
202
|
+
retry_after = getattr(e, 'retry_after', None)
|
|
203
|
+
if retry_after:
|
|
204
|
+
try:
|
|
205
|
+
delay = float(retry_after)
|
|
206
|
+
except (ValueError, TypeError):
|
|
207
|
+
# Retry-After might be HTTP-date format, fall back to exponential
|
|
208
|
+
logger.debug(f"Could not parse Retry-After '{retry_after}', using exponential backoff")
|
|
209
|
+
delay = base_delay * (2 ** attempt)
|
|
210
|
+
else:
|
|
211
|
+
delay = base_delay * (2 ** attempt)
|
|
212
|
+
# Add jitter (10-30% of delay)
|
|
213
|
+
jitter = delay * random.uniform(0.1, 0.3)
|
|
214
|
+
delay += jitter
|
|
215
|
+
|
|
216
|
+
if total_waited + delay > max_total_wait:
|
|
217
|
+
logger.warning(f"Retry would exceed max_total_wait ({max_total_wait}s), giving up")
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
logger.warning(f"Rate limited, retry {attempt + 1}/{max_retries} in {delay:.1f}s")
|
|
221
|
+
time.sleep(delay)
|
|
222
|
+
total_waited += delay
|
|
223
|
+
|
|
224
|
+
except anthropic.APITimeoutError as e:
|
|
225
|
+
last_exception = e
|
|
226
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)
|
|
227
|
+
|
|
228
|
+
if total_waited + delay > max_total_wait:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
logger.warning(f"Timeout, retry {attempt + 1}/{max_retries} in {delay:.1f}s")
|
|
232
|
+
time.sleep(delay)
|
|
233
|
+
total_waited += delay
|
|
234
|
+
|
|
235
|
+
except anthropic.APIConnectionError as e:
|
|
236
|
+
last_exception = e
|
|
237
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)
|
|
238
|
+
|
|
239
|
+
if total_waited + delay > max_total_wait:
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
logger.warning(f"Connection error, retry {attempt + 1}/{max_retries} in {delay:.1f}s")
|
|
243
|
+
time.sleep(delay)
|
|
244
|
+
total_waited += delay
|
|
245
|
+
|
|
246
|
+
except anthropic.APIStatusError as e:
|
|
247
|
+
if e.status_code >= 500:
|
|
248
|
+
last_exception = e
|
|
249
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, base_delay)
|
|
250
|
+
|
|
251
|
+
if total_waited + delay > max_total_wait:
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
logger.warning(f"Server error {e.status_code}, retry {attempt + 1}/{max_retries} in {delay:.1f}s")
|
|
255
|
+
time.sleep(delay)
|
|
256
|
+
total_waited += delay
|
|
257
|
+
else:
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
raise RuntimeError(f"API call failed after {max_retries} retries (waited {total_waited:.1f}s): {last_exception}")
|
|
261
|
+
|
|
262
|
+
def _validate_response_data(self, data: dict) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
Validate and fill missing fields in LLM response.
|
|
265
|
+
Returns sanitized data with defaults for missing fields.
|
|
266
|
+
Logs warnings when defaults are applied.
|
|
267
|
+
"""
|
|
268
|
+
defaults_applied = []
|
|
269
|
+
|
|
270
|
+
# Ensure intent exists with defaults
|
|
271
|
+
if 'intent' not in data:
|
|
272
|
+
data['intent'] = {}
|
|
273
|
+
defaults_applied.append('intent')
|
|
274
|
+
if 'summary' not in data['intent']:
|
|
275
|
+
data['intent']['summary'] = 'Unable to determine intent'
|
|
276
|
+
defaults_applied.append('intent.summary')
|
|
277
|
+
if 'reasoning' not in data['intent']:
|
|
278
|
+
data['intent']['reasoning'] = 'Analysis incomplete'
|
|
279
|
+
defaults_applied.append('intent.reasoning')
|
|
280
|
+
if 'confidence' not in data['intent']:
|
|
281
|
+
data['intent']['confidence'] = 0.5
|
|
282
|
+
defaults_applied.append('intent.confidence')
|
|
283
|
+
|
|
284
|
+
# Ensure impact_map exists with defaults
|
|
285
|
+
if 'impact_map' not in data:
|
|
286
|
+
data['impact_map'] = {}
|
|
287
|
+
defaults_applied.append('impact_map')
|
|
288
|
+
data['impact_map'].setdefault('direct_impacts', [])
|
|
289
|
+
data['impact_map'].setdefault('indirect_impacts', [])
|
|
290
|
+
data['impact_map'].setdefault('affected_components', [])
|
|
291
|
+
|
|
292
|
+
# Ensure risk_assessment exists with defaults
|
|
293
|
+
if 'risk_assessment' not in data:
|
|
294
|
+
data['risk_assessment'] = {}
|
|
295
|
+
defaults_applied.append('risk_assessment')
|
|
296
|
+
if 'overall_risk' not in data['risk_assessment']:
|
|
297
|
+
data['risk_assessment']['overall_risk'] = 'medium'
|
|
298
|
+
defaults_applied.append('risk_assessment.overall_risk')
|
|
299
|
+
data['risk_assessment'].setdefault('risks', [])
|
|
300
|
+
data['risk_assessment'].setdefault('breaking_changes', False)
|
|
301
|
+
data['risk_assessment'].setdefault('requires_migration', False)
|
|
302
|
+
|
|
303
|
+
# Ensure review_questions exists
|
|
304
|
+
data.setdefault('review_questions', [])
|
|
305
|
+
|
|
306
|
+
# Log warning if defaults were applied
|
|
307
|
+
if defaults_applied:
|
|
308
|
+
logger.warning(
|
|
309
|
+
f"LLM response missing fields, defaults applied: {', '.join(defaults_applied)}. "
|
|
310
|
+
"This may indicate a prompt issue or API degradation."
|
|
311
|
+
)
|
|
312
|
+
# Mark low confidence when critical fields were missing
|
|
313
|
+
if any(field in defaults_applied for field in ['intent', 'intent.summary', 'risk_assessment']):
|
|
314
|
+
data['intent']['confidence'] = min(data['intent']['confidence'], 0.3)
|
|
315
|
+
|
|
316
|
+
return data
|
|
317
|
+
|
|
318
|
+
def analyze(
|
|
319
|
+
self,
|
|
320
|
+
commit_info: dict,
|
|
321
|
+
files: List[FileChange],
|
|
322
|
+
project_context: dict
|
|
323
|
+
) -> SemanticAnalysis:
|
|
324
|
+
"""
|
|
325
|
+
Analyze a commit using Claude.
|
|
326
|
+
Returns structured SemanticAnalysis.
|
|
327
|
+
"""
|
|
328
|
+
# Build the prompt
|
|
329
|
+
prompt = self.ANALYSIS_PROMPT.format(
|
|
330
|
+
commit_hash=commit_info['short_hash'],
|
|
331
|
+
commit_message=commit_info['message'],
|
|
332
|
+
author=commit_info['author'],
|
|
333
|
+
date=commit_info['date'],
|
|
334
|
+
project_context=self._format_project_context(project_context),
|
|
335
|
+
files_summary=self._format_files_summary(files),
|
|
336
|
+
diffs=self._format_diffs(files)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Call Claude with retry
|
|
340
|
+
response = self._call_api_with_retry(prompt)
|
|
341
|
+
|
|
342
|
+
self.last_usage = {
|
|
343
|
+
'input_tokens': response.usage.input_tokens,
|
|
344
|
+
'output_tokens': response.usage.output_tokens,
|
|
345
|
+
'total_tokens': response.usage.input_tokens + response.usage.output_tokens
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
# Parse and validate response
|
|
349
|
+
response_text = response.content[0].text
|
|
350
|
+
data = self._parse_response(response_text)
|
|
351
|
+
data = self._validate_response_data(data)
|
|
352
|
+
|
|
353
|
+
# Build structured response
|
|
354
|
+
intent = Intent(
|
|
355
|
+
summary=data['intent']['summary'],
|
|
356
|
+
reasoning=data['intent']['reasoning'],
|
|
357
|
+
confidence=data['intent']['confidence']
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
impact_map = ImpactMap(
|
|
361
|
+
direct_impacts=[
|
|
362
|
+
Impact(
|
|
363
|
+
area=i['area'],
|
|
364
|
+
description=i['description'],
|
|
365
|
+
severity=RiskLevel(i['severity'])
|
|
366
|
+
) for i in data['impact_map'].get('direct_impacts', [])
|
|
367
|
+
],
|
|
368
|
+
indirect_impacts=[
|
|
369
|
+
Impact(
|
|
370
|
+
area=i['area'],
|
|
371
|
+
description=i['description'],
|
|
372
|
+
severity=RiskLevel(i['severity'])
|
|
373
|
+
) for i in data['impact_map'].get('indirect_impacts', [])
|
|
374
|
+
],
|
|
375
|
+
affected_components=data['impact_map'].get('affected_components', [])
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
risk_assessment = RiskAssessment(
|
|
379
|
+
overall_risk=RiskLevel(data['risk_assessment']['overall_risk']),
|
|
380
|
+
risks=[
|
|
381
|
+
Risk(
|
|
382
|
+
description=r['description'],
|
|
383
|
+
severity=RiskLevel(r['severity']),
|
|
384
|
+
mitigation=r.get('mitigation'),
|
|
385
|
+
edge_cases=r.get('edge_cases', [])
|
|
386
|
+
) for r in data['risk_assessment'].get('risks', [])
|
|
387
|
+
],
|
|
388
|
+
breaking_changes=data['risk_assessment'].get('breaking_changes', False),
|
|
389
|
+
requires_migration=data['risk_assessment'].get('requires_migration', False)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
review_questions = [
|
|
393
|
+
ReviewQuestion(
|
|
394
|
+
question=q['question'],
|
|
395
|
+
context=q['context'],
|
|
396
|
+
priority=RiskLevel(q.get('priority', 'medium'))
|
|
397
|
+
) for q in data.get('review_questions', [])
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
return SemanticAnalysis(
|
|
401
|
+
commit_hash=commit_info['hash'],
|
|
402
|
+
commit_message=commit_info['message'],
|
|
403
|
+
author=commit_info['author'],
|
|
404
|
+
date=commit_info['date'],
|
|
405
|
+
files_changed=files,
|
|
406
|
+
intent=intent,
|
|
407
|
+
impact_map=impact_map,
|
|
408
|
+
risk_assessment=risk_assessment,
|
|
409
|
+
review_questions=review_questions,
|
|
410
|
+
analysis_model=self.model,
|
|
411
|
+
analysis_timestamp=datetime.now().isoformat(),
|
|
412
|
+
tokens_used=self.last_usage['total_tokens']
|
|
413
|
+
)
|
semantic_diff/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI interface for semantic-diff
|
|
3
|
+
"""
|
|
4
|
+
import click
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from semantic_diff.parsers.git_parser import GitParser
|
|
10
|
+
from semantic_diff.analyzers.llm_analyzer import LLMAnalyzer
|
|
11
|
+
from semantic_diff.formatters.console_formatter import ConsoleFormatter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.argument('commit_hash', default='HEAD')
|
|
16
|
+
@click.option('--repo', '-r', default='.', help='Path to git repository')
|
|
17
|
+
@click.option('--model', '-m', default=None, help='Model to use (default: from .env)')
|
|
18
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
19
|
+
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
|
|
20
|
+
def main(commit_hash: str, repo: str, model: str, output_json: bool, verbose: bool):
|
|
21
|
+
"""
|
|
22
|
+
Analyze a git commit semantically.
|
|
23
|
+
|
|
24
|
+
COMMIT_HASH: The commit to analyze (default: HEAD)
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
|
|
28
|
+
semantic-diff HEAD
|
|
29
|
+
|
|
30
|
+
semantic-diff abc123
|
|
31
|
+
|
|
32
|
+
semantic-diff HEAD~1 --repo /path/to/repo
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
# Initialize components
|
|
36
|
+
if verbose:
|
|
37
|
+
click.echo(f"Initializing parser for {repo}...")
|
|
38
|
+
|
|
39
|
+
parser = GitParser(repo if repo != '.' else None)
|
|
40
|
+
|
|
41
|
+
if verbose:
|
|
42
|
+
click.echo(f"Getting commit info for {commit_hash}...")
|
|
43
|
+
|
|
44
|
+
# Get commit data
|
|
45
|
+
commit_info = parser.get_commit_info(commit_hash)
|
|
46
|
+
files = parser.get_file_changes(commit_hash)
|
|
47
|
+
project_context = parser.get_project_context()
|
|
48
|
+
|
|
49
|
+
if verbose:
|
|
50
|
+
click.echo(f"Found {len(files)} changed files")
|
|
51
|
+
click.echo(f"Project languages: {project_context.get('languages', [])}")
|
|
52
|
+
|
|
53
|
+
if not files:
|
|
54
|
+
click.echo("No changes found in this commit.", err=True)
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
# Analyze
|
|
58
|
+
if verbose:
|
|
59
|
+
click.echo("Calling LLM for analysis...")
|
|
60
|
+
|
|
61
|
+
analyzer = LLMAnalyzer(model=model)
|
|
62
|
+
analysis = analyzer.analyze(commit_info, files, project_context)
|
|
63
|
+
|
|
64
|
+
# Output
|
|
65
|
+
if output_json:
|
|
66
|
+
import json
|
|
67
|
+
# Convert to dict, handling enums
|
|
68
|
+
data = analysis.model_dump()
|
|
69
|
+
click.echo(json.dumps(data, indent=2, default=str))
|
|
70
|
+
else:
|
|
71
|
+
formatter = ConsoleFormatter()
|
|
72
|
+
formatter.format(analysis)
|
|
73
|
+
|
|
74
|
+
except ValueError as e:
|
|
75
|
+
click.echo(f"Error: {e}", err=True)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
if verbose:
|
|
79
|
+
import traceback
|
|
80
|
+
click.echo(traceback.format_exc(), err=True)
|
|
81
|
+
click.echo(f"Error: {e}", err=True)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == '__main__':
|
|
86
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console formatter for semantic diff output using Rich
|
|
3
|
+
"""
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.markdown import Markdown
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich import box
|
|
10
|
+
|
|
11
|
+
from semantic_diff.models import SemanticAnalysis, RiskLevel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleFormatter:
|
|
15
|
+
"""Formats SemanticAnalysis for terminal output"""
|
|
16
|
+
|
|
17
|
+
RISK_COLORS = {
|
|
18
|
+
RiskLevel.LOW: "green",
|
|
19
|
+
RiskLevel.MEDIUM: "yellow",
|
|
20
|
+
RiskLevel.HIGH: "red",
|
|
21
|
+
RiskLevel.CRITICAL: "bold red",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
RISK_ICONS = {
|
|
25
|
+
RiskLevel.LOW: "✓",
|
|
26
|
+
RiskLevel.MEDIUM: "⚠",
|
|
27
|
+
RiskLevel.HIGH: "⚡",
|
|
28
|
+
RiskLevel.CRITICAL: "🔥",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.console = Console()
|
|
33
|
+
|
|
34
|
+
def _risk_style(self, level: RiskLevel) -> str:
|
|
35
|
+
return self.RISK_COLORS.get(level, "white")
|
|
36
|
+
|
|
37
|
+
def _risk_icon(self, level: RiskLevel) -> str:
|
|
38
|
+
return self.RISK_ICONS.get(level, "•")
|
|
39
|
+
|
|
40
|
+
def format(self, analysis: SemanticAnalysis) -> None:
|
|
41
|
+
"""Print formatted analysis to console"""
|
|
42
|
+
|
|
43
|
+
# Header
|
|
44
|
+
self.console.print()
|
|
45
|
+
self.console.print(Panel(
|
|
46
|
+
f"[bold]{analysis.commit_message}[/bold]\n\n"
|
|
47
|
+
f"[dim]{analysis.commit_hash[:8]} by {analysis.author}[/dim]\n"
|
|
48
|
+
f"[dim]{analysis.date}[/dim]",
|
|
49
|
+
title="📋 Semantic Diff Analysis",
|
|
50
|
+
border_style="blue"
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# Files changed summary
|
|
54
|
+
files_table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
55
|
+
files_table.add_column("File", style="cyan")
|
|
56
|
+
files_table.add_column("Change", style="dim")
|
|
57
|
+
files_table.add_column("+", style="green", justify="right")
|
|
58
|
+
files_table.add_column("-", style="red", justify="right")
|
|
59
|
+
files_table.add_column("Lang", style="dim")
|
|
60
|
+
|
|
61
|
+
for f in analysis.files_changed[:10]: # Limit to 10
|
|
62
|
+
files_table.add_row(
|
|
63
|
+
f.path[:50] + "..." if len(f.path) > 50 else f.path,
|
|
64
|
+
f.change_type,
|
|
65
|
+
str(f.additions),
|
|
66
|
+
str(f.deletions),
|
|
67
|
+
f.language or "-"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if len(analysis.files_changed) > 10:
|
|
71
|
+
files_table.add_row(
|
|
72
|
+
f"... and {len(analysis.files_changed) - 10} more",
|
|
73
|
+
"", "", "", ""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self.console.print(Panel(files_table, title="📁 Files Changed", border_style="dim"))
|
|
77
|
+
|
|
78
|
+
# Intent
|
|
79
|
+
confidence_bar = "█" * int(analysis.intent.confidence * 10) + "░" * (10 - int(analysis.intent.confidence * 10))
|
|
80
|
+
self.console.print(Panel(
|
|
81
|
+
f"[bold]{analysis.intent.summary}[/bold]\n\n"
|
|
82
|
+
f"{analysis.intent.reasoning}\n\n"
|
|
83
|
+
f"[dim]Confidence: [{confidence_bar}] {analysis.intent.confidence:.0%}[/dim]",
|
|
84
|
+
title="🎯 Intent",
|
|
85
|
+
border_style="green"
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
# Impact Map
|
|
89
|
+
impact_text = Text()
|
|
90
|
+
|
|
91
|
+
if analysis.impact_map.direct_impacts:
|
|
92
|
+
impact_text.append("Direct Impacts:\n", style="bold")
|
|
93
|
+
for impact in analysis.impact_map.direct_impacts:
|
|
94
|
+
icon = self._risk_icon(impact.severity)
|
|
95
|
+
color = self._risk_style(impact.severity)
|
|
96
|
+
impact_text.append(f" {icon} ", style=color)
|
|
97
|
+
impact_text.append(f"{impact.area}: ", style="bold")
|
|
98
|
+
impact_text.append(f"{impact.description}\n")
|
|
99
|
+
impact_text.append("\n")
|
|
100
|
+
|
|
101
|
+
if analysis.impact_map.indirect_impacts:
|
|
102
|
+
impact_text.append("Indirect Impacts:\n", style="bold")
|
|
103
|
+
for impact in analysis.impact_map.indirect_impacts:
|
|
104
|
+
icon = self._risk_icon(impact.severity)
|
|
105
|
+
color = self._risk_style(impact.severity)
|
|
106
|
+
impact_text.append(f" {icon} ", style=color)
|
|
107
|
+
impact_text.append(f"{impact.area}: ", style="bold")
|
|
108
|
+
impact_text.append(f"{impact.description}\n")
|
|
109
|
+
impact_text.append("\n")
|
|
110
|
+
|
|
111
|
+
if analysis.impact_map.affected_components:
|
|
112
|
+
impact_text.append("Affected Components: ", style="bold")
|
|
113
|
+
impact_text.append(", ".join(analysis.impact_map.affected_components))
|
|
114
|
+
|
|
115
|
+
self.console.print(Panel(impact_text, title="🗺️ Impact Map", border_style="yellow"))
|
|
116
|
+
|
|
117
|
+
# Risk Assessment
|
|
118
|
+
risk = analysis.risk_assessment
|
|
119
|
+
risk_color = self._risk_style(risk.overall_risk)
|
|
120
|
+
risk_icon = self._risk_icon(risk.overall_risk)
|
|
121
|
+
|
|
122
|
+
risk_text = Text()
|
|
123
|
+
risk_text.append(f"Overall Risk: {risk_icon} {risk.overall_risk.value.upper()}\n\n", style=f"bold {risk_color}")
|
|
124
|
+
|
|
125
|
+
if risk.breaking_changes:
|
|
126
|
+
risk_text.append("⚠️ BREAKING CHANGES DETECTED\n", style="bold red")
|
|
127
|
+
if risk.requires_migration:
|
|
128
|
+
risk_text.append("📦 Migration required\n", style="bold yellow")
|
|
129
|
+
|
|
130
|
+
if risk.risks:
|
|
131
|
+
risk_text.append("\nIdentified Risks:\n", style="bold")
|
|
132
|
+
for r in risk.risks:
|
|
133
|
+
icon = self._risk_icon(r.severity)
|
|
134
|
+
color = self._risk_style(r.severity)
|
|
135
|
+
risk_text.append(f"\n {icon} ", style=color)
|
|
136
|
+
risk_text.append(f"[{r.severity.value}] ", style=f"bold {color}")
|
|
137
|
+
risk_text.append(f"{r.description}\n")
|
|
138
|
+
if r.mitigation:
|
|
139
|
+
risk_text.append(f" 💡 Mitigation: {r.mitigation}\n", style="dim")
|
|
140
|
+
if r.edge_cases:
|
|
141
|
+
risk_text.append(f" ⚡ Edge cases: {', '.join(r.edge_cases)}\n", style="dim")
|
|
142
|
+
|
|
143
|
+
self.console.print(Panel(risk_text, title="⚠️ Risk Assessment", border_style=risk_color))
|
|
144
|
+
|
|
145
|
+
# Review Questions
|
|
146
|
+
if analysis.review_questions:
|
|
147
|
+
questions_text = Text()
|
|
148
|
+
for i, q in enumerate(analysis.review_questions, 1):
|
|
149
|
+
icon = self._risk_icon(q.priority)
|
|
150
|
+
color = self._risk_style(q.priority)
|
|
151
|
+
questions_text.append(f"{i}. ", style="bold")
|
|
152
|
+
questions_text.append(f"{q.question}\n", style="bold")
|
|
153
|
+
questions_text.append(f" {icon} ", style=color)
|
|
154
|
+
questions_text.append(f"{q.context}\n\n", style="dim")
|
|
155
|
+
|
|
156
|
+
self.console.print(Panel(questions_text, title="❓ Review Questions", border_style="cyan"))
|
|
157
|
+
|
|
158
|
+
# Footer
|
|
159
|
+
self.console.print()
|
|
160
|
+
self.console.print(
|
|
161
|
+
f"[dim]Analysis by {analysis.analysis_model} | "
|
|
162
|
+
f"{analysis.tokens_used:,} tokens | "
|
|
163
|
+
f"{analysis.analysis_timestamp}[/dim]"
|
|
164
|
+
)
|
|
165
|
+
self.console.print()
|
semantic_diff/models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for semantic diff analysis
|
|
3
|
+
"""
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RiskLevel(str, Enum):
|
|
10
|
+
LOW = "low"
|
|
11
|
+
MEDIUM = "medium"
|
|
12
|
+
HIGH = "high"
|
|
13
|
+
CRITICAL = "critical"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileChange(BaseModel):
|
|
17
|
+
"""Represents a single file change in a commit"""
|
|
18
|
+
path: str
|
|
19
|
+
change_type: str # added, modified, deleted, renamed
|
|
20
|
+
additions: int = 0
|
|
21
|
+
deletions: int = 0
|
|
22
|
+
diff_content: str = ""
|
|
23
|
+
language: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Intent(BaseModel):
|
|
27
|
+
"""What the developer was trying to accomplish"""
|
|
28
|
+
summary: str = Field(description="One-line summary of the intent")
|
|
29
|
+
reasoning: str = Field(description="Detailed reasoning behind this assessment")
|
|
30
|
+
confidence: float = Field(ge=0, le=1, description="Confidence in this assessment")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Impact(BaseModel):
|
|
34
|
+
"""A single impact point"""
|
|
35
|
+
area: str = Field(description="What area is affected")
|
|
36
|
+
description: str = Field(description="How it's affected")
|
|
37
|
+
severity: RiskLevel = Field(description="How significant is this impact")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ImpactMap(BaseModel):
|
|
41
|
+
"""Map of all areas impacted by the change"""
|
|
42
|
+
direct_impacts: List[Impact] = Field(default_factory=list)
|
|
43
|
+
indirect_impacts: List[Impact] = Field(default_factory=list)
|
|
44
|
+
affected_components: List[str] = Field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Risk(BaseModel):
|
|
48
|
+
"""A single identified risk"""
|
|
49
|
+
description: str
|
|
50
|
+
severity: RiskLevel
|
|
51
|
+
mitigation: Optional[str] = None
|
|
52
|
+
edge_cases: List[str] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RiskAssessment(BaseModel):
|
|
56
|
+
"""Overall risk assessment"""
|
|
57
|
+
overall_risk: RiskLevel
|
|
58
|
+
risks: List[Risk] = Field(default_factory=list)
|
|
59
|
+
breaking_changes: bool = False
|
|
60
|
+
requires_migration: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReviewQuestion(BaseModel):
|
|
64
|
+
"""A question for the code author"""
|
|
65
|
+
question: str
|
|
66
|
+
context: str
|
|
67
|
+
priority: RiskLevel = RiskLevel.MEDIUM
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SemanticAnalysis(BaseModel):
|
|
71
|
+
"""Complete semantic analysis of a commit"""
|
|
72
|
+
commit_hash: str
|
|
73
|
+
commit_message: str
|
|
74
|
+
author: str
|
|
75
|
+
date: str
|
|
76
|
+
files_changed: List[FileChange]
|
|
77
|
+
|
|
78
|
+
intent: Intent
|
|
79
|
+
impact_map: ImpactMap
|
|
80
|
+
risk_assessment: RiskAssessment
|
|
81
|
+
review_questions: List[ReviewQuestion]
|
|
82
|
+
|
|
83
|
+
# Metadata
|
|
84
|
+
analysis_model: str
|
|
85
|
+
analysis_timestamp: str
|
|
86
|
+
tokens_used: int = 0
|
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git diff parser - extracts structured information from git commits
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
from git import Repo, InvalidGitRepositoryError
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from semantic_diff.models import FileChange
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitParser:
|
|
13
|
+
"""Parses git repository information"""
|
|
14
|
+
|
|
15
|
+
LANGUAGE_EXTENSIONS = {
|
|
16
|
+
'.py': 'python',
|
|
17
|
+
'.js': 'javascript',
|
|
18
|
+
'.ts': 'typescript',
|
|
19
|
+
'.tsx': 'typescript',
|
|
20
|
+
'.jsx': 'javascript',
|
|
21
|
+
'.rs': 'rust',
|
|
22
|
+
'.go': 'go',
|
|
23
|
+
'.java': 'java',
|
|
24
|
+
'.rb': 'ruby',
|
|
25
|
+
'.php': 'php',
|
|
26
|
+
'.c': 'c',
|
|
27
|
+
'.cpp': 'cpp',
|
|
28
|
+
'.h': 'c',
|
|
29
|
+
'.hpp': 'cpp',
|
|
30
|
+
'.cs': 'csharp',
|
|
31
|
+
'.swift': 'swift',
|
|
32
|
+
'.kt': 'kotlin',
|
|
33
|
+
'.scala': 'scala',
|
|
34
|
+
'.sql': 'sql',
|
|
35
|
+
'.md': 'markdown',
|
|
36
|
+
'.json': 'json',
|
|
37
|
+
'.yaml': 'yaml',
|
|
38
|
+
'.yml': 'yaml',
|
|
39
|
+
'.toml': 'toml',
|
|
40
|
+
'.xml': 'xml',
|
|
41
|
+
'.html': 'html',
|
|
42
|
+
'.css': 'css',
|
|
43
|
+
'.scss': 'scss',
|
|
44
|
+
'.sh': 'bash',
|
|
45
|
+
'.bash': 'bash',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def __init__(self, repo_path: Optional[str] = None):
|
|
49
|
+
"""
|
|
50
|
+
Initialize parser with repository path.
|
|
51
|
+
If not provided, uses current directory.
|
|
52
|
+
"""
|
|
53
|
+
self.repo_path = repo_path or os.getcwd()
|
|
54
|
+
try:
|
|
55
|
+
self.repo = Repo(self.repo_path)
|
|
56
|
+
except InvalidGitRepositoryError:
|
|
57
|
+
raise ValueError(f"Not a git repository: {self.repo_path}")
|
|
58
|
+
|
|
59
|
+
def get_commit(self, commit_hash: str):
|
|
60
|
+
"""Get a commit by hash (full or short)"""
|
|
61
|
+
try:
|
|
62
|
+
return self.repo.commit(commit_hash)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise ValueError(f"Could not find commit: {commit_hash}") from e
|
|
65
|
+
|
|
66
|
+
def get_commit_info(self, commit_hash: str) -> dict:
|
|
67
|
+
"""Get basic commit information"""
|
|
68
|
+
commit = self.get_commit(commit_hash)
|
|
69
|
+
return {
|
|
70
|
+
'hash': commit.hexsha,
|
|
71
|
+
'short_hash': commit.hexsha[:8],
|
|
72
|
+
'message': commit.message.strip(),
|
|
73
|
+
'author': f"{commit.author.name} <{commit.author.email}>",
|
|
74
|
+
'date': commit.committed_datetime.isoformat(),
|
|
75
|
+
'parents': [p.hexsha for p in commit.parents],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def detect_language(self, filepath: str) -> Optional[str]:
|
|
79
|
+
"""Detect programming language from file extension"""
|
|
80
|
+
ext = Path(filepath).suffix.lower()
|
|
81
|
+
return self.LANGUAGE_EXTENSIONS.get(ext)
|
|
82
|
+
|
|
83
|
+
def get_file_changes(self, commit_hash: str) -> List[FileChange]:
|
|
84
|
+
"""
|
|
85
|
+
Get all file changes for a commit.
|
|
86
|
+
Returns structured FileChange objects.
|
|
87
|
+
"""
|
|
88
|
+
commit = self.get_commit(commit_hash)
|
|
89
|
+
changes = []
|
|
90
|
+
|
|
91
|
+
# Handle initial commit (no parents)
|
|
92
|
+
if not commit.parents:
|
|
93
|
+
for item in commit.tree.traverse():
|
|
94
|
+
if item.type == 'blob':
|
|
95
|
+
content = ""
|
|
96
|
+
diff_content = "[binary file]"
|
|
97
|
+
try:
|
|
98
|
+
content = item.data_stream.read().decode('utf-8', errors='replace')
|
|
99
|
+
diff_content = f"+{content}"
|
|
100
|
+
except (UnicodeDecodeError, IOError, OSError):
|
|
101
|
+
pass # Keep defaults: empty content, binary marker
|
|
102
|
+
|
|
103
|
+
changes.append(FileChange(
|
|
104
|
+
path=item.path,
|
|
105
|
+
change_type='added',
|
|
106
|
+
additions=len(content.split('\n')) if content else 0,
|
|
107
|
+
deletions=0,
|
|
108
|
+
diff_content=diff_content[:5000], # Limit size
|
|
109
|
+
language=self.detect_language(item.path)
|
|
110
|
+
))
|
|
111
|
+
return changes
|
|
112
|
+
|
|
113
|
+
# Normal commit with parent
|
|
114
|
+
parent = commit.parents[0]
|
|
115
|
+
diffs = parent.diff(commit, create_patch=True)
|
|
116
|
+
|
|
117
|
+
for diff in diffs:
|
|
118
|
+
# Determine change type
|
|
119
|
+
if diff.new_file:
|
|
120
|
+
change_type = 'added'
|
|
121
|
+
path = diff.b_path
|
|
122
|
+
elif diff.deleted_file:
|
|
123
|
+
change_type = 'deleted'
|
|
124
|
+
path = diff.a_path
|
|
125
|
+
elif diff.renamed:
|
|
126
|
+
change_type = 'renamed'
|
|
127
|
+
path = f"{diff.a_path} -> {diff.b_path}"
|
|
128
|
+
else:
|
|
129
|
+
change_type = 'modified'
|
|
130
|
+
path = diff.b_path or diff.a_path
|
|
131
|
+
|
|
132
|
+
# Get diff content
|
|
133
|
+
try:
|
|
134
|
+
diff_content = diff.diff.decode('utf-8', errors='replace')
|
|
135
|
+
except (UnicodeDecodeError, AttributeError, TypeError):
|
|
136
|
+
diff_content = "[binary file]"
|
|
137
|
+
|
|
138
|
+
# Count additions/deletions
|
|
139
|
+
additions = 0
|
|
140
|
+
deletions = 0
|
|
141
|
+
for line in diff_content.split('\n'):
|
|
142
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
143
|
+
additions += 1
|
|
144
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
145
|
+
deletions += 1
|
|
146
|
+
|
|
147
|
+
changes.append(FileChange(
|
|
148
|
+
path=path,
|
|
149
|
+
change_type=change_type,
|
|
150
|
+
additions=additions,
|
|
151
|
+
deletions=deletions,
|
|
152
|
+
diff_content=diff_content[:5000], # Limit size for LLM
|
|
153
|
+
language=self.detect_language(path)
|
|
154
|
+
))
|
|
155
|
+
|
|
156
|
+
return changes
|
|
157
|
+
|
|
158
|
+
def get_project_context(self, max_files: int = 20) -> dict:
|
|
159
|
+
"""
|
|
160
|
+
Get project context - file structure, main files, etc.
|
|
161
|
+
Useful for understanding impact.
|
|
162
|
+
"""
|
|
163
|
+
context = {
|
|
164
|
+
'root_files': [],
|
|
165
|
+
'directories': [],
|
|
166
|
+
'languages': set(),
|
|
167
|
+
'has_tests': False,
|
|
168
|
+
'has_ci': False,
|
|
169
|
+
'package_manager': None,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Check root files
|
|
173
|
+
for item in self.repo.tree().traverse():
|
|
174
|
+
if item.type == 'blob':
|
|
175
|
+
path = item.path
|
|
176
|
+
|
|
177
|
+
# Root level important files
|
|
178
|
+
if '/' not in path:
|
|
179
|
+
context['root_files'].append(path)
|
|
180
|
+
|
|
181
|
+
# Detect package manager
|
|
182
|
+
if path == 'package.json':
|
|
183
|
+
context['package_manager'] = 'npm'
|
|
184
|
+
elif path == 'requirements.txt' or path == 'pyproject.toml':
|
|
185
|
+
context['package_manager'] = 'pip'
|
|
186
|
+
elif path == 'Cargo.toml':
|
|
187
|
+
context['package_manager'] = 'cargo'
|
|
188
|
+
elif path == 'go.mod':
|
|
189
|
+
context['package_manager'] = 'go'
|
|
190
|
+
|
|
191
|
+
# Detect tests
|
|
192
|
+
if 'test' in path.lower() or 'spec' in path.lower():
|
|
193
|
+
context['has_tests'] = True
|
|
194
|
+
|
|
195
|
+
# Detect CI
|
|
196
|
+
if '.github/workflows' in path or '.gitlab-ci' in path:
|
|
197
|
+
context['has_ci'] = True
|
|
198
|
+
|
|
199
|
+
# Track languages
|
|
200
|
+
lang = self.detect_language(path)
|
|
201
|
+
if lang:
|
|
202
|
+
context['languages'].add(lang)
|
|
203
|
+
|
|
204
|
+
elif item.type == 'tree' and '/' not in item.path:
|
|
205
|
+
context['directories'].append(item.path)
|
|
206
|
+
|
|
207
|
+
context['languages'] = list(context['languages'])
|
|
208
|
+
return context
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semantic-diff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered semantic analysis of git commits
|
|
5
|
+
Author-email: Kenaz GmbH <hello@kenaz.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tkenaz/semantic_diff
|
|
8
|
+
Project-URL: Repository, https://github.com/tkenaz/semantic_diff
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: anthropic>=0.40.0
|
|
18
|
+
Requires-Dist: click>=8.1.0
|
|
19
|
+
Requires-Dist: rich>=13.0.0
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
21
|
+
Requires-Dist: gitpython>=3.1.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# semantic-diff
|
|
30
|
+
|
|
31
|
+
AI-powered semantic analysis of git commits. Goes beyond `git diff` to show **intent**, **impact**, **risk**, and **review questions**.
|
|
32
|
+
|
|
33
|
+
## What it does
|
|
34
|
+
|
|
35
|
+
Regular `git diff` shows *what* changed. `semantic-diff` shows:
|
|
36
|
+
|
|
37
|
+
- **🎯 Intent** — What was the developer trying to accomplish? (not what changed, but *why*)
|
|
38
|
+
- **🗺️ Impact Map** — What parts of the system are affected directly and indirectly?
|
|
39
|
+
- **⚠️ Risk Assessment** — What could break? Edge cases? Breaking changes?
|
|
40
|
+
- **❓ Review Questions** — What should a reviewer ask the author?
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Clone the repo
|
|
46
|
+
git clone git@github.com:tkenaz/semantic_diff.git
|
|
47
|
+
cd semantic_diff
|
|
48
|
+
|
|
49
|
+
# Create virtual environment
|
|
50
|
+
python -m venv .venv
|
|
51
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
52
|
+
|
|
53
|
+
# Install
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
Create a `.env` file:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ANTHROPIC_API_KEY=sk-ant-...
|
|
63
|
+
SEMANTIC_DIFF_MODEL=claude-sonnet-4-5-20250929 # or claude-opus-4-5-20251101
|
|
64
|
+
SEMANTIC_DIFF_MAX_RETRIES=3 # optional, API retry attempts
|
|
65
|
+
SEMANTIC_DIFF_MAX_WAIT=30.0 # optional, max total retry wait time in seconds
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Analyze HEAD commit in current repo
|
|
72
|
+
semantic-diff
|
|
73
|
+
|
|
74
|
+
# Analyze specific commit
|
|
75
|
+
semantic-diff abc123
|
|
76
|
+
|
|
77
|
+
# Analyze commit in another repo
|
|
78
|
+
semantic-diff HEAD --repo /path/to/repo
|
|
79
|
+
|
|
80
|
+
# Output as JSON (for piping to other tools)
|
|
81
|
+
semantic-diff HEAD --json
|
|
82
|
+
|
|
83
|
+
# Verbose mode
|
|
84
|
+
semantic-diff HEAD -v
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Example Output
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
╭──────────────────── 📋 Semantic Diff Analysis ────────────────────╮
|
|
91
|
+
│ Fix authentication bypass in login endpoint │
|
|
92
|
+
│ │
|
|
93
|
+
│ abc12345 by developer@example.com │
|
|
94
|
+
│ 2024-12-22T10:30:00 │
|
|
95
|
+
╰────────────────────────────────────────────────────────────────────╯
|
|
96
|
+
|
|
97
|
+
╭──────────────────────── 🎯 Intent ────────────────────────╮
|
|
98
|
+
│ Prevent unauthorized access by validating session tokens │
|
|
99
|
+
│ before processing login requests. │
|
|
100
|
+
│ │
|
|
101
|
+
│ Confidence: [████████░░] 85% │
|
|
102
|
+
╰────────────────────────────────────────────────────────────╯
|
|
103
|
+
|
|
104
|
+
╭──────────────────── ⚠️ Risk Assessment ───────────────────╮
|
|
105
|
+
│ Overall Risk: ⚡ HIGH │
|
|
106
|
+
│ │
|
|
107
|
+
│ ⚠️ BREAKING CHANGES DETECTED │
|
|
108
|
+
│ │
|
|
109
|
+
│ Identified Risks: │
|
|
110
|
+
│ ⚡ [high] Existing sessions may be invalidated │
|
|
111
|
+
│ 💡 Mitigation: Add migration for active sessions │
|
|
112
|
+
╰────────────────────────────────────────────────────────────╯
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Install dev dependencies
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
|
|
121
|
+
# Run tests
|
|
122
|
+
pytest
|
|
123
|
+
|
|
124
|
+
# Format code
|
|
125
|
+
black semantic_diff
|
|
126
|
+
ruff check semantic_diff
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT — Kenaz GmbH
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
semantic_diff/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
semantic_diff/cli.py,sha256=8y-y_lNxGR5AiUweGQACWcvQ0D8pD9idxIoiOOBB2vg,2680
|
|
3
|
+
semantic_diff/models.py,sha256=pkV1dskq9frikpGTzATuPeEM4LY99IdezlJFpsF2rTM,2368
|
|
4
|
+
semantic_diff/analyzers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
semantic_diff/analyzers/llm_analyzer.py,sha256=B7yhKKqbjDGHCxOXrNtzHZNZYEcXaDtUsyTdjJ0y5_c,16286
|
|
6
|
+
semantic_diff/formatters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
semantic_diff/formatters/console_formatter.py,sha256=MybzynifCnULBqNI3P1_sF_q3lm-MECATw0cN14dZTQ,6745
|
|
8
|
+
semantic_diff/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
semantic_diff/parsers/git_parser.py,sha256=59iZ1LLDO28zxAhM-YiEDkvGSbjH-rW7WTzS4QVXiBw,7369
|
|
10
|
+
semantic_diff-0.1.0.dist-info/METADATA,sha256=H5ZXRBCNLrHEGAtcItvfRzEfY7LGi5jnmyo8mljCFlk,4821
|
|
11
|
+
semantic_diff-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
semantic_diff-0.1.0.dist-info/entry_points.txt,sha256=jAOkEjqBBPCIYz-fHkAjOWEk6CSJmAgCSNukSN9oQCw,57
|
|
13
|
+
semantic_diff-0.1.0.dist-info/top_level.txt,sha256=KnLPUVj5bNp8QVIRhay9drM_pXoC1S-Dof7bwzo0v2o,14
|
|
14
|
+
semantic_diff-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
semantic_diff
|