diff --git a/docs/环境与部署/本地工具路径环境变量方案.md b/docs/环境与部署/本地工具路径环境变量方案.md index f98ca74c..fc8470ae 100644 --- a/docs/环境与部署/本地工具路径环境变量方案.md +++ b/docs/环境与部署/本地工具路径环境变量方案.md @@ -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 阶段迁移及离线/实机验收范围。未改工具调用代码 | diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 9a63cbbf..456c9223 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -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()