Skip to content

Commit 86fab29

Browse files
committed
test(pain-fixes): 夹具 pom 补全 artifactId(缺失导致 _maven 前置失败恒 UNVERIFIED);skip_event 审计明细增强
- JavaExecutableContractTest 夹具补 groupId/artifactId/version/packaging—— 不完整 pom 会让 _maven 在 wrapper 判定前就抛"缺少 artifactId", 契约测试恒 UNVERIFIED 无法到达 executable 断言 - record_skip_event 增加可选 detail 参数:skipGate-read-error 与 monorepo-fallback 事件记录具体原因(豁免/跳过必须可回溯到"哪个仓、 什么时候、为什么");resolve_project_roots 回退路径同步传 None lanes - 测试断言消息附带 plan reasons(失败时直接看到判定依据)
1 parent 02d0c18 commit 86fab29

4 files changed

Lines changed: 188 additions & 12 deletions

File tree

‎hooks/gate_lib.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -816,11 +816,12 @@ def record_gate_decision(project_root: Path, lang: str, cmd: list[str], rc: int,
816816
path.write_text("\n".join(lines[-limit:]) + "\n", encoding="utf-8")
817817

818818

819-
def record_skip_event(kind: str, project_root: Path | None = None) -> None:
819+
def record_skip_event(kind: str, project_root: Path | None = None,
820+
detail: str | None = None) -> None:
820821
"""记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。
821822
822-
除计数外保留最近 20 条明细(时间 + 仓库 + 类型)——豁免必须可回溯:
823-
只有总数时无法回答"哪个仓、什么时候被跳过的"。
823+
除计数外保留最近 20 条明细(时间 + 仓库 + 类型 + 可选 detail)——豁免
824+
必须可回溯:只有总数时无法回答"哪个仓、什么时候、为什么被跳过的"。
824825
"""
825826
state = {}
826827
path = session_state_path()
@@ -838,6 +839,7 @@ def record_skip_event(kind: str, project_root: Path | None = None) -> None:
838839
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
839840
"kind": kind,
840841
"repo": project_root.name if project_root else None,
842+
**({"detail": detail} if detail else {}),
841843
})
842844
del events[:-20]
843845
try:

