Skip to content

Commit 4621774

Browse files
authored
会话实测优化——规则聚合计数/java_project executable/scope 加固 (#42)
* 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 已获用户明确指令启用。 * fix(lint): ruff 修复 * 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 e8145e7 commit 4621774

7 files changed

Lines changed: 496 additions & 20 deletions

File tree

‎hooks/gate_lib.py‎

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException"
3232
)
3333

34+
# .zsh 送检剥离的统一话术:剥离不是静默丢弃——skipped 必须带上可执行的修复
35+
# 指令(ShellCheck 不支持 zsh 方言;文件头加方言声明注释后即可正常送检)。
36+
# delta/全量/UPS 三个面共用这一份认知(改这里即三处同变)。
37+
_ZSH_SKIP_NOTE = ("ShellCheck 不支持 zsh 方言,{n} 个 zsh 文件未送检。"
38+
"修复:在每个 .zsh 文件头添加 `# shellcheck shell=bash` 注释后重试")
39+
3440
from detect_lang import (
3541
LANG_COMMANDS,
3642
detect_language,
@@ -317,11 +323,32 @@ def _log_path(project_root: Path, lang: str) -> Path:
317323
return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log"
318324

319325

326+
def _rule_summary(full: str) -> str:
327+
"""ruff/checkstyle 输出的规则聚合计数行(如 "EXE001×3 UP009×1")。
328+
329+
2026-09-23 opencli 会话实测:门禁输出被截断后 AI 修一个才暴露下一个
330+
(whack-a-mole 升级版),聚合计数让 AI 一次看到问题全集与规模。
331+
非 linter 标准行格式(无规则码锚)时返回空串。
332+
"""
333+
import re as _re
334+
counts: dict[str, int] = {}
335+
for line in full.splitlines():
336+
m = _re.search(r"\b([A-Z]{2,5}\d{3,4})\b", line)
337+
if m and (":" in line or "-->" in line):
338+
counts[m.group(1)] = counts.get(m.group(1), 0) + 1
339+
if not counts:
340+
return ""
341+
return "规则汇总: " + " ".join(f"{k}×{v}" for k, v in sorted(counts.items()))
342+
343+
320344
def _truncate_detail(full: str, project_root: Path, lang: str) -> str:
321345
"""节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、
322346
重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。"""
323347
lines = [ln for ln in full.splitlines() if ln.strip()]
348+
summary_line = _rule_summary(full)
324349
detail = full[:600]
350+
if summary_line:
351+
detail = summary_line + "\n" + detail
325352
if len(lines) > 8 or len(full) > 600:
326353
try:
327354
path = _log_path(project_root, lang)
@@ -403,6 +430,9 @@ def _run_gate_uncached(
403430
"""
404431
from concurrent.futures import ThreadPoolExecutor
405432

433+
# 部分剥离场景的 skipped 备注(闭包列表:worker 线程 append,GIL 下安全)
434+
zsh_notes: list[str] = []
435+
406436
def check(lang: str):
407437
cmd_def = LANG_COMMANDS.get(lang)
408438
if not cmd_def:
@@ -415,7 +445,9 @@ def check(lang: str):
415445
if outcome["status"] == "PASS":
416446
return None
417447
if outcome["status"] != "FAIL":
418-
return (lang, None, f"java {outcome['status']}: {outcome['reason']}")
448+
hint_extra = ";若 wrapper 缺执行位:chmod +x mvnw" \
449+
if "不可执行" in outcome.get("reason", "") else ""
450+
return (lang, None, f"java {outcome['status']}: {outcome['reason']}{hint_extra}")
419451
detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), project_root, lang)
420452
if outcome.get("log_path"):
421453
detail += f"\n完整输出: {outcome['log_path']}"
@@ -428,14 +460,17 @@ def check(lang: str):
428460
if detect_language(f, project_root) == lang and (project_root / f).is_file()
429461
]
430462
# ShellCheck 不支持 zsh(SC1071 是 error 级固有限制)——.zsh 送检
431-
# 必红且不是代码违规。从目标面剔除并明示"未验证",不静默丢弃。
463+
# 必红且不是代码违规。从目标面剔除并明示"未验证"+ 可执行修复指令,
464+
# 不静默丢弃。部分 zsh 场景备注进 zsh_skips,余下 .sh 照常送检
465+
# (会话实测:静默剥离让提交方不知道有一部分文件压根没被检查)。
432466
if lang == "shell":
433467
zsh_files = [f for f in lang_files if f.endswith(".zsh")]
434468
if zsh_files:
435469
lang_files = [f for f in lang_files if not f.endswith(".zsh")]
470+
note = _ZSH_SKIP_NOTE.format(n=len(zsh_files))
436471
if not lang_files:
437-
return (lang, None,
438-
f"shell {len(zsh_files)} 个 zsh 文件未验证(ShellCheck 不支持 zsh)")
472+
return (lang, None, note)
473+
zsh_notes.append(note)
439474
if not lang_files:
440475
return (lang, None, f"{lang} 本次改动未涉及,跳过")
441476
uses_delta_files = bool(lang_files) and len(lang_files) <= 50
@@ -470,7 +505,7 @@ def check(lang: str):
470505
return (lang, None,
471506
(f"{lang} 项目分析 UNVERIFIED({_jp.get('build_system', '?')}),"
472507
f"原因: {'; '.join(_jp.get('reasons', []))},本次未验证"))
473-
exe = _jp.get("executable")
508+
exe = _java_executable_from_plan(_jp)
474509
if exe and base_cmd and base_cmd[0] in ("mvn", "gradle"):
475510
base_cmd = [exe] + base_cmd[1:]
476511
except Exception as exc: # noqa: BLE001 — 分析失败不阻塞门禁,走原路径
@@ -595,7 +630,7 @@ def _run_one(cmd: list[str], timeout: int, lang: str, hint: str) -> tuple[int, s
595630
skipped.append(openspec_check["skipped"])
596631
except Exception as exc: # noqa: BLE001 — 调用面异常同样归"未验证",不静默吞
597632
skipped.append(f"openspec UNVERIFIED:{exc!r}")
598-
return failures, skipped
633+
return failures, skipped + zsh_notes
599634

600635

601636
def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict:
@@ -627,6 +662,26 @@ def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict:
627662
hint = "见 https://openspec.dev 或 `npm i -g @fission-ai/openspec`"
628663
return {"failures": [("openspec", detail, fix, hint)], "skipped": None}
629664

665+
def _java_executable_from_plan(plan: dict) -> str | None:
666+
"""从 java_project.analyze 的产物里解析构建可执行文件。
667+
668+
两条来源按优先级:
669+
1. 顶层 ``executable`` 键(java_project 正常产出);
670+
2. 回退 ``commands[0].argv[0]``——历史版本的 java_project 只把
671+
wrapper 体现在计划命令里(实测 68c8a37 曾因此让 mvnw 感知成为
672+
死代码:消费者读的键生产者从不写)。
673+
非 mvn/gradle wrapper(如 codeguard.json 显式命令)不参与替换。
674+
"""
675+
exe = plan.get("executable")
676+
if exe:
677+
return exe
678+
for cmd in plan.get("commands", []) or []:
679+
argv = cmd.get("argv") or []
680+
if argv and Path(argv[0]).name in ("mvnw", "gradlew"):
681+
return argv[0]
682+
return None
683+
684+
630685
def skip_gate_via_git_config(project_root: Path) -> bool:
631686
"""仓库级豁免:git config codeguard.skipGate true。
632687
@@ -761,11 +816,12 @@ def record_gate_decision(project_root: Path, lang: str, cmd: list[str], rc: int,
761816
path.write_text("\n".join(lines[-limit:]) + "\n", encoding="utf-8")
762817

763818

764-
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:
765821
"""记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。
766822
767-
除计数外保留最近 20 条明细(时间 + 仓库 + 类型)——豁免必须可回溯:
768-
只有总数时无法回答"哪个仓、什么时候被跳过的"。
823+
除计数外保留最近 20 条明细(时间 + 仓库 + 类型 + 可选 detail)——豁免
824+
必须可回溯:只有总数时无法回答"哪个仓、什么时候、为什么被跳过的"。
769825
"""
770826
state = {}
771827
path = session_state_path()
@@ -783,6 +839,7 @@ def record_skip_event(kind: str, project_root: Path | None = None) -> None:
783839
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
784840
"kind": kind,
785841
"repo": project_root.name if project_root else None,
842+
**({"detail": detail} if detail else {}),
786843
})
787844
del events[:-20]
788845
try:
@@ -861,6 +918,8 @@ def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[s
861918
f" ▶ 怎么修: {fix}",
862919
f" ▶ 未安装工具时先安装: {hint}",
863920
]
921+
if lang == "python" and "[*]" in (detail or ""):
922+
tail.append(" ▶ 含 [*] 可自动修复项: ruff check --fix <涉及路径>")
864923
body = detail if detail else " lint 退出码非零,无文本输出"
865924
if fix_first:
866925
block += tail + [body]
@@ -886,19 +945,24 @@ def format_failure_report(failures: list) -> str:
886945
return "\n".join(lines)
887946

888947

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

‎hooks/pre_tool_git_guard.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
should_suppress_event,
3434
skip_gate_via_git_config, # 规范实现已上移 gate_lib(UPS 也要用)
3535
)
36+
from scope import changed_files
3637

