codedna-analyzer 1.4.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 (37) hide show
  1. codedna/__init__.py +3 -0
  2. codedna/analyzers/__init__.py +1 -0
  3. codedna/analyzers/ai_analyzer.py +111 -0
  4. codedna/analyzers/architecture_detector.py +150 -0
  5. codedna/analyzers/cache_manager.py +70 -0
  6. codedna/analyzers/code_smell_detector.py +302 -0
  7. codedna/analyzers/complexity_analyzer.py +329 -0
  8. codedna/analyzers/dependency_mapper.py +171 -0
  9. codedna/analyzers/developer_analyzer.py +148 -0
  10. codedna/analyzers/diff_engine.py +395 -0
  11. codedna/analyzers/dna_generator.py +308 -0
  12. codedna/analyzers/evolution_engine.py +193 -0
  13. codedna/analyzers/github_analyzer.py +86 -0
  14. codedna/analyzers/language_detector.py +151 -0
  15. codedna/analyzers/repo_cloner.py +91 -0
  16. codedna/analyzers/security_detector.py +271 -0
  17. codedna/analyzers/structure_analyzer.py +91 -0
  18. codedna/ci/__init__.py +5 -0
  19. codedna/ci/gatekeeper.py +421 -0
  20. codedna/cli.py +839 -0
  21. codedna/compliance/__init__.py +5 -0
  22. codedna/compliance/sbom.py +410 -0
  23. codedna/config.py +167 -0
  24. codedna/mcp/__init__.py +5 -0
  25. codedna/mcp/protocol.py +65 -0
  26. codedna/mcp/server.py +393 -0
  27. codedna/remediation/__init__.py +5 -0
  28. codedna/remediation/planner.py +244 -0
  29. codedna/visualization/__init__.py +1 -0
  30. codedna/visualization/html_export.py +507 -0
  31. codedna/visualization/renderer.py +373 -0
  32. codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
  33. codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
  34. codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
  35. codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
  36. codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
  37. codedna_analyzer-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,329 @@
