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