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,201 @@
1
+ import numpy as np
2
+ from typing import List, Optional
3
+ from pathlib import Path
4
+
5
+ from ..models import FileDiff, CommitCluster
6
+ from ..embeddings import EmbeddingsGenerator, SimilarityGraph
7
+ from ..analysis.dependency_graph import DependencyGraph
8
+ from ..logger import get_logger
9
+ from ..constants import DEFAULT_SIMILARITY_THRESHOLD, MAX_CLUSTER_SIZE
10
+ from .heuristic import HeuristicClustering
11
+ from .graph_clustering import GraphClustering
12
+
13
+ logger = get_logger("clustering.semantic")
14
+
15
+
16
+ class SemanticClustering:
17
+ """Clusters files using semantic similarity and graph clustering."""
18
+
19
+ def __init__(self, threshold: float = DEFAULT_SIMILARITY_THRESHOLD):
20
+ """Initialize semantic clustering."""
21
+ self.threshold = threshold
22
+ self.embeddings_gen = None
23
+ self.similarity_graph = None
24
+ self.graph_clustering = GraphClustering()
25
+ self.dep_graph = None
26
+ try:
27
+ self.embeddings_gen = EmbeddingsGenerator()
28
+ except Exception as exc:
29
+ logger.warning("Embeddings unavailable: %s", exc)
30
+
31
+ def cluster_files(self, file_diffs: list[FileDiff], repo_path: Optional[Path] = None) -> list[CommitCluster]:
32
+ """Cluster files using semantic and graph-based clustering."""
33
+ if len(file_diffs) <= 1:
34
+ return [
35
+ CommitCluster(
36
+ id="cluster_0",
37
+ files=file_diffs,
38
+ confidence=0.99,
39
+ reason="Single file commit.",
40
+ )
41
+ ]
42
+
43
+ # Init dep graph if possible
44
+ if repo_path and not self.dep_graph:
45
+ self.dep_graph = DependencyGraph(repo_path)
46
+
47
+ if not self.similarity_graph and self.embeddings_gen:
48
+ self.similarity_graph = SimilarityGraph(self.embeddings_gen, self.dep_graph)
49
+
50
+ if not self.embeddings_gen or not self.similarity_graph:
51
+ return HeuristicClustering().cluster_files(file_diffs, MAX_CLUSTER_SIZE)
52
+
53
+ # Build similarity matrix
54
+ logger.info("Building similarity graph...")
55
+ similarity_matrix = self.similarity_graph.build_graph(file_diffs)
56
+
57
+ # Generate embeddings
58
+ logger.info("Generating embeddings...")
59
+ embeddings = []
60
+ for file_diff in file_diffs:
61
+ embedding = self.embeddings_gen.generate_file_embedding(file_diff)
62
+ embeddings.append(embedding)
63
+
64
+ # Perform clustering
65
+ logger.info("Clustering files...")
66
+
67
+ # Select strategy: graph for large, agglomerative for small
68
+ if self.graph_clustering.is_available() and len(file_diffs) > 15:
69
+ logger.info("Using Graph Clustering (Louvain)")
70
+ cluster_labels = self.graph_clustering.cluster_files(similarity_matrix, file_diffs)
71
+ else:
72
+ try:
73
+ cluster_labels = self._agglomerative_clustering(similarity_matrix, file_diffs)
74
+ except Exception as e:
75
+ logger.warning(f"Agglomerative clustering failed: {e}, falling back")
76
+ cluster_labels = list(range(len(file_diffs)))
77
+
78
+ # Group by cluster
79
+ clusters_dict = {}
80
+ for idx, label in enumerate(cluster_labels):
81
+ if label not in clusters_dict:
82
+ clusters_dict[label] = []
83
+ clusters_dict[label].append(file_diffs[idx])
84
+
85
+ # Create clusters with embeddings
86
+ clusters = []
87
+ for cluster_idx, (label, files) in enumerate(sorted(clusters_dict.items())):
88
+ valid_embeddings = [e for e in [embeddings[file_diffs.index(f)] for f in files] if e]
89
+ cluster_embedding = np.mean(valid_embeddings, axis=0).tolist() if valid_embeddings else None
90
+
91
+ reason = self._generate_reason(files, similarity_matrix, file_diffs)
92
+ cluster = CommitCluster(
93
+ id=f"cluster_{cluster_idx}",
94
+ files=files,
95
+ embedding=cluster_embedding,
96
+ confidence=self._cluster_confidence(files, similarity_matrix, file_diffs),
97
+ reason=reason,
98
+ )
99
+ clusters.append(cluster)
100
+
101
+ logger.info(f"Created {len(clusters)} clusters from {len(file_diffs)} files")
102
+ return clusters
103
+
104
+ def _agglomerative_clustering(self, similarity_matrix: np.ndarray,
105
+ file_diffs: list[FileDiff]) -> list[int]:
106
+ """Perform agglomerative clustering."""
107
+ try:
108
+ from sklearn.cluster import AgglomerativeClustering
109
+ distance_matrix = 1.0 - similarity_matrix
110
+ n_clusters = max(1, len(file_diffs) // 3)
111
+
112
+ clustering = AgglomerativeClustering(
113
+ n_clusters=n_clusters,
114
+ linkage='average',
115
+ metric='precomputed'
116
+ )
117
+
118
+ labels = clustering.fit_predict(distance_matrix)
119
+ return labels.tolist()
120
+
121
+ except ImportError:
122
+ logger.warning("scikit-learn not available, using greedy clustering")
123
+ return self._greedy_clustering(similarity_matrix)
124
+
125
+ @staticmethod
126
+ def _greedy_clustering(similarity_matrix: np.ndarray) -> list[int]:
127
+ """Greedy clustering fallback."""
128
+ n = len(similarity_matrix)
129
+ labels = list(range(n))
130
+
131
+ for i in range(n):
132
+ for j in range(i + 1, n):
133
+ if similarity_matrix[i][j] > 0.6:
134
+ if labels[j] != labels[i]:
135
+ old_label = labels[j]
136
+ new_label = labels[i]
137
+ labels = [new_label if x == old_label else x for x in labels]
138
+
139
+ unique_labels = list(set(labels))
140
+ label_map = {old: new for new, old in enumerate(unique_labels)}
141
+ return [label_map[x] for x in labels]
142
+
143
+ @staticmethod
144
+ def _cluster_confidence(
145
+ files: list[FileDiff],
146
+ similarity_matrix: np.ndarray,
147
+ all_files: list[FileDiff],
148
+ ) -> float:
149
+ if len(files) <= 1:
150
+ return 0.99
151
+
152
+ indexes = [all_files.index(file_diff) for file_diff in files]
153
+ scores = []
154
+ for left_index, left in enumerate(indexes):
155
+ for right in indexes[left_index + 1 :]:
156
+ scores.append(float(similarity_matrix[left][right]))
157
+
158
+ if not scores:
159
+ return 0.74
160
+ return max(0.55, min(0.98, sum(scores) / len(scores)))
161
+
162
+ @staticmethod
163
+ def _generate_reason(
164
+ files: list[FileDiff],
165
+ similarity_matrix: np.ndarray,
166
+ all_files: list[FileDiff],
167
+ ) -> str:
168
+ """Generate human-readable subsystem-level reason for grouping."""
169
+ if len(files) <= 1:
170
+ if files and files[0].is_renamed:
171
+ return "File rename operation."
172
+ return "Single file commit."
173
+
174
+ dirs = set(f.file_path.rsplit('/', 1)[0] if '/' in f.file_path else 'root' for f in files)
175
+ primary_dir = list(dirs)[0] if dirs else "core"
176
+
177
+ # Subsystem-level explanation
178
+ subsystem = f"'{primary_dir}' subsystem" if len(dirs) == 1 else "multiple subsystems"
179
+
180
+ # Aggregate significant symbols
181
+ all_added = []
182
+ all_modified = []
183
+ for f in files:
184
+ all_added.extend(f.extracted_symbols.added + f.extracted_symbols.classes)
185
+ all_modified.extend(f.extracted_symbols.modified + f.extracted_symbols.functions)
186
+
187
+ all_added = list(set(all_added))
188
+ all_modified = list(set(all_modified))
189
+
190
+ actions = []
191
+ if all_added:
192
+ actions.append(f"introducing {', '.join(all_added[:2])}")
193
+ if all_modified:
194
+ actions.append(f"updating {', '.join(all_modified[:2])}")
195
+
196
+ if actions:
197
+ reason = f"These files modify the {subsystem} by {' and '.join(actions)}."
198
+ else:
199
+ reason = f"These files heavily relate to the {subsystem} based on shared dependencies and structural similarity."
200
+
201
+ return reason
@@ -0,0 +1,186 @@
1
+ """Semantic clustering of file changes."""
2
+
3
+ from typing import List, Optional
4
+ import numpy as np
5
+ from .models import CommitCluster, FileDiff
6
+ from .embeddings import EmbeddingsGenerator
7
+
8
+
9
+ class Clustering:
10
+ """Groups related file changes using embeddings and ML."""
11
+
12
+ def __init__(self, method: str = "agglomerative", threshold: float = 0.5):
13
+ """Initialize clustering.
14
+
15
+ Args:
16
+ method: Clustering algorithm ('agglomerative' or 'hdbscan').
17
+ threshold: Similarity threshold for grouping.
18
+ """
19
+ self.method = method
20
+ self.threshold = threshold
21
+ self.embeddings_gen = EmbeddingsGenerator(use_local=True)
22
+
23
+ def cluster_files(self, file_diffs: List[FileDiff]) -> List[CommitCluster]:
24
+ """Cluster files into logical groups.
25
+
26
+ Args:
27
+ file_diffs: List of FileDiff objects.
28
+
29
+ Returns:
30
+ List of CommitCluster objects.
31
+ """
32
+ if not file_diffs:
33
+ return []
34
+
35
+ # Generate embeddings for all files
36
+ embeddings = []
37
+ for file_diff in file_diffs:
38
+ embedding = self.embeddings_gen.generate_file_embedding(file_diff)
39
+ embeddings.append(embedding)
40
+
41
+ # Perform clustering
42
+ if self.method == "agglomerative":
43
+ cluster_labels = self._agglomerative_clustering(embeddings)
44
+ else:
45
+ cluster_labels = self._rule_based_clustering(file_diffs)
46
+
47
+ # Group files by cluster label
48
+ clusters_dict = {}
49
+ for idx, label in enumerate(cluster_labels):
50
+ if label not in clusters_dict:
51
+ clusters_dict[label] = []
52
+ clusters_dict[label].append(file_diffs[idx])
53
+
54
+ # Create CommitCluster objects
55
+ clusters = []
56
+ for idx, (label, files) in enumerate(sorted(clusters_dict.items())):
57
+ cluster = CommitCluster(
58
+ id=f"cluster_{idx}",
59
+ files=files,
60
+ embedding=embeddings[files[0] == file_diffs[0] and next(i for i, f in enumerate(file_diffs) if f == files[0])]
61
+ if files else None
62
+ )
63
+ clusters.append(cluster)
64
+
65
+ return clusters
66
+
67
+ def _agglomerative_clustering(self, embeddings: List[Optional[List[float]]]) -> List[int]:
68
+ """Perform agglomerative clustering.
69
+
70
+ Args:
71
+ embeddings: List of embedding vectors.
72
+
73
+ Returns:
74
+ Cluster labels for each embedding.
75
+ """
76
+ try:
77
+ from sklearn.cluster import AgglomerativeClustering
78
+
79
+ # Filter out None embeddings
80
+ valid_embeddings = []
81
+ valid_indices = []
82
+
83
+ for idx, emb in enumerate(embeddings):
84
+ if emb is not None:
85
+ valid_embeddings.append(emb)
86
+ valid_indices.append(idx)
87
+
88
+ if len(valid_embeddings) < 2:
89
+ return list(range(len(embeddings)))
90
+
91
+ # Compute distance threshold
92
+ n_clusters = max(1, len(valid_embeddings) // 3)
93
+
94
+ clustering = AgglomerativeClustering(
95
+ n_clusters=n_clusters,
96
+ linkage='average'
97
+ )
98
+
99
+ labels = clustering.fit_predict(valid_embeddings)
100
+
101
+ # Map back to original indices
102
+ result = [0] * len(embeddings)
103
+ for i, idx in enumerate(valid_indices):
104
+ result[idx] = labels[i]
105
+
106
+ return result
107
+
108
+ except ImportError:
109
+ return self._rule_based_clustering_simple(embeddings)
110
+
111
+ def _rule_based_clustering(self, file_diffs: List[FileDiff]) -> List[int]:
112
+ """Perform rule-based clustering by directory and extension.
113
+
114
+ Args:
115
+ file_diffs: List of FileDiff objects.
116
+
117
+ Returns:
118
+ Cluster labels for each file.
119
+ """
120
+ labels = []
121
+
122
+ for file_diff in file_diffs:
123
+ # Extract directory and extension
124
+ path_parts = file_diff.file_path.split('/')
125
+ directory = path_parts[0] if path_parts else "root"
126
+
127
+ # Convert to a simple hash label
128
+ label = hash((directory, file_diff.file_path.split('.')[-1])) % 100
129
+ labels.append(label)
130
+
131
+ return labels
132
+
133
+ def _rule_based_clustering_simple(self, embeddings: List[Optional[List[float]]]) -> List[int]:
134
+ """Simple rule-based clustering as fallback.
135
+
136
+ Args:
137
+ embeddings: List of embedding vectors (unused, kept for compatibility).
138
+
139
+ Returns:
140
+ Cluster labels.
141
+ """
142
+ # Each file gets its own cluster if no embeddings
143
+ return list(range(len(embeddings)))
144
+
145
+ def merge_similar_clusters(self, clusters: List[CommitCluster]) -> List[CommitCluster]:
146
+ """Merge clusters that are very similar.
147
+
148
+ Args:
149
+ clusters: List of CommitCluster objects.
150
+
151
+ Returns:
152
+ Merged list of clusters.
153
+ """
154
+ if len(clusters) < 2:
155
+ return clusters
156
+
157
+ merged = []
158
+ used = set()
159
+
160
+ for i, cluster1 in enumerate(clusters):
161
+ if i in used:
162
+ continue
163
+
164
+ merged_files = cluster1.files.copy()
165
+
166
+ for j, cluster2 in enumerate(clusters[i+1:], i+1):
167
+ if j in used:
168
+ continue
169
+
170
+ similarity = self.embeddings_gen.compute_cluster_similarity(cluster1, cluster2)
171
+
172
+ if similarity > self.threshold:
173
+ merged_files.extend(cluster2.files)
174
+ used.add(j)
175
+
176
+ # Create new merged cluster
177
+ merged_cluster = CommitCluster(
178
+ id=cluster1.id,
179
+ files=merged_files,
180
+ embedding=cluster1.embedding
181
+ )
182
+ merged.append(merged_cluster)
183
+ used.add(i)
184
+
185
+ return merged
186
+
@@ -0,0 +1,232 @@
1
+ """Commit message generation."""
2
+
3
+ from typing import Optional
4
+ from .models import CommitCluster, CommitType
5
+ from .config import Config
6
+
7
+
8
+ class CommitMessageGenerator:
9
+ """Generates Conventional Commit messages."""
10
+
11
+ def __init__(self, config: Optional[Config] = None):
12
+ """Initialize commit message generator.
13
+
14
+ Args:
15
+ config: Configuration object.
16
+ """
17
+ self.config = config or Config.load()
18
+
19
+ def generate_message(self, cluster: CommitCluster) -> str:
20
+ """Generate a full commit message for a cluster.
21
+
22
+ Args:
23
+ cluster: CommitCluster to generate message for.
24
+
25
+ Returns:
26
+ Commit message string.
27
+ """
28
+ title = self.generate_title(cluster)
29
+ description = self.generate_description(cluster)
30
+
31
+ if description:
32
+ return f"{title}\n\n{description}"
33
+
34
+ return title
35
+
36
+ def generate_title(self, cluster: CommitCluster) -> str:
37
+ """Generate the commit title (first line).
38
+
39
+ Args:
40
+ cluster: CommitCluster to analyze.
41
+
42
+ Returns:
43
+ Commit title.
44
+ """
45
+ commit_type = cluster.commit_type or self._infer_commit_type(cluster)
46
+ scope = cluster.scope or self._infer_scope(cluster)
47
+ message = cluster.message or self._generate_message_text(cluster)
48
+ message = self._normalize_subject(message)
49
+
50
+ # Format as conventional commit
51
+ title = f"{commit_type.value}({scope}): {message}" if scope else f"{commit_type.value}: {message}"
52
+ return self._truncate_title(title)
53
+
54
+ def _infer_commit_type(self, cluster: CommitCluster) -> CommitType:
55
+ paths = [file_diff.file_path.lower() for file_diff in cluster.files]
56
+ if paths and all(self._get_file_type(path) == "docs" for path in paths):
57
+ return CommitType.DOCS
58
+ if any("test" in path or "spec" in path for path in paths):
59
+ return CommitType.TEST
60
+ if any(
61
+ path.endswith((".toml", ".yaml", ".yml", ".json", ".ini", ".cfg", ".env"))
62
+ or path in {".gitignore", ".env.example"}
63
+ for path in paths
64
+ ):
65
+ return CommitType.CHORE
66
+ return self._infer_commit_type_from_summary(cluster.summary)
67
+
68
+ @staticmethod
69
+ def _infer_commit_type_from_summary(summary: Optional[str]) -> CommitType:
70
+ """Infer commit type from summary text.
71
+
72
+ Args:
73
+ summary: Summary text.
74
+
75
+ Returns:
76
+ Inferred CommitType.
77
+ """
78
+ if not summary:
79
+ return CommitType.CHORE
80
+
81
+ summary_lower = summary.lower()
82
+
83
+ if any(x in summary_lower for x in ['fix', 'bug', 'issue', 'broken']):
84
+ return CommitType.FIX
85
+ elif any(x in summary_lower for x in ['feat', 'feature', 'add', 'implement', 'new']):
86
+ return CommitType.FEAT
87
+ elif any(x in summary_lower for x in ['refactor', 'clean', 'improve', 'simplify']):
88
+ return CommitType.REFACTOR
89
+ elif any(x in summary_lower for x in ['test', 'spec', 'unit']):
90
+ return CommitType.TEST
91
+ elif any(x in summary_lower for x in ['doc', 'readme', 'comment', 'document']):
92
+ return CommitType.DOCS
93
+ elif any(x in summary_lower for x in ['style', 'format', 'lint']):
94
+ return CommitType.STYLE
95
+ elif any(x in summary_lower for x in ['perf', 'performance', 'optimize', 'speed']):
96
+ return CommitType.PERF
97
+
98
+ return CommitType.CHORE
99
+
100
+ def generate_description(self, cluster: CommitCluster) -> str:
101
+ """Generate commit description (body).
102
+
103
+ Args:
104
+ cluster: CommitCluster to analyze.
105
+
106
+ Returns:
107
+ Commit description.
108
+ """
109
+ if not cluster.files:
110
+ return ""
111
+
112
+ files_by_type = {}
113
+ for file_diff in cluster.files:
114
+ file_type = self._get_file_type(file_diff.file_path)
115
+ if file_type not in files_by_type:
116
+ files_by_type[file_type] = []
117
+ files_by_type[file_type].append(file_diff.file_path)
118
+
119
+ description_lines = ["Changed files:"]
120
+
121
+ for file_type, files in sorted(files_by_type.items()):
122
+ description_lines.append(f"\n{file_type}:")
123
+ for file_path in files[:5]: # Limit to 5 per type
124
+ description_lines.append(f" - {file_path}")
125
+
126
+ if len(files) > 5:
127
+ description_lines.append(f" - and {len(files) - 5} more...")
128
+
129
+ return "\n".join(description_lines)
130
+
131
+ def _infer_scope(self, cluster: CommitCluster) -> Optional[str]:
132
+ """Infer scope from files in cluster.
133
+
134
+ Args:
135
+ cluster: CommitCluster to analyze.
136
+
137
+ Returns:
138
+ Scope string or None.
139
+ """
140
+ if not cluster.files:
141
+ return None
142
+
143
+ # Extract common directory or feature name
144
+ file_path = cluster.files[0].file_path
145
+ parts = file_path.split('/')
146
+
147
+ ignored = {"src", "lib", "app", "apps", "packages", "tests", "test", "smart_commit"}
148
+ for part in parts[:-1]:
149
+ clean = part.replace("_", "-").lower()
150
+ if clean and clean not in ignored:
151
+ return clean
152
+
153
+ # Try to infer from file names
154
+ if any('auth' in f.file_path.lower() for f in cluster.files):
155
+ return "auth"
156
+ elif any('api' in f.file_path.lower() for f in cluster.files):
157
+ return "api"
158
+ elif any('ui' in f.file_path.lower() or 'component' in f.file_path.lower() for f in cluster.files):
159
+ return "ui"
160
+
161
+ return None
162
+
163
+ def _generate_message_text(self, cluster: CommitCluster) -> str:
164
+ """Generate the message text from summary.
165
+
166
+ Args:
167
+ cluster: CommitCluster to analyze.
168
+
169
+ Returns:
170
+ Message text.
171
+ """
172
+ if cluster.summary:
173
+ summary = cluster.summary.strip().split(";")[0]
174
+ normalized = self._normalize_subject(summary)
175
+ if normalized in {"add content", "update content", "remove content"}:
176
+ return self._fallback_subject(cluster, normalized.split()[0])
177
+ return normalized
178
+
179
+ return self._fallback_subject(cluster, "update")
180
+
181
+ def _fallback_subject(self, cluster: CommitCluster, verb: str) -> str:
182
+ if len(cluster.files) == 1:
183
+ return f"{verb} {cluster.files[0].file_path.split('/')[-1]}"
184
+
185
+ scope = cluster.scope or self._infer_scope(cluster)
186
+ if scope:
187
+ return f"{verb} {scope} changes"
188
+ return f"{verb} related files"
189
+
190
+ @staticmethod
191
+ def _normalize_subject(text: str) -> str:
192
+ text = text.strip().rstrip(".")
193
+ if not text:
194
+ return "update files"
195
+ text = text[0].lower() + text[1:]
196
+ replacements = {
197
+ "added new content": "add content",
198
+ "modified content": "update content",
199
+ "removed content": "remove content",
200
+ }
201
+ return replacements.get(text, text)
202
+
203
+ @staticmethod
204
+ def _truncate_title(title: str, limit: int = 72) -> str:
205
+ if len(title) <= limit:
206
+ return title
207
+ truncated = title[:limit].rsplit(" ", 1)[0]
208
+ return truncated if len(truncated) >= 30 else title[:limit].rstrip()
209
+
210
+ @staticmethod
211
+ def _get_file_type(file_path: str) -> str:
212
+ """Get file type category.
213
+
214
+ Args:
215
+ file_path: Path to file.
216
+
217
+ Returns:
218
+ File type category.
219
+ """
220
+ file_lower = file_path.lower()
221
+
222
+ if any(x in file_lower for x in ['.jsx', '.tsx', '.vue', '.css']):
223
+ return "frontend"
224
+ elif any(x in file_lower for x in ['.py', '.java', '.go', '.rs', '.rb']):
225
+ return "backend"
226
+ elif any(x in file_lower for x in ['.test.', '.spec.']):
227
+ return "tests"
228
+ elif any(x in file_lower for x in ['.md', '.txt']):
229
+ return "docs"
230
+
231
+ return "other"
232
+