chore(gate): 门禁钩子改走 gate_python 启动器,worktree 统一插件模型

门禁解释器解耦
  44 条 language: system 钩子此前把解释器写成仓库检出的绝对路径,换机、
  换克隆目录都要重写。改为 entry 只写 scripts/gate_python.py + 脚本名,
  由启动器按候选链解析:$DTU_GATE_PYTHON > <仓库>/.venv >
  <仓库>/.gate/python > PATH。启动器用 execve 替换自身,并把选中的
  解释器经 DTU_GATE_PYTHON 传给子进程,保证脚本内再调 Python 时一致。
  实测 pre-commit 4.6.2 不展开 ${VAR};language: script 的 shebang 只做
  纯 PATH 改写,会把决定权交给 PATH 首个 python,故不采用。
  ci_local.py 预检识别启动器并报出解析结果。

并行工作树统一
  task-worktree 新增 bench-gate(强制台架验收)与 merge(编排式合并),
  删除 new/abort,done 标 DEPRECATED;台架通过记录改存
  .agents/worktrees/.bench-pass-log。keil_build.py 的 plugin_tree_owner
  识别 .agents/worktrees/<name> 并返回 pw:<name>。
  check_doc_links / gen_refgraph / check_layer_deps 的扫描排除面补上
  .agents;并行任务工作树方案重写为单一模型(7 节),取消工作树数量上限,
  删历史实测记录节,两处 §7.3 悬空引用改为按语义引用。

文档与约定
  本地工具路径环境变量方案 §5 重写为四节:谁跑在哪个 Python 上、写新钩子、
  预检对号入座、动环境五步。文档规范的变更记录例外名单由 5 篇减为 4 篇
  (本篇实际没有该节)。AGENTS.md 补委派验收口径三条。

