diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
index 5bfdb2b..92ef818 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.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"
}
}
]
diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json
index 2aae0e9..25f7123 100644
--- a/.codex-plugin/plugin.json
+++ b/.codex-plugin/plugin.json
@@ -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",
diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json
index 060889c..980891b 100644
--- a/.zcode-plugin/plugin.json
+++ b/.zcode-plugin/plugin.json
@@ -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.",
diff --git a/README.md b/README.md
index 3cb9525..2cde516 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e966886..4a7e8f0 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -231,7 +231,11 @@ 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 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。
@@ -239,7 +243,8 @@ exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它
`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。
逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次
-绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒。钩子共享状态位于
+绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒,审计还保留最近 20
+条明细(时间 + 仓库 + 类型)。钩子共享状态位于
`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过
审计。
diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py
index 0ddbfe4..f330ba4 100644
--- a/hooks/gate_lib.py
+++ b/hooks/gate_lib.py
@@ -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,
@@ -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))
@@ -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)
@@ -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:
@@ -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")
diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py
index 8fcee4f..22c264c 100755
--- a/hooks/pre_tool_git_guard.py
+++ b/hooks/pre_tool_git_guard.py
@@ -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 门禁 + 提交内容安全检查
diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py
index b4c36c3..20d5efe 100755
--- a/hooks/user_prompt_validator.py
+++ b/hooks/user_prompt_validator.py
@@ -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()
diff --git a/kimi.plugin.json b/kimi.plugin.json
index 5577008..5a9a4b5 100644
--- a/kimi.plugin.json
+++ b/kimi.plugin.json
@@ -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"
diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md b/openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md
new file mode 100644
index 0000000..7388a16
--- /dev/null
+++ b/openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md
@@ -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 '*/
/*'`,幂等,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 豁免可回溯、依赖解析失败有出路指引。
diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md b/openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md
new file mode 100644
index 0000000..3db823d
--- /dev/null
+++ b/openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md
@@ -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
diff --git a/scripts/languages.json b/scripts/languages.json
index fdcc585..09e7373 100644
--- a/scripts/languages.json
+++ b/scripts/languages.json
@@ -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"
diff --git a/scripts/scope.py b/scripts/scope.py
index 108ef63..522f362 100644
--- a/scripts/scope.py
+++ b/scripts/scope.py
@@ -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(调用方据此退回全量作用域)。
@@ -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 配置时。
@@ -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:
@@ -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("#")]
diff --git a/scripts/validate_languages_json.py b/scripts/validate_languages_json.py
index 4c18ca5..a6bfbe9 100644
--- a/scripts/validate_languages_json.py
+++ b/scripts/validate_languages_json.py
@@ -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] = []
@@ -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
@@ -235,4 +256,4 @@ def main() -> int:
if __name__ == "__main__":
- sys.exit(main())
\ No newline at end of file
+ sys.exit(main())
diff --git a/tests/test_full_scan_excludes.py b/tests/test_full_scan_excludes.py
new file mode 100644
index 0000000..7ed0afe
--- /dev/null
+++ b/tests/test_full_scan_excludes.py
@@ -0,0 +1,167 @@
+"""全量门禁扫描剔除(构建产物目录)回归测试:2026-09-22 实战批次。
+
+每个用例锚定 ddd4j-boot 13 分支仓实测踩过的坑:
+- java 门禁 `mvn javadoc:jar` 在 target/**/apidocs/ 生成无 shebang 的
+ javadoc.sh,shell 门禁的 find 把它当源码扫 → SC2148 必红;
+ 两个 gate 步骤互相矛盾,删了也输(java lint 并发再生成)。
+- shellcheck gate 必须钉扎 --severity:老发布版本缺省时 info 级
+ (SC2059/SC2295)也阻塞,门禁结论随 cache 版本漂移。
+- skipGate 豁免只有总数没有明细,无法回答"哪个仓、何时被跳过"。
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+PLUGIN = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PLUGIN / "scripts"))
+sys.path.insert(0, str(PLUGIN / "hooks"))
+
+import gate_lib # noqa: E402
+import scope # noqa: E402
+import validate_languages_json as vlj # noqa: E402
+
+REGISTRY = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8"))
+SHELL_GATE = next(lang for lang in REGISTRY["languages"] if lang["id"] == "shell")["gate"]
+
+
+def _shellcheck_available() -> bool:
+ try:
+ subprocess.run(["shellcheck", "--version"], capture_output=True, check=False, timeout=10)
+ return True
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+
+
+class FindExcludeInjectionTests(unittest.TestCase):
+ """full_excludes 必须覆盖 find 型 gate,且幂等、不影响 {file} 路径。"""
+
+ def test_shell_gate_gains_target_exclude(self) -> None:
+ out = scope.scope_cmd(SHELL_GATE, ".", full_excludes=True)
+ expr = out[-1]
+ self.assertIn("-not -path '*/target/*'", expr)
+ # 注入必须落在 find 表达式内(-print0 之前),xargs 管道段不动
+ self.assertLess(expr.index("'*/target/*'"), expr.index("-print0"))
+ self.assertIn("xargs -0 -r shellcheck --severity=warning", expr)
+
+ def test_injection_is_idempotent(self) -> None:
+ once = scope.scope_cmd(SHELL_GATE, ".", full_excludes=True)
+ twice = scope.scope_cmd(once, ".", full_excludes=True)
+ self.assertEqual(once, twice)
+
+ def test_existing_exclusion_not_duplicated(self) -> None:
+ expr = scope.scope_cmd(SHELL_GATE, ".", full_excludes=True)[-1]
+ self.assertEqual(expr.count("'*/node_modules/*'"), 1)
+
+ def test_full_excludes_off_leaves_gate_verbatim(self) -> None:
+ self.assertEqual(scope.scope_cmd(SHELL_GATE, "."), SHELL_GATE)
+
+ def test_single_file_mode_unaffected(self) -> None:
+ out = scope.scope_cmd(
+ ["shellcheck", "--severity=warning", "{file}"], ".", single_file="a.sh")
+ self.assertEqual(out, ["shellcheck", "--severity=warning", "a.sh"])
+
+ def test_delta_fallback_full_scan_excludes(self) -> None:
+ """delta 超 50 文件回退全量命令时(files=None)也必须剔除构建产物。"""
+ out = scope.scope_cmd(SHELL_GATE, ".", files=None, full_excludes=True)
+ self.assertIn("-not -path '*/target/*'", out[-1])
+
+
+@unittest.skipUnless(_shellcheck_available(), "shellcheck not installed")
+class JavadocShScenarioTests(unittest.TestCase):
+ """端到端:java 门禁的 javadoc.sh 生成物不再让 shell 门禁误红。"""
+
+ def _gate_exit(self, root: Path) -> int:
+ cmd = scope.scope_cmd(SHELL_GATE, str(root), full_excludes=True)
+ return subprocess.run(cmd, cwd=root, capture_output=True, timeout=60).returncode
+
+ def test_generated_javadoc_sh_under_target_passes(self) -> None:
+ root = Path(tempfile.mkdtemp(prefix="cg-scan-"))
+ gen = root / "target" / "reports" / "apidocs"
+ gen.mkdir(parents=True)
+ # 与 maven-javadoc-plugin 生成的 wrapper 同构:无 shebang 的单行 javadoc 调用
+ (gen / "javadoc.sh").write_text(
+ "/usr/bin/javadoc -J-Duser.language= -J-Duser.country= @options @packages\n")
+ self.assertEqual(self._gate_exit(root), 0)
+
+ def test_no_shell_files_after_exclusion_passes(self) -> None:
+ """GNU xargs 空输入仍会执行一次 shellcheck(exit 123)——gate 必须带 -r,
+ 否则"所有 .sh 都在构建目录里"的仓在 Linux 上永久误红。"""
+ root = Path(tempfile.mkdtemp(prefix="cg-scan-"))
+ gen = root / "target" / "apidocs"
+ gen.mkdir(parents=True)
+ (gen / "javadoc.sh").write_text("/usr/bin/javadoc @options @packages\n")
+ self.assertEqual(self._gate_exit(root), 0)
+
+ def test_unshebangd_script_outside_target_still_fails(self) -> None:
+ root = Path(tempfile.mkdtemp(prefix="cg-scan-"))
+ (root / "evil.sh").write_text("echo hi\n")
+ self.assertNotEqual(self._gate_exit(root), 0)
+
+
+class SkipAuditTrailTests(unittest.TestCase):
+ """豁免审计:只有总数不够,必须能回答"哪个仓、何时"。"""
+
+ def test_record_skip_event_keeps_repo_and_timestamp(self) -> None:
+ old_home = os.environ.get("CODEGUARD_HOME")
+ os.environ["CODEGUARD_HOME"] = tempfile.mkdtemp(prefix="cg-audit-")
+ try:
+ gate_lib.record_skip_event("skipGate", Path("/somewhere/myrepo"))
+ state = json.loads(gate_lib.session_state_path().read_text(encoding="utf-8"))
+ meta = state["_skip"]
+ self.assertEqual(meta["count"], 1)
+ event = meta["events"][-1]
+ self.assertEqual(event["repo"], "myrepo")
+ self.assertEqual(event["kind"], "skipGate")
+ self.assertIn("T", event["ts"]) # ISO 形态时间戳
+ finally:
+ if old_home is None:
+ os.environ.pop("CODEGUARD_HOME", None)
+ else:
+ os.environ["CODEGUARD_HOME"] = old_home
+
+ def test_events_capped_at_20(self) -> None:
+ os.environ["CODEGUARD_HOME"] = tempfile.mkdtemp(prefix="cg-audit-")
+ for _ in range(25):
+ gate_lib.record_skip_event("skipGate", Path("/r"))
+ state = json.loads(gate_lib.session_state_path().read_text(encoding="utf-8"))
+ self.assertEqual(len(state["_skip"]["events"]), 20)
+ self.assertEqual(state["_skip"]["count"], 25) # 总数不受截断影响
+
+
+class DependencyResolutionHintTests(unittest.TestCase):
+ """java 门禁依赖解析失败必须给行动指引,而不是只留 maven 堆栈尾部。"""
+
+ def test_java_dependency_failure_gets_hint(self) -> None:
+ out = "[ERROR] Could not resolve dependencies for project io.ddd4j:x:jar:1.0"
+ hint = gate_lib._dependency_resolution_hint("java", out)
+ self.assertIsNotNone(hint)
+ self.assertIn("mvn install", hint)
+
+ def test_non_java_or_unrelated_output_gets_none(self) -> None:
+ self.assertIsNone(gate_lib._dependency_resolution_hint(
+ "shell", "Could not resolve dependencies"))
+ self.assertIsNone(gate_lib._dependency_resolution_hint("java", "BUILD SUCCESS"))
+
+
+class RegistrySeverityPinTests(unittest.TestCase):
+ """注册表契约:shellcheck gate 必须钉扎 --severity(防版本漂移回归)。"""
+
+ def test_current_registry_passes(self) -> None:
+ errors = [e for e in vlj.check(REGISTRY) if "severity" in e]
+ self.assertEqual(errors, [])
+
+ def test_unpinned_shellcheck_gate_is_rejected(self) -> None:
+ reg = {"languages": [{"id": "shell", "status": "stable",
+ "gate": ["bash", "-c", "find . -name '*.sh' -print0 | xargs -0 shellcheck"]}]}
+ errors = [e for e in vlj.check(reg) if "severity" in e]
+ self.assertTrue(any(e.startswith("shell:gate:") for e in errors))
+
+
+if __name__ == "__main__":
+ unittest.main()