Files
STM32F4-Base/scripts/check_ids.py
T
wandering1 bda7ab2b50 refactor(编号): 16 个测试模块码单词化 + 门禁识别面连带放宽
批 2(用户授权改名 + 「新名要一眼看懂」裁定)
- 16 个有歧义模块码单词化,共 1,146 处随 scripts/rename_ids.py 改写:
  AI→ADCIN、AH→ADCHEALTH、PS→PROTSET、PE→PROTELEM、PH→PHASOR、
  TS→TIMESYNC、VL→VOLLOCK、NP→NANDPORT、CFG→CONFIG、CLK→SYSCLK、
  WD→WATCHDOG、STO→STORAGE、SH→SHA256、FA→FTPAUTH、FR→FREQ、104→IEC104
- 不动 NET/GD5F/RTC/SOE/UI/ADC/LOG/RS485/TOOL(已是单词、芯片名或业界通用词,
  改了纯属 churn)
- 别名按全局登记:这批字面量已完全限定(TC-<码>-<nnn>),不存在裸形多义
- 映射表存档 scripts/rename_maps/batch2.json 作审计轨迹;§6 模块码表同步

连带修门禁(关键教训)
- 模块码涨到 9 位(ADCHEALTH/WATCHDOG)后,候选正则与族正则的 {0,4} 上界
  会让改名后的号**整批从门禁眼里消失**:实测编号字面量从 8271 掉到 7315、
  悬空假降 490 处。已放宽到 {0,9} 并写进 §6 注记
- 结论:改名批次必须连带复核识别面,只改数据不改判据 = 静默放行;
  这次是靠「可判定比例跌破基线即失败」那条线拦下来的

现状与遗留
- 校验 1 未登记前缀 0 处;可判定比例 82.71%(基线 82.6%,只涨不跌成立)
- 遗留:7 个模块的「单元测试说明.md」仍缺(PROTSET 116、NANDPORT 109、
  PROTELEM 65、FREQ 48、FTPAUTH 45、SHA256 25、SOE 14 处悬空)——改名不解决
  缺文档,处置方式待议
- 遗留:T-2/T-3/T-4/T-6 历史任务号悬空(别名表未收全,属本工具批次漏项)
- 未改任何 C/H 可编译语义(仅注释与宏名内的编号),故不需 @build
2026-10-21 09:53:28 +08:00

