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()