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.
Files changed (53) hide show
  1. smart_commit/__init__.py +6 -0
  2. smart_commit/ai/__init__.py +7 -0
  3. smart_commit/ai/client.py +123 -0
  4. smart_commit/ai/reviewer.py +91 -0
  5. smart_commit/ai.py +136 -0
  6. smart_commit/analysis/__init__.py +7 -0
  7. smart_commit/analysis/ast_parser.py +173 -0
  8. smart_commit/analysis/dependency_graph.py +114 -0
  9. smart_commit/analysis/diff_parser.py +172 -0
  10. smart_commit/analysis/summarizer.py +101 -0
  11. smart_commit/analysis/symbol_extractor.py +88 -0
  12. smart_commit/benchmark.py +88 -0
  13. smart_commit/cache.py +65 -0
  14. smart_commit/cli.py +391 -0
  15. smart_commit/clustering/__init__.py +7 -0
  16. smart_commit/clustering/graph_clustering.py +78 -0
  17. smart_commit/clustering/heuristic.py +178 -0
  18. smart_commit/clustering/semantic.py +201 -0
  19. smart_commit/clustering.py +186 -0
  20. smart_commit/commit_messages.py +232 -0
  21. smart_commit/committer.py +91 -0
  22. smart_commit/config.py +88 -0
  23. smart_commit/constants.py +92 -0
  24. smart_commit/diff_parser.py +158 -0
  25. smart_commit/embeddings/__init__.py +7 -0
  26. smart_commit/embeddings/generator.py +128 -0
  27. smart_commit/embeddings/similarity.py +159 -0
  28. smart_commit/embeddings.py +108 -0
  29. smart_commit/exceptions.py +102 -0
  30. smart_commit/execution/__init__.py +6 -0
  31. smart_commit/execution/committer.py +102 -0
  32. smart_commit/git/__init__.py +8 -0
  33. smart_commit/git/safety.py +75 -0
  34. smart_commit/git/scanner.py +182 -0
  35. smart_commit/git/service.py +129 -0
  36. smart_commit/git_service.py +302 -0
  37. smart_commit/logger.py +99 -0
  38. smart_commit/models.py +73 -0
  39. smart_commit/plugins/__init__.py +6 -0
  40. smart_commit/plugins/base.py +135 -0
  41. smart_commit/plugins/django.py +40 -0
  42. smart_commit/plugins/fastapi.py +39 -0
  43. smart_commit/plugins/react.py +49 -0
  44. smart_commit/preview/__init__.py +6 -0
  45. smart_commit/preview/editor.py +224 -0
  46. smart_commit/preview/renderer.py +94 -0
  47. smart_commit/preview.py +131 -0
  48. smart_commit/summarizer.py +93 -0
  49. smart_commit/utils.py +83 -0
  50. smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
  51. smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
  52. smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
  53. smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,6 @@
1
+ """Smart Commit - AI-powered Git commit automation."""
2
+
3
+ __version__ = "0.2.0"
4
+ __author__ = "Smart Commit Contributors"
5
+ __license__ = "MIT"
6
+
@@ -0,0 +1,7 @@
1
+ """AI package."""
2
+
3
+ from .client import LLMClient
4
+ from .reviewer import ClusterReviewer
5
+
6
+ __all__ = ["LLMClient", "ClusterReviewer"]
7
+
@@ -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,7 @@
1
+ """Analysis package."""
2
+
3
+ from .diff_parser import DiffParser
4
+ from .summarizer import Summarizer
5
+
6
+ __all__ = ["DiffParser", "Summarizer"]
7
+
@@ -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