#!/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"(? 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["_def_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)) # 位号:硬件语料里的 R/C/D/J/X/U/T + 数字既不算编号、**也不进比例分母** # (约 4200 处位号若进 refs_total,会把分母灌水、掩盖真实变化) _rx = getattr(type(self), "_desig_rx", None) if _rx is None: _rx = type(self)._desig_rx = re.compile(r"(?:[RCDJXUT][0-9]{1,3}|[A-Z]{2,3}[0-9]{1,2}(?:-[0-9]{1,2})?)") type(self)._desig_paths = ("docs/硬件平台/", "docs/芯片手册/") if rel.startswith(type(self)._desig_paths) and _rx.fullmatch(literal): self.stats["designator_skipped"] = self.stats.get("designator_skipped", 0) + 1 continue 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["_def_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["_def_owners"]: return f for f in fams: if f.get("adjacent_required") and self._has_adjacent_cue(f, line, start): return f # **定义位优先**:号在哪个族的属主登记处真定义了,就按那个族判。 # 没有这一条,`TC-NANDPORT-007`(定义在 test/host/nand_port 的 TC-HOST 族)在别处 # 被引用时会落回通用 TC 族(属主 = docs/测试说明/),假报一片悬空。 for f in fams: # §4.3 **冻结历史快照族不参与定族**:它的号只是当年报告的存档,不是活的属主。 # 否则 `N-21`(登记簿条目已销项)会被报告里同号的另一码事"接管",报出 # "请写《项目审查与优化建议报告》的文档名"——照做就把登记簿条目和审计报告 # 搅成一条,正是 §1.2 的 N-31/N-32 撞号制造方式(实测这么改,属主不明暴涨 392 条、 # 可判定比例假崩到 69.9%)。快照族只在**有紧邻文档名**时才被上面的线索步选中。 if f.get("snapshot_only"): continue if literal in self.owner_tokens[f["name"]]: return f for f in fams: if f.get("check_dangling") and f["_def_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["_def_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=""): # 位号类型识别(批 8 第一步:先落地机制,**暂不解除 docs/硬件平台/ 的语料排除**, # 因此本规则今天对现有数字零影响)。硬件类文档里「单个大写字母 + 1~3 位数字」 # 是 R/C/D/J/X/U/T 位号(电阻、电容、接插件、芯片、晶振、变压器),不是实体编号。 # 刻意不含 B(V0.0 网表报告里的 B1 是真专项条目),且 fullmatch 要求无连字符, # 所以 D-7 / N-18 / TC-206 / TC1003 这类真编号或违规形态都不受影响。 _rx = getattr(type(self), "_desig_rx", None) if _rx is None: _rx = type(self)._desig_rx = re.compile(r"(?:[RCDJXUT][0-9]{1,3}|[A-Z]{2,3}[0-9]{1,2}(?:-[0-9]{1,2})?)") type(self)._desig_paths = ("docs/硬件平台/", "docs/芯片手册/") if rel.startswith(type(self)._desig_paths) and _rx.fullmatch((literal or "").strip()): self.stats["designator_skipped"] = self.stats.get("designator_skipped", 0) + 1 return 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["_def_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["_def_owners"] or rel in fam["_def_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 if fam.get("subitem_ok"): # 小写子项后缀(`TC-TIMESYNC-003b`):§4.1 明写「后缀只允许 -<小写字母>」, # 是**合法形态**,母号在册就算语义可判定;逐条登记子项只会把文档写成代码复述。 # 可见性不丢——check_test_traceability.py 仍按完整字面量报 码有文档无。 base = re.sub(r"[a-z]$", "", literal) if base != literal and base in self.defined_all: self.stats["subitem_hits"] += 1 return sev = fam.get("dangling_outside_owner", "error") if rel not in fam["_def_owners"] else "error" self.add(3, sev, rel, lineno, literal, "悬空引用:%s 在属主登记处(%s)查无定义,也不在别名表里(§8 校验 3)" % (literal, " / ".join(fam["_def_owners"][:2])), "补定义行,或删引用(§5.5:销项 = 删行 + 清全库引用)") def _check_adjacent(self, rel, lineno, literal, fam, line, start): if not fam.get("adjacent_required") or rel in fam["_def_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["_def_owners"][0] if fam["_def_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 skip: # `[示例]` / `[计划]` 行:本行引用的就是非法形态的样子,全部规则一律不判。 # 这里原先写成 `(skip and name != "空格形 Trap")` —— 等于"示例行只放过其它规则、 # 偏要抓 Trap",把标记反着用(实测带 `[示例]` 的行仍在报空格形 Trap 错误)。 continue if docs_only and not is_md: 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["_def_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["_def_owners"]: continue if fam.get("allow_orphans"): # 属主登记处 = 代码的族(TC-HOST-*):号定义在测试函数上、本来就只在 # 自己文件里出现,按"无人引用"报孤儿是纯噪音(118 条),显式声明不判 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["_def_owners"]} if not outside: self.add(7, "warn", fam["_def_owners"][0], 0, lit, "孤儿条目:%s 定义在 %s,全库无引用(§8 校验 7)" % (lit, fam["_def_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()} BACKLOG_FILE = REPO_ROOT / "scripts" / "id_known_backlog.json" def err_groups(ck): """当前错误级问题按 `(文件, 校验号, 字面量)` 归并计数——**债务台账的指纹**。 指纹故意**不含行号**:行号每编辑一次就漂移,拿它当指纹会让台账天天"新增", 三天后 strict 输出全是假警报,没人敢再看——台账失效比没有台账更糟。 用**计数**而不是集合:同一文件里同一个号第 N+1 次出现必须能抓出来, 复制粘贴式扩散正是编号烂掉的常见路径(§1.2 的跨文档同号不同义就这么来的)。 """ g = defaultdict(int) for f in ck.findings: if f.severity == "error": g[(f.rel, f.code, f.literal)] += 1 return g def load_backlog(): """台账 → {(file, code, literal): count};文件不存在返回 None。""" if not BACKLOG_FILE.is_file(): return None doc = json.loads(BACKLOG_FILE.read_text(encoding="utf-8")) return {(e["file"], int(e["code"]), e["literal"]): int(e["count"]) for e in doc.get("entries", [])} def backlog_doc(entries, errs, ratio): return { "schema": 1, "note": ("编号债务台账(docs/编号规范.md §8.4):--strict 只对**台账之外**的新错误级问题拦提交。" "台账只许减不许增——还清的用 --prune-backlog 收掉;确需新增一条必须写 reason 字段" "说明这笔债为什么该存在,默认禁止顺手加行(否则一周后台账就变成第二个 backlog)。" "指纹 = 文件 + 校验号 + 字面量,不含行号(行号会漂,用它台账天天假新增)。"), "generated_by": "scripts/check_ids.py --write-backlog", "total_error_findings": errs, "decidable_ratio_at_generation": round(ratio, 2), "entries": entries, } def main(): ap = argparse.ArgumentParser( description="编号门禁:docs/编号规范.md §8 的 8 项校验(默认 warn-only)") ap.add_argument("--strict", action="store_true", help="增量拦截:台账(scripts/id_known_backlog.json)之外的新错误级问题 → 拦提交") ap.add_argument("--write-backlog", action="store_true", help="把当前全部错误级问题写成债务台账(关闸前的初始化动作)") ap.add_argument("--prune-backlog", action="store_true", help="从台账里收掉已还清的条目(只减不增),并回写") 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)) groups = err_groups(ck) err_total = sum(groups.values()) if args.only and (args.strict or args.write_backlog or args.prune_backlog): print("[ERR] --only 是局部排查,不能与 --strict / 台账写入同用(门禁结论必须全量)", file=sys.stderr) return 2 if args.write_backlog: entries = [{"file": k[0], "code": k[1], "literal": k[2], "count": v} for k, v in sorted(groups.items())] BACKLOG_FILE.write_text( json.dumps(backlog_doc(entries, err_total, ratio), ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") print("[OK] 债务台账已写 %s:%d 条指纹,覆盖 %d 处错误级问题" % (BACKLOG_FILE.name, len(entries), err_total)) return 0 if args.prune_backlog: led = load_backlog() if led is None: print("[ERR] 还没有台账,无从清理", file=sys.stderr) return 2 paid = [k for k in led if groups.get(k, 0) == 0] grew = [(k, led[k], groups.get(k, 0)) for k in led if groups.get(k, 0) > led[k]] # ⚠ 必须保留原条目的**全部字段**(尤其 reason):早先这里用 dict 字面量重建条目, # 把 reason 静默丢掉,等于把「这笔债为什么该存在」的审计说明清掉了(实测一次丢掉 21 类)。 raw = json.loads(BACKLOG_FILE.read_text(encoding="utf-8")) orig = {(e["file"], int(e["code"]), e["literal"]): e for e in raw.get("entries", [])} kept = [] for k in sorted(led): if groups.get(k, 0) <= 0: continue e = dict(orig.get(k, {"file": k[0], "code": k[1], "literal": k[2]})) e.update({"file": k[0], "code": k[1], "literal": k[2], "count": groups[k]}) kept.append(e) BACKLOG_FILE.write_text( json.dumps(backlog_doc(kept, err_total, ratio), ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") print("[OK] 台账已还清 %d 条,剩 %d 条(只减不增)" % (len(paid), len(kept))) for k, was, now in grew[:10]: print("[WARN] 台账内条目**变多了**(这属于新增债务,strict 会拦):%s 校验%d %s %d→%d" % (k[0], k[1], k[2], was, now)) return 0 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 err_total: if args.strict: led = load_backlog() if led is None: print("\n[FAIL] --strict 但缺 scripts/id_known_backlog.json —— 拒绝退化成" "「一次性拦死全部存量」(那样只会逼人 --no-verify)。" "先跑 --write-backlog 生成台账(§8.4)", file=sys.stderr) return 2 new = {k: c - led.get(k, 0) for k, c in groups.items() if c > led.get(k, 0)} if new: print("\n[FAIL] 编号门禁·增量拦截:%d 类**台账之外**的错误级问题(新引入的债,§8.4)" % len(new)) for (rel, code, lit), n in sorted(new.items(), key=lambda kv: -kv[1])[:args.top]: print(" +%d [校验%d] %s %s" % (n, code, rel, lit)) print(" → 改引用/补登记处;确属该欠的债要在台账写 reason(默认禁止顺手加)") return 1 print("\n[OK] 增量拦截通过:无台账外新账(存量 %d 处按台账豁免,§8.4)" % err_total) else: print("\n[WARN] 编号 backlog:%d 处错误级问题,warn-only 不拦提交" "(关闸方式见 §8.4:--strict + 债务台账)" % err_total) else: print("\n[OK] 校验 1~6 无错误") return 0 if __name__ == "__main__": sys.exit(main())