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.
Files changed (39) hide show
  1. sphinx_mkdocs_migrate/__init__.py +8 -0
  2. sphinx_mkdocs_migrate/analyzer/__init__.py +17 -0
  3. sphinx_mkdocs_migrate/analyzer/ci.py +223 -0
  4. sphinx_mkdocs_migrate/analyzer/dependencies.py +134 -0
  5. sphinx_mkdocs_migrate/analyzer/markdown.py +148 -0
  6. sphinx_mkdocs_migrate/analyzer/mkdocs.py +263 -0
  7. sphinx_mkdocs_migrate/analyzer/models.py +348 -0
  8. sphinx_mkdocs_migrate/analyzer/navigation.py +108 -0
  9. sphinx_mkdocs_migrate/analyzer/project.py +511 -0
  10. sphinx_mkdocs_migrate/cli.py +507 -0
  11. sphinx_mkdocs_migrate/parsing/doc_ir.py +533 -0
  12. sphinx_mkdocs_migrate/parsing/flow_extractor.py +457 -0
  13. sphinx_mkdocs_migrate/parsing/html_flow_parser.py +349 -0
  14. sphinx_mkdocs_migrate/parsing/markdown.py +22 -0
  15. sphinx_mkdocs_migrate/parsing/markdown_ir.py +49 -0
  16. sphinx_mkdocs_migrate/parsing/markdown_it_adapter.py +496 -0
  17. sphinx_mkdocs_migrate/parsing/requirements.py +155 -0
  18. sphinx_mkdocs_migrate/planner/accountability.py +111 -0
  19. sphinx_mkdocs_migrate/planner/ci.py +142 -0
  20. sphinx_mkdocs_migrate/planner/conf_builder.py +183 -0
  21. sphinx_mkdocs_migrate/planner/models.py +379 -0
  22. sphinx_mkdocs_migrate/planner/planner.py +1867 -0
  23. sphinx_mkdocs_migrate/planner/policy.py +474 -0
  24. sphinx_mkdocs_migrate/planner/theme_constants.py +70 -0
  25. sphinx_mkdocs_migrate/planner/toctree.py +158 -0
  26. sphinx_mkdocs_migrate/py.typed +1 -0
  27. sphinx_mkdocs_migrate/rules/catalog.py +154 -0
  28. sphinx_mkdocs_migrate/rules/engine.py +94 -0
  29. sphinx_mkdocs_migrate/rules/models.py +176 -0
  30. sphinx_mkdocs_migrate/transformer/engine.py +897 -0
  31. sphinx_mkdocs_migrate/transformer/models.py +59 -0
  32. sphinx_mkdocs_migrate/transformer/myst_transformer.py +393 -0
  33. sphinx_mkdocs_migrate/validator/models.py +40 -0
  34. sphinx_mkdocs_migrate/validator/verifier.py +377 -0
  35. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/METADATA +199 -0
  36. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/RECORD +39 -0
  37. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/WHEEL +4 -0
  38. sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/entry_points.txt +2 -0
  39. 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