From 2d5457a0b46ace635db77053025337ed91d771a9 Mon Sep 17 00:00:00 2001
From: loong10k <20489781+loong10k@users.noreply.github.com>
Date: Wed, 23 Sep 2026 02:53:34 +0800
Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E5=AE=9E?=
=?UTF-8?q?=E6=B5=8B=E4=BC=98=E5=8C=96=E2=80=94=E2=80=94=E8=A7=84=E5=88=99?=
=?UTF-8?q?=E8=81=9A=E5=90=88=E8=AE=A1=E6=95=B0/java=5Fproject=20executabl?=
=?UTF-8?q?e=20=E9=94=AE/scope=20=E8=BE=B9=E7=95=8C=E5=8A=A0=E5=9B=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
基于 opencode-java-sdk 六分支发布实测的后续优化:
- gate_lib:新增 _rule_summary() 聚合 lint 规则计数(AI 被截断后
一次看到问题全集与规模,不再 whack-a-mole 逐个修)
- pre_tool_git_guard:gate_directive 传入 project_root(供日志/决策)
- java_project:plan 顶层暴露 executable 键——门禁消费者直接读取
用于 mvn→./mvnw 替换(POM 4.1.0 场景)
- scope:新增 is_git_repo 工具函数 + full_excludes 泛化改进
测试 248 个全绿。codeguard skipGate 已获用户明确指令启用。
---
hooks/gate_lib.py | 61 ++++++++++-
hooks/pre_tool_git_guard.py | 2 +-
scripts/java_project.py | 4 +
scripts/scope.py | 39 ++++++-
tests/test_session_pain_fixes.py | 172 +++++++++++++++++++++++++++++++
5 files changed, 268 insertions(+), 10 deletions(-)
create mode 100644 tests/test_session_pain_fixes.py
diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py
index da08f2d..84418cf 100644
--- a/hooks/gate_lib.py
+++ b/hooks/gate_lib.py
@@ -317,11 +317,32 @@ def _log_path(project_root: Path, lang: str) -> Path:
return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log"
+def _rule_summary(full: str) -> str:
+ """ruff/checkstyle 输出的规则聚合计数行(如 "EXE001×3 UP009×1")。
+
+ 2026-09-23 opencli 会话实测:门禁输出被截断后 AI 修一个才暴露下一个
+ (whack-a-mole 升级版),聚合计数让 AI 一次看到问题全集与规模。
+ 非 linter 标准行格式(无规则码锚)时返回空串。
+ """
+ import re as _re
+ counts: dict[str, int] = {}
+ for line in full.splitlines():
+ m = _re.search(r"\b([A-Z]{2,5}\d{3,4})\b", line)
+ if m and (":" in line or "-->" in line):
+ counts[m.group(1)] = counts.get(m.group(1), 0) + 1
+ if not counts:
+ return ""
+ return "规则汇总: " + " ".join(f"{k}×{v}" for k, v in sorted(counts.items()))
+
+
def _truncate_detail(full: str, project_root: Path, lang: str) -> str:
"""节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、
重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。"""
lines = [ln for ln in full.splitlines() if ln.strip()]
+ summary_line = _rule_summary(full)
detail = full[:600]
+ if summary_line:
+ detail = summary_line + "\n" + detail
if len(lines) > 8 or len(full) > 600:
try:
path = _log_path(project_root, lang)
@@ -415,7 +436,9 @@ def check(lang: str):
if outcome["status"] == "PASS":
return None
if outcome["status"] != "FAIL":
- return (lang, None, f"java {outcome['status']}: {outcome['reason']}")
+ hint_extra = ";若 wrapper 缺执行位:chmod +x mvnw" \
+ if "不可执行" in outcome.get("reason", "") else ""
+ return (lang, None, f"java {outcome['status']}: {outcome['reason']}{hint_extra}")
detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), project_root, lang)
if outcome.get("log_path"):
detail += f"\n完整输出: {outcome['log_path']}"
@@ -470,7 +493,7 @@ def check(lang: str):
return (lang, None,
(f"{lang} 项目分析 UNVERIFIED({_jp.get('build_system', '?')}),"
f"原因: {'; '.join(_jp.get('reasons', []))},本次未验证"))
- exe = _jp.get("executable")
+ exe = _java_executable_from_plan(_jp)
if exe and base_cmd and base_cmd[0] in ("mvn", "gradle"):
base_cmd = [exe] + base_cmd[1:]
except Exception as exc: # noqa: BLE001 — 分析失败不阻塞门禁,走原路径
@@ -627,6 +650,26 @@ def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict:
hint = "见 https://openspec.dev 或 `npm i -g @fission-ai/openspec`"
return {"failures": [("openspec", detail, fix, hint)], "skipped": None}
+def _java_executable_from_plan(plan: dict) -> str | None:
+ """从 java_project.analyze 的产物里解析构建可执行文件。
+
+ 两条来源按优先级:
+ 1. 顶层 ``executable`` 键(java_project 正常产出);
+ 2. 回退 ``commands[0].argv[0]``——历史版本的 java_project 只把
+ wrapper 体现在计划命令里(实测 68c8a37 曾因此让 mvnw 感知成为
+ 死代码:消费者读的键生产者从不写)。
+ 非 mvn/gradle wrapper(如 codeguard.json 显式命令)不参与替换。
+ """
+ exe = plan.get("executable")
+ if exe:
+ return exe
+ for cmd in plan.get("commands", []) or []:
+ argv = cmd.get("argv") or []
+ if argv and Path(argv[0]).name in ("mvnw", "gradlew"):
+ return argv[0]
+ return None
+
+
def skip_gate_via_git_config(project_root: Path) -> bool:
"""仓库级豁免:git config codeguard.skipGate true。
@@ -861,6 +904,8 @@ def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[s
f" ▶ 怎么修: {fix}",
f" ▶ 未安装工具时先安装: {hint}",
]
+ if lang == "python" and "[*]" in (detail or ""):
+ tail.append(" ▶ 含 [*] 可自动修复项: ruff check --fix <涉及路径>")
body = detail if detail else " lint 退出码非零,无文本输出"
if fix_first:
block += tail + [body]
@@ -886,19 +931,24 @@ def format_failure_report(failures: list) -> str:
return "\n".join(lines)
-def gate_directive(failures: list) -> str:
+def gate_directive(failures: list, project_root: Path | str | None = None) -> str:
"""给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户。
+ project_root:本次门禁的目标仓库根——cwd 漂移会让钩子扫到"非目标仓"
+ (实测:在 codex 仓 cwd 下提交 opencli,POM 4.1.0 报错让人以为改错了
+ 文件),首行显式标注目标仓库可第一时间发现扫错对象。
+
结构 = 综述(首行契约)→ 强制指令(前置!)→ 每语言细节(fix 先于
detail)→ 修复入口;整体压到 REPORT_MAX_CHARS 内。指令必须前置:宿主把
超长 stderr 从尾部截断,此前指令在报告末段,长报告下 AI 只看到首段 linter
报错、「拆两次调用」与 skipGate 逃生门全部丢失(实测)。首行仍为综述、
综述不重复、含「具体问题」——run_all/硬门禁契约保持。
"""
+ head_repo = f"本次门禁目标仓库: {Path(project_root).resolve()}\n" if project_root else ""
header = [
summarize_failures(failures),
f"codeguard v{CODEGUARD_VERSION}",
- "─" * 60,
+ head_repo + "─" * 60,
"**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n"
+ "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash "
"调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与"
@@ -914,7 +964,8 @@ def gate_directive(failures: list) -> str:
+ "确需绕过(仅用户明确要求时):**单次豁免**用 `git -c codeguard.skipGate=true commit …`"
"(不落配置、无残留,推荐);**仓库级豁免**在该仓库执行 git config codeguard.skipGate true,"
"完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE "
- "只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。",
+ "只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。"
+ "注意:仓库级豁免对该克隆**所有分支**生效且跨会话残留,务必按上方说明 unset 恢复。",
"─" * 60,
]
footer = f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py"
diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py
index 7723e85..c106ca8 100755
--- a/hooks/pre_tool_git_guard.py
+++ b/hooks/pre_tool_git_guard.py
@@ -652,7 +652,7 @@ def main() -> int:
if failures:
# 版本自标识由 gate_directive 首行综述之后的第二行承担——
# 此处不再前置横幅:stderr 首行必须是综述(三个契约测试锁定)。
- reports.append(gate_directive(failures))
+ reports.append(gate_directive(failures, project_root=project_root))
unknown = [s for s in _skipped if "本次改动未涉及" not in s and " SKIPPED:" not in s
and "markdown 风格告警" not in s]
if unknown:
diff --git a/scripts/java_project.py b/scripts/java_project.py
index 57caea7..2974898 100644
--- a/scripts/java_project.py
+++ b/scripts/java_project.py
@@ -236,6 +236,10 @@ def analyze(project_root: str | Path, changed: list[str] | None = None) -> dict:
executable = "./" + wrapper if (root / wrapper).is_file() else ("mvn" if system == "maven" else "gradle")
if (root / wrapper).exists() and os.name != "nt" and not os.access(root / wrapper, os.X_OK):
raise ValueError(f"wrapper 不可执行: {wrapper}")
+ # 顶层 executable 键:门禁消费者(gate_lib Java 门禁)直接读取,
+ # 用于把 PATH 上的 mvn/gradle 替换为项目自带 wrapper——
+ # POM 4.1.0 等 Maven 4 仓库在 Maven 3 下必然解析失败(实测)。
+ plan["executable"] = executable
relevant = None if changed is None else [p for p in changed if p.endswith((".java", ".kt", ".groovy", ".scala"))
or "/src/" in "/" + p
or Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/"))]
diff --git a/scripts/scope.py b/scripts/scope.py
index 16f14a8..88c885b 100644
--- a/scripts/scope.py
+++ b/scripts/scope.py
@@ -50,6 +50,31 @@
)
+def is_git_repo(root: str | Path) -> bool:
+ """路径自身是否为 git 仓(.git 存在)。"""
+ return (Path(root) / ".git").exists()
+
+
+def child_git_repo_names(root: str | Path) -> list[str]:
+ """扫描根**不是** git 仓(典型:会话工作区根)时,列出其下自带 .git
+ 的直接子目录名。
+
+ 这些子目录是独立仓库、由它们各自的提交门禁负责;非 git 根的全量扫描
+ 若把它们一并扫进去,就会出现"提交 A 仓却被 B 仓的存量问题拦截"的
+ 跨仓误伤(2026-09-23 opencli-java-sdk 会话实测:workspace 根的
+ push-all-branches.sh 触发门禁时,根级脚本与子仓文件混在同一份报告里)。
+ 根级别的散文件(不属于任何子仓)仍保留在扫描面内——它们没有别的门禁。
+ """
+ out: list[str] = []
+ try:
+ for child in sorted(Path(root).iterdir()):
+ if child.is_dir() and (child / ".git").exists():
+ out.append(child.name)
+ except OSError:
+ pass
+ return out
+
+
def is_build_artifact(path: str | Path) -> bool:
"""路径是否落在构建产物/依赖快照目录下(任一段命中即算)。
@@ -63,7 +88,7 @@ def is_build_artifact(path: str | Path) -> bool:
_RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml"
-def _inject_find_excludes(expr: str) -> str:
+def _inject_find_excludes(expr: str, excludes: tuple[str, ...] | list[str] = FULL_SCAN_EXCLUDES) -> str:
"""给 `find …` 型 gate 表达式注入构建产物目录排除(-not -path)。
覆盖三种实测形态——只认一种就有整族门禁漏网:
@@ -79,7 +104,7 @@ def _inject_find_excludes(expr: str) -> str:
return expr
additions = "".join(
f" -not -path '*/{d}/*'"
- for d in FULL_SCAN_EXCLUDES
+ for d in excludes
if f"'*/{d}/*'" not in expr and f"'*/{d}'" not in expr
)
if not additions:
@@ -179,16 +204,22 @@ def scope_cmd(
if not out:
return out
targets = [single_file] if single_file else list(files or [])
+ root = Path(project_root)
+ # 非 git 根(会话工作区)的全量扫描:排除子 git 仓——它们由各自的
+ # 提交门禁负责,混进来就是跨仓误伤(见 child_git_repo_names)。
+ scan_excludes = list(FULL_SCAN_EXCLUDES)
+ if full_excludes and not is_git_repo(root):
+ scan_excludes += child_git_repo_names(root)
if "{file}" in " ".join(out):
return [c.replace("{file}", single_file or "") for c in out]
if out[0] == "ruff":
args = ruff_config_args(out, project_root)
out = [out[0]] + args + out[1:]
if full_excludes:
- for d in FULL_SCAN_EXCLUDES:
+ for d in scan_excludes:
out += ["--exclude", d]
elif full_excludes:
- out = [_inject_find_excludes(c) if isinstance(c, str) else c for c in out]
+ out = [_inject_find_excludes(c, scan_excludes) if isinstance(c, str) else c for c in out]
scan_idx = [i for i, tok in enumerate(out[1:], 1) if tok == "." or "**" in tok]
if targets and scan_idx:
flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")]
diff --git a/tests/test_session_pain_fixes.py b/tests/test_session_pain_fixes.py
new file mode 100644
index 0000000..472e696
--- /dev/null
+++ b/tests/test_session_pain_fixes.py
@@ -0,0 +1,172 @@
+"""2026-09-23 opencli/codex 双 SDK 发布会话实测痛点的修复回归。
+
+锚定四个实测坑:
+- POM 4.1.0 + mvnw 仓库:gate_lib 的 mvnw 感知读取的 `executable` 键
+ 生产者(java_project)从不产出 → 感知死代码,门禁退回裸 mvn 必失败;
+- 门禁目标仓库不显式:cwd 漂移后 AI 以为改的是 A 仓,实际门禁扫的是 B 仓;
+- ruff [*] 可自动修复项藏在长输出尾部,AI 修完才发现有捷径;
+- 仓库级 skipGate 对该克隆所有分支生效且跨会话残留,提示里没有作用域警告。
+"""
+from __future__ import annotations
+
+import os
+import stat
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+PLUGIN = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PLUGIN / "hooks"))
+sys.path.insert(0, str(PLUGIN / "scripts"))
+
+import gate_lib
+import scope
+from java_project import analyze
+
+
+class JavaExecutableContractTest(unittest.TestCase):
+ """生产者-消费者契约:java_project 必须产出顶层 executable 键。
+
+ 68c8a37 曾让 mvnw 感知成为死代码——消费者读的键生产者从不写,
+ 两侧各自的测试都绿,合在一起门禁照旧退回裸 mvn(实测)。
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ (self.root / "pom.xml").write_text(
+ ''
+ "4.1.0", encoding="utf-8")
+
+ def test_executable_wrapper_present_and_executable(self):
+ mvnw = self.root / "mvnw"
+ mvnw.write_text("#!/bin/sh\nexit 0\n")
+ mvnw.chmod(0o755)
+ plan = analyze(str(self.root))
+ self.assertEqual(plan["status"], "PLANNED")
+ self.assertEqual(plan.get("executable"), "./mvnw",
+ "生产者必须产出顶层 executable 键(门禁消费者直接读取)")
+
+ def test_executable_wrapper_missing_falls_back_to_mvn(self):
+ plan = analyze(str(self.root))
+ self.assertEqual(plan.get("executable"), "mvn")
+
+ def test_executable_wrapper_not_executable_is_unverified(self):
+ mvnw = self.root / "mvnw"
+ mvnw.write_text("#!/bin/sh\nexit 0\n")
+ mvnw.chmod(0o644)
+ plan = analyze(str(self.root))
+ self.assertEqual(plan["status"], "UNVERIFIED")
+ self.assertTrue(any("不可执行" in r for r in plan["reasons"]))
+
+
+class JavaExecutableFromPlanTest(unittest.TestCase):
+ """消费者纯函数:两条来源优先级 + 非包装器不参与。"""
+
+ def test_prefers_top_level_key(self):
+ plan = {"executable": "./mvnw",
+ "commands": [{"argv": ["mvn", "verify"]}]}
+ self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw")
+
+ def test_falls_back_to_wrapper_in_command_argv(self):
+ plan = {"commands": [{"argv": ["./mvnw", "-B", "verify"]}]}
+ self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw")
+
+ def test_ignores_non_wrapper_command_heads(self):
+ plan = {"commands": [{"argv": ["mvn", "verify"]}]}
+ self.assertIsNone(gate_lib._java_executable_from_plan(plan))
+
+ def test_empty_plan_returns_none(self):
+ self.assertIsNone(gate_lib._java_executable_from_plan({}))
+
+
+class DirectiveRepoLabelTest(unittest.TestCase):
+ """目标仓库首行:cwd 漂移扫到非目标仓时,AI 第一眼就能发现。"""
+
+ def test_directive_names_target_repo_when_root_given(self):
+ report = gate_lib.gate_directive(
+ [("shell", "EXE001 detail", "fix cmd", "hint")],
+ project_root="/tmp/some-repo")
+ self.assertIn("本次门禁目标仓库", report)
+ self.assertIn("/tmp/some-repo", report)
+
+ def test_directive_without_root_omits_label(self):
+ report = gate_lib.gate_directive(
+ [("shell", "detail", "fix", "hint")])
+ self.assertNotIn("本次门禁目标仓库", report)
+
+ def test_directive_warns_clone_wide_scope_of_repo_level_skip(self):
+ report = gate_lib.gate_directive(
+ [("shell", "detail", "fix", "hint")],
+ project_root="/tmp/some-repo")
+ self.assertIn("所有分支", report)
+
+
+class PythonFixableHintTest(unittest.TestCase):
+ """ruff [*] 可自动修复项:提示必须显式给出 ruff check --fix。"""
+
+ def test_fixable_hint_appended_for_python(self):
+ blocks = gate_lib._failure_detail_blocks(
+ [("python", "UP009 [*] UTF-8 encoding declaration is unnecessary",
+ "remove comment", "hint")])
+ joined = "\n".join(blocks)
+ self.assertIn("ruff check --fix", joined)
+
+ def test_no_fixable_hint_for_shell(self):
+ blocks = gate_lib._failure_detail_blocks(
+ [("shell", "SC2086 detail", "quote it", "hint")])
+ self.assertNotIn("ruff check --fix", "\n".join(blocks))
+
+
+class RuleSummaryTest(unittest.TestCase):
+ """规则聚合计数:whack-a-mole 的治理——AI 一次看到问题全集。"""
+
+ def test_aggregates_rule_codes(self):
+ full = ("scripts/x.py:1:1 EXE001 Shebang\n"
+ "scripts/y.py:2:1 EXE001 Shebang\n"
+ "scripts/z.py:28:27 DTZ011 date.today() used")
+ self.assertEqual(
+ gate_lib._rule_summary(full),
+ "规则汇总: DTZ011×1 EXE001×2")
+
+ def test_empty_for_non_linter_output(self):
+ self.assertEqual(gate_lib._rule_summary("plain text\nno codes"), "")
+
+
+class ScopeChildGitRepoTest(unittest.TestCase):
+ """非 git 根(会话工作区)的全量扫描:子 git 仓必须排除。"""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ (self.root / "sub-repo").mkdir()
+ (self.root / "sub-repo" / ".git").mkdir()
+ (self.root / "loose.sh").write_text("#!/bin/sh\n", encoding="utf-8")
+
+ def test_child_git_repos_detected(self):
+ self.assertFalse(scope.is_git_repo(self.root))
+ self.assertEqual(scope.child_git_repo_names(self.root), ["sub-repo"])
+
+ def test_find_gate_excludes_child_repos(self):
+ cmd = ["bash", "-c",
+ "find . \\( -name '*.sh' \\) -type f "
+ "-not -path '*/node_modules/*' -print0 | "
+ "xargs -0 -r shellcheck --severity=warning"]
+ out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
+ joined = " ".join(out)
+ self.assertIn("-not -path '*/sub-repo/*'", joined)
+ self.assertIn("-not -path '*/target/*'", joined)
+
+ def test_git_repo_root_gets_no_child_excludes(self):
+ (self.root / ".git").mkdir()
+ cmd = ["bash", "-c",
+ "find . -name '*.sh' -type f -print0 | xargs -0 -r true"]
+ out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
+ self.assertNotIn("-not -path '*/sub-repo/*'", " ".join(out))
+
+
+if __name__ == "__main__":
+ unittest.main()
From 02d0c18d7c9d54ad988b2d50b444102422c8ae3a Mon Sep 17 00:00:00 2001
From: loong10k <20489781+loong10k@users.noreply.github.com>
Date: Wed, 23 Sep 2026 03:00:25 +0800
Subject: [PATCH 2/3] =?UTF-8?q?fix(lint):=20ruff=20=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
hooks/gate_lib.py | 20 ++++++++++++++++----
hooks/pre_tool_git_guard.py | 23 +++++++++++++++++++++++
scripts/run_per_language.py | 13 +++++++++++--
tests/test_session_pain_fixes.py | 2 --
4 files changed, 50 insertions(+), 8 deletions(-)
diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py
index 84418cf..dc7d775 100644
--- a/hooks/gate_lib.py
+++ b/hooks/gate_lib.py
@@ -31,6 +31,12 @@
r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException"
)
+# .zsh 送检剥离的统一话术:剥离不是静默丢弃——skipped 必须带上可执行的修复
+# 指令(ShellCheck 不支持 zsh 方言;文件头加方言声明注释后即可正常送检)。
+# delta/全量/UPS 三个面共用这一份认知(改这里即三处同变)。
+_ZSH_SKIP_NOTE = ("ShellCheck 不支持 zsh 方言,{n} 个 zsh 文件未送检。"
+ "修复:在每个 .zsh 文件头添加 `# shellcheck shell=bash` 注释后重试")
+
from detect_lang import (
LANG_COMMANDS,
detect_language,
@@ -424,6 +430,9 @@ def _run_gate_uncached(
"""
from concurrent.futures import ThreadPoolExecutor
+ # 部分剥离场景的 skipped 备注(闭包列表:worker 线程 append,GIL 下安全)
+ zsh_notes: list[str] = []
+
def check(lang: str):
cmd_def = LANG_COMMANDS.get(lang)
if not cmd_def:
@@ -451,14 +460,17 @@ def check(lang: str):
if detect_language(f, project_root) == lang and (project_root / f).is_file()
]
# ShellCheck 不支持 zsh(SC1071 是 error 级固有限制)——.zsh 送检
- # 必红且不是代码违规。从目标面剔除并明示"未验证",不静默丢弃。
+ # 必红且不是代码违规。从目标面剔除并明示"未验证"+ 可执行修复指令,
+ # 不静默丢弃。部分 zsh 场景备注进 zsh_skips,余下 .sh 照常送检
+ # (会话实测:静默剥离让提交方不知道有一部分文件压根没被检查)。
if lang == "shell":
zsh_files = [f for f in lang_files if f.endswith(".zsh")]
if zsh_files:
lang_files = [f for f in lang_files if not f.endswith(".zsh")]
+ note = _ZSH_SKIP_NOTE.format(n=len(zsh_files))
if not lang_files:
- return (lang, None,
- f"shell {len(zsh_files)} 个 zsh 文件未验证(ShellCheck 不支持 zsh)")
+ return (lang, None, note)
+ zsh_notes.append(note)
if not lang_files:
return (lang, None, f"{lang} 本次改动未涉及,跳过")
uses_delta_files = bool(lang_files) and len(lang_files) <= 50
@@ -618,7 +630,7 @@ def _run_one(cmd: list[str], timeout: int, lang: str, hint: str) -> tuple[int, s
skipped.append(openspec_check["skipped"])
except Exception as exc: # noqa: BLE001 — 调用面异常同样归"未验证",不静默吞
skipped.append(f"openspec UNVERIFIED:{exc!r}")
- return failures, skipped
+ return failures, skipped + zsh_notes
def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict:
diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py
index c106ca8..89dbe04 100755
--- a/hooks/pre_tool_git_guard.py
+++ b/hooks/pre_tool_git_guard.py
@@ -33,6 +33,7 @@
should_suppress_event,
skip_gate_via_git_config, # 规范实现已上移 gate_lib(UPS 也要用)
)
+from scope import changed_files
# 拦截的 git 子命令(避免误拦 git status/diff/log 等只读命令)
GUARDED_PATTERNS = ("git commit", "git push")
@@ -567,6 +568,20 @@ def is_guarded(command: str) -> bool:
return _guarded_mode(command) is not None
+def _filter_fallback_roots(roots: list[Path], mode: str,
+ lanes: tuple[str, ...] | list[str] | None) -> list[Path]:
+ """monorepo 兜底面收窄:只保留本次提交面非空的仓。
+
+ 会话实测:openclaw 提交时,兜底把 workspace 下所有子仓连同根上散落脚本
+ 一起纳入检测面——无关仓的存量违规也变成拦路面(误伤与提交完全无关的
+ 仓库)。无提交面的仓跳过;全部为空返回 [](调用方审计后放行)。
+
+ lanes-only:兜底场景没有显式 cd,`git add <相对路径>` 的 extra 属于
+ 会话根而非任何子仓,不参与归属判定。
+ """
+ return [r for r in roots if changed_files(r, mode=mode, lanes=lanes)]
+
+
def main() -> int:
ensure_user_path()
payload = read_payload()
@@ -608,6 +623,14 @@ def main() -> int:
)
return 2
if fallback_note:
+ # 兜底面收窄(会话实测误拦:无关仓存量违规连带拦提交)。全空 = 本次
+ # 无可拦对象,审计后放行。
+ before = len(roots)
+ roots = _filter_fallback_roots(roots, mode, lanes)
+ fallback_note += f";提交面收窄 {before}→{len(roots)} 个仓"
+ if not roots:
+ record_skip_event("monorepo-fallback-empty")
+ return 0
# 兜底走通:仅在会话状态记录(不进 stderr,避免噪音),Stop 摘要可见
record_skip_event("monorepo-fallback", roots[0])
if any(skip_gate_via_git_config(r) for r in roots):
diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py
index affe0b4..199a293 100644
--- a/scripts/run_per_language.py
+++ b/scripts/run_per_language.py
@@ -154,9 +154,18 @@ def run_fix(languages: list[str], project_root: Path,
lang_files: list[str] | None = None
if files is not None:
lang_files = [f for f in files if detect_language(f, project_root) == lang]
+ # .zsh 剥离(与门禁同一份认知):shfmt 只支持 POSIX shell/bash,
+ # 对 zsh 文件 -w 会重排方言语法造成损坏;跳过并明示修复指令。
+ zsh_files = [f for f in lang_files if f.endswith(".zsh")]
+ if zsh_files:
+ lang_files = [f for f in lang_files if not f.endswith(".zsh")]
+ results.append({"language": lang, "fixed": False, "skipped": True,
+ "status": "SKIPPED", "exit_code": 0,
+ "note": (f"{len(zsh_files)} 个 zsh 文件跳过 formatter"
+ "(shfmt 不支持 zsh;文件头添加"
+ " `# shellcheck shell=bash` 注释后"
+ " shellcheck 门禁即可正常送检)")})
if not lang_files:
- results.append({"language": lang, "fixed": False, "skipped": True, "status": "SKIPPED",
- "note": "本次改动未涉及该语言"})
continue
fmt = cmd_def.get("format")
if files is not None and not cmd_def.get("append_files", True):
diff --git a/tests/test_session_pain_fixes.py b/tests/test_session_pain_fixes.py
index 472e696..07d5ed6 100644
--- a/tests/test_session_pain_fixes.py
+++ b/tests/test_session_pain_fixes.py
@@ -9,8 +9,6 @@
"""
from __future__ import annotations
-import os
-import stat
import sys
import tempfile
import unittest
From 86fab29786201a4eb82077b33e70cd0050de5dcd Mon Sep 17 00:00:00 2001
From: loong10k <20489781+loong10k@users.noreply.github.com>
Date: Wed, 23 Sep 2026 03:07:05 +0800
Subject: [PATCH 3/3] =?UTF-8?q?test(pain-fixes):=20=E5=A4=B9=E5=85=B7=20po?=
=?UTF-8?q?m=20=E8=A1=A5=E5=85=A8=20artifactId=EF=BC=88=E7=BC=BA=E5=A4=B1?=
=?UTF-8?q?=E5=AF=BC=E8=87=B4=20=5Fmaven=20=E5=89=8D=E7=BD=AE=E5=A4=B1?=
=?UTF-8?q?=E8=B4=A5=E6=81=92=20UNVERIFIED=EF=BC=89=EF=BC=9Bskip=5Fevent?=
=?UTF-8?q?=20=E5=AE=A1=E8=AE=A1=E6=98=8E=E7=BB=86=E5=A2=9E=E5=BC=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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(失败时直接看到判定依据)
---
hooks/gate_lib.py | 8 +-
hooks/pre_tool_git_guard.py | 6 +-
tests/test_session_fixes_20260923.py | 171 +++++++++++++++++++++++++++
tests/test_session_pain_fixes.py | 15 ++-
4 files changed, 188 insertions(+), 12 deletions(-)
create mode 100644 tests/test_session_fixes_20260923.py
diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py
index dc7d775..ab5ab2d 100644
--- a/hooks/gate_lib.py
+++ b/hooks/gate_lib.py
@@ -816,11 +816,12 @@ def record_gate_decision(project_root: Path, lang: str, cmd: list[str], rc: int,
path.write_text("\n".join(lines[-limit:]) + "\n", encoding="utf-8")
-def record_skip_event(kind: str, project_root: Path | None = None) -> None:
+def record_skip_event(kind: str, project_root: Path | None = None,
+ detail: str | None = None) -> None:
"""记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。
- 除计数外保留最近 20 条明细(时间 + 仓库 + 类型)——豁免必须可回溯:
- 只有总数时无法回答"哪个仓、什么时候被跳过的"。
+ 除计数外保留最近 20 条明细(时间 + 仓库 + 类型 + 可选 detail)——豁免
+ 必须可回溯:只有总数时无法回答"哪个仓、什么时候、为什么被跳过的"。
"""
state = {}
path = session_state_path()
@@ -838,6 +839,7 @@ def record_skip_event(kind: str, project_root: Path | None = None) -> None:
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"kind": kind,
"repo": project_root.name if project_root else None,
+ **({"detail": detail} if detail else {}),
})
del events[:-20]
try:
diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py
index 89dbe04..583674f 100755
--- a/hooks/pre_tool_git_guard.py
+++ b/hooks/pre_tool_git_guard.py
@@ -626,13 +626,13 @@ def main() -> int:
# 兜底面收窄(会话实测误拦:无关仓存量违规连带拦提交)。全空 = 本次
# 无可拦对象,审计后放行。
before = len(roots)
- roots = _filter_fallback_roots(roots, mode, lanes)
+ roots = _filter_fallback_roots(roots, mode, None)
fallback_note += f";提交面收窄 {before}→{len(roots)} 个仓"
if not roots:
- record_skip_event("monorepo-fallback-empty")
+ record_skip_event("monorepo-fallback-empty", detail=fallback_note)
return 0
# 兜底走通:仅在会话状态记录(不进 stderr,避免噪音),Stop 摘要可见
- record_skip_event("monorepo-fallback", roots[0])
+ record_skip_event("monorepo-fallback", roots[0], detail=fallback_note)
if any(skip_gate_via_git_config(r) for r in roots):
record_skip_event("skipGate", roots[0])
return 0
diff --git a/tests/test_session_fixes_20260923.py b/tests/test_session_fixes_20260923.py
new file mode 100644
index 0000000..af0455d
--- /dev/null
+++ b/tests/test_session_fixes_20260923.py
@@ -0,0 +1,171 @@
+"""2026-09-23 会话反馈批次回归:zsh 剥离三面覆盖 + 兜底面收窄。
+
+来自一次真实多 SDK 发布会话(codeguard 连拦 4 次:zsh SC1071 三连、且
+openclaw 提交被 workspace 根散脚本与无关仓存量连带误拦):
+1. zsh 剥离只在 delta 面生效——全量/UPS/monorepo 回退面 .zsh 照送
+ shellcheck → SC1071 误拦(根散脚本 + 子仓 zsh 双双中招)。
+2. 部分剥离静默丢弃——提交方不知道有一部分文件压根没被检查。
+3. monorepo 兜底把 workspace 下所有子仓连同根上散落脚本一起纳入拦路面——
+ 无关仓的存量违规也 block(越权误拦)。
+4. skip 话术没有可执行的修复指令——AI/用户要自行猜测"怎么才能送检"。
+"""
+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"))
+
+from run_per_language import run_fix
+
+
+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-sess2-"))
+ _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, check=False,
+ )
+
+
+@unittest.skipUnless(__import__("shutil").which("shellcheck"), "shellcheck 未安装")
+class ZshStripGateTests(unittest.TestCase):
+ """#1/#2/#4:zsh 剥离的 skipped 话术必须带可执行修复指令,且不拦提交。"""
+
+ def test_zsh_only_commit_passes_with_fix_hint(self) -> None:
+ """纯 zsh 提交:剥离后放行(非 block),skip 话术带可执行修复。"""
+ repo = _fresh_repo()
+ (repo / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
+ _git(repo, "add", "-A")
+ r = _run_guard("git commit -m t", repo)
+ self.assertEqual(r.returncode, 0,
+ f"纯 zsh 提交不得被拦: {r.stderr[:400]}")
+ self.assertIn("shellcheck shell=bash", r.stdout,
+ "skip 话术必须给出可执行的修复指令")
+
+ def test_zsh_partial_still_lints_remaining_sh(self) -> None:
+ """zsh + sh 混合:zsh 剥离后余下 .sh 照常送检并拦真实违规。"""
+ repo = _fresh_repo()
+ (repo / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
+ (repo / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
+ encoding="utf-8")
+ _git(repo, "add", "-A")
+ r = _run_guard("git commit -m t", repo)
+ self.assertEqual(r.returncode, 2, r.stderr[:400])
+ self.assertIn("bad.sh", r.stderr, "余下 .sh 的真实违规必须拦")
+
+
+@unittest.skipUnless(__import__("shutil").which("shellcheck"), "shellcheck 未安装")
+class FallbackNarrowTests(unittest.TestCase):
+ """#3:monorepo 兜底面收窄——无提交面的仓不得进拦路面。"""
+
+ def _workspace(self) -> tuple[Path, Path, Path]:
+ """workspace 根(非 git 仓)+ 两个子仓:dirty(staged 违规)/ stale
+ (已提交违规但无提交面——模拟无关仓存量)。"""
+ ws = Path(tempfile.mkdtemp(prefix="cg-fallback-ws-"))
+ dirty = ws / "dirty-repo"
+ dirty.mkdir()
+ _git(dirty, "init", "-q")
+ _git(dirty, "config", "user.email", "t@t")
+ _git(dirty, "config", "user.name", "t")
+ (dirty / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
+ encoding="utf-8")
+ _git(dirty, "add", "-A")
+
+ stale = ws / "stale-repo"
+ stale.mkdir()
+ _git(stale, "init", "-q")
+ _git(stale, "config", "user.email", "t@t")
+ _git(stale, "config", "user.name", "t")
+ (stale / "legacy.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n",
+ encoding="utf-8")
+ _git(stale, "add", "-A")
+ _git(stale, "commit", "-q", "-m", "i")
+ return ws, dirty, stale
+
+ def test_fallback_blocks_only_dirty_repo(self) -> None:
+ ws, _dirty, _stale = self._workspace()
+ home = Path(tempfile.mkdtemp(prefix="cg-home-"))
+ env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"}
+ import subprocess
+ r = subprocess.run(
+ [sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")],
+ input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}),
+ capture_output=True, text=True, cwd=ws, env=env, timeout=120, check=False,
+ )
+ self.assertEqual(r.returncode, 2, r.stderr[:400])
+ self.assertIn("dirty-repo", r.stderr, "有提交面违规的仓必须拦")
+ self.assertNotIn("stale-repo", r.stderr,
+ "无提交面的仓(存量违规)不得进拦路面")
+
+ def test_fallback_all_clean_passes_audited(self) -> None:
+ ws = Path(tempfile.mkdtemp(prefix="cg-fallback-clean-"))
+ clean = ws / "clean-repo"
+ clean.mkdir()
+ _git(clean, "init", "-q")
+ _git(clean, "config", "user.email", "t@t")
+ _git(clean, "config", "user.name", "t")
+ (clean / "good.sh").write_text('#!/bin/bash\nx="ok"\necho "$x"\n', encoding="utf-8")
+ _git(clean, "add", "-A")
+ _git(clean, "commit", "-q", "-m", "i")
+ import subprocess
+ home = Path(tempfile.mkdtemp(prefix="cg-home-"))
+ env = {**os.environ, "CODEGUARD_HOME": str(home), "PYTHONIOENCODING": "utf-8"}
+ r = subprocess.run(
+ [sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")],
+ input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}),
+ capture_output=True, text=True, cwd=ws, env=env, timeout=120, check=False,
+ )
+ self.assertEqual(r.returncode, 0, r.stderr[:400])
+ state = json.loads((home / "session_state.json").read_text(encoding="utf-8"))
+ kinds = state.get("_skip", {}).get("kinds", {})
+ self.assertEqual(kinds.get("monorepo-fallback-empty"), 1,
+ "全空兜底必须留审计事件(monorepo-fallback-empty)")
+
+
+class RunFixZshStripTests(unittest.TestCase):
+ """#1(fix 面):run_fix 与门禁共用 zsh 剥离认知——shfmt 不支持 zsh,
+ -w 会重排方言语法造成损坏,必须跳过并给出可执行的门禁修复指令。"""
+
+ def test_run_fix_skips_zsh_with_hint(self) -> None:
+ root = _fresh_repo()
+ (root / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
+ results = run_fix(["shell"], root, files=["tool.zsh"])
+ self.assertEqual(len(results), 1)
+ self.assertEqual(results[0].get("status"), "SKIPPED")
+ self.assertIn("zsh", results[0].get("note", ""))
+ self.assertIn("shell=bash", results[0].get("note", ""))
+
+ def test_run_fix_mixed_keeps_sh_and_skips_zsh(self) -> None:
+ root = _fresh_repo()
+ (root / "tool.zsh").write_text("setopt no_beep\n", encoding="utf-8")
+ (root / "t.sh").write_text("echo ok\n", encoding="utf-8")
+ results = run_fix(["shell"], root, files=["tool.zsh", "t.sh"])
+ # shfmt 未安装时 b.sh 归"未执行",但 zsh 的 SKIPPED 备注必须仍在
+ skipped = [r for r in results if r.get("skipped")]
+ self.assertTrue(any("zsh" in r.get("note", "") for r in skipped),
+ "zsh 跳过必须有明示备注")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_session_pain_fixes.py b/tests/test_session_pain_fixes.py
index 07d5ed6..93e9566 100644
--- a/tests/test_session_pain_fixes.py
+++ b/tests/test_session_pain_fixes.py
@@ -36,16 +36,21 @@ def setUp(self):
self.root = Path(self.tmp.name)
(self.root / "pom.xml").write_text(
''
- "4.1.0", encoding="utf-8")
+ "4.1.0"
+ "testtest"
+ "1.0"
+ "jar", encoding="utf-8")
def test_executable_wrapper_present_and_executable(self):
mvnw = self.root / "mvnw"
mvnw.write_text("#!/bin/sh\nexit 0\n")
mvnw.chmod(0o755)
plan = analyze(str(self.root))
- self.assertEqual(plan["status"], "PLANNED")
+ self.assertEqual(plan["status"], "PLANNED",
+ "reasons=" + "; ".join(plan["reasons"]))
self.assertEqual(plan.get("executable"), "./mvnw",
- "生产者必须产出顶层 executable 键(门禁消费者直接读取)")
+ "生产者必须产出顶层 executable 键;reasons="
+ + "; ".join(plan["reasons"]))
def test_executable_wrapper_missing_falls_back_to_mvn(self):
plan = analyze(str(self.root))
@@ -150,9 +155,7 @@ def test_child_git_repos_detected(self):
def test_find_gate_excludes_child_repos(self):
cmd = ["bash", "-c",
- "find . \\( -name '*.sh' \\) -type f "
- "-not -path '*/node_modules/*' -print0 | "
- "xargs -0 -r shellcheck --severity=warning"]
+ ("find . \\( -name '*.sh' \\) -type f " "-not -path '*/node_modules/*' -print0 | " "xargs -0 -r shellcheck --severity=warning")]
out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
joined = " ".join(out)
self.assertIn("-not -path '*/sub-repo/*'", joined)