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,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