51d89cf9d3
配置:摘掉该钩子,原位置留一条退役注记(不复述数钩子数的字面量)。 YAML 解析口径实测:声明 51 / 带 manual 3 / 提交期实跑 48。 脚本:git mv 进 scripts/archive/id_gate/check_ids_new.py,与已退役的 check_ids.py 同档;docstring 改退役口径,REPO_ROOT 因归档位置深两级 改取 parents[3],失败提示与自述路径同步改写。从归档位置实测: --self-test 5 正例拦下 / 29 反例放行 rc=0;--worktree 与暂存区模式 rc=0。 文档(七份):AGENTS.md 六处——总数、加钩流水(顺带补上 2026-10-29 漏记的 skill-size 一笔)、枚举项删除、本行相加数、提交期实跑、门禁索引 与主题表两行;门禁说明 README 八处——声明数、实跑数、计数流水、grep 陷阱的举例数、§2 标题、姿态表「只查新增」行、两行索引状态;编号规范 七处——引导块改「本文即唯一事实源」、族总表一处、§8 标题与正文、 §8.5 补第④步、④ 之后无机械牙那段、速查;门禁说明 id-check-new.md 整篇重写为退役留档(查什么 / 落地前的噪声回放 / 退役五条理由 / 手工 跑法 / 此后靠什么 / 认下丢了什么);门禁说明 id-check.md 与归档目录 README、check_ids.py 里「现行门禁 = id-check-new」的指针同步改写。 技能(三份):id-family-semantic-migration 一行、stale-doc-count-sweep 的 pitfalls 与 steps 各一行改手工跑法(steps 第 5 条那笔历史计数例不动)。 此后编号形态没有机器面:判据只剩 docs/编号规范.md §1 三句自问、§4.1 族总表与人工评审;2026-10-14 那类"销项删行后号被重新取走"不再有提交期 警报——这条代价已写进留档,别当成"检查没了随便写"。 纯文档与门禁配置,未跑 @build。全程未 push。
891 lines
50 KiB
Python
891 lines
50 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
check_ids.py — 编号门禁(docs/编号规范.md §8 的 8 项校验)——**已退役(2026-10-28)**。
|
||
|
||
⚠ 退役说明(先读这段,别把它当活门禁用):
|
||
本脚本与两份语料(id_registry.json / id_aliases.json)已整体移入 scripts/archive/id_gate/,
|
||
**不再进 pre-commit**,也不再有人被要求维护语料。接班的 scripts/check_ids_new.py
|
||
(钩子 id-check-new:只查新增行、零语料)已于 2026-10-29 一并退役,也进了本目录——
|
||
**编号形态此后没有机器面**。本脚本保留下来只做一件事:
|
||
想复查「存量编号现状」时手工跑一遍出报告。
|
||
路线(①②③④ 四步)→ docs/编号规范.md §8.5;两道门禁的留档 → docs/门禁说明/id-check.md
|
||
与 docs/门禁说明/id-check-new.md。
|
||
下面这份 docstring 是退役前的原样,未改,作为历史口径保留。
|
||
|
||
两份配置(数据源 = 规范 §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 打印「编号引用可判定比例」 每次运行,只报告不拦提交(2026-10-28 起)
|
||
|
||
「定义」的机械判据:**字面量出现在该族的属主登记处文档里**(不依赖表格行首这类易漂移的
|
||
形态)。因此 §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 项(两根护栏)自 2026-10-28 起**只报告、不拦提交**(用户裁定);退役路线见 docs/编号规范.md §8.5。
|
||
|
||
用法(仓库根):
|
||
& '.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")
|
||
|
||
# 退役后本脚本住在 scripts/archive/id_gate/:仓库根是往上第 4 层,
|
||
# 两份语料与本脚本同目录(一起搬进来的,不跟着仓库结构再挪)。
|
||
HERE = pathlib.Path(__file__).resolve().parent
|
||
REPO_ROOT = HERE.parents[2]
|
||
REGISTRY_FILE = HERE / "id_registry.json"
|
||
ALIASES_FILE = HERE / "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.\-])")
|
||
|
||
# 变更记录行(表格首格是日期,或「(规划稿)」)= 冻结历史,不参与编号判定。
|
||
# 与 rename_ids.py / check_doc_links.py / check_test_traceability.py 同一条口径——此前只有
|
||
# 本脚本缺这条豁免,于是把「政策上禁止回改的历史行」(§5.5)当活账拦提交:2026-10-26 实测
|
||
# 36 处错误级 + 20 处告警全部来自历史行。豁免计数打印在案(skip_changelog),
|
||
# 不让它变成看不见的面分母缩水。
|
||
CHANGELOG_ROW = re.compile(
|
||
r"^\s*\|\s*(?:20\d\d-\d\d-\d\d|(规划稿))\s*\|"
|
||
# 另一类变更记录:首格是版本号、次格是日期(形如 | v1.6 | 2026-10-25 | …,实测见
|
||
# docs/子系统设计/控制台单写者日志设计说明.md 的附录表)。只认「版本号 + 日期」两格连排,
|
||
# 单独一个版本号不算——普通表格也可能拿版本号当首格(2026-10-26 方案 B 补)。
|
||
r'|^\s*\|\s*v\d+(?:\.\d+){1,2}\s*\|\s*(?:20\d\d-\d\d-\d\d|\d{4}-\d{2})\s*\|')
|
||
|
||
# 紧邻窗口(与 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 第 10 条),把无关存量顶成校验 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 rel.endswith(".md") and CHANGELOG_ROW.match(line):
|
||
# 冻结历史行:形态/悬空/紧邻一律不判(口径同 [示例] / [计划],见 CHANGELOG_ROW 注释)
|
||
self.stats["skip_changelog"] += len(CAND_MOD.findall(line)) + len(CAND_HYPH.findall(line))
|
||
self._scan_malformed(rel, lineno, line, skip=True)
|
||
return
|
||
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 处、命中别名表 %d 处)"
|
||
% (self.stats["files"], self.stats["refs_total"], self.stats["skip_example"],
|
||
self.stats["skip_changelog"], 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(" 可判定 = 字面量落进已登记前缀族**且该族属主登记处已存在**;"
|
||
"未登记前缀、形态违规、属主登记处还是 [计划] 的都算不可判定(指标口径保留;自 2026-10-28 起只报告)")
|
||
# 绝对数护栏(2026-10-26 方案 B 同批加):比值会被口径稀释——**删掉一批「本可判定」
|
||
# 的引用反而让比例下跌**(清号批次实测 -0.2pp),拿比值当唯一护栏等于惩罚清号。
|
||
# 不可判定引用的**绝对数**不受稀释影响:号只许越清越少,不会因分母变小而"变多"。
|
||
und_now = total - dec
|
||
ceil_now = self.reg.get("undecidable_total_ceiling")
|
||
print(" 不可判定引用绝对数 %d 处%s(只报告,不拦提交;比值会骗人,绝对数不会)"
|
||
% (und_now, "(未设上限)" if ceil_now is None else ",上限 %d" % ceil_now))
|
||
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 = HERE / "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
|
||
# 绝对数上限只许往下走(同一条「只朝好的方向」纪律);本次比上限高就拒绝,
|
||
# 免得有人拿 --update-baseline 给「号变多」开后门。
|
||
und_now = ck.stats["refs_total"] - ck.stats["decidable"]
|
||
ceil_old = reg.get("undecidable_total_ceiling")
|
||
if ceil_old is not None and und_now > ceil_old:
|
||
print("[ERR] 拒绝上调绝对数上限:本次不可判定 %d > 上限 %d"
|
||
% (und_now, ceil_old), file=sys.stderr)
|
||
return 1
|
||
reg["undecidable_total_ceiling"] = und_now
|
||
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)
|
||
|
||
# 两根护栏自 2026-10-28 起转「报告制」(用户裁定):只打印,不影响退出码。
|
||
# 理由:它们逼着每一笔提交维护 99 族语料(近 80 笔提交有 61 笔被编号体系牵连),
|
||
# 而真正防回潮的是校验 1~6 的台账外新账。退役路线见 docs/编号规范.md §8.5。
|
||
base = reg.get("decidable_ratio_baseline")
|
||
if base is not None and ratio + RATIO_TOL < base and not args.update_baseline:
|
||
print("\n[报告] 校验 8:可判定比例 %.2f%% 低于记录基线 %.2f%%(容差 %.1fpp)"
|
||
% (ratio, base, RATIO_TOL))
|
||
print(" 只报告、不拦提交。想把当前值记成新基线跑 --update-baseline;"
|
||
"口径见 docs/编号规范.md §8.5")
|
||
# 绝对数护栏同样转报告制(2026-10-28):越线只打印,不影响退出码。
|
||
und_now = ck.stats["refs_total"] - ck.stats["decidable"]
|
||
ceil_now = reg.get("undecidable_total_ceiling")
|
||
if ceil_now is not None and und_now > ceil_now and not args.update_baseline:
|
||
print("\n[报告] 校验 8:不可判定引用绝对数 %d 高于记录上限 %d(只报告、不拦提交)"
|
||
% (und_now, ceil_now))
|
||
print(" 清理方向不变:号只该越清越少;口径见 docs/编号规范.md §8.5")
|
||
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())
|