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「顺带收编」行同步三档口径
This commit is contained in:
2026-10-21 11:24:05 +08:00
parent 4b9a4f4e24
commit d74cd030ea
4 changed files with 77 additions and 12 deletions
+65 -9
View File
@@ -20,6 +20,14 @@
作用域**只取 `*_Test_Guide.md`**:TC-CFG / TC-CLK / TC-WD 属主机侧单元
测试文档(`配置文件单元测试说明.md` 等),不参与板级追溯,天然被排除。
**2026-10-21 B 层口径对齐**(docs/编号规范.md §8.1「口径修正记录」):主机侧单测的
号改认 `test/host/<模块>/*` 为属主登记处(实测这些号在代码目录外零消费者),
于是"文档没有"不再一律算断链。真差异收敛为三条:
① 文档声明了、代码没有 → DOC-ONLY;
② 代码有、母号不在文档、**且号旁边没写人话意图** → CODE-ONLY(真未登记);
③ 代码有、靠"旁边有意图"放过 → 单独打印为 IN-CODE,**弱于文档**,不算已登记。
小写子项后缀(`TC-XXX-003b`)按母号算(`subitem_ok`),与编号门禁同口径。
退出码:
0 = 无差异;或有差异但**默认仅告警**(与 doc-links 演进路径一致)
1 = 有差异且显式 `--strict`
@@ -48,9 +56,12 @@ DOC_DIR = REPO_ROOT / "docs" / "测试说明"
# ① 规范 §4.1 的合规形态 `TC-[A-Z]{1,4}-\d{3,4}[a-z]?`(带小写后缀,TC-NET-1502b);
# ② 现存的大写后缀形态(TC-AI-01A [示例],§7.1 待统一小写)也要能抓出来,否则整条漏扫;
# ③ 无模块码形态(iec104 的 TC-014/TC-104,§7.2 列为孤儿)纳入比对而不是静默忽略。
TC_RE = re.compile(r"TC-(?:[A-Z][A-Z0-9]{0,4}-\d{1,4}[A-Za-z]?|\d{2,4}[a-z]?)")
# ⚠ 模块码上界必须跟着 §6 模块码表走:批 2 单词化后码长可达 9 位(ADCHEALTH),
# 这里曾停在 {0,4},导致代码侧集合从 556 悄悄缩到 217——**同一批改名要复核所有
# 识别面,不止 check_ids.py**(2026-10-21 实测踩到)。
TC_RE = re.compile(r"TC-(?:[A-Z][A-Z0-9]{0,8}-\d{1,4}[A-Za-z]?|\d{2,4}[a-z]?)")
# 规范合规形态(用于 --verbose 里区分"形态待迁移"与"纯追溯断链")
TC_CANON_RE = re.compile(r"^TC-[A-Z]{1,4}-\d{3,4}[a-z]?$")
TC_CANON_RE = re.compile(r"^TC-[A-Z][A-Z0-9]{0,8}-\d{3,4}[a-z]?$") # 与 §4.1 实测形态同步
# 占位符前缀(文档模板里的 `TC-XXX-…`)不参与比对
PLACEHOLDER_PREFIXES = {"XXX"}
@@ -59,23 +70,52 @@ def _prefix(tc: str) -> str:
return tc.rsplit("-", 1)[0]
def collect(paths, label):
"""返回 (ids_set, per_prefix_counts)。逐文件扫描,缺文件不致命。"""
def _base(tc: str) -> str:
"""去掉小写子项后缀:`TC-TIMESYNC-003b` → `TC-TIMESYNC-003`(与 check_ids 的
`subitem_ok` 同口径——母号已在登记处即视为已覆盖,子项差异不在这里报)。"""
return re.sub(r"[a-z]$", "", tc) if re.search(r"\d[a-z]$", tc) else tc
def has_intent(text: str, tc: str) -> bool:
"""该号在代码里是否**旁边写着意图**(B 层口径:`test/host/<模块>/` 就是属主登记处,
不逐号写文档,但号必须自带一句人话,否则"代码即登记处"等于没有登记处)。
判据刻意保守:号后面(或本行注释里)要有 ≥6 个有效字符,且不能只是代码 token。
这是**弱于文档**的保证,脚本会如实打印有多少号是靠这条弱判据过关的,别当成已登记。
"""
for line in text.splitlines():
if tc not in line:
continue
tail = line.split(tc, 1)[1]
tail = re.sub(r"^[\"'\)\]\*[::,,。\-_/`\\s]+", "", tail)
tail = re.sub(r"[\"'\)\]\*\\s]+$", "", tail)
body = re.sub(r"[A-Za-z0-9_]{1,3}$", "", tail) # 尾随的短代码 token
if len(re.sub(r"[\s\-\*=.。,,::;;()()\[\]{}<>|/\\'\"`,]+", "", body)) >= 6:
if re.search(r"[一-鿿]|[A-Za-z]{4,}", body):
return True
return False
def collect(paths, label, want_intent=False):
"""返回 (ids_set, per_prefix_counts[, intent_set])。逐文件扫描,缺文件不致命。"""
ids = set()
intents = set()
for p in paths:
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError as e: # pragma: no cover - 环境错误
print(f"[ERR] 无法读取 {p}: {e}", file=sys.stderr)
return None, None
return (None, None, None) if want_intent else (None, None)
for m in TC_RE.findall(text):
if _prefix(m) in PLACEHOLDER_PREFIXES:
continue
ids.add(m)
if want_intent and has_intent(text, m):
intents.add(m)
counts = {}
for i in sorted(ids):
counts[_prefix(i)] = counts.get(_prefix(i), 0) + 1
return ids, counts
return (ids, counts, intents) if want_intent else (ids, counts)
def main():
@@ -112,13 +152,22 @@ def main():
print(f"[ERR] 未找到文档侧文件:{doc_dir}/*_Test_Guide.md", file=sys.stderr)
return 2
code_ids, code_counts = collect(code_files, "code")
code_ids, code_counts, code_intents = collect(code_files, "code", want_intent=True)
doc_ids, doc_counts = collect(doc_files, "doc")
if code_ids is None or doc_ids is None:
return 2
doc_bases = {_base(i) for i in doc_ids}
# 真差异定义(B 层口径 2026-10-21,docs/编号规范.md §8.1):
# ① 文档声明了、代码里没有 → 未实现/未标注(保留,这是硬断链);
# ② 代码有、文档没有,**且**母号也不在文档、**且**号旁边没写意图 → 真未登记;
# ③ 代码有、文档没有,但靠"代码即登记处"过关(旁边有意图)→ 单列统计,
# 不算差异也不算合规达成——它是**弱于文档**的保证,必须看得见数量。
doc_only = sorted(doc_ids - code_ids)
code_only = sorted(code_ids - doc_ids)
code_only = sorted(i for i in code_ids - doc_ids
if _base(i) not in doc_bases and i not in code_intents)
code_only_in_code = sorted(i for i in code_ids - doc_ids
if _base(i) not in doc_bases and i in code_intents)
print("=== S4 用例追溯校验(TC 编号双向比对)===")
print(f"代码侧:{len(code_files)} 个文件,{len(code_ids)} 个 TC")
@@ -134,9 +183,16 @@ def main():
print(f"[DOC-ONLY] 文档有、代码无(未实现/代码未标注):{len(doc_only)}")
for i in doc_only:
print(f" {i}")
print(f"[CODE-ONLY] 代码有、文档无(未登记/文档漏写):{len(code_only)}")
print(f"[CODE-ONLY] 代码有、文档无,且母号不在文档、旁边也没写意图(真未登记):{len(code_only)}")
for i in code_only:
print(f" {i}")
print(f"[IN-CODE] 代码有、文档无,但号旁边写着意图 → 按「代码即属主登记处」放过:"
f"{len(code_only_in_code)}")
print(" ⚠ 这是**弱于文档**的保证(只证明该行有人话,不证明意图正确);"
"对外举证时仍需 C 层模块级说明,清单见 --verbose")
if args.verbose:
for i in code_only_in_code:
print(f" {i}")
if not doc_only and not code_only:
print("\n[OK] 双向一一对应,无差异")