4"""Gate: authored diagrams render, and the hand-authored figures stay valid.
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.
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
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.
25So this gate reads the *generated output*, not the configuration.
26Configuration is what everyone checked; output is what was broken.
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:
36 distinct authored block bodies == distinct rendered SVGs referenced
38Both sides are deduplicated by content, which is the identity doxygen itself
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.
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.
58 check_doc_diagrams.py --html build/docs-gate/html # the gate
59 check_doc_diagrams.py --selftest # both-direction proof
61Exit 0 when clean, 1 on findings, 2 on a selftest failure or a missing ``dot``.
64from __future__
import annotations
72from collections.abc
import Iterator
73from pathlib
import Path
74from typing
import NamedTuple
75from xml.parsers
import expat
77sys.path.insert(0, str(Path(__file__).resolve().parent))
79from lint_targets
import is_build_output_path
81REPO_ROOT = Path(__file__).resolve().parents[2]
85SCAN_ROOTS = (
"docs",
"libs",
"port",
"examples",
"tools",
"apps",
"tests")
88SCAN_SUFFIXES = (
".md",
".c",
".h",
".cpp",
".hpp",
".dox")
91EXCLUDED = (
"third_party",
"/generated/",
"doxygen_theme")
95STARTUML_DOC_ALLOWLIST = (
96 "docs/STYLE_GUIDE.md",
98 "docs/formats/BINARY_FORMATS.md",
105STAMP_NAME =
".ra8-diagram-stamp"
111DIAGRAM_DIR =
"docs/diagrams"
123SVG_LOCAL_REF = (
"#",
"data:")
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*"([^"]*)"')
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?")
136def in_scope(path: Path) -> bool:
137 """Whether a file is scanned for diagram blocks.
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).
143 posix = path.as_posix()
144 if is_build_output_path(posix)
or any(frag
in posix
for frag
in EXCLUDED):
146 return path.suffix
in SCAN_SUFFIXES
149def iter_sources(root: Path) -> Iterator[Path]:
150 """Yield every in-scope source file beneath the configured scan roots.
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.
155 for top
in SCAN_ROOTS:
157 if not base.is_dir():
159 for path
in sorted(base.rglob(
"*")):
160 if path.is_file()
and not path.is_symlink()
and in_scope(path):
164def normalise(body_lines: list[str]) -> str:
165 """Reduce a block body to the text doxygen hashes.
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.
171 out = [COMMENT_LEADER.sub(
"", ln).rstrip()
for ln
in body_lines]
172 while out
and not out[0]:
174 while out
and not out[-1]:
176 return "\n".join(out)
180 """One rule violation, rendered as ``where: message``."""
182 def __init__(self, where: str, message: str) ->
None:
183 """Record one finding at ``where`` (a ``path:line``) with its message."""
185 self.message = message
187 def __str__(self) -> str:
188 """Render as ``path:line: message`` -- editor-jumpable."""
189 return f
"{self.where}: {self.message}"
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:
199 "@startuml renders nothing (PLANTUML_JAR_PATH is unset and no "
200 "JVM is provisioned) -- use @dot",
202 for idx, line
in enumerate(lines, 1)
203 if "@startuml" in line
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
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):
220 findings.append(Finding(f
"{rel}:{idx}",
"@enddot without @dot"))
222 key = normalise(body)
224 findings.append(Finding(f
"{rel}:{open_at}",
"empty @dot block"))
225 blocks.setdefault(key, []).append(f
"{rel}:{open_at}")
227 elif open_at
is not None:
230 if open_at
is not None:
231 findings.append(Finding(f
"{rel}:{open_at}",
"@dot block never closed"))
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))
245 text = path.read_text(encoding=
"utf-8")
246 except (UnicodeDecodeError, OSError):
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)
253 return blocks, findings
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()
261 for page
in sorted(html_dir.rglob(
"*.html")):
263 text = page.read_text(encoding=
"utf-8", errors=
"replace")
266 referenced.update(SVG_REF.findall(text))
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"))
274 content = svg.read_text(encoding=
"utf-8", errors=
"replace")
276 if 'class="node"' not in content:
278 Finding(name,
"rendered SVG contains no graph nodes (dot produced an empty layout)")
286 literal = [t
for t
in SVG_TEXT.findall(content)
if "\\n" in t]
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]),
299 return referenced, live, findings
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"))
308 return digest.hexdigest()
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.
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
321 stamp = html_dir / STAMP_NAME
322 if not stamp.is_file():
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'.",
331 recorded = stamp.read_text(encoding=
"utf-8").strip()
332 current = source_fingerprint(blocks)
333 if recorded != current:
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.",
346def check(root: Path, html_dir: Path) -> list[Finding]:
347 """Run every rule and return the accumulated findings."""
348 blocks, findings = scan_sources(root)
353 stale = check_stamp(html_dir, blocks)
355 return findings + stale
357 _referenced, live, html_findings = scan_html(html_dir)
358 findings += html_findings
360 n_src, n_live = len(blocks), len(live)
364 f
" distinct authored @dot blocks : {n_src}",
365 f
" distinct rendered SVGs : {n_live}",
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
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()))
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.")
390 " More rendered diagrams than authored blocks -- a scan root "
391 "or suffix is missing from this gate."
395 "diagram-count",
"authored and rendered diagram counts disagree" +
"\n".join(detail)
406def _svg_wellformed(rel: str, data: bytes) -> list[Finding]:
407 """Reject a figure that is not parseable XML, at the parser's own position.
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
414 parser = expat.ParserCreate()
418 parser.Parse(data,
True)
419 except expat.ExpatError
as exc:
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",
431def _svg_ascii(rel: str, data: bytes) -> list[Finding]:
432 """Reject any byte outside 7-bit ASCII, naming the first one.
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.
442 except UnicodeDecodeError
as exc:
443 line = data[: exc.start].count(b
"\n") + 1
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 "
455def _svg_self_contained(rel: str, text: str) -> list[Finding]:
456 """Reject an off-page fetch, and any ``url(#id)`` with no local definition.
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.
468 f
"external reference {value!r} -- a figure must carry everything it draws",
470 for value
in SVG_HREF.findall(text)
471 if not value.startswith(SVG_LOCAL_REF)
473 ids = set(SVG_ID_DEF.findall(text))
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)
481def check_diagram_svgs(root: Path) -> list[Finding]:
482 """Validate every hand-authored figure under ``docs/diagrams/``.
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.
489 base = root / DIAGRAM_DIR
490 svgs = sorted(base.glob(
"*.svg"))
if base.is_dir()
else []
491 if len(svgs) < DIAGRAM_FLOOR:
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 "
500 findings: list[Finding] = []
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
508 findings += _svg_self_contained(rel, data.decode(
"ascii", errors=
"replace"))
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>'
522def _md(*bodies: str) -> str:
523 return "Prose paragraph.\n\n" +
"\n".join(
"@dot\n" + b +
"\n@enddot\n" for b
in bodies)
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>"
531def _hashed(body: str) -> str:
532 """Synthesise a doxygen-style SVG filename for a selftest fixture.
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.
537 digest = hashlib.md5(body.encode(), usedforsecurity=
False).hexdigest()
538 return f
"dot_inline_dotgraph_{digest}.svg"
548_G1 =
"digraph a { x -> y; }"
549_G2 =
"digraph b { p -> q; }"
550_S1, _S2 = _hashed(_G1), _hashed(_G2)
553 "/**\n * @dot\n" +
"\n".join(
" * " + ln
for ln
in _G1.splitlines()) +
"\n * @enddot\n */\n"
557class _DiagramCase(NamedTuple):
558 """One selftest scenario: a fixture tree and the verdict the gate must reach.
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.
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/``."""
570 """Doxygen-output-relative path -> file text, materialised under ``html/``."""
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``."""
577_SELFTEST_CASES: tuple[_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)},
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)},
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)},
599 "dropped: 2 authored, 1 rendered",
600 {
"docs/x.md": _md(_G1, _G2)},
601 {
"p.html": _page(_S1), _S1: _svg(nodes=
True)},
605 "dropped: every diagram missing (HAVE_DOT=NO)",
606 {
"docs/x.md": _md(_G1, _G2)},
607 {
"p.html":
"<html><body>no diagrams</body></html>"},
611 "empty: rendered SVG has no nodes",
612 {
"docs/x.md": _md(_G1)},
613 {
"p.html": _page(_S1), _S1: _svg(nodes=
False)},
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")},
623 "missing: referenced SVG absent from disk",
624 {
"docs/x.md": _md(_G1)},
625 {
"p.html": _page(_S1)},
629 "startuml: banned construct present",
630 {
"docs/x.md":
"@startuml\n[*] --> A\n@enduml\n"},
631 {
"p.html":
"<html></html>"},
635 "unbalanced: @dot never closed",
636 {
"docs/x.md":
"@dot\ndigraph z { a -> b; }\n"},
637 {
"p.html":
"<html></html>"},
645 "stale: leftover render masks a dropped diagram",
648 {
"docs/x.md": _md(_G1, _G2)},
649 {
"p.html": _page(_S1), _S1: _svg(nodes=
True), _S2: _svg(nodes=
True)},
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)},
661 "unknown provenance: no build stamp at all",
662 {
"docs/x.md": _md(_G1)},
663 {
"p.html": _page(_S1), _S1: _svg(nodes=
True)},
665 stamp_mode=
"missing",
670def _materialise_case(case: _DiagramCase, td: str) -> tuple[Path, Path]:
671 """Write one case's fixture tree under ``td`` and seed its build stamp.
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.
678 root = Path(td) /
"repo"
679 hdir = Path(td) /
"html"
680 hdir.mkdir(parents=
True)
681 for rel, text
in case.sources.items():
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")
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")
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")
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")
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)
717class _SvgCase(NamedTuple):
718 """One docs/diagrams scenario: a fixture directory and the verdict required."""
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/``."""
725 """True when the sweep MUST report a finding for this tree."""
728def _fig(marker: str, extra: str =
"") -> str:
729 """One figure in the house style: a marker reached through ``url(#id)``."""
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}'
738def _figs(count: int = DIAGRAM_FLOOR, **replace: str) -> dict[str, str]:
739 """``count`` clean figures keyed by path, with named entries replaced.
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.
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()})
749_SVG_CASES: tuple[_SvgCase, ...] = (
751 _SvgCase(
"figures: a clean directory", _figs(), expect_fire=
False),
757 "figures: double hyphen inside an XML comment",
758 _figs(f0=_fig(
"a0",
"<!-- drawn -- badly -->")),
762 "figures: external image reference",
763 _figs(f0=_fig(
"a0",
'<image href="https://example.invalid/x.png" width="4"/>')),
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)"/>')),
772 "figures: a non-ASCII byte",
773 _figs(f0=_fig(
"a0",
'<text x="1" y="1">\u00b5s</text>')),
777 "figures: directory below the non-vacuity floor",
778 _figs(DIAGRAM_FLOOR - 1),
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():
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)
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)
803 sys.stderr.write(f
"check_doc_diagrams.py: selftest FAILED ({failures}/{total} cases).\n")
805 fires = sum(expected)
807 f
"check_doc_diagrams.py: selftest PASSED ({total} cases -- "
808 f
"{fires} assert the gate fires, {total - fires} assert it does not).\n"
813def _build_parser() -> argparse.ArgumentParser:
814 """Build the command-line parser for this gate."""
815 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
817 "--html", default=
"build/docs-gate/html", help=
"generated HTML directory to inspect"
820 "--selftest", action=
"store_true", help=
"run synthetic both-direction fixtures and exit"
825 help=
"record the authored diagram fingerprint into the HTML tree "
826 "(called by scripts/builders/docs.sh once a build finishes)",
831def _graphviz_present() -> bool:
832 """True when graphviz ``dot`` is on PATH; writes the FATAL message when not.
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.
838 if shutil.which(
"dot")
is not None:
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"
848def _resolve_html_dir(raw: str) -> Path |
None:
849 """Resolve ``raw`` against the repo root; None (with a message) if absent."""
851 if not html_dir.is_absolute():
852 html_dir = REPO_ROOT / html_dir
853 if html_dir.is_dir():
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"
862def _report_findings(findings: list[Finding]) ->
None:
863 """Print every finding, then advice matched to what actually went wrong.
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
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)]
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"
880 if any(
"stale output" in f.message
or "no build stamp" in f.message
for f
in findings):
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"
886 elif len(figures) != len(findings):
888 "\nAn authored diagram that does not reach the HTML is invisible "
889 "to every reader of the published site.\n"
894 """Verify every authored diagram actually reached the generated HTML.
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
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.
908 Returns 0 when every authored diagram is present, 1 on any finding.
910 args = _build_parser().parse_args()
915 if not _graphviz_present():
917 html_dir = _resolve_html_dir(args.html)
922 blocks, _ = scan_sources(REPO_ROOT)
923 (html_dir / STAMP_NAME).write_text(source_fingerprint(blocks), encoding=
"utf-8")
925 f
"check_doc_diagrams.py: stamped {len(blocks)} authored diagram(s) "
926 f
"into {html_dir.relative_to(REPO_ROOT)}.\n"
930 findings = check_diagram_svgs(REPO_ROOT) +
check(REPO_ROOT, html_dir)
932 _report_findings(findings)
935 blocks, _ = scan_sources(REPO_ROOT)
936 figures = len(list((REPO_ROOT / DIAGRAM_DIR).glob(
"*.svg")))
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"
945if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.