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.
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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ semantic-diff = semantic_diff.cli:main
@@ -0,0 +1 @@
1
+ semantic_diff