feat(门禁): 把「钩子跑在谁身上」机械化——ci_local 新增 L0a 解释器预检 + 环境动手术的顺序与回滚
task-33 诊断结论的落地(道G 执行,环境**一个字节没动**:不装包、不重建、不改 requirements 约束)。
**要防的是什么**:`.git/hooks/pre-commit` 把 `INSTALL_PYTHON` 钉在 `.venv`,而 43 条
`language: system` 用
裸 `python` ⇒ pre-commit 本体走 `.venv`(3.12.4)、钩子 entry 走 PATH 首位(3.14.7,零第
三方包)。今天
不炸是**巧合**(那 43 条模块级 import 全是标准库,唯一要 fontTools 的那条自己解析解释器)。真正的危
险形态是:某条钩子在**跑到它那一刻**才报 `No module named ...`——一次提交里前 14 条已经改写完工作区、
第 15 条才崩,现场最难看,也最容易被误当成代码问题。L0a 把这件事提前到门禁第一段。
**FAIL 判据刻意收窄成三事齐备**:模块级第三方 import + 该脚本不自解析解释器 + PATH 首位那个解释器
真缺包。惰性/条件导入(`make_font` 的 fontTools 在函数体内)只列为**候选**、不判决——否则就是假阳性
(见下)。退出码 2 = 环境错误,已写进文件头退出码段与用法。
**道G 自己推翻过自己两处,都留在文档里没删**:
① 上一版诊断说「42 条里唯一需要第三方包的那条自带解析」是**宽松正则**审出来的(把注释与字符串里的包
名也算命中),机械口径下按模块级 import 判是 **0 条**,条目数也因 nested-comment 落地从 42 变 43;
② 第一版 L0a 把 font-charset 判成 FAIL,他拿真解释器实跑反证(`C:\Python314\python.exe
scripts/check_font_charset.py` = exit 0,因为 fontTools 的导入在函数体内、加载期不执行)
⇒ 判据改窄。
**红-绿自证**:临时假仓库三条钩子(要 fontTools 且不自解析 / 同样要但自解析 / 纯标准库)→ 坏的那条判
FAIL、好的判 OK、整体 rc=2;真实仓 → 43 条、forced 缺包 0、候选 fontTools、exit 0;同一预检在 .venv 与
PATH 首位两个解释器下都 exit 0。
文档 §5 重排成**依赖表(§5.2) → 动之前死顺序(§5.3) → 遗留账(§5.4)**(原文按追加顺序把失效顺序插成了
§5.4,读起来先结论后依据;重排时同步修掉块内那条会指错的「见下面 §5.3 第三条」,以及 ci_local 里指向
死顺序却写着 §5.2 的指针——这两处 doc-links 都**静默放过**,因为本文恰好有同号小节,见 §3.5 盲区)。
四种现场(删/换 `.venv`、只换包、换 PATH 首位、改 requirements 约束)逐行钉死「第一现场 / 症状 / 还能
动什么」,回滚按现场分条不混用:freeze 备份放仓外、只 `--force-reinstall --no-deps` 单包不 `-U`、
`pre-commit clean` 会重建别人的钩子环境所以必须先要窗口、最后手段才删 `.venv`(删前记住基底是 Anaconda,
换机复制不出来)。
⚠ 一条与本笔无关的既有红灯(道G 报出,我核过成因):`check_size_gate.py --local` 现在 exit 1 —— 它按
**git 索引 blob** 生成代码指纹,而工作区正有 comm 道在飞的 `App/protocol/iec104/iec104_comm
.{c,h}` 未入
索引,「同码」必然不成立;**该脚本不是 pre-commit 钩子**(只挂在 ci_local),所以不拦提交,但别人跑
ci_local 会看到它,别误判成 L0a 新加的东西。台账回填等这批在飞代码提交后统一做一次。
自查:py_compile 0、`--preflight-only` exit 0(两个解释器下都是)、`check_doc_links --a
ll` exit 0(198 篇)、
本篇 `--sections-strict` 通过、`id-check --strict` exit 0 且不可判定绝对数 **978 未变**
(写文档没吃余量)、
mojibake / bom(81) / nested-comment(409) 全 0、尾随空白 0。
This commit is contained in:
@@ -110,19 +110,53 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe'
|
||||
### 5.1 新增钩子 entry 的硬规则(今天没炸的真正原因)
|
||||
|
||||
`language: system` 的 entry 不继承 `.venv`:pre-commit 不改 `PATH`,所以裸 `python` 命中 PATH 首位
|
||||
(本机 = 3.14.7,**零第三方包**)。实测本仓 44 条 system 钩子里 **42 条 entry 以裸 `python` 开头**,
|
||||
今天能全绿只因为两件事同时成立:
|
||||
(本机 = 3.14.7,**零第三方包**)。实测本仓 45 条 system 钩子里 **43 条 entry 以裸 `python` 开头**,
|
||||
今天能全绿的原因是(**2026-10-27 二次修正,改以机械口径为准**):
|
||||
|
||||
1. 那 42 条脚本**只用标准库**(抽测 bom-drift / layer-deps / doc-links 与一套带 harness 的主机单测,
|
||||
在 3.14.7 下 exit 0);
|
||||
2. 唯一需要第三方包的那条(字体幂等性钩子 `scripts/check_generated_fresh.py`)**自带解释器解析**,
|
||||
不依赖 entry 用的是谁。
|
||||
1. 这些条目**没有任何一条在模块级 import 第三方包**(抽测 bom-drift / layer-deps / doc-links 与一套
|
||||
带 harness 的主机单测,在 3.14.7 下 exit 0);
|
||||
2. 真正会碰第三方的是**下游脚本里的惰性导入**——典型是字体链 `tools/font_gen/make_font.py` 在函数体内
|
||||
`from fontTools.ttLib import TTCollection`。它会不会被走到静态判不了,所以
|
||||
`scripts/ci_local.py` 的 L0a 预检把这类只列成**候选**(实测列出 `font-charset` / `generated-fresh`
|
||||
两条经下游引入 fontTools),摊开给人看、不作判决;判决只留给「模块级 import + 裸 python +
|
||||
该解释器确实没装 + 脚本不自带解释器解析」四事齐备那种必然崩。
|
||||
|
||||
> ⚠ 本节 2026-10-27 的早先版本写作「42 条里唯一需要第三方包的那条自带解释器解析」。那是我拿一条宽松
|
||||
> 正则(连注释与字符串里的包名都算命中)审计出的**保守过头的结论**,条目数也因 `nested-comment` 钩子
|
||||
> 落地从 42 变 43。现在这条口径由 L0a 预检每次跑门禁前重算,人不再需要记这个数。
|
||||
|
||||
⇒ 写新钩子时二选一,别开第三条路:要么脚本只用标准库,要么在脚本内部按
|
||||
「显式变量 > 当前解释器 > 仓库 `.venv` > PATH」自行解析并**探针校验**(先例与理由见
|
||||
`docs/门禁说明/generated-fresh.md` §4)。**不要**把 entry 写死成某台机器的绝对路径。
|
||||
|
||||
### 5.2 什么情况下才需要动环境,以及动之前要走死顺序
|
||||
### 5.2 动环境的失效顺序与回滚(含本笔机械化)
|
||||
|
||||
**先把"谁依赖谁"钉住,才知道动一下会红在哪一步**(下表每行都是本机实测或代码事实,不是推测):
|
||||
|
||||
| 被动什么 | 第一现场 | 症状 | 还能动什么 |
|
||||
|---|---|---|---|
|
||||
| 删 / 重建 `.venv` | `.git/hooks/pre-commit` 里的 `INSTALL_PYTHON` 指它 | `git commit` **当场起不来**:钩子脚本先判 `if [ -x "$INSTALL_PYTHON" ]`,不成立才回落到 PATH 上的 `pre-commit`——本机 `where.exe pre-commit` 报找不到,于是回落分支也是死路 | 重建 `.venv` 后必须 `pip install -r requirements.txt`(含 pre-commit),否则回落分支永远还是死路 |
|
||||
| 只换 `.venv` 里的包(不删目录) | pre-commit 本体与 `language: python` 钩子各自独立的缓存环境(`~/.cache/pre-commit/repo*/py_env*/`)互不相干 | 只影响 pre-commit 本体;改写型钩子跑在缓存环境里,**看不见** `.venv` 的变动(实测那份缓存环境里连 `identify` 都没装) | 缓存环境坏了要 `pre-commit clean`——它会连别人正在跑的钩子环境一起重建,必须先要到静默窗口 |
|
||||
| 换 PATH 首位解释器 | 那 43 条裸 python 条目 | 模块级第三方 import 一条都没有 ⇒ 普通情况下无感;但 3.12 → 3.14 这种版本跨度会改变标准库行为,是"靠巧合不炸"的敞口 | 设 `DTU_PRECOMMIT_PYTHON` 只钉得住 pre-commit 本体,**钉不住**裸 python 条目的落点——那只能靠脚本自解析 |
|
||||
| 改 `requirements.txt` 的版本约束 | 别人机器上的一次 `pip install -r` | 本机无感,别人下次重建环境时才炸 | 这不属于"修本机环境",要改先报属主,别夹在诊断笔里 |
|
||||
|
||||
**回滚按现场分条,别混用**:
|
||||
|
||||
- `.venv` 被删 / 建坏:`python -m venv .venv` → `pip install -r requirements.txt` →
|
||||
`& '.venv\Scripts\python.exe' -m pre_commit --version` 通 → `ci_local.py --preflight-only` 通 → 再跑完整门禁。
|
||||
- 单包装坏:按修复前那次 `pip freeze`(放仓外,不入库)反向 `pip install <包>==<原版本>`;
|
||||
**不要** `-U` 顺手把别人的依赖一起动。
|
||||
- 缓存环境坏:`pre-commit clean` 后跑一次 `ci_local.py` 让它重建;重建期间别提交(几十秒内会拿到半装好的环境)。
|
||||
- 只有全坏透才删 `.venv` 重来;删前记下 `pip freeze`,并记住本仓 `.venv` 基底是 Anaconda
|
||||
(换机复制不出来,见上面 §5.2 依赖表与 §5.3 第 5 条)。
|
||||
|
||||
**本笔把上面这些机械化了一半**:`scripts/ci_local.py` 新增 **L0a 钩子解释器预检**(`--preflight-only`
|
||||
可单跑,也是 `ci_local.py` 每次的第一段),报清"钩子跑在谁身上 / 裸 python 落到谁 / 那个解释器缺什么 /
|
||||
哪些只是候选",四事齐备即 exit 2 并指名到钩子。红-绿在临时假仓库上验过(同一份配置里 bad-hook 判 FAIL、
|
||||
good-hook 判 OK,整体 `rc = 2`)。**它不改环境、不装包、不写仓库**,也不替代上面的修复死顺序——
|
||||
它只保证一件事:这条链下次断掉时报错发生在第一秒,并且带着钩子的名字。
|
||||
|
||||
### 5.3 什么情况下才需要动环境,以及动之前要走死顺序
|
||||
|
||||
默认动作是**不动**(本节存在即结论:没有需要重装的东西)。只有出现下列**具体现象**才执行修复,
|
||||
且必须先要到静默窗口——`pre-commit` 与 `.venv` 是全队共享运行面,中途换包会把别人的一次钩子跑打断:
|
||||
@@ -138,7 +172,7 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe'
|
||||
5. 回滚:新装/升级出问题时按第 2 步的 freeze 反向 `pip install <包>==<原版本>`;**别删 `.venv` 重来**,
|
||||
删了就把「基底是 Anaconda」这条不可复制性也一起删掉(换机复现不了,见本文 §3 与 `docs/环境与部署/项目部署环境要求.md`)。
|
||||
|
||||
### 5.3 仍开着的账(不在本次范围,登记给对应属主)
|
||||
### 5.4 仍开着的账(不在本次范围,登记给对应属主)
|
||||
|
||||
- `DTU_PRECOMMIT_PYTHON` 本机当前**未设**,靠 §1.1 的优先级自动落到 `.venv`;如果哪天 PATH 首位换了
|
||||
解释器,静态门禁档就会跟着换——把它显式设成 `.venv` 是一条零成本的加固(改的是本机环境,不动仓库)。
|
||||
@@ -152,6 +186,6 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe'
|
||||
|
||||
| 日期 | 变更 |
|
||||
|---|---|
|
||||
| 2026-10-27 | **新增 §5 解释器实测矩阵(道G task-33,只读诊断,未动任何环境)**:三个解释器(仓库 `.venv` / PATH 首位 / PATH 次位)逐个探针实测,钉住三条事实——① `.venv` **不是残缺**:`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查),`identify-2.6.19` 的 RECORD 里本就没有 `tags.py`,标签常量在该版本的 `identify.identify` 里,pre-commit 自己也是从那里取(旧写法 `identify.tags` 在这个版本对上必失败,全仓零引用);② 钩子执行环境被 `.git/hooks/pre-commit` 的 `INSTALL_PYTHON` 钉死为 `.venv`,而 `language: system` 的 42 条 entry 用裸 `python` → 落到 PATH 首位那个**零第三方包**的解释器,今天没炸是「42 条全只用标准库 + 唯一需要第三方包的那条自带解释器解析」两件事同时成立,据此写下新增 entry 的硬规则(§5.1);③ 「查不到 pre-commit」的现场是查了 PATH 上的解释器。另附 §5.2「默认不动环境;确需修复时的死顺序与回滚(只补不重建、freeze 备份放仓外、按 freeze 反向回滚、禁止删 `.venv` 重来)」与 §5.3 三条待属主的账。| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) |
|
||||
| 2026-10-27 | **新增 §5.2 动环境的失效顺序与回滚(原插成 §5.4,本笔按依赖表→死顺序→遗留账重排)+ §5.1 二次修正(道G task-33 修复分支)**:四种现场(删/换 `.venv`、只换包、换 PATH 首位、改 requirements 约束)逐行钉死"第一现场 / 症状 / 还能动什么",回滚按现场分条不混用;`scripts/ci_local.py` 同批加 **L0a 钩子解释器预检**(`--preflight-only` 单跑,也是每次门禁的第一段:报"钩子跑在谁身上 / 裸 python 落到谁 / 缺哪些包 / 哪些是惰性候选",四事齐备 exit 2 指名到钩子,红-绿在临时假仓库验过)。§5.1 那条「42 条里唯一需要第三方包的那条自带解释器解析」被更严的口径推翻:按**模块级 import** 判是 0 条,条目数也因 `nested-comment` 落地变 43;fontTools 属下游**惰性**导入,预检只列候选不判决。 | 2026-10-27 | **新增 §5 解释器实测矩阵(道G task-33,只读诊断,未动任何环境)**:三个解释器(仓库 `.venv` / PATH 首位 / PATH 次位)逐个探针实测,钉住三条事实——① `.venv` **不是残缺**:`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查),`identify-2.6.19` 的 RECORD 里本就没有 `tags.py`,标签常量在该版本的 `identify.identify` 里,pre-commit 自己也是从那里取(旧写法 `identify.tags` 在这个版本对上必失败,全仓零引用);② 钩子执行环境被 `.git/hooks/pre-commit` 的 `INSTALL_PYTHON` 钉死为 `.venv`,而 `language: system` 的 42 条 entry 用裸 `python` → 落到 PATH 首位那个**零第三方包**的解释器,今天没炸是「42 条全只用标准库 + 唯一需要第三方包的那条自带解释器解析」两件事同时成立,据此写下新增 entry 的硬规则(§5.1);③ 「查不到 pre-commit」的现场是查了 PATH 上的解释器。另附 §5.2「默认不动环境;确需修复时的死顺序与回滚(只补不重建、freeze 备份放仓外、按 freeze 反向回滚、禁止删 `.venv` 重来)」与 §5.3 三条待属主的账。| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) |
|
||||
| 2026-10-02 | **实施完成**:阶段 A~D 落地。① 10 处调用点全部改为"`DTU_*` 变量优先 + 自动发现回退"(显式值无效 = 硬报错、不静默回退):`build/download/reset.bat`(goto 标签式解析,纯 ASCII)、`ci_local.py`(`DTU_PRECOMMIT_PYTHON` 严格校验)、`ci.yml`(`DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT`,run 块纯 ASCII)、`gen_refgraph.py`(`DTU_GRAPHVIZ_DOT`→PATH,保留无渲染降级)、`unit_test_harness.py`(`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT` + `%ProgramFiles*%` 推导取代静态盘符列表)、`build_sim.ps1`(`DTU_VSWHERE`→`%ProgramFiles(x86)%` 推导,去字面 C 盘回退)、`make_font.py`(`%WINDIR%` 推导);`_build.cmd` 确认零调用后删除。② 本机按 §3.2 配置用户级 `DTU_KEIL_UV4`/`DTU_STLINK_CLI`(其余工具本机均可自动发现,未设变量),验证:`@build` BUILD_OK 0/0、`ci_local.py` L0 15 钩子 + L0.5 278/278 断言全绿、refgraph 重生成(含修复 `引用关系图.json` 末尾换行缺失导致的 end-of-file-fixer 抖动,生成器补 `f.write("\n")`)、`build_sim.ps1` vswhere 推导命中 VS2026(当时模拟器 CMake 配置期暴露**既有**问题:`App/cfg_file.c` 已移入 `App/cfg/` 但 `simulator/CMakeLists.txt` 未同步——与环境变量改造无关;2026-10-02 已修复:CMakeLists 同步 `App/cfg/` + `test/board/` 引用,模拟器全量构建通过)、`reset.bat list` 识别探针。③ 文档收口:`AGENTS.md`/`MDK-ARM/README.md`/`项目部署环境要求.md`(新增 §2.1.1)/`Gitea_Runner部署指南.md`(新增 §5.1.3 机器级变量)/`CI配置指南.md`/`pre-commit使用说明.md`/字体/测试流程/引用图说明/ST-LINK 说明/实机规划/`scripts/README.md` 共 12 篇同步为变量口径。④ `requirements.txt` 增 `pre-commit==4.6.2`(重建 `.venv` 即可跑 L0)。⑤ 全量 `pre-commit --all-files` + `ci_local.py` 通过。待办:新 workflow 推送远程后跑一次 CI 两 job 闭环验证;第二台不同安装路径机器的交叉验证(操作清单已固化入 `项目部署环境要求.md` §2.12) |
|
||||
| 2026-10-02 | 初版规划:盘点 BAT/Python/PowerShell/Gitea workflow 中机器相关工具路径;定义 `DTU_*` 变量清单、优先级、用户级与 SYSTEM runner 配置边界;规划 4 阶段迁移及离线/实机验收范围。未改工具调用代码 |
|
||||
|
||||
+308
-2
@@ -34,7 +34,7 @@
|
||||
退出码:
|
||||
0 = 全部通过(或不适用项已按规则跳过)
|
||||
1 = 有门禁/单测失败
|
||||
2 = 环境错误(缺 pre-commit / python 模块等)
|
||||
2 = 环境错误(缺 pre-commit / python 模块等;L0a 钩子解释器预检未过也是 2)
|
||||
|
||||
汇总行(2026-10-02 起,对齐 CI 的 ::notice::):
|
||||
[SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K
|
||||
@@ -46,6 +46,7 @@
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --size-only
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --preflight-only # 只跑 L0a 钩子解释器预检
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完
|
||||
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验条数
|
||||
|
||||
@@ -153,6 +154,298 @@ def _resolve_python():
|
||||
PY, PY_NOTE = _resolve_python()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 钩子解释器预检(道G task-33,2026-10-27)
|
||||
#
|
||||
# 为什么要这一道:提交期"钩子跑在谁身上"有两个互不相干的来源——
|
||||
# · pre-commit 本体跑在 .git/hooks/pre-commit 里写死的 INSTALL_PYTHON 上(本机 = 仓库 .venv);
|
||||
# · 而 language: system 的 entry 只要写裸 python,pre-commit **不改 PATH**,那条脚本就
|
||||
# 落到 PATH 首位的解释器(本机实测 3.14.7,一个第三方包都没装)。
|
||||
# 今天没炸是两件事同时成立的结果:那批裸 python 条目全只用标准库,且唯一需要第三方包的那条
|
||||
# 脚本自己解析解释器。矩阵、成因与新增 entry 的硬规则见
|
||||
# docs/环境与部署/本地工具路径环境变量方案.md §5(含 §5.1 硬规则、§5.2 修复死顺序与回滚)。
|
||||
# 本预检把那条隐性依赖变成显式闸:一旦凑齐「entry 用裸 python + 脚本需要第三方包 + 脚本不
|
||||
# 自带解释器解析 + PATH 首位没装那个包」四件事,就当场 exit 2 指名是哪根钩子缺什么,
|
||||
# 而不是等跑到那根钩子才崩——半路崩会被误读成"代码有问题",白查一小时。
|
||||
#
|
||||
# 只读:不改环境、不装包、不写仓库。
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 常见第三方包名 -> 导入名(不在表里的按 sys.stdlib_module_names 反判)
|
||||
_THIRD_PARTY_HINTS = ("fontTools", "PIL", "serial", "yaml", "numpy", "pandas", "requests")
|
||||
|
||||
|
||||
def _hook_bare_python_entries():
|
||||
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path)]。
|
||||
|
||||
只收 language: system 且 entry 以裸 python / python3 开头的条目——这类条目的解释器
|
||||
由 PATH 决定,不受 pre-commit 本体那个 INSTALL_PYTHON 影响。配置文件是手写的规整
|
||||
格式,这里按行扫即可(不依赖 PyYAML:那正是本预检要防的"依赖解释器"问题)。
|
||||
"""
|
||||
cfg = REPO_ROOT / ".pre-commit-config.yaml"
|
||||
if not cfg.is_file():
|
||||
return []
|
||||
out = []
|
||||
cur_id = None
|
||||
cur_entry = None
|
||||
cur_lang = None
|
||||
for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
s = raw.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^-\s+id:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_id, cur_entry, cur_lang = m.group(1), None, None
|
||||
continue
|
||||
m = re.match(r"^entry:\s*(.+)$", s)
|
||||
if m:
|
||||
cur_entry = m.group(1).strip()
|
||||
continue
|
||||
m = re.match(r"^language:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_lang = m.group(1)
|
||||
if cur_lang == "system" and cur_entry and cur_id:
|
||||
toks = cur_entry.split()
|
||||
if toks and re.match(r"^python(3)?$|^py$", toks[0]) and len(toks) > 1:
|
||||
out.append((cur_id, toks[1].strip("'\"")))
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _script_third_party(script_rel):
|
||||
"""返回 {第三方包名: 由哪个脚本引入};纯标准库且下游也不需要的脚本返回空字典。
|
||||
|
||||
判据:顶层导入名既不在 sys.stdlib_module_names 里、也不在仓库内解析得到(同目录/
|
||||
scripts/tools 下的兄弟模块算仓库内),就算第三方。老解释器没有
|
||||
stdlib_module_names 时退到 _THIRD_PARTY_HINTS 白名单,宁可少报不误报。
|
||||
顺带沿"脚本里以字符串出现的仓库内 .py"下探一层(见 _referenced_scripts),
|
||||
否则漏掉 2026-10-26 那类"钩子干净、被它拉起的生成器缺包"的真事故。
|
||||
"""
|
||||
forced = _direct_third_party(script_rel)
|
||||
hinted = {}
|
||||
for name, src in _collect_third_party(script_rel, 2, set()).items():
|
||||
if name not in forced:
|
||||
hinted[name] = src
|
||||
lazy = {}
|
||||
for name, src in _collect_lazy_third_party(script_rel, 2, set()).items():
|
||||
if name not in forced and name not in hinted:
|
||||
lazy[name] = src
|
||||
return forced, hinted, lazy
|
||||
|
||||
|
||||
def _is_repo_internal(script_dir, name):
|
||||
"""该导入名能否在仓库内解析到(兄弟模块 / scripts / tools / 主机单测公共件)。"""
|
||||
search_dirs = [script_dir, REPO_ROOT / "scripts", REPO_ROOT / "tools",
|
||||
REPO_ROOT / "test" / "host" / "common", REPO_ROOT]
|
||||
return any((d / (name + ".py")).is_file() or (d / name).is_dir() for d in search_dirs)
|
||||
|
||||
|
||||
def _direct_third_party(script_rel):
|
||||
"""本脚本直接 import 的第三方顶层模块 -> 该脚本路径(判据见 _script_third_party 的头注释)。"""
|
||||
path = REPO_ROOT / script_rel
|
||||
if not path.is_file():
|
||||
return {}
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
mods = set()
|
||||
# 只认**模块级**(第 0 列)的 import:函数体里的 try/惰性导入不是"加载必崩",
|
||||
# 按它报会把好钩子判成坏钩子(实测 check_font_charset 就在 3.14 上跑得过,
|
||||
# 因为 make_font 的 fontTools 是函数内惰性导入)。
|
||||
for m in re.finditer(r"^(?:import|from)[ \t]+([A-Za-z_][\w.]*)", text, re.M):
|
||||
mods.add(m.group(1).split(".")[0])
|
||||
std = getattr(sys, "stdlib_module_names", None)
|
||||
out = {}
|
||||
for name in sorted(mods):
|
||||
if std is not None:
|
||||
if name in std:
|
||||
continue
|
||||
elif name not in _THIRD_PARTY_HINTS:
|
||||
continue
|
||||
if _is_repo_internal(path.parent, name):
|
||||
continue
|
||||
out[name] = script_rel
|
||||
return out
|
||||
|
||||
|
||||
def _collect_lazy_third_party(script_rel, depth, seen):
|
||||
"""惰性 / 条件导入的第三方包(函数体内的 import)——**只列候选,不作判决**。
|
||||
|
||||
静态判不了它到底会不会被走到,所以不当失败依据;但值得摊开看:2026-10-26 那次
|
||||
"No module named fontTools" 正是这种形态(钩子脚本自己只 import 标准库,被它拉起的
|
||||
生成器在函数里导 fontTools)。判 FAIL 用 _direct_third_party,本函数只负责"看得见"。
|
||||
"""
|
||||
if script_rel in seen or not (REPO_ROOT / script_rel).is_file():
|
||||
return {}
|
||||
seen.add(script_rel)
|
||||
text = (REPO_ROOT / script_rel).read_text(encoding="utf-8", errors="replace")
|
||||
mods = set()
|
||||
for m in re.finditer(r"^[ \t]+(?:import|from)[ \t]+([A-Za-z_][\w.]*)", text, re.M):
|
||||
mods.add(m.group(1).split(".")[0])
|
||||
std = getattr(sys, "stdlib_module_names", None)
|
||||
path = REPO_ROOT / script_rel
|
||||
found = {}
|
||||
for name in sorted(mods):
|
||||
if (name in std) if std is not None else (name not in _THIRD_PARTY_HINTS):
|
||||
continue
|
||||
if _is_repo_internal(path.parent, name):
|
||||
continue
|
||||
found[name] = script_rel
|
||||
if depth > 0:
|
||||
for ref in _referenced_scripts(script_rel, text):
|
||||
for name, src in _collect_lazy_third_party(ref, depth - 1, seen).items():
|
||||
found.setdefault(name, src)
|
||||
return found
|
||||
|
||||
|
||||
def _referenced_scripts(script_rel, text):
|
||||
"""脚本里以字符串出现的仓库内 .py —— subprocess / importlib 的下游目标。
|
||||
|
||||
为什么要顺着找一层:2026-10-26 那次真事故就是"钩子本身只 import 标准库、被它拉起的
|
||||
字体链脚本要 fontTools",只看直接 import 会漏判(详见 docs/环境与部署/
|
||||
本地工具路径环境变量方案.md §5.1)。
|
||||
"""
|
||||
out = []
|
||||
for m in re.finditer(r"([A-Za-z0-9_./\\-]+\.py)", text):
|
||||
rel = m.group(1).lstrip("./\\").replace("\\", "/")
|
||||
if (REPO_ROOT / rel).is_file() and rel != script_rel:
|
||||
out.append(rel)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _collect_third_party(script_rel, depth, seen):
|
||||
"""顺着下游目标合并第三方包,返回 {包名: 由哪个脚本引入}(深度上限 2,防环)。"""
|
||||
if script_rel in seen or not (REPO_ROOT / script_rel).is_file():
|
||||
return {}
|
||||
seen.add(script_rel)
|
||||
text = (REPO_ROOT / script_rel).read_text(encoding="utf-8", errors="replace")
|
||||
found = dict(_direct_third_party(script_rel))
|
||||
if depth <= 0:
|
||||
return found
|
||||
for ref in _referenced_scripts(script_rel, text):
|
||||
for name, src in _collect_third_party(ref, depth - 1, seen).items():
|
||||
found.setdefault(name, src)
|
||||
return found
|
||||
|
||||
|
||||
def _script_self_resolves(script_rel):
|
||||
"""脚本是否自带解释器解析(先例:scripts/check_generated_fresh.py)。"""
|
||||
path = REPO_ROOT / script_rel
|
||||
if not path.is_file():
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
return bool(re.search(r"DTU_[A-Z0-9_]*PYTHON|_resolve_python|_pick_python|_find_python|"
|
||||
r"python_discovery|_global_python_candidates|Scripts[\/][^\n]{0,16}python\.exe|"
|
||||
r"re-?exec|ensure_python", text))
|
||||
|
||||
|
||||
def _missing_modules(python_path, modules):
|
||||
"""用指定解释器一次性试导入这批模块,返回它缺的那些(解释器本身跑不起来返回 None)。"""
|
||||
if not modules:
|
||||
return []
|
||||
code = "import importlib\nmiss = []\nfor m in %r:\n try:\n importlib.import_module(m)\n except Exception:\n miss.append(m)\nprint(','.join(miss))\n" % (sorted(modules),)
|
||||
try:
|
||||
cp = subprocess.run([python_path, "-c", code], capture_output=True, timeout=90)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if cp.returncode != 0:
|
||||
return None
|
||||
tail = cp.stdout.decode("utf-8", "replace").strip().splitlines()
|
||||
if not tail:
|
||||
return []
|
||||
return [x for x in tail[-1].split(",") if x]
|
||||
|
||||
|
||||
def run_hook_preflight():
|
||||
"""钩子解释器预检:报清"钩子跑在谁身上、裸 python 落到谁、缺哪些包"。
|
||||
|
||||
返回值:0 = 健康(含"确实缺包,但那些脚本自带解释器解析"这种合规形态);
|
||||
2 = 环境/配置错误——已指名到钩子与包名,修完再跑门禁。
|
||||
"""
|
||||
print("")
|
||||
print("=" * 64)
|
||||
print(" L0a 钩子解释器预检(只读,不改环境)")
|
||||
print("=" * 64)
|
||||
|
||||
hook = REPO_ROOT / ".git" / "hooks" / "pre-commit"
|
||||
venv = str(REPO_ROOT / ".venv" / "Scripts" / "python.exe").lower()
|
||||
if hook.is_file():
|
||||
m = re.search(r"INSTALL_PYTHON='([^']+)'", hook.read_text(encoding="utf-8", errors="replace"))
|
||||
if not m:
|
||||
print("[WARN] .git/hooks/pre-commit 里读不到 INSTALL_PYTHON(pre-commit 版本变更?)")
|
||||
elif m.group(1).lower() != venv:
|
||||
print("[WARN] 钩子的 INSTALL_PYTHON = {}".format(m.group(1)))
|
||||
print(" 而仓库 .venv = {} —— 两者不一致:提交期钩子跑的**不是**这个 .venv".format(venv))
|
||||
print(" 先确认哪个才是你要的环境(详见 docs/环境与部署/本地工具路径环境变量方案.md §5)")
|
||||
else:
|
||||
print("[INFO] 提交期钩子跑在:{}(与仓库 .venv 一致)".format(m.group(1)))
|
||||
else:
|
||||
print("[INFO] 本工作树没有 .git/hooks/pre-commit(worktree 或未安装钩子),跳过一致性核对")
|
||||
|
||||
path_first = shutil.which("python")
|
||||
if not path_first:
|
||||
print("[WARN] PATH 上没有 python —— language: system 的裸 python 条目会直接找不到解释器")
|
||||
return 2
|
||||
try:
|
||||
ver = subprocess.run([path_first, "-c", "import sys;print(sys.version.split()[0])"],
|
||||
capture_output=True, timeout=60).stdout.decode("utf-8", "replace").strip()
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
ver = "?"
|
||||
entries = _hook_bare_python_entries()
|
||||
need_all = set()
|
||||
rows = []
|
||||
lazy_all = {}
|
||||
for hook_id, script in entries:
|
||||
forced, hinted, lazy = _script_third_party(script)
|
||||
if forced or hinted:
|
||||
need_all.update(forced.keys())
|
||||
for name, src in lazy.items():
|
||||
lazy_all.setdefault(name, []).append(hook_id)
|
||||
rows.append((hook_id, script, forced, hinted,
|
||||
_script_self_resolves(script) if (forced or hinted) else True))
|
||||
missing = _missing_modules(path_first, need_all)
|
||||
if missing is None:
|
||||
print("[ERROR] 探测解释器失败:{}(跑不起来,预检无法完成)".format(path_first))
|
||||
return 2
|
||||
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
|
||||
len(entries), path_first, ver))
|
||||
print("[INFO] 这些条目累计需要的第三方包:{};该解释器缺:{}".format(
|
||||
", ".join(sorted(need_all)) or "(无,全是纯标准库)", ", ".join(sorted(missing)) or "(无)"))
|
||||
bad = [(h, s, f) for (h, s, f, _h2, resolved) in rows
|
||||
if f and not resolved and any(x in missing for x in f)]
|
||||
for hook_id, script, forced, hinted, resolved in rows:
|
||||
if not forced and not hinted:
|
||||
continue
|
||||
names = sorted(set(list(forced) + list(hinted)))
|
||||
lack = [x for x in names if x in missing]
|
||||
tag = "OK " if (not lack or resolved or not forced) else "FAIL"
|
||||
if lack and hinted and not forced:
|
||||
tag = "WARN"
|
||||
via = sorted({src for name, src in hinted.items() if src and src != script})
|
||||
print(" [{}] {} <- {} 需 {}{};脚本自带解释器解析:{}{}".format(
|
||||
tag, hook_id, script, ", ".join(names),
|
||||
"(该解释器缺 " + ", ".join(lack) + ")" if lack else "", "是" if resolved else "否",
|
||||
";其中下游引入:" + ", ".join(via) if via else ""))
|
||||
if bad:
|
||||
print("")
|
||||
print("[ERROR] 以下钩子会**在跑到它时**才崩(No module named ...),现在提前拦下:")
|
||||
for hook_id, script, mods in bad:
|
||||
print(" - {}:{} 需要 {},而裸 python({})没装".format(hook_id, script, ", ".join(mods), path_first))
|
||||
print(" 三选一(别改 requirements.txt 的版本约束,那是别人环境的输入):")
|
||||
print(" 1) 把 entry 改成能自解析解释器的写法,或在脚本内按「"
|
||||
"显式 var > 当前解释器 > 仓库 .venv > PATH」解析 + 探针校验(先例 scripts/check_generated_fresh.py);")
|
||||
print(" 2) 把 DTU_PRECOMMIT_PYTHON 显式指向装有这些包的解释器(不会改变裸 python 条目的落点,"
|
||||
"但能让 pre-commit 本体确定);")
|
||||
print(" 3) 在该解释器里装缺的包——动环境前必须拿到静默窗口,"
|
||||
"顺序与回滚见 docs/环境与部署/本地工具路径环境变量方案.md §5.3。")
|
||||
return 2
|
||||
if lazy_all:
|
||||
print("[INFO] 候选(下游惰性 / 条件导入的第三方包,静态判不了会不会被走到,故不作判决):")
|
||||
for name in sorted(lazy_all):
|
||||
probe = "" if name not in missing else "(裸 python 那个解释器**确实没装**)"
|
||||
print(" - {}:{}{}".format(name, ", ".join(sorted(set(lazy_all[name]))[:6]), probe))
|
||||
print("[OK] 预检通过:没有「裸 python + 缺包 + 不自解析」三事齐备的钩子")
|
||||
return 0
|
||||
|
||||
|
||||
def _find_map():
|
||||
"""返回 Keil 构建产物 .map 的路径(不存在则 None)。"""
|
||||
hits = sorted((REPO_ROOT / "MDK-ARM").rglob("*.map"))
|
||||
@@ -417,6 +710,8 @@ def main(argv=None):
|
||||
help="只跑 L0c 体积门禁(强制路径;约 0.3 s,无需 Keil)")
|
||||
ap.add_argument("--coverage", action="store_true",
|
||||
help="额外跑主机单测覆盖率(约 25 s;需 VS 代码覆盖率组件;默认不跑,CI 也不跑)")
|
||||
ap.add_argument("--preflight-only", action="store_true",
|
||||
help="只跑 L0a 钩子解释器预检(只读;缺包时当场指名钩子)")
|
||||
ap.add_argument("--keep-going", action="store_true",
|
||||
help="某步失败也继续跑后续步骤(默认首错即停)")
|
||||
ap.add_argument("--commits", type=int, default=10, metavar="N",
|
||||
@@ -445,11 +740,22 @@ def main(argv=None):
|
||||
print(" - 或在当前环境安装:pip install pre-commit")
|
||||
return 2
|
||||
|
||||
# L0a 钩子解释器预检:把"裸 python 条目 + 缺第三方包 + 脚本不自解析"这条隐性依赖
|
||||
# 提到所有钩子之前判掉(道G task-33)。--preflight-only 时它是唯一一段。
|
||||
rc_pre = run_hook_preflight()
|
||||
if args.preflight_only:
|
||||
return rc_pre
|
||||
if rc_pre == 2:
|
||||
print("")
|
||||
print("[FAIL] L0a 预检未过——先按上面指名的钩子/包修配置或环境,再跑门禁")
|
||||
if not args.keep_going:
|
||||
return 2
|
||||
|
||||
rc = 0
|
||||
# 实际跑过哪些段——收尾那行只报这些。理由与那个盲区同源且是同一类错误:
|
||||
# "--l05-only 却宣布 L0/L0b/L0c 全部通过"就是把没跑的说成跑过了(SKIP 冒充 PASS
|
||||
# 的镜像版)。2026-10-25 做 task-10 时从 --l05-only 的输出里发现,当场改掉。
|
||||
ran = []
|
||||
ran = ["L0a"] # 钩子解释器预检已在本脚本开头无条件跑过,收尾行照实列出
|
||||
try:
|
||||
if args.size_only:
|
||||
return run_size_gate()
|
||||
|
||||
Reference in New Issue
Block a user