20617a33f7
用户裁定(2026-10-29):「啥号都不发」= 任何族都不再新增编号, 含 §4.1 那五个原本"有脚本按字面量匹配"的存量族;既有号只读引用, 族里新增内容一律写语义行名。 规范本体: - §1 由「先问三句」改成「默认答案:不发号」,三问表换成场景对照表; - §4.1 由「允许用号的族」改成「存量族」,第四列改「既有号被谁读 / 新写怎么办」,逐行写明新写怎么做(TRAP 写语义行名、FR 用 §5 行名、 CODE-RULE / MENU-STATE 先问用户、P-n 只读存量页号); - 末框四条对照 + 唯一例外入口(确需机器按字面量匹配 → 先问用户); - 新增族 = 一律不许;批准后顺序:本文 §4.1 → 属主文档 → 才动 registry。 跨文件同步(12 份):门禁说明 id-check-new / id-check / host-unit-tests (把"校验 5/3 拦新号"这类现状叙述历史化)、两份 TRAP 记录的编号口径、 开机账本用例号说明、AGENTS 两处、id-family 技能与其 steps-alpha 模板、 归档 README 与两份归档脚本 docstring。 AGENTS 主题表那行原写「允许用号的 7 个族」,与规范的 5 行本就对不上, 旧计数一并消掉。 未做:pre-commit 的 default_stages 一行——用户对该项未置可否。 验证:py_compile 与 check_ids_new.py --self-test 均 rc=0;逐篇 check_doc_links.py --sections-strict rc=0(编号规范 70 处引用 / 68 可判定 / 无法判定 0);13 份改动文件表列数与字节体检相对基线零新增。
265 lines
13 KiB
Python
265 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""check_ids_new.py — 编号门禁·新增行(**已退役 2026-10-29,用户裁定**;本脚本降为手工报告工具)
|
||
|
||
退役口径:钩子已从 .pre-commit-config.yaml 摘除(YAML 解析条目 52 → 51),编号形态此后
|
||
**没有机器面**——靠 docs/编号规范.md §1 的默认答案(一律停发)+ 评审。留档说明 → docs/门禁说明/id-check-new.md。
|
||
本脚本与 check_ids.py(2026-10-28 退役的全库八项校验)同处一档:手工复查「这批改动 / 这段历史
|
||
里有没有野生编号」时才跑,跑不跑都不影响提交。
|
||
|
||
判据(三条,都不依赖任何族清单 / 游标 / 属主登记处):
|
||
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/archive/id_gate/check_ids_new.py # 查暂存区
|
||
python scripts/archive/id_gate/check_ids_new.py --worktree # 查工作区未暂存的改动
|
||
python scripts/archive/id_gate/check_ids_new.py --range A..B # 查一段提交的新增行
|
||
python scripts/archive/id_gate/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
|
||
|
||
# 归档位置比原位置深两级(scripts/archive/id_gate/)→ 上溯到仓库根要 parents[3]
|
||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
|
||
# --- 破折形态:首段白名单(活族 + 标准编号;新增族要先登记到这里,这就是"允许用号的唯一口子")
|
||
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",
|
||
# 录波段文件名:固件生成的 100 个号轮转(WAVE00 到 WAVE99 的 .BIN,App/algo/wave_store.c 拼名
|
||
# FTP 根下 /wave/ 目录;文档与日志里都要写这个字面量,2026-10-29 登记)
|
||
"WAVE",
|
||
})
|
||
|
||
# --- 前缀后面接 -数字 其实是算术(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_DIRS 的 scripts/archive/ 里,这行只是历史留笔)
|
||
})
|
||
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(" 确属设备名/协议名/位号,就改形态(写语义短语)——本门禁已于 2026-10-29 退役,")
|
||
print(" 不再是提交钩子;要再拿它当尺子就手工跑 scripts/archive/id_gate/check_ids_new.py。")
|
||
return 1
|
||
print("[OK] 新增行无未登记编号%s"
|
||
% ("(%d 处停发族提示)" % len(warns) if warns else ""))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|