#!/usr/bin/env python3 # -*- coding: utf-8 -*- """check_registration.py - 双端注册反向校验门禁(纯标准库)。 背景(登记簿条目「rs485_task 白名单缺口」): 本工程 Keil 工程与 PC 模拟器都用**显式文件列表**(无通配扫描)。新 `.c` 必须手动注册两处才会被编译: ① `MDK-ARM/STM32F407-Demo.uvprojx` 的 `` 条目(MCU 固件构建) ② `simulator/CMakeLists.txt` 的显式源列表(PC 模拟器构建) 历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c` **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。 本脚本做什么(四类缺口): 扫描 `App/`、`Drivers/BSP/`、`Src/` 下全部 `.c`(排除第三方),对照两份注册表: ① 存在但未进 uvprojx -> 固件不编译(死代码 / 漏注册) ② 存在但未进 CMakeLists -> 模拟器不编译(**仅当该文件本应进模拟器时**才是缺口; MCU-only 文件不进模拟器是**正确**的,见下方豁免) ③ 注册了但文件不存在(反向)-> 幽灵注册(改名/删除后残留的注册条目) 另对 UI 页面模块(UI 字符集与像素回归门禁补强,2026-10-15,报告 §6.5): ④ `Drivers/BSP/UI/ui_page_*.c` 定义了 `ui_page__desc`,但该符号在 UI 目录内 除自身 .c/.h 外无任何引用 -> 页面未接入 `ui_content_page.c` 的 `s_defs[]` 描述符表:两端**编译 链接全过**(desc 无人引用,链接器整段剥离),菜单永远 打不开这一页 = 又一形态的"写了却从不生效"死代码。 ①②③ 对此完全照不出(2026-10-15 实测),故补本类; 即"新增 UI 页面必须过 registration-check"的机械口径。 豁免机制(关键): 模拟器只建模 **UI + 网络配置共享核心**;其余模块(MCU 驱动 / 固件任务 / 纯算法 / CubeMX 生成层)**不进模拟器是设计事实**,模拟器另有 `_sim` 替身或根本无对应。 豁免用**显式清单 + 每行理由注释**(命名模式 `*_mcu.c` + 个别点名), **不做整目录一刀切**(这一项的教训正是"目录级盲区")。唯一例外是 `Src/`: CubeMX 生成的外设初始化/中断/main,按构造即 MCU 专属、不可能有模拟器替身, 故整目录豁免(任务口径明确认可"Src/ 全部")。 已知缺口过渡(存量不拦、只对新缺口 FAIL): `KNOWN_GAPS` 登记存量缺口(白名单存量,Lead 处置中):命中 -> 打印 `[WARN]` 不拦; 新出现的缺口 -> 打印 `[GAP]` 并 FAIL。`--strict` 关闭白名单(存量也 FAIL),供审计。 退出码: 0 = 一致(无新缺口;存量缺口已白名单 WARN) 1 = 有缺口(存在未白名单的新缺口或幽灵注册) 2 = 脚本环境错误(注册表文件缺失 / 无法读取) 用法(仓库根目录): & '.venv\\Scripts\\python.exe' scripts/check_registration.py & '.venv\\Scripts\\python.exe' scripts/check_registration.py --strict 设计依据:docs/任务规划/登记簿.md 条目「rs485_task 白名单缺口」;.dsh/skills/new-source-file 创建日期:2026-10-12 """ import argparse import fnmatch import os import re import sys # --- 仓库根(scripts/ 的上一级)--- REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) UVPROJX = os.path.join(REPO_ROOT, "MDK-ARM", "STM32F407-Demo.uvprojx") CMAKELISTS = os.path.join(REPO_ROOT, "simulator", "CMakeLists.txt") SIM_DIR = os.path.join(REPO_ROOT, "simulator") # --- 扫描范围与第三方排除 --- SCAN_ROOTS = ["App", "Drivers/BSP", "Src"] # 第三方目录(即便落在扫描根下也跳过) THIRD_PARTY = [ "Drivers/STM32F4xx_HAL_Driver", "Drivers/CMSIS", "Middlewares", "Lib", ] # --- 模拟器豁免清单(category ②:不进模拟器是**正确**的)--- # 每项 = (fnmatch 模式(对仓库相对 posix 路径匹配), 理由)。 # 原则:命名模式 + 个别点名,不整目录一刀切;唯一目录级豁免 Src/(CubeMX 生成,MCU 专属)。 SIM_EXEMPT = [ # CubeMX 生成的外设初始化 / 中断向量 / main / freertos:按构造即 MCU 专属, # 模拟器无对应替身(任务口径明确认可"Src/ 全部")。唯一目录级豁免。 ("Src/*", "CubeMX 生成的外设初始化/中断/main,MCU 专属,模拟器无替身"), # MCU 平台后端:命名约定 *_mcu.c,模拟器另有同名 *_sim.c 替身(在 simulator/sim/)。 ("*_mcu.c", "MCU 平台后端,模拟器另有 *_sim.c 替身"), # --- App/:固件任务 / MCU 依赖 / 纯算法(模拟器无消费路径)--- ("App/algo/adc_health.c", "语义取数与有效性校验纯算法,模拟器无消费路径"), ("App/algo/adc_input.c", "依赖 HAL/TPAFE 取数,MCU 专属"), ("App/algo/phasor.c", "全周傅氏相量纯算法,模拟器无消费路径"), ("App/algo/prot_elm.c", "保护判据元件纯算法,模拟器无消费路径"), ("App/util/boot_stats.c", "开机账本(SD2506 用户 SRAM + RCC_CSR 快照,依赖 I2C/HAL 位掩码),MCU 专属;主机侧纯逻辑单测覆盖于 test/host/boot_stats"), ("App/algo/prot_set.c", "动作留痕保护定值纯算法,模拟器无消费路径"), ("App/algo/freq.c", "全周傅氏相量派生测频纯算法(输入=phasor 使用区),模拟器无消费路径"), ("App/cfg/prot_set_store.c", "定值持久化 MCU 胶水(依赖 prot_set 纯算法),模拟器无消费路径"), ("App/log/soe_rec.c", "SOE 记录编解码纯逻辑(B9),主机侧纯逻辑单测覆盖,模拟器无消费路径"), ("App/log/soe_store.c", "SOE 落盘 MCU 胶水(队列+FatFs 单写者,B9),模拟器无消费路径"), ("App/algo/sample_ring.c", "全周傅氏相量环形缓冲纯算法,模拟器无消费路径"), ("App/algo/adc_cal.c", "标定折算纯算法 + cfg 段注册(消费者 = MCU 侧 adcTask 每拍;模拟器不跑采样任务),模拟器无消费路径"), ("App/algo/meas.c", "测频与遥测快照发布纯算法(唯一发布者 = MCU 侧 adcTask 200 ms 分频;104 改读快照与 UI 侧消费属另一波次另行接线,模拟器无消费路径;主机侧纯逻辑单测 test/host/meas 覆盖),模拟器无消费路径"), ("App/algo/run_par.c", "运行参数段纯算法 + [run_par] cfg 段注册 + 104 地址映射(写者 = iec104Task 直通、属主落盘 defaultTask;模拟器不跑 104 协议,模拟器无消费路径;主机侧纯逻辑单测 test/host/run_par 覆盖),模拟器无消费路径"), ("App/algo/sb_led_map.c", "屏板状态灯位映射纯算法(唯一消费者 = MCU 侧 uiTask 经 sb_link 组帧;模拟器无屏板串口,模拟器无消费路径;主机侧纯逻辑单测 test/host/sb_led_map 覆盖),模拟器无消费路径"), ("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"), ("App/app/ui_data_feed.c", "UI 快照装配器(依赖 phasor/prot_set/iec104 端口常量等固件侧数据源);模拟器不跑装配链,快照由 simulator/main.c 直接发布固定帧,模拟器无消费路径"), ("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"), ("App/fs/vol_idle_sync.c", "MCU 存储属主后台空闲同步(FatFs disk_ioctl + 卷锁 + sys_events),主机侧纯逻辑单测覆盖于 test/host/vol_idle_sync/,模拟器无消费路径"), ("App/log/console_log.c", "MCU 控制台输出,模拟器无消费路径"), ("App/sync/time_sync.c", "对时任务(net+RTOS),MCU 专属"), ("App/task/adc_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), ("App/task/default_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), ("App/task/iec104_serv.c", "固件 FreeRTOS 任务(104 上行,B4),模拟器不跑固件任务"), ("App/task/protect_task.c", "固件 FreeRTOS 任务(保护判据节拍,B5),模拟器不跑固件任务"), ("App/task/net_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"), ("App/task/rs485_task.c", "固件 RS-485 任务,MCU 专属(另:uvprojx 缺口见 KNOWN_GAPS)"), ("App/task/ui_task.c", "固件 UI 任务,模拟器另有 main.c 驱动 UI"), ("App/protocol/iec104/iec104_asdu.c", "104 ASDU 编解码纯逻辑,主机侧纯逻辑单测覆盖,模拟器无消费路径"), ("App/protocol/iec104/iec104_link.c", "104 链路状态机纯逻辑(I/O 注入),主机侧纯逻辑单测覆盖,模拟器无消费路径"), ("App/protocol/iec104/iec104_pts.c", "104 点表/数据源适配(phasor/prot 依赖),模拟器无消费路径"), ("App/util/sha256.c", "纯工具(FTP 鉴权在 MCU 端),模拟器无消费路径"), ("App/util/sys_clock.c", "RTC/SD2506 依赖墙钟,MCU 专属"), ("App/util/task_prof.c", "任务性能探针,MCU 专属"), ("App/util/task_watchdog.c", "看门狗判定(RTOS 依赖),MCU 专属"), # --- Drivers/BSP/:MCU 芯片/网络驱动与 FTP 服务(模拟器无对应)--- ("Drivers/BSP/CH395F/ch395f.c", "MCU SPI 网卡驱动"), ("Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c", "MCU SPI NAND 驱动"), ("Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "dhara FTL 适配,MCU 存储栈"), ("Drivers/BSP/NET/lftpd/lftpd.c", "MCU FTP 服务器"), ("Drivers/BSP/NET/lftpd/lftpd_auth.c", "MCU FTP 鉴权"), ("Drivers/BSP/NET/lftpd/lftpd_inet.c", "MCU FTP 网络层"), ("Drivers/BSP/NET/lftpd/lftpd_io.c", "MCU FTP 文件 IO"), ("Drivers/BSP/NET/lftpd/lftpd_string.c", "MCU FTP 字符串工具"), ("Drivers/BSP/NET/net_select.c", "MCU socket 选择层"), ("Drivers/BSP/NET/net_socket.c", "MCU socket 后端,模拟器另有 net_socket_sim.c"), ("Drivers/BSP/NET/net_listener.c", "MCU 监听器生命周期(依赖 ch395f_* SPI 驱动与 net_socket.c 静态状态),模拟器无对应(Winsock 后端无孤儿态/重监听概念)"), ("Drivers/BSP/NET/net_events.c", "MCU 芯片事件处理 + 100ms 周期对账(依赖 ch395f_* SPI 驱动与驱动状态缓存),模拟器无对应(Winsock 后端无\"事件未收到/静默回收\"概念,无失同步可收敛)"), ("Drivers/BSP/NET/net_pending.c", "MCU 挂起 recv 模型(依赖 ch395f_* SPI 读出与 FreeRTOS 通知),模拟器另有 net_socket_sim.c 按外部语义模拟"), ("Drivers/BSP/NET/net_udp.c", "MCU UDP 数据面执行器与挂起 recv 槽(2026-11-03 UDP 队列化改造;依赖 ch395f_* SPI 读写、net_send_locked 与 FreeRTOS 通知),模拟器另有 net_socket_sim.c 按外部语义模拟"), ("Drivers/BSP/RS485/rs485.c", "MCU RS-485 驱动"), ("Drivers/BSP/SD2506/sd2506.c", "MCU RTC 驱动"), ("Drivers/BSP/ScreenLink/sb_proto.c", "屏板链路协议层纯逻辑(帧 A 解析 / 帧 B 组帧 / CRC-16),消费者 = MCU 侧 USART1 收发起发(批 1/2),模拟器无消费路径;主机侧纯逻辑单测覆盖于 test/host/sb_proto"), ("Drivers/BSP/ScreenLink/sb_link.c", "屏板链路传输层(USART1 IDLE 中断收 + ORE 清除 + 统计),依赖 HAL 句柄与中断上下文,MCU 专属;主机侧单测以桩覆盖于 test/host/sb_link"), ("Drivers/BSP/TPAFE5160/tpafe5160.c", "MCU 计量芯片驱动"), ("Drivers/BSP/UI/ui_port.c", "MCU UI 端口层(显示/触摸硬件),模拟器另有 sim/sim_port.c"), ] # --- 已知缺口白名单(存量,Lead 已处置)--- # 命中 -> WARN 不拦;新缺口 -> GAP FAIL。仅针对"存在但未注册"方向。 KNOWN_GAPS = { "App/task/rs485_task.c": "RS-485 接线门禁(驱动契约未修完,随 B2 暂缓)", } def to_repo_rel(path): """把绝对/相对路径归一为仓库相对 posix 路径。""" p = os.path.normpath(path) rel = os.path.relpath(p, REPO_ROOT) return rel.replace("\\", "/") def is_scan_scope(repo_rel): """是否在扫描范围内(App/、Drivers/BSP/、Src/ 下的 .c,排除第三方)。""" if not repo_rel.endswith(".c"): return False for tp in THIRD_PARTY: if repo_rel == tp or repo_rel.startswith(tp + "/"): return False for root in SCAN_ROOTS: if repo_rel.startswith(root + "/"): return True return False def scan_sources(): """扫描扫描根下全部 .c,返回仓库相对 posix 路径集合。""" found = set() for root in SCAN_ROOTS: abs_root = os.path.join(REPO_ROOT, root.replace("/", os.sep)) if not os.path.isdir(abs_root): continue for dirpath, _dirnames, filenames in os.walk(abs_root): for fn in filenames: if fn.endswith(".c"): abs_p = os.path.join(dirpath, fn) rel = to_repo_rel(abs_p) if is_scan_scope(rel): found.add(rel) return found def parse_uvprojx(): """解析 uvprojx 的 ,返回仓库相对 posix 路径集合(含 .c/.s)。""" with open(UVPROJX, "r", encoding="utf-8", errors="replace") as f: text = f.read() mdk_dir = os.path.dirname(UVPROJX) entries = set() for m in re.finditer(r"(.*?)", text): raw = m.group(1).strip() if not raw: continue # uvprojx 路径相对 MDK-ARM/ 目录,反斜杠或正斜杠混用 joined = os.path.join(mdk_dir, raw.replace("\\", os.sep)) entries.add(to_repo_rel(joined)) return entries def _expand_var(s, varmap, depth=0): """递归展开 ${VAR}。""" if depth > 12: return s def repl(m): name = m.group(1) val = varmap.get(name, m.group(0)) return _expand_var(val, varmap, depth + 1) return re.sub(r"\$\{(\w+)\}", repl, s) def parse_cmake(): """解析 CMakeLists 的 *_SOURCES 列表(先展开变量),返回仓库相对 posix 路径集合。""" with open(CMAKELISTS, "r", encoding="utf-8", errors="replace") as f: text = f.read() # 1) 单值 set(NAME value):目录变量(不含 *_SOURCES) varmap = {"CMAKE_CURRENT_SOURCE_DIR": SIM_DIR} for m in re.finditer(r'set\s*\(\s*([A-Za-z_]\w*)\s+("?)([^"\r\n]*?)\2\s*\)', text): name = m.group(1) val = m.group(3).strip() if name.endswith("_SOURCES"): continue varmap[name] = val # 2) 多值 set(NAME ... ) 中名字以 _SOURCES 结尾的块 sources = set() for m in re.finditer(r"set\s*\(\s*([A-Za-z_]\w*_SOURCES)\b(.*?)\)", text, re.DOTALL): body = m.group(2) # 逐行剥注释 lines = [] for line in body.splitlines(): idx = line.find("#") if idx >= 0: line = line[:idx] lines.append(line) body = " ".join(lines) for tok in body.split(): expanded = _expand_var(tok, varmap) if not expanded: continue # 展开后若仍是绝对/带盘符路径直接用,否则相对 simulator/ 目录 if os.path.isabs(expanded) or re.match(r"^[A-Za-z]:[\\/]", expanded): cand = expanded else: cand = os.path.join(SIM_DIR, expanded) sources.add(to_repo_rel(cand)) return sources # --- UI 页面模块 → 描述符表接线(④,UI 字符集与像素回归门禁补强)--- UI_DIR_REL = "Drivers/BSP/UI" def check_ui_page_wiring(): """每个定义 `ui_page__desc` 的 `ui_page_*.c`,其 desc 必须被 UI 目录内其他文件引用。 UI 页面的"注册"有**两层**:构建注册(uvprojx + CMakeLists,①② 类)与框架注册 (`ui_content_page.h` 枚举 + `ui_content_page.c` 的 `s_defs[]` 表)。漏了后者的页面 两端照常编译链接(desc 无引用 → 链接器剥离),①②③ 全绿但菜单打不开这一页。 判定:页面模块 = `ui_page_*.c` 中定义 `_desc =` 的文件(不定义 desc 的 `ui_page_*.c` 不是页面模块——未来 UI 页面原型化生成的 `ui_page_registry.c` 承载表引用, 本规则依旧成立且不会把它误判为页面);引用面 = `Drivers/BSP/UI/` 下全部 .c/.h, 排除该页自身 .c/.h(自身头文件里的 extern 声明不算接线)。 """ ui_dir = os.path.join(REPO_ROOT, UI_DIR_REL.replace("/", os.sep)) if not os.path.isdir(ui_dir): return [], 0 # 1) 页面模块清单:ui_page_*.c 中定义 `_desc =` 的 pages = {} # sym -> repo-rel .c for fn in sorted(os.listdir(ui_dir)): if not (fn.startswith("ui_page_") and fn.endswith(".c")): continue try: text = open(os.path.join(ui_dir, fn), encoding="utf-8-sig", errors="replace").read() except OSError: continue sym = fn[:-2] + "_desc" if re.search(r"\b%s\b\s*=" % re.escape(sym), text): pages[sym] = "%s/%s" % (UI_DIR_REL, fn) if not pages: return [], 0 # 2) 引用面扫描:UI 目录全部 .c/.h,排除各页自身 .c/.h referenced = {sym: False for sym in pages} for dirpath, _dirnames, filenames in os.walk(ui_dir): for fname in filenames: if not fname.endswith((".c", ".h")): continue rel_p = to_repo_rel(os.path.join(dirpath, fname)) try: text = open(os.path.join(dirpath, fname), encoding="utf-8-sig", errors="replace").read() except OSError: continue for sym, own_c in pages.items(): if rel_p in (own_c, own_c[:-2] + ".h"): continue if re.search(r"\b%s\b" % re.escape(sym), text): referenced[sym] = True gaps = [] for sym, rel_c in sorted(pages.items()): if not referenced[sym]: gaps.append("[GAP] %s | 页面未接入描述符表(%s 在 %s/ 内除自身外无引用 -> " "编译得过但菜单打不开,死代码;新增 UI 页面必须过本项)" % (rel_c, sym, UI_DIR_REL)) return gaps, len(pages) def main(): ap = argparse.ArgumentParser(description="双端注册反向校验(uvprojx + CMakeLists)") ap.add_argument("--strict", action="store_true", help="关闭 KNOWN_GAPS 白名单:存量缺口也按 FAIL 处理(供审计)") args = ap.parse_args() # 环境校验 for path, label in ((UVPROJX, "uvprojx"), (CMAKELISTS, "CMakeLists.txt")): if not os.path.isfile(path): print("[ENV-ERROR] 注册表缺失: %s (%s)" % (label, path)) return 2 try: scanned = scan_sources() uv = parse_uvprojx() cmake = parse_cmake() except OSError as e: print("[ENV-ERROR] 读取失败: %s" % e) return 2 uv_c = {p for p in uv if is_scan_scope(p)} cmake_c = {p for p in cmake if is_scan_scope(p)} gaps = [] # 新缺口 -> FAIL warns = [] # 存量白名单 -> WARN # ① 存在但未进 uvprojx(无豁免:扫描范围内每个 .c 都应编入固件) for f in sorted(scanned): if f not in uv: if (not args.strict) and f in KNOWN_GAPS: warns.append("[WARN] %s | 缺 uvprojx(存量:%s)" % (f, KNOWN_GAPS[f])) else: gaps.append("[GAP] %s | 缺 uvprojx" % f) # ② 存在但未进 CMakeLists(MCU-only 豁免) for f in sorted(scanned): if f in cmake_c: continue if any(fnmatch.fnmatch(f, pat) for pat, _ in SIM_EXEMPT): continue if (not args.strict) and f in KNOWN_GAPS: warns.append("[WARN] %s | 缺 CMakeLists(存量:%s)" % (f, KNOWN_GAPS[f])) else: gaps.append("[GAP] %s | 缺 CMakeLists" % f) # ③ 幽灵注册:注册了但文件不存在(反向) for f in sorted(uv_c): if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))): gaps.append("[GAP] %s | 幽灵注册(uvprojx)" % f) for f in sorted(cmake): if not os.path.isfile(os.path.join(REPO_ROOT, f.replace("/", os.sep))): gaps.append("[GAP] %s | 幽灵注册(CMakeLists)" % f) # ④ UI 页面模块未接入描述符表(框架注册,UI 字符集与像素回归门禁;①②③ 照不出的盲区) ui_gaps, ui_pages = check_ui_page_wiring() gaps.extend(ui_gaps) # 输出 print("== 双端注册反向校验 ==") print("扫描 .c: %d | uvprojx(扫描范围): %d | CMakeLists(扫描范围): %d | UI 页面模块: %d" % (len(scanned), len(uv_c), len(cmake_c), ui_pages)) for w in warns: print(w) for g in gaps: print(g) if gaps: print("结果: FAIL(%d 个新缺口/幽灵注册/页面未接线,%d 个存量 WARN)" % (len(gaps), len(warns))) return 1 if warns: print("结果: PASS(无新缺口;%d 个存量缺口已白名单 WARN,白名单缺口由 Lead 处置中)" % len(warns)) return 0 print("结果: PASS(双端注册一致)") return 0 if __name__ == "__main__": sys.exit(main())