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
+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