Files
STM32F4-Base/scripts/check_test_traceability.py
wandering1 f754d59218 docs(测试说明): 按用途分六个子目录并同步全库引用
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/,
顶层只留新增的目录导航 README.md(归层判据 + 硬约束 + 本次处置记录)。

- 搬迁 28 个路径:测试数据三个目录 58 个文件为纯改名(R100,内容逐字节不变)
- 全库引用同步 124 个文件:AGENTS / docs 索引 / skills / 门禁注释 / scripts
- 门禁面:scripts/check_test_traceability.py 的 glob 改 rglob
  (分层后不递归扫描=追溯门禁直接瞎掉)
- scripts/rename_ref.py:收尾的 ℹ 提示在 GBK 代码页抛 UnicodeEncodeError,
  让进程以退出码 1 收场而改写其实已落盘(RC 与真实结果相反);改为把
  stdout/stderr reconfigure 成 UTF-8,与 check_doc_links.py 等同法
- docs/文档规范.md §1.1 补「子目录文件过多时再分一层」;§3.6 补 rename_ref
  搬整目录的两个实测坑:① 会改写目录内部文件的内容 ② 内部文件的 ../ 出链
  不补层级(本次 23 个被误改的证据文件已按 HEAD 原始字节逐字节还原)
- docs/README.md §6 改为与六个子目录一一对应,并补登原先漏登记的
  「启动日志原子性复验流程」一行
- .cubemx_survival_baseline.json:App/util/task_watchdog.c 的注释路径也随分层更新,
  该文件是存活审计的 watchdog-module 漂移锚点,按脚本自己的提示跑 --write 重建基线
  (实测重建后仅 watchdog-module 一条哈希变化)
- 铁律 4 例外(本次在任务树里改了单写者文件):AGENTS.md 与
  .pre-commit-config.yaml 只被 rename_ref 改写路径字面量(后者全在注释行),
  任务表块与钩子声明一字未动

验证:check_doc_links.py --sections-strict 覆盖全部 88 个改动 .md 全绿;
check_test_traceability.py 输出与主树逐行一致;196 个文本文件无 BOM、无新增
CR、无 U+FFFD、无行尾空白;陈旧引用面由主树 25 处降到 6 处(无新增断链)。
2026-10-30 07:16:07 +08:00

