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,8 @@
1
+ """Sphinx MkDocs Migrate.
2
+
3
+ Deterministic, evidence-driven analyzer and migration engine from MkDocs to Sphinx + MyST.
4
+ """
5
+
6
+ # PEP 440 developmental release version
7
+ __version__ = "0.0.1.dev0"
8
+ __all__ = ["__version__"]
@@ -0,0 +1,17 @@
1
+ """Analyzer subsystem module."""
2
+
3
+ from .project import ProjectAnalyzer
4
+ from .models import (
5
+ ProjectAnalysisReport,
6
+ Classification,
7
+ ConstructFinding,
8
+ SubsystemSummary,
9
+ )
10
+
11
+ __all__ = [
12
+ "ProjectAnalyzer",
13
+ "ProjectAnalysisReport",
14
+ "Classification",
15
+ "ConstructFinding",
16
+ "SubsystemSummary",
17
+ ]
@@ -0,0 +1,223 @@
1
+ """Subsystem analyzer for CI/CD, local test runners, and deployment workflows."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional, List, Dict, Tuple
6
+ from .models import CIAnalysis
7
+
8
+ DEFAULT_CHECKOUT_TAG = "v7.0.1"
9
+ DEFAULT_CHECKOUT_SHA = "3d3c42e5aac5ba805825da76410c181273ba90b1"
10
+ DEFAULT_SETUP_UV_TAG = "v10.2.0"
11
+ DEFAULT_SETUP_UV_SHA = "c18668ad3cf93ea998bef934396af7bb5c839dc7"
12
+
13
+
14
+ def resolve_github_action_ref(
15
+ action_repo: str, fallback_tag: str, fallback_sha: str
16
+ ) -> Tuple[str, str]:
17
+ """Resolves the latest release tag and commit SHA for a GitHub Action repository via git ls-remote."""
18
+ import subprocess
19
+
20
+ try:
21
+ url = f"https://github.com/{action_repo}.git"
22
+ out = subprocess.check_output(
23
+ ["git", "ls-remote", "--tags", url],
24
+ text=True,
25
+ timeout=5,
26
+ stdin=subprocess.DEVNULL,
27
+ stderr=subprocess.DEVNULL,
28
+ )
29
+ tag_shas: Dict[str, str] = {}
30
+ for line in out.splitlines():
31
+ parts = line.split()
32
+ if len(parts) >= 2:
33
+ sha, ref = parts[0], parts[1]
34
+ m = re.match(r"^refs/tags/(v?[0-9]+(?:\.[0-9]+)*)(\^\{\})?$", ref)
35
+ if m:
36
+ tag = m.group(1)
37
+ if ref.endswith("^{}"):
38
+ tag_shas[tag] = sha
39
+ elif tag not in tag_shas:
40
+ tag_shas[tag] = sha
41
+ if tag_shas:
42
+
43
+ def semver_key(t: str):
44
+ return [int(x) for x in re.findall(r"[0-9]+", t)]
45
+
46
+ sorted_tags = sorted(tag_shas.keys(), key=semver_key)
47
+ latest_tag = sorted_tags[-1]
48
+ return latest_tag, tag_shas[latest_tag]
49
+ except Exception:
50
+ pass
51
+ return fallback_tag, fallback_sha
52
+
53
+
54
+ class CIAnalyzer:
55
+ """Discovers and inspects project CI/CD workflows, runners (tox/nox/make), and hosted doc configs."""
56
+
57
+ def __init__(self, project_root: Path):
58
+ self.project_root = project_root
59
+
60
+ def analyze(self) -> CIAnalysis:
61
+ # 1. GitHub Actions (.github/workflows/*.yml)
62
+ github_workflows = self.project_root / ".github" / "workflows"
63
+ workflow_files: List[str] = []
64
+ docs_workflow_files: List[str] = []
65
+ has_deploy = False
66
+ has_tox_in_ci = False
67
+ has_uv_in_ci = False
68
+ has_gh_pages_action = False
69
+ checkout_ref: Optional[str] = None
70
+ setup_uv_ref: Optional[str] = None
71
+ checkout_pinned_ref: Optional[str] = None
72
+ setup_uv_pinned_ref: Optional[str] = None
73
+ github_actions_detected = github_workflows.is_dir()
74
+
75
+ if github_actions_detected:
76
+ chk_tag, chk_sha = resolve_github_action_ref(
77
+ "actions/checkout", DEFAULT_CHECKOUT_TAG, DEFAULT_CHECKOUT_SHA
78
+ )
79
+ uv_tag, uv_sha = resolve_github_action_ref(
80
+ "astral-sh/setup-uv", DEFAULT_SETUP_UV_TAG, DEFAULT_SETUP_UV_SHA
81
+ )
82
+ checkout_pinned_ref = f"{chk_sha} # {chk_tag}" if chk_sha else chk_tag
83
+ setup_uv_pinned_ref = f"{uv_sha} # {uv_tag}" if uv_sha else uv_tag
84
+
85
+ if github_actions_detected:
86
+ for yml_file in sorted(github_workflows.glob("*.y*ml")):
87
+ rel = yml_file.relative_to(self.project_root).as_posix()
88
+ workflow_files.append(rel)
89
+ try:
90
+ content = yml_file.read_text(encoding="utf-8")
91
+ if (
92
+ "mkdocs gh-deploy" in content
93
+ or "mkdocs build" in content
94
+ or "mike deploy" in content
95
+ ):
96
+ has_deploy = True
97
+ docs_workflow_files.append(rel)
98
+ if "tox" in content or "uvx tox" in content:
99
+ has_tox_in_ci = True
100
+ if (
101
+ "astral-sh/setup-uv" in content
102
+ or "setup-uv" in content
103
+ or "uv run" in content
104
+ ):
105
+ has_uv_in_ci = True
106
+ if (
107
+ "peaceiris/actions-gh-pages" in content
108
+ or "actions/deploy-pages" in content
109
+ ):
110
+ has_gh_pages_action = True
111
+
112
+ # Extract current action version refs
113
+ m_chk = re.search(r"uses:\s*actions/checkout@([^\s\n]+)", content)
114
+ if m_chk and not checkout_ref:
115
+ checkout_ref = m_chk.group(1).split("#")[0].strip()
116
+ m_uv = re.search(r"uses:\s*astral-sh/setup-uv@([^\s\n]+)", content)
117
+ if m_uv and not setup_uv_ref:
118
+ setup_uv_ref = m_uv.group(1).split("#")[0].strip()
119
+ except Exception:
120
+ pass
121
+
122
+ # 2. Local test & doc runners: tox.ini
123
+ tox_ini = self.project_root / "tox.ini"
124
+ has_tox = tox_ini.is_file()
125
+ tox_file = (
126
+ tox_ini.relative_to(self.project_root).as_posix() if has_tox else None
127
+ )
128
+ tox_has_docs_env = False
129
+ tox_docs_commands: List[str] = []
130
+ tox_dependency_spec: Optional[str] = None
131
+
132
+ if has_tox:
133
+ try:
134
+ tox_text = tox_ini.read_text(encoding="utf-8")
135
+ if "[testenv:docs]" in tox_text:
136
+ tox_has_docs_env = True
137
+ # Extract section content
138
+ m_sec = re.search(
139
+ r"\[testenv:docs\](.*?)(?=\n\[|\Z)", tox_text, re.DOTALL
140
+ )
141
+ if m_sec:
142
+ sec_text = m_sec.group(1)
143
+ for line in sec_text.splitlines():
144
+ line_s = line.strip()
145
+ if (
146
+ line_s.startswith("dependency_groups")
147
+ or line_s.startswith("extras")
148
+ or line_s.startswith("deps")
149
+ ):
150
+ tox_dependency_spec = line_s
151
+ elif line_s.startswith("commands =") or (
152
+ line.startswith(" ")
153
+ and ("sphinx-build" in line_s or "mkdocs" in line_s)
154
+ ):
155
+ tox_docs_commands.append(line_s)
156
+ elif "mkdocs" in tox_text:
157
+ tox_docs_commands.append("mkdocs")
158
+ except Exception:
159
+ pass
160
+
161
+ # 3. ReadTheDocs (.readthedocs.yaml or .readthedocs.yml)
162
+ rtd_config = self.project_root / ".readthedocs.yaml"
163
+ if not rtd_config.exists():
164
+ rtd_config = self.project_root / ".readthedocs.yml"
165
+ has_rtd = rtd_config.is_file()
166
+ rtd_file = (
167
+ rtd_config.relative_to(self.project_root).as_posix() if has_rtd else None
168
+ )
169
+
170
+ # 4. Nox runner (noxfile.py)
171
+ noxfile = self.project_root / "noxfile.py"
172
+ has_nox = noxfile.is_file()
173
+ nox_file = (
174
+ noxfile.relative_to(self.project_root).as_posix() if has_nox else None
175
+ )
176
+
177
+ # 5. Makefile
178
+ makefile = self.project_root / "Makefile"
179
+ has_makefile = makefile.is_file()
180
+
181
+ # 6. Pre-commit (.pre-commit-config.yaml)
182
+ pre_commit = self.project_root / ".pre-commit-config.yaml"
183
+ has_pre_commit = pre_commit.is_file()
184
+
185
+ # 7. GitLab CI (.gitlab-ci.yml)
186
+ gitlab_ci = self.project_root / ".gitlab-ci.yml"
187
+ gitlab_ci_detected = gitlab_ci.is_file()
188
+
189
+ # Primary CI system resolution
190
+ ci_system = None
191
+ if github_actions_detected:
192
+ ci_system = "github_actions"
193
+ elif gitlab_ci_detected:
194
+ ci_system = "gitlab_ci"
195
+ elif has_rtd:
196
+ ci_system = "readthedocs"
197
+
198
+ return CIAnalysis(
199
+ ci_system=ci_system,
200
+ workflow_files=workflow_files,
201
+ docs_workflow_files=docs_workflow_files,
202
+ has_mkdocs_deploy=has_deploy,
203
+ readthedocs_detected=has_rtd,
204
+ rtd_config_file=rtd_file,
205
+ github_actions_detected=github_actions_detected,
206
+ has_tox_in_ci=has_tox_in_ci,
207
+ has_uv_in_ci=has_uv_in_ci,
208
+ has_gh_pages_action=has_gh_pages_action,
209
+ checkout_action_ref=checkout_ref,
210
+ setup_uv_action_ref=setup_uv_ref,
211
+ checkout_pinned_ref=checkout_pinned_ref,
212
+ setup_uv_pinned_ref=setup_uv_pinned_ref,
213
+ has_tox=has_tox,
214
+ tox_file=tox_file,
215
+ tox_has_docs_env=tox_has_docs_env,
216
+ tox_docs_commands=tox_docs_commands,
217
+ tox_dependency_spec=tox_dependency_spec,
218
+ has_nox=has_nox,
219
+ nox_file=nox_file,
220
+ has_makefile=has_makefile,
221
+ has_pre_commit=has_pre_commit,
222
+ gitlab_ci_detected=gitlab_ci_detected,
223
+ )
@@ -0,0 +1,134 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Dict
3
+ import importlib.metadata
4
+ from ..parsing.requirements import (
5
+ parse_pyproject_toml,
6
+ parse_requirements_txt,
7
+ NormalizedManifest,
8
+ )
9
+ from .models import DependencyAnalysis, VersionEnvironment, PackageVersionInfo
10
+
11
+ KNOWN_MKDOCS_PACKAGES = {
12
+ "mkdocs",
13
+ "mkdocs-material",
14
+ "mkdocstrings",
15
+ "mkdocstrings-python",
16
+ "mkdocs-material-extensions",
17
+ "pymdown-extensions",
18
+ "mkdocs-mermaid2-plugin",
19
+ "mike",
20
+ "mkdocs-autorefs",
21
+ "mkdocs-literate-nav",
22
+ "mkdocs-macros-plugin",
23
+ "mdx_truly_sane_lists",
24
+ "mkdocs-awesome-pages-plugin",
25
+ "mkdocs-gen-files",
26
+ }
27
+
28
+
29
+ class DependencyAnalyzer:
30
+ def __init__(self, project_root: Path):
31
+ self.project_root = project_root
32
+
33
+ def analyze(self) -> tuple[Optional[DependencyAnalysis], VersionEnvironment]:
34
+ pyproject = self.project_root / "pyproject.toml"
35
+ docs_req = self.project_root / "docs" / "requirements.txt"
36
+ req = self.project_root / "requirements.txt"
37
+
38
+ manifest: Optional[NormalizedManifest] = None
39
+ if pyproject.exists():
40
+ manifest = parse_pyproject_toml(pyproject)
41
+ elif docs_req.exists():
42
+ manifest = parse_requirements_txt(docs_req)
43
+ elif req.exists():
44
+ manifest = parse_requirements_txt(req)
45
+
46
+ if not manifest:
47
+ return None, VersionEnvironment()
48
+
49
+ to_remove = []
50
+ source_group_type: Optional[str] = None
51
+ source_group_name: Optional[str] = None
52
+ for dep in manifest.dependencies:
53
+ if dep.name in KNOWN_MKDOCS_PACKAGES:
54
+ to_remove.append(dep.name)
55
+ if not source_group_name and dep.source_location:
56
+ if dep.source_location.startswith("dependency-groups."):
57
+ source_group_type = "dependency-groups"
58
+ source_group_name = dep.source_location.split(".", 1)[1]
59
+ elif dep.source_location.startswith(
60
+ "project.optional-dependencies."
61
+ ):
62
+ source_group_type = "optional-dependencies"
63
+ source_group_name = dep.source_location.split(".", 2)[2]
64
+ elif "requirements" in dep.source_location:
65
+ source_group_type = "requirements"
66
+ source_group_name = dep.source_location
67
+
68
+ suggested_to_add = ["sphinx", "myst-parser", "sphinx-design"]
69
+ if "mkdocs-material" in to_remove:
70
+ suggested_to_add.append("sphinx-immaterial")
71
+
72
+ dep_analysis = DependencyAnalysis(
73
+ manifest_type=manifest.manifest_type,
74
+ detected_packages_to_remove=sorted(list(set(to_remove))),
75
+ suggested_packages_to_add=suggested_to_add,
76
+ source_group_type=source_group_type,
77
+ source_group_name=source_group_name,
78
+ )
79
+
80
+ packages_info: Dict[str, PackageVersionInfo] = {}
81
+ plugin_vers: Dict[str, str] = {}
82
+ mkdocs_ver = None
83
+
84
+ import sys
85
+
86
+ current_py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
87
+
88
+ for dep in manifest.dependencies:
89
+ if dep.name == "mkdocs" or dep.name in KNOWN_MKDOCS_PACKAGES:
90
+ # Check if installed in active environment
91
+ installed_ver = None
92
+ status = "DECLARED_ONLY"
93
+ res_source = "MANIFEST_DECLARATION"
94
+ satisfies_constraint = None
95
+
96
+ try:
97
+ installed_ver = importlib.metadata.version(dep.name)
98
+ status = "INSTALLED_RESOLVED"
99
+ res_source = "CURRENT_PYTHON_ENVIRONMENT"
100
+ # Basic constraint check if `>=` specified
101
+ if ">=" in dep.raw_spec:
102
+ req_ver = dep.raw_spec.split(">=")[-1].strip()
103
+ satisfies_constraint = installed_ver >= req_ver
104
+ else:
105
+ satisfies_constraint = True
106
+ except Exception:
107
+ pass
108
+
109
+ pkg_info = PackageVersionInfo(
110
+ package_name=dep.name,
111
+ declared_spec=dep.raw_spec,
112
+ resolved_version=installed_ver,
113
+ resolution_status=status,
114
+ resolution_source=res_source,
115
+ satisfies_declared_constraint=satisfies_constraint,
116
+ )
117
+ packages_info[dep.name] = pkg_info
118
+
119
+ if dep.name == "mkdocs":
120
+ mkdocs_ver = dep.raw_spec
121
+ else:
122
+ plugin_vers[dep.name] = dep.raw_spec
123
+
124
+ version_env = VersionEnvironment(
125
+ python_version=manifest.python_requirement,
126
+ python_constraint=manifest.python_requirement,
127
+ python_resolved_version=current_py_ver,
128
+ python_resolution_status="INSTALLED_RESOLVED",
129
+ packages=packages_info,
130
+ detected_mkdocs_version=mkdocs_ver,
131
+ detected_plugin_versions=plugin_vers,
132
+ )
133
+
134
+ return dep_analysis, version_env
@@ -0,0 +1,148 @@
1
+ """Markdown AST analyzer inspecting DocumentIR nodes to extract migration findings."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Tuple, Optional
5
+ from ..parsing.markdown import MarkdownParser
6
+ from ..parsing.markdown_ir import NodeKind, BaseIRNode
7
+ from .models import ConstructFinding, Classification
8
+
9
+
10
+ class MarkdownAnalyzer:
11
+ """Analyzes Markdown files by parsing them into DocumentIR and recording migration findings."""
12
+
13
+ def __init__(self, project_root: Optional[Path] = None):
14
+ self.project_root = project_root
15
+ self.parser = MarkdownParser()
16
+
17
+ def analyze_file(self, file_path: Path) -> List[ConstructFinding]:
18
+ if not file_path.exists():
19
+ return []
20
+
21
+ doc_ir = self.parser.parse_file(file_path)
22
+ findings: List[ConstructFinding] = []
23
+
24
+ def visit_node(node: BaseIRNode):
25
+ snippet = (
26
+ node.raw_text.splitlines()[0]
27
+ if node.raw_text.splitlines()
28
+ else f"<{node.kind.value}>"
29
+ )
30
+
31
+ if node.kind == NodeKind.ADMONITION:
32
+ findings.append(
33
+ ConstructFinding(
34
+ category="Markdown",
35
+ construct_type="admonition",
36
+ file_path=doc_ir.file_path,
37
+ line_number=node.start_line,
38
+ end_line_number=node.end_line,
39
+ raw_snippet=snippet,
40
+ classification=Classification.TRANSFORM,
41
+ metadata={
42
+ "admonition_type": node.metadata.get("admonition_type"),
43
+ "title": node.metadata.get("title"),
44
+ },
45
+ )
46
+ )
47
+ elif node.kind == NodeKind.DETAILS_DROPDOWN:
48
+ findings.append(
49
+ ConstructFinding(
50
+ category="Markdown",
51
+ construct_type="details_dropdown",
52
+ file_path=doc_ir.file_path,
53
+ line_number=node.start_line,
54
+ end_line_number=node.end_line,
55
+ raw_snippet=snippet,
56
+ classification=Classification.TRANSFORM,
57
+ metadata={"title": node.metadata.get("title")},
58
+ )
59
+ )
60
+ elif node.kind == NodeKind.TAB_SET:
61
+ findings.append(
62
+ ConstructFinding(
63
+ category="Markdown",
64
+ construct_type="tab_set",
65
+ file_path=doc_ir.file_path,
66
+ line_number=node.start_line,
67
+ end_line_number=node.end_line,
68
+ raw_snippet=snippet,
69
+ classification=Classification.TRANSFORM,
70
+ metadata={"tab_count": len(node.children)},
71
+ )
72
+ )
73
+ elif node.kind == NodeKind.API_DIRECTIVE:
74
+ findings.append(
75
+ ConstructFinding(
76
+ category="API Documentation",
77
+ construct_type="mkdocstrings",
78
+ file_path=doc_ir.file_path,
79
+ line_number=node.start_line,
80
+ end_line_number=node.end_line,
81
+ raw_snippet=snippet,
82
+ classification=Classification.MANUAL,
83
+ metadata={"symbol": node.metadata.get("symbol")},
84
+ )
85
+ )
86
+ elif node.kind == NodeKind.SNIPPET_INCLUDE:
87
+ findings.append(
88
+ ConstructFinding(
89
+ category="Markdown",
90
+ construct_type="snippet_include",
91
+ file_path=doc_ir.file_path,
92
+ line_number=node.start_line,
93
+ end_line_number=node.end_line,
94
+ raw_snippet=snippet,
95
+ classification=Classification.TRANSFORM,
96
+ metadata={"filepath": node.metadata.get("filepath")},
97
+ )
98
+ )
99
+ elif node.kind == NodeKind.MERMAID_DIAGRAM:
100
+ findings.append(
101
+ ConstructFinding(
102
+ category="Markdown",
103
+ construct_type="mermaid_diagram",
104
+ file_path=doc_ir.file_path,
105
+ line_number=node.start_line,
106
+ end_line_number=node.end_line,
107
+ raw_snippet=snippet,
108
+ classification=Classification.TRANSFORM,
109
+ metadata={},
110
+ )
111
+ )
112
+ elif node.kind == NodeKind.LINK_REF:
113
+ href = node.metadata.get("href", "")
114
+ if href.endswith(".md") or ".md#" in href:
115
+ findings.append(
116
+ ConstructFinding(
117
+ category="Markdown",
118
+ construct_type="document_link",
119
+ file_path=doc_ir.file_path,
120
+ line_number=node.start_line,
121
+ end_line_number=node.end_line,
122
+ raw_snippet=href,
123
+ classification=Classification.PRESERVE,
124
+ metadata={"href": href, "text": node.metadata.get("text")},
125
+ )
126
+ )
127
+
128
+ # Recurse children
129
+ for child in node.children:
130
+ visit_node(child)
131
+
132
+ for root_node in doc_ir.nodes:
133
+ visit_node(root_node)
134
+
135
+ return findings
136
+
137
+ def analyze_directory(self, docs_dir: Path) -> Tuple[int, List[ConstructFinding]]:
138
+ if not docs_dir.exists():
139
+ return 0, []
140
+
141
+ all_findings: List[ConstructFinding] = []
142
+ md_files = list(docs_dir.rglob("*.md"))
143
+
144
+ for md_file in md_files:
145
+ file_findings = self.analyze_file(md_file)
146
+ all_findings.extend(file_findings)
147
+
148
+ return len(md_files), all_findings