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:
+17
-2
@@ -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
-1
@@ -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
@@ -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 源文件 | 已写 |
|
||||
|
||||
@@ -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。 |
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user