pi-visualize-code-changes 1.0.0

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.
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env python3
2
+ """Validate every Mermaid block in a Markdown (or .mmd) file.
3
+
4
+ A diagram that does not parse is worse than no diagram: it renders as a red
5
+ error box on GitHub and silently wastes the reader's time. This script is the
6
+ cheap way to be certain before you hand the file over.
7
+
8
+ Two modes, chosen automatically:
9
+
10
+ render Pipes each diagram through `mmdc` (mermaid-cli), which is the real
11
+ Mermaid parser. Authoritative -- if it passes here it renders.
12
+ lint Fallback when mmdc or a Chrome binary is unavailable. Pure-Python
13
+ heuristics covering the failure modes that actually bite. Catches
14
+ most breakage but cannot promise a clean render.
15
+
16
+ Usage:
17
+ python3 validate_mermaid.py docs/diagrams/auth.md
18
+ python3 validate_mermaid.py --render-to out/ docs/diagrams/auth.md
19
+ python3 validate_mermaid.py --lint-only docs/diagrams/auth.md
20
+
21
+ Exit code is 0 when every block is valid, 1 otherwise. Error line numbers are
22
+ reported against the source file, not the extracted block, so they are
23
+ directly actionable.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Block extraction
41
+ # ---------------------------------------------------------------------------
42
+
43
+ FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})[ \t]*mermaid[ \t]*$", re.I)
44
+ COLON_OPEN_RE = re.compile(r"^[ \t]*:::[ \t]*mermaid[ \t]*$", re.I)
45
+ COLON_CLOSE_RE = re.compile(r"^[ \t]*:::[ \t]*$")
46
+
47
+
48
+ @dataclass
49
+ class Block:
50
+ """One Mermaid diagram lifted out of a source file."""
51
+
52
+ index: int
53
+ body: str
54
+ start_line: int # 1-based source line of the diagram's FIRST content line
55
+ heading: str = ""
56
+ errors: list[str] = field(default_factory=list)
57
+
58
+ @property
59
+ def label(self) -> str:
60
+ where = f"line {self.start_line}"
61
+ if self.heading:
62
+ return f"block {self.index} ({self.heading}, {where})"
63
+ return f"block {self.index} ({where})"
64
+
65
+
66
+ def _nearest_heading(lines: list[str], upto: int) -> str:
67
+ for i in range(upto - 1, -1, -1):
68
+ line = lines[i].strip()
69
+ if line.startswith("#"):
70
+ return line.lstrip("#").strip()
71
+ return ""
72
+
73
+
74
+ def extract_blocks(path: Path) -> list[Block]:
75
+ """Pull Mermaid diagrams out of Markdown, or treat a .mmd file as one block."""
76
+ text = path.read_text(encoding="utf-8")
77
+ lines = text.splitlines()
78
+
79
+ if path.suffix.lower() in {".mmd", ".mermaid"}:
80
+ return [Block(index=1, body=text, start_line=1)]
81
+
82
+ blocks: list[Block] = []
83
+ i = 0
84
+ n = 0
85
+ while i < len(lines):
86
+ fence_match = FENCE_RE.match(lines[i])
87
+ colon_match = COLON_OPEN_RE.match(lines[i])
88
+
89
+ if not (fence_match or colon_match):
90
+ i += 1
91
+ continue
92
+
93
+ if fence_match:
94
+ fence = fence_match.group("fence")
95
+ closer = re.compile(rf"^[ \t]*{re.escape(fence[0])}{{{len(fence)},}}[ \t]*$")
96
+ else:
97
+ closer = COLON_CLOSE_RE
98
+
99
+ body_start = i + 1
100
+ j = body_start
101
+ while j < len(lines) and not closer.match(lines[j]):
102
+ j += 1
103
+
104
+ n += 1
105
+ blocks.append(
106
+ Block(
107
+ index=n,
108
+ body="\n".join(lines[body_start:j]),
109
+ start_line=body_start + 1, # 1-based
110
+ heading=_nearest_heading(lines, i),
111
+ )
112
+ )
113
+ i = j + 1
114
+
115
+ return blocks
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Renderer discovery
120
+ # ---------------------------------------------------------------------------
121
+
122
+ CHROME_CANDIDATES = [
123
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
124
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
125
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
126
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
127
+ "/usr/bin/google-chrome",
128
+ "/usr/bin/google-chrome-stable",
129
+ "/usr/bin/chromium",
130
+ "/usr/bin/chromium-browser",
131
+ "/snap/bin/chromium",
132
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
133
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
134
+ ]
135
+
136
+
137
+ def find_mmdc() -> str | None:
138
+ found = shutil.which("mmdc")
139
+ if found:
140
+ return found
141
+ # nvm installs often sit outside a non-login shell's PATH.
142
+ for base in (Path.home() / ".nvm/versions/node").glob("*/bin/mmdc"):
143
+ if os.access(base, os.X_OK):
144
+ return str(base)
145
+ return None
146
+
147
+
148
+ def mmdc_env(mmdc: str) -> dict:
149
+ """Environment for running mmdc.
150
+
151
+ `mmdc` is a Node script whose shebang resolves `node` through PATH. When we
152
+ discover it inside an nvm directory that PATH doesn't contain, node isn't
153
+ reachable either, and the script dies with "env: node: No such file or
154
+ directory". Putting its own bin directory on PATH fixes that.
155
+ """
156
+ env = os.environ.copy()
157
+ bindir = str(Path(mmdc).parent)
158
+ if bindir not in env.get("PATH", "").split(os.pathsep):
159
+ env["PATH"] = bindir + os.pathsep + env.get("PATH", "")
160
+ return env
161
+
162
+
163
+ # Signatures meaning "the renderer could not start", as opposed to "your
164
+ # diagram is wrong". Confusing the two is the worst thing this script can do:
165
+ # reporting a valid diagram as broken sends the caller off fixing nothing.
166
+ INFRA_ERROR_MARKERS = (
167
+ "env: node", "node: No such file", "command not found",
168
+ "Could not find chrome", "Failed to launch", "spawn ",
169
+ "cannot open display", "ENOENT",
170
+ )
171
+
172
+
173
+ def looks_like_infra_failure(stderr: str) -> bool:
174
+ if "Parse error" in stderr or "Syntax error" in stderr:
175
+ return False
176
+ return any(m.lower() in stderr.lower() for m in INFRA_ERROR_MARKERS)
177
+
178
+
179
+ PROBE = 'flowchart TD\n A["probe"] --> B["ok"]\n'
180
+
181
+
182
+ def renderer_works(mmdc: str, pptr_cfg: Path | None) -> tuple[bool, str]:
183
+ """Render a trivial known-good diagram to prove the toolchain runs."""
184
+ with tempfile.TemporaryDirectory() as td:
185
+ src, dst = Path(td) / "probe.mmd", Path(td) / "probe.svg"
186
+ src.write_text(PROBE, encoding="utf-8")
187
+ cmd = [mmdc, "-i", str(src), "-o", str(dst), "-q"]
188
+ if pptr_cfg:
189
+ cmd += ["-p", str(pptr_cfg)]
190
+ try:
191
+ proc = subprocess.run(cmd, capture_output=True, text=True,
192
+ timeout=180, env=mmdc_env(mmdc))
193
+ except (subprocess.TimeoutExpired, OSError) as exc:
194
+ return False, str(exc)
195
+ if proc.returncode == 0 and dst.exists():
196
+ return True, ""
197
+ return False, (proc.stderr or proc.stdout).strip().splitlines()[0][:200] if (proc.stderr or proc.stdout) else "unknown failure"
198
+
199
+
200
+ def puppeteer_has_browser() -> bool:
201
+ """True when Puppeteer already downloaded its own Chrome; no config needed."""
202
+ cache = Path(os.environ.get("PUPPETEER_CACHE_DIR", Path.home() / ".cache/puppeteer"))
203
+ if not cache.is_dir():
204
+ return False
205
+ return any(cache.glob("chrome*/**/*"))
206
+
207
+
208
+ def find_chrome() -> str | None:
209
+ for env in ("PUPPETEER_EXECUTABLE_PATH", "CHROME_PATH"):
210
+ val = os.environ.get(env)
211
+ if val and Path(val).exists():
212
+ return val
213
+ for cand in CHROME_CANDIDATES:
214
+ if Path(cand).exists():
215
+ return cand
216
+ for name in ("google-chrome", "chromium", "chromium-browser", "microsoft-edge"):
217
+ found = shutil.which(name)
218
+ if found:
219
+ return found
220
+ return None
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Render-based validation (authoritative)
225
+ # ---------------------------------------------------------------------------
226
+
227
+ PARSE_LINE_RE = re.compile(r"(Parse error on line )(\d+)", re.I)
228
+
229
+
230
+ def _clean_error(stderr: str, block: Block) -> str:
231
+ """Trim mmdc's stack trace to the useful part, remapping line numbers."""
232
+ keep: list[str] = []
233
+ for line in stderr.splitlines():
234
+ s = line.strip()
235
+ if not s:
236
+ continue
237
+ if s.startswith("at ") or "node_modules" in s or s.startswith("file://"):
238
+ continue
239
+ if "Generating single mermaid chart" in s:
240
+ continue
241
+ keep.append(s)
242
+ if len(keep) >= 8:
243
+ break
244
+ msg = "\n".join(keep) if keep else stderr.strip()[:600]
245
+
246
+ # Mermaid reports lines relative to the block; translate to the real file.
247
+ def shift(m: re.Match) -> str:
248
+ return f"{m.group(1)}{int(m.group(2)) + block.start_line - 1}"
249
+
250
+ return PARSE_LINE_RE.sub(shift, msg)
251
+
252
+
253
+ def render_block(
254
+ mmdc: str, block: Block, pptr_cfg: Path | None, out_path: Path
255
+ ) -> tuple[bool, str]:
256
+ with tempfile.TemporaryDirectory() as td:
257
+ src = Path(td) / "d.mmd"
258
+ src.write_text(block.body, encoding="utf-8")
259
+ cmd = [mmdc, "-i", str(src), "-o", str(out_path), "-q"]
260
+ if pptr_cfg:
261
+ cmd += ["-p", str(pptr_cfg)]
262
+ try:
263
+ proc = subprocess.run(cmd, capture_output=True, text=True,
264
+ timeout=120, env=mmdc_env(mmdc))
265
+ except subprocess.TimeoutExpired:
266
+ return False, "mmdc timed out after 120s"
267
+ except OSError as exc:
268
+ return False, f"could not run mmdc: {exc}"
269
+
270
+ if proc.returncode == 0 and out_path.exists():
271
+ return True, ""
272
+ return False, _clean_error(proc.stderr or proc.stdout, block)
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Heuristic linting (fallback)
277
+ # ---------------------------------------------------------------------------
278
+
279
+ VALID_HEADERS = (
280
+ "flowchart", "graph", "sequencediagram", "classdiagram", "statediagram",
281
+ "statediagram-v2", "erdiagram", "journey", "gantt", "pie", "quadrantchart",
282
+ "requirementdiagram", "gitgraph", "mindmap", "timeline", "zenuml",
283
+ "sankey-beta", "xychart-beta", "block-beta", "packet-beta", "architecture-beta",
284
+ "c4context", "c4container", "c4component", "c4dynamic", "kanban", "radar-beta",
285
+ "treemap-beta", "flowchart-elk",
286
+ )
287
+
288
+ # Characters that terminate a label early unless the label is quoted.
289
+ RISKY_IN_LABEL = set("()[]{}<>|;")
290
+
291
+ SHAPE_OPENERS = [
292
+ ("[[", "]]"), ("[(", ")]"), ("[/", "/]"), ("[\\", "\\]"),
293
+ ("([", "])"), ("((", "))"), ("{{", "}}"), ("(-", "-)"),
294
+ ("[", "]"), ("(", ")"), ("{", "}"),
295
+ ]
296
+
297
+ # Node ids start with a letter/underscore. Crucially the id must NOT be allowed
298
+ # to begin with '-', or the '--' of an arrow gets read as an id.
299
+ NODE_DECL_RE = re.compile(
300
+ r"(?<![A-Za-z0-9_.-])(?P<id>[A-Za-z_][A-Za-z0-9_.-]*)"
301
+ r"(?P<open>\[\[|\[\(|\[/|\[\\|\(\[|\(\(|\{\{|\[|\(|\{)"
302
+ )
303
+
304
+ # Every arrow/link form Mermaid accepts, longest first.
305
+ ARROW_RE = re.compile(
306
+ r"(<-->|<==>|-\.->|-\.-|={2,}>|={3,}|-{2,}>|-{3,}|--[xo]|==[xo]|~~~)"
307
+ )
308
+
309
+
310
+ def _blank_edge_syntax(s: str) -> str:
311
+ """Remove arrows and |edge labels| so only node declarations remain.
312
+
313
+ Without this, `A --> B["x"]` reads the `--` as a node id and the `>` as a
314
+ shape opener, producing nonsense diagnostics.
315
+ """
316
+ s = ARROW_RE.sub(lambda m: " " * len(m.group(0)), s)
317
+ s = re.sub(r"\|[^|]*\|", lambda m: " " * len(m.group(0)), s)
318
+ return s
319
+
320
+
321
+ def _strip_quoted(s: str) -> str:
322
+ """Blank out quoted spans so their contents don't trip other checks."""
323
+ out, in_q, i = [], False, 0
324
+ while i < len(s):
325
+ c = s[i]
326
+ if c == '"':
327
+ in_q = not in_q
328
+ out.append('"')
329
+ else:
330
+ out.append(" " if in_q else c)
331
+ i += 1
332
+ return "".join(out)
333
+
334
+
335
+ def lint_block(block: Block) -> list[str]:
336
+ problems: list[str] = []
337
+ raw_lines = block.body.splitlines()
338
+
339
+ content = [
340
+ (n, ln) for n, ln in enumerate(raw_lines, start=1)
341
+ if ln.strip() and not ln.strip().startswith("%%")
342
+ ]
343
+ if not content:
344
+ return ["empty diagram (no content lines)"]
345
+
346
+ def at(n: int) -> int:
347
+ return block.start_line + n - 1
348
+
349
+ # 1. Header must name a real diagram type.
350
+ first_no, first = content[0]
351
+ head = first.strip().split()[0].lower().rstrip(":")
352
+ if head not in VALID_HEADERS:
353
+ problems.append(
354
+ f"line {at(first_no)}: '{first.strip()[:40]}' does not start with a known "
355
+ f"diagram type (flowchart, sequenceDiagram, stateDiagram-v2, ...)"
356
+ )
357
+
358
+ is_flowchart = head in ("flowchart", "graph", "flowchart-elk")
359
+
360
+ # 2. subgraph / end must balance.
361
+ depth = 0
362
+ for n, ln in content:
363
+ s = ln.strip()
364
+ low = s.lower()
365
+ if low.startswith("subgraph"):
366
+ depth += 1
367
+ elif low == "end":
368
+ depth -= 1
369
+ if depth < 0:
370
+ problems.append(f"line {at(n)}: 'end' without a matching 'subgraph'")
371
+ depth = 0
372
+ if depth > 0:
373
+ problems.append(f"{depth} unclosed 'subgraph' block(s) -- each needs a matching 'end'")
374
+
375
+ # 3. Unquoted risky characters inside node labels (the #1 real-world break).
376
+ if is_flowchart:
377
+ for n, ln in content:
378
+ s = ln.strip()
379
+ if s.lower().startswith(("classdef", "class ", "style ", "linkstyle", "subgraph", "click")):
380
+ continue
381
+ s = _blank_edge_syntax(s)
382
+ for m in NODE_DECL_RE.finditer(s):
383
+ opener = m.group("open")
384
+ closer = next((c for o, c in SHAPE_OPENERS if o == opener), None)
385
+ if closer is None:
386
+ continue
387
+ inner_start = m.end()
388
+ depth_b, k, in_q, end = 1, inner_start, False, -1
389
+ while k < len(s):
390
+ if s[k] == '"':
391
+ in_q = not in_q
392
+ k += 1
393
+ continue
394
+ if not in_q:
395
+ if s.startswith(closer, k):
396
+ depth_b -= 1
397
+ if depth_b == 0:
398
+ end = k
399
+ break
400
+ k += len(closer)
401
+ continue
402
+ if s.startswith(opener, k):
403
+ depth_b += 1
404
+ k += len(opener)
405
+ continue
406
+ k += 1
407
+ if end == -1:
408
+ continue
409
+ label = s[inner_start:end].strip()
410
+ if not label or (label.startswith('"') and label.endswith('"')):
411
+ continue
412
+ bad = sorted(RISKY_IN_LABEL & set(label))
413
+ if bad:
414
+ problems.append(
415
+ f"line {at(n)}: label {label[:38]!r} contains {''.join(bad)} "
416
+ f'-- wrap it in double quotes: {m.group("id")}{opener}"{label}"{closer}'
417
+ )
418
+
419
+ # 4. Every :::class / class stmt should point at a defined classDef.
420
+ defined = set()
421
+ for _, ln in content:
422
+ m = re.match(r"\s*classDef\s+([A-Za-z0-9_,-]+)", ln)
423
+ if m:
424
+ defined.update(p.strip() for p in m.group(1).split(","))
425
+ used: dict[str, int] = {}
426
+ for n, ln in content:
427
+ for m in re.finditer(r":::([A-Za-z0-9_]+)", ln):
428
+ used.setdefault(m.group(1), n)
429
+ m = re.match(r"\s*class\s+[A-Za-z0-9_,\s.-]+\s+([A-Za-z0-9_]+)\s*;?\s*$", ln)
430
+ if m:
431
+ used.setdefault(m.group(1), n)
432
+ for name, n in used.items():
433
+ if name not in defined and name != "default":
434
+ problems.append(
435
+ f"line {at(n)}: style class '{name}' is used but never defined "
436
+ f"-- add a `classDef {name} ...` line"
437
+ )
438
+
439
+ # 5. linkStyle indices must exist.
440
+ arrow_re = re.compile(r"(-{2,3}>|-{3,}|-\.->|-\.-|={2,}>|={3,}|--[xo]|<-->|<==>)")
441
+ edges = sum(len(arrow_re.findall(_strip_quoted(ln))) for _, ln in content
442
+ if not ln.strip().lower().startswith(("classdef", "linkstyle", "style ")))
443
+ for n, ln in content:
444
+ m = re.match(r"\s*linkStyle\s+([\d,\s]+)", ln)
445
+ if m and edges:
446
+ for part in re.findall(r"\d+", m.group(1)):
447
+ if int(part) >= edges:
448
+ problems.append(
449
+ f"line {at(n)}: linkStyle {part} but the diagram only has "
450
+ f"{edges} link(s) (indices are 0-based, in declaration order)"
451
+ )
452
+
453
+ # 6. `end` as a bare node id breaks the flowchart parser.
454
+ if is_flowchart:
455
+ for n, ln in content:
456
+ if re.search(r"(^|[\s>-])end([\s\[({]|$)", ln) and ln.strip().lower() != "end":
457
+ problems.append(
458
+ f"line {at(n)}: 'end' is reserved -- rename the node "
459
+ f"(e.g. 'endNode') or capitalise it"
460
+ )
461
+ break
462
+
463
+ return problems
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Driver
468
+ # ---------------------------------------------------------------------------
469
+
470
+ def main() -> int:
471
+ ap = argparse.ArgumentParser(description="Validate Mermaid blocks in Markdown/.mmd files.")
472
+ ap.add_argument("files", nargs="+", type=Path)
473
+ ap.add_argument("--lint-only", action="store_true",
474
+ help="Skip rendering; use heuristics only (fast, no browser).")
475
+ ap.add_argument("--render-to", type=Path, metavar="DIR",
476
+ help="Also keep the rendered SVGs in DIR.")
477
+ ap.add_argument("--quiet", action="store_true", help="Only print failures.")
478
+ args = ap.parse_args()
479
+
480
+ mmdc = None if args.lint_only else find_mmdc()
481
+ pptr_cfg_file = None
482
+ tmpdir = tempfile.mkdtemp(prefix="mermaid-validate-")
483
+ mode = "lint"
484
+
485
+ fallback_reason = ""
486
+ if mmdc:
487
+ if not puppeteer_has_browser():
488
+ chrome = find_chrome()
489
+ if chrome:
490
+ cfg = Path(tmpdir) / "pptr.json"
491
+ cfg.write_text(json.dumps({
492
+ "executablePath": chrome,
493
+ "headless": "new",
494
+ "args": ["--no-sandbox", "--disable-setuid-sandbox"],
495
+ }), encoding="utf-8")
496
+ pptr_cfg_file = cfg
497
+ else:
498
+ fallback_reason = "no Chrome/Chromium found"
499
+
500
+ if not fallback_reason:
501
+ # Prove the toolchain actually runs before trusting its verdicts.
502
+ # Without this probe, an unlaunchable mmdc reports every diagram as
503
+ # a parse failure and sends the caller chasing imaginary bugs.
504
+ ok, why = renderer_works(mmdc, pptr_cfg_file)
505
+ mode = "render" if ok else "lint"
506
+ if not ok:
507
+ fallback_reason = f"mmdc could not run ({why})"
508
+ elif not args.lint_only:
509
+ fallback_reason = "mmdc not found"
510
+
511
+ if not args.quiet:
512
+ if mode == "render":
513
+ print("validating with mmdc (authoritative render check)\n")
514
+ elif args.lint_only:
515
+ print("lint-only mode (heuristics)\n")
516
+ else:
517
+ print(f"NOTE: {fallback_reason} -- falling back to heuristic lint.\n"
518
+ f" Diagrams below are checked by rules, not a real render.\n"
519
+ f" For a guaranteed-renders check: npm i -g @mermaid-js/mermaid-cli\n")
520
+
521
+ if args.render_to:
522
+ args.render_to.mkdir(parents=True, exist_ok=True)
523
+
524
+ total = failed = 0
525
+ for path in args.files:
526
+ if not path.exists():
527
+ print(f"FAIL {path}: file not found")
528
+ failed += 1
529
+ total += 1
530
+ continue
531
+
532
+ blocks = extract_blocks(path)
533
+ if not blocks:
534
+ print(f"WARN {path}: no mermaid blocks found")
535
+ continue
536
+
537
+ print(f"{path} ({len(blocks)} diagram{'s' if len(blocks) != 1 else ''})")
538
+ for b in blocks:
539
+ total += 1
540
+ if mode == "render" and mmdc:
541
+ dest = ((args.render_to / f"{path.stem}-{b.index}.svg")
542
+ if args.render_to else Path(tmpdir) / f"{path.stem}-{b.index}.svg")
543
+ ok, err = render_block(mmdc, b, pptr_cfg_file, dest)
544
+ b.errors = [err] if err else []
545
+ # A clean render still benefits from the semantic checks
546
+ # (undefined classDef renders fine but silently loses colour).
547
+ if ok:
548
+ b.errors = [p for p in lint_block(b) if "style class" in p or "linkStyle" in p]
549
+ ok = not b.errors
550
+ else:
551
+ b.errors = lint_block(b)
552
+ ok = not b.errors
553
+
554
+ if ok:
555
+ if not args.quiet:
556
+ print(f" ok {b.label}")
557
+ else:
558
+ failed += 1
559
+ print(f" FAIL {b.label}")
560
+ for e in b.errors:
561
+ for ln in e.splitlines():
562
+ print(f" {ln}")
563
+ print()
564
+
565
+ if not (args.render_to and mode == "render"):
566
+ shutil.rmtree(tmpdir, ignore_errors=True)
567
+
568
+ if failed:
569
+ print(f"{failed} of {total} diagram(s) failed validation.")
570
+ return 1
571
+ print(f"All {total} diagram(s) valid.")
572
+ return 0
573
+
574
+
575
+ if __name__ == "__main__":
576
+ sys.exit(main())