Files
STM32F4-Base/scripts/check_ids_new.py
T
wandering1 1668842d61 chore(id-check): 退役全库编号语料审计(钩子 52 → 51)
第三步(用户裁定跳过观察期直接做)。第一步 bf2a5c21 把两根护栏转报告制,
第二步 a5832788 立起只查新增行的新门禁,本笔把旧的全库语料审计整体退役:
语义标识已是默认形态,这套 99 族语料不再有人需要维护。

移动(保留可复现性,不再进提交门禁):
- scripts/check_ids.py → scripts/archive/id_gate/check_ids.py
- id_registry.json(87.6 KB,99 族)/ id_aliases.json(187 KB)/ id_known_backlog.json
  一并归档;新写 archive README:为什么退役、现在用什么、怎么手工跑、归档纪律。
- 归档脚本改了自定位(仓库根往上 4 层、语料与脚本同目录)与一行退役横幅;
  从新路径实跑:811 文件 / 8,051 字面量,--strict rc=0。

钩子:删 id-check(52 → 51)。现役编号门禁只剩 id-check-new(只查新增行、零语料)。
声明数 51 = commit 阶段 50 + gitlint 1,三数已对齐(AGENTS / 门禁索引 / YAML)。

连带改动:
- rename_ids.py 的别名表路径指向归档(改名工具本身没退役);
- check_ids_new.py 排除面加 scripts/archive/(语料里全是编号字面量,天经地义);
- doc-links 跳过 .mnemon/ 与 ci-artifacts/(本地记忆状态与草稿,非项目文档)——
  语料搬家后这些目录里的旧路径引用会假红,它们本来就不该参与文档门禁;
- 全库 .md 里的旧路径引用改指 scripts/archive/id_gate/(含两份 skill 手册);
- id-check.md 加退役横幅并保留原文作留档;门禁索引把该行标成已退役;
- 编号规范 §8 改成「现行只有一道门禁」+ §8.1~§8.4 标为退役留档 + §8.5 ③ 置已完成;
  各加一条 2026-10-28 变更记录行。

验证(实测):YAML 口径 51 个钩子、id-check 不在其中;doc-links --all --sections-strict
通过(194 文档);mojibake / bom-guard / coding-standard / git diff --check 全 rc=0;
新门禁自检 5 正例 + 29 反例仍全过。
2026-10-28 12:38:38 +08:00

257 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__/",
"scripts/archive/", # 2026-10-28 退役的全库编号语料:里面全是编号字面量,天经地义
)
EXCLUDED_FILES = frozenset({
"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())