feat(id-check): 编号门禁第二步——新增行拦截(零语料)

背景:第一步(bf2a5c21)把两根护栏转成报告制,但全库语料审计仍在跑。
它真正的用途只有一个——拦住「新写一个自造编号」;这件事不需要 99 族语料。
所以本笔落第二步:只查 git diff 的新增行,判据收窄成两张前缀表。

新增 scripts/check_ids_new.py(241 行,零语料):
- 破折形态:首段不在 KNOWN_DASH(活族 + 标准名,11 条)→ FAIL;
  在 RETIRED_DASH(已停发族)→ 只 WARN,提示改语义短语。
- 裸形态:前缀不在 KNOWN_BARE(外设/器件/协议/点号,约 90 条)→ FAIL。
- 单字母 + 数字一律放行(元件位号与层次号同形,显式盲区)。
- 算术排除(SIZE-2 / LEN-1 这类是减法不是编号);跳过 [示例]/[计划] 行;
  行内 [编号豁免] 为最后手段。默认查暂存区,另有 --worktree / --range / --self-test。

接线:新钩子 id-check-new(钩子数 51 → 52,AGENTS 与门禁索引已同批改),
与 id-check 双跑一个观察期;观察期后按 docs/编号规范.md §8.5 ③ 退役旧语料。
旧门禁的排除面加上新脚本(它必然写着示例令牌,否则自己拦自己)。

白名单是按真实数据调出来的,不是拍脑袋:回放最近 80 笔提交,未知形态只有 15 种,
其中 12 种是设备/协议名(RS232、RS485、OVER8、YT/YH 点号等,已进白名单)、
3 种是已停发族(P-8、N-1、F-NET-*,只 WARN)→ 调好后回放误报 0。

自证(实测):
- 自检 5 正例全部拦下、29 反例全部放行(含 TC-NET-204 / TRAP-ADC-01 / TPAFE5160 /
  GD5F2GQ5UE / C12 / U15 / L3 / SIZE-2 / GPIO_PIN_5)。
- 端到端:暂存一个自造前缀 + 数字 → 钩子 FAIL;清理后 Passed。
- 反向:本页文档里举的反例自己也被拦过一次,改成带 [示例] 标记后才过——
  这条正好证明它没有「文档路径」的例外面。
