Files
STM32F4-Base/scripts/check_mermaid.py
T
wandering1 f8e62a9ba2 feat(门禁): 新增 mermaid-syntax 钩子,围栏内确定性语法提交前必拦
- scripts/check_mermaid.py(纯标准库):R0 围栏未闭合 / R1 图类型头缺
  错 / R2 -. 后直接跟管道(确定性语法错误形态)/ R3 subgraph 与
  sequence 块的 end 计数配对;--all / --selftest(5 形态)/ --prune;
  排除面与 doc-links 同口径;零语料空基线
- 接线 .pre-commit-config.yaml(doc-links 后),docs/门禁说明/
  mermaid-syntax.md 专篇(八段结构含限制与盲区),README 索引与
  AGENTS.md 计数 51→52、提交期实跑 48→49
- 门禁说明 README 顺带修正两处历史矛盾:gitlint 实际就在本配置
  (commit-msg 阶段),旧文「52−3−gitlint」与「gitlint 不在配置里」
  均过时——锚定口径 52 = 47 pre-commit + 3 manual + 1 commit-msg,
  加钩后 48 + 1 = 49;三处 grep 争执例句改抽象表述(例句里的数字
  自己会过时)
- docs/子系统设计/屏板链路设计说明.md:38 修复触发本钩子的坏图:
  -. 后直接跟管道不是合法边 operator,改为 -.->|label| 形态

实测:YAML 解析 52 / commit 阶段枚举 48 行全 Passed(含新钩子)/
check_mermaid --all 29 文件 60 块围栏 0 命中 rc=0 /
check_doc_links --all rc=0(215 文档)/ 六文件无 BOM/CR/U+FFFD。
2026-10-30 11:03:58 +08:00

335 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()