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
3 changes: 2 additions & 1 deletion hooks/pre_tool_git_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion hooks/user_prompt_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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` 不再豁免是行为变更:此前误依赖该行为的调用方会被拦——这是把静默关闭变显式拦截,方向正确。
- 文档断言测试对文案措辞有轻微耦合:断言用关键词而非全文匹配,控制脆性。
Original file line number Diff line number Diff line change
@@ -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。
Original file line number Diff line number Diff line change
@@ -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** 测试失败
Original file line number Diff line number Diff line change
@@ -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 豁免的值词表一致
Original file line number Diff line number Diff line change
@@ -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/合并 + 两仓推送
2 changes: 1 addition & 1 deletion scripts/check_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
5 changes: 3 additions & 2 deletions scripts/codeguard/reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
Expand Down
12 changes: 12 additions & 0 deletions scripts/codeguard/repository_policy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""仓库路径安全与显式豁免;不处理宿主协议或语言检查。"""
from __future__ import annotations

import os
from pathlib import Path

from git_snapshot import proposed_paths
Expand All @@ -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]]:
Expand Down
92 changes: 92 additions & 0 deletions tests/test_doc_behavior_parity.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading