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,946 @@
1
+ """Migration intent detection and MIGRATION_MAP.md generation.
2
+
3
+ Detects legacy codebase signals, scores each module by composite risk,
4
+ and produces MIGRATION_MAP.md + ref-reports/migration_map.json.
5
+
6
+ Reads from ref-reports/platform_engineer/health_report.json (written by
7
+ scan_health) — no additional filesystem scan passes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ import structlog
21
+
22
+ logger = structlog.get_logger(__name__)
23
+
24
+ # --- Thresholds -----------------------------------------------------------
25
+
26
+ _STRONG_SIGNAL_DEAD_CODE_PCT = 30.0 # SIG-S4: whole-codebase dead code %
27
+ _WEAK_SIGNAL_DEAD_CODE_PCT = 20.0 # SIG-W2: single-module dead code %
28
+ _WEAK_SIGNAL_COMPLEXITY_FUNCS = 3 # SIG-W3: min functions with HIGH/CRITICAL complexity
29
+ _WEAK_SIGNAL_MONOLITH_LOC = 1000 # SIG-W4: LOC threshold
30
+ _WEAK_SIGNAL_MARKER_FILES = 5 # SIG-W1: min files with legacy markers
31
+
32
+ _LEGACY_MARKERS = re.compile(
33
+ r"\b(DEPRECATED|TODO:\s*migrate|TODO:\s*rewrite|FIXME:\s*legacy)\b",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+ _SOAP_PATTERNS = re.compile(
38
+ r"\b(zeep|suds|SOAPpy|pysimplesoap|xmlrpclib|node-soap|soap-client)\b",
39
+ re.IGNORECASE,
40
+ )
41
+
42
+ _DB_CONTROLLER_DIRS = re.compile(
43
+ r"(view|controller|route|handler|endpoint)", re.IGNORECASE
44
+ )
45
+
46
+ _DB_IMPORTS = re.compile(
47
+ r"\b(psycopg2|cx_Oracle|pymysql|sqlite3|pyodbc|MySQLdb)\.connect\b",
48
+ re.IGNORECASE,
49
+ )
50
+
51
+ # Composite score weights (must sum to 1.0)
52
+ _W_DEBT = 0.35
53
+ _W_DEAD = 0.20
54
+ _W_COMPLEXITY = 0.25
55
+ _W_COVERAGE = 0.20
56
+
57
+ # Recommended action thresholds by composite score
58
+ _ACTION_THRESHOLDS: list[tuple[float, str]] = [
59
+ (3.0, "Keep"),
60
+ (5.5, "Refactor"),
61
+ (7.5, "Migrate"),
62
+ (9.0, "Rewrite"),
63
+ (10.1, "Delete"),
64
+ ]
65
+
66
+ _RISK_THRESHOLDS: list[tuple[float, str]] = [
67
+ (3.0, "LOW"),
68
+ (5.5, "MEDIUM"),
69
+ (7.5, "HIGH"),
70
+ (10.1, "CRITICAL"),
71
+ ]
72
+
73
+
74
+ # --- Data classes ---------------------------------------------------------
75
+
76
+
77
+ @dataclass
78
+ class DetectedSignal:
79
+ """A single migration intent signal found in the codebase."""
80
+
81
+ id: str
82
+ category: Literal["strong", "weak"]
83
+ description: str
84
+ files_affected: list[str] = field(default_factory=list)
85
+
86
+
87
+ @dataclass
88
+ class MigrationIntentResult:
89
+ """Result of migration intent evaluation."""
90
+
91
+ is_migration: bool
92
+ signals: list[DetectedSignal]
93
+ trigger_reason: str
94
+ mode_source: Literal["auto_detected", "explicit_param", "explicit_env"]
95
+
96
+
97
+ @dataclass
98
+ class ModuleAssessment:
99
+ """Risk assessment for a single module (directory)."""
100
+
101
+ path: str
102
+ debt_score: float | None
103
+ dead_code_score: float | None
104
+ complexity_score: float | None
105
+ composite_score: float | None
106
+ recommended_action: Literal[
107
+ "Keep", "Refactor", "Migrate", "Rewrite", "Delete", "Unknown"
108
+ ]
109
+ batch: int
110
+ risk: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
111
+ unknown: bool = False
112
+
113
+
114
+ # --- Signal detection -----------------------------------------------------
115
+
116
+
117
+ def _load_health_report(directory: Path) -> dict | None:
118
+ """Load cached health report JSON if available.
119
+
120
+ Args:
121
+ directory: Project root directory.
122
+
123
+ Returns:
124
+ Parsed JSON dict or None if not found / invalid.
125
+ """
126
+ report_path = directory / "ref-reports" / "platform_engineer" / "health_report.json"
127
+ if not report_path.exists():
128
+ logger.debug("health_report_not_found", path=str(report_path))
129
+ return None
130
+ try:
131
+ return json.loads(report_path.read_text(encoding="utf-8"))
132
+ except (json.JSONDecodeError, OSError) as exc:
133
+ logger.warning(
134
+ "health_report_load_failed", path=str(report_path), error=str(exc)
135
+ )
136
+ return None
137
+
138
+
139
+ def _detect_soap_patterns(directory: Path) -> DetectedSignal | None:
140
+ """SIG-S1: Detect SOAP/XML-RPC usage.
141
+
142
+ Args:
143
+ directory: Project root directory.
144
+
145
+ Returns:
146
+ DetectedSignal if found, else None.
147
+ """
148
+ affected: list[str] = []
149
+ source_exts = {".py", ".ts", ".js", ".java", ".cs", ".xml"}
150
+
151
+ for path in directory.rglob("*"):
152
+ if not path.is_file() or path.suffix not in source_exts:
153
+ continue
154
+ if any(
155
+ skip in path.parts
156
+ for skip in ("node_modules", "__pycache__", ".git", "dist")
157
+ ):
158
+ continue
159
+ try:
160
+ text = path.read_text(encoding="utf-8", errors="ignore")
161
+ if _SOAP_PATTERNS.search(text):
162
+ affected.append(str(path.relative_to(directory)))
163
+ except OSError:
164
+ continue
165
+
166
+ # Also check package manifests
167
+ for manifest in ("requirements.txt", "setup.cfg", "pyproject.toml"):
168
+ manifest_path = directory / manifest
169
+ if manifest_path.exists():
170
+ try:
171
+ text = manifest_path.read_text(encoding="utf-8", errors="ignore")
172
+ if _SOAP_PATTERNS.search(text) and str(manifest) not in affected:
173
+ affected.append(manifest)
174
+ except OSError:
175
+ continue
176
+
177
+ if not affected:
178
+ return None
179
+
180
+ return DetectedSignal(
181
+ id="SIG-S1",
182
+ category="strong",
183
+ description=f"SOAP/XML-RPC patterns detected in {len(affected)} file(s)",
184
+ files_affected=affected,
185
+ )
186
+
187
+
188
+ def _detect_db_in_controller(directory: Path) -> DetectedSignal | None:
189
+ """SIG-S2: Direct DB access in UI/controller layer (3+ files).
190
+
191
+ Args:
192
+ directory: Project root directory.
193
+
194
+ Returns:
195
+ DetectedSignal if found, else None.
196
+ """
197
+ affected: list[str] = []
198
+ source_exts = {".py", ".ts", ".js"}
199
+
200
+ for path in directory.rglob("*"):
201
+ if not path.is_file() or path.suffix not in source_exts:
202
+ continue
203
+ if any(skip in path.parts for skip in ("node_modules", "__pycache__", ".git")):
204
+ continue
205
+ if not _DB_CONTROLLER_DIRS.search(path.stem) and not _DB_CONTROLLER_DIRS.search(
206
+ str(path.parent.name)
207
+ ):
208
+ continue
209
+ try:
210
+ text = path.read_text(encoding="utf-8", errors="ignore")
211
+ if _DB_IMPORTS.search(text):
212
+ affected.append(str(path.relative_to(directory)))
213
+ except OSError:
214
+ continue
215
+
216
+ if len(affected) < 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
217
+ return None
218
+
219
+ return DetectedSignal(
220
+ id="SIG-S2",
221
+ category="strong",
222
+ description=f"Direct DB access in controller/view layer ({len(affected)} files)",
223
+ files_affected=affected,
224
+ )
225
+
226
+
227
+ def _detect_old_framework_version(directory: Path) -> DetectedSignal | None:
228
+ """SIG-S3: Framework version >3 major releases behind current.
229
+
230
+ Checks pyproject.toml, requirements.txt, package.json.
231
+
232
+ Args:
233
+ directory: Project root directory.
234
+
235
+ Returns:
236
+ DetectedSignal if found, else None.
237
+ """
238
+ # Current major versions (update as ecosystem evolves)
239
+ _PYTHON_FRAMEWORK_CURRENT: dict[str, int] = {
240
+ "django": 5,
241
+ "flask": 3,
242
+ "fastapi": 0,
243
+ "sqlalchemy": 2,
244
+ "celery": 5,
245
+ }
246
+ _NODE_FRAMEWORK_CURRENT: dict[str, int] = {
247
+ "react": 18,
248
+ "angular": 17,
249
+ "vue": 3,
250
+ "express": 4,
251
+ "next": 14,
252
+ }
253
+
254
+ version_re = re.compile(r"[=><~^]*(\d+)\.")
255
+ affected: list[str] = []
256
+
257
+ # Check Python manifests
258
+ for manifest_name in ("requirements.txt", "pyproject.toml", "Pipfile"):
259
+ manifest = directory / manifest_name
260
+ if not manifest.exists():
261
+ continue
262
+ try:
263
+ text = manifest.read_text(encoding="utf-8", errors="ignore")
264
+ except OSError:
265
+ continue
266
+ for pkg, current_major in _PYTHON_FRAMEWORK_CURRENT.items():
267
+ pattern = re.compile(
268
+ rf"^\s*{re.escape(pkg)}\s*[=><~^]*\s*(\d+)\.",
269
+ re.IGNORECASE | re.MULTILINE,
270
+ )
271
+ match = pattern.search(text)
272
+ if match:
273
+ detected_major = int(match.group(1))
274
+ if current_major - detected_major >= 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
275
+ affected.append(
276
+ f"{manifest_name}: {pkg}=={detected_major}.x (current: {current_major}.x)"
277
+ )
278
+
279
+ # Check package.json
280
+ pkg_json = directory / "package.json"
281
+ if pkg_json.exists():
282
+ try:
283
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
284
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
285
+ for pkg, current_major in _NODE_FRAMEWORK_CURRENT.items():
286
+ version_str = deps.get(pkg, "")
287
+ m = version_re.search(version_str)
288
+ if m:
289
+ detected_major = int(m.group(1))
290
+ if current_major - detected_major >= 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
291
+ affected.append(
292
+ f"package.json: {pkg}@{detected_major}.x (current: {current_major}.x)"
293
+ )
294
+ except (json.JSONDecodeError, OSError):
295
+ pass
296
+
297
+ if not affected:
298
+ return None
299
+
300
+ return DetectedSignal(
301
+ id="SIG-S3",
302
+ category="strong",
303
+ description=f"Framework version >3 major releases behind: {', '.join(affected)}",
304
+ files_affected=affected,
305
+ )
306
+
307
+
308
+ def _detect_high_dead_code_codebase(report: dict | None) -> DetectedSignal | None:
309
+ """SIG-S4: Dead code density >30% across whole codebase.
310
+
311
+ Args:
312
+ report: Parsed health_report.json or None.
313
+
314
+ Returns:
315
+ DetectedSignal if found, else None.
316
+ """
317
+ if not report:
318
+ return None
319
+
320
+ findings = report.get("findings", [])
321
+ total_files = max(1, report.get("files_scanned", 1))
322
+ dead_code_files = {f["file"] for f in findings if f.get("scanner") == "dead_code"}
323
+ pct = len(dead_code_files) / total_files * 100
324
+
325
+ if pct < _STRONG_SIGNAL_DEAD_CODE_PCT:
326
+ return None
327
+
328
+ return DetectedSignal(
329
+ id="SIG-S4",
330
+ category="strong",
331
+ description=f"Dead code density {pct:.1f}% across codebase (threshold: {_STRONG_SIGNAL_DEAD_CODE_PCT}%)",
332
+ files_affected=list(dead_code_files)[:20], # cap for readability
333
+ )
334
+
335
+
336
+ def _detect_legacy_markers(directory: Path) -> DetectedSignal | None:
337
+ """SIG-W1: DEPRECATED/TODO: migrate markers in 5+ files.
338
+
339
+ Args:
340
+ directory: Project root directory.
341
+
342
+ Returns:
343
+ DetectedSignal if found, else None.
344
+ """
345
+ affected: list[str] = []
346
+ source_exts = {".py", ".ts", ".tsx", ".js", ".java", ".cs", ".go", ".rb"}
347
+
348
+ for path in directory.rglob("*"):
349
+ if not path.is_file() or path.suffix not in source_exts:
350
+ continue
351
+ if any(
352
+ skip in path.parts
353
+ for skip in ("node_modules", "__pycache__", ".git", "dist")
354
+ ):
355
+ continue
356
+ try:
357
+ text = path.read_text(encoding="utf-8", errors="ignore")
358
+ if _LEGACY_MARKERS.search(text):
359
+ affected.append(str(path.relative_to(directory)))
360
+ except OSError:
361
+ continue
362
+
363
+ if len(affected) < _WEAK_SIGNAL_MARKER_FILES:
364
+ return None
365
+
366
+ return DetectedSignal(
367
+ id="SIG-W1",
368
+ category="weak",
369
+ description=f"Legacy markers (DEPRECATED/TODO: migrate) in {len(affected)} files",
370
+ files_affected=affected,
371
+ )
372
+
373
+
374
+ def _detect_module_dead_code(report: dict | None) -> DetectedSignal | None:
375
+ """SIG-W2: Dead code density >20% in any single module.
376
+
377
+ Args:
378
+ report: Parsed health_report.json or None.
379
+
380
+ Returns:
381
+ DetectedSignal if found, else None.
382
+ """
383
+ if not report:
384
+ return None
385
+
386
+ findings = report.get("findings", [])
387
+ # Group dead_code findings by module (parent dir)
388
+ module_dead: dict[str, set[str]] = {}
389
+ module_files: dict[str, set[str]] = {}
390
+
391
+ for f in findings:
392
+ file_path = f.get("file", "")
393
+ if not file_path:
394
+ continue
395
+ module = str(Path(file_path).parent)
396
+ module_files.setdefault(module, set()).add(file_path)
397
+ if f.get("scanner") == "dead_code":
398
+ module_dead.setdefault(module, set()).add(file_path)
399
+
400
+ hot_modules: list[str] = []
401
+ for module, files in module_files.items():
402
+ dead_pct = len(module_dead.get(module, set())) / max(1, len(files)) * 100
403
+ if dead_pct > _WEAK_SIGNAL_DEAD_CODE_PCT:
404
+ hot_modules.append(f"{module} ({dead_pct:.1f}%)")
405
+
406
+ if not hot_modules:
407
+ return None
408
+
409
+ return DetectedSignal(
410
+ id="SIG-W2",
411
+ category="weak",
412
+ description=f"Dead code >20% in modules: {', '.join(hot_modules[:5])}",
413
+ files_affected=hot_modules,
414
+ )
415
+
416
+
417
+ def _detect_high_complexity(report: dict | None) -> DetectedSignal | None:
418
+ """SIG-W3: Cyclomatic complexity >30 in 3+ functions.
419
+
420
+ Args:
421
+ report: Parsed health_report.json or None.
422
+
423
+ Returns:
424
+ DetectedSignal if found, else None.
425
+ """
426
+ if not report:
427
+ return None
428
+
429
+ findings = report.get("findings", [])
430
+ complex_funcs = [
431
+ f
432
+ for f in findings
433
+ if f.get("scanner") == "complexity"
434
+ and f.get("severity") in ("CRITICAL", "HIGH")
435
+ ]
436
+
437
+ if len(complex_funcs) < _WEAK_SIGNAL_COMPLEXITY_FUNCS:
438
+ return None
439
+
440
+ affected = [f.get("file", "") for f in complex_funcs[:20]]
441
+ return DetectedSignal(
442
+ id="SIG-W3",
443
+ category="weak",
444
+ description=f"High cyclomatic complexity in {len(complex_funcs)} functions",
445
+ files_affected=affected,
446
+ )
447
+
448
+
449
+ def _detect_monolithic_files(directory: Path) -> DetectedSignal | None:
450
+ """SIG-W4: Files >1000 LOC with no separation of concerns.
451
+
452
+ Args:
453
+ directory: Project root directory.
454
+
455
+ Returns:
456
+ DetectedSignal if found, else None.
457
+ """
458
+ source_exts = {".py", ".ts", ".tsx", ".js", ".java", ".cs"}
459
+ affected: list[str] = []
460
+
461
+ for path in directory.rglob("*"):
462
+ if not path.is_file() or path.suffix not in source_exts:
463
+ continue
464
+ if any(
465
+ skip in path.parts
466
+ for skip in ("node_modules", "__pycache__", ".git", "dist")
467
+ ):
468
+ continue
469
+ try:
470
+ loc = sum(1 for _ in path.open(encoding="utf-8", errors="ignore"))
471
+ if loc > _WEAK_SIGNAL_MONOLITH_LOC:
472
+ affected.append(str(path.relative_to(directory)))
473
+ except OSError:
474
+ continue
475
+
476
+ if not affected:
477
+ return None
478
+
479
+ return DetectedSignal(
480
+ id="SIG-W4",
481
+ category="weak",
482
+ description=f"Monolithic files (>{_WEAK_SIGNAL_MONOLITH_LOC} LOC): {len(affected)} found",
483
+ files_affected=affected,
484
+ )
485
+
486
+
487
+ def detect_migration_intent(
488
+ directory: Path,
489
+ explicit: bool = False,
490
+ ) -> MigrationIntentResult:
491
+ """Evaluate codebase signals against migration thresholds.
492
+
493
+ Reads ref-reports/platform_engineer/health_report.json for scanner-based
494
+ signals (dead code, complexity). Performs lightweight filesystem checks
495
+ for structural signals (SOAP, framework version, legacy markers).
496
+
497
+ Args:
498
+ directory: Project root directory.
499
+ explicit: If True, migration mode activates regardless of signals.
500
+
501
+ Returns:
502
+ MigrationIntentResult with detection outcome and signals found.
503
+ """
504
+ # Determine mode source
505
+ env_explicit = os.environ.get("REF_MIGRATION_MODE") == "1"
506
+ if env_explicit:
507
+ mode_source: Literal["auto_detected", "explicit_param", "explicit_env"] = (
508
+ "explicit_env"
509
+ )
510
+ elif explicit:
511
+ mode_source = "explicit_param"
512
+ else:
513
+ mode_source = "auto_detected"
514
+
515
+ report = _load_health_report(directory)
516
+
517
+ # Evaluate all signals
518
+ signal_checks = [
519
+ _detect_soap_patterns(directory),
520
+ _detect_db_in_controller(directory),
521
+ _detect_old_framework_version(directory),
522
+ _detect_high_dead_code_codebase(report),
523
+ _detect_legacy_markers(directory),
524
+ _detect_module_dead_code(report),
525
+ _detect_high_complexity(report),
526
+ _detect_monolithic_files(directory),
527
+ ]
528
+
529
+ signals = [s for s in signal_checks if s is not None]
530
+ strong = [s for s in signals if s.category == "strong"]
531
+ weak = [s for s in signals if s.category == "weak"]
532
+
533
+ auto_trigger = len(strong) >= 1 or len(weak) >= 2 # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
534
+ is_migration = explicit or env_explicit or auto_trigger
535
+
536
+ if is_migration:
537
+ if explicit or env_explicit:
538
+ trigger_reason = f"Migration mode: explicitly enabled ({mode_source})"
539
+ elif strong:
540
+ trigger_reason = (
541
+ f"1 strong signal: {strong[0].id} ({strong[0].description[:60]})"
542
+ )
543
+ else:
544
+ trigger_reason = (
545
+ f"{len(weak)} weak signals: {', '.join(s.id for s in weak[:3])}"
546
+ )
547
+ else:
548
+ weak_count = len(weak)
549
+ strong_count = len(strong)
550
+ trigger_reason = (
551
+ f"Insufficient signals: {strong_count} strong, {weak_count} weak "
552
+ f"(need 1 strong OR 2+ weak)"
553
+ )
554
+ if signals:
555
+ trigger_reason += f" — advisory: {signals[0].id} detected"
556
+
557
+ logger.info(
558
+ "migration_intent_evaluated",
559
+ is_migration=is_migration,
560
+ strong_signals=len(strong),
561
+ weak_signals=len(weak),
562
+ trigger_reason=trigger_reason,
563
+ )
564
+
565
+ return MigrationIntentResult(
566
+ is_migration=is_migration,
567
+ signals=signals,
568
+ trigger_reason=trigger_reason,
569
+ mode_source=mode_source,
570
+ )
571
+
572
+
573
+ # --- Module assessment ----------------------------------------------------
574
+
575
+
576
+ def _classify_action(score: float) -> str:
577
+ """Map composite score to recommended action.
578
+
579
+ Args:
580
+ score: Composite risk score 0.0–10.0.
581
+
582
+ Returns:
583
+ Recommended action string.
584
+ """
585
+ for threshold, action in _ACTION_THRESHOLDS:
586
+ if score <= threshold:
587
+ return action
588
+ return "Delete"
589
+
590
+
591
+ def _classify_risk(score: float) -> str:
592
+ """Map composite score to risk level.
593
+
594
+ Args:
595
+ score: Composite risk score 0.0–10.0.
596
+
597
+ Returns:
598
+ Risk level string.
599
+ """
600
+ for threshold, risk in _RISK_THRESHOLDS:
601
+ if score <= threshold:
602
+ return risk
603
+ return "CRITICAL"
604
+
605
+
606
+ class MigrationMapper:
607
+ """Aggregates scanner findings into per-module assessments and produces artifacts.
608
+
609
+ Args:
610
+ directory: Project root directory.
611
+ report: Parsed health_report.json dict, or None to load from disk.
612
+ """
613
+
614
+ def __init__(self, directory: Path, report: dict | None = None) -> None:
615
+ self._directory = directory
616
+ self._report = report or _load_health_report(directory) or {}
617
+
618
+ def _compute_composite_score(
619
+ self,
620
+ debt_score: float,
621
+ dead_code_score: float,
622
+ complexity_score: float,
623
+ coverage_score: float,
624
+ ) -> float:
625
+ """Compute weighted composite risk score.
626
+
627
+ Args:
628
+ debt_score: Debt density 0.0–10.0.
629
+ dead_code_score: Dead code density 0.0–10.0.
630
+ complexity_score: Complexity risk 0.0–10.0.
631
+ coverage_score: Inverse coverage 0.0–10.0 (high = low coverage = high risk).
632
+
633
+ Returns:
634
+ Composite score 0.0–10.0.
635
+ """
636
+ raw = (
637
+ debt_score * _W_DEBT
638
+ + dead_code_score * _W_DEAD
639
+ + complexity_score * _W_COMPLEXITY
640
+ + coverage_score * _W_COVERAGE
641
+ )
642
+ return round(min(10.0, max(0.0, raw)), 2)
643
+
644
+ def assess_modules(self) -> list[ModuleAssessment]:
645
+ """Build per-module risk assessments from health report findings.
646
+
647
+ Groups findings by parent directory (= module). Normalises counts
648
+ to 0–10 scores. Assigns batch order by risk (CRITICAL=0, HIGH=1, ...).
649
+
650
+ Returns:
651
+ Sorted list of ModuleAssessment (highest composite score first).
652
+ """
653
+ findings = self._report.get("findings", [])
654
+ if not findings:
655
+ return []
656
+
657
+ # Group by module (parent dir of each finding's file)
658
+ module_debt: dict[str, int] = {}
659
+ module_dead: dict[str, set[str]] = {}
660
+ module_complexity_sev: dict[str, list[str]] = {}
661
+ module_file_count: dict[str, set[str]] = {}
662
+
663
+ sev_weight = {"CRITICAL": 10.0, "HIGH": 7.5, "MEDIUM": 5.0, "LOW": 2.5}
664
+
665
+ for f in findings:
666
+ file_path = f.get("file", "")
667
+ if not file_path:
668
+ continue
669
+ module = str(Path(file_path).parent)
670
+ scanner = f.get("scanner", "")
671
+ severity = f.get("severity", "LOW")
672
+ module_file_count.setdefault(module, set()).add(file_path)
673
+
674
+ if scanner == "debt":
675
+ module_debt[module] = module_debt.get(module, 0) + 1
676
+ elif scanner == "dead_code":
677
+ module_dead.setdefault(module, set()).add(file_path)
678
+ elif scanner == "complexity":
679
+ module_complexity_sev.setdefault(module, []).append(severity)
680
+
681
+ if not module_file_count:
682
+ return []
683
+
684
+ assessments: list[ModuleAssessment] = []
685
+
686
+ for module, files in module_file_count.items():
687
+ file_count = max(1, len(files))
688
+
689
+ # Debt: findings per file, normalised 0–10
690
+ debt_raw = module_debt.get(module, 0)
691
+ debt_score = min(10.0, (debt_raw / file_count) * 2.5)
692
+
693
+ # Dead code: affected files / total files, normalised 0–10
694
+ dead_files = len(module_dead.get(module, set()))
695
+ dead_code_score = min(10.0, (dead_files / file_count) * 10.0)
696
+
697
+ # Complexity: average severity weight of complexity findings
698
+ sev_list = module_complexity_sev.get(module, [])
699
+ if sev_list:
700
+ complexity_score = min(
701
+ 10.0,
702
+ sum(sev_weight.get(s, 2.5) for s in sev_list) / len(sev_list),
703
+ )
704
+ else:
705
+ complexity_score = 0.0
706
+
707
+ # Coverage: no coverage data available from health scanner — use neutral 5.0
708
+ coverage_score = 5.0
709
+
710
+ all_none = debt_raw == 0 and dead_files == 0 and not sev_list
711
+ if all_none:
712
+ assessment = ModuleAssessment(
713
+ path=module,
714
+ debt_score=None,
715
+ dead_code_score=None,
716
+ complexity_score=None,
717
+ composite_score=None,
718
+ recommended_action="Unknown",
719
+ batch=99,
720
+ risk="LOW",
721
+ unknown=True,
722
+ )
723
+ else:
724
+ composite = self._compute_composite_score(
725
+ debt_score, dead_code_score, complexity_score, coverage_score
726
+ )
727
+ action = _classify_action(composite)
728
+ risk = _classify_risk(composite)
729
+ # Batch: CRITICAL→0, HIGH→1, MEDIUM→2, LOW→3
730
+ batch_map = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
731
+ assessment = ModuleAssessment(
732
+ path=module,
733
+ debt_score=round(debt_score, 2),
734
+ dead_code_score=round(dead_code_score, 2),
735
+ complexity_score=round(complexity_score, 2),
736
+ composite_score=composite,
737
+ recommended_action=action, # type: ignore[arg-type]
738
+ batch=batch_map.get(risk, 3),
739
+ risk=risk, # type: ignore[arg-type]
740
+ unknown=False,
741
+ )
742
+ assessments.append(assessment)
743
+
744
+ assessments.sort(key=lambda a: (a.composite_score or 0.0), reverse=True)
745
+ return assessments
746
+
747
+ def generate_map(
748
+ self,
749
+ intent: MigrationIntentResult,
750
+ assessments: list[ModuleAssessment],
751
+ ) -> tuple[Path, Path]:
752
+ """Generate MIGRATION_MAP.md and ref-reports/migration_map.json.
753
+
754
+ Args:
755
+ intent: Migration intent detection result.
756
+ assessments: Per-module risk assessments from assess_modules().
757
+
758
+ Returns:
759
+ Tuple of (markdown_path, json_path).
760
+
761
+ Raises:
762
+ OSError: If artifact write fails.
763
+ """
764
+ md_path = self._write_markdown(intent, assessments)
765
+ json_path = self._write_json(intent, assessments)
766
+ logger.info(
767
+ "migration_map_generated",
768
+ md_path=str(md_path),
769
+ json_path=str(json_path),
770
+ modules=len(assessments),
771
+ )
772
+ return md_path, json_path
773
+
774
+ def _write_markdown(
775
+ self,
776
+ intent: MigrationIntentResult,
777
+ assessments: list[ModuleAssessment],
778
+ ) -> Path:
779
+ """Write docs/MIGRATION_MAP.md.
780
+
781
+ Args:
782
+ intent: Migration intent result.
783
+ assessments: Module assessments.
784
+
785
+ Returns:
786
+ Path to written file.
787
+ """
788
+ action_counts: dict[str, int] = {}
789
+ for a in assessments:
790
+ action_counts[a.recommended_action] = (
791
+ action_counts.get(a.recommended_action, 0) + 1
792
+ )
793
+
794
+ scores = [
795
+ a.composite_score for a in assessments if a.composite_score is not None
796
+ ]
797
+ overall_score = sum(scores) / max(1, len(scores)) if scores else 0.0
798
+ overall_risk = _classify_risk(overall_score)
799
+
800
+ batches = {a.batch for a in assessments if not a.unknown}
801
+ estimated_batches = len(batches - {99})
802
+
803
+ lines = [
804
+ "# Migration Map",
805
+ "",
806
+ "<!-- REF:AUTO -->",
807
+ "## Summary",
808
+ "",
809
+ f"- **Total modules assessed:** {len(assessments)}",
810
+ f"- **Migrate:** {action_counts.get('Migrate', 0)} | "
811
+ f"**Rewrite:** {action_counts.get('Rewrite', 0)} | "
812
+ f"**Delete:** {action_counts.get('Delete', 0)} | "
813
+ f"**Keep:** {action_counts.get('Keep', 0)} | "
814
+ f"**Refactor:** {action_counts.get('Refactor', 0)}",
815
+ f"- **Estimated migration batches:** {estimated_batches}",
816
+ f"- **Overall migration risk:** {overall_risk}",
817
+ f"- **Migration mode trigger:** {intent.trigger_reason}",
818
+ "",
819
+ "---",
820
+ "",
821
+ "## Intent Signals Detected",
822
+ "",
823
+ ]
824
+
825
+ if intent.signals:
826
+ lines.append("| Signal ID | Category | Description | Files Affected |")
827
+ lines.append("|-----------|----------|-------------|----------------|")
828
+ for sig in intent.signals:
829
+ files_str = str(len(sig.files_affected))
830
+ lines.append(
831
+ f"| `{sig.id}` | {sig.category} | {sig.description[:70]} | {files_str} |"
832
+ )
833
+ else:
834
+ lines.append("_No signals detected. Migration mode explicitly enabled._")
835
+
836
+ lines += [
837
+ "",
838
+ "---",
839
+ "",
840
+ "## Module Assessment",
841
+ "",
842
+ "| Module | Debt Score | Dead Code Score | Complexity Score | Composite | Action | Batch | Risk |",
843
+ "|--------|-----------|-----------------|-----------------|-----------|--------|-------|------|",
844
+ ]
845
+
846
+ for a in assessments:
847
+ if a.unknown:
848
+ lines.append(
849
+ f"| `{a.path}` | — | — | — | — | Unknown (manual review) | — | — |"
850
+ )
851
+ else:
852
+ lines.append(
853
+ f"| `{a.path}` | {a.debt_score:.1f} | {a.dead_code_score:.1f} | "
854
+ f"{a.complexity_score:.1f} | **{a.composite_score:.1f}** | "
855
+ f"**{a.recommended_action}** | {a.batch} | {a.risk} |"
856
+ )
857
+
858
+ lines += [
859
+ "",
860
+ "---",
861
+ "",
862
+ "## Dependency Order",
863
+ "",
864
+ "```mermaid",
865
+ "graph TD",
866
+ ]
867
+
868
+ batch_modules: dict[int, list[str]] = {}
869
+ for a in assessments:
870
+ if not a.unknown:
871
+ batch_modules.setdefault(a.batch, []).append(a.path)
872
+
873
+ for batch_num in sorted(batch_modules.keys()):
874
+ prev_batch = batch_num - 1
875
+ for module in batch_modules[batch_num]:
876
+ safe_id = re.sub(r"[^a-zA-Z0-9_]", "_", module)
877
+ lines.append(f" {safe_id}[{module}]")
878
+ if prev_batch in batch_modules:
879
+ for prev_mod in batch_modules[prev_batch]:
880
+ prev_id = re.sub(r"[^a-zA-Z0-9_]", "_", prev_mod)
881
+ lines.append(f" {prev_id} --> {safe_id}")
882
+
883
+ lines.append("```")
884
+ lines.append("")
885
+
886
+ docs_dir = self._directory / "docs"
887
+ docs_dir.mkdir(exist_ok=True)
888
+ md_path = docs_dir / "MIGRATION_MAP.md"
889
+ md_path.write_text("\n".join(lines), encoding="utf-8")
890
+ return md_path
891
+
892
+ def _write_json(
893
+ self,
894
+ intent: MigrationIntentResult,
895
+ assessments: list[ModuleAssessment],
896
+ ) -> Path:
897
+ """Write ref-reports/migration_map.json.
898
+
899
+ Args:
900
+ intent: Migration intent result.
901
+ assessments: Module assessments.
902
+
903
+ Returns:
904
+ Path to written file.
905
+ """
906
+ scores = [
907
+ a.composite_score for a in assessments if a.composite_score is not None
908
+ ]
909
+ overall_score = sum(scores) / max(1, len(scores)) if scores else 0.0
910
+
911
+ payload = {
912
+ "schema_version": "1.0",
913
+ "generated_at": datetime.now(timezone.utc).isoformat(),
914
+ "migration_mode": intent.mode_source,
915
+ "overall_risk": _classify_risk(overall_score),
916
+ "trigger_reason": intent.trigger_reason,
917
+ "signals": [
918
+ {
919
+ "id": s.id,
920
+ "category": s.category,
921
+ "description": s.description,
922
+ "files_affected": s.files_affected,
923
+ }
924
+ for s in intent.signals
925
+ ],
926
+ "modules": [
927
+ {
928
+ "path": a.path,
929
+ "debt_score": a.debt_score,
930
+ "dead_code_score": a.dead_code_score,
931
+ "complexity_score": a.complexity_score,
932
+ "composite_score": a.composite_score,
933
+ "recommended_action": a.recommended_action,
934
+ "batch": a.batch,
935
+ "risk": a.risk,
936
+ "unknown": a.unknown,
937
+ }
938
+ for a in assessments
939
+ ],
940
+ }
941
+
942
+ reports_dir = self._directory / "ref-reports"
943
+ reports_dir.mkdir(exist_ok=True)
944
+ json_path = reports_dir / "migration_map.json"
945
+ json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
946
+ return json_path