Files
STM32F4-Base/scripts/check_ids_new.py
T
wandering1 a5832788e9 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 文档)。
2026-10-28 12:27:57 +08:00

256 lines
12 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 -*-
"""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())