ref-agents 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
"""Technical Debt Scanner for REF Agents.
|
|
2
|
+
|
|
3
|
+
Multi-language debt scanner supporting:
|
|
4
|
+
- Python, TypeScript, JavaScript, Java
|
|
5
|
+
- React, Angular, Spring Boot frameworks
|
|
6
|
+
|
|
7
|
+
Uses configurable YAML rules for extensibility.
|
|
8
|
+
Supports incremental scanning and checkpoint-based resume.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import structlog
|
|
21
|
+
|
|
22
|
+
from ref_agents.constants import Severity
|
|
23
|
+
from ref_agents.core.config_loader import ConfigLoader, LanguageConfig
|
|
24
|
+
from ref_agents.core.language_detector import LanguageDetector, LanguageInfo
|
|
25
|
+
from ref_agents.utils.git_utils import get_changed_files, is_git_repo
|
|
26
|
+
|
|
27
|
+
logger = structlog.get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
# Cache and checkpoint file names
|
|
30
|
+
CACHE_FILE = "debt_cache.json"
|
|
31
|
+
CHECKPOINT_FILE = "debt_checkpoint.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DebtItem:
|
|
36
|
+
"""Represents a technical debt item.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
debt_id: Unique identifier.
|
|
40
|
+
location: File path and line number.
|
|
41
|
+
debt_type: Category of debt.
|
|
42
|
+
severity: Severity level.
|
|
43
|
+
description: Detailed description.
|
|
44
|
+
line_number: Line number in file.
|
|
45
|
+
language: Source language.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
debt_id: str
|
|
49
|
+
location: str
|
|
50
|
+
debt_type: str
|
|
51
|
+
severity: str
|
|
52
|
+
description: str
|
|
53
|
+
line_number: int | None = None
|
|
54
|
+
language: str = "python"
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict[str, str | int | None]:
|
|
57
|
+
"""Convert to dictionary."""
|
|
58
|
+
return {
|
|
59
|
+
"id": self.debt_id,
|
|
60
|
+
"location": self.location,
|
|
61
|
+
"type": self.debt_type,
|
|
62
|
+
"severity": self.severity,
|
|
63
|
+
"description": self.description,
|
|
64
|
+
"line": self.line_number,
|
|
65
|
+
"language": self.language,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def to_markdown_row(self) -> str:
|
|
69
|
+
"""Format as TECH_DEBT.md table row."""
|
|
70
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
71
|
+
return (
|
|
72
|
+
f"| {self.debt_id} | `{self.location}` | {self.debt_type} | "
|
|
73
|
+
f"{self.severity} | - | Open | Scanner | {today} |"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ScanResult:
|
|
79
|
+
"""Result of debt scan.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
items: List of debt items found.
|
|
83
|
+
files_scanned: Number of files scanned.
|
|
84
|
+
errors: List of errors encountered.
|
|
85
|
+
languages: Languages detected.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
items: list[DebtItem] = field(default_factory=list)
|
|
89
|
+
files_scanned: int = 0
|
|
90
|
+
errors: list[str] = field(default_factory=list)
|
|
91
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def by_type(self) -> dict[str, list[DebtItem]]:
|
|
95
|
+
"""Group items by debt type."""
|
|
96
|
+
grouped: dict[str, list[DebtItem]] = {}
|
|
97
|
+
for item in self.items:
|
|
98
|
+
if item.debt_type not in grouped:
|
|
99
|
+
grouped[item.debt_type] = []
|
|
100
|
+
grouped[item.debt_type].append(item)
|
|
101
|
+
return grouped
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def by_severity(self) -> dict[str, int]:
|
|
105
|
+
"""Count items by severity."""
|
|
106
|
+
counts: dict[str, int] = {
|
|
107
|
+
Severity.CRITICAL: 0,
|
|
108
|
+
Severity.HIGH: 0,
|
|
109
|
+
Severity.MEDIUM: 0,
|
|
110
|
+
Severity.LOW: 0,
|
|
111
|
+
}
|
|
112
|
+
for item in self.items:
|
|
113
|
+
if item.severity in counts:
|
|
114
|
+
counts[item.severity] += 1
|
|
115
|
+
return counts
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class FileCacheEntry:
|
|
120
|
+
"""Cached scan result for a single file.
|
|
121
|
+
|
|
122
|
+
Attributes:
|
|
123
|
+
file_hash: MD5 hash of file content.
|
|
124
|
+
items: Debt items found in file.
|
|
125
|
+
scanned_at: Timestamp of scan.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
file_hash: str
|
|
129
|
+
items: list[dict[str, Any]]
|
|
130
|
+
scanned_at: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class ScanCheckpoint:
|
|
135
|
+
"""Checkpoint for resumable scanning.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
files_to_scan: List of files to scan.
|
|
139
|
+
current_index: Current file index.
|
|
140
|
+
completed_files: Files already scanned.
|
|
141
|
+
started_at: Scan start time.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
files_to_scan: list[str]
|
|
145
|
+
current_index: int
|
|
146
|
+
completed_files: list[str]
|
|
147
|
+
started_at: str
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _get_ref_dir(base_dir: Path) -> Path:
|
|
151
|
+
"""Get REF cache directory path for the scanned directory.
|
|
152
|
+
|
|
153
|
+
For debt scanning, cache is stored in the scanned directory itself,
|
|
154
|
+
not the project root. This allows independent caching per directory.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
base_dir: Directory being scanned.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Path to .ref_cache directory within base_dir.
|
|
161
|
+
"""
|
|
162
|
+
from ref_agents.utils.git_utils import REF_CACHE_DIR
|
|
163
|
+
|
|
164
|
+
ref_dir = base_dir / REF_CACHE_DIR
|
|
165
|
+
ref_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
return ref_dir
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _compute_file_hash(file_path: Path) -> str:
|
|
170
|
+
"""Compute MD5 hash of file content."""
|
|
171
|
+
try:
|
|
172
|
+
content = file_path.read_bytes()
|
|
173
|
+
return hashlib.md5(content, usedforsecurity=False).hexdigest()
|
|
174
|
+
except OSError:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_cache(base_dir: Path) -> dict[str, FileCacheEntry]:
|
|
179
|
+
"""Load debt cache from disk.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
base_dir: Project root directory.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Dictionary mapping file paths to cache entries.
|
|
186
|
+
"""
|
|
187
|
+
cache_path = _get_ref_dir(base_dir) / CACHE_FILE
|
|
188
|
+
if not cache_path.exists():
|
|
189
|
+
return {}
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
193
|
+
return {
|
|
194
|
+
path: FileCacheEntry(
|
|
195
|
+
file_hash=entry["file_hash"],
|
|
196
|
+
items=entry["items"],
|
|
197
|
+
scanned_at=entry["scanned_at"],
|
|
198
|
+
)
|
|
199
|
+
for path, entry in data.items()
|
|
200
|
+
}
|
|
201
|
+
except (json.JSONDecodeError, KeyError, OSError) as e:
|
|
202
|
+
logger.warning("cache_load_failed", error=str(e))
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _save_cache(base_dir: Path, cache: dict[str, FileCacheEntry]) -> None:
|
|
207
|
+
"""Save debt cache to disk.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
base_dir: Project root directory.
|
|
211
|
+
cache: Cache dictionary to save.
|
|
212
|
+
"""
|
|
213
|
+
cache_path = _get_ref_dir(base_dir) / CACHE_FILE
|
|
214
|
+
data = {
|
|
215
|
+
path: {
|
|
216
|
+
"file_hash": entry.file_hash,
|
|
217
|
+
"items": entry.items,
|
|
218
|
+
"scanned_at": entry.scanned_at,
|
|
219
|
+
}
|
|
220
|
+
for path, entry in cache.items()
|
|
221
|
+
}
|
|
222
|
+
try:
|
|
223
|
+
cache_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
224
|
+
except OSError as e:
|
|
225
|
+
logger.warning("cache_save_failed", error=str(e))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _load_checkpoint(base_dir: Path) -> ScanCheckpoint | None:
|
|
229
|
+
"""Load scan checkpoint from disk.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
base_dir: Project root directory.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Checkpoint if exists, None otherwise.
|
|
236
|
+
"""
|
|
237
|
+
checkpoint_path = _get_ref_dir(base_dir) / CHECKPOINT_FILE
|
|
238
|
+
if not checkpoint_path.exists():
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(checkpoint_path.read_text(encoding="utf-8"))
|
|
243
|
+
return ScanCheckpoint(
|
|
244
|
+
files_to_scan=data["files_to_scan"],
|
|
245
|
+
current_index=data["current_index"],
|
|
246
|
+
completed_files=data["completed_files"],
|
|
247
|
+
started_at=data["started_at"],
|
|
248
|
+
)
|
|
249
|
+
except (json.JSONDecodeError, KeyError, OSError) as e:
|
|
250
|
+
logger.warning("checkpoint_load_failed", error=str(e))
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _save_checkpoint(base_dir: Path, checkpoint: ScanCheckpoint) -> None:
|
|
255
|
+
"""Save scan checkpoint to disk (called after each file).
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
base_dir: Project root directory.
|
|
259
|
+
checkpoint: Checkpoint to save.
|
|
260
|
+
"""
|
|
261
|
+
checkpoint_path = _get_ref_dir(base_dir) / CHECKPOINT_FILE
|
|
262
|
+
data = {
|
|
263
|
+
"files_to_scan": checkpoint.files_to_scan,
|
|
264
|
+
"current_index": checkpoint.current_index,
|
|
265
|
+
"completed_files": checkpoint.completed_files,
|
|
266
|
+
"started_at": checkpoint.started_at,
|
|
267
|
+
}
|
|
268
|
+
try:
|
|
269
|
+
checkpoint_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
270
|
+
except OSError as e:
|
|
271
|
+
logger.warning("checkpoint_save_failed", error=str(e))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _clear_checkpoint(base_dir: Path) -> None:
|
|
275
|
+
"""Remove checkpoint file after successful scan completion."""
|
|
276
|
+
checkpoint_path = _get_ref_dir(base_dir) / CHECKPOINT_FILE
|
|
277
|
+
try:
|
|
278
|
+
if checkpoint_path.exists():
|
|
279
|
+
checkpoint_path.unlink()
|
|
280
|
+
except OSError:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def get_cached_debt_count(base_dir: Path) -> int | None:
|
|
285
|
+
"""Get debt count from cache without scanning.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
base_dir: Project root directory.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Total debt items in cache, or None if no cache exists.
|
|
292
|
+
"""
|
|
293
|
+
cache = _load_cache(base_dir)
|
|
294
|
+
if not cache:
|
|
295
|
+
return None
|
|
296
|
+
return sum(len(entry.items) for entry in cache.values())
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class MultiLanguageScanner:
|
|
300
|
+
"""Multi-language debt scanner using configurable rules.
|
|
301
|
+
|
|
302
|
+
Attributes:
|
|
303
|
+
config_loader: Configuration loader instance.
|
|
304
|
+
language_detector: Language detector instance.
|
|
305
|
+
project_root: Project root directory.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
309
|
+
"""Initialize scanner.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
project_root: Project root for framework detection.
|
|
313
|
+
"""
|
|
314
|
+
self.config_loader = ConfigLoader()
|
|
315
|
+
self.project_root = project_root or Path.cwd()
|
|
316
|
+
self.language_detector = LanguageDetector(self.project_root)
|
|
317
|
+
self._debt_counter = 0
|
|
318
|
+
|
|
319
|
+
def _next_debt_id(self) -> str:
|
|
320
|
+
"""Generate next debt ID."""
|
|
321
|
+
self._debt_counter += 1
|
|
322
|
+
return f"DEBT-{self._debt_counter:03d}"
|
|
323
|
+
|
|
324
|
+
def _severity_emoji(self, severity: str) -> str:
|
|
325
|
+
"""Convert severity string to emoji format."""
|
|
326
|
+
return Severity.from_string(severity)
|
|
327
|
+
|
|
328
|
+
def scan_file(self, file_path: Path) -> list[DebtItem]:
|
|
329
|
+
"""Scan a file for debt patterns.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
file_path: Path to file.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
List of debt items found.
|
|
336
|
+
"""
|
|
337
|
+
# Detect language and framework
|
|
338
|
+
lang_info = self.language_detector.detect(file_path)
|
|
339
|
+
|
|
340
|
+
if lang_info.language == "unknown":
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
# Load configuration
|
|
344
|
+
try:
|
|
345
|
+
config = self.config_loader.load(lang_info.language, lang_info.framework)
|
|
346
|
+
except FileNotFoundError:
|
|
347
|
+
logger.warning(
|
|
348
|
+
"config_not_found",
|
|
349
|
+
language=lang_info.language,
|
|
350
|
+
framework=lang_info.framework,
|
|
351
|
+
)
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
# Read file content
|
|
355
|
+
try:
|
|
356
|
+
content = file_path.read_text(encoding="utf-8")
|
|
357
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
358
|
+
logger.warning("file_read_error", file=str(file_path), error=str(e))
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
# Scan based on parser type
|
|
362
|
+
if config.parser == "ast" and lang_info.language == "python":
|
|
363
|
+
return self._scan_python_ast(file_path, content, config, lang_info)
|
|
364
|
+
return self._scan_regex(file_path, content, config, lang_info)
|
|
365
|
+
|
|
366
|
+
def _scan_python_ast(
|
|
367
|
+
self,
|
|
368
|
+
file_path: Path,
|
|
369
|
+
content: str,
|
|
370
|
+
config: LanguageConfig,
|
|
371
|
+
lang_info: LanguageInfo,
|
|
372
|
+
) -> list[DebtItem]:
|
|
373
|
+
"""Scan Python file using AST.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
file_path: Path to file.
|
|
377
|
+
content: File content.
|
|
378
|
+
config: Language configuration.
|
|
379
|
+
lang_info: Detected language info.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
List of debt items.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
tree = ast.parse(content)
|
|
386
|
+
except SyntaxError as e:
|
|
387
|
+
logger.warning("syntax_error", file=str(file_path), error=str(e))
|
|
388
|
+
return []
|
|
389
|
+
|
|
390
|
+
scanner = PythonASTScanner(
|
|
391
|
+
str(file_path),
|
|
392
|
+
content,
|
|
393
|
+
config,
|
|
394
|
+
self._next_debt_id,
|
|
395
|
+
)
|
|
396
|
+
scanner.visit(tree)
|
|
397
|
+
scanner.scan_todos()
|
|
398
|
+
scanner.scan_deprecated_imports(tree)
|
|
399
|
+
|
|
400
|
+
return scanner.items
|
|
401
|
+
|
|
402
|
+
def _scan_regex(
|
|
403
|
+
self,
|
|
404
|
+
file_path: Path,
|
|
405
|
+
content: str,
|
|
406
|
+
config: LanguageConfig,
|
|
407
|
+
lang_info: LanguageInfo,
|
|
408
|
+
) -> list[DebtItem]:
|
|
409
|
+
"""Scan file using regex patterns from config.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
file_path: Path to file.
|
|
413
|
+
content: File content.
|
|
414
|
+
config: Language configuration.
|
|
415
|
+
lang_info: Detected language info.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
List of debt items.
|
|
419
|
+
"""
|
|
420
|
+
items: list[DebtItem] = []
|
|
421
|
+
lines = content.splitlines()
|
|
422
|
+
|
|
423
|
+
for pattern_def in config.debt_patterns:
|
|
424
|
+
if not pattern_def.pattern:
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
regex = re.compile(pattern_def.pattern)
|
|
429
|
+
except re.error as e:
|
|
430
|
+
logger.warning(
|
|
431
|
+
"invalid_pattern",
|
|
432
|
+
pattern=pattern_def.name,
|
|
433
|
+
error=str(e),
|
|
434
|
+
)
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# Scan each line
|
|
438
|
+
for i, line in enumerate(lines, start=1):
|
|
439
|
+
if regex.search(line):
|
|
440
|
+
location = f"{file_path}:{i}"
|
|
441
|
+
items.append(
|
|
442
|
+
DebtItem(
|
|
443
|
+
debt_id=self._next_debt_id(),
|
|
444
|
+
location=location,
|
|
445
|
+
debt_type=pattern_def.name,
|
|
446
|
+
severity=self._severity_emoji(pattern_def.severity),
|
|
447
|
+
description=pattern_def.description,
|
|
448
|
+
line_number=i,
|
|
449
|
+
language=str(lang_info),
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Check deprecated imports
|
|
454
|
+
for deprecated, suggestion in config.deprecated_imports.items():
|
|
455
|
+
if deprecated in content:
|
|
456
|
+
for i, line in enumerate(lines, start=1):
|
|
457
|
+
if deprecated in line and ("import" in line or "from" in line):
|
|
458
|
+
items.append(
|
|
459
|
+
DebtItem(
|
|
460
|
+
debt_id=self._next_debt_id(),
|
|
461
|
+
location=f"{file_path}:{i}",
|
|
462
|
+
debt_type="deprecated",
|
|
463
|
+
severity=Severity.HIGH,
|
|
464
|
+
description=f"Deprecated `{deprecated}`: {suggestion}",
|
|
465
|
+
line_number=i,
|
|
466
|
+
language=str(lang_info),
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
return items
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class PythonASTScanner(ast.NodeVisitor):
|
|
475
|
+
"""AST visitor for Python debt patterns.
|
|
476
|
+
|
|
477
|
+
Attributes:
|
|
478
|
+
file_path: Path to file.
|
|
479
|
+
content: File content.
|
|
480
|
+
config: Language configuration.
|
|
481
|
+
items: Collected debt items.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
def __init__(
|
|
485
|
+
self,
|
|
486
|
+
file_path: str,
|
|
487
|
+
content: str,
|
|
488
|
+
config: LanguageConfig,
|
|
489
|
+
id_generator: Any,
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Initialize scanner.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
file_path: Path to file.
|
|
495
|
+
content: File content.
|
|
496
|
+
config: Language configuration.
|
|
497
|
+
id_generator: Function to generate debt IDs.
|
|
498
|
+
"""
|
|
499
|
+
self.file_path = file_path
|
|
500
|
+
self.content = content
|
|
501
|
+
self.config = config
|
|
502
|
+
self.lines = content.splitlines()
|
|
503
|
+
self.items: list[DebtItem] = []
|
|
504
|
+
self._next_id = id_generator
|
|
505
|
+
|
|
506
|
+
def _add_item(
|
|
507
|
+
self,
|
|
508
|
+
debt_type: str,
|
|
509
|
+
severity: str,
|
|
510
|
+
description: str,
|
|
511
|
+
line_number: int | None = None,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Add a debt item."""
|
|
514
|
+
location = self.file_path
|
|
515
|
+
if line_number:
|
|
516
|
+
location = f"{self.file_path}:{line_number}"
|
|
517
|
+
|
|
518
|
+
self.items.append(
|
|
519
|
+
DebtItem(
|
|
520
|
+
debt_id=self._next_id(),
|
|
521
|
+
location=location,
|
|
522
|
+
debt_type=debt_type,
|
|
523
|
+
severity=severity,
|
|
524
|
+
description=description,
|
|
525
|
+
line_number=line_number,
|
|
526
|
+
language="python",
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
531
|
+
"""Check function definitions."""
|
|
532
|
+
self._check_function(node)
|
|
533
|
+
self.generic_visit(node)
|
|
534
|
+
|
|
535
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
536
|
+
"""Check async function definitions."""
|
|
537
|
+
self._check_function(node)
|
|
538
|
+
self.generic_visit(node)
|
|
539
|
+
|
|
540
|
+
def _check_type_hints(
|
|
541
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_private: bool
|
|
542
|
+
) -> None:
|
|
543
|
+
"""Check for missing type hints."""
|
|
544
|
+
if is_private:
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
has_return_type = node.returns is not None
|
|
548
|
+
if not has_return_type:
|
|
549
|
+
self._add_item(
|
|
550
|
+
"no-types",
|
|
551
|
+
Severity.MEDIUM,
|
|
552
|
+
f"Function `{node.name}` missing return type hint",
|
|
553
|
+
node.lineno,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
total_args = len(node.args.args)
|
|
557
|
+
args_with_types = sum(1 for arg in node.args.args if arg.annotation is not None)
|
|
558
|
+
|
|
559
|
+
if total_args > 0 and args_with_types < total_args:
|
|
560
|
+
missing = total_args - args_with_types
|
|
561
|
+
self._add_item(
|
|
562
|
+
"no-types",
|
|
563
|
+
Severity.MEDIUM,
|
|
564
|
+
f"Function `{node.name}` missing {missing} arg type hints",
|
|
565
|
+
node.lineno,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
def _check_docstring(
|
|
569
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_private: bool
|
|
570
|
+
) -> None:
|
|
571
|
+
"""Check for missing docstring."""
|
|
572
|
+
if not is_private and not ast.get_docstring(node):
|
|
573
|
+
self._add_item(
|
|
574
|
+
"no-docs",
|
|
575
|
+
Severity.LOW,
|
|
576
|
+
f"Function `{node.name}` missing docstring",
|
|
577
|
+
node.lineno,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def _check_complexity_metric(
|
|
581
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, thresholds: Any
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Check cyclomatic complexity."""
|
|
584
|
+
complexity = self._estimate_complexity(node)
|
|
585
|
+
if complexity > thresholds.complexity:
|
|
586
|
+
self._add_item(
|
|
587
|
+
"complexity",
|
|
588
|
+
Severity.HIGH,
|
|
589
|
+
f"`{node.name}` complexity {complexity} > {thresholds.complexity}",
|
|
590
|
+
node.lineno,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
def _check_function_length_metric(
|
|
594
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, thresholds: Any
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Check function length."""
|
|
597
|
+
if hasattr(node, "end_lineno") and node.end_lineno:
|
|
598
|
+
func_length = node.end_lineno - node.lineno
|
|
599
|
+
if func_length > thresholds.function_length:
|
|
600
|
+
self._add_item(
|
|
601
|
+
"long-function",
|
|
602
|
+
Severity.MEDIUM,
|
|
603
|
+
f"`{node.name}` is {func_length} lines (max {thresholds.function_length})",
|
|
604
|
+
node.lineno,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
def _check_nesting_depth_metric(
|
|
608
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef, thresholds: Any
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Check nesting depth."""
|
|
611
|
+
max_depth = self._max_nesting_depth(node)
|
|
612
|
+
if max_depth > thresholds.nesting_depth:
|
|
613
|
+
self._add_item(
|
|
614
|
+
"deep-nesting",
|
|
615
|
+
Severity.MEDIUM,
|
|
616
|
+
f"`{node.name}` nesting depth {max_depth} > {thresholds.nesting_depth}",
|
|
617
|
+
node.lineno,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _check_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
621
|
+
"""Check function for debt patterns."""
|
|
622
|
+
is_private = node.name.startswith("_")
|
|
623
|
+
thresholds = self.config.thresholds
|
|
624
|
+
|
|
625
|
+
self._check_type_hints(node, is_private)
|
|
626
|
+
self._check_docstring(node, is_private)
|
|
627
|
+
self._check_complexity_metric(node, thresholds)
|
|
628
|
+
self._check_function_length_metric(node, thresholds)
|
|
629
|
+
self._check_nesting_depth_metric(node, thresholds)
|
|
630
|
+
|
|
631
|
+
def _estimate_complexity(self, node: ast.AST) -> int:
|
|
632
|
+
"""Estimate cyclomatic complexity."""
|
|
633
|
+
complexity = 1
|
|
634
|
+
for child in ast.walk(node):
|
|
635
|
+
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
|
|
636
|
+
complexity += 1
|
|
637
|
+
elif isinstance(child, ast.BoolOp):
|
|
638
|
+
complexity += len(child.values) - 1
|
|
639
|
+
elif isinstance(
|
|
640
|
+
child, (ast.ListComp, ast.DictComp, ast.SetComp, ast.GeneratorExp)
|
|
641
|
+
):
|
|
642
|
+
complexity += 1
|
|
643
|
+
return complexity
|
|
644
|
+
|
|
645
|
+
def _max_nesting_depth(self, node: ast.AST, current_depth: int = 0) -> int:
|
|
646
|
+
"""Calculate maximum nesting depth."""
|
|
647
|
+
max_depth = current_depth
|
|
648
|
+
nesting_nodes = (ast.If, ast.For, ast.While, ast.With, ast.Try)
|
|
649
|
+
|
|
650
|
+
for child in ast.iter_child_nodes(node):
|
|
651
|
+
if isinstance(child, nesting_nodes):
|
|
652
|
+
child_depth = self._max_nesting_depth(child, current_depth + 1)
|
|
653
|
+
else:
|
|
654
|
+
child_depth = self._max_nesting_depth(child, current_depth)
|
|
655
|
+
max_depth = max(max_depth, child_depth)
|
|
656
|
+
|
|
657
|
+
return max_depth
|
|
658
|
+
|
|
659
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
660
|
+
"""Check exception handlers."""
|
|
661
|
+
if node.type is None:
|
|
662
|
+
self._add_item(
|
|
663
|
+
"bare-except",
|
|
664
|
+
Severity.CRITICAL,
|
|
665
|
+
"Bare `except:` clause - catches all exceptions",
|
|
666
|
+
node.lineno,
|
|
667
|
+
)
|
|
668
|
+
elif isinstance(node.type, ast.Name) and node.type.id == "Exception":
|
|
669
|
+
self._add_item(
|
|
670
|
+
"bare-except",
|
|
671
|
+
Severity.HIGH,
|
|
672
|
+
"`except Exception:` - too broad",
|
|
673
|
+
node.lineno,
|
|
674
|
+
)
|
|
675
|
+
self.generic_visit(node)
|
|
676
|
+
|
|
677
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
678
|
+
"""Check class definitions."""
|
|
679
|
+
if not ast.get_docstring(node):
|
|
680
|
+
self._add_item(
|
|
681
|
+
"no-docs",
|
|
682
|
+
Severity.LOW,
|
|
683
|
+
f"Class `{node.name}` missing docstring",
|
|
684
|
+
node.lineno,
|
|
685
|
+
)
|
|
686
|
+
self.generic_visit(node)
|
|
687
|
+
|
|
688
|
+
def scan_todos(self) -> None:
|
|
689
|
+
"""Scan for TODO/FIXME comments."""
|
|
690
|
+
todo_pattern = re.compile(
|
|
691
|
+
r"#\s*(TODO|FIXME|XXX|HACK|BUG)[\s:]+(.+)", re.IGNORECASE
|
|
692
|
+
)
|
|
693
|
+
for i, line in enumerate(self.lines, start=1):
|
|
694
|
+
match = todo_pattern.search(line)
|
|
695
|
+
if match:
|
|
696
|
+
tag = match.group(1).upper()
|
|
697
|
+
message = match.group(2).strip()[:50]
|
|
698
|
+
severity = Severity.HIGH if tag in ("FIXME", "BUG") else Severity.MEDIUM
|
|
699
|
+
self._add_item(
|
|
700
|
+
"todo-fixme",
|
|
701
|
+
severity,
|
|
702
|
+
f"{tag}: {message}",
|
|
703
|
+
i,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def scan_deprecated_imports(self, tree: ast.AST) -> None:
|
|
707
|
+
"""Scan for deprecated imports."""
|
|
708
|
+
deprecated = self.config.deprecated_imports
|
|
709
|
+
|
|
710
|
+
for node in ast.walk(tree):
|
|
711
|
+
if isinstance(node, ast.Import):
|
|
712
|
+
for alias in node.names:
|
|
713
|
+
module = alias.name.split(".")[0]
|
|
714
|
+
if module in deprecated:
|
|
715
|
+
self._add_item(
|
|
716
|
+
"deprecated",
|
|
717
|
+
Severity.HIGH,
|
|
718
|
+
f"Deprecated `{module}`: {deprecated[module]}",
|
|
719
|
+
node.lineno,
|
|
720
|
+
)
|
|
721
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
722
|
+
module = node.module.split(".")[0]
|
|
723
|
+
if module in deprecated:
|
|
724
|
+
self._add_item(
|
|
725
|
+
"deprecated",
|
|
726
|
+
Severity.HIGH,
|
|
727
|
+
f"Deprecated `{module}`: {deprecated[module]}",
|
|
728
|
+
node.lineno,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def scan_file(file_path: Path) -> list[DebtItem]:
|
|
733
|
+
"""Scan a single file for debt patterns.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
file_path: Path to file.
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
List of debt items found.
|
|
740
|
+
"""
|
|
741
|
+
scanner = MultiLanguageScanner(file_path.parent)
|
|
742
|
+
return scanner.scan_file(file_path)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# Common directories to skip
|
|
746
|
+
SKIP_DIRECTORIES = frozenset(
|
|
747
|
+
{
|
|
748
|
+
"__pycache__",
|
|
749
|
+
".git",
|
|
750
|
+
".venv",
|
|
751
|
+
".ref",
|
|
752
|
+
"venv",
|
|
753
|
+
"node_modules",
|
|
754
|
+
".tox",
|
|
755
|
+
"dist",
|
|
756
|
+
"build",
|
|
757
|
+
"target",
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
# Supported extensions
|
|
762
|
+
SUPPORTED_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".java", ".cs", ".csx"}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _should_skip_path(path: Path) -> bool:
|
|
766
|
+
"""Check if path should be skipped."""
|
|
767
|
+
return any(excluded in path.parts for excluded in SKIP_DIRECTORIES)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _get_files_to_scan(
|
|
771
|
+
dir_path: Path,
|
|
772
|
+
extensions: set[str],
|
|
773
|
+
incremental: bool,
|
|
774
|
+
cache: dict[str, FileCacheEntry],
|
|
775
|
+
) -> list[Path]:
|
|
776
|
+
"""Determine which files need to be scanned.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
dir_path: Root directory to scan.
|
|
780
|
+
extensions: Set of supported file extensions.
|
|
781
|
+
incremental: Whether to use incremental scanning.
|
|
782
|
+
cache: Existing cache.
|
|
783
|
+
|
|
784
|
+
Returns:
|
|
785
|
+
List of paths to scan.
|
|
786
|
+
"""
|
|
787
|
+
if incremental and is_git_repo(dir_path) and cache:
|
|
788
|
+
changed_files = get_changed_files(dir_path, extensions)
|
|
789
|
+
changed_set = {str(dir_path / f) for f in changed_files}
|
|
790
|
+
|
|
791
|
+
files_to_scan = []
|
|
792
|
+
for file_path in dir_path.rglob("*"):
|
|
793
|
+
if not file_path.is_file():
|
|
794
|
+
continue
|
|
795
|
+
if file_path.suffix not in extensions:
|
|
796
|
+
continue
|
|
797
|
+
if _should_skip_path(file_path):
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
str_path = str(file_path)
|
|
801
|
+
if str_path in changed_set:
|
|
802
|
+
files_to_scan.append(file_path)
|
|
803
|
+
elif str_path in cache:
|
|
804
|
+
current_hash = _compute_file_hash(file_path)
|
|
805
|
+
if current_hash != cache[str_path].file_hash:
|
|
806
|
+
files_to_scan.append(file_path)
|
|
807
|
+
else:
|
|
808
|
+
files_to_scan.append(file_path)
|
|
809
|
+
|
|
810
|
+
logger.info(
|
|
811
|
+
"incremental_scan",
|
|
812
|
+
changed_files=len(files_to_scan),
|
|
813
|
+
cached_files=len(cache),
|
|
814
|
+
)
|
|
815
|
+
return files_to_scan
|
|
816
|
+
|
|
817
|
+
# Full scan
|
|
818
|
+
return [
|
|
819
|
+
f
|
|
820
|
+
for f in dir_path.rglob("*")
|
|
821
|
+
if f.is_file() and f.suffix in extensions and not _should_skip_path(f)
|
|
822
|
+
]
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def scan_directory(directory: str | Path, incremental: bool = True) -> ScanResult:
|
|
826
|
+
"""Scan directory for technical debt with incremental and checkpoint support.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
directory: Path to directory.
|
|
830
|
+
incremental: If True, only scan changed files (uses git + cache).
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
ScanResult with all debt items.
|
|
834
|
+
"""
|
|
835
|
+
result = ScanResult()
|
|
836
|
+
dir_path = Path(directory).resolve()
|
|
837
|
+
|
|
838
|
+
if not dir_path.exists():
|
|
839
|
+
result.errors.append(f"Directory not found: {directory}")
|
|
840
|
+
return result
|
|
841
|
+
|
|
842
|
+
scanner = MultiLanguageScanner(dir_path)
|
|
843
|
+
|
|
844
|
+
# Excluded directories (unused)
|
|
845
|
+
|
|
846
|
+
# Supported extensions
|
|
847
|
+
extensions = {".py", ".ts", ".tsx", ".js", ".jsx", ".java", ".cs", ".csx"}
|
|
848
|
+
|
|
849
|
+
# Load cache
|
|
850
|
+
cache = _load_cache(dir_path)
|
|
851
|
+
|
|
852
|
+
# Check for existing checkpoint (resume interrupted scan)
|
|
853
|
+
checkpoint = _load_checkpoint(dir_path)
|
|
854
|
+
if checkpoint:
|
|
855
|
+
logger.info(
|
|
856
|
+
"resuming_scan",
|
|
857
|
+
from_index=checkpoint.current_index,
|
|
858
|
+
total_files=len(checkpoint.files_to_scan),
|
|
859
|
+
)
|
|
860
|
+
files_to_scan = [Path(f) for f in checkpoint.files_to_scan]
|
|
861
|
+
start_index = checkpoint.current_index
|
|
862
|
+
else:
|
|
863
|
+
files_to_scan = _get_files_to_scan(dir_path, extensions, incremental, cache)
|
|
864
|
+
start_index = 0
|
|
865
|
+
|
|
866
|
+
checkpoint = ScanCheckpoint(
|
|
867
|
+
files_to_scan=[str(f) for f in files_to_scan],
|
|
868
|
+
current_index=0,
|
|
869
|
+
completed_files=[],
|
|
870
|
+
started_at=datetime.now(timezone.utc).isoformat(),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Scan files with checkpoint after each
|
|
874
|
+
for i in range(start_index, len(files_to_scan)):
|
|
875
|
+
file_path = (
|
|
876
|
+
files_to_scan[i]
|
|
877
|
+
if isinstance(files_to_scan[i], Path)
|
|
878
|
+
else Path(files_to_scan[i])
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
if not file_path.exists():
|
|
882
|
+
continue
|
|
883
|
+
|
|
884
|
+
result.files_scanned += 1
|
|
885
|
+
|
|
886
|
+
# Track language
|
|
887
|
+
lang_info = scanner.language_detector.detect(file_path)
|
|
888
|
+
lang_key = str(lang_info)
|
|
889
|
+
result.languages[lang_key] = result.languages.get(lang_key, 0) + 1
|
|
890
|
+
|
|
891
|
+
# Scan file
|
|
892
|
+
items = scanner.scan_file(file_path)
|
|
893
|
+
result.items.extend(items)
|
|
894
|
+
|
|
895
|
+
str_path = str(file_path)
|
|
896
|
+
cache[str_path] = FileCacheEntry(
|
|
897
|
+
file_hash=_compute_file_hash(file_path),
|
|
898
|
+
items=[item.to_dict() for item in items],
|
|
899
|
+
scanned_at=datetime.now(timezone.utc).isoformat(),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
checkpoint.current_index = i + 1
|
|
903
|
+
checkpoint.completed_files.append(str_path)
|
|
904
|
+
_save_checkpoint(dir_path, checkpoint)
|
|
905
|
+
|
|
906
|
+
# Merge cached results for unchanged files
|
|
907
|
+
if incremental and cache:
|
|
908
|
+
scanned_paths = {str(f) for f in files_to_scan}
|
|
909
|
+
for path, entry in cache.items():
|
|
910
|
+
if path not in scanned_paths:
|
|
911
|
+
for item_dict in entry.items:
|
|
912
|
+
result.items.append(
|
|
913
|
+
DebtItem(
|
|
914
|
+
debt_id=item_dict.get("id", "DEBT-???"),
|
|
915
|
+
location=item_dict.get("location", path),
|
|
916
|
+
debt_type=item_dict.get("type", "unknown"),
|
|
917
|
+
severity=item_dict.get("severity", Severity.MEDIUM),
|
|
918
|
+
description=item_dict.get("description", ""),
|
|
919
|
+
line_number=item_dict.get("line"),
|
|
920
|
+
language=item_dict.get("language", "unknown"),
|
|
921
|
+
)
|
|
922
|
+
)
|
|
923
|
+
# Count cached file
|
|
924
|
+
result.files_scanned += 1
|
|
925
|
+
|
|
926
|
+
# Save final cache and clear checkpoint
|
|
927
|
+
_save_cache(dir_path, cache)
|
|
928
|
+
_clear_checkpoint(dir_path)
|
|
929
|
+
|
|
930
|
+
logger.info(
|
|
931
|
+
"debt_scan_complete",
|
|
932
|
+
files_scanned=result.files_scanned,
|
|
933
|
+
items_found=len(result.items),
|
|
934
|
+
languages=result.languages,
|
|
935
|
+
incremental=incremental,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
return result
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def generate_debt_report(directory: str) -> str:
|
|
942
|
+
"""Generate technical debt report.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
directory: Path to scan.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Markdown formatted report.
|
|
949
|
+
"""
|
|
950
|
+
result = scan_directory(directory)
|
|
951
|
+
|
|
952
|
+
if result.errors:
|
|
953
|
+
return f"Errors: {result.errors}"
|
|
954
|
+
|
|
955
|
+
output = f"""# Technical Debt Scan Report
|
|
956
|
+
|
|
957
|
+
*Auto-generated by debt_scanner.py (multi-language)*
|
|
958
|
+
*Scanned: {result.files_scanned} files*
|
|
959
|
+
*Found: {len(result.items)} items*
|
|
960
|
+
|
|
961
|
+
## Languages Detected
|
|
962
|
+
|
|
963
|
+
| Language | Files |
|
|
964
|
+
|----------|-------|
|
|
965
|
+
"""
|
|
966
|
+
for lang, count in sorted(result.languages.items()):
|
|
967
|
+
output += f"| {lang} | {count} |\n"
|
|
968
|
+
|
|
969
|
+
output += "\n## Summary by Severity\n\n"
|
|
970
|
+
output += "| Severity | Count |\n"
|
|
971
|
+
output += "|----------|-------|\n"
|
|
972
|
+
for severity, count in result.by_severity.items():
|
|
973
|
+
output += f"| {severity} | {count} |\n"
|
|
974
|
+
|
|
975
|
+
output += "\n## Items by Type\n\n"
|
|
976
|
+
|
|
977
|
+
for debt_type, items in result.by_type.items():
|
|
978
|
+
output += f"### {debt_type} ({len(items)})\n\n"
|
|
979
|
+
output += "| ID | Location | Severity | Description |\n"
|
|
980
|
+
output += "|----|----------|----------|-------------|\n"
|
|
981
|
+
for item in items:
|
|
982
|
+
loc = item.location
|
|
983
|
+
desc = (
|
|
984
|
+
item.description[:50]
|
|
985
|
+
if len(item.description) > 50 # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
986
|
+
else item.description
|
|
987
|
+
)
|
|
988
|
+
output += f"| {item.debt_id} | `{loc}` | {item.severity} | {desc} |\n"
|
|
989
|
+
output += "\n"
|
|
990
|
+
|
|
991
|
+
return output
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def append_to_tech_debt_md(directory: str, tech_debt_path: str) -> str:
|
|
995
|
+
"""Scan and append new items to TECH_DEBT.md.
|
|
996
|
+
|
|
997
|
+
Args:
|
|
998
|
+
directory: Path to scan.
|
|
999
|
+
tech_debt_path: Path to TECH_DEBT.md file.
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
Status message.
|
|
1003
|
+
"""
|
|
1004
|
+
result = scan_directory(directory)
|
|
1005
|
+
|
|
1006
|
+
if result.errors:
|
|
1007
|
+
return f"Errors: {result.errors}"
|
|
1008
|
+
|
|
1009
|
+
if not result.items:
|
|
1010
|
+
return "No debt items found."
|
|
1011
|
+
|
|
1012
|
+
# Generate new rows
|
|
1013
|
+
new_rows = "\n".join(item.to_markdown_row() for item in result.items)
|
|
1014
|
+
|
|
1015
|
+
# Read existing file
|
|
1016
|
+
debt_file = Path(tech_debt_path)
|
|
1017
|
+
if not debt_file.exists():
|
|
1018
|
+
return f"TECH_DEBT.md not found at {tech_debt_path}"
|
|
1019
|
+
|
|
1020
|
+
try:
|
|
1021
|
+
content = debt_file.read_text(encoding="utf-8")
|
|
1022
|
+
marker = "| DEBT-001"
|
|
1023
|
+
if marker in content:
|
|
1024
|
+
content += "\n" + new_rows
|
|
1025
|
+
else:
|
|
1026
|
+
example = "| DEBT-001 | `src/example.py:45` | No type hints "
|
|
1027
|
+
example += "| 🟡 Medium | STORY-042 | Open | Discovery | 2024-12-22 |"
|
|
1028
|
+
content = content.replace(example, new_rows)
|
|
1029
|
+
|
|
1030
|
+
debt_file.write_text(content, encoding="utf-8")
|
|
1031
|
+
return f"Appended {len(result.items)} items to {tech_debt_path}"
|
|
1032
|
+
|
|
1033
|
+
except OSError as e:
|
|
1034
|
+
return f"Failed to update TECH_DEBT.md: {e}"
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# Tool functions for MCP integration
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def scan_debt_tool(directory: str, summary: bool = False) -> str:
|
|
1041
|
+
"""Scan directory for technical debt.
|
|
1042
|
+
|
|
1043
|
+
Multi-language support: Python, TypeScript, JavaScript, Java.
|
|
1044
|
+
Framework support: React, Angular, Spring Boot.
|
|
1045
|
+
|
|
1046
|
+
Args:
|
|
1047
|
+
directory: Path to scan.
|
|
1048
|
+
summary: If True, return quick summary. If False, return full report.
|
|
1049
|
+
|
|
1050
|
+
Returns:
|
|
1051
|
+
Formatted debt report or summary.
|
|
1052
|
+
"""
|
|
1053
|
+
if not summary:
|
|
1054
|
+
return generate_debt_report(directory)
|
|
1055
|
+
|
|
1056
|
+
# Summary mode
|
|
1057
|
+
result = scan_directory(directory)
|
|
1058
|
+
|
|
1059
|
+
if result.errors:
|
|
1060
|
+
return f"❌ Errors: {', '.join(result.errors)}"
|
|
1061
|
+
|
|
1062
|
+
if not result.items:
|
|
1063
|
+
return f"✅ No technical debt found in {result.files_scanned} files."
|
|
1064
|
+
|
|
1065
|
+
lines = [
|
|
1066
|
+
f"📋 **Debt Summary** ({result.files_scanned} files scanned)\n",
|
|
1067
|
+
]
|
|
1068
|
+
|
|
1069
|
+
# Languages
|
|
1070
|
+
if result.languages:
|
|
1071
|
+
lines.append("| Language | Files |")
|
|
1072
|
+
lines.append("|----------|-------|")
|
|
1073
|
+
for lang, count in sorted(result.languages.items()):
|
|
1074
|
+
lines.append(f"| {lang} | {count} |")
|
|
1075
|
+
lines.append("")
|
|
1076
|
+
|
|
1077
|
+
lines.append("| Severity | Count |")
|
|
1078
|
+
lines.append("|----------|-------|")
|
|
1079
|
+
for severity, count in result.by_severity.items():
|
|
1080
|
+
if count > 0:
|
|
1081
|
+
lines.append(f"| {severity} | {count} |")
|
|
1082
|
+
|
|
1083
|
+
lines.append("")
|
|
1084
|
+
lines.append("| Type | Count |")
|
|
1085
|
+
lines.append("|------|-------|")
|
|
1086
|
+
for debt_type, items in result.by_type.items():
|
|
1087
|
+
lines.append(f"| {debt_type} | {len(items)} |")
|
|
1088
|
+
|
|
1089
|
+
lines.append(f"\n**Total:** {len(result.items)} items")
|
|
1090
|
+
lines.append("\nRun `scan_debt(directory, summary=False)` for full report.")
|
|
1091
|
+
|
|
1092
|
+
return "\n".join(lines)
|