Skip to content

Commit 58c1f9b

Browse files
authored
release: v0.8.1 推送面补齐 + 门禁口径一致性 (#28)
* release: v0.8.1(推送面补齐 + 门禁口径一致性) - push 面:changed_files 增 mode,push=提交面∪未推送提交(up...HEAD 三点差, 无 upstream 三级回退不崩)——堵死 0.8.0 delta 引入的空洞(坏提交入史后 工作树干净、push 门禁曾得到空集,实测) - _guarded_mode 单源面判定(直接+一层间接同套扫描,push 优先),is_guarded 由其派生;run_gate/缓存键携带 mode(同 HEAD 两面不互污) - UPS 按 push/推送 词面选面并透传安全检查——软硬两门永远看同一组文件 - requiresConfig 先于 probe(未接入是纯 fs 判定且更根本;修无 yamllint 机器 上的 yaml 测试失败 → run_all 141/0 清零) - CLI exit 127 与 exit 2 同归 unverified,与钩子 skipped 口径对齐 - SessionStart/Stop 按 session_id 双副本去重(四钩子同一规则) - README 双语如实化:strict_mode 标注保留未接线(实测零消费;其声称的 PostToolUse 阻塞与协议 §1 恒 exit 0 矛盾)+ 门禁作用域/绕过审计/未验证说明 - OpenSpec 2026-09-22-push-face-delta(MODIFIED 推送面 requirement + 5 场景) 已归档并入主 specs;__protocol__.md 同步面语义与四钩子去重规则 - 测试:run_all 141/0(0 失败 0 跳过)、unittest 84(+11 回归) * fix: exit 127 恢复为 CLI 失败(按场景分歧取代误统一) CI 实证:test_mcp_server 的失败日志/安静模式用例依赖 check 面在工具缺失 (exit 127)时报失败这一环境无关性(runner 无 ruff,靠 127=失败才进得了 失败路径)——v0.8.1 首版把 127 并入 unverified 导致 PR#28 CI 红。 - run_per_language:仅 exit 2 归 unverified;127 恢复为失败(含设计注释) - 交互钩子路径对 127 仍 skipped:按场景的有意分歧(健康面红、工作面不挡人), UnverifiedParityTests 反转锁住该分歧(127 失败 / 2 unverified) - README 双语 exit-127 句改为按场景分流表述 - OpenSpec 2026-09-22-restore-cli-127-strictness(skip_specs)归档, 记录对 push-face-delta 归档文案的取代 验证:unittest 85、run_all 141/0、test_mcp_server 5/5、openspec 12/12、lint 全绿
1 parent 9dbbb8d commit 58c1f9b

24 files changed

Lines changed: 592 additions & 58 deletions

File tree

‎.agents/plugins/marketplace.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@
99
"source": {
1010
"source": "url",
1111
"url": "https://github.com/partme-ai/partme-codeguard-plugin.git",
12-
"ref": "v0.8.0"
12+
"ref": "v0.8.1"
1313
},
1414
"policy": {
1515
"installation": "AVAILABLE",
1616
"authentication": "ON_USE"
1717
},
1818
"category": "Developer Tools",
19-
"version": "0.8.0",
19+
"version": "0.8.1",
2020
"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.",
21-
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png",
21+
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/assets/official-logo.png",
2222
"interface": {
2323
"displayName": "代码规范守卫",
2424
"shortDescription": "Make AI-written code pass lint on first try",
25-
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.0/assets/official-logo.png"
25+
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.8.1/assets/official-logo.png"
2626
}
2727
}
2828
]