‎hooks/pre_tool_git_guard.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -626,13 +626,13 @@ def main() -> int:
626626
# 兜底面收窄(会话实测误拦:无关仓存量违规连带拦提交)。全空 = 本次
627627
# 无可拦对象,审计后放行。
628628
before = len(roots)
629-
roots = _filter_fallback_roots(roots, mode, lanes)
629+
roots = _filter_fallback_roots(roots, mode, None)
630630
fallback_note += f";提交面收窄 {before}→{len(roots)} 个仓"
631631
if not roots:
632-
record_skip_event("monorepo-fallback-empty")
632+
record_skip_event("monorepo-fallback-empty", detail=fallback_note)
633633
return 0
634634
# 兜底走通:仅在会话状态记录(不进 stderr,避免噪音),Stop 摘要可见
635-
record_skip_event("monorepo-fallback", roots[0])
635+
record_skip_event("monorepo-fallback", roots[0], detail=fallback_note)
636636
if any(skip_gate_via_git_config(r) for r in roots):
637637
record_skip_event("skipGate", roots[0])
638638
return 0
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""2026-09-23 会话反馈批次回归:zsh 剥离三面覆盖 + 兜底面收窄。
2+
3+
来自一次真实多 SDK 发布会话(codeguard 连拦 4 次:zsh SC1071 三连、且
4+
openclaw 提交被 workspace 根散脚本与无关仓存量连带误拦):
5+
1. zsh 剥离只在 delta 面生效——全量/UPS/monorepo 回退面 .zsh 照送
6+
shellcheck → SC1071 误拦(根散脚本 + 子仓 zsh 双双中招)。
7+
2. 部分剥离静默丢弃——提交方不知道有一部分文件压根没被检查。
8+
3. monorepo 兜底把 workspace 下所有子仓连同根上散落脚本一起纳入拦路面——
9+
无关仓的存量违规也 block(越权误拦)。
10+
4. skip 话术没有可执行的修复指令——AI/用户要自行猜测"怎么才能送检"。
11+
"""
12+
from __future__ import annotations
13+
14+
import json
15+
import os
16+
import subprocess
17+
import sys
18+
import tempfile
19+
import unittest
20+
from pathlib import Path
21+
22+
PLUGIN = Path(__file__).resolve().parents[1]
23+
sys.path.insert(0, str(PLUGIN / "scripts"))
24+
sys.path.insert(0, str(PLUGIN / "hooks"))
25+
26+
from run_per_language import run_fix
27+
28+
29+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess:
30+
return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False)
31+
32+
33+
def _fresh_repo() -> Path:
34+
root = Path(tempfile.mkdtemp(prefix="cg-sess2-"))
35+
_git(root, "init", "-q")
36+
_git(root, "config", "user.email", "t@t")
37+
_git(root, "config", "user.name", "t")
38+
return root
39+
40+
41+
def _run_guard(command: str, cwd: Path) -> subprocess.CompletedProcess:
42+
home = Path(tempfile.mkdtemp(prefix="cg-home-"))
43+
env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"}
44+
return subprocess.run(
45+
[sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")],
46+
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}),
47+
capture_output=True, text=True, cwd=cwd, env=env, timeout=120, check=False,
48+
)
49+
50+
51+
@unittest.skipUnless(__import__("shutil").which("shellcheck"), "shellcheck 未安装")
52+
class ZshStripGateTests(unittest.TestCase):
53+
"""#1/#2/#4:zsh 剥离的 skipped 话术必须带可执行修复指令,且不拦提交。"""
54+
55+
def test_zsh_only_commit_passes_with_fix_hint(self) -> None:
56+
"""纯 zsh 提交:剥离后放行(非 block),skip 话术带可执行修复。"""
57+
repo = _fresh_repo()
58+
(repo / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
59+
_git(repo, "add", "-A")
60+
r = _run_guard("git commit -m t", repo)
61+
self.assertEqual(r.returncode, 0,
62+
f"纯 zsh 提交不得被拦: {r.stderr[:400]}")
63+
self.assertIn("shellcheck shell=bash", r.stdout,
64+
"skip 话术必须给出可执行的修复指令")
65+
66+
def test_zsh_partial_still_lints_remaining_sh(self) -> None:
67+
"""zsh + sh 混合:zsh 剥离后余下 .sh 照常送检并拦真实违规。"""
68+
repo = _fresh_repo()
69+
(repo / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
70+
(repo / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
71+
encoding="utf-8")
72+
_git(repo, "add", "-A")
73+
r = _run_guard("git commit -m t", repo)
74+
self.assertEqual(r.returncode, 2, r.stderr[:400])
75+
self.assertIn("bad.sh", r.stderr, "余下 .sh 的真实违规必须拦")
76+
77+
78+
@unittest.skipUnless(__import__("shutil").which("shellcheck"), "shellcheck 未安装")
79+
class FallbackNarrowTests(unittest.TestCase):
80+
"""#3:monorepo 兜底面收窄——无提交面的仓不得进拦路面。"""
81+
82+
def _workspace(self) -> tuple[Path, Path, Path]:
83+
"""workspace 根(非 git 仓)+ 两个子仓:dirty(staged 违规)/ stale
84+
(已提交违规但无提交面——模拟无关仓存量)。"""
85+
ws = Path(tempfile.mkdtemp(prefix="cg-fallback-ws-"))
86+
dirty = ws / "dirty-repo"
87+
dirty.mkdir()
88+
_git(dirty, "init", "-q")
89+
_git(dirty, "config", "user.email", "t@t")
90+
_git(dirty, "config", "user.name", "t")
91+
(dirty / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
92+
encoding="utf-8")
93+
_git(dirty, "add", "-A")
94+
95+
stale = ws / "stale-repo"
96+
stale.mkdir()
97+
_git(stale, "init", "-q")
98+
_git(stale, "config", "user.email", "t@t")
99+
_git(stale, "config", "user.name", "t")
100+
(stale / "legacy.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
101+
encoding="utf-8")
102+
_git(stale, "add", "-A")
103+
_git(stale, "commit", "-q", "-m", "i")
104+
return ws, dirty, stale
105+
106+
def test_fallback_blocks_only_dirty_repo(self) -> None:
107+
ws, _dirty, _stale = self._workspace()
108+
home = Path(tempfile.mkdtemp(prefix="cg-home-"))
109+
env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"}
110+
import subprocess
111+
r = subprocess.run(
112+
[sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")],
113+
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}),
114+
capture_output=True, text=True, cwd=ws, env=env, timeout=120, check=False,
115+
)
116+
self.assertEqual(r.returncode, 2, r.stderr[:400])
117+
self.assertIn("dirty-repo", r.stderr, "有提交面违规的仓必须拦")
118+
self.assertNotIn("stale-repo", r.stderr,
119+
"无提交面的仓(存量违规)不得进拦路面")
120+
121+
def test_fallback_all_clean_passes_audited(self) -> None:
122+
ws = Path(tempfile.mkdtemp(prefix="cg-fallback-clean-"))
123+
clean = ws / "clean-repo"
124+
clean.mkdir()
125+
_git(clean, "init", "-q")
126+
_git(clean, "config", "user.email", "t@t")
127+
_git(clean, "config", "user.name", "t")
128+
(clean / "good.sh").write_text('#!/bin/bash\nx="ok"\necho "$x"\n', encoding="utf-8")
129+
_git(clean, "add", "-A")
130+
_git(clean, "commit", "-q", "-m", "i")
131+
import subprocess
132+
home = Path(tempfile.mkdtemp(prefix="cg-home-"))
133+
env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"}
134+
r = subprocess.run(
135+
[sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")],
136+
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}),
137+
capture_output=True, text=True, cwd=ws, env=env, timeout=120, check=False,
138+
)
139+
self.assertEqual(r.returncode, 0, r.stderr[:400])
140+
state = json.loads((home / "session_state.json").read_text(encoding="utf-8"))
141+
kinds = state.get("_skip", {}).get("kinds", {})
142+
self.assertEqual(kinds.get("monorepo-fallback-empty"), 1,
143+
"全空兜底必须留审计事件(monorepo-fallback-empty)")
144+
145+
146+
class RunFixZshStripTests(unittest.TestCase):
147+
"""#1(fix 面):run_fix 与门禁共用 zsh 剥离认知——shfmt 不支持 zsh,
148+
-w 会重排方言语法造成损坏,必须跳过并给出可执行的门禁修复指令。"""
149+
150+
def test_run_fix_skips_zsh_with_hint(self) -> None:
151+
root = _fresh_repo()
152+
(root / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
153+
results = run_fix(["shell"], root, files=["tool.zsh"])
154+
self.assertEqual(len(results), 1)
155+
self.assertEqual(results[0].get("status"), "SKIPPED")
156+
self.assertIn("zsh", results[0].get("note", ""))
157+
self.assertIn("shell=bash", results[0].get("note", ""))
158+
159+
def test_run_fix_mixed_keeps_sh_and_skips_zsh(self) -> None:
160+
root = _fresh_repo()
161+
(root / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
162+
(root / "t.sh").write_text("echo ok\n", encoding="utf-8")
163+
results = run_fix(["shell"], root, files=["tool.zsh", "t.sh"])
164+
# shfmt 未安装时 b.sh 归"未执行",但 zsh 的 SKIPPED 备注必须仍在
165+
skipped = [r for r in results if r.get("skipped")]
166+
self.assertTrue(any("zsh" in r.get("note", "") for r in skipped),
167+
"zsh 跳过必须有明示备注")
168+
169+
170+
if __name__ == "__main__":
171+
unittest.main()

‎tests/test_session_pain_fixes.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,21 @@ def setUp(self):
3636
self.root = Path(self.tmp.name)
3737
(self.root / "pom.xml").write_text(
3838
'<project xmlns="http://maven.apache.org/POM/4.1.0">'
39-
"<modelVersion>4.1.0</modelVersion></project>", encoding="utf-8")
39+
"<modelVersion>4.1.0</modelVersion>"
40+
"<groupId>test</groupId><artifactId>test</artifactId>"
41+
"<version>1.0</version>"
42+
"<packaging>jar</packaging></project>", encoding="utf-8")
4043

4144
def test_executable_wrapper_present_and_executable(self):
4245
mvnw = self.root / "mvnw"
4346
mvnw.write_text("#!/bin/sh\nexit 0\n")
4447
mvnw.chmod(0o755)
4548
plan = analyze(str(self.root))
46-
self.assertEqual(plan["status"], "PLANNED")
49+
self.assertEqual(plan["status"], "PLANNED",
50+
"reasons=" + "; ".join(plan["reasons"]))
4751
self.assertEqual(plan.get("executable"), "./mvnw",
48-
"生产者必须产出顶层 executable 键(门禁消费者直接读取)")
52+
"生产者必须产出顶层 executable 键;reasons="
53+
+ "; ".join(plan["reasons"]))
4954

5055
def test_executable_wrapper_missing_falls_back_to_mvn(self):
5156
plan = analyze(str(self.root))
@@ -150,9 +155,7 @@ def test_child_git_repos_detected(self):
150155

151156
def test_find_gate_excludes_child_repos(self):
152157
cmd = ["bash", "-c",
153-
"find . \\( -name '*.sh' \\) -type f "
154-
"-not -path '*/node_modules/*' -print0 | "
155-
"xargs -0 -r shellcheck --severity=warning"]
158+
("find . \\( -name '*.sh' \\) -type f " "-not -path '*/node_modules/*' -print0 | " "xargs -0 -r shellcheck --severity=warning")]
156159
out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
157160
joined = " ".join(out)
158161
self.assertIn("-not -path '*/sub-repo/*'", joined)

0 commit comments

Comments
 (0)