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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +42 -0
- package/preview.png +0 -0
- package/skills/visualize-code-changes/SKILL.md +331 -0
- package/skills/visualize-code-changes/assets/template.md +71 -0
- package/skills/visualize-code-changes/references/diagram-types.md +340 -0
- package/skills/visualize-code-changes/references/syntax-pitfalls.md +191 -0
- package/skills/visualize-code-changes/scripts/validate_mermaid.py +576 -0
|
@@ -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())
|