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,308 @@
|
|
|
1
|
+
"""DNA Generator — aggregates all analysis results into a final DNA profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from codedna import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DNAGenerator:
|
|
12
|
+
"""Produces the final DNA profile summary from all analysis modules."""
|
|
13
|
+
|
|
14
|
+
def generate(
|
|
15
|
+
self,
|
|
16
|
+
repo_source: str,
|
|
17
|
+
languages: dict,
|
|
18
|
+
structure: dict,
|
|
19
|
+
dependencies: dict,
|
|
20
|
+
architecture: dict,
|
|
21
|
+
smells: dict,
|
|
22
|
+
developers: dict,
|
|
23
|
+
evolution: dict,
|
|
24
|
+
security: dict = None,
|
|
25
|
+
github: dict = None,
|
|
26
|
+
mermaid_graph: str = "",
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""Aggregate all analysis into a DNA profile.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Complete DNA profile dict.
|
|
32
|
+
"""
|
|
33
|
+
# Compute overall health
|
|
34
|
+
health = self._compute_overall_health(smells, dependencies, architecture, security)
|
|
35
|
+
|
|
36
|
+
# Build risk signals
|
|
37
|
+
risks = self._extract_risks(smells, dependencies, security)
|
|
38
|
+
|
|
39
|
+
# Build DNA signature
|
|
40
|
+
signature = self._build_signature(languages, architecture, developers, structure)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"metadata": {
|
|
44
|
+
"source": repo_source,
|
|
45
|
+
"analyzed_at": datetime.now().isoformat(),
|
|
46
|
+
"version": __version__,
|
|
47
|
+
},
|
|
48
|
+
"signature": signature,
|
|
49
|
+
"system_type": architecture.get("primary_pattern", "Unknown"),
|
|
50
|
+
"languages": languages,
|
|
51
|
+
"architecture": architecture,
|
|
52
|
+
"structure_stats": {
|
|
53
|
+
"total_files": structure.get("total_files", 0),
|
|
54
|
+
"total_dirs": structure.get("total_dirs", 0),
|
|
55
|
+
"max_depth": structure.get("max_depth", 0),
|
|
56
|
+
"modules": len(structure.get("modules", [])),
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"total_modules": dependencies.get("total_modules", 0),
|
|
60
|
+
"total_edges": dependencies.get("total_edges", 0),
|
|
61
|
+
"density": dependencies.get("density", 0),
|
|
62
|
+
"has_circular_deps": dependencies.get("has_circular_deps", False),
|
|
63
|
+
"circular_count": len(dependencies.get("cycles", [])),
|
|
64
|
+
},
|
|
65
|
+
"health": health,
|
|
66
|
+
"risks": risks,
|
|
67
|
+
"developer_genome": {
|
|
68
|
+
"total_contributors": developers.get("total_contributors", 0),
|
|
69
|
+
"bus_factor": developers.get("bus_factor", 0),
|
|
70
|
+
"primary_architect": (
|
|
71
|
+
developers["contributors"][0]["name"]
|
|
72
|
+
if developers.get("contributors")
|
|
73
|
+
else "Unknown"
|
|
74
|
+
),
|
|
75
|
+
"top_contributors": [
|
|
76
|
+
{"name": c["name"], "role": c["role"], "commits": c["commits"]}
|
|
77
|
+
for c in developers.get("contributors", [])[:5]
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
"evolution": {
|
|
81
|
+
"total_commits": evolution.get("total_commits", 0),
|
|
82
|
+
"first_commit": evolution.get("first_commit"),
|
|
83
|
+
"last_commit": evolution.get("last_commit"),
|
|
84
|
+
"patterns": evolution.get("patterns", []),
|
|
85
|
+
},
|
|
86
|
+
"complexity": smells.get("complexity", {}) if isinstance(smells, dict) else {},
|
|
87
|
+
"security": security or {"vulnerabilities": [], "total_critical": 0, "has_secrets": False}, # noqa: E501
|
|
88
|
+
"github": github or {},
|
|
89
|
+
"mermaid_graph": mermaid_graph,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def to_markdown(self, profile: dict) -> str:
|
|
93
|
+
"""Convert DNA profile to a markdown report."""
|
|
94
|
+
lines = []
|
|
95
|
+
lines.append("# 🧬 CodeDNA Profile\n")
|
|
96
|
+
lines.append(f"> Analyzed: `{profile['metadata']['source']}`")
|
|
97
|
+
lines.append(f"> Date: {profile['metadata']['analyzed_at'][:10]}")
|
|
98
|
+
|
|
99
|
+
if profile.get("github", {}).get("is_github"):
|
|
100
|
+
gh = profile["github"]
|
|
101
|
+
def _fmt_num(val):
|
|
102
|
+
if isinstance(val, str):
|
|
103
|
+
try:
|
|
104
|
+
val = float(val)
|
|
105
|
+
if val.is_integer():
|
|
106
|
+
val = int(val)
|
|
107
|
+
except ValueError:
|
|
108
|
+
pass
|
|
109
|
+
return f"{val:,}" if isinstance(val, (int, float)) else str(val)
|
|
110
|
+
|
|
111
|
+
stars_str = _fmt_num(gh.get('stars', 0))
|
|
112
|
+
forks_str = _fmt_num(gh.get('forks', 0))
|
|
113
|
+
issues_str = _fmt_num(gh.get('issues', 0))
|
|
114
|
+
|
|
115
|
+
lines.append(f"> ⭐️ {stars_str} Stars | 🔱 {forks_str} Forks | 🐛 {issues_str} Issues\n") # noqa: E501
|
|
116
|
+
else:
|
|
117
|
+
lines.append("\n")
|
|
118
|
+
|
|
119
|
+
lines.append("---\n")
|
|
120
|
+
|
|
121
|
+
# System Type
|
|
122
|
+
lines.append(f"## 🏗️ System Type: **{profile['system_type']}**\n")
|
|
123
|
+
|
|
124
|
+
# Architecture
|
|
125
|
+
arch = profile["architecture"]
|
|
126
|
+
if arch.get("detected_patterns"):
|
|
127
|
+
lines.append("### Architecture Patterns Detected")
|
|
128
|
+
for p in arch["detected_patterns"]:
|
|
129
|
+
bar = "█" * int(p["confidence"] * 10)
|
|
130
|
+
lines.append(f"- **{p['pattern']}** ({int(p['confidence']*100)}%) `{bar}`")
|
|
131
|
+
lines.append("")
|
|
132
|
+
|
|
133
|
+
if arch.get("traits"):
|
|
134
|
+
lines.append("### Infrastructure Traits")
|
|
135
|
+
for t in arch["traits"]:
|
|
136
|
+
lines.append(f"- ✅ {t}")
|
|
137
|
+
lines.append("")
|
|
138
|
+
|
|
139
|
+
# Languages
|
|
140
|
+
lang_data = profile["languages"]
|
|
141
|
+
if lang_data.get("languages"):
|
|
142
|
+
lines.append("## 📊 Language Distribution\n")
|
|
143
|
+
lines.append("| Language | Files | Lines | Share |")
|
|
144
|
+
lines.append("|----------|-------|-------|-------|")
|
|
145
|
+
for lang, data in lang_data["languages"].items():
|
|
146
|
+
bar = "█" * max(1, int(data["percentage"] / 5))
|
|
147
|
+
lines_val = data['lines']
|
|
148
|
+
if isinstance(lines_val, str):
|
|
149
|
+
try:
|
|
150
|
+
lines_val = float(lines_val)
|
|
151
|
+
if lines_val.is_integer():
|
|
152
|
+
lines_val = int(lines_val)
|
|
153
|
+
except ValueError:
|
|
154
|
+
pass
|
|
155
|
+
lines_str = f"{lines_val:,}" if isinstance(lines_val, (int, float)) else str(lines_val)
|
|
156
|
+
lines.append(f"| {lang} | {data['files']} | {lines_str} | {data['percentage']}% `{bar}` |") # noqa: E501
|
|
157
|
+
lines.append("")
|
|
158
|
+
|
|
159
|
+
# Health
|
|
160
|
+
lines.append(f"## 🩺 Health Score: **{profile['health']['overall']}**\n")
|
|
161
|
+
counts = profile["health"]["severity_counts"]
|
|
162
|
+
lines.append(f"- 🔴 Critical: {counts.get('critical', 0)}")
|
|
163
|
+
lines.append(f"- 🟡 Warning: {counts.get('warning', 0)}")
|
|
164
|
+
lines.append(f"- 🔵 Info: {counts.get('info', 0)}")
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
# Complexity & Maintainability
|
|
168
|
+
cmp = profile.get("complexity", {})
|
|
169
|
+
if cmp and cmp.get("total_functions_analyzed", 0) > 0:
|
|
170
|
+
lines.append("## ⚙️ Maintainability & Cognitive Complexity\n")
|
|
171
|
+
lines.append(f"- Maintainability Index: **{cmp.get('maintainability_index', 0.0)}/100 (Rank {cmp.get('maintainability_rank', 'N/A')})**")
|
|
172
|
+
lines.append(f"- Average Cyclomatic Complexity: **{cmp.get('average_cyclomatic_complexity', 1.0)}**")
|
|
173
|
+
lines.append(f"- Peak Cyclomatic Complexity: **{cmp.get('max_cyclomatic_complexity', 1)}**")
|
|
174
|
+
lines.append(f"- Average Cognitive Complexity: **{cmp.get('average_cognitive_complexity', 0.0)}**")
|
|
175
|
+
lines.append(f"- Functions Analyzed: **{cmp.get('total_functions_analyzed', 0)}**\n")
|
|
176
|
+
|
|
177
|
+
# Risks
|
|
178
|
+
if profile["risks"]:
|
|
179
|
+
lines.append("## ⚠️ Risk Signals\n")
|
|
180
|
+
for risk in profile["risks"][:8]:
|
|
181
|
+
lines.append(f"- {risk}")
|
|
182
|
+
lines.append("")
|
|
183
|
+
|
|
184
|
+
# Dependencies
|
|
185
|
+
deps = profile["dependencies"]
|
|
186
|
+
lines.append("## 🔗 Dependency Graph\n")
|
|
187
|
+
lines.append(f"- Modules: **{deps['total_modules']}**")
|
|
188
|
+
lines.append(f"- Connections: **{deps['total_edges']}**")
|
|
189
|
+
lines.append(f"- Density: **{deps['density']}**")
|
|
190
|
+
lines.append(f"- Circular Dependencies: **{'Yes ⚠️' if deps['has_circular_deps'] else 'None ✅'}**\n") # noqa: E501
|
|
191
|
+
|
|
192
|
+
if profile.get("mermaid_graph"):
|
|
193
|
+
lines.append("```mermaid\n" + profile["mermaid_graph"] + "\n```\n")
|
|
194
|
+
|
|
195
|
+
# Developer Genome
|
|
196
|
+
dev = profile["developer_genome"]
|
|
197
|
+
lines.append("## 👥 Developer Genome\n")
|
|
198
|
+
lines.append(f"- Contributors: **{dev['total_contributors']}**")
|
|
199
|
+
lines.append(f"- Bus Factor: **{dev['bus_factor']}**")
|
|
200
|
+
lines.append(f"- Primary Architect: **{dev['primary_architect']}**\n")
|
|
201
|
+
|
|
202
|
+
if dev["top_contributors"]:
|
|
203
|
+
lines.append("| Developer | Role | Commits |")
|
|
204
|
+
lines.append("|-----------|------|---------|")
|
|
205
|
+
for c in dev["top_contributors"]:
|
|
206
|
+
lines.append(f"| {c['name']} | {c['role']} | {c['commits']} |")
|
|
207
|
+
lines.append("")
|
|
208
|
+
|
|
209
|
+
# Evolution
|
|
210
|
+
evo = profile["evolution"]
|
|
211
|
+
lines.append("## 📈 Evolution\n")
|
|
212
|
+
lines.append(f"- Total Commits: **{evo['total_commits']}**")
|
|
213
|
+
if evo.get("first_commit"):
|
|
214
|
+
lines.append(f"- First Commit: {evo['first_commit'][:10]}")
|
|
215
|
+
if evo.get("patterns"):
|
|
216
|
+
lines.append(f"- Patterns: {', '.join(evo['patterns'])}")
|
|
217
|
+
lines.append("")
|
|
218
|
+
|
|
219
|
+
# DNA Signature
|
|
220
|
+
lines.append("## 🧬 DNA Signature\n")
|
|
221
|
+
lines.append(f"```\n{profile['signature']}\n```\n")
|
|
222
|
+
|
|
223
|
+
lines.append("---")
|
|
224
|
+
lines.append("*Generated by [CodeDNA](https://github.com/shenald-dev/codedna)*")
|
|
225
|
+
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
def to_json(self, profile: dict) -> str:
|
|
229
|
+
"""Convert DNA profile to JSON."""
|
|
230
|
+
return json.dumps(profile, indent=2, default=str)
|
|
231
|
+
|
|
232
|
+
def _compute_overall_health(self, smells: dict, deps: dict, arch: dict, security: dict = None) -> dict: # noqa: E501
|
|
233
|
+
"""Compute overall health assessment."""
|
|
234
|
+
severity = smells.get("severity_counts", {})
|
|
235
|
+
health_score = smells.get("health_score", "Unknown")
|
|
236
|
+
|
|
237
|
+
if security and security.get("has_secrets"):
|
|
238
|
+
health_score = "Critical"
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"overall": health_score,
|
|
242
|
+
"severity_counts": severity,
|
|
243
|
+
"coupling": arch.get("coupling", "Unknown"),
|
|
244
|
+
"circular_deps": deps.get("has_circular_deps", False),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def _extract_risks(self, smells: dict, deps: dict, security: dict = None) -> list[str]:
|
|
248
|
+
"""Extract top risk signals from analysis."""
|
|
249
|
+
risks = []
|
|
250
|
+
|
|
251
|
+
if security:
|
|
252
|
+
for vuln in security.get("vulnerabilities", []):
|
|
253
|
+
risks.append(f"🔴 {vuln['type']} in `{vuln['file']}`: {vuln['detail']}")
|
|
254
|
+
|
|
255
|
+
for smell in smells.get("smells", []):
|
|
256
|
+
if smell["severity"] == "critical":
|
|
257
|
+
risks.append(f"🔴 {smell['type']} in `{smell['file']}`: {smell['detail']}")
|
|
258
|
+
|
|
259
|
+
if deps.get("has_circular_deps"):
|
|
260
|
+
cycle_count = len(deps.get("cycles", []))
|
|
261
|
+
risks.append(f"🔴 {cycle_count} circular dependencies detected")
|
|
262
|
+
|
|
263
|
+
for smell in smells.get("smells", []):
|
|
264
|
+
if smell["severity"] == "warning" and len(risks) < 10:
|
|
265
|
+
risks.append(f"🟡 {smell['type']} in `{smell['file']}`")
|
|
266
|
+
|
|
267
|
+
return risks[:10]
|
|
268
|
+
|
|
269
|
+
def _build_signature(self, languages: dict, architecture: dict, developers: dict, structure: dict) -> str: # noqa: E501
|
|
270
|
+
"""Build a DNA-style signature string."""
|
|
271
|
+
parts = []
|
|
272
|
+
|
|
273
|
+
# Language gene
|
|
274
|
+
primary = languages.get("primary", "?")
|
|
275
|
+
parts.append(f"LANG:{primary[:3].upper()}")
|
|
276
|
+
|
|
277
|
+
# Architecture gene
|
|
278
|
+
arch = architecture.get("primary_pattern", "MON")[:3].upper()
|
|
279
|
+
parts.append(f"ARCH:{arch}")
|
|
280
|
+
|
|
281
|
+
# Size gene
|
|
282
|
+
files = structure.get("total_files", 0)
|
|
283
|
+
if files > 1000:
|
|
284
|
+
size = "XL"
|
|
285
|
+
elif files > 100:
|
|
286
|
+
size = "LG"
|
|
287
|
+
elif files > 20:
|
|
288
|
+
size = "MD"
|
|
289
|
+
else:
|
|
290
|
+
size = "SM"
|
|
291
|
+
parts.append(f"SIZE:{size}")
|
|
292
|
+
|
|
293
|
+
# Team gene
|
|
294
|
+
contribs = developers.get("total_contributors", 0)
|
|
295
|
+
if contribs > 20:
|
|
296
|
+
team = "ENTERPRISE"
|
|
297
|
+
elif contribs > 5:
|
|
298
|
+
team = "TEAM"
|
|
299
|
+
elif contribs > 1:
|
|
300
|
+
team = "DUO"
|
|
301
|
+
else:
|
|
302
|
+
team = "SOLO"
|
|
303
|
+
parts.append(f"TEAM:{team}")
|
|
304
|
+
|
|
305
|
+
# Health gene
|
|
306
|
+
parts.append(f"HEALTH:{developers.get('bus_factor', 0)}")
|
|
307
|
+
|
|
308
|
+
return " | ".join(parts)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Evolution Engine — tracks how the codebase architecture changed over time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter, defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from git import Repo
|
|
10
|
+
from git.exc import InvalidGitRepositoryError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EvolutionEngine:
|
|
14
|
+
"""Analyzes codebase evolution across Git commit history."""
|
|
15
|
+
|
|
16
|
+
def analyze(self, repo_path: Path, snapshots: int = 6) -> dict:
|
|
17
|
+
"""Track how the codebase evolved over time.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
repo_path: Path to the repository.
|
|
21
|
+
snapshots: Number of time-points to analyze.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dict with growth timeline, churn hotspots, and evolution patterns.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
repo = Repo(str(repo_path))
|
|
29
|
+
except InvalidGitRepositoryError:
|
|
30
|
+
return {"error": "Not a Git repository", "timeline": []}
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
log_output = repo.git.log(
|
|
34
|
+
"--format=tformat:COMMIT::%H::%cI::%cd::%s",
|
|
35
|
+
"--date=short",
|
|
36
|
+
"--shortstat",
|
|
37
|
+
"-n", "500"
|
|
38
|
+
)
|
|
39
|
+
except Exception:
|
|
40
|
+
log_output = ""
|
|
41
|
+
|
|
42
|
+
commits = []
|
|
43
|
+
current_commit = {}
|
|
44
|
+
for line in log_output.split('\n'):
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if not line:
|
|
47
|
+
continue
|
|
48
|
+
if line.startswith("COMMIT::"):
|
|
49
|
+
if current_commit:
|
|
50
|
+
commits.append(current_commit)
|
|
51
|
+
parts = line.split("::", 4)
|
|
52
|
+
current_commit = {
|
|
53
|
+
"hexsha": parts[1] if len(parts) > 1 else "",
|
|
54
|
+
"iso_date": parts[2] if len(parts) > 2 else "",
|
|
55
|
+
"date": parts[3] if len(parts) > 3 else "",
|
|
56
|
+
"message": parts[4].strip().split("\n")[0][:60] if len(parts) > 4 else "",
|
|
57
|
+
"additions": 0,
|
|
58
|
+
"deletions": 0,
|
|
59
|
+
}
|
|
60
|
+
elif current_commit and ("files changed" in line or "file changed" in line):
|
|
61
|
+
ins_match = re.search(r'(\d+)\s+insertion', line)
|
|
62
|
+
del_match = re.search(r'(\d+)\s+deletion', line)
|
|
63
|
+
if ins_match:
|
|
64
|
+
current_commit["additions"] = int(ins_match.group(1))
|
|
65
|
+
if del_match:
|
|
66
|
+
current_commit["deletions"] = int(del_match.group(1))
|
|
67
|
+
|
|
68
|
+
if current_commit:
|
|
69
|
+
commits.append(current_commit)
|
|
70
|
+
|
|
71
|
+
if not commits:
|
|
72
|
+
return {"timeline": [], "patterns": []}
|
|
73
|
+
|
|
74
|
+
# Build timeline snapshots
|
|
75
|
+
timeline = self._build_timeline(commits, snapshots, repo)
|
|
76
|
+
|
|
77
|
+
# Detect churn (files that change most frequently)
|
|
78
|
+
churn = self._compute_churn(repo)
|
|
79
|
+
|
|
80
|
+
# Detect evolution patterns
|
|
81
|
+
patterns = self._detect_patterns(timeline)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"total_commits": len(commits),
|
|
85
|
+
"first_commit": commits[-1]["iso_date"] if commits else None,
|
|
86
|
+
"last_commit": commits[0]["iso_date"] if commits else None,
|
|
87
|
+
"timeline": timeline,
|
|
88
|
+
"churn_hotspots": churn[:10],
|
|
89
|
+
"patterns": patterns,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def _build_timeline(self, commits: list[dict], snapshots: int, repo: 'Repo') -> list[dict]:
|
|
93
|
+
"""Build time-based snapshots of the project's evolution."""
|
|
94
|
+
if len(commits) < 2:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
step = max(1, len(commits) // snapshots)
|
|
98
|
+
timeline = []
|
|
99
|
+
|
|
100
|
+
for i in range(0, len(commits), step):
|
|
101
|
+
commit = commits[i]
|
|
102
|
+
|
|
103
|
+
# Count files at this point
|
|
104
|
+
try:
|
|
105
|
+
output = repo.git.ls_tree("-r", commit["hexsha"])
|
|
106
|
+
file_count = output.count('\n') + 1 if output else 0
|
|
107
|
+
except Exception:
|
|
108
|
+
file_count = 0
|
|
109
|
+
|
|
110
|
+
timeline.append({
|
|
111
|
+
"date": commit["date"],
|
|
112
|
+
"commit": commit["hexsha"][:8],
|
|
113
|
+
"message": commit["message"],
|
|
114
|
+
"file_count": file_count,
|
|
115
|
+
"additions": commit["additions"],
|
|
116
|
+
"deletions": commit["deletions"],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return timeline[:snapshots]
|
|
120
|
+
|
|
121
|
+
def _compute_churn(self, repo: 'Repo') -> list[dict]:
|
|
122
|
+
"""Find files with the highest change frequency (churn)."""
|
|
123
|
+
file_changes: Counter = Counter()
|
|
124
|
+
file_additions: defaultdict = defaultdict(int)
|
|
125
|
+
file_deletions: defaultdict = defaultdict(int)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
# Note: Explicit tformat: prefix is required by newer Git versions for custom strings
|
|
129
|
+
output = repo.git.log(
|
|
130
|
+
"--numstat",
|
|
131
|
+
"--format=tformat:COMMIT",
|
|
132
|
+
"-n 200",
|
|
133
|
+
"--no-renames"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for line in output.split('\n'):
|
|
137
|
+
line = line.strip()
|
|
138
|
+
if not line or line == "COMMIT":
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
parts = line.split('\t')
|
|
142
|
+
if len(parts) >= 3:
|
|
143
|
+
ins, dels, filepath = parts[0], parts[1], parts[2]
|
|
144
|
+
try:
|
|
145
|
+
ins_int = int(ins) if ins != '-' else 0
|
|
146
|
+
del_int = int(dels) if dels != '-' else 0
|
|
147
|
+
except ValueError:
|
|
148
|
+
ins_int, del_int = 0, 0
|
|
149
|
+
|
|
150
|
+
file_changes[filepath] += 1
|
|
151
|
+
file_additions[filepath] += ins_int
|
|
152
|
+
file_deletions[filepath] += del_int
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
"file": f,
|
|
159
|
+
"changes": c,
|
|
160
|
+
"total_additions": file_additions[f],
|
|
161
|
+
"total_deletions": file_deletions[f],
|
|
162
|
+
}
|
|
163
|
+
for f, c in file_changes.most_common(15)
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
def _detect_patterns(self, timeline: list[dict]) -> list[str]:
|
|
167
|
+
"""Detect evolutionary patterns from the timeline."""
|
|
168
|
+
patterns = []
|
|
169
|
+
|
|
170
|
+
if len(timeline) < 2:
|
|
171
|
+
return ["Insufficient data"]
|
|
172
|
+
|
|
173
|
+
# Check for rapid growth
|
|
174
|
+
file_counts = [t["file_count"] for t in timeline if t["file_count"] > 0]
|
|
175
|
+
if len(file_counts) >= 2 and file_counts[0] > 0:
|
|
176
|
+
growth_ratio = file_counts[-1] / file_counts[0] if file_counts[0] else 1
|
|
177
|
+
if growth_ratio > 3:
|
|
178
|
+
patterns.append("Rapid Growth")
|
|
179
|
+
elif growth_ratio > 1.5:
|
|
180
|
+
patterns.append("Steady Growth")
|
|
181
|
+
elif growth_ratio < 0.8:
|
|
182
|
+
patterns.append("Code Consolidation")
|
|
183
|
+
|
|
184
|
+
# Check for high churn
|
|
185
|
+
total_additions = sum(t["additions"] for t in timeline)
|
|
186
|
+
total_deletions = sum(t["deletions"] for t in timeline)
|
|
187
|
+
if total_deletions > 0 and total_additions / max(total_deletions, 1) < 1.5:
|
|
188
|
+
patterns.append("High Refactoring Activity")
|
|
189
|
+
|
|
190
|
+
if not patterns:
|
|
191
|
+
patterns.append("Stable Evolution")
|
|
192
|
+
|
|
193
|
+
return patterns
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""GitHub Analyzer — fetches community stats for GitHub repositories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from .cache_manager import CacheManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitHubAnalyzer:
|
|
14
|
+
"""Fetches metadata and community stats from GitHub API."""
|
|
15
|
+
|
|
16
|
+
def analyze(self, source: str) -> dict:
|
|
17
|
+
"""Fetch repository stats if the source is a GitHub URL.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
source: The repository source (local path or URL).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dict containing GitHub stats if available, otherwise empty.
|
|
24
|
+
"""
|
|
25
|
+
stats = {
|
|
26
|
+
"is_github": False,
|
|
27
|
+
"stars": 0,
|
|
28
|
+
"forks": 0,
|
|
29
|
+
"issues": 0,
|
|
30
|
+
"description": "",
|
|
31
|
+
"homepage": "",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
parsed = urlparse(source)
|
|
36
|
+
except Exception:
|
|
37
|
+
return stats
|
|
38
|
+
|
|
39
|
+
if parsed.scheme not in ("http", "https") or parsed.hostname != "github.com":
|
|
40
|
+
return stats
|
|
41
|
+
|
|
42
|
+
# Check Cache
|
|
43
|
+
cache = CacheManager()
|
|
44
|
+
cached_stats = cache.get("github_api", source)
|
|
45
|
+
if cached_stats:
|
|
46
|
+
return cached_stats
|
|
47
|
+
|
|
48
|
+
# Extract owner/repo from path (e.g., /owner/repo)
|
|
49
|
+
path_parts = [p for p in parsed.path.split("/") if p]
|
|
50
|
+
if len(path_parts) >= 2:
|
|
51
|
+
owner, repo = path_parts[0], path_parts[1]
|
|
52
|
+
if repo.endswith(".git"):
|
|
53
|
+
repo = repo[:-4]
|
|
54
|
+
|
|
55
|
+
# Validate that owner and repo are safe strings
|
|
56
|
+
def is_valid_identifier(s):
|
|
57
|
+
return s and s not in ("..", ".") and all(c.isalnum() or c in "-_." for c in s)
|
|
58
|
+
|
|
59
|
+
if not is_valid_identifier(owner) or not is_valid_identifier(repo):
|
|
60
|
+
return stats
|
|
61
|
+
|
|
62
|
+
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
req = Request(
|
|
66
|
+
api_url,
|
|
67
|
+
headers={"User-Agent": "CodeDNA-Analyzer"}
|
|
68
|
+
)
|
|
69
|
+
with urlopen(req, timeout=5) as response:
|
|
70
|
+
data = json.loads(response.read().decode())
|
|
71
|
+
|
|
72
|
+
stats.update({
|
|
73
|
+
"is_github": True,
|
|
74
|
+
"stars": data.get("stargazers_count", 0),
|
|
75
|
+
"forks": data.get("forks_count", 0),
|
|
76
|
+
"issues": data.get("open_issues_count", 0),
|
|
77
|
+
"description": data.get("description", "") or "",
|
|
78
|
+
"homepage": data.get("homepage", "") or "",
|
|
79
|
+
})
|
|
80
|
+
# Save to cache
|
|
81
|
+
cache.set("github_api", source, stats)
|
|
82
|
+
except (URLError, HTTPError):
|
|
83
|
+
# Fail gracefully if offline or rate limited
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return stats
|