feat(门禁): 新增 layer-deps——BSP 反向 include App 的增量拦截(37→38)

推荐清单第 5 步的拆分执行:反转依赖(5a)暂缓,**只把"防扩大"的门禁先上**。

**为什么门禁比搬迁值钱**:AGENTS.md 与分层图早就写着"六层单向依赖",可从来没有
机械检查——于是同一件事的记载从 2026-09 审查报告的"NETCFG 一处",漂到 2026-10-22
实测的 **8 个文件 9 处**,全靠人读代码才发现。边界的静默腐蚀只能靠门禁止住。

**机制(与 `id-check` 的增量拦截同构)**:`scripts/check_layer_deps.py` 扫
`Drivers/BSP/**.{c,h}` 的 include,按 `App/**` 头文件索引解析反向边(同名头按字面
路径消歧),命中分三类:
· ALLOW 永久豁免 3 条横切叶子头——`App/log/console_log.h`(日志后端,BSP 的
  dbg_log.h 就建在它上面)、`App/util/sys_clock.h`(时间源)、`App/util/sha256.h`
  (纯算法、无状态)。它们是"任何层都要用的叶子能力",不是上层服务;新增豁免
  必须先回答"它为什么不是上层服务"。
· BASELINE 钉住存量 6 条:`cfg_file.h`×2(NETCFG)、`vol_lock.h`×1(lftpd_io)、
  `sys_log.h`×3(UI 两处 + `ui_w_records.h`)。**只减不增**(脚本没有 `--write`)。
· 新出现的 (BSP 文件, App 头) 组合 → FAIL,并给出三条出路(注入回调 / 上层传参 /
  确属叶子才可豁免)。
`--prune` 在某条存量还清时点名"可从基线删除",于是"还欠几条"成为可见进度,而不是
文档里的一句口号。

**当场自证**:落地时临时给 `Drivers/BSP/RS485/rs485.c` 加了一行 include `cfg_file.h
`,钩子当场 FAIL 并点名该文件与该头(退出码 1),删掉后转绿——没被触发过的门禁
等于没有门禁。

**边界要说清(免得被误读成缺陷)**:本钩子只拦**静态 include 方向**。运行时的属主
纪律本来就没破——UI 只投递 `net_cfg_save_request()`,真正写盘在 defaultTask,模拟器
也早在主机侧跑整条配置链。

**口径同步**:钩子数 37 → 38 共 6 处(`AGENTS.md` 两处、`pre-commit使用说明.md` 两处
并新增 §2.26 详解、`并行任务工作树方案.md`、报告 §0.1/§1.1);
`项目架构总览.md` §1 分层原则处补"此方向已机械检查";报告 §0.4 的 S-1 计数由
"7 文件 15 处"更正为脚本口径的 8 文件 9 处,§9 状态改 🟡。

**暂缓项(都已在报告 §0.4 记因)**:5a 反转依赖——没有功能在等它,且要过"改 IP
免重启生效"整条真机链,留给下次真动 NETCFG/UI 时顺带做;5b 目录搬迁——uvprojx 326
条 + IncludePath + CMakeLists + 42 个 UI 文件 + 全库引用改写,收益只是"目录名好看",
而远程 CI 仍刻意不跑 `@build`,兜底未成立。