1
+ """Complexity Analyzer — computes Cyclomatic Complexity, Cognitive Complexity, and Maintainability Index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import math
7
+ import os
8
+ import re
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .language_detector import IGNORE_DIRS
14
+
15
+ if TYPE_CHECKING:
16
+ from codedna.config import CodeDNAConfig
17
+
18
+ MAX_FILE_SIZE = int(os.environ.get("CODEDNA_MAX_FILE_SIZE", 5 * 1024 * 1024))
19
+
20
+ # Heuristic patterns for non-Python languages (anchored to prevent ReDoS)
21
+ FUNC_PATTERNS = {
22
+ ".js": re.compile(
23
+ r"^[ \t]*(?:function\s+([A-Za-z0-9_$]+)|(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>)",
24
+ re.MULTILINE,
25
+ ),
26
+ ".ts": re.compile(
27
+ r"^[ \t]*(?:function\s+([A-Za-z0-9_$]+)|(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|(?:(?:public|private|protected|async|static|readonly)\s+)*([A-Za-z0-9_$]+)\s*\([^)]*\)\s*:\s*[A-Za-z0-9_$<>]+\s*\{)",
28
+ re.MULTILINE,
29
+ ),
30
+ ".go": re.compile(r"^[ \t]*func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_]+)\s*\(", re.MULTILINE),
31
+ ".java": re.compile(
32
+ r"^[ \t]*(?:(?:public|private|protected|static|final|synchronized)\s+)+[\w<>\[\]]+\s+([A-Za-z0-9_]+)\s*\([^)]*\)\s*\{",
33
+ re.MULTILINE,
34
+ ),
35
+ ".rs": re.compile(r"^[ \t]*(?:pub\s+)?fn\s+([A-Za-z0-9_]+)\s*(?:<[^>]+>)?\s*\(", re.MULTILINE),
36
+ }
37
+
38
+ BRANCH_TOKEN_PATTERN = re.compile(r"\b(?:if|else\s+if|for|while|catch|case)\b|&&|\|\||\?")
39
+
40
+
41
+ @dataclass
42
+ class FunctionComplexity:
43
+ """Complexity metrics for an individual function or method."""
44
+
45
+ name: str
46
+ file: str
47
+ line: int
48
+ lines_of_code: int
49
+ cyclomatic_complexity: int
50
+ cognitive_complexity: int
51
+ severity: str # "normal" | "warning" | "critical"
52
+
53
+ def to_dict(self) -> dict:
54
+ return asdict(self)
55
+
56
+
57
+ class PyComplexityVisitor(ast.NodeVisitor):
58
+ """AST visitor that computes Cyclomatic and Cognitive complexity for Python functions."""
59
+
60
+ def __init__(self, filename: str) -> None:
61
+ self.filename = filename
62
+ self.functions: list[FunctionComplexity] = []
63
+
64
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
65
+ self._analyze_function(node)
66
+ self.generic_visit(node)
67
+
68
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
69
+ self._analyze_function(node)
70
+ self.generic_visit(node)
71
+
72
+ def _analyze_function(self, func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
73
+ name = func_node.name
74
+ line = func_node.lineno
75
+ end_line = getattr(func_node, "end_lineno", line + 1)
76
+ loc = max(1, end_line - line + 1)
77
+
78
+ # 1. Cyclomatic Complexity (McCabe)
79
+ # Base complexity is 1
80
+ cc = 1
81
+ for node in ast.walk(func_node):
82
+ if isinstance(node, (ast.If, ast.While, ast.For, ast.AsyncFor)):
83
+ cc += 1
84
+ elif isinstance(node, ast.ExceptHandler):
85
+ cc += 1
86
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
87
+ cc += 1
88
+ elif isinstance(node, ast.Assert):
89
+ cc += 1
90
+ elif isinstance(node, ast.IfExp): # ternary
91
+ cc += 1
92
+ elif isinstance(node, ast.BoolOp):
93
+ cc += len(node.values) - 1
94
+ elif isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
95
+ cc += len(node.generators)
96
+ elif hasattr(ast, "Match") and isinstance(node, ast.Match):
97
+ cc += len(node.cases)
98
+
99
+ # 2. Cognitive Complexity (Sonarsource definition)
100
+ cognitive = self._compute_cognitive_complexity(func_node)
101
+
102
+ # Severity determination
103
+ if cc > 20 or cognitive > 25:
104
+ severity = "critical"
105
+ elif cc > 10 or cognitive > 15:
106
+ severity = "warning"
107
+ else:
108
+ severity = "normal"
109
+
110
+ self.functions.append(
111
+ FunctionComplexity(
112
+ name=name,
113
+ file=self.filename,
114
+ line=line,
115
+ lines_of_code=loc,
116
+ cyclomatic_complexity=cc,
117
+ cognitive_complexity=cognitive,
118
+ severity=severity,
119
+ )
120
+ )
121
+
122
+ def _compute_cognitive_complexity(self, func_node: ast.AST) -> int:
123
+ """Calculate cognitive complexity penalizing nesting depth and control structures."""
124
+ score = 0
125
+ stack: list[tuple[ast.AST, int]] = [(func_node, 0)]
126
+ max_depth = 120
127
+
128
+ while stack:
129
+ current_node, nesting = stack.pop()
130
+ if nesting > max_depth:
131
+ continue
132
+
133
+ for child in ast.iter_child_nodes(current_node):
134
+ if isinstance(child, (ast.If, ast.While, ast.For, ast.AsyncFor)):
135
+ score += 1 + nesting
136
+ stack.append((child, nesting + 1))
137
+ elif isinstance(child, ast.ExceptHandler):
138
+ score += 1 + nesting
139
+ stack.append((child, nesting + 1))
140
+ elif isinstance(child, ast.IfExp):
141
+ score += 1 + nesting
142
+ stack.append((child, nesting + 1))
143
+ elif isinstance(child, ast.BoolOp):
144
+ score += 1
145
+ stack.append((child, nesting))
146
+ else:
147
+ stack.append((child, nesting))
148
+
149
+ return score
150
+
151
+
152
+ class ComplexityAnalyzer:
153
+ """Analyzes source files for Cyclomatic Complexity, Cognitive Complexity, and Maintainability Index."""
154
+
155
+ def analyze_file(self, file_path: Path, relative_path: str = "", content: str | None = None) -> list[FunctionComplexity]:
156
+ """Analyze a single source file and return function complexities."""
157
+ rel = relative_path or str(file_path)
158
+ ext = file_path.suffix.lower()
159
+
160
+ if content is None:
161
+ try:
162
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
163
+ except OSError:
164
+ return []
165
+
166
+ if ext == ".py":
167
+ try:
168
+ tree = ast.parse(content, filename=rel)
169
+ visitor = PyComplexityVisitor(filename=rel)
170
+ visitor.visit(tree)
171
+ return visitor.functions
172
+ except (SyntaxError, ValueError, RecursionError, MemoryError):
173
+ return []
174
+
175
+ # Heuristic analysis for other supported languages
176
+ if ext in FUNC_PATTERNS:
177
+ return self._heuristic_analyze(content, rel, ext)
178
+
179
+ return []
180
+
181
+ def _heuristic_analyze(self, content: str, relative_path: str, ext: str) -> list[FunctionComplexity]:
182
+ """Heuristic complexity scanner for non-Python source files."""
183
+ pattern = FUNC_PATTERNS[ext]
184
+ results: list[FunctionComplexity] = []
185
+
186
+ last_idx = 0
187
+ current_line = 1
188
+
189
+ for match in pattern.finditer(content):
190
+ start_idx = match.start()
191
+ current_line += content.count('\n', last_idx, start_idx)
192
+ last_idx = start_idx
193
+
194
+ # Extract first non-empty capture group
195
+ func_name = next((g for g in match.groups() if g), f"function_L{current_line}")
196
+
197
+ # Approximate function scope: scan until matching closing brace or next 80 lines
198
+ end_idx = start_idx
199
+ line_count = 0
200
+ while line_count < 80 and end_idx != -1:
201
+ end_idx = content.find('\n', end_idx + 1)
202
+ line_count += 1
203
+
204
+ if end_idx == -1:
205
+ end_idx = len(content)
206
+
207
+ block_text = content[start_idx:end_idx]
208
+ branches = len(BRANCH_TOKEN_PATTERN.findall(block_text))
209
+ cc = max(1, branches + 1)
210
+ cognitive = max(0, int(branches * 1.2))
211
+
212
+ severity = "normal"
213
+ if cc > 20 or cognitive > 25:
214
+ severity = "critical"
215
+ elif cc > 10 or cognitive > 15:
216
+ severity = "warning"
217
+
218
+ loc = content.count('\n', start_idx, end_idx) + 1
219
+ if content[start_idx:end_idx].endswith('\n'):
220
+ loc -= 1
221
+
222
+ results.append(
223
+ FunctionComplexity(
224
+ name=func_name,
225
+ file=relative_path,
226
+ line=current_line,
227
+ lines_of_code=max(1, loc),
228
+ cyclomatic_complexity=cc,
229
+ cognitive_complexity=cognitive,
230
+ severity=severity,
231
+ )
232
+ )
233
+
234
+ return results
235
+
236
+ def analyze(self, repo_path: Path, config: CodeDNAConfig | None = None) -> dict:
237
+ """Run repository-wide complexity assessment.
238
+
239
+ Returns:
240
+ Dictionary with aggregated metrics, function listings, and maintainability index.
241
+ """
242
+ all_functions: list[FunctionComplexity] = []
243
+ file_mi_list: list[float] = []
244
+ total_files = 0
245
+ supported_exts = {".py", ".js", ".ts", ".go", ".java", ".rs"}
246
+
247
+ stack = [repo_path]
248
+ while stack:
249
+ current = stack.pop()
250
+ try:
251
+ for item in current.iterdir():
252
+ if item.name in IGNORE_DIRS or item.name.startswith(".git") or item.is_symlink():
253
+ continue
254
+ if item.is_dir():
255
+ stack.append(item)
256
+ elif item.is_file() and item.suffix.lower() in supported_exts:
257
+ try:
258
+ rel = str(item.relative_to(repo_path))
259
+ if config and config.is_path_ignored(rel):
260
+ continue
261
+ if item.stat().st_size <= MAX_FILE_SIZE:
262
+ content = item.read_text(encoding="utf-8", errors="ignore")
263
+ file_loc = max(1, content.count("\n") + 1)
264
+ total_files += 1
265
+
266
+ funcs = self.analyze_file(item, relative_path=rel, content=content)
267
+ all_functions.extend(funcs)
268
+
269
+ # Per-file maintainability index
270
+ file_avg_cc = (sum(f.cyclomatic_complexity for f in funcs) / len(funcs)) if funcs else 1.0
271
+ file_vol = max(1.0, float(file_loc * 5))
272
+ mi_raw = 171.0 - (5.2 * math.log(file_vol)) - (0.23 * file_avg_cc) - (16.2 * math.log(file_loc))
273
+ mi_file_scaled = max(0.0, min(100.0, (mi_raw / 171.0) * 100.0))
274
+ file_mi_list.append(mi_file_scaled)
275
+ except (OSError, ValueError):
276
+ pass
277
+ except PermissionError:
278
+ pass
279
+
280
+ if not all_functions:
281
+ return {
282
+ "total_functions_analyzed": 0,
283
+ "average_cyclomatic_complexity": 1.0,
284
+ "max_cyclomatic_complexity": 1,
285
+ "average_cognitive_complexity": 0.0,
286
+ "max_cognitive_complexity": 0,
287
+ "maintainability_index": 100.0,
288
+ "maintainability_rank": "A",
289
+ "complex_functions": [],
290
+ }
291
+
292
+ cc_values = [f.cyclomatic_complexity for f in all_functions]
293
+ cog_values = [f.cognitive_complexity for f in all_functions]
294
+ avg_cc = round(sum(cc_values) / len(cc_values), 2)
295
+ max_cc = max(cc_values)
296
+ avg_cog = round(sum(cog_values) / len(cog_values), 2)
297
+ max_cog = max(cog_values)
298
+
299
+ # Average Maintainability Index across source files
300
+ mi_scaled = round(sum(file_mi_list) / max(1, len(file_mi_list)), 1) if file_mi_list else 100.0
301
+
302
+ if mi_scaled >= 35.0:
303
+ rank = "A"
304
+ elif mi_scaled >= 25.0:
305
+ rank = "B"
306
+ elif mi_scaled >= 15.0:
307
+ rank = "C"
308
+ elif mi_scaled >= 10.0:
309
+ rank = "D"
310
+ else:
311
+ rank = "F"
312
+
313
+ # Filter complex functions
314
+ complex_list = [
315
+ f.to_dict()
316
+ for f in sorted(all_functions, key=lambda x: (x.cyclomatic_complexity + x.cognitive_complexity), reverse=True)
317
+ if f.severity in ("warning", "critical")
318
+ ]
319
+
320
+ return {
321
+ "total_functions_analyzed": len(all_functions),
322
+ "average_cyclomatic_complexity": avg_cc,
323
+ "max_cyclomatic_complexity": max_cc,
324
+ "average_cognitive_complexity": avg_cog,
325
+ "max_cognitive_complexity": max_cog,
326
+ "maintainability_index": mi_scaled,
327
+ "maintainability_rank": rank,
328
+ "complex_functions": complex_list[:25],
329
+ }
@@ -0,0 +1,171 @@
1
+ """Dependency Mapper — parses import statements and builds a dependency graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ import logging
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from .language_detector import IGNORE_DIRS
12
+
13
+ try:
14
+ MAX_FILE_SIZE = int(os.environ.get("CODEDNA_MAX_FILE_SIZE", 5 * 1024 * 1024))
15
+ except ValueError:
16
+ logging.getLogger(__name__).warning("Invalid CODEDNA_MAX_FILE_SIZE. Using default 5MB.")
17
+ MAX_FILE_SIZE = 5 * 1024 * 1024
18
+
19
+ # Import patterns per language
20
+
21
+ IMPORT_PATTERNS: dict[str, list[re.Pattern]] = {
22
+ "Python": [
23
+ re.compile(r"^[ \t]*import\s+([\w.]+)", re.MULTILINE),
24
+ re.compile(r"^[ \t]*from\s+([\w.]+)\s+import", re.MULTILINE),
25
+ ],
26
+ "JavaScript": [
27
+ re.compile(r"""\bimport\b(?:[^'"]){1,250}?\bfrom\s+['"]([^'"]+)['"]""", re.DOTALL),
28
+ re.compile(r"""\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)""", re.MULTILINE),
29
+ ],
30
+ "TypeScript": [
31
+ re.compile(r"""\bimport\b(?:[^'"]){1,250}?\bfrom\s+['"]([^'"]+)['"]""", re.DOTALL),
32
+ re.compile(r"""\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)""", re.MULTILINE),
33
+ ],
34
+ "Go": [
35
+ re.compile(r'"([^"]+)"', re.MULTILINE),
36
+ ],
37
+ "Java": [
38
+ re.compile(r"^[ \t]*import\s+([\w.]+);", re.MULTILINE),
39
+ ],
40
+ "Rust": [
41
+ re.compile(r"^[ \t]*use\s+([\w:]+)", re.MULTILINE),
42
+ re.compile(r"^[ \t]*extern\s+crate\s+(\w+)", re.MULTILINE),
43
+ ],
44
+ }
45
+
46
+ LANG_EXTENSIONS = {
47
+ ".py": "Python",
48
+ ".js": "JavaScript",
49
+ ".jsx": "JavaScript",
50
+ ".ts": "TypeScript",
51
+ ".tsx": "TypeScript",
52
+ ".go": "Go",
53
+ ".java": "Java",
54
+ ".rs": "Rust",
55
+ }
56
+
57
+
58
+ class DependencyMapper:
59
+ """Parses imports across source files and builds a NetworkX dependency graph."""
60
+
61
+ def map(self, repo_path: Path) -> dict:
62
+ """Build dependency map for the repository.
63
+
64
+ Note: The betweenness centrality metric uses an approximation (k=50)
65
+ to ensure high-performance execution on large dependency graphs at the
66
+ trade-off of exact accuracy for less central nodes.
67
+
68
+ Returns:
69
+ Dict with graph stats, edges, and centrality metrics.
70
+ """
71
+ import networkx as nx
72
+
73
+ graph = nx.DiGraph()
74
+
75
+ for file_path in self._walk_source(repo_path):
76
+ ext = file_path.suffix.lower()
77
+ lang = LANG_EXTENSIONS.get(ext)
78
+ if not lang or lang not in IMPORT_PATTERNS:
79
+ continue
80
+
81
+ try:
82
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
83
+ except OSError:
84
+ continue
85
+
86
+ relative = str(file_path.relative_to(repo_path))
87
+ module_name = self._to_module(relative)
88
+ graph.add_node(module_name)
89
+
90
+ for pattern in IMPORT_PATTERNS[lang]:
91
+ for dep in pattern.findall(content):
92
+ # Skip stdlib / external packages (heuristic: no dots for local)
93
+ if lang == "Python" and "." not in dep:
94
+ continue
95
+ if dep.startswith(".") or "/" in dep:
96
+ dep = self._normalize_import(dep)
97
+ graph.add_edge(module_name, dep)
98
+
99
+ # Compute centrality metrics
100
+ centrality = {}
101
+ if len(graph.nodes) > 0:
102
+ try:
103
+ pr = nx.pagerank(graph)
104
+ bc = nx.betweenness_centrality(graph, k=min(50, len(graph.nodes)), seed=42)
105
+ centrality = {
106
+ node: {
107
+ "pagerank": round(pr.get(node, 0), 4),
108
+ "betweenness": round(bc.get(node, 0), 4),
109
+ }
110
+ for node in sorted(pr, key=pr.get, reverse=True)[:10]
111
+ }
112
+ except Exception:
113
+ pass
114
+
115
+ # Detect circular dependencies
116
+ try:
117
+ cycles = list(itertools.islice(nx.simple_cycles(graph), 10))
118
+ except Exception:
119
+ cycles = []
120
+
121
+ return {
122
+ "total_modules": len(graph.nodes),
123
+ "total_edges": len(graph.edges),
124
+ "density": round(nx.density(graph), 4) if len(graph.nodes) > 1 else 0,
125
+ "cycles": [list(c) for c in cycles[:10]],
126
+ "has_circular_deps": len(cycles) > 0,
127
+ "top_central_modules": centrality,
128
+ "edges": [{"from": u, "to": v} for u, v in itertools.islice(graph.edges, 100)],
129
+ }
130
+
131
+ def build_mermaid(self, dependencies: dict) -> str:
132
+ """Generate a Mermaid diagram of the dependency graph from pre-computed data."""
133
+ lines = ["graph LR"]
134
+
135
+ seen = set()
136
+ for edge in dependencies.get("edges", [])[:30]: # Limit for readability
137
+ src = edge["from"].replace(".", "_").replace("/", "_")
138
+ tgt = edge["to"].replace(".", "_").replace("/", "_")
139
+ key = f"{src}->{tgt}"
140
+ if key not in seen:
141
+ lines.append(f" {src} --> {tgt}")
142
+ seen.add(key)
143
+
144
+ return "\n".join(lines)
145
+
146
+ def _walk_source(self, root: Path):
147
+ stack = [root]
148
+ while stack:
149
+ current = stack.pop()
150
+ try:
151
+ for item in current.iterdir():
152
+ if item.name in IGNORE_DIRS:
153
+ continue
154
+ if item.is_dir():
155
+ stack.append(item)
156
+ elif item.is_file() and item.suffix.lower() in LANG_EXTENSIONS:
157
+ try:
158
+ if item.stat().st_size <= MAX_FILE_SIZE:
159
+ yield item
160
+ except OSError:
161
+ pass
162
+ except PermissionError:
163
+ pass
164
+
165
+ def _to_module(self, path: str) -> str:
166
+ return path.replace("\\", "/").rsplit(".", 1)[0]
167
+
168
+ def _normalize_import(self, dep: str) -> str:
169
+ if dep.startswith("./") or dep.startswith("../"):
170
+ return re.sub(r"^(?:\.\.?/)+", "", dep)
171
+ return dep
@@ -0,0 +1,148 @@
1
+ """Developer Analyzer — analyzes Git commit history for contributor behavior patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections import Counter, defaultdict
7
+ from pathlib import Path
8
+
9
+ import git
10
+
11
+
12
+ class DeveloperAnalyzer:
13
+ """Analyzes developer contribution patterns from Git history."""
14
+
15
+ def analyze(self, repo_path: Path, max_commits: int = 500) -> dict:
16
+ """Analyze developer behavior from Git history.
17
+
18
+ Returns:
19
+ Dict with contributors, hotspots, collaboration data, and commit patterns.
20
+ """
21
+ try:
22
+ repo = git.Repo(str(repo_path))
23
+ except git.exc.InvalidGitRepositoryError:
24
+ return {"error": "Not a Git repository", "contributors": []}
25
+
26
+ contributors: Counter = Counter()
27
+ file_changes: Counter = Counter()
28
+ monthly_commits: defaultdict = defaultdict(int)
29
+ contributor_files: defaultdict = defaultdict(set)
30
+ commit_count = 0
31
+
32
+ try:
33
+ # Note: Explicit tformat: prefix is required by newer Git versions for custom strings
34
+ log_output = repo.git.log(
35
+ "--name-only",
36
+ "--format=tformat:COMMIT::%H::%aN::%aE::%ad",
37
+ "--date=short",
38
+ f"-n {max_commits}"
39
+ )
40
+
41
+ current_author = None
42
+
43
+ for line in log_output.split('\n'):
44
+ line = line.strip()
45
+ if not line:
46
+ continue
47
+
48
+ if line.startswith("COMMIT::"):
49
+ parts = line.split("::")
50
+ if len(parts) >= 5:
51
+ name = parts[2]
52
+ email = parts[3]
53
+ date = parts[4]
54
+ current_author = name or email
55
+ contributors[current_author] += 1
56
+ commit_count += 1
57
+
58
+ if len(date) >= 7:
59
+ month_key = date[:7]
60
+ monthly_commits[month_key] += 1
61
+ elif current_author:
62
+ file_changes[line] += 1
63
+ contributor_files[current_author].add(line)
64
+ except Exception as e:
65
+ logging.getLogger(__name__).warning(f"Git log parsing failed: {e}")
66
+
67
+ # Build contributor profiles
68
+ total_commits = commit_count or 1
69
+ contributor_list = []
70
+ for author, count in contributors.most_common():
71
+ files_touched_count = len(contributor_files.get(author, set()))
72
+ role = self._classify_role(count, total_commits, files_touched_count) # noqa: E501
73
+ contributor_list.append({
74
+ "name": author,
75
+ "commits": count,
76
+ "percentage": round((count / total_commits) * 100, 1),
77
+ "files_touched": files_touched_count,
78
+ "role": role,
79
+ })
80
+
81
+ # Hotspots (most changed files)
82
+ hotspots = [
83
+ {"file": f, "changes": c}
84
+ for f, c in file_changes.most_common(15)
85
+ ]
86
+
87
+ # Collaboration pairs (authors who modify the same files)
88
+ collaboration = self._detect_collaboration(contributor_files)
89
+
90
+ return {
91
+ "total_commits": commit_count,
92
+ "total_contributors": len(contributors),
93
+ "contributors": contributor_list[:20],
94
+ "hotspots": hotspots,
95
+ "collaboration_pairs": collaboration[:10],
96
+ "monthly_activity": dict(sorted(monthly_commits.items())[-12:]),
97
+ "bus_factor": self._compute_bus_factor(contributors, total_commits),
98
+ }
99
+
100
+ def _classify_role(self, commits: int, total: int, files: int) -> str:
101
+ """Classify a contributor's role based on activity."""
102
+ pct = (commits / total) * 100
103
+ if pct > 50:
104
+ return "Primary Architect"
105
+ elif pct > 20:
106
+ return "Core Maintainer"
107
+ elif pct > 5:
108
+ return "Regular Contributor"
109
+ elif commits > 1:
110
+ return "Occasional Contributor"
111
+ return "Drive-by Contributor"
112
+
113
+ def _detect_collaboration(self, contributor_files: dict) -> list[dict]:
114
+ """Find pairs of developers who work on the same files."""
115
+ import itertools
116
+
117
+ # Build mapping from file to a set of authors who modified it
118
+ file_to_authors = defaultdict(set)
119
+ for author, files in contributor_files.items():
120
+ for f in files:
121
+ file_to_authors[f].add(author)
122
+
123
+ pair_counts = defaultdict(int)
124
+ for authors in file_to_authors.values():
125
+ # Bounded complexity to prevent O(M^2) blowup on massive files
126
+ if 1 < len(authors) <= 100:
127
+ for a1, a2 in itertools.combinations(sorted(authors), 2):
128
+ pair_counts[(a1, a2)] += 1
129
+
130
+ pairs = []
131
+ for (a1, a2), count in pair_counts.items():
132
+ if count > 2:
133
+ pairs.append({
134
+ "pair": [a1, a2],
135
+ "shared_files": count,
136
+ })
137
+
138
+ pairs.sort(key=lambda x: x["shared_files"], reverse=True)
139
+ return pairs
140
+
141
+ def _compute_bus_factor(self, contributors: Counter, total: int) -> int:
142
+ """Estimate bus factor — minimum contributors responsible for 50% of commits."""
143
+ cumulative = 0
144
+ for i, (_, count) in enumerate(contributors.most_common(), 1):
145
+ cumulative += count
146
+ if cumulative >= total * 0.5:
147
+ return i
148
+ return len(contributors)