‎.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeguard",
3-
"version": "0.8.0+codex.20260922",
3+
"version": "0.8.1+codex.20260922",
44
"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.",
55
"author": {
66
"name": "Full Stack Skills / PartMe.AI",

‎.zcode-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"en": "CodeGuard",
66
"zh-CN": "代码规范检查"
77
},
8-
"version": "0.8.0",
8+
"version": "0.8.1",
99
"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.",
1010
"description_i18n": {
1111
"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.",

‎README.md‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ AI code that passes lint on first try
6767
|---|---|
6868
| Plugin ID | `partme-codeguard-plugin` |
6969
| Hosts | ZCode, Claude Code, Codex CLI, Kimi Code |
70-
| Current version | `0.8.0` |
70+
| Current version | `0.8.1` |
7171
| ZCode manifest | `.zcode-plugin/plugin.json` |
7272
| Codex manifest | `.codex-plugin/plugin.json` |
7373
| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start |
@@ -233,10 +233,36 @@ codeguard:
233233
| Key | Default | Effect |
234234
|---|---|---|
235235
| `enabled_languages` | `auto` (detect) | Restrict which linters run |
236-
| `strict_mode` | `true` | Whether lint failure blocks AI |
236+
| `strict_mode` | `true` | Reserved, currently unwired: PostToolUse never blocks (always exit 0, see `hooks/__protocol__.md`) |
237237
| `auto_fix_on_save` | `true` | Whether to attempt auto-fix before reporting failure |
238238
| `lint_timeout_seconds` | `120` | Per-linter timeout |
239239

240+
### Gate scope, escape audit, and unverified verdicts
241+
242+
Gate scanning is scoped to **what you are about to change**, decided by git state:
243+
244+
- **Commit face** (`staged + unstaged + untracked`): checked before `git commit`.
245+
Pre-existing issues in HEAD do not block an unrelated new commit.
246+
- **Push face** (commit face ∪ unpushed commits, `up...HEAD`): checked before
247+
`git push`, so a bad commit made outside the gate is still caught on the way out.
248+
- Project-level `codeguard.json` (repo root) overrides the default:
249+
`{"gate_scope": "repo"}` restores full-repository scanning; `extensions` and
250+
`exclude` customize language detection. Full scans always skip vendor/build
251+
snapshots (immutable supply-chain content).
252+
253+
Verdict honesty: a linter crashing with exit 2 (usage/dependency failure) is
254+
reported as **unverified**, never as a lint failure — "cannot verify" is not
255+
"verified bad", and a tool crash never triggers auto-fix. Exit 127 (command
256+
missing) is split by context on purpose: interactive hooks skip it (never block
257+
a person for missing tooling), while `check`/CI treats it as **failure** — a
258+
health surface must go red when tooling is absent.
259+
260+
Escape hatch: `git config codeguard.skipGate true` bypasses both the soft and
261+
hard gate for one repository; every bypass is counted and surfaced by the Stop
262+
summary, which also warns when the flag is left enabled. Shared hook state lives
263+
under `CODEGUARD_HOME` (default `~/.codeguard`): session lint statistics,
264+
double-install dedup keys, and the skip audit.
265+
240266
## Repository layout
241267

242268
```

‎README.zh-CN.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码
6767
|---|---|
6868
| 插件 ID | `partme-codeguard-plugin` |
6969
| 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code |
70-
| 当前版本 | `0.8.0` |
70+
| 当前版本 | `0.8.1` |
7171
| ZCode manifest | `.zcode-plugin/plugin.json` |
7272
| Codex manifest | `.codex-plugin/plugin.json` |
7373
| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 |
@@ -217,10 +217,32 @@ codeguard:
217217
| 键 | 默认 | 作用 |
218218
|---|---|---|
219219
| `enabled_languages` | `auto`(自动检测) | 限定只跑哪些语言的 linter |
220-
| `strict_mode` | `true` | lint 失败时是否阻塞 AI |
220+
| `strict_mode` | `true` | 保留字段,当前未接线:PostToolUse 从不阻塞(恒 exit 0,见 `hooks/__protocol__.md`) |
221221
| `auto_fix_on_save` | `true` | 是否在报错前先尝试自动修复 |
222222
| `lint_timeout_seconds` | `120` | 每次 linter 调用的超时秒数 |
223223

224+
### 门禁作用域、绕过审计与未验证判定
225+
226+
门禁只扫描**你即将改动的东西**,由 git 状态决定:
227+
228+
- **提交面**(staged + 未暂存 + 未跟踪):`git commit` 前检查。HEAD 里的存量
229+
问题不会拦截无关的新提交。
230+
- **推送面**(提交面 ∪ 未推送提交,`up...HEAD`):`git push` 前检查——绕过门禁
231+
提交进历史的坏改动,出门时仍会被拦下。
232+
- 项目根的 `codeguard.json` 可覆盖缺省:`{"gate_scope": "repo"}` 恢复全仓扫描;
233+
`extensions` 与 `exclude` 自定义语言识别。全仓扫描始终剔除 vendor/build
234+
快照(供应链不可变内容)。
235+
236+
判定诚实性:linter 以 exit 2(用法/依赖崩溃)退出时按**未验证**上报,绝不计
237+
为 lint 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。
238+
exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它(不因工具缺失挡人);
239+
`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。
240+
241+
逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次
242+
绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒。钩子共享状态位于
243+
`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过
244+
审计。
245+
224246
## 仓库结构
225247

226248
```

‎hooks/__protocol__.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,12 @@ if __name__ == "__main__":
8686
**一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免,
8787
且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行
8888
`_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint
89-
变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id`、UPS 用
90-
`session_id + 文本`;payload 不带这些字段(测试协议)时不去重。
89+
变成永久红,实测)。双副本事件去重键(四个钩子同一规则):PreToolUse 用 `tool_use_id`、
90+
UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`;
91+
payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。
92+
推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套
93+
扫描;commit 面=暂存+未暂存+未跟踪,push 面=并集未推送提交(`up...HEAD`);
94+
UserPromptSubmit 按提示词里的 `push/推送` 选同一套面,软硬两门永远看同一组文件。
9195

9296
---
9397

‎hooks/env_check.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
3. 输出一段 AGENTS.md 风格的提示,让 AI 知道自己在一个被 codeguard 管理的项目里
88
"""
99
import contextlib
10+
import json
1011
import os
1112
import subprocess
1213
import sys
@@ -58,7 +59,15 @@ def detect_linter_config(project_root: Path) -> dict:
5859
return found
5960

6061

61-
def main() -> int:
62+
def main(payload: dict | None = None) -> int:
63+
# 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的
64+
# 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段
65+
# (测试协议/其它宿主)时不去重,保持旧行为。
66+
from gate_lib import should_suppress_event
67+
68+
session_id = (payload or {}).get("session_id")
69+
if session_id and should_suppress_event(f"start:{session_id}"):
70+
return 0
6271
ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录,先补齐再盘点
6372
project_root = find_project_root(os.getcwd())
6473
if project_root is None:
@@ -142,7 +151,15 @@ def main() -> int:
142151

143152
if __name__ == "__main__":
144153
try:
145-
sys.exit(main())
154+
payload: dict = {}
155+
if not sys.stdin.isatty():
156+
try:
157+
data = json.loads(sys.stdin.read())
158+
if isinstance(data, dict):
159+
payload = data
160+
except (json.JSONDecodeError, ValueError):
161+
payload = {}
162+
sys.exit(main(payload))
146163
except SystemExit:
147164
raise
148165
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流

‎hooks/gate_lib.py‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,11 @@ def _worktree_fingerprint(project_root: Path) -> str:
139139
return h.hexdigest()[:16]
140140

141141

142-
def _gate_cache_key(project_root: Path, languages: list) -> str | None:
143-
"""缓存键:HEAD + 暂存区指纹 + 工作区内容指纹。任一变化即失效。"""
142+
def _gate_cache_key(project_root: Path, languages: list, *, mode: str = "commit") -> str | None:
143+
"""缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)。
144+
145+
同一 HEAD/工作树下 commit 面与 push 面看到的文件集不同(push 面含未推送
146+
提交),不带 mode 会互相污染缓存。"""
144147
head = _git(project_root, "rev-parse", "HEAD")
145148
if head is None:
146149
return None
@@ -151,14 +154,20 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None:
151154
return None
152155
return (
153156
f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}"
154-
f"|{','.join(sorted(languages))}"
157+
f"|{mode}|{','.join(sorted(languages))}"
155158
)
156159

157160

158161
GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用
159162

160163

161-
def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tuple[list, list]:
164+
def run_gate(
165+
project_root: Path,
166+
cfg: dict,
167+
languages: list | None = None,
168+
*,
169+
mode: str = "commit",
170+
) -> tuple[list, list]:
162171
"""运行 linter 门禁(跨进程结果缓存 + 并行执行)。
163172
164173
`languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的
@@ -179,15 +188,15 @@ def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tu
179188
return [], []
180189
# 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、
181190
# 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。
182-
changed = changed_files(project_root)
191+
changed = changed_files(project_root, mode=mode)
183192
scope = (get_overrides(project_root) or {}).get("gate_scope") or (
184193
"delta" if changed is not None else "repo"
185194
)
186195
enabled = cfg.get("enabled_languages", [])
187196
if enabled and enabled != ["auto"]:
188197
languages = [lang for lang in languages if lang in enabled]
189198

