0f7b5a87fb
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号) 一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、 采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。 门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏 undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8 reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF; id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。 test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字, 是本批唯一改变构建产物的文件集合。 为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged 部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、 不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。 实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01% (基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 / 告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。 基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处 引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使 识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。 ⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二 根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有), 今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。 固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0; App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889 断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿; check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带 BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。 .cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法: Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。 同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%, RW_CCM 57,376 B / 87.9%,阈值 92%。
281 lines
16 KiB
Python
281 lines
16 KiB
Python
#!/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-<模块码>-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 []
|
||
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
|
||
|
||
# 文档侧的两个单列档整体从文档全集里减掉后再比差异(数量已在上面打印,
|
||
# --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())
|