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,1211 @@
|
|
|
1
|
+
"""Health Scanner - Codebase health orchestrator.
|
|
2
|
+
|
|
3
|
+
Orchestrates all available scanners and produces unified health report.
|
|
4
|
+
Routes findings to appropriate agents for action.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from ref_agents.tools.health_scanner import scan_health
|
|
8
|
+
result = scan_health("/path/to/project")
|
|
9
|
+
|
|
10
|
+
Dependencies:
|
|
11
|
+
- All scanner modules in ref_agents.tools
|
|
12
|
+
- structlog for logging
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
from ref_agents.tools.report_utils import write_report
|
|
24
|
+
|
|
25
|
+
logger = structlog.get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _generate_tech_debt_md(findings: list, directory: Path) -> Path | None:
|
|
29
|
+
"""Generate docs/TECH_DEBT.md from findings.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
findings: List of HealthFinding objects.
|
|
33
|
+
directory: Project root directory.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path to generated file or None if failed.
|
|
37
|
+
"""
|
|
38
|
+
debt_findings = [f for f in findings if f.scanner == "debt"]
|
|
39
|
+
if not debt_findings:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
docs_dir = directory / "docs"
|
|
43
|
+
docs_dir.mkdir(exist_ok=True)
|
|
44
|
+
|
|
45
|
+
content = """# Technical Debt Registry
|
|
46
|
+
|
|
47
|
+
## Overview
|
|
48
|
+
|
|
49
|
+
This document tracks technical debt identified during codebase analysis.
|
|
50
|
+
|
|
51
|
+
| Severity | Count |
|
|
52
|
+
|----------|-------|
|
|
53
|
+
"""
|
|
54
|
+
severity_counts = {}
|
|
55
|
+
for f in debt_findings:
|
|
56
|
+
sev = f.severity.value
|
|
57
|
+
severity_counts[sev] = severity_counts.get(sev, 0) + 1
|
|
58
|
+
|
|
59
|
+
for sev in ["critical", "high", "medium", "low"]:
|
|
60
|
+
count = severity_counts.get(sev, 0)
|
|
61
|
+
if count > 0:
|
|
62
|
+
content += f"| {sev.capitalize()} | {count} |\n"
|
|
63
|
+
|
|
64
|
+
content += "\n## Debt Items\n\n"
|
|
65
|
+
content += "| ID | File | Line | Type | Severity | Description |\n"
|
|
66
|
+
content += "|----|------|------|------|----------|-------------|\n"
|
|
67
|
+
|
|
68
|
+
for i, f in enumerate(debt_findings[:100], 1): # Limit to 100
|
|
69
|
+
file_name = Path(f.file).name
|
|
70
|
+
content += f"| D-{i:03d} | `{file_name}` | {f.line} | {f.rule} | {f.severity.value} | {f.message[:50]}... |\n"
|
|
71
|
+
|
|
72
|
+
if len(debt_findings) > 100: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
73
|
+
content += f"\n*...and {len(debt_findings) - 100} more items*\n"
|
|
74
|
+
|
|
75
|
+
content += "\n---\n*Auto-generated by health scanner. See handoff_log.jsonl for full details.*\n"
|
|
76
|
+
|
|
77
|
+
debt_path = docs_dir / "TECH_DEBT.md"
|
|
78
|
+
try:
|
|
79
|
+
debt_path.write_text(content, encoding="utf-8")
|
|
80
|
+
return debt_path
|
|
81
|
+
except OSError:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _relocate_artifacts(directory: Path) -> list[str]:
|
|
86
|
+
"""Relocate misplaced artifacts to docs/.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
directory: Project root directory.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of relocation messages.
|
|
93
|
+
"""
|
|
94
|
+
relocations: list[str] = []
|
|
95
|
+
docs_dir = directory / "docs"
|
|
96
|
+
|
|
97
|
+
# Files to relocate: root -> docs/
|
|
98
|
+
to_relocate = {
|
|
99
|
+
"ARCHITECTURE.md": "ARCHITECTURE.md",
|
|
100
|
+
"EXTERNAL_DEPS.md": "EXTERNAL_DEPS.md",
|
|
101
|
+
"EXTERNAL_APIS.md": "EXTERNAL_DEPS.md", # Rename
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for src_name, dst_name in to_relocate.items():
|
|
105
|
+
src_path = directory / src_name
|
|
106
|
+
dst_path = docs_dir / dst_name
|
|
107
|
+
|
|
108
|
+
if src_path.exists() and not dst_path.exists():
|
|
109
|
+
docs_dir.mkdir(exist_ok=True)
|
|
110
|
+
try:
|
|
111
|
+
# Copy content (don't move to preserve git history option)
|
|
112
|
+
content = src_path.read_text(encoding="utf-8")
|
|
113
|
+
dst_path.write_text(content, encoding="utf-8")
|
|
114
|
+
relocations.append(f"Copied `{src_name}` → `docs/{dst_name}`")
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
return relocations
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Common directories to skip in all scanners
|
|
122
|
+
SKIP_DIRECTORIES = frozenset(
|
|
123
|
+
{
|
|
124
|
+
".git",
|
|
125
|
+
"__pycache__",
|
|
126
|
+
".venv",
|
|
127
|
+
"venv",
|
|
128
|
+
"node_modules",
|
|
129
|
+
"dist",
|
|
130
|
+
"build",
|
|
131
|
+
"target",
|
|
132
|
+
".tox",
|
|
133
|
+
".mypy_cache",
|
|
134
|
+
".pytest_cache",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _should_skip_path(path: Path | str) -> bool:
|
|
140
|
+
"""Check if path should be skipped during scanning.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path: File or directory path.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True if path contains any skip directories.
|
|
147
|
+
"""
|
|
148
|
+
path_str = str(path)
|
|
149
|
+
return any(skip in path_str for skip in SKIP_DIRECTORIES)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _determine_complexity_severity(value: int, threshold: int) -> "Severity":
|
|
153
|
+
"""Determine severity based on value vs threshold.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
value: Current value (complexity, LOC, etc.).
|
|
157
|
+
threshold: Threshold from config.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Severity level.
|
|
161
|
+
"""
|
|
162
|
+
if value > threshold * 2.5:
|
|
163
|
+
return Severity.CRITICAL
|
|
164
|
+
if value > threshold * 1.5:
|
|
165
|
+
return Severity.HIGH
|
|
166
|
+
return Severity.LOW
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _deduplicate_findings(findings: list["HealthFinding"]) -> list["HealthFinding"]:
|
|
170
|
+
"""Deduplicate findings based on file, line, and message.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
findings: List of findings to deduplicate.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of unique findings.
|
|
177
|
+
"""
|
|
178
|
+
seen: set[tuple[str, int, str]] = set()
|
|
179
|
+
unique_findings: list["HealthFinding"] = []
|
|
180
|
+
|
|
181
|
+
for finding in findings:
|
|
182
|
+
key = (finding.file, finding.line, finding.message[:50])
|
|
183
|
+
if key not in seen:
|
|
184
|
+
seen.add(key)
|
|
185
|
+
unique_findings.append(finding)
|
|
186
|
+
|
|
187
|
+
return unique_findings
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ProjectType(str, Enum):
|
|
191
|
+
"""Detected project type."""
|
|
192
|
+
|
|
193
|
+
PYTHON = "python"
|
|
194
|
+
JAVASCRIPT = "javascript"
|
|
195
|
+
TYPESCRIPT = "typescript"
|
|
196
|
+
JAVA = "java"
|
|
197
|
+
CSHARP = "csharp"
|
|
198
|
+
UNKNOWN = "unknown"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Severity(str, Enum):
|
|
202
|
+
"""Issue severity levels."""
|
|
203
|
+
|
|
204
|
+
CRITICAL = "critical"
|
|
205
|
+
HIGH = "high"
|
|
206
|
+
MEDIUM = "medium"
|
|
207
|
+
LOW = "low"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass
|
|
211
|
+
class HealthFinding:
|
|
212
|
+
"""Single health finding from any scanner."""
|
|
213
|
+
|
|
214
|
+
scanner: str
|
|
215
|
+
file: str
|
|
216
|
+
line: int
|
|
217
|
+
message: str
|
|
218
|
+
severity: Severity
|
|
219
|
+
owner: str
|
|
220
|
+
rule: str = ""
|
|
221
|
+
|
|
222
|
+
def to_dict(self) -> dict:
|
|
223
|
+
"""Convert to dictionary."""
|
|
224
|
+
return {
|
|
225
|
+
"scanner": self.scanner,
|
|
226
|
+
"file": self.file,
|
|
227
|
+
"line": self.line,
|
|
228
|
+
"message": self.message,
|
|
229
|
+
"severity": self.severity.value,
|
|
230
|
+
"owner": self.owner,
|
|
231
|
+
"rule": self.rule,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class HealthReport:
|
|
237
|
+
"""Aggregated health report."""
|
|
238
|
+
|
|
239
|
+
directory: str
|
|
240
|
+
project_type: ProjectType
|
|
241
|
+
timestamp: str = field(
|
|
242
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
243
|
+
)
|
|
244
|
+
files_scanned: int = 0
|
|
245
|
+
findings: list[HealthFinding] = field(default_factory=list)
|
|
246
|
+
scanner_errors: dict[str, str] = field(default_factory=dict)
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def critical_count(self) -> int:
|
|
250
|
+
"""Count critical findings."""
|
|
251
|
+
return sum(1 for f in self.findings if f.severity == Severity.CRITICAL)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def high_count(self) -> int:
|
|
255
|
+
"""Count high severity findings."""
|
|
256
|
+
return sum(1 for f in self.findings if f.severity == Severity.HIGH)
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def medium_count(self) -> int:
|
|
260
|
+
"""Count medium severity findings."""
|
|
261
|
+
return sum(1 for f in self.findings if f.severity == Severity.MEDIUM)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def low_count(self) -> int:
|
|
265
|
+
"""Count low severity findings."""
|
|
266
|
+
return sum(1 for f in self.findings if f.severity == Severity.LOW)
|
|
267
|
+
|
|
268
|
+
def by_owner(self) -> dict[str, list[HealthFinding]]:
|
|
269
|
+
"""Group findings by owner agent."""
|
|
270
|
+
grouped: dict[str, list[HealthFinding]] = {}
|
|
271
|
+
for finding in self.findings:
|
|
272
|
+
if finding.owner not in grouped:
|
|
273
|
+
grouped[finding.owner] = []
|
|
274
|
+
grouped[finding.owner].append(finding)
|
|
275
|
+
return grouped
|
|
276
|
+
|
|
277
|
+
def by_scanner(self) -> dict[str, list[HealthFinding]]:
|
|
278
|
+
"""Group findings by scanner."""
|
|
279
|
+
grouped: dict[str, list[HealthFinding]] = {}
|
|
280
|
+
for finding in self.findings:
|
|
281
|
+
if finding.scanner not in grouped:
|
|
282
|
+
grouped[finding.scanner] = []
|
|
283
|
+
grouped[finding.scanner].append(finding)
|
|
284
|
+
return grouped
|
|
285
|
+
|
|
286
|
+
def to_json(self) -> str:
|
|
287
|
+
"""Convert to JSON string."""
|
|
288
|
+
return json.dumps(
|
|
289
|
+
{
|
|
290
|
+
"directory": self.directory,
|
|
291
|
+
"project_type": self.project_type.value,
|
|
292
|
+
"timestamp": self.timestamp,
|
|
293
|
+
"files_scanned": self.files_scanned,
|
|
294
|
+
"summary": {
|
|
295
|
+
"critical": self.critical_count,
|
|
296
|
+
"high": self.high_count,
|
|
297
|
+
"medium": self.medium_count,
|
|
298
|
+
"low": self.low_count,
|
|
299
|
+
"total": len(self.findings),
|
|
300
|
+
},
|
|
301
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
302
|
+
"errors": self.scanner_errors,
|
|
303
|
+
},
|
|
304
|
+
indent=2,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def detect_project_type(directory: Path) -> ProjectType:
|
|
309
|
+
"""Detect project type from directory contents.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
directory: Path to project directory.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Detected project type.
|
|
316
|
+
"""
|
|
317
|
+
if (directory / "pyproject.toml").exists() or (directory / "setup.py").exists():
|
|
318
|
+
return ProjectType.PYTHON
|
|
319
|
+
if (directory / "package.json").exists():
|
|
320
|
+
if (directory / "tsconfig.json").exists():
|
|
321
|
+
return ProjectType.TYPESCRIPT
|
|
322
|
+
return ProjectType.JAVASCRIPT
|
|
323
|
+
if (directory / "pom.xml").exists() or (directory / "build.gradle").exists():
|
|
324
|
+
return ProjectType.JAVA
|
|
325
|
+
if list(directory.rglob("*.csproj")) or list(directory.rglob("*.sln")):
|
|
326
|
+
return ProjectType.CSHARP
|
|
327
|
+
# Check for Python files
|
|
328
|
+
if list(directory.rglob("*.py")):
|
|
329
|
+
return ProjectType.PYTHON
|
|
330
|
+
# Check for C# files without project descriptor
|
|
331
|
+
if list(directory.rglob("*.cs")):
|
|
332
|
+
return ProjectType.CSHARP
|
|
333
|
+
return ProjectType.UNKNOWN
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def run_code_quality_scanner(directory: Path, report: HealthReport) -> None:
|
|
337
|
+
"""Run code quality scanner and add findings to report.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
directory: Directory to scan.
|
|
341
|
+
report: Health report to add findings to.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
from ref_agents.tools.code_quality_scanner import scan_directory
|
|
345
|
+
|
|
346
|
+
result = scan_directory(directory)
|
|
347
|
+
report.files_scanned = max(report.files_scanned, result.files_scanned)
|
|
348
|
+
|
|
349
|
+
for issue in result.issues:
|
|
350
|
+
report.findings.append(
|
|
351
|
+
HealthFinding(
|
|
352
|
+
scanner="code_quality",
|
|
353
|
+
file=issue.file,
|
|
354
|
+
line=issue.line,
|
|
355
|
+
message=issue.message,
|
|
356
|
+
severity=Severity(issue.severity.value),
|
|
357
|
+
owner=issue.owner.value,
|
|
358
|
+
rule=issue.rule,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.error("scanner_error", scanner="code_quality", error=str(e))
|
|
363
|
+
report.scanner_errors["code_quality"] = str(e)
|
|
364
|
+
raise
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def run_complexity_scanner(directory: Path, report: HealthReport) -> None:
|
|
368
|
+
"""Run complexity scanner and add findings to report.
|
|
369
|
+
|
|
370
|
+
Supports Python, TypeScript, JavaScript, Java.
|
|
371
|
+
Uses ConfigLoader to get language-specific thresholds.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
directory: Directory to scan.
|
|
375
|
+
report: Health report to add findings to.
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
from ref_agents.core.config_loader import ConfigLoader
|
|
379
|
+
from ref_agents.core.language_detector import LANGUAGE_EXTENSIONS
|
|
380
|
+
from ref_agents.tools.complexity import analyze_file
|
|
381
|
+
|
|
382
|
+
config_loader = ConfigLoader()
|
|
383
|
+
|
|
384
|
+
# Collect all supported language files
|
|
385
|
+
supported_files: list[Path] = []
|
|
386
|
+
for extensions in LANGUAGE_EXTENSIONS.values():
|
|
387
|
+
for ext in extensions:
|
|
388
|
+
supported_files.extend(directory.rglob(f"*{ext}"))
|
|
389
|
+
|
|
390
|
+
files_counted = 0
|
|
391
|
+
for file_path in supported_files:
|
|
392
|
+
if _should_skip_path(file_path):
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
result = analyze_file(file_path, config_loader)
|
|
396
|
+
if result.get("error"):
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
files_counted += 1
|
|
400
|
+
complexity = result.get("complexity", 0)
|
|
401
|
+
loc = result.get("loc", 0)
|
|
402
|
+
thresholds = result.get("thresholds", {})
|
|
403
|
+
complexity_threshold = thresholds.get("complexity", 20)
|
|
404
|
+
file_length_threshold = thresholds.get("file_length", 500)
|
|
405
|
+
|
|
406
|
+
# Check file-level complexity using config thresholds
|
|
407
|
+
severity = _determine_complexity_severity(complexity, complexity_threshold)
|
|
408
|
+
|
|
409
|
+
if severity in (Severity.HIGH, Severity.CRITICAL):
|
|
410
|
+
report.findings.append(
|
|
411
|
+
HealthFinding(
|
|
412
|
+
scanner="complexity",
|
|
413
|
+
file=str(file_path),
|
|
414
|
+
line=1,
|
|
415
|
+
message=f"File complexity {complexity} (threshold: {complexity_threshold}, LOC: {loc})",
|
|
416
|
+
severity=severity,
|
|
417
|
+
owner="architect",
|
|
418
|
+
rule="CC001",
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Check LOC using config threshold
|
|
423
|
+
if loc > file_length_threshold:
|
|
424
|
+
report.findings.append(
|
|
425
|
+
HealthFinding(
|
|
426
|
+
scanner="complexity",
|
|
427
|
+
file=str(file_path),
|
|
428
|
+
line=1,
|
|
429
|
+
message=f"File too large: {loc} lines (threshold: {file_length_threshold})",
|
|
430
|
+
severity=Severity.HIGH,
|
|
431
|
+
owner="architect",
|
|
432
|
+
rule="LOC001",
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
report.files_scanned = max(report.files_scanned, files_counted)
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logger.error("scanner_error", scanner="complexity", error=str(e))
|
|
438
|
+
report.scanner_errors["complexity"] = str(e)
|
|
439
|
+
raise
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def run_dead_code_scanner(directory: Path, report: HealthReport) -> None:
|
|
443
|
+
"""Run dead code scanner and add findings to report.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
directory: Directory to scan.
|
|
447
|
+
report: Health report to add findings to.
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
from ref_agents.tools.dead_code_scanner import DeadCodeScanner
|
|
451
|
+
|
|
452
|
+
scanner = DeadCodeScanner(project_root=directory)
|
|
453
|
+
result = scanner.scan_directory(directory)
|
|
454
|
+
|
|
455
|
+
report.files_scanned = max(report.files_scanned, result.files_scanned)
|
|
456
|
+
|
|
457
|
+
for item in result.unused_imports:
|
|
458
|
+
report.findings.append(
|
|
459
|
+
HealthFinding(
|
|
460
|
+
scanner="dead_code",
|
|
461
|
+
file=item.location,
|
|
462
|
+
line=item.line_number or 0,
|
|
463
|
+
message=item.description,
|
|
464
|
+
severity=Severity.MEDIUM,
|
|
465
|
+
owner="developer",
|
|
466
|
+
rule="DC001",
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
for item in result.orphan_files:
|
|
471
|
+
report.findings.append(
|
|
472
|
+
HealthFinding(
|
|
473
|
+
scanner="dead_code",
|
|
474
|
+
file=item.location,
|
|
475
|
+
line=1,
|
|
476
|
+
message=item.description,
|
|
477
|
+
severity=Severity.MEDIUM,
|
|
478
|
+
owner="developer",
|
|
479
|
+
rule="DC002",
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.error("scanner_error", scanner="dead_code", error=str(e))
|
|
484
|
+
report.scanner_errors["dead_code"] = str(e)
|
|
485
|
+
raise
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def run_debt_scanner(directory: Path, report: HealthReport) -> None:
|
|
489
|
+
"""Run tech debt scanner and add findings to report.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
directory: Directory to scan.
|
|
493
|
+
report: Health report to add findings to.
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
from ref_agents.tools.debt_scanner import scan_directory
|
|
497
|
+
|
|
498
|
+
result = scan_directory(directory)
|
|
499
|
+
|
|
500
|
+
report.files_scanned = max(report.files_scanned, result.files_scanned)
|
|
501
|
+
|
|
502
|
+
for item in result.items:
|
|
503
|
+
# Map debt severity to health severity
|
|
504
|
+
severity_map = {
|
|
505
|
+
"critical": Severity.CRITICAL,
|
|
506
|
+
"high": Severity.HIGH,
|
|
507
|
+
"medium": Severity.MEDIUM,
|
|
508
|
+
"low": Severity.LOW,
|
|
509
|
+
}
|
|
510
|
+
severity = severity_map.get(item.severity.lower(), Severity.MEDIUM)
|
|
511
|
+
|
|
512
|
+
# Route based on debt type and severity
|
|
513
|
+
# Code quality issues -> developer
|
|
514
|
+
# Architecture/debt issues -> architect
|
|
515
|
+
# Security issues -> security_owner
|
|
516
|
+
owner = "developer"
|
|
517
|
+
|
|
518
|
+
# Code quality patterns (regex-based for TS/JS/Java)
|
|
519
|
+
code_quality_patterns = {
|
|
520
|
+
"any_type",
|
|
521
|
+
"ts_ignore",
|
|
522
|
+
"console_log",
|
|
523
|
+
"empty_catch",
|
|
524
|
+
"missing_return_type",
|
|
525
|
+
"unused_import",
|
|
526
|
+
"implicit_any",
|
|
527
|
+
"non_null_assertion",
|
|
528
|
+
"any_in_generic",
|
|
529
|
+
"missing_async_await",
|
|
530
|
+
"direct_mutation",
|
|
531
|
+
"var_usage",
|
|
532
|
+
"loose_equality",
|
|
533
|
+
"typeof_undefined",
|
|
534
|
+
"with_statement",
|
|
535
|
+
"eval_usage",
|
|
536
|
+
"innerhtml_assignment",
|
|
537
|
+
"document_write",
|
|
538
|
+
"sync_xhr",
|
|
539
|
+
"catch_exception",
|
|
540
|
+
"catch_throwable",
|
|
541
|
+
"system_out",
|
|
542
|
+
"printStackTrace",
|
|
543
|
+
"raw_types",
|
|
544
|
+
"public_fields",
|
|
545
|
+
"string_concat_loop",
|
|
546
|
+
"missing_javadoc",
|
|
547
|
+
"null_pointer_risk",
|
|
548
|
+
"missing_override",
|
|
549
|
+
"equals_without_hashcode",
|
|
550
|
+
"final_mutable_collections",
|
|
551
|
+
"string_concatenation",
|
|
552
|
+
"magic_string",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
# Security issues
|
|
556
|
+
security_patterns = {"eval_usage", "innerhtml_assignment"}
|
|
557
|
+
|
|
558
|
+
# Architecture/complexity issues
|
|
559
|
+
architecture_patterns = {
|
|
560
|
+
"deprecated",
|
|
561
|
+
"complexity",
|
|
562
|
+
"long_function",
|
|
563
|
+
"high_complexity",
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if item.debt_type in security_patterns:
|
|
567
|
+
owner = "security_owner"
|
|
568
|
+
elif item.debt_type in architecture_patterns:
|
|
569
|
+
owner = "architect"
|
|
570
|
+
elif item.debt_type in code_quality_patterns:
|
|
571
|
+
owner = "developer"
|
|
572
|
+
# Default to developer for unknown patterns
|
|
573
|
+
|
|
574
|
+
report.findings.append(
|
|
575
|
+
HealthFinding(
|
|
576
|
+
scanner="debt",
|
|
577
|
+
file=item.location,
|
|
578
|
+
line=item.line_number or 0,
|
|
579
|
+
message=item.description,
|
|
580
|
+
severity=severity,
|
|
581
|
+
owner=owner,
|
|
582
|
+
rule=f"DEBT-{item.debt_type.upper()[:3]}",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
except Exception as e:
|
|
586
|
+
logger.error("scanner_error", scanner="debt", error=str(e))
|
|
587
|
+
report.scanner_errors["debt"] = str(e)
|
|
588
|
+
raise
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def run_docs_scanner(directory: Path, report: HealthReport) -> None:
|
|
592
|
+
"""Run documentation scanner and add findings to report.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
directory: Directory to scan.
|
|
596
|
+
report: Health report to add findings to.
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
from ref_agents.tools.docs_scanner import scan_docs
|
|
600
|
+
|
|
601
|
+
result = scan_docs(directory)
|
|
602
|
+
|
|
603
|
+
report.files_scanned = max(report.files_scanned, result.files_scanned)
|
|
604
|
+
|
|
605
|
+
for issue in result.issues:
|
|
606
|
+
# Map doc severity
|
|
607
|
+
severity_map = {
|
|
608
|
+
"critical": Severity.CRITICAL,
|
|
609
|
+
"high": Severity.HIGH,
|
|
610
|
+
"medium": Severity.MEDIUM,
|
|
611
|
+
"low": Severity.LOW,
|
|
612
|
+
}
|
|
613
|
+
severity = severity_map.get(issue.severity.lower(), Severity.MEDIUM)
|
|
614
|
+
|
|
615
|
+
report.findings.append(
|
|
616
|
+
HealthFinding(
|
|
617
|
+
scanner="docs",
|
|
618
|
+
file=issue.file_path,
|
|
619
|
+
line=issue.line,
|
|
620
|
+
message=issue.message,
|
|
621
|
+
severity=severity,
|
|
622
|
+
owner="docs_curator",
|
|
623
|
+
rule=issue.issue_type,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.error("scanner_error", scanner="docs", error=str(e))
|
|
628
|
+
report.scanner_errors["docs"] = str(e)
|
|
629
|
+
raise
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def run_codemap_freshness_scanner(directory: Path, report: HealthReport) -> None:
|
|
633
|
+
"""Run codemap freshness check and add findings to report.
|
|
634
|
+
|
|
635
|
+
Stale codemap → MEDIUM finding. Missing codemap → HIGH finding.
|
|
636
|
+
Fresh codemap → no finding (informational only, not appended).
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
directory: Project root directory.
|
|
640
|
+
report: Health report to add findings to.
|
|
641
|
+
"""
|
|
642
|
+
try:
|
|
643
|
+
from ref_agents.tools.codemap_freshness import get_freshness_result
|
|
644
|
+
|
|
645
|
+
result = get_freshness_result(str(directory))
|
|
646
|
+
|
|
647
|
+
if result.status == "missing":
|
|
648
|
+
report.findings.append(
|
|
649
|
+
HealthFinding(
|
|
650
|
+
scanner="codemap_freshness",
|
|
651
|
+
file="codemap/",
|
|
652
|
+
line=0,
|
|
653
|
+
message=(
|
|
654
|
+
"Codemap directory or CODE_MAP.md not found. "
|
|
655
|
+
"Call generate_codemap('<project_root>')."
|
|
656
|
+
),
|
|
657
|
+
severity=Severity.HIGH,
|
|
658
|
+
owner="developer",
|
|
659
|
+
rule="CODEMAP_MISSING",
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
elif result.status == "stale":
|
|
663
|
+
stale_files = [f.path for f in result.files if f.is_stale]
|
|
664
|
+
report.findings.append(
|
|
665
|
+
HealthFinding(
|
|
666
|
+
scanner="codemap_freshness",
|
|
667
|
+
file="codemap/CODE_MAP.md",
|
|
668
|
+
line=0,
|
|
669
|
+
message=(
|
|
670
|
+
f"Codemap is stale (files > 4h old: {', '.join(stale_files)}). "
|
|
671
|
+
"Query results are advisory."
|
|
672
|
+
),
|
|
673
|
+
severity=Severity.MEDIUM,
|
|
674
|
+
owner="developer",
|
|
675
|
+
rule="CODEMAP_STALE",
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
# NOTE: No re-raise — scan_health_impl does not wrap sub-scanner calls.
|
|
679
|
+
# Re-raising here would crash the entire health report for a non-critical check.
|
|
680
|
+
except (ImportError, AttributeError, RuntimeError, OSError) as e:
|
|
681
|
+
logger.error("scanner_error", scanner="codemap_freshness", error=str(e))
|
|
682
|
+
report.scanner_errors["codemap_freshness"] = str(e)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def run_test_smell_scanner(directory: Path, report: HealthReport) -> None:
|
|
686
|
+
"""Run structural test-smell scanner (STORY-TQ-001 M1b).
|
|
687
|
+
|
|
688
|
+
Walks every recognised test file under ``directory`` through
|
|
689
|
+
``AIPatternDetector``. HIGH-severity ``test_smell.*`` findings are routed
|
|
690
|
+
into the CRITICAL bucket so the existing auto-remediation bootstrap fires
|
|
691
|
+
(FR-13).
|
|
692
|
+
|
|
693
|
+
Args:
|
|
694
|
+
directory: Project root directory.
|
|
695
|
+
report: Health report to mutate.
|
|
696
|
+
"""
|
|
697
|
+
try:
|
|
698
|
+
from ref_agents.tools.ai_pattern_detector import AIPatternDetector
|
|
699
|
+
from ref_agents.tools.test_smell_walker import is_test_path
|
|
700
|
+
|
|
701
|
+
# Inline default mirrors src/ref_agents/config/ai_patterns.yaml; full
|
|
702
|
+
# YAML loader lands in a follow-on commit.
|
|
703
|
+
smells_config = {
|
|
704
|
+
"enabled": True,
|
|
705
|
+
"test_path_globs": ("tests/**/*.py", "test_*.py", "*_test.py"),
|
|
706
|
+
"excluded_filenames": ("conftest.py", "__init__.py"),
|
|
707
|
+
"excluded_path_substrings": ("/fixtures/",),
|
|
708
|
+
"patterns": {
|
|
709
|
+
"no_assertions": {"enabled": True, "severity": "HIGH"},
|
|
710
|
+
"trivial_assertion": {"enabled": True, "severity": "HIGH"},
|
|
711
|
+
"mock_called_only": {"enabled": True, "severity": "HIGH"},
|
|
712
|
+
"uut_mocked": {"enabled": True, "severity": "HIGH"},
|
|
713
|
+
"swallowed_exception": {"enabled": True, "severity": "HIGH"},
|
|
714
|
+
# Demoted MEDIUM → LOW per 2026-05-25 baseline tune.
|
|
715
|
+
"not_none_only": {"enabled": True, "severity": "LOW"},
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
detector = AIPatternDetector(project_root=directory)
|
|
720
|
+
candidate_files: list[Path] = []
|
|
721
|
+
for ext in (".py", ".ts", ".tsx", ".js", ".jsx", ".go"):
|
|
722
|
+
candidate_files.extend(directory.rglob(f"*{ext}"))
|
|
723
|
+
|
|
724
|
+
for file_path in candidate_files:
|
|
725
|
+
if _should_skip_path(file_path):
|
|
726
|
+
continue
|
|
727
|
+
if not is_test_path(file_path, smells_config, directory):
|
|
728
|
+
continue
|
|
729
|
+
try:
|
|
730
|
+
findings = detector.detect_patterns(file_path)
|
|
731
|
+
except (OSError, ValueError, SyntaxError) as e:
|
|
732
|
+
logger.debug(
|
|
733
|
+
"test_smell_scan_file_error",
|
|
734
|
+
file=str(file_path),
|
|
735
|
+
error=str(e),
|
|
736
|
+
)
|
|
737
|
+
continue
|
|
738
|
+
for finding in findings:
|
|
739
|
+
if not finding.pattern_id.startswith("test_smell."):
|
|
740
|
+
continue
|
|
741
|
+
severity = _map_test_smell_severity(finding.severity)
|
|
742
|
+
if severity is None:
|
|
743
|
+
continue
|
|
744
|
+
report.findings.append(
|
|
745
|
+
HealthFinding(
|
|
746
|
+
scanner="test_smell",
|
|
747
|
+
file=str(file_path),
|
|
748
|
+
line=finding.line,
|
|
749
|
+
message=f"{finding.pattern_id}: {finding.message}",
|
|
750
|
+
severity=severity,
|
|
751
|
+
owner="developer",
|
|
752
|
+
rule=finding.pattern_id,
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
# NOTE: No re-raise — sub-scanner failure cannot crash the whole health report.
|
|
756
|
+
except (ImportError, AttributeError, RuntimeError, OSError) as e:
|
|
757
|
+
logger.error("scanner_error", scanner="test_smell", error=str(e))
|
|
758
|
+
report.scanner_errors["test_smell"] = str(e)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def run_comment_smell_scanner(directory: Path, report: HealthReport) -> None:
|
|
762
|
+
"""Run C5 (commented-out code) scanner and add findings to report.
|
|
763
|
+
|
|
764
|
+
Uses heuristic regex with optional eradicate integration for Python.
|
|
765
|
+
Findings are always HIGH severity — commented-out code is unconditional debt.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
directory: Project root directory.
|
|
769
|
+
report: Health report to mutate.
|
|
770
|
+
"""
|
|
771
|
+
try:
|
|
772
|
+
from ref_agents.tools.comment_smell_scanner import scan_comment_smells
|
|
773
|
+
|
|
774
|
+
findings = scan_comment_smells(directory)
|
|
775
|
+
|
|
776
|
+
for finding in findings:
|
|
777
|
+
report.findings.append(
|
|
778
|
+
HealthFinding(
|
|
779
|
+
scanner="comment_smell",
|
|
780
|
+
file=finding.file,
|
|
781
|
+
line=finding.line_number,
|
|
782
|
+
message=f"C5 commented-out code: {finding.line_content[:80]}",
|
|
783
|
+
severity=Severity.HIGH,
|
|
784
|
+
owner="developer",
|
|
785
|
+
rule="C5",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
# NOTE: No re-raise — sub-scanner failure cannot crash the whole health report.
|
|
789
|
+
except (ImportError, AttributeError, RuntimeError, OSError) as e:
|
|
790
|
+
logger.error("scanner_error", scanner="comment_smell", error=str(e))
|
|
791
|
+
report.scanner_errors["comment_smell"] = str(e)
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _map_test_smell_severity(raw: str) -> "Severity | None":
|
|
795
|
+
"""Map walker severity tokens onto ``HealthFinding`` severities.
|
|
796
|
+
|
|
797
|
+
HIGH walker → CRITICAL bucket (drives auto-remediation per FR-13).
|
|
798
|
+
MEDIUM → MEDIUM. LOW → LOW (informational). WARNING → LOW.
|
|
799
|
+
Anything else → drop.
|
|
800
|
+
"""
|
|
801
|
+
token = (raw or "").upper()
|
|
802
|
+
if token == "HIGH":
|
|
803
|
+
return Severity.CRITICAL
|
|
804
|
+
if token == "MEDIUM":
|
|
805
|
+
return Severity.MEDIUM
|
|
806
|
+
if token in ("LOW", "WARNING"):
|
|
807
|
+
return Severity.LOW
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def scan_health_impl(directory: str) -> HealthReport:
|
|
812
|
+
"""Orchestrate all scanners and build health report.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
directory: Path to directory to scan.
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
Aggregated health report.
|
|
819
|
+
"""
|
|
820
|
+
dir_path = Path(directory)
|
|
821
|
+
|
|
822
|
+
if not dir_path.exists():
|
|
823
|
+
report = HealthReport(directory=directory, project_type=ProjectType.UNKNOWN)
|
|
824
|
+
report.scanner_errors["init"] = f"Directory not found: {directory}"
|
|
825
|
+
return report
|
|
826
|
+
|
|
827
|
+
project_type = detect_project_type(dir_path)
|
|
828
|
+
report = HealthReport(directory=directory, project_type=project_type)
|
|
829
|
+
|
|
830
|
+
logger.info(
|
|
831
|
+
"health_scan_start", directory=directory, project_type=project_type.value
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Run multi-language scanners for all project types
|
|
835
|
+
run_debt_scanner(dir_path, report)
|
|
836
|
+
run_docs_scanner(dir_path, report)
|
|
837
|
+
run_complexity_scanner(dir_path, report)
|
|
838
|
+
run_dead_code_scanner(dir_path, report)
|
|
839
|
+
run_codemap_freshness_scanner(dir_path, report)
|
|
840
|
+
run_test_smell_scanner(dir_path, report)
|
|
841
|
+
run_comment_smell_scanner(dir_path, report)
|
|
842
|
+
|
|
843
|
+
# Run Python-specific scanners only for Python projects
|
|
844
|
+
if project_type == ProjectType.PYTHON:
|
|
845
|
+
run_code_quality_scanner(dir_path, report)
|
|
846
|
+
|
|
847
|
+
# Sort findings by severity
|
|
848
|
+
severity_order = {
|
|
849
|
+
Severity.CRITICAL: 0,
|
|
850
|
+
Severity.HIGH: 1,
|
|
851
|
+
Severity.MEDIUM: 2,
|
|
852
|
+
Severity.LOW: 3,
|
|
853
|
+
}
|
|
854
|
+
report.findings.sort(key=lambda x: (severity_order[x.severity], x.file, x.line))
|
|
855
|
+
|
|
856
|
+
# Deduplicate similar findings (same file:line from multiple scanners)
|
|
857
|
+
report.findings = _deduplicate_findings(report.findings)
|
|
858
|
+
|
|
859
|
+
logger.info(
|
|
860
|
+
"health_scan_complete",
|
|
861
|
+
files_scanned=report.files_scanned,
|
|
862
|
+
total_findings=len(report.findings),
|
|
863
|
+
critical=report.critical_count,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
return report
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def format_health_report(report: HealthReport) -> str:
|
|
870
|
+
"""Format health report as Markdown.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
report: Health report to format.
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
Markdown formatted report.
|
|
877
|
+
"""
|
|
878
|
+
lines = [
|
|
879
|
+
"# Codebase Health Report",
|
|
880
|
+
"",
|
|
881
|
+
f"**Generated:** {report.timestamp}",
|
|
882
|
+
f"**Directory:** `{report.directory}`",
|
|
883
|
+
f"**Project Type:** {report.project_type.value}",
|
|
884
|
+
f"**Files Scanned:** {report.files_scanned}",
|
|
885
|
+
"",
|
|
886
|
+
"## Summary",
|
|
887
|
+
"",
|
|
888
|
+
"| Severity | Count | Status |",
|
|
889
|
+
"|----------|-------|--------|",
|
|
890
|
+
f"| Critical | {report.critical_count} | {'🛑 Block' if report.critical_count > 0 else '✅'} |",
|
|
891
|
+
f"| High | {report.high_count} | {'⚠️ Fix soon' if report.high_count > 0 else '✅'} |",
|
|
892
|
+
f"| Medium | {report.medium_count} | {'📋 Backlog' if report.medium_count > 0 else '✅'} |",
|
|
893
|
+
f"| Low | {report.low_count} | {'ℹ️ Info' if report.low_count > 0 else '✅'} |",
|
|
894
|
+
"",
|
|
895
|
+
]
|
|
896
|
+
|
|
897
|
+
# Critical issues
|
|
898
|
+
critical = [f for f in report.findings if f.severity == Severity.CRITICAL]
|
|
899
|
+
if critical:
|
|
900
|
+
lines.extend(
|
|
901
|
+
[
|
|
902
|
+
"## 🛑 Critical Issues (Action Required)",
|
|
903
|
+
"",
|
|
904
|
+
"| Scanner | File | Line | Message | Owner |",
|
|
905
|
+
"|---------|------|------|---------|-------|",
|
|
906
|
+
]
|
|
907
|
+
)
|
|
908
|
+
for finding in critical:
|
|
909
|
+
lines.append(
|
|
910
|
+
f"| {finding.scanner} | `{Path(finding.file).name}` | {finding.line} | {finding.message} | {finding.owner} |"
|
|
911
|
+
)
|
|
912
|
+
lines.append("")
|
|
913
|
+
|
|
914
|
+
# High priority
|
|
915
|
+
high = [f for f in report.findings if f.severity == Severity.HIGH]
|
|
916
|
+
if high:
|
|
917
|
+
lines.extend(
|
|
918
|
+
[
|
|
919
|
+
"## ⚠️ High Priority",
|
|
920
|
+
"",
|
|
921
|
+
"| Scanner | File | Line | Message | Owner |",
|
|
922
|
+
"|---------|------|------|---------|-------|",
|
|
923
|
+
]
|
|
924
|
+
)
|
|
925
|
+
for finding in high[:15]:
|
|
926
|
+
lines.append(
|
|
927
|
+
f"| {finding.scanner} | `{Path(finding.file).name}` | {finding.line} | {finding.message} | {finding.owner} |"
|
|
928
|
+
)
|
|
929
|
+
if len(high) > 15: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
930
|
+
lines.append(f"| ... | ... | ... | *({len(high) - 15} more)* | ... |")
|
|
931
|
+
lines.append("")
|
|
932
|
+
|
|
933
|
+
# By owner
|
|
934
|
+
by_owner = report.by_owner()
|
|
935
|
+
if by_owner:
|
|
936
|
+
lines.append("## By Owner")
|
|
937
|
+
lines.append("")
|
|
938
|
+
for owner, findings in sorted(by_owner.items()):
|
|
939
|
+
crit = sum(1 for f in findings if f.severity == Severity.CRITICAL)
|
|
940
|
+
high_c = sum(1 for f in findings if f.severity == Severity.HIGH)
|
|
941
|
+
lines.append(
|
|
942
|
+
f"### {owner} ({len(findings)} issues: {crit} critical, {high_c} high)"
|
|
943
|
+
)
|
|
944
|
+
lines.append("")
|
|
945
|
+
for finding in findings[:8]:
|
|
946
|
+
marker = (
|
|
947
|
+
"🛑"
|
|
948
|
+
if finding.severity == Severity.CRITICAL
|
|
949
|
+
else "⚠️"
|
|
950
|
+
if finding.severity == Severity.HIGH
|
|
951
|
+
else "•"
|
|
952
|
+
)
|
|
953
|
+
lines.append(
|
|
954
|
+
f"- {marker} `{Path(finding.file).name}:{finding.line}` - {finding.message}"
|
|
955
|
+
)
|
|
956
|
+
if len(findings) > 8: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
957
|
+
lines.append(f"- *... and {len(findings) - 8} more*")
|
|
958
|
+
lines.append("")
|
|
959
|
+
|
|
960
|
+
# Recommended actions
|
|
961
|
+
lines.extend(
|
|
962
|
+
[
|
|
963
|
+
"## Recommended Actions",
|
|
964
|
+
"",
|
|
965
|
+
]
|
|
966
|
+
)
|
|
967
|
+
if report.critical_count > 0:
|
|
968
|
+
lines.append("1. 🛑 **BLOCK:** Fix critical issues before any deployment")
|
|
969
|
+
if "developer" in by_owner:
|
|
970
|
+
lines.append(
|
|
971
|
+
f"2. Activate `developer` → Fix {len(by_owner.get('developer', []))} code issues"
|
|
972
|
+
)
|
|
973
|
+
if "architect" in by_owner:
|
|
974
|
+
lines.append(
|
|
975
|
+
f"3. Activate `architect` → Review {len(by_owner.get('architect', []))} architectural issues"
|
|
976
|
+
)
|
|
977
|
+
if "docs_curator" in by_owner:
|
|
978
|
+
lines.append(
|
|
979
|
+
f"4. Activate `pr_reviewer` → Validate documentation ({len(by_owner.get('docs_curator', []))} issues)"
|
|
980
|
+
)
|
|
981
|
+
lines.append("")
|
|
982
|
+
|
|
983
|
+
# Scanner errors
|
|
984
|
+
if report.scanner_errors:
|
|
985
|
+
lines.extend(["## Scanner Errors", ""])
|
|
986
|
+
for scanner, error in report.scanner_errors.items():
|
|
987
|
+
lines.append(f"- **{scanner}:** {error}")
|
|
988
|
+
lines.append("")
|
|
989
|
+
|
|
990
|
+
return "\n".join(lines)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _log_dashboard_event(report: HealthReport) -> None:
|
|
994
|
+
"""Log scan summary to dashboard via handoff logger.
|
|
995
|
+
|
|
996
|
+
Records one phase-complete entry only. Individual findings are written
|
|
997
|
+
to health_report.md and health_report.json — no per-finding log writes.
|
|
998
|
+
Per-finding logging of 1000+ violations blocks the async event loop.
|
|
999
|
+
"""
|
|
1000
|
+
try:
|
|
1001
|
+
from ref_agents.utils import handoff_logger
|
|
1002
|
+
|
|
1003
|
+
handoff_logger.log_phase_complete(
|
|
1004
|
+
story_id="health-check",
|
|
1005
|
+
phase="health_scan",
|
|
1006
|
+
outcome=(
|
|
1007
|
+
f"Completed with {report.critical_count} critical, "
|
|
1008
|
+
f"{report.high_count} high, {report.medium_count} medium issues "
|
|
1009
|
+
f"across {report.files_scanned} files"
|
|
1010
|
+
),
|
|
1011
|
+
next_phase="remediation" if report.critical_count > 0 else "development",
|
|
1012
|
+
)
|
|
1013
|
+
except (OSError, ValueError) as e:
|
|
1014
|
+
logger.warning("failed_to_log_to_dashboard", error=str(e))
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _populate_context_graph(
|
|
1018
|
+
directory: str, report: "HealthReport | None" = None
|
|
1019
|
+
) -> str:
|
|
1020
|
+
"""Populate context graph from AST extraction and scan findings.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
directory: Project directory to scan.
|
|
1024
|
+
report: Optional HealthReport — findings become issue nodes in graph.
|
|
1025
|
+
|
|
1026
|
+
Returns:
|
|
1027
|
+
Status string for scan summary.
|
|
1028
|
+
"""
|
|
1029
|
+
try:
|
|
1030
|
+
from datetime import datetime, timezone
|
|
1031
|
+
|
|
1032
|
+
from ref_agents.tools.brownfield_populator import BrownfieldPopulator
|
|
1033
|
+
from ref_agents.tools.context_graph import ContextNode, get_context_graph
|
|
1034
|
+
|
|
1035
|
+
populator = BrownfieldPopulator(Path(directory))
|
|
1036
|
+
stats = populator.populate(levels=["L1", "L2"])
|
|
1037
|
+
|
|
1038
|
+
# Push scan findings as issue nodes with affects edges to file nodes
|
|
1039
|
+
issues_added = 0
|
|
1040
|
+
if report:
|
|
1041
|
+
graph = get_context_graph()
|
|
1042
|
+
ts_base = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
1043
|
+
for i, finding in enumerate(report.findings):
|
|
1044
|
+
node_id = f"issue-scan-{ts_base}-{i}"
|
|
1045
|
+
issue_node = ContextNode(
|
|
1046
|
+
id=node_id,
|
|
1047
|
+
node_type="issue",
|
|
1048
|
+
content=f"[{finding.severity.value}] {finding.scanner}: {finding.message[:120]}",
|
|
1049
|
+
origin="context",
|
|
1050
|
+
metadata={
|
|
1051
|
+
"scanner": finding.scanner,
|
|
1052
|
+
"severity": finding.severity.value,
|
|
1053
|
+
"rule": finding.rule,
|
|
1054
|
+
"line": finding.line,
|
|
1055
|
+
"file": finding.file,
|
|
1056
|
+
},
|
|
1057
|
+
)
|
|
1058
|
+
graph.add_node(issue_node)
|
|
1059
|
+
|
|
1060
|
+
# Link to file node if it exists in graph
|
|
1061
|
+
file_node_id = "file-" + finding.file.replace("/", "-").replace(
|
|
1062
|
+
".", "_"
|
|
1063
|
+
)
|
|
1064
|
+
if file_node_id in graph._graph:
|
|
1065
|
+
try:
|
|
1066
|
+
graph.add_edge(node_id, file_node_id, "affects", confidence=0.9)
|
|
1067
|
+
except ValueError:
|
|
1068
|
+
pass
|
|
1069
|
+
issues_added += 1
|
|
1070
|
+
|
|
1071
|
+
graph.save()
|
|
1072
|
+
|
|
1073
|
+
logger.info(
|
|
1074
|
+
"context_graph_populated",
|
|
1075
|
+
nodes=stats["total_nodes"],
|
|
1076
|
+
edges=stats["total_edges"],
|
|
1077
|
+
issues=issues_added,
|
|
1078
|
+
)
|
|
1079
|
+
return (
|
|
1080
|
+
f"\n**Context Graph:** {stats['total_nodes']} nodes, "
|
|
1081
|
+
f"{stats['total_edges']} edges, {issues_added} issue nodes"
|
|
1082
|
+
)
|
|
1083
|
+
except (OSError, ValueError, RuntimeError) as e:
|
|
1084
|
+
logger.warning("context_graph_population_failed", error=str(e))
|
|
1085
|
+
return f"\n**Context Graph:** ⚠️ Failed ({str(e)[:50]})"
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _generate_tech_debt(report: HealthReport, directory: str) -> str:
|
|
1089
|
+
"""Generate TECH_DEBT.md and return status string."""
|
|
1090
|
+
try:
|
|
1091
|
+
debt_path = _generate_tech_debt_md(report.findings, Path(directory))
|
|
1092
|
+
if debt_path:
|
|
1093
|
+
logger.info("tech_debt_md_generated", path=str(debt_path))
|
|
1094
|
+
return f"\n**Tech Debt:** `{debt_path}`"
|
|
1095
|
+
return ""
|
|
1096
|
+
except (OSError, ValueError) as e:
|
|
1097
|
+
logger.warning("tech_debt_generation_failed", error=str(e))
|
|
1098
|
+
raise
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _relocate_misplaced_artifacts(directory: str) -> str:
|
|
1102
|
+
"""Relocate artifacts and return status string."""
|
|
1103
|
+
try:
|
|
1104
|
+
relocations = _relocate_artifacts(Path(directory))
|
|
1105
|
+
if relocations:
|
|
1106
|
+
logger.info("artifacts_relocated", count=len(relocations))
|
|
1107
|
+
return "\n**Relocated:** " + ", ".join(relocations)
|
|
1108
|
+
return ""
|
|
1109
|
+
except (OSError, ValueError) as e:
|
|
1110
|
+
logger.warning("artifact_relocation_failed", error=str(e))
|
|
1111
|
+
raise
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _build_status_message( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
|
|
1115
|
+
report: HealthReport,
|
|
1116
|
+
context_graph_status: str,
|
|
1117
|
+
tech_debt_status: str,
|
|
1118
|
+
relocation_status: str,
|
|
1119
|
+
md_path: Path,
|
|
1120
|
+
json_path: Path,
|
|
1121
|
+
) -> str:
|
|
1122
|
+
"""Build final status message."""
|
|
1123
|
+
status = (
|
|
1124
|
+
"🛑 CRITICAL"
|
|
1125
|
+
if report.critical_count > 0
|
|
1126
|
+
else "⚠️ ISSUES"
|
|
1127
|
+
if report.high_count > 0
|
|
1128
|
+
else "✅ HEALTHY"
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
f"{status} Health scan complete.\n\n"
|
|
1133
|
+
f"**Project:** {report.project_type.value} | "
|
|
1134
|
+
f"**Files:** {report.files_scanned}\n"
|
|
1135
|
+
f"**Critical:** {report.critical_count} | "
|
|
1136
|
+
f"**High:** {report.high_count} | "
|
|
1137
|
+
f"**Medium:** {report.medium_count} | "
|
|
1138
|
+
f"**Low:** {report.low_count}"
|
|
1139
|
+
f"{context_graph_status}"
|
|
1140
|
+
f"{tech_debt_status}"
|
|
1141
|
+
f"{relocation_status}\n\n"
|
|
1142
|
+
f"Reports:\n"
|
|
1143
|
+
f"- Markdown: `{md_path}`\n"
|
|
1144
|
+
f"- JSON: `{json_path}`"
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def scan_health(directory: str) -> str:
|
|
1149
|
+
"""MCP tool: Scan codebase health across all dimensions.
|
|
1150
|
+
|
|
1151
|
+
Orchestrates multiple scanners:
|
|
1152
|
+
- Code Quality (pe_rules)
|
|
1153
|
+
- Complexity
|
|
1154
|
+
- Dead Code
|
|
1155
|
+
- Tech Debt
|
|
1156
|
+
- Documentation
|
|
1157
|
+
|
|
1158
|
+
Routes findings to appropriate agents for action.
|
|
1159
|
+
|
|
1160
|
+
Args:
|
|
1161
|
+
directory: Path to directory to scan.
|
|
1162
|
+
|
|
1163
|
+
Returns:
|
|
1164
|
+
Status message with summary and path to reports.
|
|
1165
|
+
"""
|
|
1166
|
+
# Auto-set project_root so reports write into the scanned project, not cwd.
|
|
1167
|
+
try:
|
|
1168
|
+
from ref_agents.session import SessionManager
|
|
1169
|
+
|
|
1170
|
+
_p = Path(directory).resolve()
|
|
1171
|
+
if _p.exists() and not SessionManager.get().project_root:
|
|
1172
|
+
SessionManager.get().project_root = _p
|
|
1173
|
+
except (ImportError, AttributeError):
|
|
1174
|
+
pass
|
|
1175
|
+
|
|
1176
|
+
report = scan_health_impl(directory)
|
|
1177
|
+
md_content = format_health_report(report)
|
|
1178
|
+
json_content = report.to_json()
|
|
1179
|
+
|
|
1180
|
+
# Log findings to dashboard
|
|
1181
|
+
_log_dashboard_event(report)
|
|
1182
|
+
|
|
1183
|
+
# Write reports
|
|
1184
|
+
try:
|
|
1185
|
+
md_path = write_report(
|
|
1186
|
+
agent="platform_engineer",
|
|
1187
|
+
report_name="health_report",
|
|
1188
|
+
content=md_content,
|
|
1189
|
+
title="Codebase Health Report",
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
# Write JSON for CI
|
|
1193
|
+
json_path = Path(md_path).with_suffix(".json")
|
|
1194
|
+
json_path.write_text(json_content, encoding="utf-8")
|
|
1195
|
+
|
|
1196
|
+
# Post-scan actions
|
|
1197
|
+
context_graph_status = _populate_context_graph(directory, report)
|
|
1198
|
+
tech_debt_status = _generate_tech_debt(report, directory)
|
|
1199
|
+
relocation_status = _relocate_misplaced_artifacts(directory)
|
|
1200
|
+
|
|
1201
|
+
return _build_status_message(
|
|
1202
|
+
report,
|
|
1203
|
+
context_graph_status,
|
|
1204
|
+
tech_debt_status,
|
|
1205
|
+
relocation_status,
|
|
1206
|
+
md_path,
|
|
1207
|
+
json_path,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
except OSError as e:
|
|
1211
|
+
return f"Scan complete but failed to save report: {e}\n\n{md_content}"
|