#!/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--`(注释、DBG_INFO 串皆计入); ② 文档侧:`docs/测试说明/*_Test_Guide.md` 里出现的 `TC--`。 [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--。 允许字母数字(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())