门禁:`layer-deps` 自身通过;`check_doc_links.py --all --sections-strict` 146 文档 0
问题;`check_ids.py --strict` 增量拦截通过、可判定 82.84%;`mem-numbers` 0;
`py_compile` 通过。本轮无 `.c/.h` 改动,未重跑 `@build`。未推送。
This commit is contained in:
2026-10-22 19:30:02 +08:00
parent 7e77af08a0
commit 6ede1443e9
7 changed files with 212 additions and 9 deletions
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""分层方向门禁:Drivers/BSP/** 不得 include App/** 的头(反向依赖增量拦截)。
背景(2026-10-22 加)
----------------
AGENTS.md 与 docs/架构设计/项目架构总览.md 都写着"六层单向依赖:业务 → UI/基础服务
→ 中间件 → BSP 驱动 → HAL/RTOS → 硬件",但**此前没有任何机械检查**:Drivers/BSP/ 里
可以随手 include "cfg_file.h" 而无人报警。后果是可测量的口径漂移——2026-09 审查报告
记"NETCFG 一处",2026-10-22 实测已长到 8 个文件 9 处。边界的静默腐蚀只能靠门禁止住:
先把现状钉成基线,然后**只拦新增**。
与 scripts/check_ids.py 的"增量拦截"同构:
· 新出现的 (引用方, App 头) 组合 → FAIL(拦提交)
· 基线里的组合已消失 → WARN,并提示收紧(基线只减不增,绝没有 --write 往里加)
· 横切基础设施头走 ALLOW 永久豁免:它们是"任何层都要用的叶子能力",不是上层服务
用法
----
python scripts/check_layer_deps.py # 门禁模式(pre-commit / CI)
python scripts/check_layer_deps.py --verbose # 列出全部反向边与判定
python scripts/check_layer_deps.py --prune # 只报告可以删掉的基线条目
退出码:0 = 通过;1 = 发现新增反向依赖;2 = 用法/环境错误
"""
import argparse
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BSP_PREFIX = "Drivers/BSP/"
APP_PREFIX = "App/"
SRC_EXT = (".c", ".h")
# 横切基础设施:任何层都可用,允许被 BSP include。
# 新增条目必须先回答"它为什么不是上层服务"——否则就是给自己开后门。
ALLOW = {
"App/log/console_log.h", # 日志后端:BSP 的 dbg_log.h 本来就建在它上面
"App/util/sys_clock.h", # 时间源:日志时间戳的唯一提供者
"App/util/sha256.h", # 纯算法工具:无状态、不依赖任何上层服务
}
# 存量基线(2026-10-22 清点,只减不增)。每条 = (引用方, 被引用的 App 头)。
# 修法是"注入/传参",不是把上层服务搬进 BSP——详见报告 §0.4 的 S-1 行。
BASELINE = {
("Drivers/BSP/NETCFG/net_cfg.c", "App/cfg/cfg_file.h"), # 配置引擎属主 = defaultTask
("Drivers/BSP/NETCFG/ftp_cfg.c", "App/cfg/cfg_file.h"), # 同上([ftp] 段注册)
("Drivers/BSP/NET/lftpd/lftpd_io.c", "App/fs/vol_lock.h"), # 卷锁在 App/fs
("Drivers/BSP/UI/ui_page_runlog.c", "App/log/sys_log.h"), # 记录读取应由上层递进来
("Drivers/BSP/UI/ui_page_selftest.c", "App/log/sys_log.h"), # 同上
("Drivers/BSP/UI/ui_w_records.h", "App/log/sys_log.h"), # 同上(组件头,会外溢到 include 链)
}
INC_RE = re.compile(r'^[ \t]*#[ \t]*include[ \t]*"([^"]+)"', re.M)
def tracked_files():
"""以 git ls-files 为准(不扫构建产物);git 不可用时退回 os.walk。"""
try:
out = subprocess.check_output(["git", "ls-files", "-z"], cwd=REPO)
return [p.decode("utf-8") for p in out.split(b"\0") if p]
except Exception: # noqa: BLE001
files = []
for root, dirs, names in os.walk(REPO):
dirs[:] = [d for d in dirs if d not in (".git", ".venv", "build")];
for n in names:
files.append(os.path.relpath(os.path.join(root, n), REPO).replace(os.sep, "/"))
return files
def app_header_index(files):
idx = {}
for f in files:
if f.startswith(APP_PREFIX) and f.endswith(".h"):
idx.setdefault(os.path.basename(f), []).append(f)
return idx
def scan(files, idx):
edges = set()
for f in files:
if not f.startswith(BSP_PREFIX) or not f.endswith(SRC_EXT):
continue
try:
with open(os.path.join(REPO, f), encoding="utf-8-sig") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as exc:
print("[WARN] 读取失败,跳过:%s(%s)" % (f, exc))
continue
for m in INC_RE.finditer(text):
literal = m.group(1)
owners = idx.get(os.path.basename(literal))
if not owners:
continue
norm = os.path.normpath(literal).replace(os.sep, "/")
exact = [o for o in owners if norm == o or o.endswith("/" + norm)]
for o in (exact or owners):
edges.add((f, o))
return sorted(edges)
def main(argv=None):
ap = argparse.ArgumentParser(description="BSP 不得反向 include App 的头(增量拦截)")
ap.add_argument("--verbose", action="store_true", help="列出全部反向边与判定")
ap.add_argument("--prune", action="store_true", help="只报告已消失、可从基线删除的条目")
args = ap.parse_args(argv)
files = tracked_files()
edges = scan(files, app_header_index(files))
live = set(e for e in edges if e[1] not in ALLOW)
allowed = [e for e in edges if e[1] in ALLOW]
new = sorted(live - BASELINE)
gone = sorted(BASELINE - live)
if args.verbose:
print("== 反向 include 全量边(含豁免)==")
for src, hdr in edges:
tag = "豁免" if hdr in ALLOW else ("基线" if (src, hdr) in BASELINE else "新增")
print(" [%s] %s -> %s" % (tag, src, hdr))
if args.prune:
if not gone:
print("[OK] 基线无需收紧(%d 条存量全部仍成立)" % len(BASELINE))
return 0
print("以下基线条目已不再成立,请从 BASELINE 删除(基线只减不增):")
for src, hdr in gone:
print(' ("%s", "%s"),' % (src, hdr))
return 0
print("分层方向:BSP 反向 include App 共 %d 处(豁免 %d / 基线内 %d / 新增 %d)"
% (len(edges), len(allowed), len(live & BASELINE), len(new)))
if gone:
print("[WARN] %d 条基线已消失,建议收紧(跑 --prune 看明细)" % len(gone))
if new:
print("")
print("[FAIL] 新增反向依赖 %d 处——BSP 只依赖相邻下层,不许 include App 层的头:" % len(new))
for src, hdr in new:
print(" %s: include \"%s\"" % (src, hdr))
print("")
print(" 正确做法(三选一):")
print(" ① 把上层能力注入给 BSP(函数指针/回调,参照 net_cfg_set_save_req_cb 的先例);")
print(" ② 由上层把数据取好再传进来(参数/结构体),BSP 不自己去拿;")
print(" ③ 若它其实是叶子工具(无状态、不依赖上层服务),可加进 ALLOW 并注明理由。")
print(" 顺手修掉存量也欢迎:跑 --prune 看哪些基线可以删。")
return 1
print("[OK] 无新增反向依赖(存量基线只减不增)")
return 0
if __name__ == "__main__":
sys.exit(main())