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.
- codedna/__init__.py +3 -0
- codedna/analyzers/__init__.py +1 -0
- codedna/analyzers/ai_analyzer.py +111 -0
- codedna/analyzers/architecture_detector.py +150 -0
- codedna/analyzers/cache_manager.py +70 -0
- codedna/analyzers/code_smell_detector.py +302 -0
- codedna/analyzers/complexity_analyzer.py +329 -0
- codedna/analyzers/dependency_mapper.py +171 -0
- codedna/analyzers/developer_analyzer.py +148 -0
- codedna/analyzers/diff_engine.py +395 -0
- codedna/analyzers/dna_generator.py +308 -0
- codedna/analyzers/evolution_engine.py +193 -0
- codedna/analyzers/github_analyzer.py +86 -0
- codedna/analyzers/language_detector.py +151 -0
- codedna/analyzers/repo_cloner.py +91 -0
- codedna/analyzers/security_detector.py +271 -0
- codedna/analyzers/structure_analyzer.py +91 -0
- codedna/ci/__init__.py +5 -0
- codedna/ci/gatekeeper.py +421 -0
- codedna/cli.py +839 -0
- codedna/compliance/__init__.py +5 -0
- codedna/compliance/sbom.py +410 -0
- codedna/config.py +167 -0
- codedna/mcp/__init__.py +5 -0
- codedna/mcp/protocol.py +65 -0
- codedna/mcp/server.py +393 -0
- codedna/remediation/__init__.py +5 -0
- codedna/remediation/planner.py +244 -0
- codedna/visualization/__init__.py +1 -0
- codedna/visualization/html_export.py +507 -0
- codedna/visualization/renderer.py +373 -0
- codedna_analyzer-1.4.0.dist-info/METADATA +425 -0
- codedna_analyzer-1.4.0.dist-info/RECORD +37 -0
- codedna_analyzer-1.4.0.dist-info/WHEEL +5 -0
- codedna_analyzer-1.4.0.dist-info/entry_points.txt +2 -0
- codedna_analyzer-1.4.0.dist-info/licenses/LICENSE +21 -0
- 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)
|