Files
STM32F4-Base/scripts/check_ids.py
T
wandering1 a92baa269e docs(boot_log_data): f3rounds 验收两轮日志存档 + id-check 二进制工件跳过
道F task-22 commit 2/2(F1 票收尾)。素材 = 2026-10-25 最终主干 1a624b1(含看门狗
v1.5 按位分账 + F3 推流心跳边界)上的两轮 30min 判据轮;serial-assistant live 日志
按字节偏移切片(不 clear、行边界对齐),gz 前置 ASCII 头行记录偏移窗/负载定义/
v1.1 判据结论。原 megawin 16:18-16:48 摘录因 @test clear 轮转灭失(全盘无副本,
lead 核定非事故),归档位按裁改存这两只(v1.1 判据下的达标基线 + 残留真信号)。

轮①(:7003 relisten_stress 单独 30min,2200 connect/close 零拒绝)= PASS:
stall=0;MISS 758 条 NET 缺席=0(旧 12 条 NET 参与组合消失达成);uisn 在途峰
103ms / uis 完成峰 183ms / UI 周期最坏 254ms——F2 复合零误报 + F3 阻塞型治愈的
定案样本。sha256 前 16 = 383dd291f37c6a5d(gz 165KB / raw 1.87MB)。

轮②(e02_probe 单独 30min,50 掉线窗)= FAIL(如实申报,零放水):stall=8 = 3
事件(20:06:05/20:16:54/20:20:30;mask=0x1E7 纯 UI×7 + 0x167 UI+I104×1,h= 环全部
>=8 连窗真申报);饿死型证据链:每样本点 uisn=0 + mg=217/262 + 拖点在 feed/port
墙钟而非推流段——「reconcile 突发饿死 uiTask」登记行与 E-27/28 批 3 的直接立项
证据(lead 裁定单列,处置方向 = 优先级矩阵复核 / reconcile 突发分片让路两条)。
全窗无 IWDG 复位。sha256 前 16 = b44eb3371c25e04c(gz 79KB / raw 0.75MB)。

README §3 表首两行沿用 B6/B7 验收轮先例(第三列放判据摘要)+ 表后存档说明行
(判读证据存档 != [STACK] 水位快照;判据口径指针 §7.2/工具名)。

【配套门禁修复(越界申报,因果耦合)】id-check 对二进制工件按文本 errors=replace
扫描:gz 乱码流随机拼出「字母-数字」形态伪编号,把无关存量顶成校验 1 台账外新账
(本 gz 入库实测拦下提交;png 属同类侥幸未撞形态)。iter_files 增加二进制嗅探跳过
(gzip 魔数或头 2KB 含 NUL → binary_skipped),与 noise_literals/盲区同口径;
docs/门禁说明/id-check.md §6 第 9 条 + 变更记录同步。教训自含:修复说明里引用噪声
示例本身会被再扫一遍——文档措辞只描述形态不举例(本次真实返工一次)。
scripts/check_ids.py 可写工具面,非登记簿/单写者文件;.pre-commit-config.yaml 未动。

【门禁】本树补跑 @build 0/0 落 .map;--all-files 48 绿 0 红;uvprojx UV4 抖动未入库;
登记簿双销项 + 新语义行 = lead 单写,本提交不碰 docs/任务规划/**。
2026-10-25 20:51:44 +08:00

833 lines
45 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)
# 定义集属主 ≠ 登记处属主。冻结历史快照族(snapshot_only)的登记处只能挂在
# 规范正文(docs/编号规范.md §4.5 那种存档表)时,那张表**是存档不是定义**:
# 规范里还混着大量写法示例(`N-99`、`S1`、`N-31`),若进定义集会同时打穿
# 裸形判定(`S1` 被判成 `S-1` 的漏连字符,实测 238 条)与撞号判定(§4.5 的
# `S-3` 与登记簿 S-3 被判重复定义)。因此只对快照族做这个剔除。
f["_def_owners"] = ([o for o in f["_owners"] if o not in self.spec_docs]
if fam.get("snapshot_only") else f["_owners"])
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
p = REPO_ROOT / f
if not p.is_file():
continue
# 二进制工件整体跳过(2026-10-25 道F task-22:boot_log_data 的 gz 串口存档被
# read_text(errors=replace) 解出乱码流,随机字节拼出「字母-数字」形态伪编号(形态故不举
# 例,举例即入扫,见 docs/门禁说明/id-check.md §6 第 9 条),把无关存量顶成校验 1 新账。
# gzip 魔数或头 2KB 含 NUL 即判二进制——
# 字节流没有编号语义,与 registry noise_literals/盲区豁免同一口径,不该进文本面。
# ⚠ 不是豁免策略:png 等既有二进制此前是"侥幸没撞上形态",现在才是按性质处理。
try:
head = p.open("rb").read(2048)
except OSError as exc:
print("[WARN] 探测失败 %s:%s" % (f, exc), file=sys.stderr)
continue
if head[:2] == b"\x1f\x8b" or b"\x00" in head:
self.stats["binary_skipped"] += 1
continue
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())