3738
# 拦截的 git 子命令(避免误拦 git status/diff/log 等只读命令)
3839
GUARDED_PATTERNS = ("git commit", "git push")
@@ -567,6 +568,20 @@ def is_guarded(command: str) -> bool:
567568
return _guarded_mode(command) is not None
568569

569570

571+
def _filter_fallback_roots(roots: list[Path], mode: str,
572+
lanes: tuple[str, ...] | list[str] | None) -> list[Path]:
573+
"""monorepo 兜底面收窄:只保留本次提交面非空的仓。
574+
575+
会话实测:openclaw 提交时,兜底把 workspace 下所有子仓连同根上散落脚本
576+
一起纳入检测面——无关仓的存量违规也变成拦路面(误伤与提交完全无关的
577+
仓库)。无提交面的仓跳过;全部为空返回 [](调用方审计后放行)。
578+
579+
lanes-only:兜底场景没有显式 cd,`git add <相对路径>` 的 extra 属于
580+
会话根而非任何子仓,不参与归属判定。
581+
"""
582+
return [r for r in roots if changed_files(r, mode=mode, lanes=lanes)]
583+
584+
570585
def main() -> int:
571586
ensure_user_path()
572587
payload = read_payload()
@@ -608,8 +623,16 @@ def main() -> int:
608623
)
609624
return 2
610625
if fallback_note:
626+
# 兜底面收窄(会话实测误拦:无关仓存量违规连带拦提交)。全空 = 本次
627+
# 无可拦对象,审计后放行。
628+
before = len(roots)
629+
roots = _filter_fallback_roots(roots, mode, None)
630+
fallback_note += f";提交面收窄 {before}→{len(roots)} 个仓"
631+
if not roots:
632+
record_skip_event("monorepo-fallback-empty", detail=fallback_note)
633+
return 0
611634
# 兜底走通:仅在会话状态记录(不进 stderr,避免噪音),Stop 摘要可见
612-
record_skip_event("monorepo-fallback", roots[0])
635+
record_skip_event("monorepo-fallback", roots[0], detail=fallback_note)
613636
if any(skip_gate_via_git_config(r) for r in roots):
614637
record_skip_event("skipGate", roots[0])
615638
return 0
@@ -652,7 +675,7 @@ def main() -> int:
652675
if failures:
653676
# 版本自标识由 gate_directive 首行综述之后的第二行承担——
654677
# 此处不再前置横幅:stderr 首行必须是综述(三个契约测试锁定)。
655-
reports.append(gate_directive(failures))
678+
reports.append(gate_directive(failures, project_root=project_root))
656679
unknown = [s for s in _skipped if "本次改动未涉及" not in s and " SKIPPED:" not in s
657680
and "markdown 风格告警" not in s]
658681
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/run_per_language.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,18 @@ def run_fix(languages: list[str], project_root: Path,
154154
lang_files: list[str] | None = None
155155
if files is not None:
156156
lang_files = [f for f in files if detect_language(f, project_root) == lang]
157+
# .zsh 剥离(与门禁同一份认知):shfmt 只支持 POSIX shell/bash,
158+
# 对 zsh 文件 -w 会重排方言语法造成损坏;跳过并明示修复指令。
159+
zsh_files = [f for f in lang_files if f.endswith(".zsh")]
160+
if zsh_files:
161+
lang_files = [f for f in lang_files if not f.endswith(".zsh")]
162+
results.append({"language": lang, "fixed": False, "skipped": True,
163+
"status": "SKIPPED", "exit_code": 0,
164+
"note": (f"{len(zsh_files)} 个 zsh 文件跳过 formatter"
165+
"(shfmt 不支持 zsh;文件头添加"
166+
" `# shellcheck shell=bash` 注释后"
167+
" shellcheck 门禁即可正常送检)")})
157168
if not lang_files:
158-
results.append({"language": lang, "fixed": False, "skipped": True, "status": "SKIPPED",
159-
"note": "本次改动未涉及该语言"})
160169
continue
161170
fmt = cmd_def.get("format")
162171
if files is not None and not cmd_def.get("append_files", True):

‎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("#")]

0 commit comments

Comments
 (0)