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.
Files changed (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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)