feat(门禁): id-check 关闸——按债务台账增量拦截,warn-only 时代结束

机制(--strict 不再「有错就失败」,而是只拦台账外的新账)
- scripts/id_known_backlog.json:340 条指纹覆盖 710 处存量(②167 类 / ⑥135 / ③38)
- 指纹 = (文件, 校验号, 字面量) → 计数,故意不含行号:行号每改必漂,
  用它当指纹会让台账天天假新增,三天后没人敢看 strict,台账失效比没台账更糟
- 带计数而非集合:同一文件里同一个号多出现一次也算新账(复制粘贴式扩散
  正是编号烂掉的主路径,只按「出现过没有」判会放过它)
- 新增 --write-backlog 与 --prune-backlog(只减不增;台账内条目变多当场点名);
  --strict 缺台账返回 2,绝不退化成一次性拦死存量;--only 与 --strict 互斥
- 钩子 entry 加 --strict;--only 与 --strict 互斥
- 顺带修 _scan_malformed 的反向 bug:原条件 (skip and name != "空格形 Trap")
  语义正好写反,带 [示例] 的行反而专被 Trap 规则咬;改为示例行全部不判
- 第一次提交就被自己的台账咬住(id_known_backlog.json 里当然写着 P1-3 等号,
  被当成新引用)→ 台账加入 excluded_paths:登记号的表不是引用号的文档,
  与 id_aliases.json / id_registry.json 同一条道理

为什么不照「清零再升 strict」的原节奏(§8.4 详述)
- 存量 710 处清完要数周,这期间新债一条都不拦,而新增才是烂掉主因
- 「等清零」让人习惯「报红但照样提交」,真关闸那天没人再信门禁;
  本仓已有 mem-budget / mem-numbers 走 --no-verify 的先例,不再开第二个口子

关闸验收三例(临时件当场还原、git diff 干净)
- A 全新前缀 Z-99 → exit 1 点名 ✅ 咬
- B 同号计数 1→2 → exit 1 点名 ✅ 咬
- C 干净态 710 处 → exit 0 放行 ✅ 放(放不住存量就会逼出 --no-verify)

验证:--strict 干净态 exit 0;可判定比例 82.74%(基线 82.6%);
doc-links 失效/待确认 0;未改 C/H 不需 @build。
文档:编号规范 §8 上线节奏改写 + 新增 §8.4 机制表;pre-commit使用说明 §2.25;
AGENTS.md 门禁行由 warn-only 改为已关闸增量拦截。
This commit is contained in:
2026-10-21 11:35:30 +08:00
parent d74cd030ea
commit 858434492c
7 changed files with 2184 additions and 11 deletions
+98 -6
View File
@@ -612,11 +612,56 @@ def parse_only(s):
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="校验 1~6 的错误改为拦提交(默认 warn-only,不拦)")
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")
@@ -635,6 +680,39 @@ def main():
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]]
kept = [{"file": k[0], "code": k[1], "literal": k[2], "count": groups[k]}
for k in sorted(led) if groups.get(k, 0) > 0]
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:
@@ -672,12 +750,26 @@ def main():
print("\n[FAIL] 校验 8:可判定比例跌破基线 %.2f%% < %.2f%%(只许涨不许跌,容差 %.1fpp)"
% (ratio, base, RATIO_TOL))
return 1
if errs:
if err_total:
if args.strict:
print("\n[FAIL] 编号门禁:%d 处错误级问题(--strict 拦提交)" % errs)
return 1
print("\n[WARN] 编号 backlog:%d 处错误级问题,warn-only 不拦提交"
"(§8 上线节奏:清账后加 --strict)" % errs)
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