Files
moli/scripts/check-mermaid-docs.py
2026-08-11 00:10:12 +08:00

427 lines
13 KiB
Python

#!/usr/bin/env python3
"""Validate Mermaid diagrams embedded in Markdown files.
The script always performs lightweight static checks that catch common reasons
Mermaid diagrams fail to render. If the Mermaid CLI (`mmdc`) is available on
PATH, it also renders each block to a temporary SVG so Mermaid's own parser is
used as the final authority.
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
DEFAULT_DOC = Path("docs/chromium-module-tree-optimization-audit-2026-06-26.md")
@dataclass(frozen=True)
class MermaidBlock:
index: int
start_line: int
end_line: int
body: str
@property
def first_line(self) -> str:
for line in self.body.splitlines():
if line.strip():
return line.strip()
return "<empty>"
@dataclass(frozen=True)
class Diagnostic:
level: str
path: Path
block_index: int
line: int
message: str
def format(self) -> str:
return (
f"{self.path}:{self.line}: {self.level}: "
f"mermaid block {self.block_index:02d}: {self.message}"
)
def extract_blocks(path: Path) -> tuple[list[MermaidBlock], list[Diagnostic]]:
diagnostics: list[Diagnostic] = []
lines = path.read_text(encoding="utf-8").splitlines()
blocks: list[MermaidBlock] = []
inside = False
start_line = 0
body: list[str] = []
for line_no, line in enumerate(lines, 1):
if not inside and line.strip() == "```mermaid":
inside = True
start_line = line_no + 1
body = []
continue
if inside and line.strip() == "```":
blocks.append(
MermaidBlock(
index=len(blocks) + 1,
start_line=start_line,
end_line=line_no - 1,
body="\n".join(body),
)
)
inside = False
continue
if inside:
body.append(line)
if inside:
diagnostics.append(
Diagnostic(
"ERROR",
path,
len(blocks) + 1,
start_line - 1,
"unclosed Mermaid code fence",
)
)
return blocks, diagnostics
ALLOWED_HEADERS = {
"flowchart",
"graph",
"sequenceDiagram",
"stateDiagram-v2",
"classDiagram",
}
NODE_DEF_RE = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\[|\(|\{|>|\[\[|\(\()")
PARTICIPANT_RE = re.compile(
r"^\s*(?:participant|actor)\s+([A-Za-z][A-Za-z0-9_]*)\b(?:\s+as\s+(.+?))?\s*$"
)
SEQ_ARROW_RE = re.compile(
r"^\s*([A-Za-z][A-Za-z0-9_]*)\s*(?:-+>>|-->>|->>|-->|->|-)\+?\s*"
r"([A-Za-z][A-Za-z0-9_]*)\s*:"
)
STATE_TRANSITION_RE = re.compile(
r"^\s*(\[\*\]|[A-Za-z][A-Za-z0-9_]*)\s*--?>\s*"
r"(\[\*\]|[A-Za-z][A-Za-z0-9_]*)"
)
STATE_DECL_RE = re.compile(r'^\s*state\s+"[^"]+"\s+as\s+([A-Za-z][A-Za-z0-9_]*)\s*$')
CLASS_DECL_RE = re.compile(r"^\s*class\s+([A-Za-z][A-Za-z0-9_]*)\b")
CLASS_REL_RE = re.compile(
r"^\s*([A-Za-z][A-Za-z0-9_]*)\s+"
r"(?:<\|--|--\|>|\*--|o--|-->|<--|\.\.>|<\.\.)\s+"
r"([A-Za-z][A-Za-z0-9_]*)"
)
def strip_label_strings(line: str) -> str:
return re.sub(r'"[^"]*"', '""', line)
def static_check(path: Path, block: MermaidBlock) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
numbered_lines = [
(block.start_line + offset, line)
for offset, line in enumerate(block.body.splitlines())
if line.strip()
]
if not numbered_lines:
return [
Diagnostic("ERROR", path, block.index, block.start_line, "empty Mermaid block")
]
header_line_no, header_line = numbered_lines[0]
header = header_line.strip().split()[0]
if header not in ALLOWED_HEADERS:
diagnostics.append(
Diagnostic(
"ERROR",
path,
block.index,
header_line_no,
f"unsupported or unknown Mermaid header `{header_line.strip()}`",
)
)
for line_no, line in numbered_lines:
stripped = line.strip()
if stripped.startswith("%%"):
continue
if line.count('"') % 2:
diagnostics.append(
Diagnostic("ERROR", path, block.index, line_no, "odd number of double quotes")
)
if "`" in line:
diagnostics.append(
Diagnostic(
"WARNING",
path,
block.index,
line_no,
"contains backticks; some Mermaid renderers fail on markdown labels",
)
)
if header in {"flowchart", "graph"}:
diagnostics.extend(check_flowchart(path, block, numbered_lines[1:]))
elif header == "sequenceDiagram":
diagnostics.extend(check_sequence(path, block, numbered_lines[1:]))
elif header == "stateDiagram-v2":
diagnostics.extend(check_state(path, block, numbered_lines[1:]))
elif header == "classDiagram":
diagnostics.extend(check_class(path, block, numbered_lines[1:]))
return diagnostics
def check_flowchart(
path: Path, block: MermaidBlock, lines: list[tuple[int, str]]
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
declared: set[str] = set()
used: set[str] = set()
edge_re = re.compile(
r"\b([A-Za-z][A-Za-z0-9_]*)\b\s*(?:-->|---|-.->|==>|--x|--o)\s*"
r"\b([A-Za-z][A-Za-z0-9_]*)\b"
)
for line_no, line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("%%"):
continue
for match in NODE_DEF_RE.finditer(line):
declared.add(match.group(1))
for match in edge_re.finditer(strip_label_strings(line)):
used.update(match.groups())
if re.search(r"(?:-->|---|-.->|==>|--x|--o)\s*$", stripped):
diagnostics.append(
Diagnostic("ERROR", path, block.index, line_no, "flowchart edge has no target")
)
for name in sorted(used - declared):
diagnostics.append(
Diagnostic(
"WARNING",
path,
block.index,
block.start_line,
f"flowchart node `{name}` is used implicitly",
)
)
return diagnostics
def check_sequence(
path: Path, block: MermaidBlock, lines: list[tuple[int, str]]
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
declared: set[str] = set()
used: set[str] = set()
for line_no, line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("%%"):
continue
match = PARTICIPANT_RE.match(line)
if match:
declared.add(match.group(1))
alias = match.group(2)
if alias and not (
(alias.startswith('"') and alias.endswith('"'))
or (alias.startswith("'") and alias.endswith("'"))
):
alias_tokens = alias.split()
if len(alias_tokens) > 1:
diagnostics.append(
Diagnostic(
"ERROR",
path,
block.index,
line_no,
"sequence participant alias with spaces must be quoted "
"or converted to a single token; some Mermaid renderers "
"merge the next message line into the participant",
)
)
match = SEQ_ARROW_RE.match(line)
if match:
used.update(match.groups())
elif re.search(r"-{1,2}>>|-->|->", stripped) and ":" not in stripped:
diagnostics.append(
Diagnostic(
"ERROR",
path,
block.index,
line_no,
"sequence message arrow is missing a colon label",
)
)
for name in sorted(used - declared):
diagnostics.append(
Diagnostic(
"WARNING",
path,
block.index,
block.start_line,
f"sequence participant `{name}` is used before explicit declaration",
)
)
return diagnostics
def check_state(
path: Path, block: MermaidBlock, lines: list[tuple[int, str]]
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
declared: set[str] = set()
used: set[str] = set()
for line_no, line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("%%"):
continue
match = STATE_DECL_RE.match(line)
if match:
declared.add(match.group(1))
match = STATE_TRANSITION_RE.match(line)
if match:
used.update(name for name in match.groups() if name != "[*]")
elif "-->" in stripped:
diagnostics.append(
Diagnostic(
"ERROR",
path,
block.index,
line_no,
"state transition endpoint must be an identifier or [*]; "
'use `state "Label" as Id` for labels',
)
)
for name in sorted(used - declared):
diagnostics.append(
Diagnostic(
"WARNING",
path,
block.index,
block.start_line,
f"state `{name}` is used without explicit `state ... as ...` declaration",
)
)
return diagnostics
def check_class(
path: Path, block: MermaidBlock, lines: list[tuple[int, str]]
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
declared: set[str] = set()
used: set[str] = set()
for _, line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("%%"):
continue
match = CLASS_DECL_RE.match(line)
if match:
declared.add(match.group(1))
match = CLASS_REL_RE.match(line)
if match:
used.update(match.groups())
for name in sorted(used - declared):
diagnostics.append(
Diagnostic(
"WARNING",
path,
block.index,
block.start_line,
f"class `{name}` is used without explicit class declaration",
)
)
return diagnostics
def render_with_mmdc(path: Path, block: MermaidBlock, mmdc: str) -> list[Diagnostic]:
with tempfile.TemporaryDirectory(prefix="mermaid-check-") as tmp:
tmp_dir = Path(tmp)
input_path = tmp_dir / f"block-{block.index:02d}.mmd"
output_path = tmp_dir / f"block-{block.index:02d}.svg"
input_path.write_text(block.body + "\n", encoding="utf-8")
result = subprocess.run(
[mmdc, "-i", str(input_path), "-o", str(output_path), "-q"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode == 0 and output_path.exists():
return []
message = (result.stderr or result.stdout or "mmdc failed without output").strip()
return [
Diagnostic(
"ERROR",
path,
block.index,
block.start_line,
"Mermaid CLI render failed: " + message.replace("\n", " | "),
)
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"paths",
nargs="*",
type=Path,
default=[DEFAULT_DOC],
help="Markdown files to validate",
)
parser.add_argument(
"--require-mmdc",
action="store_true",
help="fail if Mermaid CLI (`mmdc`) is not available",
)
args = parser.parse_args()
mmdc = shutil.which("mmdc")
if args.require_mmdc and not mmdc:
print("ERROR: `mmdc` was not found on PATH", file=sys.stderr)
return 1
all_diagnostics: list[Diagnostic] = []
total_blocks = 0
for path in args.paths:
blocks, diagnostics = extract_blocks(path)
all_diagnostics.extend(diagnostics)
total_blocks += len(blocks)
print(f"{path}: found {len(blocks)} Mermaid block(s)")
for block in blocks:
print(
f" block {block.index:02d}: "
f"lines {block.start_line}-{block.end_line}: {block.first_line}"
)
all_diagnostics.extend(static_check(path, block))
if mmdc:
all_diagnostics.extend(render_with_mmdc(path, block, mmdc))
if not mmdc:
print(
"NOTE: `mmdc` not found; only static Mermaid checks were run. "
"Install @mermaid-js/mermaid-cli or pass --require-mmdc in CI.",
file=sys.stderr,
)
errors = [diag for diag in all_diagnostics if diag.level == "ERROR"]
warnings = [diag for diag in all_diagnostics if diag.level == "WARNING"]
for diag in errors + warnings:
print(diag.format(), file=sys.stderr)
if errors:
print(f"FAILED: {len(errors)} error(s), {len(warnings)} warning(s)", file=sys.stderr)
return 1
print(f"OK: checked {total_blocks} Mermaid block(s), {len(warnings)} warning(s)")
return 0
if __name__ == "__main__":
raise SystemExit(main())