658 lines
34 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.py — 编号门禁(docs/编号规范.md §8 的 8 项校验)。
两份配置(数据源 = 规范 §4 编号空间总表,逐项对应,不凭印象):
scripts/id_registry.json 前缀族 → {形态正则, 属主登记处, 是否冻结, 游标, 作用域, 级别}
scripts/id_aliases.json 旧字面量 → 现行字面量(照搬 scripts/section_aliases.json 的成功范式)
8 项校验(级别按规范 §8 表):
1 出现未登记前缀(自造编号) 错误
2 形态不合规:裸形 S3 / 空格形 Trap 07 / 中文形 陷阱 1 /
补零错 / 档位词带后缀 P1-8 / 大写后缀 / 缺模块码 错误
3 引用查无定义且不在别名表(悬空引用) 错误
4 同一字面量被两个属主重复定义(N-16 类事故) 错误
5 冻结前缀出现游标之外的新号 错误
6 文档内编号引用未紧邻文档名 错误
7 定义了但全库无引用(孤儿条目) 告警
8 打印「编号引用可判定比例」 每次运行,只许涨不许跌
「定义」的机械判据:**字面量出现在该族的属主登记处文档里**(不依赖表格行首这类易漂移的
形态)。因此 §5.5 的「销项 = 删行 + 清全库引用」一旦只做一半,校验 3 立刻报——这正是
§1.2 里 N-31/N-32 撞号与悬空的成因。
排除(规范 §8 + §4.4):Lib/、Middlewares/、HAL/CMSIS、docs/芯片手册/、docs/硬件平台/
(元器件位号 U1/C12/R3/L5/D7 与编号字面同形,是 B1/C1/L1 撞形的根源,必须整体排除)、
构建产物。裸形盲区显式登记在 id_registry.json 的 `unmanaged_bare`,不是「检查通过」。
上线节奏(§8):默认 **warn-only**(退出码 0);`--strict` 才让校验 1~6 拦提交。
第 8 项的比例跌破基线**任何模式都失败**(它就是防回退的那根线)。
用法(仓库根):
& '.venv\\Scripts\\python.exe' scripts/check_ids.py # warn-only 全库
& '.venv\\Scripts\\python.exe' scripts/check_ids.py --strict # 错误拦提交
& '.venv\\Scripts\\python.exe' scripts/check_ids.py --top 30 --verbose # 多打 + 修法
& '.venv\\Scripts\\python.exe' scripts/check_ids.py --update-baseline # 抬升第 8 项基线
& '.venv\\Scripts\\python.exe' scripts/check_ids.py --only 1,3,5 # 只跑部分校验
退出码:0 = 通过 / 仅告警;1 = 错误(--strict)或比例跌破基线;2 = 环境错误。
"""
import argparse
import fnmatch
import json
import math
import os
import pathlib
import re
import subprocess
import sys
from collections import Counter, defaultdict
# 控制台编码兜底:pre-commit 钩子环境的代码页是 936/GBK,打印中文或箭头会抛
# UnicodeEncodeError → 钩子带 traceback 以退出码 1 死亡(同 check_doc_links.py 的教训)。
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
REGISTRY_FILE = REPO_ROOT / "scripts" / "id_registry.json"
ALIASES_FILE = REPO_ROOT / "scripts" / "id_aliases.json"
TRACE_SCRIPT = REPO_ROOT / "scripts" / "check_test_traceability.py"
# 候选池:任何「1~4 位大写字母 + 连字符 + 数字」先进来,再判是否登记过。
# 三段式(TC-NET-1501 / TRAP-NET-18)单独一条——中段可能含数字(GD5F),
# 只用两段式正则会截出 "NET-1501" 这种假前缀。
CAND_MOD = re.compile(r"(?<![\w./\\-])([A-Z][A-Z0-9]{0,3})-([A-Z][A-Z0-9]{0,9})-(\d{1,4}[A-Za-z]?)(?![\w-])")
CAND_HYPH = re.compile(r"(?<![\w./\\-])([A-Z][A-Z0-9]{0,3})-(\d{1,4}[A-Za-z]?)(?![\w-])")
CAND_BARE = re.compile(r"(?<![\w.\-/])([A-Z])(?:\.(\d))?(1[0-9]|[0-9])(?![\w.\-])")
# 紧邻窗口(与 check_doc_links.py 的 SEC_MAX_GAP / SEC_GAP_CHARS 同口径)
ADJ_WINDOW = 60
# 第 8 项的容差(百分点):比例的分母随"这一批改了几篇文档"漂移,0.1pp 以内不算回退
RATIO_TOL = 0.1
def die(msg):
print("[ERR] " + msg, file=sys.stderr)
return 2
def load_json(path, what):
if not path.is_file():
raise SystemExit(die("缺少 %s(%s)" % (what, path.relative_to(REPO_ROOT))))
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise SystemExit(die("无法解析 %s:%s" % (path.relative_to(REPO_ROOT), exc)))
class Finding(object):
__slots__ = ("code", "severity", "rel", "lineno", "literal", "msg", "hint")
def __init__(self, code, severity, rel, lineno, literal, msg, hint=""):
self.code = code
self.severity = severity
self.rel = rel
self.lineno = lineno
self.literal = literal
self.msg = msg
self.hint = hint
def alias_hit(ck, literal, rel):
"""别名命中判定:带 `scope` 的条目只在所列路径内生效。
为什么要限定作用域——同一旧字面量可能同时是**两个族的旧号**:实测 `S3` 在
scripts/check_coding_standard.py 与 docs/代码规范/ 下是「代码规范条款」旧号,
在 simulator/tests 与 docs/UI设计/ 下是「UI 像素回归状态」旧号。认成全局别名就会把
另一族的真悬空引用洗成「已解析」——那等于用工具复制 §1.2 记的 N-31/N-32 撞号事故。
"""
for ent in ck.alias_map.get(literal, []):
scope = ent.get("scope")
if not scope or any(fnmatch.fnmatch(rel, g) for g in scope):
return True
return False
class Checker(object):
def __init__(self, registry, aliases, verbose=False):
self.reg = registry
self.alias_map = {}
for lit, ent in ((aliases or {}).get("aliases", {})).items():
# 同一旧字面量可能有**多条 scoped 别名**(实测 `S3` 既是被改名的代码规范条款、
# 又是被改名的 UI 像素状态)→ 统一成列表,命中判定时按 scope 选条
self.alias_map[lit] = ent if isinstance(ent, list) else [ent]
self.verbose = verbose
self.excl = tuple(registry.get("excluded_paths", []))
self.excl_suffix = tuple(registry.get("excluded_suffixes", []))
self.owner_excl = list(registry.get("owner_docs_exclude", []))
self.noise = [re.compile(p) for p in registry.get("noise_literals", [])]
self.spec_docs = set(registry.get("spec_docs", []))
self.bare_letters = set(registry.get("bare_check_letters", []))
self.bare_forbidden = registry.get("bare_forbidden", {})
self.bare_exempt_files = set(registry.get("bare_exempt_files", []))
self.malformed = [(m["name"], re.compile(m["pattern"]), m["severity"], m["why"],
bool(m.get("docs_only")))
for m in registry.get("malformed", [])]
self.families = []
for fam in registry["families"]:
f = dict(fam)
f["_re"] = re.compile(fam["pattern"])
f["_full"] = re.compile("^(?:%s)$" % fam["pattern"])
f["_owners"] = self._expand_owners(fam)
f["_max"] = self._cursor_max(fam)
self.families.append(f)
# 长形态优先匹配:TC-UPPER 要先于 TC,避免大写后缀被当成合规
self.families.sort(key=lambda x: (-len(x["pattern"]), x["name"]))
self.findings = []
self.stats = Counter()
self.form_fired = set()
self.refs = {} # literal -> set(rel),属主文档之外的引用处
self.owner_tokens = defaultdict(set) # family -> 属主文档里出现的字面量(=定义集)
self.file_cache = {}
self.owner_docs = set()
# ---------- 属主登记处展开 ----------
def _expand_owners(self, fam):
paths = list(fam.get("owner_docs", []))
for g in fam.get("owner_docs_glob", []):
for hit in REPO_ROOT.glob(g):
if hit.is_file():
paths.append(hit.relative_to(REPO_ROOT).as_posix())
# 属主文档本身不得落在排除目录(位号/芯片手册/生成物不算属主)
# 另:模板规范类文档里的 `DP-01` / `TC-XXX` 是**写法示例**,不是条目定义
out = [p for p in paths if not p.startswith(self.excl)]
for e in self.owner_excl:
out = [p for p in out if p != e]
return sorted(set(out))
def _cursor_max(self, fam):
cur = fam.get("cursor")
if not cur:
return None
m = re.search(r"([0-9]+)\s*$", str(cur))
return int(m.group(1)) if m else None
# ---------- 文件枚举(以 git 索引为准,生成物天然挡在外面)----------
def iter_files(self):
try:
out = subprocess.run(["git", "ls-files", "-z"], cwd=str(REPO_ROOT),
capture_output=True, check=True).stdout
files = out.decode("utf-8", "replace").split("\0")
except (OSError, subprocess.CalledProcessError):
files = [p.relative_to(REPO_ROOT).as_posix() for p in REPO_ROOT.rglob("*")
if p.is_file()]
for f in files:
if not f or f.startswith(self.excl) or f.endswith(self.excl_suffix):
continue
if "/__pycache__/" in ("/" + f) or f.endswith(".pyc"):
continue
if (REPO_ROOT / f).is_file():
yield f
def read(self, rel):
if rel not in self.file_cache:
try:
self.file_cache[rel] = (REPO_ROOT / rel).read_text(
encoding="utf-8", errors="replace")
except OSError as exc:
print("[WARN] 读取失败 %s:%s" % (rel, exc), file=sys.stderr)
self.file_cache[rel] = ""
return self.file_cache[rel]
# ---------- 主流程(两遍:先建定义集,再判引用)----------
def run(self):
rels = list(self.iter_files())
if not rels:
return die("一个文件都没扫到(git ls-files 返回空?)")
self.stats["files"] = len(rels)
for rel in rels:
self.read(rel)
for fam in self.families:
for owner in fam["_owners"]:
self.owner_docs.add(owner)
self.owner_tokens[fam["name"]].update(
m.group(0) for m in fam["_re"].finditer(self.read(owner)))
self.defined_all = set()
for s in self.owner_tokens.values():
self.defined_all |= s
for rel in rels:
for i, line in enumerate(self.read(rel).splitlines(), 1):
self.scan_line(rel, i, line)
self.check_collisions()
self.check_orphans()
return 0
def _is_noise(self, literal):
return any(rx.match(literal) for rx in self.noise)
def scan_line(self, rel, lineno, line):
if "[示例]" in line or "[计划]" in line:
# 报错样例 / 尚未创建的条目:与 check_doc_links.py 共用同一套豁免约定(§5.2)
self.stats["skip_example"] += len(CAND_HYPH.findall(line))
self._scan_malformed(rel, lineno, line, skip=True)
return
spans = []
for m in CAND_MOD.finditer(line):
spans.append((m.start(), m.end(), m.group(0), True))
covered = [(a, b) for a, b, _, _ in spans]
for m in CAND_HYPH.finditer(line):
if any(a <= m.start() and m.end() <= b for a, b in covered):
continue
spans.append((m.start(), m.end(), m.group(0), True))
for m in CAND_BARE.finditer(line):
if m.group(1) not in self.bare_letters:
continue
if any(a <= m.start() and m.end() <= b for a, b in covered):
continue
lit = m.group(1) + (("." + m.group(2)) if m.group(2) else "") + m.group(3)
if "." in lit:
continue
spans.append((m.start(), m.end(), lit, False))
spans.sort()
seen_here = set()
for start, _end, literal, hyphenated in spans:
if (literal, start) in seen_here:
continue
seen_here.add((literal, start))
self.stats["refs_total"] += 1
fams = [f for f in self.families if f["_full"].match(literal)]
if not fams:
if self._is_noise(literal):
self.stats["noise_allowed"] += 1
continue
if not hyphenated:
# 裸形盲区(§1.2 同字母多义 / §4.4 位号同形):显式不判,见 unmanaged_bare
self.stats["skip_bare_unmanaged"] += 1
continue
if re.match(r"^P[0-3]-\d", literal):
self.add(2, "error", rel, lineno, literal,
"档位词带后缀:P0/P1/P2/P3 是严重度与优先级档位词,"
"永不带 -<数字>(§4.4)",
"只写 `P1`,严重度进表格列(§2 P-2)")
self.stats["undecidable"] += 1
else:
self.add(1, "error", rel, lineno, literal,
"未登记前缀:%s(§4.1 / §4.2 总表里没有这一族)" % literal,
"归到既有类别;确属新类别先改 docs/编号规范.md §4,再进 registry")
self.stats["undecidable"] += 1
continue
fam = self._choose_family(literal, fams, rel, line, start)
if fam["_owners"]:
self.stats["decidable"] += 1
else:
self.stats["undecidable"] += 1
self._check_form(rel, lineno, literal, fam)
self._check_frozen(rel, lineno, literal, fam)
self._check_dangling(rel, lineno, literal, fam)
self._check_adjacent(rel, lineno, literal, fam, line, start)
if rel not in fam["_owners"]:
self.refs.setdefault(literal, set()).add(rel)
self._scan_malformed(rel, lineno, line)
def _choose_family(self, literal, fams, rel, line, start):
"""同一字面量可能落进多族(登记簿 N-16 vs 报告 N-16):属主文档 > 紧邻属主 > 全局族。"""
if len(fams) == 1:
return fams[0]
for f in fams:
if rel in f["_owners"]:
return f
for f in fams:
if f.get("adjacent_required") and self._has_adjacent_cue(f, line, start):
return f
for f in fams:
if f.get("check_dangling") and f["_owners"]:
return f
for f in fams:
if f["scope"] == "global":
return f
return fams[0]
def _cues(self, fam):
cues = list(fam.get("adjacent_cues", []))
for o in fam.get("owner_docs", []):
stem = o.rsplit("/", 1)[-1]
cues += [stem, stem.rsplit(".", 1)[0]]
for o in fam["_owners"][:6]:
stem = o.rsplit("/", 1)[-1]
cues += [stem, stem.rsplit(".", 1)[0]]
out, seen = [], set()
for c in cues:
if c and len(c) >= 2 and c not in seen:
seen.add(c)
out.append(c)
return out
def _has_adjacent_cue(self, fam, line, start):
window = line[max(0, start - ADJ_WINDOW):start]
for c in self._cues(fam):
if c in window:
return True
return False
# ---------- 单项校验 ----------
def add(self, code, severity, rel, lineno, literal, msg, hint=""):
self.findings.append(Finding(code, severity, rel, lineno, literal, msg, hint))
self.stats["hit_%d" % code] += 1
if code == 2:
# 同一处的形态违规不再重复报悬空(同一个毛病报两次,会把 backlog 冲淡)
self.form_fired.add((rel, lineno, literal))
def _check_form(self, rel, lineno, literal, fam):
if rel in self.spec_docs:
# 规范正文**必须**引用非法形态才能说明什么叫非法(`Trap 07`、`P1-8`…),
# 在规范里判"形态不合规"等于让规范判死自己;其余文档照判。
return
# 裸形判定按**字面量本身**有无连字符——不能看族正则:`[0-4]` 这类字符类里
# 就带连字符,按族判断会把 S-REVIEW 误认成"连字符族"(实测踩到过)。
if "-" not in literal:
self._check_bare_form(rel, lineno, literal, fam)
digits = re.findall(r"([0-9]+)$", literal)
width = fam.get("digit_width")
if digits and width:
lo, hi = width
if not (lo <= len(digits[0]) <= hi):
self.add(2, "error", rel, lineno, literal,
"形态不合规:%s 的序号应为 %d~%d 位(§4.1 左补零,P-4)"
% (literal, lo, hi), "补零后引用,属主登记处一并改")
if fam.get("suffix") == "lower" and re.search(r"[A-Z]$", literal):
self.add(2, "error", rel, lineno, literal,
"形态不合规:%s 后缀必须是小写字母(§4.1)" % literal,
"改 %s%s" % (literal[:-1], literal[-1].lower()))
if fam.get("suffix") == "none" and re.search(r"[A-Za-z]$", literal):
pass
if fam.get("malformed"):
self.add(2, "error", rel, lineno, literal,
"形态不合规:%s —— %s(§4.1 / §6)" % (literal, fam["malformed"]),
"按规范形态改写")
def _check_bare_form(self, rel, lineno, literal, fam):
"""裸形:S3 之于 S-3(§2 P-4 禁止裸形)。两个条件同时成立才报,避免把
「历史裸形族在属主文档里被引用」也算成违规:
① 该裸形字面量**全库无任何属主登记处定义**(在案的历史裸形引用不算违规,
它们由校验 3 以告警形式处理);
② 它的连字符孪生形(S-3)确有在册定义——说明作者是在写一个登记簿条目,
只是漏了连字符。也不在裸形豁免文件里。"""
m = re.match(r"^([A-Z])([0-9]{1,2})$", literal)
if not m:
return
letter = m.group(1)
if letter not in self.bare_forbidden or rel in self.bare_exempt_files:
return
hyph = "%s-%s" % (letter, m.group(2))
hyph_defined = any(hyph in self.owner_tokens[f["name"]] for f in self.families)
if hyph_defined and literal not in self.defined_all:
self.add(2, "error", rel, lineno, literal,
"形态不合规:裸形 %s 是 %s 的非法写法(§2 P-4;%s)"
% (literal, hyph, self.bare_forbidden[letter]),
"写全 %s,或改语义标识(§3.3 / P-8)" % hyph)
def _check_frozen(self, rel, lineno, literal, fam):
if not fam.get("frozen") or fam["_max"] is None:
return
if fam.get("cursor_owner_only") and rel not in fam["_owners"]:
# 裸形族(B5/C6/D7/M1/U3/R3/V5…)与位号、档位词、其它文档的同名清单大面积同形
# (§1.2 五义/四义)→ 越游标只在属主登记处文档内判,文档外不判(见 unmanaged_bare)
return
nums = re.findall(r"([0-9]+)", literal.rsplit("-", 1)[-1])
if not nums:
return
if int(nums[-1]) > fam["_max"]:
self.add(5, "error", rel, lineno, literal,
"冻结前缀越游标:%s > %s(%s;§4.3 / §8 校验 5)"
% (literal, fam["cursor"], fam["title"]),
"该族已封闭,新条目改语义标识(§3.3);确需新号先改 docs/编号规范.md §4")
def _check_dangling(self, rel, lineno, literal, fam):
if not fam.get("check_dangling") or not fam["_owners"] or rel in fam["_owners"]:
return
if rel in self.spec_docs:
# 规范正文里的 `N-99`→`N-100`、`Trap 07` 这类是**写法示例**,不是引用
self.stats["skip_spec_examples"] += 1
return
if (rel, lineno, literal) in self.form_fired:
return
if literal in self.defined_all:
return
if alias_hit(self, literal, rel) or literal in set(fam.get("allow_missing_defs", [])):
self.stats["alias_hits"] += 1
return
sev = fam.get("dangling_outside_owner", "error") if rel not in fam["_owners"] else "error"
self.add(3, sev, rel, lineno, literal,
"悬空引用:%s 在属主登记处(%s)查无定义,也不在别名表里(§8 校验 3)"
% (literal, " / ".join(fam["_owners"][:2])),
"补定义行,或删引用(§5.5:销项 = 删行 + 清全库引用)")
def _check_adjacent(self, rel, lineno, literal, fam, line, start):
if not fam.get("adjacent_required") or rel in fam["_owners"] or rel in self.spec_docs:
return
if "-" not in literal:
# 只约束连字符形态的文档内编号:裸形(A2/B5/U3/D7)与位号、档位词大面积同形
# (§1.2 五义/四义清单),全库要求紧邻文档名属 §9 阶段 1-③ 的改写批次。
return
if self._has_adjacent_cue(fam, line, start):
return
owner = fam["owner_docs"][0] if fam.get("owner_docs") else (
fam["_owners"][0] if fam["_owners"] else "docs/<属主文档>.md")
self.add(6, "error", rel, lineno, literal,
"文档内编号未紧邻文档名:%s 属「%s」,跨文档引用必须写全属主(§5.2 / §8 校验 6)"
% (literal, fam["title"]),
"写成 `%s` %s(或 `%s NN`)" % (owner, literal, literal))
def _scan_malformed(self, rel, lineno, line, skip=False):
is_md = rel.endswith(".md")
if rel in self.spec_docs and not skip:
# 同上:规范正文就是靠引用 `Trap 07` / `陷阱 1` / `gen 181` 这些非法形态
# 才讲得清"什么算非法",在它自己头上判形态违规没有意义
return
for name, rx, sev, why, docs_only in self.malformed:
if (docs_only and not is_md) or (skip and name != "空格形 Trap"):
continue
for m in rx.finditer(line):
self.add(2, sev, rel, lineno, m.group(0).strip(),
"形态不合规:%s —— %s" % (name, why), "按 §7.1 迁移表改写")
self.stats["malformed_%s" % name] += 1
# ---------- 校验 4 / 7 ----------
# 定义位:字面量出现在**表格首格 / 标题行 / 列表项行首**——正文里"提到过"不算定义,
# 否则任何交叉引用都会被误判成重复定义(§8 校验 4 要抓的是"两处各建一条目")。
DEF_ANCHOR = re.compile(r"^\s*(?:\|\s*\*{0,2}|#{1,6}\s+|[-*]\s+\*{0,2})"
r"([A-Z][A-Z0-9]*(?:-[A-Za-z0-9]+)+)")
def check_collisions(self):
"""同一字面量被两个属主重复定义(N-16 类事故 / §1.2 的 Trap 02 跨文档同号不同义)。
规范 §4.3 已声明的同号不同义族对由 `collides_with` 登记,不计为事故。"""
allowed = set()
for fam in self.families:
cw = fam.get("collides_with")
if cw:
allowed.add(frozenset((fam["class"], cw)))
defs = defaultdict(list) # literal -> [(class, family_title, owner_doc)]
for fam in self.families:
for owner in fam["_owners"]:
for line in self.read(owner).splitlines():
m = self.DEF_ANCHOR.match(line)
if m and fam["_full"].match(m.group(1)):
defs[m.group(1)].append((fam["class"], fam["title"], owner))
for lit, hits in sorted(defs.items()):
classes = {c for c, _, _ in hits}
docs = {d for _, _, d in hits}
pairs = [(a, b) for a in classes for b in classes if a < b]
clash = [p for p in pairs if frozenset(p) not in allowed]
if clash:
where = sorted(docs)[:2]
self.add(4, "error", where[0], 0, lit,
"重复定义:%s 同时被 %s 两个属主登记处定义(%s;§8 校验 4,"
"N-16 类事故)" % (lit, " / ".join(sorted(classes)),
", ".join(d.rsplit("/", 1)[-1] for d in where)),
"保留单一属主;另一处改语义标识(§3.3)或引用紧邻文档名(§5.2)")
elif len(classes) == 1 and len(docs) >= 2:
self.add(4, "error", sorted(docs)[0], 0, lit,
"重复定义:%s 在同一族「%s」的 %d 篇属主文档里各定义一次(%s;"
"§8 校验 4,§1.2 的 Trap 02 跨文档同号不同义类事故)"
% (lit, hits[0][1], len(docs),
", ".join(sorted(d.rsplit("/", 1)[-1] for d in docs)[:3])),
"只在一处定义;另一处改引用,或按 §6 补模块码把两族拆开")
else:
self.stats["collision_allowed"] += 1
def check_orphans(self):
for fam in self.families:
if not fam.get("check_dangling") or not fam["_owners"]:
continue
for lit in sorted(self.owner_tokens[fam["name"]]):
outside = {r for r in self.refs.get(lit, set()) if r not in fam["_owners"]}
if not outside:
self.add(7, "warn", fam["_owners"][0], 0, lit,
"孤儿条目:%s 定义在 %s,全库无引用(§8 校验 7)"
% (lit, fam["_owners"][0]),
"确认已闭环可销项(§5.5),或补上引用")
# ---------- 报告 ----------
TITLES = {
1: "校验 1 未登记前缀(自造编号)",
2: "校验 2 形态不合规(裸形/空格形/中文形/补零/档位词带后缀)",
3: "校验 3 悬空引用(查无定义且不在别名表)",
4: "校验 4 重复定义(同一字面量两个属主)",
5: "校验 5 冻结前缀越游标",
6: "校验 6 文档内编号未紧邻文档名",
7: "校验 7 孤儿条目(定义了但无引用)",
}
def report(self, top, only):
by_code = defaultdict(list)
for f in self.findings:
by_code[f.code].append(f)
print("=== 编号门禁 check_ids.py(docs/编号规范.md §8)===")
print("扫描 %d 个文件;编号字面量 %d 处(豁免 [示例]/[计划] 行 %d 处、"
"非编号噪声 %d 处、裸形盲区 %d 处、命中别名表 %d 处)"
% (self.stats["files"], self.stats["refs_total"], self.stats["skip_example"],
self.stats["noise_allowed"], self.stats["skip_bare_unmanaged"],
self.stats["alias_hits"]))
print("已声明的同号不同义族对(§4.3):%d 个字面量,不计入校验 4"
% self.stats["collision_allowed"])
for code in sorted(self.TITLES):
if only and code not in only:
continue
items = [f for f in by_code.get(code, [])]
n_err = sum(1 for f in items if f.severity == "error")
n_warn = len(items) - n_err
print("\n[%s] %s:%d 处(错误 %d / 告警 %d)"
% ("WARN" if not n_err else "FAIL", self.TITLES[code],
len(items), n_err, n_warn))
for f in items[:top]:
loc = "%s:%d" % (f.rel, f.lineno) if f.lineno else f.rel
# 带上校验号 + 级别:--top 打出来的每一行都能独立追溯回规范 §8 的表
print(" [%d-%s] %s %s" % (f.code, "E" if f.severity == "error" else "W",
loc, f.msg))
if self.verbose and f.hint:
print(" → %s" % f.hint)
if len(items) > top:
print(" … 其余 %d 处略(--top N 多打;--verbose 带修法)" % (len(items) - top))
total = self.stats["refs_total"]
# errs 恒按**全部** findings 统计:--only 是"局部排查"用的,不能因为它少扫两类
# 就把门禁结论报成"无错误"(真踩过的坑:--only 1,3 让 --strict 误报绿)
errs = sum(1 for f in self.findings
if f.severity == "error" and f.code != 8)
if only:
errs = sum(1 for f in self.findings
if f.severity == "error" and f.code in only)
print("\n⚠ --only %s:本次只跑部分校验,**不构成门禁结论**"
% ",".join(str(c) for c in sorted(only)))
dec = self.stats["decidable"]
ratio = (100.0 * dec / total) if total else 100.0
base = self.reg.get("decidable_ratio_baseline")
print("\n[比例] 校验 8 编号引用可判定 %d/%d = %.2f%%(基线 %s,容差 %.1fpp)"
% (dec, total, ratio,
"未建档" if base is None else "%.1f%%" % base, RATIO_TOL))
print(" 可判定 = 字面量落进已登记前缀族**且该族属主登记处已存在**;"
"未登记前缀、形态违规、属主登记处还是 [计划] 的都算不可判定 → 只许涨不许跌")
return ratio, errs
def parse_only(s):
if not s:
return set()
return {int(x) for x in re.split(r"[,\s]+", s) if x.strip().isdigit()}
def main():
ap = argparse.ArgumentParser(
description="编号门禁:docs/编号规范.md §8 的 8 项校验(默认 warn-only)")
ap.add_argument("--strict", action="store_true",
help="校验 1~6 的错误改为拦提交(默认 warn-only,不拦)")
ap.add_argument("--verbose", action="store_true", help="每条附带修法")
ap.add_argument("--top", type=int, default=12, help="每类校验打印的条数(默认 12)")
ap.add_argument("--only", default="", help="只跑指定校验,如 --only 1,3,5")
ap.add_argument("--update-baseline", action="store_true",
help="把本次可判定比例写进 id_registry.json 作为基线(只许涨)")
ap.add_argument("--no-trace", action="store_true",
help="跳过顺带收编的 TC 双向追溯(check_test_traceability.py)")
args = ap.parse_args()
reg = load_json(REGISTRY_FILE, "编号注册表")
aliases = load_json(ALIASES_FILE, "编号别名表")
ck = Checker(reg, aliases, verbose=args.verbose)
rc = ck.run()
if rc:
return rc
ratio, errs = ck.report(args.top, parse_only(args.only))
if args.update_baseline:
base = reg.get("decidable_ratio_baseline")
if base is not None and ratio + RATIO_TOL < base:
print("[ERR] 拒绝下调基线:本次 %.2f%% < 基线 %.2f%%(§8 校验 8「只许涨不许跌」)"
% (ratio, base), file=sys.stderr)
return 1
# 基线**下取整到 0.1 个百分点**:小数点第 2 位随"这一批改了哪几篇文档"抖动,
# 用 round() 会把 82.34 进位成 82.3 再反过来把自己判死(实测踩过),故 floor。
reg["decidable_ratio_baseline"] = math.floor(ratio * 10) / 10.0
REGISTRY_FILE.write_text(json.dumps(reg, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8", newline="\n")
print("[OK] 基线已写进 scripts/id_registry.json:%.1f%%(本次实测 %.2f%%)"
% (reg["decidable_ratio_baseline"], ratio))
if not args.no_trace and TRACE_SCRIPT.is_file():
print("\n=== 校验 8 附带:TC 用例双向追溯(顺带收编 check_test_traceability.py)===")
try:
# 子进程的 stdout 在管道下默认按本地代码页(936/GBK)编码 → 必须显式压成
# UTF-8,否则这里按 utf-8 解出来全是乱码(实测踩到:[DOC-ONLY] 行打印成 mojibake)
env = dict(os.environ, PYTHONIOENCODING="utf-8")
p = subprocess.run([sys.executable, str(TRACE_SCRIPT)], cwd=str(REPO_ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace", env=env)
for ln in (p.stdout or "").splitlines():
if ln.startswith(("代码侧", "文档侧", "交集", "[DOC-ONLY]", "[CODE-ONLY]",
"[OK]", "[WARN]", "=== ")):
print(" " + ln)
if p.returncode == 2:
print("[WARN] 追溯脚本环境错误(退出码 2),不影响本门禁结论", file=sys.stderr)
except OSError as exc:
print("[WARN] 无法运行追溯脚本:%s" % exc, file=sys.stderr)
base = reg.get("decidable_ratio_baseline")
if base is not None and ratio + RATIO_TOL < base and not args.update_baseline:
print("\n[FAIL] 校验 8:可判定比例跌破基线 %.2f%% < %.2f%%(只许涨不许跌,容差 %.1fpp)"
% (ratio, base, RATIO_TOL))
return 1
if errs:
if args.strict:
print("\n[FAIL] 编号门禁:%d 处错误级问题(--strict 拦提交)" % errs)
return 1
print("\n[WARN] 编号 backlog:%d 处错误级问题,warn-only 不拦提交"
"(§8 上线节奏:清账后加 --strict)" % errs)
else:
print("\n[OK] 校验 1~6 无错误")
return 0
if __name__ == "__main__":
sys.exit(main())