PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_docs_site.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Verify published-site integrity against the generated HTML, not against source declarations."""
3
4from __future__ import annotations
5
6import json
7import re
8import subprocess
9import sys
10import xml.etree.ElementTree as ET
11from pathlib import Path
12
13sys.path.insert(0, str(Path(__file__).resolve().parent))
14from repo_files import enumerate_repository_files # noqa: E402
15
16
17REPO_ROOT = Path(__file__).resolve().parents[2]
18CONTRACT_PATH = REPO_ROOT / "tests" / "tooling" / "docs_site_contract.json"
19SKIP_DIRS = {".git", "docs_build", "obj", "bin", "stubs", "runs", "studies", "__pycache__", ".pytest_cache"}
20SCAN_SUFFIXES = {".md", ".js", ".xml", ".html", ".yml", ".yaml"}
21
22
23def load_contract() -> dict:
24 """!
25 @brief Load the published-site integrity contract.
26 @return Parsed contract mapping.
27 """
28 return json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
29
30
31def git_tracked_files() -> list[Path] | None:
32 """!
33 @brief List tracked plus non-ignored untracked files, so the scan set is reproducible
34 for a commit and generated scratch is excluded without excluding tracked docs.
35 @return Sorted paths, or None when git enumeration is unavailable.
36 """
37 return enumerate_repository_files(REPO_ROOT)
38
39
41 """!
42 @brief Yield repository files whose text may carry project-owned URLs.
43 @return Generator of file paths.
44 """
45 candidates = git_tracked_files()
46 if candidates is None:
47 candidates = sorted(REPO_ROOT.rglob("*"))
48 for path in candidates:
49 if not path.is_file() or path.suffix not in SCAN_SUFFIXES:
50 continue
51 if SKIP_DIRS.intersection(path.relative_to(REPO_ROOT).parts):
52 continue
53 yield path
54
55
56def require_built_html(contract: dict) -> Path:
57 """!
58 @brief Resolve the generated HTML tree, failing loudly when it has not been built.
59 @param[in] contract Parsed contract mapping.
60 @return Path to the generated HTML directory.
61 @throws RuntimeError when the publication artifact is absent.
62 """
63 html_dir = REPO_ROOT / contract["html_dir"]
64 if not (html_dir / "index.html").is_file():
65 raise RuntimeError(
66 f"{contract['html_dir']}/index.html is missing. This audit validates against the built "
67 "publication artifact; run 'make build-docs' first."
68 )
69 return html_dir
70
71
72def check_forbidden_urls(contract: dict) -> list[str]:
73 """!
74 @brief Reject project-owned URL forms that are known not to resolve.
75 @param[in] contract Parsed contract mapping.
76 @return Violation lines.
77 """
78 violations: list[str] = []
79 for rule in contract["forbidden_url_patterns"]:
80 pattern = re.compile(rule["pattern"])
81 for path in iter_scannable():
82 for match in pattern.findall(path.read_text(encoding="utf-8", errors="replace")):
83 violations.append(
84 f"{path.relative_to(REPO_ROOT)}: dead project URL '{match}'\n"
85 f" reason: {rule['reason']}\n"
86 f" use: {rule['replacement_hint']}"
87 )
88 return violations
89
90
91def check_canonical_urls(contract: dict, html_dir: Path) -> list[str]:
92 """!
93 @brief Verify every canonical documentation URL names a page the build actually generates.
94 @param[in] contract Parsed contract mapping.
95 @param[in] html_dir Generated HTML directory.
96 @return Violation lines.
97 """
98 base = re.escape(contract["canonical_docs_base"])
99 pattern = re.compile(base + r"([A-Za-z0-9_.-]+\.html)")
100 violations: list[str] = []
101 for path in iter_scannable():
102 for page in sorted(set(pattern.findall(path.read_text(encoding="utf-8", errors="replace")))):
103 if not (html_dir / page).is_file():
104 violations.append(
105 f"{path.relative_to(REPO_ROOT)}: canonical URL points at '{page}', "
106 f"which the build does not generate.\n"
107 f" The page may be excluded from Doxyfile, renamed, or never to have existed."
108 )
109 return violations
110
111
112def check_layout(contract: dict, html_dir: Path) -> list[str]:
113 """!
114 @brief Verify every Doxygen layout tab resolves to a generated page.
115 @param[in] contract Parsed contract mapping.
116 @param[in] html_dir Generated HTML directory.
117 @return Violation lines.
118 """
119 external = set(contract["external_layout_urls"])
120 violations: list[str] = []
121 for tab in ET.parse(REPO_ROOT / contract["layout_file"]).getroot().iter("tab"):
122 url = tab.get("url")
123 if not url or url in external or url.startswith(("http://", "https://", "/")):
124 continue
125 if not (html_dir / url).is_file():
126 violations.append(
127 f"{contract['layout_file']}: tab '{tab.get('title')}' -> {url}\n"
128 f" no such file in the generated site; the published tab will 404"
129 )
130 return violations
131
132
133def check_orphan_pages(contract: dict, html_dir: Path) -> list[str]:
134 """!
135 @brief Verify every page the build publishes is reachable from the navigation.
136
137 A page is reachable when another page adopts it with a subpage directive, or when a layout tab
138 points at it directly. A page that is generated but adopted by nothing renders in the
139 site with no route to it except search.
140 @param[in] contract Parsed contract mapping.
141 @param[in] html_dir Generated HTML directory.
142 @return Violation lines.
143 """
144 declared: dict[str, Path] = {}
145 adopted: set[str] = set()
146 for directory in ("docs/pages", "docs"):
147 for markdown in sorted((REPO_ROOT / directory).glob("*.md")):
148 text = markdown.read_text(encoding="utf-8", errors="replace")
149 match = re.search(r"@page\s+([A-Za-z0-9_]+)", text)
150 if match:
151 declared.setdefault(match.group(1), markdown)
152 adopted.update(re.findall(r"@subpage\s+([A-Za-z0-9_]+)", text))
153 tabs = {
154 (tab.get("url") or "")[: -len(".html")]
155 for tab in ET.parse(REPO_ROOT / contract["layout_file"]).getroot().iter("tab")
156 if (tab.get("url") or "").endswith(".html")
157 }
158 violations = []
159 for page_id, source in sorted(declared.items()):
160 if not (html_dir / f"{page_id}.html").is_file():
161 continue # excluded from the build; not published, so not an orphan
162 if page_id in adopted or page_id in tabs:
163 continue
164 violations.append(
165 f"{source.relative_to(REPO_ROOT)}: page '{page_id}' is published but orphaned\n"
166 f" no page adopts it with a subpage directive and no layout tab points at it"
167 )
168 return violations
169
170
171def check_generated_fragment_links(html_dir: Path) -> list[str]:
172 """!
173 @brief Verify every link emitted by a generated fragment resolves in the rendered page.
174
175 Generated tables link into Tier-2 entries. Doxygen does not validate raw HTML
176 inserted through an HTML include, so a fragment pointing at an anchor that was
177 never written renders as a dead link and passes every other gate.
178 @param[in] html_dir Generated HTML directory.
179 @return Violation lines.
180 """
181 generated = REPO_ROOT / "docs" / "generated"
182 if not generated.is_dir():
183 return []
184 violations: list[str] = []
185 for fragment in sorted(generated.glob("*.html")):
186 text = fragment.read_text(encoding="utf-8")
187 hosts = [
188 page
189 for page in sorted((REPO_ROOT / "docs" / "pages").glob("*.md"))
190 if fragment.name in page.read_text(encoding="utf-8")
191 ]
192 if not hosts:
193 violations.append(f"docs/generated/{fragment.name}: generated but no page includes it")
194 continue
195 for host in hosts:
196 match = re.search(r"@page\s+([A-Za-z0-9_]+)", host.read_text(encoding="utf-8"))
197 if not match:
198 continue
199 rendered = html_dir / f"{match.group(1)}.html"
200 if not rendered.is_file():
201 continue
202 markup = rendered.read_text(encoding="utf-8")
203 ids = set(re.findall(r'id="([A-Za-z0-9_]+)"', markup))
204 for anchor in sorted(set(re.findall(r'href="#([A-Za-z0-9_]+)"', text))):
205 if anchor not in ids:
206 violations.append(
207 f"docs/generated/{fragment.name}: links to #{anchor}, which does not "
208 f"exist in the rendered {rendered.name}"
209 )
210 for page_ref, anchor in sorted(set(re.findall(r'href="([A-Za-z0-9_]+)\.html#([A-Za-z0-9_]+)"', text))):
211 target = html_dir / f"{page_ref}.html"
212 if not target.is_file():
213 violations.append(f"docs/generated/{fragment.name}: links to {page_ref}.html, which is not generated")
214 elif f'id="{anchor}"' not in target.read_text(encoding="utf-8"):
215 violations.append(
216 f"docs/generated/{fragment.name}: links to {page_ref}.html#{anchor}, "
217 f"which does not exist in the rendered page"
218 )
219 return violations
220
221
222def strip_non_prose(text: str) -> str:
223 """!
224 @brief Blank out fenced code, inline code, and HTML comments.
225
226 @details A link shown inside an example is documentation of a link, not a link.
227 Replacing the spans with blanks rather than deleting them keeps line
228 numbers meaningful for any future line-accurate reporting.
229 @param[in] text Raw Markdown text.
230 @return Text with non-prose spans blanked.
231 """
232 def blank(match: "re.Match") -> str:
233 """!
234 @brief Replace a matched span with spaces, preserving newlines.
235 @param[in] match Regular-expression match to blank out.
236 @return Whitespace of the same shape as the matched text.
237 """
238 return re.sub(r"[^\n]", " ", match.group(0))
239
240 text = re.sub(r"<!--.*?-->", blank, text, flags=re.S)
241 text = re.sub(r"^```.*?^```", blank, text, flags=re.S | re.M)
242 text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
243 text = re.sub(r"`[^`\n]*`", blank, text)
244 return text
245
246
247def markdown_fragment_links(text: str) -> list:
248 """!
249 @brief Extract Markdown links carrying a fragment, from prose only.
250
251 @details Covers `[t](#frag)`, `[t](#frag "title")`, and `[t](page.md#frag)`, and
252 accepts every legal fragment character rather than only word characters -
253 a hyphenated anchor is the common Markdown style and was previously
254 invisible to this check.
255 @param[in] text Raw Markdown text.
256 @return List of (target_page_or_None, fragment) pairs.
257 """
258 prose = strip_non_prose(text)
259 pattern = re.compile(
260 r"\]\‍(\s*"
261 r"(?P<path>[^)\s#]*)"
262 r"#(?P<fragment>[^)\s\"']+)"
263 r"(?:\s+[\"'][^)]*[\"'])?"
264 r"\s*\‍)"
265 )
266 results = []
267 for match in pattern.finditer(prose):
268 target = match.group("path") or None
269 results.append((target, match.group("fragment")))
270 return results
271
272
273def tracked_markdown() -> list:
274 """!
275 @brief Every Markdown file the current commit carries.
276
277 @details Uses the same git-backed enumeration as the link checker, so fragment
278 validation covers README, example documentation, and every `guide.md`
279 rather than only the Doxygen page tree.
280 @return Sorted Markdown paths.
281 """
282 found = enumerate_repository_files(REPO_ROOT, ".md", frozenset(SKIP_DIRS))
283 if found is None:
284 return sorted(path for path in REPO_ROOT.rglob("*.md") if path.is_file())
285 return found
286
287
288def rendered_ids(path: Path) -> set:
289 """!
290 @brief Anchor ids present in a rendered HTML page.
291 @param[in] path Rendered page.
292 @return Set of ids.
293 """
294 if not path.is_file():
295 return set()
296 markup = path.read_text(encoding="utf-8", errors="replace")
297 ids = set(re.findall(r'id="([^"]+)"', markup))
298 ids.update(re.findall(r'name="([^"]+)"', markup))
299 return ids
300
301
302def heading_anchor(text: str) -> str:
303 """!
304 @brief GitHub-style anchor slug for a Markdown heading.
305 @param[in] text Heading text without its leading hashes.
306 @return Anchor slug.
307 """
308 slug = text.strip().lower()
309 slug = re.sub(r"[`*_~\[\]()]", "", slug)
310 slug = re.sub(r"[^\w\s-]", "", slug)
311 return re.sub(r"\s+", "-", slug).strip("-")
312
313
314def markdown_anchors(path: Path) -> set:
315 """!
316 @brief Anchors a plain Markdown file offers.
317
318 @details A file that Doxygen does not render still has targets: explicit HTML
319 anchors, and the heading slugs a Markdown viewer generates.
320 @param[in] path Markdown file.
321 @return Set of available anchor names.
322 """
323 text = path.read_text(encoding="utf-8", errors="replace")
324 anchors = set(re.findall(r'<a\s+(?:id|name)="([^"]+)"', text))
325 anchors.update(re.findall(r"^@anchor\s+(\S+)\s*$", text, re.M))
326 for heading in re.findall(r"^#{1,6}\s+(.+?)\s*$", strip_non_prose(text), re.M):
327 anchors.add(heading_anchor(heading))
328 return anchors
329
330
331def check_page_cross_references(html_dir: Path) -> list[str]:
332 """!
333 @brief Verify hand-written Markdown fragment links resolve, repository-wide.
334
335 @details Doxygen validates its own reference graph, but a Markdown fragment link is
336 checked by nothing. Cross-page targets are resolved **relative to the
337 linking file**, so the many `guide.md` files in this repository are not
338 conflated by basename.
339 @param[in] html_dir Generated HTML directory.
340 @return Violation lines.
341 """
342 page_ids: dict = {}
343 for markdown in tracked_markdown():
344 match = re.search(r"^@page\s+([A-Za-z0-9_]+)", markdown.read_text(encoding="utf-8"), re.M)
345 if match:
346 page_ids[markdown.resolve()] = match.group(1)
347
348 def anchors_for(path: Path) -> set:
349 """!
350 @brief Anchors available in one Markdown file, rendered or not.
351 @param[in] path Markdown file.
352 @return Set of anchor names.
353 """
354 page_id = page_ids.get(path.resolve())
355 if page_id:
356 return rendered_ids(html_dir / f"{page_id}.html")
357 return markdown_anchors(path)
358
359 violations: list[str] = []
360 for markdown in tracked_markdown():
361 text = markdown.read_text(encoding="utf-8", errors="replace")
362 for target, fragment in markdown_fragment_links(text):
363 if target is None:
364 resolved = markdown
365 else:
366 resolved = (markdown.parent / target).resolve()
367 if not resolved.is_file() or resolved.suffix != ".md":
368 continue # the link checker owns missing-file reporting
369 available = anchors_for(resolved)
370 if not available:
371 continue # nothing to check against; not evidence of a broken link
372 if fragment not in available:
373 where = markdown.name if target is None else target
374 violations.append(
375 f"{markdown.relative_to(REPO_ROOT)}: fragment link '#{fragment}' does not "
376 f"resolve in {where}"
377 )
378 return violations
379
380
381def main() -> int:
382 """!
383 @brief Fail when a project-owned URL is dead or a navigation tab has no generated page.
384 @return Process status code.
385 """
386 contract = load_contract()
387 try:
388 html_dir = require_built_html(contract)
389 except RuntimeError as error:
390 print(f"Docs-site audit could not run: {error}", file=sys.stderr)
391 return 1
392 violations = (
393 check_forbidden_urls(contract)
394 + check_canonical_urls(contract, html_dir)
395 + check_layout(contract, html_dir)
396 + check_orphan_pages(contract, html_dir)
399 )
400 if violations:
401 print("Published-site integrity violations:", file=sys.stderr)
402 for violation in violations:
403 print(f" {violation}", file=sys.stderr)
404 return 1
405 print(
406 f"Docs-site audit passed: canonical URLs and navigation tabs all resolve to pages "
407 f"generated under {contract['html_dir']}; no published page is orphaned; generated fragment links resolve."
408 )
409 return 0
410
411
412if __name__ == "__main__":
413 raise SystemExit(main())
dict load_contract()
Load the published-site integrity contract.
iter_scannable()
Yield repository files whose text may carry project-owned URLs.
list tracked_markdown()
Every Markdown file the current commit carries.
list[str] check_canonical_urls(dict contract, Path html_dir)
Verify every canonical documentation URL names a page the build actually generates.
str strip_non_prose(str text)
Blank out fenced code, inline code, and HTML comments.
list[str] check_generated_fragment_links(Path html_dir)
Verify every link emitted by a generated fragment resolves in the rendered page.
list[str] check_layout(dict contract, Path html_dir)
Verify every Doxygen layout tab resolves to a generated page.
set rendered_ids(Path path)
Anchor ids present in a rendered HTML page.
Path require_built_html(dict contract)
Resolve the generated HTML tree, failing loudly when it has not been built.
list[str] check_page_cross_references(Path html_dir)
Verify hand-written Markdown fragment links resolve, repository-wide.
list markdown_fragment_links(str text)
Extract Markdown links carrying a fragment, from prose only.
set markdown_anchors(Path path)
Anchors a plain Markdown file offers.
int main()
Fail when a project-owned URL is dead or a navigation tab has no generated page.
list[str] check_forbidden_urls(dict contract)
Reject project-owned URL forms that are known not to resolve.
list[Path]|None git_tracked_files()
List tracked plus non-ignored untracked files, so the scan set is reproducible for a commit and gener...
list[str] check_orphan_pages(dict contract, Path html_dir)
Verify every page the build publishes is reachable from the navigation.
str heading_anchor(str text)
GitHub-style anchor slug for a Markdown heading.