Skip to content

Mermaid_lint

FieldValue
TypeSkill Resource
Source~/.copilot/skills/technical-writing/scripts/mermaid_lint.py
DescriptionNot specified

Source Content

#!/usr/bin/env python3
"""
Mermaid structural linter.
Usage: python3 lint.py <file.md|file.mermaid>
Exits 0 if clean, 1 if errors found.
"""
import re
import sys
import subprocess
from pathlib import Path
# ── constants ──────────────────────────────────────────────────────────────────
VALID_TYPES = {
"flowchart", "graph", "sequenceDiagram", "classDiagram",
"stateDiagram", "stateDiagram-v2", "erDiagram", "gantt",
"pie", "gitGraph", "mindmap", "timeline", "journey",
"quadrantChart", "requirementDiagram", "sankey-beta",
"c4Context", "c4Container", "c4Component", "c4Dynamic",
"C4Context", "C4Container", "C4Component", "C4Dynamic", "C4Deployment",
"xychart-beta", "block-beta", "packet-beta", "architecture-beta",
}
BLOCK_OPENERS = frozenset(["subgraph", "loop", "alt", "opt", "par", "critical", "break"])
RESERVED_IDS = frozenset([
"end", "graph", "flowchart", "style", "class", "subgraph",
"direction", "click", "call", "linkStyle", "classDef",
])
DEPRECATED = {
"graph": "flowchart",
"stateDiagram": "stateDiagram-v2",
}
# Diagram types whose labels pass through Mermaid v11's unified text renderer,
# where an inline `fas:`/`fab:` token becomes a real icon. Verified at 11.16.0.
ICON_SUPPORTED_TYPES = frozenset([
"flowchart", "graph", "stateDiagram", "stateDiagram-v2",
"classDiagram", "mindmap", "block-beta", "erDiagram",
])
# Types where a `fas:` token collides with the grammar and breaks the parse.
ICON_PARSER_BREAK_TYPES = frozenset(["quadrantChart", "requirementDiagram"])
# An inline FontAwesome token: fa:/fas:/fab:/far:/fal:/fak: followed by fa-name.
ICON_TOKEN_RE = re.compile(r"(?<![\w.])fa[bklrs]?:fa-[\w-]+")
# ── extraction ─────────────────────────────────────────────────────────────────
def extract_mermaid_blocks(text: str) -> list[tuple[str, int]]:
"""Return list of (diagram_text, start_line_number) for every ```mermaid block."""
results = []
lines = text.split("\n")
i = 0
while i < len(lines):
if lines[i].strip() == "```mermaid":
start = i + 1
j = start
while j < len(lines) and lines[j].strip() != "```":
j += 1
block = "\n".join(lines[start:j])
results.append((block, start + 1)) # 1-based line number
i = j + 1
else:
i += 1
return results
# ── validators ─────────────────────────────────────────────────────────────────
def check_diagram(diagram: str, offset: int = 1) -> list[str]:
"""Run all checks on one diagram. offset = 1-based line number of first diagram line."""
issues = []
raw_lines = diagram.split("\n")
lines = [(offset + i, ln) for i, ln in enumerate(raw_lines)]
if not any(ln.strip() for _, ln in lines):
issues.append("ERROR: Diagram is empty.")
return issues
# ── diagram type ───────────────────────────────────────────────────────────
first_no, first_ln = next(((n, l) for n, l in lines if l.strip()), (offset, ""))
keyword = first_ln.strip().split()[0] if first_ln.strip() else ""
if keyword not in VALID_TYPES:
issues.append(
f"ERROR (line {first_no}): Unknown diagram type '{keyword}'.\n"
f" Valid types: {', '.join(sorted(VALID_TYPES))}"
)
return issues # can't continue without knowing the type
if keyword in DEPRECATED:
replacement = DEPRECATED[keyword]
issues.append(
f"WARNING (line {first_no}): '{keyword}' is deprecated. Use '{replacement}' instead."
)
diagram_type = "stateDiagram-v2" if keyword == "stateDiagram" else keyword
if keyword == "graph":
diagram_type = "flowchart"
# ── skip config blocks `%%{...}%%` for line-level checks ──────────────────
is_flowchart = diagram_type in ("flowchart", "graph")
is_sequence = diagram_type == "sequenceDiagram"
is_class = diagram_type == "classDiagram"
is_state = diagram_type in ("stateDiagram", "stateDiagram-v2")
is_gantt = diagram_type == "gantt"
is_pie = diagram_type == "pie"
is_er = diagram_type == "erDiagram"
# ── block opener / end balance ─────────────────────────────────────────────
opener_count = 0
closer_count = 0
opener_positions = []
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%"):
continue
word = stripped.split()[0] if stripped.split() else ""
if word in BLOCK_OPENERS:
opener_count += 1
opener_positions.append((lineno, word))
if stripped == "end":
closer_count += 1
if opener_count != closer_count:
openers_str = ", ".join(f"'{w}' (line {n})" for n, w in opener_positions)
issues.append(
f"ERROR: Block mismatch — {opener_count} opener(s) [{openers_str}] "
f"but {closer_count} 'end'(s).\n"
f" Every subgraph / loop / alt / opt / par / critical / break needs a matching 'end'."
)
# ── unclosed double quotes ─────────────────────────────────────────────────
for lineno, ln in lines:
# Count non-escaped quotes
count = len(re.findall(r'(?<!\\)"', ln))
if count % 2 != 0:
issues.append(
f"ERROR (line {lineno}): Unclosed double quote.\n"
f" Line: {ln.strip()}"
)
# ── flowchart-specific checks ──────────────────────────────────────────────
if is_flowchart:
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%"):
continue
# Unquoted colon inside a label bracket [ ]
for m in re.finditer(r'\[([^"\]\[]*)\]', stripped):
label = m.group(1)
if ":" in label and not label.startswith('"'):
issues.append(
f"ERROR (line {lineno}): Unquoted colon in label [{label}].\n"
f" Fix: [\"{label}\"]"
)
# Reserved word used as node ID (appears before [ ( {{ >)
for word in RESERVED_IDS:
if re.search(rf'(?<![A-Za-z0-9_]){re.escape(word)}\s*[\[\({{>]', stripped):
issues.append(
f"ERROR (line {lineno}): Reserved word '{word}' used as node ID.\n"
f" Rename it — e.g. '{word}Node' or '{word.capitalize()}'."
)
# Node ID contains spaces (not quoted).
# Skip `subgraph <id>[Label]` openers — the id is the single token
# after the keyword, not a two-word node id.
if not stripped.startswith("subgraph"):
for m in re.finditer(r'(?<!\w)([A-Za-z][A-Za-z0-9_]* [A-Za-z][A-Za-z0-9_]*)\s*[\[\({{>]', stripped):
issues.append(
f"WARNING (line {lineno}): Node ID '{m.group(1)}' contains a space.\n"
f" Use underscores or camelCase for node IDs; spaces break parsing."
)
# Bare -> (single dash) instead of -->
if re.search(r'(?<!-)->(?!>)', stripped):
issues.append(
f"WARNING (line {lineno}): Single-dash arrow '->' is not valid in flowchart.\n"
f" Use '-->' (dashed) or '---' (no arrowhead)."
)
# ── sequence diagram checks ────────────────────────────────────────────────
if is_sequence:
participants_declared = set()
participants_used = set()
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%"):
continue
# Collect declared participants
m = re.match(r'^(?:participant|actor)\s+(\S+)', stripped)
if m:
participants_declared.add(m.group(1))
continue
# Wrong arrow type: flowchart --> used inside sequenceDiagram
if re.search(r'\w\s*-->\s*\w', stripped) and '->>' not in stripped and '-->>' not in stripped:
issues.append(
f"WARNING (line {lineno}): '-->' is flowchart syntax inside a sequenceDiagram.\n"
f" Use '->>' (solid open arrow) or '-->>' (dashed open arrow)."
)
# Collect participants used in messages
msg = re.match(r'^([A-Za-z0-9_]+)\s*-?->>?\+?\s*([A-Za-z0-9_]+)\s*:', stripped)
if msg:
participants_used.add(msg.group(1))
participants_used.add(msg.group(2))
# Warn on implicit participants (not necessarily wrong but risky)
undeclared = participants_used - participants_declared
if undeclared and len(participants_declared) > 0:
# Only warn if some are declared (implicit all-or-nothing)
issues.append(
f"WARNING: Participants used but not declared with 'participant': "
f"{', '.join(sorted(undeclared))}.\n"
f" Add 'participant {list(undeclared)[0]}' etc. at the top of the diagram."
)
# ── class diagram checks ───────────────────────────────────────────────────
if is_class:
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%"):
continue
# Common mistake: Dog |-- Animal (reversed inheritance)
if re.search(r'\w+\s*\|--\s*\w+', stripped) and '<' not in stripped:
issues.append(
f"WARNING (line {lineno}): Inheritance arrow may be reversed.\n"
f" Convention is 'Parent <|-- Child', not 'Child |-- Parent'.\n"
f" Line: {stripped}"
)
# ── Gantt checks ───────────────────────────────────────────────────────────
if is_gantt:
has_date_format = any(
ln.strip().startswith("dateFormat") for _, ln in lines[1:]
)
has_tasks = any(
re.match(r'^\s+\S.*:.*,', ln) for _, ln in lines[1:]
)
if has_tasks and not has_date_format:
issues.append(
"ERROR: Gantt diagram has tasks but no 'dateFormat' declaration.\n"
" Add 'dateFormat YYYY-MM-DD' (or your format) before the first section."
)
# ── pie chart checks ────────────────────────────────────────────────────────
if is_pie:
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%") or stripped.startswith("title"):
continue
if ":" in stripped:
label = stripped.split(":")[0].strip()
if label and not label.startswith('"') and not label.startswith("%%"):
issues.append(
f"ERROR (line {lineno}): Pie slice label must be quoted.\n"
f" Fix: \"{label}\" : {stripped.split(':', 1)[1].strip()}"
)
# ── ER diagram checks ───────────────────────────────────────────────────────
if is_er:
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%") or "{" in stripped or "}" == stripped:
continue
# Relationship lines: ENTITY1 CARDINALITY--CARDINALITY ENTITY2 : label
rel = re.search(r'\w+\s+([^\s]+--[^\s]+)\s+\w+', stripped)
if rel:
cardinality_str = rel.group(1)
# Check for common wrong syntax
if "--" not in cardinality_str and "." not in cardinality_str:
issues.append(
f"WARNING (line {lineno}): ER relationship may have invalid cardinality syntax.\n"
f" Expected pattern: ENTITY1 ||--o{{ ENTITY2 : \"label\"\n"
f" Got: {stripped}"
)
# ── inline-icon placement checks ────────────────────────────────────────────
# Inline `fas:`/`fab:` tokens only become icons in the unified-renderer types.
# Elsewhere they leak as literal text, get dropped, or break the parse.
icon_unsupported = diagram_type not in ICON_SUPPORTED_TYPES
for lineno, ln in lines[1:]:
stripped = ln.strip()
if not stripped or stripped.startswith("%%"):
continue
if icon_unsupported and ICON_TOKEN_RE.search(stripped):
if diagram_type in ICON_PARSER_BREAK_TYPES:
issues.append(
f"ERROR (line {lineno}): Inline FontAwesome token in a "
f"'{diagram_type}' — the ':' breaks the parser. Remove it; "
f"keep the label plain and monotone."
)
else:
issues.append(
f"WARNING (line {lineno}): Inline FontAwesome token in a "
f"'{diagram_type}' does not render — it leaks as literal text "
f"or is dropped. Remove it (keep the label plain), or model "
f"the diagram as a flowchart if it truly needs icons."
)
# Mindmap supports inline `fas:fa-*` but NOT `::icon()` from the
# stylesheet alone — that form needs a registered icon pack.
if diagram_type == "mindmap" and "::icon(" in stripped:
issues.append(
f"WARNING (line {lineno}): mindmap '::icon(...)' renders nothing "
f"without a registered icon pack. Use the inline form instead — "
f"put `fas:fa-name ` at the start of the node label."
)
return issues
# ── mmdc integration (optional) ────────────────────────────────────────────────
def try_mmdc(diagram: str) -> list[str]:
"""If mmdc is installed, write diagram to a temp file and validate via mmdc."""
try:
result = subprocess.run(
["mmdc", "--version"], capture_output=True, timeout=5
)
if result.returncode != 0:
return []
except (FileNotFoundError, subprocess.TimeoutExpired):
return []
import tempfile, os
with tempfile.NamedTemporaryFile(
mode="w", suffix=".mmd", delete=False
) as tmp:
tmp.write(diagram)
tmp_path = tmp.name
issues = []
try:
out_path = tmp_path.replace(".mmd", ".svg")
result = subprocess.run(
["mmdc", "-i", tmp_path, "-o", out_path],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
stderr = (result.stderr or result.stdout or "").strip()
if stderr:
issues.append(
f"ERROR (mmdc): Mermaid CLI rejected the diagram:\n {stderr}"
)
except subprocess.TimeoutExpired:
issues.append("WARNING (mmdc): timed out — skipping CLI validation.")
finally:
os.unlink(tmp_path)
svg = tmp_path.replace(".mmd", ".svg")
if Path(svg).exists():
os.unlink(svg)
return issues
# ── main ────────────────────────────────────────────────────────────────────────
def lint_file(path: Path) -> tuple[int, int]:
"""Lint one file and print its report. Returns (errors, warnings)."""
content = path.read_text(encoding="utf-8")
if path.suffix in (".md", ".mdx"):
blocks = extract_mermaid_blocks(content)
if not blocks:
print("No ```mermaid blocks found in file.")
return 0, 0
else:
blocks = [(content, 1)]
total_errors = 0
total_warnings = 0
for idx, (block, start_line) in enumerate(blocks, 1):
header = f"Diagram {idx} (starts line {start_line})" if len(blocks) > 1 else "Diagram"
issues = check_diagram(block, offset=start_line)
mmdc_issues = try_mmdc(block)
all_issues = issues + mmdc_issues
if not all_issues:
print(f"✓ {header}: No issues found.")
else:
print(f"\n── {header} ──")
for issue in all_issues:
print(issue)
if issue.startswith("ERROR"):
total_errors += 1
elif issue.startswith("WARNING"):
total_warnings += 1
return total_errors, total_warnings
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python3 lint.py <file.md|file.mdx|file.mmd> [more files ...]", file=sys.stderr)
sys.exit(1)
total_errors = 0
total_warnings = 0
for arg in sys.argv[1:]:
path = Path(arg)
if not path.exists():
print(f"ERROR: File not found: {path}", file=sys.stderr)
sys.exit(1)
if len(sys.argv) > 2:
print(f"\n{path}")
errors, warnings = lint_file(path)
total_errors += errors
total_warnings += warnings
print()
if total_errors == 0 and total_warnings == 0:
print("All diagrams passed.")
sys.exit(0)
else:
print(f"Summary: {total_errors} error(s), {total_warnings} warning(s)")
sys.exit(1 if total_errors > 0 else 0)
if __name__ == "__main__":
main()