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:
+154
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user