sphinx-mkdocs-migrate 0.0.1.dev0__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.
- sphinx_mkdocs_migrate/__init__.py +8 -0
- sphinx_mkdocs_migrate/analyzer/__init__.py +17 -0
- sphinx_mkdocs_migrate/analyzer/ci.py +223 -0
- sphinx_mkdocs_migrate/analyzer/dependencies.py +134 -0
- sphinx_mkdocs_migrate/analyzer/markdown.py +148 -0
- sphinx_mkdocs_migrate/analyzer/mkdocs.py +263 -0
- sphinx_mkdocs_migrate/analyzer/models.py +348 -0
- sphinx_mkdocs_migrate/analyzer/navigation.py +108 -0
- sphinx_mkdocs_migrate/analyzer/project.py +511 -0
- sphinx_mkdocs_migrate/cli.py +507 -0
- sphinx_mkdocs_migrate/parsing/doc_ir.py +533 -0
- sphinx_mkdocs_migrate/parsing/flow_extractor.py +457 -0
- sphinx_mkdocs_migrate/parsing/html_flow_parser.py +349 -0
- sphinx_mkdocs_migrate/parsing/markdown.py +22 -0
- sphinx_mkdocs_migrate/parsing/markdown_ir.py +49 -0
- sphinx_mkdocs_migrate/parsing/markdown_it_adapter.py +496 -0
- sphinx_mkdocs_migrate/parsing/requirements.py +155 -0
- sphinx_mkdocs_migrate/planner/accountability.py +111 -0
- sphinx_mkdocs_migrate/planner/ci.py +142 -0
- sphinx_mkdocs_migrate/planner/conf_builder.py +183 -0
- sphinx_mkdocs_migrate/planner/models.py +379 -0
- sphinx_mkdocs_migrate/planner/planner.py +1867 -0
- sphinx_mkdocs_migrate/planner/policy.py +474 -0
- sphinx_mkdocs_migrate/planner/theme_constants.py +70 -0
- sphinx_mkdocs_migrate/planner/toctree.py +158 -0
- sphinx_mkdocs_migrate/py.typed +1 -0
- sphinx_mkdocs_migrate/rules/catalog.py +154 -0
- sphinx_mkdocs_migrate/rules/engine.py +94 -0
- sphinx_mkdocs_migrate/rules/models.py +176 -0
- sphinx_mkdocs_migrate/transformer/engine.py +897 -0
- sphinx_mkdocs_migrate/transformer/models.py +59 -0
- sphinx_mkdocs_migrate/transformer/myst_transformer.py +393 -0
- sphinx_mkdocs_migrate/validator/models.py +40 -0
- sphinx_mkdocs_migrate/validator/verifier.py +377 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/METADATA +199 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/RECORD +39 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/WHEEL +4 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Subsystem analyzer for MkDocs declarative navigation trees with path normalization and missing file detection."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path, PurePosixPath
|
|
4
|
+
from typing import List, Any, Optional, Set
|
|
5
|
+
from .models import NavigationItem, NavigationAnalysis
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _normalize_nav_path(raw_path: str) -> str:
|
|
9
|
+
"""Normalizes relative navigation paths (e.g. './guide/index.md#sec' -> 'guide/index.md')."""
|
|
10
|
+
clean = raw_path.strip()
|
|
11
|
+
if "#" in clean:
|
|
12
|
+
clean = clean.split("#")[0]
|
|
13
|
+
if clean.startswith("./"):
|
|
14
|
+
clean = clean[2:]
|
|
15
|
+
return str(PurePosixPath(clean))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NavigationAnalyzer:
|
|
19
|
+
"""Parses, normalizes, and validates the raw nav tree from mkdocs.yml against discovered files."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, project_root: Path):
|
|
22
|
+
self.project_root = project_root
|
|
23
|
+
|
|
24
|
+
def analyze(
|
|
25
|
+
self, raw_nav: Optional[Any], discovered_md_files: List[Path], docs_dir: Path
|
|
26
|
+
) -> NavigationAnalysis:
|
|
27
|
+
if raw_nav is None:
|
|
28
|
+
return NavigationAnalysis(has_nav=False)
|
|
29
|
+
|
|
30
|
+
parsed_items: List[NavigationItem] = []
|
|
31
|
+
referenced_paths: Set[str] = set()
|
|
32
|
+
|
|
33
|
+
def parse_node(node: Any) -> Optional[NavigationItem]:
|
|
34
|
+
if isinstance(node, str):
|
|
35
|
+
norm_p = _normalize_nav_path(node)
|
|
36
|
+
referenced_paths.add(norm_p)
|
|
37
|
+
title = Path(norm_p).stem.replace("-", " ").replace("_", " ").title()
|
|
38
|
+
return NavigationItem(title=title, path=norm_p)
|
|
39
|
+
elif isinstance(node, dict):
|
|
40
|
+
for label, val in node.items():
|
|
41
|
+
if isinstance(val, str):
|
|
42
|
+
norm_p = _normalize_nav_path(val)
|
|
43
|
+
referenced_paths.add(norm_p)
|
|
44
|
+
return NavigationItem(title=str(label), path=norm_p)
|
|
45
|
+
elif isinstance(val, list):
|
|
46
|
+
child_items = [
|
|
47
|
+
c for c in (parse_node(item) for item in val) if c
|
|
48
|
+
]
|
|
49
|
+
return NavigationItem(
|
|
50
|
+
title=str(label), path=None, children=child_items
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
if isinstance(raw_nav, list):
|
|
55
|
+
for entry in raw_nav:
|
|
56
|
+
item = parse_node(entry)
|
|
57
|
+
if item:
|
|
58
|
+
parsed_items.append(item)
|
|
59
|
+
|
|
60
|
+
def count_and_depth(
|
|
61
|
+
items: List[NavigationItem], current_depth: int = 1
|
|
62
|
+
) -> tuple[int, int]:
|
|
63
|
+
total = 0
|
|
64
|
+
max_d = current_depth
|
|
65
|
+
for it in items:
|
|
66
|
+
total += 1
|
|
67
|
+
if it.children:
|
|
68
|
+
c_total, c_depth = count_and_depth(it.children, current_depth + 1)
|
|
69
|
+
total += c_total
|
|
70
|
+
max_d = max(max_d, c_depth)
|
|
71
|
+
return total, max_d
|
|
72
|
+
|
|
73
|
+
total_entries, depth = count_and_depth(parsed_items)
|
|
74
|
+
|
|
75
|
+
# Build set of normalized discovered file paths relative to docs_dir
|
|
76
|
+
discovered_rel_paths = set()
|
|
77
|
+
for f in discovered_md_files:
|
|
78
|
+
try:
|
|
79
|
+
rel = str(f.relative_to(docs_dir)).replace("\\", "/")
|
|
80
|
+
discovered_rel_paths.add(_normalize_nav_path(rel))
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Detect missing referenced files vs generated wildcard/pipeline navigation references
|
|
85
|
+
missing_refs: List[str] = []
|
|
86
|
+
generated_wildcard_refs: List[str] = []
|
|
87
|
+
for ref in referenced_paths:
|
|
88
|
+
if ref not in discovered_rel_paths and not ref.startswith("http"):
|
|
89
|
+
if "..." in ref or "*" in ref or "|" in ref:
|
|
90
|
+
generated_wildcard_refs.append(ref)
|
|
91
|
+
else:
|
|
92
|
+
missing_refs.append(ref)
|
|
93
|
+
|
|
94
|
+
# Detect orphan documents (on disk, but not in nav, excluding index.md)
|
|
95
|
+
orphans: List[str] = []
|
|
96
|
+
for disc in discovered_rel_paths:
|
|
97
|
+
if disc not in referenced_paths and disc != "index.md":
|
|
98
|
+
orphans.append(disc)
|
|
99
|
+
|
|
100
|
+
return NavigationAnalysis(
|
|
101
|
+
has_nav=True,
|
|
102
|
+
total_nav_entries=total_entries,
|
|
103
|
+
max_depth=depth,
|
|
104
|
+
missing_references=sorted(missing_refs),
|
|
105
|
+
generated_wildcard_references=sorted(generated_wildcard_refs),
|
|
106
|
+
orphan_documents=sorted(orphans),
|
|
107
|
+
tree=parsed_items,
|
|
108
|
+
)
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Project-level orchestration analyzer producing complete, effective MigrationAnalysis."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
from .mkdocs import MkDocsConfigAnalyzer
|
|
8
|
+
from .dependencies import DependencyAnalyzer
|
|
9
|
+
from .navigation import NavigationAnalyzer
|
|
10
|
+
from .ci import CIAnalyzer
|
|
11
|
+
from .markdown import MarkdownAnalyzer
|
|
12
|
+
from ..parsing.flow_extractor import DocumentFlowExtractor
|
|
13
|
+
from ..parsing.doc_ir import (
|
|
14
|
+
DocumentationSiteGraph,
|
|
15
|
+
DocumentationPage,
|
|
16
|
+
NavigationNode,
|
|
17
|
+
DocumentFlowSpec,
|
|
18
|
+
ApiDocumentationRequest,
|
|
19
|
+
DocumentElementType,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
ProjectAnalysisReport,
|
|
23
|
+
SubsystemSummary,
|
|
24
|
+
ConstructFinding,
|
|
25
|
+
Classification,
|
|
26
|
+
ResolvedEffectiveConfig,
|
|
27
|
+
ResolvedProperty,
|
|
28
|
+
PropertyResolutionState,
|
|
29
|
+
ResolutionProvenance,
|
|
30
|
+
ResolvedApiModule,
|
|
31
|
+
ResolvedApiSymbol,
|
|
32
|
+
ResolvedCapabilityItem,
|
|
33
|
+
ResolvedUnresolvedItem,
|
|
34
|
+
MigrationRequirement,
|
|
35
|
+
RequirementCategory,
|
|
36
|
+
RequirementDisposition,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectAnalyzer:
|
|
41
|
+
"""Orchestrates comprehensive factual inspection, effective configuration, and migration requirement derivation."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, project_root: Path):
|
|
44
|
+
self.project_root = project_root
|
|
45
|
+
self.config_analyzer = MkDocsConfigAnalyzer(project_root)
|
|
46
|
+
self.dep_analyzer = DependencyAnalyzer(project_root)
|
|
47
|
+
self.nav_analyzer = NavigationAnalyzer(project_root)
|
|
48
|
+
self.ci_analyzer = CIAnalyzer(project_root)
|
|
49
|
+
self.markdown_analyzer = MarkdownAnalyzer(project_root)
|
|
50
|
+
self.markdown_parser = self.markdown_analyzer.parser
|
|
51
|
+
self.flow_extractor = DocumentFlowExtractor()
|
|
52
|
+
|
|
53
|
+
def analyze(self) -> ProjectAnalysisReport:
|
|
54
|
+
config = self.config_analyzer.analyze()
|
|
55
|
+
deps, version_env = self.dep_analyzer.analyze()
|
|
56
|
+
ci = self.ci_analyzer.analyze()
|
|
57
|
+
|
|
58
|
+
# If project has no mkdocs.yml, fallback site_name from folder name
|
|
59
|
+
if not config.site_name:
|
|
60
|
+
config.site_name = self.project_root.name.replace("-", " ").title()
|
|
61
|
+
|
|
62
|
+
# Discover all Markdown files in docs_dir
|
|
63
|
+
docs_dir = self.project_root / config.docs_dir
|
|
64
|
+
md_files: List[Path] = []
|
|
65
|
+
if docs_dir.exists() and docs_dir.is_dir():
|
|
66
|
+
md_files = sorted(list(docs_dir.rglob("*.md")))
|
|
67
|
+
elif (self.project_root / "docs").exists():
|
|
68
|
+
md_files = sorted(list((self.project_root / "docs").rglob("*.md")))
|
|
69
|
+
else:
|
|
70
|
+
md_files = sorted(list(self.project_root.glob("*.md")))
|
|
71
|
+
|
|
72
|
+
# Navigation Analysis
|
|
73
|
+
nav = self.nav_analyzer.analyze(config.nav_raw, md_files, docs_dir)
|
|
74
|
+
|
|
75
|
+
# Markdown AST Construct Scanning
|
|
76
|
+
findings: List[ConstructFinding] = []
|
|
77
|
+
for md_file in md_files:
|
|
78
|
+
file_findings = self.markdown_analyzer.analyze_file(md_file)
|
|
79
|
+
findings.extend(file_findings)
|
|
80
|
+
|
|
81
|
+
# 1. Effective Config Resolution
|
|
82
|
+
effective_cfg = self._resolve_effective_config(config)
|
|
83
|
+
|
|
84
|
+
# 2. Extract DocumentFlows in exact authored sequence
|
|
85
|
+
flows: Dict[str, DocumentFlowSpec] = {}
|
|
86
|
+
api_requests: List[ApiDocumentationRequest] = []
|
|
87
|
+
site_pages: Dict[str, DocumentationPage] = {}
|
|
88
|
+
|
|
89
|
+
md_exts = [
|
|
90
|
+
ext if isinstance(ext, str) else getattr(ext, "name", str(ext))
|
|
91
|
+
for ext in (config.markdown_extensions or [])
|
|
92
|
+
]
|
|
93
|
+
self.flow_extractor.mkdocs_config = {"markdown_extensions": md_exts}
|
|
94
|
+
|
|
95
|
+
for md_file in md_files:
|
|
96
|
+
rel_path = md_file.relative_to(self.project_root).as_posix()
|
|
97
|
+
page = self.flow_extractor.extract_from_file(md_file, rel_path=rel_path)
|
|
98
|
+
flows[rel_path] = page.flow
|
|
99
|
+
|
|
100
|
+
for elem in page.flow.elements:
|
|
101
|
+
if elem.element_type == DocumentElementType.API_REQUEST and isinstance(
|
|
102
|
+
elem.content, ApiDocumentationRequest
|
|
103
|
+
):
|
|
104
|
+
api_requests.append(elem.content)
|
|
105
|
+
|
|
106
|
+
logical_route = (
|
|
107
|
+
md_file.relative_to(docs_dir).with_suffix("").as_posix()
|
|
108
|
+
if md_file.is_relative_to(docs_dir)
|
|
109
|
+
else md_file.stem
|
|
110
|
+
)
|
|
111
|
+
site_pages[logical_route] = page
|
|
112
|
+
|
|
113
|
+
# 3. Resolve API Objects (Symbol extraction from Python AST)
|
|
114
|
+
resolved_modules = self._resolve_api_symbols(api_requests)
|
|
115
|
+
|
|
116
|
+
# 4. Construct Site Graph
|
|
117
|
+
nav_node = None
|
|
118
|
+
if nav and nav.has_nav:
|
|
119
|
+
nav_node = self._build_nav_node(nav.tree)
|
|
120
|
+
|
|
121
|
+
site_graph = DocumentationSiteGraph(pages=site_pages, navigation=nav_node)
|
|
122
|
+
|
|
123
|
+
# 5. Extract Capabilities and Unresolved Items
|
|
124
|
+
capabilities, unresolved = self._resolve_capabilities(config)
|
|
125
|
+
|
|
126
|
+
# 6. Derive Migration Requirements
|
|
127
|
+
requirements = self._derive_migration_requirements(
|
|
128
|
+
config, flows, api_requests, resolved_modules, capabilities
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# 7. Subsystem Summaries
|
|
132
|
+
summaries: List[SubsystemSummary] = []
|
|
133
|
+
transform_cnt = sum(
|
|
134
|
+
1 for f in findings if f.classification == Classification.TRANSFORM
|
|
135
|
+
)
|
|
136
|
+
summaries.append(
|
|
137
|
+
SubsystemSummary(
|
|
138
|
+
name="Markdown Content",
|
|
139
|
+
status="AUTOMATIC" if transform_cnt > 0 else "PRESERVED",
|
|
140
|
+
details=f"{len(md_files)} files, {transform_cnt} construct transformations identified",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if nav.has_nav:
|
|
145
|
+
nav_status = "AUTOMATIC" if not nav.missing_references else "REVIEW"
|
|
146
|
+
summaries.append(
|
|
147
|
+
SubsystemSummary(
|
|
148
|
+
name="Navigation (nav)",
|
|
149
|
+
status=nav_status,
|
|
150
|
+
details=f"{nav.total_nav_entries} entries, depth {nav.max_depth}",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if deps and deps.manifest_type:
|
|
155
|
+
summaries.append(
|
|
156
|
+
SubsystemSummary(
|
|
157
|
+
name="Dependencies",
|
|
158
|
+
status="REVIEW",
|
|
159
|
+
details=f"{len(deps.detected_packages_to_remove)} packages to remove, {len(deps.suggested_packages_to_add)} to add",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if ci.ci_system:
|
|
164
|
+
summaries.append(
|
|
165
|
+
SubsystemSummary(
|
|
166
|
+
name="CI/CD & Hosting",
|
|
167
|
+
status="REVIEW",
|
|
168
|
+
details=f"{ci.ci_system} ({len(ci.workflow_files)} workflows)",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
manual_items: List[str] = []
|
|
173
|
+
for f in findings:
|
|
174
|
+
if f.classification == Classification.MANUAL:
|
|
175
|
+
manual_items.append(
|
|
176
|
+
f"{f.file_path}:{f.line_number} ({f.construct_type}) - Manual review required"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return ProjectAnalysisReport(
|
|
180
|
+
project_root=str(self.project_root),
|
|
181
|
+
mkdocs_config=config,
|
|
182
|
+
effective_config=effective_cfg,
|
|
183
|
+
version_env=version_env,
|
|
184
|
+
dependency_analysis=deps,
|
|
185
|
+
navigation_analysis=nav,
|
|
186
|
+
ci_analysis=ci,
|
|
187
|
+
total_markdown_files=len(md_files),
|
|
188
|
+
construct_findings=findings,
|
|
189
|
+
document_flows=flows,
|
|
190
|
+
documentation_site_graph=site_graph,
|
|
191
|
+
build_graph=None,
|
|
192
|
+
api_requests=api_requests,
|
|
193
|
+
resolved_api_modules=resolved_modules,
|
|
194
|
+
capabilities=capabilities,
|
|
195
|
+
migration_requirements=requirements,
|
|
196
|
+
unresolved_items=unresolved,
|
|
197
|
+
subsystem_summaries=summaries,
|
|
198
|
+
manual_action_items=manual_items,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def _resolve_effective_config(self, cfg) -> ResolvedEffectiveConfig:
|
|
202
|
+
props: Dict[str, ResolvedProperty] = {}
|
|
203
|
+
if cfg:
|
|
204
|
+
raw_keys = set(getattr(cfg, "raw_config_keys", []))
|
|
205
|
+
|
|
206
|
+
# 1. Theme Name
|
|
207
|
+
theme_configured = "theme" in raw_keys and cfg.theme_name is not None
|
|
208
|
+
props["theme.name"] = ResolvedProperty(
|
|
209
|
+
key="theme.name",
|
|
210
|
+
configured_value=cfg.theme_name if theme_configured else None,
|
|
211
|
+
default_value="mkdocs",
|
|
212
|
+
effective_value=cfg.theme_name or "mkdocs",
|
|
213
|
+
state=PropertyResolutionState.CONFIGURED
|
|
214
|
+
if theme_configured
|
|
215
|
+
else PropertyResolutionState.DEFAULTED,
|
|
216
|
+
provenance=ResolutionProvenance(
|
|
217
|
+
source_type="mkdocs.yml" if theme_configured else "mkdocs_default"
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# 2. Docs Dir
|
|
222
|
+
docs_dir_configured = "docs_dir" in raw_keys
|
|
223
|
+
props["docs_dir"] = ResolvedProperty(
|
|
224
|
+
key="docs_dir",
|
|
225
|
+
configured_value=cfg.docs_dir if docs_dir_configured else None,
|
|
226
|
+
default_value="docs",
|
|
227
|
+
effective_value=cfg.docs_dir or "docs",
|
|
228
|
+
state=PropertyResolutionState.CONFIGURED
|
|
229
|
+
if docs_dir_configured
|
|
230
|
+
else PropertyResolutionState.DEFAULTED,
|
|
231
|
+
provenance=ResolutionProvenance(
|
|
232
|
+
source_type="mkdocs.yml"
|
|
233
|
+
if docs_dir_configured
|
|
234
|
+
else "mkdocs_default"
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# 3. Site Name & Metadata
|
|
239
|
+
site_name_configured = "site_name" in raw_keys
|
|
240
|
+
props["site_name"] = ResolvedProperty(
|
|
241
|
+
key="site_name",
|
|
242
|
+
configured_value=cfg.site_name if site_name_configured else None,
|
|
243
|
+
default_value=None,
|
|
244
|
+
effective_value=cfg.site_name,
|
|
245
|
+
state=PropertyResolutionState.CONFIGURED
|
|
246
|
+
if site_name_configured
|
|
247
|
+
else PropertyResolutionState.DEFAULTED,
|
|
248
|
+
provenance=ResolutionProvenance(
|
|
249
|
+
source_type="mkdocs.yml" if site_name_configured else "derived"
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if cfg.site_url:
|
|
254
|
+
props["site_url"] = ResolvedProperty(
|
|
255
|
+
key="site_url",
|
|
256
|
+
configured_value=cfg.site_url,
|
|
257
|
+
default_value=None,
|
|
258
|
+
effective_value=cfg.site_url,
|
|
259
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
260
|
+
provenance=ResolutionProvenance(source_type="mkdocs.yml"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if cfg.repo_url:
|
|
264
|
+
props["repo_url"] = ResolvedProperty(
|
|
265
|
+
key="repo_url",
|
|
266
|
+
configured_value=cfg.repo_url,
|
|
267
|
+
default_value=None,
|
|
268
|
+
effective_value=cfg.repo_url,
|
|
269
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
270
|
+
provenance=ResolutionProvenance(source_type="mkdocs.yml"),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# 4. Theme Features
|
|
274
|
+
for feat in cfg.theme_features:
|
|
275
|
+
props[f"theme.features.{feat}"] = ResolvedProperty(
|
|
276
|
+
key=f"theme.features.{feat}",
|
|
277
|
+
configured_value=True,
|
|
278
|
+
default_value=False,
|
|
279
|
+
effective_value=True,
|
|
280
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
281
|
+
provenance=ResolutionProvenance(
|
|
282
|
+
source_type="mkdocs.yml", source_location="theme.features"
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# 5. Theme Palette
|
|
287
|
+
if cfg.theme_palette:
|
|
288
|
+
props["theme.palette"] = ResolvedProperty(
|
|
289
|
+
key="theme.palette",
|
|
290
|
+
configured_value=[p.model_dump() for p in cfg.theme_palette],
|
|
291
|
+
default_value=[],
|
|
292
|
+
effective_value=[p.model_dump() for p in cfg.theme_palette],
|
|
293
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
294
|
+
provenance=ResolutionProvenance(
|
|
295
|
+
source_type="mkdocs.yml", source_location="theme.palette"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# 6. Theme Icon & Logo
|
|
300
|
+
if cfg.theme_icon:
|
|
301
|
+
props["theme.icon"] = ResolvedProperty(
|
|
302
|
+
key="theme.icon",
|
|
303
|
+
configured_value=cfg.theme_icon,
|
|
304
|
+
default_value=None,
|
|
305
|
+
effective_value=cfg.theme_icon,
|
|
306
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
307
|
+
provenance=ResolutionProvenance(
|
|
308
|
+
source_type="mkdocs.yml", source_location="theme.icon"
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return ResolvedEffectiveConfig(properties=props)
|
|
313
|
+
|
|
314
|
+
def _resolve_api_symbols(
|
|
315
|
+
self, requests: List[ApiDocumentationRequest]
|
|
316
|
+
) -> Dict[str, ResolvedApiModule]:
|
|
317
|
+
modules: Dict[str, ResolvedApiModule] = {}
|
|
318
|
+
for req in requests:
|
|
319
|
+
mod_path = req.object_path
|
|
320
|
+
src_file = self._find_module_source_file(mod_path)
|
|
321
|
+
symbols = self._extract_symbols_from_ast(src_file) if src_file else {}
|
|
322
|
+
modules[mod_path] = ResolvedApiModule(
|
|
323
|
+
module_path=mod_path,
|
|
324
|
+
symbols=symbols,
|
|
325
|
+
summary_mode=req.summary_mode.value,
|
|
326
|
+
explicit_members=req.explicit_members,
|
|
327
|
+
)
|
|
328
|
+
return modules
|
|
329
|
+
|
|
330
|
+
def _find_module_source_file(self, module_path: str) -> Optional[Path]:
|
|
331
|
+
parts = module_path.split(".")
|
|
332
|
+
candidates = [
|
|
333
|
+
self.project_root
|
|
334
|
+
/ "src"
|
|
335
|
+
/ "/".join(parts).replace("/", Path("/").name + ".py"),
|
|
336
|
+
self.project_root / "/".join(parts).replace("/", Path("/").name + ".py"),
|
|
337
|
+
self.project_root / "src" / Path(*parts).with_suffix(".py"),
|
|
338
|
+
self.project_root / Path(*parts).with_suffix(".py"),
|
|
339
|
+
]
|
|
340
|
+
for c in candidates:
|
|
341
|
+
if c.exists() and c.is_file():
|
|
342
|
+
return c
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
def _extract_symbols_from_ast(
|
|
346
|
+
self, file_path: Path
|
|
347
|
+
) -> Dict[str, ResolvedApiSymbol]:
|
|
348
|
+
symbols: Dict[str, ResolvedApiSymbol] = {}
|
|
349
|
+
try:
|
|
350
|
+
tree = ast.parse(
|
|
351
|
+
file_path.read_text(encoding="utf-8"), filename=str(file_path)
|
|
352
|
+
)
|
|
353
|
+
for node in tree.body:
|
|
354
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
355
|
+
# Extract formatted signature from arguments
|
|
356
|
+
args = [a.arg for a in node.args.args]
|
|
357
|
+
sig = f"{node.name}({', '.join(args)})"
|
|
358
|
+
symbols[node.name] = ResolvedApiSymbol(
|
|
359
|
+
fully_qualified_name=node.name,
|
|
360
|
+
symbol_kind="function",
|
|
361
|
+
signature=sig,
|
|
362
|
+
docstring=ast.get_docstring(node),
|
|
363
|
+
source_file=str(file_path),
|
|
364
|
+
source_line=node.lineno,
|
|
365
|
+
)
|
|
366
|
+
elif isinstance(node, ast.ClassDef):
|
|
367
|
+
method_names = [
|
|
368
|
+
n.name
|
|
369
|
+
for n in node.body
|
|
370
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
371
|
+
]
|
|
372
|
+
symbols[node.name] = ResolvedApiSymbol(
|
|
373
|
+
fully_qualified_name=node.name,
|
|
374
|
+
symbol_kind="class",
|
|
375
|
+
signature=f"class {node.name}",
|
|
376
|
+
docstring=ast.get_docstring(node),
|
|
377
|
+
source_file=str(file_path),
|
|
378
|
+
source_line=node.lineno,
|
|
379
|
+
member_names=method_names,
|
|
380
|
+
)
|
|
381
|
+
elif isinstance(node, ast.Assign):
|
|
382
|
+
for target in node.targets:
|
|
383
|
+
if isinstance(target, ast.Name):
|
|
384
|
+
symbols[target.id] = ResolvedApiSymbol(
|
|
385
|
+
fully_qualified_name=target.id,
|
|
386
|
+
symbol_kind="attribute",
|
|
387
|
+
signature=target.id,
|
|
388
|
+
docstring=None,
|
|
389
|
+
source_file=str(file_path),
|
|
390
|
+
source_line=node.lineno,
|
|
391
|
+
)
|
|
392
|
+
elif isinstance(node, ast.AnnAssign):
|
|
393
|
+
if isinstance(node.target, ast.Name):
|
|
394
|
+
symbols[node.target.id] = ResolvedApiSymbol(
|
|
395
|
+
fully_qualified_name=node.target.id,
|
|
396
|
+
symbol_kind="attribute",
|
|
397
|
+
signature=node.target.id,
|
|
398
|
+
docstring=None,
|
|
399
|
+
source_file=str(file_path),
|
|
400
|
+
source_line=node.lineno,
|
|
401
|
+
)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
return symbols
|
|
405
|
+
|
|
406
|
+
def _build_nav_node(self, items: List[Any]) -> Optional[NavigationNode]:
|
|
407
|
+
if not items:
|
|
408
|
+
return None
|
|
409
|
+
root = NavigationNode(construct_id="nav_root", label="Root")
|
|
410
|
+
for i, item in enumerate(items):
|
|
411
|
+
child = NavigationNode(
|
|
412
|
+
construct_id=f"nav_node_{i}",
|
|
413
|
+
label=getattr(item, "title", "Page"),
|
|
414
|
+
page_route=getattr(item, "path", None),
|
|
415
|
+
)
|
|
416
|
+
root.children.append(child)
|
|
417
|
+
return root
|
|
418
|
+
|
|
419
|
+
def _resolve_capabilities(
|
|
420
|
+
self, cfg
|
|
421
|
+
) -> tuple[List[ResolvedCapabilityItem], List[ResolvedUnresolvedItem]]:
|
|
422
|
+
caps: List[ResolvedCapabilityItem] = []
|
|
423
|
+
unresolved: List[ResolvedUnresolvedItem] = []
|
|
424
|
+
|
|
425
|
+
if cfg:
|
|
426
|
+
for p in cfg.plugins:
|
|
427
|
+
p_name = (
|
|
428
|
+
p
|
|
429
|
+
if isinstance(p, str)
|
|
430
|
+
else list(p.keys())[0]
|
|
431
|
+
if isinstance(p, dict)
|
|
432
|
+
else str(p)
|
|
433
|
+
)
|
|
434
|
+
if p_name in ("search", "mkdocstrings", "autorefs"):
|
|
435
|
+
caps.append(
|
|
436
|
+
ResolvedCapabilityItem(
|
|
437
|
+
capability_name=f"plugin:{p_name}",
|
|
438
|
+
source_feature=p_name,
|
|
439
|
+
state=PropertyResolutionState.CONFIGURED,
|
|
440
|
+
target_strategy="SPHINX_EXTENSION_MAPPING",
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
unresolved.append(
|
|
445
|
+
ResolvedUnresolvedItem(
|
|
446
|
+
item_id=f"plugin:{p_name}",
|
|
447
|
+
category="plugin_option",
|
|
448
|
+
rationale=f"Third-party plugin '{p_name}' requires explicit capability policy mapping.",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return caps, unresolved
|
|
452
|
+
|
|
453
|
+
def _derive_migration_requirements(
|
|
454
|
+
self,
|
|
455
|
+
cfg,
|
|
456
|
+
flows: Dict[str, DocumentFlowSpec],
|
|
457
|
+
api_requests: List[ApiDocumentationRequest],
|
|
458
|
+
resolved_modules: Dict[str, ResolvedApiModule],
|
|
459
|
+
capabilities: List[ResolvedCapabilityItem],
|
|
460
|
+
) -> List[MigrationRequirement]:
|
|
461
|
+
reqs: List[MigrationRequirement] = []
|
|
462
|
+
|
|
463
|
+
# 1. Document Flow Authored-Sequence Requirement
|
|
464
|
+
for file_path, flow in flows.items():
|
|
465
|
+
reqs.append(
|
|
466
|
+
MigrationRequirement(
|
|
467
|
+
requirement_id=f"flow:{file_path}",
|
|
468
|
+
category=RequirementCategory.FLOW,
|
|
469
|
+
disposition=RequirementDisposition.PRESERVE,
|
|
470
|
+
source_construct=f"DocumentFlow({file_path})",
|
|
471
|
+
required_outcome="PRESERVE_AUTHORED_ELEMENT_SEQUENCE",
|
|
472
|
+
rationale="Preserve exact authored element ordering (prose before API summary/members).",
|
|
473
|
+
provenance_location=file_path,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# 2. 1:1 API Symbol Parity Requirement
|
|
478
|
+
for mod_path, mod_obj in resolved_modules.items():
|
|
479
|
+
reqs.append(
|
|
480
|
+
MigrationRequirement(
|
|
481
|
+
requirement_id=f"api:{mod_path}",
|
|
482
|
+
category=RequirementCategory.API,
|
|
483
|
+
disposition=RequirementDisposition.TRANSFORM,
|
|
484
|
+
source_construct=f"ApiModule({mod_path})",
|
|
485
|
+
required_outcome="PRESERVE_API_MODULE_DOCUMENTATION",
|
|
486
|
+
rationale=f"Represent all {len(mod_obj.symbols)} resolved symbols with 1:1 API identity.",
|
|
487
|
+
provenance_location=mod_path,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# 3. Theme Features Requirements
|
|
492
|
+
if cfg:
|
|
493
|
+
for feat in cfg.theme_features:
|
|
494
|
+
disposition = (
|
|
495
|
+
RequirementDisposition.ACCOUNT_NO_DIRECT_EQUIVALENT
|
|
496
|
+
if feat in ("navigation.instant", "navigation.top", "toc.follow")
|
|
497
|
+
else RequirementDisposition.TRANSFORM
|
|
498
|
+
)
|
|
499
|
+
reqs.append(
|
|
500
|
+
MigrationRequirement(
|
|
501
|
+
requirement_id=f"theme_feature:{feat}",
|
|
502
|
+
category=RequirementCategory.THEME_FEATURE,
|
|
503
|
+
disposition=disposition,
|
|
504
|
+
source_construct=f"theme.features.{feat}",
|
|
505
|
+
required_outcome="REALIZE_THEME_BEHAVIOR",
|
|
506
|
+
rationale=f"Realize theme feature {feat} via equivalent theme mechanism or account as chrome-only.",
|
|
507
|
+
provenance_location="mkdocs.yml:theme.features",
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return reqs
|