Skip to content

Commit 2d5457a

Browse files
committed
feat: 会话实测优化——规则聚合计数/java_project executable 键/scope 边界加固
基于 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 已获用户明确指令启用。
1 parent e8145e7 commit 2d5457a

5 files changed

Lines changed: 268 additions & 10 deletions

File tree

‎hooks/gate_lib.py‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,11 +317,32 @@ def _log_path(project_root: Path, lang: str) -> Path:
317317
return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log"
318318

319319

320+
def _rule_summary(full: str) -> str:
321+
"""ruff/checkstyle 输出的规则聚合计数行(如 "EXE001×3 UP009×1")。
322+
323+
2026-09-23 opencli 会话实测:门禁输出被截断后 AI 修一个才暴露下一个
324+
(whack-a-mole 升级版),聚合计数让 AI 一次看到问题全集与规模。
325+
非 linter 标准行格式(无规则码锚)时返回空串。
326+
"""
327+
import re as _re
328+
counts: dict[str, int] = {}
329+
for line in full.splitlines():
330+
m = _re.search(r"\b([A-Z]{2,5}\d{3,4})\b", line)
331+
if m and (":" in line or "-->" in line):
332+
counts[m.group(1)] = counts.get(m.group(1), 0) + 1
333+
if not counts:
334+
return ""
335+
return "规则汇总: " + " ".join(f"{k}×{v}" for k, v in sorted(counts.items()))
336+
337+
320338
def _truncate_detail(full: str, project_root: Path, lang: str) -> str:
321339
"""节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、
322340
重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。"""
323341
lines = [ln for ln in full.splitlines() if ln.strip()]
342+
summary_line = _rule_summary(full)
324343
detail = full[:600]
344+
if summary_line:
345+
detail = summary_line + "\n" + detail
325346
if len(lines) > 8 or len(full) > 600:
326347
try:
327348
path = _log_path(project_root, lang)
@@ -415,7 +436,9 @@ def check(lang: str):
415436
if outcome["status"] == "PASS":
416437
return None
417438
if outcome["status"] != "FAIL":
418-
return (lang, None, f"java {outcome['status']}: {outcome['reason']}")
439+
hint_extra = ";若 wrapper 缺执行位:chmod +x mvnw" \
440+
if "不可执行" in outcome.get("reason", "") else ""
441+
return (lang, None, f"java {outcome['status']}: {outcome['reason']}{hint_extra}")
419442
detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), project_root, lang)
420443
if outcome.get("log_path"):
421444
detail += f"\n完整输出: {outcome['log_path']}"
@@ -470,7 +493,7 @@ def check(lang: str):
470493
return (lang, None,
471494
(f"{lang} 项目分析 UNVERIFIED({_jp.get('build_system', '?')}),"
472495
f"原因: {'; '.join(_jp.get('reasons', []))},本次未验证"))
473-
exe = _jp.get("executable")
496+
exe = _java_executable_from_plan(_jp)
474497
if exe and base_cmd and base_cmd[0] in ("mvn", "gradle"):
475498
base_cmd = [exe] + base_cmd[1:]
476499
except Exception as exc: # noqa: BLE001 — 分析失败不阻塞门禁,走原路径
@@ -627,6 +650,26 @@ def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict:
627650
hint = "见 https://openspec.dev 或 `npm i -g @fission-ai/openspec`"
628651
return {"failures": [("openspec", detail, fix, hint)], "skipped": None}
629652

