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,59 @@
|
|
|
1
|
+
"""Data models for deterministic, plan-driven document transformations and patch reporting."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import List, Dict, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TransformationStatus(str, Enum):
|
|
9
|
+
APPLIED = "APPLIED" # Successfully transformed targeted construct spans
|
|
10
|
+
UNCHANGED = "UNCHANGED" # No transform actions; byte-for-byte source preserved
|
|
11
|
+
MANUAL_REQUIRED = "MANUAL_REQUIRED" # Document contains manual action items (retained original source)
|
|
12
|
+
SKIPPED = "SKIPPED" # Plan mismatch or skipped document
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfPyStatus(str, Enum):
|
|
16
|
+
CREATED = "CREATED" # conf.py did not exist and was generated
|
|
17
|
+
UNCHANGED = "UNCHANGED" # Existing conf.py was already identical to generated plan
|
|
18
|
+
CONFLICT = "CONFLICT" # Existing conf.py differs; skipped to prevent overwrite without --force
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DocumentTransformationResult(BaseModel):
|
|
22
|
+
"""Result of transforming a single Markdown source document via plan-driven source-span patching."""
|
|
23
|
+
|
|
24
|
+
source_file: str
|
|
25
|
+
target_file: str
|
|
26
|
+
original_content: str
|
|
27
|
+
transformed_content: str
|
|
28
|
+
source_fingerprint: str
|
|
29
|
+
transforms_applied: int = 0
|
|
30
|
+
constructs_preserved: int = 0
|
|
31
|
+
manual_items_reported: int = 0
|
|
32
|
+
unsupported_items_reported: int = 0
|
|
33
|
+
stale_actions_count: int = 0
|
|
34
|
+
stale_action_details: List[str] = Field(default_factory=list)
|
|
35
|
+
status: TransformationStatus = TransformationStatus.APPLIED
|
|
36
|
+
diff: Optional[str] = None
|
|
37
|
+
is_modified: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProjectTransformationReport(BaseModel):
|
|
41
|
+
"""Aggregate result of executing a MigrationPlan across the entire repository."""
|
|
42
|
+
|
|
43
|
+
project_root: str
|
|
44
|
+
plan_hash: str
|
|
45
|
+
transformed_documents: List[DocumentTransformationResult] = Field(
|
|
46
|
+
default_factory=list
|
|
47
|
+
)
|
|
48
|
+
generated_sphinx_files: Dict[str, str] = Field(
|
|
49
|
+
default_factory=dict
|
|
50
|
+
) # e.g. "docs/conf.py"
|
|
51
|
+
conf_py_status: ConfPyStatus = ConfPyStatus.CREATED
|
|
52
|
+
conf_py_conflict_diff: Optional[str] = None
|
|
53
|
+
documents_examined: int = 0
|
|
54
|
+
documents_changed: int = 0
|
|
55
|
+
files_written_to_disk: int = 0
|
|
56
|
+
total_transforms_executed: int = 0
|
|
57
|
+
total_stale_actions: int = 0
|
|
58
|
+
cleaned_files: List[str] = Field(default_factory=list)
|
|
59
|
+
dry_run: bool = True
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Source-preserving patch renderer transforming only targeted construct spans with safe recursive fence nesting and disjoint span guarantees."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import List, Dict, Tuple
|
|
6
|
+
from ..parsing.markdown_ir import BaseIRNode, NodeKind, DocumentIR
|
|
7
|
+
from ..rules.models import MigrationAction
|
|
8
|
+
from ..analyzer.models import Classification
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StalePlanException(Exception):
|
|
12
|
+
"""Raised when source span fingerprint does not match the planned action fingerprint."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OverlappingSpanException(Exception):
|
|
18
|
+
"""Raised when transformation plan contains unauthorized overlapping spans that are not parent-owned."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MySTDocumentTransformer:
|
|
24
|
+
"""Transforms targeted DocumentIR spans into valid MyST Markdown using source-preserving patching,
|
|
25
|
+
fingerprint stale-plan validation, safe recursive fence allocation, and disjoint parent ownership checks."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self, actions: List[MigrationAction], strict_fingerprint: bool = False
|
|
29
|
+
):
|
|
30
|
+
self.actions = actions
|
|
31
|
+
self.actions_by_span: Dict[Tuple[int, int], MigrationAction] = {
|
|
32
|
+
(a.start_line, a.end_line): a for a in actions
|
|
33
|
+
}
|
|
34
|
+
self.strict_fingerprint = strict_fingerprint
|
|
35
|
+
|
|
36
|
+
def transform_document(
|
|
37
|
+
self, doc_ir: DocumentIR, raw_source: str
|
|
38
|
+
) -> Tuple[str, int, int, int, int, int, List[str]]:
|
|
39
|
+
"""Source-preserving transformation:
|
|
40
|
+
- Validates source span fingerprints against planned actions.
|
|
41
|
+
- Verifies disjoint span invariants on root replacement spans.
|
|
42
|
+
- Leaves untouched lines completely byte-for-byte identical.
|
|
43
|
+
- Replaces only the outermost [start_line, end_line] slices for TRANSFORM actions.
|
|
44
|
+
- Preserves original source for PRESERVE, MANUAL, and UNSUPPORTED actions.
|
|
45
|
+
Returns: (transformed_text, transforms_applied, constructs_preserved, manual_reported, unsupported_reported, stale_actions_count, stale_details)
|
|
46
|
+
"""
|
|
47
|
+
# 1. Enforce Disjoint Spans Invariant across all TRANSFORM actions in the plan
|
|
48
|
+
transform_actions = [
|
|
49
|
+
a for a in self.actions if a.classification == Classification.TRANSFORM
|
|
50
|
+
]
|
|
51
|
+
sorted_transforms = sorted(
|
|
52
|
+
transform_actions, key=lambda a: (a.start_line, -a.end_line)
|
|
53
|
+
)
|
|
54
|
+
for i in range(len(sorted_transforms) - 1):
|
|
55
|
+
curr_a = sorted_transforms[i]
|
|
56
|
+
next_a = sorted_transforms[i + 1]
|
|
57
|
+
# If next start line is before curr end line, it must be strictly contained or it's an invalid partial overlap
|
|
58
|
+
if next_a.start_line <= curr_a.end_line:
|
|
59
|
+
if next_a.end_line > curr_a.end_line:
|
|
60
|
+
raise OverlappingSpanException(
|
|
61
|
+
f"Partial overlapping transformation spans detected between action '{curr_a.action_id}' "
|
|
62
|
+
f"[{curr_a.start_line}, {curr_a.end_line}] and '{next_a.action_id}' [{next_a.start_line}, {next_a.end_line}]."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
lines = raw_source.splitlines(keepends=True)
|
|
66
|
+
raw_lines_stripped = raw_source.splitlines()
|
|
67
|
+
transforms_applied = 0
|
|
68
|
+
constructs_preserved = 0
|
|
69
|
+
manual_reported = 0
|
|
70
|
+
unsupported_reported = 0
|
|
71
|
+
stale_actions_count = 0
|
|
72
|
+
stale_details: List[str] = []
|
|
73
|
+
|
|
74
|
+
# Collect non-overlapping root-level target spans to replace
|
|
75
|
+
spans_to_replace: List[Tuple[int, int, str]] = []
|
|
76
|
+
|
|
77
|
+
for node in doc_ir.nodes:
|
|
78
|
+
span_key = (node.start_line, node.end_line)
|
|
79
|
+
action = self.actions_by_span.get(span_key)
|
|
80
|
+
|
|
81
|
+
if action is None:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Action-level Stale-Plan Fingerprint Validation
|
|
85
|
+
if action.source_span_fingerprint and raw_lines_stripped:
|
|
86
|
+
start_l, end_l = node.start_line, node.end_line
|
|
87
|
+
if 1 <= start_l <= len(raw_lines_stripped):
|
|
88
|
+
end_idx = min(end_l, len(raw_lines_stripped))
|
|
89
|
+
current_span_text = "\n".join(
|
|
90
|
+
raw_lines_stripped[start_l - 1 : end_idx]
|
|
91
|
+
)
|
|
92
|
+
current_fp = hashlib.sha256(
|
|
93
|
+
current_span_text.encode("utf-8")
|
|
94
|
+
).hexdigest()[:16]
|
|
95
|
+
if current_fp != action.source_span_fingerprint:
|
|
96
|
+
stale_msg = (
|
|
97
|
+
f"{action.source_file}:{start_l}-{end_l} STALE_PLAN: "
|
|
98
|
+
f"Expected fingerprint {action.source_span_fingerprint}, got {current_fp}"
|
|
99
|
+
)
|
|
100
|
+
stale_actions_count += 1
|
|
101
|
+
stale_details.append(stale_msg)
|
|
102
|
+
if self.strict_fingerprint:
|
|
103
|
+
raise StalePlanException(stale_msg)
|
|
104
|
+
# Skip stale transformation
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if action.classification == Classification.TRANSFORM:
|
|
108
|
+
rendered_myst = self._render_node_safely(node)
|
|
109
|
+
spans_to_replace.append((node.start_line, node.end_line, rendered_myst))
|
|
110
|
+
transforms_applied += 1
|
|
111
|
+
elif action.classification == Classification.PRESERVE:
|
|
112
|
+
constructs_preserved += 1
|
|
113
|
+
elif action.classification == Classification.MANUAL:
|
|
114
|
+
manual_reported += 1
|
|
115
|
+
elif action.classification == Classification.UNSUPPORTED:
|
|
116
|
+
unsupported_reported += 1
|
|
117
|
+
|
|
118
|
+
def _sanitize_fm(text: str) -> str:
|
|
119
|
+
if text.startswith("---"):
|
|
120
|
+
parts = text.split("---", 2)
|
|
121
|
+
if len(parts) >= 3:
|
|
122
|
+
sanitized_header = re.sub(
|
|
123
|
+
r"(\bdate\s*:\s*)(\d{4}-\d{2}-\d{2})\b", r'\1"\2"', parts[1]
|
|
124
|
+
)
|
|
125
|
+
return "---" + sanitized_header + "---" + parts[2]
|
|
126
|
+
return text
|
|
127
|
+
|
|
128
|
+
if not spans_to_replace:
|
|
129
|
+
return (
|
|
130
|
+
_sanitize_fm(raw_source),
|
|
131
|
+
transforms_applied,
|
|
132
|
+
constructs_preserved,
|
|
133
|
+
manual_reported,
|
|
134
|
+
unsupported_reported,
|
|
135
|
+
stale_actions_count,
|
|
136
|
+
stale_details,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Double check disjoint spans on root replacement targets
|
|
140
|
+
spans_to_replace.sort(key=lambda s: s[0])
|
|
141
|
+
for i in range(len(spans_to_replace) - 1):
|
|
142
|
+
curr_s, curr_e, _ = spans_to_replace[i]
|
|
143
|
+
next_s, next_e, _ = spans_to_replace[i + 1]
|
|
144
|
+
if curr_e >= next_s:
|
|
145
|
+
raise OverlappingSpanException(
|
|
146
|
+
f"Overlapping transformation spans detected: [{curr_s}, {curr_e}] and [{next_s}, {next_e}]. "
|
|
147
|
+
f"Parent containers must exclusively own child transformations."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
output_lines: List[str] = []
|
|
151
|
+
current_line_idx = 1
|
|
152
|
+
|
|
153
|
+
for start_l, end_l, replacement in spans_to_replace:
|
|
154
|
+
while current_line_idx < start_l:
|
|
155
|
+
output_lines.append(lines[current_line_idx - 1])
|
|
156
|
+
current_line_idx += 1
|
|
157
|
+
|
|
158
|
+
if not replacement.endswith("\n"):
|
|
159
|
+
replacement += "\n"
|
|
160
|
+
output_lines.append(replacement)
|
|
161
|
+
current_line_idx = end_l + 1
|
|
162
|
+
|
|
163
|
+
while current_line_idx <= len(lines):
|
|
164
|
+
output_lines.append(lines[current_line_idx - 1])
|
|
165
|
+
current_line_idx += 1
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
"".join(output_lines),
|
|
169
|
+
transforms_applied,
|
|
170
|
+
constructs_preserved,
|
|
171
|
+
manual_reported,
|
|
172
|
+
unsupported_reported,
|
|
173
|
+
stale_actions_count,
|
|
174
|
+
stale_details,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _get_max_child_fence(self, node: BaseIRNode) -> int:
|
|
178
|
+
"""Recursively inspects node subtree to find the maximum existing or needed fence delimiter length."""
|
|
179
|
+
max_len = 0
|
|
180
|
+
for child in node.children:
|
|
181
|
+
if child.kind == NodeKind.CODE_BLOCK:
|
|
182
|
+
max_len = max(max_len, 3)
|
|
183
|
+
elif child.kind in (NodeKind.ADMONITION, NodeKind.DETAILS_DROPDOWN):
|
|
184
|
+
child_inner = self._get_max_child_fence(child)
|
|
185
|
+
child_container_len = max(child_inner + 1, 3)
|
|
186
|
+
max_len = max(max_len, child_container_len)
|
|
187
|
+
elif child.kind == NodeKind.TAB_SET:
|
|
188
|
+
child_inner = self._get_max_child_fence(child)
|
|
189
|
+
max_len = max(max_len, child_inner + 2) # tab-item + tab-set
|
|
190
|
+
else:
|
|
191
|
+
max_len = max(max_len, self._get_max_child_fence(child))
|
|
192
|
+
return max_len
|
|
193
|
+
|
|
194
|
+
def _render_node_safely(self, node: BaseIRNode, min_fence_len: int = 3) -> str:
|
|
195
|
+
"""Renders an IR node with strictly valid nested fence lengths:
|
|
196
|
+
Outer fence = max(max_descendant_fence + 1, min_fence_len)
|
|
197
|
+
"""
|
|
198
|
+
child_max = self._get_max_child_fence(node)
|
|
199
|
+
fence_len = max(child_max + 1, min_fence_len)
|
|
200
|
+
fence_marker = "`" * fence_len
|
|
201
|
+
|
|
202
|
+
# 1. Admonitions (!!! note "Title")
|
|
203
|
+
if node.kind == NodeKind.ADMONITION:
|
|
204
|
+
adm_type = node.metadata.get("admonition_type", "note")
|
|
205
|
+
title = node.metadata.get("title", "")
|
|
206
|
+
|
|
207
|
+
action = self.actions_by_span.get((node.start_line, node.end_line))
|
|
208
|
+
directive = (
|
|
209
|
+
action.target_directive
|
|
210
|
+
if action and action.target_directive
|
|
211
|
+
else adm_type
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if directive in (
|
|
215
|
+
"note",
|
|
216
|
+
"warning",
|
|
217
|
+
"tip",
|
|
218
|
+
"important",
|
|
219
|
+
"caution",
|
|
220
|
+
"danger",
|
|
221
|
+
"seealso",
|
|
222
|
+
):
|
|
223
|
+
header = (
|
|
224
|
+
f"{fence_marker}{{{directive}}} {title}".strip()
|
|
225
|
+
if title != directive.capitalize()
|
|
226
|
+
else f"{fence_marker}{{{directive}}}"
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
header = (
|
|
230
|
+
f"{fence_marker}{{admonition}} {title}\n:class: {adm_type}".strip()
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
body_parts = [
|
|
234
|
+
self._render_child_body(child, parent_fence_len=fence_len)
|
|
235
|
+
for child in node.children
|
|
236
|
+
]
|
|
237
|
+
body_text = "\n\n".join(b for b in body_parts if b)
|
|
238
|
+
return f"{header}\n{body_text}\n{fence_marker}"
|
|
239
|
+
|
|
240
|
+
# 2. Content Tabs (=== "Title")
|
|
241
|
+
elif node.kind == NodeKind.TAB_SET:
|
|
242
|
+
tab_items_rendered: List[str] = []
|
|
243
|
+
|
|
244
|
+
# Find the maximum fence needed by ANY tab item's content
|
|
245
|
+
max_inner_fence = 0
|
|
246
|
+
for tab_item in node.children:
|
|
247
|
+
max_inner_fence = max(
|
|
248
|
+
max_inner_fence, self._get_max_child_fence(tab_item)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Tab items need (max_inner_fence + 1) or at least 3
|
|
252
|
+
item_fence_len = max(max_inner_fence + 1, 3)
|
|
253
|
+
# Outer tab-set must strictly enclose tab-items: (item_fence_len + 1)
|
|
254
|
+
outer_fence_len = max(item_fence_len + 1, min_fence_len)
|
|
255
|
+
|
|
256
|
+
outer_fence = "`" * outer_fence_len
|
|
257
|
+
item_fence = "`" * item_fence_len
|
|
258
|
+
|
|
259
|
+
for tab_item in node.children:
|
|
260
|
+
tab_title = tab_item.metadata.get("title", "Tab").strip()
|
|
261
|
+
# Strip all inline backticks from tab title for clean sphinx-design label compatibility
|
|
262
|
+
tab_title = tab_title.replace("`", "")
|
|
263
|
+
inner_parts = [
|
|
264
|
+
self._render_child_body(c, parent_fence_len=item_fence_len)
|
|
265
|
+
for c in tab_item.children
|
|
266
|
+
]
|
|
267
|
+
inner_body = "\n\n".join(b for b in inner_parts if b)
|
|
268
|
+
item_rendered = (
|
|
269
|
+
f"{item_fence}{{tab-item}} {tab_title}\n{inner_body}\n{item_fence}"
|
|
270
|
+
)
|
|
271
|
+
tab_items_rendered.append(item_rendered)
|
|
272
|
+
|
|
273
|
+
all_tabs_text = "\n\n".join(tab_items_rendered)
|
|
274
|
+
return f"{outer_fence}{{tab-set}}\n{all_tabs_text}\n{outer_fence}"
|
|
275
|
+
|
|
276
|
+
# 3. Details Dropdowns (???+ note "Title")
|
|
277
|
+
elif node.kind == NodeKind.DETAILS_DROPDOWN:
|
|
278
|
+
title = node.metadata.get("title", "Details")
|
|
279
|
+
is_open = node.metadata.get("open_state", False)
|
|
280
|
+
open_opt = "\n:open:" if is_open else ""
|
|
281
|
+
|
|
282
|
+
body_parts = [
|
|
283
|
+
self._render_child_body(child, parent_fence_len=fence_len)
|
|
284
|
+
for child in node.children
|
|
285
|
+
]
|
|
286
|
+
body_text = "\n\n".join(b for b in body_parts if b)
|
|
287
|
+
return f"{fence_marker}{{dropdown}} {title}{open_opt}\n{body_text}\n{fence_marker}"
|
|
288
|
+
|
|
289
|
+
# 4. Snippet Includes (--8<-- "path")
|
|
290
|
+
elif node.kind == NodeKind.SNIPPET_INCLUDE:
|
|
291
|
+
filepath = node.metadata.get("filepath", "")
|
|
292
|
+
directive = (
|
|
293
|
+
"include"
|
|
294
|
+
if (filepath.endswith(".md") or filepath.endswith(".txt"))
|
|
295
|
+
else "literalinclude"
|
|
296
|
+
)
|
|
297
|
+
return f"```{{{directive}}} {filepath}\n```"
|
|
298
|
+
|
|
299
|
+
# 5. Mermaid Diagrams (```mermaid)
|
|
300
|
+
elif node.kind == NodeKind.MERMAID_DIAGRAM:
|
|
301
|
+
diagram_code = node.raw_text.strip()
|
|
302
|
+
if diagram_code.startswith("---"):
|
|
303
|
+
parts = diagram_code.split("---", 2)
|
|
304
|
+
if len(parts) >= 3:
|
|
305
|
+
diagram_code = parts[2].strip()
|
|
306
|
+
return f"```{{mermaid}}\n{diagram_code}\n```"
|
|
307
|
+
|
|
308
|
+
# 6. API Directives (::: symbol)
|
|
309
|
+
elif node.kind == NodeKind.API_DIRECTIVE:
|
|
310
|
+
symbol = node.metadata.get("symbol", "")
|
|
311
|
+
raw_text = node.raw_text
|
|
312
|
+
|
|
313
|
+
explicit_members = []
|
|
314
|
+
is_all_members = False
|
|
315
|
+
for line in raw_text.splitlines():
|
|
316
|
+
s = line.strip()
|
|
317
|
+
if s.startswith(":members:"):
|
|
318
|
+
m_val = s[len(":members:") :].strip()
|
|
319
|
+
if m_val:
|
|
320
|
+
explicit_members = [item for item in m_val.split() if item]
|
|
321
|
+
else:
|
|
322
|
+
is_all_members = True
|
|
323
|
+
|
|
324
|
+
last_part = symbol.split(".")[-1] if symbol else ""
|
|
325
|
+
if "Error" in last_part or "Exception" in last_part:
|
|
326
|
+
directive = "autoexception"
|
|
327
|
+
elif last_part and last_part[0].isupper():
|
|
328
|
+
directive = "autoclass"
|
|
329
|
+
elif "_" in last_part or (last_part and last_part.islower()):
|
|
330
|
+
directive = "autofunction"
|
|
331
|
+
else:
|
|
332
|
+
directive = "autoclass"
|
|
333
|
+
|
|
334
|
+
lines = [f".. {directive}:: {symbol}"]
|
|
335
|
+
if explicit_members:
|
|
336
|
+
lines.append(f" :members: {', '.join(explicit_members)}")
|
|
337
|
+
elif is_all_members:
|
|
338
|
+
lines.append(" :members:")
|
|
339
|
+
|
|
340
|
+
if directive in ("autoclass", "autoexception"):
|
|
341
|
+
lines.append(" :show-inheritance:")
|
|
342
|
+
|
|
343
|
+
rst_block = "\n".join(lines)
|
|
344
|
+
return f"```{{eval-rst}}\n{rst_block}\n```"
|
|
345
|
+
|
|
346
|
+
return node.raw_text
|
|
347
|
+
|
|
348
|
+
def _render_child_body(self, node: BaseIRNode, parent_fence_len: int) -> str:
|
|
349
|
+
"""Renders a child node inside a parent container directive with strictly safe fence lengths."""
|
|
350
|
+
if node.kind in (
|
|
351
|
+
NodeKind.ADMONITION,
|
|
352
|
+
NodeKind.TAB_SET,
|
|
353
|
+
NodeKind.DETAILS_DROPDOWN,
|
|
354
|
+
):
|
|
355
|
+
return self._render_node_safely(
|
|
356
|
+
node, min_fence_len=parent_fence_len - 1 if parent_fence_len > 3 else 3
|
|
357
|
+
)
|
|
358
|
+
elif node.kind == NodeKind.CODE_BLOCK:
|
|
359
|
+
info = node.metadata.get("info_string", "")
|
|
360
|
+
code_text = node.raw_text
|
|
361
|
+
if not code_text.endswith("\n"):
|
|
362
|
+
code_text += "\n"
|
|
363
|
+
# Code block inside a parent container of length N must use < N backticks (typically 3)
|
|
364
|
+
code_fence_len = min(3, parent_fence_len - 1) if parent_fence_len > 3 else 3
|
|
365
|
+
code_fence = "`" * code_fence_len
|
|
366
|
+
return f"{code_fence}{info}\n{code_text}{code_fence}"
|
|
367
|
+
elif node.kind == NodeKind.PARAGRAPH:
|
|
368
|
+
return node.raw_text
|
|
369
|
+
elif node.kind == NodeKind.HEADING:
|
|
370
|
+
level = node.metadata.get("level", 1)
|
|
371
|
+
title = node.metadata.get("title", node.raw_text)
|
|
372
|
+
return f"{'#' * level} {title}"
|
|
373
|
+
elif node.kind == NodeKind.LIST:
|
|
374
|
+
items_text = [
|
|
375
|
+
self._render_child_body(item, parent_fence_len)
|
|
376
|
+
for item in node.children
|
|
377
|
+
]
|
|
378
|
+
return "\n".join(items_text)
|
|
379
|
+
elif node.kind == NodeKind.LIST_ITEM:
|
|
380
|
+
child_text = (
|
|
381
|
+
"\n".join(
|
|
382
|
+
self._render_child_body(c, parent_fence_len) for c in node.children
|
|
383
|
+
)
|
|
384
|
+
if node.children
|
|
385
|
+
else node.raw_text
|
|
386
|
+
)
|
|
387
|
+
lines = child_text.splitlines()
|
|
388
|
+
if not lines:
|
|
389
|
+
return "- "
|
|
390
|
+
first_line = f"- {lines[0]}"
|
|
391
|
+
rest_lines = [f" {line_item}" for line_item in lines[1:]]
|
|
392
|
+
return "\n".join([first_line] + rest_lines)
|
|
393
|
+
return node.raw_text
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Data models for post-transformation validation and Sphinx build verification."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ValidationSeverity(str, Enum):
|
|
9
|
+
INFO = "INFO"
|
|
10
|
+
WARNING = "WARNING"
|
|
11
|
+
ERROR = "ERROR"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationIssue(BaseModel):
|
|
15
|
+
"""Validation finding reported during structural parsing or Sphinx build execution."""
|
|
16
|
+
|
|
17
|
+
file_path: str
|
|
18
|
+
line_number: Optional[int] = None
|
|
19
|
+
severity: ValidationSeverity
|
|
20
|
+
issue_type: str # e.g. "UNCLOSED_FENCE", "STALE_PLAN", "SPHINX_BUILD_ERROR", "SPHINX_BUILD_WARNING"
|
|
21
|
+
message: str
|
|
22
|
+
context_snippet: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationReport(BaseModel):
|
|
26
|
+
"""Overall validation report checking CommonMark structural validity, Sphinx config, and build execution."""
|
|
27
|
+
|
|
28
|
+
passed: bool
|
|
29
|
+
total_issues: int = 0
|
|
30
|
+
errors_count: int = 0
|
|
31
|
+
warnings_count: int = 0
|
|
32
|
+
issues: List[ValidationIssue] = Field(default_factory=list)
|
|
33
|
+
commonmark_parse_successful: bool = False
|
|
34
|
+
structural_validation_successful: bool = False
|
|
35
|
+
sphinx_build_attempted: bool = False
|
|
36
|
+
sphinx_build_successful: Optional[bool] = None
|
|
37
|
+
sphinx_warning_count: int = 0
|
|
38
|
+
sphinx_warnings: List[str] = Field(default_factory=list)
|
|
39
|
+
sphinx_theme_status: Optional[str] = None
|
|
40
|
+
build_output: Optional[str] = None
|