- 两条门禁互不干扰:old-gate rc=0、new-gate rc=0、doc-links 通过(211 文档)。
This commit is contained in:
2026-10-28 12:27:57 +08:00
parent 2f70230646
commit a5832788e9
7 changed files with 393 additions and 14 deletions
+17 -2
View File
@@ -682,8 +682,9 @@ repos:
# 必然一边倒误报 → 裸形族只在属主文档内判越游标、文档外降告警;收口属 §9 阶段
# 1-② 的迁移批次。**不是「检查通过」,是记在案上的盲区。**
# ⚠ 当前 warn-only(§8 上线节奏:先清 backlog → 清零后 entry 加 --strict 拦提交,
# 与 doc-links 章节校验同一条路径)。校验 8 的「可判定比例」跌破
# id_registry.json 里的基线时**任何模式都失败**——那根线就是防回退的本体。
# 与 doc-links 章节校验同一条路径)。校验 8 的两根护栏(可判定比例 / 不可判定
# 绝对数)**自 2026-10-28 起只报告、不拦提交**(用户裁定,退役路线见规范 §8.5);
# 硬拦的只有校验 1~6 的台账外新账。
# ⚠ pre-commit 4.x 已移除 `always: true` → files 必须覆盖所有能改编号的触发面
# (任意 md/c/h/py/yaml/json/txt + 本配置文件);扫描范围恒为全库
# (悬空/孤儿/重复定义是跨文件判定,pass_filenames: false)。
@@ -694,6 +695,20 @@ repos:
files: \.(md|c|h|py|ya?ml|json|txt)$|^\.pre-commit-config\.yaml$
pass_filenames: false
# 14) 编号门禁·第二步(2026-10-28 加):只查**新增行**,零语料
# 背景:语义标识已是默认形态,上面那套 99 族全库语料审计的维护成本高过收益
# (近 80 笔提交有 61 笔被编号体系牵连),退役路线见 `docs/编号规范.md` §8.5。
# 本钩子是新牙:看 git diff 的新增行里有没有**未登记形态**的「字母+数字」标识
# (自造前缀直接 FAIL;已停发族只 WARN);白名单在脚本里,按实测普查枚举、几乎不增长。
# 与 id-check **双跑**一个观察期(老的是全库语料、新的是新增行),观察后再摘掉老的。
# 自检:`python scripts/check_ids_new.py --self-test`;范围自查用 `--range A..B`。
- id: id-check-new
name: 编号门禁·新增行(零语料,拦截新造编号)
entry: python scripts/check_ids_new.py
language: system
files: \.(md|c|h|py|ya?ml|txt)$|^\.pre-commit-config\.yaml$
pass_filenames: false
# 14) 分层方向门禁(2026-10-22 加,结构债的"防扩大"那一半)
# 背景:AGENTS.md 与 项目架构总览.md 早就写着"六层单向依赖",但**从来没有机械检查**
# ——Drivers/BSP/ 里可以随手 include "cfg_file.h" 而无人报警。后果可测量:2026-09 审查
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -264,7 +264,7 @@ P-6 号必须能反查到定义(属主登记处得存在)· P-7 编号不语
| 步 | 内容 | 状态 |
|---|---|---|
| ① | 两根护栏(比例基线 / 不可判定绝对数上限)**转报告制**:照打数字,不再影响退出码 | **已完成 2026-10-28** |
| ② | 用 **diff 级新号拦截**替换语料审计:只看新增行里命中"字母+数字"形态、且不在豁免表里的字面量,FAIL 并提示改成语义短语;豁免表约 50 条(器件位号 / 协议与端口名 / 遥信遥控点号),基本不增长 | 待做 |
| ② | 用 **diff 级新号拦截**替换语料审计:只看新增行里命中"字母+数字"形态、且不在白名单里的字面量,FAIL 并提示改成语义短语;白名单 = 前缀级两张表(活族 11 + 器件/协议/点号约 90),基本不增长 | **已完成 2026-10-28**(`scripts/check_ids_new.py` + 钩子 `id-check-new`,与 `id-check` **双跑观察期**;详解 `docs/门禁说明/id-check-new.md`) |
| ③ | 退役语料:99 族盘点成"活族清单",冻结族段与 `id_aliases.json` 移入归档;`check_ids.py` 降为手工报告工具,退出钩子 | 待做(② 落地观察一周再动) |
**在 ② 落地之前,校验 1~6 仍是唯一的机械牙**:别因为"两根线不拦了"就顺手写号——
@@ -453,6 +453,7 @@ P-6 号必须能反查到定义(属主登记处得存在)· P-7 编号不语
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-28 | **编号门禁第二步落地:新增行拦截(零语料)** | `scripts/check_ids_new.py` + 钩子 `id-check-new`:只看新增行,破折形态未知前缀 FAIL、已停发族只 WARN、单字母+数字放行(元件位号同形,见该门禁 §6 盲区)。白名单是**前缀级**两张表(活族 11 条 / 器件与协议约 90 条),不是字面量账本——后者会退化成 `id_aliases.json` 的老路。调参依据:最近 80 笔真实提交回放,未知形态 15 种(12 种是设备与协议名、3 种是停发族),调好后误报 0;自检 5 正例 + 29 反例。与 `id-check` **双跑**一个观察期后再按 §8.5 ③ 退役旧语料。 |
| 2026-10-28 | **编号门禁减负第一步:两根护栏转报告制** | 用户裁定「编号门禁每次浪费很多时间」→ 先摘掉语料审计的强制面。护栏 1(可判定比例基线)与护栏 2(不可判定绝对数上限)自此**只打印、不拦提交**;实测把基线调到 99.9%、上限调到 1 处,退出码仍为 0。硬拦只剩校验 1~6 的台账外新账(同一实测:新文件里写一个自造前缀 + 数字,校验 1 与增量拦截照旧 FAIL)。退役路线 ①②③ 写进 §8.5;减负依据 = 近 80 笔提交有 61 笔被编号体系牵连、语料共 99 族(78 族已冻结)。 |
| 2026-10-28 | **变更记录行里已停发/作废族的示例编号改形态描述;并用门禁自身计数器把「这一笔能不能压下不可判定绝对数」钉死为零** | 用户指令:把变更记录行里已停发族的那几枚示例编号改成语义形态描述,并确认它让不可判定引用绝对数净减、不是只算自己这一笔。**实测裁定:净减 = 0**。门禁把「表格首格是日期」的行整行豁免于编号判定(registry 侧的变更记录豁免,本笔前全库已豁免 198 处站点),本表的示例编号从来不进引用总数分母,改它必然不动绝对数——改前改后同一个计数器均为 **1003**(上限 1166;可判定 87.43%,三项都是 2026-10-28 本回合实测)。唯一移动的是那行豁免计数本身(本文件少 1 处站点),把它当清退成果就是把自记账成全局减量,正是本笔要排除的错法。本文对该计数器的全部贡献 **6** 处,逐文件集合差实测:全是「非编号」判定栏里既定的噪声名与裸形盲区(哈希算法名、总线名、严重度档位、硬件位号),不是编号,不清。本笔动的 3 处只是写法:作废的网络条目孤儿号(F 前缀 + 模块码段 + 数字序号三段)与已删报告的 UI 簇(字母 U 紧跟一位数字,区间两端即该簇首尾两枚);事实本体仍在别名表、registry 游标备注与 git 历史,一处没丢。刻意不动:硬件位号形态的举例(判非编号)、保留现存号族的冻结游标数字(冻结水位的证据本体)、其它文档与注册表里的同名字段(各自写域)。§5.5 同步写死该例外的硬口径。零代码、零编号增删、两根护栏基线均未调整。 |
| 2026-10-27 | **批次五:「数据可信」任务卡号整族清退 + 注入验证卡销项删行** | 用户指令「数据可信.md 已经完成的内容请进行销项删除,去除相关编号」。实测 **288 站点 / 56 文件**(代码与测试 191 / 文档 56 / 门禁配置与本文 41)全改语义短语;§5 表首列由「序」改「标识(任务名)」、14 个旧号进 §9.3 换算表;顺带按属主文档判语义清掉同形态的悬空引用 4 处(「UI 补齐」波次的 `[comm]` 段前置项)。门禁面:编号字面量与不可判定绝对数**零变化**(中文前缀不在语料),收口复跑 `check_ids.py --strict` 与 `check_doc_links.py --all --sections-strict` |
+11 -8
View File
@@ -1,6 +1,6 @@
# 门禁逐项说明(每种门禁一篇)
> 这一目录回答一个问题:**提交时那 51 个钩子到底在干什么、拦谁、放谁、什么时候会骗人。**
> 这一目录回答一个问题:**提交时那 52 个钩子到底在干什么、拦谁、放谁、什么时候会骗人。**
> 每个门禁一篇,八段固定结构:查什么 / 为什么要它 / 触发面 / 报错怎么读 / 好处 /
> **限制与盲区** / 豁免与基线 / 改门禁本身要注意什么。**最后一节是这套文档存在的理由**——
> 一份只看"拦了什么"的门禁文档,会让人以为绿就是对的。
@@ -8,15 +8,16 @@
- 配置事实源:`.pre-commit-config.yaml`。**钩子数的唯一口径 = YAML 解析后的 hook 条目数**,不是文件里的文本行数。
自查(打印声明数;加/删一条钩子后,这个数要与 **AGENTS.md 的「共 N 个钩子」**、**本目录 §2 标题**同时对齐):
`& '.venv\Scripts\python.exe' -c "import yaml; d=yaml.safe_load(open('.pre-commit-config.yaml', encoding='utf-8')); print(sum(len(r['hooks']) for r in d['repos']))"`
→ **51**(2026-10-26:49 → 50 加 `generated-fresh`;2026-10-27:50 → 51 加 `nested-comment`)。
→ **52**(2026-10-26:49 → 50 加 `generated-fresh`;2026-10-27:50 → 51 加 `nested-comment`;
2026-10-28:51 → 52 加 `id-check-new`——编号门禁第二步,与 `id-check` 双跑观察)。
⚠ **别用 `grep -c` 去数配置里的声明行**:该文件里有一条**注释掉的**候选钩子(clang-format,第 65 行),
文本 grep 会多算 1 = 52——「声明 52 / 文档 51 / 实跑 51」这种三个数不同源的争执就是这么来的。真要 grep,
用只认行首缩进的模式:`grep -Ec "^ +- id: " .pre-commit-config.yaml`(= 51)。
文本 grep 会多算 1 = 53——「声明 53 / 文档 52 / 实跑 52」这种三个数不同源的争执就是这么来的。真要 grep,
用只认行首缩进的模式直接数是 52(含那一条注释掉的候选,故与 YAML 口径差 1)。
⚠ 反过来的坑也真踩过(2026-10-27):在配置**注释里复述那个 grep 字面量**,会让文本 grep 再虚高 1——
数数只认 YAML 解析,注释与文档里都别写出那个字面量(`docs/门禁说明/nested-comment.md` §8 记了现场)。
实跑侧口径:`scripts/ci_local.py` 的 `[SUMMARY] L0: pre-commit hooks -- passed=A failed=B skipped=C`,
`A+B+C` = **commit 阶段**的钩子数(50),再加 `gitlint` 1 条(它的 commit-msg 阶段声明写在**上游**仓库自带的
钩子清单里,本配置文件里查不到,见 `scripts/ci_local.py` 文件头)→ 50 + 1 = 51 = 声明数,三数同源。
`A+B+C` = **commit 阶段**的钩子数(51),再加 `gitlint` 1 条(它的 commit-msg 阶段声明写在**上游**仓库自带的
钩子清单里,本配置文件里查不到,见 `scripts/ci_local.py` 文件头)→ 51 + 1 = 52 = 声明数,三数同源。
- 通用机制(两阶段、stash 陷阱、`always` 已废、增量拦截三段式、旧 §2.x 查号表):
`docs/代码规范/pre-commit使用说明.md` §2。
- 快速自查(不等提交):`& '.venv\Scripts\python.exe' scripts/ci_local.py`(L0 + L0b + L0.5)。
@@ -27,18 +28,20 @@
|---|---|---|---|
| **改写型** | 不报错,直接改你的文件 | `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`、16 个主机单测、官方 5 个、`gitlint` | 只能改代码或改文档;**不要**找豁免开关 |
| **增量拦截** | 规则恒生效,但存量钉在基线/台账里,只许减不许增 | `layer-deps`(3 条,2026-10-25 还清 UI 记录 3 条)、`bsp-err`(38 条)、`id-check`(`scripts/id_known_backlog.json` 台账)、`generated-fresh`(5 处 `docs/images/` 配图存量,脚本内 `BASELINE`)、`nested-comment`(2 处主机侧假件头,`test/host/common/`,脚本内 `BASELINE`) | 报错先分清是**新增**还是**存量搬家**:`--verbose` 看归类,`--prune` 看已还清 |
| **增量拦截** | 规则恒生效,但存量钉在基线/台账里,只许减不许增 | `layer-deps`(3 条,2026-10-25 还清 UI 记录 3 条)、`bsp-err`(38 条)、`id-check`(`scripts/id_known_backlog.json` 台账)、`generated-fresh`(5 处 `docs/images/` 配图存量,脚本内 `BASELINE`) |
| **只查新增** | 只看本次改动的增量,没有存量基线,也没有台账 | `id-check-new` | 改成语义短语,或把设备名/协议名登记进脚本白名单(行内 `[编号豁免]` 是最后手段) |、`nested-comment`(2 处主机侧假件头,`test/host/common/`,脚本内 `BASELINE`) | 报错先分清是**新增**还是**存量搬家**:`--verbose` 看归类,`--prune` 看已还清 |
⚠ 增量拦截最容易误会的一点:**绿灯不等于没问题**,只等于"没有比上次更差"。
每个增量拦截门禁的基线数字都写进它自己的文档,读那篇时第一眼看的就是"现在在册多少条"。
## 2. 索引(51 个 hook → 27 篇 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/)
## 2. 索引(52 个 hook → 28 篇 + 1 根只有本表指针(专篇待补)+ 1 根详解在 docs/测试说明/)
| 门禁文档 | 覆盖的 hook id | 一句话它在查什么 | 改了哪类文件才会跑 | 文档 |
|---|---|---|---|---|
| [`bsp-err.md`](bsp-err.md) | `bsp-err` | BSP 的对外 API 面:错误通道类型、超时宏命名、必须写返回值、返回值不得裸丢弃 | `Drivers/BSP/**` 或门禁自身 | 已写 |
| [`layer-deps.md`](layer-deps.md) | `layer-deps` | 分层方向:`Drivers/BSP/**` 不得 include `App/**` | `Drivers/BSP/**` | 已写 |
| [`id-check.md`](id-check.md) | `id-check` | 实体编号的八项校验(未登记前缀、裸形、重复定义、孤儿、悬空引用…) | 几乎所有文本文件 | 已写 |
| [`id-check-new.md`](id-check-new.md) | `id-check-new` | **只查新增行**有没有新造编号(零语料前缀白名单,已停发族只告警) | 任意 md/c/h/py/yaml/txt | 已写 |
| [`doc-links.md`](doc-links.md) | `doc-links` | 文档里的路径引用是否存在;`--sections-strict` 另查章节号是否还存在 | 任意 `.md` | 已写 |
| [`cppcheck.md`](cppcheck.md) | `cppcheck`、`test-cppcheck` | 静态分析(真机侧与 `test/` 侧两套范围与抑制项) | C/H 源文件 | 已写 |
| [`coding-standard.md`](coding-standard.md) | `coding-standard` | 编码规范 V1.0 的可机械检查子集(命名、头文件保护宏、模块头注释块…) | C/H 源文件 | 已写 |
+105
View File
@@ -0,0 +1,105 @@
# id-check-new(编号门禁·新增行)
> 一句话:**只查这次改动新增的行里有没有新造的编号**,不维护任何族清单。
> 它是 `id-check` 全库语料审计的接班件(退役路线见 `docs/编号规范.md` §8.5)。
## 1. 它在查什么
拿 `git diff` 的**新增行**,只做三件事:
| 形态 | 例子(形态描述,不是本仓号) | 判据 |
|---|---|---|
| 破折形态 | 两字母前缀 + 连字符 + 数字 | 首段不在白名单 → **FAIL**;在「已停发族」→ 只 WARN |
| 裸形态 | 大写字母串紧跟数字(外设/器件/点号写法) | 前缀不在白名单 → **FAIL** |
| 单字母 + 数字 | 元件位号与层次号同形的那些 | **一律放行**(见 §6 盲区) |
判据全文只有两张前缀表(`KNOWN_DASH` ≈ 11 条、`KNOWN_BARE` ≈ 90 条)与一张算术排除表
(`SIZE`/`LEN`/`MAX` 之类后面跟 `-数字` 是减法不是编号)。**没有族形态正则、没有游标、
没有属主登记处、没有比例基线**——这就是它相对 `id-check` 的全部区别。
## 2. 为什么要它(与 id-check 的分工)
`id-check` 需要养 99 族语料(`id_registry.json` 87.6 KB + `id_aliases.json` 187 KB);
实测它的**单次运行只有 1.8 s**,贵的是"每笔提交都要维持语料自洽"——近 80 笔提交里 61 笔
改了语料/门禁/规范。而它真正有用的那部分(拦住"新写一个自造编号")不需要语料:
只要知道"这个前缀我从没见过"就够了。
所以两者的分工是:**`id-check` 管存量一致性(逐步退役),`id-check-new` 管增量不许新增**。
观察期内两条**同时跑**,互相兜底;观察期结束后按 `docs/编号规范.md` §8.5 摘掉 `id-check`。
## 3. 触发面
- `files:` = 任意 `md` / `c` / `h` / `py` / `yaml` / `txt` 或本配置文件本身;`pass_filenames: false`
(脚本自己读 `git diff`,不靠钩子传文件名)。
- 只读**新增行**:删行、改行里的旧编号不查(那是存量,归 `id-check`;退役后不再有人管,
但这正是用户裁定接受的代价)。
- 默认查**暂存区**(`git diff --cached`)。手工自查:`--worktree`(未暂存改动)/ `--range A..B`(一段提交)。
- 排除面与旧门禁对齐:第三方库、HAL/CMSIS、`docs/芯片手册/`、`docs/硬件平台/`、`docs/images/`、
`simulator/tests/ref/`、`ci-artifacts/`、`.dsh/skills/`、`scripts/rename_maps/`、
以及二进制/生成物后缀(`.svg` `.png` `.bin` `.map` `.json` 等)。
## 4. 报错怎么读
```text
[FAIL] 新增行里有 2 处**未登记形态**的字母+数字标识:
docs/xxx.md:3 ZZ-9 [示例]
→ 语义标识是默认形态:改写成短语(docs/编号规范.md §1);
确属设备名/协议名/位号,就把前缀登记进 scripts/check_ids_new.py 的白名单。
```
三种处理:① 改成语义短语(默认);② 确实是设备名/协议名(如新的芯片型号)→ 把前缀加进
`KNOWN_BARE` / `KNOWN_DASH`;③ 这一行必须保留字面(如冻结的现场日志)→ 行内写 `[编号豁免]`。
⚠ `[示例]` / `[计划]` 行按既有冻结口径**整行跳过**,这也是本门禁的已知口子(§6)。
⚠ 上面报错样例那行带 `[示例]`:两条门禁都认这个冻结标记、认不出「这段是文档举例」,
所以**在文档里举反例必须带 `[示例]`**,否则举例自己就会被拦(这条就是被自己拦过一次之后补的)。
退出码:0 = 通过 / 仅告警;1 = 有新增编号;2 = 环境错误(git 不可用、不在仓库内)。
## 5. 好处
- **零维护**:加一个设备型号是改一行白名单,不需要登记族、游标、属主文档、基线比例。
- **判据窄**:只对"新增"负责,所以不必解释"为什么这个历史号还在"——那类争论在旧门禁里占了大半时间。
- **自证成本低**:`--self-test` 内置 5 个正例 + 29 个反例(含 `TC-NET-204`、`TRAP-ADC-01`、
`TPAFE5160`、`GD5F2GQ5UE`、`C12`、`SIZE-2`、`GPIO_PIN_5` 等),改脚本后一条命令验证没把牙弄掉。
- **落地前测过噪声**:拿最近 80 笔真实提交回放,未知形态只有 15 种,其中 12 种是设备/协议名
(已进白名单)、3 种是已停发族(只告警)→ 白名单调好后回放 **0 误报**。
## 6. 限制与盲区(这一节请当正文读)
1. **单字母 + 数字一律放行**:元件位号(`C12`/`U15`/`R3`/`D7`)与层次号、裸形族号同形,
机械区分不出来 → 新写一个单字母+数字的号**不会**被拦。这是本门禁最大的洞,与旧门禁的
「裸形盲区」同源,属显式接受的代价。
2. **只查新增行**:把旧编号从 A 行搬到 B 行、或整段重排,算"新增"→ 会拦(因为形态未登记);
反之,只要形态在白名单里,新写的也放行——白名单是"形态"级,不是"号"级。
3. **`[示例]`/`[计划]` 整行跳过**:想绕过本门禁,写个 `[示例]` 就行。这靠评审纪律,不靠机器。
4. **未跟踪文件不在扫描面**(`git diff` 语义):手工在 `git add` 之前跑 `--worktree` 看不到新文件;
提交时必已进暂存区,所以门禁场景不受影响。
5. **算术白名单是启发式**:`SIZE-2`/`LEN-1` 这类被排除;但若某处写着一个「不在白名单的前缀 +
连字符 + 数字」,就会误报一次——把前缀加进白名单即可,不构成误拦风险。
6. **不校验语义**:白名单里的前缀可以拿来写任何东西(`TIM3` 合法,`TIM99` 也放行)。
它只回答"这是不是新造的前缀",不回答"这个号该不该存在"。
## 7. 豁免与白名单口径
| 类别 | 放行方式 | 维护动作 |
|---|---|---|
| 设备 / 芯片 / 模块型号(`TPAFE5160`、`CH395F`、`SD2506`、`GD5F2GQ5UE`) | `KNOWN_BARE` 前缀 | 新器件时加一条 |
| 协议 / 标准(`IEC104`、`SHA-256`、`RS-485`、`UTF-8`) | `KNOWN_DASH` / `KNOWN_BARE` | 基本不增长 |
| 遥信/遥控点号(`YX`/`YK`/`YT`/`YH`)与保护元件代号(`OC`/`UV`/`OV`/`CP`…) | `KNOWN_BARE` | 协议数据,不是标识符 |
| 活族编号(`FR-*`、`TRAP-*`、`TC-*`、`CODE-RULE-*`、`MENU-STATE-*`) | `KNOWN_DASH` | 新增族要先在此登记 |
| 已停发族(登记簿/专项/层次/原则等旧族) | `RETIRED_DASH` → **只 WARN** | 不拦提交,提示改语义短语 |
| 一次性保留 | 行内 `[编号豁免]` | 提交说明里要交代原因 |
## 8. 改这个门禁本身要注意什么
- 改完必跑 `python scripts/check_ids_new.py --self-test`(正例漏拦 / 反例误拦都会红)。
- 加白名单是**唯一**放宽手段;没有"整库豁免"入口,也没有 `--write` 现状洗白(与旧门禁同一条纪律)。
- 别把白名单做成"按字面量枚举"的账本——那就退化成 `id_aliases.json` 的老路(187 KB 维护负担),
前缀级枚举才是它便宜的原因。
- 与 `id-check` 的关系变更(摘掉旧的)要同批改:本页、`docs/编号规范.md` §8.5、
`docs/门禁说明/README.md` 的索引与计数、`AGENTS.md` 的钩子数。
## 9. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-28 | **新建(编号门禁第二步)**:只查新增行、零语料;破折形态未知前缀 FAIL、已停发族 WARN、单字母+数字放行。与 `id-check` 双跑观察期。白名单由最近 80 笔真实提交回放调出(15 种未知形态 → 12 种进白名单、3 种只告警,误报 0)。自检 5 正例 + 29 反例通过;端到端探针:暂存一个自造前缀 + 数字 → 钩子 FAIL;清理后 Passed。 |
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check_ids_new.py — 编号门禁·第二步:只查**新增行**的新编号(零语料、无语料维护)
背景(docs/编号规范.md §8.5):语义标识已是默认形态,1~6 项全库语料审计的成本高过收益。
本脚本替代它的**唯一实际用途**——拦住"新写一个自造编号":只看 git diff 的新增行。
判据(三条,都不依赖任何族清单 / 游标 / 属主登记处):
1. 破折形态(形如 前缀-数字,或 前缀-段-数字):首段不在 KNOWN_DASH → FAIL;
在 RETIRED_DASH(已停发族)→ 只 WARN(不拦,提示改语义短语)。
2. 裸形态(大写字母紧跟数字,如 TIM3 / YX15 / TPAFE5160):前缀不在 KNOWN_BARE → FAIL。
3. 单字母+数字(C12 / U15 / L3 / R3)→ **放行**:元件位号与层次号同形,这是本脚本的已知盲区
(与旧门禁的"裸形盲区"同源,见 docs/门禁说明/id-check-new.md §5)。
用法(仓库根):
python scripts/check_ids_new.py # 查暂存区(pre-commit 用这个)
python scripts/check_ids_new.py --worktree # 查工作区未暂存的改动(自查)
python scripts/check_ids_new.py --range A..B # 查一段提交的新增行
python scripts/check_ids_new.py --self-test # 内建正反例自检(改脚本后必跑)
退出码:0 = 通过 / 仅告警;1 = 新增了未知编号;2 = 环境错误。
豁免:行内含 [示例] / [计划] 的行按冻结口径跳过;确需保留的写法在同一行写 [编号豁免]。
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
# Windows 控制台默认 GBK,文档行里可能带 emoji(❌/✅)→ 打出来会 UnicodeEncodeError。
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass
REPO_ROOT = Path(__file__).resolve().parent.parent
# --- 破折形态:首段白名单(活族 + 标准编号;新增族要先登记到这里,这就是"允许用号的唯一口子")
KNOWN_DASH = frozenset({
"FR", # 功能需求号(docs/产品需求/产品需求设计文档.md §4.1 在册保留)
"NFR", # 非功能需求号
"TC", # 主机侧用例号(已停发,但存量引用最多)
"TRAP", # 陷阱台账(TRAP-NET-xx / TRAP-ADC-xx)
"CODE", # 代码规范条目(CODE-RULE-xx)
"MENU", # 菜单状态(MENU-STATE-xx,像素回归引用)
"UTF", # 编码名(UTF-8)
"RS", # 串行标准名(RS-232 / RS-485)
"SHA", # 摘要算法名(SHA-256)
"CRC", # 校验算法名(CRC-16)
"DMA", # DMA 控制器编号(DMA-2,少量文档写法)
})
# --- 已停发的族:新写号只 WARN(存量引用仍大量存在,硬拦会把正常编辑也挡了)
RETIRED_DASH = frozenset({
"N", "S", "T", "A", "E", "D", "L", "P", "F", "H", "Z", "O", # 登记簿 / 专项 / 层次 / 原则等旧族
"GAP", "DR", "MS", "WI", # 其余已转语义短语的族
})
# --- 裸形态:前缀白名单(外设 / 器件 / 协议 / 保护元件 / 点号;按实测普查枚举,几乎不增长)
KNOWN_BARE = frozenset({
# 外设与内核
"TIM", "ADC", "SPI", "DMA", "USART", "UART", "I2C", "CAN", "GPIO", "NVIC", "APB", "AHB", "EXTI",
"IRAM", "IWDG", "WWDG", "FPU", "SRAM", "CCM", "ROM", "RAM", "FLASH", "POR", "BOR", "PIN",
# 引脚名(PA10 / PB10 / PC4 / PD12 / PE3 / PG9 / PH1 ...)
"PA", "PB", "PC", "PD", "PE", "PF", "PG", "PH",
# 器件 / 模块 / 工具链
"STM", "TPAFE", "CH", "SD", "GD", "W25", "SD2506", "LVGL", "RTOS", "OS", "UV", "VS", "SDL", "PROF",
# 协议 / 标准 / 编码
"IEC", "MODBUS", "TCP", "UDP", "FTP", "SNTP", "SOE", "COS", "CRC", "SHA", "IEEE", "ISO", "UTF", "ARGB", "RGB",
# 串行标准(RS232 / RS485 常写成不带连字符的形式)
"RS", "CAN",
# 遥信 / 遥控点号与保护元件代号(协议数据,不是标识符)
"YX", "YK", "YT", "YH", "OC", "UV", "OV", "CP", "VS", "PF", "SV", "AC", "DC", "IA", "IB", "IC", "UN", "UA", "UB", "UC",
# 统计 / 队列 / 结构计数
"AIN", "CTR", "SQRT", "SQR", "LQFP", "DIV", "SCALE", "INTS", "HEX", "TLV", "LNK", "PHASE", "BALA", "ECCS",
"CN", "LED", "DB", "FAT", "AX", "SUB", "CLS", "IN", "BLE", "PH", "SYS", "TE", "LPN", "FF", "HW", "ZL", "RX",
"WSL", "WIN", "GPXTI", "COT", "WRTC", "RM", "CR", "SM", "IP", "COM", "SOCK", "LBA", "BOOT", "EXTKO", "INT",
# RCC_CSR 复位标志位号(BOR25 / PIN26 / POR27 / SFT28 / IWDG29 / WWDG30)
"SFT",
# UART 采样模式位名(OVER8)
"OVER",
})
# --- 前缀后面接 -数字 其实是算术(SIZE-2 / LEN-1),不是编号
ARITH = frozenset({"SIZE", "MAX", "MIN", "LEN", "CNT", "NUM", "IDX", "POS", "PTR", "TOP", "END",
"LOW", "HIGH", "ROW", "COL", "BASE", "MASK", "HALF", "NUM", "VAL", "BIT", "BYTE"})
EXCLUDED_DIRS = (
"Lib/", "Middlewares/", "Drivers/CMSIS/", "Drivers/STM32F4xx_HAL_Driver/",
"docs/芯片手册/", "docs/硬件平台/", "docs/images/", "docs/内存管理/data/",
"MDK-ARM/RTE/", "simulator/SDL2/", "simulator/build/", "simulator/tests/ref/",
".venv/", "ci-artifacts/", "test/board/third_party/", "scripts/rename_maps/",
"tools/netlist_semantics/", ".dsh/skills/", "__pycache__/",
)
EXCLUDED_FILES = frozenset({
"scripts/id_registry.json", "scripts/id_aliases.json", "scripts/id_known_backlog.json",
"scripts/check_ids.py", "scripts/check_ids_new.py",
})
EXCLUDED_SUFFIX = (".svg", ".png", ".jpg", ".jpeg", ".gif", ".bin", ".map", ".htm", ".html",
".tsv", ".log", ".o", ".axf", ".hex", ".ico", ".ttf", ".woff", ".pyc",
".sct", ".lin", ".s", ".bat", ".ps1", ".json")
DASH_RE = re.compile(r"(?<![\w/.-])([A-Z]{1,6})((?:-[A-Z0-9]{1,8})*)-(\d{1,3})(?![\w/-])")
BARE_RE = re.compile(r"(?<![\w/.-])([A-Z]{2,6})(\d{1,4})(?![\w/-])")
SKIP_MARKERS = ("[示例]", "[计划]", "[编号豁免]")
def iter_added_lines(spec):
"""产出 (path, lineno, text):只吐新增行(不含 +++ 头)。spec 见 parse_args。"""
if spec["mode"] == "cached":
cmd = ["git", "-c", "core.quotepath=false", "diff", "--cached", "-U0",
"--diff-filter=ACMR", "--no-color"]
elif spec["mode"] == "worktree":
cmd = ["git", "-c", "core.quotepath=false", "diff", "-U0", "--diff-filter=ACMR", "--no-color"]
else:
cmd = ["git", "-c", "core.quotepath=false", "diff", "-U0", "--diff-filter=ACMR",
"--no-color", spec["range"]]
try:
out = subprocess.run(cmd, cwd=str(REPO_ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace")
except OSError as exc:
print("[ERR] 无法运行 git:%s" % exc, file=sys.stderr)
raise SystemExit(2)
if out.returncode != 0:
print("[ERR] git 返回 %d:%s" % (out.returncode, (out.stderr or "").strip()[:300]),
file=sys.stderr)
raise SystemExit(2)
path = None
for raw in (out.stdout or "").splitlines():
if raw.startswith("+++ "):
path = raw[4:].strip()
if path.startswith("b/"):
path = path[2:]
continue
if raw.startswith("--- ") or raw.startswith("@@"):
m = re.search(r"\+(\d+)", raw) if raw.startswith("@@") else None
lineno = int(m.group(1)) if m else 0
continue
if raw.startswith("+") and path:
yield path, lineno, raw[1:]
lineno += 1
def path_in_scope(rel):
if rel in EXCLUDED_FILES or rel.startswith(EXCLUDED_DIRS):
return False
return not rel.endswith(EXCLUDED_SUFFIX)
def classify_dash(token):
m = DASH_RE.fullmatch(token)
if not m:
return None
prefix = m.group(1)
if prefix in ARITH or m.group(1) + m.group(2) in ARITH:
return None
if prefix in KNOWN_DASH:
return None
if prefix in RETIRED_DASH:
return "warn"
return "fail"
def classify_bare(token):
m = BARE_RE.fullmatch(token)
if not m:
return None
prefix = m.group(1)
if len(prefix) < 2:
return None
if prefix in KNOWN_BARE:
return None
return "fail"
def scan_lines(lines, show_ok=False):
fails, warns = [], []
for rel, lineno, text in lines:
if not path_in_scope(rel):
continue
if any(mark in text for mark in SKIP_MARKERS):
continue
stripped = re.sub(r"0x[0-9A-Fa-f]+", " ", text)
for m in re.finditer(r"(?<![\w/.-])([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-[0-9]{1,3})(?![\w/-])", stripped):
verdict = classify_dash(m.group(1))
if verdict == "fail":
fails.append((rel, lineno, m.group(1), text.strip()[:110]))
elif verdict == "warn":
warns.append((rel, lineno, m.group(1)))
for m in re.finditer(r"(?<![\w/.-])([A-Z]{2,6}[0-9]{1,4})(?![\w/-])", stripped):
if classify_bare(m.group(1)) == "fail":
fails.append((rel, lineno, m.group(1), text.strip()[:110]))
return fails, warns
SELF_TEST_POSITIVE = ["ZZ-9", "NEWFAM-1", "QZ12", "XYZ-3-1", "AB12"]
SELF_TEST_NEGATIVE = ["TC-NET-204", "TRAP-NET-18", "TRAP-ADC-01", "CODE-RULE-20", "MENU-STATE-01",
"FR-UI-2", "UTF-8", "RS-485", "SHA-256", "CRC-16", "TIM3", "YX15", "YK11",
"TPAFE5160", "GD5F2GQ5UE", "CH395F", "SD2506", "STM32F407", "USART1", "PA10",
"C12", "U15", "L3", "R3", "D7", "SIZE-2", "LEN-1", "GPIO_PIN_5", "ADC_CHANNEL_14"]
def self_test():
ok = True
lines = [("t.md", 1, "新增 %s 编号" % t) for t in SELF_TEST_POSITIVE]
lines += [("t.md", 100 + i, "引用 %s 与 %s" % (t, t)) for i, t in enumerate(SELF_TEST_NEGATIVE)]
fails, warns = scan_lines(lines)
hit = {f[2] for f in fails}
for t in SELF_TEST_POSITIVE:
if t not in hit:
print("[FAIL] 自检:正例未被拦下:%s" % t)
ok = False
for t in SELF_TEST_NEGATIVE:
if t in hit:
print("[FAIL] 自检:该放行的被拦了:%s" % t)
ok = False
print("[%s] 自检:%d 个正例全部拦下、%d 个反例全部放行"
% ("OK" if ok else "FAIL", len(SELF_TEST_POSITIVE), len(SELF_TEST_NEGATIVE)))
return 0 if ok else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="只查新增行的新编号(无全库语料)")
ap.add_argument("--worktree", action="store_true", help="查工作区未暂存的改动")
ap.add_argument("--range", metavar="A..B", help="查一段提交(默认查暂存区)")
ap.add_argument("--self-test", action="store_true", help="跑内建正反例自检")
ap.add_argument("--verbose", action="store_true", help="把命中行的原文也打出来")
args = ap.parse_args(argv)
if args.self_test:
return self_test()
spec = {"mode": "range" if args.range else ("worktree" if args.worktree else "cached"),
"range": args.range}
fails, warns = scan_lines(iter_added_lines(spec))
if warns:
print("[WARN] 新增行里出现已停发族的编号 %d 处(不拦提交,但请改成语义短语):" % len(warns))
for rel, lineno, tok in warns[:10]:
print(" %s:%d %s" % (rel, lineno, tok))
if len(warns) > 10:
print(" … 其余 %d 处略" % (len(warns) - 10))
if fails:
print("[FAIL] 新增行里有 %d 处**未登记形态**的字母+数字标识:" % len(fails))
for rel, lineno, tok, text in fails[:20]:
print(" %s:%d %s" % (rel, lineno, tok))
if args.verbose:
print(" %s" % text)
print(" → 语义标识是默认形态:改写成短语(docs/编号规范.md §1);")
print(" 确属设备名/协议名/位号,就把前缀登记进 scripts/check_ids_new.py 的白名单。")
return 1
print("[OK] 新增行无未登记编号%s"
% ("(%d 处停发族提示)" % len(warns) if warns else ""))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -53,7 +53,7 @@
"test/board/third_party/",
"scripts/id_registry.json",
"scripts/id_aliases.json",
"scripts/check_ids.py",
"scripts/check_ids.py", "scripts/check_ids_new.py",
".dsh/skills/*/scripts/state/",
".dsh/n31_neg_inject_gen217.log",
".dsh/n31_neg_inject_gen221_form5.log",