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
codedna/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CodeDNA — A genetic analyzer for software."""
2
+
3
+ __version__ = "1.4.0"
@@ -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"