653+
def _java_executable_from_plan(plan: dict) -> str | None:
654+
"""从 java_project.analyze 的产物里解析构建可执行文件。
655+
656+
两条来源按优先级:
657+
1. 顶层 ``executable`` 键(java_project 正常产出);
658+
2. 回退 ``commands[0].argv[0]``——历史版本的 java_project 只把
659+
wrapper 体现在计划命令里(实测 68c8a37 曾因此让 mvnw 感知成为
660+
死代码:消费者读的键生产者从不写)。
661+
非 mvn/gradle wrapper(如 codeguard.json 显式命令)不参与替换。
662+
"""
663+
exe = plan.get("executable")
664+
if exe:
665+
return exe
666+
for cmd in plan.get("commands", []) or []:
667+
argv = cmd.get("argv") or []
668+
if argv and Path(argv[0]).name in ("mvnw", "gradlew"):
669+
return argv[0]
670+
return None
671+
672+
630673
def skip_gate_via_git_config(project_root: Path) -> bool:
631674
"""仓库级豁免:git config codeguard.skipGate true。
632675
@@ -861,6 +904,8 @@ def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[s
861904
f" ▶ 怎么修: {fix}",
862905
f" ▶ 未安装工具时先安装: {hint}",
863906
]
907+
if lang == "python" and "[*]" in (detail or ""):
908+
tail.append(" ▶ 含 [*] 可自动修复项: ruff check --fix <涉及路径>")
864909
body = detail if detail else " lint 退出码非零,无文本输出"
865910
if fix_first:
866911
block += tail + [body]
@@ -886,19 +931,24 @@ def format_failure_report(failures: list) -> str:
886931
return "\n".join(lines)
887932

888933

889-
def gate_directive(failures: list) -> str:
934+
def gate_directive(failures: list, project_root: Path | str | None = None) -> str:
890935
"""给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户。
891936
937+
project_root:本次门禁的目标仓库根——cwd 漂移会让钩子扫到"非目标仓"
938+
(实测:在 codex 仓 cwd 下提交 opencli,POM 4.1.0 报错让人以为改错了
939+
文件),首行显式标注目标仓库可第一时间发现扫错对象。
940+
892941
结构 = 综述(首行契约)→ 强制指令(前置!)→ 每语言细节(fix 先于
893942
detail)→ 修复入口;整体压到 REPORT_MAX_CHARS 内。指令必须前置:宿主把
894943
超长 stderr 从尾部截断,此前指令在报告末段,长报告下 AI 只看到首段 linter
895944
报错、「拆两次调用」与 skipGate 逃生门全部丢失(实测)。首行仍为综述、
896945
综述不重复、含「具体问题」——run_all/硬门禁契约保持。
897946
"""
947+
head_repo = f"本次门禁目标仓库: {Path(project_root).resolve()}\n" if project_root else ""
898948
header = [
899949
summarize_failures(failures),
900950
f"codeguard v{CODEGUARD_VERSION}",
901-
"─" * 60,
951+
head_repo + "─" * 60,
902952
"**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n"
903953
+ "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash "
904954
"调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与"
@@ -914,7 +964,8 @@ def gate_directive(failures: list) -> str:
914964
+ "确需绕过(仅用户明确要求时):**单次豁免**用 `git -c codeguard.skipGate=true commit …`"
915965
"(不落配置、无残留,推荐);**仓库级豁免**在该仓库执行 git config codeguard.skipGate true,"
916966
"完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE "
917-
"只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。",
967+
"只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。"
968+
"注意:仓库级豁免对该克隆**所有分支**生效且跨会话残留,务必按上方说明 unset 恢复。",
918969
"─" * 60,
919970
]
920971
footer = f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py"

‎hooks/pre_tool_git_guard.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,7 @@ def main() -> int:
652652
if failures:
653653
# 版本自标识由 gate_directive 首行综述之后的第二行承担——
654654
# 此处不再前置横幅:stderr 首行必须是综述(三个契约测试锁定)。
655-
reports.append(gate_directive(failures))
655+
reports.append(gate_directive(failures, project_root=project_root))
656656
unknown = [s for s in _skipped if "本次改动未涉及" not in s and " SKIPPED:" not in s
657657
and "markdown 风格告警" not in s]
658658
if unknown:

‎scripts/java_project.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ def analyze(project_root: str | Path, changed: list[str] | None = None) -> dict:
236236
executable = "./" + wrapper if (root / wrapper).is_file() else ("mvn" if system == "maven" else "gradle")
237237
if (root / wrapper).exists() and os.name != "nt" and not os.access(root / wrapper, os.X_OK):
238238
raise ValueError(f"wrapper 不可执行: {wrapper}")
239+
# 顶层 executable 键:门禁消费者(gate_lib Java 门禁)直接读取,
240+
# 用于把 PATH 上的 mvn/gradle 替换为项目自带 wrapper——
241+
# POM 4.1.0 等 Maven 4 仓库在 Maven 3 下必然解析失败(实测)。
242+
plan["executable"] = executable
239243
relevant = None if changed is None else [p for p in changed if p.endswith((".java", ".kt", ".groovy", ".scala"))
240244
or "/src/" in "/" + p
241245
or Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/"))]

‎scripts/scope.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@
5050
)
5151

5252

53+
def is_git_repo(root: str | Path) -> bool:
54+
"""路径自身是否为 git 仓(.git 存在)。"""
55+
return (Path(root) / ".git").exists()
56+
57+
58+
def child_git_repo_names(root: str | Path) -> list[str]:
59+
"""扫描根**不是** git 仓(典型:会话工作区根)时,列出其下自带 .git
60+
的直接子目录名。
61+
62+
这些子目录是独立仓库、由它们各自的提交门禁负责;非 git 根的全量扫描
63+
若把它们一并扫进去,就会出现"提交 A 仓却被 B 仓的存量问题拦截"的
64+
跨仓误伤(2026-09-23 opencli-java-sdk 会话实测:workspace 根的
65+
push-all-branches.sh 触发门禁时,根级脚本与子仓文件混在同一份报告里)。
66+
根级别的散文件(不属于任何子仓)仍保留在扫描面内——它们没有别的门禁。
67+
"""
68+
out: list[str] = []
69+
try:
70+
for child in sorted(Path(root).iterdir()):
71+
if child.is_dir() and (child / ".git").exists():
72+
out.append(child.name)
73+
except OSError:
74+
pass
75+
return out
76+
77+
5378
def is_build_artifact(path: str | Path) -> bool:
5479
"""路径是否落在构建产物/依赖快照目录下(任一段命中即算)。
5580
@@ -63,7 +88,7 @@ def is_build_artifact(path: str | Path) -> bool:
6388
_RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml"
6489

