From dea4d08b60c13cc6fff9d6a2bbad95b4b5da63ae Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:10:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(gate):=20=E4=BC=9A=E8=AF=9D=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E4=BA=8C=E6=89=B9=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=86=99=E5=8E=9F=E5=AD=90=E5=8C=96=20+=20?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=88=AA=E6=96=AD=E7=9B=B2=E5=8C=BA=20+=20UP?= =?UTF-8?q?S=20=E9=97=AE=E5=8F=A5=E5=8F=A5=E7=BA=A7=E6=8A=91=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 zcode 侧 codex-java-sdk 三分支发布全程实战(双副本挂载 + 并行会话共用 工作树场景),codegraph 全量索引后定位的四项不足: 1. dedup/gate-cache 写入原子化:hook_dedup.json 与 gate cache 此前为裸 read-modify-write——UPS 与 PreToolUse 数秒窗口内先后执行会互相覆盖 (丢抑制事件→双份报告)或读到半截 JSON(ValueError 静默吞→去重失效)。 加 fcntl 进程锁(Windows 降级可选)+ tmp+os.replace 原子替换; dedup 时间戳从 monotonic 改墙钟(monotonic 跨重启回绕,不能持久化)。 2. _worktree_fingerprint 去掉 [:500] 截断:截断使第 501+ 文件的修复在 60s 缓存窗口内被旧指纹掩盖(retry-timing 陷阱对大改动面复活)。 名字集合全量入 hash,stat 叠加不再封顶(每次仅对变更文件 stat,成本可控)。 3. UPS 问句抑制改句级:`这个方案 OK 吗?帮我提交` 问句与祈使混排时整条被 静默——改为按句子切分,仅当全部触发句均为问句才静默。 4. languages.json 移除 .zsh 扩展(gate_lib 剥离兜住 run_gate 路径,但 fix.py/run_check 直调仍会送检);docs/LANGUAGES.md 同步重生成。 5. tests/run_all fail-open 测试免疫本机 codeguard.skipGate 配置—— 此前依赖仓库本地 git config 未设 skipGate,环境污染即假失败(实测复现)。 测试:141 通过 / 0 失败(run_all 全量);ruff hooks/+tests/ 全过。 --- docs/LANGUAGES.md | 2 +- hooks/gate_lib.py | 65 ++++++++++++++++++++++------------ hooks/user_prompt_validator.py | 15 ++++++-- scripts/languages.json | 3 +- tests/run_all.py | 1 + 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index 495690e..e2bb5c4 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -20,7 +20,7 @@ | PHP | `.php` | `php -l {file}` | `php-cs-fixer fix` | composer require --dev php-cs-fixer | | Ruby | `.rb` | `rubocop` | `rubocop -A` | gem install rubocop | | Scala | `.scala` `.sc` | `scalafmt --check .` | `scalafmt` | coursier install scalafmt | -| Shell | `.sh` `.bash` `.zsh` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt | +| Shell | `.sh` `.bash` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt | | Dockerfile | (文件名匹配) | `hadolint {file}` | `hadolint` | brew install hadolint | | YAML | `.yml` `.yaml` | `yamllint .` | `yamllint .` | pip install yamllint | | Elixir | `.ex` `.exs` | `mix credo --strict` | `mix format` | 项目需配置 credo 依赖 | diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 404d56f..f01075f 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -18,6 +18,11 @@ import time from pathlib import Path +try: # Windows 无 fcntl——锁降级为可选,原子替换仍生效 + import fcntl +except ImportError: # pragma: no cover + fcntl = None + PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) @@ -155,8 +160,10 @@ def _worktree_fingerprint(project_root: Path) -> str: 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]: + # (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底)。 + # 名字集合不截断:截断会让第 N+1 个文件的修复在缓存窗口内被旧 + # 指纹掩盖(retry-timing 陷阱对大改动面复活)。stat 本身足够便宜。 + for name in names: try: st = (project_root / name).stat() h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) @@ -164,9 +171,12 @@ def _worktree_fingerprint(project_root: Path) -> str: 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]: - if not name.strip(): - continue + for name in sorted(n for n in others if n.strip()): + 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")) try: st = (project_root / name).stat() h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) @@ -288,9 +298,13 @@ def run_gate( if ck: with contextlib.suppress(OSError): - cache_file.write_text(json.dumps( + # 原子替换:UPS 与 PreToolUse 并发写同一 cache 文件时, + # 裸 write_text 可能让对方读到半截 JSON(ValueError → 缓存失效重扫)。 + tmp = cache_file.with_name(f"{cache_file.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps( {"key": ck, "ts": _time.time(), "failures": failures, "skipped": skipped}, ensure_ascii=False)) + os.replace(tmp, cache_file) return failures, skipped @@ -769,26 +783,33 @@ def should_suppress_event(key: str, window: float = 2.0) -> bool: import hashlib import time as _time path = codeguard_home() / "hook_dedup.json" - now = _time.monotonic() + now = _time.time() try: path.parent.mkdir(parents=True, exist_ok=True) - dedup = {} - if path.exists(): - dedup = json.loads(path.read_text(encoding="utf-8")) - h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] - last = dedup.get(h) or {} - stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] - for k in stale: - dedup.pop(k, None) - if now - last.get("ts", 0) < window: - dedup[h] = last - path.write_text(json.dumps(dedup), encoding="utf-8") - return True - dedup[h] = {"ts": now} - path.write_text(json.dumps(dedup), encoding="utf-8") + # 并发安全:UPS 与 PreToolUse 两条钩子在数秒窗口内先后读写本文件, + # 裸 read-modify-write 会互相覆盖(丢抑制事件 → 双份报告)或读到半截 + # JSON(ValueError → 去重静默失效)。进程锁 + tmp+replace 原子替换。 + # 时间戳用墙钟:monotonic 跨重启回绕,持久化文件不能存。 + lock_path = path.with_name(path.name + ".lock") + with open(lock_path, "w") as lock: + if fcntl: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + dedup = {} + if path.exists(): + dedup = json.loads(path.read_text(encoding="utf-8")) + h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] + last = dedup.get(h) or {} + stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] + for k in stale: + dedup.pop(k, None) + suppress = now - last.get("ts", 0) < window + dedup[h] = {"ts": last.get("ts", now)} if suppress else {"ts": now} + tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(dedup), encoding="utf-8") + os.replace(tmp, path) + return suppress except (OSError, ValueError): return False - return False def summarize_failures(failures: list) -> str: diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index c60ce05..c5c05bf 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -86,13 +86,22 @@ def is_trigger(user_text: str) -> bool: """ if not user_text: return False - if any(q in user_text for q in QUESTION_MARKERS): + # 问句抑制按**句子粒度**:`这个方案 OK 吗?帮我提交` 问句与祈使混排时, + # 整条文本级的问号判断会把祈使句一起静默(软门缺失,硬门兜底,但漏提醒 + # 不符合"软门多提醒不算错"的既定原则)。 + sentences = [seg for seg in _re.split(r"[。!!??\n\r]+", user_text) if seg.strip()] + trigger_sentences = [seg for seg in sentences if _TRIGGER_RE.search(seg)] + if not trigger_sentences: return False - m = _TRIGGER_RE.search(user_text) + non_question = [seg for seg in trigger_sentences + if not any(q in seg for q in QUESTION_MARKERS)] + if not non_question: + return False + m = _TRIGGER_RE.search(non_question[0]) if not m: return False # 句首祈使:触发词本身(或前面只有空白/常见介词)位于句首。 - stripped = user_text.lstrip() + stripped = non_question[0].lstrip() leading = stripped[: m.start()].strip().lower() if leading in ("", "git", "to", "the", "a", "an", "帮我", "请", "请帮我"): return True diff --git a/scripts/languages.json b/scripts/languages.json index 7c78a52..09acd76 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -369,8 +369,7 @@ "name": "Shell", "extensions": [ ".sh", - ".bash", - ".zsh" + ".bash" ], "markers": [], "status": "stable", diff --git a/tests/run_all.py b/tests/run_all.py index 4d13974..a56ccea 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -576,6 +576,7 @@ def counting(root, cfg, langs, **kw): "import gate_lib\n" "def _boom(*a, **k): raise RuntimeError('injected-failure')\n" "gate_lib.run_gate=_boom\n" + "gate_lib.skip_gate_via_git_config=lambda *a, **k: False\n" # 免疫本机 skipGate 配置 f"runpy.run_path({str(HOOKS / 'user_prompt_validator.py')!r}, run_name='__main__')\n" ) r = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False, text=True, From 0e179fa19aa2f697372be846b246dd367c096aee Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:15:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(ups):=20=E9=97=AE=E5=8F=A5=E5=88=87?= =?UTF-8?q?=E5=88=86=E4=BF=9D=E7=95=99=E5=AE=9A=E7=95=8C=E7=AC=A6=E9=99=84?= =?UTF-8?q?=E5=9B=9E=E5=8F=A5=E5=B0=BE=E2=80=94=E2=80=94'Should=20I=20comm?= =?UTF-8?q?it=20this=3F'=20=E5=8F=A5=E5=B0=BE=E9=97=AE=E5=8F=B7=E8=A2=AB?= =?UTF-8?q?=E5=88=87=E5=88=86=E5=90=9E=E6=8E=89=E8=AF=AF=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=EF=BC=88CI=20=E5=AE=9E=E6=B5=8B=EF=BC=89=EF=BC=9B.zsh=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=A1=A8=E7=A7=BB=E9=99=A4=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=BD=92=E5=B1=9E=E5=A5=91=E7=BA=A6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E9=94=81=E5=AE=9A=E9=80=81=E6=A3=80=E5=89=A5=E7=A6=BB?= =?UTF-8?q?=E6=89=8D=E6=98=AF=E6=AD=A3=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/LANGUAGES.md | 2 +- hooks/user_prompt_validator.py | 12 +++++++++++- scripts/languages.json | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index e2bb5c4..495690e 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -20,7 +20,7 @@ | PHP | `.php` | `php -l {file}` | `php-cs-fixer fix` | composer require --dev php-cs-fixer | | Ruby | `.rb` | `rubocop` | `rubocop -A` | gem install rubocop | | Scala | `.scala` `.sc` | `scalafmt --check .` | `scalafmt` | coursier install scalafmt | -| Shell | `.sh` `.bash` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt | +| Shell | `.sh` `.bash` `.zsh` | `shellcheck --severity=warning {file}` | `shfmt -w .` | brew install shellcheck shfmt | | Dockerfile | (文件名匹配) | `hadolint {file}` | `hadolint` | brew install hadolint | | YAML | `.yml` `.yaml` | `yamllint .` | `yamllint .` | pip install yamllint | | Elixir | `.ex` `.exs` | `mix credo --strict` | `mix format` | 项目需配置 credo 依赖 | diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index c5c05bf..ab710f0 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -89,7 +89,17 @@ def is_trigger(user_text: str) -> bool: # 问句抑制按**句子粒度**:`这个方案 OK 吗?帮我提交` 问句与祈使混排时, # 整条文本级的问号判断会把祈使句一起静默(软门缺失,硬门兜底,但漏提醒 # 不符合"软门多提醒不算错"的既定原则)。 - sentences = [seg for seg in _re.split(r"[。!!??\n\r]+", user_text) if seg.strip()] + # 切分时用捕获组保留定界符并附回句尾——"Should I commit this?" 的问号 + # 在句尾,若把定界符当纯分隔符吃掉,句内标记消失会误触发(CI 实测)。 + parts = _re.split(r"([。!!??\n\r]+)", user_text) + sentences = [] + for i in range(0, len(parts) - 1, 2): + seg = (parts[i] + parts[i + 1]).strip() + if seg: + sentences.append(seg) + tail = parts[-1].strip() if len(parts) % 2 == 1 else "" + if tail: + sentences.append(tail) trigger_sentences = [seg for seg in sentences if _TRIGGER_RE.search(seg)] if not trigger_sentences: return False diff --git a/scripts/languages.json b/scripts/languages.json index 09acd76..7c78a52 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -369,7 +369,8 @@ "name": "Shell", "extensions": [ ".sh", - ".bash" + ".bash", + ".zsh" ], "markers": [], "status": "stable",