From 2a551ab11af0d6d3ea159d25a0b913e4b8894764 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:08:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B1=81=E5=85=8D=E5=80=BC=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E8=A7=A3=E6=9E=90=20+=20save=20=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E9=94=81=E5=AE=9A=20+=20=E6=96=87=E6=A1=A3=E5=8F=AF=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=96=AD=E8=A8=80=E4=BA=92=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save-scope-lock-and-doc-parity: - env_skip_gate 单一解析器只认 1/true/yes(=0/false 不再静默关闭门禁), 两钩子调用点统一接入;gate_directive 文案改为与行为一致 - save 面 auto-fix 三态锁定(收束/替换/追加)+ 越界触碰显式告警集成测试 - test_doc_behavior_parity 六组双向互证(绕过词表/生态别名/点前缀/probe 形态/退出码契约) - 613 测试零新增失败;ruff 干净;openspec strict 过 --- hooks/pre_tool_git_guard.py | 3 +- hooks/user_prompt_validator.py | 3 +- .../design.md | 24 +++++ .../proposal.md | 43 +++++++++ .../specs/doc-behavior-parity/spec.md | 24 +++++ .../specs/hook-protocol/spec.md | 37 ++++++++ .../tasks.md | 27 ++++++ scripts/check_architecture.py | 2 +- scripts/codeguard/reporting.py | 5 +- scripts/codeguard/repository_policy.py | 12 +++ tests/test_doc_behavior_parity.py | 92 +++++++++++++++++++ tests/test_save_autofix_scope.py | 69 ++++++++++++++ 12 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/design.md create mode 100644 openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/proposal.md create mode 100644 openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/doc-behavior-parity/spec.md create mode 100644 openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/hook-protocol/spec.md create mode 100644 openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/tasks.md create mode 100644 tests/test_doc_behavior_parity.py create mode 100644 tests/test_save_autofix_scope.py diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 5739ece..69afcff 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -38,6 +38,7 @@ inline_skip_gate, ) from codeguard.hook_state import completed_event, record_completed_event, session_scope +from codeguard.repository_policy import env_skip_gate from detect_lang import ensure_user_path, load_user_config from gate_lib import ( # 兼容原有 Python 导入接口 check_commit_safety, @@ -116,7 +117,7 @@ def _main(payload: dict) -> int: extract_command(payload), cwd=Path(os.getcwd()), load_config=load_user_config, - bypass_env=bool(os.environ.get("CODEGUARD_SKIP_GATE")), + bypass_env=env_skip_gate(), ) for context in result.contexts: print(json.dumps({"hookSpecificOutput": { diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index 7a30818..a4397d8 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -14,6 +14,7 @@ from codeguard import prompt_application, prompt_policy from codeguard.hook_state import session_scope +from codeguard.repository_policy import env_skip_gate from detect_lang import ( # 兼容原有 Python 导入接口 LANG_COMMANDS, detect_languages, @@ -93,7 +94,7 @@ def _main(payload: dict) -> int: project_root=find_project_root(os.getcwd()), session_id=payload.get("session_id"), load_config=load_user_config, - bypass_env=bool(os.environ.get("CODEGUARD_SKIP_GATE")), + bypass_env=env_skip_gate(), ) if result.notification: notify(*result.notification) diff --git a/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/design.md b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/design.md new file mode 100644 index 0000000..ae5ec45 --- /dev/null +++ b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/design.md @@ -0,0 +1,24 @@ +## Context + +三件事同源:文档说一套、代码做一套、行为没有测试。P0 的机制修复(scoped_plan + touched_others) +已在库内但零锁定;`CODEGUARD_SKIP_GATE` 存在性判断与 git config 严格词表并存;文档矛盾家族 7 例 +全部靠人工考古发现。 + +## Goals / Non-Goals + +**Goals:** 豁免值语义统一且文案一致;save 范围行为锁定;文档可执行断言测试化。 + +**Non-Goals:** 不改 scoped_plan 的收束算法本身(已正确);不动锁内技能;不做全量文档审校 +(只钉可机器判定的断言)。 + +## Decisions + +**严格词表与 git config 对齐(1/true/yes)。** 两条豁免路径共用一份语义,`=0` 是缺陷不是特性。 +**断言双向取值。** 测试同时读文档文本与实现——只测实现的测试在文档漂移时是绿的,恰是病根。 +**save 范围锁三态 + 越界告警。** 机制已在,测试补齐即防回退;裸命令的项目级 formatter +(cargo fmt 类)追加文件参数后失败退出也不改全仓——测试覆盖追加形态即可。 + +## Risks + +- `=0` 不再豁免是行为变更:此前误依赖该行为的调用方会被拦——这是把静默关闭变显式拦截,方向正确。 +- 文档断言测试对文案措辞有轻微耦合:断言用关键词而非全文匹配,控制脆性。 diff --git a/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/proposal.md b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/proposal.md new file mode 100644 index 0000000..a102503 --- /dev/null +++ b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/proposal.md @@ -0,0 +1,43 @@ +# 2026-09-23-save-scope-lock-and-doc-parity + +## Why + +用户指令(2026-09-23):按建议优化发现的问题。收敛为三件: + +1. **save 面 auto-fix 范围零测试锁定**:P0「PostToolUse 全仓 format 静默重写无关文件」的机制面 + 已由并行批次修复(`scoped_plan` scope=save 收束单文件 + `touched_others` 显式告警),但**没有任何 + 测试锁住该行为**——回退即复发,且这类回归正是「改一个文件冒出无关 diff」的源头。 +2. **`CODEGUARD_SKIP_GATE` 真值缺陷**:`bool(os.environ.get(...))` 只判断存在性——`=0`/`=false` + 也会**静默关闭门禁**(实测),而 `git config codeguard.skipGate` 路径是严格词表解析, + 两条豁免路径值语义不一致;`gate_directive` 文案称环境变量「只对手动直调 run_check 有效 + (无法传入宿主钩子进程)」,但 `pre_tool_git_guard`/`user_prompt_validator` 都真实读取它—— + 又一例文档与行为矛盾。 +3. **文档断言无机制约束**:「文档与行为矛盾」家族本会话累计 7 例(--ecosystem java 静默失效、 + trivy 兜底未接线、LANGUAGES 双向漂移、markdown 缺 glob、绕过机制文案、…)。需要把 + **可执行的文档断言钉在行为测试上**,让矛盾在 CI 现形而不是靠会话考古。 + +## What Changes + +- `env_skip_gate()` 单一解析器:环境变量豁免只认 `1/true/yes`(与 git config 同词表), + 两个钩子调用点统一接入;`gate_directive` 文案改为与行为一致的准确表述。 +- save 面 auto-fix 范围行为以回归测试锁定三态:scan token 收束单文件、`{file}` 替换、 + 裸命令文件追加;越界触碰(format 改到别的文件)必须显式告警。 +- 新增 `tests/test_doc_behavior_parity.py`:文档中的可执行断言(绕过值词表、生态别名、 + 点前缀语义、markdown probe 形态、CVE 退出码)与真实行为逐条互证,矛盾即红。 + +## Capabilities + +### New Capabilities + +- `doc-behavior-parity`:文档可执行断言必须被测试钉在行为上,矛盾可被 CI 检出。 + +### Modified Capabilities + +无(save 范围与豁免值语义以 ADDED requirement 并入 hook-protocol)。 + +## Impact + +- `scripts/codeguard/repository_policy.py`(env_skip_gate)、`hooks/pre_tool_git_guard.py`、 + `hooks/user_prompt_validator.py`(调用点)、`scripts/codeguard/reporting.py`(文案)。 +- 新测试两个模块。行为变更:`CODEGUARD_SKIP_GATE=0`/`false` 不再豁免(此前静默关闭门禁)。 +- 发版 v0.15.4。 diff --git a/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/doc-behavior-parity/spec.md b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/doc-behavior-parity/spec.md new file mode 100644 index 0000000..c9ce2af --- /dev/null +++ b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/doc-behavior-parity/spec.md @@ -0,0 +1,24 @@ +# doc-behavior-parity:文档可执行断言的行为互证 + +## Purpose + +把文档中的可执行断言钉在行为测试上:凡是文档声明的绕过语义、命令形态、默认忽略规则与退出码 +契约,MUST 有测试与真实实现互证,使「文档与行为矛盾」在 CI 现形,而不是依赖会话考古发现。 + +## ADDED Requirements + +### Requirement: Executable documentation claims SHALL be pinned by tests + +文档中可机器判定的行为断言 MUST 有对应测试互证,至少覆盖:绕过豁免的值词表、命令行生态标识 +与别名、点前缀默认忽略语义、markdown 探活命令形态、CVE 退出码契约。断言 MUST 从文档文本与 +实现两侧取值比对(而非只测实现),使文档更新与行为变更脱节时测试变红。 + +#### Scenario: Documentation and behavior diverge + +- **WHEN** 文档中的某条可执行断言(如退出码集合、别名集合)与实现不一致 +- **THEN** 对应测试失败并点名该断言 + +#### Scenario: Documented bypass values match the parser + +- **WHEN** gate 指令文案声明的豁免值词表与 env 解析器接受的词表不一致 +- **THEN** 测试失败 diff --git a/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/hook-protocol/spec.md b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/hook-protocol/spec.md new file mode 100644 index 0000000..a0b18a4 --- /dev/null +++ b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/specs/hook-protocol/spec.md @@ -0,0 +1,37 @@ +# hook-protocol(增量):save 范围锁定与豁免值语义 + +## ADDED Requirements + +### Requirement: Save-face auto-fix SHALL stay scoped to the edited file + +PostToolUse 保存面的自动修复 MUST 只作用于被编辑的单文件:带 scan token 的 format 命令 MUST +收束到该文件;`{file}` 形态 MUST 替换为该文件;裸命令形态只追加该文件参数。formatter 仍改动 +到其它文件时,反馈 MUST 显式列出被改动的文件并要求重新读取,MUST NOT 静默携带副作用。该行为 +MUST 有回归测试锁定(收束/替换/追加三态 + 越界告警)。 + +#### Scenario: Repo-wide format command collapses to the edited file + +- **WHEN** format 命令含全仓 scan token(如 `ruff check . --fix` 形态)且保存 `src/a.py` +- **THEN** 实际执行的命令只针对 `src/a.py`,项目内其它文件不被改动 + +#### Scenario: Formatter touches files beyond the edited one + +- **WHEN** formatter 实际改动了被编辑文件之外的文件 +- **THEN** 反馈显式列出这些文件并声明内存版本已过期需重新读取 + +### Requirement: Skip-gate bypass values SHALL be parsed strictly + +环境变量 `CODEGUARD_SKIP_GATE` 的豁免判定 MUST 只认 `1`/`true`/`yes`(大小写不敏感,与 +`git config codeguard.skipGate` 的值词表一致);`0`/`false`/空串等 MUST NOT 豁免——存在性判断 +会让设 `=0` 意图保持门禁的用户**静默关闭门禁**。指令文案 MUST 与实际判定语义一致 +(说明宿主命令内联赋值不会传入钩子进程,并给出准确的值词表)。 + +#### Scenario: Zero and false values do not bypass + +- **WHEN** 环境变量值为 `0` 或 `false` 且命令含 git commit/git push +- **THEN** 门禁照常评估,不豁免 + +#### Scenario: Accepted values bypass consistently + +- **WHEN** 环境变量值为 `1`、`true` 或 `yes`(任意大小写) +- **THEN** 豁免生效并记入审计,与 git config 豁免的值词表一致 diff --git a/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/tasks.md b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/tasks.md new file mode 100644 index 0000000..af17fe6 --- /dev/null +++ b/openspec/changes/2026-09-23-save-scope-lock-and-doc-parity/tasks.md @@ -0,0 +1,27 @@ +# Tasks: 2026-09-23-save-scope-lock-and-doc-parity + +## 1. 豁免值语义与文案 + +- [x] 1.1 `repository_policy.env_skip_gate()` 单一解析器(只认 1/true/yes) +- [x] 1.2 两个钩子调用点接入(pre_tool_git_guard / user_prompt_validator) +- [x] 1.3 `gate_directive` 文案改为与行为一致(内联赋值不传入钩子 + 准确值词表) + +## 2. save 范围锁定测试 + +- [x] 2.1 scan token 收束单文件 +- [x] 2.2 `{file}` 替换形态 +- [x] 2.3 裸命令追加形态 +- [x] 2.4 越界触碰显式告警(touched_others 集成) + +## 3. 文档可执行断言测试 + +- [x] 3.1 绕过值词表(文案 ↔ 解析器) +- [x] 3.2 生态标识与别名(bin 用法 ↔ canonical_ecosystem) +- [x] 3.3 点前缀语义(AGENTS.md ↔ is_dot_prefixed) +- [x] 3.4 markdown probe 形态(文档 ↔ languages.json) +- [x] 3.5 CVE 退出码契约(README/bin ↔ EXIT_*) + +## 4. 发版 + +- [x] 4.1 全量回归 + ruff 干净 + openspec strict +- [x] 4.2 dogfood 发 v0.15.4 + PR/CI/合并 + 两仓推送 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 7453d57..60d69f4 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -46,7 +46,7 @@ "codeguard.fingerprint": {"__future__", "hashlib", "json", "os", "shutil", "stat", "pathlib", "codeguard.execution"}, "codeguard.baseline": {"__future__", "pathlib", "re", "tempfile", "codeguard.execution", "codeguard.verdict"}, - "codeguard.repository_policy": {"__future__", "pathlib", "git_snapshot", "codeguard.execution", + "codeguard.repository_policy": {"__future__", "os", "pathlib", "git_snapshot", "codeguard.execution", "codeguard.hook_state", "codeguard.path_policy"}, "codeguard.spec_validation": {"__future__", "shutil", "pathlib", "codeguard.execution", "codeguard.reporting"}, "codeguard.registry_schema": {"__future__", "re"}, diff --git a/scripts/codeguard/reporting.py b/scripts/codeguard/reporting.py index 887735e..85a0648 100644 --- a/scripts/codeguard/reporting.py +++ b/scripts/codeguard/reporting.py @@ -186,8 +186,9 @@ def gate_directive(failures: list, project_root: Path | str | None = None) -> st "3) 修复完成后重新执行用户要做的提交操作。\n" + "确需绕过(仅用户明确要求时):**单次豁免**用 `git -c codeguard.skipGate=true commit …`" "(不落配置、无残留,推荐);**仓库级豁免**在该仓库执行 git config codeguard.skipGate true," - "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " - "只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。" +"完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " +"只认 1/true/yes 且须存在于钩子进程环境——宿主命令内联赋值不会传入钩子," + "设 0/false 不豁免(该变量不建议使用,优先单次豁免)。两种豁免都会记入会话审计明细。" "注意:仓库级豁免对该克隆**所有分支**生效且跨会话残留,务必按上方说明 unset 恢复。", "─" * 60, ] diff --git a/scripts/codeguard/repository_policy.py b/scripts/codeguard/repository_policy.py index 33b6f3a..eaab8f3 100644 --- a/scripts/codeguard/repository_policy.py +++ b/scripts/codeguard/repository_policy.py @@ -1,6 +1,7 @@ """仓库路径安全与显式豁免;不处理宿主协议或语言检查。""" from __future__ import annotations +import os from pathlib import Path from git_snapshot import proposed_paths @@ -9,6 +10,17 @@ from .hook_state import record_skip_event from .path_policy import check_paths +_ENV_TRUE_VALUES = ("1", "true", "yes") + + +def env_skip_gate() -> bool: + """环境变量豁免:只认 1/true/yes(与 skip_gate_via_git_config 同值词表)。 + + 存在性判断是缺陷:CODEGUARD_SKIP_GATE=0 曾被当真值**静默关闭门禁**, + 而 config 路径是严格词表——两条豁免路径值语义必须一致。 + """ + return os.environ.get("CODEGUARD_SKIP_GATE", "").strip().lower() in _ENV_TRUE_VALUES + def check_commit_safety(project_root: Path, mode: str, *, lanes=None, extra=None, pending_commit=False) -> list[tuple[str, str, str]]: diff --git a/tests/test_doc_behavior_parity.py b/tests/test_doc_behavior_parity.py new file mode 100644 index 0000000..fa4ce07 --- /dev/null +++ b/tests/test_doc_behavior_parity.py @@ -0,0 +1,92 @@ +"""文档可执行断言的行为互证(2026-09-23 doc-behavior-parity)。 + +「文档与行为矛盾」家族累计 7 例靠人工考古发现;本模块把可机器判定的文档断言 +钉在行为测试上——断言**双向取值**(读文档文本 + 读实现),文档更新与行为脱节即红。 +""" +from __future__ import annotations + +import json +import re +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) + +from codeguard.path_policy import is_dot_prefixed +from codeguard.repository_policy import env_skip_gate + + +class BypassValueParityTests(unittest.TestCase): + def test_directive_value_vocabulary_matches_parser(self) -> None: + from codeguard import reporting + text = reporting.gate_directive([("shell", "SC2086: x", "shfmt -w .", "brew")]) + # 文案声明的值词表与解析器接受的词表互证 + self.assertRegex(text, r"1/true/yes") + for value, want in (("1", True), ("true", True), ("yes", True), + ("0", False), ("false", False), ("", False)): + with unittest.mock.patch.dict("os.environ", + {"CODEGUARD_SKIP_GATE": value}, clear=False): + self.assertEqual(env_skip_gate(), want, value) + self.assertIn("内联赋值不会传入", text) # 行为:宿主内联赋值不到钩子进程环境 + + def test_parser_matches_config_value_vocabulary(self) -> None: + # 与 config 豁免同词表:true/1/yes(大小写不敏感),其余不豁免 + for value, want in (("TRUE", True), ("Yes", True), ("no", False), ("off", False)): + with unittest.mock.patch.dict("os.environ", + {"CODEGUARD_SKIP_GATE": value}, clear=False): + self.assertEqual(env_skip_gate(), want, value) + + +class EcosystemAliasParityTests(unittest.TestCase): + def test_bin_usage_ecosystems_match_canonical_set(self) -> None: + from codeguard.cve import ECOSYSTEM_SCANNERS, canonical_ecosystem + usage = (PLUGIN / "bin" / "codeguard").read_text(encoding="utf-8") + m = re.search(r"生态: ([^\n]+)", usage) + self.assertIsNotNone(m) + named = set(re.findall(r"[a-z]+", m.group(1))) + for canonical in ECOSYSTEM_SCANNERS: + self.assertIn(canonical, named, f"bin 用法未列 {canonical}") + for word in named: + if len(word) > 2: + self.assertIsNotNone(canonical_ecosystem(word), f"bin 用法列了未声明标识 {word}") + + +class DotPrefixParityTests(unittest.TestCase): + def test_agents_md_claim_matches_predicate(self) -> None: + text = (PLUGIN / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("默认忽略", text) # AGENTS.md 硬性禁令声明 + tmp = Path(tempfile.mkdtemp()) + self.assertTrue(is_dot_prefixed(tmp / ".eslintrc.js", tmp)) + self.assertFalse(is_dot_prefixed(tmp / "src" / "a.py", tmp)) + + +class MarkdownProbeParityTests(unittest.TestCase): + def test_documented_probe_form_matches_registry(self) -> None: + registry = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + md = next(l for l in registry["languages"] if l["id"] == "markdown") + # 文档记载唯一可用探活形态为 --format(--version 不被识别、会被当 glob) + self.assertIn("--format", md.get("probe", [])) + self.assertNotIn("--version", md.get("probe", [])) + + +class CveExitCodeParityTests(unittest.TestCase): + def test_readme_and_bin_claims_match_exit_constants(self) -> None: + from codeguard import cve_policy + self.assertEqual( + (cve_policy.EXIT_PASS, cve_policy.EXIT_UNVERIFIED, + cve_policy.EXIT_FINDINGS, cve_policy.EXIT_USAGE), + (0, 1, 2, 3)) + for doc in (PLUGIN / "README.md", PLUGIN / "README.zh-CN.md", + PLUGIN / "bin" / "codeguard"): + text = doc.read_text(encoding="utf-8") + # 钉码集合 0/1/2/3 的声明存在(措辞允许差异,语义由上方常量钉住) + self.assertRegex(text, r"[`\s::]0.{0,60}3", doc.name) + self.assertRegex(text, r"[`\s::]1.{0,60}[`\s::]2", doc.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_save_autofix_scope.py b/tests/test_save_autofix_scope.py new file mode 100644 index 0000000..e86daac --- /dev/null +++ b/tests/test_save_autofix_scope.py @@ -0,0 +1,69 @@ +"""save 面 auto-fix 范围锁定(2026-09-23 save-scope-lock-and-doc-parity)。 + +P0「全仓 format 静默重写无关文件」的机制面已修(scoped_plan + touched_others), +本模块把三态行为钉死防回退:scan token 收束单文件 / {file} 替换 / 裸命令追加, +以及越界触碰的显式告警。 +""" +from __future__ import annotations + +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) + +from codeguard.planning import scoped_plan + + +class SaveScopePlanTests(unittest.TestCase): + def test_scan_token_collapses_to_edited_file(self) -> None: + plan = scoped_plan(["ruff", "check", ".", "--fix"], Path("."), + scope="save", files=["src/a.py"]) + argv = list(plan.commands[0].argv) + self.assertIn("src/a.py", argv) + self.assertNotIn(".", argv) + + def test_file_placeholder_substituted(self) -> None: + plan = scoped_plan(["clang-format", "-i", "{file}"], Path("."), + scope="save", files=["src/a.c"]) + argv = list(plan.commands[0].argv) + self.assertIn("src/a.c", argv) + self.assertNotIn("{file}", argv) + + def test_bare_command_appends_file(self) -> None: + plan = scoped_plan(["cargo", "fmt"], Path("."), scope="save", files=["src/a.rs"]) + argv = list(plan.commands[0].argv) + self.assertEqual(argv[-1], "src/a.rs") + + +class TouchedOthersTests(unittest.TestCase): + def test_formatter_touching_other_file_is_reported(self) -> None: + from codeguard.save_application import evaluate_save + + root = Path(tempfile.mkdtemp()) + subprocess.run(["git", "init", "-q"], cwd=root, capture_output=True, check=False) + subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--allow-empty", "-m", "init"], + cwd=root, capture_output=True, check=False) + target = root / "a.py" + target.write_text("x = 1\n", encoding="utf-8") + # lint 先失败(FIXED_MARKER 缺席)→ format 修复并越界建 OTHER_MARKER → 复检通过 + with patch.dict("codeguard.save_application.LANG_COMMANDS", { + "python": { + "lint": ["sh", "-c", "test -f FIXED_MARKER"], + "format": ["sh", "-c", "touch FIXED_MARKER OTHER_MARKER"], + "probe": ["true"], + } + }, clear=False), patch("codeguard.save_application.detect_language", + return_value="python"): + result = evaluate_save(str(target), root, {"auto_fix_on_save": True}) + self.assertIn("本文件之外", result.additional_context) + self.assertTrue((root / "OTHER_MARKER").exists()) + + +if __name__ == "__main__": + unittest.main()