From 26cfb2019d452e7563e698bcb11814e4516b59af Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:50:01 +0800 Subject: [PATCH 1/6] =?UTF-8?q?chore(openspec):=20=E5=BD=92=E6=A1=A3=203?= =?UTF-8?q?=20=E4=B8=AA=E5=85=A8=E5=8B=BE=E7=A7=AF=E5=8E=8B=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gate-full-scan-excludes(11/11) / dot-prefix-default-skip(15/15) / java-gate-default-level(7/7) → archive;delta specs 并入主 specs (language-gate-commands 更新 + 新 scan-scope-policy)。 留下的 3 个真实在途不归档: - gate-toolchain-unverified(7.3 悬挂 hook 根治=宿主侧禁用旧副本) - fix-release-chain-and-gate-attribution(并行分支在勾,main 上仍 3 未勾) - refactor-codeguard-architecture(5.6 全目标审计门禁未过) 验证:openspec validate --all --strict 18/18 --- .../.openspec.yaml | 0 .../proposal.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/scan-scope-policy/spec.md | 0 .../tasks.md | 0 .../proposal.md | 0 .../specs/language-gate-commands/spec.md | 0 .../tasks.md | 0 openspec/specs/language-gate-commands/spec.md | 27 ++++++- openspec/specs/scan-scope-policy/spec.md | 71 +++++++++++++++++++ 11 files changed, 96 insertions(+), 2 deletions(-) rename openspec/changes/{ => archive}/2026-09-22-gate-full-scan-excludes/.openspec.yaml (100%) rename openspec/changes/{ => archive}/2026-09-22-gate-full-scan-excludes/proposal.md (100%) rename openspec/changes/{ => archive}/2026-09-22-gate-full-scan-excludes/tasks.md (100%) rename openspec/changes/{ => archive}/2026-09-23-dot-prefix-default-skip/proposal.md (100%) rename openspec/changes/{ => archive}/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md (100%) rename openspec/changes/{ => archive}/2026-09-23-dot-prefix-default-skip/tasks.md (100%) rename openspec/changes/{ => archive}/2026-09-23-java-gate-default-level/proposal.md (100%) rename openspec/changes/{ => archive}/2026-09-23-java-gate-default-level/specs/language-gate-commands/spec.md (100%) rename openspec/changes/{ => archive}/2026-09-23-java-gate-default-level/tasks.md (100%) create mode 100644 openspec/specs/scan-scope-policy/spec.md diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/.openspec.yaml b/openspec/changes/archive/2026-09-22-gate-full-scan-excludes/.openspec.yaml similarity index 100% rename from openspec/changes/2026-09-22-gate-full-scan-excludes/.openspec.yaml rename to openspec/changes/archive/2026-09-22-gate-full-scan-excludes/.openspec.yaml diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md b/openspec/changes/archive/2026-09-22-gate-full-scan-excludes/proposal.md similarity index 100% rename from openspec/changes/2026-09-22-gate-full-scan-excludes/proposal.md rename to openspec/changes/archive/2026-09-22-gate-full-scan-excludes/proposal.md diff --git a/openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md b/openspec/changes/archive/2026-09-22-gate-full-scan-excludes/tasks.md similarity index 100% rename from openspec/changes/2026-09-22-gate-full-scan-excludes/tasks.md rename to openspec/changes/archive/2026-09-22-gate-full-scan-excludes/tasks.md diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/proposal.md b/openspec/changes/archive/2026-09-23-dot-prefix-default-skip/proposal.md similarity index 100% rename from openspec/changes/2026-09-23-dot-prefix-default-skip/proposal.md rename to openspec/changes/archive/2026-09-23-dot-prefix-default-skip/proposal.md diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md b/openspec/changes/archive/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md similarity index 100% rename from openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md rename to openspec/changes/archive/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/tasks.md b/openspec/changes/archive/2026-09-23-dot-prefix-default-skip/tasks.md similarity index 100% rename from openspec/changes/2026-09-23-dot-prefix-default-skip/tasks.md rename to openspec/changes/archive/2026-09-23-dot-prefix-default-skip/tasks.md diff --git a/openspec/changes/2026-09-23-java-gate-default-level/proposal.md b/openspec/changes/archive/2026-09-23-java-gate-default-level/proposal.md similarity index 100% rename from openspec/changes/2026-09-23-java-gate-default-level/proposal.md rename to openspec/changes/archive/2026-09-23-java-gate-default-level/proposal.md diff --git a/openspec/changes/2026-09-23-java-gate-default-level/specs/language-gate-commands/spec.md b/openspec/changes/archive/2026-09-23-java-gate-default-level/specs/language-gate-commands/spec.md similarity index 100% rename from openspec/changes/2026-09-23-java-gate-default-level/specs/language-gate-commands/spec.md rename to openspec/changes/archive/2026-09-23-java-gate-default-level/specs/language-gate-commands/spec.md diff --git a/openspec/changes/2026-09-23-java-gate-default-level/tasks.md b/openspec/changes/archive/2026-09-23-java-gate-default-level/tasks.md similarity index 100% rename from openspec/changes/2026-09-23-java-gate-default-level/tasks.md rename to openspec/changes/archive/2026-09-23-java-gate-default-level/tasks.md diff --git a/openspec/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index c9227d5..d8ae3c0 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -2,9 +2,7 @@ ## Purpose 定义语言注册表中每个条目所声明命令的可执行性要求:`lint` / `format` / `probe` 必须能以声明的方式真正运行,需要路径或文件参数时必须以显式占位符或 glob 表达;并定义语言声明配置前置条件的能力,使「项目未接入」与「检查失败」不再混为一谈。 - ## Requirements - ### Requirement: Declared commands must be runnable as written 注册表声明的 `lint` / `format` / `probe` 命令 SHALL 能按声明形式直接执行;需要文件或路径参数的 SHALL 以 `{file}` 占位符或显式 glob 表达,不得依赖调用方补全。 @@ -139,3 +137,28 @@ PostToolUse MUST 将文件型命令限制到编辑文件;项目级命令 MUST #### Scenario: Changed API breaks an unchanged caller - **WHEN** 新修改接口导致未修改调用方报告错误 - **THEN** 保留失败结论,不自动标记存量债务 + +### Requirement: Default build-check level SHALL exclude test execution + +java 门禁的默认检查命令 MUST 跳过测试**执行**(Maven 追加 `-DskipTests`、 +Gradle 追加 `-x test`)——测试代码仍参与编译,编译错误照常拦截。门禁职责 +是提交面的编译/打包/静态正确性;测试执行由 CI 或项目显式声明承担。项目 +MUST 能通过 `codeguard.json` 的 `java.commands` 权威覆盖声明含测试执行的 +完整 verify。 + +#### Scenario: Maven default skips test execution +- **WHEN** 项目有 pom.xml 且无 `codeguard.json` `java.commands` +- **THEN** 生成的命令含 `-DskipTests` 且目标为 `verify`(测试编译仍执行) + +#### Scenario: Gradle default excludes the test task +- **WHEN** 项目有 Gradle 构建描述且无 `codeguard.json` `java.commands` +- **THEN** 生成的命令含 `-x test` + +#### Scenario: Project can opt into full verification +- **WHEN** `codeguard.json` 声明 `java.commands: [["./mvnw", "-B", "verify"]]` +- **THEN** 门禁按声明执行完整 verify(含测试执行),默认等级不再适用 + +#### Scenario: Test code still must compile +- **WHEN** 本次改动使测试源码编译失败 +- **THEN** 默认等级的 `verify` 生命周期编译测试代码,门禁照常 FAIL + diff --git a/openspec/specs/scan-scope-policy/spec.md b/openspec/specs/scan-scope-policy/spec.md new file mode 100644 index 0000000..7145293 --- /dev/null +++ b/openspec/specs/scan-scope-policy/spec.md @@ -0,0 +1,71 @@ +# scan-scope-policy Specification + +## Purpose +定义 codeguard 检查作用域的点前缀默认忽略规则:哪些路径面默认不检查、哪些例外面必须照常生效,以及该约束在提示词面的声明要求,使「宿主工具目录与配置文件不再产生与仓库内容无关的结论」成为可测试契约。 +## Requirements +### Requirement: Check and save faces MUST skip dot-prefixed paths by default + +点前缀路径的默认忽略 MUST 作用于全部检查面。相对项目根,任一路径段以 `.` 开头(`.`、`..` 段除外)的目录与文件为点前缀路径,默认忽略:不扫描、不检查、不报告。适用于 PostToolUse 保存面、提交门禁 delta 面、语言发现面,以及全量扫描的 ruff 与 `find` 型 gate 通道。项目根本身位于点前缀父目录下(如 `~/.config/proj/`)不构成点前缀命中。 + +#### Scenario: PostToolUse skips dot-prefixed files + +- **WHEN** AI 保存 `.cursor/rules.py` 或 `.eslintrc.js` +- **THEN** 保存面静默跳过,不触发 lint、不产生告警 + +#### Scenario: Delta gate face excludes dot-prefixed paths + +- **WHEN** 本次提交同时改动 `.github/workflows/ci.yml` 与 `src/main.py` +- **THEN** 门禁检查面只含 `src/main.py` + +#### Scenario: Language discovery ignores dot-prefixed files + +- **WHEN** 项目根仅有 `.eslintrc.js` 与 `main.py` +- **THEN** 发现面只计入 python,不因 `.eslintrc.js` 计入 javascript + +#### Scenario: Full-scan channels exclude dot-prefixed subtrees + +- **WHEN** 全量扫描执行 ruff 或 `find` 型 gate +- **THEN** ruff 命令带点前缀排除参数,`find` 表达式注入 `-not -path '*/.*'`,`.agents/` 等子树不产生结论 + +#### Scenario: Project under a dot-prefixed parent is not skipped + +- **WHEN** 项目根为 `~/.config/proj/` 且检查 `main.py` +- **THEN** 不因父目录点前缀跳过,正常检查 + +### Requirement: Commit safety face MUST NOT be weakened by dot-prefix skipping + +入库安全检查 MUST NOT 因点前缀忽略而放宽。点前缀默认忽略只作用于检查面。入库安全检查独立收集拟入库路径:密钥/凭据类文件模式(`.env`、`*.pem`、`.DS_Store` 等)无论点前缀与否照常拦截;点前缀目录(宿主插件清单与第一方配置)照常可入库。 + +#### Scenario: Secret files remain blocked + +- **WHEN** 拟提交 `.env` 或 `id_rsa` +- **THEN** 入库安全检查照常给出违规与修复指令 + +#### Scenario: Host manifest dirs remain committable + +- **WHEN** 拟提交 `.agents/plugins/marketplace.json` +- **THEN** 不因目录点前缀被判「不应入库」 + +### Requirement: Config discovery MUST keep matching dot-prefixed config files + +配置发现 MUST NOT 受点前缀忽略影响。linter 配置发现(`requiresConfig`、`linter_config_files` 项目级匹配)不受点前缀忽略影响:点前缀配置文件照常使语言判定为已接入。 + +#### Scenario: requiresConfig matches dot files + +- **WHEN** 项目根存在 `.markdownlint-cli2.jsonc` 且 markdown 声明 `requiresConfig` 含该文件名 +- **THEN** markdown 判定为已接入,进入正常检查流程 + +### Requirement: Prompt surfaces MUST state the constraint + +点前缀默认忽略规则 MUST 出现在提示词面:SessionStart「codeguard 项目记忆」上下文与 `AGENTS.md` 硬性禁令,且须同时声明两个例外面(入库安全照拦、配置发现照常),并有测试锚定提示词包含该声明。 + +#### Scenario: SessionStart context states the rule + +- **WHEN** SessionStart 生成项目记忆文本 +- **THEN** 文本含默认忽略声明及例外说明 + +#### Scenario: AGENTS.md hard rules state the rule + +- **WHEN** 读取 `AGENTS.md` 硬性禁令 +- **THEN** 含点前缀默认忽略条目 + From 3c0f7dddf1bbabacb1624818b5c1b822a1d33ecc Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:50:01 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=20CHANGELOG.md?= =?UTF-8?q?=EF=BC=88v0.1.0=E2=86=92v0.15.2=20=E4=B8=BB=E9=A2=98=E6=91=98?= =?UTF-8?q?=E8=A6=81=EF=BC=89+=20README=20=E5=8F=8C=E8=AF=AD=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8E=86=E5=8F=B2=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md:212 提交/60 版本按主题段落归纳(v0.15.x git 归因收敛 … v0.5.x 三宿主成型),来源标注 git 历史 + release 提交。 README(en+zh 同 commit,parity 门禁):License 段前各加 CHANGELOG.md 链接。 注:P2#15(安装示例 --ref pin)已被 v0.15.x README 重构消解—— 当前 README 为 checkout 直跑模式,无 marketplace 安装命令。 --- CHANGELOG.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ README.zh-CN.md | 2 ++ 3 files changed, 62 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6cb83b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +按版本段落提炼的主题摘要(生成于 2026-09-23,来源:git 历史 212 个提交与各 release 提交)。逐提交细节以 `git log` 与 GitHub Releases 为准;本文件按主题归纳,不逐条罗列。 + +## v0.15.x — Git 归因收敛与诊断安全 + +- **v0.15.2** 发版链 fail-loud(bump 漂移即抛错);非 Shell 间接 git 归因收紧;`hook-protocol` 归因 delta 并入主规格。 +- **v0.15.1** 诊断日志改为私有原子落盘;准确 Git 快照的 index 与 HEAD 身份复核。 +- **v0.15.0** 点前缀目录与文件默认忽略(代码 + 提示词双层硬约束)。 + +## v0.14.x — Git 操作判定与 MCP 诊断(15 个补丁系列) + +- 显式 / 多仓 / 间接 / 带前缀 Git 操作的仓库与暂存判定逐步收敛(0.14.3–0.14.6)。 +- 逐命令执行证据与 MCP 脱敏(0.14.7);诊断日志与 CLI 判定收敛(0.14.8)。 +- 门禁故障隔离与 MCP 诊断收敛(0.14.9);Git 提交链暂存时间归属(0.14.10)。 +- Git 快照批量对象身份与内容哈希校验(0.14.11);MCP 自动修复文件身份边界(0.14.12)。 +- MCP 逐项修复可信度判定收敛(0.14.13);外部检查进程有界输出护栏(0.14.14)。 +- Git 快照资源边界与覆盖层一致性(0.14.15);提交安全检查默认忽略点前缀目录。 + +## v0.13.x — 执行内核收敛 + +- **v0.13.0** 执行内核(execution kernel)重构:五类 Hook 应用层收敛统一判定内核。 + +## v0.12.x — 会话实测优化与 Java 影响分析 + +- 规则聚合计数、`java_project` executable 键、scope 边界加固(会话实测批)。 +- 默认检查等级不含测试执行(`-DskipTests` / `-x test`);ruff 版本基线钉扎。 +- 工具链不兼容归 UNVERIFIED 而非误红;JDK 兼容版本解析;mvnw 感知接入门禁。 +- 版本横幅不再前置——stderr 首行必须是门禁综述(契约测试锁定)。 + +## v0.8–v0.11 — 门禁硬化与构建产物认知 + +- v0.8.x:门禁硬化 14 项实弹审计修复;推送面补齐;判定归一化 + 安全检查范围收窄;守卫绕过闭环。 +- v0.10.x:门禁全量扫描剔除构建产物(`find` 型 gate 注入 `-not -path`);GNU `xargs` 空输入误红修复。 +- v0.11.x:构建产物认知补全——单一事实源 + 四通道生效;manifest 收敛。 +- v0.12.0:判定可信度收敛 + Java 项目影响分析(mvn/gradle 变更面)。 + +## v0.7.x — MCP 接入与双语门禁 + +- **v0.7.0** 门禁触发词词边界匹配(`pushed`/`deployment` 不误触发)+ 定向语言子集 + MCP 官方 SDK 三工具 + 失败日志落盘。 +- **v0.7.1** `pre_tool` import 规范化与版本行对齐收编。 + +## v0.6.x — 注册表契约与结构门禁 + +- **v0.6.1** `detect_lang` 三职责拆分(`paths.py` / `user_config.py`),hooks↔scripts 跨社区边清零;manifest bundle 测试动态化。 +- **v0.6.2** `run_per_language` 共享 per-language 编排(`run_check` / `fix` 去重)。 +- **v0.6.3** `hooks/__protocol__.md` 宿主契约 cheat-sheet(exit 码 / JSON / fail-open 单源化)。 +- **v0.6.4** `languages.json` 11 条 schema 校验器(自捕 `ansible` 误标 planned 的真实 bug)。 +- **v0.6.5** env_check 的 linter 配置盘点改由注册表驱动。 +- **v0.6.6** 零引用营销资产清理 + `assets/README.md`。 +- **v0.6.7** 双语 README 结构对齐门禁(标题层级/链接/版本串三断言)+ 技术方案文档改名去全角逗号。 +- **v0.6.8** `linter_config_files` 推全局(4 → 39/54 stable-beta)+ 覆盖守护测试。 + +## v0.5.x 及更早 — 三宿主插件成型 + +- v0.5.x:ZCode / Codex / Kimi 三端 manifest 收敛、发版与市场仓同步链成型。 +- v0.3–v0.4:早期技能 vendor 与分发安全边界(`safe-skill-distribution`)。 +- v0.1.x:初版三端插件。 diff --git a/README.md b/README.md index 9f16feb..d599f4b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ Tests include real temporary Git repositories, native subprocess fixtures and of Current implementation and evidence: [architecture and extension guide](docs/current-architecture.md), [refactor verification](openspec/changes/refactor-codeguard-architecture/verification.md). Earlier documents remain historical context: [verdict and Java architecture](docs/verdict-java-architecture.md), [prior verification](docs/verification-verdict-java.md), [original architecture](docs/partme-codeguard-plugin-Architecture.zh_CN.md), [roadmap](docs/technical-roadmap.zh_CN.md). +Version history: see [CHANGELOG.md](CHANGELOG.md) for release highlights by version. + ## License and privacy Apache-2.0 — [LICENSE](./LICENSE). Native build/scanning tools may access dependency registries and vulnerability databases; review [PRIVACY.md](./PRIVACY.md) and [TERMS.md](./TERMS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index a40adc5..270dfe9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -148,6 +148,8 @@ ruff check hooks scripts tests 当前实现与证据:[架构及扩展指南](docs/current-architecture.md)、[重构验证记录](openspec/changes/refactor-codeguard-architecture/verification.md)。旧文档保留为历史参考:[判定与 Java 架构](docs/verdict-java-architecture.md)、[此前验证报告](docs/verification-verdict-java.md)、[原架构](docs/partme-codeguard-plugin-Architecture.zh_CN.md)、[路线图](docs/technical-roadmap.zh_CN.md)。 +版本历史见 [CHANGELOG.md](CHANGELOG.md)(按版本段落的主题摘要)。 + ## 许可与隐私 Apache-2.0 — [LICENSE](./LICENSE)。原生构建器/扫描器可能访问依赖仓库和漏洞数据库,请查看 [PRIVACY.md](./PRIVACY.md) 与 [TERMS.md](./TERMS.md)。 From 3d18b54cfc5217408f9532496996edcb6d376893 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:52:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?chore(openspec):=20=E5=85=A8=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E5=AE=A1=E8=AE=A1=E9=80=9A=E8=BF=87=EF=BC=8C=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=20refactor-codeguard-architecture=EF=BC=885.6=20?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立会话审计四项证据(verification.md 新增审计段): 1. change strict valid 2. scripts/check_architecture.py → Architecture OK 3. 16 条 ADDED Requirement ↔ 12 组专属模块/测试映射全在位 4. run_all 144/0/0 如实披露:本机 6 个预存环境敏感 unittest 失败(无 ruff 致 F821 族、 多会话状态目录干扰 state 族),共享树对照同红证明非本批引入; 列为测试环境隔离债(后续 change:state fixture 注入隔离 STATE_DIR、 ruff 依赖测试自适应)。 in-flight 剩 2 个真实未完:gate-toolchain-unverified(宿主侧)、 fix-release-chain-and-gate-attribution(并行分支在勾)。 --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/execution-kernel/spec.md | 0 .../tasks.md | 2 +- .../verification.md | 23 + openspec/specs/execution-kernel/spec.md | 464 ++++++++++++++++++ 7 files changed, 488 insertions(+), 1 deletion(-) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/.openspec.yaml (100%) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/design.md (100%) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/proposal.md (100%) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/specs/execution-kernel/spec.md (100%) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/tasks.md (96%) rename openspec/changes/{refactor-codeguard-architecture => archive/2026-09-23-refactor-codeguard-architecture}/verification.md (98%) create mode 100644 openspec/specs/execution-kernel/spec.md diff --git a/openspec/changes/refactor-codeguard-architecture/.openspec.yaml b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/.openspec.yaml similarity index 100% rename from openspec/changes/refactor-codeguard-architecture/.openspec.yaml rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/.openspec.yaml diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/design.md similarity index 100% rename from openspec/changes/refactor-codeguard-architecture/design.md rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/design.md diff --git a/openspec/changes/refactor-codeguard-architecture/proposal.md b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/proposal.md similarity index 100% rename from openspec/changes/refactor-codeguard-architecture/proposal.md rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/proposal.md diff --git a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/specs/execution-kernel/spec.md similarity index 100% rename from openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/specs/execution-kernel/spec.md diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/tasks.md similarity index 96% rename from openspec/changes/refactor-codeguard-architecture/tasks.md rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/tasks.md index dab8a05..12f8108 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/tasks.md @@ -35,4 +35,4 @@ - [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 - [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第三十一批在 0.15.0 main 上本地 590 项单测、144 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 - [x] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十九批已发布 v0.14.15(插件 PR #64、市场 PR #19、导航 PR #20、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。宿主安装运行另行记录,未授权不得改宿主;发布证据见 verification.md。 -- [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 +- [x] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。(2026-09-23 独立会话审计,证据见 verification.md 全目标审计段) diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/verification.md similarity index 98% rename from openspec/changes/refactor-codeguard-architecture/verification.md rename to openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/verification.md index 6e4a3f4..682780b 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/verification.md @@ -322,3 +322,26 @@ - CodeGraph 沿 `save_application._failure_context → storage.write_private_text` 审计发现:保存检查的截断诊断仍直接用可预测的临时路径 `write_text`,不同于已收敛的 CLI/MCP/Git 门禁日志。四项真实文件系统测试先 RED,分别覆盖宽松 umask、预置文件链接、临时父目录链接和原子替换失败;现由统一私有原子写入处理,日志故障不改变保存反馈且不返回虚假路径。另以 `evaluate_save` 的应用层用例锁定错误反馈及私有日志路径。 - 在 0.15.0 main 上本地完整单测 **590/590、0 skipped**,真实 Hook **144/0/0**;Ruff、架构依赖门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。临时索引已同步到 143 文件、2,334 节点、5,263 边;CodeGraph 的受影响测试列表为空,不能替代实际回归。上述只证明本地契约,不证明三宿主现场、Windows、联网 CVE、大型 Java 工程或恶意并发文件系统。 - 并行的 0.15.0 源码 main `eb5b638` CI 已成功;缺失的 `v0.15.0` 注释 tag 与正式 Release 已补齐,均指向该 main 提交。第三十、三十一批自身仍须另行升级版本、PR/CI、市场同步和发布,不借 0.15.0 的证据声称交付。 + +## 全目标审计(2026-09-23,5.6 门禁) + +独立会话复核,四项证据: + +1. `openspec validate refactor-codeguard-architecture --strict` → valid。 +2. `python3 scripts/check_architecture.py` → `Architecture OK: declared core dependencies and first-party import cycles checked`。 +3. 16 条 ADDED Requirement ↔ 模块/测试映射核对:12 组专属模块/测试对全部在位 + (execution / planning / save_application / hook_state / fingerprint / gate / + language_check / java_analysis / cve / git_snapshot / git_staging / dockerfile); + 其余 4 条(进程证据、失败可观测、入口兼容、诊断原子)由 execution 与各协议 + 入口 + 既有契约测试覆盖(与任务 2.1–2.4、4.1–4.3 的证据台账一致)。 +4. `tests/run_all.py` 144/0/0。 + +本地契约边界(如实披露):本机 `python3 -m unittest discover` 存在 6 个预存 +环境敏感失败(`test_gate_application` ×2——本机未装 ruff 致 F821 输出为空; +`test_state_storage` ×4——多会话并发共享状态目录干扰),已在共享工作树对照 +复现同红,证明非本审计批引入;CI 钉扎 `ruff==0.16.8` 且状态目录隔离下为绿。 +该 6 项属测试环境隔离债(建议后续 change:state fixture 注入隔离 STATE_DIR、 +ruff 依赖测试按工具可用性自适应),不构成规格/代码不一致。 + +结论:代码 / 规格 / 证据一致,5.6 满足,change 可归档。三宿主现场安装验收 +仍按 4.3/5.5 声明独立进行,不在此豁免。 diff --git a/openspec/specs/execution-kernel/spec.md b/openspec/specs/execution-kernel/spec.md new file mode 100644 index 0000000..a249b42 --- /dev/null +++ b/openspec/specs/execution-kernel/spec.md @@ -0,0 +1,464 @@ +# execution-kernel Specification + +## Purpose +为 CLI、MCP、语言检查、Git 门禁和漏洞扫描提供一致且可追溯的外部进程证据,使工具故障不会因入口不同而丢失诊断、冒充通过或被误认成代码违规,同时保留原有用户接口。 +## Requirements +### Requirement: Tool execution SHALL preserve process evidence + +执行结果 MUST 保留实际 argv、工作目录、原始退出码、标准输出和标准错误;若外部工具输出超过明确的捕获预算,则 MUST 终止该执行并保留预算内已捕获的诊断,以独立故障标识报告 UNVERIFIED,不得伪造完整输出或 PASS。参数 MUST 以 argv 传递,不隐式增加 shell;工具是否发现代码违规由工具判定策略解释,不能由进程执行器推断。 +Git 快照使用同一有界进程边界的原始字节模式,MUST 保留 NUL 与 blob 字节且不在协议校验前解码;路径列举、大小响应及对象内容 MUST 各有明确捕获预算。任何超限或执行故障 MUST 阻止交付快照并成为 SnapshotError,不能以截断数据继续门禁。对象总量预算必须在读取 batch 内容之前由大小响应确定。 +工作树预测暂存覆盖层 MUST 以有界缓冲复制普通文件,并同时限制文件数量、单文件与累计字节数;检查期间文件超出预算、变成特殊文件或读取失败 MUST 拒绝交付临时树,不能把不完整覆盖层当准确 Git 快照。删除及文件与目录之间双向转换的预测语义必须保留,不能因覆盖路径迭代顺序不同而改变快照。 +同一次准确快照中,拟提交路径与实际物化的覆盖层 MUST 使用同一份已列举的路径集合;不得分别查询两次 Git 工作树后把不同时点的路径清单拼成一个已验证结论。 +准确快照 MUST 在交付临时树前及检查器返回后复核所用的 index/HEAD 对象列表身份与 HEAD 提交身份;提交模式的 HEAD 是暂存差异比较基线,推送模式的 HEAD 决定待推送范围,即使文件树相同也不能忽略提交变化。首次提交前须允许不存在的 HEAD,但若检查期间产生首次提交,MUST 报告 Git UNVERIFIED。若路径、模式、对象 ID 或 HEAD 提交在观察窗口内变化,MUST 报告 Git UNVERIFIED,不得把旧 blob 的检查结果称为当前拟提交内容的结论。此复核不声称工作树与 Git 引用具备原子事务语义。 + +#### Scenario: A checker exits nonzero +- **WHEN** 同一检查器经 CLI、保存 hook、Git 门禁或 CVE 执行并以非零退出 +- **THEN** 各入口获得相同的原始进程结果,再使用各自检查契约解释,不擅自改成成功 + +#### Scenario: An argument contains shell syntax +- **WHEN** argv 参数包含空格、分号或命令替换字符 +- **THEN** 它作为单个字面参数到达工具,不作为额外 shell 命令执行 + +#### Scenario: A checker exceeds the output budget +- **WHEN** 外部检查器向 stdout/stderr 持续写入超过进程执行预算的内容 +- **THEN** 执行器有界地保留两个流的已有诊断,终止该检查,返回明确的输出超限故障与非成功退出;下游不得把截断的成功前缀判为 PASS 或宣称已有完整诊断日志 + +#### Scenario: Git emits more data than the snapshot budget +- **WHEN** Git 路径列举、batch-check 或 batch 内容超过相应捕获预算,即使进程继续输出 +- **THEN** 执行器及时终止进程;快照入口报告未验证且不交付部分内容、不得据此前的成功前缀放行 + +#### Scenario: Many predicted worktree files exceed the aggregate overlay budget +- **WHEN** 每个文件均小于单文件上限,但所有拟暂存工作树文件的实际读取字节总数超过覆盖层预算 +- **THEN** 快照不交付部分临时树,门禁报告未验证,原工作树与 Git index 保持不变 + +#### Scenario: Overlay listing changes between observations +- **WHEN** Git 工作树路径列举在两次调用之间返回不同集合 +- **THEN** 单次快照只使用一次列举结果来计算 changed 与构建临时树;不能把第一份 changed 与第二份物化内容配对 + +#### Scenario: Index or HEAD changes during an exact check +- **WHEN** 已列举对象之后,同名路径被重新暂存为另一个 blob,或检查器运行期间 index/HEAD 的路径、模式、对象 ID 或 HEAD 提交发生变化(即使提交文件树相同,或首次提交刚被创建) +- **THEN** 快照不交付旧内容,或在检查器返回时将该次结论标为 Git UNVERIFIED;不得把两个时点的内容拼成 PASS,观察过程自身不改写真实 index + +#### Scenario: A predicted path is a special file +- **WHEN** 拟暂存路径在快照复制时变成 FIFO、socket 或其它非普通文件 +- **THEN** 快照拒绝该内容,即使类型检查与打开之间发生替换也不阻塞读取特殊文件,且不把它当作成功删除 + +### Requirement: Execution failures SHALL remain observable + +超时 MUST 保留已捕获输出并记录超时故障;缺失命令与权限/工作目录故障 MUST 返回明确不可验证的执行结果。非 UTF-8 输出 MUST 保留可解码文本并替换非法字节,不能让编码异常吞掉整次检查。不得捕获用户取消或任意编程异常作为成功结果。 + +#### Scenario: Timeout after useful diagnostics +- **WHEN** 检查器先输出诊断后超时 +- **THEN** 返回超时标识、124 和已有诊断,不能伪称没有输出或代码通过 + +#### Scenario: Tool cannot start +- **WHEN** 命令不存在或文件无执行权限 +- **THEN** 返回启动故障;下游 lint 结论为 UNVERIFIED 而非 FAIL 或 PASS + +#### Scenario: Invalid output encoding +- **WHEN** 检查器输出包含非法 UTF-8 字节 +- **THEN** 保留真实退出码和其余可读输出,不因解码异常丢失结果 + +### Requirement: Existing entry points SHALL remain compatible + +本次重构 MUST 保留原 CLI 子命令、四个 MCP 工具、五类 hook 的路径/JSON/退出语义、用户配置、语言注册表与外部受管技能。执行内核不得导入宿主 SDK 或修改项目文件、Git index、用户环境配置。 + +#### Scenario: Legacy script invocation +- **WHEN** 用户或 manifest 从原路径直接执行检查脚本 +- **THEN** 无需安装新包即可使用原有能力,PASS/FAIL/UNVERIFIED 不因协议转换改变 + +### Requirement: Check plans SHALL retain execution context + +检查计划 MUST 显式区分 repo、delta 与 save,保存物化后的命令、工作目录和逐命令环境覆盖。执行 MUST 按序进行,首个非零结果终止普通检查批次,保留已执行证据;未执行的命令不得计为成功。空计划不得生成 PASS。Java 原生计划给出的环境覆盖 MUST 在子进程中生效,不改变宿主环境。修复后的检查 MUST 复用原计划,不重新扩大范围。Git 门禁的逐文件基线豁免是独立策略,不由通用执行器决定。 +语言检查与修复的应用结果 MUST 保留实际运行的每条命令身份、目录、退出码、故障标识和有界输出摘要;MCP 适配不得只保留终止命令,但其公开执行轨迹 MUST 只包含阶段、序号、程序名、退出码、故障标识和输出长度,不得新增暴露原始 argv、命令环境覆盖或任意检查器输出。失败日志 MUST 保留同一检查批次中此前已执行命令在捕获预算内的全部输出,不能只记录最后一条;输出超限必须明确标识未验证,不得宣称日志完整。证据不得把尚未运行的计划命令写成已执行。 +MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出码、安全元数据和可用诊断路径,不得因复制内部修复字典而回显 formatter 原始 argv 或 stderr。内部兼容 `fixed` 只表示 formatter 命令成功退出;公开结果 MUST 将此事实单独标为 `formatter_succeeded`,公开的逐项 `fixed` 只有在内容变化可唯一归因于这一条成功执行、且复检后身份稳定时才为真。多条 formatter 执行、读取故障或已知失败后有内容变化时,不得把全局差异猜给某条成功命令。实际修复诊断仍应在可写时以私有原子日志保存;日志写入失败不得改变修复和复检结论,也不得回退为公开原文。 + +#### Scenario: Java commands use the selected JDK +- **WHEN** Java 项目计划选择 JAVA_HOME,且声明多条权威检查命令 +- **THEN** 每条实际运行的命令使用该环境;后续命令失败时返回失败而非首条成功,宿主 JAVA_HOME 不变 + +#### Scenario: Multi-command result retains prior execution evidence +- **WHEN** Java 权威计划的第一条命令输出诊断后通过,第二条命令失败,第三条未执行 +- **THEN** 内部检查结果保留前两条的 argv、目录、退出码、有界输出摘要和故障标识;MCP 返回对应的安全元数据与失败日志路径,不回显原始参数或输出;第三条不得出现于执行证据 + +#### Scenario: Configured command includes a credential argument +- **WHEN** 权威命令的 argv 含凭据文本,且检查器输出也包含该文本 +- **THEN** 真实子进程仍收到原始 argv,MCP 的 `check_code_style` 和 `auto_fix` 执行轨迹不回显凭据;日志仍按既有本地路径保存原始诊断 + +#### Scenario: Formatter stderr contains a credential +- **WHEN** `auto_fix` 的 formatter 命令及 stderr 包含凭据文本 +- **THEN** MCP 整个修复结果不包含该文本或原始命令参数;可用时返回仅当前用户可读的诊断日志路径,日志不可用时仍不泄漏原文 + +#### Scenario: A no-op formatter exits successfully +- **WHEN** formatter 退出码为 0,但修复前、修复后和复检后的目标内容身份相同 +- **THEN** `formatter_succeeded=true`,公开 `fix_results[].fixed=false`,并表明内容变化已验证 + +#### Scenario: Multiple formatters run but a global change cannot be attributed +- **WHEN** 两条 formatter 都执行,最终只有全局文件身份变化,无法可靠分配给其中一条 +- **THEN** 顶层仍可报告已观察到的变化,但逐项 `fixed` 不猜测归属,逐项明确未验证 + +#### Scenario: A failed formatter partially changes a file +- **WHEN** 唯一 formatter 退出非零但已改写目标文件 +- **THEN** 保留失败及复检证据,整体 UNVERIFIED 且不声称已确认修复 + +### Requirement: Diagnostic logs SHALL be private and atomic + +CLI/MCP 失败日志、Git 门禁与 PostToolUse 保存检查的截断诊断日志都可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入;保存 Hook 的临时日志目录若是符号链接也不得写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 + +#### Scenario: A pre-existing diagnostic log is a symlink +- **WHEN** 项目或临时目录里预置的日志文件指向另一文件,检查器随后产生失败诊断 +- **THEN** 链接目标内容保持不变,实际日志以仅当前用户可读的完整内容替换日志路径 + +#### Scenario: The project output directory is a symlink or unavailable +- **WHEN** 默认 `out` 是指向其它目录的符号链接或不可写入的路径 +- **THEN** 检查的 FAIL/UNVERIFIED 结论保持不变,不写入链接目标,也不返回虚假的 `log_path` + +#### Scenario: Repair retains the original scope +- **WHEN** delta 或 save 检查失败并成功运行 formatter +- **THEN** 复检执行原物化命令,仅包含原检查范围;被跳过的文件不导致漏掉其它文件的复检 + +### Requirement: CLI fix SHALL account for every repair outcome + +同一语言可同时产生跳过子范围与实际 formatter 结果。CLI MUST 遍历应用返回的每条结果,而不是按语言数截断;任何实际 formatter 失败或不可验证结果 MUST 不得被同语言的 SKIPPED 提示掩盖为成功。既有逐条提示与 dry-run 行为保持可见。formatter 退出码 0 仅证明命令执行成功;若 CLI 未采集修复前后内容身份,MUST NOT 将它呈现为已经确认文件发生修复。 + +#### Scenario: Mixed shell and zsh changes with a failing formatter +- **WHEN** Git 改动同时包含 `.zsh` 与 `.sh`,前者安全跳过 formatter,后者 formatter 实际运行并失败 +- **THEN** CLI 同时显示跳过说明与 formatter 失败,退出非零;不得只处理第一条 SKIPPED 结果 + +#### Scenario: Formatter exits successfully without changing a file +- **WHEN** CLI 对已规范的改动文件运行 formatter,进程退出码为 0,但内容保持不变 +- **THEN** CLI 仅报告 formatter 执行成功且文件变化未验证,不宣称 `fixed` + +#### Scenario: No executable commands +- **WHEN** 计划没有任何可执行命令 +- **THEN** 规划调用方返回 SKIPPED、PLANNED 或 UNVERIFIED;执行器拒绝将空计划解释为成功 + +### Requirement: MCP auto-fix SHALL observe changed files within a bounded safe scope + +MCP `auto_fix` 在运行 formatter 之前 MUST 验证所有拟修复路径仍位于项目根内、不是符号链接或特殊文件,并以有总量预算的流式内容身份记录修复前状态,不得整份载入任意大小文件。路径逃逸、读取故障、并发替换或超预算 MUST 明确 UNVERIFIED 且不启动修复。修复后身份无法可靠采集时 MUST 保留实际修复与复检证据,但不得声称已确认 `fixed`。`fixed` 只可归因于 formatter 已产生且复检后仍保留的目标文件变化;若复检本身改变这些文件,MUST 保留实际执行证据并将整体结果标为 UNVERIFIED,不可把检查器副作用当作已确认修复。已确认的内容或权限变化仍按既有 `fixed` 字段呈现。 + +#### Scenario: Changed path escapes through a parent symlink +- **WHEN** Git 改动路径经父目录符号链接指向仓外普通文件 +- **THEN** `auto_fix` 不读取仓外内容、不运行 formatter,返回明确 UNVERIFIED + +#### Scenario: Changed file exceeds the observation budget or changes during reading +- **WHEN** 改动文件超出身份预算、读取失败或被并发替换 +- **THEN** 修复前故障不启动 formatter;修复后故障保留已执行结果但不宣称 `fixed` + +#### Scenario: Checker changes a repair target after formatter returns +- **WHEN** formatter 返回后目标文件身份可采集,但随后的检查器又改动该文件 +- **THEN** `auto_fix` 保留修复与检查执行证据,整体标为 UNVERIFIED,`fixed` 不宣称已确认 + +### Requirement: Hook state SHALL preserve concurrent and session ownership + +统计、绕过明细、冷却与审计的读改写 MUST 在进程锁内完成并原子替换文件。宿主提供 session_id 时,统计 MUST 绑定会话与当前 worktree;Stop MUST 只消费该作用域的记录,并与并发写入互斥。无 session_id 时保留旧共享状态兼容,MUST 明示不能证明跨会话隔离。去重 MUST NOT 把未完成或 UNVERIFIED 的检查当成已完成检查。 +Stop MUST 在宿主输出成功刷新后才确认消费;输出失败时记录必须保留供重试。若确认前有并发新写入,允许后续 Stop 重复展示旧统计,但不得删除未展示的新记录。 +UserPromptSubmit 与 PostToolUse 的已完成事件、文件去重和会话统计 MUST 在相应 stdout 成功刷新后才登记;写入或刷新失败时不得把未交付反馈标记为已交付。PreToolUse 已得到拦截结论时,即使宿主输出失败也 MUST 保留拦截退出码,并且不得缓存该次未交付结果。 + +#### Scenario: Concurrent writers preserve all increments +- **WHEN** 多个 Hook 进程同时更新同一统计、绕过记录或审计日志 +- **THEN** 累计计数与保留上限内的日志项不因覆盖而丢失,读者不会看见半截 JSON + +#### Scenario: Stop only drains its own session and worktree +- **WHEN** 两个会话或两个 worktree 同时有检查记录,其中一个收到 Stop +- **THEN** 只汇总并清理当前作用域,其余记录保持可供各自 Stop 消费 + +#### Scenario: Stop output fails or state changes during delivery +- **WHEN** Stop 已准备总结但宿主 stdout 写入或刷新失败,或在输出期间有新统计写入 +- **THEN** 输出失败时旧统计可在重试中再次展示;并发新统计不得被确认消费误删,允许为了不丢记录而在后续 Stop 重复展示旧统计 + +#### Scenario: Buffered host feedback fails before delivery +- **WHEN** UserPromptSubmit 或 PostToolUse 已写入反馈,但 stdout 刷新失败 +- **THEN** 不登记已完成事件、文件去重或会话统计;下次触发仍可重新检查并交付反馈 + +#### Scenario: Hard Git block survives host output failure +- **WHEN** PreToolUse 已判定需要拦截,但 stdout 或 stderr 写入或刷新失败 +- **THEN** 保持拦截退出码且不缓存该次结果,不能因宿主输出故障改为放行 + +#### Scenario: Retry follows an unverified save check +- **WHEN** 相同文件未变化,上次保存检查未能获得结论,紧接着再次触发 +- **THEN** 再次执行检查,不因此前写入的去重标记而静默跳过 + +### Requirement: Cached observations SHALL bind their input identity + +软门禁缓存 MUST 绑定真实 worktree、Git HEAD/index 内容与推送基线、工作树文件内容、检查范围、用户配置和命令定义;不得仅用 mtime/size 推断内容相同。身份采集失败或超过预算 MUST 跳过缓存而继续检查,不能截断后复用。检查前后身份不一致、结果含未验证项或缓存格式无效时 MUST NOT 复用。硬门禁 MUST 始终从准确快照执行,不消费软缓存。保存去重同样 MUST 绑定已检查的内容与适用配置,检查期间变化不得登记成新内容已通过。 + +#### Scenario: Content changes without timestamp changes +- **WHEN** 文件内容被等长替换并恢复原 mtime +- **THEN** 下次检查不复用旧结果 + +#### Scenario: Configuration or linked worktree index changes +- **WHEN** 用户配置、项目配置或 linked worktree 的暂存内容变化 +- **THEN** 缓存身份变化,长驻进程也读取新项目配置 + +#### Scenario: Unknown result or concurrent edit +- **WHEN** 检查工具第一次未验证后恢复,或执行期间输入变化 +- **THEN** 不记录可复用完成结果,后续实际重跑检查 + +### Requirement: Gate application SHALL separate checks from host presentation + +门禁应用 MUST 不依赖 hooks 导入或宿主 SDK。单个语言检查 MUST 返回独立结果,汇总 MUST 保持输入顺序,不用共享可变备注列表连接并发检查。准确快照的审计 MUST 保留原 worktree 和会话归属,实际执行目录只作为执行上下文,不冒充项目身份。基线比较遇到未验证工具结果 MUST NOT 产生存量豁免。 +基线豁免 MUST 比较逐条诊断内容、出现次数及可识别的文件归属,且基线检查器本身 MUST 确认失败;规则码集合或仅有文本输出不足以证明当前发现已存在。基线临时文件路径可以映射回被检查的仓库相对路径,行列号移动不应单独取消同文件的存量豁免;不同文件的同文本诊断不得互相抵扣。 +单个并行语言检查抛出内部异常时,应用 MUST 将该语言标为 UNVERIFIED,保留其余语言已确认的失败和审计,不得让异常抹掉整批结论。Git 安全路径观察抛出预期的快照错误时,应用 MUST 以 additionalContext 明示安全扫描未验证,并保留已有 lint 拦截;只有没有已确认拦截时才沿用既有 fail-open 退出码。内部异常消息不得作为任意原文注入宿主上下文。 + +#### Scenario: Exact snapshot audit retains ownership +- **WHEN** 带会话 ID 的提交门禁从临时 Git 快照执行检查 +- **THEN** 审计记录归属原 worktree 和当前会话,并记录实际执行命令 + +#### Scenario: A parallel checker crashes after another language finds a violation +- **WHEN** 一种语言的 worker 抛出内部异常,另一种语言真实执行并确认违规 +- **THEN** 返回前者的 UNVERIFIED 提示、后者的失败和审计,不能因为异常把整批检查变为成功 + +#### Scenario: Safety path scan cannot read the proposed Git face +- **WHEN** 语言门禁已有结论,而安全路径扫描无法取得拟入库路径 +- **THEN** 宿主收到安全扫描 UNVERIFIED 上下文;已有失败仍拦截,无已知失败时保持既有未验证放行语义 + +#### Scenario: Baseline tool cannot verify +- **WHEN** 基线检查器输出类似违规文本但退出结果为 UNVERIFIED +- **THEN** 不因文本相似而豁免当前检查 + +#### Scenario: Baseline evidence must cover every current finding +- **WHEN** 基线检查器返回成功码却打印诊断、同一规则新增第二处违规,或规则码相同但诊断内容变化 +- **THEN** 当前违规不得获得存量豁免;只有基线自身确认失败且逐条诊断及出现次数覆盖当前结果时才能豁免 + +#### Scenario: Identical diagnostics belong to different files +- **WHEN** 当前输出将同一规则和描述归于另一文件,而基线输出归于被检查文件;或者 ShellCheck 的 `In line N` 段属于另一文件 +- **THEN** 不得以文本相同授予存量豁免;被检查文件自身仅行列号变化时仍可保留有证据的旧发现豁免 + +### Requirement: Language services SHALL have explicit input and lifetime + +语言注册表 MUST 由同一校验逻辑验证后派生识别与命令表,非法形状和重复 ID 不得静默覆盖。 +项目发现 MUST 使用调用方绑定根的配置,不因扫描到嵌套构建文件而丢失扩展名覆盖。 +项目配置不存在可以使用默认值;存在但无法解析、字段类型错误或排除正则非法时 MUST 明确报 +配置不可验证,不得假装配置不存在。未知扩展字段保留给各领域适配器,不封闭 Java 等现有扩展。 +探活 MUST 在目标项目工作目录执行、不消费宿主标准输入,并通过统一执行器处理编码和启动故障。 +成功探活只可在同一检查批次内去重;失败 MUST 可立即重试,跨批次 MUST 重新执行。 + +#### Scenario: A long-lived process observes tool recovery +- **WHEN** 探活失败后工具恢复,或下一批检查前工具环境改变 +- **THEN** 重新探活,不使用进程级永久缓存 + +#### Scenario: Nested source tree uses root override +- **WHEN** 显式项目根配置自定义扩展名,源码子目录另有构建标记 +- **THEN** 项目发现仍使用显式根的配置,深层文件可发现其最近的项目根 + +#### Scenario: Malformed project configuration +- **WHEN** codeguard.json 存在但内容非法 +- **THEN** CLI/MCP 返回明确 UNVERIFIED,Hook 保持既有 fail-open 并可见告警;不执行自动修复 + +### Requirement: Java analysis SHALL separate observation from policy + +构建解析、纯模块影响闭包、命令选择、Git 版本差异观察与 JDK 探测 MUST 有明确依赖边界。 +影响计算和默认命令选择 MUST 不读取文件、不启动进程;应用层负责验证根内路径、选择 +wrapper、装配配置与环境。CLI/MCP 的既有字典字段和只读规划语义 MUST 保留。 +Maven 默认 `-DskipTests verify`、Gradle 默认 `check -x test` 和显式 argv 权威覆盖 MUST 保留。 +纯版本升级仍可降为 validate/help,但 MUST 有已成功读取的基线和当前内容证明;依赖/插件版本、 +编译配置、脚本逻辑或无法证明差异性质时 MUST 不降级。动态构建/缺失静态边仍保守全量。 + +#### Scenario: Dependency version is not a release-only bump +- **WHEN** 仅修改 POM 中 dependency/version 或编译器配置,改动行不含 dependency 标签 +- **THEN** 仍计划 verify,不能仅靠行级关键词降为 validate + +#### Scenario: Pure project version update remains lightweight +- **WHEN** 成功读取的前后构建描述只改变项目自身版本号 +- **THEN** 保留 validate/help 优化,显式 java.commands 仍优先且原样执行 + +#### Scenario: Pure impact calculation is isolated +- **WHEN** 独立进程只加载影响与命令策略,输入包含循环依赖与无关模块 +- **THEN** 有限时间内获得完整反向闭包且不修改输入,不加载执行器、文件解析器或宿主服务 + +### Requirement: CVE scanning SHALL separate report evidence from orchestration + +五种扫描器的报告解析与阈值判定 MUST 不依赖进程、文件或宿主。扫描 IO、项目选择与修复复扫、 +CLI 呈现 MUST 各有单一所有者。只有有效报告且执行状态可解释时才能作出 PASS/FAIL;残缺或 +自相矛盾统计、非法分数、缺失发现标识、扫描错误不得伪装为零漏洞。原始退出码、输出和发现 +必须保留。缺严重度的发现遵循现有 LOW/高阈值合同,不得在调用工具时提前过滤 UNKNOWN。 +只在用户允许且本次有效报告确认需要修复时执行 npm audit fix;保留修复进程结果,最终判定 +必须来自同一项目、同一阈值的复扫,不能凭修复命令成功声明已修复。 + +#### Scenario: Malformed reports never become empty success +- **WHEN** 工具退出 0 但漏洞统计残缺、报告容器类型非法或 CVSS 分数不合法 +- **THEN** 返回 UNVERIFIED,给出解析原因,不执行自动修复 + +#### Scenario: Unknown severity remains observable +- **WHEN** 报告包含有效漏洞标识但没有可比较严重度 +- **THEN** 保留发现;LOW 可判定漏洞,其余阈值不可擅自 PASS 或推断高危 + +#### Scenario: Fix result is followed by a fresh audit +- **WHEN** npm 修复命令执行后复扫失败或返回干净报告 +- **THEN** 分别返回 UNVERIFIED 或 PASS,保留修复前报告和实际修复执行证据 + +### Requirement: Accurate Git snapshots SHALL validate object transport + +index/HEAD 对象列表 MUST 是完整的 NUL 分隔记录:每项有合法模式、对象格式对应的 ID、 +阶段/类型与非空路径,重复路径或不完整末项不得静默跳过。准确快照 MUST 用独立的 +Git 路径列举核对对象列表的路径集合;单次列表在记录边界被截断时也不能交付部分树。 +这些协议故障 MUST 归一化为快照 UNVERIFIED,而不是入口异常或空树 PASS。 +Git 准确快照 MUST 对 index/HEAD 列出的每个对象,逐项核对 `cat-file --batch-check` 与 +`--batch` 响应的对象 ID、blob 类型、非负大小、顺序和完整字节边界。响应缺项、重复项、 +内容截断或多余尾部字节 MUST 报告快照 UNVERIFIED,不得把错误对象或部分内容交给语言检查器。 +实际 payload MUST 按仓库对象格式重新计算 blob 哈希并与 index/HEAD 对象 ID 一致; +同长度内容被替换也不能通过仅核对响应头而被接受。 +大小预算 MUST 基于已验证的逐项大小;二进制内容中的换行与 NUL 必须原样保留。 +观察过程不得改写真实 Git index。此要求不改变既有 Hook 对未知结果的 fail-open/可见告警政策。 + +#### Scenario: Git batch response disagrees with the requested objects +- **WHEN** 批量读取的对象 ID、类型、大小、数量或顺序与已列出的 Git 对象不一致 +- **THEN** 准确快照不可交付,门禁报告 Git UNVERIFIED,而不是检查错位或部分内容 + +#### Scenario: Git object listing is malformed or incomplete +- **WHEN** `ls-files --stage` 或 `ls-tree -r` 缺少终止 NUL、含畸形/重复记录,或虽在完整记录边界结束却漏掉另一列举可见的路径 +- **THEN** 准确快照不可交付,门禁报告 Git UNVERIFIED;真实 index 不变 + +#### Scenario: Batch payload has an invalid boundary or identity +- **WHEN** 对象内容截断、缺少协议分隔符、尾随额外数据或同长度内容与对象哈希不符 +- **THEN** 不交付临时树,不改变真实 index;有效二进制 blob 仍可逐字节重建 + +### Requirement: Predicted staging SHALL preserve repository and pathspec scope + +拟暂存范围 MUST 绑定每个 git add 的实际仓库与工作目录,而非整条命令链的最终 cd 或全局 +路径并集。显式 pathspec MUST 交由只读 Git 查询成组解析,保留目录、glob、literal、exclude、 +引号路径及 -u/-f 的选择差异;展开后的文件名不得再作为 glob 解释。观察 MUST 不改写实际 +index 或工作树;不支持的动态/交互式形态必须明确未验证,不得冒充准确暂存面。 +仓库定位与拟暂存解析 MUST 使用同一个显式调用目录,不能一处用传入的 cwd、另一处读取 +门禁进程的全局工作目录。 +显式 `cd` 或 `git -C` 已给出目标时,若该目标不是可解析的 Git 工作树,MUST 阻断 +该 Git 副作用命令并报告目标不可确定;不得回退调用者仓或扫调用者一层子仓。 +只有命令未给出显式目标且调用目录不是 Git 仓时,才保留既有 workspace 子仓兜底策略。 +兜底为每个子仓构造的合成目标 MUST 同时用于暂存观察;不能仍以非 Git workspace 目录 +解析 commit,导致脏子仓的已确认违规降级为无阻断的未知结果。 +一条命令链触及多个仓库时,commit/push 门禁面和是否存在待提交操作 MUST 按仓库分别 +归属;A 仓的 commit 不得使 B 仓的纯 push 检查包含 B 仓尚未提交的 index。若同一仓 +同时 commit 和 push,则继续检查该仓已有待推送内容及拟提交内容,不得缩窄既有检查面。 +对于没有间接脚本或命令替换的静态命令链,拟暂存面 MUST 按同仓 Git 操作的先后顺序投影:最后一个 +commit 之后才出现的 add 不得回溯到已完成的 commit 或随后的 push;最后一个 commit 之前的 +add(包括多个 commit 之间的 add)仍须检查。无法可靠排序的一层间接脚本继续采用保守并集 +或明确 UNVERIFIED;`$(...)` 和反引号会先执行内层、再执行外层,现有静态展开无执行时钟, +也 MUST 保守合并。不得通过此优化遗漏可能提交的敏感内容。 +仓库级 `codeguard.skipGate` 只豁免设置它的仓库,不得因 A 仓豁免而跳过同链 B 仓。 +内联 `git -c codeguard.skipGate` 只豁免对应 Git 操作;链式 `git config` 设置/取消 +须按命令顺序归属到目标仓,不得把 A 仓的豁免传播到 B 仓或此前操作。写入 +其它配置文件的 `git config --file=...` 不构成当前仓库生效的豁免。 +链式配置变更只有经 `&&` 确认成功路径后才能传递到后续操作;跨越 `||`、`;` +或换行后的配置状态无法静态证明时,MUST 阻断并提示拆分命令,不得猜测已生效。 +命令切分 MUST 区分真实 Shell 分隔符和被引用/转义的字面文本;提交消息、echo 参数 +或文件路径中的 `;`、`&&` 不得生成虚假的 Git 操作或豁免设置。 +对一层可静态读取的 `bash`/`sh`/`zsh` 内联命令(含 `-lc` 等组合短选项)或脚本,Git 副作用的识别、仓库归属与 +拟暂存分析 MUST 使用脚本的实际调用目录和同一份内层命令;外层直接 Git 操作与内层操作 +不得互相覆盖。若已识别间接 Git 副作用但不能可靠建立仓库或暂存计划,MUST 报告 Git +意图 UNVERIFIED 并阻断,不得回退扫描无关子仓或以错误仓库的 PASS 放行。 +静态可解析的环境赋值、`env`、`command` 与无参数 `sudo` 等裸命令前缀 MUST 在 Shell +解释器检测、直接 Git 检测及 `skipGate` 设置/取消/内联豁免中共享同一归一化语义, +不能让同一个 `bash -c` 的提交因前缀而逃过门禁,也不能漏掉前缀后的豁免状态变更而 +错误继承持久豁免;带参数且无法静态解析的 wrapper 不在此保证范围内。 + +#### Scenario: Two repositories have different staging commands +- **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 +- **THEN** B 的未暂存及未跟踪文件不进入拟提交检查面 + +#### Scenario: Commit and push belong to different repositories +- **WHEN** 一条命令链提交 A 仓、只推送 B 仓,而 B 仓另有未提交的暂存改动 +- **THEN** A 使用 commit 面,B 使用纯 push 的 HEAD 面;B 的暂存改动不因 A 的 commit 被误纳入 + +#### Scenario: Commit and push belong to the same repository +- **WHEN** 同一仓在一条命令链中先提交再推送 +- **THEN** 门禁同时覆盖该仓拟提交 index 和已有待推送 HEAD 内容 + +#### Scenario: A bypass belongs to its repository +- **WHEN** 同链 A 仓设置了 `codeguard.skipGate`,B 仓没有豁免且包含应拦截内容 +- **THEN** 只审计并跳过 A 仓,仍对 B 仓运行完整门禁并阻断其违规内容 + +#### Scenario: Inline and chain bypasses are not global +- **WHEN** A 仓使用单次 `git -c` 或链式 `git config` 豁免,B 仓在同链执行无豁免提交 +- **THEN** 仅 A 的指定操作获得豁免,B 仓仍完整检查;同仓 unset 后的操作也不继承之前的豁免 + +#### Scenario: A conditional bypass setter cannot be assumed successful +- **WHEN** `git config codeguard.skipGate true` 后以 `||`、`;` 或换行连接提交,或设置写入其它配置文件 +- **THEN** 不把该设置当作已生效的本仓豁免;无法证明控制流时报告 UNVERIFIED 并要求拆分命令 + +#### Scenario: Quoted control characters are data +- **WHEN** `echo` 的引号参数或 commit 消息包含 `; git config codeguard.skipGate true` +- **THEN** 不产生虚假的 config 设置,随后未豁免的提交仍须检查并阻断违规内容 + +#### Scenario: Shell wrapper commit uses the invocation repository +- **WHEN** 从 Git 仓执行 `bash -c 'git commit'` 或相对路径脚本,脚本内存在待提交内容 +- **THEN** 将内层 commit 绑定到脚本调用目录的 Git 仓,而非报无仓或扫描其子仓 + +#### Scenario: A bare prefix does not hide a shell commit +- **WHEN** 可读的 `bash -c` 或脚本前有环境赋值、`env FOO=1`、`command` 或无参数 `sudo` 前缀,内层提交包含敏感暂存内容 +- **THEN** 检测、仓库归属和拟暂存分析仍使用同一内层命令,按目标仓阻断;前缀本身不构成豁免 + +#### Scenario: A bare prefix does not hide a bypass change +- **WHEN** 仓库原有 `skipGate=true`,同链先执行 `env FOO=1 git config codeguard.skipGate false` 再提交敏感文件 +- **THEN** 后续提交不继承原持久豁免,仍运行门禁并阻断;带前缀的 `git -c` 单次豁免仍仅作用于其自身操作 + +#### Scenario: Script arguments are not interpreter options +- **WHEN** 一个无 Git 副作用的脚本接收名为 `-c` 的普通参数及包含 `git commit` 的文本 +- **THEN** 解析器不得把脚本参数当成解释器 `-c` 代码并虚构 Git 副作用 + +#### Scenario: Inner shell staging remains visible +- **WHEN** 可静态读取的 shell 命令内先 `git add` 敏感文件再提交,或先切换到另一仓再提交 +- **THEN** 拟暂存分析包含内层 add 并归属实际目标仓;同链外层 Git 操作仍分别检查 + +#### Scenario: Staging crosses an interpreter boundary +- **WHEN** 独立的 shell 包装器只执行 `git add`,之后外层或另一个包装器才提交 +- **THEN** 无法证明完整拟暂存计划时报告 Git 意图 UNVERIFIED 并阻断;若 add 仅发生于纯 push 之后且无后续提交,不扩张该 push 的检查面 + +#### Scenario: Unmodelled indirect Git operation cannot borrow another repository +- **WHEN** 非 Shell 解释器脚本静态命中 Git 副作用,但无法可靠推断其执行目录与暂存动作 +- **THEN** 返回 Git 意图 UNVERIFIED,不以调用者的其它仓库或兜底子仓的结果替代 + +#### Scenario: An explicit Git target is not a repository +- **WHEN** 命令从 Git 仓调用,但在 `cd` 到非 Git 目录后运行 `git push`,或使用无效的 `git -C` 目标 +- **THEN** 门禁以明确诊断阻断,而不是检查调用者仓或用 workspace 兜底;后续显式 `git -C` 指向有效仓时仍能正确绑定该仓 + +#### Scenario: Explicit invocation directory binds staging intent +- **WHEN** 应用服务收到的调用目录与门禁进程当前目录不同,命令在调用目录中 `git add` 并提交 +- **THEN** 拟暂存路径按传入调用目录的仓与文件解析,不遗漏额外路径,也不纳入进程目录的仓 + +#### Scenario: Exclusion and literal pathspecs remain exact +- **WHEN** git add 指定包含与排除 pathspec,或字面文件名包含星号 +- **THEN** 快照只覆盖 Git 实际匹配的文件,排除项及同名 glob 邻居不会被额外暂存 + +#### Scenario: Add after the final commit is not retroactive +- **WHEN** 同仓静态命令链先提交已暂存的安全文件,再 `git add .env`,可选地随后执行 push,但没有第二次 commit +- **THEN** 门禁仍检查已提交内容和待推送 HEAD,不把之后才 add 的 `.env` 算进先前提交;真实 index 不被观察过程改写 + +#### Scenario: Add between commits remains guarded +- **WHEN** 同仓静态命令链先提交安全文件,再 `git add .env` 并进行第二次 commit +- **THEN** 门禁将 `.env` 纳入第二次拟提交内容并阻断整条工具调用 + +#### Scenario: Another repository's later commit does not extend the cutoff +- **WHEN** A 仓先 commit 后 add 敏感文件,命令链随后在 B 仓 commit +- **THEN** A 仓后置 add 不因 B 仓较晚的 commit 被倒算入 A 的提交;两仓的预测 index 均不被改写 + +#### Scenario: Command substitution stages before its outer commit +- **WHEN** 外层 `git commit -m "safe $(git add .env)"` 或反引号等价形式触发内层暂存 +- **THEN** 不按展开文本顺序将内层 add 裁成“提交后操作”;门禁仍将 `.env` 纳入拟提交面并阻断,index 不被观察过程改写 + +#### Scenario: Fallback uses its selected child repository +- **WHEN** 调用目录为非 Git workspace,兜底发现一个暂存违规的子仓及一个无提交面的子仓 +- **THEN** 暂存观察绑定前者的合成仓根,继续阻断其真实违规,且不连坐后者 + +### Requirement: Dockerfile checks SHALL preserve scope and incomplete evidence + +hadolint 与 Trivy config 的报告解析 MUST 与进程及 CLI 呈现分离。文本和 JSON MUST 使用同一 +结构化判定。超时、启动故障、无有效报告、非零无发现和自相矛盾报告 MUST 为 UNVERIFIED, +不能用空发现替代失败。逐文件原始进程结果 MUST 保留,后续工具失败不得删除已有发现。 +沿用 Dockerfile 双工具完整性策略:任一工具未验证时整体 exit 1,已确认风险仍逐项可见; +两工具均有效且有风险 exit 2,均无风险 exit 0。JSON 保留原 hadolint/trivy 字段并增加总判定。 +显式文件 MUST 只检查该文件,不扫描父目录;不存在的输入和无 Dockerfile MUST 返回可解析 +JSON 未验证。目录发现不得因根部文件数达到内部阈值而静默漏掉子目录。 + +#### Scenario: A scan produces no usable report +- **WHEN** hadolint 超时或 Trivy 输出非 JSON,即使没有可显示发现 +- **THEN** 两种 CLI 输出均返回 UNVERIFIED,而非 PASS 或漏洞 FAIL + +#### Scenario: One tool fails after another reports a risk +- **WHEN** 一部分文件/工具已返回有效风险,后续工具无法运行 +- **THEN** 保留既有发现与逐进程退出码,并将整体标记未完整验证 + +#### Scenario: Explicit file scope is preserved +- **WHEN** 输入为某个 Dockerfile,旁边还有其它 Dockerfile +- **THEN** 仅对指定文件执行两个工具,不静默扩大范围 + From 3c3dd400e879441d1091a543c3e293d959e27657 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:01:24 +0800 Subject: [PATCH 4/6] =?UTF-8?q?refactor(test):=20run=5Fall=20=E6=8B=86?= =?UTF-8?q?=E5=88=86=20harness/subsets=20+=20CI=20coverage=20=E6=8A=A5?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/run_all.py 809→63 行:夹具抽 _harness.py(ok/skip/run_hook/ make_repo)、8 个回归子集抽 _subsets.py;CLI 签名与逐字节输出不变 (golden diff 验证,唯一差异为 tempfile 随机名 5 行;子集分解 25+29+23+11+12+8+35+1=144 吻合) - CI:unittest 由 coverage 包装 + advisory 覆盖率报告步(非门禁) - CHANGELOG.md 补 v0.15.4 段(0.15.3 为未发布中间 bump 态) - README 双语:parity 门禁 test_local_link_targets_exist 抓到 W3 归档 后的 verification.md 断链,已改指 archive 路径(双语同 commit) 验证:run_all 144/0/0 · parity 6/6 · openspec strict 18/18 · diff clean --- .github/workflows/skills-check.yml | 4 +- CHANGELOG.md | 4 + README.md | 2 +- README.zh-CN.md | 2 +- tests/_harness.py | 55 +++ tests/_subsets.py | 721 +++++++++++++++++++++++++++ tests/run_all.py | 767 +---------------------------- 7 files changed, 796 insertions(+), 759 deletions(-) create mode 100644 tests/_harness.py create mode 100644 tests/_subsets.py diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml index e401571..5257bf1 100644 --- a/.github/workflows/skills-check.yml +++ b/.github/workflows/skills-check.yml @@ -28,7 +28,9 @@ jobs: - name: Verify lockfile pins still match upstream refs and content run: python3 scripts/vendor/skill_vendor.py check - name: Exercise vendor and manifest behavior - run: python3 -m unittest discover -s tests -p 'test_*.py' -v + run: python3 -m pip install --disable-pip-version-check coverage && python3 -m coverage run -m unittest discover -s tests -p 'test_*.py' -v + - name: Coverage report (advisory, non-gating) + run: python3 -m coverage report --include='scripts/**,hooks/**' --skip-empty - name: Validate languages.json schema run: python3 scripts/validate_languages_json.py - name: Run plugin regression suite diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb83b5..9722ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## v0.15.x — Git 归因收敛与诊断安全 +- **v0.15.4** 测试卫生与可见性(0.15.3 为未发布的中间 bump 态,不对外):`tests/run_all.py`(809 行)拆分为 + `_harness.py`(共享夹具)+ `_subsets.py`(8 个回归子集),入口签名与 + 逐字节输出保持不变(golden diff 验证);CI 单测改由 coverage 包装并 + 追加 advisory 覆盖率报告(非门禁)。 - **v0.15.2** 发版链 fail-loud(bump 漂移即抛错);非 Shell 间接 git 归因收紧;`hook-protocol` 归因 delta 并入主规格。 - **v0.15.1** 诊断日志改为私有原子落盘;准确 Git 快照的 index 与 HEAD 身份复核。 - **v0.15.0** 点前缀目录与文件默认忽略(代码 + 提示词双层硬约束)。 diff --git a/README.md b/README.md index d599f4b..0787ddf 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ ruff check hooks scripts tests Tests include real temporary Git repositories, native subprocess fixtures and official-SDK stdio MCP calls. Fixture wrapper success is not a real Maven/Gradle integration build. Live Codex/ZCode/Kimi loading, real project builds, online CVE scanner runs and precision/recall benchmarks require separate acceptance. -Current implementation and evidence: [architecture and extension guide](docs/current-architecture.md), [refactor verification](openspec/changes/refactor-codeguard-architecture/verification.md). Earlier documents remain historical context: [verdict and Java architecture](docs/verdict-java-architecture.md), [prior verification](docs/verification-verdict-java.md), [original architecture](docs/partme-codeguard-plugin-Architecture.zh_CN.md), [roadmap](docs/technical-roadmap.zh_CN.md). +Current implementation and evidence: [architecture and extension guide](docs/current-architecture.md), [refactor verification](openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/verification.md). Earlier documents remain historical context: [verdict and Java architecture](docs/verdict-java-architecture.md), [prior verification](docs/verification-verdict-java.md), [original architecture](docs/partme-codeguard-plugin-Architecture.zh_CN.md), [roadmap](docs/technical-roadmap.zh_CN.md). Version history: see [CHANGELOG.md](CHANGELOG.md) for release highlights by version. diff --git a/README.zh-CN.md b/README.zh-CN.md index 270dfe9..16c6b2d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -146,7 +146,7 @@ ruff check hooks scripts tests 测试包含真实临时 Git 仓库、原生子进程 fixture 与官方 SDK stdio MCP 调用。fixture wrapper 成功不是真实 Maven/Gradle 集成构建。Codex/ZCode/Kimi 当前版本加载、真实项目构建、在线漏洞扫描与准确率/召回率基准仍需独立验收。 -当前实现与证据:[架构及扩展指南](docs/current-architecture.md)、[重构验证记录](openspec/changes/refactor-codeguard-architecture/verification.md)。旧文档保留为历史参考:[判定与 Java 架构](docs/verdict-java-architecture.md)、[此前验证报告](docs/verification-verdict-java.md)、[原架构](docs/partme-codeguard-plugin-Architecture.zh_CN.md)、[路线图](docs/technical-roadmap.zh_CN.md)。 +当前实现与证据:[架构及扩展指南](docs/current-architecture.md)、[重构验证记录](openspec/changes/archive/2026-09-23-refactor-codeguard-architecture/verification.md)。旧文档保留为历史参考:[判定与 Java 架构](docs/verdict-java-architecture.md)、[此前验证报告](docs/verification-verdict-java.md)、[原架构](docs/partme-codeguard-plugin-Architecture.zh_CN.md)、[路线图](docs/technical-roadmap.zh_CN.md)。 版本历史见 [CHANGELOG.md](CHANGELOG.md)(按版本段落的主题摘要)。 diff --git a/tests/_harness.py b/tests/_harness.py new file mode 100644 index 0000000..d3c849b --- /dev/null +++ b/tests/_harness.py @@ -0,0 +1,55 @@ +"""tests/_harness.py:run_all 回归集共享夹具(ok/skip 收集、宿主协议钩子触发、临时 git 仓)。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +HOOKS = PLUGIN / "hooks" +sys.path.insert(0, str(PLUGIN / "scripts")) +sys.path.insert(0, str(HOOKS)) + +PASS, FAIL, SKIP = [], [], [] + + +def ok(name, cond, note=""): + (PASS if cond else FAIL).append(name) + print(f" {'✅' if cond else '❌'} {name}" + (f" — {note}" if note and not cond else "")) + + +def skip(name, why): + SKIP.append(name) + print(f" ⏭️ {name} — {why}") + + +def run_hook(script: str, payload: dict | None, cwd: Path, env_extra: dict | None = None): + """按宿主协议触发钩子:stdin JSON → (exit, stdout, stderr)""" + env = {**os.environ, "PYTHONIOENCODING": "utf-8", **(env_extra or {})} + return subprocess.run( + [sys.executable, str(HOOKS / script)], + input=json.dumps(payload) if payload is not None else "", + capture_output=True, check=False, text=True, cwd=cwd, timeout=120, env=env, + ) + + +def git(repo: Path, *args: str): + return subprocess.run(["git", *args], cwd=repo, capture_output=True, check=False, text=True) + + +def make_repo() -> Path: + """临时 git 仓(含一个必被 shellcheck 抓到的坏脚本)""" + repo = Path(tempfile.mkdtemp(prefix="cg-test-")) + git(repo, "init", "-q") + git(repo, "config", "user.email", "t@t") + git(repo, "config", "user.name", "t") + (repo / "scripts").mkdir() + (repo / "scripts" / "deploy.sh").write_text( + '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\n' + ) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "init") + return repo diff --git a/tests/_subsets.py b/tests/_subsets.py new file mode 100644 index 0000000..84e32ba --- /dev/null +++ b/tests/_subsets.py @@ -0,0 +1,721 @@ +"""tests/_subsets.py:run_all 的 8 个回归子集(langs/hooks/unit/edges/perf/field/cve/doc)。 + +由 tests/run_all.py 分发调用;断言输出格式与 run_all 的 ok/skip 收集契约一致。 +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from _harness import FAIL, HOOKS, PASS, PLUGIN, SKIP, git, make_repo, ok, run_hook, skip + + +# ══════════════════════════ 子集 1:语言规则结构审计 ══════════════════════════ + +def test_languages(): + print("\n[1] 语言规则结构审计(全部 stable/beta)") + langs = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8"))["languages"] + stable = [entry for entry in langs if entry.get("status") in ("stable", "beta")] + ok(f"stable/beta 数量 = {len(stable)}(≥50)", len(stable) >= 50) + + no_target, file_no_gate, npx_no_flag, npx_no_probe, bad_cfg = [], [], [], [], [] + for entry in stable: + lint = entry.get("lint") or [] + cs = " ".join(lint) + if not lint: + # format-only 语言(julia/pascal):无独立 linter,合法 + if not entry.get("format"): + no_target.append(entry["id"]) + continue + # 目标参数:{file} 占位 / 明确路径 / 项目级命令词(裸跑默认检查当前目录) + project_level = ( + lint[0] in { + "mvn", "cargo", "gradlew", "swiftlint", "dart", "tflint", "ameba", + "rubocop", "dotnet", "buf", "mix", "crystal", "elm-review", "elvis", + "ansible-lint", + } + or (len(lint) > 1 and lint[1] in {"fmt", "rock", "analyze"} and lint[0] in {"zig", "scalafmt"}) + or "markdownlint-cli2" in cs + or any(t in cs for t in (".", "*", "{file}", "src")) + ) + if not project_level: + no_target.append(f"{entry['id']}:{cs}") + if "{file}" in cs and not entry.get("gate"): + file_no_gate.append(entry["id"]) + if lint[0] == "npx": + if "--no-install" not in cs: + npx_no_flag.append(entry["id"]) + if not entry.get("probe"): + npx_no_probe.append(entry["id"]) + cfg = entry.get("requiresConfig") + if cfg is not None and (not isinstance(cfg, list) or not all(isinstance(x, str) and x for x in cfg)): + bad_cfg.append(entry["id"]) + ok("所有语言 lint 都有检查目标", not no_target, str(no_target)) + ok("{file} 单文件模式语言都有项目级 gate", not file_no_gate, str(file_no_gate)) + ok("npx 系全部 --no-install", not npx_no_flag, str(npx_no_flag)) + ok("npx 系全部有显式 probe", not npx_no_probe, str(npx_no_probe)) + ok("requiresConfig 结构合法", not bad_cfg, str(bad_cfg)) + + # 关键语言的修正是否落地 + by_id = {entry["id"]: entry for entry in stable} + for lid, field, needle in [ + ("nix", "lint", "{file}"), ("groovy", "lint", "{file}"), ("cfml", "lint", "{file}"), + ("html", "lint", "{file}"), ("markdown", "lint", "--no-install"), + ("vbnet", "lint", "--verify-no-changes"), + ("dockerfile", "gate", "hadolint"), ("sql", "gate", "sqlfluff"), + ("c", "gate", "clang-tidy"), ("html", "gate", "htmlhint"), + ]: + ok(f"{lid}.{field} 含 {needle!r}", needle in json.dumps(by_id.get(lid, {}).get(field))) + ok("rust 无 Cargo.toml 视为未接入", "Cargo.toml" in json.dumps(by_id.get("rust", {}).get("requiresConfig"))) + ok("java 无 pom/build 视为未接入", "pom.xml" in json.dumps(by_id.get("java", {}).get("requiresConfig"))) + ok("vbnet requiresConfig 用 glob (*.sln)", "*.sln" in json.dumps(by_id.get("vbnet", {}))) + ok("erlang requiresConfig(elvis.config)", "elvis.config" in json.dumps(by_id.get("erlang", {}))) + + # detect_lang 透传:gate/probe/requiresConfig 必须进 LANG_COMMANDS + from detect_lang import LANG_COMMANDS + ok("LANG_COMMANDS 透传 gate(shell)", bool(LANG_COMMANDS.get("shell", {}).get("gate"))) + ok("LANG_COMMANDS 透传 probe(html)", bool(LANG_COMMANDS.get("html", {}).get("probe"))) + ok("LANG_COMMANDS 透传 requiresConfig(typescript)", bool(LANG_COMMANDS.get("typescript", {}).get("requiresConfig"))) + + # runtime 抽查:已装工具的语言真跑一条坏样例 + if shutil.which("shellcheck"): + repo = make_repo() + # delta 门禁只看本次改动:把坏脚本重新暂存成"待提交内容", + # 存量已提交的坏文件不该再拦新提交(这正是 delta 的意义)。 + (repo / "scripts" / "deploy.sh").write_text( + '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\nif [ $a = b ]; then echo x; fi\n' + ) + git(repo, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) + ok("shell 门禁真跑:坏脚本 → exit 2", r.returncode == 2, f"exit={r.returncode}") + ok("shell 门禁 stderr 首行=综述", r.stderr.strip().splitlines()[0].startswith("codeguard ❌ 提交门禁未通过:")) + shutil.rmtree(repo) + else: + skip("shell 门禁真跑", "shellcheck 未安装") + + +# ══════════════════════════ 子集 2:对话级钩子触发 ══════════════════════════ + +def test_hooks(): + print("\n[2] 对话级钩子触发(stdin JSON 复刻宿主协议)") + + # ── SessionStart:env_check 在项目里盘点 ── + repo = make_repo() + r = run_hook("env_check.py", None, repo) + out = r.stdout + ok("SessionStart 盘点输出项目记忆", r.returncode == 0 and "# codeguard 项目记忆" in out) + ok("SessionStart 列出检测到的语言", "检测到语言" in out) + + # ── PostToolUse:坏 shell 文件 → 结构化告警(exit 0 + additionalContext) ── + r = run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(repo / "scripts" / "deploy.sh")}}, repo) + ok("PostToolUse 坏文件 exit 0(不阻断)", r.returncode == 0) + try: + ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) + add = ctx["hookSpecificOutput"]["additionalContext"] + first = add.splitlines()[0] + body = add[len(first):] + ok("PostToolUse 注入 additionalContext", "codeguard" in first) + ok("告警首行=综述(含语言与文件)", "shell" in first and "deploy.sh" in first) + ok("综述≠细节(首行未在正文重复出现)", first.strip() not in body) + ok("细节含具体问题编号", "SC" in body or "问题" in body) + ok("细节含怎么修指引", "怎么修" in body or "自动修复" in body) + ok("systemMessage 为短标题", 0 < len(ctx.get("systemMessage", "")) <= 60) + except (json.JSONDecodeError, KeyError, IndexError) as e: + ok("PostToolUse 输出为合法 JSON 协议", False, repr(e)) + + # ── PostToolUse:好文件 → 通过确认 ── + good = repo / "scripts" / "good.sh" + good.write_text('#!/bin/bash\nx="ok"\necho "$x"\n') + r = run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(good)}}, repo) + ok("PostToolUse 好文件 exit 0", r.returncode == 0) + ok("好文件注入通过确认", "✅" in r.stdout and "passed" in r.stdout) + + # ── UserPromptSubmit:提交意图(脏仓)→ 软引导注入(exit 0,prompt 不被弹回) ── + # delta 门禁只查 staged/未暂存/未跟踪:显式造一个待提交的坏改动, + # 与旧版"存量坏文件即脏"的效果对齐但语义是"新提交引入的问题"。 + (repo / "scripts" / "delta-bad.sh").write_text('#!/bin/bash\nif [ $q = w ]; then true; fi\n') + git(repo, "add", "-A") + r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, repo) + ok("提交意图 exit 0(软引导)", r.returncode == 0) + try: + ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) + add = ctx["hookSpecificOutput"]["additionalContext"] + first = add.splitlines()[0] + ok("UPS 注入首行=综述", first.startswith("codeguard ❌ 提交门禁未通过:")) + ok("UPS 细节含怎么修与绕过说明", "怎么修" in add and "skipGate" in add) + except (json.JSONDecodeError, KeyError, IndexError) as e: + ok("UPS 输出为合法 JSON 协议", False, repr(e)) + + # ── UserPromptSubmit:疑问句豁免(功能性询问不触发门禁) ── + for q in ("怎么提交代码?", "git 提交和推送能不能排除 .venv 么", "commit 是什么意思"): + r = run_hook("user_prompt_validator.py", {"user_prompt": q}, repo) + ok(f"疑问句豁免: {q[:14]}…", r.returncode == 0 and r.stdout.strip() == "", + f"exit={r.returncode} out={r.stdout[:60]!r}") + + # ── PreToolUse:git commit 脏仓 → 硬拦(exit 2 + stderr 综述/细节) ── + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) + ok("PreToolUse 脏仓 exit 2", r.returncode == 2) + lines = r.stderr.strip().splitlines() + ok("stderr 首行=综述", lines[0].startswith("codeguard ❌ 提交门禁未通过:")) + ok("综述后跟细节(标题≠详情)", len(lines) > 3 and "具体问题" in r.stderr) + ok("综述行未在细节中重复", lines.count(lines[0]) == 1) + + # ── PreToolUse:无 markdown 配置不能冒充检查通过(兼容 fail-open) ── + clean = Path(tempfile.mkdtemp(prefix="cg-clean-")) + git(clean, "init", "-q") + git(clean, "config", "user.email", "t@t") + git(clean, "config", "user.name", "t") + (clean / "README.md").write_text("ok\n") + git(clean, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, clean) + ok("未接入 linter 的仓放行但明确未验证", r.returncode == 0 and "未验证" in r.stdout, + f"exit={r.returncode} out={r.stdout[:40]!r}") + r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, clean) + ok("未接入 linter 不注入通过确认", r.returncode == 0 and "未验证" in r.stdout and "✅" not in r.stdout) + + # ── PreToolUse:安全文件(.env 入库)→ 🛑 拦截;点目录 .venv 默认忽略 ── + (repo / ".env").write_text("SECRET=1") + (repo / ".venv" / "lib").mkdir(parents=True) + (repo / ".venv" / "lib" / "x.py").write_text("x=1") + git(repo, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) + ok("安全违规 exit 2", r.returncode == 2) + ok("安全报告含 .env", ".env" in r.stderr) + ok("点目录 .venv 默认忽略(2026-09-23 策略)", ".venv" not in r.stderr) + ok("安全报告给出 rm --cached 修法", "rm --cached" in r.stderr) + + # ── PreToolUse:多 cd 命令链 → 逐 git 段解析边界 ── + ws = PLUGIN.parent + cmd = (f"cd {ws} && python3 fix.py && cd {repo} && git commit -m t && cd {clean} && git push") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": cmd}}, repo) + ok("多 cd 链不连坐 workspace(只查目标仓)", "partme" not in r.stderr and ws.name not in r.stderr.split("─")[0]) + + # ── Stop:会话统计 ── + r = run_hook("stop_summary.py", None, repo) + ok("Stop 汇总 exit 0", r.returncode == 0) + + shutil.rmtree(repo) + shutil.rmtree(clean) + + +# ══════════════════════════ 子集 3:纯函数单测 ══════════════════════════ + +def test_unit(): + print("\n[3] 纯函数单测") + import gate_lib + import pre_tool_git_guard as guard + from detect_lang import probe_toolchain, project_uses_linter + + # requiresConfig glob 支持 + tmp = Path(tempfile.mkdtemp()) + ok("glob 未命中=未接入", not project_uses_linter({"requiresConfig": ["*.sln"]}, tmp)) + (tmp / "App.sln").write_text("") + ok("glob 命中=已接入", project_uses_linter({"requiresConfig": ["*.sln"]}, tmp)) + ok("精确名匹配仍有效", project_uses_linter({"requiresConfig": ["App.sln"]}, tmp)) + ok("无 requiresConfig 视为已接入", project_uses_linter({}, tmp)) + + # probe:不存在的工具 + ok("probe 报工具不在 PATH", probe_toolchain({"lint": ["no-such-tool-xyz"]})[0] is False) + if shutil.which("shellcheck"): + ok("probe shellcheck 通过", probe_toolchain({"lint": ["shellcheck"]})[0] is True) + + # run_gate:{file} 无 gate 的语言必须归 skipped(防字面量当文件名) + from unittest.mock import patch + with patch.dict(gate_lib.LANG_COMMANDS, {"sql": { + "lint": ["sqlfluff", "lint", "{file}"], "install_hint": "pip install sqlfluff"}}): + failures, skipped = gate_lib.run_gate(Path(tmp), {}, languages=["sql"]) + ok("{file} 无 gate → skipped 不拦提交", not failures and any("gate" in s for s in skipped), + f"failures={failures}") + + # 综述/细节分离:综述≠细节 + report = gate_lib.format_failure_report([("shell", "SC2086: double quote\nSC2154: unassigned", "shfmt -w .", "brew install")]) + first, rest = report.splitlines()[0], "\n".join(report.splitlines()[1:]) + ok("综述为一行短语", 0 < len(first) <= 80 and "\n" not in first) + ok("综述行不在细节中重复", first not in rest) + ok("细节含问题与修法", "SC2086" in rest and "怎么修" in rest) + srep = gate_lib.format_safety_report([(".env", "敏感配置", "git rm --cached .env")]) + sfirst = srep.splitlines()[0] + ok("安全报告同构:首行综述不重复", sfirst not in "\n".join(srep.splitlines()[1:])) + + # 多 cd 边界解析 + roots = guard.resolve_project_roots(f"cd /tmp && x && cd {PLUGIN} && git commit -m t && cd {PLUGIN} && git push") + ok("边界=git 段前最近的 cd", PLUGIN in roots and Path("/tmp") not in roots) + from codeguard.git_context import GitTargetError + try: + guard.resolve_project_roots(f"cd {PLUGIN} && git commit -m t && cd /tmp && git push") + except GitTargetError: + invalid_target_blocked = True + else: + invalid_target_blocked = False + ok("显式非仓目标不回退调用者仓", invalid_target_blocked) + ok("无 git 段返回空", guard.resolve_project_roots("ls -la && echo done") == []) + ok("is_guarded 词法匹配不误伤 echo", not guard.is_guarded('echo "git push 是危险命令"')) + ok("is_guarded 命中真实 git push", guard.is_guarded("cd r && git push origin main")) + + # ── C2-3.4:requiresConfig 语义 —— 未接入→未验证不阻塞;已接入→真实检查 ── + import detect_lang as _dl + from gate_lib import run_gate as _run_gate + cfg_md = _dl.LANG_COMMANDS["markdown"] + cfg_yaml = _dl.LANG_COMMANDS["yaml"] + ok("markdown 已声明 requiresConfig", bool(cfg_md.get("requiresConfig"))) + ok("yaml 已声明 requiresConfig", bool(cfg_yaml.get("requiresConfig"))) + md_available = _dl.probe_toolchain(cfg_md)[0] + ok("markdown 探活语义正确(装了→可用;没装→不可用且原因含探活退出)", + md_available or "探活" in _dl.probe_toolchain(cfg_md)[1]) + bare = Path(tempfile.mkdtemp()) + try: + f1, s1 = _run_gate(bare, {}, languages=["markdown", "yaml"]) + ok("未接入→未验证且不阻塞", not f1 and any("未接入" in x for x in s1), + f"failures={f1} skipped={s1}") + (bare / ".markdownlint-cli2.jsonc").write_text("{}") + (bare / "bad.md").write_text("text \n") # 行尾空格 → MD009,配置在→真跑 + f2, s2 = _run_gate(bare, {}, languages=["markdown", "yaml"]) + if md_available: + ok("已接入→markdown 真跑(advisory 告警进 skipped)", + not f2 and any(x.startswith("markdown") and "告警" in x for x in s2), + f"failures={f2} skipped={s2}") + else: + # CI 无 markdownlint:已接入但工具缺失 → 未验证(不冒充检查过) + ok("已接入但工具缺失→未验证不阻塞", + not f2 and any(x.startswith("markdown") and "不可用" in x for x in s2), + f"failures={f2} skipped={s2}") + ok("已接入→yaml 仍未接入(无 .yamllint 配置)", + any(x.startswith("yaml") and "未接入" in x for x in s2)) + finally: + shutil.rmtree(bare, ignore_errors=True) + + # ── C2-3.5:未声明前置条件的语言不受影响(typescript 原有声明保持)── + ok("typescript 仍声明 requiresConfig(行为不变对照)", + bool(_dl.LANG_COMMANDS["typescript"].get("requiresConfig"))) + + shutil.rmtree(tmp) + + + +# ══════════════════════════ 子集 4:边界、逃生门与 CLI ══════════════════════════ + +def test_edges(): + print("\n[4] 边界、逃生门与 CLI") + import shutil as _sh + + repo = make_repo() + + # ── 逃生门:环境变量(对 PreToolUse 直调生效) ── + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo, + env_extra={"CODEGUARD_SKIP_GATE": "1"}) + ok("CODEGUARD_SKIP_GATE 环境变量逃生门", r.returncode == 0 and not r.stdout and not r.stderr) + + # ── 仓库级豁免(必须单独设置——钩子在命令执行前拦截,同链设置无效) ── + git(repo, "config", "codeguard.skipGate", "true") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) + ok("git config 仓库级豁免", r.returncode == 0 and not r.stdout and not r.stderr) + git(repo, "config", "--unset", "codeguard.skipGate") + + # ── markdown 文件不拦写作流 ── + md = repo / "README.md" + md.write_text("# 课题\n\n全是长长长长长长长长长长的一行" * 30 + "\n") + r = run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(md)}}, repo) + ok("markdown 写作流直接放行", r.returncode == 0 and r.stdout.strip() == "") + + # ── push 模式:无 upstream 的仓不崩(diff 基线缺失=无违规) ── + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}}, repo) + ok("push 无 upstream 不崩", r.returncode in (0, 2)) + + # ── 项目级 codeguard.json:自定义扩展 + exclude ── + (repo / "codeguard.json").write_text(json.dumps({ + "extensions": {".zigmod": "zig"}, + "exclude": ["scripts/deploy.sh"], + }, ensure_ascii=False)) + sys.path.insert(0, str(PLUGIN / "scripts")) + import detect_lang as dl + dl_detect = dl.detect_language(str(repo / "pkg.zigmod")) + ok("codeguard.json 扩展映射生效(.zigmod→zig)", dl_detect == "zig", + f"got={dl_detect}") + langs_detected = dl.detect_languages(repo) + ok("exclude 排除后 shell 不再是阻塞项的判定来源(detect 层生效)", + "shell" in langs_detected or True) # detect_languages 集合级,此处只验证不崩 + (repo / "codeguard.json").unlink() + + # ── Stop 汇总:有 lint 记录时输出统计 ── + run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(repo / "scripts" / "deploy.sh")}}, repo) + r = run_hook("stop_summary.py", None, repo) + ok("Stop 汇总输出统计", r.returncode == 0 and "codeguard" in (r.stdout + r.stderr)) + + # ── zig 运行时真跑:坏格式文件 → 门禁拦截 ── + if _sh.which("zig"): + zrepo = Path(tempfile.mkdtemp(prefix="cg-zig-")) + git(zrepo, "init", "-q") + git(zrepo, "config", "user.email", "t@t") + git(zrepo, "config", "user.name", "t") + (zrepo / "build.zig.zon").write_text(".\n.id = \"x\",\n\n") # 错误语法 + (zrepo / "bad.zig").write_text("const x=1;const y :i32=2;\n") + git(zrepo, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, zrepo) + ok("zig 真跑:坏格式 → exit 2 拦截", r.returncode == 2, f"exit={r.returncode}") + _zl = r.stderr.strip().splitlines() + ok("zig 拦截输出综述行", bool(_zl) and _zl[0].startswith("codeguard ❌")) + _sh.rmtree(zrepo) + else: + skip("zig 真跑", "zig 未安装") + + # ── CLI 子命令冒烟(bin/codeguard 编排) ── + cli = str(PLUGIN / "bin" / "codeguard") + if os.path.exists(cli): + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + r = subprocess.run([cli, "detect"], capture_output=True, check=False, text=True, cwd=repo, timeout=60, env=env) + ok("codeguard detect 退出", r.returncode in (0, 1), f"exit={r.returncode}") + r = subprocess.run([cli, "dockerfile"], capture_output=True, check=False, text=True, cwd=repo, timeout=90, env=env) + ok("codeguard dockerfile(无 Dockerfile→未验证=1 或通过=0)", r.returncode in (0, 1), f"exit={r.returncode}") + else: + skip("CLI 冒烟", "bin/codeguard 不存在") + + _sh.rmtree(repo) + + +# ══════════════════════════ 子集 5:实战回归(2026-09-19 真实项目踩坑) ══════════════════════════ + +def test_field_regressions(): + print("\n[5] 实战回归:EJS 误报豁免 / requiresConfig 基线 / 双副本去重 / severity") + + # ── 1. EJS 模板:vue-cli public/index.html 含 <% %>,htmlhint 三规则误报 ── + ejs = make_repo() + (ejs / "public").mkdir() + (ejs / "public" / "index.html").write_text( + "" + "<% if (process.env.VUE_APP_X) { %><%= msg %><% } %>" + "\n" + ) + # PostToolUse:EJS 文件 → 豁免误报规则后应通过(不再拦截) + r = run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(ejs / "public" / "index.html")}}, ejs) + ok("EJS 模板豁免后 lint 通过", r.returncode == 0 and "FAILED" not in r.stdout) + + # gate:EJS 文件被 grep -L '<%' 过滤,不再进 htmlhint + git(ejs, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": f"git -C {ejs} commit -m x"}}, ejs) + ok("html gate 过滤 EJS 文件(不再拦提交)", + "html" not in (r.stderr or "")) + + # ── 2. eslint 未接入项目(无任何 .eslintrc*)→ PostToolUse 静默跳过 ── + novue = make_repo() + (novue / "comp.vue").write_text("\n") + r = run_hook("post_tool_lint.py", + {"tool_name": "Write", "tool_input": {"file_path": str(novue / "comp.vue")}}, novue) + ok("vue 未接入 eslint → PostToolUse 静默(不误报)", + r.returncode == 0 and "FAILED" not in r.stdout and "lint passed" not in r.stdout) + + # gate:同样不拦提交 + git(novue, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", "tool_input": {"command": f"git -C {novue} commit -m x"}}, novue) + ok("vue gate:未接入项目不被 eslint 阻断", "vue" not in (r.stderr or "")) + + # ── 3. 双副本去重:2 秒内同文件同 mtime 的第二次触发静默 ── + dup = make_repo() + payload = {"tool_name": "Write", "tool_input": {"file_path": str(dup / "scripts" / "deploy.sh")}} + r1 = run_hook("post_tool_lint.py", payload, dup) + r2 = run_hook("post_tool_lint.py", payload, dup) + ok("第一次触发正常输出", r1.returncode == 0 and r1.stdout.strip() != "") + ok("第二次触发(同文件同 mtime)静默去重", + r2.returncode == 0 and "additionalContext" not in r2.stdout) + + # ── 4. shell severity=warning:info 级(SC2086)不再拦提交 ── + sev = make_repo() + (sev / "scripts" / "deploy.sh").write_text( + '#!/bin/bash\necho "$HOME"\n' # 干净脚本(无 shellcheck 问题) + ) + git(sev, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", + "tool_input": {"command": f"cd {sev} && git commit -m x"}}, sev) + ok("干净脚本不拦提交(severity=warning 基线)", + "shell" not in (r.stderr or "")) + + # ── 5. 真 warning 级问题仍拦截(severity 收紧不放走真问题) ── + sev2 = make_repo() + # 待提交内容必须真正不同于 HEAD:delta 门禁只查改动,与 HEAD 逐字节 + # 相同的"重新 add"等于本次无改动(拦它没有意义,git 也不会提交任何东西)。 + (sev2 / "scripts" / "deploy.sh").write_text( + '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\necho $undefined_var\n' # SC2154 warning 级 + ) + git(sev2, "add", "-A") + r = run_hook("pre_tool_git_guard.py", + {"tool_name": "Bash", + "tool_input": {"command": f"cd {sev2} && git commit -m x"}}, sev2) + ok("warning 级真问题(未定义变量)仍拦截", "shell" in (r.stderr or "")) + + # 清理 + for d in (ejs, novue, dup, sev, sev2): + shutil.rmtree(d, ignore_errors=True) + + + +# ══════════════════════════ 子集 5:性能与健壮性 ══════════════════════════ + +def test_perf(): + print("\n[5] 性能与健壮性(缓存/去重/兜底/冷却)") + + # ── 同批共享探活真实执行一次;不依赖机器上安装了哪个 npx 包 ── + from codeguard.toolchain import ToolchainProbe + with tempfile.TemporaryDirectory() as temp: + probe_root = Path(temp) + probe = ToolchainProbe(probe_root) + definition = {"probe": [sys.executable, "-c", ( + "from pathlib import Path; p=Path('count'); " + "p.write_text(p.read_text()+'x' if p.exists() else 'x')")]} + results = [probe.probe(definition) for _ in range(4)] + count = (probe_root / "count").read_text() + ok("探活去重(同批 4 语言共享工具 → 1 次执行)", + all(result[0] for result in results) and count == "x", f"实际 {len(count)} 次") + + # ── 门禁跨进程缓存:同状态 60s 内复用;index 变化即失效 ── + import gate_lib + from codeguard import gate as gate_application + repo = make_repo() + # 缓存完整的实际检查结果,而不是“没有改动未检查”的 skipped。 + (repo / "scripts" / "deploy.sh").write_text("#!/bin/bash\nprintf 'ok\\n'\n") + exec_count = [] + orig_uncached = gate_application.run_batch + def counting(root, cfg, langs, **kw): + exec_count.append(1) + return orig_uncached(root, cfg, langs, **kw) + gate_application.run_batch = counting + try: + f1, _s1 = gate_lib.run_gate(repo, {}, languages=["shell"]) + ok("缓存夹具确实完成检查而非跳过", not _s1, str(_s1)) + n_after_first = len(exec_count) + f2, _s2 = gate_lib.run_gate(repo, {}, languages=["shell"]) + ok("门禁缓存命中(第二次零执行)", len(exec_count) == n_after_first == 1, + f"执行 {len(exec_count)} 次") + ok("缓存结果一致", [x[0] for x in f1] == [x[0] for x in f2]) + # index 变化 → 键变 → 重新执行 + (repo / "scripts" / "more.sh").write_text("# ok\n") + git(repo, "add", "-A") + gate_lib.run_gate(repo, {}, languages=["shell"]) + ok("暂存区变化即缓存失效(重跑)", len(exec_count) == 2, f"执行 {len(exec_count)} 次") + finally: + gate_application.run_batch = orig_uncached + + # ── 异常兜底:钩子内部错误 fail-open(exit 0,无 traceback) ── + code = ( + "import sys, runpy, io, json\n" + "sys.argv=['hook']\n" + "sys.stdin=io.StringIO(json.dumps({'user_prompt':'提交代码'}))\n" + "sys.path.insert(0,'hooks')\n" + "sys.path.insert(0,'scripts')\n" + "from codeguard import prompt_application\n" + "def _boom(*a, **k): raise RuntimeError('injected-failure')\n" + "prompt_application.run_gate=_boom\n" + "prompt_application.skip_gate_via_git_config=lambda *a, **k: False\n" # 免疫本机 skipGate 配置 + f"runpy.run_path({str(HOOKS / 'user_prompt_validator.py')!r}, run_name='__main__')\n" + ) + r = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False, text=True, + cwd=PLUGIN, timeout=60) + ok("钩子内部错误 fail-open(exit 0)", r.returncode == 0, f"exit={r.returncode}") + ok("无 traceback 进输出", "Traceback" not in (r.stdout + r.stderr)) + ok("有一行降级说明", "fail-open" in r.stderr or "内部错误" in r.stderr) + + # ── PATH 继承缓存:第二次不再 spawn 登录 shell ── + from paths import ensure_user_path + spawns = [] + real_run = subprocess.run + def counting_run(cmd, *a, **kw): + if cmd and cmd[0].endswith(("zsh", "bash")) and "-lc" in cmd: + spawns.append(1) + return real_run(cmd, *a, **kw) + subprocess.run = counting_run + try: + ensure_user_path(from_login_shell=True) + n1 = len(spawns) + ensure_user_path(from_login_shell=True) + ok("PATH 缓存:第二次零 spawn", len(spawns) == n1, f"spawn {len(spawns)} 次") + finally: + subprocess.run = real_run + + # ── 通知冷却:同语言 60s 内只弹一次 ── + import importlib + + import post_tool_lint as ptl + importlib.reload(ptl) + tmp_state = Path(tempfile.mkdtemp()) / ".session_state.json" + ptl.STATE_FILE = tmp_state + ok("通知冷却:首次放行", ptl._notify_cooldown_ok("shell") is True) + ok("通知冷却:60s 内抑制", ptl._notify_cooldown_ok("shell") is False) + ok("通知冷却:其他语言不受影响", ptl._notify_cooldown_ok("python") is True) + + import shutil as _sh2 + _sh2.rmtree(repo, ignore_errors=True) + + +# ══════════════════════ 子集 6:CVE 生态标识与参数校验 ══════════════════════ + +def run_cve(args: list[str], cwd: Path): + """按 CLI 契约调用 codeguard cve:返回 (exit, stdout, stderr)""" + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + return subprocess.run( + [sys.executable, str(PLUGIN / "scripts" / "cve_check.py"), *args], + capture_output=True, text=True, cwd=cwd, timeout=120, env=env, check=False, + ) + + +def test_cve(): + print("\n[6] CVE 生态标识与参数校验") + import cve_check as cve + from codeguard import cve as cve_app + from codeguard import cve_scanners + + tmp = Path(tempfile.mkdtemp()) + + # 权威映射结构:每条自带全部要素;别名不得与规范标识冲突或彼此重复 + ok("映射条目含全部要素", + all({"aliases", "languages", "markers", "scan"} <= set(spec) + for spec in cve.ECOSYSTEM_SCANNERS.values())) + aliases = [a for spec in cve.ECOSYSTEM_SCANNERS.values() for a in spec["aliases"]] + conflicts = [a for a in aliases if a in cve.ECOSYSTEM_SCANNERS] + ok("别名不冲突且不重复", not conflicts and len(aliases) == len(set(aliases)), + f"{conflicts} {aliases}") + + # 1.3:文档化的 java 必须归一到 maven(修复前它被派发 else 静默丢弃) + ok("java 归一到 maven", cve.canonical_ecosystem("java") == "maven") + ok("trivy 归一到 universal", cve.canonical_ecosystem("trivy") == "universal") + ok("规范标识自身可解析", cve.canonical_ecosystem("python") == "python") + ok("大小写不敏感", cve.canonical_ecosystem("JAVA") == "maven") + ok("未声明标识返回 None", cve.canonical_ecosystem("go") is None) + ok("可接受值含别名", "java" in cve.ecosystem_choices() and "trivy" in cve.ecosystem_choices()) + + # 1.2:前置条件从权威映射派生,独立死表已移除 + ok("死表 ECOSYSTEM_PRECHECK 已移除", not hasattr(cve, "ECOSYSTEM_PRECHECK")) + ok("缺标志文件→不可扫描", cve.precheck(tmp, "rust")[0] is False) + (tmp / "Cargo.lock").write_text("") + ok("有标志文件→可扫描", cve.precheck(tmp, "rust")[0] is True) + ok("无前置条件生态直接可扫描", cve.precheck(tmp, "universal")[0] is True) + ok("语言映射值均为规范标识", + all(v in cve.ECOSYSTEM_SCANNERS for v in cve.language_ecosystem_map().values())) + + # 5.2:未知生态在扫描前拒绝,退出码 3,且不混用其他结论文案 + r = run_cve(["--ecosystem", "go", str(tmp)], tmp) + out = r.stdout + r.stderr + ok("未知生态退出码=3", r.returncode == 3, f"rc={r.returncode}") + ok("退出码与「存在漏洞」「无法验证」不重叠", r.returncode not in (1, 2)) + ok("错误信息列出可接受值", "可接受" in out and "java" in out) + ok("不误报「无可扫描生态」", "无可扫描生态" not in out) + ok("不误报存在漏洞", "存在漏洞" not in out and "有漏洞" not in out) + + # 5.1:--ecosystem java 真正进入 maven 派发 + r = run_cve(["--ecosystem", "java", str(tmp)], tmp) + ok("java 别名进入 maven 派发", "ecosystems: ['maven']" in r.stdout, r.stdout.strip()[:120]) + ok("java 未被当成未知生态", r.returncode != 3) + + # ── C1-5.3:severity 展开为「阈值及以上」(回归被丢弃的档位)── + ok("LOW 展开为全部四级", cve.severities_at_and_above("LOW") == ["LOW", "MEDIUM", "HIGH", "CRITICAL"]) + ok("MEDIUM 不再丢 HIGH", cve.severities_at_and_above("MEDIUM") == ["MEDIUM", "HIGH", "CRITICAL"]) + ok("HIGH 默认两档", cve.severities_at_and_above("HIGH") == ["HIGH", "CRITICAL"]) + ok("CRITICAL 单档", cve.severities_at_and_above("CRITICAL") == ["CRITICAL"]) + captured = {} + orig_run = cve_scanners.run + cve_scanners.run = lambda cmd, cwd, timeout=600: (captured.update(cmd=cmd), (0, "", ""))[1] + try: + cve.scan_trivy(tmp, "MEDIUM") + finally: + cve_scanners.run = orig_run + ok("trivy 收到完整级别集合", + captured.get("cmd", []).count("MEDIUM,HIGH,CRITICAL,UNKNOWN") == 1, str(captured.get("cmd"))) + + # ── C1-3.2:maven 数值阈值与其余扫描器同一 intent(HIGH⇒CVSS 7,不是旧映射的 3)── + seen = {} + orig_mv = cve_app.scan_maven + cve_app.scan_maven = lambda root, th, _s=seen: (_s.update(th=th), {"ecosystem": "maven", "tool": "fake", "exit": 0})[1] + try: + cve._scan_maven_ecosystem(tmp, "HIGH", False) + ok("HIGH ⇒ failBuildOnCVSS=7", seen.get("th") == 7, f"th={seen.get('th')}") + cve._scan_maven_ecosystem(tmp, "MEDIUM", False) + ok("MEDIUM ⇒ failBuildOnCVSS=4", seen.get("th") == 4, f"th={seen.get('th')}") + finally: + cve_app.scan_maven = orig_mv + + # ── C1-5.6:四个原生生态派发不回归(mock 扫描器,不依赖真实工具/网络)── + for eco, marker in (("maven", "pom.xml"), ("node", "package.json"), + ("python", "pyproject.toml"), ("rust", "Cargo.lock")): + d = Path(tempfile.mkdtemp()) + (d / marker).write_text("") + calls = [] + orig_scan = cve.ECOSYSTEM_SCANNERS[eco]["scan"] + cve.ECOSYSTEM_SCANNERS[eco]["scan"] = ( + lambda root, sev, fix, _c=calls, _e=eco: (_c.append(sev), {"ecosystem": _e, "tool": "fake", "exit": 0, "status": "PASS"})[1]) + old_argv = sys.argv + try: + sys.argv = ["cve_check.py", "--ecosystem", eco, str(d)] + rc = cve.main() + finally: + sys.argv = old_argv + cve.ECOSYSTEM_SCANNERS[eco]["scan"] = orig_scan + ok(f"{eco} 原生派发且 severity 透传", rc == 0 and calls == ["HIGH"], f"rc={rc} calls={calls}") + + # ── C1-4.3:存在漏洞优先于无法验证 ── + d = Path(tempfile.mkdtemp()) + (d / "package.json").write_text("{}") + (d / "Cargo.lock").write_text("") + orig_node = cve.ECOSYSTEM_SCANNERS["node"]["scan"] + orig_rust = cve.ECOSYSTEM_SCANNERS["rust"]["scan"] + cve.ECOSYSTEM_SCANNERS["node"]["scan"] = lambda root, sev, fix: {"ecosystem": "node", "tool": "fake", "exit": 1, "failed": True, "status": "FAIL"} + cve.ECOSYSTEM_SCANNERS["rust"]["scan"] = lambda root, sev, fix: {"ecosystem": "rust", "tool": "fake", "exit": 127} + try: + sys.argv = ["cve_check.py", str(d)] + rc_mixed = cve.main() + finally: + sys.argv = ["cve_check.py"] + cve.ECOSYSTEM_SCANNERS["node"]["scan"] = orig_node + cve.ECOSYSTEM_SCANNERS["rust"]["scan"] = orig_rust + ok("漏洞(2) 优先于无法验证(1)", rc_mixed == 2, f"rc={rc_mixed}") + + # ── C1-5.4/5.5:无原生扫描器的语言自动落兜底;trivy 缺失=无法验证而非漏洞 ── + d = Path(tempfile.mkdtemp()) + (d / "go.mod").write_text("module x\n\ngo 1.21\n") + (d / "main.go").write_text("package main\n") + r = run_cve([str(d)], d) + out = r.stdout + r.stderr + ok("go 项目自动走 universal 兜底", "ecosystems: ['universal']" in r.stdout, r.stdout.strip()[:120]) + ok("trivy 缺失 → 无法验证(1) 而非漏洞(2)", r.returncode == 1, f"rc={r.returncode}") + ok("兜底路径不误报「有漏洞」", "有漏洞" not in out) + + shutil.rmtree(tmp) + + +# ══════════════════════ 子集 7:文档与注册表可复现同步 ══════════════════════ + +def test_doc_sync(): + print("\n[7] 文档与注册表可复现同步") + import gen_language_docs as gen + + reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) + expected = "\n".join(gen.build_lines(reg["languages"])) + "\n" + actual = (PLUGIN / "docs" / "LANGUAGES.md").read_text(encoding="utf-8") + if expected == actual: + ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", True) + else: + import difflib + diff = list(difflib.unified_diff( + actual.splitlines(), expected.splitlines(), + "docs/LANGUAGES.md", "registry-generated", n=0, lineterm="")) + ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", False, + f"{len(diff)} 行差异,首 6 行: {diff[:6]}") + diff --git a/tests/run_all.py b/tests/run_all.py index 87c3915..0afac6c 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -18,764 +18,19 @@ """ from __future__ import annotations -import json -import os -import shutil -import subprocess import sys -import tempfile -from pathlib import Path -PLUGIN = Path(__file__).resolve().parents[1] -HOOKS = PLUGIN / "hooks" -sys.path.insert(0, str(PLUGIN / "scripts")) -sys.path.insert(0, str(HOOKS)) - -PASS, FAIL, SKIP = [], [], [] - - -def ok(name, cond, note=""): - (PASS if cond else FAIL).append(name) - print(f" {'✅' if cond else '❌'} {name}" + (f" — {note}" if note and not cond else "")) - - -def skip(name, why): - SKIP.append(name) - print(f" ⏭️ {name} — {why}") - - -def run_hook(script: str, payload: dict | None, cwd: Path, env_extra: dict | None = None): - """按宿主协议触发钩子:stdin JSON → (exit, stdout, stderr)""" - env = {**os.environ, "PYTHONIOENCODING": "utf-8", **(env_extra or {})} - return subprocess.run( - [sys.executable, str(HOOKS / script)], - input=json.dumps(payload) if payload is not None else "", - capture_output=True, check=False, text=True, cwd=cwd, timeout=120, env=env, - ) - - -def git(repo: Path, *args: str): - return subprocess.run(["git", *args], cwd=repo, capture_output=True, check=False, text=True) - - -def make_repo() -> Path: - """临时 git 仓(含一个必被 shellcheck 抓到的坏脚本)""" - repo = Path(tempfile.mkdtemp(prefix="cg-test-")) - git(repo, "init", "-q") - git(repo, "config", "user.email", "t@t") - git(repo, "config", "user.name", "t") - (repo / "scripts").mkdir() - (repo / "scripts" / "deploy.sh").write_text( - '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\n' - ) - git(repo, "add", "-A") - git(repo, "commit", "-q", "-m", "init") - return repo - - -# ══════════════════════════ 子集 1:语言规则结构审计 ══════════════════════════ - -def test_languages(): - print("\n[1] 语言规则结构审计(全部 stable/beta)") - langs = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8"))["languages"] - stable = [entry for entry in langs if entry.get("status") in ("stable", "beta")] - ok(f"stable/beta 数量 = {len(stable)}(≥50)", len(stable) >= 50) - - no_target, file_no_gate, npx_no_flag, npx_no_probe, bad_cfg = [], [], [], [], [] - for entry in stable: - lint = entry.get("lint") or [] - cs = " ".join(lint) - if not lint: - # format-only 语言(julia/pascal):无独立 linter,合法 - if not entry.get("format"): - no_target.append(entry["id"]) - continue - # 目标参数:{file} 占位 / 明确路径 / 项目级命令词(裸跑默认检查当前目录) - project_level = ( - lint[0] in { - "mvn", "cargo", "gradlew", "swiftlint", "dart", "tflint", "ameba", - "rubocop", "dotnet", "buf", "mix", "crystal", "elm-review", "elvis", - "ansible-lint", - } - or (len(lint) > 1 and lint[1] in {"fmt", "rock", "analyze"} and lint[0] in {"zig", "scalafmt"}) - or "markdownlint-cli2" in cs - or any(t in cs for t in (".", "*", "{file}", "src")) - ) - if not project_level: - no_target.append(f"{entry['id']}:{cs}") - if "{file}" in cs and not entry.get("gate"): - file_no_gate.append(entry["id"]) - if lint[0] == "npx": - if "--no-install" not in cs: - npx_no_flag.append(entry["id"]) - if not entry.get("probe"): - npx_no_probe.append(entry["id"]) - cfg = entry.get("requiresConfig") - if cfg is not None and (not isinstance(cfg, list) or not all(isinstance(x, str) and x for x in cfg)): - bad_cfg.append(entry["id"]) - ok("所有语言 lint 都有检查目标", not no_target, str(no_target)) - ok("{file} 单文件模式语言都有项目级 gate", not file_no_gate, str(file_no_gate)) - ok("npx 系全部 --no-install", not npx_no_flag, str(npx_no_flag)) - ok("npx 系全部有显式 probe", not npx_no_probe, str(npx_no_probe)) - ok("requiresConfig 结构合法", not bad_cfg, str(bad_cfg)) - - # 关键语言的修正是否落地 - by_id = {entry["id"]: entry for entry in stable} - for lid, field, needle in [ - ("nix", "lint", "{file}"), ("groovy", "lint", "{file}"), ("cfml", "lint", "{file}"), - ("html", "lint", "{file}"), ("markdown", "lint", "--no-install"), - ("vbnet", "lint", "--verify-no-changes"), - ("dockerfile", "gate", "hadolint"), ("sql", "gate", "sqlfluff"), - ("c", "gate", "clang-tidy"), ("html", "gate", "htmlhint"), - ]: - ok(f"{lid}.{field} 含 {needle!r}", needle in json.dumps(by_id.get(lid, {}).get(field))) - ok("rust 无 Cargo.toml 视为未接入", "Cargo.toml" in json.dumps(by_id.get("rust", {}).get("requiresConfig"))) - ok("java 无 pom/build 视为未接入", "pom.xml" in json.dumps(by_id.get("java", {}).get("requiresConfig"))) - ok("vbnet requiresConfig 用 glob (*.sln)", "*.sln" in json.dumps(by_id.get("vbnet", {}))) - ok("erlang requiresConfig(elvis.config)", "elvis.config" in json.dumps(by_id.get("erlang", {}))) - - # detect_lang 透传:gate/probe/requiresConfig 必须进 LANG_COMMANDS - from detect_lang import LANG_COMMANDS - ok("LANG_COMMANDS 透传 gate(shell)", bool(LANG_COMMANDS.get("shell", {}).get("gate"))) - ok("LANG_COMMANDS 透传 probe(html)", bool(LANG_COMMANDS.get("html", {}).get("probe"))) - ok("LANG_COMMANDS 透传 requiresConfig(typescript)", bool(LANG_COMMANDS.get("typescript", {}).get("requiresConfig"))) - - # runtime 抽查:已装工具的语言真跑一条坏样例 - if shutil.which("shellcheck"): - repo = make_repo() - # delta 门禁只看本次改动:把坏脚本重新暂存成"待提交内容", - # 存量已提交的坏文件不该再拦新提交(这正是 delta 的意义)。 - (repo / "scripts" / "deploy.sh").write_text( - '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\nif [ $a = b ]; then echo x; fi\n' - ) - git(repo, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) - ok("shell 门禁真跑:坏脚本 → exit 2", r.returncode == 2, f"exit={r.returncode}") - ok("shell 门禁 stderr 首行=综述", r.stderr.strip().splitlines()[0].startswith("codeguard ❌ 提交门禁未通过:")) - shutil.rmtree(repo) - else: - skip("shell 门禁真跑", "shellcheck 未安装") - - -# ══════════════════════════ 子集 2:对话级钩子触发 ══════════════════════════ - -def test_hooks(): - print("\n[2] 对话级钩子触发(stdin JSON 复刻宿主协议)") - - # ── SessionStart:env_check 在项目里盘点 ── - repo = make_repo() - r = run_hook("env_check.py", None, repo) - out = r.stdout - ok("SessionStart 盘点输出项目记忆", r.returncode == 0 and "# codeguard 项目记忆" in out) - ok("SessionStart 列出检测到的语言", "检测到语言" in out) - - # ── PostToolUse:坏 shell 文件 → 结构化告警(exit 0 + additionalContext) ── - r = run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(repo / "scripts" / "deploy.sh")}}, repo) - ok("PostToolUse 坏文件 exit 0(不阻断)", r.returncode == 0) - try: - ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) - add = ctx["hookSpecificOutput"]["additionalContext"] - first = add.splitlines()[0] - body = add[len(first):] - ok("PostToolUse 注入 additionalContext", "codeguard" in first) - ok("告警首行=综述(含语言与文件)", "shell" in first and "deploy.sh" in first) - ok("综述≠细节(首行未在正文重复出现)", first.strip() not in body) - ok("细节含具体问题编号", "SC" in body or "问题" in body) - ok("细节含怎么修指引", "怎么修" in body or "自动修复" in body) - ok("systemMessage 为短标题", 0 < len(ctx.get("systemMessage", "")) <= 60) - except (json.JSONDecodeError, KeyError, IndexError) as e: - ok("PostToolUse 输出为合法 JSON 协议", False, repr(e)) - - # ── PostToolUse:好文件 → 通过确认 ── - good = repo / "scripts" / "good.sh" - good.write_text('#!/bin/bash\nx="ok"\necho "$x"\n') - r = run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(good)}}, repo) - ok("PostToolUse 好文件 exit 0", r.returncode == 0) - ok("好文件注入通过确认", "✅" in r.stdout and "passed" in r.stdout) - - # ── UserPromptSubmit:提交意图(脏仓)→ 软引导注入(exit 0,prompt 不被弹回) ── - # delta 门禁只查 staged/未暂存/未跟踪:显式造一个待提交的坏改动, - # 与旧版"存量坏文件即脏"的效果对齐但语义是"新提交引入的问题"。 - (repo / "scripts" / "delta-bad.sh").write_text('#!/bin/bash\nif [ $q = w ]; then true; fi\n') - git(repo, "add", "-A") - r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, repo) - ok("提交意图 exit 0(软引导)", r.returncode == 0) - try: - ctx = json.loads([ln for ln in r.stdout.splitlines() if ln.startswith("{")][-1]) - add = ctx["hookSpecificOutput"]["additionalContext"] - first = add.splitlines()[0] - ok("UPS 注入首行=综述", first.startswith("codeguard ❌ 提交门禁未通过:")) - ok("UPS 细节含怎么修与绕过说明", "怎么修" in add and "skipGate" in add) - except (json.JSONDecodeError, KeyError, IndexError) as e: - ok("UPS 输出为合法 JSON 协议", False, repr(e)) - - # ── UserPromptSubmit:疑问句豁免(功能性询问不触发门禁) ── - for q in ("怎么提交代码?", "git 提交和推送能不能排除 .venv 么", "commit 是什么意思"): - r = run_hook("user_prompt_validator.py", {"user_prompt": q}, repo) - ok(f"疑问句豁免: {q[:14]}…", r.returncode == 0 and r.stdout.strip() == "", - f"exit={r.returncode} out={r.stdout[:60]!r}") - - # ── PreToolUse:git commit 脏仓 → 硬拦(exit 2 + stderr 综述/细节) ── - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) - ok("PreToolUse 脏仓 exit 2", r.returncode == 2) - lines = r.stderr.strip().splitlines() - ok("stderr 首行=综述", lines[0].startswith("codeguard ❌ 提交门禁未通过:")) - ok("综述后跟细节(标题≠详情)", len(lines) > 3 and "具体问题" in r.stderr) - ok("综述行未在细节中重复", lines.count(lines[0]) == 1) - - # ── PreToolUse:无 markdown 配置不能冒充检查通过(兼容 fail-open) ── - clean = Path(tempfile.mkdtemp(prefix="cg-clean-")) - git(clean, "init", "-q") - git(clean, "config", "user.email", "t@t") - git(clean, "config", "user.name", "t") - (clean / "README.md").write_text("ok\n") - git(clean, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, clean) - ok("未接入 linter 的仓放行但明确未验证", r.returncode == 0 and "未验证" in r.stdout, - f"exit={r.returncode} out={r.stdout[:40]!r}") - r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, clean) - ok("未接入 linter 不注入通过确认", r.returncode == 0 and "未验证" in r.stdout and "✅" not in r.stdout) - - # ── PreToolUse:安全文件(.env 入库)→ 🛑 拦截;点目录 .venv 默认忽略 ── - (repo / ".env").write_text("SECRET=1") - (repo / ".venv" / "lib").mkdir(parents=True) - (repo / ".venv" / "lib" / "x.py").write_text("x=1") - git(repo, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) - ok("安全违规 exit 2", r.returncode == 2) - ok("安全报告含 .env", ".env" in r.stderr) - ok("点目录 .venv 默认忽略(2026-09-23 策略)", ".venv" not in r.stderr) - ok("安全报告给出 rm --cached 修法", "rm --cached" in r.stderr) - - # ── PreToolUse:多 cd 命令链 → 逐 git 段解析边界 ── - ws = PLUGIN.parent - cmd = (f"cd {ws} && python3 fix.py && cd {repo} && git commit -m t && cd {clean} && git push") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": cmd}}, repo) - ok("多 cd 链不连坐 workspace(只查目标仓)", "partme" not in r.stderr and ws.name not in r.stderr.split("─")[0]) - - # ── Stop:会话统计 ── - r = run_hook("stop_summary.py", None, repo) - ok("Stop 汇总 exit 0", r.returncode == 0) - - shutil.rmtree(repo) - shutil.rmtree(clean) - - -# ══════════════════════════ 子集 3:纯函数单测 ══════════════════════════ - -def test_unit(): - print("\n[3] 纯函数单测") - import gate_lib - import pre_tool_git_guard as guard - from detect_lang import probe_toolchain, project_uses_linter - - # requiresConfig glob 支持 - tmp = Path(tempfile.mkdtemp()) - ok("glob 未命中=未接入", not project_uses_linter({"requiresConfig": ["*.sln"]}, tmp)) - (tmp / "App.sln").write_text("") - ok("glob 命中=已接入", project_uses_linter({"requiresConfig": ["*.sln"]}, tmp)) - ok("精确名匹配仍有效", project_uses_linter({"requiresConfig": ["App.sln"]}, tmp)) - ok("无 requiresConfig 视为已接入", project_uses_linter({}, tmp)) - - # probe:不存在的工具 - ok("probe 报工具不在 PATH", probe_toolchain({"lint": ["no-such-tool-xyz"]})[0] is False) - if shutil.which("shellcheck"): - ok("probe shellcheck 通过", probe_toolchain({"lint": ["shellcheck"]})[0] is True) - - # run_gate:{file} 无 gate 的语言必须归 skipped(防字面量当文件名) - from unittest.mock import patch - with patch.dict(gate_lib.LANG_COMMANDS, {"sql": { - "lint": ["sqlfluff", "lint", "{file}"], "install_hint": "pip install sqlfluff"}}): - failures, skipped = gate_lib.run_gate(Path(tmp), {}, languages=["sql"]) - ok("{file} 无 gate → skipped 不拦提交", not failures and any("gate" in s for s in skipped), - f"failures={failures}") - - # 综述/细节分离:综述≠细节 - report = gate_lib.format_failure_report([("shell", "SC2086: double quote\nSC2154: unassigned", "shfmt -w .", "brew install")]) - first, rest = report.splitlines()[0], "\n".join(report.splitlines()[1:]) - ok("综述为一行短语", 0 < len(first) <= 80 and "\n" not in first) - ok("综述行不在细节中重复", first not in rest) - ok("细节含问题与修法", "SC2086" in rest and "怎么修" in rest) - srep = gate_lib.format_safety_report([(".env", "敏感配置", "git rm --cached .env")]) - sfirst = srep.splitlines()[0] - ok("安全报告同构:首行综述不重复", sfirst not in "\n".join(srep.splitlines()[1:])) - - # 多 cd 边界解析 - roots = guard.resolve_project_roots(f"cd /tmp && x && cd {PLUGIN} && git commit -m t && cd {PLUGIN} && git push") - ok("边界=git 段前最近的 cd", PLUGIN in roots and Path("/tmp") not in roots) - from codeguard.git_context import GitTargetError - try: - guard.resolve_project_roots(f"cd {PLUGIN} && git commit -m t && cd /tmp && git push") - except GitTargetError: - invalid_target_blocked = True - else: - invalid_target_blocked = False - ok("显式非仓目标不回退调用者仓", invalid_target_blocked) - ok("无 git 段返回空", guard.resolve_project_roots("ls -la && echo done") == []) - ok("is_guarded 词法匹配不误伤 echo", not guard.is_guarded('echo "git push 是危险命令"')) - ok("is_guarded 命中真实 git push", guard.is_guarded("cd r && git push origin main")) - - # ── C2-3.4:requiresConfig 语义 —— 未接入→未验证不阻塞;已接入→真实检查 ── - import detect_lang as _dl - from gate_lib import run_gate as _run_gate - cfg_md = _dl.LANG_COMMANDS["markdown"] - cfg_yaml = _dl.LANG_COMMANDS["yaml"] - ok("markdown 已声明 requiresConfig", bool(cfg_md.get("requiresConfig"))) - ok("yaml 已声明 requiresConfig", bool(cfg_yaml.get("requiresConfig"))) - md_available = _dl.probe_toolchain(cfg_md)[0] - ok("markdown 探活语义正确(装了→可用;没装→不可用且原因含探活退出)", - md_available or "探活" in _dl.probe_toolchain(cfg_md)[1]) - bare = Path(tempfile.mkdtemp()) - try: - f1, s1 = _run_gate(bare, {}, languages=["markdown", "yaml"]) - ok("未接入→未验证且不阻塞", not f1 and any("未接入" in x for x in s1), - f"failures={f1} skipped={s1}") - (bare / ".markdownlint-cli2.jsonc").write_text("{}") - (bare / "bad.md").write_text("text \n") # 行尾空格 → MD009,配置在→真跑 - f2, s2 = _run_gate(bare, {}, languages=["markdown", "yaml"]) - if md_available: - ok("已接入→markdown 真跑(advisory 告警进 skipped)", - not f2 and any(x.startswith("markdown") and "告警" in x for x in s2), - f"failures={f2} skipped={s2}") - else: - # CI 无 markdownlint:已接入但工具缺失 → 未验证(不冒充检查过) - ok("已接入但工具缺失→未验证不阻塞", - not f2 and any(x.startswith("markdown") and "不可用" in x for x in s2), - f"failures={f2} skipped={s2}") - ok("已接入→yaml 仍未接入(无 .yamllint 配置)", - any(x.startswith("yaml") and "未接入" in x for x in s2)) - finally: - shutil.rmtree(bare, ignore_errors=True) - - # ── C2-3.5:未声明前置条件的语言不受影响(typescript 原有声明保持)── - ok("typescript 仍声明 requiresConfig(行为不变对照)", - bool(_dl.LANG_COMMANDS["typescript"].get("requiresConfig"))) - - shutil.rmtree(tmp) - - - -# ══════════════════════════ 子集 4:边界、逃生门与 CLI ══════════════════════════ - -def test_edges(): - print("\n[4] 边界、逃生门与 CLI") - import shutil as _sh - - repo = make_repo() - - # ── 逃生门:环境变量(对 PreToolUse 直调生效) ── - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo, - env_extra={"CODEGUARD_SKIP_GATE": "1"}) - ok("CODEGUARD_SKIP_GATE 环境变量逃生门", r.returncode == 0 and not r.stdout and not r.stderr) - - # ── 仓库级豁免(必须单独设置——钩子在命令执行前拦截,同链设置无效) ── - git(repo, "config", "codeguard.skipGate", "true") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) - ok("git config 仓库级豁免", r.returncode == 0 and not r.stdout and not r.stderr) - git(repo, "config", "--unset", "codeguard.skipGate") - - # ── markdown 文件不拦写作流 ── - md = repo / "README.md" - md.write_text("# 课题\n\n全是长长长长长长长长长长的一行" * 30 + "\n") - r = run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(md)}}, repo) - ok("markdown 写作流直接放行", r.returncode == 0 and r.stdout.strip() == "") - - # ── push 模式:无 upstream 的仓不崩(diff 基线缺失=无违规) ── - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git push origin main"}}, repo) - ok("push 无 upstream 不崩", r.returncode in (0, 2)) - - # ── 项目级 codeguard.json:自定义扩展 + exclude ── - (repo / "codeguard.json").write_text(json.dumps({ - "extensions": {".zigmod": "zig"}, - "exclude": ["scripts/deploy.sh"], - }, ensure_ascii=False)) - sys.path.insert(0, str(PLUGIN / "scripts")) - import detect_lang as dl - dl_detect = dl.detect_language(str(repo / "pkg.zigmod")) - ok("codeguard.json 扩展映射生效(.zigmod→zig)", dl_detect == "zig", - f"got={dl_detect}") - langs_detected = dl.detect_languages(repo) - ok("exclude 排除后 shell 不再是阻塞项的判定来源(detect 层生效)", - "shell" in langs_detected or True) # detect_languages 集合级,此处只验证不崩 - (repo / "codeguard.json").unlink() - - # ── Stop 汇总:有 lint 记录时输出统计 ── - run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(repo / "scripts" / "deploy.sh")}}, repo) - r = run_hook("stop_summary.py", None, repo) - ok("Stop 汇总输出统计", r.returncode == 0 and "codeguard" in (r.stdout + r.stderr)) - - # ── zig 运行时真跑:坏格式文件 → 门禁拦截 ── - if _sh.which("zig"): - zrepo = Path(tempfile.mkdtemp(prefix="cg-zig-")) - git(zrepo, "init", "-q") - git(zrepo, "config", "user.email", "t@t") - git(zrepo, "config", "user.name", "t") - (zrepo / "build.zig.zon").write_text(".\n.id = \"x\",\n\n") # 错误语法 - (zrepo / "bad.zig").write_text("const x=1;const y :i32=2;\n") - git(zrepo, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, zrepo) - ok("zig 真跑:坏格式 → exit 2 拦截", r.returncode == 2, f"exit={r.returncode}") - _zl = r.stderr.strip().splitlines() - ok("zig 拦截输出综述行", bool(_zl) and _zl[0].startswith("codeguard ❌")) - _sh.rmtree(zrepo) - else: - skip("zig 真跑", "zig 未安装") - - # ── CLI 子命令冒烟(bin/codeguard 编排) ── - cli = str(PLUGIN / "bin" / "codeguard") - if os.path.exists(cli): - env = {**os.environ, "PYTHONIOENCODING": "utf-8"} - r = subprocess.run([cli, "detect"], capture_output=True, check=False, text=True, cwd=repo, timeout=60, env=env) - ok("codeguard detect 退出", r.returncode in (0, 1), f"exit={r.returncode}") - r = subprocess.run([cli, "dockerfile"], capture_output=True, check=False, text=True, cwd=repo, timeout=90, env=env) - ok("codeguard dockerfile(无 Dockerfile→未验证=1 或通过=0)", r.returncode in (0, 1), f"exit={r.returncode}") - else: - skip("CLI 冒烟", "bin/codeguard 不存在") - - _sh.rmtree(repo) - - -# ══════════════════════════ 子集 5:实战回归(2026-09-19 真实项目踩坑) ══════════════════════════ - -def test_field_regressions(): - print("\n[5] 实战回归:EJS 误报豁免 / requiresConfig 基线 / 双副本去重 / severity") - - # ── 1. EJS 模板:vue-cli public/index.html 含 <% %>,htmlhint 三规则误报 ── - ejs = make_repo() - (ejs / "public").mkdir() - (ejs / "public" / "index.html").write_text( - "" - "<% if (process.env.VUE_APP_X) { %><%= msg %><% } %>" - "\n" - ) - # PostToolUse:EJS 文件 → 豁免误报规则后应通过(不再拦截) - r = run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(ejs / "public" / "index.html")}}, ejs) - ok("EJS 模板豁免后 lint 通过", r.returncode == 0 and "FAILED" not in r.stdout) - - # gate:EJS 文件被 grep -L '<%' 过滤,不再进 htmlhint - git(ejs, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": f"git -C {ejs} commit -m x"}}, ejs) - ok("html gate 过滤 EJS 文件(不再拦提交)", - "html" not in (r.stderr or "")) - - # ── 2. eslint 未接入项目(无任何 .eslintrc*)→ PostToolUse 静默跳过 ── - novue = make_repo() - (novue / "comp.vue").write_text("\n") - r = run_hook("post_tool_lint.py", - {"tool_name": "Write", "tool_input": {"file_path": str(novue / "comp.vue")}}, novue) - ok("vue 未接入 eslint → PostToolUse 静默(不误报)", - r.returncode == 0 and "FAILED" not in r.stdout and "lint passed" not in r.stdout) - - # gate:同样不拦提交 - git(novue, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", "tool_input": {"command": f"git -C {novue} commit -m x"}}, novue) - ok("vue gate:未接入项目不被 eslint 阻断", "vue" not in (r.stderr or "")) - - # ── 3. 双副本去重:2 秒内同文件同 mtime 的第二次触发静默 ── - dup = make_repo() - payload = {"tool_name": "Write", "tool_input": {"file_path": str(dup / "scripts" / "deploy.sh")}} - r1 = run_hook("post_tool_lint.py", payload, dup) - r2 = run_hook("post_tool_lint.py", payload, dup) - ok("第一次触发正常输出", r1.returncode == 0 and r1.stdout.strip() != "") - ok("第二次触发(同文件同 mtime)静默去重", - r2.returncode == 0 and "additionalContext" not in r2.stdout) - - # ── 4. shell severity=warning:info 级(SC2086)不再拦提交 ── - sev = make_repo() - (sev / "scripts" / "deploy.sh").write_text( - '#!/bin/bash\necho "$HOME"\n' # 干净脚本(无 shellcheck 问题) - ) - git(sev, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", - "tool_input": {"command": f"cd {sev} && git commit -m x"}}, sev) - ok("干净脚本不拦提交(severity=warning 基线)", - "shell" not in (r.stderr or "")) - - # ── 5. 真 warning 级问题仍拦截(severity 收紧不放走真问题) ── - sev2 = make_repo() - # 待提交内容必须真正不同于 HEAD:delta 门禁只查改动,与 HEAD 逐字节 - # 相同的"重新 add"等于本次无改动(拦它没有意义,git 也不会提交任何东西)。 - (sev2 / "scripts" / "deploy.sh").write_text( - '#!/bin/bash\nif [ $foo = bar ]; then echo hi; fi\necho $undefined_var\n' # SC2154 warning 级 - ) - git(sev2, "add", "-A") - r = run_hook("pre_tool_git_guard.py", - {"tool_name": "Bash", - "tool_input": {"command": f"cd {sev2} && git commit -m x"}}, sev2) - ok("warning 级真问题(未定义变量)仍拦截", "shell" in (r.stderr or "")) - - # 清理 - for d in (ejs, novue, dup, sev, sev2): - shutil.rmtree(d, ignore_errors=True) - - - -# ══════════════════════════ 子集 5:性能与健壮性 ══════════════════════════ - -def test_perf(): - print("\n[5] 性能与健壮性(缓存/去重/兜底/冷却)") - - # ── 同批共享探活真实执行一次;不依赖机器上安装了哪个 npx 包 ── - from codeguard.toolchain import ToolchainProbe - with tempfile.TemporaryDirectory() as temp: - probe_root = Path(temp) - probe = ToolchainProbe(probe_root) - definition = {"probe": [sys.executable, "-c", ( - "from pathlib import Path; p=Path('count'); " - "p.write_text(p.read_text()+'x' if p.exists() else 'x')")]} - results = [probe.probe(definition) for _ in range(4)] - count = (probe_root / "count").read_text() - ok("探活去重(同批 4 语言共享工具 → 1 次执行)", - all(result[0] for result in results) and count == "x", f"实际 {len(count)} 次") - - # ── 门禁跨进程缓存:同状态 60s 内复用;index 变化即失效 ── - import gate_lib - from codeguard import gate as gate_application - repo = make_repo() - # 缓存完整的实际检查结果,而不是“没有改动未检查”的 skipped。 - (repo / "scripts" / "deploy.sh").write_text("#!/bin/bash\nprintf 'ok\\n'\n") - exec_count = [] - orig_uncached = gate_application.run_batch - def counting(root, cfg, langs, **kw): - exec_count.append(1) - return orig_uncached(root, cfg, langs, **kw) - gate_application.run_batch = counting - try: - f1, _s1 = gate_lib.run_gate(repo, {}, languages=["shell"]) - ok("缓存夹具确实完成检查而非跳过", not _s1, str(_s1)) - n_after_first = len(exec_count) - f2, _s2 = gate_lib.run_gate(repo, {}, languages=["shell"]) - ok("门禁缓存命中(第二次零执行)", len(exec_count) == n_after_first == 1, - f"执行 {len(exec_count)} 次") - ok("缓存结果一致", [x[0] for x in f1] == [x[0] for x in f2]) - # index 变化 → 键变 → 重新执行 - (repo / "scripts" / "more.sh").write_text("# ok\n") - git(repo, "add", "-A") - gate_lib.run_gate(repo, {}, languages=["shell"]) - ok("暂存区变化即缓存失效(重跑)", len(exec_count) == 2, f"执行 {len(exec_count)} 次") - finally: - gate_application.run_batch = orig_uncached - - # ── 异常兜底:钩子内部错误 fail-open(exit 0,无 traceback) ── - code = ( - "import sys, runpy, io, json\n" - "sys.argv=['hook']\n" - "sys.stdin=io.StringIO(json.dumps({'user_prompt':'提交代码'}))\n" - "sys.path.insert(0,'hooks')\n" - "sys.path.insert(0,'scripts')\n" - "from codeguard import prompt_application\n" - "def _boom(*a, **k): raise RuntimeError('injected-failure')\n" - "prompt_application.run_gate=_boom\n" - "prompt_application.skip_gate_via_git_config=lambda *a, **k: False\n" # 免疫本机 skipGate 配置 - f"runpy.run_path({str(HOOKS / 'user_prompt_validator.py')!r}, run_name='__main__')\n" - ) - r = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False, text=True, - cwd=PLUGIN, timeout=60) - ok("钩子内部错误 fail-open(exit 0)", r.returncode == 0, f"exit={r.returncode}") - ok("无 traceback 进输出", "Traceback" not in (r.stdout + r.stderr)) - ok("有一行降级说明", "fail-open" in r.stderr or "内部错误" in r.stderr) - - # ── PATH 继承缓存:第二次不再 spawn 登录 shell ── - from paths import ensure_user_path - spawns = [] - real_run = subprocess.run - def counting_run(cmd, *a, **kw): - if cmd and cmd[0].endswith(("zsh", "bash")) and "-lc" in cmd: - spawns.append(1) - return real_run(cmd, *a, **kw) - subprocess.run = counting_run - try: - ensure_user_path(from_login_shell=True) - n1 = len(spawns) - ensure_user_path(from_login_shell=True) - ok("PATH 缓存:第二次零 spawn", len(spawns) == n1, f"spawn {len(spawns)} 次") - finally: - subprocess.run = real_run - - # ── 通知冷却:同语言 60s 内只弹一次 ── - import importlib - - import post_tool_lint as ptl - importlib.reload(ptl) - tmp_state = Path(tempfile.mkdtemp()) / ".session_state.json" - ptl.STATE_FILE = tmp_state - ok("通知冷却:首次放行", ptl._notify_cooldown_ok("shell") is True) - ok("通知冷却:60s 内抑制", ptl._notify_cooldown_ok("shell") is False) - ok("通知冷却:其他语言不受影响", ptl._notify_cooldown_ok("python") is True) - - import shutil as _sh2 - _sh2.rmtree(repo, ignore_errors=True) - - -# ══════════════════════ 子集 6:CVE 生态标识与参数校验 ══════════════════════ - -def run_cve(args: list[str], cwd: Path): - """按 CLI 契约调用 codeguard cve:返回 (exit, stdout, stderr)""" - env = {**os.environ, "PYTHONIOENCODING": "utf-8"} - return subprocess.run( - [sys.executable, str(PLUGIN / "scripts" / "cve_check.py"), *args], - capture_output=True, text=True, cwd=cwd, timeout=120, env=env, check=False, - ) - - -def test_cve(): - print("\n[6] CVE 生态标识与参数校验") - import cve_check as cve - from codeguard import cve as cve_app - from codeguard import cve_scanners - - tmp = Path(tempfile.mkdtemp()) - - # 权威映射结构:每条自带全部要素;别名不得与规范标识冲突或彼此重复 - ok("映射条目含全部要素", - all({"aliases", "languages", "markers", "scan"} <= set(spec) - for spec in cve.ECOSYSTEM_SCANNERS.values())) - aliases = [a for spec in cve.ECOSYSTEM_SCANNERS.values() for a in spec["aliases"]] - conflicts = [a for a in aliases if a in cve.ECOSYSTEM_SCANNERS] - ok("别名不冲突且不重复", not conflicts and len(aliases) == len(set(aliases)), - f"{conflicts} {aliases}") - - # 1.3:文档化的 java 必须归一到 maven(修复前它被派发 else 静默丢弃) - ok("java 归一到 maven", cve.canonical_ecosystem("java") == "maven") - ok("trivy 归一到 universal", cve.canonical_ecosystem("trivy") == "universal") - ok("规范标识自身可解析", cve.canonical_ecosystem("python") == "python") - ok("大小写不敏感", cve.canonical_ecosystem("JAVA") == "maven") - ok("未声明标识返回 None", cve.canonical_ecosystem("go") is None) - ok("可接受值含别名", "java" in cve.ecosystem_choices() and "trivy" in cve.ecosystem_choices()) - - # 1.2:前置条件从权威映射派生,独立死表已移除 - ok("死表 ECOSYSTEM_PRECHECK 已移除", not hasattr(cve, "ECOSYSTEM_PRECHECK")) - ok("缺标志文件→不可扫描", cve.precheck(tmp, "rust")[0] is False) - (tmp / "Cargo.lock").write_text("") - ok("有标志文件→可扫描", cve.precheck(tmp, "rust")[0] is True) - ok("无前置条件生态直接可扫描", cve.precheck(tmp, "universal")[0] is True) - ok("语言映射值均为规范标识", - all(v in cve.ECOSYSTEM_SCANNERS for v in cve.language_ecosystem_map().values())) - - # 5.2:未知生态在扫描前拒绝,退出码 3,且不混用其他结论文案 - r = run_cve(["--ecosystem", "go", str(tmp)], tmp) - out = r.stdout + r.stderr - ok("未知生态退出码=3", r.returncode == 3, f"rc={r.returncode}") - ok("退出码与「存在漏洞」「无法验证」不重叠", r.returncode not in (1, 2)) - ok("错误信息列出可接受值", "可接受" in out and "java" in out) - ok("不误报「无可扫描生态」", "无可扫描生态" not in out) - ok("不误报存在漏洞", "存在漏洞" not in out and "有漏洞" not in out) - - # 5.1:--ecosystem java 真正进入 maven 派发 - r = run_cve(["--ecosystem", "java", str(tmp)], tmp) - ok("java 别名进入 maven 派发", "ecosystems: ['maven']" in r.stdout, r.stdout.strip()[:120]) - ok("java 未被当成未知生态", r.returncode != 3) - - # ── C1-5.3:severity 展开为「阈值及以上」(回归被丢弃的档位)── - ok("LOW 展开为全部四级", cve.severities_at_and_above("LOW") == ["LOW", "MEDIUM", "HIGH", "CRITICAL"]) - ok("MEDIUM 不再丢 HIGH", cve.severities_at_and_above("MEDIUM") == ["MEDIUM", "HIGH", "CRITICAL"]) - ok("HIGH 默认两档", cve.severities_at_and_above("HIGH") == ["HIGH", "CRITICAL"]) - ok("CRITICAL 单档", cve.severities_at_and_above("CRITICAL") == ["CRITICAL"]) - captured = {} - orig_run = cve_scanners.run - cve_scanners.run = lambda cmd, cwd, timeout=600: (captured.update(cmd=cmd), (0, "", ""))[1] - try: - cve.scan_trivy(tmp, "MEDIUM") - finally: - cve_scanners.run = orig_run - ok("trivy 收到完整级别集合", - captured.get("cmd", []).count("MEDIUM,HIGH,CRITICAL,UNKNOWN") == 1, str(captured.get("cmd"))) - - # ── C1-3.2:maven 数值阈值与其余扫描器同一 intent(HIGH⇒CVSS 7,不是旧映射的 3)── - seen = {} - orig_mv = cve_app.scan_maven - cve_app.scan_maven = lambda root, th, _s=seen: (_s.update(th=th), {"ecosystem": "maven", "tool": "fake", "exit": 0})[1] - try: - cve._scan_maven_ecosystem(tmp, "HIGH", False) - ok("HIGH ⇒ failBuildOnCVSS=7", seen.get("th") == 7, f"th={seen.get('th')}") - cve._scan_maven_ecosystem(tmp, "MEDIUM", False) - ok("MEDIUM ⇒ failBuildOnCVSS=4", seen.get("th") == 4, f"th={seen.get('th')}") - finally: - cve_app.scan_maven = orig_mv - - # ── C1-5.6:四个原生生态派发不回归(mock 扫描器,不依赖真实工具/网络)── - for eco, marker in (("maven", "pom.xml"), ("node", "package.json"), - ("python", "pyproject.toml"), ("rust", "Cargo.lock")): - d = Path(tempfile.mkdtemp()) - (d / marker).write_text("") - calls = [] - orig_scan = cve.ECOSYSTEM_SCANNERS[eco]["scan"] - cve.ECOSYSTEM_SCANNERS[eco]["scan"] = ( - lambda root, sev, fix, _c=calls, _e=eco: (_c.append(sev), {"ecosystem": _e, "tool": "fake", "exit": 0, "status": "PASS"})[1]) - old_argv = sys.argv - try: - sys.argv = ["cve_check.py", "--ecosystem", eco, str(d)] - rc = cve.main() - finally: - sys.argv = old_argv - cve.ECOSYSTEM_SCANNERS[eco]["scan"] = orig_scan - ok(f"{eco} 原生派发且 severity 透传", rc == 0 and calls == ["HIGH"], f"rc={rc} calls={calls}") - - # ── C1-4.3:存在漏洞优先于无法验证 ── - d = Path(tempfile.mkdtemp()) - (d / "package.json").write_text("{}") - (d / "Cargo.lock").write_text("") - orig_node = cve.ECOSYSTEM_SCANNERS["node"]["scan"] - orig_rust = cve.ECOSYSTEM_SCANNERS["rust"]["scan"] - cve.ECOSYSTEM_SCANNERS["node"]["scan"] = lambda root, sev, fix: {"ecosystem": "node", "tool": "fake", "exit": 1, "failed": True, "status": "FAIL"} - cve.ECOSYSTEM_SCANNERS["rust"]["scan"] = lambda root, sev, fix: {"ecosystem": "rust", "tool": "fake", "exit": 127} - try: - sys.argv = ["cve_check.py", str(d)] - rc_mixed = cve.main() - finally: - sys.argv = ["cve_check.py"] - cve.ECOSYSTEM_SCANNERS["node"]["scan"] = orig_node - cve.ECOSYSTEM_SCANNERS["rust"]["scan"] = orig_rust - ok("漏洞(2) 优先于无法验证(1)", rc_mixed == 2, f"rc={rc_mixed}") - - # ── C1-5.4/5.5:无原生扫描器的语言自动落兜底;trivy 缺失=无法验证而非漏洞 ── - d = Path(tempfile.mkdtemp()) - (d / "go.mod").write_text("module x\n\ngo 1.21\n") - (d / "main.go").write_text("package main\n") - r = run_cve([str(d)], d) - out = r.stdout + r.stderr - ok("go 项目自动走 universal 兜底", "ecosystems: ['universal']" in r.stdout, r.stdout.strip()[:120]) - ok("trivy 缺失 → 无法验证(1) 而非漏洞(2)", r.returncode == 1, f"rc={r.returncode}") - ok("兜底路径不误报「有漏洞」", "有漏洞" not in out) - - shutil.rmtree(tmp) - - -# ══════════════════════ 子集 7:文档与注册表可复现同步 ══════════════════════ - -def test_doc_sync(): - print("\n[7] 文档与注册表可复现同步") - import gen_language_docs as gen - - reg = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) - expected = "\n".join(gen.build_lines(reg["languages"])) + "\n" - actual = (PLUGIN / "docs" / "LANGUAGES.md").read_text(encoding="utf-8") - if expected == actual: - ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", True) - else: - import difflib - diff = list(difflib.unified_diff( - actual.splitlines(), expected.splitlines(), - "docs/LANGUAGES.md", "registry-generated", n=0, lineterm="")) - ok("docs/LANGUAGES.md 与注册表完全可复现(双向一致)", False, - f"{len(diff)} 行差异,首 6 行: {diff[:6]}") +from _harness import FAIL, PASS, PLUGIN, SKIP +from _subsets import ( + test_cve, + test_doc_sync, + test_edges, + test_field_regressions, + test_hooks, + test_languages, + test_perf, + test_unit, +) def main(): From 12ff73346a5a538bc704fede78c07a098bd02ab2 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:04:31 +0800 Subject: [PATCH 5/6] =?UTF-8?q?release:=20v0.15.4=EF=BC=88run=5Fall=20?= =?UTF-8?q?=E6=8B=86=E5=88=86=20+=20CI=20coverage=20=E6=8A=A5=E5=91=8A=20+?= =?UTF-8?q?=20CHANGELOG=20=E4=BF=AE=E6=AD=A3=200.15.3=20=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 8 ++++---- .codex-plugin/plugin.json | 2 +- .zcode-plugin/plugin.json | 2 +- CHANGELOG.md | 3 ++- kimi.plugin.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 7fd3ae1..e298e43 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/full-stack-plugins/codeguard-plugin.git", - "ref": "v0.15.3" + "ref": "v0.15.4" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.15.3", + "version": "0.15.4", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.3/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.4/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Trustworthy code checks and Java impact analysis", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.3/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.4/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 000253d..bdc265a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.15.3+codex.20260923", + "version": "0.15.4+codex.20260923", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index c23b221..5366423 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "代码规范守卫", "zh-CN": "代码规范检查" }, - "version": "0.15.3", + "version": "0.15.4", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "description_i18n": { "en": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9722ac2..75a892a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ ## v0.15.x — Git 归因收敛与诊断安全 -- **v0.15.4** 测试卫生与可见性(0.15.3 为未发布的中间 bump 态,不对外):`tests/run_all.py`(809 行)拆分为 +- **v0.15.4** 测试卫生与可见性:`tests/run_all.py`(809 行)拆分为 `_harness.py`(共享夹具)+ `_subsets.py`(8 个回归子集),入口签名与 逐字节输出保持不变(golden diff 验证);CI 单测改由 coverage 包装并 追加 advisory 覆盖率报告(非门禁)。 +- **v0.15.3** heredoc 正文按归属语义归因(数据段 git 样例误报清零)。 - **v0.15.2** 发版链 fail-loud(bump 漂移即抛错);非 Shell 间接 git 归因收紧;`hook-protocol` 归因 delta 并入主规格。 - **v0.15.1** 诊断日志改为私有原子落盘;准确 Git 快照的 index 与 HEAD 身份复核。 - **v0.15.0** 点前缀目录与文件默认忽略(代码 + 提示词双层硬约束)。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 9e4eda1..3f6150c 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.15.3", + "version": "0.15.4", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI" From 7f2db98a94256bf4dd6cec087119f9b2ff797d1d Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:06:14 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(lint):=20=5Fsubsets=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E6=9C=AA=E7=94=A8=E7=9A=84=20=5Fharness=20re-export?= =?UTF-8?q?=20=E7=AC=A6=E5=8F=B7=EF=BC=88ruff=20F401=C3=974=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/_subsets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/_subsets.py b/tests/_subsets.py index 84e32ba..d335d41 100644 --- a/tests/_subsets.py +++ b/tests/_subsets.py @@ -12,8 +12,7 @@ import tempfile from pathlib import Path -from _harness import FAIL, HOOKS, PASS, PLUGIN, SKIP, git, make_repo, ok, run_hook, skip - +from _harness import HOOKS, PLUGIN, git, make_repo, ok, run_hook, skip # ══════════════════════════ 子集 1:语言规则结构审计 ══════════════════════════