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
codedna/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CodeDNA analyzers — core analysis modules."""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""AI Analyzer — synthesizes a natural language Executive Summary from the raw DNA profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AIResult:
|
|
14
|
+
executive_summary: str
|
|
15
|
+
refactoring_recommendations: list[str]
|
|
16
|
+
success: bool
|
|
17
|
+
error_message: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AIAnalyzer:
|
|
21
|
+
"""Uses an LLM to generate insights from the CodeDNA profile."""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.api_key = os.environ.get("OPENAI_API_KEY")
|
|
25
|
+
|
|
26
|
+
def synthesize(self, raw_dna_profile: dict) -> AIResult:
|
|
27
|
+
"""Sends the raw DNA profile to the LLM and parses the response.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
raw_dna_profile: The JSON-serializable dictionary of all generated metrics.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
An AIResult containing the summary and recommendations.
|
|
34
|
+
"""
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
return AIResult(
|
|
37
|
+
executive_summary="AI Analysis skipped. Set the `OPENAI_API_KEY` environment variable to enable intelligent synthesis.", # noqa: E501
|
|
38
|
+
refactoring_recommendations=[],
|
|
39
|
+
success=False,
|
|
40
|
+
error_message="Missing OPENAI_API_KEY"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from openai import OpenAI
|
|
45
|
+
client = OpenAI(api_key=self.api_key)
|
|
46
|
+
except ImportError:
|
|
47
|
+
return AIResult(
|
|
48
|
+
executive_summary="AI Analysis skipped. The `openai` python package is not installed.", # noqa: E501
|
|
49
|
+
refactoring_recommendations=[],
|
|
50
|
+
success=False,
|
|
51
|
+
error_message="Missing openai package"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Truncate profile elements if they are too large to fit in context
|
|
55
|
+
safe_profile = self._minimize_payload(raw_dna_profile)
|
|
56
|
+
|
|
57
|
+
system_prompt = (
|
|
58
|
+
"You are CodeDNA, an expert software architect and senior code reviewer. "
|
|
59
|
+
"You are provided with a 'DNA Profile' of a codebase containing metrics about languages, " # noqa: E501
|
|
60
|
+
"architecture, dependencies, developers, and code smells. "
|
|
61
|
+
"Your task is to synthesize this raw data into a brilliant, concise 'Executive Summary' (2-3 paragraphs) " # noqa: E501
|
|
62
|
+
"and extract EXACTLY 3 major 'Refactoring Recommendations' based on the worst code smells or architectural flaws detected.\n\n" # noqa: E501
|
|
63
|
+
"Format the output strictly as a JSON object with two keys:\n"
|
|
64
|
+
'{"executive_summary": "...", "refactoring_recommendations": ["...", "...", "..."]}'
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
response = client.chat.completions.create(
|
|
69
|
+
model="gpt-4o-mini",
|
|
70
|
+
messages=[
|
|
71
|
+
{"role": "system", "content": system_prompt},
|
|
72
|
+
{"role": "user", "content": json.dumps(safe_profile, default=str)}
|
|
73
|
+
],
|
|
74
|
+
response_format={"type": "json_object"},
|
|
75
|
+
timeout=30.0
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
raw_response = response.choices[0].message.content
|
|
79
|
+
parsed = json.loads(raw_response)
|
|
80
|
+
|
|
81
|
+
return AIResult(
|
|
82
|
+
executive_summary=parsed.get("executive_summary", "Synthesis failed structurally."),
|
|
83
|
+
refactoring_recommendations=parsed.get("refactoring_recommendations", []),
|
|
84
|
+
success=True
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
return AIResult(
|
|
89
|
+
executive_summary="AI Analysis failed due to an API error.",
|
|
90
|
+
refactoring_recommendations=[],
|
|
91
|
+
success=False,
|
|
92
|
+
error_message=str(e)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _minimize_payload(self, profile: dict) -> dict:
|
|
96
|
+
"""Removes large arrays or deeply nested structures before sending to LLM."""
|
|
97
|
+
clone = copy.deepcopy(profile)
|
|
98
|
+
# Drop raw file lists if they exist
|
|
99
|
+
if "structure_stats" in clone and "modules" in clone["structure_stats"]:
|
|
100
|
+
if isinstance(clone["structure_stats"]["modules"], list):
|
|
101
|
+
clone["structure_stats"]["modules"] = len(clone["structure_stats"]["modules"])
|
|
102
|
+
|
|
103
|
+
# Keep only top 5 risks to avoid blowing up context token limits
|
|
104
|
+
if "risks" in clone:
|
|
105
|
+
clone["risks"] = clone["risks"][:5]
|
|
106
|
+
|
|
107
|
+
# Strip out massive raw graphs
|
|
108
|
+
if "mermaid_graph" in clone:
|
|
109
|
+
del clone["mermaid_graph"]
|
|
110
|
+
|
|
111
|
+
return clone
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Architecture Detector — identifies architecture patterns via rule-based heuristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .language_detector import IGNORE_DIRS
|
|
9
|
+
|
|
10
|
+
# Architecture pattern definitions
|
|
11
|
+
PATTERNS = {
|
|
12
|
+
"MVC": {
|
|
13
|
+
"indicators": ["models", "views", "controllers", "templates"],
|
|
14
|
+
"min_matches": 3,
|
|
15
|
+
},
|
|
16
|
+
"Layered": {
|
|
17
|
+
"indicators": ["api", "services", "repositories", "models", "controllers", "routes"],
|
|
18
|
+
"min_matches": 3,
|
|
19
|
+
},
|
|
20
|
+
"Microservices": {
|
|
21
|
+
"indicators": ["docker-compose", "gateway", "services", "api-gateway"],
|
|
22
|
+
"min_matches": 2,
|
|
23
|
+
},
|
|
24
|
+
"Plugin Architecture": {
|
|
25
|
+
"indicators": ["plugins", "extensions", "addons", "hooks", "middleware"],
|
|
26
|
+
"min_matches": 2,
|
|
27
|
+
},
|
|
28
|
+
"Event-Driven": {
|
|
29
|
+
"indicators": ["events", "handlers", "listeners", "subscribers", "queues", "consumers"],
|
|
30
|
+
"min_matches": 2,
|
|
31
|
+
},
|
|
32
|
+
"Monorepo": {
|
|
33
|
+
"indicators": ["packages", "apps", "libs", "workspaces"],
|
|
34
|
+
"min_matches": 2,
|
|
35
|
+
},
|
|
36
|
+
"CLI Tool": {
|
|
37
|
+
"indicators": ["cli", "commands", "bin"],
|
|
38
|
+
"min_matches": 2,
|
|
39
|
+
},
|
|
40
|
+
"Clean Architecture / Hexagonal": {
|
|
41
|
+
"indicators": ["domain", "core", "ports", "adapters", "usecases", "entities"],
|
|
42
|
+
"min_matches": 3,
|
|
43
|
+
},
|
|
44
|
+
"CQRS / Event-Sourced": {
|
|
45
|
+
"indicators": ["commands", "queries", "events", "handlers", "projections", "aggregates"],
|
|
46
|
+
"min_matches": 3,
|
|
47
|
+
},
|
|
48
|
+
"Serverless": {
|
|
49
|
+
"indicators": ["functions", "lambdas", "serverless.yml", "serverless.ts", "sam.yaml"],
|
|
50
|
+
"min_matches": 2,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# File/config indicators
|
|
55
|
+
CONFIG_PATTERNS = {
|
|
56
|
+
"Containerized": ["Dockerfile", "docker-compose.yml", "docker-compose.yaml"],
|
|
57
|
+
"CI/CD Enabled": [".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci"],
|
|
58
|
+
"Tested": ["tests", "test", "__tests__", "spec", "pytest.ini", "jest.config"],
|
|
59
|
+
"Documented": ["docs", "documentation", "wiki", "ARCHITECTURE.md"],
|
|
60
|
+
"Type-Safe": ["tsconfig.json", "mypy.ini", ".mypy.ini", "pyproject.toml"],
|
|
61
|
+
"Cloud Native": ["k8s", "kubernetes", "helm", "terraform", "main.tf"],
|
|
62
|
+
"Security Hardened": [".snyk", "security.md", "trivy.yaml", ".gitleaks.toml"],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ArchitectureDetector:
|
|
67
|
+
"""Detects software architecture patterns through directory and file analysis."""
|
|
68
|
+
|
|
69
|
+
def detect(self, repo_path: Path) -> dict:
|
|
70
|
+
"""Identify architecture patterns in the repository.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Dict with detected patterns, traits, and confidence scores.
|
|
74
|
+
"""
|
|
75
|
+
all_names = set()
|
|
76
|
+
total_src_dir_depth = 0
|
|
77
|
+
src_dir_count = 0
|
|
78
|
+
|
|
79
|
+
for item, depth in self._walk(repo_path):
|
|
80
|
+
if item.is_dir():
|
|
81
|
+
all_names.add(item.name.lower())
|
|
82
|
+
if item.name not in IGNORE_DIRS:
|
|
83
|
+
total_src_dir_depth += depth
|
|
84
|
+
src_dir_count += 1
|
|
85
|
+
else:
|
|
86
|
+
all_names.add(item.name.lower())
|
|
87
|
+
# Detect architecture patterns
|
|
88
|
+
detected = []
|
|
89
|
+
for pattern_name, config in PATTERNS.items():
|
|
90
|
+
indicators = config["indicators"]
|
|
91
|
+
matches = [ind for ind in indicators if ind in all_names]
|
|
92
|
+
if len(matches) >= config["min_matches"]:
|
|
93
|
+
confidence = round(len(matches) / len(indicators), 2)
|
|
94
|
+
detected.append({
|
|
95
|
+
"pattern": pattern_name,
|
|
96
|
+
"confidence": confidence,
|
|
97
|
+
"matched_indicators": matches,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
# Detect infrastructure traits
|
|
101
|
+
traits = []
|
|
102
|
+
for trait_name, indicators in CONFIG_PATTERNS.items():
|
|
103
|
+
for ind in indicators:
|
|
104
|
+
ind_path = repo_path / ind
|
|
105
|
+
if ind_path.exists() or ind.lower() in all_names:
|
|
106
|
+
traits.append(trait_name)
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
# Determine coupling level
|
|
110
|
+
if src_dir_count == 0:
|
|
111
|
+
coupling = "Unknown"
|
|
112
|
+
else:
|
|
113
|
+
avg_depth = total_src_dir_depth / src_dir_count
|
|
114
|
+
if avg_depth > 4:
|
|
115
|
+
coupling = "High"
|
|
116
|
+
elif avg_depth > 2.5:
|
|
117
|
+
coupling = "Moderate"
|
|
118
|
+
else:
|
|
119
|
+
coupling = "Low"
|
|
120
|
+
|
|
121
|
+
# Sort by confidence
|
|
122
|
+
detected.sort(key=lambda x: x["confidence"], reverse=True)
|
|
123
|
+
|
|
124
|
+
primary_pattern = detected[0]["pattern"] if detected else "Monolith"
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"primary_pattern": primary_pattern,
|
|
128
|
+
"detected_patterns": detected,
|
|
129
|
+
"traits": traits,
|
|
130
|
+
"coupling": coupling,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
def _walk(self, root: Path, max_depth: int = 5):
|
|
134
|
+
stack = [(root, 0)]
|
|
135
|
+
while stack:
|
|
136
|
+
current_dir, current_depth = stack.pop()
|
|
137
|
+
if current_depth >= max_depth:
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
with os.scandir(current_dir) as it:
|
|
141
|
+
for entry in it:
|
|
142
|
+
if entry.name in IGNORE_DIRS or entry.name.startswith("."):
|
|
143
|
+
continue
|
|
144
|
+
# Yield entry along with its effective depth (child depth is current_depth + 1)
|
|
145
|
+
# to avoid redundant O(N) recomputation using `item.relative_to` in callers
|
|
146
|
+
yield entry, current_depth + 1
|
|
147
|
+
if entry.is_dir():
|
|
148
|
+
stack.append((Path(entry.path), current_depth + 1))
|
|
149
|
+
except (PermissionError, FileNotFoundError):
|
|
150
|
+
pass
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Cache Manager — handles disk-based caching for computationally expensive operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheManager:
|
|
12
|
+
"""Manages local disk cache for CodeDNA analysis results."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, cache_dir: Path | str = ".codedna_cache"):
|
|
15
|
+
self.cache_dir = Path(cache_dir)
|
|
16
|
+
self.ttl = timedelta(days=1)
|
|
17
|
+
self._ensure_cache_dir()
|
|
18
|
+
|
|
19
|
+
def _ensure_cache_dir(self):
|
|
20
|
+
"""Ensure the cache directory exists."""
|
|
21
|
+
if not self.cache_dir.exists():
|
|
22
|
+
try:
|
|
23
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
# Add out to .gitignore mechanism usually, but keep it local
|
|
25
|
+
ignore_file = self.cache_dir / ".gitignore"
|
|
26
|
+
if not ignore_file.exists():
|
|
27
|
+
ignore_file.write_text("*\n!.gitignore\n", encoding="utf-8")
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def _get_cache_key(self, scope: str, identifier: str) -> str:
|
|
32
|
+
"""Generate a consistent cache key."""
|
|
33
|
+
raw = f"{scope}::{identifier}"
|
|
34
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
def get(self, scope: str, identifier: str) -> dict | None:
|
|
37
|
+
"""Retrieve a value from the cache if it exists and is not expired."""
|
|
38
|
+
key = self._get_cache_key(scope, identifier)
|
|
39
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
40
|
+
|
|
41
|
+
if not cache_file.exists():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
46
|
+
timestamp = datetime.fromisoformat(data["_cached_at"])
|
|
47
|
+
|
|
48
|
+
# Check expiration
|
|
49
|
+
if datetime.now() - timestamp > self.ttl:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
return data["payload"]
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def set(self, scope: str, identifier: str, payload: dict) -> bool:
|
|
57
|
+
"""Store a value in the cache."""
|
|
58
|
+
key = self._get_cache_key(scope, identifier)
|
|
59
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
60
|
+
|
|
61
|
+
data = {
|
|
62
|
+
"_cached_at": datetime.now().isoformat(),
|
|
63
|
+
"payload": payload
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
cache_file.write_text(json.dumps(data), encoding="utf-8")
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Code Smell Detector — identifies structural design problems in codebases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .complexity_analyzer import ComplexityAnalyzer
|
|
12
|
+
from .language_detector import IGNORE_DIRS
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from codedna.config import CodeDNAConfig
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
MAX_FILE_SIZE = int(os.environ.get("CODEDNA_MAX_FILE_SIZE", 5 * 1024 * 1024))
|
|
19
|
+
except ValueError:
|
|
20
|
+
logging.getLogger(__name__).warning("Invalid CODEDNA_MAX_FILE_SIZE value. Using default 5MB.")
|
|
21
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
# Default Thresholds
|
|
24
|
+
MAX_FILE_LINES = 500
|
|
25
|
+
MAX_FUNCTION_LINES = 80
|
|
26
|
+
GOD_CLASS_METHODS = 15
|
|
27
|
+
LARGE_MODULE_FILES = 20
|
|
28
|
+
HUB_NODE_IMPORTS = 15
|
|
29
|
+
|
|
30
|
+
# Pre-compiled Regular Expressions for performance
|
|
31
|
+
MARKER_PATTERN = re.compile(r"(TODO|FIXME|HACK|XXX)", re.IGNORECASE)
|
|
32
|
+
PY_METHOD_PATTERN = re.compile(r"^[ \t]*def\s+\w+", re.MULTILINE)
|
|
33
|
+
JS_METHOD_PATTERN = re.compile(r"(?:\bfunction\s+\w+)|(?:=>\s*\{)|(?:\b[a-zA-Z_$][\w$]*\s*\([^)]*\)\s*\{)")
|
|
34
|
+
JAVA_METHOD_PATTERN = re.compile(r"(?:\bpublic|\bprivate|\bprotected)\s+\w+\s+\w+\s*\(")
|
|
35
|
+
IMPORT_PATTERN = re.compile(r"^(?:import|from)\s+([A-Za-z0-9_.]+)", re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CodeSmellDetector:
|
|
39
|
+
"""Detects common code smells and structural issues."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: CodeDNAConfig | None = None) -> None:
|
|
42
|
+
self.config = config
|
|
43
|
+
|
|
44
|
+
def detect(self, repo_path: Path, config: CodeDNAConfig | None = None) -> dict:
|
|
45
|
+
"""Scan for code smells in the repository.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dict with categorized smells and severity ratings.
|
|
49
|
+
"""
|
|
50
|
+
active_config = config or self.config
|
|
51
|
+
smells: list[dict] = []
|
|
52
|
+
source_exts = {".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".go", ".rs", ".rb"}
|
|
53
|
+
|
|
54
|
+
# Dynamic thresholds
|
|
55
|
+
max_file_lines = active_config.max_file_lines if active_config else MAX_FILE_LINES
|
|
56
|
+
max_func_lines = active_config.max_function_lines if active_config else MAX_FUNCTION_LINES
|
|
57
|
+
god_methods = active_config.god_class_methods if active_config else GOD_CLASS_METHODS
|
|
58
|
+
large_mod_files = active_config.large_module_files if active_config else LARGE_MODULE_FILES
|
|
59
|
+
|
|
60
|
+
stack = [repo_path]
|
|
61
|
+
while stack:
|
|
62
|
+
current = stack.pop()
|
|
63
|
+
try:
|
|
64
|
+
file_count = 0
|
|
65
|
+
for item in current.iterdir():
|
|
66
|
+
if item.name in IGNORE_DIRS or item.is_symlink():
|
|
67
|
+
continue
|
|
68
|
+
if item.is_dir():
|
|
69
|
+
stack.append(item)
|
|
70
|
+
elif item.is_file():
|
|
71
|
+
file_count += 1
|
|
72
|
+
if item.suffix.lower() in source_exts:
|
|
73
|
+
try:
|
|
74
|
+
rel = str(item.relative_to(repo_path))
|
|
75
|
+
if active_config and active_config.is_path_ignored(rel):
|
|
76
|
+
continue
|
|
77
|
+
if item.stat().st_size <= MAX_FILE_SIZE:
|
|
78
|
+
self._analyze_file(
|
|
79
|
+
item, repo_path, smells,
|
|
80
|
+
max_file_lines=max_file_lines,
|
|
81
|
+
max_func_lines=max_func_lines,
|
|
82
|
+
god_methods=god_methods,
|
|
83
|
+
)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# ── Large Modules ──
|
|
88
|
+
if file_count > large_mod_files and current != repo_path:
|
|
89
|
+
try:
|
|
90
|
+
rel = str(current.relative_to(repo_path))
|
|
91
|
+
if not (active_config and active_config.is_path_ignored(rel)):
|
|
92
|
+
smells.append({
|
|
93
|
+
"type": "Large Module",
|
|
94
|
+
"severity": "warning",
|
|
95
|
+
"file": rel,
|
|
96
|
+
"line": 1,
|
|
97
|
+
"detail": f"{file_count} files in module (threshold: {large_mod_files})",
|
|
98
|
+
})
|
|
99
|
+
except ValueError:
|
|
100
|
+
pass
|
|
101
|
+
except PermissionError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# ── Complexity & Maintainability Analysis ──
|
|
105
|
+
complexity_data = {}
|
|
106
|
+
try:
|
|
107
|
+
complexity_data = ComplexityAnalyzer().analyze(repo_path, config=active_config)
|
|
108
|
+
for fn in complexity_data.get("complex_functions", []):
|
|
109
|
+
# High Cyclomatic Complexity
|
|
110
|
+
if fn.get("cyclomatic_complexity", 0) > 10:
|
|
111
|
+
sev = "critical" if fn["cyclomatic_complexity"] > 20 else "warning"
|
|
112
|
+
smells.append({
|
|
113
|
+
"type": "High Cyclomatic Complexity",
|
|
114
|
+
"severity": sev,
|
|
115
|
+
"file": f"{fn['file']}:{fn['line']}",
|
|
116
|
+
"line": fn["line"],
|
|
117
|
+
"detail": f"Function '{fn['name']}' has Cyclomatic Complexity {fn['cyclomatic_complexity']} (threshold: 10)",
|
|
118
|
+
})
|
|
119
|
+
# High Cognitive Complexity
|
|
120
|
+
if fn.get("cognitive_complexity", 0) > 15:
|
|
121
|
+
sev = "critical" if fn["cognitive_complexity"] > 25 else "warning"
|
|
122
|
+
smells.append({
|
|
123
|
+
"type": "High Cognitive Complexity",
|
|
124
|
+
"severity": sev,
|
|
125
|
+
"file": f"{fn['file']}:{fn['line']}",
|
|
126
|
+
"line": fn["line"],
|
|
127
|
+
"detail": f"Function '{fn['name']}' has Cognitive Complexity {fn['cognitive_complexity']} (threshold: 15)",
|
|
128
|
+
})
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# Summary
|
|
133
|
+
severity_counts = {"critical": 0, "warning": 0, "info": 0}
|
|
134
|
+
for smell in smells:
|
|
135
|
+
severity_counts[smell["severity"]] += 1
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"smells": smells,
|
|
139
|
+
"total": len(smells),
|
|
140
|
+
"severity_counts": severity_counts,
|
|
141
|
+
"health_score": self._compute_health(severity_counts),
|
|
142
|
+
"complexity": complexity_data,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def _analyze_file(
|
|
146
|
+
self,
|
|
147
|
+
item: Path,
|
|
148
|
+
repo_path: Path,
|
|
149
|
+
smells: list[dict],
|
|
150
|
+
max_file_lines: int = MAX_FILE_LINES,
|
|
151
|
+
max_func_lines: int = MAX_FUNCTION_LINES,
|
|
152
|
+
god_methods: int = GOD_CLASS_METHODS,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Analyze a single file for code smells."""
|
|
155
|
+
try:
|
|
156
|
+
content = item.read_text(encoding="utf-8", errors="ignore")
|
|
157
|
+
relative = str(item.relative_to(repo_path))
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# ── Large File ──
|
|
162
|
+
line_count = content.count('\n') + 1
|
|
163
|
+
if line_count > max_file_lines:
|
|
164
|
+
smells.append({
|
|
165
|
+
"type": "Large File",
|
|
166
|
+
"severity": "warning" if line_count < 1000 else "critical",
|
|
167
|
+
"file": relative,
|
|
168
|
+
"line": 1,
|
|
169
|
+
"detail": f"{line_count} lines (threshold: {max_file_lines})",
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
# ── God Class ──
|
|
173
|
+
if item.suffix in (".py", ".java", ".ts", ".js"):
|
|
174
|
+
method_count = self._count_methods(content, item.suffix)
|
|
175
|
+
if method_count > god_methods:
|
|
176
|
+
smells.append({
|
|
177
|
+
"type": "God Class",
|
|
178
|
+
"severity": "critical",
|
|
179
|
+
"file": relative,
|
|
180
|
+
"line": 1,
|
|
181
|
+
"detail": f"{method_count} methods detected (threshold: {god_methods})",
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
# ── Long Functions ──
|
|
185
|
+
long_funcs = self._detect_long_functions(content, item.suffix, max_func_lines=max_func_lines)
|
|
186
|
+
for func_name, length, start_line in long_funcs:
|
|
187
|
+
smells.append({
|
|
188
|
+
"type": "Long Function",
|
|
189
|
+
"severity": "warning",
|
|
190
|
+
"file": relative,
|
|
191
|
+
"line": start_line,
|
|
192
|
+
"detail": f"'{func_name}' has {length} lines (threshold: {max_func_lines})",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# ── Spaghetti Hub Node (Excessive Coupling) ──
|
|
196
|
+
if item.suffix == ".py":
|
|
197
|
+
imports_found = set(IMPORT_PATTERN.findall(content))
|
|
198
|
+
if len(imports_found) > HUB_NODE_IMPORTS:
|
|
199
|
+
smells.append({
|
|
200
|
+
"type": "Spaghetti Hub Node",
|
|
201
|
+
"severity": "warning",
|
|
202
|
+
"file": relative,
|
|
203
|
+
"line": 1,
|
|
204
|
+
"detail": f"{len(imports_found)} distinct imports (threshold: {HUB_NODE_IMPORTS}) — high coupling risk",
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
# ── TODO/FIXME/HACK markers ──
|
|
208
|
+
if MARKER_PATTERN.search(content):
|
|
209
|
+
last_idx = 0
|
|
210
|
+
current_line = 1
|
|
211
|
+
for match in MARKER_PATTERN.finditer(content):
|
|
212
|
+
start_idx = match.start()
|
|
213
|
+
current_line += content.count('\n', last_idx, start_idx)
|
|
214
|
+
last_idx = start_idx
|
|
215
|
+
|
|
216
|
+
# Extract up to 80 chars of the line containing the marker
|
|
217
|
+
line_start = content.rfind('\n', 0, start_idx) + 1
|
|
218
|
+
line_end = content.find('\n', start_idx)
|
|
219
|
+
if line_end == -1:
|
|
220
|
+
line_end = len(content)
|
|
221
|
+
line_text = content[line_start:line_end].strip()[:80]
|
|
222
|
+
|
|
223
|
+
smells.append({
|
|
224
|
+
"type": "Code Marker",
|
|
225
|
+
"severity": "info",
|
|
226
|
+
"file": f"{relative}:{current_line}",
|
|
227
|
+
"line": current_line,
|
|
228
|
+
"detail": f"{match.group(1).upper()} found: {line_text}",
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
def _count_methods(self, content: str, ext: str) -> int:
|
|
232
|
+
"""Count method/function definitions in a file."""
|
|
233
|
+
if ext == ".py":
|
|
234
|
+
return len(PY_METHOD_PATTERN.findall(content))
|
|
235
|
+
elif ext in (".js", ".ts", ".jsx", ".tsx"):
|
|
236
|
+
return len(JS_METHOD_PATTERN.findall(content))
|
|
237
|
+
elif ext == ".java":
|
|
238
|
+
return len(JAVA_METHOD_PATTERN.findall(content))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
def _detect_long_functions(
|
|
242
|
+
self,
|
|
243
|
+
content: str,
|
|
244
|
+
ext: str,
|
|
245
|
+
max_func_lines: int = MAX_FUNCTION_LINES,
|
|
246
|
+
) -> list[tuple[str, int, int]]:
|
|
247
|
+
"""Detect functions exceeding the line threshold."""
|
|
248
|
+
results = []
|
|
249
|
+
|
|
250
|
+
if ext == ".py":
|
|
251
|
+
active_funcs = []
|
|
252
|
+
pattern = re.compile(r"^[ \t]*(def\s+(\w+)|class\s+\w+)", re.MULTILINE)
|
|
253
|
+
|
|
254
|
+
last_idx = 0
|
|
255
|
+
current_line = 0
|
|
256
|
+
|
|
257
|
+
for match in pattern.finditer(content):
|
|
258
|
+
start_idx = match.start()
|
|
259
|
+
|
|
260
|
+
# count newlines between last match and this match
|
|
261
|
+
current_line += content.count('\n', last_idx, start_idx)
|
|
262
|
+
last_idx = start_idx
|
|
263
|
+
|
|
264
|
+
# find indent
|
|
265
|
+
j = start_idx
|
|
266
|
+
while j < len(content) and content[j] in ' \t':
|
|
267
|
+
j += 1
|
|
268
|
+
indent = j - start_idx
|
|
269
|
+
|
|
270
|
+
is_def = match.group(1).startswith("def")
|
|
271
|
+
|
|
272
|
+
while active_funcs and active_funcs[-1][0] >= indent:
|
|
273
|
+
_, prev_name, prev_start = active_funcs.pop()
|
|
274
|
+
func_lines = current_line - prev_start - 1
|
|
275
|
+
if func_lines > max_func_lines:
|
|
276
|
+
results.append((prev_name, func_lines, prev_start + 1))
|
|
277
|
+
|
|
278
|
+
if is_def:
|
|
279
|
+
name = match.group(2)
|
|
280
|
+
active_funcs.append((indent, name, current_line))
|
|
281
|
+
|
|
282
|
+
if active_funcs:
|
|
283
|
+
total_lines = current_line + content.count('\n', last_idx)
|
|
284
|
+
if content and not content.endswith('\n'):
|
|
285
|
+
total_lines += 1
|
|
286
|
+
|
|
287
|
+
for _, name, start in active_funcs:
|
|
288
|
+
func_lines = total_lines - start - 1
|
|
289
|
+
if func_lines > max_func_lines:
|
|
290
|
+
results.append((name, func_lines, start + 1))
|
|
291
|
+
|
|
292
|
+
return results
|
|
293
|
+
|
|
294
|
+
def _compute_health(self, counts: dict) -> str:
|
|
295
|
+
"""Compute overall health score."""
|
|
296
|
+
if counts["critical"] > 5:
|
|
297
|
+
return "Critical"
|
|
298
|
+
elif counts["critical"] > 0 or counts["warning"] > 10:
|
|
299
|
+
return "Needs Attention"
|
|
300
|
+
elif counts["warning"] > 3:
|
|
301
|
+
return "Fair"
|
|
302
|
+
return "Healthy"
|