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
8 changes: 4 additions & 4 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
"source": {
"source": "url",
"url": "https://github.com/partme-ai/partme-codeguard-plugin.git",
"ref": "v0.8.2"
"ref": "v0.10.0"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_USE"
},
"category": "Developer Tools",
"version": "0.8.2",
"version": "0.10.0",
"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.2/assets/official-logo.png",
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.10.0/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.2/assets/official-logo.png"
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.10.0/assets/official-logo.png"
}
}
]
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeguard",
"version": "0.8.2+codex.20260922",
"version": "0.10.0+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",
Expand Down
2 changes: 1 addition & 1 deletion .zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"en": "CodeGuard",
"zh-CN": "代码规范检查"
},
"version": "0.8.2",
"version": "0.10.0",
"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.",
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ Gate scanning is scoped to **what you are about to change**, decided by git stat
- 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).
snapshots (immutable supply-chain content) and build-output directories
(`target`, `dist`, `build`, … — also injected into `find -print0` style gates,
so generated artifacts like maven-javadoc's `javadoc.sh` no longer trip the
shell gate). The >50-file delta fallback to a full command applies the same
exclusions. Java gate failures caused by unresolvable dependencies now carry
an actionable hint (run `mvn install` first) instead of a bare maven stack.

Verdict honesty: a linter crashing with exit 2 (usage/dependency failure) is
reported as **unverified**, never as a lint failure — "cannot verify" is not
Expand All @@ -259,7 +264,8 @@ 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
summary, which also warns when the flag is left enabled. The audit keeps the
last 20 events (timestamp + repository + kind). Shared hook state lives
under `CODEGUARD_HOME` (default `~/.codeguard`): session lint statistics,
double-install dedup keys, and the skip audit.

Expand Down
9 changes: 7 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,20 @@ codeguard:
提交进历史的坏改动,出门时仍会被拦下。
- 项目根的 `codeguard.json` 可覆盖缺省:`{"gate_scope": "repo"}` 恢复全仓扫描;
`extensions` 与 `exclude` 自定义语言识别。全仓扫描始终剔除 vendor/build
快照(供应链不可变内容)。
快照(供应链不可变内容)与构建产物目录(`target`、`dist`、`build` 等,
含 `find -print0` 型 gate——生成物如 maven-javadoc 的 `javadoc.sh` 不再让
shell 门禁误红)。delta 超 50 文件回退全量命令时同样剔除。
Java 门禁报依赖解析失败时附行动指引(`mvn install` 前置说明),而非只留
maven 堆栈尾部。

判定诚实性:linter 以 exit 2(用法/依赖崩溃)退出时按**未验证**上报,绝不计
为 lint 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。
exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它(不因工具缺失挡人);
`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。

逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次
绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒。钩子共享状态位于
绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒,审计还保留最近 20
条明细(时间 + 仓库 + 类型)。钩子共享状态位于
`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过
审计。

Expand Down
50 changes: 47 additions & 3 deletions hooks/gate_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@
import contextlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))

# maven 依赖解析失败的特征(javadoc:jar 独立 goal 在 reactor 上跑时的典型崩法)
_DEP_RESOLUTION_RE = re.compile(
r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException"
)

