sphinx-mkdocs-migrate 0.0.1.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sphinx_mkdocs_migrate/__init__.py +8 -0
- sphinx_mkdocs_migrate/analyzer/__init__.py +17 -0
- sphinx_mkdocs_migrate/analyzer/ci.py +223 -0
- sphinx_mkdocs_migrate/analyzer/dependencies.py +134 -0
- sphinx_mkdocs_migrate/analyzer/markdown.py +148 -0
- sphinx_mkdocs_migrate/analyzer/mkdocs.py +263 -0
- sphinx_mkdocs_migrate/analyzer/models.py +348 -0
- sphinx_mkdocs_migrate/analyzer/navigation.py +108 -0
- sphinx_mkdocs_migrate/analyzer/project.py +511 -0
- sphinx_mkdocs_migrate/cli.py +507 -0
- sphinx_mkdocs_migrate/parsing/doc_ir.py +533 -0
- sphinx_mkdocs_migrate/parsing/flow_extractor.py +457 -0
- sphinx_mkdocs_migrate/parsing/html_flow_parser.py +349 -0
- sphinx_mkdocs_migrate/parsing/markdown.py +22 -0
- sphinx_mkdocs_migrate/parsing/markdown_ir.py +49 -0
- sphinx_mkdocs_migrate/parsing/markdown_it_adapter.py +496 -0
- sphinx_mkdocs_migrate/parsing/requirements.py +155 -0
- sphinx_mkdocs_migrate/planner/accountability.py +111 -0
- sphinx_mkdocs_migrate/planner/ci.py +142 -0
- sphinx_mkdocs_migrate/planner/conf_builder.py +183 -0
- sphinx_mkdocs_migrate/planner/models.py +379 -0
- sphinx_mkdocs_migrate/planner/planner.py +1867 -0
- sphinx_mkdocs_migrate/planner/policy.py +474 -0
- sphinx_mkdocs_migrate/planner/theme_constants.py +70 -0
- sphinx_mkdocs_migrate/planner/toctree.py +158 -0
- sphinx_mkdocs_migrate/py.typed +1 -0
- sphinx_mkdocs_migrate/rules/catalog.py +154 -0
- sphinx_mkdocs_migrate/rules/engine.py +94 -0
- sphinx_mkdocs_migrate/rules/models.py +176 -0
- sphinx_mkdocs_migrate/transformer/engine.py +897 -0
- sphinx_mkdocs_migrate/transformer/models.py +59 -0
- sphinx_mkdocs_migrate/transformer/myst_transformer.py +393 -0
- sphinx_mkdocs_migrate/validator/models.py +40 -0
- sphinx_mkdocs_migrate/validator/verifier.py +377 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/METADATA +199 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/RECORD +39 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/WHEEL +4 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/entry_points.txt +2 -0
- sphinx_mkdocs_migrate-0.0.1.dev0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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
|