From f8e62a9ba2c98b9ad30a0700356e812527d21185 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 30 Oct 2026 11:03:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=97=A8=E7=A6=81):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20mermaid-syntax=20=E9=92=A9=E5=AD=90=EF=BC=8C=E5=9B=B4?= =?UTF-8?q?=E6=A0=8F=E5=86=85=E7=A1=AE=E5=AE=9A=E6=80=A7=E8=AF=AD=E6=B3=95?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=89=8D=E5=BF=85=E6=8B=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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。 --- .pre-commit-config.yaml | 23 ++ AGENTS.md | 4 +- docs/子系统设计/屏板链路设计说明.md | 2 +- docs/门禁说明/README.md | 34 +-- docs/门禁说明/mermaid-syntax.md | 96 ++++++++ scripts/check_mermaid.py | 334 ++++++++++++++++++++++++++++ 6 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 docs/门禁说明/mermaid-syntax.md create mode 100644 scripts/check_mermaid.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f9081c8c..1aa27463 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -187,6 +187,29 @@ repos: MDK-ARM/RTE/| docs/芯片手册/ ) + # mermaid 图:围栏内确定性语法预检(第 52 条,2026-10-30 加,口径 51 → 52,提交期实跑 48 → 49)。 + # 为什么要有:屏板链路设计说明.md §1 的 flowchart 里 `-.|"文字"|`(`-.` 直接跟管道,不是任何合法 + # 箭头 token)让 mermaid 渲染 `Syntax error in text`——该图 2026-10-29 落库、期间 doc-links 等 + # 全绿:文档门禁查链接与章节,**从不查围栏内图表语法**,这是它们的共同盲区。 + # v1 只查「错了必渲染失败」三条(不重造解析器):R1 缺/错图类型头;R2 `-.` 后直接跟 `|` + # (带文字点线合法形态只有 `-.->|t|` 与 `-. 文字 .->`,selftest 证明两种合法形态不误伤); + # R3 flowchart 的 subgraph 与独占行 `end` 计数配对、sequence 的块起点与 `end` 计数配对。 + # 存量基线:接线当日 --all 实测 29 个自有文件 60 块围栏、0 命中 ⇒ **空基线零语料**(还清类 + # 先例:id-check-new);docs/芯片手册/ 是导入转录、围栏不受控,与 doc-links 同排除面不进语料。 + # 自证:--selftest 五条(正例含三种合法点线 / 本坑形态必响 / 缺 end 必响 / 假头必响且 + # sequence alt-else-end 不误伤 / 基线吞得下且清空后同命中算新发)。 + - id: mermaid-syntax + name: "mermaid 围栏语法预检(图类型头 / 非法点线管道 / 块配对)" + entry: python scripts/check_mermaid.py + language: system + files: \.md$ + exclude: | + (?x)^( + \.opencode/node_modules/| + Lib/lvgl/| + MDK-ARM/RTE/| + docs/芯片手册/ + ) # 数值:README/AGENTS 任务表须与 Src/freertos.c 一致 - id: task-table name: FreeRTOS 任务表一致性 diff --git a/AGENTS.md b/AGENTS.md index 6a08a706..893dbdf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **51 个钩子**(**口径 = YAML 解析后的 hook 条目数**:自查命令与「声明 / 文档 / 实跑」三数同源核对见 `docs/门禁说明/README.md` §2 首条——⚠ 直接 grep 配置里的声明行会多算一条**注释掉的**候选钩子(比口径多 1),不是口径;**配置注释里也别复述那个 grep 字面量**,它会让文本计数再虚高 1(2026-10-27 实测踩过);加一条钩子就同批改 AGENTS 与门禁索引两处计数。2026-10-25 道E 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三条,2026-10-26 加 `generated-fresh` 一条,2026-10-27 加 `nested-comment` 一条;2026-10-28 加 `id-check-new` 一条并同批退役 `id-check`(口径 52 → 51:编号门禁换牙——新的只查新增行、零语料,旧的八项全库校验连语料一起移入 `scripts/archive/id_gate/`,手工普查与恢复机器面见 `docs/编号规范.md` §8.5);2026-10-29 加 `skill-size` 一条(51 → 52),同日按用户裁定**退役 `id-check-new`**(52 → **51**:只查新增行的编号门禁整体退场,脚本与白名单一起移入 `scripts/archive/id_gate/`,编号形态此后无机器面,留档 `docs/门禁说明/id-check-new.md`);**每种门禁一篇详解(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径)→ `docs/门禁说明/README.md` 索引**;通用机制与整体口径 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + `bom-guard` / `setview-check` / `uinav-selftest`(2026-10-25 道E 那三条:前半句的加钩流水里有它们、逐项枚举里漏了,补上后本行各项相加才等于 51)+ 主机侧单测 **22 根专属钩子**(`-unit-test` 后缀 = 有独立 `files:` 触发面、需要秒级归因的那个子集;2026-10-25 增补 adc-cal / prot-dim / vol-idle-sync / ui-data / meas / run-par 六根)——**这是口径不是缺口**:`test/host/` 实测 44 套带 `run_unit_test.py`,其余 **22 套**(含 `test/host/ui_sink` 与录波三套)没有专属钩子,但每次 CI / `scripts/ci_local.py` 都被 `test-coverage` 总闸全量真跑并守基线回退,**失败照样拦**,只是归因落在总闸输出上;**没有专属钩子 ≠ 没被跑**,22 套台账与补钩子的判据 → `docs/门禁说明/host-unit-tests.md` §6 第 4 条。**Lead 裁定 2026-10-27:不补第 23 根**——读「这套跑没跑」去看总闸输出,别数钩子(原写「23 个 / 含 ui-sink 七个」是双重错:既把 22 说成 23,又宣称 ui-sink 有钩子——配置历史里 `ui-sink-unit-test` 从未存在)+ `test-coverage`(**覆盖率总闸**:跑全部 44 套并守基线回退;缺 mscoverage 组件时按用户裁定 **FAIL**、不 skip,2026-10-24)+ `test-suite-switch` + `registration-check`(双端注册反向校验 + UI 页面描述符表接线,登记簿条目「rs485_task 白名单缺口」)+ `generated-fresh`(**生成器幂等性**:把 `tools/menu_gen/gen_menu.py`、中文字库链、`tools/arch_diagram/gen_*.py` 拷进**系统临时目录**重跑,与工作区**逐字节**必须一致——钩子只读、不写仓库也不 stash 别人在飞的改动;实证 `0f7b5a87` 改了模板文字没重跑生成物,接线前实测另有 5 张 `docs/images/` 配图已漂 → 钉成只减不增基线,**业务生成物零豁免面** → `docs/门禁说明/generated-fresh.md`)+ `nested-comment`(**块注释嵌套**:注释体内再出现 /* 会被 ARMCC 报 9-D,且**按包含者数量刷屏**、只在重编译该 TU 时才暴露;本仓诱因就是「在注释里写作用域/通配路径」,改法是写全目录或用尖括号 ;接线当日实扫 403 个 .c/.h,新发 0、存量 2 处(`test/host/common/` 两份假件头)钉只减不增基线 → `docs/门禁说明/nested-comment.md`)+ `skill-size`(**SKILL.md 不超插件写侧上限 65536 字节**:dsh-mnemon 只在写侧校验、加载侧不查,超限技能照常可读却每次「生成草案」被拒、永久卡在「生成中」;另查 `reference/` 指针悬空与 BOM → `docs/门禁说明/skill-size.md`)+ `mem-budget`(RAM 92% 预算门禁,登记簿条目「缩堆迁 CCM 后半(暂缓)」)+ `font-charset`(UI 字符集差集检查,防缺字白框)+ `ui-golden`(UI 像素回归,菜单 8 状态 + 内容页 66 张黄金参考)+ `layer-deps`(**分层方向**:`Drivers/BSP/**` 不得 include `App/**` 的头;存量 3 条基线只减不增(2026-10-25 记录 reader 注入削掉 UI 记录 3 条边,6→3))+ `bsp-err`(**BSP 错误码与超时命名约定**:公开 API 不得拿 `HAL_StatusTypeDef`/`bool` 当错误通道、`*_TIMEOUT` 宏不得取负值、非 void 须写「返回值:」、**返回错误码的函数不得裸丢弃返回值**(须接住或显式 `(void)`;实测编译器与 cppcheck 都不报这类,只有这条会响);约定正文 `Drivers/BSP/bsp_err.h`,存量基线只减不增(2026-10-23 首清点 19 条 → 已还 18;`consume-result` 上线又暴露 37 条存量丢弃 → **现 38 条**))。**提交期实跑 48 根**(2026-10-28 用户裁定):`cppcheck` / `test-cppcheck` / `test-coverage` 三根带 `stages: [manual]`——实测合计 ~165 s = 一次带代码提交耗时的三分之二,提交期只跑快的那些;这三根由**远端 CI** 强制(`.gitea/workflows/ci.yml` 的「手动阶段门禁」步,逐根 `--hook-stage manual`)与 `scripts/ci_local.py` 的 manual 段,本地手工复跑 `python -m pre_commit run --all-files --hook-stage manual `(一次只能一个 id) +- 任一失败即拦截,共 **52 个钩子**(**口径 = YAML 解析后的 hook 条目数**:自查命令与「声明 / 文档 / 实跑」三数同源核对见 `docs/门禁说明/README.md` §2 首条——⚠ 直接 grep 配置里的声明行会多算一条**注释掉的**候选钩子(比口径多 1),不是口径;**配置注释里也别复述那个 grep 字面量**,它会让文本计数再虚高 1(2026-10-27 实测踩过);加一条钩子就同批改 AGENTS 与门禁索引两处计数。2026-10-25 道E 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三条,2026-10-26 加 `generated-fresh` 一条,2026-10-27 加 `nested-comment` 一条;2026-10-28 加 `id-check-new` 一条并同批退役 `id-check`(口径 52 → 51:编号门禁换牙——新的只查新增行、零语料,旧的八项全库校验连语料一起移入 `scripts/archive/id_gate/`,手工普查与恢复机器面见 `docs/编号规范.md` §8.5);2026-10-29 加 `skill-size` 一条(51 → 52),同日按用户裁定**退役 `id-check-new`**(52 → **51**:只查新增行的编号门禁整体退场,脚本与白名单一起移入 `scripts/archive/id_gate/`,编号形态此后无机器面,留档 `docs/门禁说明/id-check-new.md`);2026-10-30 加 `mermaid-syntax` 一条(51 → **52**:mermaid 围栏内确定性语法——坏图此前只能靠肉眼在渲染端发现,实证见专篇);**每种门禁一篇详解(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径)→ `docs/门禁说明/README.md` 索引**;通用机制与整体口径 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + `bom-guard` / `setview-check` / `uinav-selftest`(2026-10-25 道E 那三条:前半句的加钩流水里有它们、逐项枚举里漏了,补上后本行各项相加才等于 51;2026-10-30 又加 `mermaid-syntax` 后此句的"等于"目标数为 52)+ 主机侧单测 **22 根专属钩子**(`-unit-test` 后缀 = 有独立 `files:` 触发面、需要秒级归因的那个子集;2026-10-25 增补 adc-cal / prot-dim / vol-idle-sync / ui-data / meas / run-par 六根)——**这是口径不是缺口**:`test/host/` 实测 44 套带 `run_unit_test.py`,其余 **22 套**(含 `test/host/ui_sink` 与录波三套)没有专属钩子,但每次 CI / `scripts/ci_local.py` 都被 `test-coverage` 总闸全量真跑并守基线回退,**失败照样拦**,只是归因落在总闸输出上;**没有专属钩子 ≠ 没被跑**,22 套台账与补钩子的判据 → `docs/门禁说明/host-unit-tests.md` §6 第 4 条。**Lead 裁定 2026-10-27:不补第 23 根**——读「这套跑没跑」去看总闸输出,别数钩子(原写「23 个 / 含 ui-sink 七个」是双重错:既把 22 说成 23,又宣称 ui-sink 有钩子——配置历史里 `ui-sink-unit-test` 从未存在)+ `test-coverage`(**覆盖率总闸**:跑全部 44 套并守基线回退;缺 mscoverage 组件时按用户裁定 **FAIL**、不 skip,2026-10-24)+ `test-suite-switch` + `registration-check`(双端注册反向校验 + UI 页面描述符表接线,登记簿条目「rs485_task 白名单缺口」)+ `generated-fresh`(**生成器幂等性**:把 `tools/menu_gen/gen_menu.py`、中文字库链、`tools/arch_diagram/gen_*.py` 拷进**系统临时目录**重跑,与工作区**逐字节**必须一致——钩子只读、不写仓库也不 stash 别人在飞的改动;实证 `0f7b5a87` 改了模板文字没重跑生成物,接线前实测另有 5 张 `docs/images/` 配图已漂 → 钉成只减不增基线,**业务生成物零豁免面** → `docs/门禁说明/generated-fresh.md`)+ `nested-comment`(**块注释嵌套**:注释体内再出现 /* 会被 ARMCC 报 9-D,且**按包含者数量刷屏**、只在重编译该 TU 时才暴露;本仓诱因就是「在注释里写作用域/通配路径」,改法是写全目录或用尖括号 ;接线当日实扫 403 个 .c/.h,新发 0、存量 2 处(`test/host/common/` 两份假件头)钉只减不增基线 → `docs/门禁说明/nested-comment.md`)+ `skill-size`(**SKILL.md 不超插件写侧上限 65536 字节**:dsh-mnemon 只在写侧校验、加载侧不查,超限技能照常可读却每次「生成草案」被拒、永久卡在「生成中」;另查 `reference/` 指针悬空与 BOM → `docs/门禁说明/skill-size.md`)+ `mermaid-syntax`(**mermaid 围栏语法预检**:` ```mermaid ` 块里只查「错了必渲染失败」三条——图类型头 / `-.` 后直接跟管道(本坑:屏板链路设计说明 §1 坏图靠肉眼在渲染端发现,期间各文档门禁全绿)/ subgraph 与 sequence 块的 `end` 计数配对;零语料空基线、`docs/芯片手册/` 与 doc-links 同排除面 → `docs/门禁说明/mermaid-syntax.md`)+ `mem-budget`(RAM 92% 预算门禁,登记簿条目「缩堆迁 CCM 后半(暂缓)」)+ `font-charset`(UI 字符集差集检查,防缺字白框)+ `ui-golden`(UI 像素回归,菜单 8 状态 + 内容页 66 张黄金参考)+ `layer-deps`(**分层方向**:`Drivers/BSP/**` 不得 include `App/**` 的头;存量 3 条基线只减不增(2026-10-25 记录 reader 注入削掉 UI 记录 3 条边,6→3))+ `bsp-err`(**BSP 错误码与超时命名约定**:公开 API 不得拿 `HAL_StatusTypeDef`/`bool` 当错误通道、`*_TIMEOUT` 宏不得取负值、非 void 须写「返回值:」、**返回错误码的函数不得裸丢弃返回值**(须接住或显式 `(void)`;实测编译器与 cppcheck 都不报这类,只有这条会响);约定正文 `Drivers/BSP/bsp_err.h`,存量基线只减不增(2026-10-23 首清点 19 条 → 已还 18;`consume-result` 上线又暴露 37 条存量丢弃 → **现 38 条**))。**提交期实跑 49 根**(2026-10-30 起,前值是 48;三根慢钩子让位的裁定 2026-10-28):`cppcheck` / `test-cppcheck` / `test-coverage` 三根带 `stages: [manual]`——实测合计 ~165 s = 一次带代码提交耗时的三分之二,提交期只跑快的那些;这三根由**远端 CI** 强制(`.gitea/workflows/ci.yml` 的「手动阶段门禁」步,逐根 `--hook-stage manual`)与 `scripts/ci_local.py` 的 manual 段,本地手工复跑 `python -m pre_commit run --all-files --hook-stage manual `(一次只能一个 id) - **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见 `docs/代码规范/pre-commit使用说明.md` §5.5) - ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/` @@ -177,7 +177,7 @@ | ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` | | 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` | | RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` | -| **每个提交门禁一篇详解**(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径) | `docs/门禁说明/README.md`(51 个钩子 → 29 篇索引,其中 2 篇是退役留档 + 1 指针) | +| **每个提交门禁一篇详解**(查什么 / 拦下过什么真事 / 限制与盲区 / 基线口径) | `docs/门禁说明/README.md`(52 个钩子 → 30 篇索引,其中 2 篇是退役留档 + 1 指针) | | **还想给东西发编号?**(**2026-10-29 用户裁定:一律不发**,写语义短语;§4.1 五个存量族的既有号只读引用——「停发」不等于「暂缓」,也不等于「可以清号」)/ 编号门禁现状(**两道都已退役**:全库八项校验 2026-10-28、只查新增行 2026-10-29;此后无机器面,只靠 §1 与评审) | `docs/编号规范.md`(§1 默认答案、§4.1 存量族〔穷举〕与『已停发』栏、§8.5 手工普查与恢复机器面) | | 提交门禁通用机制(阶段 / stash 陷阱 / 触发面 / 增量拦截三段式) | `docs/代码规范/pre-commit使用说明.md` §2 | | 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` | diff --git a/docs/子系统设计/屏板链路设计说明.md b/docs/子系统设计/屏板链路设计说明.md index f43bce12..703c6284 100644 --- a/docs/子系统设计/屏板链路设计说明.md +++ b/docs/子系统设计/屏板链路设计说明.md @@ -35,7 +35,7 @@ flowchart TB A -->|无条件| A2["重挂 ReceiveToIdle_IT"] end S -.-> D - S -.|"组帧 OK 且本拍注过键 → 交凭据"| T + S -.->|"组帧 OK 且本拍注过键 → 交凭据"| T KQ -.-> K D --> TC["DMA2_Stream7 传完中断"] --> C["清 busy(攒了 dirty → 下一拍补组)"] ``` diff --git a/docs/门禁说明/README.md b/docs/门禁说明/README.md index c43cf11b..5dd6fcd3 100644 --- a/docs/门禁说明/README.md +++ b/docs/门禁说明/README.md @@ -1,7 +1,7 @@ # 门禁逐项说明(每种门禁一篇) -> 本目录逐项说明各门禁:**声明的 51 个钩子到底在干什么、拦谁、放谁、什么时候会骗人**——其中 3 根慢钩子带 -> `stages: [manual]`,一次 `git commit` 只实跑 48 根(口径见下条)。 +> 本目录逐项说明各门禁:**声明的 52 个钩子到底在干什么、拦谁、放谁、什么时候会骗人**——其中 3 根慢钩子带 +> `stages: [manual]`,一次 `git commit` 只实跑 49 根(口径见下条)。 > 每个门禁一篇,八段固定结构:查什么 / 为什么要它 / 触发面 / 报错怎么读 / 好处 / > **限制与盲区** / 豁免与基线 / 改门禁本身要注意什么。**最后一节是这套文档存在的理由**—— > 一份只看"拦了什么"的门禁文档,会让人以为绿就是对的。 @@ -12,41 +12,48 @@ → **51**(2026-10-26:49 → 50 加 `generated-fresh`;2026-10-27:50 → 51 加 `nested-comment`; 2026-10-28:51 → 52 加 `id-check-new`、随即 52 → 51 摘掉 `id-check`(退役全库语料审计);2026-10-29:51 → 52 加 `skill-size`(SKILL.md 写侧上限与 reference 指针,详见 [`skill-size.md`](skill-size.md)); 同日 52 → **51** 退役 `id-check-new`——只查新增行的编号门禁整体退场,脚本与白名单移入 - `scripts/archive/id_gate/`,此后**编号形态没有机器面**(留档 [`id-check-new.md`](id-check-new.md)))。 + `scripts/archive/id_gate/`,此后**编号形态没有机器面**(留档 [`id-check-new.md`](id-check-new.md)); + 2026-10-30:51 → **52** 加 `mermaid-syntax`(mermaid 围栏内确定性语法:坏图渲染失败靠肉眼才逮过, + 详见 [`mermaid-syntax.md`](mermaid-syntax.md)))。 ⚠ **别用 `grep -c` 去数配置里的声明行**:该文件里有一条**注释掉的**候选钩子(clang-format,第 65 行), - 文本 grep 会多算 1(当前口径 51 会数成 52)——「声明 52 / 文档 51 / 实跑 51」这种三个数不同源的争执就是这么来的。真要 grep, - 用**锚定行首缩进**的模式(它天然跳过以 # 开头的注释行)2026-10-28 实测直接得 **51**(当时口径),与 YAML 口径一致; + 文本 grep 会多算 1(当前口径 52 会数成 53)——「声明比文档多 1 / 实跑又少一档」这种三个数不同源的争执就是这么来的。真要 grep, + 用**锚定行首缩进**的模式(它天然跳过以 # 开头的注释行)2026-10-28 实测直接得 **51**(当时口径)、 + 2026-10-30 复测得 **52**,均与 YAML 口径一致; 会多算 1(比口径多 1)的是**不锚定行首、按子串**匹配声明行的写法(那条被注释掉的候选也被它算进去)。 ⚠ 反过来的坑也真踩过(2026-10-27):在配置**注释里复述那个 grep 字面量**,会让文本 grep 再虚高 1—— 数数只认 YAML 解析,注释与文档里都别写出那个字面量(`docs/门禁说明/nested-comment.md` §8 记了现场)。 实跑侧口径:`scripts/ci_local.py` 的静态门禁汇总行 `[SUMMARY] …: pre-commit hooks -- passed=A failed=B skipped=C`(方括号里那一段是**层级名**,脚本输出与文档层的语义名正分头迁移 → 抓这行**按 `passed=` 字段匹配**,别把层级名前缀写死进字面量,否则会 grep 不到而误判「没有实跑数」), - `A+B+C` = **commit 阶段**的钩子数(48 = 52 − 3 根 `stages: [manual]` − `gitlint`),再加 `gitlint` 1 条(它的 - commit-msg 阶段声明写在**上游**仓库自带的钩子清单里,本配置文件里查不到,见 `scripts/ci_local.py` 文件头) - → 48 + 1 = **49 = 一次 `git commit` 实跑的钩子数**;再加 3 根 manual 阶段的慢钩子才是声明数 52,三数同源(2026-10-29 加 `skill-size` 之前这四个数是 51 / 47 / 48)。 + `A+B+C` = **pre-commit 阶段**的钩子数(48 = 52 − 3 根 `stages: [manual]` − `gitlint`;2026-10-30 加 + `mermaid-syntax` 前该式子右侧是 51/47),再加 `gitlint` 1 条(它声明在本配置的 `repo: jorisroovers/gitlint` + 条目、`stages: [commit-msg]`(第 70 行),`--hook-stage commit` 的枚举不含它——2026-10-30 实测: + `--hook-stage commit` 枚举 **47 行** / `manual` 枚举 **50 行** / YAML 解析声明 **51**(加本钩后 48/51/52), + 47 + 3 + 1 = 51,差的那个就是 `gitlint`;旧说法「gitlint 声明在上游清单、本配置里查不到」已于当日随 + `stages:` 接线作废) + → 48 + 1 = **49 = 一次 `git commit` 实跑的钩子数**;再加 3 根 manual 阶段的慢钩子才是声明数 52,三数同源(2026-10-29 加 `skill-size` 之前这四个数是 51 / 47 / 48;2026-10-30 加 `mermaid-syntax` 后是 52 / 48 / 49)。 枚举自查(不执行任何钩子,`SKIP` 列全部 id):`SKIP=<全部 id> -m pre_commit run --all-files --hook-stage commit` 输出 47 行、`--hook-stage manual` 输出 50 行(47 常规 + 3 慢的)。 - **三根慢钩子在 manual 阶段**:`cppcheck`、`test-cppcheck`、`test-coverage` 带 `stages: [manual]` → `git commit` 阶段**不跑**(实测三根合计约 165 s,占一次带代码提交耗时的约三分之二),只在**远端 CI 的「手动阶段门禁」步** (`.gitea/workflows/ci.yml`,逐根 `--hook-stage manual`)与 `scripts/ci_local.py` 的 **manual 段**里跑。 本地手工复跑:`& '.venv\Scripts\python.exe' -m pre_commit run --all-files --hook-stage manual ` - (**一次只能给一个 id**,多 id 实测报 `unrecognized arguments`)。声明数仍是 51,变的是「提交期实跑几根」。 + (**一次只能给一个 id**,多 id 实测报 `unrecognized arguments`)。声明数仍是 52,变的是「提交期实跑几根」。 - 通用机制(三个阶段 = `pre-commit` / `commit-msg` / `manual`、stash 陷阱、`always` 已废、增量拦截三段式、旧 §2.x 查号表): `docs/代码规范/pre-commit使用说明.md` §2。 -- 快速自查(不等提交):`& '.venv\Scripts\python.exe' scripts/ci_local.py`(静态段 47 根 + manual 段三根 + 提交信息门禁 + 体积门禁 + 主机侧纯逻辑单测;manual 段默认跑)。 +- 快速自查(不等提交):`& '.venv\Scripts\python.exe' scripts/ci_local.py`(静态段 48 根 + manual 段三根 + 提交信息门禁 + 体积门禁 + 主机侧纯逻辑单测;manual 段默认跑)。 ## 1. 三种拦截姿态(先分清这个,报错时才知道该干什么) | 姿态 | 含义 | 有哪些 | 失败时该做什么 | |---|---|---|---| | **改写型** | 不报错,直接改你的文件 | `trailing-whitespace`、`end-of-file-fixer` | 重新 `git add` 被改的文件再提交(它改的是工作区,不是索引) | -| **硬拦截** | 违反即失败,没有存量豁免 | `cppcheck`、`coding-standard`、`doc-links`、`no-mojibake`、`mem-budget`、`ui-golden`、`font-charset`、`lv-conf-sync`、`task-table`、`mem-numbers`、`registration-check`、`cubemx-survival`、`test-suite-switch`、22 个主机单测、官方 5 个、`gitlint` | 只能改代码或改文档;**不要**找豁免开关 | +| **硬拦截** | 违反即失败,没有存量豁免 | `cppcheck`、`coding-standard`、`doc-links`、`no-mojibake`、`mem-budget`、`ui-golden`、`font-charset`、`lv-conf-sync`、`task-table`、`mem-numbers`、`registration-check`、`cubemx-survival`、`test-suite-switch`、`mermaid-syntax`(基线为空,先例是已退役的 `id-check-new`「零语料」;唯一排除面 = 外部资料目录,见其专篇 §7)、22 个主机单测、官方 5 个、`gitlint` | 只能改代码或改文档;**不要**找豁免开关 | | **增量拦截** | 规则恒生效,但存量钉在基线/台账里,只许减不许增 | `layer-deps`(3 条,2026-10-25 还清 UI 记录 3 条)、`bsp-err`(38 条)、`generated-fresh`(5 处 `docs/images/` 配图存量,脚本内 `BASELINE`)、`nested-comment`(2 处主机侧假件头,`test/host/common/`,脚本内 `BASELINE`) | 报错先分清是**新增**还是**存量搬家**:`--verbose` 看归类,`--prune` 看已还清 | | **只查新增** | 只看本次改动的增量,没有存量基线,也没有台账 | ~~`id-check-new`~~(**2026-10-29 退役**,脚本在 `scripts/archive/id_gate/`;这一姿态已无现役门禁) | 手工复查时才谈得上:改成语义短语(门禁不再跑,行内 `[编号豁免]` 也随之失效) | ⚠ 增量拦截最容易误会的一点:**绿灯不等于没问题**,只等于"没有比上次更差"。 每个增量拦截门禁的基线数字都写进它自己的文档,读那篇时第一眼看的就是"现在在册多少条"。 -## 2. 索引(51 个 hook → 29 篇,其中 2 篇是已退役门禁的留档 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/) +## 2. 索引(52 个 hook → 30 篇,其中 2 篇是已退役门禁的留档 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/) > 带 `stages: [manual]` 的三根(`cppcheck` / `test-cppcheck` / `test-coverage`)**提交期不跑**,只在远端 CI 的 > 「手动阶段门禁」步与 `scripts/ci_local.py` 的 manual 段跑;下表对应两行的「文档」列已标注。 @@ -58,6 +65,7 @@ | [`id-check.md`](id-check.md) | ~~`id-check`~ | **已退役(2026-10-28)**:全库语料的八项校验,留档备查;接班件也在下一行 | 在时有:几乎所有文本文件 | 已写(退役留档) | | [`id-check-new.md`](id-check-new.md) | ~~`id-check-new`~ | **已退役(2026-10-29,用户裁定)**:只查新增行的编号门禁,留档备查(含退役理由与手工跑法);**编号形态此后无机器面** | 在时有:任意 md/c/h/py/yaml/txt | 已写(退役留档) | | [`doc-links.md`](doc-links.md) | `doc-links` | 文档里的路径引用是否存在;`--sections-strict` 另查章节号是否还存在 | 任意 `.md` | 已写 | +| [`mermaid-syntax.md`](mermaid-syntax.md) | `mermaid-syntax` | ` ```mermaid ` 围栏内确定性语法:图类型头 / `-.` 直接跟管道 / subgraph 与 sequence 块的 `end` 配对 | 任意 `.md`(`docs/芯片手册/` 等与 doc-links 同排除面) | 已写 | | [`cppcheck.md`](cppcheck.md) | `cppcheck`、`test-cppcheck` | 静态分析(真机侧与 `test/` 侧两套范围与抑制项) | C/H 源文件 | 已写(`stages: [manual]`) | | [`coding-standard.md`](coding-standard.md) | `coding-standard` | 编码规范 V1.0 的可机械检查子集(命名、头文件保护宏、模块头注释块…) | C/H 源文件 | 已写 | | [`no-mojibake.md`](no-mojibake.md) | `no-mojibake` | 禁 `U+FFFD`——中文被按错误编码重解码后不可逆损坏的痕迹 | 全部文本 | 已写 | @@ -98,7 +106,7 @@ ## 4. 现状与下一步 -- **本目录 29 篇已全部成文**(2026-10-29 加 `skill-size` 一篇 → 29;2026-10-27 实测当时 27 篇;2026-10-28 当时复核:`docs/门禁说明/` 下除 README 外 28 篇,与上表「文档」列 +- **本目录 30 篇已全部成文**(2026-10-30 加 `mermaid-syntax` 一篇 → 30;2026-10-29 加 `skill-size` 一篇 → 29;2026-10-27 实测当时 27 篇;2026-10-28 当时复核:`docs/门禁说明/` 下除 README 外 28 篇,与上表「文档」列 **一一对应**——「有页无行」与「有行无页」两个差集都是空集;一行覆盖多个 hook 的行(`cppcheck`+`test-cppcheck`、 22 个 `*-unit-test`)按行内列举计。上一句写「23 篇」是存量误差:2026-10-25 连加 `bom-guard` / `setview-check` / `uinav-selftest` 三根、本轮加 `generated-fresh` 都没同步它(本轮前实有 25 篇)。 diff --git a/docs/门禁说明/mermaid-syntax.md b/docs/门禁说明/mermaid-syntax.md new file mode 100644 index 00000000..4edc3ac0 --- /dev/null +++ b/docs/门禁说明/mermaid-syntax.md @@ -0,0 +1,96 @@ +# mermaid 围栏语法预检(mermaid-syntax)— 围栏里的图坏了必在提交时响 + +> 一句话:` ```mermaid ` 代码块是**渲染期才报错**的内容——md 链接与章节门禁全绿也拦不住一张 +> 语法坏的图。本闸只查「错了必渲染失败」的三条确定性形态,不重造 mermaid 解析器。 +> 脚本 `scripts/check_mermaid.py`(纯标准库、只读,2026-10-30 接线)。 + +## 1. 它在查什么 + +对每个 md 提取 ` ```mermaid ` 围栏块(排除面见 §3),逐块查: + +| 判定 | 形态 | 说明 | +|---|---|---| +| **R0 算** | 围栏未闭合 | 有 ` ```mermaid ` 开栏找不到闭栏 | +| **R1 算** | 块内首个非空非注释行不以已知图类型关键字开头(`flowchart`/`graph`/`sequenceDiagram`/`stateDiagram`/`classDiagram`/`erDiagram`/`gantt`/`journey`/`pie`/`mindmap`/`timeline`/`quadrantChart`/`gitGraph`/`C4Context`… 全表见脚本 `DIAGRAM_HEADS`) | 没有头 = 100% `Syntax error in text`;`flowChat` 这类拼错也算 | +| **R2 算** | `-.` 后(允许空白)直接出现管道 `\|`,即 `-.|"文字"\|` 形态 | 接线当日逮到的**真事原形**(§2);带文字点线的合法形态只有 `-.->\|t\|` 与 `-. 文字 .->`,selftest 证明两者不误伤 | +| **R3 算** | flowchart/graph 的 `subgraph` 行数 ≠ 独占行 `end` 行数;sequenceDiagram 的行首块起点(`alt`/`opt`/`loop`/`par`/`critical`/`rect`/`break`)行数 ≠ 行首 `end` 行数 | 缺 `end`(或多 `end`)必渲染失败;`else`/`and`/`option` 是分支不消费 end,不误伤 | +| 不算 | `%%` 注释行、`rem` 行 | 与渲染一致地跳过 | +| 不算 | 标签内出现 `end`/管道/引号(如 `E["end 事件"]`、`-"x"|-`在合法箭头内) | 判定只看行首 token 与 `-.`+`\|` 邻接,不碰引号内文本 | + +## 2. 为什么要它 + +2026-10-29 落库的 `docs/子系统设计/屏板链路设计说明.md` §1 flowchart 里,一行带文字点线写成: + +``` +S -.|"组帧 OK 且本拍注过键 → 交凭据"| T ← 坏:`-.` 后直接跟管道,`-.|` 不是箭头 token +S -.->|"组帧 OK 且本拍注过键 → 交凭据"| T ← 修复:补 `>`,`-.->|t|` 是合法形态 +``` + +mermaid 10.9.8 对前者整图渲染 `Syntax error in text`(headless Edge 截图实证,图旁红炸弹图标), +对后者渲染完整正确。**该坏图从落库到 2026-10-30 被用户肉眼发现期间,doc-links 等文档门禁全绿** +——它们查链接、章节、表格断裂,**没有任何一根查围栏内图表语法**;芯片手册转录目录之外,全仓 +自有围栏 60 块,全靠人眼守。 + +### 2.1 接线当日实测(都可复现) + +| 测项 | 结果 | +|---|---| +| 全仓 `--all` 实扫 | **29 个自有文件共 60 块围栏:新发 0、基线 0**,退出码 0 ⇒ 空基线零语料 | +| `--selftest` 五条 | **5/5**:正例(三种合法点线 + 配对 subgraph)0 命中 / 本坑形态 R2 必响 / subgraph 缺 end 必响 / 假图类型头必响且 sequence `alt-else-end` 不误伤 / 基线吞得下且清空后同命中算新发 | +| 钩子端到端 | 暂存一个复刻本坑的坏 md → `mermaid-syntax Failed`、定位到 `文件:38 [R2-dot-pipe]` 原行;修复形态 `--all-files` Passed | +| 原图渲染对照 | 坏版 `Syntax error in text`、好版完整渲染,两 PNG 见当日会话(mermaid 10.9.8 CDN + headless Edge) | + +## 3. 触发面 + +钩子 `files: \.md$` + exclude 与 `doc-links` **同口径**(`.opencode/node_modules/`、`Lib/lvgl/`、 +`MDK-ARM/RTE/`、`docs/芯片手册/`)——芯片手册是外部转录,围栏形态不受控,与 doc-links 一样整体 +不进语料。`scripts/check_mermaid.py --all` 的文件集与钩子实际喂入**刻意同口径**。 + +单独自查:`& .venv\Scripts\python.exe scripts/check_mermaid.py --all` + +## 4. 报错怎么读 + +``` +docs/子系统设计/xxx.md:38 [R2-dot-pipe] dotpipe:S -.|"组帧 OK 且本拍注过键 → 交凭据"| T +``` + +行号是 **md 文件绝对行号**(不是块内偏移),直接跳。四条判读打印在输出尾行: +R1 改图类型头;R2 改 `-.->|t|` 或 `-. t .->`;R3 补/删 `end`(打印里带 `pair:sg=N,end=M` +两个计数,谁多谁少一眼可见);R0 补闭栏。 + +## 5. 好处 + +- 把「肉眼盯渲染」变成机器兜底:围栏内坏图在**提交时**响,不用等 GitWeb/IDE 预览点亮红块。 +- 空基线零语料:今天起任何新坏图都拦得住,不背历史债。 +- 毫秒级、纯标准库、只读——不需要网络、不需要 node、不需要浏览器。 + +## 6. 限制与盲区(绿 ≠ 图对) + +- **不是解析器**:只覆盖三条「必炸」形态。大量仍会漏——引号不平衡、非法节点字符(中文引号 + 包标签)、sequence 消息语法错、`style`/`classDef` 目标不存在、layout 指令错等。最终权威 + 仍是**真实渲染**(mermaid CDN + headless Edge 截图回读,`draw-diagram` skill 载有流程)。 +- **R2 只钉 `-.`+`|` 本坑形态**:其它非法箭头变体(如 `==|`、`-.->x`)不响;出现新形态时 + 加判据(本闸按「增量长牙」演进,与 `nested-comment` 同哲学)。 +- **R3 是计数配对**:subgraph 多一个**且** end 也恰好多一个(两处错互相抵消)测不出; + 也不校验 `end` 是否配对在正确的嵌套层级(只数总量)。 +- `%%` 注释行里的坏形态不响(与渲染行为一致);围栏缩进嵌在列表项里的块同样被扫(无盲区), + 但 **indented code block 里的 mermaid 不会被识别**(GFM 本也不渲染它,一致)。 +- 只扫 ` ```mermaid `;` ~~~mermaid ` 波浪围栏全仓未用,未支持。 + +## 7. 豁免与基线 + +- **基线为空**(2026-10-30 接线实扫 0 命中,先例 = 已退役的 `id-check-new`「零语料」形态), + 脚本内 `BASELINE = ()`。若将来被迫登记存量:必须写「文件 + 规则 + 上下文指纹 + 为什么不改」, + `--prune` 打台账、只减不增。 +- 唯一排除面是 §3 的目录前缀(外部资料),**自有文档零豁免**。 + +## 8. 改门禁本身要注意什么 + +- **加判定必带正例自测**:往 `hits_for_block` 加任何新形态前,先在 `--selftest` T1 的正例里 + 放入该形态的**合法**写法——没有正例守卫的新判据就是给自己造误报机(`nested-comment` §8 同训)。 +- **排除面两处同步**:`.pre-commit-config.yaml` 本钩的 `exclude` 与脚本 `EXCLUDE_PREFIXES` + 是两份字面拷贝,改一处必改另一处——手跑面与提交面不一致就会变成「两个数」。 +- 判定用「行首 token / 邻接字符」这类保守形态学,**不要**用「看起来像标签」的启发式—— + 本仓围栏里混排中英文、全角符号、`µ`/`≠`/`→` 等字符,启发式必误伤。 +- 本条钩子使 YAML 解析口径 **51 → 52**、提交期实跑 **48 → 49**;加/摘钩子按 + `docs/门禁说明/README.md` §2 首条的自查命令三数同核(AGENTS 与索引两处计数同批改)。 diff --git a/scripts/check_mermaid.py b/scripts/check_mermaid.py new file mode 100644 index 00000000..dc53579c --- /dev/null +++ b/scripts/check_mermaid.py @@ -0,0 +1,334 @@ +#!/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()