Files
STM32F4-Base/scripts/check_mermaid.py
T
wandering1 1634088bd7 docs(screenlink): 屏板链路档去「说明」并瘦身,控制台档收编台架日志口
屏板链路设计说明.md -> 屏板链路设计.md(去档名「说明」,与 ADC 节拍 /
运行与自检记录两篇口径一致);控制台单写者日志设计说明.md 同改。
两档并存期间旧名引用 0 残留。

- 屏板链路 §3.2 台架日志口(RS232#1 / CN1 端子 / COM3)移入控制台档
  §3.4:日志口的接线属 UART5 控制台通道,不属屏板链路;屏板档只留
  「别去 CN1 找 USART1 端子」这句接线防错提示。
- 屏板链路 §7.1 由「根因账本 + 修复前后对比」改写为现状描述:延迟
  应答机制表 + 耗时分解表 + 两个计数口径 + 已知边界,40 行压到 24 行。
  顺带修正 §8 的错引:原指 `网络自愈架构设计.md` §8(实为「延后项」,
  不含 uiTask 内容)改为 §4.1 `net_reconcile_tick`。
- §9.1 增补长稳行(串口日志 2026-11-13 18:31 -> 2026-11-15 21:43,
  单次上电 51.2 h:rx=658262 ok=658262、crc/len/fmt/err 全 0、
  drop=7 仅启动期、kerr/abor/bad=0、全程 off=0);§9.2 销项三条
  (仪器项、2 h 长稳、len=7 观察项),新增「按键通路」一条——51.2 h
  日志 keys=0 kq=0 dfok=0 dfold=0,延迟应答在真机上从未执行过。
- 删除 sb_frame_b_build_logo() 及 SB_LOGO_MAGIC_* / SB_SCREEN_W/H
  与对应单测(TC-SBPROTO-022/023 全组、024c/024d),组帧入口只剩
  sb_frame_b_build_image()。sb_proto 单测 59 -> 45 条,45/45 通过。
- cfg_file.c 出厂模板三条中文字面量改 \uXXXX 转义:消除全量重编时的
  4 条 #870-D invalid multibyte character sequence,运行期字节不变,
  源码回到纯 ASCII(符合「字符串字面量必须纯 ASCII」)。

验证:keil-build 0 Error 0 Warning;sb_proto 45/45、sb_link 164/164;
pre-commit 48 钩子全 Passed;check_doc_links --sections-strict 通过。
2026-11-15 21:51:24 +08:00

340 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 不进语料
# 另含两类**整份仓库副本**的目录(与 check_doc_links.py 的 SKIP_PARTS 同口径):
# DSH 会话/任务插件把工作树建在仓库内,rglob 会把同一篇文档按别人的任务状态
# 各判一遍——既是假红也是白跑,而它们本来就被 gitignore 挡在钩子语料之外。
EXCLUDE_PREFIXES = (
".opencode/node_modules/",
"Lib/lvgl/",
"MDK-ARM/RTE/",
"docs/芯片手册/",
".agents/",
".dsh-worktrees/",
)
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()