ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_doc_diagrams.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: authored diagrams render, and the hand-authored figures stay valid.
5
6An authored Graphviz block is written as ``@dot`` ... ``@enddot`` inside a
7Markdown page or a Doxygen comment. Doxygen renders each one by shelling out
8to ``dot`` and emitting ``<img src="dot_inline_dotgraph_<md5>.svg">`` into the
9page. Nothing in this repository ever checked that the second half happened.
10
11That gap is not hypothetical. Three separate failure modes drop a diagram
12while leaving the page HTTP 200, the prose intact and every existing gate
13green:
14
15* ``HAVE_DOT=NO`` -- ``build_docs.sh`` degrades to text-only output when
16 ``dot`` is absent from ``PATH``. Doxygen then ignores every authored block,
17 the published site loses every diagram, and nothing fails.
18* ``PLANTUML_JAR_PATH`` unset -- doxygen ignored every ``@startuml`` block and
19 emitted one warning per block, which ``scripts/ci.sh`` explicitly filtered
20 out. 24 state-machine diagrams rendered nowhere for the entire life of the
21 tree while ``CLAUDE.md`` mandated the construct that produced them.
22* A block doxygen parses but ``dot`` fails to lay out yields an SVG file with
23 no graph content in it -- present, referenced, and empty.
24
25So this gate reads the *generated output*, not the configuration.
26Configuration is what everyone checked; output is what was broken.
27
28Counting model
29--------------
30Comparing raw embed counts does not work: doxygen renders one comment block on
31both the file page and the topic page it belongs to, so a single ``@dot``
32legitimately produces several ``<img>`` embeds. Doxygen also names each
33rendered SVG after a hash of the block body, so two byte-identical blocks share
34one file. The invariant that survives both effects is:
35
36 distinct authored block bodies == distinct rendered SVGs referenced
37
38Both sides are deduplicated by content, which is the identity doxygen itself
39uses.
40
41The hand-authored figures
42-------------------------
43``docs/diagrams/*.svg`` are not generated by anything. They are written by
44hand and embedded straight into Markdown pages with ``<img>``, so GitHub and
45the published site serve them from the repository as-is. No build step
46touches them and no other gate reads them -- fix-encoding.py's suffix set
47does not include ``.svg`` -- so a figure can stop being an SVG while every
48check in the tree stays green and the page renders an empty box.
49
50That is not hypothetical either: a ``--`` inside an XML comment is illegal,
51every renderer refuses the whole document over it, and one shipped as far as
52a green docs-gate run. So this gate also sweeps that directory for
53well-formedness, 7-bit ASCII, self-containment, and ``url(#id)`` references
54that resolve to a locally-defined id.
55
56Usage::
57
58 check_doc_diagrams.py --html build/docs-gate/html # the gate
59 check_doc_diagrams.py --selftest # both-direction proof
60
61Exit 0 when clean, 1 on findings, 2 on a selftest failure or a missing ``dot``.
62"""
63
64from __future__ import annotations
65
66import argparse
67import hashlib
68import re
69import shutil
70import sys
71import tempfile
72from collections.abc import Iterator
73from pathlib import Path
74from typing import NamedTuple
75from xml.parsers import expat
76
77sys.path.insert(0, str(Path(__file__).resolve().parent))
78
79from lint_targets import is_build_output_path
80
81REPO_ROOT = Path(__file__).resolve().parents[2]
82
83#: Roots scanned for authored diagram blocks. Mirrors the CLAUDE.md scope
84#: note: every first-party file, not just the firmware.
85SCAN_ROOTS = ("docs", "libs", "port", "examples", "tools", "apps", "tests")
86
87#: Suffixes that can carry a doxygen comment or a doxygen-rendered page.
88SCAN_SUFFIXES = (".md", ".c", ".h", ".cpp", ".hpp", ".dox")
89
90#: Path fragments marking vendored or generated trees, which are out of scope.
91EXCLUDED = ("third_party", "/generated/", "doxygen_theme")
92
93#: Files allowed to name the banned construct while documenting the rule.
94#: They describe the policy; they do not carry a renderable block.
95STARTUML_DOC_ALLOWLIST = (
96 "docs/STYLE_GUIDE.md",
97 "docs/DOCS.md",
98 "docs/formats/BINARY_FORMATS.md",
99)
100
101#: Written into the generated HTML tree by build_docs.sh once a build finishes.
102#: Its contents are the fingerprint of the authored diagram set that build saw,
103#: which is what lets this gate tell "output built from the tree under test"
104#: apart from "whatever HTML was lying around".
105STAMP_NAME = ".ra8-diagram-stamp"
106
107#: Where the hand-authored figures live. Nothing generates them and nothing
108#: else in the tree reads them, so a file that stops being a valid SVG breaks
109#: silently: the embedding page renders an empty box and every gate stays
110#: green.
111DIAGRAM_DIR = "docs/diagrams"
112
113#: Non-vacuity floor for that sweep. The directory holds eight figures today
114#: (one system map, seven cluster maps). A sweep finding fewer has lost its
115#: subject -- a rename, a move, an emptied directory -- and a scan of nothing
116#: must fail rather than report clean.
117DIAGRAM_FLOOR = 8
118
119#: What a self-contained figure may point at: a fragment inside itself, or an
120#: inline data: payload. Anything else is a fetch the reader's browser makes
121#: against a host that may not answer, which is how a figure renders here and
122#: nowhere else.
123SVG_LOCAL_REF = ("#", "data:")
124
125SVG_HREF = re.compile(r'(?:xlink:href|href|src)\s*=\s*"([^"]*)"')
126SVG_URL_REF = re.compile(r"url\‍(\s*#([^)\s]+?)\s*\‍)")
127SVG_ID_DEF = re.compile(r'\bid\s*=\s*"([^"]*)"')
128
129DOT_OPEN = re.compile(r"^\s*(?:\*\s?)?@dot\s*$")
130DOT_CLOSE = re.compile(r"^\s*(?:\*\s?)?@enddot\s*$")
131SVG_REF = re.compile(r"dot_inline_dotgraph_[0-9a-f]+\.svg")
132SVG_TEXT = re.compile(r"<text[^>]*>([^<]*)</text>")
133COMMENT_LEADER = re.compile(r"^\s*\*\s?")
134
135
136def in_scope(path: Path) -> bool:
137 """Whether a file is scanned for diagram blocks.
138
139 Build output is excluded via the shared is_build_output_path predicate
140 rather than a local substring test, so this gate and the size gates cannot
141 disagree about what counts as generated (#377).
142 """
143 posix = path.as_posix()
144 if is_build_output_path(posix) or any(frag in posix for frag in EXCLUDED):
145 return False
146 return path.suffix in SCAN_SUFFIXES
147
148
149def iter_sources(root: Path) -> Iterator[Path]:
150 """Yield every in-scope source file beneath the configured scan roots.
151
152 Symlinks are skipped, so a link pointing back into the tree cannot make
153 one diagram body appear at two origins and read as a duplicate.
154 """
155 for top in SCAN_ROOTS:
156 base = root / top
157 if not base.is_dir():
158 continue
159 for path in sorted(base.rglob("*")):
160 if path.is_file() and not path.is_symlink() and in_scope(path):
161 yield path
162
163
164def normalise(body_lines: list[str]) -> str:
165 """Reduce a block body to the text doxygen hashes.
166
167 Strips the Doxygen comment leader (`` * ``) and trailing whitespace, so the
168 same diagram written in a ``.h`` comment and in a ``.md`` page normalises
169 identically -- which is what makes them collide into one rendered SVG.
170 """
171 out = [COMMENT_LEADER.sub("", ln).rstrip() for ln in body_lines]
172 while out and not out[0]:
173 out.pop(0)
174 while out and not out[-1]:
175 out.pop()
176 return "\n".join(out)
177
178
179class Finding:
180 """One rule violation, rendered as ``where: message``."""
181
182 def __init__(self, where: str, message: str) -> None:
183 """Record one finding at ``where`` (a ``path:line``) with its message."""
184 self.where = where
185 self.message = message
186
187 def __str__(self) -> str:
188 """Render as ``path:line: message`` -- editor-jumpable."""
189 return f"{self.where}: {self.message}"
190
191
192def _scan_startuml(rel: str, lines: list[str]) -> list[Finding]:
193 """Reject the construct that renders nowhere in this tree."""
194 if rel in STARTUML_DOC_ALLOWLIST:
195 return []
196 return [
197 Finding(
198 f"{rel}:{idx}",
199 "@startuml renders nothing (PLANTUML_JAR_PATH is unset and no "
200 "JVM is provisioned) -- use @dot",
201 )
202 for idx, line in enumerate(lines, 1)
203 if "@startuml" in line
204 ]
205
206
207def _scan_dot_blocks(rel: str, lines: list[str], blocks: dict[str, list[str]]) -> list[Finding]:
208 """Collect ``@dot`` bodies into ``blocks``; return marker findings."""
209 findings: list[Finding] = []
210 open_at: int | None = None
211 body: list[str] = []
212
213 for idx, line in enumerate(lines, 1):
214 if DOT_OPEN.match(line):
215 if open_at is not None:
216 findings.append(Finding(f"{rel}:{idx}", "nested @dot: previous block never closed"))
217 open_at, body = idx, []
218 elif DOT_CLOSE.match(line):
219 if open_at is None:
220 findings.append(Finding(f"{rel}:{idx}", "@enddot without @dot"))
221 continue
222 key = normalise(body)
223 if not key:
224 findings.append(Finding(f"{rel}:{open_at}", "empty @dot block"))
225 blocks.setdefault(key, []).append(f"{rel}:{open_at}")
226 open_at = None
227 elif open_at is not None:
228 body.append(line)
229
230 if open_at is not None:
231 findings.append(Finding(f"{rel}:{open_at}", "@dot block never closed"))
232 return findings
233
234
235def scan_sources(
236 root: Path, files: list[Path] | None = None
237) -> tuple[dict[str, list[str]], list[Finding]]:
238 """Return ``(blocks, findings)``; ``blocks`` maps normalised body -> origins."""
239 blocks: dict[str, list[str]] = {}
240 findings: list[Finding] = []
241 sources = files if files is not None else list(iter_sources(root))
242
243 for path in sources:
244 try:
245 text = path.read_text(encoding="utf-8")
246 except (UnicodeDecodeError, OSError):
247 continue
248 rel = path.relative_to(root).as_posix() if path.is_absolute() else path.as_posix()
249 lines = text.splitlines()
250 findings += _scan_startuml(rel, lines)
251 findings += _scan_dot_blocks(rel, lines, blocks)
252
253 return blocks, findings
254
255
256def scan_html(html_dir: Path) -> tuple[set[str], set[str], list[Finding]]:
257 """Return ``(referenced, live, findings)`` for diagrams under ``html_dir``."""
258 findings: list[Finding] = []
259 referenced: set[str] = set()
260
261 for page in sorted(html_dir.rglob("*.html")):
262 try:
263 text = page.read_text(encoding="utf-8", errors="replace")
264 except OSError:
265 continue
266 referenced.update(SVG_REF.findall(text))
267
268 live: set[str] = set()
269 for name in sorted(referenced):
270 svg = html_dir / name
271 if not svg.is_file():
272 findings.append(Finding(name, "referenced by a page but not generated"))
273 continue
274 content = svg.read_text(encoding="utf-8", errors="replace")
275 # A laid-out graphviz SVG always carries at least one node group.
276 if 'class="node"' not in content:
277 findings.append(
278 Finding(name, "rendered SVG contains no graph nodes (dot produced an empty layout)")
279 )
280 continue
281 # A diagram can render and still be wrong. `\\n` in a dot label is an
282 # escaped backslash followed by 'n', so graphviz draws the characters
283 # "\n" instead of breaking the line -- the label reads as one run of
284 # text with literal escapes in it. This has shipped twice, so catch it
285 # in the rendered text rather than trusting the source spelling.
286 literal = [t for t in SVG_TEXT.findall(content) if "\\n" in t]
287 if literal:
288 findings.append(
289 Finding(
290 name,
291 "rendered label contains a literal '\\n' -- the dot source "
292 "double-escaped a line break (use \\n, not \\\\n): "
293 + "; ".join(sorted(literal)[:3]),
294 )
295 )
296 continue
297 live.add(name)
298
299 return referenced, live, findings
300
301
302def source_fingerprint(blocks: dict[str, list[str]]) -> str:
303 """Fingerprint the authored diagram set, order-independently."""
304 digest = hashlib.sha256()
305 for key in sorted(blocks):
306 digest.update(key.encode("utf-8"))
307 digest.update(b"\0")
308 return digest.hexdigest()
309
310
311def check_stamp(html_dir: Path, blocks: dict[str, list[str]]) -> list[Finding]:
312 """Refuse to judge output that was not built from the tree under test.
313
314 Doxygen never deletes what it no longer produces, so a directory can hold a
315 mixture of two builds. That is dangerous in both directions: an orphan
316 render can mask a diagram the current tree drops, and a diagram added since
317 the last build looks dropped. Rather than trusting the caller to have
318 rebuilt, compare the authored diagram set against the fingerprint the build
319 recorded.
320 """
321 stamp = html_dir / STAMP_NAME
322 if not stamp.is_file():
323 return [
324 Finding(
325 str(html_dir),
326 "no build stamp -- this HTML was not produced by scripts/builders/docs.sh, "
327 "so it cannot be trusted to reflect the current tree. Rebuild with "
328 "'bash scripts/builders/docs.sh --gate'.",
329 )
330 ]
331 recorded = stamp.read_text(encoding="utf-8").strip()
332 current = source_fingerprint(blocks)
333 if recorded != current:
334 return [
335 Finding(
336 str(html_dir),
337 "stale output -- the authored diagram set has changed since this "
338 f"HTML was built (stamp {recorded[:12]}, tree {current[:12]}). "
339 "Rebuild with 'bash scripts/builders/docs.sh --gate'; do not judge "
340 "the diagrams from a mixture of two builds.",
341 )
342 ]
343 return []
344
345
346def check(root: Path, html_dir: Path) -> list[Finding]:
347 """Run every rule and return the accumulated findings."""
348 blocks, findings = scan_sources(root)
349
350 # Freshness first: every count below is meaningless against stale output,
351 # and reporting a bogus count mismatch is how a gate teaches people to
352 # `rm -rf` and re-run until green.
353 stale = check_stamp(html_dir, blocks)
354 if stale:
355 return findings + stale
356
357 _referenced, live, html_findings = scan_html(html_dir)
358 findings += html_findings
359
360 n_src, n_live = len(blocks), len(live)
361 if n_src != n_live:
362 detail = [
363 "",
364 f" distinct authored @dot blocks : {n_src}",
365 f" distinct rendered SVGs : {n_live}",
366 "",
367 ]
368 if n_src > n_live:
369 # Report SOURCE files, never rendered-SVG filenames. Doxygen names
370 # each SVG after a hash of its own normalised copy of the block, and
371 # that normalisation is not reproducible from the source text, so an
372 # SVG name cannot be attributed back to the block that produced it.
373 # Printing one anyway names a file the author never touched, which
374 # is exactly what made this hard to read the first time it fired.
375 per_file: dict[str, int] = {}
376 for origins in blocks.values():
377 for origin in origins:
378 per_file[origin.rsplit(":", 1)[0]] = (
379 per_file.get(origin.rsplit(":", 1)[0], 0) + 1
380 )
381 detail.append(f" {n_src - n_live} authored diagram(s) did not reach the HTML.")
382 detail.append(" Authored @dot blocks by source file:")
383 detail.extend(f" {count:3d} {path}" for path, count in sorted(per_file.items()))
384 detail.append("")
385 detail.append(" The build is fresh (stamp matched), so a block in one of these")
386 detail.append(" files was parsed but not rendered. Check it for a dot syntax")
387 detail.append(" error, or a node count over DOT_GRAPH_MAX_NODES.")
388 else:
389 detail.append(
390 " More rendered diagrams than authored blocks -- a scan root "
391 "or suffix is missing from this gate."
392 )
393 findings.append(
394 Finding(
395 "diagram-count", "authored and rendered diagram counts disagree" + "\n".join(detail)
396 )
397 )
398 return findings
399
400
401# ---------------------------------------------------------------------------
402# The hand-authored figures under docs/diagrams/
403# ---------------------------------------------------------------------------
404
405
406def _svg_wellformed(rel: str, data: bytes) -> list[Finding]:
407 """Reject a figure that is not parseable XML, at the parser's own position.
408
409 expat rather than ElementTree or minidom on purpose: it is the engine
410 underneath both, it hands back the failing line and column directly, and it
411 is the one spelling ruff's flake8-bandit family does not require a
412 suppression for.
413 """
414 parser = expat.ParserCreate()
415 try:
416 # expat takes isfinal POSITIONALLY: Parse() rejects the keyword form
417 # outright, so this boolean has no more readable spelling.
418 parser.Parse(data, True) # noqa: FBT003 -- keyword form unsupported
419 except expat.ExpatError as exc:
420 return [
421 Finding(
422 f"{rel}:{parser.ErrorLineNumber}",
423 f"not well-formed XML at column {parser.ErrorColumnNumber}: "
424 f"{expat.ErrorString(exc.code)} -- every renderer refuses the "
425 "whole document, so the page shows an empty box",
426 )
427 ]
428 return []
429
430
431def _svg_ascii(rel: str, data: bytes) -> list[Finding]:
432 """Reject any byte outside 7-bit ASCII, naming the first one.
433
434 The repository-wide ASCII policy is enforced by fix-encoding.py, whose
435 suffix set has never included ``.svg``; these figures have therefore never
436 been covered by it. Asserted here rather than by widening that gate's scope,
437 because fix-encoding.py REWRITES what it finds and transliterating inside a
438 coordinate or path stream is not obviously safe.
439 """
440 try:
441 data.decode("ascii")
442 except UnicodeDecodeError as exc:
443 line = data[: exc.start].count(b"\n") + 1
444 return [
445 Finding(
446 f"{rel}:{line}",
447 f"non-ASCII byte 0x{data[exc.start]:02x} at offset {exc.start} "
448 "-- figures are held to the same 7-bit rule as every other "
449 "first-party file",
450 )
451 ]
452 return []
453
454
455def _svg_self_contained(rel: str, text: str) -> list[Finding]:
456 """Reject an off-page fetch, and any ``url(#id)`` with no local definition.
457
458 Two failure modes, one symptom: a figure that renders here and not there.
459 An external href is a request to a host outside this repository, which a
460 reader behind a proxy or an offline copy of the docs never gets. A
461 ``url(#id)`` naming an id no element defines -- a renamed marker, a
462 fragment copied between figures -- leaves the arrowhead or gradient
463 silently missing from a document that is otherwise perfectly valid.
464 """
465 findings = [
466 Finding(
467 rel,
468 f"external reference {value!r} -- a figure must carry everything it draws",
469 )
470 for value in SVG_HREF.findall(text)
471 if not value.startswith(SVG_LOCAL_REF)
472 ]
473 ids = set(SVG_ID_DEF.findall(text))
474 findings += [
475 Finding(rel, f"url(#{ref}) names an id no element in this file defines")
476 for ref in sorted(set(SVG_URL_REF.findall(text)) - ids)
477 ]
478 return findings
479
480
481def check_diagram_svgs(root: Path) -> list[Finding]:
482 """Validate every hand-authored figure under ``docs/diagrams/``.
483
484 Independent of the docs build: these files ship verbatim, so what matters
485 is a property of the file rather than of any generated output. A malformed
486 figure is reported on its own -- the pattern rules would only add noise
487 about a document the parser has already rejected.
488 """
489 base = root / DIAGRAM_DIR
490 svgs = sorted(base.glob("*.svg")) if base.is_dir() else []
491 if len(svgs) < DIAGRAM_FLOOR:
492 return [
493 Finding(
494 DIAGRAM_DIR,
495 f"only {len(svgs)} SVG(s) found, floor is {DIAGRAM_FLOOR} -- the "
496 "sweep has lost its subject, and a scan of nothing must not "
497 "report clean",
498 )
499 ]
500 findings: list[Finding] = []
501 for path in svgs:
502 rel = path.relative_to(root).as_posix()
503 data = path.read_bytes()
504 findings += _svg_ascii(rel, data)
505 malformed = _svg_wellformed(rel, data)
506 findings += malformed
507 if not malformed:
508 findings += _svg_self_contained(rel, data.decode("ascii", errors="replace"))
509 return findings
510
511
512# ---------------------------------------------------------------------------
513# Selftest
514# ---------------------------------------------------------------------------
515
516
517def _svg(*, nodes: bool, label: str = "Idle") -> str:
518 body = f'<g class="node"><title>A</title><text x="0" y="0">{label}</text></g>' if nodes else ""
519 return f'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg">{body}</svg>'
520
521
522def _md(*bodies: str) -> str:
523 return "Prose paragraph.\n\n" + "\n".join("@dot\n" + b + "\n@enddot\n" for b in bodies)
524
525
526def _page(*svgs: str) -> str:
527 imgs = "".join(f'<div class="dotgraph"><img src="{s}"/></div>' for s in svgs)
528 return f"<html><body>{imgs}</body></html>"
529
530
531def _hashed(body: str) -> str:
532 """Synthesise a doxygen-style SVG filename for a selftest fixture.
533
534 Not a security primitive: this only has to be a stable, unique-per-body
535 name so the fixtures can wire a page to its SVG the way doxygen does.
536 """
537 digest = hashlib.md5(body.encode(), usedforsecurity=False).hexdigest()
538 return f"dot_inline_dotgraph_{digest}.svg"
539
540
541# --- selftest fixtures ------------------------------------------------------
542# The cases below are DATA, not logic, and they live at module scope for that
543# reason: wrapping a 100-line table in a function only moves the length, since
544# the size gate measures a function body whether it holds statements or a list
545# literal. Splitting the table itself into arbitrary chunks would scatter one
546# cohesive spec across several names for no reader's benefit.
547
548_G1 = "digraph a { x -> y; }"
549_G2 = "digraph b { p -> q; }"
550_S1, _S2 = _hashed(_G1), _hashed(_G2)
551
552_DUP_HEADER = (
553 "/**\n * @dot\n" + "\n".join(" * " + ln for ln in _G1.splitlines()) + "\n * @enddot\n */\n"
554)
555
556
557class _DiagramCase(NamedTuple):
558 """One selftest scenario: a fixture tree and the verdict the gate must reach.
559
560 Replaces the positional tuples this table used to hold, where the optional
561 fifth field was read as ``case[STAMP_MODE_FIELD] if len(case) > ...``. The
562 named default says the same thing without an index constant.
563 """
564
565 name: str
566 """Human-readable scenario name, printed in the per-case verdict line."""
567 sources: dict[str, str]
568 """Repo-relative source path -> file text, materialised under ``repo/``."""
569 html: dict[str, str]
570 """Doxygen-output-relative path -> file text, materialised under ``html/``."""
571 expect_fire: bool
572 """True when the gate MUST report a finding for this tree."""
573 stamp_mode: str = "good"
574 """How the build stamp is seeded: ``good`` / ``wrong`` / ``missing``."""
575
576
577_SELFTEST_CASES: tuple[_DiagramCase, ...] = (
578 # --- the gate must STAY SILENT on correct output ------------------
579 _DiagramCase(
580 "correct: 2 authored, 2 rendered",
581 {"docs/x.md": _md(_G1, _G2)},
582 {"p.html": _page(_S1, _S2), _S1: _svg(nodes=True), _S2: _svg(nodes=True)},
583 expect_fire=False,
584 ),
585 _DiagramCase(
586 "correct: one block embedded on two pages",
587 {"docs/x.md": _md(_G1)},
588 {"a.html": _page(_S1), "b.html": _page(_S1), _S1: _svg(nodes=True)},
589 expect_fire=False,
590 ),
591 _DiagramCase(
592 "correct: identical blocks share one SVG",
593 {"docs/x.md": _md(_G1), "libs/y.h": _DUP_HEADER},
594 {"p.html": _page(_S1), _S1: _svg(nodes=True)},
595 expect_fire=False,
596 ),
597 # --- the gate must FIRE when a diagram is dropped -----------------
598 _DiagramCase(
599 "dropped: 2 authored, 1 rendered",
600 {"docs/x.md": _md(_G1, _G2)},
601 {"p.html": _page(_S1), _S1: _svg(nodes=True)},
602 expect_fire=True,
603 ),
604 _DiagramCase(
605 "dropped: every diagram missing (HAVE_DOT=NO)",
606 {"docs/x.md": _md(_G1, _G2)},
607 {"p.html": "<html><body>no diagrams</body></html>"},
608 expect_fire=True,
609 ),
610 _DiagramCase(
611 "empty: rendered SVG has no nodes",
612 {"docs/x.md": _md(_G1)},
613 {"p.html": _page(_S1), _S1: _svg(nodes=False)},
614 expect_fire=True,
615 ),
616 _DiagramCase(
617 "double-escaped: label renders a literal backslash-n",
618 {"docs/x.md": _md(_G1)},
619 {"p.html": _page(_S1), _S1: _svg(nodes=True, label="ra8_init\\nstep two")},
620 expect_fire=True,
621 ),
622 _DiagramCase(
623 "missing: referenced SVG absent from disk",
624 {"docs/x.md": _md(_G1)},
625 {"p.html": _page(_S1)},
626 expect_fire=True,
627 ),
628 _DiagramCase(
629 "startuml: banned construct present",
630 {"docs/x.md": "@startuml\n[*] --> A\n@enduml\n"},
631 {"p.html": "<html></html>"},
632 expect_fire=True,
633 ),
634 _DiagramCase(
635 "unbalanced: @dot never closed",
636 {"docs/x.md": "@dot\ndigraph z { a -> b; }\n"},
637 {"p.html": "<html></html>"},
638 expect_fire=True,
639 ),
640 # --- the gate must REFUSE output it cannot attribute to this tree ----
641 # Stale output is dangerous in both directions, and the dangerous one
642 # is a leftover render MASKING a diagram the current tree drops. Both
643 # of these would otherwise "pass" on the counts alone.
644 _DiagramCase(
645 "stale: leftover render masks a dropped diagram",
646 # Two blocks authored; only one is rendered, but a leftover SVG
647 # from an older build makes the count add up.
648 {"docs/x.md": _md(_G1, _G2)},
649 {"p.html": _page(_S1), _S1: _svg(nodes=True), _S2: _svg(nodes=True)},
650 expect_fire=True,
651 stamp_mode="wrong",
652 ),
653 _DiagramCase(
654 "stale: output predates a newly added diagram",
655 {"docs/x.md": _md(_G1, _G2)},
656 {"p.html": _page(_S1, _S2), _S1: _svg(nodes=True), _S2: _svg(nodes=True)},
657 expect_fire=True,
658 stamp_mode="wrong",
659 ),
660 _DiagramCase(
661 "unknown provenance: no build stamp at all",
662 {"docs/x.md": _md(_G1)},
663 {"p.html": _page(_S1), _S1: _svg(nodes=True)},
664 expect_fire=True,
665 stamp_mode="missing",
666 ),
667)
668
669
670def _materialise_case(case: _DiagramCase, td: str) -> tuple[Path, Path]:
671 """Write one case's fixture tree under ``td`` and seed its build stamp.
672
673 Returns ``(repo_root, html_dir)``. The stamp is what lets the gate tell
674 output built from the tree under test from output that was not: "good"
675 models a directory build_docs.sh has just written, "wrong" a stale render,
676 "missing" output of unknown provenance.
677 """
678 root = Path(td) / "repo"
679 hdir = Path(td) / "html"
680 hdir.mkdir(parents=True)
681 for rel, text in case.sources.items():
682 path = root / rel
683 path.parent.mkdir(parents=True, exist_ok=True)
684 path.write_text(text, encoding="utf-8")
685 for rel, text in case.html.items():
686 (hdir / rel).write_text(text, encoding="utf-8")
687
688 if case.stamp_mode == "good":
689 seen, _ = scan_sources(root)
690 (hdir / STAMP_NAME).write_text(source_fingerprint(seen), encoding="utf-8")
691 elif case.stamp_mode == "wrong":
692 (hdir / STAMP_NAME).write_text("0" * 64, encoding="utf-8")
693 # "missing" writes nothing at all.
694 return root, hdir
695
696
697def _verdict(name: str, *, fired: bool, expect_fire: bool) -> bool:
698 """Print one case's verdict line; True when the gate behaved as specified."""
699 if fired == expect_fire:
700 verdict = "fires" if expect_fire else "silent"
701 sys.stdout.write(f" ok ({verdict:6s}) {name}\n")
702 return True
703 want = "FIRE" if expect_fire else "stay silent"
704 got = "fired" if fired else "stayed silent"
705 sys.stderr.write(f" selftest FAIL [{name}]: expected the gate to {want}, it {got}\n")
706 return False
707
708
709def _run_case(case: _DiagramCase) -> bool:
710 """Run one case in a throwaway tree; True when the gate behaved as specified."""
711 with tempfile.TemporaryDirectory() as td:
712 root, hdir = _materialise_case(case, td)
713 fired = bool(check(root, hdir))
714 return _verdict(case.name, fired=fired, expect_fire=case.expect_fire)
715
716
717class _SvgCase(NamedTuple):
718 """One docs/diagrams scenario: a fixture directory and the verdict required."""
719
720 name: str
721 """Human-readable scenario name, printed in the per-case verdict line."""
722 files: dict[str, str]
723 """Repo-relative path -> file text, materialised under ``repo/``."""
724 expect_fire: bool
725 """True when the sweep MUST report a finding for this tree."""
726
727
728def _fig(marker: str, extra: str = "") -> str:
729 """One figure in the house style: a marker reached through ``url(#id)``."""
730 return (
731 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20" width="40">'
732 f'<defs><marker id="{marker}"><path d="M0,0 L4,2 L0,4 z"/></marker></defs>'
733 f'<line x1="0" y1="10" x2="30" y2="10" marker-end="url(#{marker})"/>{extra}'
734 "</svg>\n"
735 )
736
737
738def _figs(count: int = DIAGRAM_FLOOR, **replace: str) -> dict[str, str]:
739 """``count`` clean figures keyed by path, with named entries replaced.
740
741 ``_figs(f0=...)`` swaps one figure for a broken one, so each case below
742 differs from the clean case in exactly the defect it is asserting.
743 """
744 tree = {f"{DIAGRAM_DIR}/f{i}.svg": _fig(f"a{i}") for i in range(count)}
745 tree.update({f"{DIAGRAM_DIR}/{name}.svg": text for name, text in replace.items()})
746 return tree
747
748
749_SVG_CASES: tuple[_SvgCase, ...] = (
750 # --- the sweep must STAY SILENT on a sound directory ------------------
751 _SvgCase("figures: a clean directory", _figs(), expect_fire=False),
752 # --- ...and FIRE on each way a figure breaks --------------------------
753 # The first case is the exact defect that motivated this sweep: a double
754 # hyphen inside an XML comment. The file still looks like an SVG to every
755 # text-level check in the tree, and renders as nothing.
756 _SvgCase(
757 "figures: double hyphen inside an XML comment",
758 _figs(f0=_fig("a0", "<!-- drawn -- badly -->")),
759 expect_fire=True,
760 ),
761 _SvgCase(
762 "figures: external image reference",
763 _figs(f0=_fig("a0", '<image href="https://example.invalid/x.png" width="4"/>')),
764 expect_fire=True,
765 ),
766 _SvgCase(
767 "figures: url(#id) naming an id nothing defines",
768 _figs(f0=_fig("a0", '<line x1="0" y1="1" x2="9" y2="1" marker-end="url(#gone)"/>')),
769 expect_fire=True,
770 ),
771 _SvgCase(
772 "figures: a non-ASCII byte",
773 _figs(f0=_fig("a0", '<text x="1" y="1">\u00b5s</text>')),
774 expect_fire=True,
775 ),
776 _SvgCase(
777 "figures: directory below the non-vacuity floor",
778 _figs(DIAGRAM_FLOOR - 1),
779 expect_fire=True,
780 ),
781)
782
783
784def _run_svg_case(case: _SvgCase) -> bool:
785 """Run one docs/diagrams case in a throwaway tree; True when it behaved."""
786 with tempfile.TemporaryDirectory() as td:
787 root = Path(td) / "repo"
788 for rel, text in case.files.items():
789 path = root / rel
790 path.parent.mkdir(parents=True, exist_ok=True)
791 path.write_text(text, encoding="utf-8")
792 fired = bool(check_diagram_svgs(root))
793 return _verdict(case.name, fired=fired, expect_fire=case.expect_fire)
794
795
796def selftest() -> int:
797 """Prove both halves fire on a broken tree and stay silent on a sound one."""
798 failures = sum(1 for case in _SELFTEST_CASES if not _run_case(case))
799 failures += sum(1 for case in _SVG_CASES if not _run_svg_case(case))
800 expected = [c.expect_fire for c in _SELFTEST_CASES] + [c.expect_fire for c in _SVG_CASES]
801 total = len(expected)
802 if failures:
803 sys.stderr.write(f"check_doc_diagrams.py: selftest FAILED ({failures}/{total} cases).\n")
804 return 2
805 fires = sum(expected)
806 sys.stdout.write(
807 f"check_doc_diagrams.py: selftest PASSED ({total} cases -- "
808 f"{fires} assert the gate fires, {total - fires} assert it does not).\n"
809 )
810 return 0
811
812
813def _build_parser() -> argparse.ArgumentParser:
814 """Build the command-line parser for this gate."""
815 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
816 parser.add_argument(
817 "--html", default="build/docs-gate/html", help="generated HTML directory to inspect"
818 )
819 parser.add_argument(
820 "--selftest", action="store_true", help="run synthetic both-direction fixtures and exit"
821 )
822 parser.add_argument(
823 "--write-stamp",
824 action="store_true",
825 help="record the authored diagram fingerprint into the HTML tree "
826 "(called by scripts/builders/docs.sh once a build finishes)",
827 )
828 return parser
829
830
831def _graphviz_present() -> bool:
832 """True when graphviz ``dot`` is on PATH; writes the FATAL message when not.
833
834 A gate whose dependency is absent must FAIL, never silently pass: without
835 ``dot`` every authored diagram is dropped, and this gate cannot tell that
836 apart from a build that was never run.
837 """
838 if shutil.which("dot") is not None:
839 return True
840 sys.stderr.write(
841 "check_doc_diagrams.py: FATAL -- graphviz 'dot' is not on PATH.\n"
842 "Every authored diagram is dropped without it, and this gate "
843 "cannot tell that apart from a build that was never run.\n"
844 )
845 return False
846
847
848def _resolve_html_dir(raw: str) -> Path | None:
849 """Resolve ``raw`` against the repo root; None (with a message) if absent."""
850 html_dir = Path(raw)
851 if not html_dir.is_absolute():
852 html_dir = REPO_ROOT / html_dir
853 if html_dir.is_dir():
854 return html_dir
855 sys.stderr.write(
856 f"check_doc_diagrams.py: FATAL -- no generated HTML at {html_dir}.\n"
857 "Build the docs first (scripts/builders/docs.sh --gate).\n"
858 )
859 return None
860
861
862def _report_findings(findings: list[Finding]) -> None:
863 """Print every finding, then advice matched to what actually went wrong.
864
865 Kept separate from the decision to fail because the closing advice must
866 stay truthful: a provenance failure means "this measurement is not valid
867 yet", not "a diagram is missing". Telling someone their diagrams are gone
868 when the real answer is "rebuild" is how a gate earns a reputation for
869 crying wolf.
870 """
871 sys.stderr.write("check_doc_diagrams.py: FAILED\n\n")
872 for finding in findings:
873 sys.stderr.write(f" {finding}\n")
874 figures = [f for f in findings if f.where.startswith(DIAGRAM_DIR)]
875 if figures:
876 sys.stderr.write(
877 f"\nA figure under {DIAGRAM_DIR} ships verbatim: no build step would "
878 "notice, and the page that embeds it renders an empty box.\n"
879 )
880 if any("stale output" in f.message or "no build stamp" in f.message for f in findings):
881 sys.stderr.write(
882 "\nNothing was measured: the generated HTML does not correspond "
883 "to the current tree.\nRebuild and re-run before drawing any "
884 "conclusion about the diagrams.\n"
885 )
886 elif len(figures) != len(findings):
887 sys.stderr.write(
888 "\nAn authored diagram that does not reach the HTML is invisible "
889 "to every reader of the published site.\n"
890 )
891
892
893def main() -> int:
894 """Verify every authored diagram actually reached the generated HTML.
895
896 The gate this replaces was the cautionary case: 24 mandated state diagrams
897 used ``@startuml``, doxygen ignored every one of them for want of a
898 PlantUML jar, and the docs gate filtered the resulting warning away -- so
899 the diagrams rendered nowhere for the life of the tree while the style
900 rules demanded them. Hence two rules here rather than one: reject
901 ``@startuml`` outright, and prove each authored ``@dot`` body appears in
902 the output.
903
904 ``--write-stamp`` records the authored fingerprint after a docs build and
905 checks nothing; the build script calls it, and CI must not, or the stamp
906 is written from the same run it is supposed to validate.
907
908 Returns 0 when every authored diagram is present, 1 on any finding.
909 """
910 args = _build_parser().parse_args()
911
912 if args.selftest:
913 return selftest()
914
915 if not _graphviz_present():
916 return 2
917 html_dir = _resolve_html_dir(args.html)
918 if html_dir is None:
919 return 2
920
921 if args.write_stamp:
922 blocks, _ = scan_sources(REPO_ROOT)
923 (html_dir / STAMP_NAME).write_text(source_fingerprint(blocks), encoding="utf-8")
924 sys.stdout.write(
925 f"check_doc_diagrams.py: stamped {len(blocks)} authored diagram(s) "
926 f"into {html_dir.relative_to(REPO_ROOT)}.\n"
927 )
928 return 0
929
930 findings = check_diagram_svgs(REPO_ROOT) + check(REPO_ROOT, html_dir)
931 if findings:
932 _report_findings(findings)
933 return 1
934
935 blocks, _ = scan_sources(REPO_ROOT)
936 figures = len(list((REPO_ROOT / DIAGRAM_DIR).glob("*.svg")))
937 sys.stdout.write(
938 f"check_doc_diagrams.py: OK -- {len(blocks)} authored diagram(s) all "
939 f"render in {html_dir.relative_to(REPO_ROOT)}, and {figures} figure(s) "
940 f"under {DIAGRAM_DIR} are well-formed, ASCII and self-contained.\n"
941 )
942 return 0
943
944
945if __name__ == "__main__":
946 sys.exit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298