285 lines
17 KiB
Python
Raw Permalink 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 - 用例追溯校验(默认仅告警,不阻断提交)。
背景(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>`
(2026-10-30 测试说明目录分层后改**递归**匹配,板级指南现居 `板级测试/`)。
[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-<模块码>-003b` 这类小写尾号)按母号算(`subitem_ok`),与编号门禁同口径。
**批 6 再分两档**:硬件层人工用例(标题/章节含「(硬件层)」)记为 `BENCH`、文档已标未实现/待补/暂缓/已取消的记为 `NOTIMPL`,两者**单列示数**不进差异——但也不是「已覆盖」,只是「不该在这里期望代码」。
**2026-10-21 第二次口径修正(批 5)**:变更记录行与 `[示例]`/`[计划]` 行**不参与比对**
(冻结历史按编号规范 §8.1 不回改,旧号靠 `id_aliases.json` 解析;这条豁免另三支脚本早有,本脚本是最后缺它的一个,`DOC-ONLY` 因此 38 → 29、交集不变);被豁免的号存进 `frozen_ids`,`--verbose` 全量列出——**豁免不等于已对应**,别拿它当收敛。
退出码:
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
# 控制台编码兜底:pre-commit / CI 之外的裸跑环境代码页常是 936(GBK),
# print 中文或 ⚠ 会抛 UnicodeEncodeError 让脚本带 traceback 以非零码死掉
# (同 check_ids.py / check_doc_links.py 的做法;2026-10-26 补齐,此前裸跑在第 245 行崩)。
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")
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 文档里缺模块码的那批用例号,§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"}
# 与 check_ids.py / rename_ids.py / check_doc_links.py **同一套**豁免口径:
# 变更记录行与 [示例] / [计划] 行是**冻结历史或写法示例**,不是活引用(§8.1 纪律:
# 历史行里的旧号不回改,靠别名表解析)。追溯器原先缺这一条,产生系统性假阳性——
# 2026-10-21 实测:DOC-ONLY 38 处里 12 处来自变更记录行,例如
# `| 2026-10-10 | 新增 TC-AI-070..078(70 → 79 用例)|` 写的是**当年**的用例名,
# 该号在批 2 已随代码改名成 TC-ADCIN-*,历史行不该回改,拿它去代码里找同号必然落空。
SKIP_LINE = re.compile(r"\[示例\]|\[计划\]|^\s*\|\s*20\d\d-\d\d-\d\d\s*\|")
frozen_ids: set = set() # 只在 --verbose 里列:被豁免的号(历史/示例),不是"已对应"
# 2026-10-21 批 6:`DOC-ONLY` 里混着两种"不是缺口"的号,一律**单列示数**而不是塞进差异,
# 也**不静默消失**——数量恒打印,明细 `--verbose` 可查:
# BENCH 硬件层人工用例(标题或所属章节含「(硬件层)」):本来就没有代码载体,
# 属主是人工台架步骤,不是 C 代码(例:§12.5 阶段 15「自愈驱动原语(硬件层,无需 PC)」)。
# NOTIMPL 文档已显式标了状态词(未实现 / 待补 / 暂缓 / 已取消):是**已登记的欠账**,
# 不是"查无此号"的假警报。
# ⚠ 反过来别把 BENCH 当"已覆盖":它只说明"不该期望代码里有",不证明人工真做过。
BENCH_RX = re.compile(r"[((]\s*硬件层")
NOTIMPL_RX = re.compile(r"未实现|待补|暂缓|已取消")
# 定义行(决定这个号的**载体类型**):markdown 标题行,或表格里号在**首格**的行。
# 引用行(正文里"同 TC-NET-601"、"参见 TC-NET-302")不改变载体判断——否则一个人工
# 用例只要在别处被提一次,就又被要求"代码里必须有"(2026-10-21 实测:按"任意行都算"
# 实现时,BENCH 收了 16 个号却只消掉 1 处差异,等于没生效)。
DEF_LINE_RX = re.compile(r"^(?:#{2,6}\s|\|\s*\*{0,2}\s*TC-)")
bench_ids: set = set()
notimpl_ids: set = set()
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()
global frozen_ids, bench_ids, notimpl_ids
in_bench_section = False
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 line in text.splitlines():
if re.match(r"^#{2,6}\s+\S", line): # markdown 章节:记住是否硬件层
in_bench_section = bool(BENCH_RX.search(line))
is_def = bool(DEF_LINE_RX.search(line))
bench = bool(BENCH_RX.search(line)) or (in_bench_section and is_def)
notimpl = bool(NOTIMPL_RX.search(line))
skip = bool(SKIP_LINE.search(line))
for m in TC_RE.findall(line):
if _prefix(m) in PLACEHOLDER_PREFIXES:
continue
if skip: # 冻结历史 / 示例行:不参与双向比对,只留痕
frozen_ids.add(m)
continue
if label == "doc" and is_def:
# 只对文档侧分类:载体类型由**文档里的定义行**说了算,
# 且必须从全集里整体减掉(见上面注释的实测坑)
if bench:
bench_ids.add(m) # 硬件层人工 → 不期望代码载体
if notimpl:
notimpl_ids.add(m) # 已标未实现/待补 → 已登记的欠账
ids.add(m)
if want_intent and has_intent(line, 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 []
# 2026-10-30:docs/测试说明 按用途分了子目录(板级测试/ 单元测试/ …,见
# docs/测试说明/README.md),文档侧一律**递归**找,否则分层等于静默放行——
# 这里若还是 glob 单层,doc_files 会变成空表并直接 return 2(门禁瞎掉)。
doc_files = sorted(doc_dir.rglob("*_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.rglob("*单元测试说明.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
# 文档侧的两个单列档整体从文档全集里减掉后再比差异(数量已在上面打印,
# --verbose 有明细):BENCH = 人工台架定义,NOTIMPL = 已显式登记未实现
doc_ids -= bench_ids | notimpl_ids
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)}")
# 注意:交集/文档侧计数在减档之前打印,这里补一行减档后的口径
print(f"(减 BENCH/NOTIMPL 后参与比对:文档 {len(doc_ids)}、交集 {len(code_ids & doc_ids)})")
print(f"豁免:变更记录/[示例]/[计划] 行的号 {len(frozen_ids)} 个(口径同 check_ids.py,见 §8.1)")
print(f"单列 BENCH(硬件层人工用例,不期望有代码载体):{len(bench_ids)} 个")
print(f"单列 NOTIMPL(文档已标未实现/待补/暂缓/已取消):{len(notimpl_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}")
for name, bag in (("BENCH 硬件层人工", bench_ids), ("NOTIMPL 已登记未实现", notimpl_ids)):
if args.verbose and bag:
print("--- %s:不参与差异比对,但**不等于已验证**(共 %d 个)---" % (name, len(bag)))
for i in sorted(bag):
print(" ", i)
if args.verbose and frozen_ids:
print("--- 被豁免的号(变更记录 / [示例] / [计划] 行,共 %d 个)**不是已对应**,"
"历史旧号靠 id_aliases.json 解析 ---" % len(frozen_ids))
for i in sorted(frozen_ids):
print(" ", 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())