diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index c635075..0ebdd4d 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.11.0" + "ref": "v0.11.1" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.11.0", + "version": "0.11.1", "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.11.0/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.11.1/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.11.0/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.11.1/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 98c28d8..787dd51 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.11.0+codex.20260922", + "version": "0.11.1+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 692d8ec..480a10c 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.11.0", + "version": "0.11.1", "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 523c8ef..da434b7 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ AI code that passes lint on first try |---|---| | Plugin ID | `partme-codeguard-plugin` | | Hosts | ZCode, Claude Code, Codex CLI, Kimi Code | -| Current version | `0.10.1` | +| Current version | `0.11.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start | diff --git a/README.zh-CN.md b/README.zh-CN.md index a9781a4..1a2bfc7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ AI 一次写出就过 lint 的代码 |---|---| | 插件 ID | `partme-codeguard-plugin` | | 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.10.1` | +| 当前版本 | `0.11.1` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 | diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index 6c9cf5f..b3ea2d9 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -33,16 +33,15 @@ probe_toolchain, project_uses_linter, ) -from scope import changed_files, scope_cmd +from scope import FULL_SCAN_EXCLUDES, changed_files, scope_cmd # === git 提交内容安全检查:绝不该进版本库的文件 === -# 目录(路径任一段落匹配即违规):依赖/虚拟环境/构建产物/IDE/缓存 -GUARD_EXCLUDE_DIRS = { - ".venv", "venv", "env", "node_modules", "__pycache__", ".pytest_cache", - ".mypy_cache", ".ruff_cache", "target", "dist", "build", "out", ".next", - ".nuxt", ".gradle", "vendor", ".idea", ".vscode", "coverage", ".terraform", - ".tox", ".eggs", "htmlcov", ".turbo", ".parcel-cache", -} +# 目录 = 构建产物/依赖快照**单一事实源**(scope.FULL_SCAN_EXCLUDES)+ IDE 目录。 +# 从单一来源派生:清单只在 scope.py 改一处,"入库面"与"扫描面"永不漂移 +# (此前两份手抄清单已经漂移:扫描面缺 out/.next/coverage 等 14 个目录, +# 入库面缺 upstream)。路径任一段落匹配即违规;vendor 的仓根级特例在 +# check_commit_safety 里(嵌套 scripts/vendor 是第一方源码树)。 +GUARD_EXCLUDE_DIRS = set(FULL_SCAN_EXCLUDES) | {".idea", ".vscode"} # 文件名模式(fnmatch,任意层级):密钥/凭据/本地环境/系统垃圾 GUARD_EXCLUDE_FILES = [ ".env", ".env.*", "*.env", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", diff --git a/hooks/post_tool_lint.py b/hooks/post_tool_lint.py index e52fe01..0661ac2 100644 --- a/hooks/post_tool_lint.py +++ b/hooks/post_tool_lint.py @@ -27,7 +27,7 @@ project_uses_linter, ) from gate_lib import codeguard_home, session_state_path -from scope import scope_cmd # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) +from scope import is_build_artifact, scope_cmd # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) # 双副本去重:同一插件可能以多个 marketplace 副本安装(partme-ai/ 与 # full-stack-plugins/ 各一份,钩子双份触发——实测),用户级固定路径跨副本共享 @@ -186,6 +186,12 @@ def extract_file_path(payload: dict) -> str: def should_skip(file_path: str, languages: list[str]) -> tuple[bool, str]: if not file_path: return True, "" + # 构建产物静默跳过:写到 target/site、target/apidocs 的生成 HTML/sh 由 + # 构建流程负责,检查它们只会给出下次构建就被重写的假告警,AI 还可能 + # "自动修复"生成物(prettier 改完、构建一跑又变回去)。 + # 目录认知单源:scope.FULL_SCAN_EXCLUDES。 + if is_build_artifact(file_path): + return True, "" lang = detect_language(file_path) if not lang: return True, "" diff --git a/kimi.plugin.json b/kimi.plugin.json index 65d8b41..fdf9c4a 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.11.0", + "version": "0.11.1", "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/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index b2d9dec..e4e9701 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -88,7 +88,7 @@ The functions `load_user_config`, `load_project_overrides`, and `get_overrides` ### Requirement: Gates in git repositories SHALL default to changed-file scope -git 仓库内的门禁 MUST 缺省只检查**本次操作面**涉及的文件,并按语言归属过滤;存量问题 MUST NOT 阻塞无关的新提交。操作面 MUST 由操作类型决定:**commit 面**(`git commit` 前)= staged + 未暂存 + 未跟踪;**push 面**(`git push` 前)= 提交面并集**未推送提交**(`up...HEAD` 三点差;无 upstream 时按 `origin/<当前分支>`→`origin/main`→`origin/master` 逐个尝试,均不可解析则不猜测、不崩溃)。面的判定 MUST 单源:命中判定与选面共用同一套扫描,直接命令与一层解释器间接不得分叉;同时命中 commit 与 push 时 MUST 取 push 面。提示词触发的软门禁 MUST 以同一套面语义选择(推送意图选 push 面)。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。全量模式 MUST 从 ruff 扫描中剔除依赖快照与构建产物目录(vendor/build/dist 等)。门禁结果缓存 MUST 按面隔离(mode 进缓存键),同一 HEAD 下两面不得互相污染。 +git 仓库内的门禁 MUST 缺省只检查**本次操作面**涉及的文件,并按语言归属过滤;存量问题 MUST NOT 阻塞无关的新提交。操作面 MUST 由操作类型决定:**commit 面**(`git commit` 前)= staged + 未暂存 + 未跟踪;**push 面**(`git push` 前)= 提交面并集**未推送提交**(`up...HEAD` 三点差;无 upstream 时按 `origin/<当前分支>`→`origin/main`→`origin/master` 逐个尝试,均不可解析则不猜测、不崩溃)。面的判定 MUST 单源:命中判定与选面共用同一套扫描,直接命令与一层解释器间接不得分叉;同时命中 commit 与 push 时 MUST 取 push 面。提示词触发的软门禁 MUST 以同一套面语义选择(推送意图选 push 面)。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。构建产物与依赖快照 MUST 有**单一事实源清单**(`scope.FULL_SCAN_EXCLUDES`),覆盖全量与 delta 两条路径的**所有门禁族**:ruff `--exclude`、find 型 gate 的 `-not -path` 注入(`-print0`/`-exec`/无 NUL 锚三形态全覆盖)、PostToolUse 对产物路径静默跳过、`changed_files` 过滤产物路径(force-add 的 target 文件不进 delta 面);「入库面」清单 MUST 由该单一事实源派生(+IDE 目录),两侧不得各自手抄。html 门禁 MUST 以 NUL 管道传递文件清单——`-exec … {} + | xargs -0` 的换行分隔会在产物数百个时把整串路径塞进单参数(`xargs: insufficient space` 实测)。门禁结果缓存 MUST 按面隔离(mode 进缓存键),同一 HEAD 下两面不得互相污染。 #### Scenario: A committed legacy issue is untouched by a clean change diff --git a/scripts/languages.json b/scripts/languages.json index 59a923e..7e010e3 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -540,7 +540,7 @@ "gate": [ "bash", "-c", - "find . -name '*.c' -o -name '*.h' | xargs -r clang-tidy --quiet" + "find . \\( -name '*.c' -o -name '*.h' \\) | xargs -r clang-tidy --quiet" ], "linter_config_files": [ ".clang-tidy", @@ -603,7 +603,7 @@ "gate": [ "bash", "-c", - "find . -name '*.m' -o -name '*.mm' | xargs -r clang-tidy --quiet" + "find . \\( -name '*.m' -o -name '*.mm' \\) | xargs -r clang-tidy --quiet" ], "linter_config_files": [ ".clang-tidy", @@ -898,7 +898,7 @@ "gate": [ "bash", "-c", - "find . \\( -name '*.html' -o -name '*.htm' \\) -type f -not -path '*/node_modules/*' -exec grep -L '<%' {} + | xargs -0 -r npx --no-install htmlhint" + "find . \\( -name '*.html' -o -name '*.htm' \\) -type f -not -path '*/node_modules/*' -print0 | xargs -0 -r grep -L '<%' | tr '\\n' '\\0' | xargs -0 -r npx --no-install htmlhint" ], "linter_config_files": [ ".htmlhintrc" diff --git a/scripts/scope.py b/scripts/scope.py index cbe691f..f55e67f 100644 --- a/scripts/scope.py +++ b/scripts/scope.py @@ -23,27 +23,54 @@ import subprocess from pathlib import Path -# 门禁/CI 全量模式下默认剔除的目录(不可编辑的依赖快照与构建产物)。 -# 与 hooks/gate_lib.GUARD_EXCLUDE_DIRS 语义重叠但职责不同:那边管"能否入库", -# 这边管"扫不扫"。vendor 快照是供应链不可变内容,扫它只会得到"永久红"; -# 构建产物(target/ 下的 maven-javadoc javadoc.sh 等)是生成物,扫它得到的 -# 也是与仓库内容无关的"永久红"(实测:java 门禁自己生成的 javadoc.sh 让 -# shell 门禁必红——两个 gate 步骤互相矛盾)。ruff 走 --exclude, -# find 型 gate 注入 -not -path,两侧共用这一份清单。 +# 构建产物/依赖快照的**单一事实源**——"哪些目录不需要检测"由这里回答。 +# 语义边界:gate_lib.GUARD_EXCLUDE_DIRS 管"能否入库"(= 本清单 + IDE 目录, +# 从本清单派生),本清单管"扫不扫";两侧永不漂移(改这里即两侧同变)。 +# 为什么必须完备(两类实测永久红):target/ 下 maven-javadoc 生成的 +# javadoc.sh 让 shell 门禁必红(java 与 shell 两个 gate 步骤互相矛盾); +# target/site/jacoco 与 target/apidocs 的生成 HTML 让 html 门禁必红——生成物 +# 不会被"修复",下次构建就重写,扫描它们得到的永远是与仓库内容无关的红。 +# vendor/upstream 是供应链依赖快照,内容不可编辑,同理只产"永久红"。 +# 生效通道必须四条全覆盖(缺一条就漏一类门禁): +# 1) ruff --exclude(全量) +# 2) find 型 gate 注入 -not -path(-print0/-exec/无 NUL 锚三形态) +# 3) PostToolUse 对产物路径静默跳过(写 target/ 的生成物不检查) +# 4) changed_files 过滤产物路径(force-add 的 target 文件不进 delta 面) FULL_SCAN_EXCLUDES = ( - ".venv", "venv", "node_modules", "vendor", "upstream", "build", "dist", - "target", ".tox", "__pycache__", + ".venv", "venv", "env", "node_modules", "vendor", "upstream", + "build", "dist", "target", "out", ".next", ".nuxt", ".gradle", + "coverage", ".terraform", ".tox", ".eggs", "htmlcov", ".turbo", + ".parcel-cache", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", ) + + +def is_build_artifact(path: str | Path) -> bool: + """路径是否落在构建产物/依赖快照目录下(任一段命中即算)。 + + 单一事实源的谓词形态,供 PostToolUse(生成物不检查)与 changed_files + (产物不进 delta 面)复用同一份认知,避免两处各写一遍目录名。 + 注意不能用 lstrip("./")——会把 `.tox` 的点一起剥掉(实测踩点)。 + """ + parts = [seg for seg in str(path).replace("\\", "/").split("/") + if seg not in ("", ".")] + return any(seg in FULL_SCAN_EXCLUDES for seg in parts) _RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml" def _inject_find_excludes(expr: str) -> str: - """给 `find … -print0` 表达式注入构建产物目录排除(-not -path)。 - + """给 `find …` 型 gate 表达式注入构建产物目录排除(-not -path)。 + + 覆盖三种实测形态——只认一种就有整族门禁漏网: + - `find … -print0 | xargs -0 …`(shell/php/sql 等主流)→ 锚在 ` -print0` 前; + - `find … -exec … {} +`(旧 html gate 形态,曾经因此漏掉 target HTML)→ + 锚在 ` -exec` 前; + - `find . -name … | xargs …`(c/objc/cuda 无 NUL 锚)→ 锚在 + `find ` 之后(要求 languages.json 中 -o 组已加括号——否则 + `-not -path … -name a -o -name b` 的 OR 优先级会让排除形同虚设)。 幂等:已声明同类排除('*/target/*' 或 '*/target')的目录不重复注入。 - 只在首个 -print0 前插入,保持 xargs 管道段不动。 """ - if "-print0" not in expr or "find " not in expr: + if "find " not in expr: return expr additions = "".join( f" -not -path '*/{d}/*'" @@ -52,7 +79,14 @@ def _inject_find_excludes(expr: str) -> str: ) if not additions: return expr - return expr.replace(" -print0", f"{additions} -print0", 1) + import re as _re + for anchor in (" -print0", " -exec"): + if anchor in expr: + return expr.replace(anchor, additions + anchor, 1) + m = _re.search(r"find\s+\S+\s+", expr) + if m: + return expr[:m.end()] + additions[1:] + " " + expr[m.end():] + return expr def ruff_config_args(cmd: list, project_root: str | Path) -> list: @@ -209,4 +243,6 @@ def changed_files( names.add(p) if mode == "push": names.update(_unpushed_files(root)) - return sorted(names) + # 构建产物不进任何面:force-add 进索引的 target 文件、未被 gitignore 的 + # 生成物,对 linter 只是"下次构建就重写"的假红(单一事实源谓词过滤) + return sorted(n for n in names if not is_build_artifact(n)) diff --git a/tests/test_artifact_awareness.py b/tests/test_artifact_awareness.py new file mode 100644 index 0000000..9ee8fd0 --- /dev/null +++ b/tests/test_artifact_awareness.py @@ -0,0 +1,191 @@ +"""构建产物认知测试(v0.11.1):单一事实源清单 + 四条生效通道。 + +需求来源:用户实测「codeguard 得知道哪些是构建产物,不需要进行检测」。 +两类永久红实测:target/**/apidocs/javadoc.sh(java 门禁自己生成、shell 门禁 +必红)与 target/site/jacoco/*.html(html 门禁必红);html 门禁旧 `-exec … {} +` +形态在产物数百个时把整串换行路径塞进单参数(`xargs: insufficient space`)。 + +锁四件事: +1. 清单单一事实源:scope.FULL_SCAN_EXCLUDES 完备,入库面(GUARD)由它派生; +2. 注入器三形态全覆盖:-print0 / -exec / 无 NUL 锚(c 的 -o 组已加括号); +3. changed_files 过滤产物、PostToolUse 对产物路径静默跳过; +4. html 门禁改 NUL 全管道,不再存在换行→单参数的溢出面。 +""" +from __future__ import annotations + +import json +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 + +# 双副本/历史漂移中真实缺失过的产物目录(扫描面曾缺前 14 个,入库面曾缺 upstream) +CRITICAL_ARTIFACTS = ( + "target", "build", "dist", "out", "node_modules", "coverage", ".next", + ".nuxt", ".gradle", "vendor", "upstream", "env", ".venv", ".tox", + "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".eggs", + "htmlcov", ".turbo", ".parcel-cache", ".terraform", +) + + +class CanonicalListTests(unittest.TestCase): + """1) 单一事实源清单:完备 + 入库面派生不漂移。""" + + def test_critical_artifacts_all_listed(self) -> None: + missing = [d for d in CRITICAL_ARTIFACTS if d not in scope.FULL_SCAN_EXCLUDES] + self.assertEqual(missing, [], f"清单缺产物目录: {missing}") + + def test_guard_derived_from_single_source(self) -> None: + self.assertEqual( + gate_lib.GUARD_EXCLUDE_DIRS, + set(scope.FULL_SCAN_EXCLUDES) | {".idea", ".vscode"}, + "入库面必须由单一事实源派生,禁止手抄第二份", + ) + + def test_is_build_artifact_covers_forms_and_edges(self) -> None: + yes = ("target/site/jacoco/x.html", "./target/a.sh", ".tox/x", + "a/target/b.sh", "/abs/path/target/apidocs/i.html", + "node_modules/p/index.html", "upstream/dep.py") + no = ("src/main.py", "src/targeted/main.py", "docs/build-guide.md", + "my-target-notes.txt") + for p in yes: + with self.subTest(p=p): + self.assertTrue(scope.is_build_artifact(p), p) + for p in no: + with self.subTest(p=p): + self.assertFalse(scope.is_build_artifact(p), p) + + +class InjectionCoverageTests(unittest.TestCase): + """2) 注入器三形态:只认 -print0 就有整族门禁漏网(html/c 实测漏)。""" + + def test_print0_form_anchor(self) -> None: + expr = "find . -name '*.sh' -type f -print0 | xargs -0 -r shellcheck" + out = scope._inject_find_excludes(expr) + self.assertIn("-not -path '*/target/*'", out) + self.assertLess(out.index("-not -path '*/target/*'"), out.index("-print0")) + + def test_exec_form_anchor(self) -> None: + """旧 html gate 形态:无 -print0,曾在 v0.10 注入下整族漏网。""" + expr = ("find . \\( -name '*.html' \\) -type f " + "-exec grep -L '<%' {} + | xargs -0 -r htmlhint") + out = scope._inject_find_excludes(expr) + self.assertIn("-not -path '*/target/*'", out) + self.assertLess(out.index("-not -path '*/target/*'"), out.index("-exec")) + + def test_no_nul_anchor_form_after_path(self) -> None: + """c/objc 无 -print0/-exec:锚在 find 之后,且 -o 组必须带括号。""" + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + by = {l["id"]: l for l in reg["languages"]} + c_gate = " ".join(by["c"]["gate"]) + self.assertIn("\\( -name '*.c' -o -name '*.h' \\)", c_gate, + "c gate 的 -o 组必须加括号,否则注入的 -not -path 被 OR 短路") + out = scope._inject_find_excludes(c_gate) + self.assertIn("-not -path '*/target/*'", out) + self.assertLess(out.index("-not -path '*/target/*'"), out.index("\\(")) + + def test_idempotent_on_new_html_form(self) -> None: + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + html_gate = " ".join(next(l for l in reg["languages"] if l["id"] == "html")["gate"]) + once = scope._inject_find_excludes(html_gate) + twice = scope._inject_find_excludes(once) + self.assertEqual(once, twice) + + +class HtmlGatePipelineTests(unittest.TestCase): + """4) html 门禁 NUL 全管道:换行→单参数溢出面必须消失。""" + + def setUp(self) -> None: + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + self.gate = " ".join(next(l for l in reg["languages"] if l["id"] == "html")["gate"]) + + def test_nul_pipeline_end_to_end_shape(self) -> None: + self.assertIn("-print0", self.gate) + self.assertIn("xargs -0 -r grep -L '<%'", self.gate) + self.assertIn("tr '\\n' '\\0'", self.gate) + self.assertNotIn("-exec grep", self.gate, "旧 -exec 换行形态必须移除") + + def test_materialized_html_gate_excludes_target(self) -> None: + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + html_gate = " ".join(next(l for l in reg["languages"] if l["id"] == "html")["gate"]) + cmd = scope.scope_cmd(["bash", "-c", html_gate], PLUGIN, full_excludes=True) + self.assertIn("-not -path '*/target/*'", cmd[2]) + + +class ChangedFilesFilterTests(unittest.TestCase): + """3a) delta 面过滤:force-add 的产物文件不进检查集。""" + + def test_artifact_paths_filtered_from_changed_files(self) -> None: + root = Path(tempfile.mkdtemp(prefix="cg-art-")) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "src").mkdir() + (root / "src" / "main.py").write_text("x = 1\n", encoding="utf-8") + (root / "target").mkdir() + (root / "target" / "gen.sh").write_text("#!/bin/bash\nif [ $x = y ]; fi\n", + encoding="utf-8") + subprocess.run(["git", "add", "src/main.py"], cwd=root, check=True) + subprocess.run(["git", "add", "-f", "target/gen.sh"], cwd=root, check=True) + got = scope.changed_files(root, lanes=("staged",)) + self.assertEqual(got, ["src/main.py"], + f"产物必须被过滤,实际: {got}") + + def test_non_artifact_still_present(self) -> None: + root = Path(tempfile.mkdtemp(prefix="cg-art2-")) + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + (root / "a.py").write_text("x = 1\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + self.assertEqual(scope.changed_files(root, lanes=("staged",)), ["a.py"]) + + +class PostToolUseArtifactSkipTests(unittest.TestCase): + """3b) PostToolUse:写到 target/ 的生成物静默跳过(不跑 linter、零输出)。""" + + def _run_hook(self, file_path: str) -> subprocess.CompletedProcess: + import json as _json, os + env = {**os.environ, "CODEGUARD_HOME": tempfile.mkdtemp(prefix="cg-h-"), + "PYTHONIOENCODING": "utf-8"} + return subprocess.run( + [sys.executable, str(PLUGIN / "hooks" / "post_tool_lint.py")], + input=_json.dumps({"tool_name": "Write", + "tool_input": {"file_path": file_path}}), + capture_output=True, text=True, cwd=PLUGIN, env=env, timeout=120, + ) + + def test_generated_html_under_target_silently_skipped(self) -> None: + fake = str(PLUGIN / "target" / "site" / "jacoco" / "report.html") + r = self._run_hook(fake) + self.assertEqual(r.returncode, 0) + self.assertEqual(r.stdout.strip(), "", "产物跳过必须零输出") + self.assertEqual(r.stderr.strip(), "") + + def test_generated_sh_under_target_silently_skipped(self) -> None: + fake = str(PLUGIN / "target" / "reports" / "apidocs" / "javadoc.sh") + r = self._run_hook(fake) + self.assertEqual(r.returncode, 0) + self.assertEqual(r.stdout.strip(), "") + + +class SafetyDerivationTests(unittest.TestCase): + """入库面派生的连带效果:upstream/ 依赖快照不再只被扫描面认识。""" + + def test_upstream_snapshot_flagged_as_unsafe(self) -> None: + import subprocess as _sp + root = Path(tempfile.mkdtemp(prefix="cg-up-")) + _sp.run(["git", "init", "-q"], cwd=root, check=True) + (root / "upstream").mkdir() + (root / "upstream" / "dep.py").write_text("y = 2\n", encoding="utf-8") + _sp.run(["git", "add", "-A"], cwd=root, check=True) + paths = [v[0] for v in gate_lib.check_commit_safety(root, "commit")] + self.assertIn("upstream/dep.py", paths) + + +if __name__ == "__main__": + unittest.main()