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,263 @@
1
+ """Subsystem analyzer for mkdocs.yml configuration with robust YAML tag handling."""
2
+
3
+ import yaml
4
+ from pathlib import Path
5
+ from typing import Optional, List, Any, Dict
6
+ from .models import ConfigAnalysis, ThemePalette, ThemeFont
7
+
8
+
9
+ class SafeMkDocsLoader(yaml.SafeLoader):
10
+ """Custom YAML loader ignoring python-specific tags and environment constructors in mkdocs.yml."""
11
+
12
+ pass
13
+
14
+
15
+ # Ignore all unknown custom YAML tags like !!python/name, !ENV, !relative, etc.
16
+ def _ignore_unknown_tags(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node):
17
+ if isinstance(node, yaml.ScalarNode):
18
+ return loader.construct_scalar(node)
19
+ elif isinstance(node, yaml.SequenceNode):
20
+ return loader.construct_sequence(node)
21
+ elif isinstance(node, yaml.MappingNode):
22
+ return loader.construct_mapping(node)
23
+ return None
24
+
25
+
26
+ SafeMkDocsLoader.add_multi_constructor("!", _ignore_unknown_tags)
27
+ SafeMkDocsLoader.add_multi_constructor(
28
+ "tag:yaml.org,2002:python/", _ignore_unknown_tags
29
+ )
30
+
31
+
32
+ class MkDocsConfigAnalyzer:
33
+ def __init__(self, project_root: Path):
34
+ self.project_root = project_root
35
+
36
+ def _find_config_file(self) -> Optional[Path]:
37
+ """Discovers mkdocs.yml in root or common documentation subdirectories."""
38
+ for candidate in [
39
+ "mkdocs.yml",
40
+ "mkdocs.yaml",
41
+ "docs/en/mkdocs.yml",
42
+ "docs/mkdocs.yml",
43
+ ".mkdocs.yml",
44
+ "mkdocs.template.yml",
45
+ ]:
46
+ path = self.project_root / candidate
47
+ if path.exists():
48
+ return path
49
+ # Fallback search
50
+ all_ymls = list(self.project_root.glob("*mkdocs*.yml")) + list(
51
+ self.project_root.glob("*mkdocs*.yaml")
52
+ )
53
+ if all_ymls:
54
+ return all_ymls[0]
55
+ return None
56
+
57
+ def analyze(self) -> ConfigAnalysis:
58
+ mkdocs_file = self._find_config_file()
59
+ if not mkdocs_file or not mkdocs_file.exists():
60
+ return ConfigAnalysis()
61
+
62
+ try:
63
+ content = mkdocs_file.read_text(encoding="utf-8")
64
+ data = yaml.load(content, Loader=SafeMkDocsLoader) or {}
65
+ except Exception:
66
+ try:
67
+ # Fallback simple load
68
+ data = yaml.safe_load(mkdocs_file.read_text(encoding="utf-8")) or {}
69
+ except Exception:
70
+ return ConfigAnalysis()
71
+
72
+ theme_data = data.get("theme", {})
73
+ theme_logo = None
74
+ theme_icon = None
75
+ theme_favicon = None
76
+ theme_language = None
77
+ theme_font = None
78
+ theme_palette: List[ThemePalette] = []
79
+
80
+ if isinstance(theme_data, str):
81
+ theme_name = theme_data
82
+ features = []
83
+ elif isinstance(theme_data, dict):
84
+ theme_name = theme_data.get("name", "mkdocs")
85
+ features = theme_data.get("features", [])
86
+ theme_logo = theme_data.get("logo")
87
+ theme_icon = (
88
+ theme_data.get("icon")
89
+ if isinstance(theme_data.get("icon"), dict)
90
+ else None
91
+ )
92
+ theme_favicon = theme_data.get("favicon")
93
+ theme_language = theme_data.get("language")
94
+
95
+ # Palette parsing (can be a dict or list of dicts in Material for MkDocs)
96
+ pal = theme_data.get("palette")
97
+ if isinstance(pal, dict):
98
+ theme_palette.append(
99
+ ThemePalette(
100
+ scheme=pal.get("scheme"),
101
+ primary=pal.get("primary"),
102
+ accent=pal.get("accent"),
103
+ toggle_icon=pal.get("toggle", {}).get("icon")
104
+ if isinstance(pal.get("toggle"), dict)
105
+ else None,
106
+ toggle_name=pal.get("toggle", {}).get("name")
107
+ if isinstance(pal.get("toggle"), dict)
108
+ else None,
109
+ )
110
+ )
111
+ elif isinstance(pal, list):
112
+ for p in pal:
113
+ if isinstance(p, dict):
114
+ theme_palette.append(
115
+ ThemePalette(
116
+ scheme=p.get("scheme"),
117
+ primary=p.get("primary"),
118
+ accent=p.get("accent"),
119
+ toggle_icon=p.get("toggle", {}).get("icon")
120
+ if isinstance(p.get("toggle"), dict)
121
+ else None,
122
+ toggle_name=p.get("toggle", {}).get("name")
123
+ if isinstance(p.get("toggle"), dict)
124
+ else None,
125
+ )
126
+ )
127
+
128
+ # Font parsing
129
+ font_data = theme_data.get("font")
130
+ if isinstance(font_data, dict):
131
+ theme_font = ThemeFont(
132
+ text=font_data.get("text"), code=font_data.get("code")
133
+ )
134
+ else:
135
+ theme_name = "mkdocs"
136
+ features = []
137
+
138
+ plugins: List[str] = []
139
+ plugins_config: Dict[str, Any] = {}
140
+ for p in data.get("plugins", []):
141
+ if isinstance(p, str):
142
+ plugins.append(p)
143
+ plugins_config[p] = {}
144
+ elif isinstance(p, dict):
145
+ for k, v in p.items():
146
+ plugins.append(k)
147
+ plugins_config[k] = v if isinstance(v, dict) else {}
148
+ hooks = data.get("hooks", [])
149
+
150
+ # Normalize markdown_extensions list (strings or {name: dict_options})
151
+ raw_md_exts = data.get("markdown_extensions", [])
152
+ normalized_md_exts: List[str] = []
153
+ if isinstance(raw_md_exts, list):
154
+ for ext in raw_md_exts:
155
+ if isinstance(ext, str):
156
+ normalized_md_exts.append(ext)
157
+ elif isinstance(ext, dict):
158
+ normalized_md_exts.extend(list(ext.keys()))
159
+
160
+ # Determine docs_dir: if config is in a subdirectory (e.g. docs/en/mkdocs.yml), adjust relative path
161
+ raw_docs_dir = data.get("docs_dir", "docs")
162
+ if mkdocs_file.parent != self.project_root:
163
+ rel_parent = mkdocs_file.parent.relative_to(self.project_root).as_posix()
164
+ if raw_docs_dir == "docs":
165
+ adjusted_docs_dir = (
166
+ f"{rel_parent}/docs"
167
+ if (self.project_root / rel_parent / "docs").exists()
168
+ else rel_parent
169
+ )
170
+ else:
171
+ adjusted_docs_dir = f"{rel_parent}/{raw_docs_dir}"
172
+ else:
173
+ adjusted_docs_dir = raw_docs_dir
174
+
175
+ site_name = (
176
+ data.get("site_name") or self.project_root.name.replace("-", " ").title()
177
+ )
178
+
179
+ # Extra CSS & JS
180
+ extra_css = data.get("extra_css", [])
181
+ if isinstance(extra_css, str):
182
+ extra_css = [extra_css]
183
+ extra_js = data.get("extra_javascript", [])
184
+ if isinstance(extra_js, str):
185
+ extra_js = [extra_js]
186
+ extra = data.get("extra", {}) if isinstance(data.get("extra"), dict) else {}
187
+
188
+ return ConfigAnalysis(
189
+ site_name=site_name,
190
+ site_description=data.get("site_description"),
191
+ site_author=data.get("site_author"),
192
+ site_url=data.get("site_url"),
193
+ repo_url=data.get("repo_url"),
194
+ repo_name=data.get("repo_name"),
195
+ edit_uri=data.get("edit_uri"),
196
+ copyright=data.get("copyright"),
197
+ docs_dir=adjusted_docs_dir,
198
+ theme_name=theme_name,
199
+ theme_logo=theme_logo,
200
+ theme_icon=theme_icon,
201
+ theme_favicon=theme_favicon,
202
+ theme_language=theme_language,
203
+ theme_palette=theme_palette,
204
+ theme_font=theme_font,
205
+ theme_features=features,
206
+ plugins=plugins,
207
+ plugins_config=plugins_config,
208
+ markdown_extensions=normalized_md_exts,
209
+ nav_raw=data.get("nav"),
210
+ custom_hooks=hooks,
211
+ extra_css=extra_css,
212
+ extra_javascript=extra_js,
213
+ extra=extra,
214
+ raw_config_keys=list(data.keys()) if isinstance(data, dict) else [],
215
+ )
216
+
217
+
218
+ def detect_obsolete_generator_scripts(
219
+ project_root: Path,
220
+ mkdocs_config: Optional[ConfigAnalysis],
221
+ additional_scripts: Optional[List[str]] = None,
222
+ ) -> List[str]:
223
+ """Detect obsolete MkDocs generator scripts and hooks (e.g. scripts/gen_ref_nav.py) that should be removed.
224
+
225
+ These scripts run at build time under MkDocs (e.g., mkdocs-gen-files) to generate virtual stubs.
226
+ Once Sphinx autodoc/autosummary is configured and MkDocs dependencies are removed, these scripts
227
+ become obsolete, broken, and trigger repo lint failures.
228
+ """
229
+ if not mkdocs_config:
230
+ return []
231
+
232
+ candidate_scripts: set[str] = set()
233
+ gen_cfg = mkdocs_config.plugins_config.get("gen-files", {})
234
+ if isinstance(gen_cfg, dict):
235
+ for s in gen_cfg.get("scripts", []):
236
+ if isinstance(s, str):
237
+ candidate_scripts.add(s)
238
+
239
+ if additional_scripts:
240
+ for s in additional_scripts:
241
+ if isinstance(s, str):
242
+ candidate_scripts.add(s)
243
+
244
+ for hook in mkdocs_config.custom_hooks or []:
245
+ if isinstance(hook, str):
246
+ candidate_scripts.add(hook)
247
+
248
+ obsolete: List[str] = []
249
+ for s_rel in sorted(candidate_scripts):
250
+ s_path = project_root / s_rel
251
+ if s_path.is_file():
252
+ try:
253
+ content = s_path.read_text(encoding="utf-8")
254
+ except Exception:
255
+ content = ""
256
+ if (
257
+ "mkdocs" in content
258
+ or "mkdocstrings" in content
259
+ or (isinstance(gen_cfg, dict) and s_rel in gen_cfg.get("scripts", []))
260
+ ):
261
+ obsolete.append(s_rel)
262
+
263
+ return obsolete
@@ -0,0 +1,348 @@
1
+ """Data models for migration analysis, findings, effective specifications, and migration requirements."""
2
+
3
+ import hashlib
4
+ import json
5
+ from enum import Enum
6
+ from typing import List, Dict, Any, Optional
7
+ from pydantic import BaseModel, Field
8
+
9
+ from ..parsing.doc_ir import (
10
+ DocumentationSiteGraph,
11
+ DocumentationBuildGraph,
12
+ DocumentFlowSpec,
13
+ ApiDocumentationRequest,
14
+ DOCUMENTATION_IR_SCHEMA_VERSION,
15
+ )
16
+
17
+
18
+ class Classification(str, Enum):
19
+ PRESERVE = "PRESERVE"
20
+ TRANSFORM = "TRANSFORM"
21
+ MANUAL = "MANUAL"
22
+ UNSUPPORTED = "UNSUPPORTED"
23
+
24
+
25
+ class RequirementDisposition(str, Enum):
26
+ PRESERVE = "PRESERVE"
27
+ TRANSFORM = "TRANSFORM"
28
+ GENERATE = "GENERATE"
29
+ COPY = "COPY"
30
+ CONFIGURE = "CONFIGURE"
31
+ ACCOUNT_NO_DIRECT_EQUIVALENT = "ACCOUNT_NO_DIRECT_EQUIVALENT"
32
+ MANUAL_ACTION = "MANUAL_ACTION"
33
+
34
+
35
+ class RequirementCategory(str, Enum):
36
+ FLOW = "FLOW"
37
+ API = "API"
38
+ THEME_FEATURE = "THEME_FEATURE"
39
+ MARKDOWN_EXTENSION = "MARKDOWN_EXTENSION"
40
+ PLUGIN = "PLUGIN"
41
+ NAVIGATION = "NAVIGATION"
42
+ BUILD_GRAPH = "BUILD_GRAPH"
43
+ ASSET = "ASSET"
44
+ CONFIG = "CONFIG"
45
+
46
+
47
+ class MigrationRequirement(BaseModel):
48
+ """Explicit requirement derived during analysis that must be realized by the migration plan."""
49
+
50
+ requirement_id: str
51
+ category: RequirementCategory
52
+ disposition: RequirementDisposition
53
+ source_construct: str
54
+ required_outcome: str
55
+ rationale: str
56
+ verification_check: Optional[str] = None
57
+ provenance_location: Optional[str] = None
58
+
59
+
60
+ class PropertyResolutionState(str, Enum):
61
+ CONFIGURED = "CONFIGURED" # Explicitly declared in mkdocs.yml
62
+ DEFAULTED = "DEFAULTED" # Inferred from package / theme default
63
+ DERIVED = "DERIVED" # Computed from another configuration property
64
+ GENERATED = "GENERATED" # Generated by plugin / build step
65
+ UNRESOLVED = "UNRESOLVED" # Unknown / cannot resolve
66
+ UNSUPPORTED = "UNSUPPORTED" # Not supported in migration target
67
+
68
+
69
+ class ResolutionProvenance(BaseModel):
70
+ source_type: (
71
+ str # e.g., "mkdocs.yml", "package_default", "theme_default", "derived"
72
+ )
73
+ source_location: Optional[str] = None
74
+ package_name: Optional[str] = None
75
+ package_version: Optional[str] = None
76
+ rationale: Optional[str] = None
77
+
78
+
79
+ class ResolvedProperty(BaseModel):
80
+ key: str
81
+ configured_value: Optional[Any] = None
82
+ default_value: Optional[Any] = None
83
+ effective_value: Any
84
+ state: PropertyResolutionState
85
+ provenance: ResolutionProvenance
86
+ description: Optional[str] = None
87
+
88
+
89
+ class ResolvedEffectiveConfig(BaseModel):
90
+ properties: Dict[str, ResolvedProperty] = Field(default_factory=dict)
91
+
92
+ def get(self, key: str, default: Any = None) -> Any:
93
+ prop = self.properties.get(key)
94
+ if prop is not None:
95
+ return prop.effective_value
96
+ return default
97
+
98
+
99
+ class ResolvedApiSymbol(BaseModel):
100
+ fully_qualified_name: str
101
+ symbol_kind: str # module, class, function, method, attribute, exception
102
+ docstring: Optional[str] = None
103
+ signature: Optional[str] = None
104
+ is_public: bool = True
105
+ parent_symbol: Optional[str] = None
106
+ source_file: Optional[str] = None
107
+ source_line: Optional[int] = None
108
+ member_names: List[str] = Field(default_factory=list)
109
+
110
+
111
+ class ResolvedApiModule(BaseModel):
112
+ module_path: str
113
+ docstring: Optional[str] = None
114
+ symbols: Dict[str, ResolvedApiSymbol] = Field(default_factory=dict)
115
+ summary_mode: str = "NOT_REQUESTED"
116
+ explicit_members: List[str] = Field(default_factory=list)
117
+
118
+
119
+ class ResolvedCapabilityItem(BaseModel):
120
+ capability_name: str
121
+ source_feature: str
122
+ state: PropertyResolutionState
123
+ target_strategy: str
124
+ details: Optional[str] = None
125
+
126
+
127
+ class ResolvedUnresolvedItem(BaseModel):
128
+ item_id: str
129
+ category: str
130
+ raw_snippet: Optional[str] = None
131
+ rationale: str
132
+
133
+
134
+ class ConstructFinding(BaseModel):
135
+ category: str
136
+ construct_type: str
137
+ file_path: str
138
+ line_number: int
139
+ end_line_number: Optional[int] = None
140
+ raw_snippet: str
141
+ metadata: Dict[str, Any] = Field(default_factory=dict)
142
+ classification: Classification = Classification.TRANSFORM
143
+
144
+
145
+ class MigrationRule(BaseModel):
146
+ source_construct: str
147
+ target_directive_or_construct: str
148
+ required_sphinx_extensions: List[str] = Field(default_factory=list)
149
+ description: str
150
+
151
+
152
+ class ThemePalette(BaseModel):
153
+ scheme: Optional[str] = None
154
+ primary: Optional[str] = None
155
+ accent: Optional[str] = None
156
+ toggle_icon: Optional[str] = None
157
+ toggle_name: Optional[str] = None
158
+
159
+
160
+ class ThemeFont(BaseModel):
161
+ text: Optional[str] = None
162
+ code: Optional[str] = None
163
+
164
+
165
+ class ConfigAnalysis(BaseModel):
166
+ site_name: Optional[str] = None
167
+ site_description: Optional[str] = None
168
+ site_author: Optional[str] = None
169
+ site_url: Optional[str] = None
170
+ repo_url: Optional[str] = None
171
+ repo_name: Optional[str] = None
172
+ edit_uri: Optional[str] = None
173
+ copyright: Optional[str] = None
174
+ docs_dir: str = "docs"
175
+ theme_name: str = "mkdocs"
176
+ theme_logo: Optional[str] = None
177
+ theme_icon: Optional[Dict[str, Any]] = None
178
+ theme_favicon: Optional[str] = None
179
+ theme_language: Optional[str] = None
180
+ theme_palette: List[ThemePalette] = Field(default_factory=list)
181
+ theme_font: Optional[ThemeFont] = None
182
+ theme_features: List[str] = Field(default_factory=list)
183
+ plugins: List[str] = Field(default_factory=list)
184
+ plugins_config: Dict[str, Any] = Field(default_factory=dict)
185
+ markdown_extensions: List[str] = Field(default_factory=list)
186
+ nav_raw: Optional[Any] = None
187
+ custom_hooks: List[str] = Field(default_factory=list)
188
+ extra_css: List[str] = Field(default_factory=list)
189
+ extra_javascript: List[str] = Field(default_factory=list)
190
+ extra: Dict[str, Any] = Field(default_factory=dict)
191
+ raw_config_keys: List[str] = Field(default_factory=list)
192
+
193
+
194
+ class NavigationItem(BaseModel):
195
+ title: str
196
+ path: Optional[str] = None
197
+ children: List["NavigationItem"] = Field(default_factory=list)
198
+
199
+
200
+ class NavigationAnalysis(BaseModel):
201
+ has_nav: bool = False
202
+ total_nav_entries: int = 0
203
+ max_depth: int = 0
204
+ missing_references: List[str] = Field(default_factory=list)
205
+ generated_wildcard_references: List[str] = Field(default_factory=list)
206
+ orphan_documents: List[str] = Field(default_factory=list)
207
+ tree: List[NavigationItem] = Field(default_factory=list)
208
+
209
+
210
+ class PackageVersionInfo(BaseModel):
211
+ package_name: str
212
+ declared_spec: str
213
+ resolved_version: Optional[str] = None
214
+ resolution_status: str = (
215
+ "DECLARED_ONLY" # "INSTALLED_RESOLVED" | "DECLARED_ONLY" | "UNRESOLVED"
216
+ )
217
+ resolution_source: Optional[str] = (
218
+ None # e.g., "CURRENT_PYTHON_ENVIRONMENT" | "MANIFEST_CONSTRAINT"
219
+ )
220
+ satisfies_declared_constraint: Optional[bool] = None
221
+
222
+
223
+ class VersionEnvironment(BaseModel):
224
+ python_version: Optional[str] = None # Deprecated alias for backwards compat
225
+ python_constraint: Optional[str] = None
226
+ python_resolved_version: Optional[str] = None
227
+ python_resolution_status: str = (
228
+ "DECLARED_ONLY" # "INSTALLED_RESOLVED" | "DECLARED_ONLY"
229
+ )
230
+ packages: Dict[str, PackageVersionInfo] = Field(default_factory=dict)
231
+ detected_mkdocs_version: Optional[str] = None
232
+ detected_plugin_versions: Dict[str, str] = Field(default_factory=dict)
233
+ target_sphinx_version: str = ">=7.0"
234
+ target_myst_version: str = ">=2.0"
235
+
236
+
237
+ class DependencyAnalysis(BaseModel):
238
+ manifest_type: Optional[str] = None
239
+ detected_packages_to_remove: List[str] = Field(default_factory=list)
240
+ suggested_packages_to_add: List[str] = Field(default_factory=list)
241
+ source_group_type: Optional[str] = None
242
+ source_group_name: Optional[str] = None
243
+
244
+
245
+ class CIAnalysis(BaseModel):
246
+ ci_system: Optional[str] = None
247
+ workflow_files: List[str] = Field(default_factory=list)
248
+ docs_workflow_files: List[str] = Field(default_factory=list)
249
+ has_mkdocs_deploy: bool = False
250
+ readthedocs_detected: bool = False
251
+ rtd_config_file: Optional[str] = None
252
+ github_actions_detected: bool = False
253
+ has_tox_in_ci: bool = False
254
+ has_uv_in_ci: bool = False
255
+ has_gh_pages_action: bool = False
256
+ checkout_action_ref: Optional[str] = None
257
+ setup_uv_action_ref: Optional[str] = None
258
+ checkout_pinned_ref: Optional[str] = None
259
+ setup_uv_pinned_ref: Optional[str] = None
260
+ has_tox: bool = False
261
+ tox_file: Optional[str] = None
262
+ tox_has_docs_env: bool = False
263
+ tox_docs_commands: List[str] = Field(default_factory=list)
264
+ tox_dependency_spec: Optional[str] = None
265
+ has_nox: bool = False
266
+ nox_file: Optional[str] = None
267
+ has_makefile: bool = False
268
+ has_pre_commit: bool = False
269
+ gitlab_ci_detected: bool = False
270
+
271
+
272
+ class SubsystemSummary(BaseModel):
273
+ name: str
274
+ status: str
275
+ details: str
276
+
277
+
278
+ class ProjectAnalysisReport(BaseModel):
279
+ """Complete, factual and effective analysis of the source MkDocs documentation repository."""
280
+
281
+ schema_version: str = DOCUMENTATION_IR_SCHEMA_VERSION
282
+ project_root: str
283
+ mkdocs_config: Optional[ConfigAnalysis] = None
284
+ effective_config: ResolvedEffectiveConfig = Field(
285
+ default_factory=ResolvedEffectiveConfig
286
+ )
287
+ version_env: Optional[VersionEnvironment] = None
288
+ dependency_analysis: Optional[DependencyAnalysis] = None
289
+ navigation_analysis: Optional[NavigationAnalysis] = None
290
+ ci_analysis: Optional[CIAnalysis] = None
291
+ total_markdown_files: int = 0
292
+ construct_findings: List[ConstructFinding] = Field(default_factory=list)
293
+ document_flows: Dict[str, DocumentFlowSpec] = Field(default_factory=dict)
294
+ documentation_site_graph: Optional[DocumentationSiteGraph] = None
295
+ build_graph: Optional[DocumentationBuildGraph] = None
296
+ api_requests: List[ApiDocumentationRequest] = Field(default_factory=list)
297
+ resolved_api_modules: Dict[str, ResolvedApiModule] = Field(default_factory=dict)
298
+ capabilities: List[ResolvedCapabilityItem] = Field(default_factory=list)
299
+ migration_requirements: List[MigrationRequirement] = Field(default_factory=list)
300
+ unresolved_items: List[ResolvedUnresolvedItem] = Field(default_factory=list)
301
+ subsystem_summaries: List[SubsystemSummary] = Field(default_factory=list)
302
+ manual_action_items: List[str] = Field(default_factory=list)
303
+
304
+ def source_canonical_dict(self) -> Dict[str, Any]:
305
+ """Returns deterministic source repository semantics excluding ephemeral environment observations."""
306
+ data = self.model_dump()
307
+ # Exclude environment-specific observation details from source canonical identity
308
+ if "version_env" in data and data["version_env"]:
309
+ v = dict(data["version_env"])
310
+ v.pop("python_resolved_version", None)
311
+ v.pop("python_resolution_status", None)
312
+ if "packages" in v and isinstance(v["packages"], dict):
313
+ cleaned_pkgs = {}
314
+ for name, pkg in v["packages"].items():
315
+ if isinstance(pkg, dict):
316
+ p_copy = dict(pkg)
317
+ p_copy.pop("resolved_version", None)
318
+ p_copy.pop("resolution_status", None)
319
+ p_copy.pop("resolution_source", None)
320
+ p_copy.pop("satisfies_declared_constraint", None)
321
+ cleaned_pkgs[name] = p_copy
322
+ v["packages"] = cleaned_pkgs
323
+ data["version_env"] = v
324
+ return data
325
+
326
+ def canonical_dict(self) -> Dict[str, Any]:
327
+ return self.source_canonical_dict()
328
+
329
+ def canonical_hash(self) -> str:
330
+ canonical_json = json.dumps(self.canonical_dict(), sort_keys=True, default=str)
331
+ return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
332
+
333
+ def environment_fingerprint(self) -> Optional[str]:
334
+ """Returns stable fingerprint of the observed execution-environment."""
335
+ if not self.version_env:
336
+ return None
337
+ env_dict = {
338
+ "python_resolved_version": self.version_env.python_resolved_version,
339
+ "packages": {
340
+ name: {
341
+ "resolved_version": pkg.resolved_version,
342
+ "resolution_source": pkg.resolution_source,
343
+ }
344
+ for name, pkg in self.version_env.packages.items()
345
+ },
346
+ }
347
+ env_json = json.dumps(env_dict, sort_keys=True, default=str)
348
+ return hashlib.sha256(env_json.encode("utf-8")).hexdigest()