#!/usr/bin/env python3 # -*- coding: utf-8 -*- """mermaid 代码块语法预检(提交门禁:mermaid-syntax) 为什么要有(2026-10-30 接线): docs/子系统设计/屏板链路设计说明.md §1 的 flowchart 里一行带文字点线写成了 `S -.|"..."| T`(合法形态是 `-.->|"..."|` 或 `-. 文字 .->`,`-.|` 不是箭头 token),mermaid 渲染 `Syntax error in text`。该图自 2026-10-29 落库起就是坏的, doc-links 等文档门禁全绿也拦不住——因为它们查链接与章节,**不查围栏内图表语法**。 本闸只查围栏内的确定性形态,不重造 mermaid 解析器。 查什么(v1 三条,都是「错了必渲染失败」的确定性形态): R1 图类型头:```mermaid 后首个非空行必须以已知图类型关键字开头 (flowchart/graph/sequenceDiagram/stateDiagram.../classDiagram/erDiagram/ gantt/journey/pie/mindmap/timeline/quadrantChart/gitGraph/C4Context...)。 没有头的块 100% 渲染失败。 R2 `-.` 后直接跟管道(本坑形态):`-.` + 空白 + `|` 不是任何合法箭头; 带文字点线的合法形态只有 `-.->|t|`、`-.-|t|`? 没有、以及 `-. t .->`。 (`-.->` 在 `-.` 后是 `>`,`-. t .->` 在 `-.` 后是空白+文字再 `.->`, 故本判据不误伤这两种——见 --selftest T2。) R3 容器配对:flowchart/graph 的 `subgraph` 行数 == 独占一行的 `end` 行数; sequenceDiagram 的行首块起点(alt/opt/loop/par/critical/rect/break)行数 == 行首 `end` 行数(else/and/option 是分支不消费 end)。缺一边必渲染失败。 增量拦截(照 bom-guard / bsp-err / nested-comment 先例,只减不增): 存量钉在 BASELINE(按 文件 + 规则 + 上下文指纹 计数,不按行号); 基线之外的新命中 → 退出码 1。还清某条就删那一行,--prune 打台账。 用法: python scripts/check_mermaid.py [files...] # 钩子模式(喂暂存的 md) python scripts/check_mermaid.py --all # 全仓自查(口径 = 钩子实际会喂的文件集) python scripts/check_mermaid.py --prune # 打基线台账 + 点名已还清可删的行 python scripts/check_mermaid.py --selftest # 自证五条 排除面(与 doc-links 同口径,见 .pre-commit-config.yaml 的 exclude): 芯片手册转录(外部资料,围栏形态不受控)与第三方目录整体不进语料。 """ import argparse import pathlib import re import sys REPO = pathlib.Path(__file__).resolve().parent.parent # 与钩子 exclude 一致:这些前缀下的 md 不进语料 EXCLUDE_PREFIXES = ( ".opencode/node_modules/", "Lib/lvgl/", "MDK-ARM/RTE/", "docs/芯片手册/", ) DIAGRAM_HEADS = ( "flowchart", "graph", "sequenceDiagram", "stateDiagram", "stateDiagram-v2", "classDiagram", "erDiagram", "gantt", "journey", "pie", "mindmap", "timeline", "quadrantChart", "xychart-beta", "gitGraph", "requirementDiagram", "C4Context", "C4Container", "C4Component", "packet", "block-beta", "kanban", "architecture-beta", "radar", "treemap", "sankey-beta", "wardley", ) RE_FENCE_OPEN = re.compile(r"^\s*```\s*mermaid\s*$") RE_FENCE_CLOSE = re.compile(r"^\s*```\s*$") # R2:`-.` 后(允许空白)直接出现管道 = 非法箭头 + 管道文字(本坑形态 `-.|"t"|`) RE_BAD_DOT_PIPE = re.compile(r"-\.(\s*\|)") # R3 用:行首关键字 RE_SUBGRAPH = re.compile(r"^\s*subgraph\b") RE_END_LINE = re.compile(r"^\s*end\b\s*(\[\s*\])?\s*$") RE_SEQ_BLOCK_START = re.compile(r"^\s*(alt|opt|loop|par|critical|rect|break)\b") RE_SEQ_END = re.compile(r"^\s*end\b") RE_COMMENT = re.compile(r"^\s*(%%|rem\b|#)") def iter_blocks(lines): """产出 (fence_line_no_1based, [block lines])。围栏未闭合也报(当空块+坏)。""" i = 0 n = len(lines) while i < n: m = RE_FENCE_OPEN.match(lines[i]) if not m: i += 1 continue start = i j = i + 1 while j < n and not RE_FENCE_CLOSE.match(lines[j]): j += 1 yield start + 1, lines[start + 1:j], j < n i = j + 1 def diagram_head(block): for ln in block: if not ln.strip() or RE_COMMENT.match(ln): continue return ln.strip() return "" def hits_for_block(lines, offset): """返回 [(abs_line_1based, rule, 上下文指纹)]""" hits = [] head = diagram_head(lines) if not head: hits.append((offset, "R1-no-head", "nohead")) elif not head.startswith(DIAGRAM_HEADS): first = re.split(r"[\s\[({]", head, 1)[0] hits.append((offset, "R1-bad-head", "head:" + first)) is_flow = head.startswith(("flowchart", "graph")) is_seq = head.startswith("sequenceDiagram") sg = en = sb = se = 0 for k, raw in enumerate(lines): ln = raw if RE_COMMENT.match(ln): continue if RE_BAD_DOT_PIPE.search(ln): ctx = "dotpipe:" + re.sub(r"\s+", " ", ln.strip())[:80] hits.append((offset + k + 1, "R2-dot-pipe", ctx)) if RE_SUBGRAPH.match(ln): sg += 1 elif RE_END_LINE.match(ln): en += 1 if is_seq: if RE_SEQ_BLOCK_START.match(ln) and not RE_SEQ_END.match(ln): sb += 1 elif RE_SEQ_END.match(ln): se += 1 if is_flow: lo = min(sg, en) for _ in range(abs(sg - en)): hits.append((offset, "R3-subgraph-end", "pair:sg=%d,end=%d" % (sg, en))) if is_seq: for _ in range(abs(sb - se)): hits.append((offset, "R3-seq-end", "pair:blk=%d,end=%d" % (sb, se))) return hits def scan_text(text): hits = [] lines = text.splitlines() for start_no, block, closed in iter_blocks(lines): if not closed: hits.append((start_no, "R0-unclosed-fence", "unclosed")) hits.extend(hits_for_block(block, start_no)) return hits def check_file(path): """path: pathlib.Path(相对仓库根)。返回 (blocks数, [(abs_line, rule, ctx)]) 或 None(跳过)。""" try: text = path.read_text(encoding="utf-8") except (UnicodeDecodeError, OSError) as e: print(" [skip] %s(%s)" % (path, e)) return None return count_and_hits(text) def count_and_hits(text): hits = [] lines = text.splitlines() nblk = 0 for start_no, block, closed in iter_blocks(lines): nblk += 1 if not closed: hits.append((start_no, "R0-unclosed-fence", "unclosed")) hits.extend(hits_for_block(block, start_no)) return nblk, hits # ---------------------------------------------------------------- 基线与报告 # 存量基线(接线当天 2026-10-30 --all 实扫钉入,只减不增;还清删行,--prune 点名): BASELINE = ( ) def fingerprint(rel, rule, ctx): # 上下文指纹不逐字节钉死(改图会漂移):R3 的 pair 数字归一化 if rule.startswith("R3"): return rel + "|" + rule + "|pair" return rel + "|" + rule + "|" + ctx def collect_baseline(): d = {} for rel, rule, ctx in BASELINE: key = fingerprint(rel, rule, ctx) d[key] = d.get(key, 0) + 1 return d def all_md_files(): out = [] for p in REPO.rglob("*.md"): rel = p.relative_to(REPO).as_posix() if any(rel.startswith(pre) for pre in EXCLUDE_PREFIXES): continue if "/node_modules/" in ("/" + rel) or "/.git/" in ("/" + rel): continue out.append(p) return sorted(out) def report(files, show_baseline=False, prune=False): base = collect_baseline() consumed = dict(base) new_hits = [] files_with = 0 blocks = 0 for p in files: rel = p.relative_to(REPO).as_posix() r = check_file(p) if r is None or r[0] == 0: continue nblk, hits = r files_with += 1 blocks += nblk for (ln, rule, ctx) in hits: key = fingerprint(rel, rule, ctx) if consumed.get(key, 0) > 0: consumed[key] -= 1 else: new_hits.append((rel, ln, rule, ctx)) print("mermaid 预检:%d 个文件共 %d 块围栏,新发 %d 处,基线在册 %d 处" % (files_with, blocks, len(new_hits), sum(base.values()))) if new_hits: print("\n--- 新发(不被基线吞)---") for (rel, ln, rule, ctx) in new_hits: print(" %s:%d [%s] %s" % (rel, ln, rule, ctx)) print("\n判读:R1=缺图类型头;R2=`-.` 后直接跟 `|`(改 `-.->|` 或 `-. t .->`);" "R3=subgraph/块起点与独占行 end 不配对;R0=围栏未闭合。") if prune or (show_baseline and base): print("\n--- 存量基线台账(只减不增)---") for (rel, rule, ctx) in BASELINE: left = consumed.get(fingerprint(rel, rule, ctx), 0) mark = "已还清,可删" if left == 0 else "仍占 %d" % left print(" %-70s [%s] %s" % (rel, rule, mark)) if prune: restorable = [b for b in BASELINE if consumed.get(fingerprint(*b), 0) == 0] if restorable: print("\n[TIGHTEN] 基线 %d 条本轮实测已还清,可下架:%s" % (len(restorable), restorable)) return 1 if new_hits else 0 # ---------------------------------------------------------------- selftest def selftest(): ok = True def scan(text): return scan_text(text) # T1 正例:合法 flowchart(含 `-.->|` 与 `-. t .->` 两种合法点线)+ sequence 配对 good = ( "```mermaid\nflowchart TB\n" ' A["甲"] -->|"有字"| B["乙"]\n' " A -.->|点线有字| B\n" " A -. 点线文字 .-> B\n" " subgraph SG[\"组\"]\n B --> C[\"丙\"]\n end\n" "```\n") h = [x for x in scan(good) if x[1] != "R0-unclosed-fence" or True] print(" T1 正例(含三种合法点线 + 配对 subgraph)→ 命中 %d(要求 0)" % len(h)) ok &= (len(h) == 0) # T2 本坑形态必响,合法形态不误伤 bad = "```mermaid\nflowchart TB\n S -.|\"文字\"| T\n```\n" h = [x for x in scan(bad) if x[1] == "R2-dot-pipe"] print(" T2 本坑 `-.|\"..\"|` → R2 命中 %d(要求 1)" % len(h)) ok &= (len(h) == 1) # T3 subgraph 缺 end 必响 bad3 = "```mermaid\nflowchart LR\n subgraph X[\"x\"]\n A-->B\n```\n" h = [x for x in scan(bad3) if x[1].startswith("R3-subgraph")] print(" T3 subgraph 无 end → R3 命中 %d(要求 1)" % len(h)) ok &= (len(h) == 1) # T4 缺图类型头必响;sequence 配对不误伤 bad1 = "```mermaid\nflowChat TB\n A-->B\n```\n" h = [x for x in scan(bad1) if x[1] == "R1-bad-head"] gseq = ("```mermaid\nsequenceDiagram\n participant A\n alt 条件\n A->>A: x\n" " else 其他\n A->>A: y\n end\n```\n") h2 = scan(gseq) print(" T4 假头必响 %d(要求 1)/ sequence alt-else-end 不误伤 %d(要求 0)" % (len(h), len(h2))) ok &= (len(h) == 1 and len(h2) == 0) # T5 基线吞得下 + 只减不增有牙 rel = "fake/file.md" hit = scan(bad)[0] key_new = fingerprint(rel, hit[1], hit[2]) b1 = {key_new: 1} within = 1 if collect_hit_in(b1, rel, hit) else 0 b2 = {} outside = 0 if collect_hit_in(b2, rel, hit) else 1 print(" T5 基线在册吞 %d(要求 1)/ 基线清空后同命中算新发 %d(要求 1)" % (within, outside)) ok &= (within == 1 and outside == 1) print("SELFTEST:", "PASS" if ok else "FAIL") return 0 if ok else 1 def collect_hit_in(base, rel, hit): key = fingerprint(rel, hit[1], hit[2]) return base.get(key, 0) > 0 # ---------------------------------------------------------------- main def main(): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") ap = argparse.ArgumentParser(description="mermaid 围栏语法预检(只减不增基线)") ap.add_argument("files", nargs="*", help="md 文件(钩子喂暂存文件)") ap.add_argument("--all", action="store_true", help="全仓自查(口径=钩子文件集)") ap.add_argument("--prune", action="store_true", help="打基线台账并点名已还清行") ap.add_argument("--selftest", action="store_true", help="自证五条") args = ap.parse_args() if args.selftest: sys.exit(selftest()) if args.all: files = all_md_files() else: files = [pathlib.Path(f).resolve() for f in args.files] files = [p for p in files if not any(p.relative_to(REPO).as_posix().startswith(pre) for pre in EXCLUDE_PREFIXES)] if not files: print("mermaid 预检:0 个文件(跳过 = 通过)") sys.exit(0) sys.exit(report(files, show_baseline=bool(args.all), prune=args.prune)) if __name__ == "__main__": main()