tapps-agents 3.5.41__py3-none-any.whl → 3.6.1__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 (141) hide show
  1. tapps_agents/__init__.py +2 -2
  2. tapps_agents/agents/reviewer/scoring.py +1566 -1566
  3. tapps_agents/agents/reviewer/tools/__init__.py +41 -41
  4. tapps_agents/cli/commands/health.py +665 -665
  5. tapps_agents/cli/commands/top_level.py +3586 -3586
  6. tapps_agents/core/artifact_context_builder.py +293 -0
  7. tapps_agents/core/config.py +33 -0
  8. tapps_agents/health/orchestrator.py +271 -271
  9. tapps_agents/resources/__init__.py +5 -0
  10. tapps_agents/resources/claude/__init__.py +1 -0
  11. tapps_agents/resources/claude/commands/README.md +156 -0
  12. tapps_agents/resources/claude/commands/__init__.py +1 -0
  13. tapps_agents/resources/claude/commands/build-fix.md +22 -0
  14. tapps_agents/resources/claude/commands/build.md +77 -0
  15. tapps_agents/resources/claude/commands/debug.md +53 -0
  16. tapps_agents/resources/claude/commands/design.md +68 -0
  17. tapps_agents/resources/claude/commands/docs.md +53 -0
  18. tapps_agents/resources/claude/commands/e2e.md +22 -0
  19. tapps_agents/resources/claude/commands/fix.md +54 -0
  20. tapps_agents/resources/claude/commands/implement.md +53 -0
  21. tapps_agents/resources/claude/commands/improve.md +53 -0
  22. tapps_agents/resources/claude/commands/library-docs.md +64 -0
  23. tapps_agents/resources/claude/commands/lint.md +52 -0
  24. tapps_agents/resources/claude/commands/plan.md +65 -0
  25. tapps_agents/resources/claude/commands/refactor-clean.md +21 -0
  26. tapps_agents/resources/claude/commands/refactor.md +55 -0
  27. tapps_agents/resources/claude/commands/review.md +67 -0
  28. tapps_agents/resources/claude/commands/score.md +60 -0
  29. tapps_agents/resources/claude/commands/security-review.md +22 -0
  30. tapps_agents/resources/claude/commands/security-scan.md +54 -0
  31. tapps_agents/resources/claude/commands/tdd.md +24 -0
  32. tapps_agents/resources/claude/commands/test-coverage.md +21 -0
  33. tapps_agents/resources/claude/commands/test.md +54 -0
  34. tapps_agents/resources/claude/commands/update-codemaps.md +20 -0
  35. tapps_agents/resources/claude/commands/update-docs.md +21 -0
  36. tapps_agents/resources/claude/skills/__init__.py +1 -0
  37. tapps_agents/resources/claude/skills/analyst/SKILL.md +272 -0
  38. tapps_agents/resources/claude/skills/analyst/__init__.py +1 -0
  39. tapps_agents/resources/claude/skills/architect/SKILL.md +282 -0
  40. tapps_agents/resources/claude/skills/architect/__init__.py +1 -0
  41. tapps_agents/resources/claude/skills/backend-patterns/SKILL.md +30 -0
  42. tapps_agents/resources/claude/skills/backend-patterns/__init__.py +1 -0
  43. tapps_agents/resources/claude/skills/coding-standards/SKILL.md +29 -0
  44. tapps_agents/resources/claude/skills/coding-standards/__init__.py +1 -0
  45. tapps_agents/resources/claude/skills/debugger/SKILL.md +203 -0
  46. tapps_agents/resources/claude/skills/debugger/__init__.py +1 -0
  47. tapps_agents/resources/claude/skills/designer/SKILL.md +243 -0
  48. tapps_agents/resources/claude/skills/designer/__init__.py +1 -0
  49. tapps_agents/resources/claude/skills/documenter/SKILL.md +252 -0
  50. tapps_agents/resources/claude/skills/documenter/__init__.py +1 -0
  51. tapps_agents/resources/claude/skills/enhancer/SKILL.md +307 -0
  52. tapps_agents/resources/claude/skills/enhancer/__init__.py +1 -0
  53. tapps_agents/resources/claude/skills/evaluator/SKILL.md +204 -0
  54. tapps_agents/resources/claude/skills/evaluator/__init__.py +1 -0
  55. tapps_agents/resources/claude/skills/frontend-patterns/SKILL.md +29 -0
  56. tapps_agents/resources/claude/skills/frontend-patterns/__init__.py +1 -0
  57. tapps_agents/resources/claude/skills/implementer/SKILL.md +188 -0
  58. tapps_agents/resources/claude/skills/implementer/__init__.py +1 -0
  59. tapps_agents/resources/claude/skills/improver/SKILL.md +218 -0
  60. tapps_agents/resources/claude/skills/improver/__init__.py +1 -0
  61. tapps_agents/resources/claude/skills/ops/SKILL.md +281 -0
  62. tapps_agents/resources/claude/skills/ops/__init__.py +1 -0
  63. tapps_agents/resources/claude/skills/orchestrator/SKILL.md +390 -0
  64. tapps_agents/resources/claude/skills/orchestrator/__init__.py +1 -0
  65. tapps_agents/resources/claude/skills/planner/SKILL.md +254 -0
  66. tapps_agents/resources/claude/skills/planner/__init__.py +1 -0
  67. tapps_agents/resources/claude/skills/reviewer/SKILL.md +434 -0
  68. tapps_agents/resources/claude/skills/reviewer/__init__.py +1 -0
  69. tapps_agents/resources/claude/skills/security-review/SKILL.md +31 -0
  70. tapps_agents/resources/claude/skills/security-review/__init__.py +1 -0
  71. tapps_agents/resources/claude/skills/simple-mode/SKILL.md +695 -0
  72. tapps_agents/resources/claude/skills/simple-mode/__init__.py +1 -0
  73. tapps_agents/resources/claude/skills/tester/SKILL.md +219 -0
  74. tapps_agents/resources/claude/skills/tester/__init__.py +1 -0
  75. tapps_agents/resources/cursor/.cursorignore +35 -0
  76. tapps_agents/resources/cursor/__init__.py +1 -0
  77. tapps_agents/resources/cursor/commands/__init__.py +1 -0
  78. tapps_agents/resources/cursor/commands/build-fix.md +11 -0
  79. tapps_agents/resources/cursor/commands/build.md +11 -0
  80. tapps_agents/resources/cursor/commands/e2e.md +11 -0
  81. tapps_agents/resources/cursor/commands/fix.md +11 -0
  82. tapps_agents/resources/cursor/commands/refactor-clean.md +11 -0
  83. tapps_agents/resources/cursor/commands/review.md +11 -0
  84. tapps_agents/resources/cursor/commands/security-review.md +11 -0
  85. tapps_agents/resources/cursor/commands/tdd.md +11 -0
  86. tapps_agents/resources/cursor/commands/test-coverage.md +11 -0
  87. tapps_agents/resources/cursor/commands/test.md +11 -0
  88. tapps_agents/resources/cursor/commands/update-codemaps.md +10 -0
  89. tapps_agents/resources/cursor/commands/update-docs.md +11 -0
  90. tapps_agents/resources/cursor/rules/__init__.py +1 -0
  91. tapps_agents/resources/cursor/rules/agent-capabilities.mdc +687 -0
  92. tapps_agents/resources/cursor/rules/coding-style.mdc +31 -0
  93. tapps_agents/resources/cursor/rules/command-reference.mdc +2081 -0
  94. tapps_agents/resources/cursor/rules/cursor-mode-usage.mdc +125 -0
  95. tapps_agents/resources/cursor/rules/git-workflow.mdc +29 -0
  96. tapps_agents/resources/cursor/rules/performance.mdc +29 -0
  97. tapps_agents/resources/cursor/rules/project-context.mdc +163 -0
  98. tapps_agents/resources/cursor/rules/project-profiling.mdc +197 -0
  99. tapps_agents/resources/cursor/rules/quick-reference.mdc +630 -0
  100. tapps_agents/resources/cursor/rules/security.mdc +32 -0
  101. tapps_agents/resources/cursor/rules/simple-mode.mdc +500 -0
  102. tapps_agents/resources/cursor/rules/testing.mdc +31 -0
  103. tapps_agents/resources/cursor/rules/when-to-use.mdc +156 -0
  104. tapps_agents/resources/cursor/rules/workflow-presets.mdc +179 -0
  105. tapps_agents/resources/customizations/__init__.py +1 -0
  106. tapps_agents/resources/customizations/example-custom.yaml +83 -0
  107. tapps_agents/resources/hooks/__init__.py +1 -0
  108. tapps_agents/resources/hooks/templates/README.md +5 -0
  109. tapps_agents/resources/hooks/templates/__init__.py +1 -0
  110. tapps_agents/resources/hooks/templates/add-project-context.yaml +8 -0
  111. tapps_agents/resources/hooks/templates/auto-format-js.yaml +10 -0
  112. tapps_agents/resources/hooks/templates/auto-format-python.yaml +10 -0
  113. tapps_agents/resources/hooks/templates/git-commit-check.yaml +7 -0
  114. tapps_agents/resources/hooks/templates/notify-on-complete.yaml +8 -0
  115. tapps_agents/resources/hooks/templates/quality-gate.yaml +8 -0
  116. tapps_agents/resources/hooks/templates/security-scan-on-edit.yaml +10 -0
  117. tapps_agents/resources/hooks/templates/session-end-log.yaml +7 -0
  118. tapps_agents/resources/hooks/templates/show-beads-ready.yaml +8 -0
  119. tapps_agents/resources/hooks/templates/test-on-edit.yaml +10 -0
  120. tapps_agents/resources/hooks/templates/update-docs-on-complete.yaml +8 -0
  121. tapps_agents/resources/hooks/templates/user-prompt-log.yaml +7 -0
  122. tapps_agents/resources/scripts/__init__.py +1 -0
  123. tapps_agents/resources/scripts/set_bd_path.ps1 +51 -0
  124. tapps_agents/resources/workflows/__init__.py +1 -0
  125. tapps_agents/resources/workflows/presets/__init__.py +1 -0
  126. tapps_agents/resources/workflows/presets/brownfield-analysis.yaml +235 -0
  127. tapps_agents/resources/workflows/presets/fix.yaml +78 -0
  128. tapps_agents/resources/workflows/presets/full-sdlc.yaml +122 -0
  129. tapps_agents/resources/workflows/presets/quality.yaml +82 -0
  130. tapps_agents/resources/workflows/presets/rapid-dev.yaml +84 -0
  131. tapps_agents/simple_mode/orchestrators/base.py +185 -185
  132. tapps_agents/simple_mode/orchestrators/build_orchestrator.py +2700 -2667
  133. tapps_agents/simple_mode/orchestrators/fix_orchestrator.py +723 -723
  134. tapps_agents/workflow/cursor_executor.py +2337 -2337
  135. tapps_agents/workflow/message_formatter.py +188 -188
  136. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/METADATA +6 -6
  137. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/RECORD +141 -18
  138. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/WHEEL +0 -0
  139. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/entry_points.txt +0 -0
  140. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/licenses/LICENSE +0 -0
  141. {tapps_agents-3.5.41.dist-info → tapps_agents-3.6.1.dist-info}/top_level.txt +0 -0