from detect_lang import (
LANG_COMMANDS,
detect_language,
Expand Down Expand Up @@ -321,7 +328,13 @@ def check(lang: str):
break
else:
if scope == "delta":
cmd = scope_cmd(base_cmd, project_root, files=lang_files if uses_delta_files else None)
# files=None(delta 超 50 文件回退全量命令)时同样要剔除构建产物——
# 否则回退路径反而比常规全量门禁更容易扫到 target/ 生成物。
cmd = scope_cmd(
base_cmd, project_root,
files=lang_files if uses_delta_files else None,
full_excludes=not uses_delta_files,
)
else:
cmd = scope_cmd(base_cmd, project_root, full_excludes=True)
outputs.append(_run_one(cmd, timeout, lang, hint))
Expand All @@ -342,6 +355,9 @@ def check(lang: str):
return (lang, None, f"{lang} 工具链异常未验证:exit 2(非 lint 结论){('|' + head[:80]) if head else ''}")
full = "\n".join(seg for seg in ((out or "").rstrip(), (err or "").rstrip()) if seg)
detail = _truncate_detail(full or "(linter 无输出)", project_root, lang)
dep_hint = _dependency_resolution_hint(lang, full)
if dep_hint:
detail += f"\n{dep_hint}"
fix = f"自动修复: {' '.join(cmd_def['format'])}" if cmd_def.get("format") else "按上述问题逐项修复"
return (lang, (lang, detail, fix, hint), None)

Expand Down Expand Up @@ -399,8 +415,29 @@ def session_state_path() -> Path:
return codeguard_home() / "session_state.json"


def record_skip_event(kind: str) -> None:
"""记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。"""
def _dependency_resolution_hint(lang: str, full: str) -> str | None:
"""Java 门禁报依赖解析失败时给行动指引,而不是只留 maven 堆栈尾部。

javadoc:jar 以独立 goal 跑在 reactor 上,sample 类模块常设
maven.install.skip=true,它们的构件不在本地仓——下游模块必然
Could not resolve。没有这行提示,用户看到的是与"该改什么"无关的堆栈。
"""
if lang != "java":
return None
if not _DEP_RESOLUTION_RE.search(full):
return None
return (
"提示:依赖构件不可解析(如 samples 模块 maven.install.skip=true 未入本地仓)。"
"先在仓根执行 mvn install -DskipTests(必要时加 -Dmaven.install.skip=false)再重试门禁。"
)


def record_skip_event(kind: str, project_root: Path | None = None) -> None:
"""记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。

除计数外保留最近 20 条明细(时间 + 仓库 + 类型)——豁免必须可回溯:
只有总数时无法回答"哪个仓、什么时候被跳过的"。
"""
state = {}
path = session_state_path()
try:
Expand All @@ -412,6 +449,13 @@ def record_skip_event(kind: str) -> None:
meta["count"] = int(meta.get("count", 0)) + 1
kinds = meta.setdefault("kinds", {})
kinds[kind] = int(kinds.get(kind, 0)) + 1
events = meta.setdefault("events", [])
events.append({
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"kind": kind,
"repo": project_root.name if project_root else None,
})
del events[:-20]
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
Expand Down
2 changes: 1 addition & 1 deletion hooks/pre_tool_git_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def main() -> int:
if not roots:
return 0
if any(skip_gate_via_git_config(r) for r in roots):
record_skip_event("skipGate")
record_skip_event("skipGate", roots[0])
return 0

# 每个被操作的仓库独立跑:linter 门禁 + 提交内容安全检查
Expand Down
2 changes: 1 addition & 1 deletion hooks/user_prompt_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def main() -> int:
if skip_gate_via_git_config(project_root):
# 软门禁与硬门禁共用同一条仓库级豁免:此前 UPS 不认 skipGate,
# 出现过"硬门放行、软门仍喊禁止提交"的自相矛盾。
record_skip_event("skipGate")
record_skip_event("skipGate", project_root)
return 0

cfg = load_user_config()
Expand Down
2 changes: 1 addition & 1 deletion kimi.plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeguard",
"version": "0.8.2",
"version": "0.10.0",
"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"
Expand Down
30 changes: 30 additions & 0 deletions openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 2026-09-22-gate-full-scan-excludes

## Why

ddd4j-boot 13 分支仓实测:java 门禁 `mvn javadoc:jar` 在 `target/**/apidocs/`
生成无 shebang 的 `javadoc.sh`,shell 门禁的 find 把它当源码扫(SC2148),
commit/push 门禁必红且删了也输(java lint 并发再生成)——两个 gate 步骤
互相矛盾。排除基建已存在但只物化 ruff:`scope_cmd(full_excludes=True)`
仅对 `ruff` 追加 `--exclude`,`find -print0` 型 gate 原样透传。同批实测
还发现:老发布版本 shellcheck gate 缺 `--severity` 钉扎,info 级发现
(SC2059/SC2295)也阻塞;skipGate 豁免只有总数没有明细。

## What Changes

- `scope.py`:`_RUFF_FULL_EXCLUDES` → 公开的 `FULL_SCAN_EXCLUDES`(单一
事实源);新增 `_inject_find_excludes`——`full_excludes=True` 时给
`find … -print0` 型命令注入 `-not -path '*/<dir>/*'`,幂等,ruff 路径不变。
- `gate_lib.py`:delta 超 50 文件回退全量命令的路径补 `full_excludes=True`
(回退路径此前比常规全量门禁更容易扫到 target/);java 门禁依赖解析
失败(Could not resolve / Could not find artifact)追加行动指引;
`record_skip_event` 增加最近 20 条明细(时间 + 仓库 + 类型),总数与
kinds 字段保持兼容。
- `validate_languages_json.py`:新契约——gate/lint 中调用 shellcheck 必须
钉扎 `--severity=`(防"门禁结论随 cache 版本漂移"回归)。

## Impact

- Affected specs: `language-gate-commands`(gate 命令可执行性 + 一致性契约)
- 受益场景:任何 Maven/Gradle 项目(构建产物不再引发 shell/java 门禁互斥)、
skipGate 豁免可回溯、依赖解析失败有出路指引。
24 changes: 24 additions & 0 deletions openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Tasks: 2026-09-22-gate-full-scan-excludes

## 1. 扫描剔除泛化

- [x] 1.1 `scope.py`:`_RUFF_FULL_EXCLUDES` 重命名为公开 `FULL_SCAN_EXCLUDES`
- [x] 1.2 `scope.py`:新增 `_inject_find_excludes`(`find -print0` 注入 `-not -path`,幂等)
- [x] 1.3 `scope_cmd`:`full_excludes=True` 时对字符串元素应用 find 注入(ruff 分支不变)
- [x] 1.4 `gate_lib`:delta 回退全量路径补 `full_excludes=not uses_delta_files`

## 2. 可诊断性与可回溯性

- [x] 2.1 `gate_lib`:`_dependency_resolution_hint`——java 依赖解析失败给行动指引
- [x] 2.2 `gate_lib`:`record_skip_event` 增加最近 20 条明细(ts/kind/repo),总数兼容
- [x] 2.3 两个 hook 调用点传入 `project_root`

## 3. 注册表契约

- [x] 3.1 `validate_languages_json`:shellcheck gate/lint 必须钉扎 `--severity=`

## 4. 测试与文档

- [x] 4.1 `tests/test_full_scan_excludes.py`:14 用例(注入/幂等/端到端 javadoc.sh/审计/提示/契约)
- [x] 4.2 README(zh+en):全量门禁自动剔除构建产物目录说明
- [x] 4.3 `run_all.py` 全量 141 通过;ruff 全绿;版本 bump
2 changes: 1 addition & 1 deletion scripts/languages.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@
"gate": [
"bash",
"-c",
"find . \\( -name '*.sh' -o -name '*.bash' -o -name '*.zsh' \\) -type f -not -path '*/node_modules/*' -print0 | xargs -0 shellcheck --severity=warning"
"find . \\( -name '*.sh' -o -name '*.bash' -o -name '*.zsh' \\) -type f -not -path '*/node_modules/*' -print0 | xargs -0 -r shellcheck --severity=warning"
],
"linter_config_files": [
".shellcheckrc"
Expand Down
38 changes: 32 additions & 6 deletions scripts/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
(判定可复现性:规则集不能随机器上恰好装的 ruff 漂移;注入文件必须是
ruff 原生格式,[tool.ruff] 包装的片段会被 TOML 解析拒绝)。
- `scope_cmd`:物化命令——{file} 替换、全仓扫描 token 收敛为文件列表、
裸命令追加文件、全量模式剔除依赖快照目录。
裸命令追加文件、全量模式剔除依赖快照与构建产物目录(ruff 走 --exclude,
`find -print0` 型 gate 注入 -not -path)。
- `changed_files`:git 仓的本次改动集(staged + 未暂存 + 未跟踪);
非 git 目录返回 None(调用方据此退回全量作用域)。

Expand All @@ -20,16 +21,38 @@
import subprocess
from pathlib import Path

# 门禁/CI 全量模式下 ruff 默认剔除的目录(不可编辑的依赖快照与构建产物)。
# 门禁/CI 全量模式下默认剔除的目录(不可编辑的依赖快照与构建产物)。
# 与 hooks/gate_lib.GUARD_EXCLUDE_DIRS 语义重叠但职责不同:那边管"能否入库",
# 这边管"扫不扫"。vendor 快照是供应链不可变内容,扫它只会得到"永久红"。
_RUFF_FULL_EXCLUDES = (
# 这边管"扫不扫"。vendor 快照是供应链不可变内容,扫它只会得到"永久红";
# 构建产物(target/ 下的 maven-javadoc javadoc.sh 等)是生成物,扫它得到的
# 也是与仓库内容无关的"永久红"(实测:java 门禁自己生成的 javadoc.sh 让
# shell 门禁必红——两个 gate 步骤互相矛盾)。ruff 走 --exclude,
# find 型 gate 注入 -not -path,两侧共用这一份清单。
FULL_SCAN_EXCLUDES = (
".venv", "venv", "node_modules", "vendor", "upstream", "build", "dist",
"target", ".tox", "__pycache__",
)
_RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml"


def _inject_find_excludes(expr: str) -> str:
"""给 `find … -print0` 表达式注入构建产物目录排除(-not -path)。

幂等:已声明同类排除('*/target/*' 或 '*/target')的目录不重复注入。
只在首个 -print0 前插入,保持 xargs 管道段不动。
"""
if "-print0" not in expr or "find " not in expr:
return expr
additions = "".join(
f" -not -path '*/{d}/*'"
for d in FULL_SCAN_EXCLUDES
if f"'*/{d}/*'" not in expr and f"'*/{d}'" not in expr
)
if not additions:
return expr
return expr.replace(" -print0", f"{additions} -print0", 1)


def ruff_config_args(cmd: list, project_root: str | Path) -> list:
"""ruff 命令注入 codeguard 默认配置——仅当项目没有自己的 ruff 配置时。

Expand Down Expand Up @@ -70,7 +93,8 @@ def scope_cmd(
- 既无占位符也无扫描 token 的裸命令:给了文件就**追加**(yamllint 这类
不带路径时读 stdin 恒"通过",追加才检查得到东西);
- ruff 命令注入 ruff_config_args 的默认配置;full_excludes=True(全量
模式)再追加 --exclude,剔除依赖快照与构建产物。
模式)时剔除依赖快照与构建产物:ruff 追加 --exclude,`find -print0`
型命令(bash -c find … | xargs 形态的 gate)注入 -not -path。
"""
out = list(cmd)
if not out:
Expand All @@ -82,8 +106,10 @@ def scope_cmd(
args = ruff_config_args(out, project_root)
out = [out[0]] + args + out[1:]
if full_excludes:
for d in _RUFF_FULL_EXCLUDES:
for d in FULL_SCAN_EXCLUDES:
out += ["--exclude", d]
elif full_excludes:
out = [_inject_find_excludes(c) if isinstance(c, str) else c for c in out]
scan_idx = [i for i, tok in enumerate(out[1:], 1) if tok == "." or "**" in tok]
if targets and scan_idx:
flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")]
Expand Down
23 changes: 22 additions & 1 deletion scripts/validate_languages_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,26 @@ def _check_since(reg: dict) -> list[str]:
return errs


def _check_gate_severity_pin(reg: dict) -> list[str]:
"""gate 命令里调用支持 severity 的工具时必须钉扎级别。

背景(0.8.x 前的实测坑):同一份 shell gate 在部分发布版本里没带
`--severity=warning`,info/style 级发现(SC2059/SC2295)也会阻塞提交;
缺了这个钉扎,门禁结论取决于用户 cache 里恰好是哪个发布版本。
shellcheck 支持 --severity;markdownlint 走 config 文件,不在此列。
"""
errs: list[str] = []
for lang in reg.get("languages", []):
if not isinstance(lang, dict):
continue
lid = lang.get("id", "<?>")
for field in ("gate", "lint"):
joined = " ".join(lang.get(field) or [])
if "shellcheck" in joined and "--severity=" not in joined:
errs.append(f"{lid}:{field}: shellcheck must pin --severity= (drift guard)")
return errs


def check(registry: dict) -> list[str]:
"""Return all schema and consistency errors; empty list means valid."""
errs: list[str] = []
Expand All @@ -198,6 +218,7 @@ def check(registry: dict) -> list[str]:
errs += _check_requires_config(registry)
errs += _check_linter_config_files(registry)
errs += _check_since(registry)
errs += _check_gate_severity_pin(registry)
return errs


Expand Down Expand Up @@ -235,4 +256,4 @@ def main() -> int:


if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
Loading
Loading