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,234 @@
1
+ """Codemap Freshness Scanner for REF Agents.
2
+
3
+ Enforces codemap_standard.md § 6.1 freshness gate.
4
+ Fixed threshold: 4 hours. No per-file configuration.
5
+
6
+ NOTE: st_mtime may differ from wall clock in containerised/remote envs.
7
+ Acceptable — documented per risk register (REF-CODEMAP-001).
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+ FRESHNESS_THRESHOLD_HOURS: float = 4.0
19
+
20
+
21
+ @dataclass
22
+ class CodemapFileStatus:
23
+ """Freshness status for a single codemap file.
24
+
25
+ Attributes:
26
+ path: Relative path from project root (e.g. 'codemap/CODE_MAP.md').
27
+ last_modified: ISO-8601 UTC timestamp of st_mtime.
28
+ age_hours: Age in decimal hours, rounded to 2 decimal places.
29
+ is_stale: True if age_hours > FRESHNESS_THRESHOLD_HOURS.
30
+ """
31
+
32
+ path: str
33
+ last_modified: str
34
+ age_hours: float
35
+ is_stale: bool
36
+
37
+
38
+ @dataclass
39
+ class CodemapFreshnessResult:
40
+ """Aggregated result of the codemap freshness scan.
41
+
42
+ Attributes:
43
+ status: One of 'fresh' | 'stale' | 'missing'.
44
+ files: Per-file breakdown. Empty when status == 'missing'.
45
+ codemap_dir: Resolved absolute path inspected.
46
+ """
47
+
48
+ status: str
49
+ files: list[CodemapFileStatus] = field(default_factory=list)
50
+ codemap_dir: str = ""
51
+
52
+
53
+ def scan_codemap_freshness(directory: str) -> str:
54
+ """Scan codemap/ for freshness. Entry point for scan(target='codemap_freshness').
55
+
56
+ Constructs codemap path as Path(directory) / 'codemap', checks freshness,
57
+ logs codemap_stale artifact if stale, and returns formatted markdown report.
58
+
59
+ Args:
60
+ directory: Project root path string.
61
+
62
+ Returns:
63
+ Markdown string matching AC-1 format. Never raises.
64
+ """
65
+ # NOTE: Intentional broad catch — public entry point contract guarantees no raise.
66
+ try:
67
+ codemap_dir = Path(directory) / "codemap"
68
+ result = _check_freshness(codemap_dir)
69
+ if result.status == "stale":
70
+ _log_stale_artifact(directory)
71
+ return _format_result(result)
72
+ except (OSError, ValueError, TypeError, RuntimeError) as e:
73
+ logger.error("codemap_freshness_scan_failed", directory=directory, error=str(e))
74
+ return (
75
+ "## Codemap Freshness\nstatus: missing\n\n"
76
+ "Scan error. Run `generate_codemap('<project_root>')` to initialise."
77
+ )
78
+
79
+
80
+ def get_freshness_result(directory: str) -> CodemapFreshnessResult:
81
+ """Return structured freshness result for a project root.
82
+
83
+ Public API for callers needing structured data (e.g. health_scanner).
84
+ Use scan_codemap_freshness() for the formatted string entry point.
85
+
86
+ Args:
87
+ directory: Project root path string.
88
+
89
+ Returns:
90
+ CodemapFreshnessResult with status fresh | stale | missing. Never raises.
91
+ """
92
+ try:
93
+ return _check_freshness(Path(directory) / "codemap")
94
+ except (OSError, ValueError, TypeError) as e:
95
+ logger.error(
96
+ "codemap_freshness_result_failed", directory=directory, error=str(e)
97
+ )
98
+ return CodemapFreshnessResult(status="missing")
99
+
100
+
101
+ def _check_freshness(codemap_dir: Path) -> CodemapFreshnessResult:
102
+ """Check freshness of all .md files in codemap_dir.
103
+
104
+ Logic:
105
+ 1. codemap_dir absent → missing
106
+ 2. No .md files → missing
107
+ 3. CODE_MAP.md absent → missing
108
+ 4. stat() each file → build file list
109
+ 5. Any is_stale=True → stale, else fresh
110
+
111
+ Args:
112
+ codemap_dir: Absolute path to the codemap/ directory.
113
+
114
+ Returns:
115
+ CodemapFreshnessResult. Never raises.
116
+ """
117
+ if not codemap_dir.exists():
118
+ return CodemapFreshnessResult(status="missing", codemap_dir=str(codemap_dir))
119
+
120
+ md_files = sorted(codemap_dir.glob("*.md"))
121
+ if not md_files:
122
+ return CodemapFreshnessResult(status="missing", codemap_dir=str(codemap_dir))
123
+
124
+ if not (codemap_dir / "CODE_MAP.md").exists():
125
+ return CodemapFreshnessResult(status="missing", codemap_dir=str(codemap_dir))
126
+
127
+ now = datetime.now(timezone.utc)
128
+ file_statuses: list[CodemapFileStatus] = []
129
+
130
+ for f in md_files:
131
+ file_status = _stat_file(f, now)
132
+ if file_status is not None:
133
+ file_statuses.append(file_status)
134
+
135
+ if not file_statuses:
136
+ return CodemapFreshnessResult(status="missing", codemap_dir=str(codemap_dir))
137
+
138
+ overall = "stale" if any(fs.is_stale for fs in file_statuses) else "fresh"
139
+ return CodemapFreshnessResult(
140
+ status=overall,
141
+ files=file_statuses,
142
+ codemap_dir=str(codemap_dir),
143
+ )
144
+
145
+
146
+ def _stat_file(path: Path, now: datetime) -> CodemapFileStatus | None:
147
+ """Stat a single file and compute age.
148
+
149
+ Returns None on any OSError — handles TOCTOU race (file vanished between
150
+ glob and stat) and permission errors without surfacing to caller.
151
+
152
+ Args:
153
+ path: Absolute path to file.
154
+ now: Current UTC datetime for consistent age calculation across all files.
155
+
156
+ Returns:
157
+ CodemapFileStatus or None if stat failed.
158
+ """
159
+ try:
160
+ mtime = path.stat().st_mtime
161
+ last_modified_dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
162
+ age_hours = round((now - last_modified_dt).total_seconds() / 3600, 2)
163
+ return CodemapFileStatus(
164
+ path=str(path.relative_to(path.parent.parent)),
165
+ last_modified=last_modified_dt.isoformat(),
166
+ age_hours=age_hours,
167
+ is_stale=age_hours > FRESHNESS_THRESHOLD_HOURS,
168
+ )
169
+ except PermissionError:
170
+ logger.warning("codemap_stat_permission_denied", path=str(path))
171
+ return None
172
+ except OSError as e:
173
+ logger.warning("codemap_stat_failed", path=str(path), error=str(e))
174
+ return None
175
+
176
+
177
+ def _format_result(result: CodemapFreshnessResult) -> str:
178
+ """Format CodemapFreshnessResult as AC-1 markdown string.
179
+
180
+ Args:
181
+ result: Freshness result to render.
182
+
183
+ Returns:
184
+ Markdown string with status: header and file table.
185
+ """
186
+ lines = [f"## Codemap Freshness\nstatus: {result.status}"]
187
+
188
+ if result.status == "missing":
189
+ lines.append(
190
+ "\nCodemap not found or empty. "
191
+ "Run `generate_codemap('<project_root>')` to initialise."
192
+ )
193
+ return "\n".join(lines)
194
+
195
+ lines.append("\n| File | Last Modified (ISO-8601) | Age (hours) |")
196
+ lines.append("|------|--------------------------|-------------|")
197
+ for fs in result.files:
198
+ marker = " ⚠️" if fs.is_stale else ""
199
+ lines.append(f"| {fs.path}{marker} | {fs.last_modified} | {fs.age_hours:.2f} |")
200
+
201
+ if result.status == "stale":
202
+ lines.append(
203
+ f"\n⚠️ Stale files detected (threshold: {FRESHNESS_THRESHOLD_HOURS:.0f}h). "
204
+ "Codemap results are advisory until regenerated."
205
+ )
206
+
207
+ return "\n".join(lines)
208
+
209
+
210
+ def _log_stale_artifact(directory: str) -> None:
211
+ """Log codemap_stale artifact to active workflow story if one exists.
212
+
213
+ Calls mark_artifact_complete(artifact='codemap_stale') with no story_id —
214
+ WorkflowManager resolves the active story internally from disk state.
215
+ If no active story: logs warning, skips call, does not raise.
216
+
217
+ Args:
218
+ directory: Project root path (used for log context only).
219
+ """
220
+ try:
221
+ from ref_agents.tools.workflow_tools import mark_artifact_complete
222
+
223
+ result = mark_artifact_complete(artifact="codemap_stale")
224
+ if "No active story" in result:
225
+ logger.warning(
226
+ "codemap_stale_no_active_story",
227
+ directory=directory,
228
+ detail="codemap_stale artifact not logged — no active workflow story",
229
+ )
230
+ # NOTE: Intentional broad catch — fire-and-forget, must not interrupt caller.
231
+ except (ImportError, AttributeError, RuntimeError, OSError) as e:
232
+ logger.warning(
233
+ "codemap_stale_artifact_log_failed", directory=directory, error=str(e)
234
+ )
@@ -0,0 +1,346 @@
1
+ """Comment Smell Scanner — C5 (commented-out code) detector.
2
+
3
+ Detects commented-out code in Python and JS/TS files using heuristic regex
4
+ patterns, with optional eradicate integration for Python files.
5
+
6
+ Runs against ANY target directory; no assumptions about target tooling.
7
+
8
+ Usage:
9
+ from ref_agents.tools.comment_smell_scanner import scan_comment_smells
10
+ findings = scan_comment_smells(Path("/path/to/repo"))
11
+ """
12
+
13
+ import re
14
+ import subprocess
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import structlog
19
+
20
+ from ref_agents.constants import COMMENT_SMELL_EXTENSIONS
21
+
22
+ logger = structlog.get_logger(__name__)
23
+
24
+ # Directories that are never scanned (build artifacts, deps, VCS, caches).
25
+ _SKIP_DIRS: frozenset[str] = frozenset(
26
+ {
27
+ ".venv",
28
+ "venv",
29
+ "node_modules",
30
+ ".git",
31
+ "dist",
32
+ "build",
33
+ "__pycache__",
34
+ }
35
+ )
36
+
37
+ # Python: commented-out function calls — `# old_func(arg)`
38
+ _PY_CALL_RE: re.Pattern[str] = re.compile(r"^\s*#\s+[a-zA-Z_]\w*\s*\(")
39
+
40
+ # Python: commented-out control-flow / structural keywords
41
+ _PY_KEYWORD_RE: re.Pattern[str] = re.compile(
42
+ r"^\s*#\s+(return|if|for|while|class|def|import|from)\s"
43
+ )
44
+
45
+ # JS/TS: commented-out expressions
46
+ _JS_PATTERNS: list[re.Pattern[str]] = [
47
+ re.compile(r"//\s+[a-zA-Z_]\w*\("), # function call
48
+ re.compile(r"//\s+return\s"), # return statement
49
+ re.compile(r"//\s+const\s"), # const declaration
50
+ re.compile(r"//\s+let\s"), # let declaration
51
+ re.compile(r"//\s+function\s"), # function declaration
52
+ ]
53
+
54
+ # Suppression marker — any occurrence on the line disables C5 for that line.
55
+ _SUPPRESS_MARKER: str = "ref:allow C5"
56
+
57
+
58
+ @dataclass
59
+ class CommentSmellFinding:
60
+ """Single C5 (commented-out code) finding.
61
+
62
+ Attributes:
63
+ file: Relative path to the file (relative to scanned directory).
64
+ line_number: 1-based line number of the finding.
65
+ line_content: Stripped line content.
66
+ language: Source language identifier ("python" or "js_ts").
67
+ severity: Always "HIGH" — commented-out code is an unconditional debt signal.
68
+ """
69
+
70
+ file: str
71
+ line_number: int
72
+ line_content: str
73
+ language: str
74
+ severity: str = field(default="HIGH")
75
+
76
+
77
+ def _is_skipped_path(path: Path) -> bool:
78
+ """Return True if any path component is in the skip-dirs set.
79
+
80
+ Args:
81
+ path: Absolute or relative path to check.
82
+
83
+ Returns:
84
+ True when the path should be excluded from scanning.
85
+ """
86
+ return any(part in _SKIP_DIRS for part in path.parts)
87
+
88
+
89
+ def _is_test_file(path: Path) -> bool:
90
+ """Return True when the file is a test file.
91
+
92
+ Criteria:
93
+ - Any component of the path is exactly ``tests``
94
+ - Filename starts with ``test_``
95
+
96
+ Args:
97
+ path: File path to evaluate.
98
+
99
+ Returns:
100
+ True for test files; False otherwise.
101
+ """
102
+ if path.name.startswith("test_"):
103
+ return True
104
+ return "tests" in path.parts
105
+
106
+
107
+ def _is_generated_file(path: Path) -> bool:
108
+ """Heuristic: skip files that look machine-generated.
109
+
110
+ Currently matches ``*.min.js`` and ``*.min.css`` suffixes.
111
+
112
+ Args:
113
+ path: File path.
114
+
115
+ Returns:
116
+ True for generated files.
117
+ """
118
+ return path.name.endswith((".min.js", ".min.css"))
119
+
120
+
121
+ def _eradicate_available() -> bool:
122
+ """Return True if ``eradicate`` binary is on PATH.
123
+
124
+ Returns:
125
+ True when ``eradicate --version`` exits without error.
126
+ """
127
+ try:
128
+ subprocess.run( # noqa: S603 # NOTE: Controlled input — no shell expansion.
129
+ ["eradicate", "--version"],
130
+ capture_output=True,
131
+ timeout=5,
132
+ )
133
+ return True
134
+ except (FileNotFoundError, subprocess.TimeoutExpired):
135
+ return False
136
+
137
+
138
+ def _scan_python_with_eradicate(
139
+ file_path: Path, relative_path: Path
140
+ ) -> list[CommentSmellFinding]:
141
+ """Run eradicate --check and parse flagged line numbers.
142
+
143
+ Args:
144
+ file_path: Absolute path to the Python file.
145
+ relative_path: Relative path used in finding output.
146
+
147
+ Returns:
148
+ List of findings from eradicate. Empty on error or clean file.
149
+ """
150
+ findings: list[CommentSmellFinding] = []
151
+ try:
152
+ result = subprocess.run( # noqa: S603 # NOTE: Controlled input — no shell expansion.
153
+ ["eradicate", "--check", str(file_path)],
154
+ capture_output=True,
155
+ text=True,
156
+ timeout=30,
157
+ )
158
+ # eradicate prints lines like: filename.py:42: ...
159
+ for output_line in result.stdout.splitlines() + result.stderr.splitlines():
160
+ parts = output_line.split(":")
161
+ if len(parts) < 2: # noqa: PLR2004
162
+ continue
163
+ try:
164
+ lineno = int(parts[1].strip())
165
+ except ValueError:
166
+ continue
167
+
168
+ # Re-read the source line for context
169
+ try:
170
+ src_lines = file_path.read_text(encoding="utf-8").splitlines()
171
+ if 1 <= lineno <= len(src_lines):
172
+ raw_line = src_lines[lineno - 1]
173
+ if _SUPPRESS_MARKER in raw_line:
174
+ continue
175
+ findings.append(
176
+ CommentSmellFinding(
177
+ file=str(relative_path),
178
+ line_number=lineno,
179
+ line_content=raw_line.strip(),
180
+ language="python",
181
+ )
182
+ )
183
+ except (OSError, UnicodeDecodeError):
184
+ pass
185
+ except (subprocess.TimeoutExpired, OSError) as exc:
186
+ logger.debug("eradicate_run_failed", file=str(file_path), error=str(exc))
187
+ return findings
188
+
189
+
190
+ def _scan_python_heuristic(
191
+ lines: list[str], relative_path: Path
192
+ ) -> list[CommentSmellFinding]:
193
+ """Regex heuristic fallback for Python C5 detection.
194
+
195
+ Args:
196
+ lines: File content split into lines (0-indexed).
197
+ relative_path: Relative path used in finding output.
198
+
199
+ Returns:
200
+ List of C5 findings.
201
+ """
202
+ findings: list[CommentSmellFinding] = []
203
+ for idx, raw in enumerate(lines, start=1):
204
+ if _SUPPRESS_MARKER in raw:
205
+ continue
206
+ if _PY_CALL_RE.search(raw) or _PY_KEYWORD_RE.search(raw):
207
+ findings.append(
208
+ CommentSmellFinding(
209
+ file=str(relative_path),
210
+ line_number=idx,
211
+ line_content=raw.strip(),
212
+ language="python",
213
+ )
214
+ )
215
+ return findings
216
+
217
+
218
+ def _scan_js_ts_heuristic(
219
+ lines: list[str], relative_path: Path
220
+ ) -> list[CommentSmellFinding]:
221
+ """Regex heuristic for JS/TS C5 detection.
222
+
223
+ Args:
224
+ lines: File content split into lines (0-indexed).
225
+ relative_path: Relative path used in finding output.
226
+
227
+ Returns:
228
+ List of C5 findings.
229
+ """
230
+ findings: list[CommentSmellFinding] = []
231
+ for idx, raw in enumerate(lines, start=1):
232
+ if _SUPPRESS_MARKER in raw:
233
+ continue
234
+ if any(pat.search(raw) for pat in _JS_PATTERNS):
235
+ findings.append(
236
+ CommentSmellFinding(
237
+ file=str(relative_path),
238
+ line_number=idx,
239
+ line_content=raw.strip(),
240
+ language="js_ts",
241
+ )
242
+ )
243
+ return findings
244
+
245
+
246
+ def _scan_file(
247
+ file_path: Path,
248
+ directory: Path,
249
+ use_eradicate: bool,
250
+ ) -> list[CommentSmellFinding]:
251
+ """Scan a single file for C5 violations.
252
+
253
+ Args:
254
+ file_path: Absolute path to the file.
255
+ directory: Root directory of the scan (for relative paths).
256
+ use_eradicate: Whether eradicate is available.
257
+
258
+ Returns:
259
+ List of findings; empty when file is clean or unreadable.
260
+ """
261
+ try:
262
+ content = file_path.read_text(encoding="utf-8")
263
+ except (OSError, UnicodeDecodeError) as exc:
264
+ logger.debug("comment_smell_read_error", file=str(file_path), error=str(exc))
265
+ return []
266
+
267
+ try:
268
+ relative_path = file_path.relative_to(directory)
269
+ except ValueError:
270
+ relative_path = file_path
271
+
272
+ suffix = file_path.suffix.lower()
273
+ lines = content.splitlines()
274
+
275
+ if suffix == ".py":
276
+ if use_eradicate:
277
+ eradicate_results = _scan_python_with_eradicate(file_path, relative_path)
278
+ if eradicate_results:
279
+ return eradicate_results
280
+ return _scan_python_heuristic(lines, relative_path)
281
+
282
+ # JS / TS family
283
+ return _scan_js_ts_heuristic(lines, relative_path)
284
+
285
+
286
+ def scan_comment_smells(directory: Path) -> list[CommentSmellFinding]:
287
+ """Scan a directory tree for C5 (commented-out code) violations.
288
+
289
+ Self-contained: no target tooling assumed beyond optional ``eradicate``
290
+ for Python. Falls back to regex heuristics when eradicate is absent.
291
+
292
+ Skips:
293
+ - Directories: .venv, venv, node_modules, .git, dist, build, __pycache__
294
+ - Test files: path contains ``/tests/`` or filename starts with ``test_``
295
+ - Generated files: ``*.min.js``, ``*.min.css``
296
+ - Extensions not in ``COMMENT_SMELL_EXTENSIONS``
297
+
298
+ Args:
299
+ directory: Root directory to scan recursively.
300
+
301
+ Returns:
302
+ Sorted list of CommentSmellFinding (by file path, then line number).
303
+
304
+ Raises:
305
+ NotADirectoryError: If ``directory`` does not exist or is not a directory.
306
+ """
307
+ if not directory.is_dir():
308
+ raise NotADirectoryError(f"Not a directory: {directory}")
309
+
310
+ use_eradicate = _eradicate_available()
311
+ if use_eradicate:
312
+ logger.info("comment_smell_eradicate_active")
313
+ else:
314
+ logger.info("comment_smell_heuristic_mode")
315
+
316
+ all_findings: list[CommentSmellFinding] = []
317
+
318
+ for file_path in directory.rglob("*"):
319
+ if not file_path.is_file():
320
+ continue
321
+ if file_path.suffix.lower() not in COMMENT_SMELL_EXTENSIONS:
322
+ continue
323
+ if _is_skipped_path(file_path.relative_to(directory)):
324
+ continue
325
+ if _is_test_file(file_path):
326
+ continue
327
+ if _is_generated_file(file_path):
328
+ continue
329
+
330
+ findings = _scan_file(file_path, directory, use_eradicate)
331
+ all_findings.extend(findings)
332
+ if findings:
333
+ logger.debug(
334
+ "comment_smell_findings",
335
+ file=str(file_path),
336
+ count=len(findings),
337
+ )
338
+
339
+ all_findings.sort(key=lambda f: (f.file, f.line_number))
340
+
341
+ logger.info(
342
+ "comment_smell_scan_complete",
343
+ directory=str(directory),
344
+ total_findings=len(all_findings),
345
+ )
346
+ return all_findings