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,1867 @@
1
+ """Migration Planner synthesizing findings, declarative rules, policy engine, and provenance into a deterministic MigrationPlan."""
2
+
3
+ import ast
4
+ import datetime
5
+ import html
6
+ import re
7
+ from pathlib import Path
8
+ from typing import List, Dict, Any, Optional, Set
9
+ from ..analyzer.models import Classification
10
+ from .models import (
11
+ MigrationPlan,
12
+ MigrationPlanMetadata,
13
+ PlanActionSummary,
14
+ RequirementProvenance,
15
+ RequirementItem,
16
+ ThemeMigrationProposal,
17
+ ConfigMigrationProposal,
18
+ ManualReviewItem,
19
+ GeneratedDocumentProposal,
20
+ DocumentationPlan,
21
+ DocumentationArtifact,
22
+ DocumentFlowAction,
23
+ ArtifactProvenance,
24
+ ApiGenerationStrategy,
25
+ ApiDirectiveKind,
26
+ NavigationPlan,
27
+ GeneratedPipelinePlan,
28
+ CrossReferenceAction,
29
+ AssetAction,
30
+ ExternalInventoryConfig,
31
+ VersioningDeploymentPlan,
32
+ SystemCapabilityAccountability,
33
+ CapabilityDisposition,
34
+ ImplementationStrategy,
35
+ VerificationStatus,
36
+ )
37
+ from .conf_builder import build_conf_py
38
+ from .toctree import resolve_navigation_docnames
39
+ from .ci import plan_ci_workflow
40
+ from .policy import PolicyEngine
41
+ from ..analyzer.mkdocs import detect_obsolete_generator_scripts
42
+ from ..analyzer.project import ProjectAnalyzer
43
+ from ..rules.engine import MigrationRuleEngine
44
+ from ..rules.models import MigrationAction
45
+ from ..parsing.markdown import MarkdownParser
46
+ from ..parsing.flow_extractor import DocumentFlowExtractor
47
+ from ..parsing.html_flow_parser import HtmlFlowParser, HtmlFlowRole
48
+ from ..parsing.doc_ir import (
49
+ DocumentElementType,
50
+ HeadingElement,
51
+ ParagraphElement,
52
+ ListElement,
53
+ CodeBlockElement,
54
+ AdmonitionElement,
55
+ SnippetElement,
56
+ ApiDocumentationRequest,
57
+ DocumentationPage,
58
+ )
59
+
60
+
61
+ class MigrationPlanner:
62
+ """Orchestrates deterministic planning from ProjectAnalysisReport, FeaturePolicyCatalog, and MigrationRuleEngine."""
63
+
64
+ def __init__(self, project_root: Path):
65
+ self.project_root = project_root
66
+ self.analyzer = ProjectAnalyzer(project_root)
67
+ self.parser = MarkdownParser()
68
+ self.rule_engine = MigrationRuleEngine()
69
+ self.policy_engine = PolicyEngine()
70
+ self.flow_extractor = DocumentFlowExtractor()
71
+
72
+ def create_plan(
73
+ self, deterministic_timestamp: Optional[str] = None
74
+ ) -> MigrationPlan:
75
+ """Generates a canonical, read-only MigrationPlan without mutating repository files."""
76
+ # 1. Analyze repository subsystems
77
+ report = self.analyzer.analyze()
78
+
79
+ # Pass MkDocs config to flow extractor if available
80
+ mkdocs_dict = {}
81
+ if report.mkdocs_config:
82
+ mkdocs_dict["markdown_extensions"] = [
83
+ ext if isinstance(ext, str) else getattr(ext, "name", str(ext))
84
+ for ext in (report.mkdocs_config.markdown_extensions or [])
85
+ ]
86
+ self.flow_extractor.mkdocs_config = mkdocs_dict
87
+
88
+ # 2. Extract DocumentIRs, DocumentationPages, and evaluate declarative rules per file
89
+ all_actions: List[MigrationAction] = []
90
+ action_counter = 1
91
+
92
+ # Track detected extensions from markdown inspection
93
+ detected_markdown_extensions: Set[str] = set()
94
+ for c in report.construct_findings:
95
+ if c.construct_type == "tab_group":
96
+ detected_markdown_extensions.add("pymdownx.tabbed")
97
+ elif c.construct_type == "admonition":
98
+ detected_markdown_extensions.add("admonition")
99
+ elif c.construct_type == "details":
100
+ detected_markdown_extensions.add("pymdownx.details")
101
+ elif c.construct_type == "snippet":
102
+ detected_markdown_extensions.add("pymdownx.snippets")
103
+ elif c.construct_type == "mermaid":
104
+ detected_markdown_extensions.add("pymdownx.superfences")
105
+
106
+ docs_dir_name = (
107
+ report.mkdocs_config.docs_dir if report.mkdocs_config else "docs"
108
+ )
109
+ docs_dir = self.project_root / docs_dir_name
110
+ md_files = sorted(list(docs_dir.rglob("*.md"))) if docs_dir.exists() else []
111
+
112
+ # Check MkDocs mkdocstrings inherited_members configuration
113
+ has_inherited_members = False
114
+ global_inherited_members_list: Optional[List[str]] = None
115
+ if report.mkdocs_config:
116
+ mkdocstrings_cfg = report.mkdocs_config.plugins_config.get(
117
+ "mkdocstrings", {}
118
+ )
119
+ if isinstance(mkdocstrings_cfg, dict):
120
+ py_opts = (
121
+ mkdocstrings_cfg.get("handlers", {})
122
+ .get("python", {})
123
+ .get("options", {})
124
+ )
125
+ if not isinstance(py_opts, dict):
126
+ py_opts = mkdocstrings_cfg.get("options", {})
127
+ if isinstance(py_opts, dict) and "inherited_members" in py_opts:
128
+ val = py_opts["inherited_members"]
129
+ if isinstance(val, list):
130
+ has_inherited_members = True
131
+ global_inherited_members_list = [str(x) for x in val]
132
+ else:
133
+ has_inherited_members = bool(val)
134
+
135
+ # Statically analyze all project class hierarchies using AST to distinguish
136
+ # internal base classes (which should have members documented) from external/stdlib base classes.
137
+ src_paths: List[Path] = []
138
+ if report.mkdocs_config:
139
+ mkdocstrings_cfg = report.mkdocs_config.plugins_config.get(
140
+ "mkdocstrings", {}
141
+ )
142
+ if isinstance(mkdocstrings_cfg, dict):
143
+ py_paths = (
144
+ mkdocstrings_cfg.get("handlers", {})
145
+ .get("python", {})
146
+ .get("paths", [])
147
+ )
148
+ for p in py_paths:
149
+ candidate = self.project_root / p
150
+ if candidate.exists() and candidate.is_dir():
151
+ src_paths.append(candidate)
152
+ if not src_paths:
153
+ default_src = self.project_root / "src"
154
+ if default_src.exists() and default_src.is_dir():
155
+ src_paths.append(default_src)
156
+ else:
157
+ src_paths.append(self.project_root)
158
+
159
+ class_bases: Dict[str, List[str]] = {}
160
+ for src_dir in src_paths:
161
+ for py_f in src_dir.rglob("*.py"):
162
+ if any(
163
+ p.startswith(".") or p in ("venv", ".tox", "site-packages")
164
+ for p in py_f.parts
165
+ ):
166
+ continue
167
+ try:
168
+ m_ast = ast.parse(
169
+ py_f.read_text(encoding="utf-8"), filename=str(py_f)
170
+ )
171
+ for n in m_ast.body:
172
+ if isinstance(n, ast.ClassDef) and not n.name.startswith("_"):
173
+ b_list = []
174
+ for b in n.bases:
175
+ if isinstance(b, ast.Name):
176
+ b_list.append(b.id)
177
+ elif isinstance(b, ast.Attribute):
178
+ b_list.append(b.attr)
179
+ class_bases[n.name] = b_list
180
+ except Exception:
181
+ pass
182
+
183
+ project_classes = set(class_bases.keys())
184
+
185
+ def get_ext_bases(cls_name: str, visited: Set[str]) -> Set[str]:
186
+ if cls_name in visited:
187
+ return set()
188
+ visited.add(cls_name)
189
+ ext = set()
190
+ for base in class_bases.get(cls_name, []):
191
+ leaf = base.split(".")[-1]
192
+ if leaf in project_classes:
193
+ ext.update(get_ext_bases(leaf, visited))
194
+ else:
195
+ ext.add(leaf)
196
+ return ext
197
+
198
+ class_external_bases: Dict[str, Set[str]] = {
199
+ c: get_ext_bases(c, set()) for c in class_bases
200
+ }
201
+
202
+ planned_pages: List[DocumentationArtifact] = []
203
+ planned_api_strategies: List[ApiGenerationStrategy] = []
204
+ planned_cross_refs: List[CrossReferenceAction] = []
205
+ planned_assets: List[AssetAction] = []
206
+ site_pages: Dict[str, DocumentationPage] = {}
207
+
208
+ for md_file in md_files:
209
+ rel_file = md_file.relative_to(self.project_root).as_posix()
210
+ raw_text = md_file.read_text(encoding="utf-8")
211
+ raw_lines = raw_text.splitlines()
212
+
213
+ doc_ir = self.parser.parse_text(raw_text, file_path=rel_file)
214
+ file_actions = self.rule_engine.create_actions(doc_ir, raw_lines=raw_lines)
215
+
216
+ # Ensure stable, ordered action IDs
217
+ for act in file_actions:
218
+ act.action_id = f"act_{action_counter:04d}"
219
+ action_counter += 1
220
+ all_actions.append(act)
221
+
222
+ # Flow extraction for DocumentFlowSpec
223
+ page_ir = self.flow_extractor.extract_from_text(
224
+ raw_text, file_path=rel_file
225
+ )
226
+ site_pages[page_ir.logical_route] = page_ir
227
+
228
+ # Build planned flow actions preserving source order
229
+ flow_actions: List[DocumentFlowAction] = []
230
+ page_construct_ids: List[str] = []
231
+ for elem in page_ir.flow.elements:
232
+ page_construct_ids.append(elem.construct_id)
233
+ summary_text = ""
234
+ strategy_name = "PRESERVE"
235
+ target_dir = None
236
+
237
+ if elem.element_type == DocumentElementType.HEADING and isinstance(
238
+ elem.content, HeadingElement
239
+ ):
240
+ summary_text = f"H{elem.content.level}: {elem.content.text}"
241
+ strategy_name = "TRANSFORM"
242
+ elif elem.element_type == DocumentElementType.PARAGRAPH and isinstance(
243
+ elem.content, ParagraphElement
244
+ ):
245
+ summary_text = elem.content.text[:50]
246
+ strategy_name = "TRANSFORM"
247
+ elif (
248
+ elem.element_type == DocumentElementType.API_REQUEST
249
+ and isinstance(elem.content, ApiDocumentationRequest)
250
+ ):
251
+ api_req = elem.content
252
+ summary_text = f"API Request: {api_req.object_path}"
253
+ strategy_name = "AUTODOC"
254
+
255
+ if api_req.object_kind.value == "module":
256
+ kind = ApiDirectiveKind.AUTOMODULE
257
+ directive_name = "automodule"
258
+ elif api_req.object_kind.value == "function":
259
+ kind = ApiDirectiveKind.AUTOFUNCTION
260
+ directive_name = "autofunction"
261
+ elif api_req.object_kind.value == "exception":
262
+ kind = ApiDirectiveKind.AUTOEXCEPTION
263
+ directive_name = "autoexception"
264
+ else:
265
+ kind = ApiDirectiveKind.AUTOCLASS
266
+ directive_name = "autoclass"
267
+
268
+ dir_lines = [f".. {directive_name}:: {api_req.object_path}"]
269
+ if (
270
+ api_req.member_selection.value == "EXPLICIT"
271
+ and api_req.explicit_members
272
+ ):
273
+ dir_lines.append(
274
+ f" :members: {', '.join(api_req.explicit_members)}"
275
+ )
276
+ elif api_req.member_selection.value == "ALL_PUBLIC":
277
+ dir_lines.append(" :members:")
278
+
279
+ inh_prop_str = None
280
+ if api_req.object_kind.value in ("class", "exception"):
281
+ dir_lines.append(" :show-inheritance:")
282
+ raw_inh = api_req.normalized_options.get("inherited_members")
283
+ if raw_inh is None:
284
+ raw_inh = api_req.raw_options.get("inherited_members")
285
+
286
+ if raw_inh is not None:
287
+ should_inh = bool(raw_inh)
288
+ allowed_bases = (
289
+ raw_inh if isinstance(raw_inh, list) else None
290
+ )
291
+ else:
292
+ should_inh = has_inherited_members
293
+ allowed_bases = global_inherited_members_list
294
+
295
+ if should_inh:
296
+ cls_leaf = api_req.object_path.split(".")[-1]
297
+ ext_bases = set(class_external_bases.get(cls_leaf, set()))
298
+ if allowed_bases:
299
+ all_superclasses = (
300
+ set(class_bases.get(cls_leaf, [])) | ext_bases
301
+ )
302
+ excluded = (
303
+ all_superclasses - set(allowed_bases)
304
+ ) | ext_bases
305
+ if excluded:
306
+ inh_prop_str = ", ".join(sorted(excluded))
307
+ dir_lines.append(
308
+ f" :inherited-members: {inh_prop_str}"
309
+ )
310
+ else:
311
+ dir_lines.append(" :inherited-members:")
312
+ elif ext_bases:
313
+ inh_prop_str = ", ".join(sorted(ext_bases))
314
+ dir_lines.append(
315
+ f" :inherited-members: {inh_prop_str}"
316
+ )
317
+ else:
318
+ dir_lines.append(" :inherited-members:")
319
+
320
+ target_dir = "\n".join(dir_lines)
321
+
322
+ api_strat = ApiGenerationStrategy(
323
+ object_path=api_req.object_path,
324
+ directive_kind=kind,
325
+ options=api_req.normalized_options,
326
+ members=api_req.explicit_members
327
+ if api_req.explicit_members
328
+ else None,
329
+ inherited_members=inh_prop_str,
330
+ source_construct_id=elem.construct_id,
331
+ rationale=f"Synthesized from source {api_req.handler} API request for {api_req.object_path}.",
332
+ )
333
+ planned_api_strategies.append(api_strat)
334
+ elif elem.element_type == DocumentElementType.LIST and isinstance(
335
+ elem.content, ListElement
336
+ ):
337
+ summary_text = f"List ({len(elem.content.items)} items, {'ordered' if elem.content.ordered else 'unordered'})"
338
+ strategy_name = "PRESERVE"
339
+ elif elem.element_type == DocumentElementType.CODE_BLOCK and isinstance(
340
+ elem.content, CodeBlockElement
341
+ ):
342
+ summary_text = f"Code block ({elem.content.language or 'text'})"
343
+ strategy_name = "TRANSFORM"
344
+ elif elem.element_type == DocumentElementType.ADMONITION and isinstance(
345
+ elem.content, AdmonitionElement
346
+ ):
347
+ summary_text = f"Admonition ({elem.content.kind})"
348
+ strategy_name = "TRANSFORM"
349
+ elif elem.element_type == DocumentElementType.SNIPPET and isinstance(
350
+ elem.content, SnippetElement
351
+ ):
352
+ summary_text = f"Snippet include: {elem.content.snippet_path}"
353
+ strategy_name = "TRANSFORM"
354
+ target_dir = f"```{{include}} {elem.content.snippet_path}\n```"
355
+ else:
356
+ summary_text = elem.element_type.value
357
+ strategy_name = "PRESERVE"
358
+
359
+ flow_actions.append(
360
+ DocumentFlowAction(
361
+ action_id=f"flow_{elem.construct_id}",
362
+ order_index=elem.source_order_index,
363
+ source_construct_id=elem.construct_id,
364
+ element_type=elem.element_type.value,
365
+ strategy=strategy_name,
366
+ content_summary=summary_text,
367
+ target_directive=target_dir,
368
+ rationale=f"Realize source element {elem.construct_id} in target document.",
369
+ )
370
+ )
371
+
372
+ # Cross references and asset actions
373
+ for link in page_ir.outgoing_links:
374
+ planned_cross_refs.append(
375
+ CrossReferenceAction(
376
+ source_construct_id=link.source_construct_id,
377
+ source_file=rel_file,
378
+ source_target=link.target,
379
+ transformed_target=link.target.replace(".md", ".html")
380
+ if link.target.endswith(".md")
381
+ else link.target,
382
+ is_doc_ref=link.link_kind == "internal_page",
383
+ rationale="Transformed MkDocs relative doc link to Sphinx target reference.",
384
+ )
385
+ )
386
+
387
+ for asset in page_ir.referenced_assets:
388
+ planned_assets.append(
389
+ AssetAction(
390
+ source_construct_id=asset.source_construct_id,
391
+ source_path=asset.source_path,
392
+ target_path=f"_static/{Path(asset.source_path).name}",
393
+ asset_kind=asset.asset_kind,
394
+ rationale="Static asset copy to Sphinx _static directory.",
395
+ )
396
+ )
397
+
398
+ planned_pages.append(
399
+ DocumentationArtifact(
400
+ artifact_id=f"art_{page_ir.logical_route.replace('/', '_') or 'index'}",
401
+ target_path=rel_file,
402
+ source_file=rel_file,
403
+ title=page_ir.title or Path(rel_file).stem,
404
+ artifact_kind="markdown_doc",
405
+ provenance=RequirementProvenance.DOCUMENT_CONSTRUCT,
406
+ rationale="Transformed source markdown document preserving ordered semantic flow.",
407
+ flow_actions=flow_actions,
408
+ artifact_provenance=ArtifactProvenance(
409
+ source_construct_ids=page_construct_ids,
410
+ source_files=[rel_file],
411
+ required_extensions=[],
412
+ required_packages=[],
413
+ ),
414
+ )
415
+ )
416
+
417
+ # 3. Categorize Summary & Collect Requirements with Source Tracking
418
+ summary = PlanActionSummary(total_actions=len(all_actions))
419
+ manual_items: List[ManualReviewItem] = []
420
+ unsupported_items: List[MigrationAction] = []
421
+
422
+ ext_sources: Dict[str, List[str]] = {}
423
+ pkg_sources: Dict[str, List[str]] = {}
424
+ provenance_map: Dict[str, RequirementProvenance] = {}
425
+ rationale_map: Dict[str, str] = {}
426
+
427
+ for action in all_actions:
428
+ loc_str = f"{action.source_file}:{action.start_line}"
429
+ if action.classification == Classification.TRANSFORM:
430
+ summary.transform_count += 1
431
+ elif action.classification == Classification.PRESERVE:
432
+ summary.preserve_count += 1
433
+ elif action.classification == Classification.MANUAL:
434
+ summary.manual_count += 1
435
+ manual_items.append(
436
+ ManualReviewItem(
437
+ item_id=f"manual_{action.action_id}",
438
+ source_file=action.source_file,
439
+ line_number=action.start_line,
440
+ construct_type=action.source_kind.value,
441
+ instruction=action.manual_instruction
442
+ or "Manual review required.",
443
+ rationale=action.description,
444
+ )
445
+ )
446
+ elif action.classification == Classification.UNSUPPORTED:
447
+ summary.unsupported_count += 1
448
+ unsupported_items.append(action)
449
+
450
+ # Map extensions & packages from construct actions
451
+ for ext in action.required_extensions:
452
+ ext_sources.setdefault(ext, []).append(loc_str)
453
+ provenance_map[ext] = RequirementProvenance.DOCUMENT_CONSTRUCT
454
+ rationale_map[ext] = (
455
+ "Required by document construct(s) in project Markdown files."
456
+ )
457
+ for pkg in action.required_packages:
458
+ pkg_sources.setdefault(pkg, []).append(loc_str)
459
+ provenance_map[pkg] = RequirementProvenance.DOCUMENT_CONSTRUCT
460
+ rationale_map[pkg] = (
461
+ "Required by document construct(s) in project Markdown files."
462
+ )
463
+
464
+ # 4. Automatic Source Theme Identification & Sphinx Equivalent Mapping
465
+ source_theme = (
466
+ report.mkdocs_config.theme_name if report.mkdocs_config else "mkdocs"
467
+ )
468
+
469
+ # Declarative theme translation matrix
470
+ THEME_EQUIVALENTS: Dict[str, Dict[str, Any]] = {
471
+ "material": {
472
+ "target_theme": "sphinx_immaterial",
473
+ "target_package": "sphinx-immaterial>=0.11.0",
474
+ "target_extension": "sphinx_immaterial",
475
+ "rationale": "Source uses Material for MkDocs; maps automatically to sphinx_immaterial for exact header, palette, and navigation chrome fidelity.",
476
+ },
477
+ "readthedocs": {
478
+ "target_theme": "sphinx_rtd_theme",
479
+ "target_package": "sphinx-rtd-theme>=2.0.0",
480
+ "target_extension": None,
481
+ "rationale": "Source uses ReadTheDocs theme; maps automatically to sphinx_rtd_theme.",
482
+ },
483
+ "mkdocs": {
484
+ "target_theme": "sphinx_rtd_theme",
485
+ "target_package": "sphinx-rtd-theme>=2.0.0",
486
+ "target_extension": None,
487
+ "rationale": "Source uses standard mkdocs theme; maps automatically to sphinx_rtd_theme.",
488
+ },
489
+ }
490
+
491
+ matched_spec: Optional[Dict[str, Any]] = None
492
+ for k, spec in THEME_EQUIVALENTS.items():
493
+ if k in source_theme.lower():
494
+ matched_spec = spec
495
+ break
496
+
497
+ if matched_spec:
498
+ theme_proposal = ThemeMigrationProposal(
499
+ source_theme=source_theme,
500
+ target_theme=matched_spec["target_theme"],
501
+ target_package=matched_spec["target_package"],
502
+ classification=Classification.TRANSFORM,
503
+ rationale=matched_spec["rationale"],
504
+ )
505
+ pkg_sources.setdefault(matched_spec["target_package"], []).append(
506
+ f"theme_policy:{source_theme}"
507
+ )
508
+ provenance_map[matched_spec["target_package"]] = (
509
+ RequirementProvenance.THEME_POLICY
510
+ )
511
+ rationale_map[matched_spec["target_package"]] = theme_proposal.rationale
512
+
513
+ if matched_spec.get("target_extension"):
514
+ ext_sources.setdefault(matched_spec["target_extension"], []).append(
515
+ f"theme_policy:{source_theme}"
516
+ )
517
+ provenance_map[matched_spec["target_extension"]] = (
518
+ RequirementProvenance.THEME_POLICY
519
+ )
520
+ rationale_map[matched_spec["target_extension"]] = (
521
+ theme_proposal.rationale
522
+ )
523
+ else:
524
+ # Custom or unrecognized theme requires MANUAL decision
525
+ theme_proposal = ThemeMigrationProposal(
526
+ source_theme=source_theme,
527
+ target_theme=None,
528
+ target_package=None,
529
+ classification=Classification.MANUAL,
530
+ rationale=f"Custom/unrecognized theme '{source_theme}' has no automated Sphinx equivalent. Manual theme selection required.",
531
+ )
532
+ manual_items.append(
533
+ ManualReviewItem(
534
+ item_id="manual_theme_selection",
535
+ source_file="mkdocs.yml",
536
+ line_number=1,
537
+ construct_type="theme",
538
+ instruction=f"Select an appropriate Sphinx theme (e.g. Furo, PyData Sphinx Theme, or sphinx-rtd-theme) for '{source_theme}'.",
539
+ rationale=theme_proposal.rationale,
540
+ )
541
+ )
542
+
543
+ # 5. Declarative Policy Evaluation for Theme Features, Extensions, and Plugins
544
+ myst_enabled: Set[str] = {"colon_fence"}
545
+ custom_conf_options: Dict[str, Any] = {}
546
+
547
+ if report.mkdocs_config:
548
+ # 5a. Theme Features
549
+ for feat in report.mkdocs_config.theme_features or []:
550
+ rule = self.policy_engine.evaluate_feature(feat)
551
+ if rule:
552
+ for ext in rule.required_extensions:
553
+ ext_sources.setdefault(ext, []).append(f"theme_feature:{feat}")
554
+ provenance_map[ext] = RequirementProvenance.FEATURE_POLICY
555
+ rationale_map[ext] = rule.rationale
556
+ for pkg in rule.required_packages:
557
+ pkg_sources.setdefault(pkg, []).append(f"theme_feature:{feat}")
558
+ provenance_map[pkg] = RequirementProvenance.FEATURE_POLICY
559
+ rationale_map[pkg] = rule.rationale
560
+ for m_ext in rule.myst_extensions:
561
+ myst_enabled.add(m_ext)
562
+ if rule.conf_settings:
563
+ custom_conf_options.update(rule.conf_settings)
564
+ if rule.classification == Classification.MANUAL:
565
+ manual_items.append(
566
+ ManualReviewItem(
567
+ item_id=f"manual_feat_{feat.replace('.', '_')}",
568
+ source_file="mkdocs.yml",
569
+ line_number=1,
570
+ construct_type="theme_feature",
571
+ instruction=rule.manual_instruction
572
+ or "Manual review required.",
573
+ rationale=rule.rationale,
574
+ )
575
+ )
576
+
577
+ # 5b. Markdown Extensions
578
+ for ext_entry in report.mkdocs_config.markdown_extensions or []:
579
+ ext_name = (
580
+ ext_entry
581
+ if isinstance(ext_entry, str)
582
+ else getattr(ext_entry, "name", str(ext_entry))
583
+ )
584
+ rule = self.policy_engine.evaluate_feature(ext_name)
585
+ if rule:
586
+ for ext in rule.required_extensions:
587
+ ext_sources.setdefault(ext, []).append(
588
+ f"markdown_extension:{ext_name}"
589
+ )
590
+ provenance_map.setdefault(
591
+ ext, RequirementProvenance.EXTENSION_POLICY
592
+ )
593
+ rationale_map.setdefault(ext, rule.rationale)
594
+ for pkg in rule.required_packages:
595
+ pkg_sources.setdefault(pkg, []).append(
596
+ f"markdown_extension:{ext_name}"
597
+ )
598
+ provenance_map.setdefault(
599
+ pkg, RequirementProvenance.EXTENSION_POLICY
600
+ )
601
+ rationale_map.setdefault(pkg, rule.rationale)
602
+ for m_ext in rule.myst_extensions:
603
+ myst_enabled.add(m_ext)
604
+ if rule.conf_settings:
605
+ custom_conf_options.update(rule.conf_settings)
606
+ if rule.classification == Classification.MANUAL:
607
+ manual_items.append(
608
+ ManualReviewItem(
609
+ item_id=f"manual_ext_{ext_name.replace('.', '_')}",
610
+ source_file="mkdocs.yml",
611
+ line_number=1,
612
+ construct_type="markdown_extension",
613
+ instruction=rule.manual_instruction
614
+ or "Manual review required.",
615
+ rationale=rule.rationale,
616
+ )
617
+ )
618
+
619
+ # 5c. Plugins
620
+ for plg_entry in report.mkdocs_config.plugins or []:
621
+ plg_name = (
622
+ plg_entry
623
+ if isinstance(plg_entry, str)
624
+ else getattr(plg_entry, "name", str(plg_entry))
625
+ )
626
+ rule = self.policy_engine.evaluate_feature(plg_name)
627
+ if rule:
628
+ for ext in rule.required_extensions:
629
+ ext_sources.setdefault(ext, []).append(f"plugin:{plg_name}")
630
+ provenance_map.setdefault(
631
+ ext, RequirementProvenance.EXTENSION_POLICY
632
+ )
633
+ rationale_map.setdefault(ext, rule.rationale)
634
+ for pkg in rule.required_packages:
635
+ pkg_sources.setdefault(pkg, []).append(f"plugin:{plg_name}")
636
+ provenance_map.setdefault(
637
+ pkg, RequirementProvenance.EXTENSION_POLICY
638
+ )
639
+ rationale_map.setdefault(pkg, rule.rationale)
640
+ for m_ext in rule.myst_extensions:
641
+ myst_enabled.add(m_ext)
642
+ if rule.conf_settings:
643
+ custom_conf_options.update(rule.conf_settings)
644
+ if rule.classification == Classification.MANUAL:
645
+ manual_items.append(
646
+ ManualReviewItem(
647
+ item_id=f"manual_plugin_{plg_name.replace('.', '_')}",
648
+ source_file="mkdocs.yml",
649
+ line_number=1,
650
+ construct_type="plugin",
651
+ instruction=rule.manual_instruction
652
+ or "Manual review required.",
653
+ rationale=rule.rationale,
654
+ )
655
+ )
656
+
657
+ # Check in-document detected extensions
658
+ if "tasklist" in detected_markdown_extensions:
659
+ myst_enabled.add("tasklist")
660
+ if "attrs_block" in detected_markdown_extensions:
661
+ myst_enabled.add("attrs_block")
662
+
663
+ # 6. Generated Documentation Pipeline Planning (e.g. gen-files, mkdocstrings, literate-nav)
664
+ # In MkDocs, dynamic generator scripts (e.g. `scripts/gen_ref_nav.py`) run at build time
665
+ # to construct in-memory virtual markdown documents (`mkdocs_gen_files.open(...)`).
666
+ # Sphinx builds from permanent filesystem files. We statically inspect the generator
667
+ # scripts via AST to deduce the source directories, output prefix, and literate nav files,
668
+ # then synthesize concrete MyST reference stubs directly into the documentation tree.
669
+ # This replaces the need for build-time Python generation and allows the obsolete script
670
+ # to be deleted during post-migration cleanup.
671
+ generated_doc_proposals: List[GeneratedDocumentProposal] = []
672
+ api_reference_mappings: Dict[str, str] = {}
673
+ if report.mkdocs_config and "gen-files" in report.mkdocs_config.plugins:
674
+ gen_cfg = report.mkdocs_config.plugins_config.get("gen-files", {})
675
+ scripts = gen_cfg.get("scripts", [])
676
+
677
+ # Statically analyze declared generator scripts (e.g. scripts/gen_ref_nav.py)
678
+ script_meta: Dict[str, Any] = {
679
+ "source_dir": "src",
680
+ "output_dir": "reference",
681
+ "literate_nav": None,
682
+ "pattern": "::: {ident}",
683
+ }
684
+ for s_path_str in scripts:
685
+ s_file = self.project_root / s_path_str
686
+ if s_file.exists():
687
+ try:
688
+ s_tree = ast.parse(
689
+ s_file.read_text(encoding="utf-8"), filename=str(s_file)
690
+ )
691
+ for node in ast.walk(s_tree):
692
+ if isinstance(node, ast.Call):
693
+ if (
694
+ isinstance(node.func, ast.Name)
695
+ and node.func.id == "Path"
696
+ ):
697
+ for arg in node.args:
698
+ if isinstance(arg, ast.Constant) and isinstance(
699
+ arg.value, str
700
+ ):
701
+ if (self.project_root / arg.value).is_dir():
702
+ script_meta["source_dir"] = arg.value
703
+ elif arg.value in (
704
+ "reference",
705
+ "api",
706
+ "docs",
707
+ ):
708
+ script_meta["output_dir"] = arg.value
709
+ elif (
710
+ isinstance(node.func, ast.Attribute)
711
+ and node.func.attr == "open"
712
+ ):
713
+ for arg in node.args:
714
+ if isinstance(arg, ast.Constant) and isinstance(
715
+ arg.value, str
716
+ ):
717
+ if "SUMMARY" in arg.value:
718
+ script_meta["literate_nav"] = arg.value
719
+ elif "/" in arg.value:
720
+ script_meta["output_dir"] = (
721
+ arg.value.split("/")[0]
722
+ )
723
+ except Exception:
724
+ pass
725
+
726
+ output_prefix = script_meta.get("output_dir", "reference")
727
+
728
+ # Discover Python packages & modules to materialize deterministic reference documentation
729
+ package_modules: Dict[str, List[str]] = {}
730
+ module_members: Dict[str, Dict[str, List[str]]] = {}
731
+ for src_dir in src_paths:
732
+ py_files = sorted(list(src_dir.rglob("*.py")))
733
+ for py_f in py_files:
734
+ rel_mod = py_f.relative_to(src_dir).with_suffix("")
735
+ parts = list(rel_mod.parts)
736
+
737
+ is_pkg_index = False
738
+ if parts[-1] == "__init__":
739
+ parts = parts[:-1]
740
+ is_pkg_index = True
741
+ elif parts[-1].startswith("_"):
742
+ continue
743
+
744
+ if not parts:
745
+ continue
746
+
747
+ # Inspect source statically; never import the project being
748
+ # migrated merely to create an API index.
749
+ members: Dict[str, List[str]] = {
750
+ "classes": [],
751
+ "functions": [],
752
+ "attributes": [],
753
+ }
754
+ module_docstring = None
755
+ try:
756
+ module_ast = ast.parse(
757
+ py_f.read_text(encoding="utf-8"), filename=str(py_f)
758
+ )
759
+ module_docstring = ast.get_docstring(module_ast)
760
+ for node in module_ast.body:
761
+ if isinstance(
762
+ node, ast.ClassDef
763
+ ) and not node.name.startswith("_"):
764
+ members["classes"].append(node.name)
765
+ elif isinstance(
766
+ node, (ast.FunctionDef, ast.AsyncFunctionDef)
767
+ ) and not node.name.startswith("_"):
768
+ members["functions"].append(node.name)
769
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
770
+ targets = (
771
+ node.targets
772
+ if isinstance(node, ast.Assign)
773
+ else [node.target]
774
+ )
775
+ for target in targets:
776
+ if isinstance(
777
+ target, ast.Name
778
+ ) and not target.id.startswith("_"):
779
+ members["attributes"].append(target.id)
780
+ except (OSError, SyntaxError, UnicodeDecodeError):
781
+ pass
782
+
783
+ pkg_key = (
784
+ "/".join(parts[:-1]) if not is_pkg_index else "/".join(parts)
785
+ )
786
+ if not is_pkg_index:
787
+ package_modules.setdefault(pkg_key, []).append(parts[-1])
788
+
789
+ module_qualname = ".".join(parts)
790
+ module_members[module_qualname] = members
791
+ if is_pkg_index:
792
+ target_rel = f"{docs_dir_name}/{output_prefix}/{'/'.join(parts)}/index.md"
793
+ else:
794
+ target_rel = (
795
+ f"{docs_dir_name}/{output_prefix}/{'/'.join(parts)}.md"
796
+ )
797
+
798
+ # Generate summary tables from the same names that will be
799
+ # rendered by autodoc. ``autosummary_generate`` remains
800
+ # disabled because these migration proposals already own
801
+ # the destination pages and their local navigation.
802
+ doc_title = parts[-1]
803
+ flow_actions = []
804
+ order_idx = 0
805
+
806
+ # Check if rendered HTML from mkdocs build exists
807
+ site_dir_cand = self.project_root / "site"
808
+ cand_html_paths = [
809
+ site_dir_cand / "reference" / ("/".join(parts)) / "index.html",
810
+ site_dir_cand / "reference" / f"{'/'.join(parts)}.html",
811
+ ]
812
+
813
+ found_html = None
814
+ for cand in cand_html_paths:
815
+ if cand.exists():
816
+ found_html = cand
817
+ break
818
+
819
+ if found_html:
820
+ html_parser = HtmlFlowParser()
821
+ html_flow = html_parser.parse_file(
822
+ found_html, rel_route=target_rel
823
+ )
824
+ if html_flow.title:
825
+ doc_title = html_flow.title
826
+
827
+ summary_elems = [
828
+ el
829
+ for el in html_flow.elements
830
+ if el.role == HtmlFlowRole.AUTOSUMMARY
831
+ ]
832
+ api_member_elems = [
833
+ el
834
+ for el in html_flow.elements
835
+ if el.role
836
+ in (
837
+ HtmlFlowRole.API_CLASS,
838
+ HtmlFlowRole.API_FUNCTION,
839
+ HtmlFlowRole.API_ATTRIBUTE,
840
+ )
841
+ ]
842
+ mod_elems = [
843
+ el
844
+ for el in html_flow.elements
845
+ if el.role == HtmlFlowRole.API_MODULE
846
+ ]
847
+
848
+ flow_actions.append(
849
+ DocumentFlowAction(
850
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
851
+ order_index=order_idx,
852
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
853
+ element_type="HEADING",
854
+ strategy="PRESERVE",
855
+ content_summary=doc_title,
856
+ rationale="API reference document heading.",
857
+ )
858
+ )
859
+ order_idx += 1
860
+
861
+ if is_pkg_index:
862
+ lines = [f"# {doc_title}\n"]
863
+ if summary_elems:
864
+ lines.append(
865
+ f"```{{eval-rst}}\n.. currentmodule:: {module_qualname}\n\n.. autosummary::\n :nosignatures:\n\n"
866
+ )
867
+ for sm in summary_elems:
868
+ flow_actions.append(
869
+ DocumentFlowAction(
870
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
871
+ order_index=order_idx,
872
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
873
+ element_type="AUTOSUMMARY",
874
+ strategy="AUTOSUMMARY",
875
+ content_summary=(
876
+ ", ".join(sm.symbols)
877
+ if sm.symbols
878
+ else ""
879
+ )[:50],
880
+ target_directive="autosummary",
881
+ rationale="Package index module summary table.",
882
+ )
883
+ )
884
+ order_idx += 1
885
+ for s in sm.symbols:
886
+ lines.append(f" {s}\n")
887
+ lines.append("```\n")
888
+ doc_content = "\n".join(lines).rstrip() + "\n"
889
+ else:
890
+ blocks: list[str] = [f"# {doc_title}\n"]
891
+
892
+ # Block 1: automodule with :no-members: to render module docstring & register module
893
+ blocks.append(
894
+ f"```{{eval-rst}}\n.. automodule:: {module_qualname}\n :no-members:\n```\n"
895
+ )
896
+ flow_actions.append(
897
+ DocumentFlowAction(
898
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
899
+ order_index=order_idx,
900
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
901
+ element_type="API_MODULE",
902
+ strategy="AUTODOC",
903
+ content_summary=f"automodule::{module_qualname}",
904
+ target_directive=f".. automodule:: {module_qualname}",
905
+ rationale="Module registration and docstring presentation without dumping members.",
906
+ )
907
+ )
908
+ order_idx += 1
909
+
910
+ # Block 2: Autosummary tables grouped under currentmodule with rubrics and ~Symbol
911
+ if summary_elems:
912
+ summary_lines = [
913
+ f"```{{eval-rst}}\n.. currentmodule:: {module_qualname}"
914
+ ]
915
+ for sm in summary_elems:
916
+ rubric = None
917
+ if sm.headers:
918
+ h = sm.headers[0].upper()
919
+ if "CLASS" in h:
920
+ rubric = "Classes"
921
+ elif "FUNC" in h:
922
+ rubric = "Functions"
923
+ elif "ATTR" in h:
924
+ rubric = "Attributes"
925
+ elif "EXCEPT" in h:
926
+ rubric = "Exceptions"
927
+ if rubric:
928
+ summary_lines.append(f"\n.. rubric:: {rubric}")
929
+ nosig = (
930
+ "\n :nosignatures:"
931
+ if sm.options.get("nosignatures", True)
932
+ else ""
933
+ )
934
+ summary_lines.append(f"\n.. autosummary::{nosig}\n")
935
+ for s in sm.symbols:
936
+ s_clean = s.split("(")[0].strip()
937
+ s_fmt = (
938
+ s_clean
939
+ if s_clean.startswith("~")
940
+ else f"~{s_clean}"
941
+ )
942
+ summary_lines.append(f" {s_fmt}")
943
+ flow_actions.append(
944
+ DocumentFlowAction(
945
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
946
+ order_index=order_idx,
947
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
948
+ element_type="AUTOSUMMARY",
949
+ strategy="AUTOSUMMARY",
950
+ content_summary=(
951
+ ", ".join(sm.symbols)
952
+ if sm.symbols
953
+ else ""
954
+ )[:50],
955
+ target_directive="autosummary",
956
+ rationale="Summary table mapped to Sphinx autosummary.",
957
+ )
958
+ )
959
+ order_idx += 1
960
+ summary_lines.append("```\n")
961
+ blocks.append("\n".join(summary_lines))
962
+
963
+ # Block 3: Individual member directives in exact parsed DOM sequence
964
+ if api_member_elems:
965
+ member_lines = [
966
+ f"```{{eval-rst}}\n.. currentmodule:: {module_qualname}"
967
+ ]
968
+ for el in api_member_elems:
969
+ target_name = (
970
+ el.qname.split(".")[-1]
971
+ if el.qname
972
+ else module_qualname
973
+ )
974
+ if el.role == HtmlFlowRole.API_ATTRIBUTE:
975
+ directive = el.directive or "autodata"
976
+ member_lines.append(
977
+ f"\n.. {directive}:: {target_name}"
978
+ )
979
+ elif el.role == HtmlFlowRole.API_CLASS:
980
+ raw_inh = el.options.get("inherited_members")
981
+ if raw_inh is None:
982
+ raw_inh = el.options.get(
983
+ "inherited-members"
984
+ )
985
+
986
+ if raw_inh is not None:
987
+ should_inh = bool(raw_inh)
988
+ allowed_bases = (
989
+ raw_inh
990
+ if isinstance(raw_inh, list)
991
+ else None
992
+ )
993
+ else:
994
+ should_inh = has_inherited_members
995
+ allowed_bases = (
996
+ global_inherited_members_list
997
+ )
998
+
999
+ if should_inh:
1000
+ ext_bases = set(
1001
+ class_external_bases.get(
1002
+ target_name, set()
1003
+ )
1004
+ )
1005
+ if allowed_bases:
1006
+ all_superclasses = (
1007
+ set(
1008
+ class_bases.get(target_name, [])
1009
+ )
1010
+ | ext_bases
1011
+ )
1012
+ excluded = (
1013
+ all_superclasses
1014
+ - set(allowed_bases)
1015
+ ) | ext_bases
1016
+ if excluded:
1017
+ inh_str = f"\n :inherited-members: {', '.join(sorted(excluded))}"
1018
+ else:
1019
+ inh_str = "\n :inherited-members:"
1020
+ elif ext_bases:
1021
+ inh_str = f"\n :inherited-members: {', '.join(sorted(ext_bases))}"
1022
+ else:
1023
+ inh_str = "\n :inherited-members:"
1024
+ else:
1025
+ inh_str = ""
1026
+ member_lines.append(
1027
+ f"\n.. autoclass:: {target_name}\n :members:\n :undoc-members:\n :show-inheritance:{inh_str}"
1028
+ )
1029
+ elif el.role == HtmlFlowRole.API_FUNCTION:
1030
+ directive = el.directive or "autofunction"
1031
+ member_lines.append(
1032
+ f"\n.. {directive}:: {target_name}"
1033
+ )
1034
+ flow_actions.append(
1035
+ DocumentFlowAction(
1036
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1037
+ order_index=order_idx,
1038
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1039
+ element_type=el.role.value,
1040
+ strategy="AUTODOC",
1041
+ content_summary=(el.qname or "")[:50],
1042
+ target_directive=el.directive,
1043
+ rationale=f"Ordered API element {el.role.value} mapped to Sphinx {el.directive}.",
1044
+ )
1045
+ )
1046
+ order_idx += 1
1047
+ member_lines.append("```\n")
1048
+ blocks.append("\n".join(member_lines))
1049
+ elif not summary_elems and mod_elems:
1050
+ # Stub or memberless module
1051
+ pass
1052
+
1053
+ doc_content = "\n".join(blocks).rstrip() + "\n"
1054
+ else:
1055
+ # Deterministic fallback to Python AST analysis
1056
+ flow_actions.append(
1057
+ DocumentFlowAction(
1058
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1059
+ order_index=order_idx,
1060
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1061
+ element_type="HEADING",
1062
+ strategy="PRESERVE",
1063
+ content_summary=doc_title,
1064
+ rationale="API reference document heading.",
1065
+ )
1066
+ )
1067
+ order_idx += 1
1068
+
1069
+ if is_pkg_index:
1070
+ doc_content = f"# {doc_title}\n\n"
1071
+ if module_docstring:
1072
+ for p_txt in module_docstring.strip().split("\n\n"):
1073
+ p_clean = p_txt.strip()
1074
+ if p_clean:
1075
+ flow_actions.append(
1076
+ DocumentFlowAction(
1077
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1078
+ order_index=order_idx,
1079
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1080
+ element_type="PARAGRAPH",
1081
+ strategy="PRESERVE",
1082
+ content_summary=p_clean[:50],
1083
+ rationale="Module docstring overview prose.",
1084
+ )
1085
+ )
1086
+ order_idx += 1
1087
+ doc_content += f"{module_docstring.strip()}\n\n"
1088
+ doc_content += f"```{{eval-rst}}\n.. currentmodule:: {module_qualname}\n\n.. autosummary::\n :nosignatures:\n\n"
1089
+ flow_actions.append(
1090
+ DocumentFlowAction(
1091
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1092
+ order_index=order_idx,
1093
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1094
+ element_type="AUTOSUMMARY",
1095
+ strategy="AUTOSUMMARY",
1096
+ content_summary="Package index summary table",
1097
+ target_directive="autosummary",
1098
+ rationale="Summary table for package index.",
1099
+ )
1100
+ )
1101
+ order_idx += 1
1102
+ else:
1103
+ doc_content = f"# {doc_title}\n\n"
1104
+ if module_docstring:
1105
+ for p_txt in module_docstring.strip().split("\n\n"):
1106
+ p_clean = p_txt.strip()
1107
+ if p_clean:
1108
+ flow_actions.append(
1109
+ DocumentFlowAction(
1110
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1111
+ order_index=order_idx,
1112
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1113
+ element_type="PARAGRAPH",
1114
+ strategy="PRESERVE",
1115
+ content_summary=p_clean[:50],
1116
+ rationale="Module docstring overview prose.",
1117
+ )
1118
+ )
1119
+ order_idx += 1
1120
+ doc_content += f"{module_docstring.strip()}\n\n"
1121
+ doc_content += f"```{{eval-rst}}\n.. currentmodule:: {module_qualname}\n"
1122
+ for heading, key in (
1123
+ ("Classes", "classes"),
1124
+ ("Functions", "functions"),
1125
+ ("Attributes", "attributes"),
1126
+ ):
1127
+ if members[key]:
1128
+ doc_content += f"\n.. rubric:: {heading}\n\n.. autosummary::\n :nosignatures:\n\n"
1129
+ doc_content += "".join(
1130
+ f" {name}\n" for name in members[key]
1131
+ )
1132
+ flow_actions.append(
1133
+ DocumentFlowAction(
1134
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1135
+ order_index=order_idx,
1136
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1137
+ element_type="AUTOSUMMARY",
1138
+ strategy="AUTOSUMMARY",
1139
+ content_summary=f"Summary table for {heading}",
1140
+ target_directive="autosummary",
1141
+ rationale=f"Summary table for {heading}.",
1142
+ )
1143
+ )
1144
+ order_idx += 1
1145
+ doc_content += f"\n.. automodule:: {module_qualname}\n :members:\n :undoc-members:\n :show-inheritance:\n"
1146
+ flow_actions.append(
1147
+ DocumentFlowAction(
1148
+ action_id=f"flow_doc_{target_rel.replace('/', '_').replace('.', '_')}_{order_idx}",
1149
+ order_index=order_idx,
1150
+ source_construct_id=f"doc:{target_rel}:elem:{order_idx:04d}",
1151
+ element_type="API_REQUEST",
1152
+ strategy="AUTODOC",
1153
+ content_summary=f"automodule::{module_qualname}",
1154
+ target_directive=f".. automodule:: {module_qualname}",
1155
+ rationale="Full module autodoc expansion.",
1156
+ )
1157
+ )
1158
+ order_idx += 1
1159
+ doc_content += "```\n"
1160
+
1161
+ generated_doc_proposals.append(
1162
+ GeneratedDocumentProposal(
1163
+ target_path=target_rel,
1164
+ title=doc_title,
1165
+ content=doc_content,
1166
+ generator_plugin="gen-files",
1167
+ generator_script=scripts[0] if scripts else None,
1168
+ provenance=RequirementProvenance.GENERATED_PIPELINE,
1169
+ rationale=f"Synthesized API reference documentation for {module_qualname} matching MkDocs gen-files/mkdocstrings pipeline.",
1170
+ flow_actions=flow_actions,
1171
+ )
1172
+ )
1173
+ api_reference_mappings[module_qualname] = target_rel
1174
+
1175
+ # If package index exists, append toctree of child modules so Sphinx tree is complete
1176
+ for gen_prop in generated_doc_proposals:
1177
+ if gen_prop.target_path.endswith("/index.md"):
1178
+ # Find package key
1179
+ rel_no_prefix = gen_prop.target_path[
1180
+ len(f"{docs_dir_name}/{output_prefix}/") : -len("/index.md")
1181
+ ]
1182
+ child_mods = package_modules.get(rel_no_prefix, [])
1183
+ if child_mods:
1184
+ if ".. autosummary::" in gen_prop.content and not any(
1185
+ f" {cm}" in gen_prop.content for cm in child_mods
1186
+ ):
1187
+ summary_lines = [
1188
+ f" {child}" for child in sorted(child_mods)
1189
+ ]
1190
+ insertion = "\n".join(summary_lines) + "\n```\n"
1191
+ gen_prop.content = gen_prop.content.replace(
1192
+ "```\n", insertion, 1
1193
+ )
1194
+ toc_lines = ["\n```{toctree}", ":hidden:", ":maxdepth: 1", ""]
1195
+ for cm in sorted(child_mods):
1196
+ short_title = cm.split(".")[-1]
1197
+ toc_lines.append(f"{short_title} <{cm}>")
1198
+ toc_lines.append("```\n")
1199
+ gen_prop.content += "\n".join(toc_lines)
1200
+
1201
+ if generated_doc_proposals:
1202
+ # Ensure API rendering and its summary tables are enabled.
1203
+ for ext in [
1204
+ "sphinx.ext.autodoc",
1205
+ "sphinx.ext.autosummary",
1206
+ "sphinx.ext.napoleon",
1207
+ ]:
1208
+ ext_sources.setdefault(ext, []).append(
1209
+ "pipeline:gen-files+mkdocstrings"
1210
+ )
1211
+ provenance_map.setdefault(
1212
+ ext, RequirementProvenance.GENERATED_PIPELINE
1213
+ )
1214
+ rationale_map.setdefault(
1215
+ ext, "Required for generated API reference documentation."
1216
+ )
1217
+
1218
+ # 7. Build Traceable RequirementItems
1219
+ requirements: List[RequirementItem] = []
1220
+
1221
+ # Target Baseline
1222
+ requirements.append(
1223
+ RequirementItem(
1224
+ name="Sphinx>=7.0.0",
1225
+ kind="package",
1226
+ provenance=RequirementProvenance.TARGET_BASELINE,
1227
+ rationale="Core target documentation engine.",
1228
+ sources=["target_baseline"],
1229
+ )
1230
+ )
1231
+ requirements.append(
1232
+ RequirementItem(
1233
+ name="myst-parser>=2.0.0",
1234
+ kind="package",
1235
+ provenance=RequirementProvenance.TARGET_BASELINE,
1236
+ rationale="Required for CommonMark and MyST Markdown parsing in Sphinx.",
1237
+ sources=["target_baseline"],
1238
+ )
1239
+ )
1240
+ requirements.append(
1241
+ RequirementItem(
1242
+ name="myst_parser",
1243
+ kind="extension",
1244
+ provenance=RequirementProvenance.TARGET_BASELINE,
1245
+ rationale="Sphinx extension for MyST Parser.",
1246
+ sources=["target_baseline"],
1247
+ )
1248
+ )
1249
+
1250
+ # Theme Package Requirement
1251
+ if theme_proposal.target_package:
1252
+ requirements.append(
1253
+ RequirementItem(
1254
+ name=theme_proposal.target_package,
1255
+ kind="package",
1256
+ provenance=RequirementProvenance.THEME_POLICY,
1257
+ rationale=theme_proposal.rationale,
1258
+ sources=pkg_sources.get(
1259
+ theme_proposal.target_package, [f"theme_policy:{source_theme}"]
1260
+ ),
1261
+ )
1262
+ )
1263
+
1264
+ # Extensions & Packages with Traceable Provenance
1265
+ for ext, sources in sorted(ext_sources.items()):
1266
+ if ext != "myst_parser":
1267
+ requirements.append(
1268
+ RequirementItem(
1269
+ name=ext,
1270
+ kind="extension",
1271
+ provenance=provenance_map.get(
1272
+ ext, RequirementProvenance.DOCUMENT_CONSTRUCT
1273
+ ),
1274
+ rationale=rationale_map.get(
1275
+ ext, f"Required by {len(sources)} source item(s)."
1276
+ ),
1277
+ sources=sources,
1278
+ )
1279
+ )
1280
+
1281
+ for pkg, sources in sorted(pkg_sources.items()):
1282
+ if pkg not in (
1283
+ "Sphinx>=7.0.0",
1284
+ "myst-parser>=2.0.0",
1285
+ theme_proposal.target_package,
1286
+ ):
1287
+ requirements.append(
1288
+ RequirementItem(
1289
+ name=pkg,
1290
+ kind="package",
1291
+ provenance=provenance_map.get(
1292
+ pkg, RequirementProvenance.DOCUMENT_CONSTRUCT
1293
+ ),
1294
+ rationale=rationale_map.get(
1295
+ pkg, f"Required by {len(sources)} source item(s)."
1296
+ ),
1297
+ sources=sources,
1298
+ )
1299
+ )
1300
+
1301
+ site_name = (
1302
+ report.mkdocs_config.site_name if report.mkdocs_config else "Documentation"
1303
+ )
1304
+ all_ext_names = sorted(
1305
+ list({r.name for r in requirements if r.kind == "extension"})
1306
+ )
1307
+
1308
+ conf_opts: Dict[str, Any] = {
1309
+ "project": site_name,
1310
+ "html_title": site_name,
1311
+ "html_theme": theme_proposal.target_theme or "sphinx_rtd_theme",
1312
+ "add_module_names": False,
1313
+ }
1314
+
1315
+ # Map MkDocs metadata & theme properties to Sphinx conf options
1316
+ if report.mkdocs_config:
1317
+ cfg = report.mkdocs_config
1318
+ CONFIG_FIELD_MAP = {
1319
+ "copyright": "copyright",
1320
+ "site_author": "author",
1321
+ "theme_language": "language",
1322
+ "theme_logo": "html_logo",
1323
+ "theme_favicon": "html_favicon",
1324
+ "extra_css": "html_css_files",
1325
+ "extra_javascript": "html_js_files",
1326
+ }
1327
+ for attr, conf_key in CONFIG_FIELD_MAP.items():
1328
+ val = getattr(cfg, attr, None)
1329
+ if val:
1330
+ if (
1331
+ attr == "copyright"
1332
+ and isinstance(val, str)
1333
+ and ("<" in val or "&" in val)
1334
+ ):
1335
+ clean_c = html.unescape(val)
1336
+ clean_c = re.sub(r"<[^>]+>", "", clean_c)
1337
+ clean_c = re.sub(
1338
+ r"^(?:copyright|\(c\)|©|\s)+",
1339
+ "",
1340
+ clean_c,
1341
+ flags=re.IGNORECASE,
1342
+ ).strip()
1343
+ val = clean_c or val
1344
+ conf_opts[conf_key] = val
1345
+
1346
+ # Theme-specific options (e.g. Furo / Immaterial / RTD)
1347
+ theme_opts: Dict[str, Any] = {}
1348
+ target_t = theme_proposal.target_theme or "sphinx_rtd_theme"
1349
+
1350
+ if target_t == "sphinx_immaterial":
1351
+ # Declarative attribute-to-option mapping
1352
+ DIRECT_FIELD_MAPPING = {
1353
+ "theme_icon": "icon",
1354
+ "site_url": "site_url",
1355
+ "repo_url": "repo_url",
1356
+ "edit_uri": "edit_uri",
1357
+ "theme_features": "features",
1358
+ }
1359
+ for attr, opt_key in DIRECT_FIELD_MAPPING.items():
1360
+ val = getattr(cfg, attr, None)
1361
+ if val:
1362
+ theme_opts[opt_key] = val
1363
+
1364
+ if cfg.repo_url and "github.com/" in cfg.repo_url:
1365
+ theme_opts["repo_name"] = cfg.repo_url.rstrip("/").split(
1366
+ "github.com/"
1367
+ )[1]
1368
+
1369
+ # Material palette mapping
1370
+ immaterial_palettes = [
1371
+ {
1372
+ **(
1373
+ {
1374
+ "scheme": p.scheme.value
1375
+ if hasattr(p.scheme, "value")
1376
+ else p.scheme
1377
+ }
1378
+ if p.scheme
1379
+ else {}
1380
+ ),
1381
+ **({"primary": p.primary} if p.primary else {}),
1382
+ **({"accent": p.accent} if p.accent else {}),
1383
+ **(
1384
+ {
1385
+ "toggle": {
1386
+ k: v
1387
+ for k, v in [
1388
+ ("icon", p.toggle_icon),
1389
+ ("name", p.toggle_name),
1390
+ ]
1391
+ if v
1392
+ }
1393
+ }
1394
+ if (p.toggle_icon or p.toggle_name)
1395
+ else {}
1396
+ ),
1397
+ }
1398
+ for p in cfg.theme_palette
1399
+ ]
1400
+ if immaterial_palettes:
1401
+ theme_opts["palette"] = immaterial_palettes
1402
+ # Version selector mapping
1403
+ if cfg.extra and "version" in cfg.extra:
1404
+ theme_opts["version_dropdown"] = True
1405
+ v_val = cfg.extra["version"]
1406
+ if isinstance(v_val, dict) and "default" in v_val:
1407
+ conf_opts["version"] = str(v_val["default"])
1408
+ elif isinstance(v_val, str):
1409
+ conf_opts["version"] = v_val
1410
+
1411
+ theme_opts["globaltoc_collapse"] = False
1412
+
1413
+ elif target_t == "furo":
1414
+ if cfg.repo_url:
1415
+ # Parse repo info for Furo
1416
+ clean_repo = cfg.repo_url.rstrip("/")
1417
+ if "github.com/" in clean_repo:
1418
+ repo_path = clean_repo.split("github.com/")[1]
1419
+ theme_opts["source_repository"] = (
1420
+ f"https://github.com/{repo_path}"
1421
+ )
1422
+ theme_opts["source_branch"] = "main"
1423
+ theme_opts["source_directory"] = cfg.docs_dir
1424
+
1425
+ from .theme_constants import resolve_material_color, ThemeScheme
1426
+
1427
+ light_vars = {}
1428
+ dark_vars = {}
1429
+
1430
+ for p in cfg.theme_palette:
1431
+ primary_hex = (
1432
+ resolve_material_color(p.primary) if p.primary else None
1433
+ )
1434
+ accent_hex = (
1435
+ resolve_material_color(p.accent, is_accent=True)
1436
+ if p.accent
1437
+ else None
1438
+ )
1439
+
1440
+ if p.scheme in (
1441
+ ThemeScheme.SLATE,
1442
+ ThemeScheme.DARK,
1443
+ "slate",
1444
+ "dark",
1445
+ ):
1446
+ if primary_hex:
1447
+ dark_vars["color-brand-primary"] = primary_hex
1448
+ if accent_hex:
1449
+ dark_vars["color-brand-content"] = accent_hex
1450
+ else:
1451
+ if primary_hex:
1452
+ light_vars["color-brand-primary"] = primary_hex
1453
+ if accent_hex:
1454
+ light_vars["color-brand-content"] = accent_hex
1455
+
1456
+ if light_vars:
1457
+ theme_opts["light_css_variables"] = light_vars
1458
+ if dark_vars:
1459
+ theme_opts["dark_css_variables"] = dark_vars
1460
+
1461
+ elif target_t == "sphinx_rtd_theme":
1462
+ if cfg.theme_logo:
1463
+ theme_opts["logo_only"] = False
1464
+ if cfg.repo_url:
1465
+ theme_opts["vcs_pageview_mode"] = "blob"
1466
+
1467
+ if theme_opts:
1468
+ conf_opts["html_theme_options"] = theme_opts
1469
+
1470
+ # Check mkdocstrings options to set native Sphinx conf.py properties
1471
+ if report.mkdocs_config:
1472
+ mkdocstrings_cfg = report.mkdocs_config.plugins_config.get(
1473
+ "mkdocstrings", {}
1474
+ )
1475
+ if isinstance(mkdocstrings_cfg, dict):
1476
+ py_opts = (
1477
+ mkdocstrings_cfg.get("handlers", {})
1478
+ .get("python", {})
1479
+ .get("options", {})
1480
+ )
1481
+ if not isinstance(py_opts, dict):
1482
+ py_opts = mkdocstrings_cfg.get("options", {})
1483
+ if isinstance(py_opts, dict):
1484
+ if py_opts.get("merge_init_into_class"):
1485
+ conf_opts["autoclass_content"] = "both"
1486
+
1487
+ # Statically inspect pyproject.toml for optional dependencies to mock in autodoc if needed
1488
+ mock_imports: List[str] = []
1489
+ pyproject_path = self.project_root / "pyproject.toml"
1490
+ if pyproject_path.exists():
1491
+ try:
1492
+ import tomllib
1493
+
1494
+ pyproj_data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
1495
+ opt_deps = pyproj_data.get("project", {}).get(
1496
+ "optional-dependencies", {}
1497
+ )
1498
+ for group_name in opt_deps:
1499
+ if group_name != "all":
1500
+ mock_imports.append(group_name)
1501
+ except Exception:
1502
+ pass
1503
+ if mock_imports:
1504
+ conf_opts["autodoc_mock_imports"] = sorted(list(set(mock_imports)))
1505
+
1506
+ conf_opts.update(custom_conf_options)
1507
+ if generated_doc_proposals:
1508
+ conf_opts["autosummary_generate"] = False
1509
+
1510
+ sphinx_config_proposal = ConfigMigrationProposal(
1511
+ project_name=site_name or self.project_root.name,
1512
+ theme=theme_proposal,
1513
+ extensions_to_add=all_ext_names,
1514
+ myst_enable_extensions=sorted(list(myst_enabled)),
1515
+ custom_options=conf_opts,
1516
+ rationale={
1517
+ "extensions": "Derived from declarative policy engine across document actions, plugins, and theme features.",
1518
+ "myst_syntax": f"Derived from MkDocs extensions config and document syntax ({', '.join(sorted(list(myst_enabled)))}).",
1519
+ },
1520
+ )
1521
+ sphinx_config_proposal.rendered_content = build_conf_py(
1522
+ cfg=sphinx_config_proposal,
1523
+ project_root=self.project_root,
1524
+ has_generated_docs=bool(generated_doc_proposals),
1525
+ )
1526
+
1527
+ # 8. Synthesize DocumentationPlan from parsed pages and generated proposals
1528
+ doc_plan_artifacts: List[DocumentationArtifact] = list(planned_pages)
1529
+ for gen_prop in generated_doc_proposals:
1530
+ doc_plan_artifacts.append(
1531
+ DocumentationArtifact(
1532
+ artifact_id=f"art_gen_{gen_prop.target_path.replace('/', '_').replace('.', '_')}",
1533
+ target_path=gen_prop.target_path,
1534
+ source_file=None,
1535
+ title=gen_prop.title,
1536
+ artifact_kind="generated_stub",
1537
+ provenance=gen_prop.provenance,
1538
+ rationale=gen_prop.rationale,
1539
+ flow_actions=gen_prop.flow_actions,
1540
+ artifact_provenance=ArtifactProvenance(
1541
+ source_construct_ids=[
1542
+ act.source_construct_id
1543
+ for act in gen_prop.flow_actions
1544
+ if act.source_construct_id
1545
+ ],
1546
+ source_files=[],
1547
+ generated_from_pipeline=gen_prop.generator_plugin,
1548
+ required_extensions=[
1549
+ "sphinx.ext.autodoc",
1550
+ "sphinx.ext.autosummary",
1551
+ ],
1552
+ required_packages=[],
1553
+ ),
1554
+ )
1555
+ )
1556
+
1557
+ docs_dir = self.project_root / docs_dir_name
1558
+ all_md_files: List[Path] = (
1559
+ sorted(list(docs_dir.rglob("*.md"))) if docs_dir.exists() else []
1560
+ )
1561
+ nav_entries = (
1562
+ report.navigation_analysis.tree
1563
+ if (report.navigation_analysis and report.navigation_analysis.has_nav)
1564
+ else []
1565
+ )
1566
+
1567
+ gen_targets: Set[str] = set()
1568
+ for gen_doc in generated_doc_proposals:
1569
+ try:
1570
+ gen_targets.add(
1571
+ Path(gen_doc.target_path)
1572
+ .relative_to(docs_dir_name)
1573
+ .with_suffix("")
1574
+ .as_posix()
1575
+ )
1576
+ except ValueError:
1577
+ pass
1578
+
1579
+ root_toctrees: List[str] = []
1580
+ if report.navigation_analysis and report.navigation_analysis.tree:
1581
+
1582
+ def extract_nav_paths(items: List[Any]) -> List[str]:
1583
+ paths = []
1584
+ for item in items:
1585
+ if getattr(item, "path", None):
1586
+ paths.append(item.path)
1587
+ if getattr(item, "children", None):
1588
+ paths.extend(extract_nav_paths(item.children))
1589
+ return paths
1590
+
1591
+ root_toctrees = extract_nav_paths(report.navigation_analysis.tree)
1592
+
1593
+ resolved_root_toctrees = resolve_navigation_docnames(
1594
+ nav_entries=nav_entries,
1595
+ all_files=all_md_files,
1596
+ docs_dir=docs_dir,
1597
+ project_root=self.project_root,
1598
+ generated_targets=gen_targets,
1599
+ )
1600
+
1601
+ nav_plan = NavigationPlan(
1602
+ root_toctrees=root_toctrees, sub_toctrees={}, hidden_routes=[]
1603
+ )
1604
+
1605
+ gen_pipelines_plan: List[GeneratedPipelinePlan] = []
1606
+ if generated_doc_proposals:
1607
+ first_script = (
1608
+ scripts[0]
1609
+ if (
1610
+ report.mkdocs_config
1611
+ and "gen-files" in report.mkdocs_config.plugins
1612
+ and scripts
1613
+ )
1614
+ else None
1615
+ )
1616
+ gen_pipelines_plan.append(
1617
+ GeneratedPipelinePlan(
1618
+ pipeline_id="pipe_gen_files_mkdocstrings",
1619
+ source_plugin="gen-files",
1620
+ generator_script=first_script,
1621
+ target_strategy="AUTODOC_AUTOSUMMARY_STUBS",
1622
+ target_artifacts=[p.target_path for p in generated_doc_proposals],
1623
+ rationale=(
1624
+ f"Automated synthesis of API reference stubs replacing mkdocstrings/gen-files build step. "
1625
+ f"Analyzed generator script '{first_script}': scanned '{script_meta.get('source_dir', 'src')}', "
1626
+ f"output to '{output_prefix}', literate nav: '{script_meta.get('literate_nav', 'reference/SUMMARY.txt')}'."
1627
+ )
1628
+ if first_script
1629
+ else "Automated synthesis of API reference stubs replacing mkdocstrings/gen-files build step.",
1630
+ )
1631
+ )
1632
+
1633
+ # External Inventory Configuration (e.g. docs.python.org/3 objects.inv)
1634
+ external_invs: List[ExternalInventoryConfig] = []
1635
+ if report.mkdocs_config and "mkdocstrings" in report.mkdocs_config.plugins:
1636
+ mkd_cfg = report.mkdocs_config.plugins_config.get("mkdocstrings", {})
1637
+ import_invs = (
1638
+ mkd_cfg.get("handlers", {}).get("python", {}).get("import", [])
1639
+ if isinstance(mkd_cfg, dict)
1640
+ else []
1641
+ )
1642
+ for inv_url in import_invs:
1643
+ if "docs.python.org" in inv_url:
1644
+ base_url = inv_url.rsplit("/objects.inv", 1)[0]
1645
+ external_invs.append(
1646
+ ExternalInventoryConfig(
1647
+ inventory_id="python",
1648
+ url=base_url,
1649
+ objects_inv=inv_url,
1650
+ provenance=RequirementProvenance.EXTENSION_POLICY,
1651
+ rationale="Maps external Python documentation inventory to sphinx.ext.intersphinx.",
1652
+ )
1653
+ )
1654
+ # Ensure intersphinx is added
1655
+ if "sphinx.ext.intersphinx" not in all_ext_names:
1656
+ all_ext_names.append("sphinx.ext.intersphinx")
1657
+ conf_opts.setdefault("intersphinx_mapping", {})["python"] = (
1658
+ base_url,
1659
+ None,
1660
+ )
1661
+
1662
+ # Versioning / Deployment Strategy (e.g. mike)
1663
+ versioning_plan: Optional[VersioningDeploymentPlan] = None
1664
+ if report.mkdocs_config and "mike" in report.mkdocs_config.plugins:
1665
+ mike_cfg = (
1666
+ report.mkdocs_config.plugins_config.get("mike", {})
1667
+ if isinstance(report.mkdocs_config.plugins_config, dict)
1668
+ else {}
1669
+ )
1670
+ canon_v = mike_cfg.get("canonical_version", "latest")
1671
+ versioning_plan = VersioningDeploymentPlan(
1672
+ source_tool="mike",
1673
+ canonical_version=canon_v,
1674
+ target_strategy="SPHINX_VERSIONING_DEPLOYMENT_WORKFLOW",
1675
+ status="ACCOUNTED",
1676
+ rationale="Separated documentation generation from deployment/versioning. Handled via CI multi-version Sphinx deployment.",
1677
+ )
1678
+
1679
+ # Complete System Capability Accountability Audit
1680
+ capability_accountability: List[SystemCapabilityAccountability] = []
1681
+ if report.mkdocs_config:
1682
+ # Theme
1683
+ capability_accountability.append(
1684
+ SystemCapabilityAccountability(
1685
+ capability_name=report.mkdocs_config.theme_name,
1686
+ source_category="theme",
1687
+ disposition=CapabilityDisposition.TRANSFORM,
1688
+ implementation_strategy=ImplementationStrategy.SPHINX_EXTENSION,
1689
+ verification_status=VerificationStatus.VERIFIED,
1690
+ target_equivalent=theme_proposal.target_theme,
1691
+ rationale=theme_proposal.rationale,
1692
+ )
1693
+ )
1694
+ # Plugins
1695
+ for p_name in report.mkdocs_config.plugins:
1696
+ if p_name == "autorefs":
1697
+ capability_accountability.append(
1698
+ SystemCapabilityAccountability(
1699
+ capability_name=p_name,
1700
+ source_category="plugin",
1701
+ disposition=CapabilityDisposition.PRESERVE,
1702
+ implementation_strategy=ImplementationStrategy.NATIVE_SPHINX,
1703
+ verification_status=VerificationStatus.NOT_YET_VERIFIED,
1704
+ target_equivalent="sphinx_immaterial / stdlib crossrefs",
1705
+ rationale="Autorefs cross-referencing is natively provided by Sphinx domain references.",
1706
+ )
1707
+ )
1708
+ elif p_name == "awesome-pages":
1709
+ capability_accountability.append(
1710
+ SystemCapabilityAccountability(
1711
+ capability_name=p_name,
1712
+ source_category="plugin",
1713
+ disposition=CapabilityDisposition.ACCOUNTED_NO_DIRECT_EQUIVALENT,
1714
+ implementation_strategy=ImplementationStrategy.TOCTREE,
1715
+ verification_status=VerificationStatus.VERIFIED,
1716
+ target_equivalent="NavigationNode graph / toctree",
1717
+ rationale="Effective navigation structure synthesized into Sphinx toctrees; no runtime Sphinx extension needed.",
1718
+ )
1719
+ )
1720
+ elif p_name == "gen-files":
1721
+ capability_accountability.append(
1722
+ SystemCapabilityAccountability(
1723
+ capability_name=p_name,
1724
+ source_category="plugin",
1725
+ disposition=CapabilityDisposition.TRANSFORM,
1726
+ implementation_strategy=ImplementationStrategy.AUTODOC_AUTOSUMMARY_STUBS,
1727
+ verification_status=VerificationStatus.VERIFIED,
1728
+ target_equivalent="AUTODOC_AUTOSUMMARY_STUBS",
1729
+ rationale="Transformed build-time generated file pipeline into deterministic API reference stubs.",
1730
+ )
1731
+ )
1732
+ elif p_name == "mkdocstrings":
1733
+ capability_accountability.append(
1734
+ SystemCapabilityAccountability(
1735
+ capability_name=p_name,
1736
+ source_category="plugin",
1737
+ disposition=CapabilityDisposition.TRANSFORM,
1738
+ implementation_strategy=ImplementationStrategy.AUTODOC,
1739
+ verification_status=VerificationStatus.VERIFIED,
1740
+ target_equivalent="sphinx.ext.autodoc + sphinx.ext.autosummary",
1741
+ rationale="Migrated mkdocstrings Python handler to Sphinx autodoc/autosummary directives.",
1742
+ )
1743
+ )
1744
+ elif p_name == "literate-nav":
1745
+ capability_accountability.append(
1746
+ SystemCapabilityAccountability(
1747
+ capability_name=p_name,
1748
+ source_category="plugin",
1749
+ disposition=CapabilityDisposition.ACCOUNTED_NO_DIRECT_EQUIVALENT,
1750
+ implementation_strategy=ImplementationStrategy.TOCTREE,
1751
+ verification_status=VerificationStatus.VERIFIED,
1752
+ target_equivalent="Sphinx toctrees",
1753
+ rationale="Nav files compiled into deterministic hierarchical toctrees.",
1754
+ )
1755
+ )
1756
+ elif p_name == "mike":
1757
+ capability_accountability.append(
1758
+ SystemCapabilityAccountability(
1759
+ capability_name=p_name,
1760
+ source_category="plugin",
1761
+ disposition=CapabilityDisposition.ACCOUNTED_NO_DIRECT_EQUIVALENT,
1762
+ implementation_strategy=ImplementationStrategy.DEPLOYMENT_WORKFLOW,
1763
+ verification_status=VerificationStatus.NOT_YET_VERIFIED,
1764
+ target_equivalent="Sphinx versioning deployment",
1765
+ rationale="Multi-version hosting decoupled from documentation compilation.",
1766
+ )
1767
+ )
1768
+ elif p_name == "search":
1769
+ capability_accountability.append(
1770
+ SystemCapabilityAccountability(
1771
+ capability_name=p_name,
1772
+ source_category="plugin",
1773
+ disposition=CapabilityDisposition.PRESERVE,
1774
+ implementation_strategy=ImplementationStrategy.NATIVE_SPHINX,
1775
+ verification_status=VerificationStatus.VERIFIED,
1776
+ target_equivalent="Sphinx built-in search",
1777
+ rationale="Search is natively built into Sphinx HTML builder.",
1778
+ )
1779
+ )
1780
+ else:
1781
+ capability_accountability.append(
1782
+ SystemCapabilityAccountability(
1783
+ capability_name=p_name,
1784
+ source_category="plugin",
1785
+ disposition=CapabilityDisposition.ACCOUNTED_NO_DIRECT_EQUIVALENT,
1786
+ implementation_strategy=ImplementationStrategy.NONE,
1787
+ verification_status=VerificationStatus.NOT_YET_VERIFIED,
1788
+ target_equivalent=None,
1789
+ rationale=f"Evaluated plugin {p_name}.",
1790
+ )
1791
+ )
1792
+
1793
+ documentation_plan = DocumentationPlan(
1794
+ pages=doc_plan_artifacts,
1795
+ api_strategies=planned_api_strategies,
1796
+ navigation=nav_plan,
1797
+ generated_pipelines=gen_pipelines_plan,
1798
+ cross_references=planned_cross_refs,
1799
+ external_inventories=external_invs,
1800
+ versioning_deployment=versioning_plan,
1801
+ capability_accountability=capability_accountability,
1802
+ asset_actions=planned_assets,
1803
+ required_extensions=all_ext_names,
1804
+ required_packages=[r.name for r in requirements if r.kind == "package"],
1805
+ manual_items=manual_items,
1806
+ unsupported_items=unsupported_items,
1807
+ # Backward compatibility properties
1808
+ artifacts=doc_plan_artifacts,
1809
+ api_generation_strategy={
1810
+ s.object_path: s.directive_kind.value for s in planned_api_strategies
1811
+ },
1812
+ toctree_hierarchies={"root": resolved_root_toctrees},
1813
+ cross_reference_mappings={
1814
+ **{c.source_target: c.transformed_target for c in planned_cross_refs},
1815
+ **api_reference_mappings,
1816
+ },
1817
+ )
1818
+
1819
+ ci_plan = plan_ci_workflow(
1820
+ ci_analysis=report.ci_analysis,
1821
+ dep_analysis=report.dependency_analysis,
1822
+ project_root=self.project_root,
1823
+ )
1824
+
1825
+ pkgs_to_remove = (
1826
+ report.dependency_analysis.detected_packages_to_remove
1827
+ if report.dependency_analysis
1828
+ else []
1829
+ )
1830
+ ts = (
1831
+ deterministic_timestamp
1832
+ if deterministic_timestamp is not None
1833
+ else datetime.datetime.now(datetime.timezone.utc).isoformat()
1834
+ )
1835
+
1836
+ # Planned obsolete files to clean up post-migration (e.g. generator scripts and MkDocs hooks)
1837
+ pipe_scripts = [
1838
+ pipe.generator_script
1839
+ for pipe in gen_pipelines_plan
1840
+ if pipe.generator_script
1841
+ ]
1842
+ planned_obsolete_files = detect_obsolete_generator_scripts(
1843
+ project_root=self.project_root,
1844
+ mkdocs_config=report.mkdocs_config,
1845
+ additional_scripts=pipe_scripts,
1846
+ )
1847
+
1848
+ return MigrationPlan(
1849
+ project_root=str(self.project_root),
1850
+ metadata=MigrationPlanMetadata(generated_at=ts),
1851
+ source_mkdocs_config=report.mkdocs_config,
1852
+ version_environment=report.version_env,
1853
+ navigation_analysis=report.navigation_analysis,
1854
+ dependency_analysis=report.dependency_analysis,
1855
+ ci_analysis=report.ci_analysis,
1856
+ documentation_plan=documentation_plan,
1857
+ document_actions=all_actions,
1858
+ generated_documents=generated_doc_proposals,
1859
+ summary=summary,
1860
+ requirements=requirements,
1861
+ packages_to_remove=pkgs_to_remove,
1862
+ proposed_sphinx_config=sphinx_config_proposal,
1863
+ ci_plan=ci_plan,
1864
+ manual_action_items=manual_items,
1865
+ unsupported_constructs=unsupported_items,
1866
+ obsolete_files=planned_obsolete_files,
1867
+ )