验证:pre_commit run --all-files 48 钩子全 Passed;ci_local.py
--preflight-only 报 44 条经启动器 -> 仓库 .venv;keil-build 0 errors
0 warnings;check_doc_links --sections-strict 全通过。
This commit is contained in:
2026-11-15 19:39:56 +08:00
parent 9f68b7bc03
commit 96a3d15efc
17 changed files with 826 additions and 720 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ def tracked_files():
files = []
for root, dirs, names in os.walk(REPO):
dirs[:] = [d for d in dirs if d not in
(".git", ".venv", "build", ".dsh-worktrees")];
(".git", ".venv", "build", ".dsh-worktrees", ".agents")];
for n in names:
files.append(os.path.relpath(os.path.join(root, n), REPO).replace(os.sep, "/"))
return files
+154 -2
View File
@@ -61,6 +61,7 @@
"""
import argparse
import importlib
import os
import re
import shutil
@@ -343,6 +344,103 @@ def _script_self_resolves(script_rel):
r"re-?exec|ensure_python", text))
def _hook_launcher_entries():
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path)]。
收 language: system 且 entry 以 scripts/gate_python.py 开头的条目——解耦之后本仓 44 条
都是这个形态:配置里不出现任何解释器路径,由启动器在运行时解析(见
docs/环境与部署/本地工具路径环境变量方案.md §5.2)。
"""
cfg = REPO_ROOT / ".pre-commit-config.yaml"
if not cfg.is_file():
return []
out = []
cur_id = cur_entry = 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 len(toks) >= 2 and toks[0].replace("\\", "/").endswith("scripts/gate_python.py"):
out.append((cur_id, toks[1].strip("'\"")))
continue
return out
def _launcher_resolves():
"""让启动器自己报出它选中的解释器,返回 (说明, 路径);解析失败返回 (None, None)。
直接 import 启动器模块调 candidates(),而不是跑一条子进程:启动器本来就要求纯标准库
单文件,import 是它最轻的用法,也让「预检看到的解释器」与「钩子实际用的解释器」共用一份逻辑。
"""
launcher = REPO_ROOT / "scripts" / "gate_python.py"
if not launcher.is_file():
return None, None
sys.path.insert(0, str(launcher.parent))
try:
mod = importlib.import_module("gate_python")
picked = mod.pick(mod.repo_root())
except Exception:
return None, None
finally:
sys.path.pop(0)
if not picked:
return None, None
return picked
def _hook_pinned_python_entries():
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path, python_exe)]。
收 language: system 且 entry 以显式 python 可执行文件开头的条目——统一到仓库 .venv
之后本仓 44 条都是这个形态:入口解释器由 entry 写死,与 PATH 首位无关。
entry 形如 '"D:\\...\\python.exe" scripts/check_x.py'(双引号包住含空格的路径),
取不到脚本时只报入口本身的问题。扫描逻辑与 _hook_bare_python_entries 同源。
"""
cfg = REPO_ROOT / ".pre-commit-config.yaml"
if not cfg.is_file():
return []
out = []
cur_id = cur_entry = 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 not toks:
continue
exe = toks[0].strip("'\"")
if re.search(r"python(3)?(\.exe)?$", exe, re.I) and (os.sep in exe or "/" in exe):
script = toks[1].strip("'\"") if len(toks) > 1 else ""
out.append((cur_id, script, exe))
continue
return out
def _missing_modules(python_path, modules):
"""用指定解释器一次性试导入这批模块,返回它缺的那些(解释器本身跑不起来返回 None)。"""
if not modules:
@@ -396,9 +494,32 @@ def run_hook_preflight():
except (OSError, subprocess.TimeoutExpired):
ver = "?"
entries = _hook_bare_python_entries()
pinned = _hook_pinned_python_entries()
launched = _hook_launcher_entries()
need_all = set()
rows = []
lazy_all = {}
pinned_bad = []
pin_kind = None
if pinned:
exes = sorted({os.path.normcase(e) for _h, _s, e in pinned})
pin_kind = exes[0] if len(exes) == 1 else "<%d 个不同解释器>" % len(exes)
if len(exes) == 1 and exes[0] != os.path.normcase(venv):
print("[WARN] entry 里写死的解释器 = {}".format(exes[0]))
print(" 而仓库 .venv = {} —— 两者不一致:这批钩子跑的不是这个 .venv".format(venv))
pinned_bad = [(h, s, e) for h, s, e in pinned if not os.path.isfile(e)]
for hook_id, script, exe in pinned:
if not script:
continue
forced, hinted, _lazy = _script_third_party(script)
need = set(forced.keys()) | set(hinted.keys())
if not need:
continue
lack = _missing_modules(exe, need)
if lack is None:
pinned_bad.append((hook_id, script, "{} 探测失败".format(exe)))
elif lack:
pinned_bad.append((hook_id, script, "{} 缺 {}".format(exe, ", ".join(sorted(lack)))))
for hook_id, script in entries:
forced, hinted, lazy = _script_third_party(script)
if forced or hinted:
@@ -411,8 +532,39 @@ def run_hook_preflight():
if missing is None:
print("[ERROR] 探测解释器失败:{}(跑不起来,预检无法完成)".format(path_first))
return 2
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
len(entries), path_first, ver))
if launched:
label, resolved = _launcher_resolves()
if resolved is None:
print("[WARN] scripts/gate_python.py 存在 {} 条钩子在用,但启动器解析不出可用解释器".format(
len(launched)))
else:
same = os.path.normcase(resolved) == os.path.normcase(venv)
print("[INFO] 经启动器找解释器的钩子:{} 条 -> {}({}){}".format(
len(launched), resolved, label,
"" if same else " ⚠ 与仓库 .venv 不一致"))
if not same:
print(" 这批钩子跑的不是仓库 .venv:先确认这是有意的(DTU_GATE_PYTHON 覆盖?)还是 .venv 缺失")
if pinned:
print("[INFO] entry 写死了解释器的钩子:{} 条,都指向 {}(入口解释器与 PATH 无关)".format(
len(pinned), pin_kind))
if entries:
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
len(entries), path_first, ver))
elif launched:
print("[INFO] language: system 用裸 python 的条目:0 条(全部经启动器或写死解释器)")
else:
print("[INFO] language: system 用裸 python 的条目:0 条(全部显式指定了解释器)")
if pinned_bad:
print("")
print("[ERROR] 以下钩子的入口解释器不可用,跑到它时必然失败:")
for hook_id, script, why in pinned_bad:
print(" - {}:{} -> {}".format(hook_id, script, why))
print(" 修法:确认 .venv 存在(重建见 docs/环境与部署/本地工具路径环境变量方案.md §5.4),"
"或把该 entry 改回裸 python(前提见同文 §5.2)。")
return 2
if not entries:
print("[OK] 预检通过:钩子入口解释器全部可用,且没有裸 python 条目")
return 0 if not launched or _launcher_resolves()[1] else 2
print("[INFO] 这些条目累计需要的第三方包:{};该解释器缺:{}".format(
", ".join(sorted(need_all)) or "(无,全是纯标准库)", ", ".join(sorted(missing)) or "(无)"))
bad = [(h, s, f) for (h, s, f, _h2, resolved) in rows
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""门禁钩子的统一启动器:替钩子找到该用的解释器,再原样转交脚本。
被提交的 .pre-commit-config.yaml 只写 `entry: scripts/gate_python.py <脚本> [参数...]`,
不出现任何机器相关的解释器路径——换机、换克隆目录、换 PATH 首位都不必改配置。
解释器按下面的顺序找,第一个可用的胜出:
1. $DTU_GATE_PYTHON 显式覆盖(临时切版本、排查「到底跑在谁身上」)
2. <仓库>/.venv/Scripts/python.exe 约定的仓库内环境(requirements.txt 装在这里)
3. <仓库>/.gate/python/python.exe 便携解释器(可选,本仓当前未提供)
4. PATH 上的 python 最后兜底
约定纪律(改动前先读 docs/环境与部署/本地工具路径环境变量方案.md §5.1):
- 纯标准库、单文件、不 import 仓库内任何模块——它运行在「解释器还不知道是谁」的时刻;
- 解析出的解释器用环境变量 DTU_GATE_PYTHON 传给子进程,脚本内部再调 Python 时能与钩子保持一致,
否则「门禁用的版本」和「生成器用的版本」会分叉。
退出码:0 表示被调脚本自己退出为 0;启动器自身失败一律 2(与 pre-commit「环境不可用」口径一致,
见 docs/草稿约定:2 = 配置/环境问题,1 = 检查结论不通过)。
"""
from __future__ import annotations
import os
import sys
LAUNCHER_NAME = "gate_python.py"
def repo_root() -> str:
"""启动器所在仓库根(本文件位于 <仓库>/scripts/ 下)。"""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def candidates(root: str) -> list[tuple[str, str]]:
"""返回 [(说明, 解释器路径)],顺序即优先级。"""
items: list[tuple[str, str]] = []
override = os.environ.get("DTU_GATE_PYTHON")
if override:
items.append(("$DTU_GATE_PYTHON", override))
items.append(("仓库 .venv", os.path.join(root, ".venv", "Scripts", "python.exe")))
items.append(("便携解释器 .gate", os.path.join(root, ".gate", "python", "python.exe")))
path_python = path_lookup("python")
if path_python:
items.append(("PATH", path_python))
return items
def path_lookup(name: str) -> str:
"""按 Windows 语义在 PATH 里找可执行文件(含 PATHEXT 展开)。"""
exts = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(os.pathsep)
for directory in os.environ.get("PATH", "").split(os.pathsep):
if not directory:
continue
for ext in [""] + exts:
candidate = os.path.join(directory, name + ext.lower())
if os.path.isfile(candidate):
return candidate
return ""
def pick(root: str) -> tuple[str, str] | None:
for label, exe in candidates(root):
if exe and os.path.isfile(exe):
return label, exe
return None
def fail(message: str, root: str) -> int:
print(f"[gate_python] {message}", file=sys.stderr)
print("[gate_python] 解释器候选检查结果:", file=sys.stderr)
for label, exe in candidates(root):
state = "可用" if exe and os.path.isfile(exe) else "不存在"
print(f" {label:<16} {exe or '<未设置>'} [{state}]", file=sys.stderr)
print(
"[gate_python] 修复:按下面任一方式让第 2 个候选就位——\n"
" python -m venv .venv && .venv/Scripts/python.exe -m pip install -r requirements.txt\n"
" 或临时指定:set DTU_GATE_PYTHON=<某个装了依赖的 python.exe>\n"
" 详细规程 → docs/环境与部署/本地工具路径环境变量方案.md §5",
file=sys.stderr,
)
return 2
def main(argv: list[str]) -> int:
root = repo_root()
if len(argv) < 2:
return fail("缺少目标脚本参数(用法:gate_python.py <脚本> [参数...])", root)
target = argv[1]
if not os.path.isabs(target):
target = os.path.join(root, target)
if not os.path.isfile(target):
return fail(f"目标脚本不存在:{argv[1]}", root)
chosen = pick(root)
if chosen is None:
return fail("找不到可用的解释器", root)
label, exe = chosen
if os.environ.get("DTU_GATE_VERBOSE"):
print(f"[gate_python] {label} -> {exe}", file=sys.stderr)
env = dict(os.environ)
env["DTU_GATE_PYTHON"] = exe
child_argv = [exe, target, *argv[2:]]
os.execve(exe, child_argv, env)
return 0 # 不会走到这里:execve 成功即替换进程
if __name__ == "__main__":
sys.exit(main(sys.argv))