Files
STM32F4-Base/scripts/check_test_traceability.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

208 lines
10 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_test_traceability.py - S4 用例追溯校验(默认仅告警,不阻断提交)。
背景(docs/测试说明/实机测试实施指南.md §8.2;原复审项 S4,已闭环):
文档声称 91 个 TC、代码有 222 个 TEST_CHECK(断言点数 > 用例数正常),
但**双向是否一一对应无人校验**——某个 TC 可能"文档写了、代码没实现",
或"代码实现了、文档没登记"。纯靠人肉比对。
本脚本做什么:
提取两侧的 TC 编号集合,输出双向差异:
① 代码侧:`test/board/*.c`(含 `*_test_task.c` 与 `net_latency_test.c`)
里出现的 `TC-<PFX>-<NNN>`(注释、DBG_INFO 串皆计入);
② 文档侧:`docs/测试说明/*_Test_Guide.md` 里出现的 `TC-<PFX>-<NNN>`。
[DOC-ONLY] 文档有、代码无 -> 未实现(或代码未标注)
[CODE-ONLY] 代码有、文档无 -> 未登记(或文档漏写)
作用域**只取 `*_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`
2 = 环境错误(目录/文件缺失)
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' scripts/check_test_traceability.py
& '.venv\\Scripts\\python.exe' scripts/check_test_traceability.py --strict
& '.venv\\Scripts\\python.exe' scripts/check_test_traceability.py --verbose
设计依据:docs/测试说明/实机测试实施指南.md §8.2(S4 用例追溯,原复审项已闭环)
创建日期:2026-10-03
"""
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CODE_DIR = REPO_ROOT / "test" / "board"
DOC_DIR = REPO_ROOT / "docs" / "测试说明"
# TC 编号:TC-<PFX>-<NNN>。<PFX> 允许字母数字(NET/GD5F/STO/RTC…)。
# 2026-10-20 收编进 id-check 门禁时补的三条(docs/编号规范.md §8「顺带收编」):
# ① 规范 §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 列为孤儿)纳入比对而不是静默忽略。
# ⚠ 模块码上界必须跟着 §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][A-Z0-9]{0,8}-\d{3,4}[a-z]?$") # 与 §4.1 实测形态同步
# 占位符前缀(文档模板里的 `TC-XXX-…`)不参与比对
PLACEHOLDER_PREFIXES = {"XXX"}
def _prefix(tc: str) -> str:
return tc.rsplit("-", 1)[0]
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, 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, intents) if want_intent else (ids, counts)
def main():
ap = argparse.ArgumentParser(description="S4 用例追溯校验(TC 编号双向比对)")
ap.add_argument("--strict", action="store_true",
help="有差异时返回退出码 1(默认仅告警,返回 0)")
ap.add_argument("--verbose", action="store_true",
help="额外打印各前缀的 TC 计数")
ap.add_argument("--code-dir", default=str(CODE_DIR))
ap.add_argument("--doc-dir", default=str(DOC_DIR))
ap.add_argument("--board-only", action="store_true",
help="只比板级(test/board/*.c ↔ *_Test_Guide.md),"
"不含主机侧单测一侧(2026-10-20 起默认纳入 7 篇「单元测试说明.md」)")
args = ap.parse_args()
code_dir = Path(args.code_dir)
doc_dir = Path(args.doc_dir)
host_dir = REPO_ROOT / "test" / "host"
code_files = sorted(code_dir.glob("*.c")) if code_dir.is_dir() else []
doc_files = sorted(doc_dir.glob("*_Test_Guide.md")) if doc_dir.is_dir() else []
if not args.board_only:
# 主机侧单测一侧:7 篇「单元测试说明.md」+ test/host 下的测试代码(*.c / *_test.py /
# run_unit_test.py)——TC 编号在两侧都是**追溯链的锚点**,缺一侧就是断链。
doc_files += sorted(doc_dir.glob("*单元测试说明.md"))
if host_dir.is_dir():
code_files += sorted(host_dir.rglob("*.c"))
code_files += sorted(host_dir.rglob("*.py"))
if not code_files:
print(f"[ERR] 未找到代码侧文件:{code_dir}/*.c", file=sys.stderr)
return 2
if not doc_files:
print(f"[ERR] 未找到文档侧文件:{doc_dir}/*_Test_Guide.md", file=sys.stderr)
return 2
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(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")
print(f"文档侧:{len(doc_files)} 个文件,{len(doc_ids)} 个 TC")
print(f"交集:{len(code_ids & doc_ids)}")
if args.verbose:
print("--- 各前缀 TC 计数(代码 / 文档)---")
for pfx in sorted(set(code_counts) | set(doc_counts)):
print(f" {pfx}: code={code_counts.get(pfx, 0)} doc={doc_counts.get(pfx, 0)}")
print()
print(f"[DOC-ONLY] 文档有、代码无(未实现/代码未标注):{len(doc_only)}")
for i in doc_only:
print(f" {i}")
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] 双向一一对应,无差异")
return 0
print(f"\n[WARN] 共 {len(doc_only) + len(code_only)} 处差异"
f"(默认仅告警,不阻断提交;收敛后再考虑升为门禁)")
return 1 if args.strict else 0
if __name__ == "__main__":
sys.exit(main())