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,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
|