1668842d61
第三步(用户裁定跳过观察期直接做)。第一步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 反例仍全过。
257 lines
12 KiB
Python
257 lines
12 KiB
Python
#!/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())
|