Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/skills-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install requirements (mcp SDK so MCP server tests run, not skip)
run: python3 -m pip install --disable-pip-version-check -r requirements.txt
- name: Verify vendored skills match the lockfile digests
run: python3 scripts/vendor/skill_vendor.py check --offline
- name: Verify lockfile pins still match upstream refs and content
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ build/
dist/
htmlcov/
Thumbs.db
# codeguard 失败日志落盘目录
out/
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ AI code that passes lint on first try
| Current version | `0.6.7` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP server | Not published until the SDK-backed protocol implementation is ready; use the CLI and hooks |
| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start |
| Primary language | Python 3.10+ (hooks), YAML/JSON (config) |
| License | Apache-2.0 |

Expand Down Expand Up @@ -199,6 +199,25 @@ When this plugin is active, you do **not** need to do anything manually:
- When the user says "commit", you will receive a final all-linters-must-pass gate check.
- At session end, you will see a summary of which lints passed/failed.

### MCP server

`run_check.py --mcp` starts a stdio MCP server (official `mcp` SDK; install
dependencies with `pip install -r requirements.txt`) exposing three tools:

| Tool | Purpose |
|---|---|
| `check_code_style` | Run lint and return a per-language envelope with `stderr_path` and `log_path` |
| `auto_fix` | Run the formatter chain, then re-run lint and embed the check envelope |
| `list_languages` | List supported language ids and display names (no internal commands) |

```bash
python3 scripts/run_check.py --mcp .
```

Failed runs write the full combined output to `<project>/out/.codeguard-last.log`
(the same path appears in the tool envelope and the CLI summary line; suppress
with `--quiet`).

## Configuration

User config in `~/.zcode/settings.local.yaml` (ZCode) or equivalent for other hosts:
Expand Down
20 changes: 19 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ AI 一次写出就过 lint 的代码
| 当前版本 | `0.6.7` |
| ZCode manifest | `.zcode-plugin/plugin.json` |
| Codex manifest | `.codex-plugin/plugin.json` |
| MCP 服务 | SDK 协议实现完成前不随清单发布;当前使用 CLI 与 Hooks |
| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 |
| 主要语言 | Python 3.10+(钩子)、YAML/JSON(配置) |
| 协议 | Apache-2.0 |

Expand Down Expand Up @@ -184,6 +184,24 @@ ln -s $PWD ~/.kimi/plugins/partme-codeguard-plugin
- 用户说「commit」时,你会受到「所有 linter 必须通过」的最后门禁
- 会话结束时会看到本会话 lint 通过/失败的总结

### MCP 服务器

`run_check.py --mcp` 启动 stdio MCP 服务(官方 `mcp` SDK;先用
`pip install -r requirements.txt` 安装依赖),暴露三个工具:

| 工具 | 用途 |
|---|---|
| `check_code_style` | 跑 lint,返回逐语言信封与 `stderr_path`、`log_path` |
| `auto_fix` | 先跑 formatter 链再复检 lint(嵌入 check 信封) |
| `list_languages` | 列出语言 id 与显示名(不含内部命令) |

```bash
python3 scripts/run_check.py --mcp .
```

失败时完整输出落盘到 `<项目根>/out/.codeguard-last.log`(与工具信封及 CLI
摘要行里的路径一致;`--quiet` 可关闭)。

## 配置

`~/.zcode/settings.local.yaml`(ZCode)或各平台对应文件:
Expand Down
3 changes: 1 addition & 2 deletions hooks/env_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
3. 输出一段 AGENTS.md 风格的提示,让 AI 知道自己在一个被 codeguard 管理的项目里
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -129,6 +128,6 @@ def main() -> int:
sys.exit(main())
except SystemExit:
raise
except Exception as exc: # 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)
27 changes: 17 additions & 10 deletions hooks/gate_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))

