Files
STM32F4-Base/scripts/check_ids.py
T
wandering1 d74cd030ea fix(门禁): 追溯脚本与 B 层口径对齐 + [示例] 行豁免写反的 bug
为什么改(第 1 件,用户裁定的顺序)
- B 层已认 test/host/<模块>/* 为 TC 号属主登记处,但 check_test_traceability.py
  仍按老规矩要求"每个号都必须出现在文档" → 两处判据互相打脸
- 实测矛盾面 284 处里有 173 处属于该口径:照老口径升 --strict 会当场拦死这些
  本不该写文档的号,唯一出路是 --no-verify——门禁公信力就是这么丢的

追溯差异改为三档(口径与编号门禁一致)
- DOC-ONLY:文档声明了、代码没有 → 真断链,38 处
- CODE-ONLY:代码有、母号不在登记处、**且号旁边没写人话意图** → 真未登记,7 处
- IN-CODE:靠"号旁写着意图"放过 → 238 处,**弱于文档的保证,单列示数不算过关**,
  对外举证仍需 C 层模块级说明(脚本里写明这条边界,禁止把它读成"已登记")
- 小写子项后缀(TC-TIMESYNC-003b)按母号算,与 check_ids 的 subitem_ok 同口径
- 真差异从 284 降到 45(38 + 7)

顺带修两个自己的 bug
- TC_RE 模块码上界停在 {0,4}:批 2 模块码单词化(ADCHEALTH 9 位)后代码侧集合
  从 556 悄悄缩到 217(339 个号隐形)→ 放宽到 {0,8}。**同一个坑第二次踩**,
  第一次是 check_ids.py 的候选正则——改名必须复核**所有**识别面,已写进 §8.1
- _scan_malformed 的豁免条件写成 `(skip and name != "空格形 Trap")`,语义正好反了:
  带 [示例] 的行反而专门被 Trap 规则咬。改为示例行**全部**形态规则不判,
  并给引用示例号的两行文档补 [示例] 标记

验证
- 编号门禁:错误级 712 → 710,可判定比例 82.73%(基线 82.6% 未跌破),校验 1/4/5 仍 0
- 追溯:代码侧 578 / 文档侧 298 / 交集 260 / 差异 45;--board-only 回退口径不变
- check_doc_links.py --all 文档路径与章节引用均通过;字节卫生复查 BOM=否、CR=0
- 未改 C/H,不需 @build

文档:docs/编号规范.md §8「顺带收编」条目改写 + §8.1 口径修正记录补两行 + 变更记录;
docs/代码规范/pre-commit使用说明.md §2.25「顺带收编」行同步三档口径
2026-10-21 11:24:05 +08:00

688 lines
36 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
# **定义位优先**:号在哪个族的属主登记处真定义了,就按那个族判。
# 没有这一条,`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["_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
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["_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 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["_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
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["_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())