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