190-
ck = _gate_cache_key(project_root, languages)
199+
ck = _gate_cache_key(project_root, languages, mode=mode)
191200
cache_file = _gate_cache_path(project_root)
192201
if ck:
193202
try:
@@ -274,11 +283,15 @@ def check(lang: str):
274283
return (lang, None, f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证")
275284
timeout = cfg.get("lint_timeout_seconds", 120)
276285
hint = cmd_def.get("install_hint") or "见 docs/LANGUAGES.md"
286+
# 「未接入」先于「工具不可用」:requiresConfig 是纯文件系统判定(快、
287+
# 不起进程),且是更根本的原因——项目没接这个 linter 时,装没装工具
288+
# 都不该影响结论;反过来会把"未接入"报成"工具不可用"(本机无 yamllint
289+
# 时既有 yaml 测试即因此失败)。
290+
if not project_uses_linter(cmd_def, project_root):
291+
return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证")
277292
ok, reason = probe_toolchain(cmd_def)
278293
if not ok:
279294
return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})")
280-
if not project_uses_linter(cmd_def, project_root):
281-
return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证")
282295

283296
outputs: list[tuple[int, str, str]] = []
284297
if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []):

‎hooks/pre_tool_git_guard.py‎

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,21 @@ def resolve_project_roots(command: str) -> list[Path]:
9494
return roots
9595