6590

66-
def _inject_find_excludes(expr: str) -> str:
91+
def _inject_find_excludes(expr: str, excludes: tuple[str, ...] | list[str] = FULL_SCAN_EXCLUDES) -> str:
6792
"""给 `find …` 型 gate 表达式注入构建产物目录排除(-not -path)。
6893
6994
覆盖三种实测形态——只认一种就有整族门禁漏网:
@@ -79,7 +104,7 @@ def _inject_find_excludes(expr: str) -> str:
79104
return expr
80105
additions = "".join(
81106
f" -not -path '*/{d}/*'"
82-
for d in FULL_SCAN_EXCLUDES
107+
for d in excludes
83108
if f"'*/{d}/*'" not in expr and f"'*/{d}'" not in expr
84109
)
85110
if not additions:
@@ -179,16 +204,22 @@ def scope_cmd(
179204
if not out:
180205
return out
181206
targets = [single_file] if single_file else list(files or [])
207+
root = Path(project_root)
208+
# 非 git 根(会话工作区)的全量扫描:排除子 git 仓——它们由各自的
209+
# 提交门禁负责,混进来就是跨仓误伤(见 child_git_repo_names)。
210+
scan_excludes = list(FULL_SCAN_EXCLUDES)
211+
if full_excludes and not is_git_repo(root):
212+
scan_excludes += child_git_repo_names(root)
182213
if "{file}" in " ".join(out):
183214
return [c.replace("{file}", single_file or "") for c in out]
184215
if out[0] == "ruff":
185216
args = ruff_config_args(out, project_root)
186217
out = [out[0]] + args + out[1:]
187218
if full_excludes:
188-
for d in FULL_SCAN_EXCLUDES:
219+
for d in scan_excludes:
189220
out += ["--exclude", d]
190221
elif full_excludes:
191-
out = [_inject_find_excludes(c) if isinstance(c, str) else c for c in out]
222+
out = [_inject_find_excludes(c, scan_excludes) if isinstance(c, str) else c for c in out]
192223
scan_idx = [i for i, tok in enumerate(out[1:], 1) if tok == "." or "**" in tok]
193224
if targets and scan_idx:
194225
flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")]

‎tests/test_session_pain_fixes.py‎

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""2026-09-23 opencli/codex 双 SDK 发布会话实测痛点的修复回归。
2+
3+
锚定四个实测坑:
4+
- POM 4.1.0 + mvnw 仓库:gate_lib 的 mvnw 感知读取的 `executable` 键
5+
生产者(java_project)从不产出 → 感知死代码,门禁退回裸 mvn 必失败;
6+
- 门禁目标仓库不显式:cwd 漂移后 AI 以为改的是 A 仓,实际门禁扫的是 B 仓;
7+
- ruff [*] 可自动修复项藏在长输出尾部,AI 修完才发现有捷径;
8+
- 仓库级 skipGate 对该克隆所有分支生效且跨会话残留,提示里没有作用域警告。
9+
"""
10+
from __future__ import annotations
11+
12+
import os
13+
import stat
14+
import sys
15+
import tempfile
16+
import unittest
17+
from pathlib import Path
18+
19+
PLUGIN = Path(__file__).resolve().parents[1]
20+
sys.path.insert(0, str(PLUGIN / "hooks"))
21+
sys.path.insert(0, str(PLUGIN / "scripts"))
22+
23+
import gate_lib
24+
import scope
25+
from java_project import analyze
26+
27+
28+
class JavaExecutableContractTest(unittest.TestCase):
29+
"""生产者-消费者契约:java_project 必须产出顶层 executable 键。
30+
31+
68c8a37 曾让 mvnw 感知成为死代码——消费者读的键生产者从不写,
32+
两侧各自的测试都绿,合在一起门禁照旧退回裸 mvn(实测)。
33+
"""
34+
35+
def setUp(self):
36+
self.tmp = tempfile.TemporaryDirectory()
37+
self.addCleanup(self.tmp.cleanup)
38+
self.root = Path(self.tmp.name)
39+
(self.root / "pom.xml").write_text(
40+
'<project xmlns="http://maven.apache.org/POM/4.1.0">'
41+
"<modelVersion>4.1.0</modelVersion></project>", encoding="utf-8")
42+
43+
def test_executable_wrapper_present_and_executable(self):
44+
mvnw = self.root / "mvnw"
45+
mvnw.write_text("#!/bin/sh\nexit 0\n")
46+
mvnw.chmod(0o755)
47+
plan = analyze(str(self.root))
48+
self.assertEqual(plan["status"], "PLANNED")
49+
self.assertEqual(plan.get("executable"), "./mvnw",
50+
"生产者必须产出顶层 executable 键(门禁消费者直接读取)")
51+
52+
def test_executable_wrapper_missing_falls_back_to_mvn(self):
53+
plan = analyze(str(self.root))
54+
self.assertEqual(plan.get("executable"), "mvn")
55+
56+
def test_executable_wrapper_not_executable_is_unverified(self):
57+
mvnw = self.root / "mvnw"
58+
mvnw.write_text("#!/bin/sh\nexit 0\n")
59+
mvnw.chmod(0o644)
60+
plan = analyze(str(self.root))
61+
self.assertEqual(plan["status"], "UNVERIFIED")
62+
self.assertTrue(any("不可执行" in r for r in plan["reasons"]))
63+
64+
65+
class JavaExecutableFromPlanTest(unittest.TestCase):
66+
"""消费者纯函数:两条来源优先级 + 非包装器不参与。"""
67+
68+
def test_prefers_top_level_key(self):
69+
plan = {"executable": "./mvnw",
70+
"commands": [{"argv": ["mvn", "verify"]}]}
71+
self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw")
72+
73+
def test_falls_back_to_wrapper_in_command_argv(self):
74+
plan = {"commands": [{"argv": ["./mvnw", "-B", "verify"]}]}
75+
self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw")
76+
77+
def test_ignores_non_wrapper_command_heads(self):
78+
plan = {"commands": [{"argv": ["mvn", "verify"]}]}
79+
self.assertIsNone(gate_lib._java_executable_from_plan(plan))
80+
81+
def test_empty_plan_returns_none(self):
82+
self.assertIsNone(gate_lib._java_executable_from_plan({}))
83+
84+
85+
class DirectiveRepoLabelTest(unittest.TestCase):
86+
"""目标仓库首行:cwd 漂移扫到非目标仓时,AI 第一眼就能发现。"""
87+
88+
def test_directive_names_target_repo_when_root_given(self):
89+
report = gate_lib.gate_directive(
90+
[("shell", "EXE001 detail", "fix cmd", "hint")],
91+
project_root="/tmp/some-repo")
92+
self.assertIn("本次门禁目标仓库", report)
93+
self.assertIn("/tmp/some-repo", report)
94+
95+
def test_directive_without_root_omits_label(self):
96+
report = gate_lib.gate_directive(
97+
[("shell", "detail", "fix", "hint")])
98+
self.assertNotIn("本次门禁目标仓库", report)
99+
100+
def test_directive_warns_clone_wide_scope_of_repo_level_skip(self):
101+
report = gate_lib.gate_directive(
102+
[("shell", "detail", "fix", "hint")],
103+
project_root="/tmp/some-repo")
104+
self.assertIn("所有分支", report)
105+
106+
107+
class PythonFixableHintTest(unittest.TestCase):
108+
"""ruff [*] 可自动修复项:提示必须显式给出 ruff check --fix。"""
109+
110+
def test_fixable_hint_appended_for_python(self):
111+
blocks = gate_lib._failure_detail_blocks(
112+
[("python", "UP009 [*] UTF-8 encoding declaration is unnecessary",
113+
"remove comment", "hint")])
114+
joined = "\n".join(blocks)
115+
self.assertIn("ruff check --fix", joined)
116+
117+
def test_no_fixable_hint_for_shell(self):
118+
blocks = gate_lib._failure_detail_blocks(
119+
[("shell", "SC2086 detail", "quote it", "hint")])
120+
self.assertNotIn("ruff check --fix", "\n".join(blocks))
121+
122+
123+
class RuleSummaryTest(unittest.TestCase):
124+
"""规则聚合计数:whack-a-mole 的治理——AI 一次看到问题全集。"""
125+
126+
def test_aggregates_rule_codes(self):
127+
full = ("scripts/x.py:1:1 EXE001 Shebang\n"
128+
"scripts/y.py:2:1 EXE001 Shebang\n"
129+
"scripts/z.py:28:27 DTZ011 date.today() used")
130+
self.assertEqual(
131+
gate_lib._rule_summary(full),
132+
"规则汇总: DTZ011×1 EXE001×2")
133+
134+
def test_empty_for_non_linter_output(self):
135+
self.assertEqual(gate_lib._rule_summary("plain text\nno codes"), "")
136+
137+
138+
class ScopeChildGitRepoTest(unittest.TestCase):
139+
"""非 git 根(会话工作区)的全量扫描:子 git 仓必须排除。"""
140+
141+
def setUp(self):
142+
self.tmp = tempfile.TemporaryDirectory()
143+
self.addCleanup(self.tmp.cleanup)
144+
self.root = Path(self.tmp.name)
145+
(self.root / "sub-repo").mkdir()
146+
(self.root / "sub-repo" / ".git").mkdir()
147+
(self.root / "loose.sh").write_text("#!/bin/sh\n", encoding="utf-8")
148+
149+
def test_child_git_repos_detected(self):
150+
self.assertFalse(scope.is_git_repo(self.root))
151+
self.assertEqual(scope.child_git_repo_names(self.root), ["sub-repo"])
152+
153+
def test_find_gate_excludes_child_repos(self):
154+
cmd = ["bash", "-c",
155+
"find . \\( -name '*.sh' \\) -type f "
156+
"-not -path '*/node_modules/*' -print0 | "
157+
"xargs -0 -r shellcheck --severity=warning"]
158+
out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
159+
joined = " ".join(out)
160+
self.assertIn("-not -path '*/sub-repo/*'", joined)
161+
self.assertIn("-not -path '*/target/*'", joined)
162+
163+
def test_git_repo_root_gets_no_child_excludes(self):
164+
(self.root / ".git").mkdir()
165+
cmd = ["bash", "-c",
166+
"find . -name '*.sh' -type f -print0 | xargs -0 -r true"]
167+
out = scope.scope_cmd(cmd, str(self.root), full_excludes=True)
168+
self.assertNotIn("-not -path '*/sub-repo/*'", " ".join(out))
169+
170+
171+
if __name__ == "__main__":
172+
unittest.main()

0 commit comments

Comments
 (0)