diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 0ad583c..9ca734b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.8.0" + "ref": "v0.8.1" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.8.0", + "version": "0.8.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Make AI-written code pass lint on first try", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bbb2838..a80fc88 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.8.0+codex.20260922", + "version": "0.8.1+codex.20260922", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 93ecdc3..4dbfd3b 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.8.0", + "version": "0.8.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "description_i18n": { "en": "Cross-language code lint enforcement. PostToolUse hook auto-runs the language-specific linter on every AI-written file; failed lint blocks further writes when strict_mode is on. Ships ready-to-go .pre-commit-config.yaml templates for Java/Rust/TypeScript/Python.", diff --git a/README.md b/README.md index 8709f76..f71a13f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ AI code that passes lint on first try |---|---| | Plugin ID | `partme-codeguard-plugin` | | Hosts | ZCode, Claude Code, Codex CLI, Kimi Code | -| Current version | `0.8.0` | +| Current version | `0.8.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start | @@ -233,10 +233,36 @@ codeguard: | Key | Default | Effect | |---|---|---| | `enabled_languages` | `auto` (detect) | Restrict which linters run | -| `strict_mode` | `true` | Whether lint failure blocks AI | +| `strict_mode` | `true` | Reserved, currently unwired: PostToolUse never blocks (always exit 0, see `hooks/__protocol__.md`) | | `auto_fix_on_save` | `true` | Whether to attempt auto-fix before reporting failure | | `lint_timeout_seconds` | `120` | Per-linter timeout | +### Gate scope, escape audit, and unverified verdicts + +Gate scanning is scoped to **what you are about to change**, decided by git state: + +- **Commit face** (`staged + unstaged + untracked`): checked before `git commit`. + Pre-existing issues in HEAD do not block an unrelated new commit. +- **Push face** (commit face ∪ unpushed commits, `up...HEAD`): checked before + `git push`, so a bad commit made outside the gate is still caught on the way out. +- Project-level `codeguard.json` (repo root) overrides the default: + `{"gate_scope": "repo"}` restores full-repository scanning; `extensions` and + `exclude` customize language detection. Full scans always skip vendor/build + snapshots (immutable supply-chain content). + +Verdict honesty: a linter crashing with exit 2 (usage/dependency failure) is +reported as **unverified**, never as a lint failure — "cannot verify" is not +"verified bad", and a tool crash never triggers auto-fix. Exit 127 (command +missing) is split by context on purpose: interactive hooks skip it (never block +a person for missing tooling), while `check`/CI treats it as **failure** — a +health surface must go red when tooling is absent. + +Escape hatch: `git config codeguard.skipGate true` bypasses both the soft and +hard gate for one repository; every bypass is counted and surfaced by the Stop +summary, which also warns when the flag is left enabled. Shared hook state lives +under `CODEGUARD_HOME` (default `~/.codeguard`): session lint statistics, +double-install dedup keys, and the skip audit. + ## Repository layout ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index d588b35..1498291 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码 |---|---| | 插件 ID | `partme-codeguard-plugin` | | 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.8.0` | +| 当前版本 | `0.8.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 | @@ -217,10 +217,32 @@ codeguard: | 键 | 默认 | 作用 | |---|---|---| | `enabled_languages` | `auto`(自动检测) | 限定只跑哪些语言的 linter | -| `strict_mode` | `true` | lint 失败时是否阻塞 AI | +| `strict_mode` | `true` | 保留字段,当前未接线:PostToolUse 从不阻塞(恒 exit 0,见 `hooks/__protocol__.md`) | | `auto_fix_on_save` | `true` | 是否在报错前先尝试自动修复 | | `lint_timeout_seconds` | `120` | 每次 linter 调用的超时秒数 | +### 门禁作用域、绕过审计与未验证判定 + +门禁只扫描**你即将改动的东西**,由 git 状态决定: + +- **提交面**(staged + 未暂存 + 未跟踪):`git commit` 前检查。HEAD 里的存量 + 问题不会拦截无关的新提交。 +- **推送面**(提交面 ∪ 未推送提交,`up...HEAD`):`git push` 前检查——绕过门禁 + 提交进历史的坏改动,出门时仍会被拦下。 +- 项目根的 `codeguard.json` 可覆盖缺省:`{"gate_scope": "repo"}` 恢复全仓扫描; + `extensions` 与 `exclude` 自定义语言识别。全仓扫描始终剔除 vendor/build + 快照(供应链不可变内容)。 + +判定诚实性:linter 以 exit 2(用法/依赖崩溃)退出时按**未验证**上报,绝不计 +为 lint 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。 +exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它(不因工具缺失挡人); +`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。 + +逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次 +绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒。钩子共享状态位于 +`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过 +审计。 + ## 仓库结构 ``` diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index bc554f1..4990008 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -86,8 +86,12 @@ if __name__ == "__main__": **一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, 且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 `_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint -变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id`、UPS 用 -`session_id + 文本`;payload 不带这些字段(测试协议)时不去重。 +变成永久红,实测)。双副本事件去重键(四个钩子同一规则):PreToolUse 用 `tool_use_id`、 +UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`; +payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。 +推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套 +扫描;commit 面=暂存+未暂存+未跟踪,push 面=并集未推送提交(`up...HEAD`); +UserPromptSubmit 按提示词里的 `push/推送` 选同一套面,软硬两门永远看同一组文件。 --- diff --git a/hooks/env_check.py b/hooks/env_check.py index f14453f..eab89e4 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -7,6 +7,7 @@ 3. 输出一段 AGENTS.md 风格的提示,让 AI 知道自己在一个被 codeguard 管理的项目里 """ import contextlib +import json import os import subprocess import sys @@ -58,7 +59,15 @@ def detect_linter_config(project_root: Path) -> dict: return found -def main() -> int: +def main(payload: dict | None = None) -> int: + # 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的 + # 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段 + # (测试协议/其它宿主)时不去重,保持旧行为。 + from gate_lib import should_suppress_event + + session_id = (payload or {}).get("session_id") + if session_id and should_suppress_event(f"start:{session_id}"): + return 0 ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录,先补齐再盘点 project_root = find_project_root(os.getcwd()) if project_root is None: @@ -142,7 +151,15 @@ def main() -> int: if __name__ == "__main__": try: - sys.exit(main()) + payload: dict = {} + if not sys.stdin.isatty(): + try: + data = json.loads(sys.stdin.read()) + if isinstance(data, dict): + payload = data + except (json.JSONDecodeError, ValueError): + payload = {} + sys.exit(main(payload)) except SystemExit: raise except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流 diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 4374642..829d95f 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -139,8 +139,11 @@ def _worktree_fingerprint(project_root: Path) -> str: return h.hexdigest()[:16] -def _gate_cache_key(project_root: Path, languages: list) -> str | None: - """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹。任一变化即失效。""" +def _gate_cache_key(project_root: Path, languages: list, *, mode: str = "commit") -> str | None: + """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)。 + + 同一 HEAD/工作树下 commit 面与 push 面看到的文件集不同(push 面含未推送 + 提交),不带 mode 会互相污染缓存。""" head = _git(project_root, "rev-parse", "HEAD") if head is None: return None @@ -151,14 +154,20 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None: return None return ( f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}" - f"|{','.join(sorted(languages))}" + f"|{mode}|{','.join(sorted(languages))}" ) GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用 -def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tuple[list, list]: +def run_gate( + project_root: Path, + cfg: dict, + languages: list | None = None, + *, + mode: str = "commit", +) -> tuple[list, list]: """运行 linter 门禁(跨进程结果缓存 + 并行执行)。 `languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的 @@ -179,7 +188,7 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu return [], [] # 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、 # 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。 - changed = changed_files(project_root) + changed = changed_files(project_root, mode=mode) scope = (get_overrides(project_root) or {}).get("gate_scope") or ( "delta" if changed is not None else "repo" ) @@ -187,7 +196,7 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu if enabled and enabled != ["auto"]: languages = [lang for lang in languages if lang in enabled] - ck = _gate_cache_key(project_root, languages) + ck = _gate_cache_key(project_root, languages, mode=mode) cache_file = _gate_cache_path(project_root) if ck: try: @@ -274,11 +283,15 @@ def check(lang: str): return (lang, None, f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证") timeout = cfg.get("lint_timeout_seconds", 120) hint = cmd_def.get("install_hint") or "见 docs/LANGUAGES.md" + # 「未接入」先于「工具不可用」:requiresConfig 是纯文件系统判定(快、 + # 不起进程),且是更根本的原因——项目没接这个 linter 时,装没装工具 + # 都不该影响结论;反过来会把"未接入"报成"工具不可用"(本机无 yamllint + # 时既有 yaml 测试即因此失败)。 + if not project_uses_linter(cmd_def, project_root): + return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证") ok, reason = probe_toolchain(cmd_def) if not ok: return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})") - if not project_uses_linter(cmd_def, project_root): - return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证") outputs: list[tuple[int, str, str]] = [] if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []): diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 8015cd4..682ef09 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -94,17 +94,21 @@ def resolve_project_roots(command: str) -> list[Path]: return roots -def _segment_is_git_side_effect(seg: str) -> bool: - """单个命令段是不是 git commit/push:git 为分隔符后的命令词。 +def _git_side_effect_sub(seg: str) -> str | None: + """命令段段首为 git commit|push 时返回子命令(commit/push),否则 None。 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`)。 """ tokens = seg.strip().split() if len(tokens) < 2: - return False + return None first = tokens[0].strip("\"'") second = tokens[1].strip("\"'") - return first == "git" and second in ("commit", "push") + return second if first == "git" and second in ("commit", "push") else None + + +def _segment_is_git_side_effect(seg: str) -> bool: + return _git_side_effect_sub(seg) is not None _SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") @@ -153,18 +157,69 @@ def _command_indirect(command: str) -> bool: return False +def _collect_subs(text: str) -> list[str]: + import re + return [ + sub for seg in re.split(r"&&|\|\||;|\n", text) + if (sub := _git_side_effect_sub(seg)) + ] + + +def _guarded_mode(command: str) -> str | None: + """门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时 + 推送面是提交面的超集,按更宽的面检查);未命中返回 None。 + + 直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。 + is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描, + 避免"命中了却选错面"的分叉。 + """ + subs = _collect_subs(command) + if not subs: + import re + for seg in re.split(r"&&|\|\||;|\n", command): + tokens = seg.strip().split() + if not tokens: + continue + prog = tokens[0].rsplit("/", 1)[-1] + if prog not in _INTERPRETERS: + continue + rest = tokens[1:] + if "-c" in rest: + body = " ".join(rest[rest.index("-c") + 1:]) + subs = _collect_subs(body) + if subs: + break + continue + for tok in rest: + if tok.startswith("-"): + continue + target = Path(tok) + if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: + break + try: + if target.stat().st_size > 1_000_000: + break + body = target.read_text(encoding="utf-8", errors="ignore") + except OSError: + break + subs = _collect_subs(body) + break + if subs: + break + if not subs: + return None + return "push" if "push" in subs else "commit" + + def is_guarded(command: str) -> bool: """精确匹配 git commit/push:直接命令 + 一层解释器间接。 直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含 "git push" 字面量),所以按分隔符切段、git 必须是段首命令词。 - 间接:解释器执行的脚本/内联代码按同规则扫一层(见 _command_indirect)。 + 间接:解释器执行的脚本/内联代码按同规则扫一层。 + 命中后面(commit/push)由 _guarded_mode 决定,供门禁选 delta 面。 """ - import re - if any(_segment_is_git_side_effect(seg) - for seg in re.split(r"&&|\|\||;|\n", command)): - return True - return _command_indirect(command) + return _guarded_mode(command) is not None def main() -> int: @@ -185,6 +240,7 @@ def main() -> int: return 0 cfg = load_user_config() + mode = _guarded_mode(command) or "commit" roots = resolve_project_roots(command) if not roots: return 0 @@ -196,11 +252,10 @@ def main() -> int: # (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件) reports = [] for project_root in roots: - failures, _skipped = run_gate(project_root, cfg) + failures, _skipped = run_gate(project_root, cfg, mode=mode) if failures: reports.append(gate_directive(failures)) - safety_mode = "push" if "git push" in command.lower() else "commit" - violations = check_commit_safety(project_root, safety_mode) + violations = check_commit_safety(project_root, mode) if violations: reports.append(format_safety_report(violations) + ( "\n\n**给 AI 的强制指令**:先把上述文件移出版本库" diff --git a/hooks/stop_summary.py b/hooks/stop_summary.py index fb9dbcf..3485abd 100755 --- a/hooks/stop_summary.py +++ b/hooks/stop_summary.py @@ -75,7 +75,13 @@ def _skipgate_left_enabled() -> bool: return False -def main() -> int: +def main(payload: dict | None = None) -> int: + from gate_lib import should_suppress_event + + session_id = (payload or {}).get("session_id") + if session_id and should_suppress_event(f"stop:{session_id}"): + # 双副本:统计与清理由第一份完成,第二份静默(避免摘要×2、清两次)。 + return 0 state = load_state() print(summarize(state)) if _skipgate_left_enabled(): @@ -92,7 +98,15 @@ def main() -> int: if __name__ == "__main__": try: - sys.exit(main()) + payload: dict = {} + if not sys.stdin.isatty(): + try: + data = json.loads(sys.stdin.read()) + if isinstance(data, dict): + payload = data + except (json.JSONDecodeError, ValueError): + payload = {} + sys.exit(main(payload)) except SystemExit: raise except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流 diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index e9dac92..591f336 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -179,9 +179,12 @@ def main() -> int: # 2.2: 消息里提到了具体语言时只跑子集;没提到则回退全量探测(2.3)。 detected = detect_languages(project_root) subset = _detect_languages_in_text(user_text, detected) - failures, skipped = run_gate(project_root, cfg, languages=(subset or None)) + # 推送意图走 push 面(含未推送提交),提交意图走 commit 面(暂存+工作区)—— + # 与硬门禁的 _guarded_mode 同一套语义,软硬两门看到同一组文件。 + mode = "push" if _re.search(r"\bpush\b|推送", user_text, _re.IGNORECASE) else "commit" + failures, skipped = run_gate(project_root, cfg, languages=(subset or None), mode=mode) # 提交内容安全检查:.venv/node_modules/.env/密钥等不应入库 - violations = check_commit_safety(project_root, "commit") + violations = check_commit_safety(project_root, mode) if failures or violations: # 软引导:prompt 正常送达 AI,同时注入修复指令,AI 自动修复后重新提交 diff --git a/kimi.plugin.json b/kimi.plugin.json index c0a99a0..c2e2199 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.8.0", + "version": "0.8.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI" diff --git a/openspec/changes/archive/2026-09-22-push-face-delta/.openspec.yaml b/openspec/changes/archive/2026-09-22-push-face-delta/.openspec.yaml new file mode 100644 index 0000000..1b9acb7 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-push-face-delta/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/archive/2026-09-22-push-face-delta/design.md b/openspec/changes/archive/2026-09-22-push-face-delta/design.md new file mode 100644 index 0000000..76469c6 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-push-face-delta/design.md @@ -0,0 +1,37 @@ +## Context + +delta 默认是 0.8.0 引入的(存量不拦新提交),其"改动集"当时只定义了提交面。push 场景下工作树恒净、改动已入历史——同一函数返回空集,门禁形同虚设。这是自引入的正确性缺口,必须在下一补丁版本闭合,否则"修存量不拦新提交"会顺带变成"什么都拦不住"。 + +## Goals / Non-Goals + +**Goals:** + +- 推送面前移到"尚未送出的提交",堵住绕过提交后的出门路径。 +- 面判定单源:命中与选面共用一次扫描(`_guarded_mode`),软硬两门共用语义(UPS 以提示词意图选面)。 +- 缓存按面隔离;不可解析 upstream 时优雅回退(既有"push 无 upstream 不崩"保持)。 + +**Non-Goals:** + +- 不做跨仓脚本的 cd 追踪(脚本内部换仓仍是能力边界,协议已声明)。 +- 不实现 `strict_mode`(会违反协议 §1 PostToolUse 恒 exit 0 的设计;仅文档如实化)。 +- 不改退出码与 JSON schema。 + +## Decisions + +1. **push 面用三点差 `up...HEAD`(我这侧的分歧)而非两点差**——两点差会把上游侧的文件也卷进来,那些不是"我们要送出的改动"。 +2. **push 优先的面选择**——`git commit && git push` 链同时命中两面时取更宽的 push 面,宁多勿漏。 +3. **mode 进缓存键**——同 HEAD/工作树下两面文件集不同,共键会互相污染(commit 面的空结果把 push 面的失败缓存掉)。 +4. **面判定复用命中判定**——`is_guarded` 由 `_guarded_mode` 派生,杜绝"命中了却选错面"的两条代码路径。 +5. **`requiresConfig` 先于 probe**——纯文件系统判定不起步进程、更快,且是更根本的原因(没接这个 linter 时工具装没装不影响结论);同时修复既有 yaml 测试在无 yamllint 机器上的失败。 + +## Risks / Trade-offs + +- [push 面在大型仓多算一次 `up...HEAD` diff] → 只取文件名列表,量级与提交面三路 git 调用相同;不可解析时零成本返回空。 +- [无 upstream 的首次推送拿不到提交面] → 与既有"不崩"行为一致;首次 push 由 GitHub 端 CI 兜底,且下一次起 upstream 即存在。 +- [UPS 的 push 意图靠词面(push/推送)] → 与硬门禁的命令面判定是两套输入(自然语言 vs 命令),词面是可得的最优信号;误选 commit 面时仍查提交面,不失去拦截、只可能少查未推送提交(随后硬门禁 push 面补上)。 + +## Migration Plan + +1. 本变更规格先行(MODIFIED requirement 全文更新 + 新场景)。 +2. 实现(mode 化 + 顺序 + rc127 + 四钩子去重 + 文档)与回归测试同批落地。 +3. run_all/unittest/openpec/lint 全绿后 bump 0.8.1(修复类,patch),走仓内 PR 流程发版。 diff --git a/openspec/changes/archive/2026-09-22-push-face-delta/proposal.md b/openspec/changes/archive/2026-09-22-push-face-delta/proposal.md new file mode 100644 index 0000000..e2c7840 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-push-face-delta/proposal.md @@ -0,0 +1,26 @@ +## Why + +delta 作用域落地后出现了一个由它自己引入的空洞:**推送面失守**。坏改动一旦被提交(绕过门禁的提交、装插件之前的提交、用户手动提交),工作树即干净——`git push` 前的门禁按"本次改动"得到空集,全语言"未涉及"跳过,推送畅通无阻(实测:坏提交入史后 commit 面返回 `[]`)。提交门禁防的是"这次要提交什么",推送门禁必须防"这次要送出什么"——两者不是同一组文件。同批还修两处一致性:`requiresConfig`(未接入)判定排在探活之前(纯文件系统判定更快且更根本,本机无 yamllint 时既有 yaml 测试因顺序报错消息不对而失败);CLI 对 exit 127(命令不存在)按失败上报,与钩子路径的 skipped 口径分叉。 + +## What Changes + +- `changed_files` 增加 `mode`:`commit` 面 = staged + 未暂存 + 未跟踪;`push` 面 = 提交面 ∪ 未推送提交(`up...HEAD` 三点差,无 upstream 时按 `origin/`→`origin/main`→`origin/master` 逐个尝试,均不可解析则保持空、不崩)。 +- 硬门禁新增 `_guarded_mode`:直接命令与一层解释器间接共用同一套扫描,命中即返回生效面(commit/push 并存时 push 优先——推送面是提交面的超集);`is_guarded` 即其存在性判断,命中与选面不分叉。`run_gate`/缓存键携带 mode(同 HEAD 下两面文件集不同,不带 mode 会互相污染缓存)。 +- `UserPromptSubmit` 按提示词 `push/推送` 选同一套面,透传给 `run_gate` 与提交内容安全检查——软硬两门永远看同一组文件。 +- `run_per_language`:exit 127 与 exit 2 同归 unverified(此前 CLI 报失败、钩子报 skipped,口径分叉)。 +- 顺带完成双副本去重的补齐:SessionStart/Stop 也按 `session_id` 去重(四钩子同一规则),`__protocol__.md` 同步。 +- README 双语如实化:`strict_mode` 标注为保留未接线(实测零消费;其声称的"PostToolUse exit 2 阻塞"与协议 §1 恒 exit 0 矛盾),并新增门禁作用域/绕过审计/未验证判定说明。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `language-gate-commands`: "Gates in git repositories SHALL default to changed-file scope" 增加 mode 拆分与推送面场景(原 requirement 全文随 MODIFIED 更新)。 + +## Impact + +`scripts/scope.py`、`hooks/gate_lib.py`、`hooks/pre_tool_git_guard.py`、`hooks/user_prompt_validator.py`、`scripts/run_per_language.py`、`hooks/{env_check,stop_summary}.py`、`hooks/__protocol__.md`、README 双语、新增回归测试。退出码与 JSON schema 不变。行为变化:推送前会检查未推送提交里的问题;CLI 的 127 改报 unverified。 diff --git a/openspec/changes/archive/2026-09-22-push-face-delta/specs/language-gate-commands/spec.md b/openspec/changes/archive/2026-09-22-push-face-delta/specs/language-gate-commands/spec.md new file mode 100644 index 0000000..61be0d6 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-push-face-delta/specs/language-gate-commands/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Gates in git repositories SHALL default to changed-file scope + +git 仓库内的门禁 MUST 缺省只检查**本次操作面**涉及的文件,并按语言归属过滤;存量问题 MUST NOT 阻塞无关的新提交。操作面 MUST 由操作类型决定:**commit 面**(`git commit` 前)= staged + 未暂存 + 未跟踪;**push 面**(`git push` 前)= 提交面并集**未推送提交**(`up...HEAD` 三点差;无 upstream 时按 `origin/<当前分支>`→`origin/main`→`origin/master` 逐个尝试,均不可解析则不猜测、不崩溃)。面的判定 MUST 单源:命中判定与选面共用同一套扫描,直接命令与一层解释器间接不得分叉;同时命中 commit 与 push 时 MUST 取 push 面。提示词触发的软门禁 MUST 以同一套面语义选择(推送意图选 push 面)。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。门禁结果缓存 MUST 按面隔离(mode 进缓存键),同一 HEAD 下两面不得互相污染。 + +#### Scenario: A committed legacy issue is untouched by a clean change + +- **WHEN** 仓内 HEAD 已含存量 lint 问题,本次提交只涉及无问题的文档文件 +- **THEN** commit 面判定通过,存量问题不拦截本次提交 + +#### Scenario: A new staged file introduces a problem + +- **WHEN** 新增或修改的文件被暂存且含 lint 问题 +- **THEN** commit 面按改动集检出该问题并正常拦截 + +#### Scenario: A bad commit made outside the gate must not slip through the push + +- **WHEN** 工作树干净、提交已完成(绕过或早于门禁的坏提交),上游可解析 +- **THEN** push 面检出未推送提交中的问题文件并拦截 `git push` + +#### Scenario: Push with no resolvable upstream does not crash + +- **WHEN** 仓库无 upstream 且 `origin/main`/`origin/master` 均不存在 +- **THEN** 提交面照常计算、push 面保持空集,门禁不报错、按提交面结论决定 + +#### Scenario: A chained commit-and-push takes the wider face + +- **WHEN** 单条命令链同时包含 `git commit` 与 `git push`(或间接脚本体内两者并存) +- **THEN** 生效面为 push 面(提交面的超集),两面的文件并集被检查 diff --git a/openspec/changes/archive/2026-09-22-push-face-delta/tasks.md b/openspec/changes/archive/2026-09-22-push-face-delta/tasks.md new file mode 100644 index 0000000..c969c34 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-push-face-delta/tasks.md @@ -0,0 +1,22 @@ +## 1. Specification + +- [x] 1.1 MODIFIED delta(mode 拆分全文 + 5 个场景) +- [x] 1.2 `openspec validate --strict` 通过 + +## 2. Push face + +- [x] 2.1 `scope.changed_files(mode=...)`:commit 三路 + push `up...HEAD` 三点差与回退链 +- [x] 2.2 guard `_guarded_mode` 单源判定(直接+间接,push 优先);`is_guarded` 由其派生 +- [x] 2.3 `run_gate(mode=...)` + 缓存键带 mode;UPS 按 push/推送 词面选面并透传安全检查 + +## 3. Consistency + +- [x] 3.1 `requiresConfig` 判定先于 probe(修无 yamllint 机器上的 yaml 测试失败) +- [x] 3.2 CLI exit 127 与 exit 2 同归 unverified(与钩子 skipped 口径对齐) +- [x] 3.3 SessionStart/Stop 按 session_id 双副本去重;`__protocol__.md` 四钩子规则与面语义同步 +- [x] 3.4 README 双语:strict_mode 如实标注未接线 + 门禁作用域/绕过审计/未验证判定说明(parity 通过) + +## 4. Verification + +- [x] 4.1 回归测试:push 面 e2e(坏提交→push 拦、commit 放行)、UPS 双面、_guarded_mode 判定、rc127 unverified、四钩子去重 +- [x] 4.2 全套终验:run_all / unittest / openspec --all / 门禁等价 lint diff --git a/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/.openspec.yaml b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/.openspec.yaml new file mode 100644 index 0000000..4b5ddbb --- /dev/null +++ b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-22 +skip_specs: true diff --git a/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/proposal.md b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/proposal.md new file mode 100644 index 0000000..620c92b --- /dev/null +++ b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/proposal.md @@ -0,0 +1,24 @@ +## Why + +`2026-09-22-push-face-delta` 的 proposal/design 文案把 exit 127 与 exit 2 一并写成"同归 unverified"。落地后 CI 立刻红了:`test_mcp_server` 的失败日志与安静模式用例依赖 **CLI 在工具缺失(127)时报失败**这一环境无关性(CI runner 无 ruff,靠 127=失败才能可靠进入失败路径)。结论:127 的 CLI/hook 分叉是**按场景的有意分歧**,不是口径 bug——交互钩子跳过(不挡人),健康面必须红。该行为已恢复,此处仅记录对归档文案的取代;spec 层无变更(主 spec 的 unverified 要求只写了 exit 2,未涉及 127)。 + +## What Changes + +- 恢复 `run_per_language` 的 exit 127 = 失败(rc 2 的 unverified 保持,0.8.0 起 CI 已验证)。 +- 回归测试反转为锁住该分歧(`UnverifiedParityTests`:CLI 127 失败 / CLI 2 unverified)。 +- README 双语的 exit-127 句同步改为按场景分流的表述。 +- 本文档取代 `2026-09-22-push-face-delta` 归档 proposal/design 中关于 127 的那段陈述。 + +## Capabilities + +### New Capabilities + +None. Infrastructure-only correction; `skip_specs: true`. + +### Modified Capabilities + +None. + +## Impact + +`scripts/run_per_language.py`、`tests/test_hardening_fixes.py`、README 双语;不影响退出码总契约(PostToolUse 恒 0、PreToolUse 0/2 未动)与 JSON schema。 diff --git a/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/tasks.md b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/tasks.md new file mode 100644 index 0000000..6486562 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-restore-cli-127-strictness/tasks.md @@ -0,0 +1,4 @@ +- [x] 1.1 恢复 run_per_language rc127=失败(rc2 unverified 保留) +- [x] 1.2 UnverifiedParityTests 反转锁住有意分歧 +- [x] 1.3 README 双语 127 句按场景分流 +- [x] 1.4 `openspec validate --strict` 通过并归档 diff --git a/openspec/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index 6bbe8f2..b2d9dec 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -88,17 +88,32 @@ The functions `load_user_config`, `load_project_overrides`, and `get_overrides` ### Requirement: Gates in git repositories SHALL default to changed-file scope -git 仓库内的门禁 MUST 缺省只检查本次改动涉及的文件(staged + 未暂存 + 未跟踪,按语言归属过滤);存量问题 MUST NOT 阻塞无关的新提交。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。 +git 仓库内的门禁 MUST 缺省只检查**本次操作面**涉及的文件,并按语言归属过滤;存量问题 MUST NOT 阻塞无关的新提交。操作面 MUST 由操作类型决定:**commit 面**(`git commit` 前)= staged + 未暂存 + 未跟踪;**push 面**(`git push` 前)= 提交面并集**未推送提交**(`up...HEAD` 三点差;无 upstream 时按 `origin/<当前分支>`→`origin/main`→`origin/master` 逐个尝试,均不可解析则不猜测、不崩溃)。面的判定 MUST 单源:命中判定与选面共用同一套扫描,直接命令与一层解释器间接不得分叉;同时命中 commit 与 push 时 MUST 取 push 面。提示词触发的软门禁 MUST 以同一套面语义选择(推送意图选 push 面)。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。门禁结果缓存 MUST 按面隔离(mode 进缓存键),同一 HEAD 下两面不得互相污染。 #### Scenario: A committed legacy issue is untouched by a clean change - **WHEN** 仓内 HEAD 已含存量 lint 问题,本次提交只涉及无问题的文档文件 -- **THEN** 门禁判定通过,存量问题不拦截本次提交 +- **THEN** commit 面判定通过,存量问题不拦截本次提交 #### Scenario: A new staged file introduces a problem - **WHEN** 新增或修改的文件被暂存且含 lint 问题 -- **THEN** 门禁按改动集检出该问题并正常拦截 +- **THEN** commit 面按改动集检出该问题并正常拦截 + +#### Scenario: A bad commit made outside the gate must not slip through the push + +- **WHEN** 工作树干净、提交已完成(绕过或早于门禁的坏提交),上游可解析 +- **THEN** push 面检出未推送提交中的问题文件并拦截 `git push` + +#### Scenario: Push with no resolvable upstream does not crash + +- **WHEN** 仓库无 upstream 且 `origin/main`/`origin/master` 均不存在 +- **THEN** 提交面照常计算、push 面保持空集,门禁不报错、按提交面结论决定 + +#### Scenario: A chained commit-and-push takes the wider face + +- **WHEN** 单条命令链同时包含 `git commit` 与 `git push`(或间接脚本体内两者并存) +- **THEN** 生效面为 push 面(提交面的超集),两面的文件并集被检查 ### Requirement: A toolchain crash SHALL be recorded as unverified, not as a lint failure diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py index 28b98c9..7f0cc3f 100644 --- a/scripts/run_per_language.py +++ b/scripts/run_per_language.py @@ -58,8 +58,13 @@ def run_check(languages: list[str], project_root: Path, lint_cmd = scope_cmd(lint_cmd, project_root, full_excludes=True) rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) if rc == 2: - # exit 2 = 用法/依赖/配置崩溃,与仓库内容无关(house 规则: - # 无法验证 ≠ 验证失败)。不触发 fix、不写失败日志。 + # exit 2 = 用法/依赖/配置崩溃,与仓库内容无关 → unverified + # (不计失败、不触发 fix、不写失败日志)。 + # exit 127(命令不存在)**故意保持失败**:check CLI 是 CI/健康面, + # "工具没装"在那里就该红——test_mcp_server 的失败日志与安静模式用例 + # 依赖这条环境无关性(CI 无 ruff,靠 127=失败才进得了失败路径)。 + # 交互钩子路径对 127 归 skipped(不挡人工作):按场景的有意分歧, + # 不是口径 bug(v0.8.1 曾误统一过一次,本提交修正)。 results.append({ "language": lang, "passed": True, diff --git a/scripts/scope.py b/scripts/scope.py index 951d848..108ef63 100644 --- a/scripts/scope.py +++ b/scripts/scope.py @@ -94,11 +94,50 @@ def scope_cmd( return out -def changed_files(project_root: str | Path) -> list[str] | None: - """本次改动涉及的文件(staged + 未暂存 + 未跟踪);非 git 仓返回 None。 +def _git_out(root: Path, *args: str) -> str | None: + try: + proc = subprocess.run( + ["git", *args], + cwd=root, capture_output=True, check=False, text=True, timeout=15, + ) + except (subprocess.TimeoutExpired, OSError): + return None + return proc.stdout if proc.returncode == 0 else None + - 门禁的 delta 作用域据此判定"这次要检查什么":存量问题不拦新提交, - 不可编辑的 vendor 快照没动就不会被扫。排序去重,路径相对仓根。 +def _unpushed_files(root: Path) -> list[str]: + """push 面:相对上游「我这侧」的合并分歧文件(up...HEAD 三点差)。 + + 无 upstream 时按 origin/<当前分支> → origin/main → origin/master 逐个尝试; + 全部不可解析(如首次推送前的裸仓)返回空——不猜测、不崩(既有 + 「push 无 upstream 不崩」行为保持)。 + """ + ranges: list[str] = [] + upstream = _git_out(root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") + if upstream: + ranges.append(f"{upstream.strip()}...HEAD") + else: + branch = _git_out(root, "rev-parse", "--abbrev-ref", "HEAD") + if branch and branch.strip() not in ("HEAD",): + ranges.append(f"origin/{branch.strip()}...HEAD") + ranges += ["origin/main...HEAD", "origin/master...HEAD"] + for rng in ranges: + out = _git_out(root, "diff", "--name-only", rng) + if out is not None: + return [line for line in out.splitlines() if line.strip()] + return [] + + +def changed_files( + project_root: str | Path, *, mode: str = "commit", +) -> list[str] | None: + """本次改动涉及的文件;非 git 仓返回 None。 + + mode="commit"(提交面):staged + 未暂存 + 未跟踪——"这次要提交什么"。 + mode="push"(推送面):在提交面基础上**并集未推送提交的文件** + (up...HEAD)——工作树干净但本地领先时,坏改动已入库、提交面为空, + 推送面必须接管,否则 push 门禁形同虚设(实测:坏提交入史后 + changed_files 返回 [])。排序去重,路径相对仓根。 """ root = Path(project_root) try: @@ -116,13 +155,9 @@ def changed_files(project_root: str | Path) -> list[str] | None: ("diff", "--name-only"), ("ls-files", "--others", "--exclude-standard"), ): - try: - out = subprocess.run( - ["git", *args], - cwd=root, capture_output=True, check=False, text=True, timeout=15, - ) - except (subprocess.TimeoutExpired, OSError): - continue - if out.returncode == 0 and out.stdout: - names.update(line for line in out.stdout.splitlines() if line.strip()) + out = _git_out(root, *args) + if out: + names.update(line for line in out.splitlines() if line.strip()) + if mode == "push": + names.update(_unpushed_files(root)) return sorted(names) diff --git a/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py index 3c829a3..b57811d 100644 --- a/tests/test_hardening_fixes.py +++ b/tests/test_hardening_fixes.py @@ -334,3 +334,180 @@ def test_different_key_not_suppressed(self) -> None: if __name__ == "__main__": unittest.main() + + +# ══════════ 2026-09-22 push-face batch(2026-09-22-push-face-delta)══════════ + +class GuardedModeTests(unittest.TestCase): + """面判定与命中判定同源:直接与一层间接都必须选出正确的面。""" + + def test_direct_faces(self) -> None: + self.assertEqual(guard._guarded_mode("git commit -m x"), "commit") + self.assertEqual(guard._guarded_mode("cd r && git push"), "push") + + def test_chained_takes_push_face(self) -> None: + self.assertEqual( + guard._guarded_mode("git add -A && git commit -m x && git push"), "push" + ) + + def test_not_guarded_returns_none(self) -> None: + self.assertIsNone(guard._guarded_mode('echo "git push"')) + self.assertIsNone(guard._guarded_mode("git status")) + + def test_indirect_script_face(self) -> None: + script = Path(tempfile.mkdtemp(prefix="cg-face-")) / "release.sh" + script.write_text("set -e\ngit push origin main\n", encoding="utf-8") + self.assertEqual(guard._guarded_mode(f"bash {script}"), "push") + + +def _pushable_repo() -> Path: + """bare 远端 + 工作克隆 + 一个绕过门禁的坏提交(工作树干净)。""" + import shutil + bare = Path(tempfile.mkdtemp(prefix="cg-bare-")) + work = Path(tempfile.mkdtemp(prefix="cg-work-")) + subprocess.run(["git", "init", "--bare", "-q", str(bare)], check=True) + subprocess.run(["git", "clone", "-q", str(bare), str(work)], capture_output=True) + _git(work, "config", "user.email", "t@t") + _git(work, "config", "user.name", "t") + (work / "good.txt").write_text("ok\n", encoding="utf-8") + _git(work, "add", "-A") + _git(work, "commit", "-q", "-m", "init") + r = subprocess.run( + ["git", "push", "-q", "-u", "origin", "HEAD"], cwd=work, + capture_output=True, text=True, + ) + assert r.returncode == 0, r.stderr + # 绕过门禁(模拟用户手动提交/装插件前的提交):坏 shell 文件入库 + (work / "bad.sh").write_text("#!/bin/bash\nif [ $x = y ]; then true; fi\n", encoding="utf-8") + _git(work, "add", "-A") + _git(work, "commit", "-q", "-m", "bad commit outside the gate") + shutil.rmtree(bare, ignore_errors=True) # 远端留否无所谓:upstream 已建立 + return work + + +class PushFaceTests(unittest.TestCase): + """0.8.0 引入的空洞:坏提交入史后工作树干净,push 门禁曾得到空集。""" + + def test_changed_files_faces(self) -> None: + work = _pushable_repo() + self.assertEqual(scope.changed_files(work, mode="commit"), []) + self.assertEqual(scope.changed_files(work, mode="push"), ["bad.sh"]) + + @unittest.skipUnless( + __import__("shutil").which("shellcheck"), "shellcheck 未安装" + ) + def test_push_is_blocked_and_commit_is_not(self) -> None: + import shutil + work = _pushable_repo() + env_extra = {"CODEGUARD_HOME": str(Path(tempfile.mkdtemp(prefix="cg-home-")))} + r_push = _run_hook( + "pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git push"}}, + work, env_extra=env_extra, + ) + self.assertEqual(r_push.returncode, 2, r_push.stderr[:400]) + self.assertIn("shell", r_push.stderr) + r_commit = _run_hook( + "pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, + work, env_extra=env_extra, + ) + # commit 面空集:历史里已入库的东西不该拦"下一次干净提交" + self.assertEqual(r_commit.returncode, 0, r_commit.stderr[:400]) + shutil.rmtree(work, ignore_errors=True) + + def test_ups_uses_push_face_for_push_intent(self) -> None: + import shutil + work = _pushable_repo() + env_extra = {"CODEGUARD_HOME": str(Path(tempfile.mkdtemp(prefix="cg-home-")))} + r_push = _run_hook( + "user_prompt_validator.py", {"user_prompt": "请帮我 push"}, work, + env_extra=env_extra, + ) + self.assertEqual(r_push.returncode, 0) + self.assertIn("codeguard ❌", r_push.stdout, "push 意图必须走 push 面并报出问题") + r_commit = _run_hook( + "user_prompt_validator.py", {"user_prompt": "提交代码"}, work, + env_extra=env_extra, + ) + self.assertIn("✅", r_commit.stdout, "commit 意图走空的 commit 面应通过") + shutil.rmtree(work, ignore_errors=True) + + +class UnverifiedParityTests(unittest.TestCase): + """rc2/127 按场景分流:rc2 = unverified(两处一致);rc127 = 故意分歧。 + + check CLI 是 CI/健康面——工具缺失必须红(环境无关性由 test_mcp_server + 的失败日志/安静模式用例依赖);交互钩子对 127 归 skipped 不挡工作。 + 这是有意设计,本测试锁住它,防止再被"统一"。 + """ + + @staticmethod + def _fake_run(returncode: int): + import run_per_language as rpl + + class _Fake: + pass + + _Fake.returncode = returncode + _Fake.stdout = "" + _Fake.stderr = "boom" if returncode != 0 else "" + original = rpl.subprocess.run + rpl.subprocess.run = lambda *a, **k: _Fake() + return original + + def test_rc_127_is_failure_in_cli(self) -> None: + import run_per_language as rpl + + original = self._fake_run(127) + try: + results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-127-"))) + finally: + rpl.subprocess.run = original + self.assertFalse(results[0]["passed"], "CLI 面工具缺失必须报失败") + self.assertNotIn("unverified", results[0]) + + def test_rc_2_is_unverified_in_cli(self) -> None: + import run_per_language as rpl + + original = self._fake_run(2) + try: + results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-2-"))) + finally: + rpl.subprocess.run = original + self.assertTrue(results[0]["passed"]) + self.assertIn("exit 2", results[0].get("unverified", "")) + + +class FourHookDedupTests(unittest.TestCase): + """SessionStart/Stop 也按 session_id 去重(四钩子同一规则)。""" + + def setUp(self) -> None: + self.home = Path(tempfile.mkdtemp(prefix="cg-home-")) + self.env = {"CODEGUARD_HOME": str(self.home)} + + def test_session_start_second_copy_silent(self) -> None: + repo = _fresh_repo() + (repo / "a.py").write_text("x = 1\n", encoding="utf-8") + payload = {"session_id": "s-dedup-1"} + first = _run_hook("env_check.py", payload, repo, env_extra=self.env) + second = _run_hook("env_check.py", payload, repo, env_extra=self.env) + self.assertEqual(first.returncode, 0) + self.assertTrue(first.stdout.strip(), "第一份必须正常输出") + self.assertEqual(second.returncode, 0) + self.assertEqual(second.stdout.strip(), "", "同会话第二份必须静默") + + def test_stop_second_copy_silent(self) -> None: + repo = _fresh_repo() + payload = {"session_id": "s-dedup-2"} + first = _run_hook("stop_summary.py", payload, repo, env_extra=self.env) + second = _run_hook("stop_summary.py", payload, repo, env_extra=self.env) + self.assertTrue(first.stdout.strip()) + self.assertEqual(second.stdout.strip(), "", "同会话第二份必须静默") + + def test_different_sessions_not_deduped(self) -> None: + repo = _fresh_repo() + a = _run_hook("stop_summary.py", {"session_id": "s-a"}, repo, env_extra=self.env) + b = _run_hook("stop_summary.py", {"session_id": "s-b"}, repo, env_extra=self.env) + self.assertTrue(a.stdout.strip()) + self.assertTrue(b.stdout.strip())