9696

97-
def _segment_is_git_side_effect(seg: str) -> bool:
98-
"""单个命令段是不是 git commit/push:git 为分隔符后的命令词。
97+
def _git_side_effect_sub(seg: str) -> str | None:
98+
"""命令段段首为 git commit|push 时返回子命令(commit/push),否则 None。
9999
100100
首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`)。
101101
"""
102102
tokens = seg.strip().split()
103103
if len(tokens) < 2:
104-
return False
104+
return None
105105
first = tokens[0].strip("\"'")
106106
second = tokens[1].strip("\"'")
107-
return first == "git" and second in ("commit", "push")
107+
return second if first == "git" and second in ("commit", "push") else None
108+
109+
110+
def _segment_is_git_side_effect(seg: str) -> bool:
111+
return _git_side_effect_sub(seg) is not None
108112

109113

110114
_SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts")
@@ -153,18 +157,69 @@ def _command_indirect(command: str) -> bool:
153157
return False
154158

155159

160+
def _collect_subs(text: str) -> list[str]:
161+
import re
162+
return [
163+
sub for seg in re.split(r"&&|\|\||;|\n", text)
164+
if (sub := _git_side_effect_sub(seg))
165+
]
166+
167+
168+
def _guarded_mode(command: str) -> str | None:
169+
"""门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时
170+
推送面是提交面的超集,按更宽的面检查);未命中返回 None。
171+
172+
直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。
173+
is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描,
174+
避免"命中了却选错面"的分叉。
175+
"""
176+
subs = _collect_subs(command)
177+
if not subs:
178+
import re
179+
for seg in re.split(r"&&|\|\||;|\n", command):
180+
tokens = seg.strip().split()
181+
if not tokens:
182+
continue
183+
prog = tokens[0].rsplit("/", 1)[-1]
184+
if prog not in _INTERPRETERS:
185+
continue
186+
rest = tokens[1:]
187+
if "-c" in rest:
188+
body = " ".join(rest[rest.index("-c") + 1:])
189+
subs = _collect_subs(body)
190+
if subs:
191+
break
192+
continue
193+
for tok in rest:
194+
if tok.startswith("-"):
195+
continue
196+
target = Path(tok)
197+
if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES:
198+
break
199+
try:
200+
if target.stat().st_size > 1_000_000:
201+
break
202+
body = target.read_text(encoding="utf-8", errors="ignore")
203+
except OSError:
204+
break
205+
subs = _collect_subs(body)
206+
break
207+
if subs:
208+
break
209+
if not subs:
210+
return None
211+
return "push" if "push" in subs else "commit"
212+
213+
156214
def is_guarded(command: str) -> bool:
157215
"""精确匹配 git commit/push:直接命令 + 一层解释器间接。
158216
159217
直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含
160218
"git push" 字面量),所以按分隔符切段、git 必须是段首命令词。
161-
间接:解释器执行的脚本/内联代码按同规则扫一层(见 _command_indirect)。
219+
间接:解释器执行的脚本/内联代码按同规则扫一层。
220+
命中后面(commit/push)由 _guarded_mode 决定,供门禁选 delta 面。
162221
"""
163-
import re
164-
if any(_segment_is_git_side_effect(seg)
165-
for seg in re.split(r"&&|\|\||;|\n", command)):
166-
return True
167-
return _command_indirect(command)
222+
return _guarded_mode(command) is not None
168223

169224

170225
def main() -> int:
@@ -185,6 +240,7 @@ def main() -> int:
185240
return 0
186241

187242
cfg = load_user_config()
243+
mode = _guarded_mode(command) or "commit"
188244
roots = resolve_project_roots(command)
189245
if not roots:
190246
return 0
@@ -196,11 +252,10 @@ def main() -> int:
196252
# (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件)
197253
reports = []
198254
for project_root in roots:
199-
failures, _skipped = run_gate(project_root, cfg)
255+
failures, _skipped = run_gate(project_root, cfg, mode=mode)
200256
if failures:
201257
reports.append(gate_directive(failures))
202-
safety_mode = "push" if "git push" in command.lower() else "commit"
203-
violations = check_commit_safety(project_root, safety_mode)
258+
violations = check_commit_safety(project_root, mode)
204259
if violations:
205260
reports.append(format_safety_report(violations) + (
206261
"\n\n**给 AI 的强制指令**:先把上述文件移出版本库"

0 commit comments

Comments
 (0)