diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index a028a88..a881c6a 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.12.1" + "ref": "v0.13.0" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.12.1", + "version": "0.13.0", "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.12.1/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.13.0/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.12.1/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.13.0/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 63c229f..5b80144 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.12.1+codex.20260923", + "version": "0.13.0+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/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml index 263b159..e401571 100644 --- a/.github/workflows/skills-check.yml +++ b/.github/workflows/skills-check.yml @@ -21,6 +21,8 @@ jobs: run: python3 -m pip install --disable-pip-version-check ruff==0.16.8 - name: Check executable code quality run: ruff check hooks scripts tests + - name: Verify core dependency boundaries and import cycles + run: python3 scripts/check_architecture.py - name: Verify vendored skills match the lockfile digests run: python3 scripts/vendor/skill_vendor.py check --offline - name: Verify lockfile pins still match upstream refs and content diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 41d9a7e..04e54d9 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.12.1", + "version": "0.13.0", "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/README.md b/README.md index c7b1636..fefea6d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ CodeGuard provides native check evidence and guards supported Git commit/push calls from AI coding assistants. PostToolUse gives feedback, not blocking. Verified violations block the Git call; unavailable checks remain explicitly UNVERIFIED. Passing a configured check is not proof of complete code correctness. -Current development version: **0.12.0**. No new skills were added for this release: priority is verdict integrity and Java project awareness. +The installed plugin version is recorded in its manifests. The architecture refactor keeps the existing 68 managed skills and focuses on reliable evidence, clear module ownership and Java project awareness. ### Runtime boundaries @@ -138,12 +138,13 @@ python3 scripts/vendor/skill_vendor.py check python3 -m unittest discover -s tests -q python3 tests/run_all.py python3 scripts/validate_languages_json.py +python3 scripts/check_architecture.py 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](docs/verdict-java-architecture.md), [verification report](docs/verification-verdict-java.md). Earlier design documents remain historical context: [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/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). ## License and privacy diff --git a/README.zh-CN.md b/README.zh-CN.md index a5087e8..0e54854 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@ CodeGuard 为 AI 编程助手提供原生检查证据,守护已支持的 Git 提交/推送调用。PostToolUse 只反馈、不阻断;确定违规会拦截 Git 调用;检查无法完成时明确 UNVERIFIED。某项检查通过,不等于代码完全正确。 -当前开发版本:**0.12.0**。本版没有增加技能数量,重点是判定可信度与 Java 项目感知。 +已安装插件的版本以各清单为准。本次架构重构保留现有 68 个受管技能,重点收敛判定证据、模块职责与 Java 项目感知。 ### 运行边界 @@ -138,12 +138,13 @@ python3 scripts/vendor/skill_vendor.py check python3 -m unittest discover -s tests -q python3 tests/run_all.py python3 scripts/validate_languages_json.py +python3 scripts/check_architecture.py ruff check hooks scripts tests ``` 测试包含真实临时 Git 仓库、原生子进程 fixture 与官方 SDK stdio MCP 调用。fixture wrapper 成功不是真实 Maven/Gradle 集成构建。Codex/ZCode/Kimi 当前版本加载、真实项目构建、在线漏洞扫描与准确率/召回率基准仍需独立验收。 -当前实现与证据:[架构](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/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)。 ## 许可与隐私 diff --git a/docs/current-architecture.md b/docs/current-architecture.md new file mode 100644 index 0000000..647af9f --- /dev/null +++ b/docs/current-architecture.md @@ -0,0 +1,65 @@ +# CodeGuard 当前架构与扩展指南 + +本文描述当前工作树的职责边界。发布版本以 `.zcode-plugin/plugin.json` 及其它生成清单为准;历史设计、计划和测试数量不能替代运行证据。 + +## 请求到结论 + +```mermaid +flowchart TD + Host[Codex / ZCode / Kimi] --> Entry[CLI / MCP / 五类 Hook] + Entry --> Check[check_application / language_check] + Entry --> Gate[gate / gate_checks] + Entry --> Scan[cve / dockerfile] + Check --> Plan[planning / Java 模块分析] + Gate --> Plan + Gate --> Git[Git 意图 / 暂存快照 / 基线] + Check --> Exec[execution: argv + cwd + 原始输出] + Gate --> Exec + Scan --> Exec + Exec --> Verdict[verdict / 报告解析] + Plan --> Verdict + Git --> Verdict + Verdict --> Report[reporting / 宿主协议输出] + Entry -. 会话与去重 .-> State[storage / hook_state / cache] +``` + +`execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 + +## 模块所有权 + +| 位置 | 责任 | 扩展边界 | +|---|---|---| +| `bin/codeguard`、`scripts/run_check.py`、`scripts/fix.py` | CLI 参数、输出与 MCP stdio 注册 | 不复制扫描器或判定逻辑 | +| `hooks/` | 宿主 JSON、会话事件、通知与退出协议 | PreToolUse/PostToolUse 仍有待迁移的编排,不能称为纯适配器 | +| `scripts/codeguard/check_application.py`、`language_check.py` | 语言选择、检查与修复请求、MCP 工具分发 | 不导入 MCP SDK;旧 `run_per_language.py` 仅再导出 | +| `gate.py`、`gate_checks.py`、`repository_policy.py`、`baseline.py` | Git 门禁、单语言结果、安全规则及有证据的存量比较 | 只有准确基线失败且每条诊断及次数覆盖当前结果才可豁免 | +| `java_build.py`、`java_impact.py`、`java_planning.py` 等 | 构建读取、纯影响闭包、命令选择与环境观察 | 默认保留跳过测试的行为;复杂构建保守扩大检查范围 | +| `cve_reports.py`、`cve_policy.py`、`cve_scanners.py`、`cve.py` | 漏洞报告、阈值、进程适配与复扫编排 | 无有效结构化报告就没有安全通过结论 | +| `dockerfile_reports.py`、`dockerfile.py` | hadolint/Trivy 结构化证据与逐文件扫描 | `dockerfile_security.py` 只解析参数和呈现报告 | +| `execution.py`、`git_staging.py`、`git_snapshot.py`、`storage.py` | 外部进程、Git 内容面、原子状态读改写 | Git blob 保持字节,不经过文本检查执行器 | +| `registry.py`、`registry_schema.py`、`discovery.py`、`config.py` | 已校验的语言表、发现与配置 | `languages.json` 是语言清单的事实源 | + +这些是源代码层的边界。`scripts/check_architecture.py` 对第一方静态导入和环做门禁;动态加载、进程副作用和宿主行为仍需测试。 + +## 添加或修改能力 + +1. 先在现有 OpenSpec change 中描述用户可观察行为、失败和未验证场景,确定归属模块。新的入口只做参数与协议转换;应用服务编排,纯策略解析证据,执行器保留原始进程结果。 +2. 添加语言时修改 `scripts/languages.json`,跑 schema 与文档生成/一致性测试。稳定项必须有明确检查命令、探活、项目配置前提及范围规则;`{file}` 命令需要项目级 gate 或明确返回 UNVERIFIED。 +3. 添加扫描器时先定义有效报告结构、非零返回语义与阈值,再实现进程适配。缺工具、超时、坏报告、修复后复扫失败均要保留为未验证;不得从空 stdout 推断安全。 +4. 添加 Git 命令形态时在 `git_syntax` 解释意图,在 `git_staging` 绑定真实仓和 pathspec,在快照层保留字节;用临时 Git 仓验证实际 index 不被修改。 +5. 更新 `scripts/check_architecture.py` 的依赖白名单,并用反例测试证明逆向依赖或环会被拒绝。保留 CLI 路径、MCP 工具名、五类 hook JSON/退出协议及旧 Python 导入接口,除非有单独的兼容性变更。 + +## 本地验证与未覆盖面 + +```bash +python3 -m unittest discover -s tests -q +python3 tests/run_all.py +ruff check hooks scripts tests +python3 scripts/check_architecture.py +python3 scripts/validate_languages_json.py +python3 scripts/vendor/skill_vendor.py check --offline +python3 scripts/vendor/skill_vendor.py check +openspec validate refactor-codeguard-architecture --strict +``` + +真实 Maven/Gradle 大型项目、联网漏洞数据库、57 种工具链、Windows 与三宿主已安装运行均需要独立证据。临时 Git 快照不是执行沙箱;构建和扫描命令以当前用户权限运行。当前重构的任务进度和本地证据见 `openspec/changes/refactor-codeguard-architecture/verification.md`,发布记录需待版本、CI、tag、市场和宿主分别核对。 diff --git a/docs/partme-codeguard-plugin-Architecture.zh_CN.md b/docs/partme-codeguard-plugin-Architecture.zh_CN.md index 7342bc6..7caf94a 100644 --- a/docs/partme-codeguard-plugin-Architecture.zh_CN.md +++ b/docs/partme-codeguard-plugin-Architecture.zh_CN.md @@ -1,6 +1,6 @@ # partme-codeguard-plugin 系统架构设计 -> **历史设计,不作为当前行为证明**:下文保存早期架构思路,其中 PostToolUse 阻断、javadoc 默认命令、延迟和“全覆盖”等叙述已不适用。当前 0.12.0 实现以[判定与 Java 架构](verdict-java-architecture.md)、[钩子协议](../hooks/__protocol__.md)及 OpenSpec 为准;历史图表中的指标不是本次实测。 +> **历史设计,不作为当前行为证明**:下文保存早期架构思路,其中 PostToolUse 阻断、javadoc 默认命令、延迟和“全覆盖”等叙述已不适用。当前模块职责见[架构及扩展指南](current-architecture.md)、[钩子协议](../hooks/__protocol__.md)及 OpenSpec;历史图表中的指标不是本次实测。 > **文档说明**:本架构文档描述 partme-codeguard-plugin 插件的内部结构、模块划分、数据流与三端适配设计。 > diff --git a/docs/technical-roadmap.zh_CN.md b/docs/technical-roadmap.zh_CN.md index 3ac51fc..a2a5a6c 100644 --- a/docs/technical-roadmap.zh_CN.md +++ b/docs/technical-roadmap.zh_CN.md @@ -1,6 +1,6 @@ # PartMe CodeGuard 技术方案与路线 -> **历史路线档案,不代表当前验收**:下文的 100% 准确率、<2 秒、零网络等数字未在本轮测量,不能当完成证据。0.12.0 已实现判定可信度与 Java 模块影响规划;当前事实见[架构与下一阶段](verdict-java-architecture.md)和[验证报告](verification-verdict-java.md)。后续优先真实项目验收、符号级影响、基线差分和修复复验,不扩张技能数量。 +> **历史路线档案,不代表当前验收**:下文的 100% 准确率、<2 秒、零网络等数字未在本轮测量,不能当完成证据。当前模块职责见[架构及扩展指南](current-architecture.md),本次重构进度见[验证记录](../openspec/changes/refactor-codeguard-architecture/verification.md)。后续真实项目、符号级影响与三宿主验收须分别取得证据。 > **文档说明**:本文档描述 partme-codeguard-plugin 插件的技术选型、关键决策、ADR(架构决策记录)和版本路线图。 > diff --git a/docs/verdict-java-architecture.md b/docs/verdict-java-architecture.md index d25ecee..1572b89 100644 --- a/docs/verdict-java-architecture.md +++ b/docs/verdict-java-architecture.md @@ -1,6 +1,8 @@ # 判定可信度与 Java 项目感知 -版本:0.12.0(本地开发态)。规格事实源:`openspec/specs/`;实施记录归档为 `openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/`。本轮不增加技能,不改外部受管技能。 +> 历史范围:本页记录 v0.12.0 的判定与 Java 感知设计;当前模块职责见[架构及扩展指南](current-architecture.md)。下文默认命令与后续计划仅适用于当时状态,不能代替当前 manifest、OpenSpec 或实测证据。 + +版本:0.12.0(当时的开发态)。规格事实源:`openspec/specs/`;实施记录归档为 `openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/`。当时未增加技能,也未改外部受管技能。 ## 1. 可执行链路 diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index 02a832e..8bdb4c0 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -21,10 +21,25 @@ 实现定位(**符号而非行号**——行号随每次重构腐烂,符号不腐;v0.8.0 起弃用行号指针): - SessionStart 摘要:`hooks/env_check.py::main`(含双副本告警段)。 - UserPromptSubmit JSON:`hooks/user_prompt_validator.py::main`(非 git 目录跳过说明 `_non_git_note`)与 `::is_trigger`。 -- PreToolUse stderr:`hooks/pre_tool_git_guard.py::main`;拦截判定 `::is_guarded`(直接 + `_command_indirect` 一层间接)。 +- PreToolUse stderr:`hooks/pre_tool_git_guard.py::main`;静态语法在 `scripts/codeguard/git_syntax.py`,仓库观察与 `is_guarded/_guarded_mode` 在 `scripts/codeguard/git_context.py`;旧 Hook helper 导入继续兼容。一层脚本观察共用 `_indirect_bodies`。 - PostToolUse JSON:`hooks/post_tool_lint.py::main`(通过/自动修复后通过/失败 additionalContext 三处 print)。 - Stop 摘要:`hooks/stop_summary.py::main`(含绕过计数 `summarize` 与 skipGate 遗留告警)。 +门禁内部边界:`hooks/gate_lib.py` 只保留兼容导出;`scripts/codeguard/gate.py` 选择内容面与保序汇总, +`gate_checks.py` 返回各语言独立 `GateOutcome`;`repository_policy.py` 检查路径安全和显式豁免, +`baseline.py` 核验存量证据,`spec_validation.py` 适配可选 OpenSpec 校验,`reporting.py` 保留反馈格式。 +应用服务无需 hooks 路径或宿主 SDK 即可运行;兼容面不承担第二份业务实现。 + +语言服务边界:`registry_schema` 的纯校验同时服务注册表 CLI 与运行时加载,`registry` 派生 +识别/命令表;`config` 读取配置,`discovery` 只做项目发现,`toolchain` 负责探活。 +SessionStart 与每轮门禁各持有独立 `ToolchainProbe`,在目标目录执行且 stdin=DEVNULL; +同批同命令成功探活去重,不同命令仍并行,失败与下一批检查都重新探测。 +探活成功不代表 lint 通过。`detect_lang`、`user_config` 的原公开导入继续兼容。 + +`codeguard.json` 缺失时保留默认值;存在但 JSON/字段类型/扩展语言 ID/排除正则无效时, +CLI/MCP 明确返回 UNVERIFIED,自动修复不执行。门禁以未验证说明放行而非报告通过; +其它 Hook 的配置异常仍遵循既有 fail-open 和 stderr 告警,不把它变成代码违规。 + --- ## 2. 三端兼容矩阵 @@ -100,16 +115,26 @@ if __name__ == "__main__": **一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, 且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 `_non_git_note` 说明并 exit 0(工作区根被回退扫描 = 上百无关仓的存量 lint -变成永久红,实测)。双副本事件去重键(四个钩子同一规则):PreToolUse 用 `tool_use_id`、 -UserPromptSubmit 用 `session_id + 文本前缀`、SessionStart/Stop 用 `session_id`; -payload 不带这些字段(测试协议/其它宿主)时不去重,保持旧行为。 +变成永久红,实测)。双副本事件去重键:PreToolUse 用 `tool_use_id + 命令`、 +UserPromptSubmit 用 `session_id + 完整文本 + 检查输入身份`、SessionStart/Stop 用 `session_id`; +这些键同时绑定当前会话/worktree。payload 不带对应字段时不做事件去重。 +去重仅记录**已经完成**的结果:SessionStart/Stop 异常后可重试;软门禁有跳过或未验证项 +时不记录完成。硬门禁只短时重放已完成的拒绝(保留 exit 2 与诊断),绝不缓存放行; +第一份仍在执行时第二份可以重复检查,不可因“事件见过”直接返回 0。 +这不是 exactly-once 执行保证,也不能代替当前代码快照。 推送语义:门禁面由 `_guarded_mode` 统一判定——直接命令与一层间接共用同一套 扫描;**commit 面 = 按命令链预测的实际提交面**(`pre_tool_git_guard.staging_intent`: -纯 `git commit` → 仅暂存区;`git add -A/-a/-u` 或 `commit -a` → 相应扩到 -未暂存/未跟踪;`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, +纯 `git commit` → 仅暂存区;`git add -A` → 含未跟踪;`git add -u` 或 `commit -a` → 仅已跟踪改动; +`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, 不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交, 曾因此被误拦);纯 push 在 HEAD 快照中检查未推送差异(`up...HEAD`),无上游时检查 HEAD 树。 commit+push 使用预测提交快照并合并未推送范围。删除文件进入影响分析,不作为新增敏感文件。 +`git_staging` 将每段 cd/-C、pathspec 与其 worktree 根绑定;硬门禁逐仓传入目标身份, +另一仓的 add -A 不得扩张本仓的纯 commit。Git ls-files 成组解析 glob/目录/literal/exclude; +`-u` 不包含未跟踪文件,`-f` 可包含指定忽略文件,解析后的名字在快照中按字面处理。 +子目录 add . 不扩到兄弟目录;路径含空格与连续相对 cd/-C 保留上下文。 +动态路径、交互暂存和 pathspec-from-file 等未支持形态返回 JSON additionalContext 的 +git UNVERIFIED,保持 exit 0 兼容放行;不能被描述为已准确验证,也不冒充程序内部异常。 硬门禁从 Git blobs 物化一次性目录,不借真实 .git,不修改原 index/工作树,也不复用软门禁缓存。 符号链接/子模块/冲突、超限、依赖不可用都明确 UNVERIFIED。快照是内容隔离,不是执行沙箱; 复杂命令链、非 HEAD refspec、并发修改仍需独立 CI 验证。 @@ -123,6 +148,44 @@ PostToolUse 只运行可限定到单文件的检查和 formatter。append_files= CLI/MCP 共用 PASS/FAIL/UNVERIFIED/SKIPPED/PLANNED,只有 PASS 的 passed=true。 CLI 的 0/1/2 与 hook 的 fail-open 退出码是不同协议,不能混用。 +Java 计划由 `java_analysis` 装配:`java_build` 读描述,`java_impact` 计算模块反向闭包, +`java_planning` 选择命令,`java_environment` 选择 wrapper/JDK,`java_changes` 观察版本差异。 +`java_project.py` 保留原 CLI/导入面。默认 skipTests/check -x test 不变,权威命令不加默认参数; +纯版本降级必须由正确 Git 基线与当前描述证明,依赖版本和编译配置变化不得降为 validate/help。 +根与变更路径先规范化;分析仍是模块级,不等于完整符号或任意构建脚本语义验证。 + +### 状态归属与持久化 + +`scripts/codeguard/hook_state.py` 负责会话归属、统计、冷却、审计和已完成结果; +`scripts/codeguard/storage.py` 负责跨进程锁与原子文件替换。锁覆盖整个读改写, +使用独立且不随数据替换的 `.lock` 文件,不能只有原子写而没有读改写互斥。 + +- 宿主提供 `session_id` 时,统计保存在 `CODEGUARD_HOME/sessions/<会话与worktree哈希>.json`; + 同一 session 在不同 Git worktree 也分开。Stop 只原子消费当前作用域,后续并发写入保留。 +- 无 `session_id` 时沿用 `session_state.json` 与旧插件内状态的兼容读取;此模式**没有跨会话隔离保证**。 + 旧全局记录不分配给任意一个带 ID 的新会话,不能把兼容读取称为可信归属迁移。 +- 门禁审计由调用线程汇总后写入,`worktree` 是原仓根,`execution_root` 是实际检查目录 + (可能为临时快照),`session_scope` 保留会话归属;`cmd` 为已物化的执行 argv,不再写未展开模板。 + 存量豁免记录为 SKIPPED 而非检查通过;当前或基线为 UNVERIFIED 时均不得授予豁免。 +- 保存检查只在获得 PASS/FAIL 后登记去重,UNVERIFIED 后允许立即重试。 + 去重同时校验文件内容和配置/命令身份;检查前后身份不一致不登记,不把执行中产生的新内容当作已检查。 +- 本批真实并发与 worktree 验证在 macOS 完成;Windows 锁实现尚未在 Windows 实机验证。 + +### 缓存身份与失效 + +`scripts/codeguard/fingerprint.py` 采集输入身份,`scripts/codeguard/cache.py` 只读写和校验完整软结果。 +缓存保存在 `CODEGUARD_HOME/gate-cache/.json`;旧 `/tmp` 条目不读取、不自动删除。 + +- 身份包含真实 worktree 路径、HEAD、Git index 的内容条目、upstream、受版本控制及非忽略工作树文件的内容、 + 常见根 linter 配置/注册表声明的配置、用户配置、命令定义和本地工具身份;不再用 mtime/size 代替源码内容。 +- Git 文件名按 NUL 分隔,linked worktree 不假设 `.git` 是目录。项目 `codeguard.json` 不保留永不过期的进程副本。 +- 预算为 10,000 个文件名、64 MiB 内容;符号链接/特殊文件、预算超限、读取失败或非 Git 目录时禁用该身份缓存, + 仍实际检查。预算不截断成一个看似完整的键;非 Git 保存也仍检查,但不采用仓库身份去重。 +- 只保存检查前后身份一致且 `skipped` 为空的软结果;损坏格式、未来时间戳和未知结果均重跑。 + 硬门禁不读取软缓存,事件去重亦不缓存放行。 +- 这是有明确输入范围的软观察优化,不是原子安全快照或可移植验收凭据;任意外部动态配置、远程依赖和未声明环境变化 + 不在完整性证明范围内。准确提交验证和 CI 仍独立运行,不得以软缓存证明整个项目已通过。 + --- ## 5. 新增 hook checklist diff --git a/hooks/env_check.py b/hooks/env_check.py index 65cffcd..b0ff428 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -23,7 +23,6 @@ ensure_user_path, find_project_root, load_user_config, - probe_toolchain, ) @@ -102,14 +101,13 @@ def version_backlog_note(current: str, cache_root: Path) -> str | None: def main(payload: dict | None = None) -> int: - # 双副本去重:宿主提供 session_id 时,同会话第二个副本静默(第一份的 - # 摘要已注入;两份都打 = 同一段"项目记忆"重复两遍)。payload 缺字段 - # (测试协议/其它宿主)时不去重,保持旧行为。 - from gate_lib import should_suppress_event - + from codeguard.hook_state import observe_once, session_scope session_id = (payload or {}).get("session_id") - if session_id and should_suppress_event(f"start:{session_id}"): - return 0 + with session_scope(payload or {}): + return observe_once(f"start:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录,先补齐再盘点 project_root = find_project_root(os.getcwd()) if project_root is None: @@ -126,12 +124,14 @@ def main(payload: dict | None = None) -> int: languages = [lang for lang in languages if lang in enabled] # linter 盘点:直接复用门禁的探活(比裸 which 准——能发现 npx 包未装、运行时损坏) + from codeguard.toolchain import ToolchainProbe + probe = ToolchainProbe(project_root) ready, missing = [], [] for lang in languages: cmd_def = LANG_COMMANDS.get(lang) if not cmd_def: continue - ok, reason = probe_toolchain(cmd_def) + ok, reason = probe.probe(cmd_def) if ok: ready.append(lang) else: diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index ab5ab2d..226c9ed 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -1,42 +1,54 @@ -"""hooks/gate_lib.py:提交门禁的共享检查逻辑。 - -被两个钩子复用: -- user_prompt_validator.py(UserPromptSubmit):失败 → exit 0 + additionalContext 修复指令 -- pre_tool_git_guard.py(PreToolUse Bash):git commit/push 前硬拦截 → exit 2 + stderr 指令 - -统一行为:通过/跳过 = 静默;失败 = 「问题节选 + 怎么修」结构化输出。 -""" +"""旧 Hook 导入兼容面;检查、仓库策略、审计与反馈均委托到独立服务。""" from __future__ import annotations -import contextlib -import json -import os -import re -import shutil -import subprocess import sys -import time from pathlib import Path -try: # Windows 无 fcntl——锁降级为可选,原子替换仍生效 - import fcntl -except ImportError: # pragma: no cover - fcntl = None - -PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 +PLUGIN_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) -# maven 依赖解析失败的特征(javadoc:jar 独立 goal 在 reactor 上跑时的典型崩法) -_DEP_RESOLUTION_RE = re.compile( - r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException" +from codeguard.baseline import ( + _git, + _mentioned_files, + _stale_attribution, + baseline_stale_finding, ) - -# .zsh 送检剥离的统一话术:剥离不是静默丢弃——skipped 必须带上可执行的修复 -# 指令(ShellCheck 不支持 zsh 方言;文件头加方言声明注释后即可正常送检)。 -# delta/全量/UPS 三个面共用这一份认知(改这里即三处同变)。 -_ZSH_SKIP_NOTE = ("ShellCheck 不支持 zsh 方言,{n} 个 zsh 文件未送检。" - "修复:在每个 .zsh 文件头添加 `# shellcheck shell=bash` 注释后重试") - +from codeguard.gate import ( + GATE_CACHE_TTL, + _gate_cache_key, + _gate_cache_path, + _worktree_fingerprint, + run_gate, +) +from codeguard.gate import run_batch as _run_gate_uncached +from codeguard.hook_state import ( + codeguard_home, + record_gate_decision, + record_skip_event, + session_state_path, + should_suppress_event, +) +from codeguard.path_policy import ( + GUARD_EXCLUDE_DIRS, + GUARD_EXCLUDE_FILES, + GUARD_ROOT_ONLY_FILES, +) +from codeguard.reporting import ( + CODEGUARD_VERSION, + REPORT_MAX_CHARS, + _dependency_resolution_hint, + _failure_detail_blocks, + _log_path, + _rule_summary, + _squeeze, + _truncate_detail, + format_failure_report, + format_safety_report, + gate_directive, + summarize_failures, +) +from codeguard.repository_policy import check_commit_safety, skip_gate_via_git_config +from codeguard.spec_validation import validate as _openspec_validate from detect_lang import ( LANG_COMMANDS, detect_language, @@ -45,622 +57,52 @@ probe_toolchain, project_uses_linter, ) -from scope import FULL_SCAN_EXCLUDES, changed_files, scope_cmd - -# === git 提交内容安全检查:绝不该进版本库的文件 === -CODEGUARD_VERSION = "0.12.0" - -# 目录 = 构建产物/依赖快照**单一事实源**(scope.FULL_SCAN_EXCLUDES)+ IDE 目录。 -# 从单一来源派生:清单只在 scope.py 改一处,"入库面"与"扫描面"永不漂移 -# (此前两份手抄清单已经漂移:扫描面缺 out/.next/coverage 等 14 个目录, -# 入库面缺 upstream)。路径任一段落匹配即违规;vendor 的仓根级特例在 -# check_commit_safety 里(嵌套 scripts/vendor 是第一方源码树)。 -GUARD_EXCLUDE_DIRS = set(FULL_SCAN_EXCLUDES) | {".idea", ".vscode"} -# 文件名模式(fnmatch,任意层级):密钥/凭据/本地环境/系统垃圾 -GUARD_EXCLUDE_FILES = [ - ".env", ".env.*", "*.env", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", - "*.keystore", "id_rsa", "id_ed25519", "id_ecdsa", "*.pem.orig", - "credentials*.json", "serviceAccount*.json", "*service-account*.json", - ".DS_Store", "Thumbs.db", "*.pyc", +from scope import changed_files + +__all__ = [ + "CODEGUARD_VERSION", + "GATE_CACHE_TTL", + "GUARD_EXCLUDE_DIRS", + "GUARD_EXCLUDE_FILES", + "GUARD_ROOT_ONLY_FILES", + "LANG_COMMANDS", + "PLUGIN_ROOT", + "REPORT_MAX_CHARS", + "_dependency_resolution_hint", + "_failure_detail_blocks", + "_gate_cache_key", + "_gate_cache_path", + "_git", + "_log_path", + "_mentioned_files", + "_openspec_validate", + "_rule_summary", + "_run_gate_uncached", + "_squeeze", + "_stale_attribution", + "_truncate_detail", + "_worktree_fingerprint", + "baseline_stale_finding", + "changed_files", + "check_commit_safety", + "codeguard_home", + "detect_language", + "detect_languages", + "format_failure_report", + "format_safety_report", + "gate_directive", + "get_overrides", + "probe_toolchain", + "project_uses_linter", + "record_gate_decision", + "record_skip_event", + "run_gate", + "session_state_path", + "should_suppress_event", + "skip_gate_via_git_config", + "summarize_failures", ] -# 仅**仓根级**命中的模式:数据库/日志常作第一方 fixture 合法入库 -# (tests/fixtures/sample.db、docs 里的归档 log),只拦散落在根目录的 -# 数据转储与调试日志——此前与密钥同池任意层级匹配,误伤合法入库(实测)。 -GUARD_ROOT_ONLY_FILES = ["*.sqlite", "*.sqlite3", "*.db", "*.log"] - - -def _git(project_root: Path, *args: str) -> str | None: - """跑只读 git 命令;失败(非 git 仓/无 upstream 等)返回 None,由调用方降级""" - try: - proc = subprocess.run( - ["git", *args], cwd=project_root, capture_output=True, check=False, text=True, timeout=15 - ) - except (subprocess.TimeoutExpired, OSError, FileNotFoundError): - return None - return proc.stdout if proc.returncode == 0 else None - - -def check_commit_safety(project_root: Path, mode: str, *, lanes=None, extra=None, - pending_commit=False) -> list[tuple[str, str, str]]: - """检查即将进入版本库的文件(commit=暂存区;push=未推送提交的 diff)。 - - 返回违规列表 [(路径, 命中规则, 建议操作)];无法判定(非 git 仓/无对比基线) - 返回空列表并由调用方按 skipped 处理——安全检查不做静默失败。 - """ - from git_snapshot import proposed_paths - paths = proposed_paths(project_root, mode, lanes=lanes, extra=extra, - pending_commit=pending_commit) - - import fnmatch - violations: list[tuple[str, str, str]] = [] - for raw in paths: - f = raw.strip() - if not f: - continue - parts = f.split("/") - hit_dir = None - for idx, seg in enumerate(parts[:-1]): - if seg not in GUARD_EXCLUDE_DIRS: - continue - # vendor 仅**仓根级**=依赖快照;嵌套(scripts/vendor、tools/vendor) - # 是第一方源码树——裸段匹配曾把 skill_vendor.py 判成"移出版本库" - # (实测误伤,且打断依赖它的 skills-check CI)。 - if seg == "vendor" and idx != 0: - continue - hit_dir = seg - break - if hit_dir: - violations.append(( - f, f"目录 ./{hit_dir}/ 属于依赖/产物/本地环境,不应入库", - f"git rm -r --cached '{f}' 并在 .gitignore 加 '{hit_dir}/'", - )) - continue - name = parts[-1] - rule = next((pat for pat in GUARD_EXCLUDE_FILES if fnmatch.fnmatch(name, pat)), None) - if rule is None and len(parts) == 1: - # 仓根级 db/log/sqlite 单独一池(见 GUARD_ROOT_ONLY_FILES 注释) - rule = next( - (pat for pat in GUARD_ROOT_ONLY_FILES if fnmatch.fnmatch(name, pat)), None - ) - if rule: - violations.append(( - f, f"文件命中敏感模式 {rule}(密钥/凭据/本地配置类)", - f"git rm --cached '{f}' 并在 .gitignore 加 '{rule}';确认是否已泄露需要轮换密钥", - )) - return violations - - -def format_safety_report(violations: list[tuple[str, str, str]]) -> str: - lines = [f"codeguard 🛑 提交内容安全检查:{len(violations)} 个文件不应入库", "─" * 60] - for f, reason, fix in violations: - lines.append(f" {f}") - lines.append(f" 原因: {reason}") - lines.append(f" 怎么修: {fix}") - lines.append("-" * 60) - lines.append("修复后重新暂存再提交(git add 时排除上述文件)") - return "\n".join(lines) - - -def _gate_cache_path(project_root: Path) -> Path: - import hashlib - import tempfile - key = hashlib.sha1(str(project_root.resolve()).encode()).hexdigest()[:12] - return Path(tempfile.gettempdir()) / f"codeguard-gate-{os.getuid()}-{key}.json" - - -def _worktree_fingerprint(project_root: Path, *, max_files: int = 500) -> str: - """工作区指纹:三路改动的**名字 + stat** 指纹(不再哈希全文内容)。 - - 只用 index mtime 会漏掉"文件已修但未 git add"——键不变 → 60 秒内继续 - 报修复前的旧失败(retry-timing 陷阱,实测踩过),所以工作树改动必须进键。 - 此前对 staged+未暂存跑两个**全仓全文 diff** 再 sha1——多工作树大仓上这是 - 每次 commit/push 前的固定 MB 级开销。改为:改动文件名单 + 每文件 - size/mtime_ns(staged 部分由 _gate_cache_key 的 index mtime+size 兜底, - 暂存内容变化必动 index);内容改动不改名不改 size 也会动 mtime_ns - (纳秒级),既有 CacheKeyTests 语义保持。 - """ - import hashlib - - h = hashlib.sha1() - for args in (("diff", "--cached", "--name-only"), ("diff", "--name-only")): - out = _git(project_root, *args) or "" - names = sorted(line for line in out.splitlines() if line.strip()) - h.update(("\n".join(names)).encode("utf-8", "replace")) - if args[:2] == ("diff", "--name-only"): - # 未暂存已跟踪文件:内容改动名字不变,必须叠加 stat 指纹 - # (staged 路不需要——暂存内容变化必动 index,由键内 idx_sig 兜底)。 - # 名字集合不截断:截断会让第 N+1 个文件的修复在缓存窗口内被旧 - # 指纹掩盖(retry-timing 陷阱对大改动面复活)。stat 本身足够便宜。 - for name in names: - try: - st = (project_root / name).stat() - h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) - except OSError: - h.update(name.encode("utf-8", "replace")) - # 未跟踪文件:ls-files 只给文件名——内容改动名字不变,必须叠加 stat 指纹 - others = (_git(project_root, "ls-files", "--others") or "").splitlines() - for name in sorted(n for n in others if n.strip()): - try: - st = (project_root / name).stat() - h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) - except OSError: - h.update(name.encode("utf-8", "replace")) - try: - st = (project_root / name).stat() - h.update(f"{name}:{st.st_size}:{st.st_mtime_ns}".encode("utf-8", "replace")) - except OSError: - h.update(name.encode("utf-8", "replace")) - return h.hexdigest()[:16] - - -def _gate_cache_key( - project_root: Path, languages: list, *, - mode: str = "commit", - lanes: tuple[str, ...] | list[str] | None = None, - extra: tuple[str, ...] | list[str] | None = None, - scope: str = "delta", -) -> str | None: - """缓存键:HEAD + 暂存区指纹 + 工作区内容指纹 + 门禁面(mode)+ 文件面(lanes/extra)+ 作用域(scope)。 - - 同一 HEAD/工作树下 commit 面与 push 面看到的文件集不同(push 面含未推送 - 提交),不带 mode 会互相污染缓存。**lanes/extra 必须进键**:纯 `git commit` - (仅 staged)与 `git add -A && git commit`(三路)在同一工作树状态下看到 - 不同文件集——只按工作树指纹,先跑的窄面结果会把宽面查询喂给同一缓存条目 - (staged 干净 + 未暂存有病 → 窄面 pass 被宽面复用 = 绕过)。 - **scope 必须进键**:UPS 软门禁可能按 repo 全量扫(gate_scope 覆盖或非 git - 目录),硬门禁按 delta——同一文件面同一 HEAD 下,repo 全量的存量失败会被 - delta 查询复用,本次改动无关的旧文件就误拦提交(会话实测:UPS 全量扫出的 - .zsh SC1071 被 60s 内的 delta 提交面复用,尽管本次提交没碰任何 .zsh)。 - """ - head = _git(project_root, "rev-parse", "HEAD") - if head is None: - return None - idx = project_root / ".git" / "index" - try: - idx_sig = f"{idx.stat().st_mtime_ns}:{idx.stat().st_size}" if idx.exists() else "no-index" - except OSError: - return None - face = ",".join(lanes or ()) + "|" + ",".join(sorted(extra or ())) - return ( - f"{head.strip()}|{idx_sig}|{_worktree_fingerprint(project_root)}" - f"|{mode}|{','.join(sorted(languages))}|{face}|{scope}" - ) - - -GATE_CACHE_TTL = 60 # 秒:UPS 软门禁与紧随的 PreToolUse 硬门禁之间复用 - - -def run_gate( - project_root: Path, - cfg: dict, - languages: list | None = None, - *, - mode: str = "commit", - lanes: tuple[str, ...] | list[str] | None = None, - extra: tuple[str, ...] | list[str] | None = None, - exact: bool = False, - pending_commit: bool = False, -) -> tuple[list, list]: - """运行 linter 门禁(跨进程结果缓存 + 并行执行)。 - - `languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的 - 语言子集,门禁只跑该子集;不传则按 `detect_languages()` 全量探测。 - `lanes`/`extra`:本次要检查的文件面(见 scope.changed_files)——硬门禁 - (PreToolUse)按命令链传入预测面;不传 = 三路宽口径(软门禁 UPS 无命令 - 上下文,按"工作树有待提交改动就提醒"的宽口径注入,不硬拦)。 - - 软门禁(UserPromptSubmit)与硬门禁(PreToolUse)在正常提交路径上 - 会对同一状态连跑两次全量 lint——缓存键含 HEAD 与暂存区指纹, - 文件未变则 60s 内直接复用;AI 修复并重新暂存后键变化自动失效。 - - 返回 (failures, skipped): - - failures: [(lang, 问题节选, 修复命令, install_hint)] - - skipped: [str] 无法验证的说明(工具未装/超时),不阻塞 - """ - import time as _time - if exact: - from git_snapshot import SnapshotError, validation_tree - from scope import is_build_artifact - try: - with validation_tree(project_root, mode, lanes=lanes, extra=extra, - pending_commit=pending_commit) as (snapshot, paths): - selected = languages if languages is not None else detect_languages(snapshot) - enabled = cfg.get("enabled_languages", []) - if enabled and enabled != ["auto"]: - selected = [lang for lang in selected if lang in enabled] - requested_scope = (get_overrides(snapshot) or {}).get("gate_scope") or "delta" - return _run_gate_uncached(snapshot, cfg, selected, scope=requested_scope, - changed=[p for p in paths if not is_build_artifact(p)]) - except (SnapshotError, OSError, ValueError) as exc: - return [], [f"git UNVERIFIED:无法验证准确内容快照:{exc}"] - if languages is None: - languages = detect_languages(project_root) - if not languages: - return [], [] - # 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、 - # 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。 - changed = changed_files(project_root, mode=mode, lanes=lanes, extra=extra) - scope = (get_overrides(project_root) or {}).get("gate_scope") or ( - "delta" if changed is not None else "repo" - ) - enabled = cfg.get("enabled_languages", []) - if enabled and enabled != ["auto"]: - languages = [lang for lang in languages if lang in enabled] - - ck = _gate_cache_key(project_root, languages, mode=mode, lanes=lanes, extra=extra, - scope=scope) - cache_file = _gate_cache_path(project_root) - if ck: - try: - blob = json.loads(cache_file.read_text()) - if blob.get("key") == ck and _time.time() - blob.get("ts", 0) < GATE_CACHE_TTL: - return blob["failures"], blob["skipped"] - except (OSError, ValueError, KeyError): - pass - - failures, skipped = _run_gate_uncached( - project_root, cfg, languages, - scope=scope, changed=changed if scope == "delta" else None, - baseline_ref="@{upstream}" if mode == "push" else "HEAD", - ) - - if ck: - with contextlib.suppress(OSError): - # 原子替换:UPS 与 PreToolUse 并发写同一 cache 文件时, - # 裸 write_text 可能让对方读到半截 JSON(ValueError → 缓存失效重扫)。 - tmp = cache_file.with_name(f"{cache_file.name}.{os.getpid()}.tmp") - tmp.write_text(json.dumps( - {"key": ck, "ts": _time.time(), "failures": failures, "skipped": skipped}, - ensure_ascii=False)) - os.replace(tmp, cache_file) - return failures, skipped - - -def _log_path(project_root: Path, lang: str) -> Path: - import hashlib - import tempfile - key = hashlib.sha1(str(project_root.resolve()).encode()).hexdigest()[:12] - return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log" - - -def _rule_summary(full: str) -> str: - """ruff/checkstyle 输出的规则聚合计数行(如 "EXE001×3 UP009×1")。 - - 2026-09-23 opencli 会话实测:门禁输出被截断后 AI 修一个才暴露下一个 - (whack-a-mole 升级版),聚合计数让 AI 一次看到问题全集与规模。 - 非 linter 标准行格式(无规则码锚)时返回空串。 - """ - import re as _re - counts: dict[str, int] = {} - for line in full.splitlines(): - m = _re.search(r"\b([A-Z]{2,5}\d{3,4})\b", line) - if m and (":" in line or "-->" in line): - counts[m.group(1)] = counts.get(m.group(1), 0) + 1 - if not counts: - return "" - return "规则汇总: " + " ".join(f"{k}×{v}" for k, v in sorted(counts.items())) - - -def _truncate_detail(full: str, project_root: Path, lang: str) -> str: - """节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、 - 重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。""" - lines = [ln for ln in full.splitlines() if ln.strip()] - summary_line = _rule_summary(full) - detail = full[:600] - if summary_line: - detail = summary_line + "\n" + detail - if len(lines) > 8 or len(full) > 600: - try: - path = _log_path(project_root, lang) - path.write_text(full, encoding="utf-8") - detail += f"\n…(截断,共 {len(lines)} 行;完整输出: {path})" - except OSError: - detail += f"\n…(截断,共 {len(lines)} 行)" - return detail - - -def _mentioned_files(full: str, project_root: Path) -> set[str]: - """从 linter 输出提取"提到的、且在仓内真实存在的"文件(仓根相对路径)。 - - 两类通行格式:`path/file.ext:12:`(javadoc/ruff/pylint/mvn——绝对路径先剥 - 仓根前缀)与 `In path/file.ext line 12`(shellcheck)。只收 root 下真实 - 存在的路径——裸 token(`version:1`)被存在性过滤挡掉。 - """ - import re - root = Path(project_root) - found: set[str] = set() - pats = ( - # path/file.ext:12:(javadoc 绝对路径 / ruff / mvn)——目录前缀可选: - # ruff 在仓根输出裸文件名 `old.py:1:1:`,强制含 / 会漏归因(实测)。 - # 无点的裸 token(SC2086:1 / version:1)不会命中;命中的再过存在性。 - re.compile(r"((?:[A-Za-z]:)?(?:/?(?:[\w.\-]+/)*)[\w.\-]+\.[A-Za-z0-9]+):\d+"), - re.compile(r"\bIn ((?:[\w.\-]+/)+[\w.\-]+\.[A-Za-z0-9]+) line \d+"), - ) - for pat in pats: - for m in pat.findall(full): - p = m - root_s = str(root) - if p.startswith(root_s + "/"): - p = p[len(root_s) + 1:] - p = p.lstrip("./") - if p.startswith("/"): - continue # 仓外绝对路径不归因 - if (root / p).exists(): - found.add(p) - return found - - -def _stale_attribution(full: str, project_root: Path, lang: str, lang_files: list[str]) -> str | None: - """[已弃用·保留接口] delta 失败路径下的"历史债"弱归因。 - - verdict-integrity 原则:**真正的豁免必须有基线复跑证据**(双跑 - baseline_stale_finding)——本函数对此**永远不豁免**,遵循"宁可误拦 - 不误放行":只靠失败文件是否在改动集推断存量债是危险的(同名文件被 - 替换时也会"不在改动集",但其实是新债),必须有 baseline_ref 上同 - 命令同工具的实际复跑才能豁免。 - - 历史与契约: - - 入口仍保留以兼容 tests/test_verdict_integrity.py 与 test_session_fixes - 中的调用;这些测试断言本函数返回 None(不豁免),本实现亦满足。 - - 真正的豁免路径在 baseline_stale_finding(hook_lib.py 行 ~430+), - 由 _run_gate_uncached 在 delta 逐文件失败循环里调用。 - - 如未来加入"按文件位置启发式豁免",本函数将是扩展点:补基线 fallback - 或与 baseline_stale_finding 双签名比对。 - - 本函数当前实现永远返回 None。 - """ - return None - -def _run_gate_uncached( - project_root: Path, - cfg: dict, - languages: list, - *, - scope: str = "repo", - changed: list[str] | None = None, - baseline_ref: str = "HEAD", -) -> tuple[list, list]: - """单次门禁(多语言并行,结果顺序保持语言表顺序)。 - - scope="delta" 时只检查 changed 里属于该语言的文件(存量问题不拦新提交; - lint 为 {file} 单文件模式则逐文件循环,上限 50 个,超出回退全量命令)。 - scope="repo" 时按项目级 gate/lint 全量扫(ruff 注入默认配置并剔除依赖 - 快照目录——vendor 是供应链不可变内容,扫它只会得到不可修的永久红)。 - """ - from concurrent.futures import ThreadPoolExecutor - - # 部分剥离场景的 skipped 备注(闭包列表:worker 线程 append,GIL 下安全) - zsh_notes: list[str] = [] - - def check(lang: str): - cmd_def = LANG_COMMANDS.get(lang) - if not cmd_def: - return (lang, None, f"{lang} PLANNED:没有可执行检查命令") - if lang == "java": - from run_per_language import run_check - outcome = run_check([lang], project_root, timeout=cfg.get("lint_timeout_seconds", 120), - files=changed if scope == "delta" else None, - log_dir=_log_path(project_root, lang).with_suffix(""))[0] - if outcome["status"] == "PASS": - return None - if outcome["status"] != "FAIL": - hint_extra = ";若 wrapper 缺执行位:chmod +x mvnw" \ - if "不可执行" in outcome.get("reason", "") else "" - return (lang, None, f"java {outcome['status']}: {outcome['reason']}{hint_extra}") - detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), project_root, lang) - if outcome.get("log_path"): - detail += f"\n完整输出: {outcome['log_path']}" - return (lang, (lang, detail, "按 Java 影响计划修复并复跑 verify/check", - "无需安装工具;用项目自带 mvnw/gradlew 与匹配 JDK 复跑"), None) - lang_files: list[str] = [] - if scope == "delta": - lang_files = [ - f for f in (changed or []) - if detect_language(f, project_root) == lang and (project_root / f).is_file() - ] - # ShellCheck 不支持 zsh(SC1071 是 error 级固有限制)——.zsh 送检 - # 必红且不是代码违规。从目标面剔除并明示"未验证"+ 可执行修复指令, - # 不静默丢弃。部分 zsh 场景备注进 zsh_skips,余下 .sh 照常送检 - # (会话实测:静默剥离让提交方不知道有一部分文件压根没被检查)。 - if lang == "shell": - zsh_files = [f for f in lang_files if f.endswith(".zsh")] - if zsh_files: - lang_files = [f for f in lang_files if not f.endswith(".zsh")] - note = _ZSH_SKIP_NOTE.format(n=len(zsh_files)) - if not lang_files: - return (lang, None, note) - zsh_notes.append(note) - if not lang_files: - return (lang, None, f"{lang} 本次改动未涉及,跳过") - uses_delta_files = bool(lang_files) and len(lang_files) <= 50 - base_cmd = (cmd_def.get("gate") or cmd_def.get("lint")) if ( - scope != "delta" or not uses_delta_files - ) else cmd_def.get("lint") - base_cmd = base_cmd or cmd_def.get("gate") or cmd_def.get("lint") - if not base_cmd: - return None - if scope != "delta" and "{file}" in " ".join(base_cmd): - # {file} 占位符只在文件模式下被替换;门禁拿字面量当文件名跑必然 - # 报"文件不存在"→ 误判成 lint 失败。归跳过。 - return (lang, None, f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证") - timeout = cfg.get("lint_timeout_seconds", 120) - hint = cmd_def.get("install_hint") or "见 docs/LANGUAGES.md" - # 「未接入」先于「工具不可用」:requiresConfig 是纯文件系统判定(快、 - # 不起进程),且是更根本的原因——项目没接这个 linter 时,装没装工具 - # 都不该影响结论;反过来会把"未接入"报成"工具不可用"(本机无 yamllint - # 时既有 yaml 测试即因此失败)。 - if not project_uses_linter(cmd_def, project_root): - return (lang, None, f"{lang} 项目未接入(缺 linter 配置文件),本次未验证") - ok, reason = probe_toolchain(cmd_def) - if not ok: - return (lang, None, f"{lang} 工具链不可用未验证:{reason}(安装: {hint})") - - # Java 门禁接入 java_project 分析:mvnw 感知 + 工具链失配归跳过。 - if lang == "java": - try: - from java_project import analyze as _jp_analyze - _jp = _jp_analyze(str(project_root)) - if _jp.get("status") == "UNVERIFIED": - return (lang, None, - (f"{lang} 项目分析 UNVERIFIED({_jp.get('build_system', '?')})," - f"原因: {'; '.join(_jp.get('reasons', []))},本次未验证")) - exe = _java_executable_from_plan(_jp) - if exe and base_cmd and base_cmd[0] in ("mvn", "gradle"): - base_cmd = [exe] + base_cmd[1:] - except Exception as exc: # noqa: BLE001 — 分析失败不阻塞门禁,走原路径 - print(f"[codeguard] java_project 分析失败(走原路径): {exc!r}", - file=sys.stderr) - record_gate_decision(project_root, lang, base_cmd, -1, - "UNVERIFIED", f"java_project 分析失败: {exc!r}") - - outputs: list[tuple[int, str, str]] = [] - stale_notes: list[str] = [] - # 项目级命令(mvn/gradle 等,无 {file} 占位符)不追加文件路径—— - # 文件被当 goal 会报 `Unknown lifecycle phase` 造成假失败;文件列表 - # 只用来决定跑不跑(delta 上面已按语言过滤)。 - append = cmd_def.get("append_files", True) - if scope == "delta" and uses_delta_files and "{file}" in " ".join(base_cmd or []): - for f in lang_files: - cmd = scope_cmd(base_cmd, project_root, single_file=f) - rc_f, out_f, err_f = _run_one(cmd, timeout, lang, hint) - if rc_f == 0: - outputs.append((rc_f, out_f, err_f)) - continue - # 失败先做双跑基线:HEAD 版本同命令同工具已有相同发现 → 存量 - # 豁免(本次未引入新问题);否则按新增违规拦截。exact 快照目录 - # 非 git 仓,git show 失败 → 不豁免(宁可误拦不误放行)。 - stale = baseline_stale_finding( - project_root, f, base_cmd, (out_f or "") + (err_f or ""), timeout, - baseline_ref=baseline_ref) - if stale is not None: - stale_notes.append(f"{f}: {stale}") - continue - outputs.append((rc_f, out_f, err_f)) - break - else: - if scope == "delta": - # files=None(delta 超 50 文件回退全量命令)时同样要剔除构建产物—— - # 否则回退路径反而比常规全量门禁更容易扫到 target/ 生成物。 - cmd = scope_cmd( - base_cmd, project_root, - files=lang_files if uses_delta_files else None, - full_excludes=not uses_delta_files, - append_files=append, - ) - else: - cmd = scope_cmd(base_cmd, project_root, full_excludes=True) - outputs.append(_run_one(cmd, timeout, lang, hint)) - - # 任一后续文件失败都不能被首个文件的成功覆盖。 - if not outputs: - # 所有失败项都比对为存量 → 放行,但必须明示豁免内容 - record_gate_decision(project_root, lang, base_cmd, 0, "PASS", "存量问题豁免(基线比对)") - return (lang, None, f"{lang} 存量问题已豁免(基线同命令同工具比对):" - + ";".join(stale_notes[:3])) - rc, out, err = next((entry for entry in outputs if entry[0] != 0), outputs[0]) - if rc == 0: - record_gate_decision(project_root, lang, base_cmd, 0, "PASS", "检查执行成功") - if stale_notes: - return (lang, None, f"{lang} 存量问题已豁免(基线比对):" - + ";".join(stale_notes[:3])) - return None - from verdict import UNVERIFIED, lint_verdict - status, reason = lint_verdict(rc, base_cmd, out + err) - if status == UNVERIFIED: - record_gate_decision(project_root, lang, base_cmd, rc, "UNVERIFIED", reason) - return (lang, None, f"{lang} 工具链异常未验证:{reason} (exit {rc})") - if lang == "markdown": - record_gate_decision(project_root, lang, base_cmd, rc, "SKIPPED", "markdown 风格告警") - return (lang, None, "markdown 风格告警(不阻塞提交)") - full = "\n".join(seg for seg in ((out or "").rstrip(), (err or "").rstrip()) if seg) - if scope == "delta" and lang_files: - # 无基线时不做“历史债”推断;保留接口供未来双跑基线扩展。 - stale = _stale_attribution(full, project_root, lang, lang_files) - if stale is not None: - return (lang, None, stale) - detail = _truncate_detail(full or "(linter 无输出)", project_root, lang) - dep_hint = _dependency_resolution_hint(lang, full) - if dep_hint: - detail += f"\n{dep_hint}" - fix = f"自动修复: {' '.join(cmd_def['format'])}" if cmd_def.get("format") else "按上述问题逐项修复" - record_gate_decision(project_root, lang, base_cmd, rc, "FAIL", "检查发现违规") - return (lang, (lang, detail, fix, hint), None) - - def _run_one(cmd: list[str], timeout: int, lang: str, hint: str) -> tuple[int, str, str]: - try: - proc = subprocess.run( - cmd, cwd=project_root, capture_output=True, check=False, - text=True, timeout=timeout, - ) - except subprocess.TimeoutExpired: - # 超时 = 不可证伪,路由到 skipped 而非 failures。上游 run_gate 把 - # timeout 当失败会把 Maven install -DskipTests 冷缓存(普遍 >2 分钟) - # 误报为阻断;报告 skip + 指引加长 timeout,不阻塞硬门禁。 - return 124, "", f"timeout after {timeout}s" - except FileNotFoundError: - return 127, "", f"{lang} linter 未安装(安装: {hint})" - # exit 124 = subprocess 自身用 124 报 timeout(与上面的 TimeoutExpired - # 区分:后者是 _run_one 自身超时,subprocess.run 不抛),同样不可证伪。 - if proc.returncode == 124: - return 124, proc.stdout or "", f"subprocess timeout after {timeout}s" - return proc.returncode, proc.stdout or "", proc.stderr or "" - - results = [] - with ThreadPoolExecutor(max_workers=min(4, max(1, len(languages)))) as pool: - results = list(pool.map(check, languages)) - - failures, skipped = [], [] - for r in results: # pool.map 保序,输出与语言表顺序一致 - if r is None: - continue - _, failure, skip = r - if failure: - failures.append(failure) - if skip: - skipped.append(skip) - # OpenSpec validate:项目使用 OpenSpec 管理 change proposal 时,跨语言 lint - # 通过后再核验 openspec/changes/* 是否仍合法(DRAFT/REVIEW_REQUIRED 提案 - # 实施时此步提醒 agent 评审未通过)。OpenSpec 未安装或项目无 config 时跳过。 - try: - openspec_check = _openspec_validate( - project_root, timeout_seconds=cfg.get("lint_timeout_seconds", 300)) - failures.extend(openspec_check["failures"]) - if openspec_check["skipped"]: - skipped.append(openspec_check["skipped"]) - except Exception as exc: # noqa: BLE001 — 调用面异常同样归"未验证",不静默吞 - skipped.append(f"openspec UNVERIFIED:{exc!r}") - return failures, skipped + zsh_notes - - -def _openspec_validate(project_root: Path, timeout_seconds: int = 300) -> dict: - """若仓根有 openspec/config.yaml 且 openspec CLI 可用,跑 strict validate。 - - 报告:failures=[("openspec", detail, 修复命令, install_hint), ...] - skipped="…" 或 None(无 openspec 项目或 CLI 缺失时)。 - """ - cfg_path = project_root / "openspec" / "config.yaml" - if not cfg_path.is_file(): - return {"failures": [], "skipped": None} - cli = shutil.which("openspec") - if cli is None: - return {"failures": [], "skipped": "openspec CLI 未安装,跳过验证"} - try: - proc = subprocess.run( - [cli, "validate", "--all", "--strict", "--no-interactive", "--json"], - cwd=project_root, capture_output=True, text=True, - check=False, timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired: - return {"failures": [], "skipped": "openspec validate 超时(lint_timeout_seconds)"} - except Exception as exc: # noqa: BLE001 — validate 自身异常归"未验证",绝不逃逸成 fail-open 静默吞 - return {"failures": [], "skipped": f"openspec validate 异常未验证: {exc!r}"} - if proc.returncode == 0: - return {"failures": [], "skipped": None} - detail = _truncate_detail((proc.stdout or "") + (proc.stderr or ""), project_root, "openspec") - fix = f"{cli} validate --all --strict --no-interactive" - hint = "见 https://openspec.dev 或 `npm i -g @fission-ai/openspec`" - return {"failures": [("openspec", detail, fix, hint)], "skipped": None} def _java_executable_from_plan(plan: dict) -> str | None: """从 java_project.analyze 的产物里解析构建可执行文件。 @@ -680,320 +122,3 @@ def _java_executable_from_plan(plan: dict) -> str | None: if argv and Path(argv[0]).name in ("mvnw", "gradlew"): return argv[0] return None - - -def skip_gate_via_git_config(project_root: Path) -> bool: - """仓库级豁免:git config codeguard.skipGate true。 - - CODEGUARD_SKIP_GATE 环境变量设在用户 shell,传不进宿主起的 hook 子进程 - (宿主环境独立);git config 由钩子进程在项目根读取,任何调用形态可用。 - - 读取失败(超时/OSError)**不静默**:低超时(3s)重试一次后仍失败才按 - "未豁免"处理,并记 skipGate-read-error 审计事件——门禁误拦 vs 误放行之间 - 宁可误拦,但必须留痕可排障(此前 timeout=10s 静默 False,门禁满负载时 - 高概率误判"没豁免"且无痕迹)。 - """ - last_exc: Exception | None = None - for _attempt in (1, 2): - try: - proc = subprocess.run( - ["git", "config", "--get", "codeguard.skipGate"], - cwd=project_root, capture_output=True, check=False, text=True, timeout=3, - ) - except (subprocess.TimeoutExpired, OSError) as exc: - last_exc = exc - continue - return proc.returncode == 0 and proc.stdout.strip().lower() in ("true", "1", "yes") - record_skip_event("skipGate-read-error", project_root) - with contextlib.suppress(OSError, ValueError): - path = session_state_path() - state = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} - events = state.get("_skip", {}).get("events", []) - if events: - events[-1]["detail"] = f"git config 读取失败(重试2次): {last_exc!r}" - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - return False - - -def codeguard_home() -> Path: - """钩子共享状态目录(可用 CODEGUARD_HOME 覆盖,测试隔离用)。""" - import os as _os - return Path(_os.environ.get("CODEGUARD_HOME") or (Path.home() / ".codeguard")) - - -def session_state_path() -> Path: - """会话 lint 状态(原先存插件安装目录——升级即清零、双副本各存一半)。""" - return codeguard_home() / "session_state.json" - - -def _dependency_resolution_hint(lang: str, full: str) -> str | None: - """Java 门禁报依赖解析失败时给行动指引,而不是只留 maven 堆栈尾部。 - - javadoc:jar 以独立 goal 跑在 reactor 上,sample 类模块常设 - maven.install.skip=true,它们的构件不在本地仓——下游模块必然 - Could not resolve。没有这行提示,用户看到的是与"该改什么"无关的堆栈。 - """ - if lang != "java": - return None - if not _DEP_RESOLUTION_RE.search(full): - return None - return ( - "提示:依赖构件不可解析(如 samples 模块 maven.install.skip=true 未入本地仓)。" - "先在仓根执行 mvn install -DskipTests(必要时加 -Dmaven.install.skip=false)再重试门禁。" - ) - - -def baseline_stale_finding( - project_root: Path, rel_path: str, base_cmd: list[str], output: str, timeout: int = 60, - *, - baseline_ref: str = "HEAD", -) -> str | None: - """同命令同工具跑**改动前版本**:基线已存在相同发现 → 存量豁免说明;否则 None。 - - verdict-integrity 既有原则:**无基线证据绝不豁免**(`_stale_attribution` 因 - 此恒返回 None)。本函数首次落地"双跑基线":把 baseline_ref: 的内容 - 写到临时文件、用同一条 linter 命令跑一遍,两次输出各取 finding_signatures; - 新发现 ⊆ 基线发现 → 全是存量(本次改动未引入新问题);基线为空/提不出 - 签名/基线跑不起来 → 一律不豁免,宁可误拦不误放行。 - - baseline_ref 必须是"改动前":commit 面(工作树/暂存改动)用 HEAD;push 面 - (检查未推送提交)必须用 @{upstream}——坏提交已经进了 HEAD,拿 HEAD 当基线 - 会把本次新引入的坏内容误判成存量(实测踩过:push 面坏提交被豁免放行)。 - """ - from verdict import finding_signatures - pre = _git(project_root, "show", f"{baseline_ref}:{rel_path}") - if pre is None: - return None - new_sigs = finding_signatures(output) - if not new_sigs: - return None - import tempfile as _tf - with _tf.TemporaryDirectory(prefix="cg-baseline-") as td: - tmp = Path(td) / ("baseline" + (Path(rel_path).suffix or ".txt")) - try: - tmp.write_text(pre, encoding="utf-8", errors="replace") - except OSError: - return None - joined = " ".join(base_cmd) - cmd = ([c.replace("{file}", str(tmp)) for c in base_cmd] if "{file}" in joined - else list(base_cmd) + [str(tmp)]) - try: - proc = subprocess.run( - cmd, cwd=project_root, capture_output=True, check=False, - text=True, timeout=timeout, - ) - except (subprocess.TimeoutExpired, OSError, FileNotFoundError): - return None - base_sigs = finding_signatures((proc.stdout or "") + (proc.stderr or "")) - if not base_sigs: - return None - return "存量问题(基线同命令同工具已存在)" if new_sigs <= base_sigs else None - - -def record_gate_decision(project_root: Path, lang: str, cmd: list[str], rc: int, - status: str, reason: str, limit: int = 500) -> None: - """门禁决策落盘(gate-decisions.jsonl):哪个语言、跑了什么命令、rc、结论。 - - 只有 skip 事件有明细时,事后无法回答"这个仓这次提交门禁为什么放行/拦截" - ——决策日志是排障与回归对比的证据链。写失败不影响门禁主流程。 - """ - entry = { - "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - "repo": project_root.name, - "lang": lang, - "cmd": list(cmd), - "rc": rc, - "status": status, - "reason": reason, - } - with contextlib.suppress(OSError): - path = codeguard_home() / "gate-decisions.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - lines: list[str] = [] - if path.exists(): - lines = path.read_text(encoding="utf-8").splitlines() - lines.append(json.dumps(entry, ensure_ascii=False)) - path.write_text("\n".join(lines[-limit:]) + "\n", encoding="utf-8") - - -def record_skip_event(kind: str, project_root: Path | None = None, - detail: str | None = None) -> None: - """记录一次绕过(skipGate/逃生门)到会话状态,Stop 汇总时可见。 - - 除计数外保留最近 20 条明细(时间 + 仓库 + 类型 + 可选 detail)——豁免 - 必须可回溯:只有总数时无法回答"哪个仓、什么时候、为什么被跳过的"。 - """ - state = {} - path = session_state_path() - try: - if path.exists(): - state = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - state = {} - meta = state.setdefault("_skip", {"count": 0, "kinds": {}}) - meta["count"] = int(meta.get("count", 0)) + 1 - kinds = meta.setdefault("kinds", {}) - kinds[kind] = int(kinds.get(kind, 0)) + 1 - events = meta.setdefault("events", []) - events.append({ - "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - "kind": kind, - "repo": project_root.name if project_root else None, - **({"detail": detail} if detail else {}), - }) - del events[:-20] - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - except OSError: - pass - - -def should_suppress_event(key: str, window: float = 2.0) -> bool: - """事件级双副本去重:同一 key 在窗口内重复触发只执行一次。 - - 双副本(partme-ai + full-stack-plugins)同时启用时每个钩子事件会跑两遍 - (PostToolUse 有文件级去重,这里给 PreToolUse/UserPromptSubmit 用)。key - 由调用方带 hook 事件 id(tool_use_id/session_id)构造;测试 payload 通常 - 不带这些字段——此时调用方不调用本函数,保持旧行为。 - """ - import hashlib - import time as _time - path = codeguard_home() / "hook_dedup.json" - now = _time.time() - try: - path.parent.mkdir(parents=True, exist_ok=True) - # 并发安全:UPS 与 PreToolUse 两条钩子在数秒窗口内先后读写本文件, - # 裸 read-modify-write 会互相覆盖(丢抑制事件 → 双份报告)或读到半截 - # JSON(ValueError → 去重静默失效)。进程锁 + tmp+replace 原子替换。 - # 时间戳用墙钟:monotonic 跨重启回绕,持久化文件不能存。 - lock_path = path.with_name(path.name + ".lock") - with open(lock_path, "w") as lock: - if fcntl: - fcntl.flock(lock.fileno(), fcntl.LOCK_EX) - dedup = {} - if path.exists(): - dedup = json.loads(path.read_text(encoding="utf-8")) - h = hashlib.sha1(key.encode("utf-8", "replace")).hexdigest()[:24] - last = dedup.get(h) or {} - stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] - for k in stale: - dedup.pop(k, None) - suppress = now - last.get("ts", 0) < window - dedup[h] = {"ts": last.get("ts", now)} if suppress else {"ts": now} - tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") - tmp.write_text(json.dumps(dedup), encoding="utf-8") - os.replace(tmp, path) - return suppress - except (OSError, ValueError): - return False - - -def summarize_failures(failures: list) -> str: - """问题综述(一行,用作标题/通知标题):哪几个生态、什么性质的问题""" - langs = "、".join(f[0] for f in failures) - return f"codeguard ❌ 提交门禁未通过:{langs} 共 {len(failures)} 个语言生态有 lint 问题" - - -REPORT_MAX_CHARS = 3000 # gate_directive 总长硬上限:宿主会把超长 stderr 从尾部 - # 截断,指令段曾因此整体丢失(实测报告只见到首段报错) - - -def _squeeze(text: str, budget: int) -> str: - """保头保尾截断:尾部含「完整输出: /tmp/…」日志路径,砍中段也不能丢。""" - if len(text) <= budget: - return text - keep_tail = min(150, budget // 3) - keep_head = max(0, budget - keep_tail - 10) - return text[:keep_head] + "\n……(中略)……" + text[-keep_tail:] - - -def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[str]: - """每个语言的细节块。fix_first=True 把「怎么修/安装」排在长 detail 之前—— - 报告被宿主截断时,行动指引必须比 linter 原始输出先存活下来。""" - blocks: list[str] = [] - for lang, detail, fix, hint in failures: - block = [f"【{lang}】具体问题:"] - tail = [ - f" ▶ 怎么修: {fix}", - f" ▶ 未安装工具时先安装: {hint}", - ] - if lang == "python" and "[*]" in (detail or ""): - tail.append(" ▶ 含 [*] 可自动修复项: ruff check --fix <涉及路径>") - body = detail if detail else " lint 退出码非零,无文本输出" - if fix_first: - block += tail + [body] - else: - block += [body] + tail - block.append("─" * 60) - blocks.append("\n".join(block)) - return blocks - - -def format_failure_report(failures: list) -> str: - """渲染「综述 + 细节」两段式报告(CLI/调试面)。 - - 第一行 = 问题综述(宿主 UI 通常取首行作标题);随后是细节, - 不再重复综述内容——修复"标题和详情一样"的问题。 - """ - lines = [ - summarize_failures(failures), - "─" * 60, - ] - lines.extend(_failure_detail_blocks(failures)) - lines.append(f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py") - return "\n".join(lines) - - -def gate_directive(failures: list, project_root: Path | str | None = None) -> str: - """给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户。 - - project_root:本次门禁的目标仓库根——cwd 漂移会让钩子扫到"非目标仓" - (实测:在 codex 仓 cwd 下提交 opencli,POM 4.1.0 报错让人以为改错了 - 文件),首行显式标注目标仓库可第一时间发现扫错对象。 - - 结构 = 综述(首行契约)→ 强制指令(前置!)→ 每语言细节(fix 先于 - detail)→ 修复入口;整体压到 REPORT_MAX_CHARS 内。指令必须前置:宿主把 - 超长 stderr 从尾部截断,此前指令在报告末段,长报告下 AI 只看到首段 linter - 报错、「拆两次调用」与 skipGate 逃生门全部丢失(实测)。首行仍为综述、 - 综述不重复、含「具体问题」——run_all/硬门禁契约保持。 - """ - head_repo = f"本次门禁目标仓库: {Path(project_root).resolve()}\n" if project_root else "" - header = [ - summarize_failures(failures), - f"codeguard v{CODEGUARD_VERSION}", - head_repo + "─" * 60, - "**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n" - + "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash " - "调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与" - "「提交」拆成两次独立的工具调用,修完再单独执行提交。\n" - + "**⚠️ 如果报错涉及 target/、dist/、build/ 下的构建产物**:请先单独" - "运行清理命令(如 `rm -rf target/reports`,不含 git commit/push)," - "清理完成后再重试提交——本门禁在命令执行前拦截,此前命令链中的清理" - "步骤不会被执行。\n" - + "请立即处理:1) 按下面「怎么修」逐项修复(能自动修复的先跑自动修复命令);" - "2) 纯 lint 类修复可直接继续、不必逐项追问;但凡涉及付费、发布、删除、" - "密钥、或跨出本仓的操作,必须先征得用户同意再执行;" - "3) 修复完成后重新执行用户要做的提交操作。\n" - + "确需绕过(仅用户明确要求时):**单次豁免**用 `git -c codeguard.skipGate=true commit …`" - "(不落配置、无残留,推荐);**仓库级豁免**在该仓库执行 git config codeguard.skipGate true," - "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " - "只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。" - "注意:仓库级豁免对该克隆**所有分支**生效且跨会话残留,务必按上方说明 unset 恢复。", - "─" * 60, - ] - footer = f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py" - head = "\n".join(header) - budget = max(400, REPORT_MAX_CHARS - len(head) - len(footer) - 64) - blocks = _failure_detail_blocks(failures, fix_first=True) - used, kept = 0, [] - for block in blocks: - remain = budget - used - if remain <= 0: - kept.append("…(其余语言的问题明细已省略,按上方怎么修逐语言处理)") - break - if len(block) > remain: - block = _squeeze(block, remain) - kept.append(block) - used += len(block) + 1 - return "\n".join([head, *kept, footer]) diff --git a/hooks/post_tool_lint.py b/hooks/post_tool_lint.py index 224dd31..1d91abb 100644 --- a/hooks/post_tool_lint.py +++ b/hooks/post_tool_lint.py @@ -18,6 +18,11 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hooks/ 的上级 = 插件根 sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) +from codeguard import hook_state +from codeguard.execution import execute +from codeguard.fingerprint import check_identity +from codeguard.hook_state import codeguard_home +from codeguard.planning import scoped_plan from detect_lang import ( # ensure_user_path/load_user_config 实际定义:scripts/paths.py、scripts/user_config.py LANG_COMMANDS, detect_language, @@ -26,78 +31,29 @@ load_user_config, project_uses_linter, ) -from gate_lib import codeguard_home, session_state_path -from scope import ( # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) - is_build_artifact, - scope_cmd, -) +from scope import is_build_artifact # 双副本去重:同一插件可能以多个 marketplace 副本安装(partme-ai/ 与 # full-stack-plugins/ 各一份,钩子双份触发——实测),用户级固定路径跨副本共享 -DEDUP_FILE = codeguard_home() / "hook_dedup.json" +# 路径按调用时的 CODEGUARD_HOME 解析;保留覆盖点供嵌入调用者隔离存储。 +DEDUP_FILE: Path | None = None + + +def _dedup_path() -> Path: + return DEDUP_FILE or codeguard_home() / "file_dedup.json" def _state_path() -> Path: - """会话状态:现行 ~/.codeguard/session_state.json;回退读旧插件根文件。""" - modern = session_state_path() - if modern.exists() or not (PLUGIN_ROOT / ".session_state.json").exists(): - return modern - return PLUGIN_ROOT / ".session_state.json" + """旧无 ID 状态可回退;带会话 ID 的新统计绝不认领无归属旧数据。""" + return hook_state.state_path(PLUGIN_ROOT / ".session_state.json") def bump_state(lang: str, passed: bool, auto_fixed: bool = False) -> None: - """累计本会话 lint 结果(Stop 钩子读取汇总);写失败静默忽略""" - state = {} - try: - path = _state_path() - if path.exists(): - state = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - pass - entry = state.get(lang) - if not isinstance(entry, dict) or "total" not in entry: - # 状态文件可能被 _notify 单独写过(只有 last_notify 的残缺条目)—— - # 直接 entry["total"] 会 KeyError 被 fail-open 吞掉、输出全空(实测)。 - entry = dict(entry) if isinstance(entry, dict) else {} - entry.update({"total": 0, "passed": 0, "failed": 0, "auto_fixed": 0}) - state[lang] = entry - entry["total"] += 1 - entry["passed" if passed else "failed"] += 1 - if auto_fixed: - entry["auto_fixed"] += 1 - try: - path = _state_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - except OSError: - pass + hook_state.bump_state(_state_path(), lang, passed, auto_fixed) def _notify_cooldown_ok(lang: str, cooldown_s: int = 60) -> bool: - """同语言通知冷却:60s 内已弹过则抑制(additionalContext 注入不受影响)。""" - import time - state = {} - try: - path = _state_path() - if path.exists(): - state = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - pass - entry = state.get(lang) - if not isinstance(entry, dict): - entry = {} - now = time.time() - if now - entry.get("last_notify", 0) < cooldown_s: - return False - entry["last_notify"] = now - state[lang] = entry - try: - path = _state_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - except OSError: - pass - return True + return hook_state.notification_allowed(_state_path(), lang, cooldown_s) def mac_notify(title: str, message: str) -> None: """macOS 系统通知(用户视觉提醒;非 macOS 静默跳过)""" @@ -112,38 +68,8 @@ def mac_notify(title: str, message: str) -> None: ) -def should_suppress_duplicate(file_path: str) -> bool: - """双副本去重:2 秒内同路径且文件未变化的重复触发静默跳过。 - - 两个 marketplace 副本各挂一份 PostToolUse 钩子时,同一 Write 事件 - 会先后起两个钩子进程——第二份的输出对 AI/用户是纯重复。判定键为 - (路径, 文件 mtime_ns):内容变了(mtime 变了)不吞,照常检查。 - """ - import time as _time - try: - mtime = Path(file_path).stat().st_mtime_ns - except OSError: - return False - now = _time.monotonic() - dedup = {} - try: - DEDUP_FILE.parent.mkdir(parents=True, exist_ok=True) - if DEDUP_FILE.exists(): - dedup = json.loads(DEDUP_FILE.read_text()) - last = dedup.get(file_path) or {} - if last.get("mtime_ns") == mtime and now - last.get("ts", 0) < 2.0: - dedup[file_path] = {"mtime_ns": mtime, "ts": now} - DEDUP_FILE.write_text(json.dumps(dedup)) - return True - dedup[file_path] = {"mtime_ns": mtime, "ts": now} - # 清理陈旧条目(防无限增长) - stale = [k for k, v in dedup.items() if now - v.get("ts", 0) > 60] - for k in stale: - dedup.pop(k, None) - DEDUP_FILE.write_text(json.dumps(dedup)) - except OSError: - return False - return False +def should_suppress_duplicate(file_path: str, identity: str | None = None) -> bool: + return hook_state.completed_file_check(file_path, _dedup_path(), identity) def is_ejs_template(file_path: str) -> bool: @@ -158,13 +84,8 @@ def is_ejs_template(file_path: str) -> bool: def run(cmd: list[str], cwd: Path, timeout: int = 300) -> tuple[int, str, str]: - try: - proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=False) - return proc.returncode, proc.stdout, proc.stderr - except subprocess.TimeoutExpired: - return 124, "", f"timeout after {timeout}s" - except FileNotFoundError: - return 127, "", "command not found" + """保存钩子的兼容执行入口,不拥有独立的故障判定。""" + return execute(cmd, cwd, timeout).as_tuple() def read_payload() -> dict: @@ -204,8 +125,14 @@ def should_skip(file_path: str, languages: list[str]) -> tuple[bool, str]: def main() -> int: - ensure_user_path() # 高频钩子:仅补静态目录(零开销),linter 找得到才跑得起来 + from codeguard.hook_state import session_scope payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + ensure_user_path() # 高频钩子:仅补静态目录(零开销),linter 找得到才跑得起来 file_path = extract_file_path(payload) if not file_path: return 0 @@ -218,10 +145,6 @@ def main() -> int: if skip: return 0 - # 双副本去重:另一 marketplace 副本的钩子刚查过同一份文件则静默 - if should_suppress_duplicate(file_path): - return 0 - # 文档类语言不拦 AI 写作流(AI 产出的报告/文档常不合 lint 严格规则, # 拦截会造成死循环);提交门禁阶段仍有宽松校验 if lang == "markdown": @@ -254,15 +177,15 @@ def main() -> int: timeout = cfg.get("lint_timeout_seconds", 120) - def materialize(cmd: list[str]) -> list[str]: - """物化到【单文件】作用域:{file} 替换 + 全仓扫描 token(`.`、`**/*.md`) - 收敛到本文件。此前 lint/format 命令都带 `.`——一次保存触发全仓检查, - format 更是全仓 `ruff check . --fix`,静默重写几十个无关文件(实测 - 多次:写 1 个文件、git 树炸出 30+ 漂移,AI 后续读写全部过期)。 - """ - return scope_cmd(cmd, project_root, single_file=str(file_path)) - - rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) + # 保存范围显式为 save;先冻结检查命令,修复后原样复检。 + lint_plan = scoped_plan(lint_cmd, project_root, scope="save", files=[str(file_path)]) + lint_argv = list(lint_plan.commands[0].argv) + def identity(): + return check_identity(project_root, cfg, {lang: cmd_def}, extra_files=[file_path]) + checked_identity = identity() + if checked_identity is not None and should_suppress_duplicate(file_path, checked_identity): + return 0 + rc, stdout, stderr = run(lint_argv, cwd=lint_plan.cwd, timeout=timeout) from verdict import UNVERIFIED, lint_verdict verdict, reason = lint_verdict(rc, lint_cmd, stdout + stderr) @@ -275,6 +198,8 @@ def materialize(cmd: list[str]) -> list[str]: return 0 if rc == 0: + if checked_identity is not None and checked_identity == identity(): + hook_state.record_file_check(file_path, _dedup_path(), checked_identity) bump_state(lang, passed=True) # 注入 AI 上下文:告诉 AI 该文件通过了门禁 print(json.dumps({ @@ -309,7 +234,8 @@ def _porcelain() -> set[str]: return set() before = _porcelain() - frc, _, _ = run(materialize(fmt_cmd), cwd=project_root, timeout=timeout + 60) + fix_plan = scoped_plan(fmt_cmd, project_root, scope="save", files=[str(file_path)]) + frc, _, _ = run(list(fix_plan.commands[0].argv), cwd=fix_plan.cwd, timeout=timeout + 60) if frc == 0: auto_fixed = True after = _porcelain() @@ -317,7 +243,8 @@ def _porcelain() -> set[str]: name for name in (after - before) if not name.rstrip("/").endswith(Path(file_path).name) ) - rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) + checked_identity = identity() + rc, stdout, stderr = run(lint_argv, cwd=lint_plan.cwd, timeout=timeout) verdict, reason = lint_verdict(rc, lint_cmd, stdout + stderr) if verdict == UNVERIFIED: @@ -328,6 +255,8 @@ def _porcelain() -> set[str]: }}, ensure_ascii=False)) return 0 passed = rc == 0 + if checked_identity is not None and checked_identity == identity(): + hook_state.record_file_check(file_path, _dedup_path(), checked_identity) bump_state(lang, passed=passed, auto_fixed=auto_fixed) if passed: diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 583674f..66bf79b 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -10,9 +10,10 @@ """ from __future__ import annotations +import contextlib +import io import json import os -import subprocess import sys from pathlib import Path @@ -20,6 +21,40 @@ sys.path.insert(0, str(PLUGIN_ROOT / "scripts")) sys.path.insert(0, str(PLUGIN_ROOT / "hooks")) +from codeguard.git_context import ( + _command_indirect, + _fallback_roots, + _filter_fallback_roots, + _guarded_mode, + _is_git_repo, + _repo_root_of, + is_guarded, + resolve_project_roots, + staging_intent, +) +from codeguard.git_syntax import ( + _analyze_segment, + _collect_subs, + _flatten_substitutions, + _git_c_path, + _git_seg_parts, + _git_side_effect_sub, + _normalize_segment, + _segment_is_git_side_effect, + chain_skip_gate, + inline_skip_gate, +) + +# 保留旧导入面;实现分别属于纯语法与仓库观察适配器。 +__all__ = [ + "GUARDED_PATTERNS", "_analyze_segment", "_collect_subs", "_command_indirect", + "_fallback_roots", "_filter_fallback_roots", "_flatten_substitutions", "_git_c_path", + "_git_seg_parts", "_git_side_effect_sub", "_guarded_mode", "_is_git_repo", + "_normalize_segment", "_repo_root_of", "_segment_is_git_side_effect", "chain_skip_gate", + "extract_command", "inline_skip_gate", "is_guarded", "main", "read_payload", + "resolve_project_roots", "staging_intent", +] + from detect_lang import ( # # ensure_user_path/load_user_config 实际定义:scripts/paths.py、scripts/user_config.py ensure_user_path, load_user_config, @@ -30,10 +65,9 @@ gate_directive, record_skip_event, run_gate, - should_suppress_event, skip_gate_via_git_config, # 规范实现已上移 gate_lib(UPS 也要用) ) -from scope import changed_files +from git_snapshot import SnapshotError # 拦截的 git 子命令(避免误拦 git status/diff/log 等只读命令) GUARDED_PATTERNS = ("git commit", "git push") @@ -54,545 +88,44 @@ def extract_command(payload: dict) -> str: return str(cmd) if cmd else "" -def _is_git_repo(p: Path) -> bool: - try: - proc = subprocess.run( - ["git", "-C", str(p), "rev-parse", "--git-dir"], - capture_output=True, check=False, text=True, timeout=10, - ) - except (subprocess.TimeoutExpired, OSError): - return False - return proc.returncode == 0 - - -def _flatten_substitutions(command: str) -> str: - """把 `$(...)` 与反引号的内层文本并入待扫面(按 shell 语义它们会真的执行)。 - - 漏检实例(实测静默放行):`ro=$(git push origin b 2>&1)`、`` x=`git commit` `` - ——段首是赋值词/反引号而非 git,切段扫描永远看不到子命令。内层可能还有 - 内层(`$(echo $(git push))`),工作队列递归展开,深度上限防畸形输入死循环。 - 能力边界:单引号内的字面量(`'$(git push)'` 不执行)不做引号语义区分, - 宁可多拦不漏拦——误拦方向由 lint 门禁自身的 delta 面兜底(不改文件不红)。 - """ - parts: list[str] = [] - queue: list[str] = [command] - seen = 0 - while queue and seen < 50: - text = queue.pop(0) - seen += 1 - parts.append(text) - i = 0 - while True: - j = text.find("$(", i) - if j < 0: - break - depth, k = 1, j + 2 - while k < len(text) and depth: - if text[k] == "(": - depth += 1 - elif text[k] == ")": - depth -= 1 - k += 1 - i = j + 2 - if depth == 0: - inner = text[j + 2:k - 1] - if inner and inner not in parts: - queue.append(inner) - if "`" in text: - seg = text.split("`") - for inner in seg[1::2]: - if inner and inner not in parts: - queue.append(inner) - return "\n".join(parts) - - -def resolve_project_roots(command: str) -> list[Path]: - """顺序扫描命令链,收集每个 git commit/push 段各自的仓库边界。 - - 链式发布(cd plugins && git commit && cd minimax && git push)操作 - 多个仓库——每个 git 段的边界 = `git -C ` 显式指定的仓,否则取它 - 之前最近的 cd(且必须是 git 仓);无 cd 则用 cwd(需是 git 仓)。 - 去重保序,找不到任何边界返回 []。 - - **必须与 is_guarded 同源判定**(`_git_side_effect_sub` 归一化后判子命令): - 此前这里用原始 tokens[0]=="git",`git -C /repo push`、`FOO=1 git push`、 - `sudo git push` 虽被 is_guarded 命中却解析出 roots=[],main() 见空直接 - 放行——归一化修复在 roots 层被击穿(实测三形态全部静默通过)。 - """ - import re - command = _flatten_substitutions(command) - roots: list[Path] = [] - last_cd: Path | None = None - for seg in re.split(r"&&|\|\||;|\n", command): - seg = seg.strip() - if not seg: - continue - m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) - if m: - target = Path(m.group(1).strip("\"'")) - # cd 到非 git 目录(如 workspace 根)后边界失效——回退 cwd(与 shell 语义一致) - last_cd = target if target.is_dir() and _is_git_repo(target) else None - continue - if _git_side_effect_sub(seg) is None: - continue - c_path = _git_c_path(seg) - if c_path is not None: - root = c_path if c_path.is_dir() and _is_git_repo(c_path) else None - else: - root = last_cd or (Path(os.getcwd()) if _is_git_repo(Path(os.getcwd())) else None) - if root and root not in roots: - roots.append(root) - # 有意行为(勿"修掉"):cd 到非 git 目录后 last_cd=None,git 段回退用 - # 调用方 cwd——与 shell 语义一致(进非仓后 git 在原 cwd 执行)。 - return roots - - -def _fallback_roots(cwd: Path) -> list[Path]: - """cwd 不是 git 仓时,扫 cwd 一层子目录找 git 仓作兜底(monorepo 模式)。 - - 返回去重后保序的 git 仓列表(不含 cwd 自身)。 - - 实现注:`_is_git_repo(p)` 调 `git -C p rev-parse --git-dir` —— git 在 - p 不是仓时会沿父目录递归找 .git 并返回成功;这会让 cwd 是任何子目录时 - 全部子目录被错判为 git 仓。fallback 必须用更严格的"仓内 .git 存在"判定: - `(child / ".git").exists()`。`_is_git_repo` 在主流程只在 cwd 自身或显式 - cd 后的目标目录上调用,子目录递归副作用在主流程可控;这里只暴露 cwd - 自身的判定语义,故 fallback 走自己独立的"子目录级 .git 存在"检查。 - """ - if not cwd.is_dir(): - return [] - cwd_resolved = cwd.resolve() - found: list[Path] = [] - for child in sorted(cwd.iterdir(), key=lambda p: p.name): - if not child.is_dir(): - continue - child_resolved = child.resolve() - if child_resolved == cwd_resolved: - continue - # 严格子目录级检查:仓根的 .git 必须真在该子目录里 - if (child / ".git").exists(): - found.append(child_resolved) - return found - - -_ASSIGN_RE = None # 延迟编译,保持模块零顶层 re 依赖 -_ASSIGN_PATTERN = r"^[A-Za-z_][A-Za-z0-9_.-]*=" -_BARE_WRAPPERS = ("env", "sudo", "nohup", "command", "time", "nice", "ionice", "setsid", "stdbuf") -# shell 控制结构引导词:`if git push; then` / `for x; do git push; done` 段首是 -# 控制词而非 git(`;` 切段后 `do git push…` 同理)——不剥就漏拦(实测四种形态 -# 全部静默通过)。只在段首序列出现时剥,不影响普通命令中的同名词。 -_SHELL_LEADERS = ("if", "then", "else", "elif", "while", "until", "do", "!") -_GIT_VALUE_FLAGS = ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", - "--namespace", "--config-env", "--attr-source") -_GIT_BOOL_FLAGS = ("--no-pager", "--bare", "--literal-pathspecs", - "--no-optional-locks", "--shallow-file", "--exec-path") - - -def _analyze_segment(seg: str) -> tuple[list[str], str | None]: - """归一化一段 shell 命令:返回 ([prog, sub, …] 形状的 tokens, `git -C` 的仓路径)。 - - 剥除顺序(与 shell 求值同序):控制结构引导词 → 环境赋值/env/裸 wrapper 前缀 - → git 全局选项。同时捕获 `-C `——它是该 git 段的显式仓库边界。 - 漏检实例(实测静默放行):`VAR=1 git push` 段首是 `VAR=1`;`git -C path push` - tokens[1] 是 `sudo`;`if git push; then` 段首是 `if`。剥完后若段首仍非 git - 则返回原 tokens(由调用方判定为非 git 段)。 - 能力边界(docstring 如实声明):带值的 wrapper 参数(`sudo -u root …`) - 不做完整解析,只剥裸 wrapper token。 - """ - import re as _re - tokens = seg.strip().split() - i, n = 0, len(tokens) - while i < n and tokens[i] in _SHELL_LEADERS: - i += 1 - while True: - while i < n and _re.match(_ASSIGN_PATTERN, tokens[i]): - i += 1 - if i < n and tokens[i] == "env": - i += 1 - continue - break - while i < n and tokens[i] in _BARE_WRAPPERS: - i += 1 - if i >= n: - return tokens, None - if tokens[i].strip("\"'") != "git": - return tokens[i:], None - i += 1 - c_path: str | None = None - while i < n: - tok = tokens[i].strip("\"'") - if tok in _GIT_BOOL_FLAGS: - i += 1 - continue - if tok in _GIT_VALUE_FLAGS: - if tok == "-C" and i + 1 < n: - c_path = tokens[i + 1].strip("\"'") - i += 2 - continue - if tok.startswith(("-C", "-c", "--git-dir", "--work-tree", "--exec-path")) and len(tok) > 2: - if tok.startswith("-C") and len(tok) > 2: - c_path = tok[2:].strip("\"'") - i += 1 - continue - break - # 保留 "git" 作为首 token(调用方按 [prog, sub, …] 形状判定),只剥掉前面的 - # 前缀与后面的全局选项;`git` 裸段(无子命令)返回 ["git"] → len<2 → None。 - root = Path(c_path).resolve() if c_path else None - return ["git"] + tokens[i:], (root if root else None) - - -def _normalize_segment(seg: str) -> list[str]: - """归一化形状(丢弃 -C 捕获)——保持既有调用面。见 _analyze_segment。""" - tokens, _c = _analyze_segment(seg) - return tokens - - -def _git_c_path(seg: str) -> Path | None: - """该段 `git -C ` 显式指定的仓库(相对路径按字面保留,由调用方判定)。""" - _tokens, c = _analyze_segment(seg) - return c - - -def _git_side_effect_sub(seg: str) -> str | None: - """归一化后,段的子命令为 commit|push 则返回之,否则 None。 - - 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`); - 前缀与 git 全局选项见 `_normalize_segment`。 - """ - tokens = _normalize_segment(seg) - if len(tokens) < 2: - return None - first = tokens[0].strip("\"'") - second = tokens[1].strip("\"'") - return second if first == "git" and second in ("commit", "push") else None - - -# 全小写比较集:git config 键名大小写不敏感(codeguard.skipGate ≡ codeguard.skipgate) -_SKIP_GATE_ASSIGN = ("codeguard.skipgate=true", "codeguard.skipgate=1", "codeguard.skipgate=yes") - -# git 全局参数里带一个独立值的 flag(`git -C config …` 的 是 flag -# 值不是子命令);带 `=` 形式(--git-dir=/x)无独立值。 -_GIT_GLOBAL_VALUE_FLAGS = frozenset( - ("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--exec-path")) - -# git config 查询/删除形态——只读/清理动作,不构成豁免意图 -_QUERY_CONFIG_FLAGS = ("--get", "--get-all", "--get-regexp", "--unset", "--unset-all", - "--list", "-l") - - -def _git_seg_parts(tokens: list[str]) -> tuple[str, int, list[str]] | None: - """按 git 全局参数语法切分单个命令段:返回 (子命令, 子命令下标, 子命令前全局参数)。 - - git 语法 = `git [全局 flag [值]]* <子命令> [参数…]`。**子命令之后的 token - 一律不是全局配置**——`git commit -m "… -c codeguard.skipGate=true …"` 的 - 消息文本、`git commit -m "config codeguard.skipGate true"` 都不构成豁免 - 意图(防的是危险方向的误判:文本里出现豁免短语 → 门禁被静默关掉)。 - 找不到子命令(纯 `git` / 全是 flag)返回 None。 - """ - if len(tokens) < 2 or tokens[0] != "git": - return None - i, pre = 1, [] - while i < len(tokens): - tok = tokens[i] - if tok.startswith("-"): - pre.append(tok) - i += 1 - if (tok.split("=", 1)[0] in _GIT_GLOBAL_VALUE_FLAGS and "=" not in tok - and i < len(tokens)): - pre.append(tokens[i]) - i += 1 - continue - return tok, i, pre - return None - - -def inline_skip_gate(command: str) -> bool: - """`git -c codeguard.skipGate=true …`(全局参数位)= **显式单次豁免意图**。 - - `git config` 豁免是仓库级、需手动 unset(忘记 unset = 门禁永久静默失效, - stop_summary 已在提醒);`-c` 内联是单次、随命令消亡、不留配置残留—— - 更适合"这一条命令我知道有环境问题,放行"的场景。此前 `-c k=v` 被 - `_analyze_segment` 剥掉后 matcher 反而漏拦(守卫绕过口),本函数把该形态 - 识别为**有审计的正门**:调用方放行时必须记 record_skip_event("inline-skipGate")。 - - 只认子命令之前的 `-c` 全局参数位(git 语法本义),消息文本/脚本参数里的 - 同名字符串不构成豁免;value 精确匹配 _SKIP_GATE_ASSIGN。 - """ - text = _flatten_substitutions(command) - import re as _re - for seg in _re.split(r"&&|\|\||;|\n", text): - tokens = [t.strip("\"'") for t in seg.strip().split()] - parts = _git_seg_parts(tokens) - if parts is None: - continue - _, _, pre = parts - for i, tok in enumerate(pre): - if tok == "-c" and i + 1 < len(pre) and pre[i + 1].lower() in _SKIP_GATE_ASSIGN: - return True - return False - - -def chain_skip_gate(command: str) -> bool: - """命令链里含 `git config codeguard.skipGate ` **设置**段 = 显式豁免意图。 - - 文档推荐的仓库级豁免是「set → 提交 → unset」,天然会被写进同一条链命令 - (`git config codeguard.skipGate true && git commit … && git config --unset …`)。 - 此前链里只要有一个 git commit/push 段就整链被拦——set 段自己都没跑成, - 文档绕过首用必败。识别为显式豁免意图放行,并记 "chain-skipGate" 审计。 - - 只认子命令就是 `config` 的**设置**形态(--get/--unset/--list 查询不算), - key 精确匹配 codeguard.skipGate(大小写不敏感)、value 须为真值;消息文本 - 里出现的同名字符串不构成豁免(见 _git_seg_parts 的子命令边界)。 - """ - text = _flatten_substitutions(command) - import re as _re - for seg in _re.split(r"&&|\|\||;|\n", text): - tokens = [t.strip("\"'") for t in seg.strip().split()] - parts = _git_seg_parts(tokens) - if parts is None: - continue - sub, si, _pre = parts - if sub != "config": - continue - rest = tokens[si + 1:] - if any(t in _QUERY_CONFIG_FLAGS for t in rest): - continue - args = [t for t in rest if not t.startswith("-")] - if len(args) < 2: - continue - key, value = args[0], args[1] - if key.lower() == "codeguard.skipgate" and value.lower() in ("true", "1", "yes"): - return True - return False - - -def _segment_is_git_side_effect(seg: str) -> bool: - return _git_side_effect_sub(seg) is not None - - -_SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") -_INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") - - -def _command_indirect(command: str) -> bool: - """间接提交识别:解释器跑的脚本文件 / -c 内联代码里含 git commit|push。 - - 硬门禁此前只看外层命令文本——`bash runner.sh`(脚本体内 git commit) - 完全绕过拦截,实测用 /tmp runner 连推 4 次全部漏网。这里对一层间接 - 做静态扫描:脚本文本按同款分隔符切段后,任一段段首为 git+commit|push - 即命中。更深的 subprocess 拼接不在静态扫描能力内,不夸大承诺。 - """ - import re - for seg in re.split(r"&&|\|\||;|\n", command): - tokens = seg.strip().split() - if not tokens: - continue - prog = tokens[0].rsplit("/", 1)[-1] - if prog not in _INTERPRETERS: - continue - rest = tokens[1:] - if "-c" in rest: - body = " ".join(rest[rest.index("-c") + 1:]) - if any(_segment_is_git_side_effect(s) - for s in re.split(r"&&|\|\||;|\n", body)): - return True - continue - for tok in rest: - if tok.startswith("-"): - continue - target = Path(tok) - if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: - break - try: - if target.stat().st_size > 1_000_000: - break - body = target.read_text(encoding="utf-8", errors="ignore") - except OSError: - break - if any(_segment_is_git_side_effect(s) - for s in re.split(r"&&|\|\||;|\n", body)): - return True - break - return False - - -def _collect_subs(text: str) -> list[str]: - import re - text = _flatten_substitutions(text) - return [ - sub for seg in re.split(r"&&|\|\||;|\n", text) - if (sub := _git_side_effect_sub(seg)) - ] - - -def _repo_root_of(p: Path) -> Path | None: - """从路径向上找 .git 所在的仓库根(纯文件系统,不起进程)。""" - cur = p if p.is_dir() else p.parent - for _ in range(10): - if (cur / ".git").exists(): - return cur - if cur.parent == cur: - return None - cur = cur.parent - return None - - -def staging_intent(command: str) -> tuple[tuple[str, ...], list[str]]: - """从命令链推断本次**真正会提交**的文件面(lanes)与 git add 显式路径。 - - PostToolUse 语义:PreToolUse 在整条命令执行**之前**触发,链中的 `git add` - 还没跑——必须按命令形态预测执行后的暂存区,同时**不把与本次提交无关的 - 工作树 WIP 算进来**(并行会话留在工作树的未暂存改动曾让纯 `git commit` - 被无关文件误拦): - - 链中无 git add、commit 无 -a → 只有暂存区会入库 → ("staged",); - - `git add -A/-a/-u/--all` 或无路径参数 → 未暂存/未跟踪将一并入库 → 三路; - - `git add ` → 暂存区 + 这些路径(add 尚未执行,路径可能还没staged); - - `git commit -a/-am/--all` → 暂存 + 已跟踪未暂存(git 不收未跟踪)→ 两路。 - 能力边界:路径含 glob/`:` 等价语法按字面子处理;相对路径找不到存在性 - 对应的仓根文件时由调用方丢弃(宁可少拦不误拦)。 - """ - import re - all_lanes = ("staged", "unstaged", "untracked") - lanes: set[str] = {"staged"} - extra: list[str] = [] - add_paths: list[str] = [] - add_all = False - add_tracked_only = False - last_cd: Path | None = None - text = _flatten_substitutions(command) - for seg in re.split(r"&&|\|\||;|\n", text): - seg = seg.strip() - if not seg: - continue - m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) - if m: - target = Path(m.group(1).strip("\"'")) - last_cd = target if target.is_dir() else None - continue - tokens = _normalize_segment(seg) - if len(tokens) < 2 or tokens[0] != "git": - continue - sub, rest = tokens[1], tokens[2:] - if sub == "add": - flags = [t for t in rest if t.startswith("-")] - paths = [t for t in rest if not t.startswith("-")] - if paths and not any(p.strip("\"'") in (".", "./", "*", ":/") for p in paths): - add_paths.extend(p.strip("\"'") for p in paths) - elif any(f in ("-u", "--update") for f in flags): - add_tracked_only = True - else: - add_all = True - elif sub == "commit" and any(t in ("-a", "-am", "--all") for t in rest): - lanes.update(("staged", "unstaged")) - if add_all: - lanes.update(all_lanes) - elif add_tracked_only: - lanes.update(("staged", "unstaged")) - for p in add_paths: - if Path(p).is_absolute(): - continue - cand_root = last_cd if last_cd is not None else Path.cwd() - # git add 的 pathspec 相对 shell 语境(last_cd 或 cwd)解析成绝对路径, - # 再换算成**仓库根**相对路径——changed_files 的 paths 全部相对仓根; - # cd 到仓库子目录(scripts/…)时相对 last_cd 会算错一截。 - try: - resolved = (cand_root / p).resolve() - except OSError: - continue - repo = _repo_root_of(resolved) or _repo_root_of(Path.cwd()) - if repo is None: - continue - try: - rel = resolved.relative_to(repo) - except ValueError: - continue - extra.append(str(rel)) - ordered = tuple(lane for lane in all_lanes if lane in lanes) - return ordered, extra - - -def _guarded_mode(command: str) -> str | None: - """门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时 - 推送面是提交面的超集,按更宽的面检查);未命中返回 None。 - - 直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。 - is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描, - 避免"命中了却选错面"的分叉。 - """ - subs = _collect_subs(command) - if not subs: - import re - for seg in re.split(r"&&|\|\||;|\n", command): - tokens = seg.strip().split() - if not tokens: - continue - prog = tokens[0].rsplit("/", 1)[-1] - if prog not in _INTERPRETERS: - continue - rest = tokens[1:] - if "-c" in rest: - body = " ".join(rest[rest.index("-c") + 1:]) - subs = _collect_subs(body) - if subs: - break - continue - for tok in rest: - if tok.startswith("-"): - continue - target = Path(tok) - if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: - break - try: - if target.stat().st_size > 1_000_000: - break - body = target.read_text(encoding="utf-8", errors="ignore") - except OSError: - break - subs = _collect_subs(body) - break - if subs: - break - if not subs: - return None - return "push" if "push" in subs else "commit" - - -def is_guarded(command: str) -> bool: - """精确匹配 git commit/push:直接命令 + 一层解释器间接。 - - 直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含 - "git push" 字面量),所以按分隔符切段、git 必须是段首命令词。 - 间接:解释器执行的脚本/内联代码按同规则扫一层。 - 命中后面(commit/push)由 _guarded_mode 决定,供门禁选 delta 面。 - """ - return _guarded_mode(command) is not None - - -def _filter_fallback_roots(roots: list[Path], mode: str, - lanes: tuple[str, ...] | list[str] | None) -> list[Path]: - """monorepo 兜底面收窄:只保留本次提交面非空的仓。 - - 会话实测:openclaw 提交时,兜底把 workspace 下所有子仓连同根上散落脚本 - 一起纳入检测面——无关仓的存量违规也变成拦路面(误伤与提交完全无关的 - 仓库)。无提交面的仓跳过;全部为空返回 [](调用方审计后放行)。 - - lanes-only:兜底场景没有显式 cd,`git add <相对路径>` 的 extra 属于 - 会话根而非任何子仓,不参与归属判定。 - """ - return [r for r in roots if changed_files(r, mode=mode, lanes=lanes)] def main() -> int: - ensure_user_path() + from codeguard.hook_state import ( + completed_event, + record_completed_event, + session_scope, + ) payload = read_payload() + with session_scope(payload): + if not payload.get("tool_use_id"): + return _main(payload) + # 事件 ID 与命令、会话/worktree 一起绑定;只复用已结束的检查, + # 第一个副本尚在检查时,第二个副本宁可重跑,也不能直接放行。 + key = "pre:" + str(payload["tool_use_id"]) + ":" + extract_command(payload) + cached = completed_event(key) + if cached is not None and cached["code"] == 2: + sys.stdout.write(cached["stdout"]) + sys.stderr.write(cached["stderr"]) + return cached["code"] + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + code = _main(payload) + out, err = stdout.getvalue(), stderr.getvalue() + # 只复用阻断结果,不缓存放行:无输出的 0 也可能来自显式豁免, + # 而未验证的 0 必须重跑。不能把事件去重缓存当作准确快照的替代。 + if code == 2: + record_completed_event(key, code, out, err) + sys.stdout.write(out) + sys.stderr.write(err) + return code + + +def _main(payload: dict) -> int: + ensure_user_path() command = extract_command(payload) - if not command or not is_guarded(command): - return 0 - - # 双副本去重:宿主带事件 id(生产 payload 的 tool_use_id)时第二副本静默; - # 测试 payload 不带 id → 不去重,保持旧行为。 - event_id = payload.get("tool_use_id") - if event_id and should_suppress_event(f"pre:{event_id}"): + mode = _guarded_mode(command) if command else None + if mode is None: return 0 if os.environ.get("CODEGUARD_SKIP_GATE"): @@ -600,7 +133,6 @@ def main() -> int: return 0 cfg = load_user_config() - mode = _guarded_mode(command) or "commit" roots = resolve_project_roots(command) # 静默跳过漏报修复:roots=[] 时扫描 cwd 一层子目录找 git 仓作兜底 # (monorepo 模式:workspace 根目录下多仓时,从根目录直跑 git push 不 @@ -659,13 +191,21 @@ def main() -> int: # 按命令链预测实际提交面(纯 commit → 仅 staged;add -A/-a → 三路), # 并入 git add 显式路径(add 尚未执行、暂存区还是旧的)。 - lanes, extra = staging_intent(command) pending_commit = "commit" in _collect_subs(command) # 每个被操作的仓库独立跑:linter 门禁 + 提交内容安全检查 # (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件) reports = [] for project_root in roots: + try: + lanes, extra = staging_intent(command, project_root=project_root) + except SnapshotError as exc: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", "additionalContext": + f"codeguard: git UNVERIFIED:{project_root} 无法准确预测暂存面:{exc};" + "请完成明确的暂存操作后单独提交,并复核检查结果。", + }}, ensure_ascii=False)) + continue # 保留删除路径用于影响分析;单文件 linter 自行过滤不存在的文件。 root_extra = list(extra) failures, _skipped = run_gate( diff --git a/hooks/stop_summary.py b/hooks/stop_summary.py index 3485abd..8ab5ced 100755 --- a/hooks/stop_summary.py +++ b/hooks/stop_summary.py @@ -9,7 +9,6 @@ """ from __future__ import annotations -import contextlib import json import sys from pathlib import Path @@ -25,15 +24,11 @@ def load_state() -> dict: - from gate_lib import session_state_path - - path = session_state_path() - target = path if path.exists() else LEGACY_STATE_FILE - if not target.exists(): - return {} + from codeguard.hook_state import state_path + from codeguard.storage import read_json try: - return json.loads(target.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): + return read_json(state_path(LEGACY_STATE_FILE)) + except OSError: return {} @@ -76,23 +71,20 @@ def _skipgate_left_enabled() -> bool: def main(payload: dict | None = None) -> int: - from gate_lib import should_suppress_event - + from codeguard.hook_state import observe_once, session_scope session_id = (payload or {}).get("session_id") - if session_id and should_suppress_event(f"stop:{session_id}"): - # 双副本:统计与清理由第一份完成,第二份静默(避免摘要×2、清两次)。 - return 0 - state = load_state() + with session_scope(payload or {}): + return observe_once(f"stop:{session_id}" if session_id else None, lambda: _main(payload)) + + +def _main(payload: dict | None = None) -> int: + from codeguard.hook_state import state_path + from codeguard.storage import take_json + state = take_json(state_path(LEGACY_STATE_FILE)) print(summarize(state)) if _skipgate_left_enabled(): print("[codeguard] ⚠️ git config codeguard.skipGate 仍为 true——该仓门禁处于豁免状态;" "若绕过已完成,请执行 git config --unset codeguard.skipGate 恢复") - # 会话结束清理状态(新旧两处都清) - from gate_lib import session_state_path - - for target in (session_state_path(), LEGACY_STATE_FILE): - with contextlib.suppress(OSError): - target.unlink(missing_ok=True) return 0 diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index ab710f0..b8d1ee8 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -27,6 +27,7 @@ import re as _re from detect_lang import ( # # ensure_user_path/load_user_config 实际定义:scripts/paths.py、scripts/user_config.py + LANG_COMMANDS, detect_languages, ensure_user_path, find_project_root, @@ -38,7 +39,6 @@ gate_directive, record_skip_event, run_gate, - should_suppress_event, skip_gate_via_git_config, summarize_failures, ) @@ -160,15 +160,21 @@ def _non_git_note() -> str: def main() -> int: + from codeguard.hook_state import session_scope payload = read_payload() + with session_scope(payload): + return _main(payload) + + +def _main(payload: dict) -> int: + from codeguard.fingerprint import check_identity + from codeguard.hook_state import completed_event, record_completed_event + user_text = str(payload.get("user_prompt") or payload.get("prompt") or "") if not is_trigger(user_text): return 0 - # 双副本去重:session_id + 文本 派生事件 key;测试 payload 无该字段→旧行为 session_id = payload.get("session_id") - if session_id and should_suppress_event(f"ups:{session_id}:{user_text[:200]}"): - return 0 ensure_user_path(from_login_shell=True) # GUI 宿主 PATH 不含用户级工具目录 # 逃生门:设置此环境变量后跳过提交门禁(用于确实需要绕过的场景) @@ -198,6 +204,15 @@ def main() -> int: # 2.2: 消息里提到了具体语言时只跑子集;没提到则回退全量探测(2.3)。 detected = detect_languages(project_root) subset = _detect_languages_in_text(user_text, detected) + def current_event_key(): + if not session_id: + return None + identity = check_identity(project_root, cfg, + {lang: LANG_COMMANDS.get(lang, {}) for lang in (subset or detected)}) + return f"ups:{session_id}:{user_text}:{identity}" if identity is not None else None + event_key = current_event_key() + if event_key and completed_event(event_key) is not None: + return 0 # 推送意图走 push 面(含未推送提交),提交意图走 commit 面。 # 文件面:软门禁**不传 lanes** → 三路宽口径(staged+未暂存+未跟踪)—— # 此刻还没有待执行命令可预测,按"工作树有待提交改动就提醒"注入;硬门禁 @@ -233,6 +248,8 @@ def main() -> int: "additionalContext": "\n\n".join(parts) } }, ensure_ascii=False)) + if event_key and not skipped and event_key == current_event_key(): + record_completed_event(event_key, 0, "", "") return 0 # 通过:注入明确的成功确认(无此行用户会以为门禁根本没跑); @@ -252,6 +269,8 @@ def main() -> int: ) } }, ensure_ascii=False)) + if event_key and not skipped and event_key == current_event_key(): + record_completed_event(event_key, 0, "", "") return 0 diff --git a/kimi.plugin.json b/kimi.plugin.json index a05f396..457f7ff 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.12.1", + "version": "0.13.0", "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/openspec/changes/refactor-codeguard-architecture/.openspec.yaml b/openspec/changes/refactor-codeguard-architecture/.openspec.yaml new file mode 100644 index 0000000..265da3d --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-23 diff --git a/openspec/changes/refactor-codeguard-architecture/design.md b/openspec/changes/refactor-codeguard-architecture/design.md new file mode 100644 index 0000000..9e630be --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -0,0 +1,284 @@ +# Design + +## Context + +基线 `61e2592`,工作树开始时干净,远端 main 一致。源码约 5,500 行(不计 vendor 与注册表)。`hooks/gate_lib.py` 999 行、`pre_tool_git_guard.py` 711 行。275 项 unittest 执行成功但 4 项历史 skip;ruff 通过。不是通过行数评价质量,而是用实际职责和失败传播路径定位问题。 + +已确认的结构性问题: + +| 现象 | 影响 | 改造落点 | +|---|---|---| +| CLI、保存 hook、Git gate、CVE 各自捕获进程异常 | 相同工具故障可能丢日志或直接抛出异常 | 一个带类型的执行边界,调用方只做领域解释 | +| gate_lib 混合快照、执行、缓存、文件规则、审计、报告 | 无法独立测试策略;任一修改牵连多入口 | 领域策略/应用编排/基础设施/报告分离 | +| Java 提前 return 后还有第二个 Java 分支 | 后一分支事实上不执行,维护者误以为已生效 | 统一语言计划;兼容行为由真实调用测试覆盖 | +| `_worktree_fingerprint` 对未跟踪文件 stat 两次 | 重复开销且掩盖边界 | 单源指纹与缓存契约 | +| JSON 状态裸读改写,多 hook 共享全局文件 | 并发丢计数、去重混用墙钟与 monotonic | 原子状态存储与清晰身份边界 | +| CLI/MCP、hook 对结果使用 dict/tuple/string 混合表达 | 每层靠字符串猜语义 | 内核类型化、仅在外部边界序列化 | +| README 仍称 0.12.0,旧架构声称 Java 默认执行测试 | 文档不能作为可靠执行指南 | 当前架构与历史证据分开、版本来源收敛 | + +未初始化 CodeGraph 前不将这些源码事实表述为图谱结论。已有其它 change 保持原状;其任务完成度不是本次实现证据。 + +## Goals / Non-Goals + +目标是让新的语言/入口只需实现其契约,不复制执行与判定;让代码能独立测试而不需 patch 半个 hook 模块。项目仍是小型 Python 插件,避免依赖注入容器、框架基类和额外服务。 + +不改产品分工:CodeGuard 产出确定性检查证据,不取代 FlowGuard 的 SDD 治理或 CodeReview 的语义审查。不扩张技能数量,不静默改变 fail-open、Java 默认跳过测试执行、Markdown 告警等政策。不承诺完整 shell 解释、安全沙箱、全语言真实工具链验收。 + +## Decisions + +### 1. 渐进替换,不并存两套业务实现 + +保留 `bin/codeguard`、现有脚本/钩子路径与 manifest;增加 `scripts/codeguard/` 内核包,兼容导出显式列出。选择目录内包而非立刻要求 pip install:已安装插件直接执行脚本的方式不能被打破。最终应以职责划分,而不是把原脚本原封不动搬成一个新巨型文件。 + +```mermaid +flowchart TD + Host[CLI / MCP / 五个 Hook 兼容入口] --> App[应用服务:检查 / 修复 / Git 门禁 / CVE] + App --> Plan[语言与项目适配器:生成检查计划] + App --> Policy[纯策略:判定 / 范围 / 安全规则] + App --> Runtime[执行边界:进程结果 / 日志 / 超时] + App --> Storage[基础设施:Git 快照 / 缓存 / 审计状态] + Plan --> Model[纯模型与值对象] + Policy --> Model + Runtime --> Model + Storage --> Model + App --> Report[结果呈现与协议序列化] +``` + +内核不得导入 hooks/CLI/MCP;框架注册和 stdout 输出只存在于适配入口。Python 标准库优先,MCP SDK仍是 MCP 入口的可选依赖。 + +### 2. 进程结果与代码判定分离 + +不可变执行结果记录 argv、cwd、退出码、stdout、stderr、启动/超时故障。执行器不把 rc=1 判断成代码违规、不跑安装、不隐式 shell。`lint_verdict` 和 CVE 报告解析负责各工具语义。保留旧 tuple 包装以兼容已有消费者,但真实执行逻辑仅一份。 + +超时保留部分输出;使用 UTF-8 替换无法解码的字节,防止 decode error 把整个门禁变成未知异常。缺命令=127、超时=124、其他启动 OS 错误=126;与已有 UNVERIFIED 语义一致。非预期程序错误仍向上暴露,不能用 except Exception 将开发缺陷吞成正常结果。 + +### 3. 策略差异必须显式,不能强行拉平 + +保存只检查单文件;CLI 仓检查可全量;Git 精确检查必须基于 index/HEAD;软提醒可缓存工作树结果。共用执行和计划基础,不将这些差异藏在一个越来越长的布尔参数列表中。已存在的基线比较能力保留,证据质量问题另做缺陷场景,不能把整条能力删除来换取测试通过。 + +当前已落地 `Command` / `CheckPlan` / `ProcessResult` / `PlanExecution` 四种不可变值对象;不引入服务基类或依赖注入容器。`scoped_plan` 将已选命令物化为 repo/delta/save 范围(复用现有 scope 的配置、排除规则),Java 适配器把原生 argv/env 转成同一计划。`execute_plan` 顺序执行至首个非零返回,保留已执行项;修复后复用相同计划。它不拥有 Git 基线豁免、Markdown advisory 或通知权限,门禁与保存应用继续显式执行这些策略。 + +三个通过真实进程复现的缺口同时收敛:Java 计划的 JAVA_HOME 不再被调用方丢弃;混合 shell/zsh 的第一项跳过不会掩盖 formatter 成功并阻止复检;delta >50 回退全量却无项目命令时明确 UNVERIFIED,不把 `{file}` 替换为空执行。以上遵循原来的范围和可信度契约,不扩大自动修复权限。 + +### 4. 标准化由自动化约束维持 + +真实进程测试覆盖超时/非零/编码/路径与副作用;真实 Git 测试覆盖内容快照;原生 stdio MCP 测试覆盖协议。架构门禁检查依赖方向和循环依赖,不以文件行数或类数量作为合格标准。所有既有测试保留行为断言;若私有导入变化,更新测试必须说明原契约对应的新边界。 + +`scripts/check_architecture.py` 仅解析第一方 scripts/hooks 的 AST,不执行被检查源码;检查所有静态导入(含函数内导入)、内核模块显式依赖白名单、相对导入与导入环。新增内核模块必须登记政策;不是 CodeGraph 的语义调用图替代,也不能保证运行时计算出来的动态加载关系。reporting 当前仍负责诊断日志落盘,所以只把 models/verdict 称为纯内核,不把整个包夸大为无 IO。CI 已配置独立检查步骤;真实远端运行留到发布验收。 + +### 5. 命令语法、仓库观察与路径政策分离 + +`git_syntax` 只解释已支持的静态命令形态,不查文件、不起进程;`git_context` 负责仓库探测、读取一层解释器脚本和映射提交面,仓库探测复用执行内核。`PreToolUse` 入口保留宿主 JSON、绕过审计、门禁调用及报告输出,旧 Python helper 导入继续可用。间接脚本读取只保留一个实现,主入口不再重复读取脚本来判定模式。 + +`path_policy` 是扫描排除表、产物识别与拟入库路径规则的单一来源;`scope` 和 `gate_lib` 只兼容导出/调用。策略接收已经确定的路径,不读取 Git,更不会执行建议中的移除操作。Git 快照采集仍在 git_snapshot,反馈格式仍在 reporting。 + +这不是完整 Shell 解释器:分隔符/引用/动态拼接和复杂相对 cd 仍有既有边界。本批保留非 Git cd 后回退调用方 cwd 的历史策略,但删去“等价于 Shell 语义”的错误说明;不凭模块拆分宣称修好了这些定位边界。后续门禁应用拆分与命令上下文完善须分别用真实 Git 场景验证。 + +参考:pre-commit 的 [Language Protocol 与通用执行](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/lang_base.py)、[Python 适配器](https://github.com/pre-commit/pre-commit/blob/main/pre_commit/languages/python.py)。只借鉴边界设计,不复制安装环境逻辑或新增插件生态协议。 + +### 6. 状态基础设施与已完成事件分开 + +`storage` 只提供带进程锁的原子读改写、消费和有界 JSONL;`hook_state` 保存归属与业务字段, +hooks 只负责取得 payload、进入会话作用域和输出。使用稳定旁路锁文件,避免替换数据 inode 后 +两个进程各锁不同文件。统计、冷却和绕过明细使用同一事务边界;Stop 原子取出当前作用域, +不会再在输出后删除另一进程刚写入的计数。无 session_id 继续旧共享状态,明确不保证会话隔离。 + +事件去重不等于权限缓存。观察 Hook 成功返回后才登记完成,异常允许重试;UPS 有 skipped 时 +保守地不登记。硬门禁只重放已完成拒绝,放行、未验证和执行中一律不复用;并发副本允许 +重复检查而不能预占后直接放行。保存检查只有已获得结论才记去重,未知可立即重试。 +文件内容/配置身份与检查期间变更由下一节的缓存策略负责。Windows 有锁分支但尚无实机证据。 + +### 7. 软结果缓存由输入身份与存储两层组成 + +`fingerprint` 使用 Git 的 NUL 路径列表及 index 内容条目,不读猜测的 `.git/index` 路径; +文件内容散列与 HEAD/upstream、worktree、配置、命令及工具身份共同构成输入摘要。 +`cache` 只接受版本明确、字段有效、时间未倒退、无 skipped 的完整结果,并复用 storage 的原子写入。 +门禁应用显式比较检查前后的输入身份,只有相同才写缓存;exact 路径不经过缓存。 +UPS 的文本去重也必须带输入身份,否则底层缓存正确而上层仍可能吞掉新代码失败。 +PostToolUse 在修复后重新捕获输入、复检后再决定是否可去重,不将修复前证据归给新内容。 + +内容散列会增加 IO,因此有 10,000 文件名/64 MiB 预算;超过预算或无法可靠读取时关闭缓存而不是 +截断身份或跳过检查。文件权限也进入内容身份。配置小文件直接重新读取,避免再维护容易漂移的 +进程内副本。缓存格式升级后不读取旧临时目录条目,但不删除用户旧缓存。 + +此设计不是任意工具输入的完备建模:忽略目录下的动态依赖、任意外部配置和远程状态可能不在 +显式输入集合中。缓存仅用于软提示;硬门禁/CI 的准确快照验证不能复用它。非 Git 保存继续检查但 +不做仓库身份缓存;Windows、多网络文件系统和超大型仓库的真实性能仍待独立验证。 + +### 8. 门禁应用与单语言检查分开 + +`gate` 负责准确/观察内容面、软缓存和保序汇总;`gate_checks.check_language` 接收明确输入并返回 +不可变 `GateOutcome`/`GateDecision`,不写全局备注或审计。`repository_policy` 拥有路径安全和 +显式豁免读取;`baseline` 拥有双跑基线;`spec_validation` 是可选外部规格校验适配器。 +`gate_lib` 只做旧入口导出,保留历史 helper 的兼容调用,不作为第二份应用服务。 + +```mermaid +flowchart TD + Hook[Hook 协议入口] --> Compat[gate_lib 兼容导出] + Compat --> Gate[gate 应用服务] + Gate --> Snapshot[准确 Git 快照 / 观察内容面] + Gate --> Checks[gate_checks 独立语言检查] + Checks --> Plan[共享计划与执行] + Checks --> Baseline[baseline 基线证据] + Checks --> Outcome[不可变 GateOutcome 与 GateDecision] + Outcome --> Merge[调用线程保序汇总] + Merge --> Audit[原 worktree + 会话归属审计] + Merge --> Report[reporting 反馈适配] + Compat --> Safety[repository_policy 路径安全与豁免] +``` + +这不仅改变文件位置:并发 worker 不再共享备注列表,也不依赖 ContextVar 隐式跨线程传播;审计在 +调用线程记录原 worktree、实际 execution_root 与 argv。基线工具或当前结果为未知时不进入豁免, +避免文本相似覆盖执行失败。Java 原有项目感知计划继续复用,详细解析/规划分离留到 task 3.4。 + +迁移测试时发现旧 fixture 修改模块全局函数且不恢复,造成后续语言检测污染;改为显式语言参数和 +有作用域的注册表覆盖。低层 timeout 故障仍在实际进程边界注入。真实替换测试还暴露了 scope 对 +任意包含 `find ` 的 argv 注入 shell 排除表达式的缺陷;现限定到已识别 Shell 的 `-c` 命令体, +Python/其它 argv 保持字面值,已有 find 型排除回归继续保留。 + +### 9. 语言服务与探活生命周期 + +`registry_schema` 只验证注册表结构和一致性;`registry` 校验后加载单一 JSON 并派生兼容表, +MCP 的语言枚举不再自行重新解析一份注册表。`config` 负责用户设置/项目覆盖,`discovery` +负责项目根、语言和接入条件发现;两者不加载执行器或 Hook。`detect_lang` / `user_config` +保留公开导入和 CLI 路径,不保留第二份实现。Java 专属配置继续由 Java 适配器解释。 + +```mermaid +flowchart LR + JSON[languages.json] --> Schema[registry_schema 纯校验] + Schema --> Registry[registry 单一索引] + Registry --> Discovery[discovery 项目发现] + Config[config 当前项目配置] --> Discovery + Discovery --> App[门禁 / CLI / Hook] + Registry --> App + App --> Probe[toolchain 单批次探活] + Probe --> Execute[execution 统一进程边界] +``` + +不再维护 `_TOOL_CACHE` 这种生命周期等于 Python 进程的全局表。每轮门禁或环境盘点创建一个 +`ToolchainProbe`,绑定目标根;只记成功,同命令加锁去重,不同工具并行,失败可立即重试。 +单次兼容函数每次新建实例。下一轮从零探活,避免在长驻 MCP 中发明复杂而不完备的永久缓存 +失效规则。运行目录与 stdin 规则显式传给统一执行器;探活通过不是代码 PASS。 + +项目扫描保留原有常见源码目录的递归覆盖,去掉重复 src/main 遍历,一轮只读一次项目覆盖; +嵌套构建标记不改变显式根,根发现不再截断于十层。坏配置不能当默认值:抛出明确配置异常, +应用/协议边界转为 UNVERIFIED,自动修复不执行;Hook 仍 fail-open 并告警,不升级为 exit 2。 +原用户设置解析器仍是既有有限 YAML 子集,不借此批次宣称支持完整 YAML。 + +### 10. Java 的观察、影响和命令策略分离 + +Java 保留模块级分析,不引入完整 JVM/Gradle 解释器。`java_build` 只读 Maven/Gradle 描述, +`java_impact` 在显式输入图上构建反向邻接表并遍历闭包,`java_planning` 只选择 argv;两项 +纯策略不读磁盘、不加载执行器,也不修改输入图。循环依赖通过访问集合有限终止。 + +```mermaid +flowchart TD + Entry[java_project CLI / MCP / Java 门禁] --> App[java_analysis 应用] + App --> Build[java_build 构建描述] + Build --> Graph[模块图 + 保守回退理由] + Graph --> Impact[java_impact 纯影响闭包] + App --> Env[java_environment wrapper / JDK] + App --> Changes[java_changes Git 基线与版本证据] + Impact --> Plan[java_planning 纯命令选择] + Env --> App + Changes --> App + Plan --> App + App --> Result[兼容计划字典:PLANNED / SKIPPED / UNVERIFIED] +``` + +应用层统一规范化 root 与变更路径,避免 `api/../service` 被词法前缀归到错误模块,也避免 +macOS `/var` 与 `/private/var` 别名误报仓外。删除文件继续参与影响分析;动态构建仍扩大范围。 +`java_environment` 与 `java_changes` 的外部观察通过统一 execution,stdin 不消费宿主输入。 +根 POM 的 Java `1.8` 与普通属性引用可解析,但不声称是完整 Maven effective POM 或 Gradle toolchain。 + +纯版本轻量检查不再依靠“变更行没出现 dependency”等负面关键词。它读取当前文件与正确 +Git 相对位置的 HEAD blob,比较完整 XML 或受限的 Gradle 顶层字面版本赋值;只剥离可确认 +的项目版本字段。依赖/插件版本、其它配置、插件脚本文本空白和外部用途的 revision 不获豁免。 +项目在 Git 仓内的子目录时使用 `HEAD:./path`,不能比较父仓的同名 POM。无法取基线、动态 +表达式、复杂多行语法或不能证明的关联版本修改继续 verify/check,而不是删除轻量检查能力。 +这是有边界的优化,不是任意构建脚本语义等价证明。 + +默认 Maven `-DskipTests verify`、Gradle `check -x test` 保持,显式命令优先并原样保留; +先检查显式声明再决定默认级别,不为权威命令额外查询版本降级。JDK 环境仍逐命令注入。 +`java_project.py` 保留公开 analyze、已有测试使用的两项兼容 helper 和 CLI;不再承载业务实现。 +实现参考边界:[Maven POM 关系](https://maven.apache.org/pom.html)、 +[Maven reactor](https://maven.apache.org/guides/mini/guide-multiple-modules.html)、 +[Gradle multi-project](https://docs.gradle.org/current/userguide/multi_project_builds.html)。 +并未复制这些工具的完整模型,真实大型项目与跨平台仍需独立验收。 + +### 11. CVE 的报告、判定、执行与编排分离 + +`cve_reports` 将五种 JSON 协议转换为 `ReportEvidence`;`cve_policy` 统一阈值、未知和聚合 +退出码,不读取磁盘或启动进程。`cve_scanners` 拥有命令、项目输入和 Maven 的一次性报告目录; +`cve` 拥有唯一生态目录、选择、配置校验和显式 npm 修复/复扫;原 `cve_check.py` 只保留公开 +导入兼容及 argparse/文本/JSON 呈现。不引入扫描器基类或插件注册框架,也不靠重定向 stdout +来模拟协议隔离。CLI 两种输出消费同一个 CveBatch。 + +```mermaid +flowchart LR + CLI[cve_check CLI] --> App[cve 选择与复扫] + App --> Scanner[cve_scanners 项目与进程 IO] + Scanner --> Execute[execution] + Scanner --> Parse[cve_reports 五种纯报告适配] + Parse --> Evidence[ReportEvidence] + Evidence --> Policy[cve_policy 阈值与可信度] + Policy --> Batch[CveBatch] + Batch --> CLI +``` + +报告必须有符合协议的容器、发现标识和可解释数值;拒绝 CVSS bool/NaN/Infinity/越界值、 +npm 档位缺失或明细/统计冲突、Cargo found/count/list 冲突、Trivy 非列表 Results。 +pip-audit 的 skipped dependency 和 Dependency-Check 的 analysisExceptions 是扫描未完成 +证据,不是零漏洞。非零退出、网络失败、坏 JSON 和探活失败不能根据 stdout 片段猜为通过。 +Maven/Trivy 已在调用时设阈值,其非零结果必须有匹配发现;npm 返回全档位统计,只有其有效 +低级别发现允许在更高阈值下 PASS。该差异是工具契约,不由通用执行器解释。 + +Trivy 的请求级别包含 UNKNOWN,防止未知发现先被外部过滤而丢失;LOW 纳入所有已知漏洞, +更高阈值保留缺严重度的发现并返回 UNVERIFIED。Cargo 的 CVSS 向量尚未实现分数计算, +沿用明确的未验证行为,不猜严重度。Trivy 省略/为空的结果需要区分:缺 Results 无可确认 +扫描证据,返回未验证;显式空列表且 exit 0 保留原有效空报告行为,不宣称项目依赖覆盖完备。 + +自动修复只接受本次有效 FAIL 和显式 --fix;保留 before_fix 与实际 fix_execution,即使修复 +失败仍复扫,最终判定来自复扫而非命令成功。坏配置及坏报告不启动修复。所有调用以 argv、 +目标 cwd、stdin=DEVNULL 执行;JSON 保留原退出码、argv/cwd/stdout/stderr 与发现。 + +报告边界核对官方实现:[npm audit JSON](https://github.com/npm/cli/blob/latest/workspaces/arborist/lib/audit-report.js)、 +[pip-audit JSON](https://github.com/pypa/pip-audit/blob/main/pip_audit/_format/json.py)、 +[RustSec report](https://github.com/RustSec/rustsec/blob/main/rustsec/src/report.rs)、 +[Trivy report](https://github.com/aquasecurity/trivy/blob/main/pkg/types/report.go)、 +[Dependency-Check JSON](https://github.com/dependency-check/DependencyCheck/blob/main/core/src/main/resources/templates/jsonReport.vsl)。 +本地可执行夹具验证协议、调用和副作用,不代表真实工具版本、联网漏洞服务或全依赖覆盖验收。 + +### 12. 暂存路径语义与仓库身份绑定 + +`git_staging` 是只读仓库观察适配器:每个 add 保留当时 cd/-C 工作目录,再查询真实 worktree +根;`staging_intent` 保留旧导入面,硬门禁增加显式 project_root 以过滤其它仓库的操作。 +不再把整条链的最终 cd 和全局 add 标记应用到所有仓库。参数分词使用 shlex,原归一化继续 +共享控制词/前缀/-C 识别;一层解释器的引号文本相应解包,保留既有间接命令覆盖。 + +路径匹配由 [git ls-files](https://git-scm.com/docs/git-ls-files) 的 --full-name/-z 完成,一次 +传入同一 add 的全部 pathspec,排除表达式必须与包含表达式共同求值。`git add -u` 查询 +tracked,`-f` 保留指定忽略文件;无显式路径的 -A/-u 仍按 [git add](https://git-scm.com/docs/git-add) +选择全仓相应内容面。输出是字面相对文件名,快照不得再次展开星号;字面目录仍可递归读取。 +`resolve_project_roots` 统一到真实仓根,避免从子目录调用时把仓内相对路径重复拼接。 + +范围解析不执行 git add、不创建临时 index、不改真实 index。测试直接比较 index 字节与 +快照文件内容,并从真实 PreToolUse JSON 入口验证敏感文件阻断及另一仓 WIP 不被误纳入。 +旧三项 skipped 测试改为命令→暂存意图→真实快照,而非创建从未存在的 match_pathspec API。 +没有文件的 src 路径假测试改用真实 src 与兄弟文件,断言范围行为而不是旧 helper 返回形状。 + +动态参数、交互式暂存和 pathspec-from-file 不在此静态模型内;返回既有 JSON 未验证协议, +不假装成功。Shell 控制流、带分隔符的引号、链中任意写入、特殊索引环境、非 HEAD refspec +及并发修改仍不等于完整 Shell/Git 执行模拟,继续保留独立 CI 验证边界。 + +## Risks / Trade-offs + +- 私有 monkeypatch 测试耦合旧模块 → 保留公开兼容面,新增黑盒行为测试先行;不以删除测试解决失败。 +- 改造过程中两条路径暂时并存 → tasks 明确每个调用点迁移,最终审计重复实现是否消失。 +- 主干还有其他会话开发 → 每批改动前核对 status;不覆盖未认领修改、不自动切分支。 +- 缓存/状态并发复杂 → 真实多进程测试;不将单进程测试当作并发验收。 +- 源码工作正常不代表宿主加载成功 → 发布与宿主验收分别记证据;未经授权不安装或更新宿主。 + +## Migration Plan + +先内核与兼容入口,再应用服务/语言适配器,再 hooks、状态和报告,最后文档/CI/发布。每批跑目标与全量回归,保持可执行状态。完成后一次发布完整版本,不移动旧标签;回退用前一不可变发布版本,不破坏本地源码。未完成 tasks 必须保留,不把阶段交付宣称为全项目优化完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/proposal.md b/openspec/changes/refactor-codeguard-architecture/proposal.md new file mode 100644 index 0000000..ce1e7a2 --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/proposal.md @@ -0,0 +1,31 @@ +# Proposal + +## Why + +CodeGuard 的能力方向成立,但入口、执行、判定、存储、报告互相穿透,修复同类缺陷时需要修改多个入口。以 `61e2592`(v0.12.1)为行为基线,在保留已有全部能力、协议和配置的前提下建立可测试的模块边界;不是换语言、堆抽象类或仅搬动文件。 + +## What Changes + +- 建立标准 Python 包内核:纯判定模型、统一进程执行、检查编排、基础设施与入口适配分离。 +- CLI、MCP、五个 hook 保持原路径和协议,逐步成为薄适配层;旧 Python 导入接口作为明确兼容面保留。 +- 合并重复的进程执行与故障归一化,保留真实退出码、命令、输出和未验证原因;超时仍保留已产生的诊断。 +- 分离 Git 意图解析/快照、安全规则、缓存、审计与报告,消除不可达分支和重复副作用,保持门禁策略不变。 +- 整理配置、注册表、Java 项目分析及 CVE 适配器的责任边界,统一依赖方向,不改变受管技能内容。 +- 建立兼容性场景清单、真实进程和 Git 契约测试、架构依赖门禁、能力追溯与发布验收。 +- 更新开发指南、架构图和验证边界;旧历史报告保留历史身份,不把计划写成完成证明。 + +## Capabilities + +### New Capabilities + +- `execution-kernel`:统一进程证据契约、故障归一化与调用方无关的执行边界。 + +### Modified Capabilities + +无。既有 verdict-integrity、hook-protocol、language-gate-commands、MCP、Java、CVE 契约均作为必须保留的行为约束。其他未归档 change 的已交付行为也纳入基线,不覆盖其 tasks 或擅自归档。 + +## Impact + +影响 `scripts/`、`hooks/`、`tests/`、开发文档和 CI;完成后按仓库规定升级版本、发布并同步市场。保持 57 个语言注册项、68 个受管技能、四个 MCP 工具、五类 hook、全部现有 CLI 子命令与默认检查等级。无需安装新运行时依赖,不初始化 SDD,不迁移规格体系,不更改用户项目或宿主配置。 + +CodeGraph 在目标仓尚未初始化,已请求用户批准;在批准前源码证据有效但不标为图谱证据。不得将已有 275 测试中的 4 项 skip 当作已验收;三宿主运行、真实工具链与线上漏洞数据另列证据。 diff --git a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md new file mode 100644 index 0000000..784937d --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -0,0 +1,208 @@ +# Execution Kernel + +## Purpose + +为 CLI、MCP、语言检查、Git 门禁和漏洞扫描提供一致且可追溯的外部进程证据,使工具故障不会因入口不同而丢失诊断、冒充通过或被误认成代码违规,同时保留原有用户接口。 + +## ADDED Requirements + +### Requirement: Tool execution SHALL preserve process evidence + +执行结果 MUST 保留实际 argv、工作目录、原始退出码、标准输出和标准错误。参数 MUST 以 argv 传递,不隐式增加 shell;工具是否发现代码违规由工具判定策略解释,不能由进程执行器推断。 + +#### Scenario: A checker exits nonzero +- **WHEN** 同一检查器经 CLI、保存 hook、Git 门禁或 CVE 执行并以非零退出 +- **THEN** 各入口获得相同的原始进程结果,再使用各自检查契约解释,不擅自改成成功 + +#### Scenario: An argument contains shell syntax +- **WHEN** argv 参数包含空格、分号或命令替换字符 +- **THEN** 它作为单个字面参数到达工具,不作为额外 shell 命令执行 + +### 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 门禁的逐文件基线豁免是独立策略,不由通用执行器决定。 + +#### Scenario: Java commands use the selected JDK +- **WHEN** Java 项目计划选择 JAVA_HOME,且声明多条权威检查命令 +- **THEN** 每条实际运行的命令使用该环境;后续命令失败时返回失败而非首条成功,宿主 JAVA_HOME 不变 + +#### Scenario: Repair retains the original scope +- **WHEN** delta 或 save 检查失败并成功运行 formatter +- **THEN** 复检执行原物化命令,仅包含原检查范围;被跳过的文件不导致漏掉其它文件的复检 + +#### Scenario: No executable commands +- **WHEN** 计划没有任何可执行命令 +- **THEN** 规划调用方返回 SKIPPED、PLANNED 或 UNVERIFIED;执行器拒绝将空计划解释为成功 + +### Requirement: Hook state SHALL preserve concurrent and session ownership + +统计、绕过明细、冷却与审计的读改写 MUST 在进程锁内完成并原子替换文件。宿主提供 session_id 时,统计 MUST 绑定会话与当前 worktree;Stop MUST 只消费该作用域的记录,并与并发写入互斥。无 session_id 时保留旧共享状态兼容,MUST 明示不能证明跨会话隔离。去重 MUST NOT 把未完成或 UNVERIFIED 的检查当成已完成检查。 + +#### 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: 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 确认失败;规则码集合或仅有文本输出不足以证明当前发现已存在。 + +#### Scenario: Exact snapshot audit retains ownership +- **WHEN** 带会话 ID 的提交门禁从临时 Git 快照执行检查 +- **THEN** 审计记录归属原 worktree 和当前会话,并记录实际执行命令 + +#### Scenario: Baseline tool cannot verify +- **WHEN** 基线检查器输出类似违规文本但退出结果为 UNVERIFIED +- **THEN** 不因文本相似而豁免当前检查 + +#### Scenario: Baseline evidence must cover every current finding +- **WHEN** 基线检查器返回成功码却打印诊断、同一规则新增第二处违规,或规则码相同但诊断内容变化 +- **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: Predicted staging SHALL preserve repository and pathspec scope + +拟暂存范围 MUST 绑定每个 git add 的实际仓库与工作目录,而非整条命令链的最终 cd 或全局 +路径并集。显式 pathspec MUST 交由只读 Git 查询成组解析,保留目录、glob、literal、exclude、 +引号路径及 -u/-f 的选择差异;展开后的文件名不得再作为 glob 解释。观察 MUST 不改写实际 +index 或工作树;不支持的动态/交互式形态必须明确未验证,不得冒充准确暂存面。 + +#### Scenario: Two repositories have different staging commands +- **WHEN** 一条命令链对 A 执行 add -A、对 B 只提交已暂存内容 +- **THEN** B 的未暂存及未跟踪文件不进入拟提交检查面 + +#### Scenario: Exclusion and literal pathspecs remain exact +- **WHEN** git add 指定包含与排除 pathspec,或字面文件名包含星号 +- **THEN** 快照只覆盖 Git 实际匹配的文件,排除项及同名 glob 邻居不会被额外暂存 + +### 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** 仅对指定文件执行两个工具,不静默扩大范围 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md new file mode 100644 index 0000000..712cfcb --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -0,0 +1,38 @@ +# Tasks + +## 1. 基线与设计 + +- [x] 1.1 核对源码/远端/既有规格与未完成 change;基线 61e2592、275 单测(4 skipped)和 ruff 已实际执行。 +- [x] 1.2 建立单一 proposal/design/specs/tasks;通过本 change 的 strict 校验后开始实现。 +- [x] 1.3 用户明确要求 CodeGraph 分析;将当前工作树复制到临时目录建立索引(123 文件、1,795 节点、4,069 边),查询 CLI/MCP、Git hook、执行器、Dockerfile 与基线调用链。源仓未初始化索引;图谱是本次临时快照的静态证据。 +- [x] 1.4 建立全能力保留矩阵(CLI/MCP/hooks/注册表/Java/CVE/快照/修复/配置/状态/发布),每项关联具体行为测试与当前边界;见 verification.md,不把已有覆盖当完整验收。 + +## 2. 执行与判定内核 + +- [x] 2.1 先用真实进程复现跨入口编码/权限/超时诊断丢失,再实现单一执行器;目标测试与全量测试通过(284 tests,保留 4 个历史 skip)。 +- [x] 2.2 CLI lint/fix、PostToolUse、Git gate、CVE 主要检查执行迁移到统一进程边界,另覆盖 Dockerfile;保持旧导入/tuple 协议,入口契约与真实 MCP 回归通过。Git 二进制/探活/基线进程在对应基础设施任务中处理。 +- [x] 2.3 结果/判定成为无宿主依赖的纯内核,既有判定用例逐项通过;架构依赖测试阻止反向导入(models/verdict 隔离导入、AST 门禁与真实反例)。 +- [x] 2.4 检查计划和执行编排统一,显式保留 delta/repo/save 策略差异;第二文件失败、Java 多命令/环境与同范围复检用例通过。Git 逐文件基线豁免仍为应用层策略。 + +## 3. 门禁与语言适配器 + +- [x] 3.1 将 Git 意图解析与 hook 协议分开,纯 git_syntax 与仓库观察 git_context 独立;共享间接脚本读取、只解析一次模式,保留原导入面与预测暂存/推送/逃生门/多仓行为,真实 Git 与命令链回归通过。 +- [x] 3.2 gate 应用、gate_checks 独立语言结果、repository_policy 安全/豁免、baseline、spec_validation 与 reporting 分离;gate_lib 为兼容导出。隔离运行、原仓/会话审计、并发保序、未知不获基线豁免实测通过;真实 argv 检查同时修复 scope 对非 Shell 参数的错误注入。 +- [x] 3.3 registry_schema/registry/config/discovery/toolchain 分离,旧导入兼容;探活改为显式目标目录与单批次成功去重,失败重试/跨批重探/并行隔离/宿主 stdin/编码实测通过。坏配置不再静默默认或绕过自动修复,CLI/MCP 返回 UNVERIFIED;深层/嵌套根配置、57 项注册表与全量回归通过。 +- [x] 3.4 Java 描述读取、纯反向闭包、纯命令策略、环境/版本差异观察与应用装配分离;默认等级、wrapper、动态全量、权威命令及环境注入回归通过。真实 Git 复现并修复版本误降级、嵌套基线和路径归属,Java 1.8/属性引用 JDK 定位修复;新增 21 项边界测试,全量 375 项(3 个历史 skip)。 +- [x] 3.5 CVE 纯报告/判定、扫描 IO、生态编排与 CLI 分离;真实进程复现并修复坏报告/矛盾统计/非法分数/缺严重度误判,修复结果与复扫保留。新增 30 项边界测试;全量 405 项(3 个历史 skip)、142 项回归通过;在线漏洞服务与真实工具版本另行验收。 + +## 4. 状态与协议 + +- [x] 4.1 统一原子状态读改写、审计及已完成事件边界;6 进程 210 次统计/审计、并发消费 240 次计数、真实会话/worktree 与未知重试测试通过。硬门禁重复拒绝仍为 2,放行不缓存;旧无 ID 状态明确不保证隔离。Windows 锁实现未实机验收;内容/配置缓存继续 4.2。 +- [x] 4.2 分离 fingerprint/cache,绑定 worktree/index 内容/upstream/文件内容/配置/命令;真实等长同 mtime 修改、linked worktree、未知恢复、配置更新和硬门禁不复用软缓存等 16 项通过。输入变化/预算超限不写缓存;软观察不是原子快照,外部动态输入与大型仓库性能边界已记录。 +- [ ] 4.3 CLI/MCP/五个 hooks 成为协议适配入口,保留用户可见行为;语言检查已归 `language_check`、请求策略已归 `check_application`,CLI/MCP 协议专项通过。五个 hook 仍需逐个收敛,不能据此勾选整体完成。 + +## 5. 质量与交付 + +- [x] 5.1 增加自动架构依赖/循环检查并接入 CI,临时源码树注入反向依赖/函数内导入/相对导入环后确实拒绝;本地已验证,远端 CI 待发布阶段。不以文件行数当合格标准。 +- [x] 5.2 四个历史 skipped 已全部恢复;Dockerfile CLI 接入统一报告解析与扫描应用,14 项真实进程边界测试通过;基线比较按诊断内容和次数核验并要求基线实际 FAIL,三项误豁免用例先红后绿。完整 433 项单测零跳过、142 项回归零失败。 +- [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 +- [ ] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;宿主安装运行独立记录,未授权不得改宿主。 +- [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md new file mode 100644 index 0000000..779c1f5 --- /dev/null +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -0,0 +1,149 @@ +# 重构证据与能力保留矩阵 + +日期:2026-09-23。基线 `61e2592`(v0.12.1)。这是**进行中的证据台账**,不是整项目完成报告,也不是新版本发布说明。 + +## 第一批实际验证 + +- 改前:275 unittest,4 skipped;ruff 通过。 +- RED:CLI/PostToolUse/CVE 的非法 UTF-8、权限故障、超时诊断保留分别失败;Git 门禁与 Dockerfile 执行面追加同类场景也观察到失败。 +- GREEN:284 unittest,4 skipped;`tests/run_all.py` 141 通过 / 0 失败 / 0 跳过;ruff、语言 schema(57 项/11 规则)、vendor 离线与在线、本 change strict、diff whitespace 均通过。在线锁仍为 v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`。 +- 核心进口隔离:独立 `python -I` 进程载入纯判定与报告,不需要 hooks/MCP/注册表;报告生成不加载 subprocess。 +- 旧 `scripts/verdict.py` 与 `gate_lib` 报告符号继续兼容导出。主要检查执行面只调用 `codeguard.execution`,不同入口仍保持各自 timeout 默认值及判定策略。 +- 移除了不可达的第二条 Java 执行分支;真实 Java 规划/门禁/MCP 测试仍通过。去掉了未跟踪文件指纹的重复 stat。 +- 测试调整不是放宽断言:两个依赖 `run_per_language.subprocess` 私有属性的假进程测试改为真实缺失命令/exit 2;一个只搜索 `_run_one` 源码的超时测试改为真实睡眠超时和主动 exit 124,保留未验证且不拦截的行为断言。 + +本批尚未 bump/提交/发布。受管技能、lock 和插件专属技能清单无改动。版本与市场发布在整体收敛后执行,不能把尚未完工的源码宣称为已发布新版本。 + +## 第二批实际验证:计划、环境和架构边界 + +- RED:Java 规划选择的 JAVA_HOME 没有进入子进程(实际读到 host-jdk);混合 shell/zsh 的 formatter 已修复文件但没有触发复检(仍返回旧 FAIL);51 个文件回退全量时把单文件占位符替换成空路径并真正执行工具。三者均由临时项目里的真实 Python 子进程复现,没有 mock 检查器结果。 +- GREEN:新增共享 `scoped_plan`、不可变模型和 `execute_plan`。Java 多命令保留 env,第二条失败阻止第三条副作用,父进程环境不变。CLI 与保存 Hook 的修复复检保持原范围,未涉及文件保持原文。空计划、空 delta、无文件列表的单文件命令被拒绝,不产生假 PASS。 +- 架构门禁先因缺失失败,再由真实临时源码树证明:内核反向导入 hooks/MCP、纯模型导入 subprocess、函数内隐藏导入、相对/包导入环、新内核模块未声明政策均返回非零;合法依赖和当前仓库返回零。纯模型另用 `python -I` 证明不加载进程或宿主模块。 +- 全量:299 unittest,4 个历史 skipped;`tests/run_all.py` 141 通过 / 0 失败 / 0 跳过;ruff、独立架构门禁、本 change strict 通过。语言 schema 57 项/11 规则,vendor 离线/在线及 diff whitespace 通过,受管技能与 lock 保持不变。 +- CI 配置增加架构检查;尚无这批代码的远端 CI 结果。CodeGraph 索引仍待授权,未初始化。未 bump、提交、推送或发布;全项目目标保持进行中。 + +## 第三批实际验证:Git 语法、仓库观察与路径政策 + +- 先通过隔离 `python -I` 进程观察独立边界缺失,再分离 `git_syntax`、`git_context`、`path_policy`;同一批测试现证明语法和路径政策不加载 hooks、Git 执行器或宿主模块,仓库观察无需 hooks 加入 sys.path。 +- 真实临时 Git 仓验证多仓边界、一层解释器间接推送与 `git add -u` 的跟踪文件范围;全量既有测试继续覆盖快照、拟暂存、敏感路径、逃生门和命令替换。`scripts/vendor` 与嵌套 fixture 的例外保持,路径策略只返回诊断,不执行移除建议。 +- 消除两份一层脚本读取实现,主 Hook 只判定一次模式;仓库探测复用统一执行器。原 pre_tool_git_guard 和 gate_lib/scope 的 helper/规则常量继续兼容导出。非 Git cd 后回退 cwd 的历史行为尚未修正,已删除错误的 Shell 等价性说明,未将其声称为完整语法支持。 +- 历史内联豁免提示测试先解除装饰器在独立进程实际执行并通过,再正式移除 skip;其余三项 pathspec 的旧测试仍待核对,没有为了绿色数量删除用例。 +- 最终全量:302 unittest,3 skipped,其余通过;`tests/run_all.py` 141/0/0;ruff、架构依赖/循环检查、语言 schema、vendor 离线、本 change strict、diff whitespace 通过。受管技能/lock 未改。在线 vendor 的最新证明仍为第二批,本批尚未提交,不把它冒充新执行结果。 +- `gate_lib` 中的检查应用编排与状态存储尚待分离,task 3.2/4.x 未勾选。未创建 CodeGraph 索引、分支、提交或发布,目标继续进行。 + +## 第四批实际验证:原子状态、会话归属与已完成事件 + +- RED:真实 6 进程各写 35 次统计,旧裸读改写只留下 5 次而非 210;同文件在第二个会话被第一会话去重;一个 Stop 消费其它会话统计。初次 UNVERIFIED 后立即保存重试只执行检查器一次;相同硬门禁事件第一次拒绝为 2,第二次却放行为 0。均已用当前测试覆盖并转为 GREEN。 +- 本轮复核的 RED:UPS 的损坏 Ruff 配置第一次明确未验证,第二次为空;观察操作抛异常后没有完成式重试接口。先修正测试夹具以只检查 Python(避免 TOML 检查器的正常违规遮盖目标),确认第二次输出被吞,再修改实现。 +- `storage` 的稳定旁路进程锁覆盖整个读改写,临时文件 fsync 后原子替换;事务回调或 replace 失败不覆盖旧数据且无临时文件残留。`hook_state` 统一统计、冷却、绕过明细、审计及归属,五个 Hook 进入会话/worktree 作用域。新作用域不猜测旧全局数据归属;无 ID 时兼容共享状态。 +- 真实多进程证明 210 次统计/绕过和 210 条唯一审计保留;3 个 writer 与消费方交错时总计 240 次不丢失。真实 Git linked worktree 及两个 session 分别 Stop 各得自己的 1 次。保存未知后立即重跑,第二次执行得到 PASS。 +- 硬门禁只重放已完成拒绝,不复用放行;同事件从干净文件变为暂存 F821 后仍得到 2。SessionStart/Stop 只在观察正常返回后登记;异常可重试。UPS 存在 skipped/未知时不登记完成。并发未完成副本可重复运行,不宣称 exactly-once。 +- 最终全量:312 unittest,3 个历史 skipped,其余通过;状态专项 10/10;`tests/run_all.py` 141/0/0。ruff、架构依赖/循环检查、语言 schema 57 项/11 规则、vendor 离线和在线、本 change strict、diff whitespace 通过。在线仍为 v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`;受管技能和 lock 未改。 +- 验证环境为 macOS/Python 3.13.5。Windows 锁分支尚未实机测试。软缓存内容/配置身份、保存检查期间文件变化、准确快照审计的原仓归属仍待后续任务;本批不宣称全项目或全平台完成。未建立 CodeGraph 索引、创建分支、提交、bump 或发布。 + +## 第五批实际验证:缓存身份与失效规则 + +- RED:8 项首批测试中 7 项失败,分别为等长同 mtime 修改继续复用旧 PASS、未来时间戳复用、linked worktree 暂存内容不进键、进程内配置永不过期、保存检查期间新内容被登记为已查、工具恢复后仍返回旧未知、用户配置变化不重跑。追加 UPS 同提示词但源码变化的真实检查也先失败(只执行一次)再修复。 +- `fingerprint` 独立采集 Git 内容条目和有预算的文件内容身份,`cache` 校验版本/字段/时间并原子保存完整结果;gate_lib 保留兼容调用点但不实现存储细节。项目配置改为实时小文件读取。UPS 去重纳入输入身份;保存检查前后校验,formatter 后重新捕获输入再复检。 +- 缓存专项 16/16:除上述 RED 外,还实测忽略的 Ruff 配置变化、换行文件名、push upstream 变动、无变化软复用/准确门禁重跑、坏缓存格式、预算降级以及外部符号链接/缺失目录不生成身份。工具执行使用真实进程;预算测试只缩小身份采集预算,不伪造检查结果。 +- 全量回归曾因旧缓存夹具缓存了“无涉及改动”的 skipped 而失败;将其明确改为实际检查 shell,新增断言无 skipped,保留缓存命中/变化重跑两条原断言。不是允许未知结果缓存来迎合旧测试,也未删失败用例。 +- 最终全量:328 unittest,3 个历史 skipped,其余通过;`tests/run_all.py` 142/0/0;ruff、架构依赖/循环、语言 schema 57 项/11 规则、vendor 离线、本 change strict、diff whitespace 通过。在线 vendor 最新实测为第四批,本批受管技能/lock 未改,尚未提交。 +- 本机对当前仓库实际采集一次输入身份可用,耗时 0.155 秒;不是性能分位数或大型仓库承诺。预算 10,000 文件名/64 MiB,超限/非 Git/特殊文件时禁用缓存但继续检查。任意外部动态配置、远程依赖和未声明环境仍不是该软缓存的完整建模对象;硬门禁始终独立准确快照。 +- 未初始化 CodeGraph、未创建分支、未 bump/提交/发布。task 3.2 门禁应用编排、3.3 注册表/探活、Java/CVE 适配器、协议入口及最终文档发布仍未完成。 + +## 第六批实际验证:门禁应用、独立结果与原仓审计 + +- RED:4 项新增测试先失败:隔离解释器无法导入独立 gate 应用;准确快照审计 worktree 为临时目录;基线 exit 2 但输出相似文本仍获豁免;当前检查 exit 2 被基线 exit 1 的相同文本掩盖为存量跳过。均已修复并转 GREEN。 +- `gate` 选择检查内容面和汇总;`gate_checks` 返回不可变 GateOutcome/GateDecision;`repository_policy` 管路径安全与显式豁免,`baseline` 管基线复跑,`spec_validation` 管可选 OpenSpec 集成。gate_lib 保留旧导入入口而无重复检查实现。Git blob 快照的二进制协议未改。 +- worker 不共享可变备注,不直接写审计;调用线程保序落盘,worktree 为原仓、execution_root 为实际临时目录、session_scope 保留调用归属、cmd 为实际 argv。追加两种不同耗时真实进程证明失败与审计顺序仍按请求顺序。Java 继续复用原项目计划,尚未宣称解析/影响/规划已完成拆分。 +- 既有私有 monkeypatch 的迁移明确失败过:7 项 AttributeError/缓存路径错误修复为对实际所有者或低层进程边界注入。没有为测试保留无意义的 shutil/subprocess 业务依赖;公开导入函数仍兼容。run_all 原来未恢复语言检测 monkeypatch,现用显式语言参数与有作用域覆盖,避免污染后续测试。 +- 将旧假进程的 rc=2 场景改为真实 Python 后,发现 scope 对包含 `find ` 的任意 argv 注入排除表达式,实际 exit=1 SyntaxError;已限定为 Shell 的 -c 命令体。该真实回归现保持 exit 2 未验证;已有 find 型扫描排除测试全部保留并通过。 +- 最终全量:333 unittest,3 个历史 skipped,其余通过;应用专项 5/5;`tests/run_all.py` 142/0/0。ruff、架构门禁、语言 schema、vendor 离线、本 change strict 和 diff whitespace 通过。在线 vendor 最新证据仍为第四批;本批未提交,受管技能/lock 未改。 +- 未初始化 CodeGraph、未创建分支、未 bump/提交/推送/发布。注册表/探活、Java/CVE 分层、协议入口整体收敛与最终文档发布仍待完成;原有粗粒度 finding_signatures 的边界仍需最终审计,不将本批未知状态修复夸大为语义等价的完整基线比较。 + +## 能力保留矩阵(持续更新) + +“已有测试”表示可复跑的证据入口,不代表该能力全部边界均已覆盖。 + +| 能力 | 必须保留的合同 | 已有测试/证据入口 | 后续审计重点 | +|---|---|---|---| +| CLI check | 路径、语言过滤、fix/dry-run、timeout、日志和聚合退出码 | test_mcp_server / test_verdict_integrity / run_all | 每种参数组合黑盒清单 | +| CLI fix | delta/repo、显式全量、项目 formatter 不扩范围 | test_verdict_integrity / test_hardening_fixes | fixed 的真实变更证据一致性 | +| CLI detect / init | 语言发现;init 仅引导不静默安装 | run_all / bin/codeguard | init 帮助与退出码独立 smoke | +| CLI dockerfile | hadolint + Trivy、JSON/文本、安全风险与未验证 | run_all / test_execution_kernel | 原有坏报告/超时疑似假通过,需单独复现并修复 | +| CVE | 五种适配器、别名、阈值、优先级、复扫、JSON | test_cve_boundaries / test_verdict_integrity / run_all | 纯报告/判定与扫描/应用已拆分;30 项边界测试覆盖异常与副作用,在线扫描、实际工具版本与完整依赖覆盖未验收 | +| 四个 MCP 工具 | 官方 stdio SDK、名称/schema、结果信封、实际修复标识 | test_mcp_server(真实 stdio) | 协议和应用服务分离,不引入必须安装的新依赖 | +| SessionStart | PATH、语言/配置/工具盘点、双副本去重 | test_hardening_fixes / run_all | 导入无副作用,版本消息不漂移 | +| PostToolUse | 单文件反馈、EJS/产物过滤、同范围修复与复检、通知 | test_verdict_integrity / test_artifact_awareness / run_all | 原子统计/冷却/去重,真实修改计数 | +| UserPromptSubmit | 意图软提醒、语言子集、非 Git 不扫、逃生门 | test_gate_trigger / test_hardening_fixes / run_all | 软硬策略一致但不同执行权限 | +| PreToolUse | 已支持命令归一化、多仓、拟暂存、HEAD 推送、敏感路径 | test_session_fixes_20260922 / test_verdict_integrity / run_all | 命令意图与仓库绑定类型化,保留能力边界 | +| Stop | 统计/绕过提醒/清理/副本去重 | test_hardening_fixes / run_all | 并发会话互不清空、旧状态迁移 | +| 判定 | 五状态、pylint 退出位、环境错误、方言、基线对比 | test_gate_hardening_usage / test_gate_hardening_batch2 | 签名粒度与基线证据可靠性;不删整项能力 | +| Git 快照 | index/HEAD、overlay、删除/重命名、不更改 index/工作树 | test_verdict_integrity / test_git_staging / test_gate_hardening_batch2 | pathspec 按仓解析、字面路径及真实 index 保持已验证;复杂 Shell、特殊索引环境、批量/子模块/超限边界仍需最终审计 | +| Java | Maven/Gradle wrapper、多模块反向闭包、动态回退、显式命令、默认 skipTests | test_java_project_impact / test_java_boundaries / test_session_pain_fixes / test_mcp_server | parser/graph/planner 与环境/版本观察已分离;真实大型项目、任意动态构建与跨平台未验收 | +| 语言注册表 | 54 stable + 3 planned,配置与文档由单一来源生成 | test_validate_languages_json / test_full_scan_excludes / run_all | 注册表类型和探活职责收敛,不宣称 57 工具链全测 | +| 项目/用户配置 | 原路径、enabled_languages、扩展映射、exclude、scope、timeout | test_session_fixes / run_all | 更新失效、坏配置边界、跨宿主一致性 | +| 审计/去重/缓存 | CODEGUARD_HOME、绕过明细、软缓存、硬快照不复用 | test_cache_identity / test_state_storage / test_hardening_fixes / test_gate_hardening_batch2 | 原子状态与软缓存显式输入身份已实测;任意外部动态输入不在保证内,Windows/大型仓库仍需独立验证 | +| 分发与技能 | 三宿主 manifest、68 个外部受管技能、不可变 lock、模板与文档 | test_plugin_manifests / test_skill_vendor / test_readme_parity | CI/Release/市场/宿主运行分别证明 | + +## 第七批实际验证:语言服务与探活生命周期 + +- RED:旧进程级缓存吞掉工具恢复、保留工具失效前的成功、跨 cwd 复用探测;隐式 `--version` 消费宿主 stdin;非法 UTF-8 导致异常;嵌套构建标记丢失根配置、超过十层找不到项目根。坏 JSON 被当默认配置,坏字段崩溃或静默通过;显式语言 check/fix 可绕过坏配置。真实子进程和临时文件夹复现后修复。 +- `registry_schema` 与运行时共用纯校验,重复 ID/非法形状产生诊断;`registry` 单一加载/派生,MCP 不重复读表;`config`、`discovery`、`toolchain` 分离。隔离 Python 进程证明项目发现不加载 subprocess、执行器、hooks 或宿主 SDK。原公开导入和命令路径保留,私有全局探活缓存删除。 +- 单轮 `ToolchainProbe` 绑定目标 cwd、stdin=DEVNULL;成功才去重,失败可立即重试,下一轮重新执行。真实计数文件证明两个并行语言共享工具一次执行且下一轮再次执行。新增双进程会合用例先揭示全局锁串行问题,再改为按命令锁:不同工具可并行,相同工具不重复启动。 +- 配置覆盖和排除规则热更新、未知 Java 扩展字段保留;坏配置返回 UNVERIFIED,实际 formatter 副作用未发生。真实 MCP stdio 中 check/auto_fix 的坏配置请求明确未验证,修复配置后同一服务继续工作,不需要重启。 +- 最终全量 **354 unittest:351 通过、3 个历史 pathspec skipped**;语言服务专项 19/19;`tests/run_all.py` **142 通过 / 0 失败 / 0 跳过**。ruff、架构依赖/循环、语言 schema 57 项/11 规则、vendor 离线和在线、本 change strict、diff whitespace 通过。在线锁仍为 v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`,68 个受管技能及 lock 未改。 +- 首轮全量暴露校验器提前返回漏掉已有 severity 诊断、回归脚本遗留变量名;保持原行为断言并修复后重跑,最终结果以上述末次为准。测试没有证明全部 57 种真实工具链、Windows 或已安装宿主运行。未初始化 CodeGraph、创建分支、提交、bump 或发布;下一项是 Java parser/graph/planner 分离,整项目目标仍进行中。 + +## 第八批实际验证:Java 观察与纯策略分离 + +- RED:只改依赖版本、compiler/release、Gradle sourceCompatibility 或嵌套插件 version 都被旧实现误判成纯版本升级;单行 POM 中仅顶层版本变化反而无法降级。`api/../service` 被词法前缀错误归到 api。Java `1.8` 被识别成 JDK 1,普通属性引用被忽略。先通过真实 Git/文件与可执行 JDK 定位夹具复现,再修改实现。 +- `java_build` 只读构建描述,`java_impact` 用反向邻接表计算闭包,`java_planning` 只选择命令;`java_changes` 与 `java_environment` 通过统一执行器观察 Git/JDK;`java_analysis` 装配兼容计划。隔离 Python 进程证明纯策略不加载 subprocess/解析器/宿主,循环图有限终止且输入不变。CLI/MCP 入口、默认 skipTests/check -x test、显式命令、wrapper 与逐命令 JAVA_HOME 保留。 +- 重构期间真实回归发现 macOS `/var` 路径别名误报仓外,已统一根路径规范化并恢复旧测试;新增嵌套项目 Git 夹具又暴露 HEAD:path 读取父仓同名 POM 的问题,改为 cwd 相对 HEAD:./path。插件脚本文本空白与 revision 属性引用同样经过先红后绿,不能混入纯版本豁免。 +- 完整描述比较仅剥离可证明的项目版本;未知语法、无法取基线、依赖/插件/编译配置或复杂关联版本修改不降低级别。真实 Git index 内容前后相同;规划不执行 wrapper、不安装工具。保留轻量优化不等于承诺完整构建语义等价。 +- 最终全量 **375 unittest:372 通过、3 个历史 pathspec skipped**;新增 Java 边界 21/21,既有 Java 影响 15/15;`tests/run_all.py` **142/0/0**。ruff、架构依赖/循环、语言 schema 57 项/11 规则、vendor 离线和在线、本 change strict、diff whitespace 通过。在线 lock v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`,受管技能与 lock 未改。 +- 当前只证明本地契约与真实进程/Git 行为,不代表真实大型 Maven/Gradle 构建、任意 effective POM/DSL、Windows 或三个宿主已验收。未初始化 CodeGraph、创建分支、提交、bump 或发布。下一项为 CVE 编排与五个扫描器报告适配,整体目标保持进行中。 + +## 第九批实际验证:CVE 报告与编排边界 + +- 第一组 RED 通过真实子进程夹具复现:Trivy 非列表 Results/未知严重度/非零空报告被误判通过;npm 残缺统计、错误信封、非零零发现误判;Maven 非法数值/缺标识/缺分数丢失;Cargo 探活失败仍继续及统计冲突;pip skipped 依赖被当作已审计;坏配置导致 traceback 或由显式生态绕过。21 项测试最初产生 26 个失败及 1 个缺失修复证据字段错误,完成实现后转绿。 +- `cve_reports` 与 `cve_policy` 纯解析/判定;`cve_scanners` 负责命令、项目依赖输入和本次 Maven 报告;`cve` 负责唯一生态目录、选择、修复/复扫;`cve_check` 是兼容导出与呈现。隔离进程证明纯解析器不加载 subprocess/执行器/扫描器/CLI,架构依赖白名单与循环门禁通过。没有引入新运行时依赖。 +- 补充 RED 又验证并修复可选 scanInfo 非法形状泄漏异常、npm 明细与计数冲突、pip 部分失败被已知发现遮盖,以及 Maven/Trivy 非零但仅有低阈值发现被错误套用 npm 规则。npm 的有效低级别发现仍允许高阈值 PASS;其余已带阈值的工具不能据此推断成功。UNKNOWN 不在 Trivy 调用阶段被过滤。 +- 真实进程夹具检查 5 个扫描器的有效干净报告与 exit 124/126/127 矩阵、pip 项目输入、不消费宿主 stdin、npm 显式修复副作用/失败修复后的成功复扫/失败复扫、文本与 JSON 一致、缺结构化判定的 exit 0 不放行。Maven 不读取项目旧报告;原 argv/cwd/exit/stdout/stderr 与发现保持可见。测试不联网扫描、不安装工具、不修改宿主或用户项目。 +- 旧测试私有 monkeypatch 迁到实际 `cve_scanners.run`/`cve.scan_maven` 所有者;单档 `{high: 0}` 假报告改为完整 npm 档位,派发 fixture 显式给出 PASS,避免为测试保留“exit 0 自动安全通过”。原行为断言、阈值映射与别名回归保留。 +- 最终全量 **405 unittest:402 通过、3 个历史 pathspec skipped**;新增 CVE 边界 30 项全过;`tests/run_all.py` **142 通过 / 0 失败 / 0 跳过**。ruff、架构依赖/循环、本 change strict、diff whitespace 通过;语言 schema 为 57 项/11 规则。vendor 离线与在线通过,锁仍为 v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c`;受管技能、lock 和白名单未改。 +- 核对五个工具官方报告实现,参考链接见 design 第 11 节;未宣称实际在线漏洞库、完整依赖覆盖、CVSS 向量求值、Windows 或三宿主生产运行已验收。未初始化 CodeGraph、创建分支、提交、bump 或发布。下一项是 task 4.3 CLI/MCP/五 Hook 协议边界与剩余历史 pathspec,整体优化仍未完成。 + +## 第十批实际验证:拟暂存范围与历史 pathspec + +- 三项历史 skipped 不再调用从未实现的 match_pathspec,改为现有 staging_intent → 真实 validation_tree 的范围与字节断言;glob、目录与无匹配三种行为均实际运行,未删除测试或伪造 helper。原来单测中的空 src 字面路径改用真实 src 文件和兄弟文件,断言“不扩范围”。 +- 新增真实 Git 场景先红后绿:exclude 与 include 被逐条并集;子目录 add . 扩到兄弟;-u pkg 带入未跟踪;-f 忽略路径漏选;git -C/空格路径/相对 cd 上下文错误;另一仓 add -A 污染本仓纯 commit;真实 Hook 从仓外执行 -C + 敏感 glob 静默放行。随后补测子目录被当作仓根导致敏感文件漏检,并修复到真实 worktree 根。 +- `git_staging` 负责只读 Git 解析与逐仓绑定,`git_context` 保留旧导入兼容;Hook 为每个目标仓独立取得 lanes/extra。成组 pathspec 交 Git,输出字面路径,快照读取不重新解释星号。测试前后 index 字节一致,目录/空格/删除/排除与 force 的快照内容和范围相符,没有实际 add 用户文件。 +- 旧全量回归曾出现 4 项失败:bash -c 引号识别两项、多仓兜底一项及空 src 假路径一项。改为正确解包一层解释器文本、纯 commit 不做无谓路径查询、真实范围 fixture 后重新验证;未忽略兼容性失败。交互暂存/外部 pathspec 文件保持能力边界,走 JSON additionalContext 的 git UNVERIFIED,而非普通内部异常或静默通过。 +- 最终全量 **416 unittest 全部通过,0 skipped**;新增暂存边界 11 项、第二批回归 18 项均通过;`tests/run_all.py` **142/0/0**。ruff、架构依赖/循环、语言 schema 57 项/11 规则、本 change strict 与 diff whitespace 通过。受管技能/lock/白名单未改;在线 vendor 最近证据为第九批,本批未提交。 +- 不将只读路径观察称为完整 Shell 执行模拟:复杂控制流、未引用的 Shell glob 展开、引号中的分隔符、链内任意写入、特殊索引环境、非 HEAD refspec 和并发编辑仍有边界。下一项继续 CLI/MCP/五 Hook 协议分层,以及 Dockerfile/基线证据遗留差距;整项目尚未完成,未初始化 CodeGraph、创建分支、bump、提交或发布。 + +## 第十一批实际验证:CodeGraph、Dockerfile 协议和基线豁免 + +- 对当前工作树的临时副本执行 `codegraph init -i -y`:123 文件、1,795 节点、4,069 边;查询 CLI/MCP→语言检查→执行器、PreToolUse→Git 暂存/门禁/基线,以及 Dockerfile 解析调用链。CodeGraph 指出 `run_check.py` 仍持有 MCP 自动修复策略,`execute` 被多个应用入口共享;这两点分别列为后续协议收敛和已落地执行边界的证据。临时副本不包含受管技能和资产,原仓没有新建索引。 +- 初次全量运行 430 项出现 28 失败、4 错误:`dockerfile_security.py` 仍使用旧扫描分支,未接入已写出的 `codeguard.dockerfile`/`dockerfile_reports`。改为只负责参数、文本/JSON 呈现,旧 Python 导入显式再导出;14 项真实进程边界全部通过。当前 JSON 和文本共用同一状态与退出码,丢报告/坏结构/超时为 UNVERIFIED,已知发现仍保留。 +- 基线误豁免新增三项 RED:同规则第二处违规、规则相同但描述不同、基线命令成功却打印错误文本。将实际豁免改为带次数的诊断比较,并要求基线自身判定 FAIL;旧 `finding_signatures` 导入保留。三项转 GREEN,原有存量/新增/无基线用例仍通过。 +- 修复架构检查中新 Dockerfile 模块尚未登记的白名单;所有内核模块继续受依赖方向和导入环门禁约束。最终完整单测 **433/433,0 skipped**;`tests/run_all.py` **142/0/0**;ruff、架构门禁、语言 schema 57 项/11 规则、vendor 离线与在线、本 change strict、diff whitespace 通过。受管技能和 lock 未改。 +- 这些结果证明本地契约及离线真实进程/Git 场景,不证明真实联网漏洞扫描、三个宿主现场加载、全部 57 个语言工具链或跨平台运行。尚未 bump、提交、推送或发布;目标继续推进。 + +## 第十二批实际验证:语言服务与 CLI/MCP 边界 + +- 将 `run_per_language.py` 的唯一实现移入 `codeguard.language_check`,旧路径只再导出;`gate_checks` 改为直接依赖应用服务。`check_application` 接管语言选择、MCP 四工具请求、改动范围自动修复与历史结果字段;`run_check.py` 继续只承担 argparse、文本呈现和 MCP SDK stdio 接线。 +- 迁移后 CLI/MCP/语言计划/执行专项 62 项通过;新 CodeGraph 快照同步后为 125 文件、1,819 节点、4,108 边,`mcp_tool_payload` 的调用者已落在 `run_check.py`。静态架构门禁通过,core 不导入 SDK 或 hooks。五个 hook 仍有入口内编排,4.3 尚未完成。 +- 报告不再硬编码旧 `0.12.0`,读取当前插件 manifest;新增独立 Python 进程核对显示版本。双语 README 移除硬编码“当前开发版本”,指向当前架构和本 change 证据;历史文档标明适用范围。增加本地链接存在性测试,目标专项 16 项通过。 +- 本批完整全量单测 **435/435,0 skipped**;`tests/run_all.py` **142/0/0**。ruff、架构门禁、语言 schema 57 项/11 规则、vendor 离线与在线、本 change strict 及 diff whitespace 均通过。此处仅是本地验证;尚无本次改动的远端 CI、发布或三宿主现场运行证据。五个 Hook 的入口分层仍未完成,4.3 与整体目标保持进行中。 + +## 不能遗忘的剩余项 + +1. CodeGraph 已对本轮工作树的临时副本建索引并查询,目标仓本身仍无 `.codegraph/`;修改后的文件需同步临时索引后才可视为新的图谱证据。 +2. 基线四项历史 skip 已全部实跑恢复;Dockerfile 坏报告/超时与基线诊断粒度已补测试并修复。仍需逐项复核其它能力边界,不能以零跳过推断全功能验收。 +3. 现有未归档 `gate-toolchain-unverified` change 与其它会话修改保持原状,不能把它们的勾选当本次验收。 +4. 报告版本及双语当前架构文档已收敛;仍需审计 CLI/Hook 其它版本文字,并完成发布链及宿主验收。 +5. 通用执行器仅处理文本检查命令;Git blob 的二进制协议必须保留字节,不得为了“一致”强制 UTF-8 解码。 +6. 第一期不解决任意命令进程树管理、完整 shell 解释、沙箱及所有动态 Java 构建;这些边界不能在文档中消失。 +7. 只审阅了 pre-commit 官方的通用语言协议与 Python 适配器作边界参考,未声称移植或达到同等成熟度。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py new file mode 100644 index 0000000..7ffd83f --- /dev/null +++ b/scripts/check_architecture.py @@ -0,0 +1,186 @@ +"""静态架构门禁:内核依赖白名单与第一方 Python 导入环。 + +只解析 AST,不导入被检查代码。函数内、条件分支中的静态 import 同样检查; +运行时拼接的动态加载不在静态保证范围内,本工具不是安全沙箱或 CodeGraph 替代。 +新增内核模块必须在此声明边界,不能依靠目录命名假装分层。 +""" +from __future__ import annotations + +import argparse +import ast +from pathlib import Path + +# 明确的层依赖;stdlib 也受约束,纯模型不能悄悄导入 subprocess/宿主。 +CORE_DEPENDENCIES = { + "codeguard.cve_policy": {"__future__", "dataclasses"}, + "codeguard.cve_reports": {"__future__", "json", "math", "codeguard.cve_policy"}, + "codeguard.cve_scanners": {"__future__", "tempfile", "pathlib", "codeguard.cve_policy", + "codeguard.cve_reports", "codeguard.execution"}, + "codeguard.cve": {"__future__", "dataclasses", "pathlib", "codeguard.config", "codeguard.discovery", + "codeguard.cve_policy", "codeguard.cve_scanners"}, + "codeguard.dockerfile_reports": {"__future__", "dataclasses", "json"}, + "codeguard.dockerfile": {"__future__", "pathlib", "codeguard.dockerfile_reports", + "codeguard.execution"}, + "codeguard": {"__future__"}, + "codeguard.models": {"__future__", "dataclasses", "pathlib", "typing"}, + "codeguard.verdict": {"__future__", "re", "collections", "pathlib", "codeguard.models"}, + "codeguard.execution": {"__future__", "os", "subprocess", "collections", "pathlib", "codeguard.models"}, + # 报告当前仍拥有诊断日志落盘;不将其误称为无 IO 的纯模型。 + "codeguard.reporting": {"__future__", "re", "json", "pathlib", "hashlib", "tempfile", + "codeguard.models", "codeguard.verdict"}, + # scope 是已有文件物化适配器:注入配置/排除目录,尚未迁入内核包。 + "codeguard.planning": {"__future__", "collections", "pathlib", "scope", "codeguard.models"}, + "codeguard.git_syntax": {"__future__", "pathlib", "re", "shlex"}, + "codeguard.git_staging": {"__future__", "os", "re", "shlex", "pathlib", "git_snapshot", "codeguard.git_syntax"}, + "codeguard.git_context": {"__future__", "os", "pathlib", "re", "shlex", "scope", + "codeguard.execution", "codeguard.git_syntax", "codeguard.git_staging"}, + "codeguard.path_policy": {"__future__", "fnmatch", "pathlib"}, + "codeguard.storage": {"__future__", "contextlib", "json", "os", "tempfile", "time", + "collections", "pathlib", "typing", "fcntl", "msvcrt"}, + "codeguard.hook_state": {"__future__", "contextlib", "hashlib", "os", "time", + "collections", "contextvars", "pathlib", "codeguard.storage", "codeguard.fingerprint"}, + "codeguard.cache": {"__future__", "contextlib", "hashlib", "time", "pathlib", "codeguard.storage"}, + "codeguard.fingerprint": {"__future__", "hashlib", "json", "os", "shutil", "stat", "pathlib", + "codeguard.execution"}, + "codeguard.baseline": {"__future__", "pathlib", "re", "tempfile", "codeguard.execution", "codeguard.verdict"}, + "codeguard.repository_policy": {"__future__", "pathlib", "git_snapshot", "codeguard.execution", + "codeguard.hook_state", "codeguard.path_policy"}, + "codeguard.spec_validation": {"__future__", "shutil", "pathlib", "codeguard.execution", "codeguard.reporting"}, + "codeguard.registry_schema": {"__future__", "re"}, + "codeguard.registry": {"__future__", "json", "pathlib", "typing", "codeguard.registry_schema"}, + "codeguard.config": {"__future__", "json", "re", "pathlib", "codeguard.registry"}, + "codeguard.discovery": {"__future__", "fnmatch", "re", "pathlib", "codeguard.config", "codeguard.registry"}, + "codeguard.toolchain": {"__future__", "pathlib", "threading", "codeguard.execution"}, + "codeguard.language_check": {"__future__", "pathlib", "codeguard.config", "codeguard.discovery", + "codeguard.execution", "codeguard.models", "codeguard.planning", + "codeguard.registry", "codeguard.verdict", "codeguard.java_analysis"}, + "codeguard.check_application": {"__future__", "pathlib", "scope", "codeguard.config", + "codeguard.discovery", "codeguard.java_analysis", + "codeguard.language_check", "codeguard.registry"}, + "codeguard.java_build": {"__future__", "re", "xml", "pathlib"}, + "codeguard.java_impact": {"__future__", "collections", "pathlib"}, + "codeguard.java_planning": {"__future__", "collections"}, + "codeguard.java_changes": {"__future__", "re", "xml", "pathlib", "codeguard.execution", "codeguard.java_build"}, + "codeguard.java_environment": {"__future__", "os", "re", "sys", "xml", "pathlib", + "codeguard.execution", "codeguard.java_build"}, + "codeguard.java_analysis": {"__future__", "json", "xml", "pathlib", "codeguard.config", + "codeguard.java_build", "codeguard.java_changes", "codeguard.java_environment", + "codeguard.java_impact", "codeguard.java_planning"}, + "codeguard.gate_checks": {"__future__", "pathlib", "codeguard.language_check", "codeguard.baseline", + "codeguard.discovery", "codeguard.registry", "codeguard.toolchain", + "codeguard.execution", "codeguard.models", "codeguard.planning", + "codeguard.reporting", "codeguard.verdict"}, + "codeguard.gate": {"__future__", "concurrent", "functools", "pathlib", "scope", "git_snapshot", + "codeguard.config", "codeguard.discovery", "codeguard.registry", "codeguard.toolchain", + "codeguard.cache", "codeguard.fingerprint", "codeguard.gate_checks", "codeguard.hook_state", + "codeguard.spec_validation"}, +} + + +def _modules(root: Path) -> dict[str, Path]: + modules = {} + for folder in ("scripts", "hooks"): + for path in sorted((root / folder).rglob("*.py")): + parts = list(path.relative_to(root / folder).with_suffix("").parts) + if parts[-1] == "__init__": + parts.pop() + if parts: + modules[".".join(parts)] = path + return modules + + +def _imports(tree: ast.AST, name: str, package: bool, modules: dict[str, Path]): + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name, node.lineno + elif isinstance(node, ast.ImportFrom): + base = node.module or "" + if node.level: + parent = name.split(".") if package else name.split(".")[:-1] + parent = parent[:len(parent) - node.level + 1] + base = ".".join(parent + ([base] if base else [])) + children = [f"{base}.{alias.name}" for alias in node.names + if f"{base}.{alias.name}" in modules] + if children: + for child in children: + yield child, node.lineno + else: + yield base, node.lineno + + +def _local_module(target: str, modules: dict[str, Path]) -> str | None: + # hooks 既可通过 sys.path 直接导入,也可按 namespace package 导入。 + if target.startswith(("scripts.", "hooks.")): + target = target.split(".", 1)[1] + parts = target.split(".") + while parts: + name = ".".join(parts) + if name in modules: + return name + parts.pop() + return None + + +def _cycles(graph: dict[str, set[str]]) -> list[str]: + done: set[str] = set() + active: list[str] = [] + found = [] + + def visit(name): + if name in active: + found.append("cycle: " + " -> ".join(active[active.index(name):] + [name])) + return + if name in done: + return + active.append(name) + for target in sorted(graph[name]): + visit(target) + active.pop() + done.add(name) + + for name in sorted(graph): + visit(name) + return found + + +def check(root: Path) -> list[str]: + """返回带位置的违例;范围涵盖 scripts/ 与 hooks/,不执行源码。""" + modules = _modules(root) + errors = [] + graph = {name: set() for name in modules} + for name, path in modules.items(): + relative = path.relative_to(root).as_posix() + if name.startswith("codeguard.") and name not in CORE_DEPENDENCIES: + errors.append(f"{relative}:1: unclassified core module: {name}") + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (SyntaxError, UnicodeError, OSError) as exc: + errors.append(f"{relative}: cannot parse: {exc}") + continue + allowed = CORE_DEPENDENCIES.get(name) + for target, line in _imports(tree, name, path.name == "__init__.py", modules): + local = _local_module(target, modules) + if local: + graph[name].add(local) + if allowed is not None and target not in allowed and target.split(".")[0] not in allowed: + errors.append(f"{relative}:{line}: forbidden dependency: {name} -> {target}") + return errors + _cycles(graph) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + if not (args.root / "scripts").is_dir(): + parser.error("目标缺少 scripts/,无法执行架构检查") + errors = check(args.root) + for error in errors: + print(error) + if not errors: + print("Architecture OK: declared core dependencies and first-party import cycles checked") + return int(bool(errors)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codeguard/__init__.py b/scripts/codeguard/__init__.py new file mode 100644 index 0000000..ebe73b8 --- /dev/null +++ b/scripts/codeguard/__init__.py @@ -0,0 +1 @@ +"""CodeGuard 内核;入口协议保留在现有 CLI 和 hooks 适配器中。""" diff --git a/scripts/codeguard/baseline.py b/scripts/codeguard/baseline.py new file mode 100644 index 0000000..6ab89d8 --- /dev/null +++ b/scripts/codeguard/baseline.py @@ -0,0 +1,106 @@ +"""基线证据比较;无基线或未验证时绝不授予存量豁免。""" +from __future__ import annotations + +from pathlib import Path + +from .execution import execute + + +def _git(project_root: Path, *args: str) -> str | None: + outcome = execute(["git", *args], project_root, 15) + return outcome.stdout if outcome.returncode == 0 else None + + +def _mentioned_files(full: str, project_root: Path) -> set[str]: + """从 linter 输出提取"提到的、且在仓内真实存在的"文件(仓根相对路径)。 + + 两类通行格式:`path/file.ext:12:`(javadoc/ruff/pylint/mvn——绝对路径先剥 + 仓根前缀)与 `In path/file.ext line 12`(shellcheck)。只收 root 下真实 + 存在的路径——裸 token(`version:1`)被存在性过滤挡掉。 + """ + import re + root = Path(project_root) + found: set[str] = set() + pats = ( + # path/file.ext:12:(javadoc 绝对路径 / ruff / mvn)——目录前缀可选: + # ruff 在仓根输出裸文件名 `old.py:1:1:`,强制含 / 会漏归因(实测)。 + # 无点的裸 token(SC2086:1 / version:1)不会命中;命中的再过存在性。 + re.compile(r"((?:[A-Za-z]:)?(?:/?(?:[\w.\-]+/)*)[\w.\-]+\.[A-Za-z0-9]+):\d+"), + re.compile(r"\bIn ((?:[\w.\-]+/)+[\w.\-]+\.[A-Za-z0-9]+) line \d+"), + ) + for pat in pats: + for m in pat.findall(full): + p = m + root_s = str(root) + if p.startswith(root_s + "/"): + p = p[len(root_s) + 1:] + p = p.lstrip("./") + if p.startswith("/"): + continue # 仓外绝对路径不归因 + if (root / p).exists(): + found.add(p) + return found + + +def _stale_attribution(full: str, project_root: Path, lang: str, lang_files: list[str]) -> str | None: + """[已弃用·保留接口] delta 失败路径下的"历史债"弱归因。 + + verdict-integrity 原则:**真正的豁免必须有基线复跑证据**(双跑 + baseline_stale_finding)——本函数对此**永远不豁免**,遵循"宁可误拦 + 不误放行":只靠失败文件是否在改动集推断存量债是危险的(同名文件被 + 替换时也会"不在改动集",但其实是新债),必须有 baseline_ref 上同 + 命令同工具的实际复跑才能豁免。 + + 历史与契约: + - 入口仍保留以兼容 tests/test_verdict_integrity.py 与 test_session_fixes + 中的调用;这些测试断言本函数返回 None(不豁免),本实现亦满足。 + - 真正的豁免路径在本模块 baseline_stale_finding,由 gate_checks 对实际 FAIL 调用。 + - 保留该旧导入面,但不作为位置启发式放行的扩展入口。 + + 本函数当前实现永远返回 None。 + """ + return None + + +def baseline_stale_finding( + project_root: Path, rel_path: str, base_cmd: list[str], output: str, timeout: int = 60, + *, + baseline_ref: str = "HEAD", +) -> str | None: + """同命令同工具跑**改动前版本**:基线已存在相同发现 → 存量豁免说明;否则 None。 + + verdict-integrity 既有原则:**无基线证据绝不豁免**(`_stale_attribution` 因 + 此恒返回 None)。本函数首次落地"双跑基线":把 baseline_ref: 的内容 + 写到临时文件、用同一条 linter 命令跑一遍,两次输出各取逐条诊断与 + 出现次数;只有基线本身确认失败且覆盖当前每条发现才可豁免。 + 基线为空/提不出诊断/基线跑不起来 → 一律不豁免。 + + baseline_ref 必须是"改动前":commit 面(工作树/暂存改动)用 HEAD;push 面 + (检查未推送提交)必须用 @{upstream}——坏提交已经进了 HEAD,拿 HEAD 当基线 + 会把本次新引入的坏内容误判成存量(实测踩过:push 面坏提交被豁免放行)。 + """ + from .verdict import FAIL, finding_instances, lint_verdict + pre = _git(project_root, "show", f"{baseline_ref}:{rel_path}") + if pre is None: + return None + new_sigs = finding_instances(output) + if not new_sigs: + return None + import tempfile as _tf + with _tf.TemporaryDirectory(prefix="cg-baseline-") as td: + tmp = Path(td) / ("baseline" + (Path(rel_path).suffix or ".txt")) + try: + tmp.write_text(pre, encoding="utf-8", errors="replace") + except OSError: + return None + joined = " ".join(base_cmd) + cmd = ([c.replace("{file}", str(tmp)) for c in base_cmd] if "{file}" in joined + else list(base_cmd) + [str(tmp)]) + proc = execute(cmd, project_root, timeout) + if lint_verdict(proc.returncode, cmd, proc.stdout + proc.stderr)[0] != FAIL: + return None + base_sigs = finding_instances((proc.stdout or "") + (proc.stderr or "")) + if not base_sigs: + return None + return ("存量问题(基线同命令同工具已存在)" if + all(count <= base_sigs[diagnostic] for diagnostic, count in new_sigs.items()) else None) diff --git a/scripts/codeguard/cache.py b/scripts/codeguard/cache.py new file mode 100644 index 0000000..5beb9d9 --- /dev/null +++ b/scripts/codeguard/cache.py @@ -0,0 +1,47 @@ +"""可失效的软检查缓存;缓存损坏或输入未知只导致重跑,不产生权限。""" +from __future__ import annotations + +import contextlib +import hashlib +import time +from pathlib import Path + +from .storage import read_json, update_json + +FORMAT = 2 + + +def cache_path(home: Path, root: Path) -> Path: + identity = hashlib.sha256(str(root.resolve()).encode()).hexdigest() + return home / "gate-cache" / (identity + ".json") + + +def load_result(path: Path, key: str | None, ttl: float) -> tuple[list, list] | None: + if key is None: + return None + try: + data = read_json(path) + if (data.get("format") != FORMAT or data.get("key") != key + or not isinstance(data.get("ts"), (int, float)) + or not 0 <= time.time() - data["ts"] < ttl + or data.get("skipped") != []): + return None + failures = data.get("failures") + if not isinstance(failures, list) or not all( + isinstance(row, list) and len(row) == 4 and all(isinstance(v, str) for v in row) + for row in failures): + return None + return [tuple(row) for row in failures], [] + except (OSError, ValueError): + return None + + +def store_result(path: Path, key: str | None, failures: list, skipped: list) -> None: + # 旧 tuple 协议未区分每种 skipped;保守不缓存任何部分结果,不猜测未知是否可忽略。 + if key is None or skipped: + return + def change(data): + data.clear() + data.update(format=FORMAT, key=key, ts=time.time(), failures=failures, skipped=[]) + with contextlib.suppress(OSError): + update_json(path, change) diff --git a/scripts/codeguard/check_application.py b/scripts/codeguard/check_application.py new file mode 100644 index 0000000..b64e59d --- /dev/null +++ b/scripts/codeguard/check_application.py @@ -0,0 +1,85 @@ +"""CLI/MCP 共用的项目检查请求;不依赖宿主协议或输出流。""" +from __future__ import annotations + +from pathlib import Path + +from scope import changed_files + +from .config import ConfigurationError, get_overrides, load_user_config +from .discovery import detect_languages, find_project_root +from .java_analysis import analyze +from .language_check import run_check, run_fix +from .registry import REGISTRY + + +def filter_languages(project_root: Path, lang_arg: str | None) -> list[str]: + config = load_user_config() + enabled = config.get("enabled_languages", []) + languages = detect_languages(project_root) + if enabled and enabled != ["auto"]: + languages = [lang for lang in languages if lang in enabled] + if lang_arg: + languages = [lang for lang in languages if lang in lang_arg.split(",")] + return languages + + +def registry_entries() -> list[dict]: + return [{"id": entry["id"], "name": entry.get("name", entry["id"])} + for entry in REGISTRY.values()] + + +def result_envelope(results: list[dict]) -> list[dict]: + """保留 MCP 历史字段;完整日志路径只来自实际检查。""" + envelope = [] + for result in results: + log_path = result.get("log_path", "") + envelope.append({ + "language": result.get("language", ""), + "passed": bool(result.get("passed")), + "status": result.get("status", "PASS" if result.get("passed") else "FAIL"), + "reason": result.get("reason") or result.get("unverified", ""), + "exit_code": int(result.get("exit_code", 0)), + "stderr_path": log_path, + "log_path": log_path, + }) + return envelope + + +def auto_fix(root: Path, languages: list[str]) -> dict: + """仅修复当前 Git 改动;无法确定范围时不扩大写入面。""" + files = changed_files(root) + if files is None: + return {"fixed": False, "status": "UNVERIFIED", "reason": "无法确定 Git 改动范围;请显式使用 CLI fix --all", + "check": []} + targets = [root / file for file in files if (root / file).is_file() and not (root / file).is_symlink()] + before = {path: path.read_bytes() for path in targets} + fix_results = run_fix(languages, root, files=files) + results = run_check(languages, root, files=files, log_dir=root / "out") + changed = any(not path.is_file() or path.read_bytes() != body for path, body in before.items()) + return {"fixed": changed, "fix_results": fix_results, "check": result_envelope(results)} + + +def mcp_tool_payload(name: str, arguments: dict | None, project_root: Path): + """按已公开的四个工具返回 JSON 可序列化结果;不包含 SDK 类型。""" + if name == "analyze_java_impact": + args = arguments or {} + return analyze(args.get("path") or project_root, args.get("changed")) + if name == "list_languages": + return registry_entries() + if name not in ("check_code_style", "auto_fix"): + return {"error": f"unknown tool: {name}"} + args = arguments or {} + raw = args.get("path") or str(project_root) + root = find_project_root(raw) or Path(raw).resolve() + requested = args.get("languages") + try: + get_overrides(root) + languages = list(requested) if requested else filter_languages(root, None) + except ConfigurationError as exc: + return {"status": "UNVERIFIED", "passed": False, "exit_code": 1, + "error": str(exc), "path": str(root)} + if not languages: + return {"error": "未识别到语言", "path": str(root)} + if name == "auto_fix": + return auto_fix(root, languages) + return result_envelope(run_check(languages, root, log_dir=root / "out")) diff --git a/scripts/codeguard/config.py b/scripts/codeguard/config.py new file mode 100644 index 0000000..246f943 --- /dev/null +++ b/scripts/codeguard/config.py @@ -0,0 +1,95 @@ +"""用户设置与项目覆盖:仅解析和校验,不扫描源码或执行工具。""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +from .registry import REGISTRY + + +class ConfigurationError(ValueError): + """显式项目配置不可用;调用方应呈现 UNVERIFIED,不可当作默认配置。""" + + +def load_user_config() -> dict: + """从 ~/.zcode/settings.local.yaml 读插件配置(简化版:正则抠块,不依赖 yaml 库)""" + cfg_path = Path.home() / ".zcode" / "settings.local.yaml" + defaults = { + "enabled_languages": [], + "strict_mode": True, + "auto_fix_on_save": True, + # 默认 300s:Maven 冷缓存 install -DskipTests 普遍超 2 分钟(旧 120s + # 实测把超时误报为阻断)。AI 可在 ~/.zcode/settings.local.yaml 覆盖。 + "lint_timeout_seconds": 300, + } + if not cfg_path.exists(): + return defaults + try: + text = cfg_path.read_text() + except OSError: + return defaults + cfg = dict(defaults) + m = re.search(r"codeguard:\s*(\{.*?\n\})", text, re.DOTALL) + if not m: + return cfg + block = m.group(1) + for key in ("strict_mode", "auto_fix_on_save"): + mm = re.search(rf"{key}:\s*(true|false)", block) + if mm: + cfg[key] = mm.group(1) == "true" + mm = re.search(r"lint_timeout_seconds:\s*(\d+)", block) + if mm: + cfg["lint_timeout_seconds"] = int(mm.group(1)) + mm = re.search(r"enabled_languages:\s*\[([^\]]*)\]", block) + if mm: + items = [s.strip().strip("\"'") for s in mm.group(1).split(",") if s.strip()] + cfg["enabled_languages"] = items + return cfg + + +# === 项目根 codeguard.json 自定义扩展映射(借鉴 codegraph.json 设计) === +def load_project_overrides(project_root: str | Path) -> dict: + """读取项目根 codeguard.json 的 extensions/exclude 自定义映射""" + cfg = Path(project_root) / "codeguard.json" + if not cfg.exists(): + return {} + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError) as exc: + raise ConfigurationError(f"{cfg}: 无法读取或解析配置: {exc}") from exc + if not isinstance(data, dict): + raise ConfigurationError(f"{cfg}: 顶层必须为对象") + extensions = data.get("extensions") + if extensions is None: + extensions = {} + if not isinstance(extensions, dict) or any( + not isinstance(key, str) or not key.startswith(".") or + not isinstance(lang, str) or lang not in REGISTRY + for key, lang in extensions.items()): + raise ConfigurationError(f"{cfg}: extensions 必须将点号扩展名映射到已注册语言 ID") + exclude = data.get("exclude") + if exclude is None: + exclude = [] + if not isinstance(exclude, list) or not all(isinstance(pattern, str) for pattern in exclude): + raise ConfigurationError(f"{cfg}: exclude 必须为正则字符串数组") + for pattern in exclude: + try: + re.compile(pattern) + except re.error as exc: + raise ConfigurationError(f"{cfg}: exclude 包含非法正则 {pattern!r}: {exc}") from exc + scope = data.get("gate_scope") + if scope is not None and scope not in ("delta", "repo"): + raise ConfigurationError(f"{cfg}: gate_scope 只能是 delta 或 repo") + return { + "extensions": {k.lower(): v for k, v in extensions.items()}, + "exclude": exclude, + # 门禁扫描范围:"delta"(默认,只查本次改动涉及的文件——存量问题不拦新提交) + # 或 "repo"(全仓扫描;仍会剔除 vendor/build 等不可编辑目录) + "gate_scope": scope, + } + + +def get_overrides(project_root: str | Path) -> dict: + # 配置体积很小;不维护一个永不失效的进程级副本(MCP 可长驻数小时)。 + return load_project_overrides(project_root) diff --git a/scripts/codeguard/cve.py b/scripts/codeguard/cve.py new file mode 100644 index 0000000..1f3587f --- /dev/null +++ b/scripts/codeguard/cve.py @@ -0,0 +1,163 @@ +"""CVE 应用服务:选择生态、执行审计、显式修复与复扫;不输出宿主协议。""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from .config import ConfigurationError, get_overrides +from .cve_policy import CVSS_BAND_FLOOR, EXIT_USAGE, aggregate_exit +from .cve_scanners import run, scan_cargo, scan_maven, scan_node, scan_pip, scan_trivy +from .discovery import detect_languages, find_project_root + + +def _scan_maven_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: + return scan_maven(root, CVSS_BAND_FLOOR[severity]) + + +def _scan_node_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: + result = scan_node(root, severity, False) + if allow_fix and result.get("status") == "FAIL": + argv = ["npm", "audit", "fix"] + rc, out, err = run(argv, cwd=root, timeout=900) + after = scan_node(root, severity, False) + after["before_fix"] = result + after["fix_execution"] = {"argv": argv, "cwd": str(root), "exit": rc, "stdout": out, "stderr": err} + return after + return result + + +def _scan_pip_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: + return scan_pip(root, severity) + + +def _scan_cargo_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: + return scan_cargo(root, severity) + + +def _scan_trivy_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: + return scan_trivy(root, severity) + + +# 唯一生态目录:规范标识、别名、语言、输入与入口一起派生。 +ECOSYSTEM_SCANNERS = { + "maven": { + "aliases": ("java",), + "languages": ("java",), + "markers": ("pom.xml",), + "scan": _scan_maven_ecosystem, + }, + "node": { + "aliases": (), + "languages": ("typescript",), + "markers": ("package.json", "package-lock.json", "npm-shrinkwrap.json"), + "scan": _scan_node_ecosystem, + }, + "python": { + "aliases": (), + "languages": ("python",), + "markers": ("requirements.txt", "requirements-dev.txt", "pyproject.toml", "Pipfile.lock"), + "scan": _scan_pip_ecosystem, + }, + "rust": { + "aliases": (), + "languages": ("rust",), + "markers": ("Cargo.lock",), + "scan": _scan_cargo_ecosystem, + }, + "universal": { + "aliases": ("trivy",), + "languages": (), + "markers": (), + "scan": _scan_trivy_ecosystem, + }, +} + + +def canonical_ecosystem(value: str) -> str | None: + """生态标识归一化:接受规范标识与其别名;未声明返回 None""" + key = value.strip().lower() + if key in ECOSYSTEM_SCANNERS: + return key + for canonical, spec in ECOSYSTEM_SCANNERS.items(): + if key in spec["aliases"]: + return canonical + return None + + +def ecosystem_choices() -> str: + """全部可接受标识(含别名),供错误信息与 --help 共用""" + parts = [] + for canonical, spec in ECOSYSTEM_SCANNERS.items(): + aliases = spec["aliases"] + parts.append(f"{canonical}(别名 {'/'.join(aliases)})" if aliases else canonical) + return "、".join(parts) + + +def language_ecosystem_map() -> dict[str, str]: + """语言 id → 规范生态标识,从权威映射派生""" + return {lang: canonical + for canonical, spec in ECOSYSTEM_SCANNERS.items() + for lang in spec["languages"]} + + +def precheck(root: Path, eco: str) -> tuple[bool, str]: + """返回 (可扫描, 说明)。缺少标志文件说明项目不属于该生态(或依赖未锁定), + 运行扫描器只会得到环境错误——归类为「无法验证」而非「有漏洞」。""" + markers = ECOSYSTEM_SCANNERS.get(eco, {}).get("markers", ()) + if not markers: + return True, "" + if any((root / m).exists() for m in markers): + return True, "" + return False, f"缺少 {' / '.join(markers)}(可能不是{eco}项目或依赖未锁定)" + + + +@dataclass(frozen=True) +class CveBatch: + """一次应用调用,CLI 文本和 JSON 使用同一个判定。""" + + root: Path + ecosystems: tuple[str, ...] + results: tuple[dict, ...] + exit_code: int + error: str = "" + + +def scan_project(path: str | Path, ecosystem: str | None = None, + severity: str = "HIGH", allow_fix: bool = False) -> CveBatch: + """先验证参数与项目配置,不允许显式生态或 --fix 绕过坏配置。""" + canonical = canonical_ecosystem(ecosystem) if ecosystem else None + root = Path(path).resolve() + if ecosystem and canonical is None: + return CveBatch(root, (), (), EXIT_USAGE, + f"未知生态 {ecosystem!r};可接受:{ecosystem_choices()}") + if severity not in CVSS_BAND_FLOOR: + return CveBatch(root, (), (), EXIT_USAGE, f"未知严重度 {severity!r}") + root = find_project_root(root) or root + results = [] + ecosystems = [] + try: + get_overrides(root) + if canonical: + ecosystems = [canonical] + else: + languages = detect_languages(root) + mapping = language_ecosystem_map() + ecosystems = sorted({mapping[lang] for lang in languages if lang in mapping}) + if languages and not ecosystems: + ecosystems = ["universal"] + except ConfigurationError as exc: + results.append({"ecosystem": canonical or "project", "tool": "configuration", "exit": 1, + "status": "UNVERIFIED", "reason": str(exc), "fix_hint": "修复项目配置后重试"}) + return CveBatch(root, tuple(ecosystems), tuple(results), aggregate_exit(results)) + for eco in ecosystems: + available, reason = precheck(root, eco) + if not available: + result = {"ecosystem": eco, "tool": eco, "exit": 127, "status": "UNVERIFIED", + "reason": reason, "summary_tail": f"无法验证: {reason}", "fix_hint": "确认项目类型后重试"} + else: + result = ECOSYSTEM_SCANNERS[eco]["scan"](root, severity, allow_fix) + if result.get("status") not in ("PASS", "FAIL", "UNVERIFIED"): + result = {**result, "status": "UNVERIFIED", "reason": "适配器缺少有效报告判定"} + results.append(result) + return CveBatch(root, tuple(ecosystems), tuple(results), aggregate_exit(results)) diff --git a/scripts/codeguard/cve_policy.py b/scripts/codeguard/cve_policy.py new file mode 100644 index 0000000..630bb49 --- /dev/null +++ b/scripts/codeguard/cve_policy.py @@ -0,0 +1,52 @@ +"""CVE 的纯证据与判定契约;进程成功本身不是安全通过。""" +from __future__ import annotations + +from dataclasses import dataclass + +SEVERITY_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3} +CVSS_BAND_FLOOR = {"LOW": 0, "MEDIUM": 4, "HIGH": 7, "CRITICAL": 9} +EXIT_PASS, EXIT_UNVERIFIED, EXIT_FINDINGS, EXIT_USAGE = 0, 1, 2, 3 + + +def severities_at_and_above(threshold: str) -> list[str]: + """阈值及以上;UNKNOWN 不属于任意已知严重度档位。""" + floor = SEVERITY_ORDER[threshold.upper()] + return [severity for severity, rank in SEVERITY_ORDER.items() if rank >= floor] + + +@dataclass(frozen=True) +class ReportEvidence: + """解析后的报告事实;findings 保留工具字段,不是宿主状态或执行结果。""" + + findings: tuple[dict, ...] = () + observed: int = 0 + exceeded: bool = False + unknown: bool = False + problem: str = "" + counts: tuple[tuple[str, int], ...] = () + over_threshold: tuple[str, ...] = () + + +def classify(report: ReportEvidence, rc: int, accepted_codes=(0, 1), *, + allow_below_threshold_exit: bool = False) -> tuple[str, str]: + """先验证扫描与报告,再解释阈值;已知发现不掩盖扫描故障。""" + if rc not in accepted_codes: + return "UNVERIFIED", f"扫描进程未正常完成(exit={rc})" + if report.problem: + return "UNVERIFIED", report.problem + if report.exceeded: + return "FAIL", "有效报告包含阈值及以上漏洞" + if report.unknown: + return "UNVERIFIED", "报告含未解析依赖或缺少可比较严重度;保留发现,请复核" + if rc and not (allow_below_threshold_exit and report.observed): + return "UNVERIFIED", "非零退出但无匹配的阈值漏洞证据" + return "PASS", "有效报告中无阈值及以上漏洞" + + +def aggregate_exit(results: list[dict]) -> int: + """混合生态以发现优先;空批次或缺失/未知判定不得通过。""" + if any(result.get("status") == "FAIL" for result in results): + return EXIT_FINDINGS + if not results or any(result.get("status") != "PASS" for result in results): + return EXIT_UNVERIFIED + return EXIT_PASS diff --git a/scripts/codeguard/cve_reports.py b/scripts/codeguard/cve_reports.py new file mode 100644 index 0000000..07efe7c --- /dev/null +++ b/scripts/codeguard/cve_reports.py @@ -0,0 +1,156 @@ +"""五种扫描报告的纯适配器;不读文件、不启动工具、不写 stdout。""" +from __future__ import annotations + +import json +import math + +from .cve_policy import ReportEvidence, severities_at_and_above + + +def _object(value, label): + if not isinstance(value, dict): + raise TypeError(f"{label} 必须是对象") + return value + + +def _list(value, label): + if not isinstance(value, list): + raise TypeError(f"{label} 必须是列表") + return value + + +def _identity(value, label): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{label} 缺少有效标识") + return value + + +def _integer(value): + return type(value) is int and value >= 0 + + +def _maven(report, threshold): + scan_info = _object(report.get("scanInfo", {}), "scanInfo") + if scan_info.get("analysisExceptions"): + raise ValueError("dependency-check 报告含分析错误") + findings = [] + exceeded = unknown = False + for dependency in _list(report.get("dependencies"), "dependencies"): + for finding in _list(_object(dependency, "dependency").get("vulnerabilities", []), "vulnerabilities"): + finding = _object(finding, "vulnerability") + identifier = _identity(finding.get("name"), "vulnerability.name") + score = None + for version in ("cvssv4", "cvssv3", "cvssv2"): + if version not in finding or finding[version] is None: + continue + cvss = _object(finding[version], version) + candidate = cvss.get("baseScore", cvss.get("score")) + if candidate is None: + continue + if type(candidate) not in (int, float) or not math.isfinite(candidate) or not 0 <= candidate <= 10: + raise ValueError(f"{version} 分数必须是 0—10 的有限数值") + score = candidate + break + findings.append({**finding, "id": identifier, "score": score}) + unknown |= score is None + exceeded |= threshold == 0 or (score is not None and score >= threshold) + return ReportEvidence(tuple(findings), len(findings), exceeded, unknown) + + +def _node(report, threshold): + meta = _object(_object(report.get("metadata"), "metadata").get("vulnerabilities"), "漏洞统计") + required = {"low", "moderate", "high", "critical"} + if not required.issubset(meta) or set(meta) - required - {"info", "total"}: + raise ValueError("漏洞统计缺少严重度档位或包含未知档位") + if any(not _integer(count) for count in meta.values()): + raise ValueError("漏洞统计必须是非负整数") + total = sum(meta.get(key, 0) for key in (*required, "info")) + if "total" in meta and meta["total"] != total: + raise ValueError("漏洞总数与严重度统计不一致") + findings = [] + if "vulnerabilities" in report: + details = _object(report["vulnerabilities"], "vulnerabilities") + detail_counts = dict.fromkeys((*required, "info"), 0) + for name, finding in details.items(): + _identity(name, "vulnerabilities.name") + severity = _object(finding, "vulnerability").get("severity") + if not isinstance(severity, str) or severity not in detail_counts: + raise ValueError("漏洞明细缺少可识别严重度") + detail_counts[severity] += 1 + findings.append(finding) + if any(detail_counts[key] != meta.get(key, 0) for key in detail_counts): + raise ValueError("漏洞明细与严重度统计不一致") + counts = {**meta, "medium": meta["moderate"]} + del counts["moderate"] + over = tuple(severity for severity in severities_at_and_above(threshold) if counts[severity.lower()]) + return ReportEvidence(findings=tuple(findings), observed=total, exceeded=bool(over), + counts=tuple(counts.items()), over_threshold=over) + + +def _pip(report, threshold): + findings = [] + skipped = False + for dependency in _list(report.get("dependencies"), "dependencies"): + dependency = _object(dependency, "dependency") + if "skip_reason" in dependency: + skipped = True + continue + for finding in _list(dependency.get("vulns"), "vulns"): + _identity(_object(finding, "vulnerability").get("id"), "vulnerability.id") + findings.append(finding) + return ReportEvidence(tuple(findings), len(findings), bool(findings) and threshold.upper() == "LOW", + bool(findings), problem="pip-audit 存在未完成审计的依赖" if skipped else "") + + +def _cargo(report, threshold): + summary = _object(report.get("vulnerabilities"), "vulnerabilities") + findings = _list(summary.get("list"), "vulnerabilities.list") + if (type(summary.get("found")) is not bool or not _integer(summary.get("count")) + or summary["found"] != bool(findings) or summary["count"] != len(findings)): + raise ValueError("cargo 漏洞 found/count/list 不一致") + for finding in findings: + advisory = _object(_object(finding, "vulnerability").get("advisory"), "advisory") + _identity(advisory.get("id"), "advisory.id") + # RustSec 的 CVSS 向量尚未实现计算,不能把字符串当可比较分数。 + return ReportEvidence(tuple(findings), len(findings), bool(findings) and threshold.upper() == "LOW", + bool(findings)) + + +def _trivy(report, threshold): + findings = [] + exceeded = unknown = False + allowed = severities_at_and_above(threshold) + for result in _list(report.get("Results"), "Results"): + result = _object(result, "Result") + _identity(result.get("Target"), "Result.Target") + # Trivy 在无漏洞时省略字段;null 切片同样代表无记录,但根 Results 必须可确认。 + vulnerabilities = result.get("Vulnerabilities") + if vulnerabilities is None: + continue + for finding in _list(vulnerabilities, "Vulnerabilities"): + _identity(_object(finding, "Vulnerability").get("VulnerabilityID"), "VulnerabilityID") + severity = finding.get("Severity") + if severity is not None and not isinstance(severity, str): + raise ValueError("Severity 必须是字符串") + severity = (severity or "UNKNOWN").upper() + if severity not in ("LOW", "MEDIUM", "HIGH", "CRITICAL", "UNKNOWN"): + raise ValueError("未知严重度标签") + findings.append(finding) + unknown |= severity == "UNKNOWN" + exceeded |= severity in allowed or (threshold.upper() == "LOW" and severity == "UNKNOWN") + return ReportEvidence(tuple(findings), len(findings), exceeded, unknown) + + +_PARSERS = {"maven": _maven, "node": _node, "python": _pip, "rust": _cargo, "universal": _trivy} + + +def parse_report(ecosystem: str, source: str, threshold: str | int) -> ReportEvidence: + """仅捕获输入协议错误;未知适配器或程序错误仍暴露给开发者。""" + parser = _PARSERS[ecosystem] + try: + report = _object(json.loads(source), "报告") + if report.get("error") or report.get("errors"): + raise ValueError("扫描报告包含错误") + return parser(report, threshold) + except (ValueError, TypeError) as exc: + return ReportEvidence(problem=f"扫描未返回有效漏洞报告:{exc}") diff --git a/scripts/codeguard/cve_scanners.py b/scripts/codeguard/cve_scanners.py new file mode 100644 index 0000000..39a1196 --- /dev/null +++ b/scripts/codeguard/cve_scanners.py @@ -0,0 +1,88 @@ +"""扫描器 IO 适配:显式项目输入、独立报告目录、统一进程边界。""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from .cve_policy import ReportEvidence, classify, severities_at_and_above +from .cve_reports import parse_report +from .execution import execute + + +def run(cmd: list[str], cwd: Path, timeout: int = 600) -> tuple[int, str, str]: + """保留旧 tuple 导入面;扫描器不消费宿主标准输入。""" + return execute(cmd, cwd, timeout, stdin_null=True).as_tuple() + + +def _result(ecosystem, tool, root, argv, outcome, report, fix_hint, accepted_codes=(0, 1), *, + allow_below_threshold_exit=False): + rc, out, err = outcome + status, reason = classify(report, rc, accepted_codes, allow_below_threshold_exit=allow_below_threshold_exit) + return {"ecosystem": ecosystem, "tool": tool, "exit": rc, "status": status, "reason": reason, + "findings": list(report.findings), "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint, + "execution": {"argv": argv, "cwd": str(root), "exit": rc, "stdout": out, "stderr": err}} + + +def scan_maven(root: Path, threshold: int) -> dict: + """只读本次独立目录的报告;项目中的旧报告永远不是证据。""" + with tempfile.TemporaryDirectory(prefix="codeguard-cve-") as output_dir: + argv = ["./mvnw" if (root / "mvnw").exists() else "mvn", "-q", + "org.owasp:dependency-check-maven:aggregate", f"-DfailBuildOnCVSS={threshold}", + "-Dformat=JSON", f"-Dodc.outputDirectory={output_dir}"] + outcome = run(argv, cwd=root, timeout=1800) + try: + source = (Path(output_dir) / "dependency-check-report.json").read_text(encoding="utf-8") + report = parse_report("maven", source, threshold) + except (OSError, UnicodeError) as exc: + report = ReportEvidence(problem=f"无法读取本次 dependency-check 报告:{exc}") + return _result("maven", "owasp dependency-check", root, argv, outcome, report, + "mvn versions:display-dependency-updates;升级受影响组件,或登记确认的误报") + + +def scan_node(root: Path, threshold: str, allow_fix: bool = False) -> dict: + """只执行审计;allow_fix 为旧签名兼容,修复由应用层显式编排。""" + argv = ["npm", "audit", "--json"] + outcome = run(argv, cwd=root, timeout=600) + report = parse_report("node", outcome[1], threshold) + result = _result("node", "npm audit", root, argv, outcome, report, "npm audit fix;确认 Node.js 与 registry 可用", + allow_below_threshold_exit=True) + result.update(counts=dict(report.counts), over_threshold=list(report.over_threshold), failed=result["status"] == "FAIL") + return result + + +def scan_pip(root: Path, threshold: str = "LOW") -> dict: + requirements = next((name for name in ("requirements.txt", "requirements-dev.txt") + if (root / name).is_file()), None) + if requirements: + target = ["--requirement", requirements] + elif (root / "pyproject.toml").is_file(): + target = ["."] + else: + return {"ecosystem": "python", "tool": "pip-audit", "exit": 1, "status": "UNVERIFIED", + "reason": "没有支持的 requirements/pyproject 项目输入", "findings": []} + argv = ["pip-audit", "--strict", "--format", "json", *target] + outcome = run(argv, cwd=root, timeout=900) + return _result("python", "pip-audit", root, argv, outcome, parse_report("python", outcome[1], threshold), + "确认 pip-audit 可用;按报告升级 requirements/pyproject 中受影响包,不自动修改依赖") + + +def scan_cargo(root: Path, threshold: str = "LOW") -> dict: + probe_argv = ["cargo", "audit", "--version"] + probe = run(probe_argv, cwd=root, timeout=60) + if probe[0]: + return _result("rust", "cargo audit", root, probe_argv, probe, + ReportEvidence(problem="cargo-audit 探活失败"), "确认 cargo-audit 已安装且可执行") + argv = ["cargo", "audit", "--json"] + outcome = run(argv, cwd=root, timeout=900) + return _result("rust", "cargo audit", root, argv, outcome, parse_report("rust", outcome[1], threshold), + "按报告升级 Cargo.toml 中的受影响 crate(cargo update 可试)") + + +def scan_trivy(root: Path, threshold: str) -> dict: + # 若先过滤 UNKNOWN,解析器看不到缺严重度的发现,会产生假 PASS。 + selected = severities_at_and_above(threshold) + ["UNKNOWN"] + argv = ["trivy", "fs", "--scanners", "vuln", "--format", "json", "--exit-code", "2", + "--severity", ",".join(selected), "."] + outcome = run(argv, cwd=root, timeout=1800) + return _result("universal", "trivy", root, argv, outcome, parse_report("universal", outcome[1], threshold), + "确认 trivy 与漏洞库可用;按报告升级受影响依赖版本", (0, 2)) diff --git a/scripts/codeguard/discovery.py b/scripts/codeguard/discovery.py new file mode 100644 index 0000000..e26a427 --- /dev/null +++ b/scripts/codeguard/discovery.py @@ -0,0 +1,99 @@ +"""项目发现:显式项目根、只读配置与语言索引,不负责工具探活。""" +from __future__ import annotations + +import fnmatch +import re +from pathlib import Path + +from .config import get_overrides +from .registry import EXT_LANG_MAP, FILE_LANG_MAP, PROJECT_MARKERS + + +def project_uses_linter(cmd_def: dict, project_root: str | Path) -> bool: + """项目是否接入了该语言的 linter(requiresConfig 声明的配置文件任一存在)。 + + 生态型 linter(如 ESLint 9+)在没有配置文件的项目里必然报错退出—— + 那是「未接入」,不是「代码违规」;未接入的语言归 skipped 不拦提交。 + requiresConfig 缺省的语言(自包含工具如 shellcheck/clippy)视为已接入。 + """ + required = cmd_def.get("requiresConfig") if isinstance(cmd_def, dict) else None + if not required: + return True + root = Path(project_root) + for name in required: + if any(w in name for w in "*?["): + if any(fnmatch.fnmatch(p.name, name) for p in root.iterdir()): + return True + elif (root / name).exists(): + return True + return False + + +def detect_language(file_path: str | Path, project_root: Path | None = None) -> str | None: + """按扩展名/文件名判断语言;支持 codeguard.json 自定义映射;非代码文件返回 None""" + p = Path(file_path) + # 项目根缺省从文件路径推导——钩子调用都不显式传 root, + # 不推导则 codeguard.json 的自定义扩展映射永远不会生效 + if project_root is None: + project_root = find_project_root(p) + overrides = get_overrides(project_root) if project_root else {} + ext_map = {**EXT_LANG_MAP, **overrides.get("extensions", {})} + lang = ext_map.get(p.suffix.lower()) + if lang: + return lang + # 文件名级识别(Dockerfile 等) + return FILE_LANG_MAP.get(p.name) + + +def find_project_root(start: str | Path) -> Path | None: + """从 start 路径向上找项目根(识别到 .git 或任一项目标记文件即停)""" + p = Path(start).resolve() + if p.is_file(): + p = p.parent + while True: + if (p / ".git").exists(): + return p + for marker in PROJECT_MARKERS: + if (p / marker).exists(): + return p + if p.parent == p: + return None + p = p.parent + + +def _excluded(f: Path, exclude_patterns: list[str]) -> bool: + return any(re.search(pat, str(f)) for pat in exclude_patterns) + + +def detect_languages(project_root: str | Path) -> list[str]: + """检测项目根中存在的语言:标记文件 + 常见源码目录递归扫描 + 根目录一层; + 支持 codeguard.json 的 exclude 排除""" + project_root = Path(project_root) + overrides = get_overrides(project_root) + exclude = overrides.get("exclude", []) + ext_map = {**EXT_LANG_MAP, **overrides.get("extensions", {})} + + def include(file: Path) -> None: + if file.is_file() and not _excluded(file, exclude): + language = ext_map.get(file.suffix.lower()) or FILE_LANG_MAP.get(file.name) + if language: + langs.add(language) + + langs: set[str] = set() + # 1) 标记文件 + for marker, lang in PROJECT_MARKERS.items(): + if (project_root / marker).exists(): + langs.add(lang) + # 2) 保留递归覆盖,但不重复遍历 src/main,不逐文件重新读取配置或推断根。 + src_dirs = ["src", "lib", "pkg", "app", "tests", "test", + "scripts", "bin", "hooks", "cmd", "internal"] + for d in src_dirs: + base = project_root / d + if not base.is_dir(): + continue + for f in base.rglob("*"): + include(f) + # 3) 根目录一层(小脚本项目) + for f in project_root.iterdir(): + include(f) + return sorted(langs) diff --git a/scripts/codeguard/dockerfile.py b/scripts/codeguard/dockerfile.py new file mode 100644 index 0000000..f9d81a3 --- /dev/null +++ b/scripts/codeguard/dockerfile.py @@ -0,0 +1,86 @@ +"""Dockerfile 检查应用:范围发现、逐文件执行、保留全部工具证据。""" +from __future__ import annotations + +from pathlib import Path + +from .dockerfile_reports import aggregate_status, parse_report +from .execution import execute + +DOCKERFILE_NAMES = {"Dockerfile"} +DOCKERFILE_EXTS = {".dockerfile"} + + +def run(cmd: list[str], cwd: Path, timeout: int = 300) -> tuple[int, str, str]: + """兼容旧 tuple 导入,子进程不得消费宿主协议输入。""" + return execute(cmd, cwd, timeout, stdin_null=True).as_tuple() + + +def find_root(path: str) -> Path: + target = Path(path).resolve() + return target if target.is_dir() else target.parent + + +def find_dockerfiles(root: Path) -> list[Path]: + """保留原文件名集合,无隐含数量截断;显式文件范围由 scan_path 处理。""" + found = {path for path in root.iterdir() if path.is_file() and ( + path.name in DOCKERFILE_NAMES or path.suffix.lower() in DOCKERFILE_EXTS)} + for pattern in ("**/Dockerfile", "**/*.dockerfile"): + found.update(path for path in root.glob(pattern) if path.is_file()) + return sorted(found) + + +def _scan(files: list[Path], root: Path, tool: str, prefix: list[str], timeout: int) -> dict: + findings, attempts, statuses = [], [], [] + for file in files: + argv = [*prefix, str(file)] + rc, out, err = run(argv, cwd=root, timeout=timeout) + evidence = parse_report(tool, rc, out) + findings.extend(evidence.findings) + statuses.append(evidence.status) + attempts.append({"file": str(file), "argv": argv, "cwd": str(root), "exit": rc, + "stdout": out, "stderr": err, "status": evidence.status, "reason": evidence.reason}) + status = aggregate_status(statuses) + exit_code = 127 if any(attempt["exit"] == 127 for attempt in attempts) else { + "PASS": 0, "FAIL": 2, "UNVERIFIED": 1}[status] + return {"tool": tool, "status": status, "exit": exit_code, "scanned": len(attempts), + "tool_ok": status != "UNVERIFIED", "findings": findings, "attempts": attempts, + "summary_tail": "\n".join(attempt["reason"] + ": " + attempt["stderr"] + for attempt in attempts if attempt["status"] == "UNVERIFIED")[-2500:]} + + +def hadolint_scan(files: list[Path], root: Path) -> dict: + result = _scan(files, root, "hadolint", ["hadolint", "--format", "json"], 300) + result["records"] = result["findings"] + result["findings"] = [f"{item['file']}:{item['line']} {item['code']} {item['level']} {item['message']}" + for item in result["records"]] + result["fix_hint"] = "确认 hadolint 已安装(brew install hadolint),按规则修复 Dockerfile" + return result + + +def trivy_scan(files: list[Path], root: Path) -> dict: + result = _scan(files, root, "trivy config", + ["trivy", "config", "--format", "json", "--quiet", "--exit-code", "2"], 600) + result["records"] = result["findings"] + result["findings"] = [{"id": item["ID"], "title": item.get("Title"), "severity": item.get("Severity"), + "message": str(item.get("Message") or "")[:200], + "resolution": str(item.get("Resolution") or "")[:200]} for item in result["records"]] + result["fix_hint"] = "确认 trivy 已安装(brew install trivy),按 resolution 修复后重扫" + return result + + +def scan_path(path: str | Path) -> dict: + """返回独立于 stdout 的报告,兼容 hadolint/trivy 字段并补充总判定。""" + target = Path(path).resolve() + try: + if not target.exists(): + raise FileNotFoundError(f"输入不存在: {target}") + root = find_root(str(target)) + files = [target] if target.is_file() else find_dockerfiles(root) + if not files: + raise ValueError(f"未找到 Dockerfile: {root}") + except (OSError, ValueError) as exc: + return {"status": "UNVERIFIED", "exit_code": 1, "files": [], "reason": str(exc)} + hadolint, trivy = hadolint_scan(files, root), trivy_scan(files, root) + status = aggregate_status([hadolint["status"], trivy["status"]]) + return {"status": status, "exit_code": {"PASS": 0, "FAIL": 2, "UNVERIFIED": 1}[status], + "files": [str(file) for file in files], "root": str(root), "hadolint": hadolint, "trivy": trivy} diff --git a/scripts/codeguard/dockerfile_reports.py b/scripts/codeguard/dockerfile_reports.py new file mode 100644 index 0000000..56f1da4 --- /dev/null +++ b/scripts/codeguard/dockerfile_reports.py @@ -0,0 +1,103 @@ +"""Dockerfile 扫描的纯协议解析;不从文本噪声或空 stdout 推断安全。""" +from __future__ import annotations + +import json +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SecurityEvidence: + """一次文件检查的判定与结构化发现,进程故障不抹去已解析的发现。""" + + status: str + reason: str + findings: tuple[dict, ...] = () + + +def _object(value): + if not isinstance(value, dict): + raise TypeError("报告记录必须是对象") + return value + + +def _list(value): + if not isinstance(value, list): + raise TypeError("报告发现必须是列表") + return value + + +def _text(value): + if not isinstance(value, str) or not value.strip(): + raise ValueError("报告缺少必要字符串字段") + return value + + +def _hadolint(data): + records = _list(data) + for record in records: + record = _object(record) + for field in ("file", "code", "message"): + _text(record.get(field)) + if type(record.get("line")) is not int or record["line"] < 1: + raise ValueError("hadolint 行号无效") + if record.get("level") not in ("error", "warning", "info", "style", "ignore"): + raise ValueError("hadolint 严重度无效") + return records + + +def _trivy(data): + report = _object(data) + if report.get("error") or report.get("errors"): + raise ValueError("Trivy 报告包含扫描错误") + results = _list(report.get("Results")) + if not results: + raise ValueError("Trivy 未返回目标 Dockerfile 的扫描证据") + findings = [] + for result in results: + result = _object(result) + _text(result.get("Target")) + if result.get("Class") != "config" or result.get("Type") != "dockerfile": + raise ValueError("Trivy 未提供 Dockerfile 配置检查结果") + records = result.get("Misconfigurations") + failures = 0 + for record in _list(records if records is not None else []): + record = _object(record) + _text(record.get("ID")) + if record.get("Status") not in ("FAIL", "PASS", "EXCEPTION"): + raise ValueError("Trivy 规则状态无效") + if record["Status"] == "FAIL": + failures += 1 + findings.append(record) + if "MisconfSummary" in result: + summary = _object(result["MisconfSummary"]) + if any(type(summary.get(key)) is not int or summary[key] < 0 + for key in ("Successes", "Failures", "Exceptions")): + raise ValueError("Trivy 汇总缺失或计数无效") + if summary["Failures"] != failures: + raise ValueError("Trivy 失败统计与规则发现不一致") + elif records is None: + raise ValueError("Trivy 缺少检查统计与规则结果") + return findings + + +def parse_report(tool: str, rc: int, source: str) -> SecurityEvidence: + """报告与返回码同时可信才下结论,解析异常只归一化为未验证。""" + parser, accepted = {"hadolint": (_hadolint, (0, 1)), "trivy config": (_trivy, (0, 2))}[tool] + try: + findings = tuple(parser(json.loads(source))) + except (ValueError, TypeError) as exc: + return SecurityEvidence("UNVERIFIED", f"无有效扫描报告:{exc}") + if rc not in accepted: + return SecurityEvidence("UNVERIFIED", f"扫描进程未完成(exit={rc})", findings) + if findings: + return SecurityEvidence("FAIL", "有效报告发现 Dockerfile 风险", findings) + if rc: + return SecurityEvidence("UNVERIFIED", "非零退出但没有有效风险证据") + return SecurityEvidence("PASS", "有效报告未发现风险") + + +def aggregate_status(statuses: list[str]) -> str: + """沿用双工具完整性要求,未知优先,但调用方必须保留已知风险。""" + if not statuses or any(status not in ("PASS", "FAIL") for status in statuses): + return "UNVERIFIED" + return "FAIL" if "FAIL" in statuses else "PASS" diff --git a/scripts/codeguard/execution.py b/scripts/codeguard/execution.py new file mode 100644 index 0000000..9bee706 --- /dev/null +++ b/scripts/codeguard/execution.py @@ -0,0 +1,59 @@ +"""外部工具的唯一通用执行边界,不解释 lint/CVE 业务结论。""" +from __future__ import annotations + +import os +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path + +from .models import CheckPlan, PlanExecution, ProcessResult + + +def _text(value: str | bytes | None) -> str: + # TimeoutExpired 的输出即使 text=True 也可能是 bytes。 + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value or "" + + +def execute(argv: Sequence[str], cwd: Path, timeout: float, *, + env: Mapping[str, str] | None = None, stdin_null: bool = False) -> ProcessResult: + """按 argv 在指定目录执行,不启 shell、不更改全局 cwd/环境。 + + 归一化可预期的 OS 启动错误与超时;程序错误和用户取消仍向上传播。 + 普通工具的退出码(包括工具自己返回的 124/127)原样保留,由领域 + 判定解释;failure 只描述执行器实际观察到的启动/超时故障。 + """ + command = tuple(argv) + root = Path(cwd) + try: + proc = subprocess.run( + list(command), cwd=root, capture_output=True, check=False, + text=True, encoding="utf-8", errors="replace", timeout=timeout, + env={**os.environ, **env} if env is not None else None, + stdin=subprocess.DEVNULL if stdin_null else None, + ) + except subprocess.TimeoutExpired as exc: + stderr = _text(exc.stderr) + if stderr and not stderr.endswith("\n"): + stderr += "\n" + return ProcessResult(command, root, 124, _text(exc.stdout), + stderr + f"timeout after {timeout}s", "timeout") + except FileNotFoundError as exc: + return ProcessResult(command, root, 127, stderr=f"command not found: {exc}", + failure="not_found") + except OSError as exc: + return ProcessResult(command, root, 126, stderr=f"cannot start command: {exc}", + failure="os_error") + return ProcessResult(command, root, proc.returncode, _text(proc.stdout), _text(proc.stderr)) + + +def execute_plan(plan: CheckPlan, timeout: float) -> PlanExecution: + """顺序执行到首个非零结果;保存全部已执行证据,不解释业务豁免。""" + attempts = [] + for command in plan.commands: + outcome = execute(command.argv, plan.cwd, timeout, env=dict(command.env) or None) + attempts.append(outcome) + if outcome.returncode: + break + return PlanExecution(tuple(attempts)) diff --git a/scripts/codeguard/fingerprint.py b/scripts/codeguard/fingerprint.py new file mode 100644 index 0000000..4b788c9 --- /dev/null +++ b/scripts/codeguard/fingerprint.py @@ -0,0 +1,122 @@ +"""有预算的输入身份采集;不是安全快照,失败时拒绝生成缓存身份。""" +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import stat +from pathlib import Path + +from .execution import execute + +MAX_FILES = 10000 +MAX_BYTES = 64 * 1024 * 1024 + +# 即使被 gitignore 忽略,这些常见根配置也影响检查;任意外部动态配置不在缓存证明范围内。 +CONFIG_NAMES = ( + "codeguard.json", "pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg", "tox.ini", + "package.json", "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", + ".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintignore", + "tsconfig.json", "Cargo.toml", "Cargo.lock", "pom.xml", "build.gradle", "build.gradle.kts", + ".shellcheckrc", ".golangci.yml", ".golangci.yaml", ".prettierrc", ".prettierignore", +) + + +def digest(value) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=True, + separators=(",", ":")).encode()).hexdigest() + + +def _git(root: Path, *args: str) -> str | None: + result = execute(["git", *args], root, 15) + # 无法往返编码的路径不能生成看似可靠的键。 + return result.stdout if result.returncode == 0 and "\ufffd" not in result.stdout else None + + +def file_content(path: Path, budget: int = MAX_BYTES) -> tuple[str, int]: + """散列普通文件内容和权限;拒绝符号链接/设备及读取期间变化。""" + before = path.lstat() + if not stat.S_ISREG(before.st_mode) or before.st_size > budget: + raise ValueError("无法在缓存预算内读取普通文件") + total = 0 + h = hashlib.sha256() + with path.open("rb") as stream: + opened = os.fstat(stream.fileno()) + if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino): + raise ValueError("读取前文件被替换") + while chunk := stream.read(min(65536, budget - total + 1)): + total += len(chunk) + if total > budget: + raise ValueError("文件增长超过缓存预算") + h.update(chunk) + after = os.fstat(stream.fileno()) + if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise ValueError("读取期间文件变化") + return digest([h.hexdigest(), stat.S_IMODE(after.st_mode)]), total + + +def repository_identity(root: Path, *, extra_files=(), max_files: int = MAX_FILES, + max_bytes: int = MAX_BYTES) -> str | None: + """Git 内容索引兼容 linked worktree;NUL 分隔保留空格与换行文件名。""" + root = root.resolve() + listing = _git(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z") + index = _git(root, "ls-files", "--stage", "-z") + if listing is None or index is None: + return None + names = set(listing.rstrip("\0").split("\0")) - {""} + names.update(CONFIG_NAMES) + names.update(str(p) for p in extra_files) + if len(names) > max_files: + return None + records = [] + remaining = max_bytes + try: + for name in sorted(names): + path = root / name + if not path.resolve().is_relative_to(root): + return None + try: + content, used = file_content(path, remaining) + remaining -= used + except FileNotFoundError: + content = "missing" + records.append((name, content)) + return digest({"root": str(root), "head": _git(root, "rev-parse", "--verify", "HEAD"), + "index": index, "upstream": _git(root, "rev-parse", "--verify", "@{upstream}"), + "files": records}) + except (OSError, ValueError): + return None + + +def check_identity(root: Path, config: dict, definitions: dict, *, extra_files=()) -> str | None: + """输入内容、实际参数、配置及本地工具身份的摘要,不落盘环境明文。""" + declared = set(extra_files) + tools = [] + try: + for definition in definitions.values(): + for pattern in definition.get("requiresConfig") or []: + declared.update(str(p.relative_to(root)) for p in root.glob(pattern)) + for key in ("lint", "gate", "format", "probe"): + command = definition.get(key) or [] + if not command: + continue + executable = shutil.which(command[0]) or command[0] + for arg in [executable, *command[1:]]: + path = Path(arg) + if not path.is_absolute(): + path = root / path + if path.is_file(): + info = path.stat() + tools.append((str(path.resolve()), info.st_size, info.st_mtime_ns, + info.st_ctime_ns, info.st_mode)) + tree = repository_identity(root, extra_files=declared) + if tree is None: + return None + environment = {key: os.environ.get(key) for key in + ("PATH", "JAVA_HOME", "NODE_OPTIONS", "PYTHONPATH", "VIRTUAL_ENV", "RUSTUP_TOOLCHAIN")} + return digest({"inputs": tree, "config": config, "commands": definitions, + "tools": tools, "environment": environment}) + except (OSError, ValueError): + return None diff --git a/scripts/codeguard/gate.py b/scripts/codeguard/gate.py new file mode 100644 index 0000000..120aebf --- /dev/null +++ b/scripts/codeguard/gate.py @@ -0,0 +1,159 @@ +"""门禁应用服务:选择准确/观察内容面,汇总检查并持久化原仓审计。""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from pathlib import Path + +from scope import changed_files + +from .cache import cache_path, load_result, store_result +from .config import ConfigurationError, get_overrides +from .discovery import detect_languages +from .fingerprint import check_identity, digest, repository_identity +from .gate_checks import check_language +from .hook_state import codeguard_home, record_gate_decision +from .registry import LANG_COMMANDS +from .spec_validation import validate as _openspec_validate +from .toolchain import ToolchainProbe + + +def _gate_cache_path(project_root: Path) -> Path: + return cache_path(codeguard_home(), project_root) + + +def _worktree_fingerprint(project_root: Path, *, max_files: int = 10000) -> str | None: + """兼容导出;有预算的内容身份,无法完整采集则不缓存。""" + return repository_identity(project_root, max_files=max_files) + + +def _gate_cache_key( + project_root: Path, languages: list, *, + mode: str = "commit", + lanes: tuple[str, ...] | list[str] | None = None, + extra: tuple[str, ...] | list[str] | None = None, + scope: str = "delta", + cfg: dict | None = None, +) -> str | None: + identity = check_identity(project_root, cfg or {}, + {lang: LANG_COMMANDS.get(lang, {}) for lang in languages}) + if identity is None: + return None + return digest({"identity": identity, "mode": mode, "languages": sorted(languages), + "lanes": lanes, "extra": sorted(extra or ()), "scope": scope}) + + +GATE_CACHE_TTL = 60 # 仅软观察结果;准确 Git 门禁从不读取它。 + + +def run_gate( + project_root: Path, + cfg: dict, + languages: list | None = None, + *, + mode: str = "commit", + lanes: tuple[str, ...] | list[str] | None = None, + extra: tuple[str, ...] | list[str] | None = None, + exact: bool = False, + pending_commit: bool = False, +) -> tuple[list, list]: + """运行 linter 门禁(跨进程结果缓存 + 并行执行)。 + + `languages` 可选:调用方(UserPromptSubmit)可传入用户消息里提到的 + 语言子集,门禁只跑该子集;不传则按 `detect_languages()` 全量探测。 + `lanes`/`extra`:本次要检查的文件面(见 scope.changed_files)——硬门禁 + (PreToolUse)按命令链传入预测面;不传 = 三路宽口径(软门禁 UPS 无命令 + 上下文,按"工作树有待提交改动就提醒"的宽口径注入,不硬拦)。 + + 仅软观察在输入身份相同且结论完整时短时复用;硬门禁 exact=True + 始终基于准确快照检查。身份采集失败不缓存,不影响实际检查。 + + 返回 (failures, skipped): + - failures: [(lang, 问题节选, 修复命令, install_hint)] + - skipped: [str] 无法验证的说明(工具未装/超时),不阻塞 + """ + if exact: + from git_snapshot import SnapshotError, validation_tree + from scope import is_build_artifact + try: + with validation_tree(project_root, mode, lanes=lanes, extra=extra, + pending_commit=pending_commit) as (snapshot, paths): + selected = languages if languages is not None else detect_languages(snapshot) + enabled = cfg.get("enabled_languages", []) + if enabled and enabled != ["auto"]: + selected = [lang for lang in selected if lang in enabled] + requested_scope = (get_overrides(snapshot) or {}).get("gate_scope") or "delta" + return run_batch(snapshot, cfg, selected, scope=requested_scope, + changed=[p for p in paths if not is_build_artifact(p)], + audit_root=project_root) + except (SnapshotError, OSError, ValueError) as exc: + return [], [f"git UNVERIFIED:无法验证准确内容快照:{exc}"] + try: + overrides = get_overrides(project_root) + if languages is None: + languages = detect_languages(project_root) + except ConfigurationError as exc: + return [], [f"配置 UNVERIFIED:{exc}"] + if not languages: + return [], [] + # 作用域:项目可用 codeguard.json gate_scope 覆盖;缺省 = git 仓 delta、 + # 非 git 目录全量。delta 只检查本次改动涉及的文件——存量问题不拦新提交。 + changed = changed_files(project_root, mode=mode, lanes=lanes, extra=extra) + scope = overrides.get("gate_scope") or ( + "delta" if changed is not None else "repo" + ) + enabled = cfg.get("enabled_languages", []) + if enabled and enabled != ["auto"]: + languages = [lang for lang in languages if lang in enabled] + + ck = _gate_cache_key(project_root, languages, mode=mode, lanes=lanes, extra=extra, + scope=scope, cfg=cfg) + cache_file = _gate_cache_path(project_root) + cached = load_result(cache_file, ck, GATE_CACHE_TTL) + if cached is not None: + return cached + + failures, skipped = run_batch( + project_root, cfg, languages, + scope=scope, changed=changed if scope == "delta" else None, + baseline_ref="@{upstream}" if mode == "push" else "HEAD", + ) + + after = _gate_cache_key(project_root, languages, mode=mode, lanes=lanes, extra=extra, + scope=scope, cfg=cfg) if ck and not skipped else None + if ck is not None and ck == after: + store_result(cache_file, ck, failures, skipped) + return failures, skipped + + + +def run_batch(project_root: Path, cfg: dict, languages: list, *, scope: str = "repo", + changed: list[str] | None = None, baseline_ref: str = "HEAD", + audit_root: Path | None = None) -> tuple[list, list]: + """worker 返回独立结果;调用线程保序汇总并保留会话归属,不共享可变备注。""" + try: + get_overrides(project_root) + except ConfigurationError as exc: + return [], [f"配置 UNVERIFIED:{exc}"] + probe = ToolchainProbe(project_root) + worker = partial(check_language, project_root, cfg, scope=scope, + changed=changed, baseline_ref=baseline_ref, probe=probe) + with ThreadPoolExecutor(max_workers=min(4, max(1, len(languages)))) as pool: + results = list(pool.map(worker, languages)) + failures, skipped = [], [] + for lang, outcome in zip(languages, results): + if outcome.failure: + failures.append(outcome.failure) + skipped.extend(outcome.notes) + for decision in outcome.decisions: + record_gate_decision(audit_root or project_root, lang, list(decision.argv), + decision.returncode, decision.status, decision.reason, + execution_root=project_root) + try: + spec = _openspec_validate(project_root, timeout_seconds=cfg.get("lint_timeout_seconds", 300)) + failures.extend(spec["failures"]) + if spec["skipped"]: + skipped.append(spec["skipped"]) + except Exception as exc: # noqa: BLE001 — 可选集成异常仍明确未验证 + skipped.append(f"openspec UNVERIFIED:{exc!r}") + return failures, skipped diff --git a/scripts/codeguard/gate_checks.py b/scripts/codeguard/gate_checks.py new file mode 100644 index 0000000..fdc7559 --- /dev/null +++ b/scripts/codeguard/gate_checks.py @@ -0,0 +1,113 @@ +"""语言门禁检查:返回独立结果,不知道宿主、会话或缓存。""" +from __future__ import annotations + +from pathlib import Path + +from .baseline import baseline_stale_finding +from .discovery import detect_language, project_uses_linter +from .execution import execute +from .models import GateDecision, GateOutcome +from .planning import scoped_plan +from .registry import LANG_COMMANDS +from .reporting import _dependency_resolution_hint, _log_path, _truncate_detail +from .toolchain import ToolchainProbe +from .verdict import FAIL, PASS, SKIPPED, UNVERIFIED, lint_verdict + +_ZSH_SKIP_NOTE = ("ShellCheck 不支持 zsh 方言,{n} 个 zsh 文件未送检。" + "修复:在每个 .zsh 文件头添加 `# shellcheck shell=bash` 注释后重试") + + +def _java_check(root: Path, cfg: dict, changed: list[str] | None) -> GateOutcome: + from .language_check import run_check + + outcome = run_check(["java"], root, timeout=cfg.get("lint_timeout_seconds", 120), + files=changed, log_dir=_log_path(root, "java").with_suffix(""))[0] + decisions = () + if outcome.get("command") and not outcome.get("dry_run") and outcome["status"] not in ("PLANNED", "SKIPPED"): + decisions = (GateDecision(tuple(outcome["command"]), outcome["exit_code"], + outcome["status"], outcome["reason"]),) + if outcome["status"] == PASS: + return GateOutcome(decisions=decisions) + if outcome["status"] != FAIL: + hint = ";若 wrapper 缺执行位:chmod +x mvnw" if "不可执行" in outcome.get("reason", "") else "" + return GateOutcome(notes=(f"java {outcome['status']}: {outcome['reason']}{hint}",), decisions=decisions) + detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), root, "java") + if outcome.get("log_path"): + detail += f"\n完整输出: {outcome['log_path']}" + return GateOutcome(("java", detail, "按 Java 影响计划修复并复跑 verify/check", + "无需安装工具;用项目自带 mvnw/gradlew 与匹配 JDK 复跑"), decisions=decisions) + + +def check_language(root: Path, cfg: dict, lang: str, *, scope: str, + changed: list[str] | None, baseline_ref: str, + probe: ToolchainProbe | None = None) -> GateOutcome: + """保留 delta/全量、Markdown advisory、zsh 能力边界及有证据的基线豁免。""" + definition = LANG_COMMANDS.get(lang) + if not definition: + return GateOutcome(notes=(f"{lang} PLANNED:没有可执行检查命令",)) + if lang == "java": + return _java_check(root, cfg, changed if scope == "delta" else None) + files = [] + notes = [] + if scope == "delta": + files = [name for name in (changed or []) + if detect_language(name, root) == lang and (root / name).is_file()] + if lang == "shell": + zsh = [name for name in files if name.endswith(".zsh")] + if zsh: + files = [name for name in files if name not in zsh] + notes.append(_ZSH_SKIP_NOTE.format(n=len(zsh))) + if not files: + return GateOutcome(notes=tuple(notes or [f"{lang} 本次改动未涉及,跳过"])) + + delta = scope == "delta" and 0 < len(files) <= 50 + base = (definition.get("lint") if delta else definition.get("gate") or definition.get("lint")) + base = base or definition.get("gate") or definition.get("lint") + if not base: + return GateOutcome(notes=tuple(notes + [f"{lang} PLANNED:没有可执行检查命令"])) + if not delta and "{file}" in " ".join(base): + return GateOutcome(notes=tuple(notes + [f"{lang} 未配置项目级 gate 命令(lint 为单文件模式),本次未验证"])) + if not project_uses_linter(definition, root): + return GateOutcome(notes=tuple(notes + [f"{lang} 项目未接入(缺 linter 配置文件),本次未验证"])) + hint = definition.get("install_hint") or "见 docs/LANGUAGES.md" + available, reason = (probe or ToolchainProbe(root)).probe(definition) + if not available: + return GateOutcome(notes=tuple(notes + [f"{lang} 工具链不可用未验证:{reason}(安装: {hint})"])) + + plan = scoped_plan(base, root, scope="delta" if delta else "repo", files=files if delta else None, + append_files=definition.get("append_files", True)) + decisions = [] + stale_notes = [] + timeout = cfg.get("lint_timeout_seconds", 120) + for index, command in enumerate(plan.commands): + outcome = execute(command.argv, plan.cwd, timeout) + status, reason = lint_verdict(outcome.returncode, list(command.argv), outcome.stdout + outcome.stderr) + # 当前或基线工具状态未知都不授予豁免;不是先比较文本再猜工具是否运行成功。 + if status == FAIL and delta and "{file}" in " ".join(base): + stale = baseline_stale_finding(root, files[index], base, outcome.stdout + outcome.stderr, + timeout, baseline_ref=baseline_ref) + if stale is not None: + stale_notes.append(f"{files[index]}: {stale}") + decisions.append(GateDecision(command.argv, outcome.returncode, SKIPPED, stale)) + continue + if status == FAIL and lang == "markdown": + status, reason = SKIPPED, "markdown 风格告警" + decisions.append(GateDecision(command.argv, outcome.returncode, status, reason)) + if status == PASS: + continue + if status == UNVERIFIED: + notes.append(f"{lang} 工具链异常未验证:{reason} (exit {outcome.returncode})") + elif status == SKIPPED: + notes.append("markdown 风格告警(不阻塞提交)") + else: + full = "\n".join(part.rstrip() for part in (outcome.stdout, outcome.stderr) if part) + detail = _truncate_detail(full or "(linter 无输出)", root, lang) + dependency = _dependency_resolution_hint(lang, full) + if dependency: + detail += f"\n{dependency}" + fix = f"自动修复: {' '.join(definition['format'])}" if definition.get("format") else "按上述问题逐项修复" + return GateOutcome((lang, detail, fix, hint), tuple(notes), tuple(decisions)) + break + if stale_notes: + notes.append(f"{lang} 存量问题已豁免(基线同命令同工具比对):" + ";".join(stale_notes[:3])) + return GateOutcome(notes=tuple(notes), decisions=tuple(decisions)) diff --git a/scripts/codeguard/git_context.py b/scripts/codeguard/git_context.py new file mode 100644 index 0000000..9d734b4 --- /dev/null +++ b/scripts/codeguard/git_context.py @@ -0,0 +1,204 @@ +"""将静态 Git 意图绑定到本地仓库;负责文件/进程观察,不产生宿主输出。""" +from __future__ import annotations + +import os +import shlex +from pathlib import Path + +from scope import changed_files + +from .execution import execute +from .git_staging import ( + repository_root, + staging_intent, # noqa: F401 — 原导入面兼容 +) +from .git_syntax import ( + _collect_subs, + _flatten_substitutions, + _git_c_path, + _git_side_effect_sub, + _segment_is_git_side_effect, +) + +_SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh", ".py", ".mjs", ".js", ".ts") +_INTERPRETERS = ("bash", "sh", "zsh", "python", "python3", "node") + + + +def _is_git_repo(p: Path) -> bool: + """只读查询 Git 仓边界;工具不可用时交由调用者降级。""" + return execute(["git", "-C", str(p), "rev-parse", "--git-dir"], Path.cwd(), 10).returncode == 0 + + +def resolve_project_roots(command: str) -> list[Path]: + """顺序扫描命令链,收集每个 git commit/push 段各自的仓库边界。 + + 链式发布(cd plugins && git commit && cd minimax && git push)操作 + 多个仓库——每个 git 段的边界 = `git -C ` 显式指定的仓,否则取它 + 之前最近的 cd(且必须是 git 仓);无 cd 则用 cwd(需是 git 仓)。 + 去重保序,找不到任何边界返回 []。 + + **必须与 is_guarded 同源判定**(`_git_side_effect_sub` 归一化后判子命令): + 此前这里用原始 tokens[0]=="git",`git -C /repo push`、`FOO=1 git push`、 + `sudo git push` 虽被 is_guarded 命中却解析出 roots=[],main() 见空直接 + 放行——归一化修复在 roots 层被击穿(实测三形态全部静默通过)。 + """ + import re + command = _flatten_substitutions(command) + roots: list[Path] = [] + last_cd: Path | None = None + current_dir = Path.cwd().resolve() + for seg in re.split(r"&&|\|\||;|\n", command): + seg = seg.strip() + if not seg: + continue + m = re.match(r"cd\s+(\"[^\"]+\"|'[^']+'|\S+)", seg) + if m: + target = (current_dir / m.group(1).strip("\"'")).resolve() + current_dir = target + # 兼容旧降级策略:非 Git 目录后回退调用方 cwd;不是完整 shell 求值。 + last_cd = target if target.is_dir() and _is_git_repo(target) else None + continue + if _git_side_effect_sub(seg) is None: + continue + c_path = _git_c_path(seg) + if c_path is not None: + c_path = (current_dir / c_path).resolve() + if c_path is not None: + root = c_path if c_path.is_dir() and _is_git_repo(c_path) else None + else: + root = last_cd or (Path(os.getcwd()) if _is_git_repo(Path(os.getcwd())) else None) + if root: + root = repository_root(root) + if root not in roots: + roots.append(root) + # 有意行为(勿"修掉"):cd 到非 git 目录后 last_cd=None,git 段回退用 + # 调用方 cwd。这是历史降级行为,不应声称等价于 shell 的 cd 语义。 + return roots + + +def _fallback_roots(cwd: Path) -> list[Path]: + """cwd 不是 git 仓时,扫 cwd 一层子目录找 git 仓作兜底(monorepo 模式)。 + + 返回去重后保序的 git 仓列表(不含 cwd 自身)。 + + 实现注:`_is_git_repo(p)` 调 `git -C p rev-parse --git-dir` —— git 在 + p 不是仓时会沿父目录递归找 .git 并返回成功;这会让 cwd 是任何子目录时 + 全部子目录被错判为 git 仓。fallback 必须用更严格的"仓内 .git 存在"判定: + `(child / ".git").exists()`。`_is_git_repo` 在主流程只在 cwd 自身或显式 + cd 后的目标目录上调用,子目录递归副作用在主流程可控;这里只暴露 cwd + 自身的判定语义,故 fallback 走自己独立的"子目录级 .git 存在"检查。 + """ + if not cwd.is_dir(): + return [] + cwd_resolved = cwd.resolve() + found: list[Path] = [] + for child in sorted(cwd.iterdir(), key=lambda p: p.name): + if not child.is_dir(): + continue + child_resolved = child.resolve() + if child_resolved == cwd_resolved: + continue + # 严格子目录级检查:仓根的 .git 必须真在该子目录里 + if (child / ".git").exists(): + found.append(child_resolved) + return found + + +def _indirect_bodies(command: str): + """只读取得一层解释器脚本/内联文本,共享大小上限与读取失败边界。""" + import re + for seg in re.split(r"&&|\|\||;|\n", command): + try: + tokens = shlex.split(seg) + except ValueError: + tokens = seg.strip().split() + if not tokens: + continue + prog = tokens[0].rsplit("/", 1)[-1] + if prog not in _INTERPRETERS: + continue + rest = tokens[1:] + if "-c" in rest: + yield " ".join(rest[rest.index("-c") + 1:]) + continue + for tok in rest: + if tok.startswith("-"): + continue + target = Path(tok) + if not target.is_file() or target.suffix not in _SCRIPT_SUFFIXES: + break + try: + if target.stat().st_size > 1_000_000: + break + body = target.read_text(encoding="utf-8", errors="ignore") + except OSError: + break + yield body + break + + +def _command_indirect(command: str) -> bool: + """兼容一层间接识别;不执行脚本,不承诺分析动态拼接的 subprocess。""" + import re + return any(_segment_is_git_side_effect(segment) + for body in _indirect_bodies(command) + for segment in re.split(r"&&|\|\||;|\n", body)) + + +def _repo_root_of(p: Path) -> Path | None: + """从路径向上找 .git 所在的仓库根(纯文件系统,不起进程)。""" + cur = p if p.is_dir() else p.parent + for _ in range(10): + if (cur / ".git").exists(): + return cur + if cur.parent == cur: + return None + cur = cur.parent + return None + + + + +def _guarded_mode(command: str) -> str | None: + """门禁命中时返回生效的门禁面:commit 或 push(push 优先——两面并存时 + 推送面是提交面的超集,按更宽的面检查);未命中返回 None。 + + 直接命令段先判定;未命中再走一层解释器间接(脚本/-c 内联)。 + is_guarded 即本函数的存在性判断——面判定与命中判定共用同一套扫描, + 避免"命中了却选错面"的分叉。 + """ + subs = _collect_subs(command) + if not subs: + for body in _indirect_bodies(command): + subs = _collect_subs(body) + if subs: + break + if not subs: + return None + return "push" if "push" in subs else "commit" + + +def is_guarded(command: str) -> bool: + """精确匹配 git commit/push:直接命令 + 一层解释器间接。 + + 直接:子串匹配会误伤命令文本里的数据(payload、调试脚本内容含 + "git push" 字面量),所以按分隔符切段、git 必须是段首命令词。 + 间接:解释器执行的脚本/内联代码按同规则扫一层。 + 命中后面(commit/push)由 _guarded_mode 决定,供门禁选 delta 面。 + """ + return _guarded_mode(command) is not None + + +def _filter_fallback_roots(roots: list[Path], mode: str, + lanes: tuple[str, ...] | list[str] | None) -> list[Path]: + """monorepo 兜底面收窄:只保留本次提交面非空的仓。 + + 会话实测:openclaw 提交时,兜底把 workspace 下所有子仓连同根上散落脚本 + 一起纳入检测面——无关仓的存量违规也变成拦路面(误伤与提交完全无关的 + 仓库)。无提交面的仓跳过;全部为空返回 [](调用方审计后放行)。 + + lanes-only:兜底场景没有显式 cd,`git add <相对路径>` 的 extra 属于 + 会话根而非任何子仓,不参与归属判定。 + """ + return [r for r in roots if changed_files(r, mode=mode, lanes=lanes)] diff --git a/scripts/codeguard/git_staging.py b/scripts/codeguard/git_staging.py new file mode 100644 index 0000000..c45d989 --- /dev/null +++ b/scripts/codeguard/git_staging.py @@ -0,0 +1,95 @@ +"""只读绑定拟暂存范围;Git 负责 pathspec,宿主负责呈现未知边界。""" +from __future__ import annotations + +import os +import re +import shlex +from pathlib import Path + +from git_snapshot import SnapshotError, git + +from .git_syntax import _analyze_segment, _flatten_substitutions + +_LANES = ("staged", "unstaged", "untracked") + + +def repository_root(cwd: Path) -> Path: + """Git 返回真实 worktree 根,不将仓内子目录当仓库身份。""" + return Path(os.fsdecode(git(cwd, "rev-parse", "--show-toplevel")).strip()).resolve() + + +def resolve_pathspecs(cwd: Path, paths: list[str], *, tracked_only=False, + force=False, literal=False) -> list[str]: + """一批 Git pathspec 一次查询,保留 exclude 的集合语义与 NUL 文件名。""" + args = ["--literal-pathspecs"] if literal else [] + args += ["ls-files", "--full-name", "--cached", "-z"] + if not tracked_only: + args.append("--others") + if not force: + args.append("--exclude-standard") + return sorted({os.fsdecode(name) for name in git(cwd, *args, "--", *paths).split(b"\0") if name}) + + +def staging_intent(command: str, project_root: Path | None = None) -> tuple[tuple[str, ...], list[str]]: + """逐段绑定 cd/-C 与仓库;返回 lanes 和已解析的仓根相对字面路径。 + + 保留旧无 project_root 的调用面;硬门禁必须传目标仓,以免多仓范围串扰。 + 不执行 add,不模拟动态 Shell、交互暂存或 pathspec-from-file;不能证明时抛 + SnapshotError,由 Hook 以既有 fail-open 协议明示未验证。 + """ + cwd = Path.cwd().resolve() + wanted = Path(project_root).resolve() if project_root is not None else None + lanes = {"staged"} + extra = set() + for segment in re.split(r"&&|\|\||;|\n", _flatten_substitutions(command)): + try: + words = shlex.split(segment) + except ValueError as exc: + raise SnapshotError("暂存意图含未闭合引用,不能准确解析") from exc + if not words: + continue + if words[0] == "cd" and len(words) == 2: + cwd = (cwd / words[1]).resolve() + continue + tokens, c_path = _analyze_segment(segment) + if len(tokens) < 2 or tokens[0] != "git" or tokens[1] not in ("add", "commit"): + continue + sub, rest = tokens[1], tokens[2:] + if sub == "commit" and not any(flag in ("-a", "-am", "--all") for flag in rest): + continue + location = (cwd / c_path).resolve() if c_path is not None else cwd + repo = repository_root(location) + if wanted is not None and repo != wanted: + continue + if sub == "commit": + if any(flag in ("-a", "-am", "--all") for flag in rest): + lanes.add("unstaged") + continue + flags, paths = [], [] + positional = False + for item in rest: + if item == "--" and not positional: + positional = True + elif item.startswith("-") and not positional: + flags.append(item) + else: + paths.append(item) + if any(flag not in ("-A", "--all", "-u", "--update", "-f", "--force", "-v", "--verbose") for flag in flags): + raise SnapshotError("不支持的 git add 选项,需要独立验证暂存面") + if any("$" in path or "`" in path for path in paths): + raise SnapshotError("动态 git add 路径需要独立验证") + tracked = any(flag in ("-u", "--update") for flag in flags) + force = any(flag in ("-f", "--force") for flag in flags) + all_files = any(flag in ("-A", "--all") for flag in flags) + if not paths: + if tracked: + lanes.add("unstaged") + elif all_files: + lanes.update(_LANES) + continue + if paths in (["."], ["./"], [":/"]) and location == repo and not force: + lanes.update(("staged", "unstaged") if tracked else _LANES) + continue + literal = "--literal-pathspecs" in words[:words.index("add")] if "add" in words else False + extra.update(resolve_pathspecs(location, paths, tracked_only=tracked, force=force, literal=literal)) + return tuple(lane for lane in _LANES if lane in lanes), sorted(extra) diff --git a/scripts/codeguard/git_syntax.py b/scripts/codeguard/git_syntax.py new file mode 100644 index 0000000..2b0cf8c --- /dev/null +++ b/scripts/codeguard/git_syntax.py @@ -0,0 +1,253 @@ +"""Git 命令的静态语法识别;不读取仓库、执行进程或依赖 Hook 协议。""" +from __future__ import annotations + +import shlex +from pathlib import Path + +_ASSIGN_PATTERN = r"^[A-Za-z_][A-Za-z0-9_.-]*=" +_BARE_WRAPPERS = ("env", "sudo", "nohup", "command", "time", "nice", "ionice", "setsid", "stdbuf") +# shell 控制结构引导词:`if git push; then` / `for x; do git push; done` 段首是 +# 控制词而非 git(`;` 切段后 `do git push…` 同理)——不剥就漏拦(实测四种形态 +# 全部静默通过)。只在段首序列出现时剥,不影响普通命令中的同名词。 +_SHELL_LEADERS = ("if", "then", "else", "elif", "while", "until", "do", "!") +_GIT_VALUE_FLAGS = ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", + "--namespace", "--config-env", "--attr-source") +_GIT_BOOL_FLAGS = ("--no-pager", "--bare", "--literal-pathspecs", + "--no-optional-locks", "--shallow-file", "--exec-path") + + +# 全小写比较集:git config 键名大小写不敏感(codeguard.skipGate ≡ codeguard.skipgate) +_SKIP_GATE_ASSIGN = ("codeguard.skipgate=true", "codeguard.skipgate=1", "codeguard.skipgate=yes") + +# git 全局参数里带一个独立值的 flag(`git -C config …` 的 是 flag +# 值不是子命令);带 `=` 形式(--git-dir=/x)无独立值。 +_GIT_GLOBAL_VALUE_FLAGS = frozenset( + ("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--exec-path")) + +# git config 查询/删除形态——只读/清理动作,不构成豁免意图 +_QUERY_CONFIG_FLAGS = ("--get", "--get-all", "--get-regexp", "--unset", "--unset-all", + "--list", "-l") + + + +def _flatten_substitutions(command: str) -> str: + """把 `$(...)` 与反引号的内层文本并入待扫面(按 shell 语义它们会真的执行)。 + + 漏检实例(实测静默放行):`ro=$(git push origin b 2>&1)`、`` x=`git commit` `` + ——段首是赋值词/反引号而非 git,切段扫描永远看不到子命令。内层可能还有 + 内层(`$(echo $(git push))`),工作队列递归展开,深度上限防畸形输入死循环。 + 能力边界:单引号内的字面量(`'$(git push)'` 不执行)不做引号语义区分, + 宁可多拦不漏拦——误拦方向由 lint 门禁自身的 delta 面兜底(不改文件不红)。 + """ + parts: list[str] = [] + queue: list[str] = [command] + seen = 0 + while queue and seen < 50: + text = queue.pop(0) + seen += 1 + parts.append(text) + i = 0 + while True: + j = text.find("$(", i) + if j < 0: + break + depth, k = 1, j + 2 + while k < len(text) and depth: + if text[k] == "(": + depth += 1 + elif text[k] == ")": + depth -= 1 + k += 1 + i = j + 2 + if depth == 0: + inner = text[j + 2:k - 1] + if inner and inner not in parts: + queue.append(inner) + if "`" in text: + seg = text.split("`") + for inner in seg[1::2]: + if inner and inner not in parts: + queue.append(inner) + return "\n".join(parts) + + +def _analyze_segment(seg: str) -> tuple[list[str], Path | None]: + """归一化一段 shell 命令:返回 ([prog, sub, …] 形状的 tokens, `git -C` 的仓路径)。 + + 剥除顺序(与 shell 求值同序):控制结构引导词 → 环境赋值/env/裸 wrapper 前缀 + → git 全局选项。同时捕获 `-C `——它是该 git 段的显式仓库边界。 + 漏检实例(实测静默放行):`VAR=1 git push` 段首是 `VAR=1`;`git -C path push` + tokens[1] 是 `sudo`;`if git push; then` 段首是 `if`。剥完后若段首仍非 git + 则返回原 tokens(由调用方判定为非 git 段)。 + 能力边界(docstring 如实声明):带值的 wrapper 参数(`sudo -u root …`) + 不做完整解析,只剥裸 wrapper token。 + """ + import re as _re + try: + tokens = shlex.split(seg) + except ValueError: + # 旧间接命令识别可能传入引号不完整的片段,保留其保守命中行为。 + tokens = seg.strip().split() + i, n = 0, len(tokens) + while i < n and tokens[i] in _SHELL_LEADERS: + i += 1 + while True: + while i < n and _re.match(_ASSIGN_PATTERN, tokens[i]): + i += 1 + if i < n and tokens[i] == "env": + i += 1 + continue + break + while i < n and tokens[i] in _BARE_WRAPPERS: + i += 1 + if i >= n: + return tokens, None + if tokens[i].strip("\"'") != "git": + return tokens[i:], None + i += 1 + c_path: str | None = None + while i < n: + tok = tokens[i].strip("\"'") + if tok in _GIT_BOOL_FLAGS: + i += 1 + continue + if tok in _GIT_VALUE_FLAGS: + if tok == "-C" and i + 1 < n: + c_path = str(Path(c_path or "") / tokens[i + 1]) + i += 2 + continue + if tok.startswith(("-C", "-c", "--git-dir", "--work-tree", "--exec-path")) and len(tok) > 2: + if tok.startswith("-C") and len(tok) > 2: + c_path = str(Path(c_path or "") / tok[2:]) + i += 1 + continue + break + # 保留 "git" 作为首 token(调用方按 [prog, sub, …] 形状判定),只剥掉前面的 + # 前缀与后面的全局选项;`git` 裸段(无子命令)返回 ["git"] → len<2 → None。 + root = Path(c_path) if c_path else None + return ["git"] + tokens[i:], (root if root else None) + + +def _normalize_segment(seg: str) -> list[str]: + """归一化形状(丢弃 -C 捕获)——保持既有调用面。见 _analyze_segment。""" + tokens, _c = _analyze_segment(seg) + return tokens + + +def _git_c_path(seg: str) -> Path | None: + """该段 `git -C ` 显式指定的仓库(相对路径按字面保留,由调用方判定)。""" + _tokens, c = _analyze_segment(seg) + return c + + +def _git_side_effect_sub(seg: str) -> str | None: + """归一化后,段的子命令为 commit|push 则返回之,否则 None。 + + 首 token 剥一层包裹引号(`bash -c "git commit …"` 切段后首词是 `"git`); + 前缀与 git 全局选项见 `_normalize_segment`。 + """ + tokens = _normalize_segment(seg) + if len(tokens) < 2: + return None + first = tokens[0].strip("\"'") + second = tokens[1].strip("\"'") + return second if first == "git" and second in ("commit", "push") else None + + +def _git_seg_parts(tokens: list[str]) -> tuple[str, int, list[str]] | None: + """按 git 全局参数语法切分单个命令段:返回 (子命令, 子命令下标, 子命令前全局参数)。 + + git 语法 = `git [全局 flag [值]]* <子命令> [参数…]`。**子命令之后的 token + 一律不是全局配置**——`git commit -m "… -c codeguard.skipGate=true …"` 的 + 消息文本、`git commit -m "config codeguard.skipGate true"` 都不构成豁免 + 意图(防的是危险方向的误判:文本里出现豁免短语 → 门禁被静默关掉)。 + 找不到子命令(纯 `git` / 全是 flag)返回 None。 + """ + if len(tokens) < 2 or tokens[0] != "git": + return None + i, pre = 1, [] + while i < len(tokens): + tok = tokens[i] + if tok.startswith("-"): + pre.append(tok) + i += 1 + if (tok.split("=", 1)[0] in _GIT_GLOBAL_VALUE_FLAGS and "=" not in tok + and i < len(tokens)): + pre.append(tokens[i]) + i += 1 + continue + return tok, i, pre + return None + + +def inline_skip_gate(command: str) -> bool: + """`git -c codeguard.skipGate=true …`(全局参数位)= **显式单次豁免意图**。 + + `git config` 豁免是仓库级、需手动 unset(忘记 unset = 门禁永久静默失效, + stop_summary 已在提醒);`-c` 内联是单次、随命令消亡、不留配置残留—— + 更适合"这一条命令我知道有环境问题,放行"的场景。此前 `-c k=v` 被 + `_analyze_segment` 剥掉后 matcher 反而漏拦(守卫绕过口),本函数把该形态 + 识别为**有审计的正门**:调用方放行时必须记 record_skip_event("inline-skipGate")。 + + 只认子命令之前的 `-c` 全局参数位(git 语法本义),消息文本/脚本参数里的 + 同名字符串不构成豁免;value 精确匹配 _SKIP_GATE_ASSIGN。 + """ + text = _flatten_substitutions(command) + import re as _re + for seg in _re.split(r"&&|\|\||;|\n", text): + tokens = [t.strip("\"'") for t in seg.strip().split()] + parts = _git_seg_parts(tokens) + if parts is None: + continue + _, _, pre = parts + for i, tok in enumerate(pre): + if tok == "-c" and i + 1 < len(pre) and pre[i + 1].lower() in _SKIP_GATE_ASSIGN: + return True + return False + + +def chain_skip_gate(command: str) -> bool: + """命令链里含 `git config codeguard.skipGate ` **设置**段 = 显式豁免意图。 + + 文档推荐的仓库级豁免是「set → 提交 → unset」,天然会被写进同一条链命令 + (`git config codeguard.skipGate true && git commit … && git config --unset …`)。 + 此前链里只要有一个 git commit/push 段就整链被拦——set 段自己都没跑成, + 文档绕过首用必败。识别为显式豁免意图放行,并记 "chain-skipGate" 审计。 + + 只认子命令就是 `config` 的**设置**形态(--get/--unset/--list 查询不算), + key 精确匹配 codeguard.skipGate(大小写不敏感)、value 须为真值;消息文本 + 里出现的同名字符串不构成豁免(见 _git_seg_parts 的子命令边界)。 + """ + text = _flatten_substitutions(command) + import re as _re + for seg in _re.split(r"&&|\|\||;|\n", text): + tokens = [t.strip("\"'") for t in seg.strip().split()] + parts = _git_seg_parts(tokens) + if parts is None: + continue + sub, si, _pre = parts + if sub != "config": + continue + rest = tokens[si + 1:] + if any(t in _QUERY_CONFIG_FLAGS for t in rest): + continue + args = [t for t in rest if not t.startswith("-")] + if len(args) < 2: + continue + key, value = args[0], args[1] + if key.lower() == "codeguard.skipgate" and value.lower() in ("true", "1", "yes"): + return True + return False + + +def _segment_is_git_side_effect(seg: str) -> bool: + return _git_side_effect_sub(seg) is not None + + +def _collect_subs(text: str) -> list[str]: + import re + text = _flatten_substitutions(text) + return [ + sub for seg in re.split(r"&&|\|\||;|\n", text) + if (sub := _git_side_effect_sub(seg)) + ] diff --git a/scripts/codeguard/hook_state.py b/scripts/codeguard/hook_state.py new file mode 100644 index 0000000..59bb9ba --- /dev/null +++ b/scripts/codeguard/hook_state.py @@ -0,0 +1,195 @@ +"""Hook 状态应用服务:归属、统计、审计和去重;不负责宿主输出。""" +from __future__ import annotations + +import contextlib +import hashlib +import os +import time +from collections.abc import Callable +from contextvars import ContextVar +from pathlib import Path + +from .fingerprint import file_content +from .storage import append_jsonl, read_json, update_json + +_SCOPE: ContextVar[str] = ContextVar("codeguard_hook_scope", default="") + + +def codeguard_home() -> Path: + return Path(os.environ.get("CODEGUARD_HOME") or (Path.home() / ".codeguard")) + + +def scope_id() -> str: + return _SCOPE.get() + + +@contextlib.contextmanager +def session_scope(payload: dict): + """同一 session 在不同 worktree 不共享统计;无 ID 保留旧共享协议。""" + session = payload.get("session_id") + identity = "" + if isinstance(session, str) and session: + cwd = Path.cwd().resolve() + root = next((p for p in (cwd, *cwd.parents) if (p / ".git").exists()), cwd) + identity = hashlib.sha256((session + "\0" + str(root)).encode()).hexdigest() + token = _SCOPE.set(identity) + try: + yield + finally: + _SCOPE.reset(token) + + +def session_state_path() -> Path: + if scope_id(): + return codeguard_home() / "sessions" / (scope_id() + ".json") + return codeguard_home() / "session_state.json" + + +def state_path(legacy: Path) -> Path: + modern = session_state_path() + # 旧记录没有归属,绝不能猜测它属于一个带 ID 的新会话。 + return modern if scope_id() or modern.exists() or not legacy.exists() else legacy + + +def bump_state(path: Path, lang: str, passed: bool, auto_fixed: bool = False) -> None: + def change(state): + entry = state.get(lang) + if not isinstance(entry, dict): + entry = {} + state[lang] = entry + for name in ("total", "passed", "failed", "auto_fixed"): + entry.setdefault(name, 0) + entry["total"] += 1 + entry["passed" if passed else "failed"] += 1 + entry["auto_fixed"] += int(auto_fixed) + with contextlib.suppress(OSError): + update_json(path, change) + + +def notification_allowed(path: Path, lang: str, cooldown: float) -> bool: + def change(state): + entry = state.get(lang) + if not isinstance(entry, dict): + entry = {} + state[lang] = entry + now = time.time() + if 0 <= now - entry.get("last_notify", 0) < cooldown: + return False + entry["last_notify"] = now + return True + try: + return update_json(path, change) + except OSError: + return True + + +def record_skip_event(kind: str, project_root: Path | None = None, detail: str | None = None) -> None: + def change(state): + meta = state.setdefault("_skip", {"count": 0, "kinds": {}}) + meta["count"] = int(meta.get("count", 0)) + 1 + kinds = meta.setdefault("kinds", {}) + kinds[kind] = int(kinds.get(kind, 0)) + 1 + events = meta.setdefault("events", []) + events.append({"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "kind": kind, + "repo": project_root.name if project_root else None, + **({"detail": detail} if detail else {})}) + del events[:-20] + with contextlib.suppress(OSError): + update_json(session_state_path(), change) + + +def record_gate_decision(project_root: Path, lang: str, cmd: list[str], rc: int, + status: str, reason: str, limit: int = 500, + *, execution_root: Path | None = None) -> None: + entry = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "repo": project_root.name, + "worktree": str(project_root.resolve()), "lang": lang, "cmd": list(cmd), + "rc": rc, "status": status, "reason": reason, + "session_scope": scope_id(), + "execution_root": str((execution_root or project_root).resolve())} + with contextlib.suppress(OSError): + append_jsonl(codeguard_home() / "gate-decisions.jsonl", entry, limit) + + +def should_suppress_event(key: str, window: float = 2.0) -> bool: + """观察型事件短时去重;不能用它替代硬门禁结果。""" + digest = hashlib.sha256((scope_id() + "\0" + key).encode()).hexdigest() + def change(state): + now = time.time() + last = state.get(digest) or {} + suppressed = 0 <= now - last.get("ts", 0) < window + for stale in [k for k, v in state.items() if not 0 <= now - v.get("ts", 0) < 60]: + state.pop(stale) + state[digest] = {"ts": last["ts"] if suppressed else now} + return suppressed + try: + return update_json(codeguard_home() / "event_dedup.json", change) + except (OSError, ValueError): + return False + + +def _file_key(path: str) -> str: + return hashlib.sha256((scope_id() + "\0" + str(Path(path).resolve())).encode()).hexdigest() + + +def _file_stamp(path: str) -> str: + return file_content(Path(path))[0] + + +def completed_file_check(path: str, store: Path, identity: str | None = None) -> bool: + """只读已完成结果;未开始/未验证的检查不能先占用去重标记。""" + try: + last = read_json(store).get(_file_key(path)) or {} + return (last.get("identity") == identity and last.get("stamp") == _file_stamp(path) + and 0 <= time.time() - last.get("ts", 0) < 2) + except (OSError, ValueError): + return False + + +def record_file_check(path: str, store: Path, identity: str | None = None) -> None: + try: + stamp = _file_stamp(path) + def change(state): + now = time.time() + for stale in [k for k, v in state.items() if not 0 <= now - v.get("ts", 0) < 60]: + state.pop(stale) + state[_file_key(path)] = {"stamp": stamp, "identity": identity, "ts": now} + update_json(store, change) + except (OSError, ValueError): + pass + + +def _event_key(key: str) -> str: + return hashlib.sha256((scope_id() + "\0" + key).encode()).hexdigest() + + +def completed_event(key: str) -> dict | None: + """仅返回已完成的宿主结果;未完成事件不存在可放行的占位记录。""" + try: + result = read_json(codeguard_home() / "gate_event_results.json").get(_event_key(key)) + if (isinstance(result, dict) and 0 <= time.time() - result.get("ts", 0) < 2 + and result.get("code") in (0, 2) + and isinstance(result.get("stdout"), str) and isinstance(result.get("stderr"), str)): + return result + except (OSError, ValueError): + pass + return None + + +def record_completed_event(key: str, code: int, stdout: str, stderr: str) -> None: + def change(state): + now = time.time() + for stale in [k for k, v in state.items() if not 0 <= now - v.get("ts", 0) < 60]: + state.pop(stale) + state[_event_key(key)] = {"ts": now, "code": code, "stdout": stdout, "stderr": stderr} + with contextlib.suppress(OSError): + update_json(codeguard_home() / "gate_event_results.json", change) + + +def observe_once(key: str | None, operation: Callable[[], int]) -> int: + """观察完成后才去重;异常可重试,并发未完成的副本允许重复观察。""" + if key and completed_event(key) is not None: + return 0 + code = operation() + if key and code == 0: + record_completed_event(key, code, "", "") + return code diff --git a/scripts/codeguard/java_analysis.py b/scripts/codeguard/java_analysis.py new file mode 100644 index 0000000..f8b8481 --- /dev/null +++ b/scripts/codeguard/java_analysis.py @@ -0,0 +1,75 @@ +"""Java 只读分析应用:装配描述、影响策略、命令与环境;不执行构建。""" +from __future__ import annotations + +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +from .config import get_overrides +from .java_build import inside, read_gradle, read_maven +from .java_changes import is_version_bump_only +from .java_environment import build_executable, resolve_java_home +from .java_impact import select_impact +from .java_planning import configured_commands, default_commands + + +def _explicit_commands(root: Path) -> list[dict] | None: + path = inside(root, root / "codeguard.json") + if not path.exists(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + java = data.get("java", {}) + if not isinstance(java, dict): + raise TypeError("codeguard.json java 必须为对象") + value = java.get("commands") + return configured_commands(value) if value is not None else None + + +def analyze(project_root: str | Path, changed: list[str] | None = None) -> dict: + """保留 CLI/MCP 计划信封;PLANNED 是尚未执行,不能冒充验证通过。""" + root = Path(project_root).resolve() + plan = {"status": "UNVERIFIED", "root": str(root), "build_system": None, + "changed_paths": changed, "analysis_level": "module", "modules": [], + "affected_modules": [], "commands": [], "conservative": False, "reasons": [], + "gaps": ["模块依赖图不是完整符号/业务语义分析"]} + try: + get_overrides(root) + normalized = None if changed is None else [inside(root, root / name).relative_to(root).as_posix() + for name in changed] + if (root / "pom.xml").is_file(): + system, (modules, reasons) = "maven", read_maven(root) + elif any((root / name).is_file() for name in ("build.gradle", "build.gradle.kts", + "settings.gradle", "settings.gradle.kts")): + system, (modules, reasons) = "gradle", read_gradle(root) + else: + raise ValueError("未找到 Maven/Gradle 构建描述") + plan.update(build_system=system, modules=list(modules.values())) + executable = build_executable(root, system) + plan["executable"] = executable + affected, full = select_impact(modules, normalized, conservative=bool(reasons)) + commands = [] + if affected: + commands = _explicit_commands(root) + if commands is not None: + reasons.append("使用 codeguard.json 明确声明的权威检查命令") + else: + bump_only = is_version_bump_only(root, normalized) + commands = default_commands(system, executable, affected, full=full, bump_only=bump_only) + if bump_only: + reasons.append("已比对 Git HEAD 与当前构建描述:仅项目版本变化,采用轻量检查") + plugins = {plugin for module in modules.values() for plugin in module["plugins"]} + if not plugins.intersection({"maven-checkstyle-plugin", "maven-pmd-plugin", "spotbugs-maven-plugin"}): + plan["gaps"].append("未确认静态规则已绑定生命周期;verify/check 成功不代表 Checkstyle/PMD 全覆盖") + home, reason = resolve_java_home(root) + if home: + for command in commands: + command["env"] = {"JAVA_HOME": home} + reasons.append(f"JAVA_HOME={home}") + elif reason: + reasons.append(reason) + plan.update(status="PLANNED" if commands else "SKIPPED", commands=commands, + affected_modules=affected, conservative=bool(full and normalized is not None), + reasons=reasons or ["按构建模块及反向依赖闭包选择;命令尚未执行"]) + except (OSError, ValueError, TypeError, ET.ParseError) as exc: + plan.update(status="UNVERIFIED", commands=[], reasons=[str(exc)]) + return plan diff --git a/scripts/codeguard/java_build.py b/scripts/codeguard/java_build.py new file mode 100644 index 0000000..38de592 --- /dev/null +++ b/scripts/codeguard/java_build.py @@ -0,0 +1,114 @@ +"""Maven/Gradle 只读描述适配器;不执行构建,不选择检查命令。""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + + +def inside(root: Path, path: Path) -> Path: + resolved = path.resolve() + if not resolved.is_relative_to(root.resolve()): + raise ValueError(f"构建路径在仓外: {path}") + return resolved + + +def _text(node, path: str, default="") -> str: + return node.findtext(path, default=default).strip() + + +def read_maven(root: Path) -> tuple[dict, list[str]]: + root = Path(root).resolve() + modules: dict = {} + reasons: list[str] = [] + + def visit(directory: Path, inherited: dict): + directory = inside(root, directory) + name = directory.relative_to(root).as_posix() + if name in modules: + return + doc = ET.parse(inside(root, directory / "pom.xml")).getroot() + props = dict(inherited) + properties = doc.find("{*}properties") + if properties is not None: + props.update({p.tag.split("}")[-1]: (p.text or "").strip() for p in properties}) + + def expand(value: str) -> str: + for _ in range(10): + newer = re.sub(r"\$\{([^}]+)\}", lambda m: props.get(m[1], m[0]), value) + if newer == value: + break + value = newer + if "${" in value: + reasons.append(f"{name}: 无法静态解析 {value},回退全模块") + return value + + group = expand(_text(doc, "{*}groupId") or _text(doc, "{*}parent/{*}groupId") + or props.get("project.groupId", "")) + artifact = expand(_text(doc, "{*}artifactId")) + if not artifact: + raise ValueError(f"{name}: 缺少 artifactId") + props.update({"project.groupId": group, "pom.groupId": group, + "project.artifactId": artifact}) + dependencies = [] + for dep in doc.findall("{*}dependencies/{*}dependency"): + dependencies.append((expand(_text(dep, "{*}groupId")), expand(_text(dep, "{*}artifactId")))) + plugins = [p.text or "" for p in doc.findall("{*}build/{*}plugins/{*}plugin/{*}artifactId")] + modules[name] = {"path": name, "coordinate": (group, artifact), + "raw_dependencies": dependencies, "dependencies": [], "plugins": plugins} + if doc.find("{*}profiles") is not None: + reasons.append(f"{name}: Maven profiles 可能改变模块/依赖,回退根 verify") + if dependencies and doc.findall("{*}modules/{*}module"): + reasons.append(f"{name}: 聚合父 POM 依赖可能被子模块继承,回退根 verify") + for child in doc.findall("{*}modules/{*}module"): + visit(directory / expand((child.text or "").strip()), props) + + visit(root, {}) + coordinates = {} + for name, module in modules.items(): + coordinate = module["coordinate"] + if coordinate in coordinates: + reasons.append("模块坐标不唯一,回退全模块") + coordinates[coordinate] = name + for module in modules.values(): + module["dependencies"] = sorted({coordinates[d] for d in module.pop("raw_dependencies") if d in coordinates}) + module.pop("coordinate") + return modules, reasons + + +def read_gradle(root: Path) -> tuple[dict, list[str]]: + root = Path(root).resolve() + reasons = [] + modules = {".": {"path": ".", "dependencies": [], "plugins": []}} + settings = next((p for p in (root / "settings.gradle.kts", root / "settings.gradle") if p.exists()), None) + if settings: + body = inside(root, settings).read_text() + includes = re.findall(r"\binclude\s*(\([^)]*\)|[^\n]+)", body) + for expression in includes: + literals = re.findall(r"['\"](:?[\w:-]+)['\"]", expression) + residue = re.sub(r"['\"](:?[\w:-]+)['\"]|[\s,()]", "", expression) + if not literals or residue: + reasons.append("Gradle include 使用动态表达式,回退根 check") + for literal in literals: + name = literal.lstrip(":").replace(":", "/") + inside(root, root / name) + modules[name] = {"path": name, "dependencies": [], "plugins": []} + if re.search(r"includeBuild|projectDir|apply\s|\bfor\s*\(|\.each\b", body): + reasons.append("Gradle settings 存在动态/复合构建,回退根 check") + for name, module in modules.items(): + directory = inside(root, root / name) + build = next((p for p in (directory / "build.gradle.kts", directory / "build.gradle") if p.exists()), None) + if not build: + reasons.append(f"{name}: 没有可读取的 Gradle 构建描述,回退根 check") + continue + body = inside(root, build).read_text() + references = re.findall(r"\bproject\s*\(\s*(?:path\s*[:=]\s*)?['\"](:[\w:-]+)['\"]\s*\)", body) + module["dependencies"] = sorted({r.lstrip(":").replace(":", "/") for r in references}) + if any(d not in modules for d in module["dependencies"]): + reasons.append(f"{name}: 引用了未声明项目,回退根 check") + if len(re.findall(r"\bproject\s*\(", body)) != len(references) or re.search( + r"apply\s+from|apply\s*\(\s*from|subprojects|allprojects|buildSrc|\bprojects\.|\bif\s*\(|\bfor\s*\(", body): + reasons.append(f"{name}: Gradle 动态构建无法可靠缩小范围,回退根 check") + if (root / "buildSrc").exists(): + reasons.append("存在 buildSrc 构建逻辑,回退根 check") + return modules, reasons diff --git a/scripts/codeguard/java_changes.py b/scripts/codeguard/java_changes.py new file mode 100644 index 0000000..451f79e --- /dev/null +++ b/scripts/codeguard/java_changes.py @@ -0,0 +1,102 @@ +"""只读 Git 基线与版本变更证据;无法证明仅项目版本变化时不降低检查等级。""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +from .execution import execute +from .java_build import inside + +_VERSION = re.compile(r"[0-9][0-9A-Za-z._+-]*") + + +def _pom_without_release(body: str) -> tuple: + document = ET.fromstring(body, parser=ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))) + version = document.find("{*}version") + revision = document.find("{*}properties/{*}revision") + if revision is not None: + # revision 用于外部依赖、插件或任意配置时不能作为项目版本豁免。 + for node in document.iter(): + if ((node is not version and "${revision}" in (node.text or "")) or + any("${revision}" in value for value in node.attrib.values())): + raise ValueError("revision 并非仅项目版本") + versions = [] + for node in (version, revision): + if node is not None and _VERSION.fullmatch((node.text or "").strip()): + versions.append(node.text) + node.text = "" + + def shape(node): + # 插件的任意嵌入脚本文本可能对空白敏感,不能把 strip 后相等当成只改版本。 + return (node.tag, tuple(sorted(node.attrib.items())), node.text or "", + node.tail or "", tuple(shape(child) for child in node)) + + return shape(document), tuple(versions) + + +def _gradle_without_release(body: str) -> tuple[str, tuple[str, ...]]: + # 只接受独占顶层行的字面版本赋值;Groovy/Kotlin 动态语法不能靠 diff 关键词证明安全。 + # 先保守排除多行字符串/注释;其余逐字符处理引号、注释和大括号,避免误认插件块里的 version。 + if any(token in body for token in ('"""', "'''", "/*", "$/")): + raise ValueError("Gradle 版本来源不支持静态确认") + depth = 0 + output = [] + versions = [] + assignment = re.compile(r"\s*version\s*=\s*(['\"])([0-9][0-9A-Za-z._+-]*)\1\s*;?\s*(?://[^\n]*)?$") + for line in body.splitlines(keepends=True): + match = assignment.fullmatch(line.rstrip("\r\n")) if depth == 0 else None + if match: + versions.append(match[2]) + line = line[:match.start(2)] + "" + line[match.end(2):] + output.append(line) + quote = None + escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + elif quote: + if char == "\\": + escaped = True + elif char == quote: + quote = None + elif char in ("'", '"'): + quote = char + elif line[index:index + 2] == "//": + break + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if quote or depth < 0: + raise ValueError("Gradle 语法无法静态确认") + if depth: + raise ValueError("Gradle 作用域未闭合") + return "".join(output), tuple(versions) + + +def is_version_bump_only(root: Path, changed: list[str] | None) -> bool: + """比较成功读取的完整描述;无 Git 基线、删除、未知语法或混合变更均不降级。""" + root = Path(root).resolve() + if not changed or any(Path(name).name not in {"pom.xml", "build.gradle", "build.gradle.kts"} + for name in changed): + return False + different = False + try: + for name in changed: + file = inside(root, root / name) + # Git 对 HEAD:./path 按 cwd 解析;模块根可能不是 Git 顶层,不能误读父仓同名 POM。 + before = execute(["git", "show", f"HEAD:./{file.relative_to(root).as_posix()}"], root, 5, + stdin_null=True) + if before.returncode or before.failure or "\ufffd" in before.stdout: + return False + current = file.read_text(encoding="utf-8") + normalize = _pom_without_release if file.name == "pom.xml" else _gradle_without_release + old_shape, old_versions = normalize(before.stdout) + new_shape, new_versions = normalize(current) + if old_shape != new_shape: + return False + different |= old_versions != new_versions + except (OSError, ValueError, ET.ParseError): + return False + return different diff --git a/scripts/codeguard/java_environment.py b/scripts/codeguard/java_environment.py new file mode 100644 index 0000000..ac98c6e --- /dev/null +++ b/scripts/codeguard/java_environment.py @@ -0,0 +1,63 @@ +"""Java 执行环境观察:wrapper 选择和宿主 JDK 定位,不运行构建或安装工具。""" +from __future__ import annotations + +import os +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from .execution import execute +from .java_build import inside + + +def build_executable(root: Path, system: str) -> str: + """优先项目 wrapper;存在但不可执行时明确拒绝,不能静默退回不兼容版本。""" + root = Path(root).resolve() + wrapper = "mvnw" if system == "maven" else "gradlew" + if os.name == "nt": + wrapper += ".cmd" if system == "maven" else ".bat" + path = inside(root, root / wrapper) + if path.exists() and os.name != "nt" and not os.access(path, os.X_OK): + raise ValueError(f"wrapper 不可执行: {wrapper}") + return "./" + wrapper if path.is_file() else "mvn" if system == "maven" else "gradle" + + +def resolve_java_home(root: Path) -> tuple[str | None, str | None]: + """只解析根 POM 显式版本及属性引用,按宿主已安装 JDK 定位;不修改 os.environ。""" + root = Path(root).resolve() + pom = inside(root, root / "pom.xml") + if not pom.is_file(): + return None, None + try: + document = ET.parse(pom).getroot() + properties = document.find("{*}properties") + values = {node.tag.split("}")[-1]: (node.text or "").strip() for node in properties} if properties is not None else {} + version = (values.get("java.version") or values.get("maven.compiler.release") or + document.findtext("{*}java.version") or document.findtext("{*}maven.compiler.release")) + if not version: + return None, None + for _ in range(10): + expanded = re.sub(r"\$\{([^}]+)\}", lambda match: values.get(match[1], match[0]), version) + if expanded == version: + break + version = expanded + major = re.fullmatch(r"(?:1\.)?(\d+)(?:[.\-+_][0-9A-Za-z._+\-]*)?", version.strip()) + if not major: + return None, f"无法静态解析 JDK 版本: {version}" + required = int(major[1]) + except (OSError, ValueError, ET.ParseError) as exc: + return None, f"无法解析 JDK 声明: {exc}" + + if sys.platform == "darwin": + result = execute(["/usr/libexec/java_home", "-v", str(required)], root, 5, stdin_null=True) + if not result.returncode and result.stdout.strip() and "\ufffd" not in result.stdout: + return result.stdout.strip(), None + elif sys.platform == "linux": + result = execute(["update-alternatives", "--list", "java"], root, 5, stdin_null=True) + if not result.returncode: + for line in result.stdout.splitlines(): + match = re.search(r"java[-.](\d+)", line) + if match and int(match[1]) == required: + return str(Path(line.strip()).parent.parent), None + return None, f"需要 JDK {required},当前环境无匹配版本" diff --git a/scripts/codeguard/java_impact.py b/scripts/codeguard/java_impact.py new file mode 100644 index 0000000..a4c1c5b --- /dev/null +++ b/scripts/codeguard/java_impact.py @@ -0,0 +1,50 @@ +"""Java 模块级影响策略;输入已归一化的相对路径,不读取文件或启动进程。""" +from __future__ import annotations + +from collections import deque +from collections.abc import Mapping, Sequence +from pathlib import PurePosixPath + +BUILD_FILES = frozenset({"pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", + "settings.gradle.kts", "gradle.properties", "mvnw", "gradlew", + "mvnw.cmd", "gradlew.bat", "codeguard.json"}) + + +def is_build_path(path: str) -> bool: + return PurePosixPath(path).name in BUILD_FILES or path.startswith((".mvn/", "gradle/")) + + +def relevant_paths(changed: Sequence[str] | None) -> list[str] | None: + """None 为全量,空数组为无适用变更;资源和删除的源码仍参与影响计算。""" + if changed is None: + return None + return [path for path in changed if path.endswith((".java", ".kt", ".groovy", ".scala")) + or "/src/" in "/" + path or is_build_path(path)] + + +def select_impact(modules: Mapping[str, dict], changed: Sequence[str] | None, *, + conservative: bool) -> tuple[list[str], bool]: + """以反向邻接表计算闭包;循环依赖有限终止,输入图不被修改。""" + relevant = relevant_paths(changed) + full = relevant is None or conservative or any(is_build_path(path) for path in relevant or []) + if relevant == []: + return [], full + if full: + return sorted(modules), True + reverse: dict[str, set[str]] = {name: set() for name in modules} + for name, module in modules.items(): + for dependency in module["dependencies"]: + if dependency in reverse: + reverse[dependency].add(name) + affected = set() + for path in relevant or []: + owners = [name for name in modules if name == "." or path.startswith(name + "/")] + if owners: + affected.add(max(owners, key=len)) + pending = deque(affected) + while pending: + for consumer in reverse[pending.popleft()]: + if consumer not in affected: + affected.add(consumer) + pending.append(consumer) + return sorted(affected), False diff --git a/scripts/codeguard/java_planning.py b/scripts/codeguard/java_planning.py new file mode 100644 index 0000000..8577fe1 --- /dev/null +++ b/scripts/codeguard/java_planning.py @@ -0,0 +1,34 @@ +"""Java 检查命令的纯策略;不负责文件解析、工具探测或执行。""" +from __future__ import annotations + +from collections.abc import Sequence + + +def default_commands(system: str, executable: str, affected: Sequence[str], *, + full: bool, bump_only: bool) -> list[dict]: + """保留默认 skipTests/check -x test、模块范围与有证据的纯版本轻量检查。""" + if not affected: + return [] + if system == "maven": + argv = [executable, "-B"] + if not full and "." not in affected: + argv += ["-pl", ",".join(sorted(affected)), "-am"] + argv += ["-DskipTests", "validate" if bump_only else "verify"] + elif system == "gradle": + targets = (["help"] if bump_only else ["check"] if full or "." in affected else + [":" + path.replace("/", ":") + ":check" for path in sorted(affected)]) + argv = [executable, *targets] + if not bump_only: + argv += ["-x", "test"] + else: + raise ValueError(f"未知 Java 构建系统: {system}") + return [{"kind": "verify", "argv": argv}] + + +def configured_commands(value: object) -> list[dict]: + """校验后复制 argv;显式声明原样优先,不添加 skipTests 或其它默认参数。""" + if not isinstance(value, list) or not value or any( + not isinstance(command, list) or not command or + any(not isinstance(arg, str) or not arg or "\0" in arg for arg in command) for command in value): + raise ValueError("java.commands 必须是非空 argv 数组列表") + return [{"kind": "configured", "argv": list(command)} for command in value] diff --git a/scripts/codeguard/language_check.py b/scripts/codeguard/language_check.py new file mode 100644 index 0000000..081e61d --- /dev/null +++ b/scripts/codeguard/language_check.py @@ -0,0 +1,196 @@ +"""按语言检查与修复的应用服务;CLI、MCP、门禁共用此实现。 + +暴露两个应用服务函数供 CLI / MCP / Java 门禁复用: +- `run_check(languages, project_root, *, timeout, fix, dry_run)` — 跑 lint。 +- `run_fix(languages, project_root, *, timeout, dry_run)` — 跑 format。 + +CLI(run_check.py / fix.py)负责 argparse + 报告格式;本层选择检查策略。 +范围物化、进程执行和判定分别由 codeguard.planning / execution / verdict 负责。 + +`run_check(fix=True)` 走「lint 失败 → 自动 fix → 复检」三段式。 +""" +from __future__ import annotations + +from pathlib import Path + +from .config import ConfigurationError, get_overrides +from .discovery import detect_language, project_uses_linter +from .execution import execute, execute_plan +from .models import CheckPlan, Command +from .planning import scoped_plan +from .registry import LANG_COMMANDS + +__all__ = ["run_check", "run_fix"] + + +def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]: + """兼容入口;进程执行与故障证据由统一内核负责。""" + return execute(cmd, cwd, timeout).as_tuple() + + +def run_check(languages: list[str], project_root: Path, + *, timeout: int = 120, fix: bool = False, + dry_run: bool = False, log_dir: Path | None = None, + files: list[str] | None = None) -> list[dict]: + """对每个语言跑 lint;fix=True 时失败后自动跑 format 复检一次。 + + `log_dir` 非空且有语言失败时,完整输出(stdout+stderr 合并——ruff 等 + linter 把诊断打到 stdout)落盘到 `/.codeguard-last.log` + (组头含语言与退出码),失败条目附带 `log_path`; + 全部通过则不产生日志文件。 + """ + from .verdict import FAIL, PASS, PLANNED, SKIPPED, UNVERIFIED, lint_verdict, result + + try: + get_overrides(project_root) + except ConfigurationError as exc: + return [result(lang, UNVERIFIED, str(exc), exit_code=1) for lang in languages] + results: list[dict] = [] + log_entries: list[tuple[str, int, str]] = [] + for lang in languages: + cmd_def = LANG_COMMANDS.get(lang) + if not cmd_def: + results.append(result(lang, PLANNED, "没有可执行检查命令")) + continue + lang_files = None if files is None else [f for f in files + if detect_language(f, project_root) == lang and (project_root / f).is_file()] + if lang != "java" and lang_files == []: + results.append(result(lang, SKIPPED, "本次改动未涉及该语言的现存文件")) + continue + if not project_uses_linter(cmd_def, project_root): + results.append(result(lang, UNVERIFIED, "项目缺少已声明的检查配置,未执行")) + continue + lint_cmd = (cmd_def.get("gate") or cmd_def.get("lint")) if files is None else cmd_def.get("lint") + java_plan = None + if lang == "java": + from .java_analysis import analyze + java_plan = analyze(project_root, files) + if not java_plan["commands"]: + results.append(result(lang, java_plan["status"], ";".join(java_plan["reasons"]), + java_plan=java_plan)) + continue + lint_cmd = java_plan["commands"][0]["argv"] + if dry_run or not lint_cmd: + results.append(result(lang, PLANNED, "计划检查,尚未执行", + dry_run=True, command=lint_cmd or [])) + continue + # CLI/MCP 是仓健康检查(全量):注入默认 ruff 配置 + 剔除 vendor 快照, + # 否则规则集随机器漂移、供应链快照给出不可修的永久红。 + if java_plan: + plan = CheckPlan(project_root, "repo" if files is None else "delta", tuple( + Command(tuple(c["argv"]), tuple(c.get("env", {}).items())) + for c in java_plan["commands"])) + else: + if "{file}" in " ".join(lint_cmd) and lang_files is None: + results.append(result(lang, UNVERIFIED, "只有单文件命令,未配置项目 gate")) + continue + plan = scoped_plan(lint_cmd, project_root, scope="repo" if lang_files is None else "delta", + files=lang_files, append_files=cmd_def.get("append_files", True)) + outcome = execute_plan(plan, timeout).terminal + rc, out, err = outcome.as_tuple() + lint_cmd = list(outcome.argv) + status, reason = lint_verdict(rc, lint_cmd, out + err) + if status == FAIL and fix: + repairs = run_fix([lang], project_root, timeout=timeout + 60, files=files) + if any(r.get("fixed") and not r.get("dry_run") for r in repairs): + outcome = execute_plan(plan, timeout).terminal + rc, out, err = outcome.as_tuple() + lint_cmd = list(outcome.argv) + status, reason = lint_verdict(rc, lint_cmd, out + err) + results.append(result(lang, status, reason, exit_code=rc, + unverified=reason if status == UNVERIFIED else "", + command=lint_cmd, java_plan=java_plan, + stderr_tail=err[-2000:], stdout_tail=out[-1000:])) + if status != PASS and (err or out): + combined = "" + if out: + combined += out + if err: + if combined and not combined.endswith("\n"): + combined += "\n" + combined += err + log_entries.append((lang, rc, combined)) + if log_dir is not None and log_entries: + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / ".codeguard-last.log" + chunks = [] + for lang, rc, content in log_entries: + block = f"===== {lang} (exit={rc}) =====\n{content}" + if not block.endswith("\n"): + block += "\n" + chunks.append(block) + try: + log_path.write_text("\n".join(chunks), encoding="utf-8") + except OSError: + log_path = None + if log_path is not None: + for r in results: + if not r["passed"]: + r["log_path"] = str(log_path.resolve()) + return results + + +def run_fix(languages: list[str], project_root: Path, + *, timeout: int = 120, dry_run: bool = False, + files: list[str] | None = None) -> list[dict]: + """对每个语言跑 format;命令缺失归失败;dry_run 仅打印 would-run 行。 + + `files` 非空 = 仅修复这些文件(fix.py 缺省 delta 模式);某语言在改动 + 里没有文件 → 该语言整行跳过(skipped 标记),绝不动仓里其它存量文件。 + """ + if files is not None and any( + (project_root / f).is_symlink() or not (project_root / f).resolve().is_relative_to(project_root.resolve()) + for f in files): + return [{"language": lang, "fixed": False, "status": "UNVERIFIED", "exit_code": 1, + "note": "修复范围含符号链接或仓外路径,未执行 formatter"} for lang in languages] + try: + get_overrides(project_root) + except ConfigurationError as exc: + return [{"language": lang, "fixed": False, "status": "UNVERIFIED", "exit_code": 1, + "note": str(exc), "reason": str(exc)} for lang in languages] + results: list[dict] = [] + for lang in languages: + cmd_def = LANG_COMMANDS.get(lang) + if not cmd_def: + results.append({"language": lang, "fixed": False, "error": "no command defined"}) + continue + lang_files: list[str] | None = None + if files is not None: + lang_files = [f for f in files if detect_language(f, project_root) == lang] + # .zsh 剥离(与门禁同一份认知):shfmt 只支持 POSIX shell/bash, + # 对 zsh 文件 -w 会重排方言语法造成损坏;跳过并明示修复指令。 + zsh_files = [f for f in lang_files if f.endswith(".zsh")] + if zsh_files: + lang_files = [f for f in lang_files if not f.endswith(".zsh")] + results.append({"language": lang, "fixed": False, "skipped": True, + "status": "SKIPPED", "exit_code": 0, + "note": (f"{len(zsh_files)} 个 zsh 文件跳过 formatter" + "(shfmt 不支持 zsh;文件头添加" + " `# shellcheck shell=bash` 注释后" + " shellcheck 门禁即可正常送检)")}) + if not lang_files: + continue + fmt = cmd_def.get("format") + if files is not None and not cmd_def.get("append_files", True): + results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", + "note": "项目级 formatter 无法限制到改动文件;需显式 --all", "exit_code": 1}) + continue + if not fmt or dry_run: + results.append({"language": lang, "fixed": False, "status": "PLANNED", + "dry_run": dry_run, "command": fmt or [], "exit_code": 1, + "note": "尚未执行修复" if dry_run else "未配置 formatter"}) + continue + if "{file}" in " ".join(fmt) and lang_files is None: + results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", + "note": "formatter 需要明确文件列表", "exit_code": 1}) + continue + plan = scoped_plan(fmt, project_root, scope="repo" if lang_files is None else "delta", + files=lang_files, append_files=cmd_def.get("append_files", True)) + rc, _out, err = execute_plan(plan, timeout).terminal.as_tuple() + results.append({ + "language": lang, + "fixed": rc == 0, + "exit_code": rc, + "stderr_tail": err[-2000:] if err else "", + }) + return results diff --git a/scripts/codeguard/models.py b/scripts/codeguard/models.py new file mode 100644 index 0000000..1866903 --- /dev/null +++ b/scripts/codeguard/models.py @@ -0,0 +1,84 @@ +"""与宿主、注册表和进程无关的不可变执行契约。""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +CheckScope = Literal["repo", "delta", "save"] + + +@dataclass(frozen=True) +class Command: + """已物化的命令;环境只保存该命令的覆盖,不复制宿主凭据。""" + + argv: tuple[str, ...] + env: tuple[tuple[str, str], ...] = () + + def __post_init__(self): + object.__setattr__(self, "argv", tuple(self.argv)) + object.__setattr__(self, "env", tuple(tuple(pair) for pair in self.env)) + if not self.argv or not self.argv[0]: + raise ValueError("检查命令不能为空") + + +@dataclass(frozen=True) +class CheckPlan: + """显式范围与顺序命令;规划完成后可原样复检,不重新扩大范围。""" + + cwd: Path + scope: CheckScope + commands: tuple[Command, ...] + + def __post_init__(self): + object.__setattr__(self, "cwd", Path(self.cwd)) + object.__setattr__(self, "commands", tuple(self.commands)) + if self.scope not in ("repo", "delta", "save") or not self.commands: + raise ValueError("执行计划必须有明确范围和至少一条命令") + + +@dataclass(frozen=True) +class ProcessResult: + """一次调用的不可变证据;非零退出不自动等于代码违规。""" + + argv: tuple[str, ...] + cwd: Path + returncode: int + stdout: str = "" + stderr: str = "" + failure: Literal["timeout", "not_found", "os_error"] | None = None + + def as_tuple(self) -> tuple[int, str, str]: + """兼容旧脚本的 (rc, stdout, stderr) 返回约定。""" + return self.returncode, self.stdout, self.stderr + + +@dataclass(frozen=True) +class PlanExecution: + """已实际执行的有序证据,不包含尚未运行的计划项。""" + + attempts: tuple[ProcessResult, ...] + + @property + def terminal(self) -> ProcessResult: + """首个非零结果,或全部执行成功时的最后一项。""" + return self.attempts[-1] + + +@dataclass(frozen=True) +class GateDecision: + """已执行检查的审计数据,由调用线程附加原仓与会话身份。""" + + argv: tuple[str, ...] + returncode: int + status: str + reason: str + + +@dataclass(frozen=True) +class GateOutcome: + """单语言的独立结果;并发 worker 不共享备注列表或写审计。""" + + failure: tuple[str, str, str, str] | None = None + notes: tuple[str, ...] = () + decisions: tuple[GateDecision, ...] = () diff --git a/scripts/codeguard/path_policy.py b/scripts/codeguard/path_policy.py new file mode 100644 index 0000000..3ea7011 --- /dev/null +++ b/scripts/codeguard/path_policy.py @@ -0,0 +1,103 @@ +"""扫描排除与提交路径安全规则的单一来源,无宿主与进程依赖。""" +from __future__ import annotations + +import fnmatch +from pathlib import Path + +# 构建产物/依赖快照的**单一事实源**——"哪些目录不需要检测"由这里回答。 +# 语义边界:gate_lib.GUARD_EXCLUDE_DIRS 管"能否入库"(= 本清单 + IDE 目录, +# 从本清单派生),本清单管"扫不扫";两侧永不漂移(改这里即两侧同变)。 +# 为什么必须完备(两类实测永久红):target/ 下 maven-javadoc 生成的 +# javadoc.sh 让 shell 门禁必红(java 与 shell 两个 gate 步骤互相矛盾); +# target/site/jacoco 与 target/apidocs 的生成 HTML 让 html 门禁必红——生成物 +# 不会被"修复",下次构建就重写,扫描它们得到的永远是与仓库内容无关的红。 +# vendor/upstream 是供应链依赖快照,内容不可编辑,同理只产"永久红"。 +# 生效通道必须四条全覆盖(缺一条就漏一类门禁): +# 1) ruff --exclude(全量) +# 2) find 型 gate 注入 -not -path(-print0/-exec/无 NUL 锚三形态) +# 3) PostToolUse 对产物路径静默跳过(写 target/ 的生成物不检查) +# 4) changed_files 过滤产物路径(force-add 的 target 文件不进 delta 面) +FULL_SCAN_EXCLUDES = ( + ".venv", "venv", "env", "node_modules", "vendor", "upstream", + "build", "dist", "target", "out", ".next", ".nuxt", ".gradle", + "coverage", ".terraform", ".tox", ".eggs", "htmlcov", ".turbo", + ".parcel-cache", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", + # Agent/宿主工具工作目录:会话实测 .mimosa/(含源码快照)与 .worktrees/ + # (git 子工作树)曾被 git add -A 带进暂存区——既不该入库,也不该被全量 + # 扫描重复检查(子工作树是同一份源码,扫两遍 = 双份误报)。 + ".mimosa", ".worktrees", ".code-review-graph", ".kimi-code", + ".zcode", ".codex-plugin", ".agents", +) + +# === git 提交内容安全检查:绝不该进版本库的文件 === + +# 目录 = 构建产物/依赖快照**单一事实源**(path_policy.FULL_SCAN_EXCLUDES)+ IDE 目录。 +# 从单一来源派生:清单只在本模块改一处,"入库面"与"扫描面"永不漂移 +# (此前两份手抄清单已经漂移:扫描面缺 out/.next/coverage 等 14 个目录, +# 入库面缺 upstream)。路径任一段落匹配即违规;vendor 的仓根级特例在 +# check_paths 里(嵌套 scripts/vendor 是第一方源码树)。 +GUARD_EXCLUDE_DIRS = set(FULL_SCAN_EXCLUDES) | {".idea", ".vscode"} +# 文件名模式(fnmatch,任意层级):密钥/凭据/本地环境/系统垃圾 +GUARD_EXCLUDE_FILES = [ + ".env", ".env.*", "*.env", "*.pem", "*.key", "*.p12", "*.pfx", "*.jks", + "*.keystore", "id_rsa", "id_ed25519", "id_ecdsa", "*.pem.orig", + "credentials*.json", "serviceAccount*.json", "*service-account*.json", + ".DS_Store", "Thumbs.db", "*.pyc", +] +# 仅**仓根级**命中的模式:数据库/日志常作第一方 fixture 合法入库 +# (tests/fixtures/sample.db、docs 里的归档 log),只拦散落在根目录的 +# 数据转储与调试日志——此前与密钥同池任意层级匹配,误伤合法入库(实测)。 +GUARD_ROOT_ONLY_FILES = ["*.sqlite", "*.sqlite3", "*.db", "*.log"] + + +def is_build_artifact(path: str | Path) -> bool: + """路径是否落在构建产物/依赖快照目录下(任一段命中即算)。 + + 单一事实源的谓词形态,供 PostToolUse(生成物不检查)与 changed_files + (产物不进 delta 面)复用同一份认知,避免两处各写一遍目录名。 + 注意不能用 lstrip("./")——会把 `.tox` 的点一起剥掉(实测踩点)。 + """ + parts = [seg for seg in str(path).replace("\\", "/").split("/") + if seg not in ("", ".")] + return any(seg in FULL_SCAN_EXCLUDES for seg in parts) + + +def check_paths(paths: list[str]) -> list[tuple[str, str, str]]: + """纯路径策略:只判断给定拟入库路径,不发现文件、不读 Git、不执行修复。""" + violations: list[tuple[str, str, str]] = [] + for raw in paths: + f = raw.strip() + if not f: + continue + parts = f.split("/") + hit_dir = None + for idx, seg in enumerate(parts[:-1]): + if seg not in GUARD_EXCLUDE_DIRS: + continue + # vendor 仅**仓根级**=依赖快照;嵌套(scripts/vendor、tools/vendor) + # 是第一方源码树——裸段匹配曾把 skill_vendor.py 判成"移出版本库" + # (实测误伤,且打断依赖它的 skills-check CI)。 + if seg == "vendor" and idx != 0: + continue + hit_dir = seg + break + if hit_dir: + violations.append(( + f, f"目录 ./{hit_dir}/ 属于依赖/产物/本地环境,不应入库", + f"git rm -r --cached '{f}' 并在 .gitignore 加 '{hit_dir}/'", + )) + continue + name = parts[-1] + rule = next((pat for pat in GUARD_EXCLUDE_FILES if fnmatch.fnmatch(name, pat)), None) + if rule is None and len(parts) == 1: + # 仓根级 db/log/sqlite 单独一池(见 GUARD_ROOT_ONLY_FILES 注释) + rule = next( + (pat for pat in GUARD_ROOT_ONLY_FILES if fnmatch.fnmatch(name, pat)), None + ) + if rule: + violations.append(( + f, f"文件命中敏感模式 {rule}(密钥/凭据/本地配置类)", + f"git rm --cached '{f}' 并在 .gitignore 加 '{rule}';确认是否已泄露需要轮换密钥", + )) + return violations diff --git a/scripts/codeguard/planning.py b/scripts/codeguard/planning.py new file mode 100644 index 0000000..5604db4 --- /dev/null +++ b/scripts/codeguard/planning.py @@ -0,0 +1,38 @@ +"""把已选工具与明确范围物化为执行计划,不选择语言或运行检查。""" +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path + +from scope import scope_cmd + +from .models import CheckPlan, CheckScope, Command + + +def scoped_plan(argv: Sequence[str], root: Path, *, scope: CheckScope, + files: Sequence[str] | None = None, append_files: bool = True) -> CheckPlan: + """复用既有 scope 配置/排除规则;空 delta 不能意外退化为全仓执行。 + + Java 权威命令由原生规划器直接构造 CheckPlan,不能走通用文件追加。 + save 必须恰有一个文件,repo 不接受文件列表,调用方先处理空范围状态。 + """ + if scope == "repo": + if files is not None: + raise ValueError("repo 计划不能携带局部文件列表") + elif scope in ("delta", "save"): + if not files or (scope == "save" and len(files) != 1): + raise ValueError("delta 需要非空文件列表;save 必须恰有一个文件") + else: + raise ValueError(f"未知检查范围: {scope}") + if not argv: + raise ValueError("检查命令不能为空") + if "{file}" in " ".join(argv): + if files is None: + raise ValueError("单文件命令需要明确文件列表") + commands = [scope_cmd(list(argv), root, single_file=f) for f in files] + elif scope == "save": + commands = [scope_cmd(list(argv), root, single_file=files[0], append_files=append_files)] + else: + commands = [scope_cmd(list(argv), root, files=list(files) if files is not None else None, + full_excludes=scope == "repo", append_files=append_files)] + return CheckPlan(root, scope, tuple(Command(tuple(cmd)) for cmd in commands)) diff --git a/scripts/codeguard/registry.py b/scripts/codeguard/registry.py new file mode 100644 index 0000000..bb05fab --- /dev/null +++ b/scripts/codeguard/registry.py @@ -0,0 +1,56 @@ +"""已校验的语言注册表及派生索引;不负责项目发现或工具执行。""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .registry_schema import check + +REGISTRY_PATH = Path(__file__).resolve().parents[1] / "languages.json" + + +def load_registry(path: Path = REGISTRY_PATH) -> dict[str, dict[str, Any]]: + """加载语言注册表:id -> language 定义""" + with path.open(encoding="utf-8") as f: + data = json.load(f) + errors = check(data) + if errors: + raise ValueError(f"{path}: " + "; ".join(errors)) + return {lang["id"]: lang for lang in data["languages"]} + + +REGISTRY = load_registry() + + +# === 派生表(由注册表构建,保持与旧 API 兼容) === +EXT_LANG_MAP: dict[str, str] = {} +FILE_LANG_MAP: dict[str, str] = {} +PROJECT_MARKERS: dict[str, str] = {} +LANG_COMMANDS: dict[str, dict[str, Any]] = {} +LANG_INSTALL_HINTS: dict[str, str] = {} +LANG_STATUS: dict[str, str] = {} + +for _id, _lang in REGISTRY.items(): + for _ext in _lang.get("extensions", []): + EXT_LANG_MAP[_ext.lower()] = _id + for _fname in _lang.get("file_names", []): + FILE_LANG_MAP[_fname] = _id + for _marker in _lang.get("markers", []): + PROJECT_MARKERS[_marker] = _id + LANG_STATUS[_id] = _lang.get("status", "planned") + # 门禁命令表只收 Stable/Beta;Planned 语言安全跳过(命令留作路线图参考) + if _lang.get("status") in ("stable", "beta"): + _lint, _fmt = _lang.get("lint"), _lang.get("format") + if _lint or _fmt: + LANG_COMMANDS[_id] = { + "lint": _lint, + "format": _fmt, + "gate": _lang.get("gate"), # 项目级门禁命令(文件型 linter 必须传文件清单) + "probe": _lang.get("probe"), # 显式探活命令(npx 系必配,覆盖包未装场景) + "requiresConfig": _lang.get("requiresConfig"), # 未接入配置的项目归 skipped + "install_hint": _lang.get("install_hint"), + "append_files": _lang.get("append_files", True), + } + if _lang.get("install_hint"): + LANG_INSTALL_HINTS[_id] = _lang["install_hint"] diff --git a/scripts/codeguard/registry_schema.py b/scripts/codeguard/registry_schema.py new file mode 100644 index 0000000..9648c6a --- /dev/null +++ b/scripts/codeguard/registry_schema.py @@ -0,0 +1,233 @@ +"""语言注册表的纯校验规则;CLI 和运行时共用,不读取文件或启动进程。""" +from __future__ import annotations + +import re + +KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def _check_top_level(reg: dict) -> list[str]: + errs: list[str] = [] + if not isinstance(reg.get("version"), int) or reg["version"] < 1: + errs.append("all:top-level: 'version' missing or not positive int") + langs = reg.get("languages") + if not isinstance(langs, list) or not langs: + errs.append("all:top-level: 'languages' missing or empty") + levels = reg.get("status_levels") + for required in ("stable", "beta", "planned"): + if not isinstance(levels, dict) or required not in levels: + errs.append(f"all:top-level: status_levels missing '{required}'") + return errs + + +def _check_unique_ids(reg: dict) -> list[str]: + errs: list[str] = [] + seen: set[str] = set() + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id") + if lid in seen: + errs.append(f"all:ids: duplicate id '{lid}'") + if isinstance(lid, str): + seen.add(lid) + return errs + + +def _check_id_kebab(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id") + if isinstance(lid, str) and not KEBAB_RE.match(lid): + errs.append(f"{lid}:id: not kebab-case") + return errs + + +def _check_status(reg: dict) -> list[str]: + errs: list[str] = [] + levels = reg.get("status_levels", {}) + valid = set(levels.keys()) + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + if st not in valid: + errs.append(f"{lid}:status: '{st}' not in status_levels {sorted(valid)}") + return errs + + +def _check_ext_or_marker_collision(reg: dict, key: str, label: str) -> list[str]: + errs: list[str] = [] + owners: dict[str, str] = {} + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + if lang.get("status") not in ("stable", "beta"): + continue + lid = lang.get("id", "") + for ext in lang.get(key, []) or []: + if ext in owners and owners[ext] != lid: + errs.append(f"all:{key}s: '{ext}' owned by '{owners[ext]}' and '{lid}'") + owners[ext] = lid + return errs + + +def _check_extensions_dot_prefix(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + for ext in lang.get("extensions", []) or []: + if not isinstance(ext, str) or not ext.startswith("."): + errs.append(f"{lid}:extensions: '{ext}' must start with '.'") + return errs + + +def _check_lint_format_shape(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + for field in ("lint", "format"): + val = lang.get(field) + if val is None: + continue + if not isinstance(val, list) or not all(isinstance(s, str) and s for s in val): + errs.append(f"{lid}:{field}: must be list of non-empty strings") + return errs + + +def _check_status_command_presence(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + if st in ("stable", "beta"): + lint = lang.get("lint") or [] + fmt = lang.get("format") or [] + if not lint and not fmt: + errs.append(f"{lid}:commands: stable/beta requires non-empty lint or format") + elif st == "planned": + lint = lang.get("lint") or [] + fmt = lang.get("format") or [] + if lint or fmt: + errs.append(f"{lid}:commands: planned must leave lint/format empty") + return errs + + +def _check_requires_config(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + rc = lang.get("requiresConfig") + if rc is None: + continue + if not isinstance(rc, list) or not all(isinstance(s, str) for s in rc): + errs.append(f"{lid}:requiresConfig: must be list of strings") + return errs + + +def _check_linter_config_files(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + cf = lang.get("linter_config_files") + if cf is None: + continue + if not isinstance(cf, list) or not all(isinstance(s, str) for s in cf): + errs.append(f"{lid}:linter_config_files: must be list of strings") + return errs + + +def _check_since(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + since = lang.get("since") + if st in ("stable", "beta") and not isinstance(since, str) or (st in ("stable", "beta") and since == ""): + errs.append(f"{lid}:since: stable/beta requires non-empty 'since' string") + return errs + + +def _check_gate_severity_pin(reg: dict) -> list[str]: + """gate 命令里调用支持 severity 的工具时必须钉扎级别。 + + 背景(0.8.x 前的实测坑):同一份 shell gate 在部分发布版本里没带 + `--severity=warning`,info/style 级发现(SC2059/SC2295)也会阻塞提交; + 缺了这个钉扎,门禁结论取决于用户 cache 里恰好是哪个发布版本。 + shellcheck 支持 --severity;markdownlint 走 config 文件,不在此列。 + """ + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + for field in ("gate", "lint"): + joined = " ".join(lang.get(field) or []) + if "shellcheck" in joined and "--severity=" not in joined: + errs.append(f"{lid}:{field}: shellcheck must pin --severity= (drift guard)") + return errs + + +def _check_shapes(registry: object) -> list[str]: + """先验证可遍历字段,语义规则不得因非法 JSON 形状而崩溃。""" + if not isinstance(registry, dict): + return ["all:top-level: registry must be an object"] + errors = [] + if "status_levels" in registry and not isinstance(registry["status_levels"], dict): + errors.append("all:top-level: status_levels must be an object") + languages = registry.get("languages") + if not isinstance(languages, list): + return errors + ["all:top-level: languages must be a list"] + for index, language in enumerate(languages): + if not isinstance(language, dict): + errors.append(f"languages[{index}]: must be an object") + continue + lid = language.get("id") + for field in ("id", "status"): + if not isinstance(language.get(field), str) or not language[field]: + errors.append(f"languages[{index}]:{field}: must be a non-empty string") + for field in ("extensions", "markers", "file_names", "lint", "format", "gate", "probe", + "requiresConfig", "linter_config_files"): + value = language.get(field) + if value is not None and (not isinstance(value, list) or + not all(isinstance(v, str) and v for v in value)): + errors.append(f"{lid}:{field}: must be list of non-empty strings") + if "append_files" in language and not isinstance(language["append_files"], bool): + errors.append(f"{lid}:append_files: must be boolean") + return errors + + +def check(registry: object) -> list[str]: + """Return all schema and consistency errors; empty list means valid.""" + errs = _check_shapes(registry) + if errs: + return errs + errs += _check_top_level(registry) + errs += _check_unique_ids(registry) + errs += _check_id_kebab(registry) + errs += _check_status(registry) + errs += _check_ext_or_marker_collision(registry, "extensions", "extension") + errs += _check_ext_or_marker_collision(registry, "markers", "marker") + errs += _check_extensions_dot_prefix(registry) + errs += _check_lint_format_shape(registry) + errs += _check_status_command_presence(registry) + errs += _check_requires_config(registry) + errs += _check_linter_config_files(registry) + errs += _check_since(registry) + errs += _check_gate_severity_pin(registry) + return errs diff --git a/scripts/codeguard/reporting.py b/scripts/codeguard/reporting.py new file mode 100644 index 0000000..2d1f59b --- /dev/null +++ b/scripts/codeguard/reporting.py @@ -0,0 +1,206 @@ +"""门禁报告呈现:只渲染已有证据,不启动检查或读取注册表。""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +PLUGIN_ROOT = Path(__file__).resolve().parents[2] + + +def plugin_version() -> str: + """已安装插件自身的 manifest 是显示版本的事实源。""" + try: + manifest = json.loads((PLUGIN_ROOT / ".zcode-plugin/plugin.json").read_text(encoding="utf-8")) + version = manifest.get("version") + return version if isinstance(version, str) and version else "unknown" + except (OSError, ValueError, TypeError): + return "unknown" + + +CODEGUARD_VERSION = plugin_version() + +# maven 依赖解析失败的特征(javadoc:jar 独立 goal 在 reactor 上跑时的典型崩法) +_DEP_RESOLUTION_RE = re.compile( + r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException" +) + +def format_safety_report(violations: list[tuple[str, str, str]]) -> str: + lines = [f"codeguard 🛑 提交内容安全检查:{len(violations)} 个文件不应入库", "─" * 60] + for f, reason, fix in violations: + lines.append(f" {f}") + lines.append(f" 原因: {reason}") + lines.append(f" 怎么修: {fix}") + lines.append("-" * 60) + lines.append("修复后重新暂存再提交(git add 时排除上述文件)") + return "\n".join(lines) + + +def _log_path(project_root: Path, lang: str) -> Path: + import hashlib + import tempfile + key = hashlib.sha1(str(project_root.resolve()).encode()).hexdigest()[:12] + return Path(tempfile.gettempdir()) / f"codeguard-gate-{key}-{lang}.log" + + +def _rule_summary(full: str) -> str: + """ruff/checkstyle 输出的规则聚合计数行(如 "EXE001×3 UP009×1")。 + + 2026-09-23 opencli 会话实测:门禁输出被截断后 AI 修一个才暴露下一个 + (whack-a-mole 升级版),聚合计数让 AI 一次看到问题全集与规模。 + 非 linter 标准行格式(无规则码锚)时返回空串。 + """ + import re as _re + counts: dict[str, int] = {} + for line in full.splitlines(): + m = _re.search(r"\b([A-Z]{2,5}\d{3,4})\b", line) + if m and (":" in line or "-->" in line): + counts[m.group(1)] = counts.get(m.group(1), 0) + 1 + if not counts: + return "" + return "规则汇总: " + " ".join(f"{k}×{v}" for k, v in sorted(counts.items())) + + +def _truncate_detail(full: str, project_root: Path, lang: str) -> str: + """节选 + 总量 + 完整日志路径:截断只留头 600 字符会让 AI 一次修 3 个、 + 重试再看 3 个(whack-a-mole);必须给出"共几行、完整在哪"。""" + lines = [ln for ln in full.splitlines() if ln.strip()] + summary_line = _rule_summary(full) + detail = full[:600] + if summary_line: + detail = summary_line + "\n" + detail + if len(lines) > 8 or len(full) > 600: + try: + path = _log_path(project_root, lang) + path.write_text(full, encoding="utf-8") + detail += f"\n…(截断,共 {len(lines)} 行;完整输出: {path})" + except OSError: + detail += f"\n…(截断,共 {len(lines)} 行)" + return detail + + +def _dependency_resolution_hint(lang: str, full: str) -> str | None: + """Java 门禁报依赖解析失败时给行动指引,而不是只留 maven 堆栈尾部。 + + javadoc:jar 以独立 goal 跑在 reactor 上,sample 类模块常设 + maven.install.skip=true,它们的构件不在本地仓——下游模块必然 + Could not resolve。没有这行提示,用户看到的是与"该改什么"无关的堆栈。 + """ + if lang != "java": + return None + if not _DEP_RESOLUTION_RE.search(full): + return None + return ( + "提示:依赖构件不可解析(如 samples 模块 maven.install.skip=true 未入本地仓)。" + "先在仓根执行 mvn install -DskipTests(必要时加 -Dmaven.install.skip=false)再重试门禁。" + ) + + +def summarize_failures(failures: list) -> str: + """问题综述(一行,用作标题/通知标题):哪几个生态、什么性质的问题""" + langs = "、".join(f[0] for f in failures) + return f"codeguard ❌ 提交门禁未通过:{langs} 共 {len(failures)} 个语言生态有 lint 问题" + + +REPORT_MAX_CHARS = 3000 # gate_directive 总长硬上限:宿主会把超长 stderr 从尾部 + # 截断,指令段曾因此整体丢失(实测报告只见到首段报错) + + +def _squeeze(text: str, budget: int) -> str: + """保头保尾截断:尾部含「完整输出: /tmp/…」日志路径,砍中段也不能丢。""" + if len(text) <= budget: + return text + keep_tail = min(150, budget // 3) + keep_head = max(0, budget - keep_tail - 10) + return text[:keep_head] + "\n……(中略)……" + text[-keep_tail:] + + +def _failure_detail_blocks(failures: list, *, fix_first: bool = False) -> list[str]: + """每个语言的细节块。fix_first=True 把「怎么修/安装」排在长 detail 之前—— + 报告被宿主截断时,行动指引必须比 linter 原始输出先存活下来。""" + blocks: list[str] = [] + for lang, detail, fix, hint in failures: + block = [f"【{lang}】具体问题:"] + tail = [ + f" ▶ 怎么修: {fix}", + f" ▶ 未安装工具时先安装: {hint}", + ] + if lang == "python" and "[*]" in (detail or ""): + tail.append(" ▶ 含 [*] 可自动修复项: ruff check --fix <涉及路径>") + body = detail if detail else " lint 退出码非零,无文本输出" + if fix_first: + block += tail + [body] + else: + block += [body] + tail + block.append("─" * 60) + blocks.append("\n".join(block)) + return blocks + + +def format_failure_report(failures: list) -> str: + """渲染「综述 + 细节」两段式报告(CLI/调试面)。 + + 第一行 = 问题综述(宿主 UI 通常取首行作标题);随后是细节, + 不再重复综述内容——修复"标题和详情一样"的问题。 + """ + lines = [ + summarize_failures(failures), + "─" * 60, + ] + lines.extend(_failure_detail_blocks(failures)) + lines.append(f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py") + return "\n".join(lines) + + +def gate_directive(failures: list, project_root: Path | str | None = None) -> str: + """给 AI 的行动指令:收到后应立即修复并重新提交,而不是询问用户。 + + project_root:本次门禁的目标仓库根——cwd 漂移会让钩子扫到"非目标仓" + (实测:在 codex 仓 cwd 下提交 opencli,POM 4.1.0 报错让人以为改错了 + 文件),首行显式标注目标仓库可第一时间发现扫错对象。 + + 结构 = 综述(首行契约)→ 强制指令(前置!)→ 每语言细节(fix 先于 + detail)→ 修复入口;整体压到 REPORT_MAX_CHARS 内。指令必须前置:宿主把 + 超长 stderr 从尾部截断,此前指令在报告末段,长报告下 AI 只看到首段 linter + 报错、「拆两次调用」与 skipGate 逃生门全部丢失(实测)。首行仍为综述、 + 综述不重复、含「具体问题」——run_all/硬门禁契约保持。 + """ + head_repo = f"本次门禁目标仓库: {Path(project_root).resolve()}\n" if project_root else "" + header = [ + summarize_failures(failures), + f"codeguard v{CODEGUARD_VERSION}", + head_repo + "─" * 60, + "**给 AI 的强制指令**:提交门禁未通过,禁止执行 git commit / git push。\n" + + "**⚠️ 整个工具调用没有执行**:被拦截的是一次包含 git commit/push 的完整 Bash " + "调用——其中非 git 的前序步骤(写文件、跑脚本)也全部未运行。请把「修复」与" + "「提交」拆成两次独立的工具调用,修完再单独执行提交。\n" + + "**⚠️ 如果报错涉及 target/、dist/、build/ 下的构建产物**:请先单独" + "运行清理命令(如 `rm -rf target/reports`,不含 git commit/push)," + "清理完成后再重试提交——本门禁在命令执行前拦截,此前命令链中的清理" + "步骤不会被执行。\n" + + "请立即处理:1) 按下面「怎么修」逐项修复(能自动修复的先跑自动修复命令);" + "2) 纯 lint 类修复可直接继续、不必逐项追问;但凡涉及付费、发布、删除、" + "密钥、或跨出本仓的操作,必须先征得用户同意再执行;" + "3) 修复完成后重新执行用户要做的提交操作。\n" + + "确需绕过(仅用户明确要求时):**单次豁免**用 `git -c codeguard.skipGate=true commit …`" + "(不落配置、无残留,推荐);**仓库级豁免**在该仓库执行 git config codeguard.skipGate true," + "完成后 git config --unset codeguard.skipGate 恢复。环境变量 CODEGUARD_SKIP_GATE " + "只对手动直调 run_check 有效(无法传入宿主钩子进程)。两种豁免都会记入会话审计明细。" + "注意:仓库级豁免对该克隆**所有分支**生效且跨会话残留,务必按上方说明 unset 恢复。", + "─" * 60, + ] + footer = f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py" + head = "\n".join(header) + budget = max(400, REPORT_MAX_CHARS - len(head) - len(footer) - 64) + blocks = _failure_detail_blocks(failures, fix_first=True) + used, kept = 0, [] + for block in blocks: + remain = budget - used + if remain <= 0: + kept.append("…(其余语言的问题明细已省略,按上方怎么修逐语言处理)") + break + if len(block) > remain: + block = _squeeze(block, remain) + kept.append(block) + used += len(block) + 1 + return "\n".join([head, *kept, footer]) diff --git a/scripts/codeguard/repository_policy.py b/scripts/codeguard/repository_policy.py new file mode 100644 index 0000000..33b6f3a --- /dev/null +++ b/scripts/codeguard/repository_policy.py @@ -0,0 +1,31 @@ +"""仓库路径安全与显式豁免;不处理宿主协议或语言检查。""" +from __future__ import annotations + +from pathlib import Path + +from git_snapshot import proposed_paths + +from .execution import execute +from .hook_state import record_skip_event +from .path_policy import check_paths + + +def check_commit_safety(project_root: Path, mode: str, *, lanes=None, extra=None, + pending_commit=False) -> list[tuple[str, str, str]]: + paths = proposed_paths(project_root, mode, lanes=lanes, extra=extra, + pending_commit=pending_commit) + return check_paths(paths) + + +def skip_gate_via_git_config(project_root: Path) -> bool: + """失败重试两次;不存在的配置是未豁免,进程失败另记审计。""" + last_error = "" + for _ in range(2): + outcome = execute(["git", "config", "--get", "codeguard.skipGate"], project_root, 3) + if outcome.failure: + last_error = outcome.stderr or outcome.failure + continue + return outcome.returncode == 0 and outcome.stdout.strip().lower() in ("true", "1", "yes") + record_skip_event("skipGate-read-error", project_root, + detail=f"git config 读取失败(重试2次): {last_error}") + return False diff --git a/scripts/codeguard/spec_validation.py b/scripts/codeguard/spec_validation.py new file mode 100644 index 0000000..ac2aeae --- /dev/null +++ b/scripts/codeguard/spec_validation.py @@ -0,0 +1,29 @@ +"""可选 OpenSpec 验证适配器;进程故障保留为未验证,不生成宿主输出。""" +from __future__ import annotations + +import shutil +from pathlib import Path + +from .execution import execute +from .reporting import _truncate_detail + + +def validate(project_root: Path, timeout_seconds: int = 300) -> dict: + if not (project_root / "openspec" / "config.yaml").is_file(): + return {"failures": [], "skipped": None} + cli = shutil.which("openspec") + if cli is None: + return {"failures": [], "skipped": "openspec CLI 未安装,跳过验证"} + try: + proc = execute([cli, "validate", "--all", "--strict", "--no-interactive", "--json"], + project_root, timeout_seconds) + except Exception as exc: # noqa: BLE001 — 可选集成异常必须可见,保留原兼容合同 + return {"failures": [], "skipped": f"openspec validate 异常未验证: {exc!r}"} + if proc.failure: + return {"failures": [], "skipped": f"openspec validate 未验证: {proc.failure} {proc.stderr}"} + if proc.returncode == 0: + return {"failures": [], "skipped": None} + detail = _truncate_detail(proc.stdout + proc.stderr, project_root, "openspec") + fix = f"{cli} validate --all --strict --no-interactive" + hint = "见 https://openspec.dev 或 `npm i -g @fission-ai/openspec`" + return {"failures": [("openspec", detail, fix, hint)], "skipped": None} diff --git a/scripts/codeguard/storage.py b/scripts/codeguard/storage.py new file mode 100644 index 0000000..ac8824a --- /dev/null +++ b/scripts/codeguard/storage.py @@ -0,0 +1,99 @@ +"""本地状态基础设施:锁覆盖整个读改写,替换对读者原子可见。""" +from __future__ import annotations + +import contextlib +import json +import os +import tempfile +import time +from collections.abc import Callable +from pathlib import Path +from typing import TypeVar + +if os.name == "nt": # Windows 也必须串行读改写,不能退化为仅原子写。 + import msvcrt +else: + import fcntl + +T = TypeVar("T") + + +@contextlib.contextmanager +def locked(path: Path): + """稳定旁路锁文件,原子替换数据文件时不替换锁 inode。""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.with_name(path.name + ".lock").open("a+b") as lock: + if os.name == "nt": + lock.seek(0, os.SEEK_END) + if not lock.tell(): + lock.write(b"\0") + lock.flush() + deadline = time.monotonic() + 10 + while True: + try: + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) + break + except OSError: + if time.monotonic() >= deadline: + raise + time.sleep(0.01) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if os.name == "nt": + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +def read_json(path: Path) -> dict: + """兼容不存在/损坏的旧 JSON;上层负责是否允许降级,不吞任意 IO 错误。""" + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def _replace(path: Path, content: str) -> None: + fd, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def update_json(path: Path, change: Callable[[dict], T]) -> T: + """在锁内读取、变更、替换;回调异常不写入任何部分结果。""" + with locked(path): + value = read_json(path) + result = change(value) + _replace(path, json.dumps(value, ensure_ascii=False)) + return result + + +def take_json(path: Path) -> dict: + """原子消费一份状态;锁释放后的新写入留给下一轮,不被 Stop 清除。""" + with locked(path): + value = read_json(path) + path.unlink(missing_ok=True) + return value + + +def append_jsonl(path: Path, entry: dict, limit: int) -> None: + """有界审计日志,保留窗口内并发写入不互相覆盖。""" + with locked(path): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + lines = [] + lines.append(json.dumps(entry, ensure_ascii=False)) + _replace(path, "\n".join(lines[-max(1, limit):]) + "\n") diff --git a/scripts/codeguard/toolchain.py b/scripts/codeguard/toolchain.py new file mode 100644 index 0000000..c9df369 --- /dev/null +++ b/scripts/codeguard/toolchain.py @@ -0,0 +1,75 @@ +"""工具探活:显式目标目录、统一执行边界、仅单轮检查内成功去重。""" +from __future__ import annotations + +from pathlib import Path +from threading import Lock + +from .execution import execute + +BINARY_DEPENDENCIES = {"npx": ["node"]} +_PIPE_GLUE = {"find", "xargs", "grep", "sort", "sh", "bash", "echo"} + + +def extract_tool_binaries(cmd_def: dict) -> list[str]: + """提取既有注册表静态命令的工具名;不是通用 Shell 解释器。""" + binaries: set[str] = set() + for field in ("lint", "gate"): + command = cmd_def.get(field) + if not command: + continue + if command[0] in ("bash", "sh") and len(command) >= 3 and command[1] == "-c": + for segment in command[2].split("|"): + tokens = segment.strip().split() + if tokens and tokens[0] not in _PIPE_GLUE: + binaries.add(tokens[0]) + else: + binaries.add(command[0]) + for binary in tuple(binaries): + binaries.update(BINARY_DEPENDENCIES.get(binary, [])) + return sorted(binary for binary in binaries if binary) + + +class ToolchainProbe: + """一轮检查持有一个实例;只记成功,下一轮必须新建。 + + 相同工具可能被并行语言 worker 共享;按命令加锁,避免重复启动 npx, + 不同工具仍可并行。探活不是 lint 通过证据,随后异常仍由真实检查报告。 + 不设置全局缓存,也不跨项目复用实例。 + """ + + def __init__(self, project_root: Path): + self.project_root = Path(project_root).resolve() + self._successful: set[tuple[tuple[str, ...], float]] = set() + self._commands: dict[tuple[tuple[str, ...], float], Lock] = {} + self._lock = Lock() + + def probe(self, cmd_def: dict, timeout: float = 10) -> tuple[bool, str]: + """显式 probe 优先,否则逐一验证工具 --version;绝不读取宿主 stdin。""" + commands = ([cmd_def["probe"]] if cmd_def.get("probe") else + [[binary, "--version"] for binary in extract_tool_binaries(cmd_def)]) + for command in commands: + key = (tuple(command), timeout) + with self._lock: + command_lock = self._commands.setdefault(key, Lock()) + with command_lock: + with self._lock: + if key in self._successful: + continue + result = execute(command, self.project_root, timeout, stdin_null=True) + if result.failure == "timeout": + return False, f"探活超时: {' '.join(command)}" + if result.failure: + return False, f"探活无法执行: {result.stderr}" + if result.returncode: + first = next((line for line in (result.stderr or result.stdout).splitlines() + if line.strip()), "") + return False, f"探活退出 {result.returncode}: {first[:100]}" + with self._lock: + self._successful.add(key) + return True, "" + + +def probe_toolchain(cmd_def: dict, timeout: int = 10, *, + project_root: str | Path | None = None) -> tuple[bool, str]: + """兼容单次调用;批次调用方显式持有 ToolchainProbe 以共享成功探活。""" + return ToolchainProbe(Path(project_root) if project_root is not None else Path.cwd()).probe(cmd_def, timeout) diff --git a/scripts/codeguard/verdict.py b/scripts/codeguard/verdict.py new file mode 100644 index 0000000..750b746 --- /dev/null +++ b/scripts/codeguard/verdict.py @@ -0,0 +1,136 @@ +"""纯判定内核:不依赖宿主、进程执行、注册表或持久化。""" +from __future__ import annotations + +import re +from collections import Counter +from pathlib import Path + +PASS, FAIL, UNVERIFIED, SKIPPED, PLANNED = "PASS", "FAIL", "UNVERIFIED", "SKIPPED", "PLANNED" +# 工具链/环境自身不兼容的特征——不是代码违规,归 UNVERIFIED 绝不拦提交。 +# 每条都来自会话实测原文(hermes/easydoc 两仓 2026-09-22 误拦复现): +# - Maven 3 读不了 POM 4.1.0('modelVersion' ... is newer than ...) +# - JDK 26 不支持 --release 8(error: release version 1.8 not supported) +# - javac target 与当前 JDK 不匹配(error: invalid target release) +# - 字节码高于当前 JVM(UnsupportedClassVersionError / class file version) +# - 依赖构件不可解析、goal 用法错、配置损坏 +_ENV_ERROR = re.compile( + r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException|" + r"Cannot find module|ModuleNotFoundError|Unknown lifecycle phase|" + r"Could not resolve all|Could not find or load main class|invalid configuration|" + r"'modelVersion' of '[^']*' is newer than the versions supported|" + r"requires a newer version of Maven|" + r"error: release version [\d.]+ not supported|release version [\d.]+ not supported|" + r"error: invalid target release|invalid target release:|" + r"UnsupportedClassVersionError|class file version|" + r"has been compiled by a more recent version", + re.IGNORECASE, +) + + +def _strip_dialect_blocks(output: str) -> str: + """剥离 ShellCheck 方言不支持(SC1071)的诊断块,保留同批其余诊断。 + + shellcheck 输出按 `In line N:` 分块;SC1071 是"工具不支持该方言" + 的固有限制,整块丢弃。不得因此掩盖同批 .sh 的真问题——剥离后仍有剩余 + 诊断就按剩余内容重判。 + """ + blocks: list[list[str]] = [] + current: list[str] = [] + for ln in output.splitlines(): + if ln.startswith("In ") and " line " in ln and current: + blocks.append(current) + current = [ln] + else: + current.append(ln) + if current: + blocks.append(current) + kept = [ln for blk in blocks if not any("SC1071" in x for x in blk) for ln in blk] + return "\n".join(kept).strip() + + +_CODE_RE = re.compile(r"\b([A-Z]{1,4}\d{2,4})\b") +_DIAGNOSTIC_CODE_RE = re.compile(r"\b[A-Z]{1,5}\d{1,4}\b") +_LOCATION_RE = re.compile(r"^.+?\.[A-Za-z0-9]+:\d+(?::\d+)?:\s*") + + +def finding_instances(output: str) -> Counter[str]: + """按诊断内容保留重复次数;不能用规则码集合授予存量豁免。 + + 只接纳带规则码或文件位置的诊断行。无法识别的输出交给调用方保持 + 未豁免,避免把工具的统计摘要误当作一条已证明的旧问题。 + """ + findings: Counter[str] = Counter() + for line in output.splitlines(): + raw = line.strip() + if not raw: + continue + located = bool(_LOCATION_RE.match(raw)) + description = _LOCATION_RE.sub("", raw) + description = re.sub(r"^\^--\s*", "", description).strip() + if not _DIAGNOSTIC_CODE_RE.search(description) and not located: + continue + description = re.sub(r"\s+", " ", description) + if description: + findings[description] += 1 + return findings + + +def finding_signatures(output: str) -> set[str]: + """从 linter 输出提取可跨运行比较的"发现签名"集合。 + + 优先用工具规则码(SC2086/F401/…);无规则码的工具回退"归一化描述行" + (剥 file:line:col 前缀与数字)——同一发现在改动前后必须签名一致, + 基线比对才有意义。提不出签名时返回空集,由调用方拒绝豁免。 + """ + sigs = set(_CODE_RE.findall(output)) + if sigs: + return sigs + for ln in output.splitlines(): + s = ln.strip() + if not s or s.startswith(("In ", "^", "~", "#!")): + continue + norm = re.sub(r"[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+:\d+(:\d+)?", "", s) + norm = re.sub(r"\d+", "#", norm).strip() + if norm: + sigs.add(norm) + return sigs + + +def lint_verdict(rc: int, command: list[str], output: str = "") -> tuple[str, str]: + """按工具契约归一结论;只允许明确的成功返回 PASS。""" + if rc == 0: + return PASS, "检查执行成功" + # ShellCheck 不支持 zsh/dash 之外的方言(SC1071 是 error 级固有限制): + # 先剥离这些诊断块——只有剥离后**无剩余**才算"方言能力边界"归 UNVERIFIED; + # 仍有剩余诊断就按剩余内容判,不许掩盖同批真问题。 + if "SC1071" in output: + rest = _strip_dialect_blocks(output) + if not rest: + return UNVERIFIED, "ShellCheck 不支持该脚本方言(SC1071)" + output = rest + if rc in (124, 127) or rc < 0 or _ENV_ERROR.search(output): + reason = {124: "检查超时", 127: "工具不存在"}.get(rc, "工具链执行异常") + return UNVERIFIED, reason + tool = Path(command[0]).name if command else "" + # pylint 的 2 为 error 位;不得沿用 ESLint/ruff 的配置错误语义。 + if tool == "pylint": + return (UNVERIFIED, "pylint 用法错误") if rc & 32 else (FAIL, "pylint 发现违规") + if rc == 1 or (tool == "cargo" and rc == 101): + return FAIL, "检查发现违规" + return UNVERIFIED, f"工具退出 {rc},没有可确认的检查结论" + + +def result(language: str, status: str, reason: str, *, exit_code: int = 0, **details) -> dict: + """兼容 passed,同时携带不会在 CLI/MCP 间丢失的明确状态。""" + return {"language": language, "status": status, "reason": reason, + "passed": status == PASS, "exit_code": exit_code, **details} + + +def exit_status(results: list[dict]) -> int: + """聚合优先级:发现违规 > 无法验证 > 已完成/无须检查。""" + statuses = {r.get("status", PASS if r.get("passed") else FAIL) for r in results} + if FAIL in statuses: + return 2 + if not statuses or statuses & {UNVERIFIED, PLANNED}: + return 1 + return 0 diff --git a/scripts/cve_check.py b/scripts/cve_check.py index aed0c0a..234d587 100644 --- a/scripts/cve_check.py +++ b/scripts/cve_check.py @@ -1,329 +1,67 @@ -"""cve_check.py:多生态 CVE 依赖漏洞扫描编排器(codeguard cve)。 - -生态 → 工具映射: - Maven(Java) → mvn org.owasp:dependency-check-maven:check(NVD 数据库,检查出即报告修复) - Node(前端) → npm audit --json(可 npm audit fix 自动修复) - Python → pip-audit(未装则提示安装) - Rust → cargo audit(未装则提示安装) - 通用兜底 → trivy fs --scanners vuln(装了 trivy 就能扫一切锁文件) - -用法: - python3 cve_check.py # 自动检测项目类型并扫描 - python3 cve_check.py --ecosystem node # 只扫 node - python3 cve_check.py --fix # 允许自动修复(当前仅 npm audit fix) - python3 cve_check.py --severity HIGH # 失败阈值(LOW/MEDIUM/HIGH/CRITICAL) -""" +"""codeguard cve:旧导入兼容与 CLI 呈现;业务实现位于 codeguard.cve。""" from __future__ import annotations import argparse import json -import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -from detect_lang import detect_languages, find_project_root - -SEVERITY_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3} - -# CVSS v3 档位下界(LOW 0.1–3.9 / MEDIUM 4.0–6.9 / HIGH 7.0–8.9 / CRITICAL 9.0–10)。 -# maven 的 failBuildOnCVSS 是数值语义,与其余扫描器的「级别集合」必须表达同一 intent: -# --severity HIGH ⇒ 恰好在 CVSS>=7 失败,而不是旧映射 SEVERITY_ORDER+1 产生的 >=3 -# (那会把全部 MEDIUM 也算进 HIGH,比其他扫描器激进一整档)。 -CVSS_BAND_FLOOR = {"LOW": 0, "MEDIUM": 4, "HIGH": 7, "CRITICAL": 9} - - -def severities_at_and_above(threshold: str) -> list[str]: - """「阈值及以上」的级别集合,标签序从低到高(trivy 的 --severity 逗号列表用)""" - floor = SEVERITY_ORDER[threshold.upper()] - return [s for s in ("LOW", "MEDIUM", "HIGH", "CRITICAL") if SEVERITY_ORDER[s] >= floor] - -# 退出码:0 通过 / 1 无法验证 / 2 存在漏洞 / 3 参数错误。 -# 3 必须独立:CI 常用 -eq 2 判定安全告警,而 argparse 默认的参数错误退出码正是 2, -# 复用会把一次拼写错误读成漏洞告警;复用 1 又会把「用错了参数」掩盖成「环境缺工具」。 -EXIT_PASS = 0 -EXIT_UNVERIFIED = 1 -EXIT_FINDINGS = 2 -EXIT_USAGE = 3 - - -def run(cmd: list[str], cwd: Path, timeout: int = 600) -> tuple[int, str, str]: - try: - proc = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False, text=True, timeout=timeout) - return proc.returncode, proc.stdout, proc.stderr - except subprocess.TimeoutExpired: - return 124, "", f"timeout after {timeout}s" - except FileNotFoundError: - return 127, "", "command not found" - - -def scan_maven(root: Path, threshold: int) -> dict: - """每次独立 JSON 报告目录,拒绝复用旧报告冒充本次漏洞证据。""" - import tempfile - findings, status = [], "UNVERIFIED" - with tempfile.TemporaryDirectory(prefix="codeguard-cve-") as output_dir: - rc, out, err = run( - ["./mvnw" if (root / "mvnw").exists() else "mvn", "-q", - "org.owasp:dependency-check-maven:aggregate", f"-DfailBuildOnCVSS={threshold}", - "-Dformat=JSON", f"-Dodc.outputDirectory={output_dir}"], cwd=root, timeout=1800) - try: - report = json.loads((Path(output_dir) / "dependency-check-report.json").read_text()) - if not isinstance(report.get("dependencies"), list): - raise TypeError("报告缺少 dependencies") - unknown_score = False - for dependency in report["dependencies"]: - for finding in dependency.get("vulnerabilities", []): - scores = [finding.get(key, {}).get("baseScore", finding.get(key, {}).get("score")) - for key in ("cvssv4", "cvssv3", "cvssv2")] - score = next((s for s in scores if isinstance(s, (int, float))), None) - if score is None: - unknown_score = True - elif score >= threshold: - findings.append({"id": finding.get("name"), "score": score}) - if rc in (0, 1): - status = "FAIL" if findings else ("UNVERIFIED" if unknown_score or rc else "PASS") - except (OSError, ValueError, TypeError, AttributeError): - pass - fix_hint = ( - "修复路径: mvn versions:display-dependency-updates 查看可升级依赖;" - "升级受影响组件版本,或在 dependency-check-suppressions.xml 登记误报" - ) - return {"ecosystem": "maven", "tool": "owasp dependency-check", "exit": rc, - "status": status, "findings": findings, - "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} - - -def scan_node(root: Path, threshold: str, allow_fix: bool) -> dict: - rc, out, err = run(["npm", "audit", "--json"], cwd=root, timeout=600) - if rc == 127: - return {"ecosystem": "node", "tool": "npm audit", "exit": 127, - "summary_tail": "npm not found", "fix_hint": "安装 Node.js"} - counts = {} - fixed_hint = "npm audit fix" - try: - meta = json.loads(out).get("metadata", {}).get("vulnerabilities") - if not isinstance(meta, dict) or not meta or any( - not isinstance(v, int) or isinstance(v, bool) or v < 0 for v in meta.values()): - raise ValueError("无有效漏洞统计") - counts = {k.lower(): v for k, v in meta.items()} - counts["medium"] = counts.pop("moderate", counts.get("medium", 0)) - except (json.JSONDecodeError, ValueError, AttributeError): - return {"ecosystem": "node", "tool": "npm audit", "exit": rc, - "status": "UNVERIFIED", "reason": "扫描未返回有效漏洞报告", - "summary_tail": (out + err)[-1000:], "fix_hint": "修复扫描环境后重试"} - if rc not in (0, 1): - return {"ecosystem": "node", "tool": "npm audit", "exit": rc, - "status": "UNVERIFIED", "reason": "扫描进程未正常完成", - "summary_tail": err[-1000:], "fix_hint": "检查网络与扫描器"} - over = [s for s in SEVERITY_ORDER - if SEVERITY_ORDER[s] >= SEVERITY_ORDER.get(threshold.upper(), 2) - and counts.get(s.lower(), 0) > 0] - failed = bool(over) - fix_hint = fixed_hint if failed else "" - return {"ecosystem": "node", "tool": "npm audit", "exit": rc, - "status": "FAIL" if failed else "PASS", "reason": "依据 npm 漏洞统计与严重级别阈值", - "counts": counts, "over_threshold": over, "failed": failed, - "summary_tail": err[-1000:], "fix_hint": fix_hint} - - -def _native_report_status(rc: int, findings: list, threshold: str) -> tuple[str, str]: - """无严重度的原生报告不能证明 HIGH 阈值通过,也不能冒充高危漏洞。""" - if rc not in (0, 1): - return "UNVERIFIED", "扫描进程未正常完成" - if findings: - if threshold == "LOW": - return "FAIL", "有效报告包含漏洞;LOW 模式报告全部漏洞" - return "UNVERIFIED", "原生报告缺少可比较严重度;已发现漏洞,但不能判定阈值,请用 trivy 复核" - return ("PASS", "有效报告未发现漏洞") if rc == 0 else ("UNVERIFIED", "非零退出但无漏洞证据") - - -def scan_pip(root: Path, threshold: str = "LOW") -> dict: - # 不扫描宿主 Python 环境:必须明确项目依赖输入。 - requirements = next((name for name in ("requirements.txt", "requirements-dev.txt") - if (root / name).is_file()), None) - if requirements: - target = ["--requirement", requirements] - elif (root / "pyproject.toml").is_file(): - target = ["."] +from codeguard.cve import ( # noqa: F401 — 旧导入兼容 + ECOSYSTEM_SCANNERS, + _scan_cargo_ecosystem, + _scan_maven_ecosystem, + _scan_node_ecosystem, + _scan_pip_ecosystem, + _scan_trivy_ecosystem, + canonical_ecosystem, + ecosystem_choices, + language_ecosystem_map, + precheck, + scan_project, +) +from codeguard.cve_policy import ( # noqa: F401 — 旧导入兼容 + CVSS_BAND_FLOOR, + EXIT_FINDINGS, + EXIT_PASS, + EXIT_UNVERIFIED, + EXIT_USAGE, + SEVERITY_ORDER, + severities_at_and_above, +) +from codeguard.cve_scanners import ( # noqa: F401 + run, + scan_cargo, + scan_maven, + scan_node, + scan_pip, + scan_trivy, +) + + +def _print_batch(batch): + print(f"[codeguard-cve] project: {batch.root}") + print(f"[codeguard-cve] ecosystems: {list(batch.ecosystems) or '(none detected)'}") + for result in batch.results: + print(f" {result['ecosystem']:8s} {result['tool']:28s} {result['status']}") + if "before_fix" in result: + print("[codeguard-cve] npm audit fix 已执行;当前结论来自复扫") + if result["status"] == "PASS": + continue + label = "❌ 有漏洞需修复" if result["status"] == "FAIL" else "⚠️ 无法验证" + print(f"[codeguard-cve] {result['ecosystem']} {label}: {result.get('reason', '')}") + if result.get("summary_tail"): + print(" " + result["summary_tail"].replace("\n", "\n ")[:2000]) + print(f" ➜ {result.get('fix_hint', '')}") + if not batch.results: + print("[codeguard-cve] 无可扫描生态(或对应工具未安装)") + elif batch.exit_code == EXIT_FINDINGS: + count = sum(result["status"] == "FAIL" for result in batch.results) + print(f"[codeguard-cve] {count} 个生态存在漏洞——检查出来了就得修,禁止带洞提交") + elif batch.exit_code == EXIT_UNVERIFIED: + print("[codeguard-cve] ⚠️ 存在无法验证的生态——不能视为通过") else: - return {"ecosystem": "python", "tool": "pip-audit", "exit": 1, - "status": "UNVERIFIED", "reason": "没有支持的 requirements/pyproject 项目输入"} - rc, out, err = run(["pip-audit", "--strict", "--format", "json", *target], cwd=root, timeout=900) - if rc == 127: - return {"ecosystem": "python", "tool": "pip-audit", "exit": 127, - "summary_tail": "pip-audit not installed (pip install pip-audit)", - "fix_hint": "pip install pip-audit"} - status, reason, findings = "UNVERIFIED", "扫描未返回有效漏洞报告", [] - try: - dependencies = json.loads(out)["dependencies"] - if not isinstance(dependencies, list) or any( - not isinstance(d, dict) or not isinstance(d.get("vulns"), list) for d in dependencies): - raise TypeError("无有效依赖列表") - findings = [v for d in dependencies for v in d["vulns"]] - if any(not isinstance(v, dict) or not v.get("id") for v in findings): - raise ValueError("漏洞缺少标识") - status, reason = _native_report_status(rc, findings, threshold) - except (ValueError, TypeError, KeyError): - pass - fix_hint = "按报告升级 requirements/pyproject 中的受影响包;本入口不自动修改 Python 依赖" - return {"ecosystem": "python", "tool": "pip-audit", "exit": rc, - "status": status, "reason": reason, "findings": findings, - "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} - - -def scan_cargo(root: Path, threshold: str = "LOW") -> dict: - # cargo 本体存在但 audit 是外部子命令——先探测,未安装返回 127(无法验证) - rc_v, _, err_v = run(["cargo", "audit", "--version"], cwd=root, timeout=60) - if rc_v == 127 or "no such command" in (err_v or "").lower() or "unrecognized" in (err_v or "").lower(): - return {"ecosystem": "rust", "tool": "cargo audit", "exit": 127, - "summary_tail": "cargo-audit not installed (cargo install cargo-audit)", - "fix_hint": "cargo install cargo-audit"} - rc, out, err = run(["cargo", "audit", "--json"], cwd=root, timeout=900) - status, reason, findings = "UNVERIFIED", "扫描未返回有效漏洞报告", [] - try: - findings = json.loads(out)["vulnerabilities"]["list"] - if not isinstance(findings, list): - raise TypeError("无有效漏洞列表") - if any(not isinstance(v, dict) or not v.get("advisory", {}).get("id") for v in findings): - raise ValueError("漏洞缺少 advisory 标识") - status, reason = _native_report_status(rc, findings, threshold) - except (ValueError, TypeError, KeyError, AttributeError): - pass - fix_hint = "按报告升级 Cargo.toml 中的受影响 crate(cargo update 可试)" - return {"ecosystem": "rust", "tool": "cargo audit", "exit": rc, - "status": status, "reason": reason, "findings": findings, - "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} - - -def scan_trivy(root: Path, threshold: str) -> dict: - rc, out, err = run( - ["trivy", "fs", "--scanners", "vuln", "--format", "json", "--exit-code", "2", - "--severity", ",".join(severities_at_and_above(threshold)), - "."], - cwd=root, timeout=1800, - ) - if rc == 127: - return {"ecosystem": "universal", "tool": "trivy", "exit": 127, - "summary_tail": "trivy not installed (brew install trivy)", - "fix_hint": "brew install trivy"} - status = "UNVERIFIED" - try: - report = json.loads(out) - if isinstance(report, dict) and "Results" in report and rc in (0, 2): - findings = [v for item in report["Results"] or [] for v in item.get("Vulnerabilities", []) or [] - if v.get("Severity") in severities_at_and_above(threshold)] - status = "FAIL" if findings else "PASS" - except (ValueError, TypeError, AttributeError): - pass - return {"ecosystem": "universal", "tool": "trivy", "exit": rc, "status": status, - "summary_tail": (out + err)[-2500:], - "fix_hint": "按报告升级受影响依赖版本"} - - -# 各扫描器的调用约定不同(阈值形式、是否支持自动修复),适配成统一入口: -# (root, severity, allow_fix) -> result。差异与生态标识放在一起,不再散落在派发分支里。 -def _scan_maven_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - # failBuildOnCVSS 按数值档位(HIGH⇒7),与其余扫描器的「阈值及以上」同一 intent - return scan_maven(root, CVSS_BAND_FLOOR[severity]) - - -def _scan_node_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - result = scan_node(root, severity, allow_fix) - if allow_fix and result.get("failed"): - print("[codeguard-cve] npm audit fix ...") - run(["npm", "audit", "fix"], cwd=root, timeout=900) - after = scan_node(root, severity, False) - after["before_fix"] = result - result = after - return result - - -def _scan_pip_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - return scan_pip(root, severity) - - -def _scan_cargo_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - return scan_cargo(root, severity) - - -def _scan_trivy_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - return scan_trivy(root, severity) - - -# 唯一权威映射:规范标识 → 别名 / 映射语言 / 前置条件标志文件 / 扫描入口。 -# 命令行入参、语言检测映射、前置条件判定与结果报告全部从这里派生, -# 标识不一致在该文件内不再可表达。 -# languages 为空的生态(universal)只能显式选择,不会被语言检测自动选中。 -ECOSYSTEM_SCANNERS = { - "maven": { - "aliases": ("java",), - "languages": ("java",), - "markers": ("pom.xml",), - "scan": _scan_maven_ecosystem, - }, - "node": { - "aliases": (), - "languages": ("typescript",), - "markers": ("package.json", "package-lock.json", "npm-shrinkwrap.json"), - "scan": _scan_node_ecosystem, - }, - "python": { - "aliases": (), - "languages": ("python",), - "markers": ("requirements.txt", "requirements-dev.txt", "pyproject.toml", "Pipfile.lock"), - "scan": _scan_pip_ecosystem, - }, - "rust": { - "aliases": (), - "languages": ("rust",), - "markers": ("Cargo.lock",), - "scan": _scan_cargo_ecosystem, - }, - "universal": { - "aliases": ("trivy",), - "languages": (), - "markers": (), - "scan": _scan_trivy_ecosystem, - }, -} - - -def canonical_ecosystem(value: str) -> str | None: - """生态标识归一化:接受规范标识与其别名;未声明返回 None""" - key = value.strip().lower() - if key in ECOSYSTEM_SCANNERS: - return key - for canonical, spec in ECOSYSTEM_SCANNERS.items(): - if key in spec["aliases"]: - return canonical - return None - - -def ecosystem_choices() -> str: - """全部可接受标识(含别名),供错误信息与 --help 共用""" - parts = [] - for canonical, spec in ECOSYSTEM_SCANNERS.items(): - aliases = spec["aliases"] - parts.append(f"{canonical}(别名 {'/'.join(aliases)})" if aliases else canonical) - return "、".join(parts) - - -def language_ecosystem_map() -> dict[str, str]: - """语言 id → 规范生态标识,从权威映射派生""" - return {lang: canonical - for canonical, spec in ECOSYSTEM_SCANNERS.items() - for lang in spec["languages"]} - - -def precheck(root: Path, eco: str) -> tuple[bool, str]: - """返回 (可扫描, 说明)。缺少标志文件说明项目不属于该生态(或依赖未锁定), - 运行扫描器只会得到环境错误——归类为「无法验证」而非「有漏洞」。""" - markers = ECOSYSTEM_SCANNERS.get(eco, {}).get("markers", ()) - if not markers: - return True, "" - if any((root / m).exists() for m in markers): - return True, "" - return False, f"缺少 {' / '.join(markers)}(可能不是{eco}项目或依赖未锁定)" + print("[codeguard-cve] ✅ 所有生态 CVE 检查通过") def main() -> int: @@ -339,85 +77,15 @@ def main() -> int: if exc.code == 2: return EXIT_USAGE raise - - results = [] + batch = scan_project(args.path, args.ecosystem, args.severity, args.fix) + if batch.error: + print(f"[codeguard-cve] {batch.error}", file=sys.stderr) if args.as_json: - import contextlib - import io - with contextlib.redirect_stdout(io.StringIO()): - rc = _scan(args, results) - print(json.dumps({"status": {0: "PASS", 1: "UNVERIFIED", 2: "FAIL", 3: "USAGE"}[rc], - "exit_code": rc, "results": results}, ensure_ascii=False)) - return rc - return _scan(args, results) - - -def _scan(args, results: list) -> int: - - root = find_project_root(args.path) or Path(args.path).resolve() - - # 参数错误必须先于任何扫描器启动被拒绝:否则会先花掉一次真实扫描再报错。 - # 退出码用独立的 EXIT_USAGE,避免被调用方读成「存在漏洞」或「无法验证」。 - if args.ecosystem: - # 显式指定优先:不依赖语言检测结果 - canonical = canonical_ecosystem(args.ecosystem) - if canonical is None: - print(f"[codeguard-cve] 未知生态 {args.ecosystem!r};可接受:{ecosystem_choices()}", - file=sys.stderr) - return EXIT_USAGE - ecosystems = [canonical] - else: - langs = detect_languages(root) - eco_map = language_ecosystem_map() - ecosystems = sorted({eco_map[l] for l in langs if l in eco_map}) - if langs and not ecosystems: - # 识别到了语言但都不属于原生生态(go/php/ruby…)→ 通用兜底给出结论, - # 而不是报「无可扫描生态」。原生生态存在时不并行兜底(替换而非追加); - # 一个语言都没识别到的目录不算「无原生扫描器的语言」,仍报无可扫描。 - ecosystems = ["universal"] - - print(f"[codeguard-cve] project: {root}") - print(f"[codeguard-cve] ecosystems: {ecosystems or '(none detected)'}") - - for eco in ecosystems: - spec = ECOSYSTEM_SCANNERS[eco] - scannable, why = precheck(root, eco) - if not scannable: - results.append({"ecosystem": eco, "tool": eco, "exit": 127, "status": "UNVERIFIED", - "summary_tail": f"无法验证: {why}", "fix_hint": "确认项目类型后重试"}) - print(f" {eco:8s} SKIP({why})") - continue - r = spec["scan"](root, args.severity, args.fix) - results.append(r) - # 未提供结构化证据的原生适配器只接受成功;非零不能凭空证明有漏洞。 - r.setdefault("status", "PASS" if r["exit"] == 0 else "UNVERIFIED") - status = r["status"] - print(f" {eco:8s} {r['tool']:28s} {status}") - - if not results: - print("[codeguard-cve] 无可扫描生态(或对应工具未安装)") - return EXIT_UNVERIFIED - - failed = [r for r in results if r.get("status") == "FAIL"] - unverifiable = [r for r in results if r.get("status", "UNVERIFIED") == "UNVERIFIED"] - print() - for r in failed: - print(f"[codeguard-cve] ❌ {r['ecosystem']} 有漏洞需修复:") - if r.get("summary_tail"): - print(" " + r["summary_tail"].replace("\n", "\n ")[:2000]) - print(f" ➜ {r.get('fix_hint', '')}") - for r in unverifiable: - print(f"[codeguard-cve] ⚠️ {r['ecosystem']} 无法验证: {r.get('reason', '')} {r.get('summary_tail', '')}") - print(f" ➜ 安装后重跑: {r.get('fix_hint', '')}") - if failed: - n = len(failed) - print(f"\n[codeguard-cve] {n} 个生态存在漏洞——检查出来了就得修,禁止带洞提交") - return EXIT_FINDINGS - if unverifiable: - print("\n[codeguard-cve] ⚠️ 存在无法验证的生态(工具缺失)——不能视为通过") - return EXIT_UNVERIFIED - print("[codeguard-cve] ✅ 所有生态 CVE 检查通过") - return EXIT_PASS + print(json.dumps({"status": {0: "PASS", 1: "UNVERIFIED", 2: "FAIL", 3: "USAGE"}[batch.exit_code], + "exit_code": batch.exit_code, "results": list(batch.results)}, ensure_ascii=False)) + elif not batch.error: + _print_batch(batch) + return batch.exit_code if __name__ == "__main__": diff --git a/scripts/detect_lang.py b/scripts/detect_lang.py index 8566dae..64a2d27 100644 --- a/scripts/detect_lang.py +++ b/scripts/detect_lang.py @@ -1,35 +1,33 @@ -"""detect_lang.py:项目语言检测 + linter 命令表(事实源 `scripts/languages.json`)。 - -实现逻辑(借鉴 codegraph 的统一注册表管线): -1. **单一事实源** `scripts/languages.json`:每语言一条注册(id/name/extensions/ - markers/lint/format/status/install_hint/since),识别与命令表全部由它构建; -2. **扩展名自动识别,零配置**:内置映射表覆盖全部注册语言; -3. **项目级自定义映射**:项目根 `codeguard.json` 的 `extensions` 可合并/覆盖内置 - 默认(如 `{ "extensions": { ".dota_lua": "lua" } }`),`exclude` 数组排除文件模式; -4. **安全降级**:注册表中 lint/format 为 null 的语言(Planned 状态)安全跳过。 - -职责边界(见 openspec/changes/add-detect-lang-splits): -- PATH 补齐:`scripts/paths.py::ensure_user_path` -- 用户/项目配置:`scripts/user_config.py::load_user_config / load_project_overrides / get_overrides` -- 本文件 re-export 上述符号以保持外部 API 不变。 - -被 hooks/、commands/、skills/ 共享。 -""" +"""旧语言检测脚本的兼容入口;所有实现分别归属 registry/discovery/config/toolchain。""" from __future__ import annotations import json -import re -import subprocess -from pathlib import Path -from typing import Any +import sys -# === 跨职责 re-export(保持外部 API 不变) === -from paths import ensure_user_path -from user_config import ( +from codeguard.config import ( + ConfigurationError, get_overrides, load_project_overrides, load_user_config, ) +from codeguard.discovery import ( + detect_language, + detect_languages, + find_project_root, + project_uses_linter, +) +from codeguard.registry import ( + EXT_LANG_MAP, + FILE_LANG_MAP, + LANG_COMMANDS, + LANG_INSTALL_HINTS, + LANG_STATUS, + PROJECT_MARKERS, + REGISTRY, + REGISTRY_PATH, +) +from codeguard.toolchain import extract_tool_binaries, probe_toolchain +from paths import ensure_user_path __all__ = [ "EXT_LANG_MAP", @@ -39,6 +37,7 @@ "LANG_STATUS", "PROJECT_MARKERS", "REGISTRY", + "REGISTRY_PATH", "detect_language", "detect_languages", "ensure_user_path", @@ -48,252 +47,19 @@ "load_project_overrides", "load_user_config", "probe_toolchain", - "project_uses_linter", + "project_uses_linter" ] -REGISTRY_PATH = Path(__file__).resolve().parent / "languages.json" - - -def _load_registry() -> dict[str, dict[str, Any]]: - """加载语言注册表:id -> language 定义""" - with REGISTRY_PATH.open(encoding="utf-8") as f: - data = json.load(f) - return {lang["id"]: lang for lang in data["languages"]} - - -REGISTRY = _load_registry() - - -# === 派生表(由注册表构建,保持与旧 API 兼容) === -EXT_LANG_MAP: dict[str, str] = {} -FILE_LANG_MAP: dict[str, str] = {} -PROJECT_MARKERS: dict[str, str] = {} -LANG_COMMANDS: dict[str, dict[str, list[str]]] = {} -LANG_INSTALL_HINTS: dict[str, str] = {} -LANG_STATUS: dict[str, str] = {} - -for _id, _lang in REGISTRY.items(): - for _ext in _lang.get("extensions", []): - EXT_LANG_MAP[_ext.lower()] = _id - for _fname in _lang.get("file_names", []): - FILE_LANG_MAP[_fname] = _id - for _marker in _lang.get("markers", []): - PROJECT_MARKERS[_marker] = _id - LANG_STATUS[_id] = _lang.get("status", "planned") - # 门禁命令表只收 Stable/Beta;Planned 语言安全跳过(命令留作路线图参考) - if _lang.get("status") in ("stable", "beta"): - _lint, _fmt = _lang.get("lint"), _lang.get("format") - if _lint or _fmt: - LANG_COMMANDS[_id] = { - "lint": _lint, - "format": _fmt, - "gate": _lang.get("gate"), # 项目级门禁命令(文件型 linter 必须传文件清单) - "probe": _lang.get("probe"), # 显式探活命令(npx 系必配,覆盖包未装场景) - "requiresConfig": _lang.get("requiresConfig"), # 未接入配置的项目归 skipped - "install_hint": _lang.get("install_hint"), - "append_files": _lang.get("append_files", True), - } - if _lang.get("install_hint"): - LANG_INSTALL_HINTS[_id] = _lang["install_hint"] - - -_TOOL_CACHE: dict[tuple, tuple] = {} - -# 运行时依赖:wrapper 命令存在不代表能用(npx 自身是 #!/usr/bin/env node 脚本) -BINARY_DEPENDENCIES = { - "npx": ["node"], -} - -# 管道胶水命令不算 linter 本体(gate 命令形如 find ... | xargs -0 shellcheck) -_PIPE_GLUE = {"find", "xargs", "grep", "sort", "sh", "bash", "echo"} - - -def extract_tool_binaries(cmd_def: dict) -> list[str]: - """提取 lint/gate 命令真正依赖的可执行名(展开运行时依赖,去胶水命令)。 - - 供门禁 pre-flight 探活与 SessionStart 盘点共用。 - """ - bins: set[str] = set() - for key in ("lint", "gate"): - cmd = cmd_def.get(key) - if not cmd: - continue - if cmd[0] in ("bash", "sh") and len(cmd) >= 3 and cmd[1] == "-c": - for segment in cmd[2].split("|"): - token = segment.strip().split() - if token and token[0] not in _PIPE_GLUE: - bins.add(token[0]) - else: - bins.add(cmd[0]) - expanded: set[str] = set() - for b in bins: - expanded.add(b) - expanded.update(BINARY_DEPENDENCIES.get(b, [])) - return sorted(b for b in expanded if b) - - -def project_uses_linter(cmd_def: dict, project_root: str | Path) -> bool: - """项目是否接入了该语言的 linter(requiresConfig 声明的配置文件任一存在)。 - - 生态型 linter(如 ESLint 9+)在没有配置文件的项目里必然报错退出—— - 那是「未接入」,不是「代码违规」;未接入的语言归 skipped 不拦提交。 - requiresConfig 缺省的语言(自包含工具如 shellcheck/clippy)视为已接入。 - """ - required = cmd_def.get("requiresConfig") if isinstance(cmd_def, dict) else None - if not required: - return True - root = Path(project_root) - import fnmatch - for name in required: - if any(w in name for w in "*?["): - if any(fnmatch.fnmatch(p.name, name) for p in root.iterdir()): - return True - elif (root / name).exists(): - return True - return False - - -def probe_toolchain(cmd_def: dict, timeout: int = 10) -> tuple[bool, str]: - """pre-flight 探活:验证工具链真的能启动。 - - 语言可用 `probe` 字段显式指定探活命令(npx 系必配——包未装时 - `--no-install ... --version` 会明确失败,而不是 lint 时才 npm error); - 缺省对每个必需二进制跑 `--version`。 - - 进程内缓存(_TOOL_CACHE):eslint 等被 typescript/vue/svelte/astro 多个 - 语言共享,同一进程只真探一次;hooks 进程短生命周期,无需落盘。 - - 返回 (ok, 失败原因)。失败即工具链问题(运行时缺失/包未装/命令损坏), - 该语言本轮无法检查——归 skipped,绝不能算 lint 失败拦提交。 - """ - - def _cached(key: tuple, fn): - if key not in _TOOL_CACHE: - _TOOL_CACHE[key] = fn() - return _TOOL_CACHE[key] - - probe = cmd_def.get("probe") - if probe: - return _cached(("probe", tuple(probe)), lambda: _run_probe_cmd(probe, timeout)) - - for b in extract_tool_binaries(cmd_def): - ok, reason = _cached(("bin", b), lambda b=b: _probe_binary(b, timeout)) - if not ok: - return False, reason - return True, "" - - -def _run_probe_cmd(probe: list, timeout: int) -> tuple[bool, str]: - try: - # stdin=DEVNULL:探活命令绝不消费宿主 stdin;--format 类探活靠 EOF 立即返回 - proc = subprocess.run(probe, capture_output=True, check=False, text=True, timeout=timeout, - stdin=subprocess.DEVNULL) - except subprocess.TimeoutExpired: - return False, f"探活超时: {' '.join(probe)}" - except OSError as exc: - return False, f"探活无法执行: {exc}" - if proc.returncode != 0: - first = next((ln for ln in (proc.stderr or proc.stdout).splitlines() if ln.strip()), "") - return False, f"探活退出 {proc.returncode}: {first[:100]}" - return True, "" - - -def _probe_binary(b: str, timeout: int) -> tuple[bool, str]: - import shutil - if shutil.which(b) is None: - return False, f"{b} 不在 PATH" +def main() -> int: try: - proc = subprocess.run([b, "--version"], capture_output=True, check=False, text=True, timeout=timeout) - except subprocess.TimeoutExpired: - return False, f"{b} --version 超时" - except OSError as exc: - return False, f"{b} 无法执行: {exc}" - if proc.returncode != 0: - first = next((ln for ln in (proc.stderr or proc.stdout).splitlines() if ln.strip()), "") - return False, f"{b} --version 退出 {proc.returncode}: {first[:100]}" - return True, "" - - -# === 项目根 codeguard.json 自定义扩展映射(借鉴 codegraph.json 设计) === -# 实现已迁至 scripts/user_config.py;re-export 见顶部 import 段。 - - -def detect_language(file_path: str | Path, project_root: Path | None = None) -> str | None: - """按扩展名/文件名判断语言;支持 codeguard.json 自定义映射;非代码文件返回 None""" - p = Path(file_path) - # 项目根缺省从文件路径推导——钩子调用都不显式传 root, - # 不推导则 codeguard.json 的自定义扩展映射永远不会生效 - if project_root is None: - project_root = find_project_root(p) - overrides = get_overrides(project_root) if project_root else {} - ext_map = {**EXT_LANG_MAP, **overrides.get("extensions", {})} - lang = ext_map.get(p.suffix.lower()) - if lang: - return lang - # 文件名级识别(Dockerfile 等) - return FILE_LANG_MAP.get(p.name) - - -def find_project_root(start: str | Path) -> Path | None: - """从 start 路径向上找项目根(识别到 .git 或任一项目标记文件即停)""" - p = Path(start).resolve() - if p.is_file(): - p = p.parent - for _ in range(10): - if (p / ".git").exists(): - return p - for marker in PROJECT_MARKERS: - if (p / marker).exists(): - return p - if p.parent == p: - return None - p = p.parent - return None - - -def _excluded(f: Path, exclude_patterns: list[str]) -> bool: - return any(re.search(pat, str(f)) for pat in exclude_patterns) - - -def detect_languages(project_root: str | Path) -> list[str]: - """检测项目根中存在的语言:标记文件 + 常见源码目录浅扫描 + 根目录一层; - 支持 codeguard.json 的 exclude 排除""" - project_root = Path(project_root) - overrides = get_overrides(project_root) - exclude = overrides.get("exclude", []) - - langs: set[str] = set() - # 1) 标记文件 - for marker, lang in PROJECT_MARKERS.items(): - if (project_root / marker).exists(): - langs.add(lang) - # 2) 常见源码目录浅扫描(含脚本/二进制/钩子目录:shell 等胶水语言常在这些位置) - src_dirs = ["src", "src/main", "lib", "pkg", "app", "tests", "test", - "scripts", "bin", "hooks", "cmd", "internal"] - for d in src_dirs: - base = project_root / d - if not base.is_dir(): - continue - for f in base.rglob("*"): - if f.is_file() and not _excluded(f, exclude): - lang = detect_language(f) - if lang: - langs.add(lang) - # 3) 根目录一层(小脚本项目) - for f in project_root.iterdir(): - if f.is_file() and not _excluded(f, exclude): - lang = detect_language(f) - if lang: - langs.add(lang) - return sorted(langs) + root = find_project_root(sys.argv[1] if len(sys.argv) > 1 else ".") + print(json.dumps(detect_languages(root), ensure_ascii=False) if root else "[]") + return 0 + except ConfigurationError as exc: + print(f"[codeguard] UNVERIFIED: {exc}", file=sys.stderr) + return 1 if __name__ == "__main__": - import sys - - pr = find_project_root(sys.argv[1] if len(sys.argv) > 1 else ".") - if pr: - print(json.dumps(detect_languages(pr), ensure_ascii=False)) - else: - print("[]") + raise SystemExit(main()) diff --git a/scripts/dockerfile_security.py b/scripts/dockerfile_security.py index 9c195f7..187d7b3 100644 --- a/scripts/dockerfile_security.py +++ b/scripts/dockerfile_security.py @@ -1,152 +1,64 @@ -"""dockerfile_security.py:Dockerfile 安全风险检查编排器(codeguard dockerfile)。 - -双层检查: - 1) hadolint —— lint + 安全规则(DL3002 root、DL3007 latest、DL3004 sudo、 - DL3020 ADD 风险、DL3016 端口、DL3025 变量注入、DL3048 HEALTHCHECK 等) - 2) trivy config —— misconfig 规则集(DS002 root 用户、DS001 未固定 digest、 - 敏感信息进镜像层、未清理包管理器缓存等;可选,装了 trivy 更强) - -用法: - python3 dockerfile_security.py # 扫当前项目全部 Dockerfile - python3 dockerfile_security.py path/to/Dockerfile - python3 dockerfile_security.py --json # 结构化输出 - -退出码:0=通过;1=无法验证(工具缺失/无 Dockerfile);2=发现安全风险 -""" +"""`codeguard dockerfile` 协议入口;扫描范围与判定由应用服务负责。""" from __future__ import annotations import argparse import json -import subprocess import sys -from pathlib import Path - -DOCKERFILE_NAMES = {"Dockerfile"} -DOCKERFILE_EXTS = {".dockerfile"} - - -def run(cmd: list[str], cwd: Path, timeout: int = 300) -> tuple[int, str, str]: - try: - proc = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False, text=True, timeout=timeout) - return proc.returncode, proc.stdout, proc.stderr - except subprocess.TimeoutExpired: - return 124, "", f"timeout after {timeout}s" - except FileNotFoundError: - return 127, "", "command not found" - - -def find_dockerfiles(root: Path) -> list[Path]: - """定位 Dockerfile:根目录名匹配 + src 树内 *.dockerfile / */Dockerfile*""" - found: list[Path] = [] - direct = [p for p in root.iterdir() if p.is_file() and ( - p.name in DOCKERFILE_NAMES or p.suffix.lower() in DOCKERFILE_EXTS)] - found.extend(sorted(direct, key=lambda p: p.name)) - for pattern in ("**/Dockerfile", "**/*.dockerfile"): - if len(found) >= 20: - break - for p in root.glob(pattern): - if p.is_file() and p not in found: - found.append(p) - return sorted(set(found)) - -def hadolint_scan(files: list[Path], root: Path) -> dict: - """hadolint:逐文件扫描,聚合失败计数(warning/error)""" - findings = [] - tool_ok = True - scanned = 0 - for f in files: - rc, out, _err = run(["hadolint", str(f)], cwd=root) - if rc == 127: - return {"tool": "hadolint", "exit": 127, "findings": [], - "summary_tail": "hadolint not installed (brew install hadolint)"} - if rc not in (0, 1): # hadolint: 0=clean, 1=has findings - tool_ok = False - scanned += 1 - # 格式: file:line DLxxxx severity message - findings.extend(out.splitlines()) - return {"tool": "hadolint", "exit": 0 if not findings else 2, - "scanned": scanned, "findings": findings[-40:], "tool_ok": tool_ok} - - -def trivy_scan(files: list[Path], root: Path) -> dict: - """trivy config:misconfig 规则(root 运行、digest 未固定、secrets 等)""" - results = [] - for f in files: - rc, out, _err = run(["trivy", "config", "--format", "json", "--quiet", str(f)], - cwd=root, timeout=600) - if rc == 127: - return {"tool": "trivy config", "exit": 127, "findings": [], - "summary_tail": "trivy not installed (brew install trivy)"} - try: - data = json.loads(out) - except json.JSONDecodeError: - continue - for res in data.get("Results", []): - for mis in res.get("Misconfigurations", []): - if mis.get("Status") == "FAIL": - results.append({ - "id": mis.get("ID"), - "title": mis.get("Title"), - "severity": mis.get("Severity"), - "message": (mis.get("Message") or "")[:200], - "resolution": (mis.get("Resolution") or "")[:200], - }) - return {"tool": "trivy config", "exit": 0 if not results else 2, "findings": results} +from codeguard.dockerfile import ( + DOCKERFILE_EXTS, + DOCKERFILE_NAMES, + find_dockerfiles, + find_root, + hadolint_scan, + run, + scan_path, + trivy_scan, +) + +__all__ = ["DOCKERFILE_EXTS", "DOCKERFILE_NAMES", "find_dockerfiles", "find_root", + "hadolint_scan", "main", "run", "scan_path", "trivy_scan"] + + +def _print_text(report: dict) -> None: + if not report.get("files"): + print(f"[codeguard-dockerfile] {report['reason']}", file=sys.stderr) + return + print(f"[codeguard-dockerfile] 扫描 {len(report['files'])} 个 Dockerfile @ {report['root']}") + for key, title in (("hadolint", "hadolint(lint + 安全规则)"), + ("trivy", "trivy config(misconfig 安全规则)")): + result = report[key] + findings = result["findings"] + print(f"\n== {title} == {len(findings)} 条发现") + for finding in findings: + if isinstance(finding, str): + print(" " + finding[:180]) + else: + print(f" [{finding.get('severity')}] {finding.get('id')}: {finding.get('title')}") + if finding.get("resolution"): + print(f" ➜ {finding['resolution']}") + if result["status"] == "UNVERIFIED": + print(f" ⚠️ 未验证:{result['summary_tail'] or '没有有效扫描报告'}") + if report["status"] == "PASS": + print("[codeguard-dockerfile] ✅ Dockerfile 安全检查通过") + elif report["status"] == "FAIL": + print("[codeguard-dockerfile] ❌ Dockerfile 存在安全风险——修复后重扫", file=sys.stderr) + else: + print("[codeguard-dockerfile] ⚠️ 检查未完整验证——修复工具或报告问题后重扫", file=sys.stderr) def main() -> int: - ap = argparse.ArgumentParser(prog="codeguard dockerfile") - ap.add_argument("--json", action="store_true", dest="as_json") - ap.add_argument("path", nargs="?", default=".") - args = ap.parse_args() - - root = find_root(args.path) - files = find_dockerfiles(root) - if not files: - print(f"[codeguard-dockerfile] 未找到 Dockerfile: {root}") - return 1 - - if not args.as_json: - print(f"[codeguard-dockerfile] 扫描 {len(files)} 个 Dockerfile @ {root}") - h = hadolint_scan(files, root) - t = trivy_scan(files, root) + parser = argparse.ArgumentParser(prog="codeguard dockerfile") + parser.add_argument("--json", action="store_true", dest="as_json") + parser.add_argument("path", nargs="?", default=".") + args = parser.parse_args() - report = {"hadolint": h, "trivy": t} + report = scan_path(args.path) if args.as_json: print(json.dumps(report, ensure_ascii=False, indent=2)) - h_ok = h.get("exit") == 0 - t_ok = t.get("exit") == 0 - unverifiable = 127 in (h.get("exit"), t.get("exit")) - if unverifiable: - return 1 - return 0 if (h_ok and t_ok) else 2 - - print(f"\n== hadolint(lint + 安全规则)== {len(h.get('findings', []))} 条发现") - for line in h.get("findings", []): - print(" " + str(line)[:180]) - tri = t.get("findings", []) - print(f"\n== trivy config(misconfig 安全规则)== {len(tri)} 条 FAIL") - for item in tri[:30]: - print(f" [{item.get('severity')}] {item.get('id')}: {item.get('title')}") - if item.get("resolution"): - print(f" ➜ {item['resolution']}") - - had_fail = bool(h.get("findings")) or h.get("exit") == 127 - tri_fail = bool(t.get("findings")) - if h.get("exit") == 127 or t.get("exit") == 127: - print("\n[codeguard-dockerfile] ⚠️ 部分工具未安装——无法完整验证,安装后重跑", file=sys.stderr) - return 1 - if had_fail or tri_fail: - print("\n[codeguard-dockerfile] ❌ Dockerfile 存在安全风险——修复后重扫", file=sys.stderr) - return 2 - print("[codeguard-dockerfile] ✅ Dockerfile 安全检查通过") - return 0 - - -def find_root(path: str) -> Path: - p = Path(path).resolve() - return p if p.is_dir() else p.parent + else: + _print_text(report) + return report["exit_code"] if __name__ == "__main__": diff --git a/scripts/fix.py b/scripts/fix.py index d41c903..cc43751 100644 --- a/scripts/fix.py +++ b/scripts/fix.py @@ -15,7 +15,8 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -import run_per_language +from codeguard import language_check as run_per_language +from codeguard.config import ConfigurationError from detect_lang import ( detect_languages, find_project_root, @@ -33,7 +34,11 @@ def main() -> int: args = parser.parse_args() project_root = find_project_root(args.path) or Path(args.path).resolve() - languages = detect_languages(project_root) + try: + languages = detect_languages(project_root) + except ConfigurationError as exc: + print(f"[codeguard] UNVERIFIED: {exc}", file=sys.stderr) + return 1 if args.lang: languages = [lang for lang in languages if lang == args.lang] diff --git a/scripts/git_snapshot.py b/scripts/git_snapshot.py index 11f3a0d..d821705 100644 --- a/scripts/git_snapshot.py +++ b/scripts/git_snapshot.py @@ -54,7 +54,10 @@ def overlays(root: Path, lanes=None, extra=None) -> set[str]: selected |= names(root, "ls-files", "--others", "--exclude-standard", "-z") for name in extra or (): path = safe_path(root, name) - selected |= names(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", name) + # extra 已由暂存意图解析;文件名中的 * / : 不得再触发 pathspec。 + # --literal-pathspecs 仍让目录参数递归展开,保留旧字面目录调用面。 + selected |= names(root, "--literal-pathspecs", "ls-files", "--cached", "--others", + "--exclude-standard", "-z", "--", name) if path.is_file() or path.is_symlink(): selected.add(name) return selected diff --git a/scripts/java_project.py b/scripts/java_project.py index 2974898..786777c 100644 --- a/scripts/java_project.py +++ b/scripts/java_project.py @@ -1,316 +1,14 @@ -"""Java 项目感知:只读构建描述,计算模块反向依赖闭包,不执行构建或索引。""" +"""Java 项目规划兼容入口;应用、构建解析、影响策略、环境观察各有单一实现。""" from __future__ import annotations import argparse import json -import os -import re -import sys -import xml.etree.ElementTree as ET -from pathlib import Path -_BUILD_FILES = {"pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", - "settings.gradle.kts", "gradle.properties", "mvnw", "gradlew", "codeguard.json"} +from codeguard.java_analysis import analyze +from codeguard.java_changes import is_version_bump_only as _is_version_bump_only +from codeguard.java_environment import resolve_java_home as _resolve_java_home - -def _inside(root: Path, path: Path) -> Path: - resolved = path.resolve() - if not resolved.is_relative_to(root): - raise ValueError(f"构建路径在仓外: {path}") - return resolved - - -def _text(node, path: str, default="") -> str: - return node.findtext(path, default=default).strip() - - -def _maven(root: Path) -> tuple[dict, list[str]]: - modules: dict = {} - reasons: list[str] = [] - - def visit(directory: Path, inherited: dict): - directory = _inside(root, directory) - name = directory.relative_to(root).as_posix() - if name in modules: - return - doc = ET.parse(_inside(root, directory / "pom.xml")).getroot() - props = dict(inherited) - properties = doc.find("{*}properties") - if properties is not None: - props.update({p.tag.split("}")[-1]: (p.text or "").strip() for p in properties}) - - def expand(value: str) -> str: - for _ in range(10): - newer = re.sub(r"\$\{([^}]+)\}", lambda m: props.get(m[1], m[0]), value) - if newer == value: - break - value = newer - if "${" in value: - reasons.append(f"{name}: 无法静态解析 {value},回退全模块") - return value - - group = expand(_text(doc, "{*}groupId") or _text(doc, "{*}parent/{*}groupId") - or props.get("project.groupId", "")) - artifact = expand(_text(doc, "{*}artifactId")) - if not artifact: - raise ValueError(f"{name}: 缺少 artifactId") - props.update({"project.groupId": group, "pom.groupId": group, - "project.artifactId": artifact}) - dependencies = [] - for dep in doc.findall("{*}dependencies/{*}dependency"): - dependencies.append((expand(_text(dep, "{*}groupId")), expand(_text(dep, "{*}artifactId")))) - plugins = [p.text or "" for p in doc.findall("{*}build/{*}plugins/{*}plugin/{*}artifactId")] - modules[name] = {"path": name, "coordinate": (group, artifact), - "raw_dependencies": dependencies, "dependencies": [], "plugins": plugins} - if doc.find("{*}profiles") is not None: - reasons.append(f"{name}: Maven profiles 可能改变模块/依赖,回退根 verify") - if dependencies and doc.findall("{*}modules/{*}module"): - reasons.append(f"{name}: 聚合父 POM 依赖可能被子模块继承,回退根 verify") - for child in doc.findall("{*}modules/{*}module"): - visit(directory / expand((child.text or "").strip()), props) - - visit(root, {}) - coordinates = {} - for name, module in modules.items(): - coordinate = module["coordinate"] - if coordinate in coordinates: - reasons.append("模块坐标不唯一,回退全模块") - coordinates[coordinate] = name - for module in modules.values(): - module["dependencies"] = sorted({coordinates[d] for d in module.pop("raw_dependencies") if d in coordinates}) - module.pop("coordinate") - return modules, reasons - - -def _gradle(root: Path) -> tuple[dict, list[str]]: - reasons = [] - modules = {".": {"path": ".", "dependencies": [], "plugins": []}} - settings = next((p for p in (root / "settings.gradle.kts", root / "settings.gradle") if p.exists()), None) - if settings: - body = _inside(root, settings).read_text() - includes = re.findall(r"\binclude\s*(\([^)]*\)|[^\n]+)", body) - for expression in includes: - literals = re.findall(r"['\"](:?[\w:-]+)['\"]", expression) - residue = re.sub(r"['\"](:?[\w:-]+)['\"]|[\s,()]", "", expression) - if not literals or residue: - reasons.append("Gradle include 使用动态表达式,回退根 check") - for literal in literals: - name = literal.lstrip(":").replace(":", "/") - _inside(root, root / name) - modules[name] = {"path": name, "dependencies": [], "plugins": []} - if re.search(r"includeBuild|projectDir|apply\s|\bfor\s*\(|\.each\b", body): - reasons.append("Gradle settings 存在动态/复合构建,回退根 check") - for name, module in modules.items(): - directory = _inside(root, root / name) - build = next((p for p in (directory / "build.gradle.kts", directory / "build.gradle") if p.exists()), None) - if not build: - reasons.append(f"{name}: 没有可读取的 Gradle 构建描述,回退根 check") - continue - body = _inside(root, build).read_text() - references = re.findall(r"\bproject\s*\(\s*(?:path\s*[:=]\s*)?['\"](:[\w:-]+)['\"]\s*\)", body) - module["dependencies"] = sorted({r.lstrip(":").replace(":", "/") for r in references}) - if any(d not in modules for d in module["dependencies"]): - reasons.append(f"{name}: 引用了未声明项目,回退根 check") - if len(re.findall(r"\bproject\s*\(", body)) != len(references) or re.search( - r"apply\s+from|apply\s*\(\s*from|subprojects|allprojects|buildSrc|\bprojects\.|\bif\s*\(|\bfor\s*\(", body): - reasons.append(f"{name}: Gradle 动态构建无法可靠缩小范围,回退根 check") - if (root / "buildSrc").exists(): - reasons.append("存在 buildSrc 构建逻辑,回退根 check") - return modules, reasons - - -def _resolve_java_home(root: Path) -> tuple[str | None, str | None]: - """解析 pom/java.version 并找到兼容 JDK;返回 (JAVA_HOME 路径, 原因)。 - - 逻辑: - 1. 从 pom.xml 的 或 读取版本号 - 2. 在 macOS 用 `/usr/libexec/java_home -v ` 查找;在 Linux 用 - `update-alternatives --list java` 匹配 - 3. 找到则返回 JAVA_HOME 路径(调用方注入 lint 子进程); - 找不到则返回 (None, "需要 JDK X,已装列表:…")——调用方将此作为 - UNVERIFIED 理由,而非 FAIL。 - """ - import subprocess - java_version = None - pom = root / "pom.xml" - if pom.is_file(): - try: - content = pom.read_text(encoding="utf-8", errors="ignore") - m = re.search(r"\s*(\d+)", content) or \ - re.search(r"\s*(\d+)", content) - if m: - java_version = int(m.group(1)) - except OSError: - pass - if java_version is None: - return None, None # 未声明版本——用默认 JDK,不干预 - # macOS: /usr/libexec/java_home -v - if sys.platform == "darwin": - try: - result = subprocess.run( - ["/usr/libexec/java_home", "-v", str(java_version)], - capture_output=True, text=True, timeout=5, check=False, - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip(), None - except (subprocess.TimeoutExpired, OSError): - pass - # Linux: update-alternatives --list java - if sys.platform == "linux": - try: - result = subprocess.run( - ["update-alternatives", "--list", "java"], - capture_output=True, text=True, timeout=5, check=False, - ) - if result.returncode == 0: - for line in result.stdout.splitlines(): - path = line.strip() - # 从路径提取版本号(如 /usr/lib/jvm/java-21-openjdk-amd64/bin/java) - vm = re.search(r"java[-.](\d+)", path) - if vm and int(vm.group(1)) == java_version: - home = str(Path(path).parent.parent) - return home, None - except (subprocess.TimeoutExpired, OSError): - pass - # 未找到匹配 JDK - return None, f"需要 JDK {java_version},当前环境无匹配版本" - - -def _is_version_bump_only(root: Path, changed: list[str] | None) -> bool: - """检测变更是否为纯版本 bump(只改 pom/build.gradle 的 version/revision 行)。 - - 用 git diff 对比变更文件的实际内容差异:新增/删除/修改的行都不含 - 依赖、插件、模块结构等关键词时,判定为纯版本 bump。无需联网或 - 构建——纯文本 diff 足以区分「改个版本号」与「改了依赖」。 - """ - import subprocess - if not changed: - return False - build_names = {"pom.xml", "build.gradle", "build.gradle.kts"} - for f in changed: - if Path(f).name not in build_names: - return False - try: - result = subprocess.run( - ["git", "diff", "HEAD", "--"] + [str(f) for f in changed], - cwd=str(root), capture_output=True, text=True, timeout=5, check=False, - ) - diff = result.stdout - except (subprocess.TimeoutExpired, OSError): - return False - # 只看 +/- 行的实际内容(排除 diff 头) - content_lines = [ - ln for ln in diff.splitlines() - if ln.startswith(("+", "-")) and not ln.startswith(("+++", "---")) - ] - if not content_lines: - return False - # 纯版本 bump:变更行只涉及 version/revision/group/artifactId/description - # 等坐标性字段,不含依赖/插件/模块/属性等结构关键词 - structural = re.compile( - r"<(dependency|plugin|module|parent|properties|build|profile)|" - r"implementation|api|testImplementation|compileOnly|runtimeOnly", - re.IGNORECASE, - ) - return not any(structural.search(ln) for ln in content_lines) - - -def analyze(project_root: str | Path, changed: list[str] | None = None) -> dict: - """返回只读计划;changed=None 全量,[] 无变更;绝不把规划当成验证通过。""" - root = Path(project_root).resolve() - plan = {"status": "UNVERIFIED", "root": str(root), "build_system": None, - "changed_paths": changed, - "analysis_level": "module", "modules": [], "affected_modules": [], "commands": [], - "conservative": False, "reasons": [], "gaps": ["模块依赖图不是完整符号/业务语义分析"]} - try: - if (root / "pom.xml").is_file(): - system, (modules, reasons) = "maven", _maven(root) - elif any((root / n).is_file() for n in ("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts")): - system, (modules, reasons) = "gradle", _gradle(root) - else: - raise ValueError("未找到 Maven/Gradle 构建描述") - plan.update(build_system=system, modules=list(modules.values())) - wrapper = "mvnw" if system == "maven" else "gradlew" - if os.name == "nt": - wrapper += ".cmd" if system == "maven" else ".bat" - executable = "./" + wrapper if (root / wrapper).is_file() else ("mvn" if system == "maven" else "gradle") - if (root / wrapper).exists() and os.name != "nt" and not os.access(root / wrapper, os.X_OK): - raise ValueError(f"wrapper 不可执行: {wrapper}") - # 顶层 executable 键:门禁消费者(gate_lib Java 门禁)直接读取, - # 用于把 PATH 上的 mvn/gradle 替换为项目自带 wrapper—— - # POM 4.1.0 等 Maven 4 仓库在 Maven 3 下必然解析失败(实测)。 - plan["executable"] = executable - relevant = None if changed is None else [p for p in changed if p.endswith((".java", ".kt", ".groovy", ".scala")) - or "/src/" in "/" + p - or Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/"))] - if relevant is not None: - for name in relevant: - _inside(root, root / name) - full = relevant is None or bool(reasons) or any( - Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/")) for p in relevant or []) - affected = set(modules) if full else set() - if not full: - for path in relevant or []: - owners = [m for m in modules if m == "." or path.startswith(m + "/")] - if owners: - affected.add(max(owners, key=len)) - # 反向传递闭包:改动依赖的模块必须重新验证。 - while True: - expanded = affected | {m for m, detail in modules.items() if affected.intersection(detail["dependencies"])} - if expanded == affected: - break - affected = expanded - if relevant == []: - affected = set() - commands = [] - if affected: - # 纯版本 bump 提交(只改 pom/build.gradle 的 version/revision 行) - # 降级为 validate/help 级——版本号变更不影响编译产物正确性, - # 全量 verify 在大仓上每次 bump 都是分钟级浪费(实测发布流程痛点)。 - bump_only = _is_version_bump_only(root, changed) - if system == "maven": - argv = [executable, "-B"] - if not full and "." not in affected: - argv += ["-pl", ",".join(sorted(affected)), "-am"] - # 默认等级不含测试执行(-DskipTests 只跳执行,测试代码仍编译): - # 门禁管提交面的编译/打包/静态正确性,测试执行交 CI 或 - # codeguard.json java.commands 显式声明——大仓全量测试普遍超 - # 门禁超时预算,跑满也只剩 UNVERIFIED,反而给不出结论。 - argv += ["-DskipTests", "validate" if bump_only else "verify"] - else: - argv = [executable] + (["help"] if bump_only else - ["check"] if full or "." in affected else - [":" + p.replace("/", ":") + ":check" for p in sorted(affected)]) - if not bump_only: - argv += ["-x", "test"] - commands = [{"kind": "verify", "argv": argv}] - config = root / "codeguard.json" - if config.exists(): - explicit = json.loads(_inside(root, config).read_text()).get("java", {}).get("commands") - if explicit is not None: - if not isinstance(explicit, list) or not explicit or any( - not isinstance(c, list) or not c or any(not isinstance(a, str) or not a for a in c) - for c in explicit): - raise ValueError("java.commands 必须是非空 argv 数组列表") - commands = [{"kind": "configured", "argv": c} for c in explicit] - reasons.append("使用 codeguard.json 明确声明的权威检查命令") - plugins = {p for m in modules.values() for p in m["plugins"]} - if not plugins.intersection({"maven-checkstyle-plugin", "maven-pmd-plugin", "spotbugs-maven-plugin"}): - plan["gaps"].append("未确认静态规则已绑定生命周期;verify/check 成功不代表 Checkstyle/PMD 全覆盖") - # JDK 兼容性:解析 java.version 并查找可用 JDK;找到则注入 JAVA_HOME - jdk_home, jdk_reason = _resolve_java_home(root) - if jdk_home: - for cmd in commands: - cmd["env"] = {"JAVA_HOME": jdk_home} - reasons.append(f"JAVA_HOME={jdk_home}") - elif jdk_reason: - reasons.append(jdk_reason) - plan.update(status="PLANNED" if commands else "SKIPPED", commands=commands, - affected_modules=sorted(affected), conservative=bool(full and relevant is not None), - reasons=reasons or ["按构建模块及反向依赖闭包选择;命令尚未执行"]) - except (OSError, ValueError, TypeError, AttributeError, ET.ParseError) as exc: - plan.update(status="UNVERIFIED", commands=[], reasons=[str(exc)]) - return plan +__all__ = ["_is_version_bump_only", "_resolve_java_home", "analyze"] def main() -> int: diff --git a/scripts/run_check.py b/scripts/run_check.py index 92dceeb..5fc1376 100644 --- a/scripts/run_check.py +++ b/scripts/run_check.py @@ -11,7 +11,7 @@ MCP 模式(OpenSpec 2026-09-22-fix-gate-trigger-and-mcp): 暴露 check_code_style / auto_fix / list_languages / analyze_java_impact 四个工具; 依赖 `mcp>=1.0`(见根 requirements.txt)。按语言执行细节全部委托给 - `scripts/run_per_language.py`;本文件负责 argparse、报告输出与 MCP 接线。 + `codeguard.language_check`;本文件负责 argparse、报告输出与 MCP 接线。 """ from __future__ import annotations @@ -22,26 +22,16 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -import run_per_language -from detect_lang import ( - REGISTRY_PATH, - detect_languages, - find_project_root, - load_user_config, -) +from codeguard import check_application +from codeguard import language_check as run_per_language +from codeguard.config import ConfigurationError +from detect_lang import find_project_root LOG_NAME = ".codeguard-last.log" - - -def _filter_languages(project_root: Path, lang_arg: str | None) -> list[str]: - cfg = load_user_config() - enabled = cfg.get("enabled_languages", []) - languages = detect_languages(project_root) - if enabled and enabled != ["auto"]: - languages = [lang for lang in languages if lang in enabled] - if lang_arg: - languages = [lang for lang in languages if lang in lang_arg.split(",")] - return languages +_auto_fix = check_application.auto_fix +_filter_languages = check_application.filter_languages +_load_registry_entries = check_application.registry_entries +_mcp_envelope = check_application.result_envelope def cli_main(): @@ -67,7 +57,11 @@ def cli_main(): else (Path(args.log_dir) if args.log_dir else project_root / "out") ) - languages = _filter_languages(project_root, args.lang) + try: + languages = _filter_languages(project_root, args.lang) + except ConfigurationError as exc: + print(f"[codeguard] UNVERIFIED: {exc}", file=sys.stderr) + return 1 if not languages: print("[codeguard] 未识别到语言", file=sys.stderr) return 1 @@ -103,49 +97,9 @@ def cli_main(): return verdict_code -# ══════════════════════════ MCP server(官方 SDK) ══════════════════════════ - -def _mcp_envelope(results: list[dict]) -> list[dict]: - """把 run_per_language 结果收成 MCP 契约信封: - {language, passed, exit_code, stderr_path, log_path}。 - stderr_path 与 log_path 指向同一完整输出文件;通过的语言为空串。 - """ - envelope = [] - for r in results: - log_path = r.get("log_path", "") - envelope.append({ - "language": r.get("language", ""), - "passed": bool(r.get("passed")), - "status": r.get("status", "PASS" if r.get("passed") else "FAIL"), - "reason": r.get("reason") or r.get("unverified", ""), - "exit_code": int(r.get("exit_code", 0)), - "stderr_path": log_path, - "log_path": log_path, - }) - return envelope - - -def _load_registry_entries() -> list[dict]: - data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) - return [ - {"id": entry["id"], "name": entry.get("name", entry["id"])} - for entry in data.get("languages", []) - ] - - -def _auto_fix(root: Path, langs: list[str]) -> dict: - """默认仅修复实际改动;无法取得 Git 范围时不扩大写入范围。""" - from scope import changed_files - files = changed_files(root) - if files is None: - return {"fixed": False, "status": "UNVERIFIED", "reason": "无法确定 Git 改动范围;请显式使用 CLI fix --all", - "check": []} - targets = [root / f for f in files if (root / f).is_file() and not (root / f).is_symlink()] - before = {p: p.read_bytes() for p in targets} - fix_results = run_per_language.run_fix(langs, root, files=files) - results = run_per_language.run_check(langs, root, files=files, log_dir=root / "out") - changed = any(not p.is_file() or p.read_bytes() != body for p, body in before.items()) - return {"fixed": changed, "fix_results": fix_results, "check": _mcp_envelope(results)} +# MCP server(官方 SDK);兼容导出供旧调用方使用。 +__all__ = ["LOG_NAME", "_auto_fix", "_filter_languages", "_load_registry_entries", + "_mcp_envelope", "cli_main", "mcp_main"] def mcp_main(project_root: Path) -> int: @@ -199,27 +153,7 @@ async def list_tools() -> list: @server.call_tool() async def call_tool(name: str, arguments: dict) -> list: - if name == "analyze_java_impact": - from java_project import analyze - args = arguments or {} - return [text_block(analyze(args.get("path") or project_root, args.get("changed")))] - if name == "list_languages": - return [text_block(_load_registry_entries())] - if name not in ("check_code_style", "auto_fix"): - return [text_block({"error": f"unknown tool: {name}"})] - args = arguments or {} - raw = args.get("path") or str(project_root) - root = find_project_root(raw) or Path(raw).resolve() - requested = args.get("languages") - langs = list(requested) if requested else _filter_languages(root, None) - if not langs: - return [text_block({"error": "未识别到语言", "path": str(root)})] - if name == "auto_fix": - payload = _auto_fix(root, langs) - else: - results = run_per_language.run_check(langs, root, log_dir=root / "out") - payload = _mcp_envelope(results) - return [text_block(payload)] + return [text_block(check_application.mcp_tool_payload(name, arguments, project_root))] async def _serve() -> None: async with stdio_server() as (read, write): diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py index 199a293..deb6325 100644 --- a/scripts/run_per_language.py +++ b/scripts/run_per_language.py @@ -1,200 +1,6 @@ -"""scripts/run_per_language.py:按语言串行执行 + 结果聚合的共享入口。 - -暴露两个纯函数供 CLI 复用: -- `run_check(languages, project_root, *, timeout, fix, dry_run)` — 跑 lint。 -- `run_fix(languages, project_root, *, timeout, dry_run)` — 跑 format。 - -CLI(run_check.py / fix.py)只负责 argparse + 报告格式;任何 subprocess -调用约定、退出码归一化、超时与命令缺失的处置都集中在本文件。 - -`run_check(fix=True)` 走「lint 失败 → 自动 fix → 复检」三段式。 -""" +"""旧脚本导入兼容入口;按语言检查的唯一实现位于 codeguard.language_check。""" from __future__ import annotations -import subprocess -from pathlib import Path - -from detect_lang import LANG_COMMANDS, detect_language, project_uses_linter -from scope import scope_cmd - -__all__ = ["run_check", "run_fix"] - - -def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]: - """subprocess 调用统一封装:归一化超时(124) / 命令缺失(127) / 其它返回原码。""" - try: - proc = subprocess.run(cmd, cwd=cwd, capture_output=True, check=False, text=True, timeout=timeout) - except subprocess.TimeoutExpired: - return 124, "", f"timeout after {timeout}s" - except FileNotFoundError as e: - return 127, "", f"command not found: {e}" - return proc.returncode, proc.stdout, proc.stderr - - -def run_check(languages: list[str], project_root: Path, - *, timeout: int = 120, fix: bool = False, - dry_run: bool = False, log_dir: Path | None = None, - files: list[str] | None = None) -> list[dict]: - """对每个语言跑 lint;fix=True 时失败后自动跑 format 复检一次。 - - `log_dir` 非空且有语言失败时,完整输出(stdout+stderr 合并——ruff 等 - linter 把诊断打到 stdout)落盘到 `/.codeguard-last.log` - (组头含语言与退出码),失败条目附带 `log_path`; - 全部通过则不产生日志文件。 - """ - from verdict import FAIL, PASS, PLANNED, SKIPPED, UNVERIFIED, lint_verdict, result - - results: list[dict] = [] - log_entries: list[tuple[str, int, str]] = [] - for lang in languages: - cmd_def = LANG_COMMANDS.get(lang) - if not cmd_def: - results.append(result(lang, PLANNED, "没有可执行检查命令")) - continue - lang_files = None if files is None else [f for f in files - if detect_language(f, project_root) == lang and (project_root / f).is_file()] - if lang != "java" and lang_files == []: - results.append(result(lang, SKIPPED, "本次改动未涉及该语言的现存文件")) - continue - if not project_uses_linter(cmd_def, project_root): - results.append(result(lang, UNVERIFIED, "项目缺少已声明的检查配置,未执行")) - continue - lint_cmd = (cmd_def.get("gate") or cmd_def.get("lint")) if files is None else cmd_def.get("lint") - java_plan = None - if lang == "java": - from java_project import analyze - java_plan = analyze(project_root, files) - if not java_plan["commands"]: - results.append(result(lang, java_plan["status"], ";".join(java_plan["reasons"]), - java_plan=java_plan)) - continue - lint_cmd = java_plan["commands"][0]["argv"] - if dry_run or not lint_cmd: - results.append(result(lang, PLANNED, "计划检查,尚未执行", - dry_run=True, command=lint_cmd or [])) - continue - # CLI/MCP 是仓健康检查(全量):注入默认 ruff 配置 + 剔除 vendor 快照, - # 否则规则集随机器漂移、供应链快照给出不可修的永久红。 - if java_plan: - commands = [c["argv"] for c in java_plan["commands"]] - elif "{file}" in " ".join(lint_cmd): - if lang_files is None: - results.append(result(lang, UNVERIFIED, "只有单文件命令,未配置项目 gate")) - continue - commands = [scope_cmd(lint_cmd, project_root, single_file=f) for f in lang_files] - else: - commands = [scope_cmd(lint_cmd, project_root, files=lang_files, - full_excludes=lang_files is None, - append_files=cmd_def.get("append_files", True))] - for lint_cmd in commands: - rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) - if rc != 0: - break - status, reason = lint_verdict(rc, lint_cmd, out + err) - if status == FAIL and fix: - fixed = run_fix([lang], project_root, timeout=timeout + 60, files=files)[0] - if fixed.get("fixed") and not fixed.get("dry_run"): - for lint_cmd in commands: - rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) - if rc: - break - status, reason = lint_verdict(rc, lint_cmd, out + err) - results.append(result(lang, status, reason, exit_code=rc, - unverified=reason if status == UNVERIFIED else "", - command=lint_cmd, java_plan=java_plan, - stderr_tail=err[-2000:], stdout_tail=out[-1000:])) - if status != PASS and (err or out): - combined = "" - if out: - combined += out - if err: - if combined and not combined.endswith("\n"): - combined += "\n" - combined += err - log_entries.append((lang, rc, combined)) - if log_dir is not None and log_entries: - log_dir.mkdir(parents=True, exist_ok=True) - log_path = log_dir / ".codeguard-last.log" - chunks = [] - for lang, rc, content in log_entries: - block = f"===== {lang} (exit={rc}) =====\n{content}" - if not block.endswith("\n"): - block += "\n" - chunks.append(block) - try: - log_path.write_text("\n".join(chunks), encoding="utf-8") - except OSError: - log_path = None - if log_path is not None: - for r in results: - if not r["passed"]: - r["log_path"] = str(log_path.resolve()) - return results - - -def run_fix(languages: list[str], project_root: Path, - *, timeout: int = 120, dry_run: bool = False, - files: list[str] | None = None) -> list[dict]: - """对每个语言跑 format;命令缺失归失败;dry_run 仅打印 would-run 行。 +from codeguard.language_check import LANG_COMMANDS, _run, run_check, run_fix - `files` 非空 = 仅修复这些文件(fix.py 缺省 delta 模式);某语言在改动 - 里没有文件 → 该语言整行跳过(skipped 标记),绝不动仓里其它存量文件。 - """ - if files is not None and any( - (project_root / f).is_symlink() or not (project_root / f).resolve().is_relative_to(project_root.resolve()) - for f in files): - return [{"language": lang, "fixed": False, "status": "UNVERIFIED", "exit_code": 1, - "note": "修复范围含符号链接或仓外路径,未执行 formatter"} for lang in languages] - results: list[dict] = [] - for lang in languages: - cmd_def = LANG_COMMANDS.get(lang) - if not cmd_def: - results.append({"language": lang, "fixed": False, "error": "no command defined"}) - continue - lang_files: list[str] | None = None - if files is not None: - lang_files = [f for f in files if detect_language(f, project_root) == lang] - # .zsh 剥离(与门禁同一份认知):shfmt 只支持 POSIX shell/bash, - # 对 zsh 文件 -w 会重排方言语法造成损坏;跳过并明示修复指令。 - zsh_files = [f for f in lang_files if f.endswith(".zsh")] - if zsh_files: - lang_files = [f for f in lang_files if not f.endswith(".zsh")] - results.append({"language": lang, "fixed": False, "skipped": True, - "status": "SKIPPED", "exit_code": 0, - "note": (f"{len(zsh_files)} 个 zsh 文件跳过 formatter" - "(shfmt 不支持 zsh;文件头添加" - " `# shellcheck shell=bash` 注释后" - " shellcheck 门禁即可正常送检)")}) - if not lang_files: - continue - fmt = cmd_def.get("format") - if files is not None and not cmd_def.get("append_files", True): - results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", - "note": "项目级 formatter 无法限制到改动文件;需显式 --all", "exit_code": 1}) - continue - if not fmt or dry_run: - results.append({"language": lang, "fixed": False, "status": "PLANNED", - "dry_run": dry_run, "command": fmt or [], "exit_code": 1, - "note": "尚未执行修复" if dry_run else "未配置 formatter"}) - continue - if "{file}" in " ".join(fmt): - if lang_files is None: - results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", - "note": "formatter 需要明确文件列表", "exit_code": 1}) - continue - commands = [scope_cmd(fmt, project_root, single_file=f) for f in lang_files] - else: - commands = [scope_cmd(fmt, project_root, files=lang_files, - full_excludes=(lang_files is None), - append_files=cmd_def.get("append_files", True))] - for cmd in commands: - rc, _out, err = _run(cmd, cwd=project_root, timeout=timeout) - if rc: - break - results.append({ - "language": lang, - "fixed": rc == 0, - "exit_code": rc, - "stderr_tail": err[-2000:] if err else "", - }) - return results +__all__ = ["LANG_COMMANDS", "_run", "run_check", "run_fix"] diff --git a/scripts/scope.py b/scripts/scope.py index 88c885b..65a5512 100644 --- a/scripts/scope.py +++ b/scripts/scope.py @@ -23,31 +23,18 @@ import subprocess from pathlib import Path -# 构建产物/依赖快照的**单一事实源**——"哪些目录不需要检测"由这里回答。 -# 语义边界:gate_lib.GUARD_EXCLUDE_DIRS 管"能否入库"(= 本清单 + IDE 目录, -# 从本清单派生),本清单管"扫不扫";两侧永不漂移(改这里即两侧同变)。 -# 为什么必须完备(两类实测永久红):target/ 下 maven-javadoc 生成的 -# javadoc.sh 让 shell 门禁必红(java 与 shell 两个 gate 步骤互相矛盾); -# target/site/jacoco 与 target/apidocs 的生成 HTML 让 html 门禁必红——生成物 -# 不会被"修复",下次构建就重写,扫描它们得到的永远是与仓库内容无关的红。 -# vendor/upstream 是供应链依赖快照,内容不可编辑,同理只产"永久红"。 -# 生效通道必须四条全覆盖(缺一条就漏一类门禁): -# 1) ruff --exclude(全量) -# 2) find 型 gate 注入 -not -path(-print0/-exec/无 NUL 锚三形态) -# 3) PostToolUse 对产物路径静默跳过(写 target/ 的生成物不检查) -# 4) changed_files 过滤产物路径(force-add 的 target 文件不进 delta 面) -FULL_SCAN_EXCLUDES = ( - ".venv", "venv", "env", "node_modules", "vendor", "upstream", - "build", "dist", "target", "out", ".next", ".nuxt", ".gradle", - "coverage", ".terraform", ".tox", ".eggs", "htmlcov", ".turbo", - ".parcel-cache", "__pycache__", ".pytest_cache", ".mypy_cache", - ".ruff_cache", - # Agent/宿主工具工作目录:会话实测 .mimosa/(含源码快照)与 .worktrees/ - # (git 子工作树)曾被 git add -A 带进暂存区——既不该入库,也不该被全量 - # 扫描重复检查(子工作树是同一份源码,扫两遍 = 双份误报)。 - ".mimosa", ".worktrees", ".code-review-graph", ".kimi-code", - ".zcode", ".codex-plugin", ".agents", -) +from codeguard.path_policy import FULL_SCAN_EXCLUDES, is_build_artifact + +__all__ = [ + "DEFAULT_LANES", + "FULL_SCAN_EXCLUDES", + "changed_files", + "child_git_repo_names", + "is_build_artifact", + "is_git_repo", + "ruff_config_args", + "scope_cmd", +] def is_git_repo(root: str | Path) -> bool: @@ -75,16 +62,6 @@ def child_git_repo_names(root: str | Path) -> list[str]: return out -def is_build_artifact(path: str | Path) -> bool: - """路径是否落在构建产物/依赖快照目录下(任一段命中即算)。 - - 单一事实源的谓词形态,供 PostToolUse(生成物不检查)与 changed_files - (产物不进 delta 面)复用同一份认知,避免两处各写一遍目录名。 - 注意不能用 lstrip("./")——会把 `.tox` 的点一起剥掉(实测踩点)。 - """ - parts = [seg for seg in str(path).replace("\\", "/").split("/") - if seg not in ("", ".")] - return any(seg in FULL_SCAN_EXCLUDES for seg in parts) _RUFF_SNIPPET = Path(__file__).resolve().parents[1] / "linters" / "ruff" / "ruff.toml" @@ -218,8 +195,13 @@ def scope_cmd( if full_excludes: for d in scan_excludes: out += ["--exclude", d] - elif full_excludes: - out = [_inject_find_excludes(c, scan_excludes) if isinstance(c, str) else c for c in out] + elif full_excludes and Path(out[0]).name in {"bash", "sh", "dash", "zsh", "ksh"}: + # 只改 Shell 的命令体;普通 argv/Python -c 中的 "find " 是字面数据,不能注入语法。 + for index in range(1, len(out) - 1): + flag = out[index] + if flag.startswith("-") and flag[1:].isalpha() and "c" in flag[1:]: + out[index + 1] = _inject_find_excludes(out[index + 1], scan_excludes) + break scan_idx = [i for i, tok in enumerate(out[1:], 1) if tok == "." or "**" in tok] if targets and scan_idx: flags = [tok for i, tok in enumerate(out) if i not in scan_idx and not tok.startswith("#")] diff --git a/scripts/user_config.py b/scripts/user_config.py index 23b52c3..22bc154 100644 --- a/scripts/user_config.py +++ b/scripts/user_config.py @@ -1,81 +1,9 @@ -"""scripts/user_config.py:用户态与项目级配置加载。 - -暴露三个符号:`load_user_config`(~/.zcode/settings.local.yaml 全局配置)、 -`load_project_overrides`(项目根 codeguard.json 自定义映射)、 -`get_overrides`(带内存缓存的轻量包装)。 -无 subprocess 依赖,被 hooks 与 tests 共享。 - -为保持向后兼容,`scripts/detect_lang.py` 顶部 re-export 这三个符号,外部 -调用方继续 `from detect_lang import load_user_config` 即可。 -""" -from __future__ import annotations - -import json -import re -from pathlib import Path - - -def load_user_config() -> dict: - """从 ~/.zcode/settings.local.yaml 读插件配置(简化版:正则抠块,不依赖 yaml 库)""" - cfg_path = Path.home() / ".zcode" / "settings.local.yaml" - defaults = { - "enabled_languages": [], - "strict_mode": True, - "auto_fix_on_save": True, - # 默认 300s:Maven 冷缓存 install -DskipTests 普遍超 2 分钟(旧 120s - # 实测把超时误报为阻断)。AI 可在 ~/.zcode/settings.local.yaml 覆盖。 - "lint_timeout_seconds": 300, - } - if not cfg_path.exists(): - return defaults - try: - text = cfg_path.read_text() - except OSError: - return defaults - cfg = dict(defaults) - m = re.search(r"codeguard:\s*(\{.*?\n\})", text, re.DOTALL) - if not m: - return cfg - block = m.group(1) - for key in ("strict_mode", "auto_fix_on_save"): - mm = re.search(rf"{key}:\s*(true|false)", block) - if mm: - cfg[key] = mm.group(1) == "true" - mm = re.search(r"lint_timeout_seconds:\s*(\d+)", block) - if mm: - cfg["lint_timeout_seconds"] = int(mm.group(1)) - mm = re.search(r"enabled_languages:\s*\[([^\]]*)\]", block) - if mm: - items = [s.strip().strip("\"'") for s in mm.group(1).split(",") if s.strip()] - cfg["enabled_languages"] = items - return cfg - - -# === 项目根 codeguard.json 自定义扩展映射(借鉴 codegraph.json 设计) === -_OVERRIDES_CACHE: dict[str, dict] = {} - - -def load_project_overrides(project_root: str | Path) -> dict: - """读取项目根 codeguard.json 的 extensions/exclude 自定义映射""" - cfg = Path(project_root) / "codeguard.json" - if not cfg.exists(): - return {} - try: - data = json.loads(cfg.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - scope = data.get("gate_scope") - return { - "extensions": {k.lower(): v for k, v in (data.get("extensions") or {}).items()}, - "exclude": list(data.get("exclude") or []), - # 门禁扫描范围:"delta"(默认,只查本次改动涉及的文件——存量问题不拦新提交) - # 或 "repo"(全仓扫描;仍会剔除 vendor/build 等不可编辑目录) - "gate_scope": scope if scope in ("delta", "repo") else None, - } - - -def get_overrides(project_root: str | Path) -> dict: - key = str(Path(project_root).resolve()) - if key not in _OVERRIDES_CACHE: - _OVERRIDES_CACHE[key] = load_project_overrides(project_root) - return _OVERRIDES_CACHE[key] +"""用户配置旧导入面的兼容导出;实现仅在 codeguard.config。""" +from codeguard.config import ( + ConfigurationError, + get_overrides, + load_project_overrides, + load_user_config, +) + +__all__ = ["ConfigurationError", "get_overrides", "load_project_overrides", "load_user_config"] diff --git a/scripts/validate_languages_json.py b/scripts/validate_languages_json.py index a6bfbe9..a23a537 100644 --- a/scripts/validate_languages_json.py +++ b/scripts/validate_languages_json.py @@ -19,207 +19,10 @@ import argparse import json -import re import sys from pathlib import Path -KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") - - -def _check_top_level(reg: dict) -> list[str]: - errs: list[str] = [] - if not isinstance(reg.get("version"), int) or reg["version"] < 1: - errs.append("all:top-level: 'version' missing or not positive int") - langs = reg.get("languages") - if not isinstance(langs, list) or not langs: - errs.append("all:top-level: 'languages' missing or empty") - levels = reg.get("status_levels") - for required in ("stable", "beta", "planned"): - if not isinstance(levels, dict) or required not in levels: - errs.append(f"all:top-level: status_levels missing '{required}'") - return errs - - -def _check_unique_ids(reg: dict) -> list[str]: - errs: list[str] = [] - seen: set[str] = set() - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id") - if lid in seen: - errs.append(f"all:ids: duplicate id '{lid}'") - if isinstance(lid, str): - seen.add(lid) - return errs - - -def _check_id_kebab(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id") - if isinstance(lid, str) and not KEBAB_RE.match(lid): - errs.append(f"{lid}:id: not kebab-case") - return errs - - -def _check_status(reg: dict) -> list[str]: - errs: list[str] = [] - levels = reg.get("status_levels", {}) - valid = set(levels.keys()) - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - st = lang.get("status") - if st not in valid: - errs.append(f"{lid}:status: '{st}' not in status_levels {sorted(valid)}") - return errs - - -def _check_ext_or_marker_collision(reg: dict, key: str, label: str) -> list[str]: - errs: list[str] = [] - owners: dict[str, str] = {} - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - if lang.get("status") not in ("stable", "beta"): - continue - lid = lang.get("id", "") - for ext in lang.get(key, []) or []: - if ext in owners and owners[ext] != lid: - errs.append(f"all:{key}s: '{ext}' owned by '{owners[ext]}' and '{lid}'") - owners[ext] = lid - return errs - - -def _check_extensions_dot_prefix(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - for ext in lang.get("extensions", []) or []: - if not isinstance(ext, str) or not ext.startswith("."): - errs.append(f"{lid}:extensions: '{ext}' must start with '.'") - return errs - - -def _check_lint_format_shape(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - for field in ("lint", "format"): - val = lang.get(field) - if val is None: - continue - if not isinstance(val, list) or not all(isinstance(s, str) and s for s in val): - errs.append(f"{lid}:{field}: must be list of non-empty strings") - return errs - - -def _check_status_command_presence(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - st = lang.get("status") - if st in ("stable", "beta"): - lint = lang.get("lint") or [] - fmt = lang.get("format") or [] - if not lint and not fmt: - errs.append(f"{lid}:commands: stable/beta requires non-empty lint or format") - elif st == "planned": - lint = lang.get("lint") or [] - fmt = lang.get("format") or [] - if lint or fmt: - errs.append(f"{lid}:commands: planned must leave lint/format empty") - return errs - - -def _check_requires_config(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - rc = lang.get("requiresConfig") - if rc is None: - continue - if not isinstance(rc, list) or not all(isinstance(s, str) for s in rc): - errs.append(f"{lid}:requiresConfig: must be list of strings") - return errs - - -def _check_linter_config_files(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - cf = lang.get("linter_config_files") - if cf is None: - continue - if not isinstance(cf, list) or not all(isinstance(s, str) for s in cf): - errs.append(f"{lid}:linter_config_files: must be list of strings") - return errs - - -def _check_since(reg: dict) -> list[str]: - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - st = lang.get("status") - since = lang.get("since") - if st in ("stable", "beta") and not isinstance(since, str) or (st in ("stable", "beta") and since == ""): - errs.append(f"{lid}:since: stable/beta requires non-empty 'since' string") - return errs - - -def _check_gate_severity_pin(reg: dict) -> list[str]: - """gate 命令里调用支持 severity 的工具时必须钉扎级别。 - - 背景(0.8.x 前的实测坑):同一份 shell gate 在部分发布版本里没带 - `--severity=warning`,info/style 级发现(SC2059/SC2295)也会阻塞提交; - 缺了这个钉扎,门禁结论取决于用户 cache 里恰好是哪个发布版本。 - shellcheck 支持 --severity;markdownlint 走 config 文件,不在此列。 - """ - errs: list[str] = [] - for lang in reg.get("languages", []): - if not isinstance(lang, dict): - continue - lid = lang.get("id", "") - for field in ("gate", "lint"): - joined = " ".join(lang.get(field) or []) - if "shellcheck" in joined and "--severity=" not in joined: - errs.append(f"{lid}:{field}: shellcheck must pin --severity= (drift guard)") - return errs - - -def check(registry: dict) -> list[str]: - """Return all schema and consistency errors; empty list means valid.""" - errs: list[str] = [] - errs += _check_top_level(registry) - errs += _check_unique_ids(registry) - errs += _check_id_kebab(registry) - errs += _check_status(registry) - errs += _check_ext_or_marker_collision(registry, "extensions", "extension") - errs += _check_ext_or_marker_collision(registry, "markers", "marker") - errs += _check_extensions_dot_prefix(registry) - errs += _check_lint_format_shape(registry) - errs += _check_status_command_presence(registry) - errs += _check_requires_config(registry) - errs += _check_linter_config_files(registry) - errs += _check_since(registry) - errs += _check_gate_severity_pin(registry) - return errs +from codeguard.registry_schema import check def main() -> int: @@ -237,7 +40,13 @@ def main() -> int: print(f"ERROR: failed to read/parse {path}: {exc}", file=sys.stderr) return 2 - langs = reg.get("languages", []) if isinstance(reg, dict) else [] + errs = check(reg) + if errs: + for err in errs: + print(f"ERROR: {err}") + print(f"SUMMARY: {len(errs)} errors", file=sys.stderr) + return 1 + langs = reg["languages"] counts: dict[str, int] = {} for lang in langs: if isinstance(lang, dict): @@ -245,14 +54,8 @@ def main() -> int: print(f"INFO: languages.json contains {len(langs)} entries " f"({counts.get('stable', 0)} stable, {counts.get('beta', 0)} beta, {counts.get('planned', 0)} planned)") - errs = check(reg) - if not errs: - print("INFO: 11 schema rules passed") - return 0 - for err in errs: - print(f"ERROR: {err}") - print(f"SUMMARY: {len(errs)} errors", file=sys.stderr) - return 1 + print("INFO: 11 schema rules passed") + return 0 if __name__ == "__main__": diff --git a/scripts/verdict.py b/scripts/verdict.py index 553cfe1..c582483 100644 --- a/scripts/verdict.py +++ b/scripts/verdict.py @@ -1,111 +1,17 @@ -"""共享结果语义:执行状态不等于代码结论,原始退出码始终保留。""" -from __future__ import annotations - -import re -from pathlib import Path - -PASS, FAIL, UNVERIFIED, SKIPPED, PLANNED = "PASS", "FAIL", "UNVERIFIED", "SKIPPED", "PLANNED" -# 工具链/环境自身不兼容的特征——不是代码违规,归 UNVERIFIED 绝不拦提交。 -# 每条都来自会话实测原文(hermes/easydoc 两仓 2026-09-22 误拦复现): -# - Maven 3 读不了 POM 4.1.0('modelVersion' ... is newer than ...) -# - JDK 26 不支持 --release 8(error: release version 1.8 not supported) -# - javac target 与当前 JDK 不匹配(error: invalid target release) -# - 字节码高于当前 JVM(UnsupportedClassVersionError / class file version) -# - 依赖构件不可解析、goal 用法错、配置损坏 -_ENV_ERROR = re.compile( - r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException|" - r"Cannot find module|ModuleNotFoundError|Unknown lifecycle phase|" - r"Could not resolve all|Could not find or load main class|invalid configuration|" - r"'modelVersion' of '[^']*' is newer than the versions supported|" - r"requires a newer version of Maven|" - r"error: release version [\d.]+ not supported|release version [\d.]+ not supported|" - r"error: invalid target release|invalid target release:|" - r"UnsupportedClassVersionError|class file version|" - r"has been compiled by a more recent version", - re.IGNORECASE, +"""旧导入路径的兼容层;判定实现仅存在于 codeguard.verdict。""" +from codeguard.verdict import ( + FAIL, + PASS, + PLANNED, + SKIPPED, + UNVERIFIED, + exit_status, + finding_signatures, + lint_verdict, + result, ) - -def _strip_dialect_blocks(output: str) -> str: - """剥离 ShellCheck 方言不支持(SC1071)的诊断块,保留同批其余诊断。 - - shellcheck 输出按 `In line N:` 分块;SC1071 是"工具不支持该方言" - 的固有限制,整块丢弃。不得因此掩盖同批 .sh 的真问题——剥离后仍有剩余 - 诊断就按剩余内容重判。 - """ - blocks: list[list[str]] = [] - current: list[str] = [] - for ln in output.splitlines(): - if ln.startswith("In ") and " line " in ln and current: - blocks.append(current) - current = [ln] - else: - current.append(ln) - if current: - blocks.append(current) - kept = [ln for blk in blocks if not any("SC1071" in x for x in blk) for ln in blk] - return "\n".join(kept).strip() - - -_CODE_RE = re.compile(r"\b([A-Z]{1,4}\d{2,4})\b") - - -def finding_signatures(output: str) -> set[str]: - """从 linter 输出提取可跨运行比较的"发现签名"集合。 - - 优先用工具规则码(SC2086/F401/…);无规则码的工具回退"归一化描述行" - (剥 file:line:col 前缀与数字)——同一发现在改动前后必须签名一致, - 基线比对才有意义。提不出签名时返回空集,由调用方拒绝豁免。 - """ - sigs = set(_CODE_RE.findall(output)) - if sigs: - return sigs - for ln in output.splitlines(): - s = ln.strip() - if not s or s.startswith(("In ", "^", "~", "#!")): - continue - norm = re.sub(r"[A-Za-z0-9_./\\-]+\.[A-Za-z0-9]+:\d+(:\d+)?", "", s) - norm = re.sub(r"\d+", "#", norm).strip() - if norm: - sigs.add(norm) - return sigs - - -def lint_verdict(rc: int, command: list[str], output: str = "") -> tuple[str, str]: - """按工具契约归一结论;只允许明确的成功返回 PASS。""" - if rc == 0: - return PASS, "检查执行成功" - # ShellCheck 不支持 zsh/dash 之外的方言(SC1071 是 error 级固有限制): - # 先剥离这些诊断块——只有剥离后**无剩余**才算"方言能力边界"归 UNVERIFIED; - # 仍有剩余诊断就按剩余内容判,不许掩盖同批真问题。 - if "SC1071" in output: - rest = _strip_dialect_blocks(output) - if not rest: - return UNVERIFIED, "ShellCheck 不支持该脚本方言(SC1071)" - output = rest - if rc in (124, 127) or rc < 0 or _ENV_ERROR.search(output): - reason = {124: "检查超时", 127: "工具不存在"}.get(rc, "工具链执行异常") - return UNVERIFIED, reason - tool = Path(command[0]).name if command else "" - # pylint 的 2 为 error 位;不得沿用 ESLint/ruff 的配置错误语义。 - if tool == "pylint": - return (UNVERIFIED, "pylint 用法错误") if rc & 32 else (FAIL, "pylint 发现违规") - if rc == 1 or (tool == "cargo" and rc == 101): - return FAIL, "检查发现违规" - return UNVERIFIED, f"工具退出 {rc},没有可确认的检查结论" - - -def result(language: str, status: str, reason: str, *, exit_code: int = 0, **details) -> dict: - """兼容 passed,同时携带不会在 CLI/MCP 间丢失的明确状态。""" - return {"language": language, "status": status, "reason": reason, - "passed": status == PASS, "exit_code": exit_code, **details} - - -def exit_status(results: list[dict]) -> int: - """聚合优先级:发现违规 > 无法验证 > 已完成/无须检查。""" - statuses = {r.get("status", PASS if r.get("passed") else FAIL) for r in results} - if FAIL in statuses: - return 2 - if not statuses or statuses & {UNVERIFIED, PLANNED}: - return 1 - return 0 +__all__ = [ + "FAIL", "PASS", "PLANNED", "SKIPPED", "UNVERIFIED", + "exit_status", "finding_signatures", "lint_verdict", "result", +] diff --git a/tests/run_all.py b/tests/run_all.py index a56ccea..5ee1150 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -289,11 +289,10 @@ def test_unit(): ok("probe shellcheck 通过", probe_toolchain({"lint": ["shellcheck"]})[0] is True) # run_gate:{file} 无 gate 的语言必须归 skipped(防字面量当文件名) - gate_lib.detect_languages = lambda root: ["sql"] - gate_lib.LANG_COMMANDS["sql"] = {"lint": ["sqlfluff", "lint", "{file}"], "install_hint": "pip install sqlfluff"} - gate_lib.project_uses_linter = lambda c, r: True - gate_lib.probe_toolchain = lambda c, timeout=10: (True, "") - failures, skipped = gate_lib.run_gate(Path(tmp), {}) + 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}") @@ -325,19 +324,13 @@ def test_unit(): ok("markdown 探活语义正确(装了→可用;没装→不可用且原因含探活退出)", md_available or "探活" in _dl.probe_toolchain(cfg_md)[1]) bare = Path(tempfile.mkdtemp()) - _dl._TOOL_CACHE.clear() - # 前面 {file} 用例遗留的 monkeypatch 会让 requiresConfig 分支永不可达——先还原真身 - gate_lib.detect_languages = lambda root: ["markdown", "yaml"] - gate_lib.project_uses_linter = _dl.project_uses_linter - gate_lib.probe_toolchain = _dl.probe_toolchain try: - f1, s1 = _run_gate(bare, {}) + 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,配置在→真跑 - _dl._TOOL_CACHE.clear() - f2, s2 = _run_gate(bare, {}) + 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), @@ -350,7 +343,6 @@ def test_unit(): ok("已接入→yaml 仍未接入(无 .yamllint 配置)", any(x.startswith("yaml") and "未接入" in x for x in s2)) finally: - _dl._TOOL_CACHE.clear() shutil.rmtree(bare, ignore_errors=True) # ── C2-3.5:未声明前置条件的语言不受影响(typescript 原有声明保持)── @@ -531,41 +523,46 @@ def test_field_regressions(): def test_perf(): print("\n[5] 性能与健壮性(缓存/去重/兜底/冷却)") - # ── 探活去重:eslint 系 4 语言只真探 1 次 ── - import detect_lang as dl - calls = [] - orig = dl._run_probe_cmd - dl._run_probe_cmd = lambda probe, t: (calls.append(tuple(probe)), orig(probe, t))[1] - try: - for lid in ("typescript", "vue", "svelte", "astro"): - dl.probe_toolchain(dl.LANG_COMMANDS[lid]) - finally: - dl._run_probe_cmd = orig - ok("探活去重(4 语言共享 eslint → 1 次执行)", len(calls) == 1, f"实际 {len(calls)} 次") + # ── 同批共享探活真实执行一次;不依赖机器上安装了哪个 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_lib._run_gate_uncached + orig_uncached = gate_application.run_batch def counting(root, cfg, langs, **kw): exec_count.append(1) return orig_uncached(root, cfg, langs, **kw) - gate_lib._run_gate_uncached = counting + gate_application.run_batch = counting try: - f1, _s1 = gate_lib.run_gate(repo, {}) + 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, {}) + 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, {}) + gate_lib.run_gate(repo, {}, languages=["shell"]) ok("暂存区变化即缓存失效(重跑)", len(exec_count) == 2, f"执行 {len(exec_count)} 次") finally: - gate_lib._run_gate_uncached = orig_uncached + gate_application.run_batch = orig_uncached # ── 异常兜底:钩子内部错误 fail-open(exit 0,无 traceback) ── code = ( @@ -586,6 +583,7 @@ def counting(root, cfg, langs, **kw): 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): @@ -594,9 +592,9 @@ def counting_run(cmd, *a, **kw): return real_run(cmd, *a, **kw) subprocess.run = counting_run try: - dl.ensure_user_path(from_login_shell=True) + ensure_user_path(from_login_shell=True) n1 = len(spawns) - dl.ensure_user_path(from_login_shell=True) + ensure_user_path(from_login_shell=True) ok("PATH 缓存:第二次零 spawn", len(spawns) == n1, f"spawn {len(spawns)} 次") finally: subprocess.run = real_run @@ -630,6 +628,8 @@ def run_cve(args: list[str], cwd: Path): 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()) @@ -679,26 +679,26 @@ def test_cve(): ok("HIGH 默认两档", cve.severities_at_and_above("HIGH") == ["HIGH", "CRITICAL"]) ok("CRITICAL 单档", cve.severities_at_and_above("CRITICAL") == ["CRITICAL"]) captured = {} - orig_run = cve.run - cve.run = lambda cmd, cwd, timeout=600: (captured.update(cmd=cmd), (0, "", ""))[1] + 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.run = orig_run + cve_scanners.run = orig_run ok("trivy 收到完整级别集合", - captured.get("cmd", []).count("MEDIUM,HIGH,CRITICAL") == 1, str(captured.get("cmd"))) + 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.scan_maven - cve.scan_maven = lambda root, th, _s=seen: (_s.update(th=th), {"ecosystem": "maven", "tool": "fake", "exit": 0})[1] + 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.scan_maven = orig_mv + cve_app.scan_maven = orig_mv # ── C1-5.6:四个原生生态派发不回归(mock 扫描器,不依赖真实工具/网络)── for eco, marker in (("maven", "pom.xml"), ("node", "package.json"), @@ -708,7 +708,7 @@ def test_cve(): 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})[1]) + 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)] diff --git a/tests/test_architecture.py b/tests/test_architecture.py new file mode 100644 index 0000000..fa0d437 --- /dev/null +++ b/tests/test_architecture.py @@ -0,0 +1,112 @@ +"""架构规则是可执行门禁:在临时源码树注入反向依赖与循环,必须被拒绝。""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CHECKER = ROOT / "scripts" / "check_architecture.py" + + +class ArchitectureTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-architecture-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + + def put(self, path, source): + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source, encoding="utf-8") + + def check(self, root=None): + self.assertTrue(CHECKER.is_file(), "需要实际运行的架构门禁,而非文档约定") + return subprocess.run([sys.executable, str(CHECKER), "--root", str(root or self.root)], + capture_output=True, text=True, timeout=10, check=False) + + def test_current_repository_obeys_declared_dependencies(self): + result = self.check(ROOT) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_pure_model_cannot_import_runtime_even_inside_function(self): + self.put("scripts/codeguard/models.py", "def hidden():\n import subprocess\n") + result = self.check() + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn("codeguard/models.py:2", result.stdout) + self.assertIn("subprocess", result.stdout) + + def test_core_cannot_import_hook_or_optional_host_sdk(self): + for target in ("gate_lib", "hooks.post_tool_lint", "mcp.server.fastmcp"): + with self.subTest(target=target): + self.put("scripts/codeguard/execution.py", f"import {target}\n") + result = self.check() + self.assertEqual(1, result.returncode, result.stdout + result.stderr) + self.assertIn(target, result.stdout) + + def test_relative_and_from_package_imports_are_resolved(self): + self.put("scripts/codeguard/models.py", "from . import execution\n") + self.put("scripts/codeguard/execution.py", "from codeguard import models\n") + result = self.check() + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn("cycle", result.stdout) + self.assertIn("codeguard.models", result.stdout) + self.assertIn("codeguard.execution", result.stdout) + + def test_legacy_cycles_are_rejected_without_importing_project_code(self): + self.put("scripts/first.py", "import second\nraise RuntimeError('must not execute')\n") + self.put("scripts/second.py", "from first import value\n") + result = self.check() + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn("cycle", result.stdout) + self.assertNotIn("RuntimeError", result.stderr) + + def test_new_core_module_requires_explicit_dependency_policy(self): + self.put("scripts/codeguard/unclassified.py", "import os\n") + result = self.check() + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn("unclassified", result.stdout) + + def test_valid_internal_dependencies_and_stdlib_are_allowed(self): + self.put("scripts/codeguard/models.py", "from dataclasses import dataclass\n") + self.put("scripts/codeguard/execution.py", "from .models import ProcessResult\nimport subprocess\n") + self.put("scripts/codeguard/planning.py", "from scope import scope_cmd\nfrom .models import CheckPlan\n") + self.put("scripts/scope.py", "from pathlib import Path\n") + self.put("hooks/gate_lib.py", "from codeguard.execution import execute\n") + result = self.check() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_models_import_without_loading_runtime_or_host_modules(self): + result = subprocess.run([sys.executable, "-I", "-c", + ("import sys;sys.path.insert(0,sys.argv[1]);" + "import codeguard.models;" + "assert not any(m in sys.modules for m in " + "('subprocess','gate_lib','detect_lang','mcp','codeguard.execution'))"), + str(ROOT / "scripts")], capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(0, result.returncode, result.stderr) + + def test_language_discovery_import_does_not_load_process_or_host_services(self): + result = subprocess.run([sys.executable, "-I", "-c", + ("import sys;sys.path.insert(0,sys.argv[1]);" + "from codeguard.discovery import detect_languages;" + "assert not any(m in sys.modules for m in " + "('subprocess','gate_lib','mcp','codeguard.execution','codeguard.toolchain'))"), + str(ROOT / "scripts")], capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(0, result.returncode, result.stderr) + + def test_report_version_matches_installed_plugin_manifest(self): + expected = json.loads((ROOT / ".zcode-plugin/plugin.json").read_text(encoding="utf-8"))["version"] + result = subprocess.run([sys.executable, "-I", "-c", + ("import sys;sys.path.insert(0,sys.argv[1]);" + "from codeguard.reporting import CODEGUARD_VERSION;" + "print(CODEGUARD_VERSION)"), + str(ROOT / "scripts")], capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(expected, result.stdout.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cache_identity.py b/tests/test_cache_identity.py new file mode 100644 index 0000000..3e82ca6 --- /dev/null +++ b/tests/test_cache_identity.py @@ -0,0 +1,238 @@ +"""真实 Git 与检查进程覆盖缓存身份;不以缓存文件存在作为行为证明。""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from functools import partial +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "hooks")] +import gate_lib +import post_tool_lint +import user_prompt_validator +from codeguard import gate as gate_application +from detect_lang import LANG_COMMANDS +from user_config import get_overrides + + +class CacheIdentityTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-cache-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.repo = self.root / "repo" + self.repo.mkdir() + self.git("init", "-q") + self.git("config", "user.name", "fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.git("config", "core.hooksPath", "/dev/null") + (self.repo / "a.py").write_text("value = 0\n") + self.git("add", "a.py") + self.git("commit", "-qm", "base") + self.addCleanup(patch.stopall) + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.root / "home")}).start() + patch.object(gate_application, "_gate_cache_path", return_value=self.root / "cache.json").start() + + def git(self, *args, cwd=None): + return subprocess.run(["git", *args], cwd=cwd or self.repo, check=True, + text=True, capture_output=True).stdout.strip() + + def checker(self, body): + script = self.root / "checker.py" + counter = self.root / "count" + script.write_text("from pathlib import Path\n" + f"p=Path({str(counter)!r})\n" + "n=int(p.read_text())+1 if p.exists() else 1\np.write_text(str(n))\n" + body) + definition = {"lint": [sys.executable, str(script)], "probe": [sys.executable, "--version"]} + patch.dict(LANG_COMMANDS, {"python": definition}).start() + (self.repo / "a.py").write_text("value = 1\n") + return counter + + def run_gate(self, cfg=None, **kwargs): + return gate_lib.run_gate(self.repo, cfg or {}, ["python"], **kwargs) + + def test_equal_size_equal_mtime_edit_invalidates_actual_result(self): + count = self.checker("import sys\nraise SystemExit(1 if '= 2' in Path('a.py').read_text() else 0)\n") + self.assertEqual(([], []), self.run_gate()) + path = self.repo / "a.py" + before = path.stat() + path.write_text("value = 2\n") + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + failures, _ = self.run_gate() + self.assertEqual("2", count.read_text()) + self.assertEqual("python", failures[0][0]) + + def test_user_configuration_change_executes_again(self): + count = self.checker("raise SystemExit(0)\n") + self.run_gate({"lint_timeout_seconds": 10}) + self.run_gate({"lint_timeout_seconds": 20}) + self.assertEqual("2", count.read_text()) + + def test_unknown_is_not_reused_after_checker_recovers(self): + count = self.checker("raise SystemExit(2 if n == 1 else 0)\n") + self.assertTrue(self.run_gate()[1]) + self.assertEqual(([], []), self.run_gate()) + self.assertEqual("2", count.read_text()) + + def test_project_override_refreshes_in_same_process_even_with_same_stat(self): + path = self.repo / "codeguard.json" + path.write_text('{"gate_scope":"delta","exclude":["old"]}') + self.assertEqual(["old"], get_overrides(self.repo)["exclude"]) + before = path.stat() + path.write_text('{"gate_scope":"delta","exclude":["new"]}') + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + self.assertEqual(["new"], get_overrides(self.repo)["exclude"]) + + def test_linked_worktree_index_content_is_in_identity(self): + other = self.root / "linked" + self.git("worktree", "add", "-q", "--detach", str(other)) + target = other / "a.py" + target.write_text("value = 1\n") + self.git("add", "a.py", cwd=other) + target.write_text("value = 9\n") + before = target.stat() + first = gate_lib._gate_cache_key(other, ["python"]) + target.write_text("value = 2\n") + self.git("add", "a.py", cwd=other) + target.write_text("value = 9\n") + os.utime(target, ns=(before.st_atime_ns, before.st_mtime_ns)) + self.assertNotEqual(first, gate_lib._gate_cache_key(other, ["python"])) + + def test_unmodified_inputs_reuse_soft_result_but_never_exact_gate(self): + count = self.checker("raise SystemExit(0)\n") + self.git("add", "a.py") + self.run_gate() + self.run_gate() + self.assertEqual("1", count.read_text()) + self.run_gate(exact=True, lanes=("staged",)) + self.assertEqual("2", count.read_text()) + + def test_future_dated_entry_is_not_reused(self): + count = self.checker("raise SystemExit(0)\n") + self.run_gate() + path = self.root / "cache.json" + data = json.loads(path.read_text()) + data["ts"] += 100000 + path.write_text(json.dumps(data)) + self.run_gate() + self.assertEqual("2", count.read_text()) + + def test_save_does_not_mark_content_changed_during_check_as_completed(self): + count = self.checker("if n == 1: Path('a.py').write_text('value = 2\\n')\nraise SystemExit(0)\n") + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(self.repo / "a.py")}).start() + patch.object(post_tool_lint, "find_project_root", return_value=self.repo).start() + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": False}).start() + with contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + post_tool_lint.main() + self.assertEqual("2", count.read_text()) + + def test_prompt_dedup_cannot_hide_new_failure_for_same_text(self): + count = self.checker("import sys\nprint('F821: fixture')\n" + "raise SystemExit(1 if '= 2' in Path('a.py').read_text() else 0)\n") + patch.object(user_prompt_validator, "read_payload", return_value={ + "session_id": "same", "user_prompt": "提交 Python 代码"}).start() + patch.object(user_prompt_validator, "find_project_root", return_value=self.repo).start() + patch.object(user_prompt_validator, "load_user_config", return_value={}).start() + patch.object(user_prompt_validator, "notify", return_value=None).start() + with contextlib.redirect_stdout(io.StringIO()): + user_prompt_validator.main() + (self.repo / "a.py").write_text("value = 2\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + user_prompt_validator.main() + self.assertEqual("2", count.read_text()) + self.assertIn("F821", out.getvalue()) + + def test_budget_failure_disables_cache_not_checking(self): + from codeguard import fingerprint + count = self.checker("raise SystemExit(0)\n") + with patch.object(fingerprint, "repository_identity", + partial(fingerprint.repository_identity, max_bytes=1)): + self.assertEqual(([], []), self.run_gate()) + self.assertEqual(([], []), self.run_gate()) + self.assertEqual("2", count.read_text()) + + def test_malformed_cache_result_is_rechecked(self): + count = self.checker("raise SystemExit(0)\n") + self.run_gate() + path = self.root / "cache.json" + data = json.loads(path.read_text()) + data["failures"] = {"status": "PASS"} + path.write_text(json.dumps(data)) + self.assertEqual(([], []), self.run_gate()) + self.assertEqual("2", count.read_text()) + + def test_ignored_linter_configuration_invalidates_save_dedup(self): + self.checker("raise SystemExit(0)\n") + (self.repo / ".gitignore").write_text("ruff.toml\n") + config = self.repo / "ruff.toml" + config.write_text('line-length = 88\n') + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(self.repo / "a.py")}).start() + patch.object(post_tool_lint, "find_project_root", return_value=self.repo).start() + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": False}).start() + with contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + before = config.stat() + config.write_text('line-length = 99\n') + os.utime(config, ns=(before.st_atime_ns, before.st_mtime_ns)) + with contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + self.assertEqual("2", (self.root / "count").read_text()) + + def test_push_upstream_change_invalidates_identity(self): + self.git("branch", "baseline") + (self.repo / "a.py").write_text("value = 1\n") + self.git("add", "a.py") + self.git("commit", "-qm", "next") + branch = self.git("branch", "--show-current") + self.git("config", f"branch.{branch}.remote", ".") + self.git("config", f"branch.{branch}.merge", "refs/heads/baseline") + before = gate_lib._gate_cache_key(self.repo, ["python"], mode="push") + self.git("update-ref", "refs/heads/baseline", "HEAD") + self.assertNotEqual(before, gate_lib._gate_cache_key(self.repo, ["python"], mode="push")) + + def test_newline_filename_content_is_not_lost_in_git_listing(self): + target = self.repo / "line\nbreak.py" + target.write_text("value = 1\n") + self.git("add", target.name) + before = gate_lib._gate_cache_key(self.repo, ["python"]) + stamp = target.stat() + target.write_text("value = 2\n") + os.utime(target, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) + self.assertNotEqual(before, gate_lib._gate_cache_key(self.repo, ["python"])) + + def test_save_content_change_with_restored_mtime_reexecutes(self): + count = self.checker("raise SystemExit(0)\n") + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(self.repo / "a.py")}).start() + patch.object(post_tool_lint, "find_project_root", return_value=self.repo).start() + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": False}).start() + with contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + target = self.repo / "a.py" + stamp = target.stat() + target.write_text("value = 2\n") + os.utime(target, ns=(stamp.st_atime_ns, stamp.st_mtime_ns)) + with contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + self.assertEqual("2", count.read_text()) + + def test_missing_or_external_symlink_input_disables_cache(self): + from codeguard.fingerprint import repository_identity + outside = self.root / "external.py" + outside.write_text("value = 1\n") + (self.repo / "link.py").symlink_to(outside) + self.assertIsNone(repository_identity(self.repo)) + self.assertIsNone(repository_identity(self.root / "missing")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check_plans.py b/tests/test_check_plans.py new file mode 100644 index 0000000..2e4e9c1 --- /dev/null +++ b/tests/test_check_plans.py @@ -0,0 +1,171 @@ +"""计划丢失环境、提前假绿或修复后扩大范围,必须通过真实进程暴露。""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from importlib.util import find_spec +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "hooks")] + +import gate_lib +import post_tool_lint +import run_per_language +from codeguard import execution +from detect_lang import LANG_COMMANDS + + +class CheckPlanIntegrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-plans-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + + def put(self, path, content): + dest = self.root / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + return dest + + def test_java_selected_environment_reaches_each_command_without_mutating_host(self): + self.put("pom.xml", "4.0.0demo" + "app1") + self.put("src/A.java", "class A {}") + selected = str(self.root / "selected-jdk") + commands = [[sys.executable, "-c", + ("import os,pathlib; p=pathlib.Path('jdk.log'); " + "p.open('a').write(os.environ.get('JAVA_HOME', 'missing')+'\\n'); " + f"raise SystemExit({rc})")] for rc in (0, 1, 0)] + self.put("codeguard.json", json.dumps({"java": {"commands": commands}})) + with patch("codeguard.java_analysis.resolve_java_home", return_value=(selected, None)), \ + patch.dict(os.environ, {"JAVA_HOME": "host-jdk", "CODEGUARD_HOME": str(self.root / "state")}): + result = run_per_language.run_check(["java"], self.root, files=["src/A.java"])[0] + self.assertEqual("FAIL", result["status"]) + self.assertEqual(commands[1], result["command"]) + self.assertEqual("host-jdk", os.environ["JAVA_HOME"]) + self.assertEqual([selected, selected], (self.root / "jdk.log").read_text().splitlines()) + (self.root / "jdk.log").unlink() + failures, skipped = gate_lib._run_gate_uncached( + self.root, {}, ["java"], scope="delta", changed=["src/A.java"]) + self.assertEqual(1, len(failures)) + self.assertEqual([], skipped) + self.assertEqual([selected, selected], (self.root / "jdk.log").read_text().splitlines()) + + def test_mixed_zsh_fix_rechecks_the_shell_plan_without_touching_other_files(self): + self.put("a.sh", "GOOD") + self.put("b.sh", "BAD") + self.put("skip.zsh", "GOOD") + self.put("untouched.sh", "BAD") + # 不是模拟进程:两工具实际读写文件,日志证明命令顺序与完整复检范围。 + checker = self.put("checker.py", "import pathlib,sys\n" + "p=pathlib.Path(sys.argv[1])\n" + "pathlib.Path('checked.log').open('a').write(p.name+'\\n')\n" + "print(p.name)\nraise SystemExit(int('BAD' in p.read_text()))\n") + formatter = self.put("formatter.py", "import pathlib,sys\n" + "p=pathlib.Path(sys.argv[1])\n" + "p.write_text(p.read_text().replace('BAD','GOOD'))\n") + command = {"lint": [sys.executable, str(checker), "{file}"], + "format": [sys.executable, str(formatter), "{file}"]} + with patch.dict(LANG_COMMANDS, {"shell": command}): + result = run_per_language.run_check( + ["shell"], self.root, files=["a.sh", "b.sh", "skip.zsh"], fix=True)[0] + self.assertEqual("PASS", result["status"], result) + self.assertEqual(["a.sh", "b.sh", "a.sh", "b.sh", "skip.zsh"], + (self.root / "checked.log").read_text().splitlines()) + self.assertEqual("GOOD", (self.root / "b.sh").read_text()) + self.assertEqual("GOOD", (self.root / "skip.zsh").read_text()) + self.assertEqual("BAD", (self.root / "untouched.sh").read_text()) + + def test_plan_retains_attempts_and_stops_before_unexecuted_side_effect(self): + self.assertTrue(hasattr(execution, "execute_plan"), "缺少可复用的计划执行边界") + from codeguard.models import CheckPlan, Command + + commands = tuple(Command((sys.executable, "-c", body)) for body in ( + "print('first')", "print('failed'); raise SystemExit(1)", + "from pathlib import Path; Path('unexpected').touch()")) + plan = CheckPlan(self.root, "delta", commands) + outcome = execution.execute_plan(plan, 5) + self.assertEqual(["first\n", "failed\n"], [a.stdout for a in outcome.attempts]) + self.assertEqual(1, outcome.terminal.returncode) + self.assertFalse((self.root / "unexpected").exists()) + + def test_scopes_are_materialized_before_execution_and_empty_scope_never_runs(self): + self.assertIsNotNone(find_spec("codeguard.planning"), "缺少共享作用域规划器") + from codeguard.planning import scoped_plan + + argv = [sys.executable, "-c", "import sys; print('|'.join(sys.argv[1:]))", "."] + for mode, files, expected in (("repo", None, ".\n"), + ("delta", ["a.py", "b.py"], "a.py|b.py\n"), + ("save", ["a.py"], "a.py\n")): + with self.subTest(scope=mode): + plan = scoped_plan(argv, self.root, scope=mode, files=files) + if files is not None: + files.append("outside.py") + outcome = execution.execute_plan(plan, 5) + self.assertEqual(expected, outcome.terminal.stdout) + for mode, files in (("delta", []), ("save", []), ("save", ["a", "b"]), + ("repo", ["a"]), ("delta", None)): + with self.subTest(scope=mode, files=files), self.assertRaises(ValueError): + scoped_plan(argv, self.root, scope=mode, files=files) + with self.assertRaises(ValueError): + scoped_plan(["linter", "{file}"], self.root, scope="repo") + + def test_empty_plan_and_empty_command_cannot_claim_success(self): + self.assertTrue(hasattr(execution, "execute_plan"), "缺少可复用的计划执行边界") + from codeguard.models import CheckPlan, Command + + with self.assertRaises(ValueError): + CheckPlan(self.root, "repo", ()) + with self.assertRaises(ValueError): + Command(()) + + def test_gate_full_fallback_without_project_command_never_runs_empty_placeholder(self): + paths = [f"file{n}.sh" for n in range(51)] + for path in paths: + self.put(path, "GOOD") + argv = [sys.executable, "-c", "from pathlib import Path; Path('unexpected').touch()", "{file}"] + with patch.dict(LANG_COMMANDS, {"shell": {"lint": argv}}), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.root / "state")}): + failures, skipped = gate_lib._run_gate_uncached( + self.root, {}, ["shell"], scope="delta", changed=paths) + self.assertFalse((self.root / "unexpected").exists(), "不得以空路径执行单文件命令冒充全量") + self.assertEqual([], failures) + self.assertTrue(any("未验证" in item for item in skipped), skipped) + + def test_save_fix_and_recheck_stay_on_original_file(self): + target = self.put("target.py", "BAD") + other = self.put("other.py", "BAD") + checker = self.put("checker", "import pathlib,sys\n" + "p=pathlib.Path(sys.argv[1])\n" + "pathlib.Path('checked.log').open('a').write(p.name+'\\n')\n" + "print('F401' if 'BAD' in p.read_text() else '')\n" + "raise SystemExit(int('BAD' in p.read_text()))\n") + formatter = self.put("formatter", "import pathlib,sys\n" + "p=pathlib.Path(sys.argv[1])\n" + "p.write_text(p.read_text().replace('BAD','GOOD'))\n") + commands = {"lint": [sys.executable, str(checker), "."], + "format": [sys.executable, str(formatter), "."]} + output = io.StringIO() + with patch.dict(LANG_COMMANDS, {"python": commands}), \ + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(target)}), \ + patch.object(post_tool_lint, "find_project_root", return_value=self.root), \ + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": True}), \ + patch.object(post_tool_lint, "should_suppress_duplicate", return_value=False), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.root / "state")}), \ + contextlib.redirect_stdout(output): + self.assertEqual(0, post_tool_lint.main()) + self.assertIn("passed", json.loads(output.getvalue())["hookSpecificOutput"]["additionalContext"]) + self.assertEqual("GOOD", target.read_text()) + self.assertEqual("BAD", other.read_text()) + self.assertEqual(["target.py", "target.py"], (self.root / "checked.log").read_text().splitlines()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cve_boundaries.py b/tests/test_cve_boundaries.py new file mode 100644 index 0000000..26004eb --- /dev/null +++ b/tests/test_cve_boundaries.py @@ -0,0 +1,328 @@ +"""离线扫描器进程夹具:报告、退出码与修复副作用必须共同决定结论。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import cve_check + +# 真正执行 argv/cwd/输出协议;不调用漏洞服务或安装扫描器。 +TOOL = '''import json, sys +from pathlib import Path +root = Path.cwd() +args = sys.argv[1:] +with (root / "calls.jsonl").open("a") as stream: + stream.write(json.dumps(args) + "\\n") +config = json.loads((root / "fixture.json").read_text()) +if config.get("read_stdin"): + (root / "scanner-input").write_text(sys.stdin.read()) +if "--version" in args: + result = config.get("probe", {"rc": 0, "report": {}}) +elif args == ["audit", "fix"]: + (root / "fixed").write_text("requested") + result = config.get("fix", {"rc": 0, "report": {}}) +else: + counter = root / "counter" + index = int(counter.read_text()) if counter.exists() else 0 + counter.write_text(str(index + 1)) + result = config["scans"][min(index, len(config["scans"]) - 1)] +body = result.get("text", json.dumps(result.get("report", {}))) +for arg in args: + if arg.startswith("-Dodc.outputDirectory=") and not result.get("no_file"): + (Path(arg.split("=", 1)[1]) / "dependency-check-report.json").write_text(body) +print(body) +print(result.get("stderr", ""), file=sys.stderr) +sys.exit(result.get("rc", 0)) +''' + + +def npm_report(**counts): + buckets = {"info": 0, "low": 0, "moderate": 0, "high": 0, "critical": 0} + buckets.update(counts) + return {"auditReportVersion": 2, "metadata": { + "vulnerabilities": {**buckets, "total": sum(buckets.values())}}} + + +class CveBoundaryTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-cve-boundary-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + tools = self.root / "bin" + tools.mkdir() + for name in ("npm", "mvn", "cargo", "pip-audit", "trivy"): + tool = tools / name + tool.write_text(f"#!{sys.executable}\n" + TOOL) + tool.chmod(0o755) + environment = patch.dict(os.environ, {"PATH": str(tools) + os.pathsep + os.environ["PATH"]}) + environment.start() + self.addCleanup(environment.stop) + (self.root / "package.json").write_text("{}") + (self.root / "requirements.txt").write_text("example==1\n") + + def configure(self, report=None, rc=0, **options): + data = {"scans": [{"report": report, "rc": rc}], **options} + (self.root / "fixture.json").write_text(json.dumps(data)) + (self.root / "counter").unlink(missing_ok=True) + + def cli(self, *args): + return subprocess.run([sys.executable, str(ROOT / "scripts/cve_check.py"), + *args, str(self.root)], capture_output=True, text=True, + cwd=self.root, timeout=15, check=False) + + def test_trivy_invalid_results_are_not_empty_success(self): + for results in ({}, "", None, [False]): + with self.subTest(results=results): + self.configure({"Results": results}) + self.assertEqual("UNVERIFIED", cve_check.scan_trivy(self.root, "HIGH")["status"]) + + def test_trivy_unknown_severity_retains_finding(self): + finding = {"VulnerabilityID": "CVE-TEST-1", "Severity": "UNKNOWN"} + self.configure({"Results": [{"Target": "lock", "Vulnerabilities": [finding]}]}, 2) + result = cve_check.scan_trivy(self.root, "HIGH") + self.assertEqual("UNVERIFIED", result["status"]) + self.assertEqual([finding], result["findings"]) + calls = [json.loads(line) for line in (self.root / "calls.jsonl").read_text().splitlines()] + self.assertIn("UNKNOWN", calls[0][calls[0].index("--severity") + 1].split(",")) + + def test_trivy_nonzero_without_findings_is_not_pass(self): + self.configure({"Results": []}, 2) + self.assertEqual("UNVERIFIED", cve_check.scan_trivy(self.root, "HIGH")["status"]) + + def test_trivy_requires_finding_identity(self): + self.configure({"Results": [{"Target": "lock", "Vulnerabilities": [{"Severity": "HIGH"}]}]}, 2) + self.assertEqual("UNVERIFIED", cve_check.scan_trivy(self.root, "HIGH")["status"]) + + def test_trivy_clean_report_and_threshold_remain_usable(self): + for severity, rc, expected in ((None, 0, "PASS"), ("LOW", 0, "PASS"), ("HIGH", 2, "FAIL")): + with self.subTest(severity=severity): + findings = [] if severity is None else [{"VulnerabilityID": "CVE-T", "Severity": severity}] + self.configure({"Results": [{"Target": "lock", "Vulnerabilities": findings}]}, rc) + self.assertEqual(expected, cve_check.scan_trivy(self.root, "HIGH")["status"]) + + def test_npm_incomplete_or_inconsistent_counts_are_unverified(self): + cases = [{"total": 2}, {"high": 0}, {"low": 0, "moderate": 0, "high": 0, "critical": 0, "total": 2}, + {"low": False, "moderate": 0, "high": 0, "critical": 0}, + {"low": 0, "moderate": 0, "high": 0, "critical": 0, "unknown": 1}] + for counts in cases: + with self.subTest(counts=counts): + self.configure({"metadata": {"vulnerabilities": counts}}) + self.assertEqual("UNVERIFIED", cve_check.scan_node(self.root, "HIGH", False)["status"]) + + def test_npm_error_payload_cannot_be_hidden_by_zero_counts(self): + self.configure({**npm_report(), "error": {"code": "EAUDIT", "summary": "registry failure"}}) + self.assertEqual("UNVERIFIED", cve_check.scan_node(self.root, "HIGH", False)["status"]) + + def test_npm_nonzero_zero_findings_is_unverified(self): + self.configure(npm_report(), 1) + self.assertEqual("UNVERIFIED", cve_check.scan_node(self.root, "HIGH", False)["status"]) + + def test_npm_lower_severity_still_passes_high(self): + self.configure(npm_report(low=1), 1) + result = cve_check.scan_node(self.root, "HIGH", False) + self.assertEqual("PASS", result["status"]) + self.assertEqual(1, result["exit"]) + + def test_maven_invalid_scores_do_not_create_findings_or_success(self): + for value in (True, float("nan"), float("inf"), -1, 11, "9.8"): + with self.subTest(score=value): + self.configure({"dependencies": [{"vulnerabilities": [ + {"name": "CVE-T", "cvssv3": {"baseScore": value}}]}]}) + self.assertEqual("UNVERIFIED", cve_check.scan_maven(self.root, 7)["status"]) + + def test_maven_missing_identity_is_not_a_confirmed_finding(self): + self.configure({"dependencies": [{"vulnerabilities": [{"cvssv3": {"baseScore": 9.8}}]}]}, 1) + self.assertEqual("UNVERIFIED", cve_check.scan_maven(self.root, 7)["status"]) + + def test_maven_unknown_score_is_preserved_and_low_reports_it(self): + report = {"dependencies": [{"vulnerabilities": [{"name": "CVE-T"}]}]} + self.configure(report) + result = cve_check.scan_maven(self.root, 7) + self.assertEqual("UNVERIFIED", result["status"]) + self.assertTrue(result["findings"]) + self.configure(report, 1) + self.assertEqual("FAIL", cve_check.scan_maven(self.root, 0)["status"]) + + def test_maven_stale_project_report_is_never_used(self): + (self.root / "dependency-check-report.json").write_text('{"dependencies": []}') + self.configure(scans=[{"rc": 1, "no_file": True, "stderr": "network failed"}]) + result = cve_check.scan_maven(self.root, 7) + self.assertEqual("UNVERIFIED", result["status"]) + self.assertTrue(result.get("reason")) + + def test_cargo_inconsistent_summary_is_not_pass(self): + self.configure({"vulnerabilities": {"found": True, "count": 2, "list": []}}) + self.assertEqual("UNVERIFIED", cve_check.scan_cargo(self.root, "HIGH")["status"]) + + def test_cargo_failed_probe_stops_before_scan(self): + self.configure({"vulnerabilities": {"found": False, "count": 0, "list": []}}, + probe={"rc": 124, "stderr": "timeout"}) + result = cve_check.scan_cargo(self.root, "HIGH") + self.assertEqual("UNVERIFIED", result["status"]) + self.assertEqual(124, result["exit"]) + self.assertFalse((self.root / "counter").exists()) + + def test_pip_skipped_dependency_cannot_be_reported_clean(self): + self.configure({"dependencies": [{"name": "x", "skip_reason": "unresolved", "vulns": []}]}) + self.assertEqual("UNVERIFIED", cve_check.scan_pip(self.root, "HIGH")["status"]) + + def test_native_unrated_findings_keep_low_and_high_distinct(self): + fixtures = ((cve_check.scan_pip, {"dependencies": [{"name": "x", "version": "1", + "vulns": [{"id": "PYSEC-T"}]}]}), + (cve_check.scan_cargo, {"vulnerabilities": {"found": True, "count": 1, + "list": [{"advisory": {"id": "RUSTSEC-T"}}]}})) + for scan, report in fixtures: + for severity, expected in (("LOW", "FAIL"), ("HIGH", "UNVERIFIED")): + with self.subTest(scan=scan.__name__, severity=severity): + self.configure(report, 1) + result = scan(self.root, severity) + self.assertEqual(expected, result["status"]) + self.assertEqual(1, len(result["findings"])) + + def test_fix_rescan_is_authoritative_and_fix_outcome_visible(self): + self.configure(scans=[{"rc": 1, "report": npm_report(high=1)}, + {"rc": 0, "report": npm_report()}], + fix={"rc": 1, "stderr": "some changes made"}) + completed = self.cli("--ecosystem", "node", "--json", "--fix") + data = json.loads(completed.stdout) + self.assertEqual(0, completed.returncode, completed.stderr) + result = data["results"][0] + self.assertEqual("FAIL", result["before_fix"]["status"]) + self.assertIn("fix_execution", result) + self.assertEqual(1, result["fix_execution"]["exit"]) + self.assertEqual("2", (self.root / "counter").read_text()) + + def test_failed_rescan_cannot_reuse_prior_result(self): + self.configure(scans=[{"rc": 1, "report": npm_report(high=1)}, + {"rc": 1, "text": "network failed"}]) + completed = self.cli("--ecosystem", "node", "--json", "--fix") + self.assertEqual(1, completed.returncode) + self.assertEqual("UNVERIFIED", json.loads(completed.stdout)["status"]) + + def test_invalid_report_does_not_authorize_fix(self): + self.configure({"metadata": {"vulnerabilities": {"high": 1}}}, 1) + completed = self.cli("--ecosystem", "node", "--json", "--fix") + self.assertEqual(1, completed.returncode) + self.assertFalse((self.root / "fixed").exists()) + + def test_bad_configuration_is_json_unverified_before_any_scan(self): + (self.root / "codeguard.json").write_text("{ broken") + self.configure(npm_report()) + for args in (("--json",), ("--json", "--ecosystem", "node", "--fix")): + with self.subTest(args=args): + completed = self.cli(*args) + self.assertEqual(1, completed.returncode) + self.assertNotIn("Traceback", completed.stderr) + self.assertEqual("UNVERIFIED", json.loads(completed.stdout)["status"]) + self.assertFalse((self.root / "calls.jsonl").exists()) + + def test_malformed_optional_scan_info_is_a_report_error(self): + from codeguard.cve_policy import classify + from codeguard.cve_reports import parse_report + for scan_info in (None, [], "invalid"): + with self.subTest(scan_info=scan_info): + source = json.dumps({"dependencies": [], "scanInfo": scan_info}) + try: + result = parse_report("maven", source, 7) + except AttributeError: + self.fail("非法报告形状不应泄漏 AttributeError") + self.assertEqual("UNVERIFIED", classify(result, 0)[0]) + + def test_npm_detail_conflict_does_not_hide_finding(self): + self.configure({**npm_report(), "vulnerabilities": { + "example": {"name": "example", "severity": "high", "via": ["CVE-T"]}}}) + self.assertEqual("UNVERIFIED", cve_check.scan_node(self.root, "HIGH", False)["status"]) + + def test_pip_partial_error_preserves_findings_but_is_unverified(self): + self.configure({"dependencies": [{"name": "x", "vulns": [{"id": "PYSEC-T"}]}, + {"name": "y", "skip_reason": "cannot resolve"}]}, 1) + result = cve_check.scan_pip(self.root, "LOW") + self.assertEqual("UNVERIFIED", result["status"]) + self.assertEqual("PYSEC-T", result["findings"][0]["id"]) + + def test_all_scanners_clean_and_process_failure_matrix(self): + cases = ((cve_check.scan_maven, 7, {"dependencies": []}), + (cve_check.scan_node, "HIGH", npm_report()), + (cve_check.scan_pip, "HIGH", {"dependencies": [{"name": "x", "version": "1", "vulns": []}]}), + (cve_check.scan_cargo, "HIGH", {"vulnerabilities": {"found": False, "count": 0, "list": []}}), + (cve_check.scan_trivy, "HIGH", {"Results": [{"Target": "lock"}]})) + for scanner, severity, report in cases: + for rc, expected in ((0, "PASS"), (124, "UNVERIFIED"), (126, "UNVERIFIED"), (127, "UNVERIFIED")): + with self.subTest(scanner=scanner.__name__, rc=rc): + self.configure(report, rc) + result = scanner(self.root, severity) + self.assertEqual(expected, result["status"]) + self.assertEqual(rc, result["exit"]) + self.assertEqual(str(self.root), result["execution"]["cwd"]) + + def test_cli_text_and_json_agree_for_all_three_verdicts(self): + for report, rc, status, exit_code in ((npm_report(), 0, "PASS", 0), + (npm_report(high=1), 1, "FAIL", 2), + ({}, 0, "UNVERIFIED", 1)): + with self.subTest(status=status): + self.configure(report, rc) + text = self.cli("--ecosystem", "node") + machine = self.cli("--ecosystem", "node", "--json") + self.assertEqual(exit_code, text.returncode) + self.assertEqual(exit_code, machine.returncode) + self.assertIn(status, text.stdout) + self.assertEqual(status, json.loads(machine.stdout)["status"]) + + def test_pure_parser_and_policy_import_without_runtime(self): + program = f'''import sys +sys.path.insert(0, {str(ROOT / "scripts")!r}) +from codeguard.cve_policy import classify +from codeguard.cve_reports import parse_report +assert classify(parse_report("maven", '{{"dependencies": []}}', 7), 0)[0] == "PASS" +assert not any(name in sys.modules for name in ("subprocess", "codeguard.execution", "codeguard.cve_scanners", "cve_check")) +''' + result = subprocess.run([sys.executable, "-I", "-S", "-c", program], + capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(0, result.returncode, result.stderr) + + def test_application_never_infers_pass_from_bare_exit_zero(self): + from codeguard.cve import ECOSYSTEM_SCANNERS, scan_project + for status in (None, "PAss"): + with self.subTest(status=status), patch.dict(ECOSYSTEM_SCANNERS["node"], { + "scan": lambda *args, verdict=status: { + "ecosystem": "node", "tool": "fixture", "exit": 0, "status": verdict}}): + result = scan_project(self.root, "node") + self.assertEqual(1, result.exit_code) + self.assertEqual("UNVERIFIED", result.results[0]["status"]) + + def test_filtered_scanner_nonzero_requires_matching_evidence(self): + cases = ((cve_check.scan_maven, 7, 1, {"dependencies": [{"vulnerabilities": [ + {"name": "CVE-T", "cvssv3": {"baseScore": 3.1}}]}]}), + (cve_check.scan_trivy, "HIGH", 2, {"Results": [{"Target": "lock", "Vulnerabilities": [ + {"VulnerabilityID": "CVE-T", "Severity": "LOW"}]}]})) + for scanner, threshold, rc, report in cases: + with self.subTest(scanner=scanner.__name__): + self.configure(report, rc) + self.assertEqual("UNVERIFIED", scanner(self.root, threshold)["status"]) + + def test_scanner_does_not_consume_host_input(self): + self.configure(npm_report(), read_stdin=True) + program = f'''import sys +from pathlib import Path +sys.path.insert(0, {str(ROOT / "scripts")!r}) +from codeguard.cve_scanners import scan_node +assert scan_node(Path.cwd(), "HIGH")["status"] == "PASS" +print(sys.stdin.read(), end="") +''' + result = subprocess.run([sys.executable, "-c", program], cwd=self.root, input="host protocol payload", + capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual("host protocol payload", result.stdout) + self.assertEqual("", (self.root / "scanner-input").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dockerfile_boundaries.py b/tests/test_dockerfile_boundaries.py new file mode 100644 index 0000000..1f7a796 --- /dev/null +++ b/tests/test_dockerfile_boundaries.py @@ -0,0 +1,187 @@ +"""离线真实进程:Dockerfile 检查不能吞掉失败或扩大单文件范围。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import dockerfile_security + +TOOL = '''import json, sys +from pathlib import Path +root = Path.cwd() +name = Path(sys.argv[0]).name +config = json.loads((root / "fixture.json").read_text())[name] +result = config.get("by_file", {}).get(Path(sys.argv[-1]).name, config) +with (root / "calls.jsonl").open("a") as stream: + stream.write(json.dumps({"tool": name, "argv": sys.argv[1:]}) + "\\n") +print(result.get("text", json.dumps(result.get("report")))) +print(result.get("stderr", ""), file=sys.stderr) +sys.exit(result.get("rc", 0)) +''' + + +def clean_trivy(): + return {"Results": [{"Target": "Dockerfile", "Class": "config", "Type": "dockerfile", + "MisconfSummary": {"Successes": 1, "Failures": 0, "Exceptions": 0}}]} + + +def hadolint_finding(): + return {"file": "Dockerfile", "line": 1, "column": 1, "level": "warning", + "code": "DL3007", "message": "pin image version"} + + +def trivy_finding(): + return {"ID": "DS002", "Title": "root user", "Severity": "HIGH", "Status": "FAIL", + "Message": "root user", "Resolution": "use non-root user"} + + +class DockerfileBoundaryTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-dockerfile-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.file = self.root / "Dockerfile" + self.file.write_text("FROM scratch\n") + tools = self.root / "bin" + tools.mkdir() + for name in ("hadolint", "trivy"): + tool = tools / name + tool.write_text(f"#!{sys.executable}\n" + TOOL) + tool.chmod(0o755) + env = patch.dict(os.environ, {"PATH": str(tools) + os.pathsep + os.environ["PATH"]}) + env.start() + self.addCleanup(env.stop) + self.configure() + + def configure(self, hadolint=None, trivy=None): + data = {"hadolint": {"report": [], "rc": 0} if hadolint is None else hadolint, + "trivy": {"report": clean_trivy(), "rc": 0} if trivy is None else trivy} + (self.root / "fixture.json").write_text(json.dumps(data)) + + def invoke(self, path=None, as_json=True): + return subprocess.run([sys.executable, str(ROOT / "scripts/dockerfile_security.py"), + *(["--json"] if as_json else []), str(path or self.root)], + cwd=self.root, capture_output=True, text=True, timeout=10, check=False) + + def assert_verdict(self, expected, code): + machine = self.invoke() + text = self.invoke(as_json=False) + self.assertEqual(code, machine.returncode, machine.stdout + machine.stderr) + self.assertEqual(code, text.returncode, text.stdout + text.stderr) + self.assertEqual(expected, json.loads(machine.stdout).get("status")) + if expected != "PASS": + self.assertNotIn("安全检查通过", text.stdout) + + def test_tool_failures_are_not_pass_or_findings(self): + for tool in ("hadolint", "trivy"): + for rc in (124, 126, 127, 3): + with self.subTest(tool=tool, rc=rc): + self.configure(**{tool: {"rc": rc, "text": "", "stderr": "tool failed"}}) + self.assert_verdict("UNVERIFIED", 1) + + def test_malformed_trivy_report_never_becomes_zero_findings(self): + for report in ({}, [], None, {"Results": {}}, {"Results": [None]}, {"Results": []}): + with self.subTest(report=report): + self.configure(trivy={"report": report}) + self.assert_verdict("UNVERIFIED", 1) + + def test_hadolint_requires_valid_report_even_at_exit_zero(self): + for text in ("", "network failure", "{}", '[{"message":"incomplete"}]'): + with self.subTest(text=text): + self.configure(hadolint={"text": text}) + self.assert_verdict("UNVERIFIED", 1) + + def test_clean_reports_pass_both_modes(self): + self.assert_verdict("PASS", 0) + + def test_hadolint_json_findings_keep_legacy_text_and_process_exit(self): + self.configure(hadolint={"rc": 1, "report": [hadolint_finding()]}) + self.assert_verdict("FAIL", 2) + data = json.loads(self.invoke().stdout)["hadolint"] + self.assertIsInstance(data["findings"][0], str) + self.assertIn("DL3007", data["findings"][0]) + self.assertEqual(1, data["attempts"][0]["exit"]) + + def test_trivy_findings_are_distinct_from_process_error(self): + report = clean_trivy() + report["Results"][0]["MisconfSummary"] = {"Successes": 0, "Failures": 1, "Exceptions": 0} + report["Results"][0]["Misconfigurations"] = [trivy_finding()] + self.configure(trivy={"rc": 2, "report": report}) + self.assert_verdict("FAIL", 2) + data = json.loads(self.invoke().stdout)["trivy"] + self.assertEqual("DS002", data["findings"][0]["id"]) + self.assertEqual(2, data["attempts"][0]["exit"]) + self.configure(trivy={"rc": 124, "report": report}) + self.assert_verdict("UNVERIFIED", 1) + + def test_trivy_invalid_finding_or_summary_cannot_pass(self): + for finding in ({"Status": "FAIL"}, {"ID": "DS002", "Status": "BOGUS"}): + with self.subTest(finding=finding): + report = clean_trivy() + report["Results"][0]["Misconfigurations"] = [finding] + self.configure(trivy={"rc": 0, "report": report}) + self.assert_verdict("UNVERIFIED", 1) + report = clean_trivy() + report["Results"][0]["MisconfSummary"]["Failures"] = 2 + self.configure(trivy={"rc": 0, "report": report}) + self.assert_verdict("UNVERIFIED", 1) + + def test_nonzero_without_finding_is_unverified(self): + self.configure(hadolint={"rc": 1, "report": []}) + self.assert_verdict("UNVERIFIED", 1) + self.configure(trivy={"rc": 2, "report": clean_trivy()}) + self.assert_verdict("UNVERIFIED", 1) + + def test_missing_tool_keeps_existing_findings_visible(self): + self.configure(hadolint={"rc": 1, "report": [hadolint_finding()]}, trivy={"rc": 127, "text": ""}) + self.assert_verdict("UNVERIFIED", 1) + self.assertTrue(json.loads(self.invoke().stdout)["hadolint"]["findings"]) + + def test_explicit_file_does_not_scan_siblings(self): + (self.root / "other.dockerfile").write_text("FROM scratch\n") + self.invoke(self.file) + calls = [json.loads(line) for line in (self.root / "calls.jsonl").read_text().splitlines()] + self.assertEqual(2, len(calls)) + self.assertTrue(all(Path(call["argv"][-1]) == self.file for call in calls)) + + def test_missing_explicit_path_does_not_scan_parent(self): + result = self.invoke(self.root / "missing.dockerfile") + self.assertEqual(1, result.returncode) + self.assertEqual("UNVERIFIED", json.loads(result.stdout)["status"]) + self.assertFalse((self.root / "calls.jsonl").exists()) + + def test_no_dockerfile_is_machine_readable_unverified(self): + self.file.unlink() + result = self.invoke() + self.assertEqual(1, result.returncode) + self.assertEqual("UNVERIFIED", json.loads(result.stdout)["status"]) + self.assertFalse((self.root / "calls.jsonl").exists()) + + def test_discovery_does_not_silently_truncate_large_root(self): + for number in range(21): + (self.root / f"{number}.dockerfile").write_text("FROM scratch\n") + nested = self.root / "nested" / "Dockerfile" + nested.parent.mkdir() + nested.write_text("FROM scratch\n") + self.assertIn(nested, dockerfile_security.find_dockerfiles(self.root)) + + def test_late_tool_failure_retains_earlier_diagnostics(self): + other = self.root / "other.dockerfile" + other.write_text("FROM scratch\n") + self.configure(hadolint={"by_file": {"Dockerfile": {"rc": 1, "report": [hadolint_finding()]}, + "other.dockerfile": {"rc": 127, "stderr": "disappeared"}}}) + report = dockerfile_security.hadolint_scan([self.file, other], self.root) + self.assertTrue(report["findings"]) + self.assertEqual("UNVERIFIED", report.get("status")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_execution_kernel.py b/tests/test_execution_kernel.py new file mode 100644 index 0000000..3fc085f --- /dev/null +++ b/tests/test_execution_kernel.py @@ -0,0 +1,133 @@ +"""跨入口的真实进程契约;防止工具故障被入口异常吞掉。 + +移除 UTF-8 容错、启动错误归一化或超时输出保留都会使本组失败。 +不 mock subprocess;所有输入/输出由独立的短进程产生。 +""" +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "hooks")) + +import cve_check +import dockerfile_security +import post_tool_lint +import run_per_language +from verdict import UNVERIFIED, lint_verdict + + +class ExecutionBoundaryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.runners = (run_per_language._run, post_tool_lint.run, cve_check.run, + dockerfile_security.run) + + def test_nonzero_preserves_both_streams(self): + argv = [sys.executable, "-c", "import sys; print('finding'); print('detail', file=sys.stderr); sys.exit(1)"] + for run in self.runners: + with self.subTest(entry=run.__module__): + self.assertEqual((1, "finding\n", "detail\n"), run(argv, self.root, 5)) + + def test_arguments_are_literal_and_cwd_is_explicit(self): + literal = "a b; $(touch unexpected)" + argv = [sys.executable, "-c", "import os,sys; print(sys.argv[1]); print(os.getcwd())", literal] + for run in self.runners: + with self.subTest(entry=run.__module__): + rc, out, err = run(argv, self.root, 5) + self.assertEqual(0, rc) + self.assertEqual([literal, str(self.root.resolve())], out.splitlines()) + self.assertEqual("", err) + self.assertFalse((self.root / "unexpected").exists()) + + def test_invalid_utf8_does_not_discard_the_checker_result(self): + argv = [sys.executable, "-c", "import os; os.write(1,b'detail \\xff'); os.write(2,b'error \\xfe'); raise SystemExit(1)"] + for run in self.runners: + with self.subTest(entry=run.__module__): + rc, out, err = run(argv, self.root, 5) + self.assertEqual((1, "detail \ufffd", "error \ufffd"), (rc, out, err)) + + def test_timeout_keeps_partial_diagnostics(self): + argv = [sys.executable, "-c", "import sys,time; print('before timeout',flush=True); print('stderr before timeout',file=sys.stderr,flush=True); time.sleep(30)"] + for run in self.runners: + with self.subTest(entry=run.__module__): + rc, out, err = run(argv, self.root, 0.5) + self.assertEqual(124, rc) + self.assertIn("before timeout", out) + self.assertIn("stderr before timeout", err) + self.assertIn("timeout", err) + self.assertEqual(UNVERIFIED, lint_verdict(rc, argv, out + err)[0]) + + @unittest.skipIf(os.name == "nt", "POSIX executable permission contract") + def test_non_executable_is_unverified_not_an_uncaught_error(self): + tool = self.root / "not-executable" + tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + tool.chmod(0o600) + for run in self.runners: + with self.subTest(entry=run.__module__): + rc, out, err = run([str(tool)], self.root, 5) + self.assertEqual(126, rc) + self.assertTrue(err) + self.assertEqual(UNVERIFIED, lint_verdict(rc, [str(tool)], out + err)[0]) + + def test_missing_tool_is_unverified(self): + argv = [str(self.root / "missing")] + for run in self.runners: + with self.subTest(entry=run.__module__): + rc, out, err = run(argv, self.root, 5) + self.assertEqual(127, rc) + self.assertTrue(err) + self.assertEqual(UNVERIFIED, lint_verdict(rc, argv, out + err)[0]) + + def test_git_gate_preserves_finding_with_invalid_output_bytes(self): + from unittest.mock import patch + + import gate_lib + + argv = [sys.executable, "-c", "import os; os.write(1,b'finding \\xff'); raise SystemExit(1)"] + with patch.dict(gate_lib.LANG_COMMANDS, {"python": {"lint": argv}}), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.root / "state")}): + failures, skipped = gate_lib._run_gate_uncached(self.root, {}, ["python"]) + self.assertEqual([], skipped) + self.assertEqual(1, len(failures)) + self.assertIn("finding \ufffd", failures[0][1]) + + def test_verdict_core_imports_without_host_or_legacy_runtime(self): + # 隔离解释器只暴露 scripts,不需要 hooks、MCP SDK 或 pip 安装。 + code = ( + "import json,sys; sys.path.insert(0,sys.argv[1]); " + "from codeguard.verdict import lint_verdict,exit_status,result; " + "status,reason=lint_verdict(127,['missing']); " + "print(json.dumps([status,exit_status([result('python',status,reason)])," + "any(n in sys.modules for n in ['gate_lib','run_check','detect_lang','mcp'])]))" + ) + proc = subprocess.run([sys.executable, "-I", "-c", code, str(ROOT / "scripts")], + capture_output=True, text=True, check=False, timeout=5) + self.assertEqual(0, proc.returncode, proc.stderr) + self.assertEqual('["UNVERIFIED", 1, false]', proc.stdout.strip()) + + def test_reporting_does_not_load_gate_or_launch_checks(self): + code = ( + "import json,sys; sys.path.insert(0,sys.argv[1]); " + "from codeguard.reporting import gate_directive; " + "report=gate_directive([('python','F401 unused import','ruff check --fix sample.py','ruff')]); " + "print(json.dumps([report.startswith('codeguard ❌'), 'F401' in report, " + "'ruff check --fix sample.py' in report, " + "any(n in sys.modules for n in ['gate_lib','run_check','detect_lang','subprocess'])]))" + ) + proc = subprocess.run([sys.executable, "-I", "-c", code, str(ROOT / "scripts")], + capture_output=True, text=True, check=False, timeout=5) + self.assertEqual(0, proc.returncode, proc.stderr) + self.assertEqual('[true, true, true, false]', proc.stdout.strip()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gate_application.py b/tests/test_gate_application.py new file mode 100644 index 0000000..678212d --- /dev/null +++ b/tests/test_gate_application.py @@ -0,0 +1,104 @@ +"""门禁应用独立执行、准确审计归属和基线不确定性回归。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "hooks")] +import gate_lib + + +class GateApplicationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-application-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.home = self.root / "state" + self.repo = self.root / "repo" + self.repo.mkdir() + for args in (("init", "-q"), ("config", "user.name", "fixture"), + ("config", "user.email", "fixture@example.invalid"), + ("config", "core.hooksPath", "/dev/null")): + self.git(*args) + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.repo, check=True, + text=True, capture_output=True).stdout + + def test_application_runs_without_hook_directory_or_host_modules(self): + script = ("import sys,json;from pathlib import Path;sys.path.insert(0,sys.argv[1]);" + "from codeguard.gate import run_gate;" + "result=run_gate(Path(sys.argv[2]),{},['python'],exact=True);" + "print(json.dumps([result, 'gate_lib' in sys.modules, 'mcp' in sys.modules]))") + (self.repo / "a.py").write_text("undefined_name()\n") + self.git("add", "a.py") + proc = subprocess.run([sys.executable, "-I", "-c", script, str(ROOT / "scripts"), str(self.repo)], + env={**os.environ, "CODEGUARD_HOME": str(self.home)}, + capture_output=True, text=True, timeout=20, check=False) + self.assertEqual(0, proc.returncode, proc.stderr) + result, legacy_imported, host_imported = json.loads(proc.stdout) + self.assertIn("F821", result[0][0][1]) + self.assertFalse(legacy_imported) + self.assertFalse(host_imported) + + def test_snapshot_audit_has_original_worktree_session_and_actual_argv(self): + (self.repo / "a.py").write_text("undefined_name()\n") + self.git("add", "a.py") + payload = {"session_id": "review-session", "tool_input": {"command": "git commit -m fixture"}} + proc = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], cwd=self.repo, + env={**os.environ, "CODEGUARD_HOME": str(self.home)}, + input=json.dumps(payload), text=True, capture_output=True, timeout=20, check=False) + self.assertEqual(2, proc.returncode, proc.stderr) + entries = [json.loads(line) for line in (self.home / "gate-decisions.jsonl").read_text().splitlines()] + entry = next(row for row in entries if row["lang"] == "python") + self.assertEqual(str(self.repo), entry["worktree"]) + self.assertTrue(entry.get("session_scope")) + self.assertIn("a.py", entry["cmd"]) + self.assertNotEqual(str(self.repo), entry.get("execution_root")) + + def test_unknown_baseline_output_never_grants_waiver(self): + (self.repo / "a.py").write_text("BAD\n") + self.git("add", "a.py") + self.git("commit", "-qm", "base") + command = [sys.executable, "-c", "print('F821 undefined'); raise SystemExit(2)", "{file}"] + self.assertIsNone(gate_lib.baseline_stale_finding(self.repo, "a.py", command, "F821 undefined")) + + def test_unknown_current_check_is_not_hidden_by_known_baseline_failure(self): + (self.repo / "a.py").write_text("OLD\n") + self.git("add", "a.py") + self.git("commit", "-qm", "base") + (self.repo / "a.py").write_text("NEW\n") + command = [sys.executable, "-c", ("import pathlib,sys;print('F821 undefined');" + "raise SystemExit(2 if 'NEW' in pathlib.Path(sys.argv[1]).read_text() else 1)"), "{file}"] + with patch.dict(gate_lib.LANG_COMMANDS, {"python": {"lint": command}}), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + failures, notes = gate_lib._run_gate_uncached(self.repo, {}, ["python"], + scope="delta", changed=["a.py"]) + self.assertEqual([], failures) + self.assertTrue(any("未验证" in note for note in notes), notes) + self.assertFalse(any("豁免" in note for note in notes), notes) + + def test_parallel_checks_aggregate_in_requested_order_and_audit_each_command(self): + commands = { + lang: {"lint": [sys.executable, "-c", + f"import time;time.sleep({delay});print('F821 {lang}');raise SystemExit(1)"]} + for lang, delay in (("python", 0.15), ("javascript", 0.01)) + } + with patch.dict(gate_lib.LANG_COMMANDS, commands), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + failures, notes = gate_lib._run_gate_uncached(self.repo, {}, ["python", "javascript"]) + self.assertEqual(["python", "javascript"], [row[0] for row in failures]) + self.assertEqual([], notes) + entries = [json.loads(line) for line in (self.home / "gate-decisions.jsonl").read_text().splitlines()] + self.assertEqual(["python", "javascript"], [entry["lang"] for entry in entries]) + for entry in entries: + self.assertEqual(commands[entry["lang"]]["lint"], entry["cmd"]) + self.assertEqual(1, entry["rc"]) + self.assertEqual("FAIL", entry["status"]) diff --git a/tests/test_gate_hardening_batch2.py b/tests/test_gate_hardening_batch2.py index 1ed5c0c..1975009 100644 --- a/tests/test_gate_hardening_batch2.py +++ b/tests/test_gate_hardening_batch2.py @@ -20,6 +20,8 @@ import gate_lib import pre_tool_git_guard import scope +from codeguard import spec_validation +from git_snapshot import validation_tree from verdict import FAIL, UNVERIFIED, finding_signatures, lint_verdict @@ -83,7 +85,8 @@ class BaselineStalenessTests(GitRepoCase): def checker(self): return [sys.executable, "-c", ("import sys,pathlib;t=pathlib.Path(sys.argv[1]).read_text();" - "sys.stdout.write('CODE1 bad\\n') if 'BAD' in t else None"), + "sys.stdout.write('CODE1 bad\\n') if 'BAD' in t else None;" + "sys.exit('BAD' in t)"), "{file}"] def test_pre_existing_finding_is_stale_with_baseline(self): @@ -117,6 +120,38 @@ def test_no_baseline_refuses_excusal(self): ) self.assertIsNone(stale, "无基线证据绝不豁免(verdict-integrity 既有原则)") + def test_second_occurrence_of_same_rule_is_not_stale(self): + self.put("lib.sh", "BAD\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + self.put("lib.sh", "BAD\nBAD\n") + checker = [sys.executable, "-c", + ("import sys,pathlib; lines=pathlib.Path(sys.argv[1]).read_text().splitlines();" + "print('\\n'.join('CODE1 bad' for line in lines if line == 'BAD')) or " + "sys.exit(any(line == 'BAD' for line in lines))"), "{file}"] + self.assertIsNone(gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, "CODE1 bad\nCODE1 bad\n")) + + def test_same_rule_with_different_diagnostic_is_not_stale(self): + self.put("lib.sh", "BAD old\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + self.put("lib.sh", "BAD new\n") + checker = [sys.executable, "-c", + ("import sys,pathlib; value=pathlib.Path(sys.argv[1]).read_text().split()[1];" + "print(f'{sys.argv[1]}:1:1: F401 `{value}` imported but unused');" + "sys.exit(1)"), "{file}"] + self.assertIsNone(gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, "lib.sh:1:1: F401 `new` imported but unused")) + + def test_successful_baseline_command_cannot_prove_a_failure(self): + self.put("lib.sh", "BAD\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + checker = [sys.executable, "-c", "print('CODE1 bad')", "{file}"] + self.assertIsNone(gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, "CODE1 bad")) + class GateDecisionLogTests(GitRepoCase): """门禁决策必须落盘可回溯(哪个语言、什么命令、rc、结论)。""" @@ -143,33 +178,34 @@ def test_decision_log_records_language_command_and_status(self): class PathspecResolutionTests(GitRepoCase): - """git add 的 pathspec 必须用 git 自己的解析(glob/目录/等价语法)。 + """从命令意图到真实 Git 快照验证范围,不依赖从未存在的 helper。""" - 实现已让位:pre_tool_git_guard.py 正被并行会话活跃修改(其 chain_skip_gate - 与本批 inline_skip_gate 互补),避免竞争写入;待其稳定后把 match_pathspec - 接进 staging_intent 再解除跳过。 - """ + def matched(self, pathspec): + import shlex + command = f"git -C {shlex.quote(str(self.root))} add {shlex.quote(pathspec)} && git -C {self.root} commit -m x" + lanes, extra = pre_tool_git_guard.staging_intent(command) + with validation_tree(self.root, lanes=lanes, extra=extra) as (snapshot, changed): + for name in changed: + self.assertEqual((self.root / name).read_bytes(), (snapshot / name).read_bytes()) + return changed - @unittest.skip("pending: pre_tool_git_guard.py 并行修改中,match_pathspec 待接线") def test_glob_pathspec_expands_to_real_files(self): self.put("a.py", "print(1)\n") self.put("pkg/b.py", "print(2)\n") - matched = pre_tool_git_guard.match_pathspec(self.root, self.root, "*.py") + matched = self.matched("*.py") self.assertCountEqual(["a.py", "pkg/b.py"], matched, "basename glob 命中任意层级") - matched = pre_tool_git_guard.match_pathspec(self.root, self.root, "pkg/*.py") + matched = self.matched("pkg/*.py") self.assertEqual(["pkg/b.py"], matched) - @unittest.skip("pending: pre_tool_git_guard.py 并行修改中,match_pathspec 待接线") def test_directory_pathspec_expands(self): self.put("pkg/a.py", "x=1\n") self.put("pkg/sub/b.py", "y=2\n") - matched = pre_tool_git_guard.match_pathspec(self.root, self.root, "pkg") + matched = self.matched("pkg") self.assertCountEqual(["pkg/a.py", "pkg/sub/b.py"], matched) - @unittest.skip("pending: pre_tool_git_guard.py 并行修改中,match_pathspec 待接线") def test_nonmatching_pathspec_returns_empty(self): self.put("a.py", "print(1)\n") - self.assertEqual([], pre_tool_git_guard.match_pathspec(self.root, self.root, "*.rs")) + self.assertEqual([], self.matched("*.rs")) class RuffTargetVersionTests(GitRepoCase): @@ -195,11 +231,9 @@ def test_no_declaration_keeps_default(self): class EscapeHatchNoticeTests(GitRepoCase): """内联豁免放行时必须向用户明示,不得静默绕过。 - 同样待 pre_tool_git_guard.py 并行修改稳定后接线(提醒输出在 main() 的 - inline_skip_gate 分支加 hookSpecificOutput)。 + 主入口已具备提醒输出,真实执行通过后恢复为常规回归,不再保留历史 skip。 """ - @unittest.skip("pending: pre_tool_git_guard.py 并行修改中,豁免提醒待接线") def test_inline_skip_emits_user_visible_notice(self): self.put("a.py", "print(1)\n") _git(self.root, "add", "-A") @@ -233,8 +267,8 @@ class OpenspecValidateResilienceTests(GitRepoCase): def test_unexpected_exception_is_skipped_not_raised(self): self.put("openspec/config.yaml", "schema: spec-driven\n") from unittest.mock import patch - with patch.object(gate_lib.shutil, "which", return_value="/usr/bin/fake-openspec"), \ - patch.object(gate_lib.subprocess, "run", side_effect=NameError("boom")): + with patch.object(spec_validation.shutil, "which", return_value="/usr/bin/fake-openspec"), \ + patch.object(subprocess, "run", side_effect=NameError("boom")): result = gate_lib._openspec_validate(self.root) self.assertIn("skipped", result) self.assertTrue(result["failures"] == []) @@ -247,18 +281,18 @@ class SkipGateRetryTests(GitRepoCase): def test_retry_after_timeout_then_success(self): _git(self.root, "config", "codeguard.skipGate", "true") calls = {"n": 0} - real_run = gate_lib.subprocess.run + real_run = subprocess.run def flaky(*args, **kwargs): calls["n"] += 1 if calls["n"] == 1: - raise gate_lib.subprocess.TimeoutExpired("git", 3) + raise subprocess.TimeoutExpired("git", 3) return real_run(*args, **kwargs) from unittest.mock import patch os.environ["CODEGUARD_HOME"] = str(Path(self._td.name) / "home") try: - with patch.object(gate_lib.subprocess, "run", side_effect=flaky): + with patch.object(subprocess, "run", side_effect=flaky): self.assertTrue(gate_lib.skip_gate_via_git_config(self.root), "第一次超时后重试成功必须算豁免") state = (Path(self._td.name) / "home" / "session_state.json") @@ -271,8 +305,8 @@ def test_two_failures_audited(self): home = Path(self._td.name) / "home" os.environ["CODEGUARD_HOME"] = str(home) try: - with patch.object(gate_lib.subprocess, "run", - side_effect=gate_lib.subprocess.TimeoutExpired("git", 3)): + with patch.object(subprocess, "run", + side_effect=subprocess.TimeoutExpired("git", 3)): self.assertFalse(gate_lib.skip_gate_via_git_config(self.root)) state = json.loads((home / "session_state.json").read_text(encoding="utf-8")) self.assertIn("skipGate-read-error", state["_skip"]["kinds"]) diff --git a/tests/test_gate_hardening_usage.py b/tests/test_gate_hardening_usage.py index eb71ed8..4b3bc12 100644 --- a/tests/test_gate_hardening_usage.py +++ b/tests/test_gate_hardening_usage.py @@ -171,8 +171,8 @@ def test_skip_gate_read_error_is_audited_not_silent(self): os.environ["CODEGUARD_HOME"] = tmp try: with unittest.mock.patch.object( - gate_lib.subprocess, "run", - side_effect=gate_lib.subprocess.TimeoutExpired("git", 10), + subprocess, "run", + side_effect=subprocess.TimeoutExpired("git", 10), ): self.assertFalse(gate_lib.skip_gate_via_git_config(Path("/x"))) state = json.loads((Path(tmp) / "session_state.json").read_text(encoding="utf-8")) diff --git a/tests/test_git_boundaries.py b/tests/test_git_boundaries.py new file mode 100644 index 0000000..e045db5 --- /dev/null +++ b/tests/test_git_boundaries.py @@ -0,0 +1,69 @@ +"""Git 语法、仓库定位和路径规则可脱离宿主加载,原入口继续使用相同行为。""" +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class GitBoundaryTests(unittest.TestCase): + def isolated(self, source, *args): + result = subprocess.run([sys.executable, "-I", "-c", + "import sys;sys.path.insert(0,sys.argv[1]);" + source, + str(ROOT / "scripts"), *map(str, args)], + capture_output=True, text=True, timeout=15, check=False) + self.assertEqual(0, result.returncode, result.stderr) + return json.loads(result.stdout) + + def test_git_syntax_does_not_load_hook_or_execute_commands(self): + actual = self.isolated( + "from codeguard.git_syntax import _collect_subs,inline_skip_gate,chain_skip_gate;" + "import json; print(json.dumps([" + "_collect_subs('x=$(git push origin main)')," + "_collect_subs('if FOO=1 git -C repo commit -m x; then :; fi')," + "_collect_subs('echo \\\"git push\\\"')," + "inline_skip_gate('git -c codeguard.skipGate=true commit -m x')," + "inline_skip_gate('git commit -m \\\"use -c codeguard.skipGate=true here\\\"')," + "chain_skip_gate('git config codeguard.skipGate true && git push')," + "any(m in sys.modules for m in ('subprocess','gate_lib','detect_lang','pre_tool_git_guard'))" + "]))") + self.assertEqual([["push"], ["commit"], [], True, False, True, False], actual) + + def test_path_policy_preserves_vendor_and_fixture_exceptions_without_git(self): + actual = self.isolated( + "from codeguard.path_policy import check_paths,is_build_artifact; import json;" + "print(json.dumps([[v[0] for v in check_paths(['.env','vendor/pkg/x.py'," + "'scripts/vendor/skill_vendor.py','tests/fixtures/sample.db','sample.db'," + "'src/secret.pem','target/site/a.html'])]," + "is_build_artifact('target/site/a.html'),is_build_artifact('src/a.py')," + "any(m in sys.modules for m in ('subprocess','scope','gate_lib','git_snapshot'))]))") + self.assertEqual([[".env", "vendor/pkg/x.py", "sample.db", "src/secret.pem", "target/site/a.html"], + True, False, False], actual) + + def test_repository_binding_and_indirection_work_without_hooks_on_sys_path(self): + with tempfile.TemporaryDirectory(prefix="cg-bindings-") as tmp: + base = Path(tmp).resolve() + left, right = base / "left", base / "right" + for repo in (left, right): + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + script = base / "publish.sh" + script.write_text("#!/bin/sh\ngit push origin main\n", encoding="utf-8") + actual = self.isolated( + "from codeguard.git_context import resolve_project_roots,_guarded_mode,staging_intent;" + "import json; a,b,script=sys.argv[2:];" + "print(json.dumps([[str(p) for p in resolve_project_roots(" + "f'git -C {a} commit -m x && git -C {b} push')]," + "_guarded_mode(f'bash {script}'),staging_intent('git add -u && git commit -m x')[0]," + "any(m in sys.modules for m in ('gate_lib','detect_lang','pre_tool_git_guard'))]))", + left, right, script) + self.assertEqual([[str(left), str(right)], "push", ["staged", "unstaged"], False], actual) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_git_staging.py b/tests/test_git_staging.py new file mode 100644 index 0000000..02b11d9 --- /dev/null +++ b/tests/test_git_staging.py @@ -0,0 +1,164 @@ +"""暂存意图按仓库与 Git pathspec 绑定,观察过程不能修改真实 index。""" +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "hooks")] +import pre_tool_git_guard +from git_snapshot import validation_tree + + +class GitStagingTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-staging-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() / "repo" + self.root.mkdir() + self.git("init", "-q") + self.git("config", "user.name", "fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.git("config", "core.hooksPath", "/dev/null") + self.put("base.txt", "base") + self.git("add", ".") + self.git("commit", "-qm", "base") + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.root, capture_output=True, + check=True, timeout=10).stdout + + def put(self, name, text="new"): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + def snapshot(self, command, expected, *, absent=()): + index = self.root / ".git/index" + before = index.read_bytes() + with patch.object(Path, "cwd", return_value=self.root): + lanes, extra = pre_tool_git_guard.staging_intent(command) + with validation_tree(self.root, lanes=lanes, extra=extra) as (snapshot, changed): + self.assertCountEqual(expected, changed) + for name in expected: + path = self.root / name + if path.exists(): + self.assertEqual(path.read_bytes(), (snapshot / name).read_bytes()) + else: + self.assertFalse((snapshot / name).exists()) + for name in absent: + self.assertFalse((snapshot / name).exists()) + self.assertEqual(before, index.read_bytes(), "观察不能重写用户 index") + + def test_exclusion_is_applied_to_whole_pathspec_set(self): + self.put("a.py") + self.put("private/b.py") + self.snapshot("git add '*.py' ':(exclude)private/**' && git commit -m x", + ["a.py"], absent=["private/b.py"]) + + def test_literal_magic_does_not_expand_file_name_twice(self): + self.put("star*.py") + self.put("star-other.py") + self.snapshot("git add ':(literal)star*.py' && git commit -m x", + ["star*.py"], absent=["star-other.py"]) + + def test_quoted_filename_and_git_directory_with_spaces(self): + self.put("space dir/a b.py") + self.snapshot(f"git -C {shlex.quote(str(self.root / 'space dir'))} add 'a b.py' && git commit -m x", + ["space dir/a b.py"]) + + def test_relative_cd_and_git_c_are_composed(self): + self.put("pkg/src/a.py") + self.put("root.py") + self.snapshot("cd pkg && git -C src add '*.py' && git commit -m x", + ["pkg/src/a.py"], absent=["root.py"]) + + def test_scoped_update_excludes_untracked_but_includes_deletions(self): + self.put("pkg/a.py", "old") + removed = self.put("pkg/removed.py") + self.git("add", ".") + self.git("commit", "-qm", "tracked") + self.put("pkg/a.py", "changed") + removed.unlink() + self.put("pkg/new.py") + self.snapshot("git add -u pkg && git commit -m x", ["pkg/a.py", "pkg/removed.py"], absent=["pkg/new.py"]) + + def test_add_dot_from_subdirectory_does_not_stage_siblings(self): + self.put("pkg/a.py") + self.put("outside.py") + self.snapshot("cd pkg && git add . && git commit -m x", ["pkg/a.py"], absent=["outside.py"]) + + def test_force_add_includes_ignored_target_only(self): + self.put(".gitignore", "ignored/\n") + self.put("ignored/a.py") + self.put("ignored/b.txt") + self.snapshot("git add -f 'ignored/*.py' && git commit -m x", ["ignored/a.py"], absent=["ignored/b.txt"]) + + def test_real_hook_does_not_copy_another_repositories_extra_paths(self): + other = self.root.parent / "other" + other.mkdir() + subprocess.run(["git", "init", "-q", str(other)], check=True, capture_output=True) + (other / "safe.txt").write_text("safe") + self.put(".env", "fixture-only") + self.put("safe.txt", "untracked") + command = f"git -C {other} add -A && git -C {other} commit -m x && git -C {self.root} commit -m x" + payload = {"tool_name": "Bash", "tool_input": {"command": command}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn(".env", result.stderr) + + def test_real_hook_detects_sensitive_glob_before_add_without_touching_index(self): + self.put("private/a.pem", "fixture-only") + before = (self.root / ".git/index").read_bytes() + payload = {"tool_name": "Bash", "tool_input": {"command": + f"git -C {self.root} add 'private/*.pem' && git -C {self.root} commit -m x"}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root.parent, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn("private/a.pem", result.stderr) + self.assertEqual(before, (self.root / ".git/index").read_bytes()) + + def test_hook_normalizes_subdirectory_to_worktree_root(self): + self.put("space dir/a.pem", "fixture-only") + commands = [(f"git -C {shlex.quote(str(self.root / 'space dir'))} add '*.pem' && " + f"git -C {shlex.quote(str(self.root / 'space dir'))} commit -m x"), + "cd 'space dir' && git add '*.pem' && git commit -m x"] + for command in commands: + with self.subTest(command=command): + payload = {"tool_name": "Bash", "tool_input": {"command": command}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn("space dir/a.pem", result.stderr) + + def test_unpredictable_add_is_visible_unverified_not_silent_pass(self): + for option in ("-p", "--pathspec-from-file=paths.txt"): + with self.subTest(option=option): + payload = {"tool_name": "Bash", "tool_input": {"command": f"git add {option} && git commit -m x"}} + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + input=json.dumps(payload), cwd=self.root, capture_output=True, + text=True, check=False, timeout=15, + env={**os.environ, "CODEGUARD_HOME": str(self.root.parent / "state")}) + self.assertEqual(0, result.returncode) + self.assertIn("暂存", result.stderr + result.stdout) + self.assertIn("UNVERIFIED", result.stderr + result.stdout) + self.assertNotIn("Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py index 63477ff..69e981e 100644 --- a/tests/test_hardening_fixes.py +++ b/tests/test_hardening_fixes.py @@ -197,25 +197,12 @@ def test_gate_rc2_is_skipped_not_failed(self) -> None: (repo / "a.py").write_text("x = 1\n", encoding="utf-8") _git(repo, "add", "-A") - class _Fake: - returncode = 2 - stdout = "npm warn junk\n" - stderr = "Error: cannot find module\n" - - original_run = gate_lib.subprocess.run - original_probe = gate_lib.probe_toolchain - original_uses = gate_lib.project_uses_linter - gate_lib.subprocess.run = lambda *a, **k: _Fake() - gate_lib.probe_toolchain = lambda *a, **k: (True, "") - gate_lib.project_uses_linter = lambda *a, **k: True - try: + from unittest.mock import patch + command = {"lint": [sys.executable, "-c", "print('Error: cannot find module');raise SystemExit(2)"]} + with patch.dict(gate_lib.LANG_COMMANDS, {"python": command}): failures, skipped = gate_lib._run_gate_uncached( repo, {"lint_timeout_seconds": 5}, ["python"], scope="repo", changed=None, ) - finally: - gate_lib.subprocess.run = original_run - gate_lib.probe_toolchain = original_probe - gate_lib.project_uses_linter = original_uses self.assertEqual(failures, []) self.assertTrue(any("工具链异常未验证" in s and "exit 2" in s for s in skipped), skipped) @@ -441,46 +428,32 @@ def test_ups_uses_push_face_for_push_intent(self) -> None: class UnverifiedParityTests(unittest.TestCase): - """rc2/127 按场景分流:rc2 = unverified(两处一致);rc127 = 故意分歧。 + """工具缺失与 exit 2 均未验证,CLI 不能把它们算通过。 - check CLI 是 CI/健康面——工具缺失必须红(环境无关性由 test_mcp_server - 的失败日志/安静模式用例依赖);交互钩子对 127 归 skipped 不挡工作。 - 这是有意设计,本测试锁住它,防止再被"统一"。 + 使用真实进程和明确命令;不再依赖旧编排器内部的 subprocess 导入。 """ - @staticmethod - def _fake_run(returncode: int): - import run_per_language as rpl - - class _Fake: - pass - - _Fake.returncode = returncode - _Fake.stdout = "" - _Fake.stderr = "boom" if returncode != 0 else "" - original = rpl.subprocess.run - rpl.subprocess.run = lambda *a, **k: _Fake() - return original - def test_rc_127_is_failure_in_cli(self) -> None: + from unittest.mock import patch + import run_per_language as rpl - original = self._fake_run(127) - try: - results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-127-"))) - finally: - rpl.subprocess.run = original + with tempfile.TemporaryDirectory(prefix="cg-127-") as td: + command = {"lint": [str(Path(td) / "missing-checker")]} + with patch.dict(rpl.LANG_COMMANDS, {"python": command}): + results = rpl.run_check(["python"], Path(td)) self.assertFalse(results[0]["passed"], "CLI 面工具缺失不能通过") self.assertEqual(results[0]["status"], "UNVERIFIED") def test_rc_2_is_unverified_in_cli(self) -> None: + from unittest.mock import patch + import run_per_language as rpl - original = self._fake_run(2) - try: - results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-2-"))) - finally: - rpl.subprocess.run = original + command = {"lint": [sys.executable, "-c", "raise SystemExit(2)"]} + with tempfile.TemporaryDirectory(prefix="cg-2-") as td, \ + patch.dict(rpl.LANG_COMMANDS, {"python": command}): + results = rpl.run_check(["python"], Path(td)) self.assertFalse(results[0]["passed"]) self.assertEqual(results[0]["status"], "UNVERIFIED") self.assertTrue(results[0].get("reason")) diff --git a/tests/test_java_boundaries.py b/tests/test_java_boundaries.py new file mode 100644 index 0000000..ce776de --- /dev/null +++ b/tests/test_java_boundaries.py @@ -0,0 +1,233 @@ +"""Java 架构与证据边界:真实 Git 差异不得被误判为仅发布版本变化。""" +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from java_project import _is_version_bump_only, analyze + + +class JavaBoundaryTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cg-java-boundary-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.git("init", "-q") + self.git("config", "user.email", "fixture@example.invalid") + self.git("config", "user.name", "fixture") + self.git("config", "core.hooksPath", "/dev/null") + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.root, capture_output=True, + text=True, check=True, timeout=10).stdout + + def baseline(self, file, body): + path = self.root / file + path.write_text(body) + self.git("add", file) + self.git("commit", "-qm", "fixture") + return path + + def pom(self, extra=""): + return ("\ntest\napp\n" + "1\n" + extra + "\n") + + def test_dependency_version_change_does_not_downgrade(self): + path = self.baseline("pom.xml", self.pom("\n\n" + "other\nlib\n" + "2\n\n")) + path.write_text(path.read_text().replace("2", "3")) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + self.assertEqual(["mvn", "-B", "-DskipTests", "verify"], + analyze(self.root, ["pom.xml"])["commands"][0]["argv"]) + + def test_compiler_configuration_change_does_not_downgrade(self): + path = self.baseline("pom.xml", self.pom("\n\n\n" + "maven-compiler-plugin\n\n" + "17\n\n\n\n")) + path.write_text(path.read_text().replace("17", "21")) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + + def test_pure_project_version_with_existing_dependencies_still_downgrades(self): + # 单行描述包含 dependency 标签,但真正变化的仅是项目顶层版本。 + body = self.pom("xy" + "2").replace("\n", "") + path = self.baseline("pom.xml", body) + path.write_text(body.replace("1", "1.1")) + self.assertTrue(_is_version_bump_only(self.root, ["pom.xml"])) + self.assertEqual("validate", analyze(self.root, ["pom.xml"])["commands"][0]["argv"][-1]) + + def test_gradle_logic_change_does_not_downgrade(self): + path = self.baseline("build.gradle", "plugins { id 'java' }\nsourceCompatibility = 17\n") + path.write_text(path.read_text().replace("17", "21")) + self.assertFalse(_is_version_bump_only(self.root, ["build.gradle"])) + self.assertEqual(["gradle", "check", "-x", "test"], + analyze(self.root, ["build.gradle"])["commands"][0]["argv"]) + + def test_gradle_nested_version_assignment_is_not_project_release(self): + path = self.baseline("build.gradle", "plugins {\n id 'java'\n id 'some.plugin'\n" + " version '1.0'\n}\n") + path.write_text(path.read_text().replace("'1.0'", "'2.0'")) + self.assertFalse(_is_version_bump_only(self.root, ["build.gradle"])) + + def test_gradle_literal_project_version_remains_lightweight(self): + path = self.baseline("build.gradle", "plugins { id 'java' }\nversion = '1'\n") + path.write_text(path.read_text().replace("version = '1'", "version = '2'")) + self.assertTrue(_is_version_bump_only(self.root, ["build.gradle"])) + self.assertEqual(["gradle", "help"], analyze(self.root, ["build.gradle"])["commands"][0]["argv"]) + + def test_authoritative_commands_remain_unchanged_for_version_bump(self): + path = self.baseline("pom.xml", self.pom()) + path.write_text(path.read_text().replace("1", "2")) + commands = [["custom-wrapper", "verify", "-Pquality"], ["other-check", "--test"]] + (self.root / "codeguard.json").write_text(json.dumps({"java": {"commands": commands}})) + self.assertEqual(commands, [c["argv"] for c in analyze(self.root, ["pom.xml"])["commands"]]) + + def test_normalized_paths_choose_actual_module(self): + (self.root / "pom.xml").write_text(self.pom("apiservice")) + for module in ("api", "service"): + (self.root / module).mkdir() + (self.root / module / "pom.xml").write_text(self.pom().replace("app", f"{module}")) + plan = analyze(self.root, ["api/../service/src/A.java"]) + self.assertEqual(["service"], plan["affected_modules"]) + + def test_impact_policy_imports_without_runtime_and_handles_cycles(self): + self.assertIsNotNone(importlib.util.find_spec("codeguard.java_impact"), "缺少独立的纯影响计算边界") + script = ("import sys,copy\nsys.path.insert(0,sys.argv[1])\n" + "from codeguard.java_impact import select_impact\n" + "from codeguard.java_planning import default_commands\n" + "modules={'.':{'dependencies':[]}, 'api':{'dependencies':['app']}, " + "'app':{'dependencies':['api']}, 'other':{'dependencies':[]}}\n" + "before=copy.deepcopy(modules)\n" + "affected,full=select_impact(modules,['api/src/A.java'],conservative=False)\n" + "assert affected == ['api','app'] and not full\nassert modules == before\n" + "assert default_commands('maven','./mvnw',affected,full=full,bump_only=False) == " + "[{'kind':'verify','argv':['./mvnw','-B','-pl','api,app','-am','-DskipTests','verify']}]\n" + "assert not any(m in sys.modules for m in " + "('subprocess','codeguard.execution','codeguard.java_build','mcp','gate_lib'))\n") + result = subprocess.run([sys.executable, "-I", "-c", script, str(ROOT / "scripts")], + text=True, capture_output=True, check=False, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + + def test_jdk_legacy_version_and_property_reference_resolve_through_real_probe(self): + from java_project import _resolve_java_home + + binary = self.root / "update-alternatives" + binary.write_text(f"#!{sys.executable}\nprint('/jdk/java-8-fixture/bin/java')\n") + binary.chmod(0o755) + for properties in ("1.8", + "${target}1.8"): + with self.subTest(properties=properties): + (self.root / "pom.xml").write_text(self.pom(f"{properties}")) + with patch("sys.platform", "linux"), patch.dict(os.environ, {"PATH": str(self.root)}): + home, reason = _resolve_java_home(self.root) + self.assertEqual("/jdk/java-8-fixture", home, reason) + + def test_unavailable_git_baseline_never_downgrades(self): + (self.root / "pom.xml").write_text(self.pom()) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + self.assertEqual("verify", analyze(self.root, ["pom.xml"])["commands"][0]["argv"][-1]) + + def test_revision_property_used_in_dependency_cannot_be_release_only(self): + body = self.pom("1\n" + "externallib" + "${revision}") + path = self.baseline("pom.xml", body) + path.write_text(body.replace("1", "2")) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + + def test_revision_property_for_project_version_keeps_lightweight_check(self): + body = self.pom("1").replace( + "1", "${revision}") + path = self.baseline("pom.xml", body) + path.write_text(body.replace("1", "2")) + self.assertTrue(_is_version_bump_only(self.root, ["pom.xml"])) + + def test_gradle_version_in_multiline_string_does_not_downgrade(self): + body = 'plugins { java }\nval template = """\nversion = \'1\'\n"""\n' + path = self.baseline("build.gradle.kts", body) + path.write_text(body.replace("'1'", "'2'")) + self.assertFalse(_is_version_bump_only(self.root, ["build.gradle.kts"])) + + def test_unknown_config_and_outside_changes_are_visible(self): + (self.root / "pom.xml").write_text(self.pom()) + for configuration in ({"java": []}, {"java": {"commands": [[]]}}, {"exclude": "oops"}): + with self.subTest(configuration=configuration): + (self.root / "codeguard.json").write_text(json.dumps(configuration)) + plan = analyze(self.root) + self.assertEqual("UNVERIFIED", plan["status"]) + self.assertEqual([], plan["commands"]) + (self.root / "codeguard.json").write_text("{}") + self.assertEqual("UNVERIFIED", analyze(self.root, ["../outside.java"])["status"]) + + def test_build_profiles_force_full_but_empty_changes_remain_skipped(self): + (self.root / "pom.xml").write_text(self.pom("dynamic")) + plan = analyze(self.root, ["src/A.java"]) + self.assertTrue(plan["conservative"]) + self.assertIn("profiles", " ".join(plan["reasons"])) + self.assertEqual("SKIPPED", analyze(self.root, [])["status"]) + + def test_planning_does_not_mutate_real_git_index(self): + path = self.baseline("pom.xml", self.pom()) + path.write_text(path.read_text().replace("1", "2")) + before = self.git("diff", "--cached", "--binary") + index = self.root / ".git" / "index" + before_bytes = index.read_bytes() + plan = analyze(self.root, ["pom.xml"]) + self.assertEqual("PLANNED", plan["status"]) + self.assertEqual(before, self.git("diff", "--cached", "--binary")) + self.assertEqual(before_bytes, index.read_bytes()) + + def test_nested_project_compares_its_own_git_blob(self): + self.baseline("pom.xml", self.pom()) + nested = self.root / "nested" + nested.mkdir() + file = nested / "pom.xml" + body = self.pom().replace("app", "nested") + file.write_text(body) + self.git("add", "nested/pom.xml") + self.git("commit", "-qm", "nested") + file.write_text(body.replace("1", "2")) + self.assertTrue(_is_version_bump_only(nested, ["pom.xml"])) + + def test_nested_parent_blob_cannot_hide_changed_dependency(self): + # 顶层碰巧与嵌套项目当前依赖相同,不能拿顶层旧文件当嵌套项目基线。 + root_body = self.pom("xdep" + "3") + self.baseline("pom.xml", root_body) + nested = self.root / "nested" + nested.mkdir() + file = nested / "pom.xml" + file.write_text(root_body.replace("3", "2")) + self.git("add", "nested/pom.xml") + self.git("commit", "-qm", "nested") + file.write_text(root_body.replace("1", "1.1")) + self.assertFalse(_is_version_bump_only(nested, ["pom.xml"])) + + def test_version_bump_with_plugin_script_whitespace_is_not_pure(self): + body = self.pom("script-plugin" + "") + path = self.baseline("pom.xml", body) + path.write_text(body.replace("1", "2").replace(" run()", "run()")) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + + def test_revision_referenced_by_plugin_attribute_is_not_pure(self): + body = self.pom('1\n' + 'script-plugin' + '') + path = self.baseline("pom.xml", body) + path.write_text(body.replace("1", "2")) + self.assertFalse(_is_version_bump_only(self.root, ["pom.xml"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_language_services.py b/tests/test_language_services.py new file mode 100644 index 0000000..7c09e3f --- /dev/null +++ b/tests/test_language_services.py @@ -0,0 +1,232 @@ +"""语言服务行为:配置不能静默失效,探活不能跨检查轮次复用旧结论。""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import detect_lang +from user_config import load_project_overrides +from validate_languages_json import check + + +class LanguageServicesTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def run_python(self, body: str, *, stdin: str = ""): + return subprocess.run( + [sys.executable, "-c", f"import sys; sys.path.insert(0, {str(SCRIPTS)!r})\n" + body], + cwd=self.root, input=stdin, text=True, capture_output=True, timeout=15, check=False, + ) + + def test_failed_probe_is_retried_after_tool_recovers(self): + probe = [sys.executable, "-c", ("from pathlib import Path; import sys; " + f"sys.exit(0 if Path({str(self.root / 'ready')!r}).exists() else 1)")] + self.assertFalse(detect_lang.probe_toolchain({"probe": probe})[0]) + (self.root / "ready").touch() + self.assertEqual((True, ""), detect_lang.probe_toolchain({"probe": probe})) + + def test_successful_probe_is_not_reused_in_later_check(self): + marker = self.root / "ready" + marker.touch() + probe = [sys.executable, "-c", ("from pathlib import Path; import sys; " + f"sys.exit(0 if Path({str(marker)!r}).exists() else 1)")] + self.assertTrue(detect_lang.probe_toolchain({"probe": probe})[0]) + marker.unlink() + self.assertFalse(detect_lang.probe_toolchain({"probe": probe})[0]) + + def test_probe_uses_each_callers_working_directory(self): + other = self.root / "other" + other.mkdir() + (self.root / "ready").touch() + result = self.run_python( + "import os, detect_lang\n" + "definition = {'probe': [sys.executable, '-c', " + "\"from pathlib import Path; import sys; sys.exit(0 if Path('ready').exists() else 1)\"]}\n" + "assert detect_lang.probe_toolchain(definition)[0]\n" + "os.chdir('other')\n" + "assert not detect_lang.probe_toolchain(definition)[0]\n" + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_invalid_probe_encoding_is_reported_without_crashing(self): + result = self.run_python( + "import detect_lang\n" + "ok, reason = detect_lang.probe_toolchain({'probe': [sys.executable, '-c', " + "\"import os; os.write(2, b'broken ' + bytes([255])); raise SystemExit(2)\"]})\n" + "assert not ok and '2' in reason and 'broken' in reason, (ok, reason)\n" + ) + self.assertEqual(0, result.returncode, result.stderr) + + @unittest.skipIf(os.name == "nt", "本夹具通过 POSIX shebang 启动;Windows 需独立运行验收") + def test_implicit_binary_probe_does_not_consume_host_input(self): + tool = self.root / "stdin-probe" + tool.write_text(f"#!{sys.executable}\nimport sys\nassert sys.stdin.read() == ''\n") + tool.chmod(0o755) + result = self.run_python( + "import detect_lang\n" + f"ok, reason = detect_lang.probe_toolchain({{'lint': [{str(tool)!r}]}})\n" + "assert ok, reason\nassert sys.stdin.read() == 'host payload'\n", stdin="host payload", + ) + self.assertEqual(0, result.returncode, result.stderr) + + def test_nested_markers_do_not_override_explicit_project_config(self): + (self.root / "codeguard.json").write_text(json.dumps({"extensions": {".custom": "lua"}})) + nested = self.root / "src" / "nested" + nested.mkdir(parents=True) + (nested / "pom.xml").touch() + (nested / "demo.custom").touch() + self.assertEqual(["lua"], detect_lang.detect_languages(self.root)) + + def test_root_discovery_is_not_limited_to_ten_parents(self): + (self.root / ".git").mkdir() + nested = self.root.joinpath(*[f"d{n}" for n in range(15)]) + nested.mkdir(parents=True) + self.assertEqual(self.root.resolve(), detect_lang.find_project_root(nested)) + + def test_invalid_configuration_is_not_treated_as_defaults(self): + for raw in ("{", "[]", '{"exclude": "vendor"}', '{"exclude": ["["]}', + '{"extensions": []}', '{"extensions": {".custom": "unknown-language"}}', + '{"gate_scope": "rep0"}'): + with self.subTest(raw=raw): + (self.root / "codeguard.json").write_text(raw) + with self.assertRaisesRegex(ValueError, "codeguard.json"): + load_project_overrides(self.root) + + def test_invalid_configuration_cli_is_unverified_not_no_language(self): + (self.root / ".git").mkdir() + (self.root / "codeguard.json").write_text("{") + result = subprocess.run([sys.executable, str(SCRIPTS / "run_check.py"), str(self.root)], + text=True, capture_output=True, timeout=15, check=False) + self.assertEqual(1, result.returncode) + self.assertIn("UNVERIFIED", result.stderr) + self.assertIn("codeguard.json", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_registry_bad_shapes_return_diagnostics(self): + base = json.loads((SCRIPTS / "languages.json").read_text()) + for bad in ([], {**base, "languages": {}}, + {**base, "languages": [{"id": [], "status": []}]}, + {**base, "languages": [None]}): + with self.subTest(bad=bad): + try: + errors = check(bad) + except (AttributeError, TypeError) as exc: + self.fail(f"注册表非法形状必须产生诊断,而非崩溃: {exc}") + self.assertTrue(errors) + + def test_batch_shares_probe_in_target_root_but_next_batch_rechecks(self): + from codeguard.gate import run_batch + + # 探活与 lint 都在夹具根执行;计数文件证明真实进程运行次数。 + (self.root / "ready").touch() + definition = { + "probe": [sys.executable, "-c", ("from pathlib import Path; " + "assert Path('ready').exists(); " + "p=Path('probes'); p.write_text(p.read_text()+'x' if p.exists() else 'x')")], + "lint": [sys.executable, "-c", "from pathlib import Path; assert Path('ready').exists()"], + "append_files": False, + } + with patch.dict(detect_lang.LANG_COMMANDS, {"typescript": definition, "vue": definition}): + self.assertEqual(([], []), run_batch(self.root, {}, ["typescript", "vue"])) + self.assertEqual("x", (self.root / "probes").read_text()) + self.assertEqual(([], []), run_batch(self.root, {}, ["typescript", "vue"])) + self.assertEqual("xx", (self.root / "probes").read_text()) + + def test_bad_config_cannot_be_bypassed_by_explicit_language_or_fix(self): + from run_per_language import run_check, run_fix + + (self.root / "codeguard.json").write_text("{") + marker = self.root / "should-not-run" + command = [sys.executable, "-c", f"from pathlib import Path; Path({str(marker)!r}).touch()"] + with patch.dict(detect_lang.LANG_COMMANDS, {"python": {"lint": command, "format": command}}): + for output in (run_check(["python"], self.root), run_fix(["python"], self.root)): + self.assertEqual("UNVERIFIED", output[0]["status"]) + self.assertFalse(marker.exists()) + + def test_soft_gate_reports_config_error_without_exception_or_pass(self): + from codeguard.gate import run_gate + + (self.root / "codeguard.json").write_text("{") + failures, notes = run_gate(self.root, {}, ["python"]) + self.assertFalse(failures) + self.assertTrue(any("UNVERIFIED" in note and "codeguard.json" in note for note in notes)) + + def test_runtime_registry_rejects_duplicates_before_building_maps(self): + from codeguard.registry import load_registry + + data = json.loads((SCRIPTS / "languages.json").read_text()) + data["languages"].append(dict(data["languages"][0])) + registry = self.root / "languages.json" + registry.write_text(json.dumps(data)) + with self.assertRaisesRegex(ValueError, "duplicate id"): + load_registry(registry) + + def test_explicit_probe_directory_does_not_change_host_cwd(self): + before = Path.cwd() + (self.root / "ready").touch() + result = detect_lang.probe_toolchain({"probe": [sys.executable, "-c", + "from pathlib import Path; assert Path('ready').exists()"]}, project_root=self.root) + self.assertEqual((True, ""), result) + self.assertEqual(before, Path.cwd()) + + def test_same_batch_failure_can_recover(self): + from codeguard.toolchain import ToolchainProbe + + service = ToolchainProbe(self.root) + definition = {"probe": [sys.executable, "-c", "from pathlib import Path; assert Path('ready').exists()"]} + self.assertFalse(service.probe(definition)[0]) + (self.root / "ready").touch() + self.assertTrue(service.probe(definition)[0]) + + def test_different_tools_are_probed_concurrently(self): + from concurrent.futures import ThreadPoolExecutor + + from codeguard.toolchain import ToolchainProbe + + service = ToolchainProbe(self.root) + commands = [] + for own, other in (("a", "b"), ("b", "a")): + body = ("from pathlib import Path\nimport time\n" + f"Path({own!r}).touch()\nlimit=time.monotonic()+2\n" + f"while not Path({other!r}).exists() and time.monotonic() @unittest.skipUnless(MCP_AVAILABLE, "mcp SDK not installed (pip install -r requirements.txt)") class McpServerTests(unittest.TestCase): + def test_bad_project_configuration_is_unverified_and_recoverable(self): + """显式 languages 不能绕过配置;坏请求不损坏长驻 stdio 服务。""" + proc = _spawn_mcp() + try: + _send(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"}}}) + _read_until(proc, 1) + _send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"}) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / ".git").mkdir() + (root / "codeguard.json").write_text("{") + for request_id, name in enumerate(("check_code_style", "auto_fix"), start=2): + _send(proc, {"jsonrpc": "2.0", "id": request_id, "method": "tools/call", "params": { + "name": name, "arguments": {"path": temp, "languages": ["python"]}}}) + response = _read_until(proc, request_id) + payload = json.loads(response["result"]["content"][0]["text"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["passed"]) + self.assertIn("codeguard.json", payload["error"]) + (root / "codeguard.json").write_text("{}") + _send(proc, {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { + "name": "check_code_style", "arguments": {"path": temp, "languages": ["cobol"]}}}) + payload = json.loads(_read_until(proc, 4)["result"]["content"][0]["text"]) + self.assertEqual("PLANNED", payload[0]["status"]) + self.assertFalse((root / "out").exists()) + finally: + if proc.stdin: + proc.stdin.close() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) + proc.stdout.close() + proc.stderr.close() + def test_mcp_boot_lists_four_tools(self): """stdio 服务暴露三个兼容工具和 Java 只读分析。""" proc = _spawn_mcp() diff --git a/tests/test_p0_routing_openspec_skipgate.py b/tests/test_p0_routing_openspec_skipgate.py index 4970323..ec3b4c5 100644 --- a/tests/test_p0_routing_openspec_skipgate.py +++ b/tests/test_p0_routing_openspec_skipgate.py @@ -52,25 +52,21 @@ def _fresh_repo() -> Path: class TimeoutRoutingTests(unittest.TestCase): def test_timeout_expired_is_routed_as_skipped_not_failure(self): - """_run_one 捕获 TimeoutExpired → 返回 (124, "", "timeout after Ns")。 - - 上游 run_gate 把 124 视为 SKIPPED;本测锁定"超时不阻塞硬门禁"的契约。 - """ - # 通过反射检查:try 块有 TimeoutExpired 分支且 return 含 124。 - import ast - import inspect - src = Path(inspect.getfile(gate_lib)).read_text(encoding="utf-8") - tree = ast.parse(src) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "_run_one": - body_src = ast.unparse(node.body) - self.assertIn("TimeoutExpired", body_src) - # 必须是 tuple 形式(return (124, ...)),不是裸 124 - self.assertRegex(body_src, r"return\s*\(124\b") - # 必须同时处理 subprocess exit 124(避免 TimeoutExpired + 子进程 124 二者漏一种) - self.assertIn("proc.returncode == 124", body_src) - return - self.fail("_run_one not found in gate_lib.py") + """实跑两类超时,断言不拦截且明确未验证;不依赖内部实现位置。""" + from unittest.mock import patch + + for code in ("import time; time.sleep(30)", "raise SystemExit(124)"): + with self.subTest(code=code), tempfile.TemporaryDirectory() as td: + root = Path(td) + command = {"lint": [sys.executable, "-c", code]} + with patch.dict(gate_lib.LANG_COMMANDS, {"python": command}), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(root / "state")}): + failures, skipped = gate_lib._run_gate_uncached( + root, {"lint_timeout_seconds": 0.1}, ["python"]) + self.assertEqual([], failures) + self.assertEqual(1, len(skipped)) + self.assertIn("未验证", skipped[0]) + self.assertIn("124", skipped[0]) def test_default_timeout_bumped_to_300s(self): """user_config 默认 lint_timeout_seconds 从 120 提到 300——Maven install 冷缓存普遍超时 2 分钟。 @@ -181,4 +177,4 @@ def test_pushed_from_workspace_root_with_subdir_repos_is_covered(self): if __name__ == "__main__": - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) diff --git a/tests/test_readme_parity.py b/tests/test_readme_parity.py index f3360f2..c71762f 100755 --- a/tests/test_readme_parity.py +++ b/tests/test_readme_parity.py @@ -73,6 +73,12 @@ def test_local_link_targets_match(self) -> None: self.assertEqual(en_l, zh_l, msg=f"link drift: only_en={en_l - zh_l} only_zh={zh_l - en_l}") + def test_local_link_targets_exist(self) -> None: + for target in _local_links(self.en) | _local_links(self.zh): + path = target.split("#", 1)[0] + if path: + self.assertTrue((ROOT / path).exists(), f"broken README link: {target}") + def test_version_strings_match(self) -> None: en_v, zh_v = _versions(self.en), _versions(self.zh) self.assertEqual(en_v, zh_v, diff --git a/tests/test_state_storage.py b/tests/test_state_storage.py new file mode 100644 index 0000000..216060a --- /dev/null +++ b/tests/test_state_storage.py @@ -0,0 +1,223 @@ +"""真实多进程验证状态更新,真实 Hook 验证会话归属和未知结果后的重试。""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "scripts"), str(ROOT / "hooks")] +import post_tool_lint +from detect_lang import LANG_COMMANDS + + +class StateStorageTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="cg-state-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.home = self.root / "state" + self.env = {**os.environ, "CODEGUARD_HOME": str(self.home), "PYTHONDONTWRITEBYTECODE": "1"} + + def test_parallel_statistics_skip_events_and_audits_do_not_lose_updates(self): + source = ( + "import sys,time;from pathlib import Path;" + "sys.path[:0]=[sys.argv[1]+'/scripts',sys.argv[1]+'/hooks'];" + "import post_tool_lint,gate_lib;" + "root=Path(sys.argv[2]);(root/('ready-'+sys.argv[3])).touch();\n" + "while not (root/'go').exists(): time.sleep(0.005)\n" + "for i in range(35):\n" + " post_tool_lint.bump_state('python',True)\n" + " gate_lib.record_skip_event('fixture')\n" + " gate_lib.record_gate_decision(root,'python',[sys.argv[3],str(i)],0,'PASS','fixture',limit=1000)\n") + processes = [subprocess.Popen([sys.executable, "-c", source, str(ROOT), str(self.root), str(n)], + env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for n in range(6)] + try: + deadline = time.monotonic() + 15 + while len(list(self.root.glob("ready-*"))) != 6 and time.monotonic() < deadline: + time.sleep(0.01) + self.assertEqual(6, len(list(self.root.glob("ready-*")))) + (self.root / "go").touch() + for proc in processes: + _out, err = proc.communicate(timeout=30) + self.assertEqual(0, proc.returncode, err) + finally: + for proc in processes: + if proc.poll() is None: + proc.kill() + proc.communicate() + state = json.loads((self.home / "session_state.json").read_text()) + self.assertEqual(210, state["python"]["total"]) + self.assertEqual(210, state["python"]["passed"]) + self.assertEqual(210, state["_skip"]["count"]) + logs = [json.loads(line) for line in (self.home / "gate-decisions.jsonl").read_text().splitlines()] + self.assertEqual(210, len(logs)) + self.assertEqual(210, len({tuple(row["cmd"]) for row in logs})) + + def hook(self, name, repo, payload): + result = subprocess.run([sys.executable, str(ROOT / "hooks" / name)], cwd=repo, + env=self.env, input=json.dumps(payload), text=True, + capture_output=True, timeout=20, check=False) + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn("fail-open", result.stderr) + return result.stdout + + def repo(self, name): + repo = self.root / name + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True, capture_output=True) + (repo / "a.py").write_text("print(1)\n") + return repo + + def test_stop_does_not_consume_another_sessions_statistics(self): + repo = self.repo("repo") + for session in ("first", "second"): + out = self.hook("post_tool_lint.py", repo, + {"session_id": session, "tool_input": {"file_path": str(repo / "a.py")}}) + self.assertIn("passed", out) + first = self.hook("stop_summary.py", repo, {"session_id": "first"}) + second = self.hook("stop_summary.py", repo, {"session_id": "second"}) + self.assertIn("共 1 次检查", first) + self.assertIn("共 1 次检查", second) + + def test_same_session_in_two_worktrees_has_separate_statistics(self): + left, right = self.repo("left"), self.root / "right" + for args in (("config", "user.name", "fixture"), ("config", "user.email", "test@example.invalid"), + ("config", "core.hooksPath", "/dev/null"), ("add", "a.py"), ("commit", "-qm", "base"), + ("worktree", "add", "-q", "--detach", str(right))): + subprocess.run(["git", *args], cwd=left, check=True, capture_output=True) + for repo in (left, right): + out = self.hook("post_tool_lint.py", repo, + {"session_id": "same", "tool_input": {"file_path": str(repo / "a.py")}}) + self.assertIn("passed", out) + for repo in (left, right): + out = self.hook("stop_summary.py", repo, {"session_id": "same"}) + self.assertIn("共 1 次检查", out) + + def test_unknown_result_does_not_suppress_immediate_retry(self): + target = self.root / "a.py" + target.write_text("print(1)\n") + checker = self.root / "checker.py" + checker.write_text("from pathlib import Path\np=Path('attempts')\n" + "n=int(p.read_text())+1 if p.exists() else 1\np.write_text(str(n))\n" + "raise SystemExit(2 if n==1 else 0)\n") + commands = {"lint": [sys.executable, str(checker), "{file}"]} + with patch.dict(os.environ, self.env), patch.dict(LANG_COMMANDS, {"python": commands}), \ + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(target)}), \ + patch.object(post_tool_lint, "find_project_root", return_value=self.root), \ + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": False}), \ + patch.object(post_tool_lint, "DEDUP_FILE", self.home / "hook_dedup.json"): + out = io.StringIO() + with contextlib.redirect_stdout(out): + post_tool_lint.main() + post_tool_lint.main() + self.assertEqual("2", (self.root / "attempts").read_text()) + self.assertIn("UNVERIFIED", out.getvalue()) + self.assertIn("passed", out.getvalue()) + + def test_duplicate_hard_gate_event_cannot_turn_a_rejection_into_permission(self): + repo = self.repo("repo") + (repo / "a.py").write_text("undefined_variable()\n") + subprocess.run(["git", "add", "a.py"], cwd=repo, check=True, capture_output=True) + payload = {"session_id": "one", "tool_use_id": "same-event", + "tool_input": {"command": "git commit -m fixture"}} + for _ in range(2): + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + cwd=repo, env=self.env, input=json.dumps(payload), text=True, + capture_output=True, timeout=20, check=False) + self.assertEqual(2, result.returncode, result.stdout + result.stderr) + self.assertIn("F821", result.stderr) + + def test_transaction_error_keeps_previous_data_and_removes_temporary_files(self): + from codeguard.storage import update_json + + path = self.root / "sample.json" + path.write_text('{"count": 3}') + def failing_change(state): + state["count"] = 4 + raise ValueError("fixture") + with self.assertRaises(ValueError): + update_json(path, failing_change) + self.assertEqual({"count": 3}, json.loads(path.read_text())) + with patch("codeguard.storage.os.replace", side_effect=OSError("disk failure")), self.assertRaises(OSError): + update_json(path, lambda state: state.update(count=5)) + self.assertEqual({"count": 3}, json.loads(path.read_text())) + self.assertEqual([], list(self.root.glob("*.tmp"))) + + def test_same_hard_event_rechecks_changed_content_after_permission(self): + repo = self.repo("repo") + payload = {"session_id": "one", "tool_use_id": "same-event", + "tool_input": {"command": "git commit -m fixture"}} + for content, expected_code in (("print(1)\n", 0), ("undefined_variable()\n", 2)): + (repo / "a.py").write_text(content) + subprocess.run(["git", "add", "a.py"], cwd=repo, check=True, capture_output=True) + result = subprocess.run([sys.executable, str(ROOT / "hooks/pre_tool_git_guard.py")], + cwd=repo, env=self.env, input=json.dumps(payload), text=True, + capture_output=True, timeout=20, check=False) + self.assertEqual(expected_code, result.returncode, result.stdout + result.stderr) + self.assertNotIn("fail-open", result.stderr) + if expected_code == 2: + self.assertIn("F821", result.stderr) + + def test_unverified_prompt_is_not_silenced_by_seen_event(self): + repo = self.repo("repo") + (repo / "ruff.toml").write_text("invalid = [\n") + subprocess.run(["git", "add", "a.py"], cwd=repo, check=True, capture_output=True) + payload = {"session_id": "same", "user_prompt": "提交 Python 代码"} + for _ in range(2): + out = self.hook("user_prompt_validator.py", repo, payload) + self.assertIn("未验证", out) + + def test_failed_observer_is_retried_before_completion_is_recorded(self): + from codeguard import hook_state + + self.assertTrue(hasattr(hook_state, "observe_once"), "必须按完成结果去重,不能预占检查事件") + attempts = [] + def observe(): + attempts.append(1) + if len(attempts) == 1: + raise RuntimeError("fixture") + return 0 + with patch.dict(os.environ, self.env): + with self.assertRaises(RuntimeError): + hook_state.observe_once("start:fixture", observe) + self.assertEqual(0, hook_state.observe_once("start:fixture", observe)) + self.assertEqual(0, hook_state.observe_once("start:fixture", observe)) + self.assertEqual(2, len(attempts)) + + def test_stop_consumption_and_concurrent_new_writes_do_not_lose_counts(self): + from codeguard.storage import take_json + + source = ("import sys;sys.path[:0]=[sys.argv[1]+'/scripts',sys.argv[1]+'/hooks'];" + "import post_tool_lint;\nfor _ in range(80): post_tool_lint.bump_state('python',True)\n") + workers = [subprocess.Popen([sys.executable, "-c", source, str(ROOT)], env=self.env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) for _ in range(3)] + total = 0 + deadline = time.monotonic() + 20 + try: + while any(p.poll() is None for p in workers) and time.monotonic() < deadline: + total += take_json(self.home / "session_state.json").get("python", {}).get("total", 0) + time.sleep(0.003) + for worker in workers: + _out, err = worker.communicate(timeout=5) + self.assertEqual(0, worker.returncode, err) + total += take_json(self.home / "session_state.json").get("python", {}).get("total", 0) + finally: + for worker in workers: + if worker.poll() is None: + worker.kill() + worker.communicate() + self.assertEqual(240, total) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verdict_integrity.py b/tests/test_verdict_integrity.py index 20e2ff5..872564f 100644 --- a/tests/test_verdict_integrity.py +++ b/tests/test_verdict_integrity.py @@ -22,6 +22,7 @@ import pre_tool_git_guard import run_check import run_per_language +from codeguard import cve_scanners from detect_lang import LANG_COMMANDS # auto_fix 用例需真实 ruff 执行 format/check——工具缺失时断言的修复路径无法覆盖。 @@ -184,10 +185,12 @@ def test_add_update_does_not_include_untracked_files(self): self.assertNotIn("untracked", lanes) def test_scoped_add_all_keeps_directory_scope(self): + self.put("src/a.py", "x = 1\n") + self.put("other.py", "x = 2\n") with patch.object(Path, "cwd", return_value=self.root): lanes, extra = pre_tool_git_guard.staging_intent("git add -A src && git commit -m t") self.assertNotIn("untracked", lanes) - self.assertIn("src", extra) + self.assertEqual(["src/a.py"], extra) def exact_gate(self, mode="commit", lanes=("staged",), extra=None): self.assertIn("exact", inspect.signature(gate_lib.run_gate).parameters, @@ -250,7 +253,7 @@ def test_deleted_file_is_retained_for_impact_but_not_safety(self): class CveEvidenceTests(RepoCase): def test_bad_severity_is_usage_not_vulnerability(self): with patch.object(sys, "argv", ["cve_check.py", "--severity", "invalid"]), \ - contextlib.redirect_stderr(io.StringIO()), patch.object(cve_check, "run") as runner: + contextlib.redirect_stderr(io.StringIO()), patch.object(cve_scanners, "run") as runner: try: code = cve_check.main() except SystemExit as exc: @@ -260,7 +263,7 @@ def test_bad_severity_is_usage_not_vulnerability(self): def invoke(self, rc, stdout, stderr, severity="HIGH"): self.put("package.json", "{}") - with patch.object(cve_check, "run", return_value=(rc, stdout, stderr)), \ + with patch.object(cve_scanners, "run", return_value=(rc, stdout, stderr)), \ patch.object(sys, "argv", ["cve_check.py", "--ecosystem", "node", "--severity", severity, str(self.root)]): out = io.StringIO() @@ -289,8 +292,9 @@ def test_moderate_is_medium_threshold(self): def test_json_output_is_one_machine_readable_document(self): self.put("package.json", "{}") - report = json.dumps({"metadata": {"vulnerabilities": {"high": 0}}}) - with patch.object(cve_check, "run", return_value=(0, report, "")), \ + report = json.dumps({"metadata": {"vulnerabilities": { + "info": 0, "low": 0, "moderate": 0, "high": 0, "critical": 0, "total": 0}}}) + with patch.object(cve_scanners, "run", return_value=(0, report, "")), \ patch.object(sys, "argv", ["cve_check.py", "--json", "--ecosystem", "node", str(self.root)]): out = io.StringIO() with contextlib.redirect_stdout(out): @@ -310,10 +314,10 @@ def fake(cmd, **kwargs): {"vulnerabilities": [{"name": "CVE-2026-0000", "cvssv3": {"baseScore": 9.1}}]}]})) return 1, "threshold exceeded", "" # 只有具备结构化输出配置后才启动 fake,缺特性作为断言失败。 - with patch.object(cve_check, "run", return_value=(1, "network timeout", "")): + with patch.object(cve_scanners, "run", return_value=(1, "network timeout", "")): unavailable = cve_check.scan_maven(self.root, 7) self.assertEqual(unavailable.get("status"), "UNVERIFIED") - with patch.object(cve_check, "run", side_effect=fake): + with patch.object(cve_scanners, "run", side_effect=fake): found = cve_check.scan_maven(self.root, 7) self.assertEqual(found["status"], "FAIL") @@ -324,20 +328,20 @@ def test_pip_report_is_scoped_to_project_requirements(self): def scan(cmd, **kwargs): seen.append(cmd) return 1, report, "" - with patch.object(cve_check, "run", side_effect=scan): + with patch.object(cve_scanners, "run", side_effect=scan): result = cve_check.scan_pip(self.root) self.assertEqual(result.get("status"), "FAIL") self.assertIn("--requirement", seen[0]) def test_cargo_json_failure_without_advisories_is_unverified(self): - with patch.object(cve_check, "run", side_effect=[(0, "cargo-audit", ""), (1, "", "network error")]): + with patch.object(cve_scanners, "run", side_effect=[(0, "cargo-audit", ""), (1, "", "network error")]): result = cve_check.scan_cargo(self.root) self.assertEqual(result.get("status"), "UNVERIFIED") def test_cargo_json_advisory_is_not_lost(self): report = json.dumps({"vulnerabilities": {"found": True, "count": 1, "list": [{"advisory": {"id": "RUSTSEC-TEST-0001"}}]}}) - with patch.object(cve_check, "run", side_effect=[(0, "cargo-audit", ""), (1, report, "")]): + with patch.object(cve_scanners, "run", side_effect=[(0, "cargo-audit", ""), (1, report, "")]): result = cve_check.scan_cargo(self.root) self.assertEqual(result.get("status"), "FAIL")