diff --git a/README.md b/README.md index 2cde516..523c8ef 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ AI code that passes lint on first try |---|---| | Plugin ID | `partme-codeguard-plugin` | | Hosts | ZCode, Claude Code, Codex CLI, Kimi Code | -| Current version | `0.8.2` | +| Current version | `0.10.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start | diff --git a/README.zh-CN.md b/README.zh-CN.md index 4a7e8f0..a9781a4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码 |---|---| | 插件 ID | `partme-codeguard-plugin` | | 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.8.2` | +| 当前版本 | `0.10.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 | diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index ba4b8de..0335d07 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -66,22 +66,36 @@ if __name__ == "__main__": ## 4. PreToolUse 硬拦截(exit 2)的语义边界 `pre_tool_git_guard.py` 是**唯一**会 exit 2 的 hook——且仅当: -1. 入参命令命中 `is_guarded()`:**直接**(分隔符切段、剥除段首 `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后子命令为 `git commit|push`; - 子串匹配会误伤 payload/echo 文本)**或一层解释器间接**(`bash|sh|python… <脚本>` - 的脚本文本、`-c` 内联代码按同规则扫描——`bash runner.sh` 式绕过曾连推 4 次漏网; - 拼接式 subprocess 不在静态扫描承诺内);并且 +1. 入参命令命中 `is_guarded()`:**直接**(先展开 `$(...)`/反引号内层文本到待扫面, + 再分隔符切段、剥除段首 shell 控制引导词(`if`/`then`/`do`/`while`/`!`…)与 + `NAME=VAL`/`env`/常见裸 wrapper 前缀并跳过 git 全局选项(`-C`/`-c`/…)后 + 子命令为 `git commit|push`;子串匹配会误伤 payload/echo 文本)**或一层解释器 + 间接**(`bash|sh|python… <脚本>` 的脚本文本、`-c` 内联代码按同规则扫描—— + `bash runner.sh` 式绕过曾连推 4 次漏网;`ro=$(git push …)`、`if git push; then` + 曾静默放行(实测);拼接式 subprocess 不在静态扫描承诺内);并且 + `resolve_project_roots()` 用**同一套归一化判定**收集仓库边界(含 `git -C ` + 的显式仓边界)——两处必须同源,否则 is_guarded 命中而 roots=[] → main 静默 + 放行,归一化修复被 roots 层击穿(0.8.2 实测:`git -C`/`FOO=1 git push`/ + `sudo git push` 三种形态全部穿透);并且 2. 仓库级 `git config codeguard.skipGate true` 未设置(命中豁免时记账一次, `gate_lib.record_skip_event`,Stop 汇总可见);并且 3. 实际跑 linter 后存在非 skipped 的 failures。 出口内容(`gate_lib.gate_directive` 生成): - **首行必须是 `codeguard ❌ 提交门禁未通过:` 综述**(tests/run_all 守护); -- 报告块**末段必须包含整调用声明**——"整个工具调用没有执行(含非 git 前序步骤), - 请把修复与提交拆成两次独立调用"。PreToolUse 的 exit 2 拒绝的是**整个 Bash 工具 - 调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用并误判"编辑被吞"。 - -**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常) -时 exit 2——这是「无法验证」而非「验证失败」。 +- 整调用声明——"整个工具调用没有执行(含非 git 前序步骤),请把修复与提交拆成 + 两次独立调用"——**必须紧跟首行综述(指令前置)**,其后才是每语言细节、 + 「怎么修」先于 linter 原始输出。**报告总长受 `REPORT_MAX_CHARS`(3000)硬上限**: + 宿主把超长 stderr 从尾部截断,指令在末段时长报告下会整体丢失(实测 AI 只见到 + 首段 linter 报错,逃生门与拆调用指引全没读到);超限按"保头保尾"截细节, + 尾部的「完整输出: /tmp/…」日志路径不得截掉。PreToolUse 的 exit 2 拒绝的是 + **整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用 + 并误判"编辑被吞"。 + +**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常/ +**存量归因**——delta 报错提到的文件全部在本次改动集之外)时 exit 2——这是 +「无法验证」而非「验证失败」;存量归因是防"历史债不还就永远提交不了 → 只能 +skipGate → 门禁信誉清零"的最后一道闸。 **一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, 且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 @@ -90,8 +104,17 @@ if __name__ == "__main__": UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`; payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。 推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套 -扫描;commit 面=暂存+未暂存+未跟踪,push 面=并集未推送提交(`up...HEAD`); -UserPromptSubmit 按提示词里的 `push/推送` 选同一套面,软硬两门永远看同一组文件。 +扫描;**commit 面 = 按命令链预测的实际提交面**(`pre_tool_git_guard.staging_intent`: +纯 `git commit` → 仅暂存区;`git add -A/-a/-u` 或 `commit -a` → 相应扩到 +未暂存/未跟踪;`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, +不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交, +曾因此被误拦);push 面 = 并集未推送提交(`up...HEAD`)。lanes/extra 必须进 +缓存键——同一工作树状态下窄面 pass 不得被宽面复用(staged 干净 + 未暂存有病 +时,纯 commit 通过的结果若被 `add -A && commit` 复用 = 绕过)。 +UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不传 lanes = +三路宽口径**——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入 +(注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免 +上严格一致。 --- diff --git a/hooks/env_check.py b/hooks/env_check.py index eab89e4..65cffcd 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -59,6 +59,48 @@ def detect_linter_config(project_root: Path) -> dict: return found +def _semver_tuple(v: str) -> tuple[int, ...]: + import re as _re + m = _re.match(r"(\d+(?:\.\d+)*)", v.strip()) + if not m: + return (0,) + return tuple(int(x) for x in m.group(1).split(".")) + + +def version_backlog_note(current: str, cache_root: Path) -> str | None: + """纯函数:本地插件 cache 存在比 current 更新的版本 → 提示文案;否则 None。 + + 检测面 = `~/.zcode/cli/plugins/cache//codeguard/` 的**版本目录 + 集合**(离线可得的唯一权威)。抓两类漂移:同 vendor 升级后旧目录未清、跨 + vendor 版本参差导致实际生效副本落后于本机已有副本。抓不到"本机最新 vs + 远端仓库最新"(需网络,钩子不联网)——文案里明示提醒核对远端。 + """ + newer: list[str] = [] + installed: list[str] = [] + if not cache_root.is_dir(): + return None + try: + for vendor_dir in sorted(cache_root.glob("*/codeguard/*")): + if not vendor_dir.is_dir(): + continue + v = vendor_dir.name + installed.append(v) + if _semver_tuple(v) > _semver_tuple(current): + newer.append(v) + except OSError: + return None + if not newer: + return None + newest = max(newer, key=_semver_tuple) + return ( + f"- ⚠️ **版本积压**:当前生效 codeguard `v{current}`,本机 cache 已有更新版本 " + f"`v{newest}`(共 {len(installed)} 份: {', '.join(sorted(installed))})——" + "钩子行为以**生效副本**为准,修复可能已存在却没跑到;请更新插件并清理旧 " + "cache(`~/.zcode/cli/plugins/cache/*/codeguard/`),同时核对远端是否还有" + "更新版本(本检测离线,看不到仓库最新版)。" + ) + + def main(payload: dict | None = None) -> int: # 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的 # 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段 @@ -140,6 +182,20 @@ def main(payload: dict | None = None) -> int: "每个钩子事件会执行两遍,报告与统计可能翻倍或分裂——建议只保留一个来源" ) + # 版本积压:本机 cache 里有比当前生效副本更新的版本(实测 8 份副本停留在 + # 0.5.x 而源码已 0.8.x——"发布了但没跑到"的盲区,SessionStart 即可见) + try: + _mf = json.loads((PLUGIN_ROOT / ".zcode-plugin" / "plugin.json").read_text(encoding="utf-8")) + _current = str(_mf.get("version") or "") + except (OSError, ValueError, AttributeError): + _current = "" + if _current: + _note = version_backlog_note( + _current, Path.home() / ".zcode" / "cli" / "plugins" / "cache" + ) + if _note: + lines.append(_note) + lines.append("- AI 写完代码会被 PostToolUse 钩子自动 lint,告警会出现在这里,按告警里的「怎么修」处理") lines.append("- 用户要求「提交/push」时,UserPromptSubmit 钩子会再次确认所有 linter 通过,未通过会拦截提交") lines.append("") diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index f330ba4..0a2b2f9 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -140,18 +140,32 @@ def _gate_cache_path(project_root: Path) -> Path: def _worktree_fingerprint(project_root: Path) -> str: - """工作区指纹:staged + 未暂存 + 未跟踪三路 diff 的内容哈希。 + """工作区指纹:三路改动的**名字 + stat** 指纹(不再哈希全文内容)。 只用 index mtime 会漏掉"文件已修但未 git add"——键不变 → 60 秒内继续 - 报修复前的旧失败(retry-timing 陷阱,实测踩过)。内容哈希一改即失效。 + 报修复前的旧失败(retry-timing 陷阱,实测踩过),所以工作树改动必须进键。 + 此前对 staged+未暂存跑两个**全仓全文 diff** 再 sha1——多工作树大仓上这是 + 每次 commit/push 前的固定 MB 级开销。改为:改动文件名单 + 每文件 + size/mtime_ns(staged 部分由 _gate_cache_key 的 index mtime+size 兜底, + 暂存内容变化必动 index);内容改动不改名不改 size 也会动 mtime_ns + (纳秒级),既有 CacheKeyTests 语义保持。 """ import hashlib h = hashlib.sha1() - # 注意:("diff") 是字符串不是元组——*unpacking 会拆成字符。单元素必须带逗号。 - for args in (("diff", "--cached"), ("diff",)): + for args in (("diff", "--cached", "--name-only"), ("diff", "--name-only")): out = _git(project_root, *args) or "" - h.update(out.encode("utf-8", "replace")) + names = sorted(line for line in out.splitlines() if line.strip()) + h.update(("\n".join(names)).encode("utf-8", "replace")) + if args[:2] == ("diff", "--name-only"): + # 未暂存已跟踪文件:内容改动名字不变,必须叠加 stat 指纹 + # (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底) + for name in names[:500]: + try: + st = (project_root / name).stat() + h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) + except OSError: + h.update(name.encode("utf-8", "replace")) # 未跟踪文件:ls-files 只给文件名——内容改动名字不变,必须叠加 stat 指纹 others = (_git(project_root, "ls-files", "--others") or "").splitlines() for name in sorted(others)[:500]: @@ -165,11 +179,21 @@ def _worktree_fingerprint(project_root: Path) -> str: return h.hexdigest()[:16] -def _gate_cache_key(project_root: Path, languages: list, *, mode: str = "commit") -> str | None: - """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)。 +def _gate_cache_key( + project_root: Path, languages: list, *, + mode: str = "commit", + lanes: tuple[str, ...] | list[str] | None = None, + extra: tuple[str, ...] | list[str] | None = None, +) -> str | None: + """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)+ 文件面(lanes/extra)。 同一 HEAD/工作树下 commit 面与 push 面看到的文件集不同(push 面含未推送 - 提交),不带 mode 会互相污染缓存。""" + 提交),不带 mode 会互相污染缓存。**lanes/extra 必须进键**:纯 `git commit` + (仅 staged)与 `git add -A && git commit`(三路)在同一工作树状态下看到 + 不同文件集——只按工作树指纹,先跑的窄面结果会把宽面查询喂给同一缓存条目 + (staged 干净 + 未暂存有病 → 窄面 pass 被宽面复用 = 绕过)。 + """ + import hashlib head = _git(project_root, "rev-parse", "HEAD") if head is None: return None @@ -178,9 +202,10 @@ def _gate_cache_key(project_root: Path, languages: list, *, mode: str = "commit" idx_sig = f"{idx.stat().st_mtime_ns}:{idx.stat().st_size}" if idx.exists() else "no-index" except OSError: return None + face = ",".join(lanes or ()) + "|" + ",".join(sorted(extra or ())) return ( f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}" - f"|{mode}|{','.join(sorted(languages))}" + f"|{mode}|{','.join(sorted(languages))}|{face}" ) @@ -193,11 +218,16 @@ def run_gate( languages: list | None = None, *, mode: str = "commit", + lanes: tuple[str, ...] | list[str] | None = None, + extra: tuple[str, ...] | list[str] | None = None, ) -> tuple[list, list]: """运行 linter 门禁(跨进程结果缓存 + 并行执行)。 `languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的 语言子集,门禁只跑该子集;不传则按 `detect_languages()` 全量探测。 + `lanes`/`extra`:本次要检查的文件面(见 scope.changed_files)——硬门禁 + (PreToolUse)按命令链传入预测面;不传 = 三路宽口径(软门禁 UPS 无命令 + 上下文,按"工作树有待提交改动就提醒"的宽口径注入,不硬拦)。 软门禁(UserPromptSubmit)与硬门禁(PreToolUse)在正常提交路径上 会对同一状态连跑两次全量 lint——缓存键含 HEAD 与暂存区指纹, @@ -214,7 +244,7 @@ def run_gate( return [], [] # 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、 # 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。 - changed = changed_files(project_root, mode=mode) + changed = changed_files(project_root, mode=mode, lanes=lanes, extra=extra) scope = (get_overrides(project_root) or {}).get("gate_scope") or ( "delta" if changed is not None else "repo" ) @@ -222,7 +252,7 @@ def run_gate( if enabled and enabled != ["auto"]: languages = [lang for lang in languages if lang in enabled] - ck = _gate_cache_key(project_root, languages, mode=mode) + ck = _gate_cache_key(project_root, languages, mode=mode, lanes=lanes, extra=extra) cache_file = _gate_cache_path(project_root) if ck: try: @@ -267,6 +297,65 @@ def _truncate_detail(full: str, project_root: Path, lang: str) -> str: return detail +def _mentioned_files(full: str, project_root: Path) -> set[str]: + """从 linter 输出提取"提到的、且在仓内真实存在的"文件(仓根相对路径)。 + + 两类通行格式:`path/file.ext:12:`(javadoc/ruff/pylint/mvn——绝对路径先剥 + 仓根前缀)与 `In path/file.ext line 12`(shellcheck)。只收 root 下真实 + 存在的路径——裸 token(`version:1`)被存在性过滤挡掉。 + """ + import re + root = Path(project_root) + found: set[str] = set() + pats = ( + # path/file.ext:12:(javadoc 绝对路径 / ruff / mvn)——目录前缀可选: + # ruff 在仓根输出裸文件名 `old.py:1:1:`,强制含 / 会漏归因(实测)。 + # 无点的裸 token(SC2086:1 / version:1)不会命中;命中的再过存在性。 + re.compile(r"((?:[A-Za-z]:)?(?:/?(?:[\w.\-]+/)*)[\w.\-]+\.[A-Za-z0-9]+):\d+"), + re.compile(r"\bIn ((?:[\w.\-]+/)+[\w.\-]+\.[A-Za-z0-9]+) line \d+"), + ) + for pat in pats: + for m in pat.findall(full): + p = m + root_s = str(root) + if p.startswith(root_s + "/"): + p = p[len(root_s) + 1:] + p = p.lstrip("./") + if p.startswith("/"): + continue # 仓外绝对路径不归因 + if (root / p).exists(): + found.add(p) + return found + + +def _stale_attribution(full: str, project_root: Path, lang: str, lang_files: list[str]) -> str | None: + """delta 面存量归因:报错提到的文件全部在本次改动集之外 → 存量,不拦。 + + 永久红场景(实测):项目级命令(mvn javadoc:jar)因工具链/历史债在**与本次 + 改动无关**的文件上失败,若一律按 failure 就变成"不还历史债就永远提交不了" + → 用户只能 skipGate,门禁信誉清零。返回 skipped 说明(含完整日志路径)或 + None(无法归因/有交集 → 维持 failure,宁可多拦不漏拦)。 + """ + mentioned = _mentioned_files(full, project_root) + if not mentioned: + return None + changed_set = set(lang_files) + if mentioned & changed_set: + return None + shown = sorted(mentioned) + head = ", ".join(shown[:3]) + ("…" if len(shown) > 3 else "") + try: + path = _log_path(project_root, lang) + path.write_text(full, encoding="utf-8") + tail = f";完整输出: {path}" + except OSError: + tail = "" + return ( + f"{lang} 报错均位于本次改动之外的存量文件({head},共 {len(shown)} 个)" + f"——不拦本次提交{tail}。如需清偿存量问题请单独修复或建 baseline。" + ) + + def _run_gate_uncached( project_root: Path, cfg: dict, @@ -320,6 +409,10 @@ def check(lang: str): return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})") outputs: list[tuple[int, str, str]] = [] + # 项目级命令(mvn/gradle 等,无 {file} 占位符)不追加文件路径—— + # 文件被当 goal 会报 `Unknown lifecycle phase` 造成假失败;文件列表 + # 只用来决定跑不跑(delta 上面已按语言过滤)。 + append = cmd_def.get("append_files", True) if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []): for f in lang_files: cmd = scope_cmd(base_cmd, project_root, single_file=f) @@ -334,6 +427,7 @@ def check(lang: str): base_cmd, project_root, files=lang_files if uses_delta_files else None, full_excludes=not uses_delta_files, + append_files=append, ) else: cmd = scope_cmd(base_cmd, project_root, full_excludes=True) @@ -354,6 +448,11 @@ def check(lang: str): head = next((ln.strip() for ln in f"{out}\n{err}".splitlines() if ln.strip()), "") return (lang, None, f"{lang} 工具链异常未验证:exit 2(非 lint 结论){('|' + head[:80]) if head else ''}") full = "\n".join(seg for seg in ((out or "").rstrip(), (err or "").rstrip()) if seg) + if scope == "delta" and lang_files: + # 存量归因:项目级命令在与本次改动无关的文件上失败 → skipped 不拦 + stale = _stale_attribution(full, project_root, lang, lang_files) + if stale is not None: + return (lang, None, stale) detail = _truncate_detail(full or "(linter 无输出)", project_root, lang) dep_hint = _dependency_resolution_hint(lang, full) if dep_hint: @@ -502,8 +601,41 @@ def summarize_failures(failures: list) -> str: return f"codeguard ❌ 提交门禁未通过:{langs} 共 {len(failures)} 个语言生态有 lint 问题" +REPORT_MAX_CHARS = 3000 # gate_directive 总长硬上限:宿主会把超长 stderr 从尾部 + # 截断,指令段曾因此整体丢失(实测报告只见到首段报错) + + +def _squeeze(text: str, budget: int) -> str: + """保头保尾截断:尾部含「完整输出: /tmp/…」日志路径,砍中段也不能丢。""" + if len(text) <= budget: + return text + keep_tail = min(150, budget // 3) + keep_head = max(0, budget - keep_tail - 10) + return text[:keep_head] + "\n……(中略)……" + text[-keep_tail:] + + +def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[str]: + """每个语言的细节块。fix_first=True 把「怎么修/安装」排在长 detail 之前—— + 报告被宿主截断时,行动指引必须比 linter 原始输出先存活下来。""" + blocks: list[str] = [] + for lang, detail, fix, hint in failures: + block = [f"【{lang}】具体问题:"] + tail = [ + f" ▶ 怎么修: {fix}", + f" ▶ 未安装工具时先安装: {hint}", + ] + body = detail if detail else " lint 退出码非零,无文本输出" + if fix_first: + block += tail + [body] + else: + block += [body] + tail + block.append("─" * 60) + blocks.append("\n".join(block)) + return blocks + + def format_failure_report(failures: list) -> str: - """渲染「综述 + 细节」两段式报告。 + """渲染「综述 + 细节」两段式报告(CLI/调试面)。 第一行 = 问题综述(宿主 UI 通常取首行作标题);随后是细节, 不再重复综述内容——修复"标题和详情一样"的问题。 @@ -512,30 +644,49 @@ def format_failure_report(failures: list) -> str: summarize_failures(failures), "─" * 60, ] - for lang, detail, fix, hint in failures: - lines.append(f"【{lang}】具体问题:") - lines.append(detail if detail else " lint 退出码非零,无文本输出") - lines.append(f" ▶ 怎么修: {fix}") - lines.append(f" ▶ 未安装工具时先安装: {hint}") - lines.append("─" * 60) + for block in _failure_detail_blocks(failures): + lines.append(block) lines.append(f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py") return "\n".join(lines) def gate_directive(failures: list) -> str: - """给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户""" - return ( - format_failure_report(failures) - + "\n\n" - + "**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n" - + "请立即处理:1) 按上面「怎么修」逐项修复(能自动修复的先跑自动修复命令);" - + "2) 纯 lint 类修复可直接继续、不必逐项追问;但凡涉及付费、发布、删除、" - + "密钥、或跨出本仓的操作,必须先征得用户同意再执行;" - + "3) 修复完成后重新执行用户要做的提交操作。\n" - + "确需绕过(仅用户明确要求时):在该仓库执行 git config codeguard.skipGate true," - + "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " - + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。\n\n" + """给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户。 + + 结构 = 综述(首行契约)→ 强制指令(前置!)→ 每语言细节(fix 先于 + detail)→ 修复入口;整体压到 REPORT_MAX_CHARS 内。指令必须前置:宿主把 + 超长 stderr 从尾部截断,此前指令在报告末段,长报告下 AI 只看到首段 linter + 报错、「拆两次调用」与 skipGate 逃生门全部丢失(实测)。首行仍为综述、 + 综述不重复、含「具体问题」——run_all/硬门禁契约保持。 + """ + header = [ + summarize_failures(failures), + "─" * 60, + "**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n" + "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash " - + "调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与" - + "「提交」拆成两次独立的工具调用,修完再单独执行提交。" - ) + "调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与" + "「提交」拆成两次独立的工具调用,修完再单独执行提交。\n" + + "请立即处理:1) 按下面「怎么修」逐项修复(能自动修复的先跑自动修复命令);" + "2) 纯 lint 类修复可直接继续、不必逐项追问;但凡涉及付费、发布、删除、" + "密钥、或跨出本仓的操作,必须先征得用户同意再执行;" + "3) 修复完成后重新执行用户要做的提交操作。\n" + + "确需绕过(仅用户明确要求时):在该仓库执行 git config codeguard.skipGate true," + "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。", + "─" * 60, + ] + footer = f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py" + head = "\n".join(header) + budget = max(400, REPORT_MAX_CHARS - len(head) - len(footer) - 64) + blocks = _failure_detail_blocks(failures, fix_first=True) + used, kept = 0, [] + for block in blocks: + remain = budget - used + if remain <= 0: + kept.append("…(其余语言的问题明细已省略,按上方怎么修逐语言处理)") + break + if len(block) > remain: + block = _squeeze(block, remain) + kept.append(block) + used += len(block) + 1 + return "\n".join([head, *kept, footer]) diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 22c264c..7821db5 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -64,14 +64,62 @@ def _is_git_repo(p: Path) -> bool: return proc.returncode == 0 +def _flatten_substitutions(command: str) -> str: + """把 `$(...)` 与反引号的内层文本并入待扫面(按 shell 语义它们会真的执行)。 + + 漏检实例(实测静默放行):`ro=$(git push origin b 2>&1)`、`` x=`git commit` `` + ——段首是赋值词/反引号而非 git,切段扫描永远看不到子命令。内层可能还有 + 内层(`$(echo $(git push))`),工作队列递归展开,深度上限防畸形输入死循环。 + 能力边界:单引号内的字面量(`'$(git push)'` 不执行)不做引号语义区分, + 宁可多拦不漏拦——误拦方向由 lint 门禁自身的 delta 面兜底(不改文件不红)。 + """ + parts: list[str] = [] + queue: list[str] = [command] + seen = 0 + while queue and seen < 50: + text = queue.pop(0) + seen += 1 + parts.append(text) + i = 0 + while True: + j = text.find("$(", i) + if j < 0: + break + depth, k = 1, j + 2 + while k < len(text) and depth: + if text[k] == "(": + depth += 1 + elif text[k] == ")": + depth -= 1 + k += 1 + i = j + 2 + if depth == 0: + inner = text[j + 2:k - 1] + if inner and inner not in parts: + queue.append(inner) + if "`" in text: + seg = text.split("`") + for inner in seg[1::2]: + if inner and inner not in parts: + queue.append(inner) + return "\n".join(parts) + + def resolve_project_roots(command: str) -> list[Path]: """顺序扫描命令链,收集每个 git commit/push 段各自的仓库边界。 链式发布(cd plugins && git commit && cd minimax && git push)操作 - 多个仓库——每个 git 段的边界 = 它之前最近的 cd(且必须是 git 仓); - 无 cd 则用 cwd(需是 git 仓)。去重保序,找不到任何边界返回 []。 + 多个仓库——每个 git 段的边界 = `git -C ` 显式指定的仓,否则取它 + 之前最近的 cd(且必须是 git 仓);无 cd 则用 cwd(需是 git 仓)。 + 去重保序,找不到任何边界返回 []。 + + **必须与 is_guarded 同源判定**(`_git_side_effect_sub` 归一化后判子命令): + 此前这里用原始 tokens[0]=="git",`git -C /repo push`、`FOO=1 git push`、 + `sudo git push` 虽被 is_guarded 命中却解析出 roots=[],main() 见空直接 + 放行——归一化修复在 roots 层被击穿(实测三形态全部静默通过)。 """ import re + command = _flatten_substitutions(command) roots: list[Path] = [] last_cd: Path | None = None for seg in re.split(r"&&|\|\||;|\n", command): @@ -84,11 +132,15 @@ def resolve_project_roots(command: str) -> list[Path]: # cd 到非 git 目录(如 workspace 根)后边界失效——回退 cwd(与 shell 语义一致) last_cd = target if target.is_dir() and _is_git_repo(target) else None continue - tokens = seg.split() - if len(tokens) >= 2 and tokens[0] == "git" and tokens[1] in ("commit", "push"): + if _git_side_effect_sub(seg) is None: + continue + c_path = _git_c_path(seg) + if c_path is not None: + root = c_path if c_path.is_dir() and _is_git_repo(c_path) else None + else: root = last_cd or (Path(os.getcwd()) if _is_git_repo(Path(os.getcwd())) else None) - if root and root not in roots: - roots.append(root) + if root and root not in roots: + roots.append(root) # 有意行为(勿"修掉"):cd 到非 git 目录后 last_cd=None,git 段回退用 # 调用方 cwd——与 shell 语义一致(进非仓后 git 在原 cwd 执行)。 return roots @@ -97,24 +149,32 @@ def resolve_project_roots(command: str) -> list[Path]: _ASSIGN_RE = None # 延迟编译,保持模块零顶层 re 依赖 _ASSIGN_PATTERN = r"^[A-Za-z_][A-Za-z0-9_.-]*=" _BARE_WRAPPERS = ("env", "sudo", "nohup", "command", "time", "nice", "ionice", "setsid", "stdbuf") +# shell 控制结构引导词:`if git push; then` / `for x; do git push; done` 段首是 +# 控制词而非 git(`;` 切段后 `do git push…` 同理)——不剥就漏拦(实测四种形态 +# 全部静默通过)。只在段首序列出现时剥,不影响普通命令中的同名词。 +_SHELL_LEADERS = ("if", "then", "else", "elif", "while", "until", "do", "!") _GIT_VALUE_FLAGS = ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace", "--config-env", "--attr-source") _GIT_BOOL_FLAGS = ("--no-pager", "--bare", "--literal-pathspecs", "--no-optional-locks", "--shallow-file", "--exec-path") -def _normalize_segment(seg: str) -> list[str]: - """剥掉段首的环境赋值/env/裸 wrapper 前缀与 git 全局选项,暴露子命令。 +def _analyze_segment(seg: str) -> tuple[list[str], str | None]: + """归一化一段 shell 命令:返回 ([prog, sub, …] 形状的 tokens, `git -C` 的仓路径)。 + 剥除顺序(与 shell 求值同序):控制结构引导词 → 环境赋值/env/裸 wrapper 前缀 + → git 全局选项。同时捕获 `-C `——它是该 git 段的显式仓库边界。 漏检实例(实测静默放行):`VAR=1 git push` 段首是 `VAR=1`;`git -C path push` - tokens[1] 是 `-C`;`sudo git push` 段首是 `sudo`。剥完后若段首仍非 git 则 - 返回原 tokens(由调用方判定为非 git 段)。 + tokens[1] 是 `sudo`;`if git push; then` 段首是 `if`。剥完后若段首仍非 git + 则返回原 tokens(由调用方判定为非 git 段)。 能力边界(docstring 如实声明):带值的 wrapper 参数(`sudo -u root …`) 不做完整解析,只剥裸 wrapper token。 """ import re as _re tokens = seg.strip().split() i, n = 0, len(tokens) + while i < n and tokens[i] in _SHELL_LEADERS: + i += 1 while True: while i < n and _re.match(_ASSIGN_PATTERN, tokens[i]): i += 1 @@ -125,25 +185,43 @@ def _normalize_segment(seg: str) -> list[str]: while i < n and tokens[i] in _BARE_WRAPPERS: i += 1 if i >= n: - return tokens + return tokens, None if tokens[i].strip("\"'") != "git": - return tokens[i:] + return tokens[i:], None i += 1 + c_path: str | None = None while i < n: tok = tokens[i].strip("\"'") if tok in _GIT_BOOL_FLAGS: i += 1 continue if tok in _GIT_VALUE_FLAGS: + if tok == "-C" and i + 1 < n: + c_path = tokens[i + 1].strip("\"'") i += 2 continue if tok.startswith(("-C", "-c", "--git-dir", "--work-tree", "--exec-path")) and len(tok) > 2: + if tok.startswith("-C") and len(tok) > 2: + c_path = tok[2:].strip("\"'") i += 1 continue break # 保留 "git" 作为首 token(调用方按 [prog, sub, …] 形状判定),只剥掉前面的 # 前缀与后面的全局选项;`git` 裸段(无子命令)返回 ["git"] → len<2 → None。 - return ["git"] + tokens[i:] + root = Path(c_path).resolve() if c_path else None + return ["git"] + tokens[i:], (root if root else None) + + +def _normalize_segment(seg: str) -> list[str]: + """归一化形状(丢弃 -C 捕获)——保持既有调用面。见 _analyze_segment。""" + tokens, _c = _analyze_segment(seg) + return tokens + + +def _git_c_path(seg: str) -> Path | None: + """该段 `git -C ` 显式指定的仓库(相对路径按字面保留,由调用方判定)。""" + _tokens, c = _analyze_segment(seg) + return c def _git_side_effect_sub(seg: str) -> str | None: @@ -212,12 +290,102 @@ def _command_indirect(command: str) -> bool: def _collect_subs(text: str) -> list[str]: import re + text = _flatten_substitutions(text) return [ sub for seg in re.split(r"&&|\|\||;|\n", text) if (sub := _git_side_effect_sub(seg)) ] +def _repo_root_of(p: Path) -> Path | None: + """从路径向上找 .git 所在的仓库根(纯文件系统,不起进程)。""" + cur = p if p.is_dir() else p.parent + for _ in range(10): + if (cur / ".git").exists(): + return cur + if cur.parent == cur: + return None + cur = cur.parent + return None + + +def staging_intent(command: str) -> tuple[tuple[str, ...], list[str]]: + """从命令链推断本次**真正会提交**的文件面(lanes)与 git add 显式路径。 + + PostToolUse 语义:PreToolUse 在整条命令执行**之前**触发,链中的 `git add` + 还没跑——必须按命令形态预测执行后的暂存区,同时**不把与本次提交无关的 + 工作树 WIP 算进来**(并行会话留在工作树的未暂存改动曾让纯 `git commit` + 被无关文件误拦): + - 链中无 git add、commit 无 -a → 只有暂存区会入库 → ("staged",); + - `git add -A/-a/-u/--all` 或无路径参数 → 未暂存/未跟踪将一并入库 → 三路; + - `git add ` → 暂存区 + 这些路径(add 尚未执行,路径可能还没staged); + - `git commit -a/-am/--all` → 暂存 + 已跟踪未暂存(git 不收未跟踪)→ 两路。 + 能力边界:路径含 glob/`:` 等价语法按字面子处理;相对路径找不到存在性 + 对应的仓根文件时由调用方丢弃(宁可少拦不误拦)。 + """ + import re + all_lanes = ("staged", "unstaged", "untracked") + lanes: set[str] = {"staged"} + extra: list[str] = [] + add_paths: list[str] = [] + add_all = False + add_tracked_only = False + last_cd: Path | None = None + text = _flatten_substitutions(command) + for seg in re.split(r"&&|\|\||;|\n", text): + seg = seg.strip() + if not seg: + continue + m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) + if m: + target = Path(m.group(1).strip("\"'")) + last_cd = target if target.is_dir() else None + continue + tokens = _normalize_segment(seg) + if len(tokens) < 2 or tokens[0] != "git": + continue + sub, rest = tokens[1], tokens[2:] + if sub == "add": + flags = [t for t in rest if t.startswith("-")] + paths = [t for t in rest if not t.startswith("-")] + if not rest or any(f in ("-A", "-a", "-u", "--all", "--ignore-removal") for f in flags) \ + or any(p.strip("\"'") in (".", "./", "*", ":/") for p in paths): + add_all = True + elif paths: + add_paths.extend(p.strip("\"'") for p in paths) + else: + add_all = True # 无法预测形态时按宽口径(宁可多拦) + if any(f in ("-u", "--update") for f in flags): + add_tracked_only = True + elif sub == "commit" and any(t in ("-a", "-am", "--all") for t in rest): + lanes.update(("staged", "unstaged")) + if add_all: + lanes.update(all_lanes) + elif add_tracked_only: + lanes.update(("staged", "unstaged")) + for p in add_paths: + if Path(p).is_absolute(): + continue + cand_root = last_cd if last_cd is not None else Path(".") + # git add 的 pathspec 相对 shell 语境(last_cd 或 cwd)解析成绝对路径, + # 再换算成**仓库根**相对路径——changed_files 的 paths 全部相对仓根; + # cd 到仓库子目录(scripts/…)时相对 last_cd 会算错一截。 + try: + resolved = (cand_root / p).resolve() + except OSError: + continue + repo = _repo_root_of(resolved) or _repo_root_of(Path.cwd()) + if repo is None: + continue + try: + rel = resolved.relative_to(repo) + except ValueError: + continue + extra.append(str(rel)) + ordered = tuple(l for l in all_lanes if l in lanes) + return ordered, extra + + def _guarded_mode(command: str) -> str | None: """门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时 推送面是提交面的超集,按更宽的面检查);未命中返回 None。 @@ -301,11 +469,20 @@ def main() -> int: record_skip_event("skipGate", roots[0]) return 0 + # 按命令链预测实际提交面(纯 commit → 仅 staged;add -A/-a → 三路), + # 并入 git add 显式路径(add 尚未执行、暂存区还是旧的)。 + lanes, extra = staging_intent(command) + # 每个被操作的仓库独立跑:linter 门禁 + 提交内容安全检查 # (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件) reports = [] for project_root in roots: - failures, _skipped = run_gate(project_root, cfg, mode=mode) + # extra 路径按各仓存在性过滤:多仓链里 add 的路径只属于其中一个仓, + # 幻影路径喂给 {file} 型 linter 会得到"文件不存在"的假失败。 + root_extra = [p for p in extra if (project_root / p).exists()] + failures, _skipped = run_gate( + project_root, cfg, mode=mode, lanes=lanes, extra=root_extra, + ) if failures: reports.append(gate_directive(failures)) violations = check_commit_safety(project_root, mode) diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index 20d5efe..2f8a24f 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -179,8 +179,11 @@ def main() -> int: # 2.2: 消息里提到了具体语言时只跑子集;没提到则回退全量探测(2.3)。 detected = detect_languages(project_root) subset = _detect_languages_in_text(user_text, detected) - # 推送意图走 push 面(含未推送提交),提交意图走 commit 面(暂存+工作区)—— - # 与硬门禁的 _guarded_mode 同一套语义,软硬两门看到同一组文件。 + # 推送意图走 push 面(含未推送提交),提交意图走 commit 面。 + # 文件面:软门禁**不传 lanes** → 三路宽口径(staged+未暂存+未跟踪)—— + # 此刻还没有待执行命令可预测,按"工作树有待提交改动就提醒"注入;硬门禁 + # (PreToolUse)知道真实命令形态,按 staging_intent 收窄到实际提交面。 + # 两者可以分歧:软门多提醒不算错(注入非阻断),硬门少拦才是底线。 mode = "push" if _re.search(r"\bpush\b|推送", user_text, _re.IGNORECASE) else "commit" failures, skipped = run_gate(project_root, cfg, languages=(subset or None), mode=mode) # 提交内容安全检查:.venv/node_modules/.env/密钥等不应入库 diff --git a/openspec/specs/hook-protocol/spec.md b/openspec/specs/hook-protocol/spec.md index e181315..422fdf3 100644 --- a/openspec/specs/hook-protocol/spec.md +++ b/openspec/specs/hook-protocol/spec.md @@ -37,22 +37,32 @@ The top-of-file docstring of `tests/run_all.py` SHALL contain a one-line referen ### Requirement: Hard-block feedback SHALL declare whole-call rejection -PreToolUse 硬拦截(exit 2)的 stderr 报告 MUST 包含一条整调用声明:本次被拒绝的是一次完整的 Bash 工具调用,其中非 git 的前序步骤(写文件、执行脚本)也全部未执行,指令 MUST 要求调用方把修复与提交拆成两次独立调用。该声明 MUST NOT 改变首行综述契约(首行仍为 `codeguard ❌ 提交门禁未通过:`)。 +PreToolUse 硬拦截(exit 2)的 stderr 报告 MUST 包含一条整调用声明:本次被拒绝的是一次完整的 Bash 工具调用,其中非 git 的前序步骤(写文件、执行脚本)也全部未执行,指令 MUST 要求调用方把修复与提交拆成两次独立调用。该声明 MUST NOT 改变首行综述契约(首行仍为 `codeguard ❌ 提交门禁未通过:`)。该声明 MUST 紧跟首行综述(指令前置):宿主会把超长 stderr 从尾部截断,指令位于末段时长报告下会整体丢失(实测),「怎么修」等行动指引 MUST 先于 linter 原始输出出现。整个报告 MUST 受总长硬上限(`REPORT_MAX_CHARS`)约束,超限时 MUST 保尾截断细节——尾部的完整日志路径不得截掉。 #### Scenario: AI retries a combined write-and-commit call - **WHEN** 一次同时包含写文件与 `git commit` 的调用被门禁拦截 -- **THEN** 报告在首行综述之后包含"整个工具调用没有执行/拆成两次独立调用"的指令,且首行仍是综述 +- **THEN** 报告在首行综述**紧后**包含"整个工具调用没有执行/拆成两次独立调用"的指令,且首行仍是综述 + +#### Scenario: A very long multi-language report is truncated by the host + +- **WHEN** 多语言失败详情使报告超过 `REPORT_MAX_CHARS` +- **THEN** 报告总长受控、指令段完整保留,被压缩的细节块仍保留其尾部完整日志路径 ### Requirement: The guard SHALL treat one-level interpreter indirection as guarded -`is_guarded()` MUST 覆盖直接命令与一层解释器间接:解释器(bash/sh/zsh/python/node)执行的脚本文件文本、或 `-c` 内联代码,按与直接命令相同的分隔符切段规则扫描到 `git commit|push` 段首命令词时 MUST 判为拦截。更深的动态构造(如 subprocess 参数拼接)MUST 在文档中声明为能力边界而非承诺。 +`is_guarded()` MUST 覆盖直接命令与一层解释器间接:解释器(bash/sh/zsh/python/node)执行的脚本文件文本、或 `-c` 内联代码,按与直接命令相同的分隔符切段规则扫描到 `git commit|push` 段首命令词时 MUST 判为拦截。切段扫描前 MUST 先展开 `$(...)` 与反引号内层文本(shell 语义下它们会被真实执行;`ro=$(git push …)` 曾静默放行),段首归一化 MUST 覆盖 shell 控制引导词(`if`/`then`/`else`/`elif`/`while`/`until`/`do`/`!`——`if git push; then`、`for x; do git push; done` 曾静默放行)。`resolve_project_roots()` MUST 用同一套归一化判定收集仓库边界(含 `git -C ` 显式仓边界)——判定不同源时会出现"命中拦截但 roots=[] → 静默放行"的击穿(0.8.2 实测)。更深的动态构造(如 subprocess 参数拼接)MUST 在文档中声明为能力边界而非承诺。 #### Scenario: A wrapper script performs the commit - **WHEN** 调用形如 `bash runner.sh` 且脚本体内含 `git commit` 或 `git push` - **THEN** 钥入判定命中,硬门禁按正常流程跑 linter 并可能 exit 2 +#### Scenario: Command substitution or shell control structure hides the git call + +- **WHEN** 调用形如 `ro=$(git push origin b 2>&1)`、`` x=`git commit` ``、`for x; do git push; done` 或 `if git push; then …; fi` +- **THEN** 展开/归一化后命中拦截,且 `resolve_project_roots` 解析出正确仓库边界(不为 []) + #### Scenario: Text mentioning git push in an echo is not guarded - **WHEN** 调用为 `echo "git push 是危险命令"` 或脚本内容仅为 `echo hi` @@ -72,3 +82,22 @@ UserPromptSubmit 与 PreToolUse MUST 共用同一条仓库级豁免(`git confi - **WHEN** cwd 不在 git 仓库内且消息触发提交意图 - **THEN** 输出"不是 git 仓库,提交门禁已跳过"的说明,不运行任何 linter、不扫描 cwd +### Requirement: The commit face SHALL match the actual staging surface + +PreToolUse 的 commit 面 MUST 按命令链预测**实际会提交**的文件集(`staging_intent`):纯 `git commit` 只取暂存区;`git add -A/-a/-u`、`commit -a` 相应扩展到未暂存/未跟踪;`git add ` MUST 并入这些路径(触发拦截时 add 尚未执行、暂存区仍是旧的)。工作树里**未被该命令链触及**的改动(如并行会话的未暂存 WIP)MUST NOT 触发硬拦。预测面(lanes/extra)MUST 进入门禁缓存键。delta 作用域下,项目级 linter 报错所提及的文件 MUST 全部位于本次改动集之外时归为 skipped(存量归因)而非 failure;无法归因或存在交集时维持 failure(宁可多拦不漏拦)。 + +#### Scenario: Plain commit with unrelated unstaged WIP in the worktree + +- **WHEN** 暂存区干净可提交,工作树存在与本命令无关的未暂存/未跟踪改动,执行 `git commit -m t` +- **THEN** 门禁只检查暂存区文件,无关 WIP 不产生 failure,提交放行 + +#### Scenario: Chained add widens the face before commit runs + +- **WHEN** 执行 `git add -A && git commit -m t` 或 `git add path/to/file && git commit -m t` +- **THEN** 门禁按执行后的暂存区预测检查(含未暂存/未跟踪或显式路径),"即将暂存"的文件不漏检 + +#### Scenario: Project-level linter fails only on files outside the changeset + +- **WHEN** delta 面内 mvn/cargo 等项目级命令失败,但输出提及的文件全部不在本次改动集 +- **THEN** 归为 skipped(存量归因 + 完整日志路径),不拦截本次提交 + diff --git a/scripts/languages.json b/scripts/languages.json index 09e7373..59a923e 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -9,6 +9,7 @@ "languages": [ { "id": "java", + "append_files": false, "name": "Java", "extensions": [ ".java" @@ -45,6 +46,7 @@ }, { "id": "rust", + "append_files": false, "name": "Rust", "extensions": [ ".rs" @@ -165,6 +167,7 @@ }, { "id": "go", + "append_files": false, "name": "Go", "extensions": [ ".go" @@ -195,6 +198,7 @@ }, { "id": "csharp", + "append_files": false, "name": "C#", "extensions": [ ".cs" @@ -219,6 +223,7 @@ }, { "id": "kotlin", + "append_files": false, "name": "Kotlin", "extensions": [ ".kt", @@ -814,6 +819,7 @@ }, { "id": "terraform", + "append_files": false, "name": "Terraform / OpenTofu", "extensions": [ ".tf", @@ -962,6 +968,7 @@ }, { "id": "protobuf", + "append_files": false, "name": "Protobuf", "extensions": [ ".proto" @@ -1350,6 +1357,7 @@ }, { "id": "elm", + "append_files": false, "name": "Elm", "extensions": [ ".elm" @@ -1503,6 +1511,7 @@ }, { "id": "vbnet", + "append_files": false, "name": "Visual Basic .NET", "extensions": [ ".vb" diff --git a/scripts/scope.py b/scripts/scope.py index 522f362..cbe691f 100644 --- a/scripts/scope.py +++ b/scripts/scope.py @@ -8,10 +8,12 @@ (判定可复现性:规则集不能随机器上恰好装的 ruff 漂移;注入文件必须是 ruff 原生格式,[tool.ruff] 包装的片段会被 TOML 解析拒绝)。 - `scope_cmd`:物化命令——{file} 替换、全仓扫描 token 收敛为文件列表、 - 裸命令追加文件、全量模式剔除依赖快照与构建产物目录(ruff 走 --exclude, + 裸命令追加文件(`append_files=False` 的项目级命令如 mvn 不追加)、 + 全量模式剔除依赖快照与构建产物目录(ruff 走 --exclude, `find -print0` 型 gate 注入 -not -path)。 -- `changed_files`:git 仓的本次改动集(staged + 未暂存 + 未跟踪); - 非 git 目录返回 None(调用方据此退回全量作用域)。 +- `changed_files`:git 仓的本次改动集;`lanes` 控制取哪几路 + (默认 staged+未暂存+未跟踪三路;硬门禁按命令链推断收窄到实际提交面), + `extra` 并入显式 git add 路径;非 git 目录返回 None(调用方退回全量作用域)。 被 hooks/gate_lib、hooks/post_tool_lint、scripts/run_per_language、 scripts/fix 共用;detect_language 的按文件语言归属仍从 detect_lang 取。 @@ -84,6 +86,7 @@ def scope_cmd( files: list[str] | None = None, single_file: str | None = None, full_excludes: bool = False, + append_files: bool = True, ) -> list: """物化一条 linter 命令:占位符替换 + 作用域收敛 + ruff 配置注入。 @@ -91,7 +94,9 @@ def scope_cmd( - 全仓扫描 token(`.`、`**/*.md` 等;`#exclude` 辅助项一并处理): 给了 files/single_file 就替换成具体文件列表,没给则保留(全量模式); - 既无占位符也无扫描 token 的裸命令:给了文件就**追加**(yamllint 这类 - 不带路径时读 stdin 恒"通过",追加才检查得到东西); + 不带路径时读 stdin 恒"通过",追加才检查得到东西);`append_files=False` + 的项目级命令(mvn/gradle——文件路径会被当 goal 报 + `Unknown lifecycle phase` 误拦)保持原命令,文件列表只决定跑不跑; - ruff 命令注入 ruff_config_args 的默认配置;full_excludes=True(全量 模式)时剔除依赖快照与构建产物:ruff 追加 --exclude,`find -print0` 型命令(bash -c find … | xargs 形态的 gate)注入 -not -path。 @@ -115,7 +120,7 @@ def scope_cmd( flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")] at = min(scan_idx) return flags[:at] + targets + flags[at:] - if targets and not scan_idx: + if targets and not scan_idx and append_files: return out + targets return out @@ -154,12 +159,28 @@ def _unpushed_files(root: Path) -> list[str]: return [] +_LANE_ARGS = { + "staged": ("diff", "--cached", "--name-only"), + "unstaged": ("diff", "--name-only"), + "untracked": ("ls-files", "--others", "--exclude-standard"), +} +DEFAULT_LANES = ("staged", "unstaged", "untracked") + + def changed_files( - project_root: str | Path, *, mode: str = "commit", + project_root: str | Path, *, + mode: str = "commit", + lanes: tuple[str, ...] | list[str] | None = None, + extra: tuple[str, ...] | list[str] | None = None, ) -> list[str] | None: """本次改动涉及的文件;非 git 仓返回 None。 - mode="commit"(提交面):staged + 未暂存 + 未跟踪——"这次要提交什么"。 + mode="commit"(提交面):按 `lanes` 取工作树各路,默认三路 = + staged + 未暂存 + 未跟踪。**硬门禁按命令链把 lanes 收窄到实际会提交的面** + (纯 `git commit` 只有暂存区入库;并行会话留在工作树的未暂存 WIP 不属于 + 本次提交,曾因此被误拦——收窄逻辑见 pre_tool_git_guard.staging_intent)。 + `extra` = 命令链里 `git add ` 的显式路径(PreToolUse 时 add 尚未 + 执行、暂存区还是旧的,不并入会漏掉"即将暂存"的文件)。 mode="push"(推送面):在提交面基础上**并集未推送提交的文件** (up...HEAD)——工作树干净但本地领先时,坏改动已入库、提交面为空, 推送面必须接管,否则 push 门禁形同虚设(实测:坏提交入史后 @@ -176,14 +197,16 @@ def changed_files( if proc.returncode != 0: return None names: set[str] = set() - for args in ( - ("diff", "--cached", "--name-only"), - ("diff", "--name-only"), - ("ls-files", "--others", "--exclude-standard"), - ): + for lane in (lanes or DEFAULT_LANES): + args = _LANE_ARGS.get(lane) + if not args: + continue out = _git_out(root, *args) if out: names.update(line for line in out.splitlines() if line.strip()) + for p in (extra or ()): + if p and not Path(p).is_absolute(): + names.add(p) if mode == "push": names.update(_unpushed_files(root)) return sorted(names) diff --git a/tests/test_session_fixes_20260922.py b/tests/test_session_fixes_20260922.py new file mode 100644 index 0000000..6ed8185 --- /dev/null +++ b/tests/test_session_fixes_20260922.py @@ -0,0 +1,353 @@ +"""2026-09-22 会话反馈批次回归(源码 v0.8.3):锁五项实测发现。 + +来自一次真实多仓修复会话(codeguard 在主工作树拦了 3 次、又被 4 种命令形态 +静默绕过、且长报告把指令段截掉): +1. 守卫绕过:`$()`/反引号/`for…do`/`if…;then` 不命中;`git -C`/`FOO=1 git`/ + `sudo git` 命中但 resolve_project_roots=[] → main 静默放行(0.8.2 半修击穿)。 +2. commit 面:并行会话的未暂存 WIP 不该拦纯 `git commit`;`git add` 链要把 + "即将暂存"的文件并进检查面;lanes/extra 必须进缓存键。 +3. 报告结构:整调用声明/skipGate 必须在首行综述紧后(宿主从尾部截断长报告), + 总长受控,被压细节保尾部日志路径。 +4. 存量归因:delta 下项目级命令报错文件全在改动集外 → skipped 不拦。 +5. 版本积压:SessionStart 检出本机 cache 存在更新版本副本。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) +sys.path.insert(0, str(PLUGIN / "hooks")) + +import gate_lib # noqa: E402 +import pre_tool_git_guard as guard # noqa: E402 +import scope # noqa: E402 +from env_check import version_backlog_note # noqa: E402 + + +def _git(root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False) + + +def _fresh_repo() -> Path: + root = Path(tempfile.mkdtemp(prefix="cg-sess-")) + _git(root, "init", "-q") + _git(root, "config", "user.email", "t@t") + _git(root, "config", "user.name", "t") + return root + + +def _run_guard(command: str, cwd: Path) -> subprocess.CompletedProcess: + home = Path(tempfile.mkdtemp(prefix="cg-home-")) + env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"} + return subprocess.run( + [sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")], + input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}), + capture_output=True, text=True, cwd=cwd, env=env, timeout=120, + ) + + +class GuardSubstitutionTests(unittest.TestCase): + """#1a:命令替换与控制结构里的 git 调用必须命中(实测四种形态全部漏网)。""" + + def test_dollar_paren_forms_guarded(self) -> None: + for cmd, want in ( + ("ro=$(git push origin b 2>&1)", "push"), + ("out=`git commit -m y`", "commit"), + ("x=$(echo $(git push))", "push"), + ): + with self.subTest(cmd=cmd): + self.assertEqual(guard._guarded_mode(cmd), want) + + def test_loop_and_control_structures_guarded(self) -> None: + for cmd, want in ( + ("for B in a; do git push origin $B; done", "push"), + ("if git push origin b; then echo ok; fi", "push"), + ("while git commit -m x; do sleep 1; done", "commit"), + ("git commit -m x || { echo retry; }", "commit"), + ): + with self.subTest(cmd=cmd): + self.assertEqual(guard._guarded_mode(cmd), want) + + def test_negatives_stay_unguarded(self) -> None: + # 只锁无命令替换时的词法阴性:echo 普通文本/只读 git 不命中。 + # 引号内散文含 `then do git push` 的展开是声明过的边界(宁可多拦)。 + self.assertFalse(guard.is_guarded('echo "git push 是危险命令"')) + self.assertFalse(guard.is_guarded("git status && ls")) + self.assertFalse(guard.is_guarded("ls -la")) + + +class RootsNormalizationTests(unittest.TestCase): + """#1b:resolve_project_roots 必须与 is_guarded 同源——否则 roots=[] 静默放行。 + + 0.8.2 实测:`git -C /repo commit`、`FOO=1 git push`、`sudo git push` + is_guarded=True 但 roots=[] → main() 首道闸直接 return 0。 + """ + + def test_normalized_forms_resolve_roots(self) -> None: + here = str(PLUGIN) + for cmd in ( + f"git -C {here} commit -m x", + f"FOO=1 git -C {here} push origin b", + f"sudo git -C {here} push origin b", + f"ro=$(git -C {here} push origin b 2>&1)", + "git commit -m x", + ): + with self.subTest(cmd=cmd): + roots = guard.resolve_project_roots(cmd) + self.assertTrue(roots, f"roots=[] 即静默放行: {cmd}") + self.assertEqual(roots[0], PLUGIN) + + def test_explicit_c_path_wins_over_cwd(self) -> None: + other = Path(tempfile.mkdtemp(prefix="cg-other-")) + _git(other, "init", "-q") + roots = guard.resolve_project_roots(f"git -C {other} commit -m x") + # -C 路径经 resolve() 规范化(macOS /var → /private/var symlink), + # 与 tempfile 的字面路径比较必须先 resolve 两侧 + self.assertEqual(len(roots), 1) + self.assertEqual(roots[0].resolve(), other.resolve()) + + def test_multi_cd_boundary_kept(self) -> None: + # run_all 同款断言的 worktree 等价:cd 边界 + 无 git 段为空 + roots = guard.resolve_project_roots( + f"cd /tmp && x && cd {PLUGIN} && git commit -m t" + ) + self.assertIn(PLUGIN, roots) + self.assertNotIn(Path("/tmp"), roots) + self.assertEqual(guard.resolve_project_roots("ls -la && echo done"), []) + + +class StagingIntentTests(unittest.TestCase): + """#2:commit 面按命令链预测;工作树无关 WIP 不进面;extra 并入。""" + + def test_plain_commit_is_staged_only(self) -> None: + lanes, extra = guard.staging_intent("git commit -m t") + self.assertEqual(lanes, ("staged",)) + self.assertEqual(extra, []) + + def test_add_all_widens_to_three_lanes(self) -> None: + for cmd in ("git add -A && git commit -m t", + "git add . && git commit -m t", + "git commit -am t"): + with self.subTest(cmd=cmd): + lanes, _ = guard.staging_intent(cmd) + self.assertEqual(lanes, ("staged", "unstaged", "untracked") + if "add" in cmd else ("staged", "unstaged")) + + def test_add_explicit_path_becomes_extra(self) -> None: + repo = _fresh_repo() + (repo / "a.py").write_text("x = 1\n", encoding="utf-8") + prev = Path.cwd() + os.chdir(repo) + try: + lanes, extra = guard.staging_intent("git add a.py && git commit -m t") + finally: + os.chdir(prev) + self.assertEqual(lanes, ("staged",)) + self.assertEqual(extra, ["a.py"]) + + def test_changed_files_honors_lanes_and_extra(self) -> None: + repo = _fresh_repo() + (repo / "base.py").write_text("x = 1\n", encoding="utf-8") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "i") + (repo / "base.py").write_text("x = 2\n", encoding="utf-8") # 未暂存 + (repo / "staged.py").write_text("y = 1\n", encoding="utf-8") + _git(repo, "add", "staged.py") + (repo / "fresh.py").write_text("z = 1\n", encoding="utf-8") # 未跟踪 + self.assertEqual( + scope.changed_files(repo, lanes=("staged",)), ["staged.py"] + ) + self.assertEqual( + scope.changed_files(repo, lanes=("staged",), extra=["fresh.py"]), + ["fresh.py", "staged.py"], + ) + self.assertEqual( + scope.changed_files(repo), + ["base.py", "fresh.py", "staged.py"], + "默认三路(软门禁宽口径)保持", + ) + + def test_lanes_and_extra_enter_cache_key(self) -> None: + repo = _fresh_repo() + (repo / "a.py").write_text("x = 1\n", encoding="utf-8") + _git(repo, "add", "-A") + # 缓存键含 HEAD——空仓 rev-parse 失败返回 None,必须先有提交 + _git(repo, "commit", "-q", "-m", "i") + k_staged = gate_lib._gate_cache_key(repo, ["python"], lanes=("staged",)) + k_all = gate_lib._gate_cache_key(repo, ["python"], + lanes=("staged", "unstaged", "untracked")) + k_extra = gate_lib._gate_cache_key(repo, ["python"], lanes=("staged",), + extra=["b.py"]) + self.assertNotEqual(k_staged, k_all, + "窄面 pass 被宽面缓存复用 = 绕过") + self.assertNotEqual(k_staged, k_extra) + + +@unittest.skipUnless(__import__("shutil").which("shellcheck"), "shellcheck 未安装") +class EndToEndFaceTests(unittest.TestCase): + """e2e:真实钩子进程——本会话两个摩擦点的直接锁。""" + + def test_git_c_commit_is_blocked(self) -> None: + """0.8.2 击穿:git -C 命中 is_guarded 但 roots=[] 静默放行。""" + repo = _fresh_repo() + (repo / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n", + encoding="utf-8") + _git(repo, "add", "-A") + outside = Path(tempfile.mkdtemp(prefix="cg-outside-")) + r = _run_guard(f"git -C {repo} commit -m t", outside) + self.assertEqual(r.returncode, 2, r.stderr[:400]) + + def test_plain_commit_ignores_unstaged_parallel_wip(self) -> None: + """本会话实测:只暂存自己的文件,工作树里别人的未暂存 WIP 不得拦。""" + repo = _fresh_repo() + (repo / "good.sh").write_text('#!/bin/bash\nx="ok"\necho "$x"\n', encoding="utf-8") + _git(repo, "add", "good.sh") + (repo / "parallel-wip.sh").write_text( + "#!/bin/bash\nif [ $x = y ]; then true; fi\n", encoding="utf-8") # 未暂存坏文件 + r = _run_guard("git commit -m t", repo) + self.assertEqual(r.returncode, 0, + f"无关未暂存 WIP 不得拦纯 commit: {r.stderr[:400]}") + + def test_dollar_paren_commit_blocked(self) -> None: + repo = _fresh_repo() + (repo / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n", + encoding="utf-8") + _git(repo, "add", "-A") + r = _run_guard("ro=$(git commit -m t 2>&1)", repo) + self.assertEqual(r.returncode, 2, r.stderr[:400]) + + +class DirectiveFrontLoadTests(unittest.TestCase): + """#3:指令前置 + 总长上限 + 保尾日志路径(宿主从尾部截断实测)。""" + + def _long_failures(self) -> list: + long_detail = ("javax.annotation stuff\n" * 40 + + "/abs/path/Ddd4j.java:7: error: package does not exist\n" + + "…(截断,共 41 行;完整输出: /tmp/codeguard-gate-x-java.log)") + return [ + ("java", long_detail, "mvn spotless:apply", "见 docs/LANGUAGES.md"), + ("shell", "SC2086: double quote\n" * 30 + "完整输出: /tmp/g-shell.log", + "shfmt -w .", "brew install"), + ] + + def test_directive_fragments_come_before_details(self) -> None: + text = gate_lib.gate_directive(self._long_failures()) + lines = text.splitlines() + self.assertTrue(lines[0].startswith("codeguard ❌ 提交门禁未通过:")) + self.assertEqual(lines.count(lines[0]), 1, "综述不得在细节中重复") + first_detail = text.index("【") + self.assertLess(text.index("整个工具调用没有执行"), first_detail) + self.assertLess(text.index("skipGate"), first_detail) + self.assertLess(text.index("拆成两次独立的工具调用"), first_detail) + self.assertIn("具体问题", text) + self.assertIn("怎么修", text) + self.assertIn("先征得用户同意", text) + self.assertNotIn("无需向用户确认", text) + + def test_total_length_capped_and_log_tail_survives(self) -> None: + text = gate_lib.gate_directive(self._long_failures()) + self.assertLessEqual(len(text), gate_lib.REPORT_MAX_CHARS + 200, + f"总长 {len(text)} 超过硬上限") + self.assertIn("/tmp/codeguard-gate-x-java.log", text, + "被压缩 detail 的尾部日志路径不得截掉") + self.assertIn("/tmp/g-shell.log", text) + + def test_format_failure_report_contract_unchanged(self) -> None: + """run_all unit 契约:综述一行、不重复、细节含问题与修法。""" + r = gate_lib.format_failure_report( + [("shell", "SC2086: double quote\nSC2154: unassigned", "shfmt -w .", "brew")] + ) + first, rest = r.splitlines()[0], "\n".join(r.splitlines()[1:]) + self.assertTrue(0 < len(first) <= 80 and "\n" not in first) + self.assertNotIn(first, rest) + self.assertIn("SC2086", rest) + self.assertIn("怎么修", rest) + + +class StaleAttributionTests(unittest.TestCase): + """#4:delta 下报错文件全在改动集外 → skipped;有交集/无法归因 → failure。""" + + def setUp(self) -> None: + self.repo = _fresh_repo() + (self.repo / "old.py").write_text("x = 1\n", encoding="utf-8") + (self.repo / "new.py").write_text("y = 2\n", encoding="utf-8") + + def test_bare_filename_outside_changeset_is_stale(self) -> None: + out = "old.py:1:1: E501 line too long\nold.py:2:2: E501 again" + note = gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) + self.assertIsNotNone(note) + self.assertIn("存量文件", note) + self.assertIn("old.py", note) + + def test_absolute_path_is_normalized_and_counted(self) -> None: + out = f"{self.repo}/old.py:1:1: error" + note = gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) + self.assertIsNotNone(note) + self.assertIn("old.py", note) + + def test_intersection_keeps_failure(self) -> None: + out = "new.py:1:1: E501" + self.assertIsNone( + gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) + ) + + def test_unattributable_keeps_failure(self) -> None: + for out in ("no file mentions", "SC2086:1 weird token"): + with self.subTest(out=out): + self.assertIsNone( + gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) + ) + + +class VersionBacklogTests(unittest.TestCase): + """#5:SessionStart 版本积压检测(纯函数)。""" + + def setUp(self) -> None: + self.cache = Path(tempfile.mkdtemp(prefix="cg-cache-")) + (self.cache / "partme-ai" / "codeguard" / "0.3.4").mkdir(parents=True) + (self.cache / "full-stack-plugins" / "codeguard" / "0.5.4").mkdir(parents=True) + + def test_stale_current_gets_note_with_newest(self) -> None: + note = version_backlog_note("0.3.4", self.cache) + self.assertIsNotNone(note) + self.assertIn("0.5.4", note) + self.assertIn("2 份", note) + + def test_current_newest_silent(self) -> None: + self.assertIsNone(version_backlog_note("0.5.4", self.cache)) + self.assertIsNone(version_backlog_note("0.9.0", self.cache)) + self.assertIsNone(version_backlog_note("1.0.0", + Path(tempfile.mkdtemp(prefix="cg-nocache-")))) + + +class AppendFilesTests(unittest.TestCase): + """#P2-6:项目级命令(mvn 等)不追加文件——追加会被当 goal 误拦。""" + + def test_mvn_not_appended_when_disabled(self) -> None: + mvn = ["mvn", "-q", "javadoc:jar", "-DskipTests"] + got = scope.scope_cmd(mvn, PLUGIN, files=["a.java"], append_files=False) + self.assertEqual(got, mvn) + + def test_bare_linter_still_appends_by_default(self) -> None: + got = scope.scope_cmd(["yamllint", "."], PLUGIN, files=["a.yaml"]) + self.assertEqual(got, ["yamllint", "a.yaml"]) + + def test_registry_marks_project_level_commands(self) -> None: + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + by_id = {l["id"]: l for l in reg["languages"]} + for lid in ("java", "kotlin", "rust", "go", "csharp", "vbnet", + "terraform", "protobuf", "elm"): + with self.subTest(lang=lid): + self.assertIs(by_id[lid].get("append_files"), False, + f"{lid} 是项目级命令,必须 append_files=false") + + +if __name__ == "__main__": + unittest.main()