#!/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/** 一定算(** 里第一个 * 后面紧跟的 / 就是 /*)。 增量拦截(照 bom-guard / bsp-err / layer-deps 三条现成先例,只减不增): 存量钉在 BASELINE(按 文件 + 上下文指纹 计数,不按行号——行号会随手改漂移); 基线之外的新命中 → 退出码 1。还清某条就删那一行,--prune 会把可删的打出来。 退出码: 0 = 通过(或只剩存量基线) 1 = 基线之外有新命中 2 = 用法或环境错误(不在 git 仓库内、--all 取文件清单失败等) 用法: python scripts/check_nested_comment.py # 钩子入口(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/),或按本仓既有做法用尖括号包住" "()") 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('/* 尖括号写法 没问题 */\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.exists(os.path.join(REPO, ".git")): print("[ENV] 仓库根定位失败:%s 里没有 .git(目录或 worktree 的 gitdir 文件都算)" % 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())