smart-commit-cli 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.
- smart_commit/__init__.py +6 -0
- smart_commit/ai/__init__.py +7 -0
- smart_commit/ai/client.py +123 -0
- smart_commit/ai/reviewer.py +91 -0
- smart_commit/ai.py +136 -0
- smart_commit/analysis/__init__.py +7 -0
- smart_commit/analysis/ast_parser.py +173 -0
- smart_commit/analysis/dependency_graph.py +114 -0
- smart_commit/analysis/diff_parser.py +172 -0
- smart_commit/analysis/summarizer.py +101 -0
- smart_commit/analysis/symbol_extractor.py +88 -0
- smart_commit/benchmark.py +88 -0
- smart_commit/cache.py +65 -0
- smart_commit/cli.py +391 -0
- smart_commit/clustering/__init__.py +7 -0
- smart_commit/clustering/graph_clustering.py +78 -0
- smart_commit/clustering/heuristic.py +178 -0
- smart_commit/clustering/semantic.py +201 -0
- smart_commit/clustering.py +186 -0
- smart_commit/commit_messages.py +232 -0
- smart_commit/committer.py +91 -0
- smart_commit/config.py +88 -0
- smart_commit/constants.py +92 -0
- smart_commit/diff_parser.py +158 -0
- smart_commit/embeddings/__init__.py +7 -0
- smart_commit/embeddings/generator.py +128 -0
- smart_commit/embeddings/similarity.py +159 -0
- smart_commit/embeddings.py +108 -0
- smart_commit/exceptions.py +102 -0
- smart_commit/execution/__init__.py +6 -0
- smart_commit/execution/committer.py +102 -0
- smart_commit/git/__init__.py +8 -0
- smart_commit/git/safety.py +75 -0
- smart_commit/git/scanner.py +182 -0
- smart_commit/git/service.py +129 -0
- smart_commit/git_service.py +302 -0
- smart_commit/logger.py +99 -0
- smart_commit/models.py +73 -0
- smart_commit/plugins/__init__.py +6 -0
- smart_commit/plugins/base.py +135 -0
- smart_commit/plugins/django.py +40 -0
- smart_commit/plugins/fastapi.py +39 -0
- smart_commit/plugins/react.py +49 -0
- smart_commit/preview/__init__.py +6 -0
- smart_commit/preview/editor.py +224 -0
- smart_commit/preview/renderer.py +94 -0
- smart_commit/preview.py +131 -0
- smart_commit/summarizer.py +93 -0
- smart_commit/utils.py +83 -0
- smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
- smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
- smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
- smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
smart_commit/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""LLM client wrapper using LiteLLM."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Dict, Optional
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ..config import Config
|
|
8
|
+
from ..logger import get_logger
|
|
9
|
+
from ..exceptions import LLMException, LLMTimeoutError, LLMRateLimitError
|
|
10
|
+
from ..constants import MAX_LLM_RETRIES, LLM_TIMEOUT
|
|
11
|
+
|
|
12
|
+
logger = get_logger("ai.client")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMClient:
|
|
16
|
+
"""Wrapper around LiteLLM for multi-provider support."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, config: Optional[Config] = None):
|
|
19
|
+
"""Initialize LLM client.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: Configuration object.
|
|
23
|
+
"""
|
|
24
|
+
self.config = config or Config.load()
|
|
25
|
+
self._init_client()
|
|
26
|
+
|
|
27
|
+
def _init_client(self):
|
|
28
|
+
"""Initialize LiteLLM client."""
|
|
29
|
+
try:
|
|
30
|
+
import litellm
|
|
31
|
+
self.client = litellm
|
|
32
|
+
logger.info(f"LLM Client initialized: {self.config.provider}/{self.config.model}")
|
|
33
|
+
except ImportError:
|
|
34
|
+
logger.error("litellm not installed")
|
|
35
|
+
raise
|
|
36
|
+
|
|
37
|
+
def complete(self, prompt: str, max_retries: int = MAX_LLM_RETRIES) -> str:
|
|
38
|
+
"""Make LLM completion request.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
prompt: Input prompt.
|
|
42
|
+
max_retries: Max retry attempts.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
LLM response.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
LLMException: If request fails.
|
|
49
|
+
"""
|
|
50
|
+
for attempt in range(max_retries):
|
|
51
|
+
try:
|
|
52
|
+
response = self.client.completion(
|
|
53
|
+
model=self._get_model_name(),
|
|
54
|
+
messages=[{"role": "user", "content": prompt}],
|
|
55
|
+
api_key=self.config.api_key,
|
|
56
|
+
temperature=0.3,
|
|
57
|
+
max_tokens=1000,
|
|
58
|
+
timeout=LLM_TIMEOUT,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return response.choices[0].message.content
|
|
62
|
+
|
|
63
|
+
except Exception as e:
|
|
64
|
+
error_str = str(e).lower()
|
|
65
|
+
|
|
66
|
+
if "rate_limit" in error_str or "429" in error_str:
|
|
67
|
+
if attempt < max_retries - 1:
|
|
68
|
+
wait_time = 2 ** attempt
|
|
69
|
+
logger.warning(f"Rate limited, waiting {wait_time}s...")
|
|
70
|
+
time.sleep(wait_time)
|
|
71
|
+
continue
|
|
72
|
+
raise LLMRateLimitError(str(e))
|
|
73
|
+
|
|
74
|
+
elif "timeout" in error_str:
|
|
75
|
+
raise LLMTimeoutError(str(e))
|
|
76
|
+
|
|
77
|
+
elif attempt == max_retries - 1:
|
|
78
|
+
raise LLMException(str(e))
|
|
79
|
+
|
|
80
|
+
else:
|
|
81
|
+
logger.warning(f"LLM request failed (attempt {attempt + 1}): {e}")
|
|
82
|
+
time.sleep(1)
|
|
83
|
+
|
|
84
|
+
def complete_json(self, prompt: str) -> Dict:
|
|
85
|
+
"""Make LLM request expecting JSON response.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
prompt: Input prompt.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Parsed JSON response.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
LLMException: If request fails or JSON parsing fails.
|
|
95
|
+
"""
|
|
96
|
+
response = self.complete(prompt)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# Try to find JSON in response
|
|
100
|
+
import re
|
|
101
|
+
json_match = re.search(r'\{.*\}', response, re.DOTALL)
|
|
102
|
+
|
|
103
|
+
if json_match:
|
|
104
|
+
json_str = json_match.group(0)
|
|
105
|
+
return json.loads(json_str)
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("No JSON found in response")
|
|
108
|
+
|
|
109
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
110
|
+
logger.error(f"Failed to parse JSON response: {response}")
|
|
111
|
+
raise LLMException(f"Invalid JSON in LLM response: {e}")
|
|
112
|
+
|
|
113
|
+
def _get_model_name(self) -> str:
|
|
114
|
+
"""Get full model name for LiteLLM.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Model name string.
|
|
118
|
+
"""
|
|
119
|
+
if "/" in self.config.model:
|
|
120
|
+
return self.config.model
|
|
121
|
+
|
|
122
|
+
return f"{self.config.provider}/{self.config.model}"
|
|
123
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""LLM-based cluster review and refinement."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from ..models import CommitCluster
|
|
7
|
+
from ..logger import get_logger
|
|
8
|
+
from .client import LLMClient
|
|
9
|
+
|
|
10
|
+
logger = get_logger("ai.reviewer")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClusterReviewer:
|
|
14
|
+
"""Uses LLM to review and refine commit clusters."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, llm_client: LLMClient):
|
|
17
|
+
"""Initialize cluster reviewer.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
llm_client: LLMClient instance.
|
|
21
|
+
"""
|
|
22
|
+
self.llm_client = llm_client
|
|
23
|
+
|
|
24
|
+
def review_cluster(self, cluster: CommitCluster) -> dict:
|
|
25
|
+
"""Review a cluster and suggest improvements.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
cluster: CommitCluster to review.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Review result with suggestions.
|
|
32
|
+
"""
|
|
33
|
+
if not cluster.summary:
|
|
34
|
+
return {"status": "skipped", "reason": "No summary"}
|
|
35
|
+
|
|
36
|
+
file_list = "\n".join([f" - {f.file_path}" for f in cluster.files[:5]])
|
|
37
|
+
if len(cluster.files) > 5:
|
|
38
|
+
file_list += f"\n - ... and {len(cluster.files) - 5} more"
|
|
39
|
+
|
|
40
|
+
prompt = f"""Review this commit group and decide if files belong together.
|
|
41
|
+
|
|
42
|
+
Cluster Summary:
|
|
43
|
+
{cluster.summary}
|
|
44
|
+
|
|
45
|
+
Files ({len(cluster.files)} total):
|
|
46
|
+
{file_list}
|
|
47
|
+
|
|
48
|
+
Respond with JSON: {{"should_split": boolean, "confidence": 0.0-1.0, "reason": "..."}}
|
|
49
|
+
Keep response concise."""
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
result = self.llm_client.complete_json(prompt)
|
|
53
|
+
|
|
54
|
+
# Ensure required fields
|
|
55
|
+
return {
|
|
56
|
+
"should_split": result.get("should_split", False),
|
|
57
|
+
"confidence": float(result.get("confidence", 0.5)),
|
|
58
|
+
"reason": result.get("reason", "")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Cluster review failed: {e}")
|
|
63
|
+
return {"status": "error", "error": str(e)}
|
|
64
|
+
|
|
65
|
+
def should_merge_clusters(self, cluster1: CommitCluster, cluster2: CommitCluster) -> bool:
|
|
66
|
+
"""Decide if two clusters should be merged.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
cluster1: First cluster.
|
|
70
|
+
cluster2: Second cluster.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if clusters should be merged.
|
|
74
|
+
"""
|
|
75
|
+
prompt = f"""Should these commits be merged into one?
|
|
76
|
+
|
|
77
|
+
Commit 1: {cluster1.summary}
|
|
78
|
+
Files: {len(cluster1.files)}
|
|
79
|
+
|
|
80
|
+
Commit 2: {cluster2.summary}
|
|
81
|
+
Files: {len(cluster2.files)}
|
|
82
|
+
|
|
83
|
+
Respond with only 'yes' or 'no'."""
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
response = self.llm_client.complete(prompt).lower().strip()
|
|
87
|
+
return "yes" in response
|
|
88
|
+
except Exception as e:
|
|
89
|
+
logger.error(f"Merge decision failed: {e}")
|
|
90
|
+
return False
|
|
91
|
+
|
smart_commit/ai.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AI pipeline for cluster refinement and analysis."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from .models import CommitCluster, CommitType
|
|
5
|
+
from .config import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AIAnalyzer:
|
|
9
|
+
"""Uses LLM to review and refine commit clusters."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, config: Optional[Config] = None):
|
|
12
|
+
"""Initialize AI analyzer.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
config: Configuration object. If None, loads from environment.
|
|
16
|
+
"""
|
|
17
|
+
self.config = config or Config.load()
|
|
18
|
+
self._init_llm()
|
|
19
|
+
|
|
20
|
+
def _init_llm(self):
|
|
21
|
+
"""Initialize LLM client."""
|
|
22
|
+
try:
|
|
23
|
+
import litellm
|
|
24
|
+
self.llm = litellm
|
|
25
|
+
except ImportError:
|
|
26
|
+
raise ImportError("litellm not installed. Install with: pip install litellm")
|
|
27
|
+
|
|
28
|
+
def review_cluster(self, cluster: CommitCluster) -> dict:
|
|
29
|
+
"""Use LLM to review and improve a cluster.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
cluster: CommitCluster to review.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Review result with suggestions.
|
|
36
|
+
"""
|
|
37
|
+
if not cluster.summary:
|
|
38
|
+
return {"status": "skipped", "reason": "No summary"}
|
|
39
|
+
|
|
40
|
+
prompt = f"""
|
|
41
|
+
You are an expert software engineer reviewing a commit group.
|
|
42
|
+
|
|
43
|
+
Cluster Summary:
|
|
44
|
+
{cluster.summary}
|
|
45
|
+
|
|
46
|
+
Files in cluster: {len(cluster.files)}
|
|
47
|
+
- {', '.join(f.file_path for f in cluster.files[:3])}
|
|
48
|
+
|
|
49
|
+
Assess if these files logically belong together. Should they be split into separate commits?
|
|
50
|
+
Respond with JSON: {{"should_split": boolean, "reason": "...", "suggestions": ["..."]}}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
response = self.llm.completion(
|
|
55
|
+
model=f"{self.config.provider}/{self.config.model}" if "/" not in self.config.model else self.config.model,
|
|
56
|
+
messages=[{"role": "user", "content": prompt}],
|
|
57
|
+
api_key=self.config.api_key,
|
|
58
|
+
temperature=0.3,
|
|
59
|
+
max_tokens=200
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Parse response
|
|
63
|
+
import json
|
|
64
|
+
content = response.choices[0].message.content
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = json.loads(content)
|
|
68
|
+
return result
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
return {"status": "error", "raw_response": content}
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
return {"status": "error", "error": str(e)}
|
|
74
|
+
|
|
75
|
+
def should_merge_clusters(self, cluster1: CommitCluster, cluster2: CommitCluster) -> bool:
|
|
76
|
+
"""Use LLM to decide if two clusters should be merged.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
cluster1: First cluster.
|
|
80
|
+
cluster2: Second cluster.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if clusters should be merged.
|
|
84
|
+
"""
|
|
85
|
+
prompt = f"""
|
|
86
|
+
Should these two commit groups be merged into one?
|
|
87
|
+
|
|
88
|
+
Group 1: {cluster1.summary}
|
|
89
|
+
Group 2: {cluster2.summary}
|
|
90
|
+
|
|
91
|
+
Respond with just 'yes' or 'no'.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
response = self.llm.completion(
|
|
96
|
+
model=f"{self.config.provider}/{self.config.model}" if "/" not in self.config.model else self.config.model,
|
|
97
|
+
messages=[{"role": "user", "content": prompt}],
|
|
98
|
+
api_key=self.config.api_key,
|
|
99
|
+
temperature=0.3,
|
|
100
|
+
max_tokens=10
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
answer = response.choices[0].message.content.lower().strip()
|
|
104
|
+
return "yes" in answer
|
|
105
|
+
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def suggest_commit_type(self, cluster: CommitCluster) -> Optional[CommitType]:
|
|
110
|
+
"""Suggest a Conventional Commit type for a cluster.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
cluster: CommitCluster to analyze.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Suggested CommitType.
|
|
117
|
+
"""
|
|
118
|
+
if not cluster.summary:
|
|
119
|
+
return CommitType.CHORE
|
|
120
|
+
|
|
121
|
+
summary_lower = cluster.summary.lower()
|
|
122
|
+
|
|
123
|
+
# Simple heuristics (can be improved with LLM)
|
|
124
|
+
if any(x in summary_lower for x in ['fix', 'bug', 'issue']):
|
|
125
|
+
return CommitType.FIX
|
|
126
|
+
elif any(x in summary_lower for x in ['feat', 'feature', 'add', 'implement']):
|
|
127
|
+
return CommitType.FEAT
|
|
128
|
+
elif any(x in summary_lower for x in ['refactor', 'clean', 'improve']):
|
|
129
|
+
return CommitType.REFACTOR
|
|
130
|
+
elif any(x in summary_lower for x in ['test', 'spec']):
|
|
131
|
+
return CommitType.TEST
|
|
132
|
+
elif any(x in summary_lower for x in ['doc', 'readme', 'comment']):
|
|
133
|
+
return CommitType.DOCS
|
|
134
|
+
|
|
135
|
+
return CommitType.CHORE
|
|
136
|
+
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Abstract Syntax Tree parsing for better code understanding."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from ..logger import get_logger
|
|
5
|
+
|
|
6
|
+
logger = get_logger("analysis.ast_parser")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ASTParser:
|
|
10
|
+
"""Parse AST for language-specific symbol extraction."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, language: str):
|
|
13
|
+
"""Initialize AST parser.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
language: Programming language (python, javascript, typescript, etc.)
|
|
17
|
+
"""
|
|
18
|
+
self.language = language.lower()
|
|
19
|
+
self._init_parser()
|
|
20
|
+
|
|
21
|
+
def _init_parser(self):
|
|
22
|
+
"""Initialize language-specific parser."""
|
|
23
|
+
# Note: Full AST parsing requires tree-sitter or language-specific libraries
|
|
24
|
+
# This is a foundation for future expansion
|
|
25
|
+
|
|
26
|
+
if self.language in ["python"]:
|
|
27
|
+
self._try_import_python_parser()
|
|
28
|
+
elif self.language in ["javascript", "typescript", "jsx", "tsx"]:
|
|
29
|
+
self._try_import_js_parser()
|
|
30
|
+
else:
|
|
31
|
+
logger.debug(f"No AST parser available for {self.language}")
|
|
32
|
+
|
|
33
|
+
def _try_import_python_parser(self):
|
|
34
|
+
"""Try to import Python AST parser."""
|
|
35
|
+
try:
|
|
36
|
+
import ast
|
|
37
|
+
self.parser = ast
|
|
38
|
+
logger.debug("Python AST parser initialized")
|
|
39
|
+
except ImportError:
|
|
40
|
+
logger.debug("ast module not available")
|
|
41
|
+
|
|
42
|
+
def _try_import_js_parser(self):
|
|
43
|
+
"""Try to import JavaScript/TypeScript parser."""
|
|
44
|
+
try:
|
|
45
|
+
import tree_sitter
|
|
46
|
+
try:
|
|
47
|
+
# new tree_sitter api in 0.22+
|
|
48
|
+
import tree_sitter_javascript as ts_js
|
|
49
|
+
JS_LANGUAGE = tree_sitter.Language(ts_js.language())
|
|
50
|
+
self.js_parser = tree_sitter.Parser(JS_LANGUAGE)
|
|
51
|
+
logger.debug("tree-sitter javascript initialized")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.debug(f"Failed to load tree_sitter_javascript: {e}")
|
|
54
|
+
self.js_parser = None
|
|
55
|
+
except ImportError:
|
|
56
|
+
logger.debug("tree-sitter not installed")
|
|
57
|
+
self.js_parser = None
|
|
58
|
+
|
|
59
|
+
def extract_functions(self, source_code: str) -> List[str]:
|
|
60
|
+
"""Extract function names from source code.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
source_code: Source code string.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of function names.
|
|
67
|
+
"""
|
|
68
|
+
if self.language == "python":
|
|
69
|
+
return self._extract_python_functions(source_code)
|
|
70
|
+
elif self.language in ["javascript", "typescript", "jsx", "tsx"]:
|
|
71
|
+
return self._extract_js_functions(source_code)
|
|
72
|
+
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def extract_classes(self, source_code: str) -> List[str]:
|
|
76
|
+
"""Extract class names from source code.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
source_code: Source code string.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of class names.
|
|
83
|
+
"""
|
|
84
|
+
if self.language == "python":
|
|
85
|
+
return self._extract_python_classes(source_code)
|
|
86
|
+
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
def extract_imports(self, source_code: str) -> List[str]:
|
|
90
|
+
"""Extract imported modules.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
source_code: Source code string.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of imported modules.
|
|
97
|
+
"""
|
|
98
|
+
if self.language == "python":
|
|
99
|
+
return self._extract_python_imports(source_code)
|
|
100
|
+
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
def _extract_python_functions(self, source_code: str) -> List[str]:
|
|
104
|
+
"""Extract Python function names."""
|
|
105
|
+
try:
|
|
106
|
+
import ast
|
|
107
|
+
tree = ast.parse(source_code)
|
|
108
|
+
|
|
109
|
+
functions = []
|
|
110
|
+
for node in ast.walk(tree):
|
|
111
|
+
if isinstance(node, ast.FunctionDef):
|
|
112
|
+
functions.append(node.name)
|
|
113
|
+
|
|
114
|
+
return functions
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.debug(f"Failed to extract Python functions: {e}")
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
def _extract_python_classes(self, source_code: str) -> List[str]:
|
|
120
|
+
"""Extract Python class names."""
|
|
121
|
+
try:
|
|
122
|
+
import ast
|
|
123
|
+
tree = ast.parse(source_code)
|
|
124
|
+
|
|
125
|
+
classes = []
|
|
126
|
+
for node in ast.walk(tree):
|
|
127
|
+
if isinstance(node, ast.ClassDef):
|
|
128
|
+
classes.append(node.name)
|
|
129
|
+
|
|
130
|
+
return classes
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.debug(f"Failed to extract Python classes: {e}")
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
def _extract_python_imports(self, source_code: str) -> List[str]:
|
|
136
|
+
"""Extract Python import statements."""
|
|
137
|
+
try:
|
|
138
|
+
import ast
|
|
139
|
+
tree = ast.parse(source_code)
|
|
140
|
+
|
|
141
|
+
imports = []
|
|
142
|
+
for node in ast.walk(tree):
|
|
143
|
+
if isinstance(node, ast.Import):
|
|
144
|
+
for alias in node.names:
|
|
145
|
+
imports.append(alias.name)
|
|
146
|
+
elif isinstance(node, ast.ImportFrom):
|
|
147
|
+
if node.module:
|
|
148
|
+
imports.append(node.module)
|
|
149
|
+
|
|
150
|
+
return list(set(imports)) # Deduplicate
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.debug(f"Failed to extract Python imports: {e}")
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
def _extract_js_functions(self, source_code: str) -> List[str]:
|
|
156
|
+
if not getattr(self, "js_parser", None):
|
|
157
|
+
return []
|
|
158
|
+
try:
|
|
159
|
+
tree = self.js_parser.parse(bytes(source_code, "utf8"))
|
|
160
|
+
query = self.js_parser.language.query("""
|
|
161
|
+
(function_declaration name: (identifier) @name)
|
|
162
|
+
(method_definition name: (property_identifier) @name)
|
|
163
|
+
(arrow_function) @name
|
|
164
|
+
""")
|
|
165
|
+
captures = query.captures(tree.root_node)
|
|
166
|
+
functions = []
|
|
167
|
+
for node, _ in captures.get("name", []):
|
|
168
|
+
functions.append(node.text.decode("utf8"))
|
|
169
|
+
return functions
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.debug(f"Failed to extract JS functions: {e}")
|
|
172
|
+
return []
|
|
173
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Dependency graph for repository topology."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import networkx as nx
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Set, Optional
|
|
7
|
+
|
|
8
|
+
from ..logger import get_logger
|
|
9
|
+
from ..cache import cache_manager
|
|
10
|
+
from .ast_parser import ASTParser
|
|
11
|
+
|
|
12
|
+
logger = get_logger("analysis.dependency_graph")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DependencyGraph:
|
|
16
|
+
"""Builds and queries a module dependency graph."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo_path: Path):
|
|
19
|
+
self.repo_path = repo_path
|
|
20
|
+
self.graph = nx.DiGraph()
|
|
21
|
+
self._build_graph()
|
|
22
|
+
|
|
23
|
+
def _build_graph(self):
|
|
24
|
+
"""Build the dependency graph for the repository."""
|
|
25
|
+
# Try to load from cache
|
|
26
|
+
cached_edges = cache_manager.get("dependency_graph", str(self.repo_path))
|
|
27
|
+
|
|
28
|
+
if cached_edges is not None:
|
|
29
|
+
self.graph.add_edges_from(cached_edges)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
# If not cached, we build a partial graph of known imports.
|
|
33
|
+
# To make it fast, we will only build it based on cached file contents
|
|
34
|
+
# or we scan the repo. Since full scan is heavy, we'll do a simple heuristic
|
|
35
|
+
# scan of Python and JS/TS files.
|
|
36
|
+
edges = set()
|
|
37
|
+
|
|
38
|
+
# We will do a fast walk for .py, .js, .ts files
|
|
39
|
+
for root, dirs, files in os.walk(self.repo_path):
|
|
40
|
+
# Skip hidden dirs and node_modules
|
|
41
|
+
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('node_modules', 'venv', 'env', '__pycache__')]
|
|
42
|
+
|
|
43
|
+
for file in files:
|
|
44
|
+
ext = file.split('.')[-1].lower() if '.' in file else ''
|
|
45
|
+
if ext in ['py', 'js', 'ts', 'jsx', 'tsx']:
|
|
46
|
+
file_path = Path(root) / file
|
|
47
|
+
rel_path = file_path.relative_to(self.repo_path).as_posix()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
51
|
+
lang = "python" if ext == "py" else "javascript"
|
|
52
|
+
parser = ASTParser(lang)
|
|
53
|
+
imports = parser.extract_imports(content)
|
|
54
|
+
|
|
55
|
+
# Add edges (module -> dependency)
|
|
56
|
+
# We don't resolve full absolute paths perfectly, but strings match
|
|
57
|
+
for imp in imports:
|
|
58
|
+
edges.add((rel_path, imp))
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.debug(f"Failed to parse {rel_path} for dependencies: {e}")
|
|
61
|
+
|
|
62
|
+
self.graph.add_edges_from(edges)
|
|
63
|
+
cache_manager.set("dependency_graph", str(self.repo_path), list(edges))
|
|
64
|
+
|
|
65
|
+
def get_neighbors(self, file_path: str) -> List[str]:
|
|
66
|
+
"""Get list of connected files (imports or imported by)."""
|
|
67
|
+
if not self.graph.has_node(file_path):
|
|
68
|
+
# Try to match without extension
|
|
69
|
+
base = file_path.rsplit('.', 1)[0]
|
|
70
|
+
matches = [n for n in self.graph.nodes if n.startswith(base) or (isinstance(n, str) and base in n)]
|
|
71
|
+
if not matches:
|
|
72
|
+
return []
|
|
73
|
+
file_path = matches[0]
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
predecessors = list(self.graph.predecessors(file_path))
|
|
77
|
+
successors = list(self.graph.successors(file_path))
|
|
78
|
+
return list(set(predecessors + successors))
|
|
79
|
+
except nx.NetworkXError:
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
def get_distance(self, file1: str, file2: str) -> float:
|
|
83
|
+
"""Get shortest path distance between two files in the graph.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Distance (1.0 for adjacent, 2.0 for 2 hops, etc.), or infinity if disconnected.
|
|
87
|
+
"""
|
|
88
|
+
# Node matching heuristics
|
|
89
|
+
n1, n2 = file1, file2
|
|
90
|
+
if not self.graph.has_node(n1):
|
|
91
|
+
n1 = self._find_best_node(file1)
|
|
92
|
+
if not self.graph.has_node(n2):
|
|
93
|
+
n2 = self._find_best_node(file2)
|
|
94
|
+
|
|
95
|
+
if not n1 or not n2:
|
|
96
|
+
return float('inf')
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# Undirected shortest path for topological distance
|
|
100
|
+
undirected = self.graph.to_undirected()
|
|
101
|
+
return float(nx.shortest_path_length(undirected, n1, n2))
|
|
102
|
+
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
|
103
|
+
return float('inf')
|
|
104
|
+
|
|
105
|
+
def _find_best_node(self, file_path: str) -> Optional[str]:
|
|
106
|
+
"""Heuristically find a node matching the file path."""
|
|
107
|
+
base = file_path.rsplit('.', 1)[0]
|
|
108
|
+
# Match exact suffix (e.g. from.module import X -> X might be a node)
|
|
109
|
+
for n in self.graph.nodes:
|
|
110
|
+
if not isinstance(n, str):
|
|
111
|
+
continue
|
|
112
|
+
if n == file_path or n == base or n.endswith('.' + base) or n.endswith('/' + base):
|
|
113
|
+
return n
|
|
114
|
+
return None
|