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,158 @@
1
+ """Semantic navigation and Sphinx toctree layout planner."""
2
+
3
+ from __future__ import annotations
4
+ from pathlib import Path
5
+ from typing import List, Optional, Set
6
+ from ..analyzer.models import NavigationItem
7
+
8
+
9
+ def resolve_navigation_docnames(
10
+ nav_entries: List[NavigationItem],
11
+ all_files: List[Path],
12
+ docs_dir: Path,
13
+ project_root: Optional[Path] = None,
14
+ generated_targets: Optional[Set[str]] = None,
15
+ ) -> List[str]:
16
+ """Resolve ordered Sphinx document names from MkDocs navigation items and file discoveries."""
17
+ ordered_docnames: List[str] = []
18
+ generated_targets = generated_targets or set()
19
+
20
+ def resolve_path(raw_path: str) -> Optional[str]:
21
+ """Resolve a page or a literate-nav wildcard to a Sphinx docname."""
22
+ clean_p = raw_path.strip()
23
+ if clean_p.startswith("http"):
24
+ return None
25
+
26
+ if "|" in clean_p:
27
+ wildcard_parts = [
28
+ part.strip() for part in clean_p.split("|") if "*" in part
29
+ ]
30
+ if wildcard_parts:
31
+ wildcard = wildcard_parts[0]
32
+ base_dir = wildcard.replace("/*", "").replace("*", "").strip("/")
33
+ index_doc = f"{base_dir}/index" if base_dir else "index"
34
+ if (
35
+ docs_dir / f"{index_doc}.md"
36
+ ).exists() or index_doc in generated_targets:
37
+ return index_doc
38
+ target_dir = docs_dir / base_dir
39
+ if target_dir.is_dir():
40
+ # No landing page exists. Keep legacy fallback.
41
+ return None
42
+ return None
43
+ clean_p = next(
44
+ (part.strip() for part in clean_p.split("|") if part.strip() != "..."),
45
+ "",
46
+ )
47
+
48
+ clean_p = clean_p.split("#", 1)[0].replace("\\", "/").strip("/")
49
+ if clean_p.endswith(".md"):
50
+ clean_p = clean_p[:-3]
51
+ if not clean_p:
52
+ return None
53
+ if (docs_dir / clean_p / "index.md").exists():
54
+ return f"{clean_p}/index"
55
+ if clean_p in generated_targets:
56
+ return clean_p
57
+ if f"{clean_p}/index" in generated_targets:
58
+ return f"{clean_p}/index"
59
+ return clean_p
60
+
61
+ def add_doc(title: Optional[str], docname: str) -> None:
62
+ if docname == "index":
63
+ entry = f"{title or 'Home'} <self>"
64
+ elif title and title.lower() != Path(docname).stem.lower():
65
+ entry = f"{title} <{docname}>"
66
+ else:
67
+ entry = docname
68
+ if entry not in ordered_docnames:
69
+ ordered_docnames.append(entry)
70
+
71
+ def section_landing(item: NavigationItem) -> Optional[str]:
72
+ """Find the page that represents a section in the target tree."""
73
+ if item.path:
74
+ return resolve_path(item.path)
75
+ for child in item.children:
76
+ candidate = section_landing(child)
77
+ if candidate:
78
+ return candidate
79
+ return None
80
+
81
+ def expand_wildcard(raw_path: str) -> None:
82
+ """Compatibility fallback for a wildcard with no index/landing page."""
83
+ wildcard = next(
84
+ (part.strip() for part in raw_path.split("|") if "*" in part), ""
85
+ )
86
+ base_dir = wildcard.replace("/*", "").replace("*", "").strip("/")
87
+ target_dir = docs_dir / base_dir
88
+ if not target_dir.is_dir():
89
+ return
90
+ for path in sorted(target_dir.rglob("*.md")):
91
+ if path.name != "index.md":
92
+ add_doc(None, path.relative_to(docs_dir).with_suffix("").as_posix())
93
+
94
+ def collect_nav_docs(items: List[NavigationItem]):
95
+ for item in items:
96
+ target = resolve_path(item.path) if item.path else None
97
+ if target:
98
+ add_doc(item.title, target)
99
+ continue
100
+ if item.path and "*" in item.path:
101
+ expand_wildcard(item.path)
102
+ continue
103
+ if item.children:
104
+ landing = section_landing(item)
105
+ if landing:
106
+ add_doc(item.title, landing)
107
+ else:
108
+ # A section without a landing document cannot be nested
109
+ # in a Sphinx toctree, so retain its explicitly listed pages.
110
+ collect_nav_docs(item.children)
111
+
112
+ if nav_entries:
113
+ collect_nav_docs(nav_entries)
114
+ else:
115
+ # Fallback if no nav defined: preserve discovered documents deterministically
116
+ for f in all_files:
117
+ rel = f.relative_to(docs_dir)
118
+ if rel.name != "index.md":
119
+ docname = rel.with_suffix("").as_posix()
120
+ if docname not in ordered_docnames:
121
+ ordered_docnames.append(docname)
122
+
123
+ return ordered_docnames
124
+
125
+
126
+ def build_semantic_toctree(
127
+ nav_entries: List[NavigationItem],
128
+ all_files: List[Path],
129
+ docs_dir: Path,
130
+ project_root: Optional[Path] = None,
131
+ generated_targets: Optional[Set[str]] = None,
132
+ hidden: bool = True,
133
+ maxdepth: int = 2,
134
+ ) -> Optional[str]:
135
+ """Generate the root toctree directive block without flattening section landing pages."""
136
+ ordered_docnames = resolve_navigation_docnames(
137
+ nav_entries=nav_entries,
138
+ all_files=all_files,
139
+ docs_dir=docs_dir,
140
+ project_root=project_root,
141
+ generated_targets=generated_targets,
142
+ )
143
+
144
+ if not ordered_docnames:
145
+ return None
146
+
147
+ lines = [
148
+ "```{toctree}",
149
+ ]
150
+ if hidden:
151
+ lines.append(":hidden:")
152
+ if maxdepth > 0:
153
+ lines.append(f":maxdepth: {maxdepth}")
154
+ lines.append("")
155
+ for docname in ordered_docnames:
156
+ lines.append(docname)
157
+ lines.append("```")
158
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561.
@@ -0,0 +1,154 @@
1
+ """Comprehensive catalog of declarative migration rules with requirements and preservation constraints."""
2
+
3
+ from typing import List, Dict
4
+ from ..parsing.markdown_ir import NodeKind
5
+ from ..analyzer.models import Classification
6
+ from .models import MigrationRule, MigrationTarget, RuleKind
7
+
8
+ SUPPORTED_MYST_ADMONITIONS: Dict[str, str] = {
9
+ "note": "note",
10
+ "warning": "warning",
11
+ "tip": "tip",
12
+ "info": "note",
13
+ "important": "important",
14
+ "caution": "caution",
15
+ "danger": "danger",
16
+ "seealso": "seealso",
17
+ "example": "admonition",
18
+ "quote": "admonition",
19
+ "abstract": "admonition",
20
+ "bug": "admonition",
21
+ "question": "admonition",
22
+ "check": "admonition",
23
+ "fail": "admonition",
24
+ "success": "admonition",
25
+ }
26
+
27
+ DEFAULT_RULES: List[MigrationRule] = [
28
+ # 1. Admonitions
29
+ MigrationRule(
30
+ rule_id="rule.admonition.myst",
31
+ source_kind=NodeKind.ADMONITION,
32
+ rule_kind=RuleKind.DETERMINISTIC,
33
+ classification=Classification.TRANSFORM,
34
+ target=MigrationTarget(
35
+ framework="MyST",
36
+ directive_name="note",
37
+ required_extensions=["myst_parser"],
38
+ required_py_packages=["myst-parser>=2.0.0"],
39
+ ),
40
+ preserves=["title", "body", "nesting", "admonition_type"],
41
+ changes=["syntax", "fence_delimiters"],
42
+ conditions={"type_map": SUPPORTED_MYST_ADMONITIONS},
43
+ manual_if=["unsupported_custom_admonition_type"],
44
+ description="Transforms MkDocs '!!! type' admonitions into MyST '{type}' or generic '{admonition}' directives.",
45
+ ),
46
+ # 2. Content Tabs
47
+ MigrationRule(
48
+ rule_id="rule.tabs.sphinx_design",
49
+ source_kind=NodeKind.TAB_SET,
50
+ rule_kind=RuleKind.EXTENSION_DEPENDENT,
51
+ classification=Classification.TRANSFORM,
52
+ target=MigrationTarget(
53
+ framework="sphinx-design",
54
+ directive_name="tab-set",
55
+ required_extensions=["sphinx_design"],
56
+ required_py_packages=["sphinx-design>=0.5.0"],
57
+ ),
58
+ preserves=["tab_titles", "child_contents", "nesting"],
59
+ changes=["syntax", "container_delimiters"],
60
+ conditions={"has_tab_items": True},
61
+ manual_if=[],
62
+ description="Transforms PyMdown '=== \"Title\"' content tabs into sphinx-design '{tab-set}' and '{tab-item}' directives.",
63
+ ),
64
+ # 3. Details / Dropdowns
65
+ MigrationRule(
66
+ rule_id="rule.details.sphinx_design",
67
+ source_kind=NodeKind.DETAILS_DROPDOWN,
68
+ rule_kind=RuleKind.EXTENSION_DEPENDENT,
69
+ classification=Classification.TRANSFORM,
70
+ target=MigrationTarget(
71
+ framework="sphinx-design",
72
+ directive_name="dropdown",
73
+ required_extensions=["sphinx_design"],
74
+ required_py_packages=["sphinx-design>=0.5.0"],
75
+ ),
76
+ preserves=["title", "open_state", "body", "nesting"],
77
+ changes=["syntax", "container_delimiters"],
78
+ conditions={},
79
+ manual_if=[],
80
+ description="Transforms PyMdown '???+ note \"Title\"' collapsible details into sphinx-design '{dropdown}' directives.",
81
+ ),
82
+ # 4. mkdocstrings / mkautodoc API Directives
83
+ MigrationRule(
84
+ rule_id="rule.api.autodoc",
85
+ source_kind=NodeKind.API_DIRECTIVE,
86
+ rule_kind=RuleKind.DETERMINISTIC,
87
+ classification=Classification.MANUAL,
88
+ target=MigrationTarget(
89
+ framework="sphinx.ext.autodoc",
90
+ directive_name="autoclass",
91
+ required_extensions=["sphinx.ext.autodoc", "sphinx.ext.napoleon"],
92
+ required_py_packages=["Sphinx>=7.0.0"],
93
+ ),
94
+ preserves=["symbol_path"],
95
+ changes=["directive_syntax"],
96
+ conditions={"symbol_format": "dotted_path"},
97
+ manual_if=["symbol_kind_unknown", "complex_filter_options"],
98
+ description="Maps '::: symbol.path' to appropriate Sphinx autodoc directives with options.",
99
+ ),
100
+ # 5. Snippet Includes
101
+ MigrationRule(
102
+ rule_id="rule.snippet.literalinclude",
103
+ source_kind=NodeKind.SNIPPET_INCLUDE,
104
+ rule_kind=RuleKind.DETERMINISTIC,
105
+ classification=Classification.TRANSFORM,
106
+ target=MigrationTarget(
107
+ framework="MyST",
108
+ directive_name="literalinclude",
109
+ required_extensions=["myst_parser"],
110
+ required_py_packages=["myst-parser>=2.0.0"],
111
+ ),
112
+ preserves=["filepath"],
113
+ changes=["include_syntax", "relative_root_resolution"],
114
+ conditions={"filepath_specified": True},
115
+ manual_if=["missing_target_file"],
116
+ description="Transforms PyMdown '--8<-- \"path\"' snippet inclusions to MyST/Sphinx '{literalinclude}' or '{include}'.",
117
+ ),
118
+ # 6. Mermaid Diagrams
119
+ MigrationRule(
120
+ rule_id="rule.mermaid.sphinxcontrib",
121
+ source_kind=NodeKind.MERMAID_DIAGRAM,
122
+ rule_kind=RuleKind.EXTENSION_DEPENDENT,
123
+ classification=Classification.TRANSFORM,
124
+ target=MigrationTarget(
125
+ framework="sphinxcontrib-mermaid",
126
+ directive_name="mermaid",
127
+ required_extensions=["sphinxcontrib.mermaid"],
128
+ required_py_packages=["sphinxcontrib-mermaid>=0.9.0"],
129
+ ),
130
+ preserves=["diagram_source", "line_mapping"],
131
+ changes=["fence_name_to_directive"],
132
+ conditions={"info_string": "mermaid"},
133
+ manual_if=[],
134
+ description="Maps '```mermaid' fenced code blocks to sphinxcontrib-mermaid '{mermaid}' directives.",
135
+ ),
136
+ # 7. Document Links
137
+ MigrationRule(
138
+ rule_id="rule.link.myst_ref",
139
+ source_kind=NodeKind.LINK_REF,
140
+ rule_kind=RuleKind.DETERMINISTIC,
141
+ classification=Classification.PRESERVE,
142
+ target=MigrationTarget(
143
+ framework="MyST",
144
+ directive_name="doc_or_ref",
145
+ required_extensions=["myst_parser"],
146
+ required_py_packages=["myst-parser>=2.0.0"],
147
+ ),
148
+ preserves=["link_text", "target_url"],
149
+ changes=["relative_md_extension_resolution"],
150
+ conditions={},
151
+ manual_if=["unresolved_internal_reference"],
152
+ description="Preserves standard links and evaluates relative markdown links for cross-document reference resolution.",
153
+ ),
154
+ ]
@@ -0,0 +1,94 @@
1
+ """Generic declarative rule engine for evaluating DocumentIR trees without procedural construct branching."""
2
+
3
+ import hashlib
4
+ from typing import List, Dict, Optional
5
+ from ..parsing.markdown_ir import BaseIRNode, NodeKind, DocumentIR
6
+ from .models import MigrationRule, RuleEvaluation, MigrationAction
7
+ from .catalog import DEFAULT_RULES
8
+
9
+
10
+ class MigrationRuleEngine:
11
+ """Evaluates DocumentIR nodes purely by interpreting declarative MigrationRule contracts."""
12
+
13
+ def __init__(self, custom_rules: Optional[List[MigrationRule]] = None):
14
+ self.rules: List[MigrationRule] = (
15
+ custom_rules if custom_rules is not None else list(DEFAULT_RULES)
16
+ )
17
+ self._rules_by_kind: Dict[NodeKind, List[MigrationRule]] = {}
18
+ for rule in self.rules:
19
+ self._rules_by_kind.setdefault(rule.source_kind, []).append(rule)
20
+
21
+ def evaluate_node(self, node: BaseIRNode) -> Optional[RuleEvaluation]:
22
+ """Generic evaluation: finds matching rules for node kind and delegates to rule contract."""
23
+ matching_rules = self._rules_by_kind.get(node.kind, [])
24
+ for rule in matching_rules:
25
+ if rule.matches(node):
26
+ return rule.evaluate(node)
27
+ return None
28
+
29
+ def evaluate_document(self, doc_ir: DocumentIR) -> List[RuleEvaluation]:
30
+ """Evaluates all nodes across a DocumentIR tree and aggregates rule evaluations."""
31
+ evaluations: List[RuleEvaluation] = []
32
+ for node in doc_ir.walk():
33
+ res = self.evaluate_node(node)
34
+ if res is not None:
35
+ evaluations.append(res)
36
+ return evaluations
37
+
38
+ def create_actions(
39
+ self, doc_ir: DocumentIR, raw_lines: Optional[List[str]] = None
40
+ ) -> List[MigrationAction]:
41
+ """Generates normalized MigrationAction items with stable source-span IDs and fingerprints."""
42
+ actions: List[MigrationAction] = []
43
+
44
+ # Helper to compute exact source span text
45
+ def get_span_fingerprint(start_l: int, end_l: int) -> str:
46
+ if raw_lines and 1 <= start_l <= len(raw_lines):
47
+ end_idx = min(end_l, len(raw_lines))
48
+ span_text = "\n".join(raw_lines[start_l - 1 : end_idx])
49
+ return hashlib.sha256(span_text.encode("utf-8")).hexdigest()[:16]
50
+ return ""
51
+
52
+ for node in doc_ir.nodes:
53
+ # We evaluate actions on nodes
54
+ self._collect_actions(node, doc_ir.file_path, get_span_fingerprint, actions)
55
+ return actions
56
+
57
+ def _collect_actions(
58
+ self,
59
+ node: BaseIRNode,
60
+ file_path: str,
61
+ fp_helper,
62
+ actions: List[MigrationAction],
63
+ ):
64
+ evaluation = self.evaluate_node(node)
65
+ if evaluation is not None:
66
+ stable_span_key = (
67
+ f"{file_path}:{node.start_line}-{node.end_line}:{evaluation.rule_id}"
68
+ )
69
+ action_id = f"act_{hashlib.sha256(stable_span_key.encode('utf-8')).hexdigest()[:12]}"
70
+
71
+ target_directive = (
72
+ evaluation.target.directive_name if evaluation.target else None
73
+ )
74
+ action = MigrationAction(
75
+ action_id=action_id,
76
+ rule_id=evaluation.rule_id,
77
+ source_file=file_path,
78
+ start_line=node.start_line,
79
+ end_line=node.end_line,
80
+ classification=evaluation.classification,
81
+ source_kind=node.kind,
82
+ source_span_fingerprint=fp_helper(node.start_line, node.end_line),
83
+ target_directive=target_directive,
84
+ required_extensions=evaluation.required_extensions,
85
+ required_packages=evaluation.required_packages,
86
+ preserves=evaluation.preserved_attributes,
87
+ manual_instruction=evaluation.action_item,
88
+ description=evaluation.rationale,
89
+ )
90
+ actions.append(action)
91
+
92
+ # For container nodes whose children also have actions (e.g. tabs or dropdowns)
93
+ for child in node.children:
94
+ self._collect_actions(child, file_path, fp_helper, actions)
@@ -0,0 +1,176 @@
1
+ """Data models for declarative migration rules, requirements, and preservation constraints."""
2
+
3
+ from enum import Enum
4
+ from typing import List, Dict, Any, Optional
5
+ from pydantic import BaseModel, Field
6
+ from ..parsing.markdown_ir import NodeKind, BaseIRNode
7
+ from ..analyzer.models import Classification
8
+
9
+
10
+ class RuleKind(str, Enum):
11
+ """How the mapping rule is determined and evaluated."""
12
+
13
+ DETERMINISTIC = "DETERMINISTIC" # 1-to-1 unambiguous structural transformation
14
+ EXTENSION_DEPENDENT = (
15
+ "EXTENSION_DEPENDENT" # Relies on target Sphinx extensions (e.g. sphinx-design)
16
+ )
17
+ RULE_DEPENDENT = (
18
+ "RULE_DEPENDENT" # Requires semantic inspection of symbol/environment
19
+ )
20
+ MANUAL_FALLBACK = "MANUAL_FALLBACK" # Fallback when no automated mapping exists
21
+
22
+
23
+ class MigrationTarget(BaseModel):
24
+ """Target Sphinx/MyST construct specification."""
25
+
26
+ framework: str = "MyST" # e.g., "MyST", "sphinx-design", "sphinx.ext.autodoc", "sphinxcontrib-mermaid"
27
+ directive_name: str # e.g., "note", "tab-set", "tab-item", "dropdown", "literalinclude", "mermaid"
28
+ syntax_template: Optional[str] = (
29
+ None # e.g. "```{directive_name} {title}\n{body}\n```"
30
+ )
31
+ required_extensions: List[str] = Field(
32
+ default_factory=list
33
+ ) # e.g. ["sphinx_design", "myst_parser"]
34
+ required_py_packages: List[str] = Field(
35
+ default_factory=list
36
+ ) # e.g. ["sphinx-design", "myst-parser"]
37
+
38
+
39
+ class MigrationRule(BaseModel):
40
+ """Declarative specification defining how a source IR construct maps to a Sphinx target."""
41
+
42
+ rule_id: str
43
+ source_kind: NodeKind
44
+ rule_kind: RuleKind
45
+ target: MigrationTarget
46
+ classification: Classification = Classification.TRANSFORM
47
+
48
+ # Declarative constraints and semantics
49
+ preserves: List[str] = Field(default_factory=list) # Contract promises
50
+ changes: List[str] = Field(default_factory=list) # Expected changes
51
+ conditions: Dict[str, Any] = Field(default_factory=dict) # Evaluation conditions
52
+ manual_if: List[str] = Field(
53
+ default_factory=list
54
+ ) # Trigger conditions for manual classification
55
+ description: str
56
+
57
+ def matches(self, node: BaseIRNode) -> bool:
58
+ """Determines if this rule applies to the given IR node."""
59
+ return node.kind == self.source_kind
60
+
61
+ def evaluate(self, node: BaseIRNode) -> "RuleEvaluation":
62
+ """Generically evaluates the node against this rule's declarations without engine hardcoding."""
63
+ is_manual = False
64
+ manual_reasons: List[str] = []
65
+ applied_changes = list(self.changes)
66
+ resolved_target = self.target.model_copy(deep=True)
67
+
68
+ # Dynamic target resolution based on node metadata
69
+ if self.source_kind == NodeKind.ADMONITION:
70
+ adm_type = (node.metadata.get("admonition_type") or "note").lower()
71
+ supported_map = self.conditions.get("type_map", {})
72
+ if supported_map:
73
+ if adm_type in supported_map:
74
+ resolved_target.directive_name = supported_map[adm_type]
75
+ applied_changes.append(
76
+ f"map_admonition_{adm_type}_to_{resolved_target.directive_name}"
77
+ )
78
+ else:
79
+ is_manual = True
80
+ manual_reasons.append(
81
+ f"Unsupported custom admonition type '{adm_type}'"
82
+ )
83
+
84
+ elif self.source_kind == NodeKind.API_DIRECTIVE:
85
+ symbol = node.metadata.get("symbol", "<unknown>")
86
+ if self.classification == Classification.MANUAL:
87
+ is_manual = True
88
+ manual_reasons.append(
89
+ f"Manual review required for API symbol '{symbol}'."
90
+ )
91
+ elif not symbol or symbol == "<unknown>":
92
+ is_manual = True
93
+ manual_reasons.append(
94
+ f"Review API symbol '{symbol}' docstring convention and select autodoc directive."
95
+ )
96
+ else:
97
+ applied_changes.append(f"map_api_directive_{symbol}")
98
+
99
+ elif self.source_kind == NodeKind.LINK_REF:
100
+ href = node.metadata.get("href", "")
101
+ is_ext = node.metadata.get("is_external", False)
102
+ if is_ext:
103
+ applied_changes = ["preserve_external_url"]
104
+ elif href.endswith(".md") or ".md#" in href:
105
+ applied_changes = ["resolve_internal_doc_ref"]
106
+ else:
107
+ applied_changes = ["preserve_standard_link"]
108
+
109
+ # Derive truthful observed preservation for this specific node
110
+ observed_preservation: List[str] = []
111
+ for attr in self.preserves:
112
+ if attr in node.metadata:
113
+ observed_preservation.append(attr)
114
+ elif attr == "raw_text" and bool(node.raw_text):
115
+ observed_preservation.append(attr)
116
+ elif attr in ("tab_titles", "child_contents", "nesting") and bool(
117
+ node.children
118
+ ):
119
+ observed_preservation.append(attr)
120
+ elif attr == "body" and (bool(node.children) or bool(node.raw_text)):
121
+ observed_preservation.append(attr)
122
+ elif attr in ("diagram_source", "line_mapping", "link_text", "target_url"):
123
+ observed_preservation.append(attr)
124
+
125
+ # Determine final classification
126
+ final_class = Classification.MANUAL if is_manual else self.classification
127
+ action_item = "; ".join(manual_reasons) if manual_reasons else None
128
+
129
+ return RuleEvaluation(
130
+ rule_id=self.rule_id,
131
+ matched=True,
132
+ classification=final_class,
133
+ target=resolved_target if not is_manual else None,
134
+ applied_changes=applied_changes,
135
+ preserved_attributes=observed_preservation,
136
+ required_extensions=list(resolved_target.required_extensions),
137
+ required_packages=list(resolved_target.required_py_packages),
138
+ action_item=action_item,
139
+ rationale=self.description,
140
+ )
141
+
142
+
143
+ class RuleEvaluation(BaseModel):
144
+ """Result of evaluating a MigrationRule against a specific DocumentIR node."""
145
+
146
+ rule_id: str
147
+ matched: bool
148
+ classification: Classification
149
+ target: Optional[MigrationTarget] = None
150
+ applied_changes: List[str] = Field(default_factory=list)
151
+ preserved_attributes: List[str] = Field(default_factory=list)
152
+ required_extensions: List[str] = Field(default_factory=list)
153
+ required_packages: List[str] = Field(default_factory=list)
154
+ action_item: Optional[str] = None
155
+ rationale: str
156
+
157
+
158
+ class MigrationAction(BaseModel):
159
+ """Normalized, source-span-derived migration action for inventory aggregation and transformation."""
160
+
161
+ action_id: str
162
+ rule_id: str
163
+ source_file: str
164
+ start_line: int
165
+ end_line: int
166
+ classification: Classification
167
+ source_kind: NodeKind
168
+ source_span_fingerprint: str = (
169
+ "" # SHA256 of the exact original raw text in [start_line, end_line]
170
+ )
171
+ target_directive: Optional[str] = None
172
+ required_extensions: List[str] = Field(default_factory=list)
173
+ required_packages: List[str] = Field(default_factory=list)
174
+ preserves: List[str] = Field(default_factory=list)
175
+ manual_instruction: Optional[str] = None
176
+ description: str