from detect_lang import ( # noqa: E402
LANG_COMMANDS, detect_languages, load_user_config, project_uses_linter, probe_toolchain,
from detect_lang import (
LANG_COMMANDS,
detect_languages,
probe_toolchain,
project_uses_linter,
)


# === git 提交内容安全检查:绝不该进版本库的文件 ===
# 目录(路径任一段落匹配即违规):依赖/虚拟环境/构建产物/IDE/缓存
GUARD_EXCLUDE_DIRS = {
Expand All @@ -43,7 +45,7 @@ def _git(project_root: Path, *args: str) -> str | None:
"""跑只读 git 命令;失败(非 git 仓/无 upstream 等)返回 None,由调用方降级"""
try:
proc = subprocess.run(
["git", *args], cwd=project_root, capture_output=True, text=True, timeout=15
["git", *args], cwd=project_root, capture_output=True, check=False, text=True, timeout=15
)
except (subprocess.TimeoutExpired, OSError, FileNotFoundError):
return None
Expand Down Expand Up @@ -101,7 +103,8 @@ def format_safety_report(violations: list[tuple[str, str, str]]) -> str:


def _gate_cache_path(project_root: Path) -> Path:
import hashlib, tempfile
import hashlib
import tempfile
key = hashlib.sha1(str(project_root.resolve()).encode()).hexdigest()[:12]
return Path(tempfile.gettempdir()) / f"codeguard-gate-{os.getuid()}-{key}.json"

Expand All @@ -122,8 +125,11 @@ def _gate_cache_key(project_root: Path, languages: list) -> str | None:
GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用


def run_gate(project_root: Path, cfg: dict) -> tuple[list, list]:
"""运行全量 linter 门禁(跨进程结果缓存 + 并行执行)。
def run_gate(project_root: Path, cfg: dict, languages: list | None = None) -> tuple[list, list]:
"""运行 linter 门禁(跨进程结果缓存 + 并行执行)。

`languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的
语言子集,门禁只跑该子集;不传则按 `detect_languages()` 全量探测。

软门禁(UserPromptSubmit)与硬门禁(PreToolUse)在正常提交路径上
会对同一状态连跑两次全量 lint——缓存键含 HEAD 与暂存区指纹,
Expand All @@ -134,7 +140,8 @@ def run_gate(project_root: Path, cfg: dict) -> tuple[list, list]:
- skipped: [str] 无法验证的说明(工具未装/超时),不阻塞
"""
import time as _time
languages = detect_languages(project_root)
if languages is None:
languages = detect_languages(project_root)
if not languages:
return [], []
enabled = cfg.get("enabled_languages", [])
Expand Down Expand Up @@ -187,7 +194,7 @@ def check(lang: str):
return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证")
try:
proc = subprocess.run(
gate_cmd, cwd=project_root, capture_output=True, text=True, timeout=timeout
gate_cmd, cwd=project_root, capture_output=True, check=False, text=True, timeout=timeout
)
except subprocess.TimeoutExpired:
return (lang, None, f"{lang} 检查超时(>{timeout}s),本次未验证")
Expand All @@ -213,7 +220,7 @@ def check(lang: str):
for r in results: # pool.map 保序,输出与语言表顺序一致
if r is None:
continue
lang, failure, skip = r
_, failure, skip = r
if failure:
failures.append(failure)
if skip:
Expand Down
4 changes: 2 additions & 2 deletions hooks/post_tool_lint.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def is_ejs_template(file_path: str) -> bool:

def run(cmd: list[str], cwd: Path, timeout: int = 300) -> tuple[int, str, str]:
try:
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False)
return proc.returncode, proc.stdout, proc.stderr
except subprocess.TimeoutExpired:
return 124, "", f"timeout after {timeout}s"
Expand Down Expand Up @@ -293,6 +293,6 @@ def materialize(cmd: list[str]) -> list[str]:
sys.exit(main())
except SystemExit:
raise
except Exception as exc: # 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)
12 changes: 6 additions & 6 deletions hooks/pre_tool_git_guard.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@
from __future__ import annotations

import json
import subprocess
import os
import subprocess
import sys
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))

from detect_lang import ensure_user_path, find_project_root, load_user_config # noqa: E402
from gate_lib import ( # noqa: E402
from detect_lang import ensure_user_path, load_user_config
from gate_lib import (
check_commit_safety,
format_safety_report,
gate_directive,
Expand Down Expand Up @@ -51,7 +51,7 @@ def _is_git_repo(p: Path) -> bool:
try:
proc = subprocess.run(
["git", "-C", str(p), "rev-parse", "--git-dir"],
capture_output=True, text=True, timeout=10,
capture_output=True, check=False, text=True, timeout=10,
)
except (subprocess.TimeoutExpired, OSError):
return False
Expand Down Expand Up @@ -113,7 +113,7 @@ def skip_gate_via_git_config(project_root: Path) -> bool:
try:
proc = subprocess.run(
["git", "config", "--get", "codeguard.skipGate"],
cwd=project_root, capture_output=True, text=True, timeout=10,
cwd=project_root, capture_output=True, check=False, text=True, timeout=10,
)
except (subprocess.TimeoutExpired, OSError):
return False
Expand Down Expand Up @@ -165,6 +165,6 @@ def main() -> int:
sys.exit(main())
except SystemExit:
raise
except Exception as exc: # 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)
2 changes: 1 addition & 1 deletion hooks/stop_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,6 @@ def main() -> int:
sys.exit(main())
except SystemExit:
raise
except Exception as exc: # 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)
68 changes: 60 additions & 8 deletions hooks/user_prompt_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,39 @@
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))

from detect_lang import detect_languages, ensure_user_path, find_project_root, load_user_config # noqa: E402
from gate_lib import ( # noqa: E402
# 触发此钩子的关键词(中英)
# 英文按词边界匹配(防止 pushed/deployment 误命中),中文子串即可。
# 2026-09-22 实测回归:`我刚才 pushed 了`、`关于 deployment 策略的讨论`
# 必须静默(子串匹配曾把它们当提交意图跑全仓 lint)。
import re as _re

from detect_lang import (
detect_languages,
ensure_user_path,
find_project_root,
load_user_config,
)
from gate_lib import (
check_commit_safety,
format_safety_report,
gate_directive,
run_gate,
summarize_failures,
)

# 触发此钩子的关键词(中英)
TRIGGER_PATTERNS = [
"commit", "push", "deploy", "提交", "发布", "部署",
]
_TRIGGER_RE = _re.compile(
r"\b(?:commit|push|deploy)\b|提交|发布|部署",
_re.IGNORECASE,
)
# 行动意图短语:触发词出现且(句首祈使 或 命中行动短语)才进门禁。
_ACTION_INTENT_RE = _re.compile(
r"请帮我|帮我|请|马上|立刻|现在|下一步|继续|please|now|next|proceed|go ahead|"
r"commit this|push this|deploy this",
_re.IGNORECASE,
)


def notify(title: str, message: str) -> None:
Expand All @@ -57,12 +77,41 @@ def notify(title: str, message: str) -> None:


def is_trigger(user_text: str) -> bool:
"""触发条件:(1) 命中触发词(英文按词边界)(2) 非疑问句 (3) 行动意图或句首祈使。

(3) 缺一不可:`我刚才 pushed 了` 有触发词但无行动意图 → 静默;
`commit this now` 句首祈使 → 触发;`请帮我 commit` 行动短语 → 触发。
"""
if not user_text:
return False
text = user_text.lower()
if not any(p in text for p in TRIGGER_PATTERNS):
if any(q in user_text for q in QUESTION_MARKERS):
return False
return not any(q in user_text for q in QUESTION_MARKERS)
m = _TRIGGER_RE.search(user_text)
if not m:
return False
# 句首祈使:触发词本身(或前面只有空白/常见介词)位于句首。
stripped = user_text.lstrip()
leading = stripped[: m.start()].strip().lower()
if leading in ("", "git", "to", "the", "a", "an", "帮我", "请", "请帮我"):
return True
return bool(_ACTION_INTENT_RE.search(user_text))


def _detect_languages_in_text(user_text: str, language_ids: list[str]) -> list[str]:
"""从用户消息中提取提到的语言子集(2.1/2.2/2.3)。

与 `languages.json` 的 `id` 字段做大小写无关匹配;没有命中返回空列表,
由调用方回退到 `detect_languages()` 全量探测(保持原有能力)。
"""
if not user_text or not language_ids:
return []
text = user_text.lower()
hits = []
for lang_id in language_ids:
# 词边界避免 go 命中 gone/going、c 命中 cmake 等子串误命中。
if _re.search(rf"\b{_re.escape(lang_id.lower())}\b", text):
hits.append(lang_id)
return hits


def read_user_text() -> str:
Expand All @@ -86,7 +135,10 @@ def main() -> int:

cfg = load_user_config()
project_root = find_project_root(os.getcwd()) or Path(os.getcwd())
failures, skipped = run_gate(project_root, cfg)
# 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))
# 提交内容安全检查:.venv/node_modules/.env/密钥等不应入库
violations = check_commit_safety(project_root, "commit")

Expand Down Expand Up @@ -131,6 +183,6 @@ def main() -> int:
sys.exit(main())
except SystemExit:
raise
except Exception as exc: # 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
except Exception as exc: # noqa: BLE001 — 内部错误 fail-open:traceback 绝不进 AI 上下文/阻断工作流
print(f"[codeguard] 内部错误已忽略(fail-open): {exc!r}", file=sys.stderr)
sys.exit(0)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-09-22
goal: 修复提交门禁触发启发式的过度敏感,让 MCP server 真正可用,并改进 linter 失败信息的可读性。
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# fix-gate-trigger-and-mcp

修复 codeguard-plugin UserPromptSubmit 钩子的触发启发式过于敏感、让 `--mcp` 入口真正可用、并改进 linter 失败信息的可读性。
Loading
Loading