Files
STM32F4-Base/scripts/check_nested_comment.py
T
wandering1 1d39571114 feat(门禁): 第 51 条 nested-comment——把 ARMCC #9-D 的按包含者刷屏拦在提交前
动机是今天一天复发三次的同一形态:在块注释里写作用域路径,那个 `/*` 被 ARMCC 当第二次注释起点(诊断 9-D),
而且**按包含者数量刷屏、只在重编译那个 TU 时才暴露**——本仓的诱因就是「注释里写 Drivers/BSP/UI/**」。
AGENTS 早就写了这条铁律(写全目录或用尖括号),但它一直是纯口头纪律,所以同一天还是复发三次。

新脚本 scripts/check_nested_comment.py:纯标准库、只读、五状态机(代码 / 行注释 / 块注释 / 字符串 / 字符
字面量),**只有块注释体内的 /* 才算问题**——字符串里的、行注释里的、未闭合判定都不误报。口径上刻意与钩子
的 types: [c] 对齐(403 个 .c/.h 同面),避免「手跑面 ≠ 提交面」这类我们今天就踩过的错位;零第三方依赖是
刻意的,为了让 generated-fresh 那天的解释器假红不长第二遍(实测 .venv 与机器级 C:Python314 两种解释器结论
一致)。

存量口径:全仓实扫**新发 0、存量 2 处**(test/host/common/ch395f_fake.h:7 与 fatfs_fake.h:9,同一形态
「注释里写作用域 test/host/**」)。两份假件头不参与 MCU 构建所以今天不报,一旦被 include 进板端 TU 就会
按包含者刷 9-D——属 test/ 写域,作者**没代改**,而是钉进 BASELINE 走只减不增,**其余零豁免**。

自证有一条必须写清的诚实边界:作者想「拿旧字节回放今天那三处真复发」,实测**做不到**——近 150 个提交里被
改过的 281 份 .c/.h 旧字节**零命中**,因为那三处同类从来没有一处在 HEAD 里留过字节(全靠人工编译后清掉)。
于是改测**形态**(这是能证的部分):10 项形态电池 10/10,含把已入库的尖括号写法退回星号形态 → 命中 1 处;
另用现存两份真字节做双跑:空基线 → 2 条 [NEW] + exit 1,加基线 → exit 0,证明**基线只决定算不算新的,
不掩盖有没有**。编译器侧原文(run_par.c(267) #9-D)冻结抄进新文档 §2.1——构建日志每次 @build 被覆盖且不入库,
所以文档里刻意没写「最近一次日志里仍有」这种会过期的话。

计数三处同批:AGENTS「共 **51** 个钩子」、门禁索引「51 个 hook → 27 篇 + 1 指针 + 1 外置」、配置声明;并记
文本 grep「 - id:」= 52 的成因(第 65 行被注释掉的 clang-format 候选),收紧行首口径 = 51。作者顺带自打了
一次:在配置注释里复述了那个 grep 字面量 → 文本计数当场虚高 1,已改写并把「配置注释里别复述字面量」写进
README §2 与 AGENTS。

同笔带上 task-28 的裁定句(AGENTS 一只文件里两卡改动天然混在一起,拆开会多两次改写与一次风险):不补第
23 根 ui-sink 专属钩子——22 根的唯一意义是秒级归因,而 ui_sink 失败面全落在 test-coverage 总闸必跑路径、
失败照样拦;读「这套跑没跑」去看总闸输出,别数钩子。

实测与本笔自查:validate-config = 0;nested-comment --selftest 4/4、--all = 403 / 新发 0 /
存量 2 → exit 0;
doc-links --sections-strict(4 篇)= 0;gen_task_table --check = 0;generated-fresh
PASS(5 处存量走 WARN);
check_ids --strict exit 0,**不可判定绝对数全程未被本闸吃掉**(作者开工 1,162、写完新闸与新文档仍 1,162;
现 1,156 是队友还清的);全部改动文件 noBOM / LF / U+FFFD=0。未跑 pre-commit --all-files(只跑本闸)。

**本笔使用 --no-verify 的凭证(唯一允许的两条之一,按 MEMORY 台账例外口径)**:把本批 6 只文件单独
staged 后跑 `pre-commit run --files <这 6 只>` → **13 条 Passed / 1 条 Failed**
唯一失败的是 `mem-budget`
(RAM 92% 阈值,**全树检查**)。本批**零固件源、零 .map 变更**,该红来自其他队友在飞的固件改动造成的 .map
漂移(当前树 Code=231,952 / RW=1,528 / ZI=136,264,在库台账 231,712 / 1,504 / 136,216
差 = run_par setter
与 vol_lock 计数两笔,均未提交);台账回填由我在树能编后统一 `--write`,不在分支上跑(避免多分支同改受管表)。
gate: only mem-budget pending lead ledger backfill。字节卫生本笔自查:6 只文件均 noBOM / L
F / U+FFFD=0,
新脚本纯 ASCII 输出路径无关,AGENTS 与索引两处计数与配置 YAML 解析口径 51 一致。

另收一条今天反复出现的元教训(Lead 与作者各错过一次、方向相反):**`pre-commit run` 与 git 钩子不是同一根
探针**——config 处于 modified-unstaged 时只有后者 [ERROR] 拒绝,前者与 validate-config 都 Passed
。它与「stash
只覆盖已跟踪未暂存」「未跟踪文件永不进 stash」同族:手跑的探针不能替真提交的探针作证。
2026-10-27 03:44:18 +08:00

383 lines
16 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
"""块注释体内不得再出现 /*(ARMCC 诊断 9-D:nested comment is not allowed)。
为什么要有这根(2026-10-26 一天内真实复发三次的同类):
在注释里写通配路径是本仓的常态写法,而 C 的块注释**不嵌套**:注释体内出现 /* 时,
ARMCC 报 "#9-D: nested comment is not allowed",并且**每个 include 该头文件的编译单元
都各打一行**——实测一例扩散到 16 条警告,一度把整批 10 条真警告挡住看不见。
要害与 bom-guard 同形:**只在重编译那个 TU 时才暴露**,历史"0 警告"不代表安全。
判定(刻意只管这一件事):
逐字节状态机走 CODE / 行注释 / 块注释 / 字符串 / 字符字面量;只有在**块注释体内**
看到的 /* 才算问题。所以这些一律不算:
const char *s = "/* 不是注释"; // 字符串里的 /*
// /* 行注释里写个 /* <Drivers/BSP/UI/ui_x.c> 尖括号写法
反过来,块注释里写 Drivers/BSP/UI/** 一定算(** 里第一个 * 后面紧跟的 / 就是 /*)。
增量拦截(照 bom-guard / bsp-err / layer-deps 三条现成先例,只减不增):
存量钉在 BASELINE(按 文件 + 上下文指纹 计数,不按行号——行号会随手改漂移);
基线之外的新命中 → 退出码 1。还清某条就删那一行,--prune 会把可删的打出来。
退出码:
0 = 通过(或只剩存量基线)
1 = 基线之外有新命中
2 = 用法或环境错误(不在 git 仓库内、--all 取文件清单失败等)
用法:
python scripts/check_nested_comment.py <file...> # 钩子入口(pre-commit 传文件名)
python scripts/check_nested_comment.py --all # 全仓审计(跳过第三方与生成物)
python scripts/check_nested_comment.py --emit-baseline # 打出现存命中的基线条目(贴回本文件)
python scripts/check_nested_comment.py --selftest # 自证有牙 + 反例不误报
python scripts/check_nested_comment.py --prune # 对照现状点名"已还清、可删"的基线行
"""
import argparse
import os
import subprocess
import sys
import tempfile
try:
sys.stdout.reconfigure(errors="replace") # GBK 控制台打不出 ▶ 等符号
sys.stderr.reconfigure(errors="replace")
except Exception:
pass
REPO = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
EXTS = (".c", ".h")
# 第三方与生成物:与 .pre-commit-config.yaml 顶层 exclude 同口径(改动要两边对一遍)
SKIP_DIRS = (
# 与 .pre-commit-config.yaml 顶层 exclude 同口径;test/ **不**排除(主机侧桩头文件里也真查出过
# 嵌套注释,见 BASELINE),只排构建产物目录。本闸 --all 的文件集必须与钩子 types: [c] 实际喂进来的
# 一致,否则"手跑面"与"提交面"又会变成两个数(本仓为这种不同源返工过好几次)。
"Lib/", "Middlewares/", "Drivers/STM32F4xx_HAL_Driver/", "Drivers/CMSIS/",
"MDK-ARM/", "simulator/build/", "test/board/build/",
)
# 存量基线:接线当天(2026-10-27)实测已存在的命中,**只减不增**(还清即删整条,--prune 打出台账)。
# 两处同一个形态:注释里写作用域 "test/host/**" —— 那个 /* 就是嵌套注释起点。它们今天不报,只因为
# 这两份假件头文件不参与 MCU 构建(换 GCC/clang 的 -Wcomment 就会报);一旦有人把它 include 进板端
# TU,就会按包含者数量刷出一排 #9-D。修法是把作用域写成语义短语("test/host 目录"),属 test/ 写域,
# 等那边的道顺手还;还清后删掉对应行,本闸立刻恢复"零豁免"。
BASELINE = (
("test/host/common/ch395f_fake.h",
"件才能测。 * * ⚠ 仅供 test/host/** 使用,不参与 MCU 构建。 * * 为"),
("test/host/common/fatfs_fake.h",
"rename。 * * 仅供 test/host/** 使用:不参与 MCU 构建,也不参与 sim"),
)
FIX_HINT = ("修法:注释里别写 /**(C 块注释不嵌套,那个 /* 会被当第二次注释起点并报 "
"#9-D)——路径写全目录就好(Drivers/BSP/UI/),或按本仓既有做法用尖括号包住"
"(<Drivers/BSP/UI/ui_x.c>)")
def rel_of(path):
try:
return os.path.relpath(os.path.abspath(path), REPO).replace(os.sep, "/")
except Exception:
return str(path).replace(os.sep, "/")
def scan_text(text):
"""状态机扫一份源码 → [(行号, 列号, 上下文指纹)],只在块注释体内报 /*。"""
hits = []
state = 0 # 0 代码 1 行注释 2 块注释 3 字符串 4 字符字面量
line, col = 1, 1
i, n = 0, len(text)
while i < n:
c = text[i]
nxt = text[i + 1] if i + 1 < n else ""
if state == 2: # 块注释体内
if c == "*" and nxt == "/":
state = 0
i += 2
col += 2
continue
if c == "/" and nxt == "*": # ← 唯一的问题形态
hits.append((line, col, context_of(text, i)))
if c == "\n":
line += 1
col = 0
i += 1
col += 1
continue
if state == 1: # 行注释
if c == "\n":
state, line = 0, line + 1
col = 0
i += 1
col += 1
continue
if state == 3: # 字符串
if c == "\\":
i += 2
col += 2
continue
if c == '"':
state = 0
i += 1
col += 1
continue
if state == 4: # 字符字面量
if c == "\\":
i += 2
col += 2
continue
if c == "'":
state = 0
i += 1
col += 1
continue
if c == "/" and nxt == "*": # 块注释开始
state = 2
i += 2
col += 2
continue
if c == "/" and nxt == "/": # 行注释开始
state = 1
elif c == '"':
state = 3
elif c == "'":
state = 4
elif c == "\n":
line += 1
col = 0
i += 1
col += 1
continue
i += 1
col += 1
return hits
def context_of(text, pos, width=26):
"""取 /* 前后一小段做指纹:压掉空白与换行,避免行号漂移让基线失效。"""
seg = text[max(0, pos - width):pos + width]
return " ".join(seg.split())
def read_text(path):
with open(path, "rb") as f:
raw = f.read()
return raw.decode("utf-8", errors="replace")
def check_file(path):
"""单个文件 → (rel, [(行, 列, 指纹)])。"""
return rel_of(path), scan_text(read_text(path))
def tracked_sources():
"""--all 用的文件清单:git 跟踪的 C/C++,按 SKIP_DIRS 排除第三方与生成物。"""
try:
out = subprocess.run(["git", "-c", "core.quotePath=false", "ls-files", "-z"],
cwd=REPO, capture_output=True, check=True).stdout
except Exception as e:
print("[ENV] git ls-files 失败(不在 git 仓库里?):%s" % e)
return None
files = []
for chunk in out.split(b"\0"):
p = chunk.decode("utf-8", errors="replace").strip().replace("\\", "/")
if not p or not p.endswith(EXTS):
continue
if p.startswith(SKIP_DIRS):
continue
full = os.path.join(REPO, *p.split("/"))
if os.path.isfile(full):
files.append(full)
return sorted(files)
def fingerprint_counts(rel, hits):
d = {}
for _line, _col, ctx in hits:
key = (rel, ctx)
d[key] = d.get(key, 0) + 1
return d
def split_against_baseline(found, baseline):
"""→ (新命中列表, 存量在册列表, 已还清可删的基线行)。新命中 = 超出基线配额的部分。"""
allow = {}
for rel, ctx in baseline:
allow[(rel, ctx)] = allow.get((rel, ctx), 0) + 1
new, inbase = [], []
for key, cnt in found.items():
quota = allow.get(key, 0)
if cnt > quota:
new.append((key, cnt - quota))
if quota:
inbase.append((key, min(cnt, quota)))
used = {}
for key, cnt in found.items():
used[key] = cnt
cleared = [(rel, ctx) for (rel, ctx) in baseline if used.get((rel, ctx), 0) == 0]
dedup_cleared = []
seen = set()
for row in cleared:
if row not in seen:
seen.add(row)
dedup_cleared.append(row)
return new, inbase, dedup_cleared
def report(files, baseline, args):
"""跑一批文件 → (退出码, 汇总 dict)。"""
found = {}
per_file = {}
for p in files:
rel, hits = check_file(p)
if hits:
per_file[rel] = hits
for key, cnt in fingerprint_counts(rel, hits).items():
found[key] = found.get(key, 0) + cnt
new, inbase, cleared = split_against_baseline(found, baseline)
for (rel, ctx), cnt in sorted(new):
first = per_file[rel][0]
print(" [NEW ] %s:%d:%d 块注释体内出现 /*(出现 %d 次同形)" % (rel, first[0], first[1], cnt))
print(" 上下文:%s" % ctx)
print(" %s" % FIX_HINT)
if args.verbose:
for (rel, ctx), cnt in sorted(inbase):
print(" [BASE] %-40s 存量基线在册 %d 处:%s" % (rel, cnt, ctx))
if cleared and baseline:
print(" [TIGHTEN] 基线 %d 条本轮实测已还清,可下架(--prune 打出台账):%s"
% (len(cleared), ", ".join(sorted({r for r, _c in cleared}))))
if args.prune:
print("\n--- 存量基线台账(只减不增)---")
for rel, ctx in baseline:
print(" %-40s %s" % (rel, ctx))
summary = {"files": len(files), "hits": sum(found.values()),
"new": sum(c for _k, c in new), "baseline": sum(c for _k, c in inbase)}
if summary["new"]:
print("汇总:查 %d 个文件,新发 %d 处,存量基线在册 %d 处"
% (summary["files"], summary["new"], summary["baseline"]))
print("结论: FAIL —— 块注释里出现了 /*(重编译该 TU 时每个包含者都会多一行 #9-D 警告)")
return 1, summary
print("汇总:查 %d 个文件,新发 0 处,存量基线在册 %d 处(走 --prune 还清)"
% (summary["files"], summary["baseline"]))
print("结论: PASS —— 没有新的块注释嵌套 /*")
return 0, summary
def emit_baseline(files):
found = {}
for p in files:
rel, hits = check_file(p)
for key, cnt in fingerprint_counts(rel, hits).items():
found[key] = found.get(key, 0) + cnt
print("# 贴回 scripts/check_nested_comment.py 的 BASELINE(只减不增):")
for (rel, ctx), cnt in sorted(found.items()):
for _i in range(cnt):
print(' ("%s",\n "%s"),' % (rel, ctx.replace('"', "'")))
return 0
def selftest(args):
"""正例必须照出来,反例必须安静——两种形态一起测,防止只会对着字符串叫。"""
print("\n--- 自证有牙(临时目录,不碰仓库)---")
tmp = tempfile.mkdtemp(prefix="dsh_nested_")
good = os.path.join(tmp, "ok_case.h")
bad = os.path.join(tmp, "bad_case.h")
with open(good, "w", encoding="utf-8", newline="\n") as f:
f.write('/* 尖括号写法 <Drivers/BSP/UI/ui_w_soe.c> 没问题 */\n'
'static const char *g = "/* 字符串里的不算 */";\n'
'// /* 行注释里写个 /* 也不算\n'
'/* 第一段结束 */\n/* 第二段正常开始 */\n')
with open(bad, "w", encoding="utf-8", newline="\n") as f:
f.write('/* 类型名表在 UI 侧 Drivers/BSP/UI/**,不在本道写域 */\n')
rel_g, hits_g = check_file(good)
rel_b, hits_b = check_file(bad)
print(" 反例(尖括号 / 字符串 / 行注释 / 两段正常注释)→ 命中 %d 处(要求 0)" % len(hits_g))
print(" 正例(注释里写 Drivers/BSP/UI/**)→ 命中 %d 处(要求 ≥1):行 %s 列 %s"
% (len(hits_b), hits_b[0][0] if hits_b else "-", hits_b[0][1] if hits_b else "-"))
ok_neg, ok_pos = (len(hits_g) == 0), (len(hits_b) >= 1)
# 基线机制也要测:同一份命中,钉进基线后必须从「新发」变成「在册」
found = fingerprint_counts(rel_b, hits_b)
total = sum(found.values())
fake_baseline = tuple((rel_b, ctx) for (_l, _col, ctx) in hits_b)
n_new_raw, _b_raw, _c_raw = split_against_baseline(found, ())
n_new, n_base, cleared = split_against_baseline(found, fake_baseline)
print(" 基线机制:命中 %d 处 —— 不钉基线算新发 %d;钉成基线后新发 %d / 在册 %d / 还清可删 %d"
% (total, sum(c for _k, c in n_new_raw), sum(c for _k, c in n_new),
sum(c for _k, c in n_base), len(cleared)))
ok_bl = (sum(c for _k, c in n_new_raw) == total and not n_new
and sum(c for _k, c in n_base) == total and not cleared)
# 「只减不增」的牙:同一个文件里再来一处**不同形态**的嵌套,必须算新发而不是被基线吞掉
bad2 = os.path.join(tmp, "bad2_case.c")
with open(bad2, "w", encoding="utf-8", newline="\n") as f:
f.write("/* 第一处:Drivers/BSP/UI/** 存量形态 */\n"
"/* 第二处:新写的 App/algo/* 作用域,基线里没有 */\n")
rel2, hits2 = check_file(bad2)
two = fingerprint_counts(rel2, hits2)
only_first = tuple((rel2, sorted(two)[0][1]) for _i in range(1))
n2_new, n2_base, _c2 = split_against_baseline(two, only_first)
print(" 只减不增:同文件两处命中、只把第一处钉进基线 → 新发 %d / 在册 %d(要求 1 / 1)"
% (sum(c for _k, c in n2_new), sum(c for _k, c in n2_base)))
ok_bl = ok_bl and (sum(c for _k, c in n2_new) == 1 and sum(c for _k, c in n2_base) == 1)
try:
os.remove(bad2)
except OSError:
pass
for p in (good, bad):
try:
os.remove(p)
except OSError:
pass
return ok_neg and ok_pos and ok_bl
def main(argv=None):
ap = argparse.ArgumentParser(description="块注释体内不得再出现 /*(ARMCC #9-D)")
ap.add_argument("files", nargs="*", help="待检查文件(钩子入口)")
ap.add_argument("--all", action="store_true", help="全仓审计(git 跟踪的 C/C++,排除第三方)")
ap.add_argument("--verbose", action="store_true", help="逐条打印存量基线在册项")
ap.add_argument("--emit-baseline", action="store_true", help="打出现存命中对应的基线条目")
ap.add_argument("--prune", action="store_true", help="打印基线台账并点名已还清的行")
ap.add_argument("--selftest", action="store_true", help="正例照得出 + 反例不误报 + 基线机制有效")
args = ap.parse_args(argv)
if not os.path.isdir(os.path.join(REPO, ".git")):
print("[ENV] 仓库根定位失败:%s 里没有 .git" % REPO)
return 2
print("=== 块注释嵌套检查 check_nested_comment.py(注释体内出现 /* = ARMCC 诊断 9-D)===")
print(" 仓库根 %s / 存量基线 %d 条(只减不增)" % (REPO, len(BASELINE)))
if args.all:
files = tracked_sources()
if files is None:
return 2
elif args.files:
files = [f if os.path.isabs(f) else os.path.join(REPO, f) for f in args.files]
files = [f for f in files if f.endswith(EXTS) and os.path.isfile(f)]
else:
files = []
if args.selftest and not selftest(args):
print(" [SELFTEST] FAIL:正例/反例/基线机制有一项不对 → 别信这根闸,先修脚本")
return 1
if args.selftest:
print(" [SELFTEST] PASS:注释里写 /** 必被点名;字符串/行注释/尖括号写法不误报")
if not (args.all or args.files):
return 0 # 只要了自测就别往下走(pre-commit 自测入口用)
if args.emit_baseline:
if not files:
print("[USAGE] --emit-baseline 要配 --all 或给文件清单")
return 2
return emit_baseline(files)
if not files:
print("[USAGE] 没给文件,也没 --all/--selftest/--emit-baseline")
return 2
code, _s = report(files, BASELINE, args)
return code
if __name__ == "__main__":
sys.exit(main())