@@ -1,1566 +1,1566 @@
1
- """
2
- Code Scoring System - Calculates objective quality metrics
3
- """
4
-
5
- import ast
6
- import json as json_lib
7
- import logging
8
- import shutil
9
- import subprocess # nosec B404 - used with fixed args, no shell
10
- import sys
11
- from pathlib import Path
12
- from typing import Any, Protocol
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- from ...core.config import ProjectConfig, ScoringWeightsConfig
17
- from ...core.language_detector import Language
18
- from ...core.subprocess_utils import wrap_windows_cmd_shim
19
- from .score_constants import ComplexityConstants, SecurityConstants
20
- from .validation import validate_code_input
21
-
22
- # Import analysis libraries
23
- try:
24
- from radon.complexity import cc_visit
25
- from radon.metrics import mi_visit
26
-
27
- HAS_RADON = True
28
- except ImportError:
29
- HAS_RADON = False
30
-
31
- try:
32
- import bandit
33
- from bandit.core import config as bandit_config
34
- from bandit.core import manager
35
-
36
- HAS_BANDIT = True
37
- except ImportError:
38
- HAS_BANDIT = False
39
-
40
- # Check if ruff is available in PATH or via python -m ruff
41
- def _check_ruff_available() -> bool:
42
- """Check if ruff is available via 'ruff' command or 'python -m ruff'"""
43
- # Check for ruff command directly
44
- if shutil.which("ruff"):
45
- return True
46
- # Check for python -m ruff
47
- try:
48
- result = subprocess.run( # nosec B603 - fixed args
49
- [sys.executable, "-m", "ruff", "--version"],
50
- capture_output=True,
51
- timeout=5,
52
- check=False,
53
- )
54
- return result.returncode == 0
55
- except (subprocess.TimeoutExpired, FileNotFoundError):
56
- return False
57
-
58
-
59
- HAS_RUFF = _check_ruff_available()
60
-
61
- # Check if mypy is available in PATH
62
- HAS_MYPY = shutil.which("mypy") is not None
63
-
64
-
65
- # Check if jscpd is available (via npm/npx)
66
- def _check_jscpd_available() -> bool:
67
- """Check if jscpd is available via jscpd command or npx jscpd"""
68
- # Check for jscpd command directly
69
- if shutil.which("jscpd"):
70
- return True
71
- # Check for npx (Node.js package runner)
72
- npx_path = shutil.which("npx")
73
- if npx_path:
74
- try:
75
- cmd = wrap_windows_cmd_shim([npx_path, "--yes", "jscpd", "--version"])
76
- result = subprocess.run( # nosec B603 - fixed args
77
- cmd,
78
- capture_output=True,
79
- timeout=5,
80
- check=False,
81
- )
82
- return result.returncode == 0
83
- except (subprocess.TimeoutExpired, FileNotFoundError):
84
- return False
85
- return False
86
-
87
-
88
- HAS_JSCPD = _check_jscpd_available()
89
-
90
- # Import coverage tools
91
- try:
92
- from coverage import Coverage
93
-
94
- HAS_COVERAGE = True
95
- except ImportError:
96
- HAS_COVERAGE = False
97
-
98
-
99
- class BaseScorer:
100
- """
101
- Base class for all scorers.
102
- Defines the interface and shared helpers: _find_project_root, _calculate_structure_score, _calculate_devex_score (7-category §3.2).
103
- """
104
-
105
- def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
106
- """Score a file and return quality metrics. Subclasses must implement."""
107
- raise NotImplementedError("Subclasses must implement score_file")
108
-
109
- @staticmethod
110
- def _find_project_root(file_path: Path) -> Path | None:
111
- """Find project root by common markers (.git, pyproject.toml, package.json, .tapps-agents, etc.)."""
112
- current = file_path.resolve().parent
113
- markers = [".git", "pyproject.toml", "setup.py", "requirements.txt", ".tapps-agents", "package.json"]
114
- for _ in range(10):
115
- for m in markers:
116
- if (current / m).exists():
117
- return current
118
- if current.parent == current:
119
- break
120
- current = current.parent
121
- return None
122
-
123
- @classmethod
124
- def _calculate_structure_score(cls, file_path: Path) -> float:
125
- """Structure score (0-10). Project layout, key files. 7-category §3.2."""
126
- root = BaseScorer._find_project_root(file_path)
127
- if root is None:
128
- return 5.0
129
- pts = 0.0
130
- if (root / "pyproject.toml").exists() or (root / "package.json").exists():
131
- pts += 2.5
132
- if (root / "README").exists() or (root / "README.md").exists() or (root / "README.rst").exists():
133
- pts += 2.0
134
- if (root / "tests").exists() or (root / "test").exists():
135
- pts += 2.0
136
- if (root / ".tapps-agents").exists() or (root / ".git").exists():
137
- pts += 1.0
138
- if (root / "setup.py").exists() or (root / "requirements.txt").exists() or (root / "package-lock.json").exists():
139
- pts += 1.5
140
- return min(10.0, pts * 2.0)
141
-
142
- @classmethod
143
- def _calculate_devex_score(cls, file_path: Path) -> float:
144
- """DevEx score (0-10). Docs, config, tooling. 7-category §3.2."""
145
- root = BaseScorer._find_project_root(file_path)
146
- if root is None:
147
- return 5.0
148
- pts = 0.0
149
- if (root / "AGENTS.md").exists() or (root / "CLAUDE.md").exists():
150
- pts += 3.0
151
- if (root / "docs").exists() and (root / "docs").is_dir():
152
- pts += 2.0
153
- if (root / ".tapps-agents").exists() or (root / ".cursor").exists():
154
- pts += 2.0
155
- pyproject, pkg = root / "pyproject.toml", root / "package.json"
156
- if pyproject.exists():
157
- try:
158
- t = pyproject.read_text(encoding="utf-8", errors="replace")
159
- if "[tool.ruff]" in t or "[tool.mypy]" in t or "pytest" in t:
160
- pts += 1.5
161
- except Exception:
162
- pass
163
- if pkg.exists():
164
- try:
165
- import json as _j
166
- d = _j.loads(pkg.read_text(encoding="utf-8", errors="replace"))
167
- dev = (d.get("devDependencies") or {}) if isinstance(d, dict) else {}
168
- if any(k in dev for k in ("eslint", "jest", "vitest", "mypy")):
169
- pts += 1.5
170
- except Exception:
171
- pass
172
- return min(10.0, pts * 2.0)
173
-
174
-
175
- class CodeScorer(BaseScorer):
176
- """Calculate code quality scores for Python files"""
177
-
178
- def __init__(
179
- self,
180
- weights: ScoringWeightsConfig | None = None,
181
- ruff_enabled: bool = True,
182
- mypy_enabled: bool = True,
183
- jscpd_enabled: bool = True,
184
- duplication_threshold: float = 3.0,
185
- min_duplication_lines: int = 5,
186
- ):
187
- self.has_radon = HAS_RADON
188
- self.has_bandit = HAS_BANDIT
189
- self.has_coverage = HAS_COVERAGE
190
- self.has_ruff = HAS_RUFF and ruff_enabled
191
- self.has_mypy = HAS_MYPY and mypy_enabled
192
- self.has_jscpd = HAS_JSCPD and jscpd_enabled
193
- self.duplication_threshold = duplication_threshold
194
- self.min_duplication_lines = min_duplication_lines
195
- self.weights = weights # Will use defaults if None
196
-
197
- def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
198
- """
199
- Calculate scores for a code file.
200
-
201
- Returns:
202
- {
203
- "complexity_score": float (0-10),
204
- "security_score": float (0-10),
205
- "maintainability_score": float (0-10),
206
- "overall_score": float (0-100),
207
- "metrics": {...}
208
- }
209
- """
210
- metrics: dict[str, float] = {}
211
- scores: dict[str, Any] = {
212
- "complexity_score": 0.0,
213
- "security_score": 0.0,
214
- "maintainability_score": 0.0,
215
- "test_coverage_score": 0.0,
216
- "performance_score": 0.0,
217
- "structure_score": 0.0, # 7-category: project layout, key files (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
218
- "devex_score": 0.0, # 7-category: docs, config, tooling (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
219
- "linting_score": 0.0, # Phase 6.1: Ruff linting score
220
- "type_checking_score": 0.0, # Phase 6.2: mypy type checking score
221
- "duplication_score": 0.0, # Phase 6.4: jscpd duplication score
222
- "metrics": metrics,
223
- }
224
-
225
- # Complexity Score (0-10, lower is better)
226
- scores["complexity_score"] = self._calculate_complexity(code)
227
- metrics["complexity"] = float(scores["complexity_score"])
228
-
229
- # Security Score (0-10, higher is better)
230
- scores["security_score"] = self._calculate_security(file_path, code)
231
- metrics["security"] = float(scores["security_score"])
232
-
233
- # Maintainability Score (0-10, higher is better)
234
- scores["maintainability_score"] = self._calculate_maintainability(code)
235
- metrics["maintainability"] = float(scores["maintainability_score"])
236
-
237
- # Test Coverage Score (0-10, higher is better)
238
- scores["test_coverage_score"] = self._calculate_test_coverage(file_path)
239
- metrics["test_coverage"] = float(scores["test_coverage_score"])
240
-
241
- # Performance Score (0-10, higher is better)
242
- # Phase 3.2: Use context-aware performance scorer
243
- from .performance_scorer import PerformanceScorer
244
- from ...core.language_detector import Language
245
-
246
- performance_scorer = PerformanceScorer()
247
- scores["performance_score"] = performance_scorer.calculate(
248
- code, Language.PYTHON, file_path, context=None
249
- )
250
- metrics["performance"] = float(scores["performance_score"])
251
-
252
- # Linting Score (0-10, higher is better) - Phase 6.1
253
- scores["linting_score"] = self._calculate_linting_score(file_path)
254
- metrics["linting"] = float(scores["linting_score"])
255
-
256
- # Get actual linting issues for transparency (P1 Improvement)
257
- linting_issues = self.get_ruff_issues(file_path)
258
- scores["linting_issues"] = self._format_ruff_issues(linting_issues)
259
- scores["linting_issue_count"] = len(linting_issues)
260
-
261
- # Type Checking Score (0-10, higher is better) - Phase 6.2
262
- scores["type_checking_score"] = self._calculate_type_checking_score(file_path)
263
- metrics["type_checking"] = float(scores["type_checking_score"])
264
-
265
- # Get actual type checking issues for transparency (P1 Improvement)
266
- type_issues = self.get_mypy_errors(file_path)
267
- scores["type_issues"] = type_issues # Already formatted
268
- scores["type_issue_count"] = len(type_issues)
269
-
270
- # Duplication Score (0-10, higher is better) - Phase 6.4
271
- scores["duplication_score"] = self._calculate_duplication_score(file_path)
272
- metrics["duplication"] = float(scores["duplication_score"])
273
-
274
- # Structure Score (0-10, higher is better) - 7-category §3.2
275
- scores["structure_score"] = self._calculate_structure_score(file_path)
276
- metrics["structure"] = float(scores["structure_score"])
277
-
278
- # DevEx Score (0-10, higher is better) - 7-category §3.2
279
- scores["devex_score"] = self._calculate_devex_score(file_path)
280
- metrics["devex"] = float(scores["devex_score"])
281
-
282
- class _Weights(Protocol):
283
- complexity: float
284
- security: float
285
- maintainability: float
286
- test_coverage: float
287
- performance: float
288
- structure: float
289
- devex: float
290
-
291
- # Overall Score (weighted average, 7-category)
292
- if self.weights is not None:
293
- w: _Weights = self.weights
294
- else:
295
- class DefaultWeights:
296
- complexity = 0.18
297
- security = 0.27
298
- maintainability = 0.24
299
- test_coverage = 0.13
300
- performance = 0.08
301
- structure = 0.05
302
- devex = 0.05
303
-
304
- w = DefaultWeights()
305
-
306
- scores["overall_score"] = (
307
- (10 - scores["complexity_score"]) * w.complexity
308
- + scores["security_score"] * w.security
309
- + scores["maintainability_score"] * w.maintainability
310
- + scores["test_coverage_score"] * w.test_coverage
311
- + scores["performance_score"] * w.performance
312
- + scores["structure_score"] * w.structure
313
- + scores["devex_score"] * w.devex
314
- ) * 10 # Scale from 0-10 weighted sum to 0-100
315
-
316
- # Phase 3.3: Validate all scores before returning
317
- from .score_validator import ScoreValidator
318
- from ...core.language_detector import Language
319
-
320
- validator = ScoreValidator()
321
- validation_results = validator.validate_all_scores(
322
- scores, language=Language.PYTHON, context=None
323
- )
324
-
325
- # Update scores with validated/clamped values and add explanations
326
- validated_scores = {}
327
- score_explanations = {}
328
- for category, result in validation_results.items():
329
- if result.valid and result.calibrated_score is not None:
330
- validated_scores[category] = result.calibrated_score
331
- if result.explanation:
332
- score_explanations[category] = {
333
- "explanation": result.explanation,
334
- "suggestions": result.suggestions,
335
- }
336
- else:
337
- validated_scores[category] = scores.get(category, 0.0)
338
-
339
- # Add explanations to result if any
340
- if score_explanations:
341
- validated_scores["_explanations"] = score_explanations
342
-
343
- # Merge: keep all scores (incl. structure_score, devex_score), overlay validated
344
- merged = {**scores, **validated_scores}
345
- return merged
346
-
347
- def _calculate_complexity(self, code: str) -> float:
348
- """Calculate cyclomatic complexity (0-10 scale)"""
349
- # Validate input
350
- validate_code_input(code, method_name="_calculate_complexity")
351
-
352
- if not self.has_radon:
353
- return 5.0 # Default neutral score
354
-
355
- try:
356
- tree = ast.parse(code)
357
- complexities = cc_visit(tree)
358
-
359
- if not complexities:
360
- return 1.0
361
-
362
- # Get max complexity
363
- max_complexity = max(cc.complexity for cc in complexities)
364
-
365
- # Scale to 0-10 using constants
366
- return min(
367
- max_complexity / ComplexityConstants.SCALING_FACTOR,
368
- ComplexityConstants.MAX_SCORE
369
- )
370
- except SyntaxError:
371
- return 10.0 # Syntax errors = max complexity
372
-
373
- def _calculate_security(self, file_path: Path | None, code: str) -> float:
374
- """Calculate security score (0-10 scale, higher is better)"""
375
- # Validate inputs
376
- validate_code_input(code, method_name="_calculate_security")
377
- if file_path is not None and not isinstance(file_path, Path):
378
- raise ValueError(f"_calculate_security: file_path must be Path or None, got {type(file_path).__name__}")
379
-
380
- if not self.has_bandit:
381
- # Basic heuristic check
382
- insecure_patterns = [
383
- "eval(",
384
- "exec(",
385
- "__import__",
386
- "pickle.loads",
387
- "subprocess.call",
388
- "os.system",
389
- ]
390
- issues = sum(1 for pattern in insecure_patterns if pattern in code)
391
- return max(
392
- 0.0,
393
- SecurityConstants.MAX_SCORE - (issues * SecurityConstants.INSECURE_PATTERN_PENALTY)
394
- )
395
-
396
- try:
397
- # Use bandit for proper security analysis
398
- # BanditManager expects a BanditConfig, not a dict. Passing a dict can raise ValueError,
399
- # which would silently degrade scoring to a neutral 5.0.
400
- b_conf = bandit_config.BanditConfig()
401
- b_mgr = manager.BanditManager(
402
- config=b_conf,
403
- agg_type="file",
404
- debug=False,
405
- verbose=False,
406
- quiet=True,
407
- profile=None,
408
- ignore_nosec=False,
409
- )
410
- b_mgr.discover_files([str(file_path)], False)
411
- b_mgr.run_tests()
412
-
413
- # Count high/medium severity issues
414
- issues = b_mgr.get_issue_list()
415
- high_severity = sum(1 for i in issues if i.severity == bandit.HIGH)
416
- medium_severity = sum(1 for i in issues if i.severity == bandit.MEDIUM)
417
-
418
- # Score: 10 - (high*3 + medium*1)
419
- score = 10.0 - (high_severity * 3.0 + medium_severity * 1.0)
420
- return max(0.0, score)
421
- except (FileNotFoundError, PermissionError, ValueError) as e:
422
- # Specific exceptions for file/system errors
423
- logger.warning(f"Security scoring failed for {file_path}: {e}")
424
- return 5.0 # Default neutral on error
425
- except Exception as e:
426
- # Catch-all for unexpected errors (should be rare)
427
- logger.warning(f"Unexpected error during security scoring for {file_path}: {e}", exc_info=True)
428
- return 5.0 # Default neutral on error
429
-
430
- def _calculate_maintainability(self, code: str) -> float:
431
- """
432
- Calculate maintainability index (0-10 scale, higher is better).
433
-
434
- Phase 3.1: Enhanced with context-aware scoring using MaintainabilityScorer.
435
- Phase 2 (P0): Maintainability issues are captured separately via get_maintainability_issues().
436
- """
437
- from .maintainability_scorer import MaintainabilityScorer
438
- from ...core.language_detector import Language
439
-
440
- # Use context-aware maintainability scorer
441
- scorer = MaintainabilityScorer()
442
- return scorer.calculate(code, Language.PYTHON, file_path=None, context=None)
443
-
444
- def get_maintainability_issues(
445
- self, code: str, file_path: Path | None = None
446
- ) -> list[dict[str, Any]]:
447
- """
448
- Get specific maintainability issues (Phase 2 - P0).
449
-
450
- Returns list of issues with details like missing docstrings, long functions, etc.
451
-
452
- Args:
453
- code: Source code content
454
- file_path: Optional path to the file
455
-
456
- Returns:
457
- List of maintainability issues with details
458
- """
459
- from .maintainability_scorer import MaintainabilityScorer
460
- from ...core.language_detector import Language
461
-
462
- scorer = MaintainabilityScorer()
463
- return scorer.get_issues(code, Language.PYTHON, file_path=file_path, context=None)
464
-
465
- def _calculate_test_coverage(self, file_path: Path) -> float:
466
- """
467
- Calculate test coverage score (0-10 scale, higher is better).
468
-
469
- Attempts to read coverage data from:
470
- 1. coverage.xml file in project root or .coverage file
471
- 2. Falls back to heuristic if no coverage data available
472
-
473
- Args:
474
- file_path: Path to the file being scored
475
-
476
- Returns:
477
- Coverage score (0-10 scale)
478
- """
479
- if not self.has_coverage:
480
- # No coverage tool available, use heuristic
481
- return self._coverage_heuristic(file_path)
482
-
483
- try:
484
- # Try to find and parse coverage data
485
- project_root = self._find_project_root(file_path)
486
- if project_root is None:
487
- return 5.0 # Neutral if can't find project root
488
-
489
- # Look for coverage.xml first (pytest-cov output)
490
- coverage_xml = project_root / "coverage.xml"
491
- if coverage_xml.exists():
492
- return self._parse_coverage_xml(coverage_xml, file_path)
493
-
494
- # Look for .coverage database file
495
- coverage_db = project_root / ".coverage"
496
- if coverage_db.exists():
497
- return self._parse_coverage_db(coverage_db, file_path)
498
-
499
- # No coverage data found, use heuristic
500
- return self._coverage_heuristic(file_path)
501
-
502
- except Exception:
503
- # Fallback to heuristic on any error
504
- return self._coverage_heuristic(file_path)
505
-
506
- def _parse_coverage_xml(self, coverage_xml: Path, file_path: Path) -> float:
507
- """Parse coverage.xml and return coverage percentage for file_path"""
508
- try:
509
- # coverage.xml is locally generated, but use defusedxml to reduce XML attack risk.
510
- from defusedxml import ElementTree as ET
511
-
512
- tree = ET.parse(coverage_xml)
513
- root = tree.getroot()
514
-
515
- # Get relative path from project root
516
- project_root = coverage_xml.parent
517
- try:
518
- rel_path = file_path.relative_to(project_root)
519
- file_path_str = str(rel_path).replace("\\", "/")
520
- except ValueError:
521
- # File not in project root
522
- return 5.0
523
-
524
- # Find coverage for this file
525
- for package in root.findall(".//package"):
526
- for class_elem in package.findall(".//class"):
527
- file_name = class_elem.get("filename", "")
528
- if file_name == file_path_str or file_path.name in file_name:
529
- # Get line-rate (coverage percentage)
530
- line_rate = float(class_elem.get("line-rate", "0.0"))
531
- # Convert 0-1 scale to 0-10 scale
532
- return line_rate * 10.0
533
-
534
- # File not found in coverage report
535
- return 0.0
536
- except Exception:
537
- return 5.0 # Default on error
538
-
539
- def _parse_coverage_db(self, coverage_db: Path, file_path: Path) -> float:
540
- """Parse .coverage database and return coverage percentage"""
541
- try:
542
- cov = Coverage()
543
- cov.load()
544
-
545
- # Get coverage data
546
- data = cov.get_data()
547
-
548
- # Try to find file in coverage data
549
- try:
550
- rel_path = file_path.relative_to(coverage_db.parent)
551
- file_path_str = str(rel_path).replace("\\", "/")
552
- except ValueError:
553
- return 5.0
554
-
555
- # Get coverage for this file
556
- if file_path_str in data.measured_files():
557
- # Calculate coverage percentage
558
- lines = data.lines(file_path_str)
559
- if not lines:
560
- return 0.0
561
-
562
- # Count covered vs total lines (simplified)
563
- # In practice, we'd need to check which lines are executable
564
- # For now, return neutral score
565
- return 5.0
566
-
567
- return 0.0 # File not covered
568
- except Exception:
569
- return 5.0
570
-
571
- def _coverage_heuristic(self, file_path: Path) -> float:
572
- """
573
- Heuristic-based coverage estimate.
574
-
575
- Phase 1 (P0): Fixed to return 0.0 when no test files exist.
576
-
577
- Checks for:
578
- - Test file existence (actual test files, not just directories)
579
- - Test directory structure
580
- - Test naming patterns
581
-
582
- Returns:
583
- - 0.0 if no test files exist (no tests written yet)
584
- - 5.0 if test files exist but no coverage data (tests exist but not run)
585
- - 10.0 if both test files and coverage data exist (not used here, handled by caller)
586
- """
587
- project_root = self._find_project_root(file_path)
588
- if project_root is None:
589
- return 0.0 # No project root = assume no tests (Phase 1 fix)
590
-
591
- # Look for test files with comprehensive patterns
592
- test_dirs = ["tests", "test", "tests/unit", "tests/integration", "tests/test", "test/test"]
593
- test_patterns = [
594
- f"test_{file_path.stem}.py",
595
- f"{file_path.stem}_test.py",
596
- f"test_{file_path.name}",
597
- # Also check for module-style test files
598
- f"test_{file_path.stem.replace('_', '')}.py",
599
- f"test_{file_path.stem.replace('-', '_')}.py",
600
- ]
601
-
602
- # Also check if the file itself is a test file
603
- if file_path.name.startswith("test_") or file_path.name.endswith("_test.py"):
604
- # File is a test file, assume it has coverage if it exists
605
- return 5.0 # Tests exist but no coverage data available
606
-
607
- # Check if any test files actually exist
608
- test_file_found = False
609
- for test_dir in test_dirs:
610
- test_dir_path = project_root / test_dir
611
- if test_dir_path.exists() and test_dir_path.is_dir():
612
- for pattern in test_patterns:
613
- test_file = test_dir_path / pattern
614
- if test_file.exists() and test_file.is_file():
615
- test_file_found = True
616
- break
617
- if test_file_found:
618
- break
619
-
620
- # Phase 1 fix: Return 0.0 if no test files exist (no tests written yet)
621
- if not test_file_found:
622
- return 0.0
623
-
624
- # Test files exist but not run yet
625
- return 5.0
626
-
627
- def _find_project_root(self, file_path: Path) -> Path | None:
628
- """Delegate to BaseScorer. Override for CodeScorer-specific markers if needed."""
629
- return BaseScorer._find_project_root(file_path)
630
-
631
- def get_performance_issues(
632
- self, code: str, file_path: Path | None = None
633
- ) -> list[dict[str, Any]]:
634
- """
635
- Get specific performance issues with line numbers (Phase 4 - P1).
636
-
637
- Returns list of performance bottlenecks with details like nested loops, expensive operations, etc.
638
-
639
- Args:
640
- code: Source code content
641
- file_path: Optional path to the file
642
-
643
- Returns:
644
- List of performance issues with line numbers
645
- """
646
- from .issue_tracking import PerformanceIssue
647
- import ast
648
-
649
- issues: list[PerformanceIssue] = []
650
- code_lines = code.splitlines()
651
-
652
- try:
653
- tree = ast.parse(code)
654
- except SyntaxError:
655
- return [PerformanceIssue(
656
- issue_type="syntax_error",
657
- message="File contains syntax errors - cannot analyze performance",
658
- severity="high"
659
- ).__dict__]
660
-
661
- # Analyze functions for performance issues
662
- for node in ast.walk(tree):
663
- if isinstance(node, ast.FunctionDef):
664
- # Check for nested loops
665
- for child in ast.walk(node):
666
- if isinstance(child, ast.For):
667
- # Check if this loop contains another loop
668
- for nested_child in ast.walk(child):
669
- if isinstance(nested_child, ast.For) and nested_child != child:
670
- # Found nested loop
671
- issues.append(PerformanceIssue(
672
- issue_type="nested_loops",
673
- message=f"Nested for loops detected in function '{node.name}' - potential O(n²) complexity",
674
- line_number=child.lineno,
675
- severity="high",
676
- operation_type="loop",
677
- context=f"Nested in function '{node.name}'",
678
- suggestion="Consider using itertools.product() or list comprehensions to flatten nested loops"
679
- ))
680
-
681
- # Check for expensive operations in loops
682
- if isinstance(child, ast.For):
683
- for loop_child in ast.walk(child):
684
- if isinstance(loop_child, ast.Call):
685
- # Check for expensive function calls in loops
686
- if isinstance(loop_child.func, ast.Name):
687
- func_name = loop_child.func.id
688
- expensive_operations = ["time.fromisoformat", "datetime.fromisoformat", "re.compile", "json.loads"]
689
- if any(exp_op in func_name for exp_op in expensive_operations):
690
- issues.append(PerformanceIssue(
691
- issue_type="expensive_operation_in_loop",
692
- message=f"Expensive operation '{func_name}' called in loop at line {loop_child.lineno} - parse once before loop",
693
- line_number=loop_child.lineno,
694
- severity="medium",
695
- operation_type=func_name,
696
- context=f"In loop at line {child.lineno}",
697
- suggestion=f"Move '{func_name}' call outside the loop and cache the result"
698
- ))
699
-
700
- # Check for list comprehensions with function calls
701
- if isinstance(node, ast.ListComp):
702
- func_calls_in_comp = sum(1 for n in ast.walk(node) if isinstance(n, ast.Call))
703
- if func_calls_in_comp > 5:
704
- issues.append(PerformanceIssue(
705
- issue_type="expensive_comprehension",
706
- message=f"List comprehension at line {node.lineno} contains {func_calls_in_comp} function calls - consider using generator or loop",
707
- line_number=node.lineno,
708
- severity="medium",
709
- operation_type="comprehension",
710
- suggestion="Consider using a generator expression or a loop for better performance"
711
- ))
712
-
713
- # Convert to dict format
714
- return [
715
- {
716
- "issue_type": issue.issue_type,
717
- "message": issue.message,
718
- "line_number": issue.line_number,
719
- "severity": issue.severity,
720
- "suggestion": issue.suggestion,
721
- "operation_type": issue.operation_type,
722
- "context": issue.context,
723
- }
724
- for issue in issues
725
- ]
726
-
727
- def _calculate_performance(self, code: str) -> float:
728
- """
729
- Calculate performance score using static analysis (0-10 scale, higher is better).
730
-
731
- Checks for:
732
- - Function size (number of lines)
733
- - Nesting depth
734
- - Inefficient patterns (N+1 queries, nested loops, etc.)
735
- - Large list/dict comprehensions
736
- """
737
- try:
738
- tree = ast.parse(code)
739
- issues = []
740
-
741
- # Analyze functions
742
- for node in ast.walk(tree):
743
- if isinstance(node, ast.FunctionDef):
744
- # Check function size
745
- # Use end_lineno if available (Python 3.8+), otherwise estimate
746
- if hasattr(node, "end_lineno") and node.end_lineno is not None:
747
- func_lines = node.end_lineno - node.lineno
748
- else:
749
- # Estimate: count lines in function body
750
- func_lines = (
751
- len(code.split("\n")[node.lineno - 1 : node.lineno + 49])
752
- if len(code.split("\n")) > node.lineno
753
- else 50
754
- )
755
-
756
- if func_lines > 50:
757
- issues.append("large_function") # > 50 lines
758
- if func_lines > 100:
759
- issues.append("very_large_function") # > 100 lines
760
-
761
- # Check nesting depth
762
- max_depth = self._get_max_nesting_depth(node)
763
- if max_depth > 4:
764
- issues.append("deep_nesting") # > 4 levels
765
- if max_depth > 6:
766
- issues.append("very_deep_nesting") # > 6 levels
767
-
768
- # Check for nested loops (potential N^2 complexity)
769
- if isinstance(node, ast.For):
770
- for child in ast.walk(node):
771
- if isinstance(child, ast.For) and child != node:
772
- issues.append("nested_loops")
773
- break
774
-
775
- # Check for list comprehensions with function calls
776
- if isinstance(node, ast.ListComp):
777
- # Count function calls in comprehension
778
- func_calls = sum(
779
- 1 for n in ast.walk(node) if isinstance(n, ast.Call)
780
- )
781
- if func_calls > 5:
782
- issues.append("expensive_comprehension")
783
-
784
- # Calculate score based on issues
785
- # Start with 10, deduct points for issues
786
- score = 10.0
787
- penalty_map = {
788
- "large_function": 0.5,
789
- "very_large_function": 1.5,
790
- "deep_nesting": 1.0,
791
- "very_deep_nesting": 2.0,
792
- "nested_loops": 1.5,
793
- "expensive_comprehension": 0.5,
794
- }
795
-
796
- seen_issues = set()
797
- for issue in issues:
798
- if issue not in seen_issues:
799
- score -= penalty_map.get(issue, 0.5)
800
- seen_issues.add(issue)
801
-
802
- return max(0.0, min(10.0, score))
803
-
804
- except SyntaxError:
805
- return 0.0 # Syntax errors = worst performance score
806
- except Exception:
807
- return 5.0 # Default on error
808
-
809
- def _get_max_nesting_depth(self, node: ast.AST, current_depth: int = 0) -> int:
810
- """Calculate maximum nesting depth in an AST node"""
811
- max_depth = current_depth
812
-
813
- for child in ast.iter_child_nodes(node):
814
- # Count nesting for control structures
815
- if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.With)):
816
- child_depth = self._get_max_nesting_depth(child, current_depth + 1)
817
- max_depth = max(max_depth, child_depth)
818
- else:
819
- child_depth = self._get_max_nesting_depth(child, current_depth)
820
- max_depth = max(max_depth, child_depth)
821
-
822
- return max_depth
823
-
824
- def _calculate_linting_score(self, file_path: Path) -> float:
825
- """
826
- Calculate linting score using Ruff (0-10 scale, higher is better).
827
-
828
- Phase 6: Modern Quality Analysis - Ruff Integration
829
-
830
- Returns:
831
- Linting score (0-10), where 10 = no issues, 0 = many issues
832
- """
833
- if not self.has_ruff:
834
- return 5.0 # Neutral score if Ruff not available
835
-
836
- # Only check Python files
837
- if file_path.suffix != ".py":
838
- return 10.0 # Perfect score for non-Python files (can't lint)
839
-
840
- try:
841
- # Run ruff check with JSON output
842
- result = subprocess.run( # nosec B603
843
- [
844
- sys.executable,
845
- "-m",
846
- "ruff",
847
- "check",
848
- "--output-format=json",
849
- str(file_path),
850
- ],
851
- capture_output=True,
852
- text=True,
853
- encoding="utf-8",
854
- errors="replace",
855
- timeout=30, # 30 second timeout
856
- cwd=file_path.parent if file_path.parent.exists() else None,
857
- )
858
-
859
- # Parse JSON output
860
- if result.returncode == 0 and not result.stdout.strip():
861
- # No issues found
862
- return 10.0
863
-
864
- try:
865
- # Ruff JSON format: list of diagnostic objects
866
- diagnostics = (
867
- json_lib.loads(result.stdout) if result.stdout.strip() else []
868
- )
869
-
870
- if not diagnostics:
871
- return 10.0
872
-
873
- # Count issues by severity
874
- # Ruff severity levels: E (Error), W (Warning), F (Fatal), I (Info)
875
- error_count = sum(
876
- 1
877
- for d in diagnostics
878
- if d.get("code", {}).get("name", "").startswith("E")
879
- )
880
- warning_count = sum(
881
- 1
882
- for d in diagnostics
883
- if d.get("code", {}).get("name", "").startswith("W")
884
- )
885
- fatal_count = sum(
886
- 1
887
- for d in diagnostics
888
- if d.get("code", {}).get("name", "").startswith("F")
889
- )
890
-
891
- # Calculate score: Start at 10, deduct points
892
- # Errors (E): -2 points each
893
- # Fatal (F): -3 points each
894
- # Warnings (W): -0.5 points each
895
- score = 10.0
896
- score -= error_count * 2.0
897
- score -= fatal_count * 3.0
898
- score -= warning_count * 0.5
899
-
900
- return max(0.0, min(10.0, score))
901
-
902
- except json_lib.JSONDecodeError:
903
- # If JSON parsing fails, check stderr for errors
904
- if result.stderr:
905
- return 5.0 # Neutral on parsing error
906
- return 10.0 # No output = no issues
907
-
908
- except subprocess.TimeoutExpired:
909
- return 5.0 # Neutral on timeout
910
- except FileNotFoundError:
911
- # Ruff not found in PATH
912
- return 5.0
913
- except Exception:
914
- # Any other error
915
- return 5.0
916
-
917
- def get_ruff_issues(self, file_path: Path) -> list[dict[str, Any]]:
918
- """
919
- Get detailed Ruff linting issues for a file.
920
-
921
- Phase 6: Modern Quality Analysis - Ruff Integration
922
-
923
- Returns:
924
- List of diagnostic dictionaries with code, message, location, etc.
925
- """
926
- if not self.has_ruff or file_path.suffix != ".py":
927
- return []
928
-
929
- try:
930
- result = subprocess.run( # nosec B603
931
- [
932
- sys.executable,
933
- "-m",
934
- "ruff",
935
- "check",
936
- "--output-format=json",
937
- str(file_path),
938
- ],
939
- capture_output=True,
940
- text=True,
941
- encoding="utf-8",
942
- errors="replace",
943
- timeout=30,
944
- cwd=file_path.parent if file_path.parent.exists() else None,
945
- )
946
-
947
- if result.returncode == 0 and not result.stdout.strip():
948
- return []
949
-
950
- try:
951
- diagnostics = (
952
- json_lib.loads(result.stdout) if result.stdout.strip() else []
953
- )
954
- return diagnostics
955
- except json_lib.JSONDecodeError:
956
- return []
957
-
958
- except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
959
- return []
960
-
961
- def _calculate_type_checking_score(self, file_path: Path) -> float:
962
- """
963
- Calculate type checking score using mypy (0-10 scale, higher is better).
964
-
965
- Phase 6.2: Modern Quality Analysis - mypy Integration
966
- Phase 5 (P1): Fixed to actually run mypy and return real scores (not static 5.0).
967
- ENH-002-S2: Prefer ScopedMypyExecutor (--follow-imports=skip, <10s) with fallback to full mypy.
968
- """
969
- if not self.has_mypy:
970
- logger.debug("mypy not available - returning neutral score")
971
- return 5.0 # Neutral score if mypy not available
972
-
973
- # Only check Python files
974
- if file_path.suffix != ".py":
975
- return 10.0 # Perfect score for non-Python files (can't type check)
976
-
977
- # ENH-002-S2: Try scoped mypy first (faster)
978
- try:
979
- from .tools.scoped_mypy import ScopedMypyExecutor
980
- executor = ScopedMypyExecutor()
981
- result = executor.run_scoped_sync(file_path, timeout=10)
982
- if result.files_checked == 1 or result.issues:
983
- error_count = len(result.issues)
984
- if error_count == 0:
985
- return 10.0
986
- score = 10.0 - (error_count * 0.5)
987
- logger.debug(
988
- "mypy (scoped) found %s errors for %s, score: %s/10",
989
- error_count, file_path, score,
990
- )
991
- return max(0.0, min(10.0, score))
992
- except Exception as e:
993
- logger.debug("scoped mypy not used, falling back to full mypy: %s", e)
994
-
995
- try:
996
- result = subprocess.run( # nosec B603
997
- [
998
- sys.executable,
999
- "-m",
1000
- "mypy",
1001
- "--show-error-codes",
1002
- "--no-error-summary",
1003
- "--no-color-output",
1004
- "--no-incremental",
1005
- str(file_path),
1006
- ],
1007
- capture_output=True,
1008
- text=True,
1009
- encoding="utf-8",
1010
- errors="replace",
1011
- timeout=30,
1012
- cwd=file_path.parent if file_path.parent.exists() else None,
1013
- )
1014
- if result.returncode == 0:
1015
- logger.debug("mypy found no errors for %s", file_path)
1016
- return 10.0
1017
- output = result.stdout.strip()
1018
- if not output:
1019
- logger.debug("mypy returned non-zero but no output for %s", file_path)
1020
- return 10.0
1021
- error_lines = [
1022
- line
1023
- for line in output.split("\n")
1024
- if "error:" in line.lower() and file_path.name in line
1025
- ]
1026
- error_count = len(error_lines)
1027
- if error_count == 0:
1028
- logger.debug("mypy returned non-zero but no parseable errors for %s", file_path)
1029
- return 10.0
1030
- score = 10.0 - (error_count * 0.5)
1031
- logger.debug("mypy found %s errors for %s, score: %s/10", error_count, file_path, score)
1032
- return max(0.0, min(10.0, score))
1033
- except subprocess.TimeoutExpired:
1034
- logger.warning("mypy timed out for %s", file_path)
1035
- return 5.0
1036
- except FileNotFoundError:
1037
- logger.debug("mypy not found in PATH for %s", file_path)
1038
- self.has_mypy = False
1039
- return 5.0
1040
- except Exception as e:
1041
- logger.warning("mypy failed for %s: %s", file_path, e, exc_info=True)
1042
- return 5.0
1043
-
1044
- def get_mypy_errors(self, file_path: Path) -> list[dict[str, Any]]:
1045
- """
1046
- Get detailed mypy type checking errors for a file.
1047
-
1048
- Phase 6.2: Modern Quality Analysis - mypy Integration
1049
- ENH-002-S2: Prefer ScopedMypyExecutor with fallback to full mypy.
1050
- """
1051
- if not self.has_mypy or file_path.suffix != ".py":
1052
- return []
1053
-
1054
- try:
1055
- from .tools.scoped_mypy import ScopedMypyExecutor
1056
- executor = ScopedMypyExecutor()
1057
- result = executor.run_scoped_sync(file_path, timeout=10)
1058
- if result.files_checked == 1 or result.issues:
1059
- return [
1060
- {
1061
- "filename": str(i.file_path),
1062
- "line": i.line,
1063
- "message": i.message,
1064
- "error_code": i.error_code,
1065
- "severity": i.severity,
1066
- }
1067
- for i in result.issues
1068
- ]
1069
- except Exception:
1070
- pass
1071
-
1072
- try:
1073
- result = subprocess.run( # nosec B603
1074
- [
1075
- sys.executable,
1076
- "-m",
1077
- "mypy",
1078
- "--show-error-codes",
1079
- "--no-error-summary",
1080
- "--no-incremental",
1081
- str(file_path),
1082
- ],
1083
- capture_output=True,
1084
- text=True,
1085
- encoding="utf-8",
1086
- errors="replace",
1087
- timeout=30,
1088
- cwd=file_path.parent if file_path.parent.exists() else None,
1089
- )
1090
- if result.returncode == 0 or not result.stdout.strip():
1091
- return []
1092
- errors = []
1093
- for line in result.stdout.strip().split("\n"):
1094
- if "error:" not in line.lower():
1095
- continue
1096
- parts = line.split(":", 3)
1097
- if len(parts) >= 4:
1098
- filename = parts[0]
1099
- try:
1100
- line_num = int(parts[1])
1101
- except ValueError:
1102
- continue
1103
- error_msg = parts[3].strip()
1104
- error_code = None
1105
- if "[" in error_msg and "]" in error_msg:
1106
- start = error_msg.rfind("[")
1107
- end = error_msg.rfind("]")
1108
- if start < end:
1109
- error_code = error_msg[start + 1 : end]
1110
- error_msg = error_msg[:start].strip()
1111
- errors.append({
1112
- "filename": filename,
1113
- "line": line_num,
1114
- "message": error_msg,
1115
- "error_code": error_code,
1116
- "severity": "error",
1117
- })
1118
- return errors
1119
- except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
1120
- return []
1121
-
1122
- def _format_ruff_issues(self, diagnostics: list[dict[str, Any]]) -> list[dict[str, Any]]:
1123
- """
1124
- Format raw ruff diagnostics into a cleaner structure for output.
1125
-
1126
- P1 Improvement: Include actual lint errors in score output.
1127
-
1128
- Args:
1129
- diagnostics: Raw ruff JSON diagnostics
1130
-
1131
- Returns:
1132
- List of formatted issues with line, code, message, severity
1133
- """
1134
- formatted = []
1135
- for diag in diagnostics:
1136
- # Extract code info (ruff format: {"code": {"name": "F401", ...}})
1137
- code_info = diag.get("code", {})
1138
- if isinstance(code_info, dict):
1139
- code = code_info.get("name", "")
1140
- else:
1141
- code = str(code_info)
1142
-
1143
- # Determine severity from code prefix
1144
- severity = "warning"
1145
- if code.startswith("E") or code.startswith("F"):
1146
- severity = "error"
1147
- elif code.startswith("W"):
1148
- severity = "warning"
1149
- elif code.startswith("I"):
1150
- severity = "info"
1151
-
1152
- # Get location info
1153
- location = diag.get("location", {})
1154
- line = location.get("row", 0) if isinstance(location, dict) else 0
1155
- column = location.get("column", 0) if isinstance(location, dict) else 0
1156
-
1157
- formatted.append({
1158
- "code": code,
1159
- "message": diag.get("message", ""),
1160
- "line": line,
1161
- "column": column,
1162
- "severity": severity,
1163
- })
1164
-
1165
- # Sort by line number
1166
- formatted.sort(key=lambda x: (x.get("line", 0), x.get("column", 0)))
1167
-
1168
- return formatted
1169
-
1170
- def _group_ruff_issues_by_code(self, issues: list[dict[str, Any]]) -> dict[str, Any]:
1171
- """
1172
- Group ruff issues by rule code for cleaner, more actionable reports.
1173
-
1174
- ENH-002 Story #18: Ruff Output Grouping
1175
-
1176
- Args:
1177
- issues: List of ruff diagnostic dictionaries
1178
-
1179
- Returns:
1180
- Dictionary with grouped issues:
1181
- {
1182
- "total_count": int,
1183
- "groups": [
1184
- {
1185
- "code": "UP006",
1186
- "count": 17,
1187
- "description": "Use dict/list instead of Dict/List",
1188
- "severity": "info",
1189
- "issues": [...]
1190
- },
1191
- ...
1192
- ],
1193
- "summary": "UP006 (17), UP045 (10), UP007 (2), F401 (1)"
1194
- }
1195
- """
1196
- if not issues:
1197
- return {
1198
- "total_count": 0,
1199
- "groups": [],
1200
- "summary": "No issues found"
1201
- }
1202
-
1203
- # Group issues by code
1204
- groups_dict: dict[str, list[dict[str, Any]]] = {}
1205
- for issue in issues:
1206
- code_info = issue.get("code", {})
1207
- if isinstance(code_info, dict):
1208
- code = code_info.get("name", "UNKNOWN")
1209
- else:
1210
- code = str(code_info) if code_info else "UNKNOWN"
1211
-
1212
- if code not in groups_dict:
1213
- groups_dict[code] = []
1214
- groups_dict[code].append(issue)
1215
-
1216
- # Create grouped structure with metadata
1217
- groups = []
1218
- for code, code_issues in groups_dict.items():
1219
- # Get first message as description (they're usually the same for same code)
1220
- description = code_issues[0].get("message", "") if code_issues else ""
1221
-
1222
- # Determine severity from code
1223
- severity = "info"
1224
- if code.startswith("E") or code.startswith("F"):
1225
- severity = "error"
1226
- elif code.startswith("W"):
1227
- severity = "warning"
1228
-
1229
- groups.append({
1230
- "code": code,
1231
- "count": len(code_issues),
1232
- "description": description,
1233
- "severity": severity,
1234
- "issues": code_issues
1235
- })
1236
-
1237
- # Sort by count (descending) then by code
1238
- groups.sort(key=lambda x: (-x["count"], x["code"]))
1239
-
1240
- # Create summary string: "UP006 (17), UP045 (10), ..."
1241
- summary_parts = [f"{g['code']} ({g['count']})" for g in groups]
1242
- summary = ", ".join(summary_parts)
1243
-
1244
- return {
1245
- "total_count": len(issues),
1246
- "groups": groups,
1247
- "summary": summary
1248
- }
1249
-
1250
- def _calculate_duplication_score(self, file_path: Path) -> float:
1251
- """
1252
- Calculate duplication score using jscpd (0-10 scale, higher is better).
1253
-
1254
- Phase 6.4: Modern Quality Analysis - jscpd Integration
1255
-
1256
- Note: jscpd works on directories/files, so we analyze the parent directory
1257
- or file directly. For single file, we analyze just that file.
1258
-
1259
- Returns:
1260
- Duplication score (0-10), where 10 = no duplication, 0 = high duplication
1261
- Score formula: 10 - (duplication_pct / 10)
1262
- """
1263
- if not self.has_jscpd:
1264
- return 5.0 # Neutral score if jscpd not available
1265
-
1266
- # jscpd works best on directories or multiple files
1267
- # For single file analysis, we'll analyze the file directly
1268
- try:
1269
- # Determine target (file or directory)
1270
- target = str(file_path)
1271
- if file_path.is_dir():
1272
- target_dir = str(file_path)
1273
- else:
1274
- target_dir = str(file_path.parent)
1275
-
1276
- # Build jscpd command
1277
- # Use npx if jscpd not directly available
1278
- jscpd_path = shutil.which("jscpd")
1279
- if jscpd_path:
1280
- cmd = [jscpd_path]
1281
- else:
1282
- npx_path = shutil.which("npx")
1283
- if not npx_path:
1284
- return 5.0 # jscpd not available
1285
- cmd = [npx_path, "--yes", "jscpd"]
1286
-
1287
- # Add jscpd arguments
1288
- cmd.extend(
1289
- [
1290
- target,
1291
- "--format",
1292
- "json",
1293
- "--min-lines",
1294
- str(self.min_duplication_lines),
1295
- "--reporters",
1296
- "json",
1297
- "--output",
1298
- ".", # Output to current directory
1299
- ]
1300
- )
1301
-
1302
- # Run jscpd
1303
- result = subprocess.run( # nosec B603 - fixed args
1304
- wrap_windows_cmd_shim(cmd),
1305
- capture_output=True,
1306
- text=True,
1307
- encoding="utf-8",
1308
- errors="replace",
1309
- timeout=120, # 2 minute timeout (jscpd can be slow on large codebases)
1310
- cwd=target_dir if Path(target_dir).exists() else None,
1311
- )
1312
-
1313
- # jscpd outputs JSON to stdout when using --reporters json
1314
- # But it might also create a file, so check both
1315
- json_output = result.stdout.strip()
1316
-
1317
- # Try to parse JSON from stdout
1318
- try:
1319
- if json_output:
1320
- report_data = json_lib.loads(json_output)
1321
- else:
1322
- # Check for jscpd-report.json in output directory
1323
- output_file = Path(target_dir) / "jscpd-report.json"
1324
- if output_file.exists():
1325
- with open(output_file, encoding="utf-8") as f:
1326
- report_data = json_lib.load(f)
1327
- else:
1328
- # No duplication found (exit code 0 typically means no issues or success)
1329
- if result.returncode == 0:
1330
- return 10.0 # Perfect score (no duplication)
1331
- return 5.0 # Neutral on parse failure
1332
- except json_lib.JSONDecodeError:
1333
- # JSON parse error - might be text output
1334
- # Try to extract duplication percentage from text output
1335
- # Format: "Found X% duplicated lines out of Y total lines"
1336
- lines = result.stdout.split("\n") + result.stderr.split("\n")
1337
- for line in lines:
1338
- if "%" in line and "duplicate" in line.lower():
1339
- # Try to extract percentage
1340
- try:
1341
- pct_str = line.split("%")[0].split()[-1]
1342
- duplication_pct = float(pct_str)
1343
- score = 10.0 - (duplication_pct / 10.0)
1344
- return max(0.0, min(10.0, score))
1345
- except (ValueError, IndexError):
1346
- pass
1347
-
1348
- # If we can't parse, default behavior
1349
- if result.returncode == 0:
1350
- return 10.0 # No duplication found
1351
- return 5.0 # Neutral on parse failure
1352
-
1353
- # Extract duplication percentage from JSON report
1354
- # jscpd JSON structure: { "percentage": X.X, ... }
1355
- duplication_pct = report_data.get("percentage", 0.0)
1356
-
1357
- # Calculate score: 10 - (duplication_pct / 10)
1358
- # This means:
1359
- # - 0% duplication = 10.0 score
1360
- # - 3% duplication (threshold) = 9.7 score
1361
- # - 10% duplication = 9.0 score
1362
- # - 30% duplication = 7.0 score
1363
- # - 100% duplication = 0.0 score
1364
- score = 10.0 - (duplication_pct / 10.0)
1365
- return max(0.0, min(10.0, score))
1366
-
1367
- except subprocess.TimeoutExpired:
1368
- return 5.0 # Neutral on timeout
1369
- except FileNotFoundError:
1370
- # jscpd not found
1371
- return 5.0
1372
- except Exception:
1373
- # Any other error
1374
- return 5.0
1375
-
1376
- def get_duplication_report(self, file_path: Path) -> dict[str, Any]:
1377
- """
1378
- Get detailed duplication report from jscpd.
1379
-
1380
- Phase 6.4: Modern Quality Analysis - jscpd Integration
1381
-
1382
- Returns:
1383
- Dictionary with duplication report data including:
1384
- - percentage: Duplication percentage
1385
- - duplicates: List of duplicate code blocks
1386
- - files: File-level duplication stats
1387
- """
1388
- if not self.has_jscpd:
1389
- return {
1390
- "available": False,
1391
- "percentage": 0.0,
1392
- "duplicates": [],
1393
- "files": [],
1394
- }
1395
-
1396
- try:
1397
- # Determine target
1398
- if file_path.is_dir():
1399
- target_dir = str(file_path)
1400
- target = str(file_path)
1401
- else:
1402
- target_dir = str(file_path.parent)
1403
- target = str(file_path)
1404
-
1405
- # Build jscpd command
1406
- jscpd_path = shutil.which("jscpd")
1407
- if jscpd_path:
1408
- cmd = [jscpd_path]
1409
- else:
1410
- npx_path = shutil.which("npx")
1411
- if not npx_path:
1412
- return {
1413
- "available": False,
1414
- "percentage": 0.0,
1415
- "duplicates": [],
1416
- "files": [],
1417
- }
1418
- cmd = [npx_path, "--yes", "jscpd"]
1419
-
1420
- cmd.extend(
1421
- [
1422
- target,
1423
- "--format",
1424
- "json",
1425
- "--min-lines",
1426
- str(self.min_duplication_lines),
1427
- "--reporters",
1428
- "json",
1429
- ]
1430
- )
1431
-
1432
- # Run jscpd
1433
- result = subprocess.run( # nosec B603 - fixed args
1434
- wrap_windows_cmd_shim(cmd),
1435
- capture_output=True,
1436
- text=True,
1437
- encoding="utf-8",
1438
- errors="replace",
1439
- timeout=120,
1440
- cwd=target_dir if Path(target_dir).exists() else None,
1441
- )
1442
-
1443
- # Parse JSON output
1444
- json_output = result.stdout.strip()
1445
- try:
1446
- if json_output:
1447
- report_data = json_lib.loads(json_output)
1448
- else:
1449
- # Check for output file
1450
- output_file = Path(target_dir) / "jscpd-report.json"
1451
- if output_file.exists():
1452
- with open(output_file, encoding="utf-8") as f:
1453
- report_data = json_lib.load(f)
1454
- else:
1455
- return {
1456
- "available": True,
1457
- "percentage": 0.0,
1458
- "duplicates": [],
1459
- "files": [],
1460
- }
1461
- except json_lib.JSONDecodeError:
1462
- return {
1463
- "available": True,
1464
- "error": "Failed to parse jscpd output",
1465
- "percentage": 0.0,
1466
- "duplicates": [],
1467
- "files": [],
1468
- }
1469
-
1470
- # Extract relevant data from jscpd report
1471
- # jscpd JSON format varies, but typically has:
1472
- # - percentage: overall duplication percentage
1473
- # - duplicates: array of duplicate pairs
1474
- # - files: file-level statistics
1475
-
1476
- return {
1477
- "available": True,
1478
- "percentage": report_data.get("percentage", 0.0),
1479
- "duplicates": report_data.get("duplicates", []),
1480
- "files": (
1481
- report_data.get("statistics", {}).get("files", [])
1482
- if "statistics" in report_data
1483
- else []
1484
- ),
1485
- "total_lines": (
1486
- report_data.get("statistics", {}).get("total", {}).get("lines", 0)
1487
- if "statistics" in report_data
1488
- else 0
1489
- ),
1490
- "duplicated_lines": (
1491
- report_data.get("statistics", {})
1492
- .get("duplicated", {})
1493
- .get("lines", 0)
1494
- if "statistics" in report_data
1495
- else 0
1496
- ),
1497
- }
1498
-
1499
- except subprocess.TimeoutExpired:
1500
- return {
1501
- "available": True,
1502
- "error": "jscpd timeout",
1503
- "percentage": 0.0,
1504
- "duplicates": [],
1505
- "files": [],
1506
- }
1507
- except FileNotFoundError:
1508
- return {
1509
- "available": False,
1510
- "percentage": 0.0,
1511
- "duplicates": [],
1512
- "files": [],
1513
- }
1514
- except Exception as e:
1515
- return {
1516
- "available": True,
1517
- "error": str(e),
1518
- "percentage": 0.0,
1519
- "duplicates": [],
1520
- "files": [],
1521
- }
1522
-
1523
-
1524
- class ScorerFactory:
1525
- """
1526
- Factory to provide appropriate scorer based on language (Strategy Pattern).
1527
-
1528
- Phase 1.2: Language-Specific Scorers
1529
-
1530
- Now uses ScorerRegistry for extensible language support.
1531
- """
1532
-
1533
- @staticmethod
1534
- def get_scorer(language: Language, config: ProjectConfig | None = None) -> BaseScorer:
1535
- """
1536
- Get the appropriate scorer for a given language.
1537
-
1538
- Uses ScorerRegistry for extensible language support with fallback chains.
1539
-
1540
- Args:
1541
- language: Detected language enum
1542
- config: Optional project configuration
1543
-
1544
- Returns:
1545
- BaseScorer instance appropriate for the language
1546
-
1547
- Raises:
1548
- ValueError: If no scorer is available for the language (even with fallbacks)
1549
- """
1550
- from .scorer_registry import ScorerRegistry
1551
-
1552
- try:
1553
- return ScorerRegistry.get_scorer(language, config)
1554
- except ValueError:
1555
- # If no scorer found, fall back to Python scorer as last resort
1556
- # This maintains backward compatibility but may not work well for non-Python code
1557
- # TODO: In the future, create a GenericScorer that uses metric strategies
1558
- if language != Language.PYTHON:
1559
- # Try Python scorer as absolute last resort
1560
- try:
1561
- return ScorerRegistry.get_scorer(Language.PYTHON, config)
1562
- except ValueError:
1563
- pass
1564
-
1565
- # If even Python scorer isn't available, raise the original error
1566
- raise
1
+ """
2
+ Code Scoring System - Calculates objective quality metrics
3
+ """
4
+
5
+ import ast
6
+ import json as json_lib
7
+ import logging
8
+ import shutil
9
+ import subprocess # nosec B404 - used with fixed args, no shell
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ from ...core.config import ProjectConfig, ScoringWeightsConfig
17
+ from ...core.language_detector import Language
18
+ from ...core.subprocess_utils import wrap_windows_cmd_shim
19
+ from .score_constants import ComplexityConstants, SecurityConstants
20
+ from .validation import validate_code_input
21
+
22
+ # Import analysis libraries
23
+ try:
24
+ from radon.complexity import cc_visit
25
+ from radon.metrics import mi_visit
26
+
27
+ HAS_RADON = True
28
+ except ImportError:
29
+ HAS_RADON = False
30
+
31
+ try:
32
+ import bandit
33
+ from bandit.core import config as bandit_config
34
+ from bandit.core import manager
35
+
36
+ HAS_BANDIT = True
37
+ except ImportError:
38
+ HAS_BANDIT = False
39
+
40
+ # Check if ruff is available in PATH or via python -m ruff
41
+ def _check_ruff_available() -> bool:
42
+ """Check if ruff is available via 'ruff' command or 'python -m ruff'"""
43
+ # Check for ruff command directly
44
+ if shutil.which("ruff"):
45
+ return True
46
+ # Check for python -m ruff
47
+ try:
48
+ result = subprocess.run( # nosec B603 - fixed args
49
+ [sys.executable, "-m", "ruff", "--version"],
50
+ capture_output=True,
51
+ timeout=5,
52
+ check=False,
53
+ )
54
+ return result.returncode == 0
55
+ except (subprocess.TimeoutExpired, FileNotFoundError):
56
+ return False
57
+
58
+
59
+ HAS_RUFF = _check_ruff_available()
60
+
61
+ # Check if mypy is available in PATH
62
+ HAS_MYPY = shutil.which("mypy") is not None
63
+
64
+
65
+ # Check if jscpd is available (via npm/npx)
66
+ def _check_jscpd_available() -> bool:
67
+ """Check if jscpd is available via jscpd command or npx jscpd"""
68
+ # Check for jscpd command directly
69
+ if shutil.which("jscpd"):
70
+ return True
71
+ # Check for npx (Node.js package runner)
72
+ npx_path = shutil.which("npx")
73
+ if npx_path:
74
+ try:
75
+ cmd = wrap_windows_cmd_shim([npx_path, "--yes", "jscpd", "--version"])
76
+ result = subprocess.run( # nosec B603 - fixed args
77
+ cmd,
78
+ capture_output=True,
79
+ timeout=5,
80
+ check=False,
81
+ )
82
+ return result.returncode == 0
83
+ except (subprocess.TimeoutExpired, FileNotFoundError):
84
+ return False
85
+ return False
86
+
87
+
88
+ HAS_JSCPD = _check_jscpd_available()
89
+
90
+ # Import coverage tools
91
+ try:
92
+ from coverage import Coverage
93
+
94
+ HAS_COVERAGE = True
95
+ except ImportError:
96
+ HAS_COVERAGE = False
97
+
98
+
99
+ class BaseScorer:
100
+ """
101
+ Base class for all scorers.
102
+ Defines the interface and shared helpers: _find_project_root, _calculate_structure_score, _calculate_devex_score (7-category §3.2).
103
+ """
104
+
105
+ def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
106
+ """Score a file and return quality metrics. Subclasses must implement."""
107
+ raise NotImplementedError("Subclasses must implement score_file")
108
+
109
+ @staticmethod
110
+ def _find_project_root(file_path: Path) -> Path | None:
111
+ """Find project root by common markers (.git, pyproject.toml, package.json, .tapps-agents, etc.)."""
112
+ current = file_path.resolve().parent
113
+ markers = [".git", "pyproject.toml", "setup.py", "requirements.txt", ".tapps-agents", "package.json"]
114
+ for _ in range(10):
115
+ for m in markers:
116
+ if (current / m).exists():
117
+ return current
118
+ if current.parent == current:
119
+ break
120
+ current = current.parent
121
+ return None
122
+
123
+ @classmethod
124
+ def _calculate_structure_score(cls, file_path: Path) -> float:
125
+ """Structure score (0-10). Project layout, key files. 7-category §3.2."""
126
+ root = BaseScorer._find_project_root(file_path)
127
+ if root is None:
128
+ return 5.0
129
+ pts = 0.0
130
+ if (root / "pyproject.toml").exists() or (root / "package.json").exists():
131
+ pts += 2.5
132
+ if (root / "README").exists() or (root / "README.md").exists() or (root / "README.rst").exists():
133
+ pts += 2.0
134
+ if (root / "tests").exists() or (root / "test").exists():
135
+ pts += 2.0
136
+ if (root / ".tapps-agents").exists() or (root / ".git").exists():
137
+ pts += 1.0
138
+ if (root / "setup.py").exists() or (root / "requirements.txt").exists() or (root / "package-lock.json").exists():
139
+ pts += 1.5
140
+ return min(10.0, pts * 2.0)
141
+
142
+ @classmethod
143
+ def _calculate_devex_score(cls, file_path: Path) -> float:
144
+ """DevEx score (0-10). Docs, config, tooling. 7-category §3.2."""
145
+ root = BaseScorer._find_project_root(file_path)
146
+ if root is None:
147
+ return 5.0
148
+ pts = 0.0
149
+ if (root / "AGENTS.md").exists() or (root / "CLAUDE.md").exists():
150
+ pts += 3.0
151
+ if (root / "docs").exists() and (root / "docs").is_dir():
152
+ pts += 2.0
153
+ if (root / ".tapps-agents").exists() or (root / ".cursor").exists():
154
+ pts += 2.0
155
+ pyproject, pkg = root / "pyproject.toml", root / "package.json"
156
+ if pyproject.exists():
157
+ try:
158
+ t = pyproject.read_text(encoding="utf-8", errors="replace")
159
+ if "[tool.ruff]" in t or "[tool.mypy]" in t or "pytest" in t:
160
+ pts += 1.5
161
+ except Exception:
162
+ pass
163
+ if pkg.exists():
164
+ try:
165
+ import json as _j
166
+ d = _j.loads(pkg.read_text(encoding="utf-8", errors="replace"))
167
+ dev = (d.get("devDependencies") or {}) if isinstance(d, dict) else {}
168
+ if any(k in dev for k in ("eslint", "jest", "vitest", "mypy")):
169
+ pts += 1.5
170
+ except Exception:
171
+ pass
172
+ return min(10.0, pts * 2.0)
173
+
174
+
175
+ class CodeScorer(BaseScorer):
176
+ """Calculate code quality scores for Python files"""
177
+
178
+ def __init__(
179
+ self,
180
+ weights: ScoringWeightsConfig | None = None,
181
+ ruff_enabled: bool = True,
182
+ mypy_enabled: bool = True,
183
+ jscpd_enabled: bool = True,
184
+ duplication_threshold: float = 3.0,
185
+ min_duplication_lines: int = 5,
186
+ ):
187
+ self.has_radon = HAS_RADON
188
+ self.has_bandit = HAS_BANDIT
189
+ self.has_coverage = HAS_COVERAGE
190
+ self.has_ruff = HAS_RUFF and ruff_enabled
191
+ self.has_mypy = HAS_MYPY and mypy_enabled
192
+ self.has_jscpd = HAS_JSCPD and jscpd_enabled
193
+ self.duplication_threshold = duplication_threshold
194
+ self.min_duplication_lines = min_duplication_lines
195
+ self.weights = weights # Will use defaults if None
196
+
197
+ def score_file(self, file_path: Path, code: str) -> dict[str, Any]:
198
+ """
199
+ Calculate scores for a code file.
200
+
201
+ Returns:
202
+ {
203
+ "complexity_score": float (0-10),
204
+ "security_score": float (0-10),
205
+ "maintainability_score": float (0-10),
206
+ "overall_score": float (0-100),
207
+ "metrics": {...}
208
+ }
209
+ """
210
+ metrics: dict[str, float] = {}
211
+ scores: dict[str, Any] = {
212
+ "complexity_score": 0.0,
213
+ "security_score": 0.0,
214
+ "maintainability_score": 0.0,
215
+ "test_coverage_score": 0.0,
216
+ "performance_score": 0.0,
217
+ "structure_score": 0.0, # 7-category: project layout, key files (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
218
+ "devex_score": 0.0, # 7-category: docs, config, tooling (MCP_SYSTEMS_IMPROVEMENT_RECOMMENDATIONS §3.2)
219
+ "linting_score": 0.0, # Phase 6.1: Ruff linting score
220
+ "type_checking_score": 0.0, # Phase 6.2: mypy type checking score
221
+ "duplication_score": 0.0, # Phase 6.4: jscpd duplication score
222
+ "metrics": metrics,
223
+ }
224
+
225
+ # Complexity Score (0-10, lower is better)
226
+ scores["complexity_score"] = self._calculate_complexity(code)
227
+ metrics["complexity"] = float(scores["complexity_score"])
228
+
229
+ # Security Score (0-10, higher is better)
230
+ scores["security_score"] = self._calculate_security(file_path, code)
231
+ metrics["security"] = float(scores["security_score"])
232
+
233
+ # Maintainability Score (0-10, higher is better)
234
+ scores["maintainability_score"] = self._calculate_maintainability(code)
235
+ metrics["maintainability"] = float(scores["maintainability_score"])
236
+
237
+ # Test Coverage Score (0-10, higher is better)
238
+ scores["test_coverage_score"] = self._calculate_test_coverage(file_path)
239
+ metrics["test_coverage"] = float(scores["test_coverage_score"])
240
+
241
+ # Performance Score (0-10, higher is better)
242
+ # Phase 3.2: Use context-aware performance scorer
243
+ from .performance_scorer import PerformanceScorer
244
+ from ...core.language_detector import Language
245
+
246
+ performance_scorer = PerformanceScorer()
247
+ scores["performance_score"] = performance_scorer.calculate(
248
+ code, Language.PYTHON, file_path, context=None
249
+ )
250
+ metrics["performance"] = float(scores["performance_score"])
251
+
252
+ # Linting Score (0-10, higher is better) - Phase 6.1
253
+ scores["linting_score"] = self._calculate_linting_score(file_path)
254
+ metrics["linting"] = float(scores["linting_score"])
255
+
256
+ # Get actual linting issues for transparency (P1 Improvement)
257
+ linting_issues = self.get_ruff_issues(file_path)
258
+ scores["linting_issues"] = self._format_ruff_issues(linting_issues)
259
+ scores["linting_issue_count"] = len(linting_issues)
260
+
261
+ # Type Checking Score (0-10, higher is better) - Phase 6.2
262
+ scores["type_checking_score"] = self._calculate_type_checking_score(file_path)
263
+ metrics["type_checking"] = float(scores["type_checking_score"])
264
+
265
+ # Get actual type checking issues for transparency (P1 Improvement)
266
+ type_issues = self.get_mypy_errors(file_path)
267
+ scores["type_issues"] = type_issues # Already formatted
268
+ scores["type_issue_count"] = len(type_issues)
269
+
270
+ # Duplication Score (0-10, higher is better) - Phase 6.4
271
+ scores["duplication_score"] = self._calculate_duplication_score(file_path)
272
+ metrics["duplication"] = float(scores["duplication_score"])
273
+
274
+ # Structure Score (0-10, higher is better) - 7-category §3.2
275
+ scores["structure_score"] = self._calculate_structure_score(file_path)
276
+ metrics["structure"] = float(scores["structure_score"])
277
+
278
+ # DevEx Score (0-10, higher is better) - 7-category §3.2
279
+ scores["devex_score"] = self._calculate_devex_score(file_path)
280
+ metrics["devex"] = float(scores["devex_score"])
281
+
282
+ class _Weights(Protocol):
283
+ complexity: float
284
+ security: float
285
+ maintainability: float
286
+ test_coverage: float
287
+ performance: float
288
+ structure: float
289
+ devex: float
290
+
291
+ # Overall Score (weighted average, 7-category)
292
+ if self.weights is not None:
293
+ w: _Weights = self.weights
294
+ else:
295
+ class DefaultWeights:
296
+ complexity = 0.18
297
+ security = 0.27
298
+ maintainability = 0.24
299
+ test_coverage = 0.13
300
+ performance = 0.08
301
+ structure = 0.05
302
+ devex = 0.05
303
+
304
+ w = DefaultWeights()
305
+
306
+ scores["overall_score"] = (
307
+ (10 - scores["complexity_score"]) * w.complexity
308
+ + scores["security_score"] * w.security
309
+ + scores["maintainability_score"] * w.maintainability
310
+ + scores["test_coverage_score"] * w.test_coverage
311
+ + scores["performance_score"] * w.performance
312
+ + scores["structure_score"] * w.structure
313
+ + scores["devex_score"] * w.devex
314
+ ) * 10 # Scale from 0-10 weighted sum to 0-100
315
+
316
+ # Phase 3.3: Validate all scores before returning
317
+ from .score_validator import ScoreValidator
318
+ from ...core.language_detector import Language
319
+
320
+ validator = ScoreValidator()
321
+ validation_results = validator.validate_all_scores(
322
+ scores, language=Language.PYTHON, context=None
323
+ )
324
+
325
+ # Update scores with validated/clamped values and add explanations
326
+ validated_scores = {}
327
+ score_explanations = {}
328
+ for category, result in validation_results.items():
329
+ if result.valid and result.calibrated_score is not None:
330
+ validated_scores[category] = result.calibrated_score
331
+ if result.explanation:
332
+ score_explanations[category] = {
333
+ "explanation": result.explanation,
334
+ "suggestions": result.suggestions,
335
+ }
336
+ else:
337
+ validated_scores[category] = scores.get(category, 0.0)
338
+
339
+ # Add explanations to result if any
340
+ if score_explanations:
341
+ validated_scores["_explanations"] = score_explanations
342
+
343
+ # Merge: keep all scores (incl. structure_score, devex_score), overlay validated
344
+ merged = {**scores, **validated_scores}
345
+ return merged
346
+
347
+ def _calculate_complexity(self, code: str) -> float:
348
+ """Calculate cyclomatic complexity (0-10 scale)"""
349
+ # Validate input
350
+ validate_code_input(code, method_name="_calculate_complexity")
351
+
352
+ if not self.has_radon:
353
+ return 5.0 # Default neutral score
354
+
355
+ try:
356
+ tree = ast.parse(code)
357
+ complexities = cc_visit(tree)
358
+
359
+ if not complexities:
360
+ return 1.0
361
+
362
+ # Get max complexity
363
+ max_complexity = max(cc.complexity for cc in complexities)
364
+
365
+ # Scale to 0-10 using constants
366
+ return min(
367
+ max_complexity / ComplexityConstants.SCALING_FACTOR,
368
+ ComplexityConstants.MAX_SCORE
369
+ )
370
+ except SyntaxError:
371
+ return 10.0 # Syntax errors = max complexity
372
+
373
+ def _calculate_security(self, file_path: Path | None, code: str) -> float:
374
+ """Calculate security score (0-10 scale, higher is better)"""
375
+ # Validate inputs
376
+ validate_code_input(code, method_name="_calculate_security")
377
+ if file_path is not None and not isinstance(file_path, Path):
378
+ raise ValueError(f"_calculate_security: file_path must be Path or None, got {type(file_path).__name__}")
379
+
380
+ if not self.has_bandit:
381
+ # Basic heuristic check
382
+ insecure_patterns = [
383
+ "eval(",
384
+ "exec(",
385
+ "__import__",
386
+ "pickle.loads",
387
+ "subprocess.call",
388
+ "os.system",
389
+ ]
390
+ issues = sum(1 for pattern in insecure_patterns if pattern in code)
391
+ return max(
392
+ 0.0,
393
+ SecurityConstants.MAX_SCORE - (issues * SecurityConstants.INSECURE_PATTERN_PENALTY)
394
+ )
395
+
396
+ try:
397
+ # Use bandit for proper security analysis
398
+ # BanditManager expects a BanditConfig, not a dict. Passing a dict can raise ValueError,
399
+ # which would silently degrade scoring to a neutral 5.0.
400
+ b_conf = bandit_config.BanditConfig()
401
+ b_mgr = manager.BanditManager(
402
+ config=b_conf,
403
+ agg_type="file",
404
+ debug=False,
405
+ verbose=False,
406
+ quiet=True,
407
+ profile=None,
408
+ ignore_nosec=False,
409
+ )
410
+ b_mgr.discover_files([str(file_path)], False)
411
+ b_mgr.run_tests()
412
+
413
+ # Count high/medium severity issues
414
+ issues = b_mgr.get_issue_list()
415
+ high_severity = sum(1 for i in issues if i.severity == bandit.HIGH)
416
+ medium_severity = sum(1 for i in issues if i.severity == bandit.MEDIUM)
417
+
418
+ # Score: 10 - (high*3 + medium*1)
419
+ score = 10.0 - (high_severity * 3.0 + medium_severity * 1.0)
420
+ return max(0.0, score)
421
+ except (FileNotFoundError, PermissionError, ValueError) as e:
422
+ # Specific exceptions for file/system errors
423
+ logger.warning(f"Security scoring failed for {file_path}: {e}")
424
+ return 5.0 # Default neutral on error
425
+ except Exception as e:
426
+ # Catch-all for unexpected errors (should be rare)
427
+ logger.warning(f"Unexpected error during security scoring for {file_path}: {e}", exc_info=True)
428
+ return 5.0 # Default neutral on error
429
+
430
+ def _calculate_maintainability(self, code: str) -> float:
431
+ """
432
+ Calculate maintainability index (0-10 scale, higher is better).
433
+
434
+ Phase 3.1: Enhanced with context-aware scoring using MaintainabilityScorer.
435
+ Phase 2 (P0): Maintainability issues are captured separately via get_maintainability_issues().
436
+ """
437
+ from .maintainability_scorer import MaintainabilityScorer
438
+ from ...core.language_detector import Language
439
+
440
+ # Use context-aware maintainability scorer
441
+ scorer = MaintainabilityScorer()
442
+ return scorer.calculate(code, Language.PYTHON, file_path=None, context=None)
443
+
444
+ def get_maintainability_issues(
445
+ self, code: str, file_path: Path | None = None
446
+ ) -> list[dict[str, Any]]:
447
+ """
448
+ Get specific maintainability issues (Phase 2 - P0).
449
+
450
+ Returns list of issues with details like missing docstrings, long functions, etc.
451
+
452
+ Args:
453
+ code: Source code content
454
+ file_path: Optional path to the file
455
+
456
+ Returns:
457
+ List of maintainability issues with details
458
+ """
459
+ from .maintainability_scorer import MaintainabilityScorer
460
+ from ...core.language_detector import Language
461
+
462
+ scorer = MaintainabilityScorer()
463
+ return scorer.get_issues(code, Language.PYTHON, file_path=file_path, context=None)
464
+
465
+ def _calculate_test_coverage(self, file_path: Path) -> float:
466
+ """
467
+ Calculate test coverage score (0-10 scale, higher is better).
468
+
469
+ Attempts to read coverage data from:
470
+ 1. coverage.xml file in project root or .coverage file
471
+ 2. Falls back to heuristic if no coverage data available
472
+
473
+ Args:
474
+ file_path: Path to the file being scored
475
+
476
+ Returns:
477
+ Coverage score (0-10 scale)
478
+ """
479
+ if not self.has_coverage:
480
+ # No coverage tool available, use heuristic
481
+ return self._coverage_heuristic(file_path)
482
+
483
+ try:
484
+ # Try to find and parse coverage data
485
+ project_root = self._find_project_root(file_path)
486
+ if project_root is None:
487
+ return 5.0 # Neutral if can't find project root
488
+
489
+ # Look for coverage.xml first (pytest-cov output)
490
+ coverage_xml = project_root / "coverage.xml"
491
+ if coverage_xml.exists():
492
+ return self._parse_coverage_xml(coverage_xml, file_path)
493
+
494
+ # Look for .coverage database file
495
+ coverage_db = project_root / ".coverage"
496
+ if coverage_db.exists():
497
+ return self._parse_coverage_db(coverage_db, file_path)
498
+
499
+ # No coverage data found, use heuristic
500
+ return self._coverage_heuristic(file_path)
501
+
502
+ except Exception:
503
+ # Fallback to heuristic on any error
504
+ return self._coverage_heuristic(file_path)
505
+
506
+ def _parse_coverage_xml(self, coverage_xml: Path, file_path: Path) -> float:
507
+ """Parse coverage.xml and return coverage percentage for file_path"""
508
+ try:
509
+ # coverage.xml is locally generated, but use defusedxml to reduce XML attack risk.
510
+ from defusedxml import ElementTree as ET
511
+
512
+ tree = ET.parse(coverage_xml)
513
+ root = tree.getroot()
514
+
515
+ # Get relative path from project root
516
+ project_root = coverage_xml.parent
517
+ try:
518
+ rel_path = file_path.relative_to(project_root)
519
+ file_path_str = str(rel_path).replace("\\", "/")
520
+ except ValueError:
521
+ # File not in project root
522
+ return 5.0
523
+
524
+ # Find coverage for this file
525
+ for package in root.findall(".//package"):
526
+ for class_elem in package.findall(".//class"):
527
+ file_name = class_elem.get("filename", "")
528
+ if file_name == file_path_str or file_path.name in file_name:
529
+ # Get line-rate (coverage percentage)
530
+ line_rate = float(class_elem.get("line-rate", "0.0"))
531
+ # Convert 0-1 scale to 0-10 scale
532
+ return line_rate * 10.0
533
+
534
+ # File not found in coverage report
535
+ return 0.0
536
+ except Exception:
537
+ return 5.0 # Default on error
538
+
539
+ def _parse_coverage_db(self, coverage_db: Path, file_path: Path) -> float:
540
+ """Parse .coverage database and return coverage percentage"""
541
+ try:
542
+ cov = Coverage()
543
+ cov.load()
544
+
545
+ # Get coverage data
546
+ data = cov.get_data()
547
+
548
+ # Try to find file in coverage data
549
+ try:
550
+ rel_path = file_path.relative_to(coverage_db.parent)
551
+ file_path_str = str(rel_path).replace("\\", "/")
552
+ except ValueError:
553
+ return 5.0
554
+
555
+ # Get coverage for this file
556
+ if file_path_str in data.measured_files():
557
+ # Calculate coverage percentage
558
+ lines = data.lines(file_path_str)
559
+ if not lines:
560
+ return 0.0
561
+
562
+ # Count covered vs total lines (simplified)
563
+ # In practice, we'd need to check which lines are executable
564
+ # For now, return neutral score
565
+ return 5.0
566
+
567
+ return 0.0 # File not covered
568
+ except Exception:
569
+ return 5.0
570
+
571
+ def _coverage_heuristic(self, file_path: Path) -> float:
572
+ """
573
+ Heuristic-based coverage estimate.
574
+
575
+ Phase 1 (P0): Fixed to return 0.0 when no test files exist.
576
+
577
+ Checks for:
578
+ - Test file existence (actual test files, not just directories)
579
+ - Test directory structure
580
+ - Test naming patterns
581
+
582
+ Returns:
583
+ - 0.0 if no test files exist (no tests written yet)
584
+ - 5.0 if test files exist but no coverage data (tests exist but not run)
585
+ - 10.0 if both test files and coverage data exist (not used here, handled by caller)
586
+ """
587
+ project_root = self._find_project_root(file_path)
588
+ if project_root is None:
589
+ return 0.0 # No project root = assume no tests (Phase 1 fix)
590
+
591
+ # Look for test files with comprehensive patterns
592
+ test_dirs = ["tests", "test", "tests/unit", "tests/integration", "tests/test", "test/test"]
593
+ test_patterns = [
594
+ f"test_{file_path.stem}.py",
595
+ f"{file_path.stem}_test.py",
596
+ f"test_{file_path.name}",
597
+ # Also check for module-style test files
598
+ f"test_{file_path.stem.replace('_', '')}.py",
599
+ f"test_{file_path.stem.replace('-', '_')}.py",
600
+ ]
601
+
602
+ # Also check if the file itself is a test file
603
+ if file_path.name.startswith("test_") or file_path.name.endswith("_test.py"):
604
+ # File is a test file, assume it has coverage if it exists
605
+ return 5.0 # Tests exist but no coverage data available
606
+
607
+ # Check if any test files actually exist
608
+ test_file_found = False
609
+ for test_dir in test_dirs:
610
+ test_dir_path = project_root / test_dir
611
+ if test_dir_path.exists() and test_dir_path.is_dir():
612
+ for pattern in test_patterns:
613
+ test_file = test_dir_path / pattern
614
+ if test_file.exists() and test_file.is_file():
615
+ test_file_found = True
616
+ break
617
+ if test_file_found:
618
+ break
619
+
620
+ # Phase 1 fix: Return 0.0 if no test files exist (no tests written yet)
621
+ if not test_file_found:
622
+ return 0.0
623
+
624
+ # Test files exist but not run yet
625
+ return 5.0
626
+
627
+ def _find_project_root(self, file_path: Path) -> Path | None:
628
+ """Delegate to BaseScorer. Override for CodeScorer-specific markers if needed."""
629
+ return BaseScorer._find_project_root(file_path)
630
+
631
+ def get_performance_issues(
632
+ self, code: str, file_path: Path | None = None
633
+ ) -> list[dict[str, Any]]:
634
+ """
635
+ Get specific performance issues with line numbers (Phase 4 - P1).
636
+
637
+ Returns list of performance bottlenecks with details like nested loops, expensive operations, etc.
638
+
639
+ Args:
640
+ code: Source code content
641
+ file_path: Optional path to the file
642
+
643
+ Returns:
644
+ List of performance issues with line numbers
645
+ """
646
+ from .issue_tracking import PerformanceIssue
647
+ import ast
648
+
649
+ issues: list[PerformanceIssue] = []
650
+ code_lines = code.splitlines()
651
+
652
+ try:
653
+ tree = ast.parse(code)
654
+ except SyntaxError:
655
+ return [PerformanceIssue(
656
+ issue_type="syntax_error",
657
+ message="File contains syntax errors - cannot analyze performance",
658
+ severity="high"
659
+ ).__dict__]
660
+
661
+ # Analyze functions for performance issues
662
+ for node in ast.walk(tree):
663
+ if isinstance(node, ast.FunctionDef):
664
+ # Check for nested loops
665
+ for child in ast.walk(node):
666
+ if isinstance(child, ast.For):
667
+ # Check if this loop contains another loop
668
+ for nested_child in ast.walk(child):
669
+ if isinstance(nested_child, ast.For) and nested_child != child:
670
+ # Found nested loop
671
+ issues.append(PerformanceIssue(
672
+ issue_type="nested_loops",
673
+ message=f"Nested for loops detected in function '{node.name}' - potential O(n²) complexity",
674
+ line_number=child.lineno,
675
+ severity="high",
676
+ operation_type="loop",
677
+ context=f"Nested in function '{node.name}'",
678
+ suggestion="Consider using itertools.product() or list comprehensions to flatten nested loops"
679
+ ))
680
+
681
+ # Check for expensive operations in loops
682
+ if isinstance(child, ast.For):
683
+ for loop_child in ast.walk(child):
684
+ if isinstance(loop_child, ast.Call):
685
+ # Check for expensive function calls in loops
686
+ if isinstance(loop_child.func, ast.Name):
687
+ func_name = loop_child.func.id
688
+ expensive_operations = ["time.fromisoformat", "datetime.fromisoformat", "re.compile", "json.loads"]
689
+ if any(exp_op in func_name for exp_op in expensive_operations):
690
+ issues.append(PerformanceIssue(
691
+ issue_type="expensive_operation_in_loop",
692
+ message=f"Expensive operation '{func_name}' called in loop at line {loop_child.lineno} - parse once before loop",
693
+ line_number=loop_child.lineno,
694
+ severity="medium",
695
+ operation_type=func_name,
696
+ context=f"In loop at line {child.lineno}",
697
+ suggestion=f"Move '{func_name}' call outside the loop and cache the result"
698
+ ))
699
+
700
+ # Check for list comprehensions with function calls
701
+ if isinstance(node, ast.ListComp):
702
+ func_calls_in_comp = sum(1 for n in ast.walk(node) if isinstance(n, ast.Call))
703
+ if func_calls_in_comp > 5:
704
+ issues.append(PerformanceIssue(
705
+ issue_type="expensive_comprehension",
706
+ message=f"List comprehension at line {node.lineno} contains {func_calls_in_comp} function calls - consider using generator or loop",
707
+ line_number=node.lineno,
708
+ severity="medium",
709
+ operation_type="comprehension",
710
+ suggestion="Consider using a generator expression or a loop for better performance"
711
+ ))
712
+
713
+ # Convert to dict format
714
+ return [
715
+ {
716
+ "issue_type": issue.issue_type,
717
+ "message": issue.message,
718
+ "line_number": issue.line_number,
719
+ "severity": issue.severity,
720
+ "suggestion": issue.suggestion,
721
+ "operation_type": issue.operation_type,
722
+ "context": issue.context,
723
+ }
724
+ for issue in issues
725
+ ]
726
+
727
+ def _calculate_performance(self, code: str) -> float:
728
+ """
729
+ Calculate performance score using static analysis (0-10 scale, higher is better).
730
+
731
+ Checks for:
732
+ - Function size (number of lines)
733
+ - Nesting depth
734
+ - Inefficient patterns (N+1 queries, nested loops, etc.)
735
+ - Large list/dict comprehensions
736
+ """
737
+ try:
738
+ tree = ast.parse(code)
739
+ issues = []
740
+
741
+ # Analyze functions
742
+ for node in ast.walk(tree):
743
+ if isinstance(node, ast.FunctionDef):
744
+ # Check function size
745
+ # Use end_lineno if available (Python 3.8+), otherwise estimate
746
+ if hasattr(node, "end_lineno") and node.end_lineno is not None:
747
+ func_lines = node.end_lineno - node.lineno
748
+ else:
749
+ # Estimate: count lines in function body
750
+ func_lines = (
751
+ len(code.split("\n")[node.lineno - 1 : node.lineno + 49])
752
+ if len(code.split("\n")) > node.lineno
753
+ else 50
754
+ )
755
+
756
+ if func_lines > 50:
757
+ issues.append("large_function") # > 50 lines
758
+ if func_lines > 100:
759
+ issues.append("very_large_function") # > 100 lines
760
+
761
+ # Check nesting depth
762
+ max_depth = self._get_max_nesting_depth(node)
763
+ if max_depth > 4:
764
+ issues.append("deep_nesting") # > 4 levels
765
+ if max_depth > 6:
766
+ issues.append("very_deep_nesting") # > 6 levels
767
+
768
+ # Check for nested loops (potential N^2 complexity)
769
+ if isinstance(node, ast.For):
770
+ for child in ast.walk(node):
771
+ if isinstance(child, ast.For) and child != node:
772
+ issues.append("nested_loops")
773
+ break
774
+
775
+ # Check for list comprehensions with function calls
776
+ if isinstance(node, ast.ListComp):
777
+ # Count function calls in comprehension
778
+ func_calls = sum(
779
+ 1 for n in ast.walk(node) if isinstance(n, ast.Call)
780
+ )
781
+ if func_calls > 5:
782
+ issues.append("expensive_comprehension")
783
+
784
+ # Calculate score based on issues
785
+ # Start with 10, deduct points for issues
786
+ score = 10.0
787
+ penalty_map = {
788
+ "large_function": 0.5,
789
+ "very_large_function": 1.5,
790
+ "deep_nesting": 1.0,
791
+ "very_deep_nesting": 2.0,
792
+ "nested_loops": 1.5,
793
+ "expensive_comprehension": 0.5,
794
+ }
795
+
796
+ seen_issues = set()
797
+ for issue in issues:
798
+ if issue not in seen_issues:
799
+ score -= penalty_map.get(issue, 0.5)
800
+ seen_issues.add(issue)
801
+
802
+ return max(0.0, min(10.0, score))
803
+
804
+ except SyntaxError:
805
+ return 0.0 # Syntax errors = worst performance score
806
+ except Exception:
807
+ return 5.0 # Default on error
808
+
809
+ def _get_max_nesting_depth(self, node: ast.AST, current_depth: int = 0) -> int:
810
+ """Calculate maximum nesting depth in an AST node"""
811
+ max_depth = current_depth
812
+
813
+ for child in ast.iter_child_nodes(node):
814
+ # Count nesting for control structures
815
+ if isinstance(child, (ast.If, ast.For, ast.While, ast.Try, ast.With)):
816
+ child_depth = self._get_max_nesting_depth(child, current_depth + 1)
817
+ max_depth = max(max_depth, child_depth)
818
+ else:
819
+ child_depth = self._get_max_nesting_depth(child, current_depth)
820
+ max_depth = max(max_depth, child_depth)
821
+
822
+ return max_depth
823
+
824
+ def _calculate_linting_score(self, file_path: Path) -> float:
825
+ """
826
+ Calculate linting score using Ruff (0-10 scale, higher is better).
827
+
828
+ Phase 6: Modern Quality Analysis - Ruff Integration
829
+
830
+ Returns:
831
+ Linting score (0-10), where 10 = no issues, 0 = many issues
832
+ """
833
+ if not self.has_ruff:
834
+ return 5.0 # Neutral score if Ruff not available
835
+
836
+ # Only check Python files
837
+ if file_path.suffix != ".py":
838
+ return 10.0 # Perfect score for non-Python files (can't lint)
839
+
840
+ try:
841
+ # Run ruff check with JSON output
842
+ result = subprocess.run( # nosec B603
843
+ [
844
+ sys.executable,
845
+ "-m",
846
+ "ruff",
847
+ "check",
848
+ "--output-format=json",
849
+ str(file_path),
850
+ ],
851
+ capture_output=True,
852
+ text=True,
853
+ encoding="utf-8",
854
+ errors="replace",
855
+ timeout=30, # 30 second timeout
856
+ cwd=file_path.parent if file_path.parent.exists() else None,
857
+ )
858
+
859
+ # Parse JSON output
860
+ if result.returncode == 0 and not result.stdout.strip():
861
+ # No issues found
862
+ return 10.0
863
+
864
+ try:
865
+ # Ruff JSON format: list of diagnostic objects
866
+ diagnostics = (
867
+ json_lib.loads(result.stdout) if result.stdout.strip() else []
868
+ )
869
+
870
+ if not diagnostics:
871
+ return 10.0
872
+
873
+ # Count issues by severity
874
+ # Ruff severity levels: E (Error), W (Warning), F (Fatal), I (Info)
875
+ error_count = sum(
876
+ 1
877
+ for d in diagnostics
878
+ if d.get("code", {}).get("name", "").startswith("E")
879
+ )
880
+ warning_count = sum(
881
+ 1
882
+ for d in diagnostics
883
+ if d.get("code", {}).get("name", "").startswith("W")
884
+ )
885
+ fatal_count = sum(
886
+ 1
887
+ for d in diagnostics
888
+ if d.get("code", {}).get("name", "").startswith("F")
889
+ )
890
+
891
+ # Calculate score: Start at 10, deduct points
892
+ # Errors (E): -2 points each
893
+ # Fatal (F): -3 points each
894
+ # Warnings (W): -0.5 points each
895
+ score = 10.0
896
+ score -= error_count * 2.0
897
+ score -= fatal_count * 3.0
898
+ score -= warning_count * 0.5
899
+
900
+ return max(0.0, min(10.0, score))
901
+
902
+ except json_lib.JSONDecodeError:
903
+ # If JSON parsing fails, check stderr for errors
904
+ if result.stderr:
905
+ return 5.0 # Neutral on parsing error
906
+ return 10.0 # No output = no issues
907
+
908
+ except subprocess.TimeoutExpired:
909
+ return 5.0 # Neutral on timeout
910
+ except FileNotFoundError:
911
+ # Ruff not found in PATH
912
+ return 5.0
913
+ except Exception:
914
+ # Any other error
915
+ return 5.0
916
+
917
+ def get_ruff_issues(self, file_path: Path) -> list[dict[str, Any]]:
918
+ """
919
+ Get detailed Ruff linting issues for a file.
920
+
921
+ Phase 6: Modern Quality Analysis - Ruff Integration
922
+
923
+ Returns:
924
+ List of diagnostic dictionaries with code, message, location, etc.
925
+ """
926
+ if not self.has_ruff or file_path.suffix != ".py":
927
+ return []
928
+
929
+ try:
930
+ result = subprocess.run( # nosec B603
931
+ [
932
+ sys.executable,
933
+ "-m",
934
+ "ruff",
935
+ "check",
936
+ "--output-format=json",
937
+ str(file_path),
938
+ ],
939
+ capture_output=True,
940
+ text=True,
941
+ encoding="utf-8",
942
+ errors="replace",
943
+ timeout=30,
944
+ cwd=file_path.parent if file_path.parent.exists() else None,
945
+ )
946
+
947
+ if result.returncode == 0 and not result.stdout.strip():
948
+ return []
949
+
950
+ try:
951
+ diagnostics = (
952
+ json_lib.loads(result.stdout) if result.stdout.strip() else []
953
+ )
954
+ return diagnostics
955
+ except json_lib.JSONDecodeError:
956
+ return []
957
+
958
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
959
+ return []
960
+
961
+ def _calculate_type_checking_score(self, file_path: Path) -> float:
962
+ """
963
+ Calculate type checking score using mypy (0-10 scale, higher is better).
964
+
965
+ Phase 6.2: Modern Quality Analysis - mypy Integration
966
+ Phase 5 (P1): Fixed to actually run mypy and return real scores (not static 5.0).
967
+ ENH-002-S2: Prefer ScopedMypyExecutor (--follow-imports=skip, <10s) with fallback to full mypy.
968
+ """
969
+ if not self.has_mypy:
970
+ logger.debug("mypy not available - returning neutral score")
971
+ return 5.0 # Neutral score if mypy not available
972
+
973
+ # Only check Python files
974
+ if file_path.suffix != ".py":
975
+ return 10.0 # Perfect score for non-Python files (can't type check)
976
+
977
+ # ENH-002-S2: Try scoped mypy first (faster)
978
+ try:
979
+ from .tools.scoped_mypy import ScopedMypyExecutor
980
+ executor = ScopedMypyExecutor()
981
+ result = executor.run_scoped_sync(file_path, timeout=10)
982
+ if result.files_checked == 1 or result.issues:
983
+ error_count = len(result.issues)
984
+ if error_count == 0:
985
+ return 10.0
986
+ score = 10.0 - (error_count * 0.5)
987
+ logger.debug(
988
+ "mypy (scoped) found %s errors for %s, score: %s/10",
989
+ error_count, file_path, score,
990
+ )
991
+ return max(0.0, min(10.0, score))
992
+ except Exception as e:
993
+ logger.debug("scoped mypy not used, falling back to full mypy: %s", e)
994
+
995
+ try:
996
+ result = subprocess.run( # nosec B603
997
+ [
998
+ sys.executable,
999
+ "-m",
1000
+ "mypy",
1001
+ "--show-error-codes",
1002
+ "--no-error-summary",
1003
+ "--no-color-output",
1004
+ "--no-incremental",
1005
+ str(file_path),
1006
+ ],
1007
+ capture_output=True,
1008
+ text=True,
1009
+ encoding="utf-8",
1010
+ errors="replace",
1011
+ timeout=30,
1012
+ cwd=file_path.parent if file_path.parent.exists() else None,
1013
+ )
1014
+ if result.returncode == 0:
1015
+ logger.debug("mypy found no errors for %s", file_path)
1016
+ return 10.0
1017
+ output = result.stdout.strip()
1018
+ if not output:
1019
+ logger.debug("mypy returned non-zero but no output for %s", file_path)
1020
+ return 10.0
1021
+ error_lines = [
1022
+ line
1023
+ for line in output.split("\n")
1024
+ if "error:" in line.lower() and file_path.name in line
1025
+ ]
1026
+ error_count = len(error_lines)
1027
+ if error_count == 0:
1028
+ logger.debug("mypy returned non-zero but no parseable errors for %s", file_path)
1029
+ return 10.0
1030
+ score = 10.0 - (error_count * 0.5)
1031
+ logger.debug("mypy found %s errors for %s, score: %s/10", error_count, file_path, score)
1032
+ return max(0.0, min(10.0, score))
1033
+ except subprocess.TimeoutExpired:
1034
+ logger.warning("mypy timed out for %s", file_path)
1035
+ return 5.0
1036
+ except FileNotFoundError:
1037
+ logger.debug("mypy not found in PATH for %s", file_path)
1038
+ self.has_mypy = False
1039
+ return 5.0
1040
+ except Exception as e:
1041
+ logger.warning("mypy failed for %s: %s", file_path, e, exc_info=True)
1042
+ return 5.0
1043
+
1044
+ def get_mypy_errors(self, file_path: Path) -> list[dict[str, Any]]:
1045
+ """
1046
+ Get detailed mypy type checking errors for a file.
1047
+
1048
+ Phase 6.2: Modern Quality Analysis - mypy Integration
1049
+ ENH-002-S2: Prefer ScopedMypyExecutor with fallback to full mypy.
1050
+ """
1051
+ if not self.has_mypy or file_path.suffix != ".py":
1052
+ return []
1053
+
1054
+ try:
1055
+ from .tools.scoped_mypy import ScopedMypyExecutor
1056
+ executor = ScopedMypyExecutor()
1057
+ result = executor.run_scoped_sync(file_path, timeout=10)
1058
+ if result.files_checked == 1 or result.issues:
1059
+ return [
1060
+ {
1061
+ "filename": str(i.file_path),
1062
+ "line": i.line,
1063
+ "message": i.message,
1064
+ "error_code": i.error_code,
1065
+ "severity": i.severity,
1066
+ }
1067
+ for i in result.issues
1068
+ ]
1069
+ except Exception:
1070
+ pass
1071
+
1072
+ try:
1073
+ result = subprocess.run( # nosec B603
1074
+ [
1075
+ sys.executable,
1076
+ "-m",
1077
+ "mypy",
1078
+ "--show-error-codes",
1079
+ "--no-error-summary",
1080
+ "--no-incremental",
1081
+ str(file_path),
1082
+ ],
1083
+ capture_output=True,
1084
+ text=True,
1085
+ encoding="utf-8",
1086
+ errors="replace",
1087
+ timeout=30,
1088
+ cwd=file_path.parent if file_path.parent.exists() else None,
1089
+ )
1090
+ if result.returncode == 0 or not result.stdout.strip():
1091
+ return []
1092
+ errors = []
1093
+ for line in result.stdout.strip().split("\n"):
1094
+ if "error:" not in line.lower():
1095
+ continue
1096
+ parts = line.split(":", 3)
1097
+ if len(parts) >= 4:
1098
+ filename = parts[0]
1099
+ try:
1100
+ line_num = int(parts[1])
1101
+ except ValueError:
1102
+ continue
1103
+ error_msg = parts[3].strip()
1104
+ error_code = None
1105
+ if "[" in error_msg and "]" in error_msg:
1106
+ start = error_msg.rfind("[")
1107
+ end = error_msg.rfind("]")
1108
+ if start < end:
1109
+ error_code = error_msg[start + 1 : end]
1110
+ error_msg = error_msg[:start].strip()
1111
+ errors.append({
1112
+ "filename": filename,
1113
+ "line": line_num,
1114
+ "message": error_msg,
1115
+ "error_code": error_code,
1116
+ "severity": "error",
1117
+ })
1118
+ return errors
1119
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
1120
+ return []
1121
+
1122
+ def _format_ruff_issues(self, diagnostics: list[dict[str, Any]]) -> list[dict[str, Any]]:
1123
+ """
1124
+ Format raw ruff diagnostics into a cleaner structure for output.
1125
+
1126
+ P1 Improvement: Include actual lint errors in score output.
1127
+
1128
+ Args:
1129
+ diagnostics: Raw ruff JSON diagnostics
1130
+
1131
+ Returns:
1132
+ List of formatted issues with line, code, message, severity
1133
+ """
1134
+ formatted = []
1135
+ for diag in diagnostics:
1136
+ # Extract code info (ruff format: {"code": {"name": "F401", ...}})
1137
+ code_info = diag.get("code", {})
1138
+ if isinstance(code_info, dict):
1139
+ code = code_info.get("name", "")
1140
+ else:
1141
+ code = str(code_info)
1142
+
1143
+ # Determine severity from code prefix
1144
+ severity = "warning"
1145
+ if code.startswith("E") or code.startswith("F"):
1146
+ severity = "error"
1147
+ elif code.startswith("W"):
1148
+ severity = "warning"
1149
+ elif code.startswith("I"):
1150
+ severity = "info"
1151
+
1152
+ # Get location info
1153
+ location = diag.get("location", {})
1154
+ line = location.get("row", 0) if isinstance(location, dict) else 0
1155
+ column = location.get("column", 0) if isinstance(location, dict) else 0
1156
+
1157
+ formatted.append({
1158
+ "code": code,
1159
+ "message": diag.get("message", ""),
1160
+ "line": line,
1161
+ "column": column,
1162
+ "severity": severity,
1163
+ })
1164
+
1165
+ # Sort by line number
1166
+ formatted.sort(key=lambda x: (x.get("line", 0), x.get("column", 0)))
1167
+
1168
+ return formatted
1169
+
1170
+ def _group_ruff_issues_by_code(self, issues: list[dict[str, Any]]) -> dict[str, Any]:
1171
+ """
1172
+ Group ruff issues by rule code for cleaner, more actionable reports.
1173
+
1174
+ ENH-002 Story #18: Ruff Output Grouping
1175
+
1176
+ Args:
1177
+ issues: List of ruff diagnostic dictionaries
1178
+
1179
+ Returns:
1180
+ Dictionary with grouped issues:
1181
+ {
1182
+ "total_count": int,
1183
+ "groups": [
1184
+ {
1185
+ "code": "UP006",
1186
+ "count": 17,
1187
+ "description": "Use dict/list instead of Dict/List",
1188
+ "severity": "info",
1189
+ "issues": [...]
1190
+ },
1191
+ ...
1192
+ ],
1193
+ "summary": "UP006 (17), UP045 (10), UP007 (2), F401 (1)"
1194
+ }
1195
+ """
1196
+ if not issues:
1197
+ return {
1198
+ "total_count": 0,
1199
+ "groups": [],
1200
+ "summary": "No issues found"
1201
+ }
1202
+
1203
+ # Group issues by code
1204
+ groups_dict: dict[str, list[dict[str, Any]]] = {}
1205
+ for issue in issues:
1206
+ code_info = issue.get("code", {})
1207
+ if isinstance(code_info, dict):
1208
+ code = code_info.get("name", "UNKNOWN")
1209
+ else:
1210
+ code = str(code_info) if code_info else "UNKNOWN"
1211
+
1212
+ if code not in groups_dict:
1213
+ groups_dict[code] = []
1214
+ groups_dict[code].append(issue)
1215
+
1216
+ # Create grouped structure with metadata
1217
+ groups = []
1218
+ for code, code_issues in groups_dict.items():
1219
+ # Get first message as description (they're usually the same for same code)
1220
+ description = code_issues[0].get("message", "") if code_issues else ""
1221
+
1222
+ # Determine severity from code
1223
+ severity = "info"
1224
+ if code.startswith("E") or code.startswith("F"):
1225
+ severity = "error"
1226
+ elif code.startswith("W"):
1227
+ severity = "warning"
1228
+
1229
+ groups.append({
1230
+ "code": code,
1231
+ "count": len(code_issues),
1232
+ "description": description,
1233
+ "severity": severity,
1234
+ "issues": code_issues
1235
+ })
1236
+
1237
+ # Sort by count (descending) then by code
1238
+ groups.sort(key=lambda x: (-x["count"], x["code"]))
1239
+
1240
+ # Create summary string: "UP006 (17), UP045 (10), ..."
1241
+ summary_parts = [f"{g['code']} ({g['count']})" for g in groups]
1242
+ summary = ", ".join(summary_parts)
1243
+
1244
+ return {
1245
+ "total_count": len(issues),
1246
+ "groups": groups,
1247
+ "summary": summary
1248
+ }
1249
+
1250
+ def _calculate_duplication_score(self, file_path: Path) -> float:
1251
+ """
1252
+ Calculate duplication score using jscpd (0-10 scale, higher is better).
1253
+
1254
+ Phase 6.4: Modern Quality Analysis - jscpd Integration
1255
+
1256
+ Note: jscpd works on directories/files, so we analyze the parent directory
1257
+ or file directly. For single file, we analyze just that file.
1258
+
1259
+ Returns:
1260
+ Duplication score (0-10), where 10 = no duplication, 0 = high duplication
1261
+ Score formula: 10 - (duplication_pct / 10)
1262
+ """
1263
+ if not self.has_jscpd:
1264
+ return 5.0 # Neutral score if jscpd not available
1265
+
1266
+ # jscpd works best on directories or multiple files
1267
+ # For single file analysis, we'll analyze the file directly
1268
+ try:
1269
+ # Determine target (file or directory)
1270
+ target = str(file_path)
1271
+ if file_path.is_dir():
1272
+ target_dir = str(file_path)
1273
+ else:
1274
+ target_dir = str(file_path.parent)
1275
+
1276
+ # Build jscpd command
1277
+ # Use npx if jscpd not directly available
1278
+ jscpd_path = shutil.which("jscpd")
1279
+ if jscpd_path:
1280
+ cmd = [jscpd_path]
1281
+ else:
1282
+ npx_path = shutil.which("npx")
1283
+ if not npx_path:
1284
+ return 5.0 # jscpd not available
1285
+ cmd = [npx_path, "--yes", "jscpd"]
1286
+
1287
+ # Add jscpd arguments
1288
+ cmd.extend(
1289
+ [
1290
+ target,
1291
+ "--format",
1292
+ "json",
1293
+ "--min-lines",
1294
+ str(self.min_duplication_lines),
1295
+ "--reporters",
1296
+ "json",
1297
+ "--output",
1298
+ ".", # Output to current directory
1299
+ ]
1300
+ )
1301
+
1302
+ # Run jscpd
1303
+ result = subprocess.run( # nosec B603 - fixed args
1304
+ wrap_windows_cmd_shim(cmd),
1305
+ capture_output=True,
1306
+ text=True,
1307
+ encoding="utf-8",
1308
+ errors="replace",
1309
+ timeout=120, # 2 minute timeout (jscpd can be slow on large codebases)
1310
+ cwd=target_dir if Path(target_dir).exists() else None,
1311
+ )
1312
+
1313
+ # jscpd outputs JSON to stdout when using --reporters json
1314
+ # But it might also create a file, so check both
1315
+ json_output = result.stdout.strip()
1316
+
1317
+ # Try to parse JSON from stdout
1318
+ try:
1319
+ if json_output:
1320
+ report_data = json_lib.loads(json_output)
1321
+ else:
1322
+ # Check for jscpd-report.json in output directory
1323
+ output_file = Path(target_dir) / "jscpd-report.json"
1324
+ if output_file.exists():
1325
+ with open(output_file, encoding="utf-8") as f:
1326
+ report_data = json_lib.load(f)
1327
+ else:
1328
+ # No duplication found (exit code 0 typically means no issues or success)
1329
+ if result.returncode == 0:
1330
+ return 10.0 # Perfect score (no duplication)
1331
+ return 5.0 # Neutral on parse failure
1332
+ except json_lib.JSONDecodeError:
1333
+ # JSON parse error - might be text output
1334
+ # Try to extract duplication percentage from text output
1335
+ # Format: "Found X% duplicated lines out of Y total lines"
1336
+ lines = result.stdout.split("\n") + result.stderr.split("\n")
1337
+ for line in lines:
1338
+ if "%" in line and "duplicate" in line.lower():
1339
+ # Try to extract percentage
1340
+ try:
1341
+ pct_str = line.split("%")[0].split()[-1]
1342
+ duplication_pct = float(pct_str)
1343
+ score = 10.0 - (duplication_pct / 10.0)
1344
+ return max(0.0, min(10.0, score))
1345
+ except (ValueError, IndexError):
1346
+ pass
1347
+
1348
+ # If we can't parse, default behavior
1349
+ if result.returncode == 0:
1350
+ return 10.0 # No duplication found
1351
+ return 5.0 # Neutral on parse failure
1352
+
1353
+ # Extract duplication percentage from JSON report
1354
+ # jscpd JSON structure: { "percentage": X.X, ... }
1355
+ duplication_pct = report_data.get("percentage", 0.0)
1356
+
1357
+ # Calculate score: 10 - (duplication_pct / 10)
1358
+ # This means:
1359
+ # - 0% duplication = 10.0 score
1360
+ # - 3% duplication (threshold) = 9.7 score
1361
+ # - 10% duplication = 9.0 score
1362
+ # - 30% duplication = 7.0 score
1363
+ # - 100% duplication = 0.0 score
1364
+ score = 10.0 - (duplication_pct / 10.0)
1365
+ return max(0.0, min(10.0, score))
1366
+
1367
+ except subprocess.TimeoutExpired:
1368
+ return 5.0 # Neutral on timeout
1369
+ except FileNotFoundError:
1370
+ # jscpd not found
1371
+ return 5.0
1372
+ except Exception:
1373
+ # Any other error
1374
+ return 5.0
1375
+
1376
+ def get_duplication_report(self, file_path: Path) -> dict[str, Any]:
1377
+ """
1378
+ Get detailed duplication report from jscpd.
1379
+
1380
+ Phase 6.4: Modern Quality Analysis - jscpd Integration
1381
+
1382
+ Returns:
1383
+ Dictionary with duplication report data including:
1384
+ - percentage: Duplication percentage
1385
+ - duplicates: List of duplicate code blocks
1386
+ - files: File-level duplication stats
1387
+ """
1388
+ if not self.has_jscpd:
1389
+ return {
1390
+ "available": False,
1391
+ "percentage": 0.0,
1392
+ "duplicates": [],
1393
+ "files": [],
1394
+ }
1395
+
1396
+ try:
1397
+ # Determine target
1398
+ if file_path.is_dir():
1399
+ target_dir = str(file_path)
1400
+ target = str(file_path)
1401
+ else:
1402
+ target_dir = str(file_path.parent)
1403
+ target = str(file_path)
1404
+
1405
+ # Build jscpd command
1406
+ jscpd_path = shutil.which("jscpd")
1407
+ if jscpd_path:
1408
+ cmd = [jscpd_path]
1409
+ else:
1410
+ npx_path = shutil.which("npx")
1411
+ if not npx_path:
1412
+ return {
1413
+ "available": False,
1414
+ "percentage": 0.0,
1415
+ "duplicates": [],
1416
+ "files": [],
1417
+ }
1418
+ cmd = [npx_path, "--yes", "jscpd"]
1419
+
1420
+ cmd.extend(
1421
+ [
1422
+ target,
1423
+ "--format",
1424
+ "json",
1425
+ "--min-lines",
1426
+ str(self.min_duplication_lines),
1427
+ "--reporters",
1428
+ "json",
1429
+ ]
1430
+ )
1431
+
1432
+ # Run jscpd
1433
+ result = subprocess.run( # nosec B603 - fixed args
1434
+ wrap_windows_cmd_shim(cmd),
1435
+ capture_output=True,
1436
+ text=True,
1437
+ encoding="utf-8",
1438
+ errors="replace",
1439
+ timeout=120,
1440
+ cwd=target_dir if Path(target_dir).exists() else None,
1441
+ )
1442
+
1443
+ # Parse JSON output
1444
+ json_output = result.stdout.strip()
1445
+ try:
1446
+ if json_output:
1447
+ report_data = json_lib.loads(json_output)
1448
+ else:
1449
+ # Check for output file
1450
+ output_file = Path(target_dir) / "jscpd-report.json"
1451
+ if output_file.exists():
1452
+ with open(output_file, encoding="utf-8") as f:
1453
+ report_data = json_lib.load(f)
1454
+ else:
1455
+ return {
1456
+ "available": True,
1457
+ "percentage": 0.0,
1458
+ "duplicates": [],
1459
+ "files": [],
1460
+ }
1461
+ except json_lib.JSONDecodeError:
1462
+ return {
1463
+ "available": True,
1464
+ "error": "Failed to parse jscpd output",
1465
+ "percentage": 0.0,
1466
+ "duplicates": [],
1467
+ "files": [],
1468
+ }
1469
+
1470
+ # Extract relevant data from jscpd report
1471
+ # jscpd JSON format varies, but typically has:
1472
+ # - percentage: overall duplication percentage
1473
+ # - duplicates: array of duplicate pairs
1474
+ # - files: file-level statistics
1475
+
1476
+ return {
1477
+ "available": True,
1478
+ "percentage": report_data.get("percentage", 0.0),
1479
+ "duplicates": report_data.get("duplicates", []),
1480
+ "files": (
1481
+ report_data.get("statistics", {}).get("files", [])
1482
+ if "statistics" in report_data
1483
+ else []
1484
+ ),
1485
+ "total_lines": (
1486
+ report_data.get("statistics", {}).get("total", {}).get("lines", 0)
1487
+ if "statistics" in report_data
1488
+ else 0
1489
+ ),
1490
+ "duplicated_lines": (
1491
+ report_data.get("statistics", {})
1492
+ .get("duplicated", {})
1493
+ .get("lines", 0)
1494
+ if "statistics" in report_data
1495
+ else 0
1496
+ ),
1497
+ }
1498
+
1499
+ except subprocess.TimeoutExpired:
1500
+ return {
1501
+ "available": True,
1502
+ "error": "jscpd timeout",
1503
+ "percentage": 0.0,
1504
+ "duplicates": [],
1505
+ "files": [],
1506
+ }
1507
+ except FileNotFoundError:
1508
+ return {
1509
+ "available": False,
1510
+ "percentage": 0.0,
1511
+ "duplicates": [],
1512
+ "files": [],
1513
+ }
1514
+ except Exception as e:
1515
+ return {
1516
+ "available": True,
1517
+ "error": str(e),
1518
+ "percentage": 0.0,
1519
+ "duplicates": [],
1520
+ "files": [],
1521
+ }
1522
+
1523
+
1524
+ class ScorerFactory:
1525
+ """
1526
+ Factory to provide appropriate scorer based on language (Strategy Pattern).
1527
+
1528
+ Phase 1.2: Language-Specific Scorers
1529
+
1530
+ Now uses ScorerRegistry for extensible language support.
1531
+ """
1532
+
1533
+ @staticmethod
1534
+ def get_scorer(language: Language, config: ProjectConfig | None = None) -> BaseScorer:
1535
+ """
1536
+ Get the appropriate scorer for a given language.
1537
+
1538
+ Uses ScorerRegistry for extensible language support with fallback chains.
1539
+
1540
+ Args:
1541
+ language: Detected language enum
1542
+ config: Optional project configuration
1543
+
1544
+ Returns:
1545
+ BaseScorer instance appropriate for the language
1546
+
1547
+ Raises:
1548
+ ValueError: If no scorer is available for the language (even with fallbacks)
1549
+ """
1550
+ from .scorer_registry import ScorerRegistry
1551
+
1552
+ try:
1553
+ return ScorerRegistry.get_scorer(language, config)
1554
+ except ValueError:
1555
+ # If no scorer found, fall back to Python scorer as last resort
1556
+ # This maintains backward compatibility but may not work well for non-Python code
1557
+ # TODO: In the future, create a GenericScorer that uses metric strategies
1558
+ if language != Language.PYTHON:
1559
+ # Try Python scorer as absolute last resort
1560
+ try:
1561
+ return ScorerRegistry.get_scorer(Language.PYTHON, config)
1562
+ except ValueError:
1563
+ pass
1564
+
1565
+ # If even Python scorer isn't available, raise the original error
1566
+ raise