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)