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+
3440from 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+
320344def _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
601636def _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+
630685def 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"
0 commit comments