From ffc4ffba8ecbc6df782518a8fb559bf1f7a81930 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:08:42 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v0.14.7=20=E9=80=90=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=89=A7=E8=A1=8C=E8=AF=81=E6=8D=AE=E4=B8=8E=20MCP=20?= =?UTF-8?q?=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 8 +-- .codex-plugin/plugin.json | 2 +- .zcode-plugin/plugin.json | 2 +- README.md | 6 +- README.zh-CN.md | 6 +- docs/current-architecture.md | 1 + kimi.plugin.json | 2 +- .../specs/execution-kernel/spec.md | 9 +++ .../refactor-codeguard-architecture/tasks.md | 2 +- .../verification.md | 10 +++ scripts/codeguard/check_application.py | 29 +++++++- scripts/codeguard/language_check.py | 56 ++++++++++++---- tests/test_check_plans.py | 66 ++++++++++++++++++- tests/test_mcp_server.py | 48 ++++++++++++++ 14 files changed, 215 insertions(+), 32 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index fae5a15..ce145e1 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.14.6" + "ref": "v0.14.7" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.6", + "version": "0.14.7", "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.14.6/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.7/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.14.6/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.7/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5db15c0..f2d8522 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.6+codex.20260923", + "version": "0.14.7+codex.20260923", "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 7322161..ad9a9f6 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.6", + "version": "0.14.7", "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 0b10011..2c8d728 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The installed plugin version is recorded in its manifests. The architecture refa | PostToolUse | Edited file, for file-scoped tools | Feedback, exit 0; project-level checks deferred | | UserPromptSubmit | Working-tree changes relevant to commit intent | Advisory, never blocks the user message | | PreToolUse Git gate | Proposed index snapshot or HEAD snapshot for push | Verified violations exit 2; uncertain checks report UNVERIFIED and fail open | -| CLI check / MCP check_code_style | Project checks, including Java build verification | Explicit status, reason, raw exit code and output log | +| CLI check / MCP check_code_style | Project checks, including Java build verification | Explicit status, reason, raw exit code, ordered execution trace and output log | | pre-commit / CI | Independently configured checks | Separate acceptance; not replaced by hook success | Hooks do not run in every host command surface automatically. Historical V0.5.4 installation evidence is not acceptance of this version in Codex, ZCode or Kimi. @@ -107,12 +107,12 @@ python3 scripts/run_check.py --mcp /path/to/project | Tool | Contract | |---|---| -| check_code_style | Per-language status/reason/passed/raw exit code and full output log path | +| check_code_style | Per-language status/reason/passed/raw exit code, per-command status metadata and full failure log path | | auto_fix | Format Git-changed files and recheck the same scope; refuse unbounded project formatters; fixed means actual modifications | | list_languages | Registry ids and display names | | analyze_java_impact | Read-only plan; accepts path and optional changed array | -Output logs default to /out/.codeguard-last.log; CLI --quiet disables log writing. MCP auto_fix does not write when a Git scope cannot be established. +Output logs default to /out/.codeguard-last.log; CLI --quiet disables log writing. A failed multi-command check logs output from every executed check. The MCP execution trace reports phase, sequence, program, exit/failure and output lengths; it does not echo argv, environment overrides or captured output. Local logs can contain sensitive checker output: keep them out of version control. MCP auto_fix does not write when a Git scope cannot be established. ## Configuration and coverage diff --git a/README.zh-CN.md b/README.zh-CN.md index e19e75d..eebd1d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@ CodeGuard 为 AI 编程助手提供原生检查证据,守护已支持的 Git | PostToolUse | 文件型工具检查本次编辑文件 | 反馈、exit 0;项目级检查延后 | | UserPromptSubmit | 与提交意图相关的工作树变更 | 建议,不阻断用户消息 | | PreToolUse Git 门禁 | 拟提交 index 快照或推送 HEAD 快照 | 确定违规 exit 2;不确定项明确 UNVERIFIED 并 fail-open | -| CLI check / MCP check_code_style | 项目检查,含 Java 构建验证 | 明确状态、原因、原始退出码和日志 | +| CLI check / MCP check_code_style | 项目检查,含 Java 构建验证 | 明确状态、原因、原始退出码、顺序执行证据和日志 | | pre-commit / CI | 独立配置的检查 | 单独验收,不能由钩子成功代替 | 钩子不会自动覆盖宿主的每个命令入口。历史 V0.5.4 安装证据不能代表当前版本已在 Codex、ZCode、Kimi 验收。 @@ -107,12 +107,12 @@ python3 scripts/run_check.py --mcp /path/to/project | 工具 | 契约 | |---|---| -| check_code_style | 逐语言状态、原因、passed、原始退出码与完整日志路径 | +| check_code_style | 逐语言状态、原因、passed、原始退出码、逐命令状态元数据与完整失败日志路径 | | auto_fix | 只修 Git 改动文件、同范围复检,拒绝无界项目 formatter;fixed 表示实际修改 | | list_languages | 注册表语言标识和名称 | | analyze_java_impact | 只读计划,接受 path 和可选 changed 数组 | -日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。MCP auto_fix 无法确定 Git 范围时不会写入。 +日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。多命令检查失败时,日志保存每条已执行检查的完整输出。MCP 执行轨迹只返回阶段、序号、程序名、退出/故障及输出长度,不回显 argv、环境覆盖或检查器输出。本地日志可能含敏感文本,应排除出版本控制。MCP auto_fix 无法确定 Git 范围时不会写入。 ## 配置与覆盖 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 4f9963f..02c02e9 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,6 +27,7 @@ flowchart TD ``` `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 +`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 一条命令链中的 Git 副作用先绑定为仓库与操作的有序对,再按仓库聚合检查面:纯 push 不读取该仓未提交的 index,同仓 commit→push 则保留已有 HEAD 与拟提交内容的并集。仓库级、内联及链式 `skipGate` 均按目标仓与操作顺序生效;已存在的仓库配置也可被同链先行 `git config --unset` 取消。链式设置跨 `||`、`;` 或换行时无法证明生效,门禁要求拆分命令;写入其它文件的 config 设置也不构成本仓豁免。语法、仓库归属、间接脚本与拟暂存分析共用引号/转义感知切分;参数文本中的控制符不能合成豁免。解析能力仍限于受支持的静态 Shell 形态。 一层 `bash`/`sh`/`zsh` 的 `-c`(含组合短选项)及可读脚本现在携带调用目录与正文进入同一 Git 操作计划;脚本内的 `git add` 可进入拟暂存范围,外层和内层 Git 操作按仓分别检查。识别到无法建模的间接 Git 操作、脚本内 `skipGate` 配置变更或跨解释器的暂存/提交关系时,明确以 Git 意图 UNVERIFIED 阻断,不借用调用者仓的检查结果。动态脚本、任意控制流及 Python/Node 中构造的 subprocess 仍未建模。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 609f1a1..19ab735 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.6", + "version": "0.14.7", "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/specs/execution-kernel/spec.md b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md index 28b99e3..c6b9ffa 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -45,11 +45,20 @@ ### Requirement: Check plans SHALL retain execution context 检查计划 MUST 显式区分 repo、delta 与 save,保存物化后的命令、工作目录和逐命令环境覆盖。执行 MUST 按序进行,首个非零结果终止普通检查批次,保留已执行证据;未执行的命令不得计为成功。空计划不得生成 PASS。Java 原生计划给出的环境覆盖 MUST 在子进程中生效,不改变宿主环境。修复后的检查 MUST 复用原计划,不重新扩大范围。Git 门禁的逐文件基线豁免是独立策略,不由通用执行器决定。 +语言检查与修复的应用结果 MUST 保留实际运行的每条命令身份、目录、退出码、故障标识和有界输出摘要;MCP 适配不得只保留终止命令,但其公开执行轨迹 MUST 只包含阶段、序号、程序名、退出码、故障标识和输出长度,不得新增暴露原始 argv、命令环境覆盖或任意检查器输出。失败日志 MUST 保留同一检查批次中此前已执行命令的完整输出,不能只记录最后一条。证据不得把尚未运行的计划命令写成已执行。 #### Scenario: Java commands use the selected JDK - **WHEN** Java 项目计划选择 JAVA_HOME,且声明多条权威检查命令 - **THEN** 每条实际运行的命令使用该环境;后续命令失败时返回失败而非首条成功,宿主 JAVA_HOME 不变 +#### Scenario: Multi-command result retains prior execution evidence +- **WHEN** Java 权威计划的第一条命令输出诊断后通过,第二条命令失败,第三条未执行 +- **THEN** 内部检查结果保留前两条的 argv、目录、退出码、有界输出摘要和故障标识;MCP 返回对应的安全元数据与失败日志路径,不回显原始参数或输出;第三条不得出现于执行证据 + +#### Scenario: Configured command includes a credential argument +- **WHEN** 权威命令的 argv 含凭据文本,且检查器输出也包含该文本 +- **THEN** 真实子进程仍收到原始 argv,MCP 的 `check_code_style` 和 `auto_fix` 执行轨迹不回显凭据;日志仍按既有本地路径保存原始诊断 + #### Scenario: Repair retains the original scope - **WHEN** delta 或 save 检查失败并成功运行 formatter - **THEN** 复检执行原物化命令,仅包含原检查范围;被跳过的文件不导致漏掉其它文件的复检 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 8dda4dc..189fc1f 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -34,5 +34,5 @@ - [x] 5.2 四个历史 skipped 已全部恢复;Dockerfile CLI 接入统一报告解析与扫描应用,14 项真实进程边界测试通过;基线比较按诊断内容和次数核验并要求基线实际 FAIL,三项误豁免用例先红后绿。完整 433 项单测零跳过、142 项回归零失败。 - [x] 5.3 新增当前架构 Mermaid 与扩展指南;报告版本读取插件 manifest,双语 README 指向当前证据,旧设计标明历史范围;双语标题/链接/版本及本地链接存在性测试通过。发布版本仍需在 5.5 检查。 - [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十批 500 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第十九批已发布 v0.14.5(插件 PR #51、市场 PR #8、PR/main CI、注释 tag/正式 Release 均核对)。第二十批已准备 v0.14.6 元数据与本地验证,远端 PR/CI/tag/Release/市场主线仍待核对。宿主安装运行独立记录,未授权不得改宿主。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十批已发布 v0.14.6(插件 PR #52、市场 PR #9、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十一批新增的逐命令证据仍只在本地,需独立升级版本并发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index f8d7b3f..5e87c89 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -213,3 +213,13 @@ - `/opt/anaconda3/bin/python3` 3.13.5 完整执行 **498 unittest、0 skipped**;真实 Hook 回归 **143/0/0**。Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。CodeGraph 的 `affected` 对本次核心文件返回空,但真实目标测试存在且已运行,故不把空图谱结果当无影响证明。受管技能和 lock 未改;本批尚未版本升级、提交或发布,三宿主现场/Windows/在线漏洞库仍未验收。 - 同步最新 CodeGraph 后继续追踪 `git_syntax` 发现:`inline_skip_gate` 和 `skip_gate_config_change` 仍用原始空白分词,与已共用的直接 Git/解释器前缀语义分叉。真实仓库 RED 证明已有持久 `skipGate=true` 时,`env FOO=1 git config codeguard.skipGate false && git commit` 被错误放行;带前缀的 `git -c` 单次豁免则被错误阻断且失去审计。二者现复用同一 `_segment_tokens`,目标测试转绿;此证据补充前述批内回归,不将早先 498 项计数当最终全量结果。 - 最终本地完整回归:`/opt/anaconda3/bin/python3` 3.13.5 执行 **500 unittest、0 skipped**,真实 Hook **143/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。两项新 RED 场景及既有豁免/引号边界均转绿。本批已准备 v0.14.6 元数据;在 PR/CI/tag/Release/市场主线核对前,不能用 v0.14.5 的证据证明新改动已发布。 + +- 第二十批随后发布为 v0.14.6:源码 PR #52 合并为 `e32b10648b2d3e048b30ee6fc04f7945affa44fc`,市场 PR #9 合并为 `9430df2e24095dbb50eb65dde3e9df670853c9e8`;源码 PR 和合并后 main 的 `skills-check` 成功,远端注释 tag 解引用到源码 main 合并提交,GitHub Release 非 draft、非 prerelease。市场三宿主清单与 README 导航同步到 0.14.6,合并后远端 tag/Release 校验通过;市场 PR 没有报告 CI 检查,不能声称市场 CI 已通过。此发布不覆盖以下第二十一批工作树。 + +## 第二十一批实际验证:逐命令执行证据不在应用层丢失 + +- 将 v0.14.6 的当前源码同步到临时 CodeGraph 索引(136 文件;源仓未初始化索引),沿 `execute_plan → language_check.run_check/run_fix → check_application.result_envelope` 追踪。内核 `PlanExecution` 保留全部已执行命令,但应用只读取 `terminal`;Java 第一条命令通过并输出诊断、第二条失败时,前者从结果和失败日志消失。真实 Python 子进程测试先 RED(缺少 `execution_trace`),不是模拟返回值。 +- 语言检查现保留检查、修复和复检各阶段的实际命令、目录、退出码、故障标识及有界 stdout/stderr 尾部;MCP 的既有字段不变,附加逐命令安全元数据。失败日志记录所有已执行检查的完整输出,未执行的第三条命令不进入证据。Java 选定的 `JAVA_HOME` 进入子进程但不出现在公开 trace,宿主环境不变;`.zsh` 仍跳过 formatter,但修复后的复检保持原范围。 +- 真实 MCP stdio RED 复现:`codeguard.json` 权威命令中的哨兵 token 经原始 argv 和检查器输出进入新增轨迹,扩大了公开面。`check_application` 现将 `check_code_style` 与 `auto_fix` 的新增轨迹压成阶段/序号/程序名/退出与故障/输出长度,不回显 argv、环境覆盖或输出文本;原始证据仍由内部执行结果和本地失败日志保留。后者可能含敏感内容,文档明确要求排除版本控制,不宣称日志已脱敏。 +- 新目标测试经真实 `mcp_tool_payload` 路径转绿;进一步通过官方 MCP SDK 的 stdio 客户端从 `check_code_style` 收到两条实际命令状态与完整失败日志路径,token 未出现在 JSON 响应。加入 `auto_fix` 轨迹收敛测试后完整本地单测 **503/503、0 skipped**,Hook 回归 **143/0/0**,Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线/在线、本 change strict 与 diff whitespace 通过。受管技能与 lock 未改;这仍是本地证据,当前发布的 v0.14.6 不含本批修改,三宿主安装现场、真实 Maven/Gradle、在线漏洞库及 Windows 未验收。 +- 宿主安装登记只读抽查:本机 ZCode 的 `installed_plugins.json` 将 `codeguard@full-stack-plugins` 指向 `v0.12.1` 与其缓存目录;它不能证明 v0.14.6 或本批在 ZCode 的加载/触发。当前抽查的 Codex/Kimi 缓存路径未提供当前版证据,但不能据路径缺失推断所有宿主环境均未安装。宿主安装和真实加载需在后续有权限的独立验收中验证,不由源码测试、市场 ref 或旧缓存替代。 diff --git a/scripts/codeguard/check_application.py b/scripts/codeguard/check_application.py index b64e59d..7796782 100644 --- a/scripts/codeguard/check_application.py +++ b/scripts/codeguard/check_application.py @@ -28,12 +28,30 @@ def registry_entries() -> list[dict]: for entry in REGISTRY.values()] +def _public_trace(trace: list[dict]) -> list[dict]: + """MCP 只暴露逐命令状态;原始 argv/输出仍留在进程结果与本地日志。""" + phases: dict[str, int] = {} + output = [] + for attempt in trace: + phase = attempt["phase"] + number = phases.get(phase, 0) + 1 + phases[phase] = number + output.append({ + "phase": phase, "number": number, + "program": Path(attempt["command"][0]).name, + "exit_code": attempt["exit_code"], "failure": attempt["failure"], + "stdout_chars": attempt["stdout_chars"], + "stderr_chars": attempt["stderr_chars"], + }) + return output + + def result_envelope(results: list[dict]) -> list[dict]: """保留 MCP 历史字段;完整日志路径只来自实际检查。""" envelope = [] for result in results: log_path = result.get("log_path", "") - envelope.append({ + item = { "language": result.get("language", ""), "passed": bool(result.get("passed")), "status": result.get("status", "PASS" if result.get("passed") else "FAIL"), @@ -41,7 +59,10 @@ def result_envelope(results: list[dict]) -> list[dict]: "exit_code": int(result.get("exit_code", 0)), "stderr_path": log_path, "log_path": log_path, - }) + } + if "execution_trace" in result: + item["execution_trace"] = _public_trace(result["execution_trace"]) + envelope.append(item) return envelope @@ -56,7 +77,9 @@ def auto_fix(root: Path, languages: list[str]) -> dict: 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)} + public_fixes = [{**item, "execution_trace": _public_trace(item["execution_trace"])} + if "execution_trace" in item else item for item in fix_results] + return {"fixed": changed, "fix_results": public_fixes, "check": result_envelope(results)} def mcp_tool_payload(name: str, arguments: dict | None, project_root: Path): diff --git a/scripts/codeguard/language_check.py b/scripts/codeguard/language_check.py index 081e61d..f53fc7b 100644 --- a/scripts/codeguard/language_check.py +++ b/scripts/codeguard/language_check.py @@ -16,7 +16,7 @@ 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 .models import CheckPlan, Command, PlanExecution from .planning import scoped_plan from .registry import LANG_COMMANDS @@ -28,6 +28,28 @@ def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]: return execute(cmd, cwd, timeout).as_tuple() +def _execution_trace(execution: PlanExecution, phase: str) -> list[dict]: + """将实际运行的命令转成应用层有界证据;宿主适配再收敛公开字段。""" + return [{"phase": phase, "command": list(item.argv), "cwd": str(item.cwd), + "exit_code": item.returncode, "failure": item.failure, + "stdout_chars": len(item.stdout), "stderr_chars": len(item.stderr), + "stdout_tail": item.stdout[-1000:], "stderr_tail": item.stderr[-2000:]} + for item in execution.attempts] + + +def _execution_output(executions: list[tuple[str, PlanExecution]]) -> str: + """失败日志保留所有已执行检查的完整输出,不写计划中未运行的命令。""" + blocks = [] + for phase, execution in executions: + for number, item in enumerate(execution.attempts, start=1): + output = item.stdout + if item.stderr: + output += ("\n" if output and not output.endswith("\n") else "") + item.stderr + if output: + blocks.append(f"[{phase} #{number} exit={item.returncode}]\n{output}") + return "\n".join(blocks) + + def run_check(languages: list[str], project_root: Path, *, timeout: int = 120, fix: bool = False, dry_run: bool = False, log_dir: Path | None = None, @@ -86,30 +108,34 @@ def run_check(languages: list[str], project_root: Path, 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 + execution = execute_plan(plan, timeout) + check_executions = [("check", execution)] + trace = _execution_trace(execution, "check") + outcome = execution.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) + for repair in repairs: + trace.extend(repair.get("execution_trace", [])) if any(r.get("fixed") and not r.get("dry_run") for r in repairs): - outcome = execute_plan(plan, timeout).terminal + execution = execute_plan(plan, timeout) + check_executions.append(("recheck", execution)) + trace.extend(_execution_trace(execution, "recheck")) + outcome = execution.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)) + stderr_tail=err[-2000:], stdout_tail=out[-1000:], + execution_trace=trace)) + if status != PASS: + combined = _execution_output(check_executions) + if combined: + 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" @@ -186,11 +212,13 @@ def run_fix(languages: list[str], project_root: Path, 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() + execution = execute_plan(plan, timeout) + rc, _out, err = execution.terminal.as_tuple() results.append({ "language": lang, "fixed": rc == 0, "exit_code": rc, "stderr_tail": err[-2000:] if err else "", + "execution_trace": _execution_trace(execution, "fix"), }) return results diff --git a/tests/test_check_plans.py b/tests/test_check_plans.py index 3c51ea4..c062885 100644 --- a/tests/test_check_plans.py +++ b/tests/test_check_plans.py @@ -18,7 +18,7 @@ import gate_lib import post_tool_lint import run_per_language -from codeguard import execution, save_application +from codeguard import check_application, execution, save_application from detect_lang import LANG_COMMANDS @@ -49,6 +49,9 @@ def test_java_selected_environment_reaches_each_command_without_mutating_host(se 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(["check", "check"], + [item["phase"] for item in result["execution_trace"]]) + self.assertNotIn(selected, json.dumps(result["execution_trace"])) self.assertEqual("host-jdk", os.environ["JAVA_HOME"]) self.assertEqual([selected, selected], (self.root / "jdk.log").read_text().splitlines()) (self.root / "jdk.log").unlink() @@ -58,6 +61,65 @@ def test_java_selected_environment_reaches_each_command_without_mutating_host(se self.assertEqual([], skipped) self.assertEqual([selected, selected], (self.root / "jdk.log").read_text().splitlines()) + def test_java_multi_command_evidence_survives_result_mcp_and_failure_log(self): + self.put("pom.xml", "4.0.0demo" + "app1") + self.put("src/A.java", "class A {}") + commands = [ + [sys.executable, "-c", "print('x' * 2000 + 'preflight diagnostic')"], + [sys.executable, "-c", "import sys; print('compile failed', file=sys.stderr); sys.exit(1)"], + [sys.executable, "-c", "from pathlib import Path; Path('unexpected').touch()"], + ] + self.put("codeguard.json", json.dumps({"java": {"commands": commands}})) + with patch("codeguard.java_analysis.resolve_java_home", return_value=(None, None)): + result = run_per_language.run_check( + ["java"], self.root, files=["src/A.java"], log_dir=self.root / "out")[0] + self.assertEqual("FAIL", result["status"]) + self.assertEqual(commands[1], result["command"]) + self.assertFalse((self.root / "unexpected").exists()) + trace = result["execution_trace"] + self.assertEqual(commands[:2], [item["command"] for item in trace]) + self.assertEqual([0, 1], [item["exit_code"] for item in trace]) + self.assertEqual([str(self.root)] * 2, [item["cwd"] for item in trace]) + self.assertIn("preflight diagnostic", trace[0]["stdout_tail"]) + self.assertLessEqual(len(trace[0]["stdout_tail"]), 1000) + self.assertIn("compile failed", trace[1]["stderr_tail"]) + self.assertIsNone(trace[0]["failure"]) + public_trace = check_application.result_envelope([result])[0]["execution_trace"] + self.assertEqual([0, 1], [item["exit_code"] for item in public_trace]) + self.assertEqual([1, 2], [item["number"] for item in public_trace]) + self.assertEqual([Path(sys.executable).name] * 2, + [item["program"] for item in public_trace]) + self.assertGreater(public_trace[0]["stdout_chars"], 2000) + self.assertNotIn("preflight diagnostic", json.dumps(public_trace)) + self.assertNotIn("command", public_trace[0]) + with patch("codeguard.java_analysis.resolve_java_home", return_value=(None, None)): + mcp_payload = check_application.mcp_tool_payload( + "check_code_style", {"path": str(self.root), "languages": ["java"]}, self.root) + self.assertEqual(public_trace, mcp_payload[0]["execution_trace"]) + log_text = Path(result["log_path"]).read_text() + self.assertIn("preflight diagnostic", log_text) + self.assertIn("x" * 2000, log_text) + + def test_mcp_auto_fix_does_not_echo_new_trace_command_or_output(self): + self.put("src/A.java", "class A {}") + secret = "CG_TEST_SECRET_TOKEN_8b7f" + trace = [{"phase": "fix", "command": ["formatter", f"--token={secret}"], + "cwd": str(self.root), "exit_code": 0, "failure": None, + "stdout_chars": len(secret), "stderr_chars": 0, + "stdout_tail": secret, "stderr_tail": ""}] + fix_result = {"language": "java", "fixed": True, "exit_code": 0, + "stderr_tail": "", "execution_trace": trace} + check_result = {"language": "java", "passed": True, "status": "PASS", + "reason": "", "exit_code": 0, "execution_trace": trace} + with patch.object(check_application, "changed_files", return_value=["src/A.java"]), \ + patch.object(check_application, "run_fix", return_value=[fix_result]), \ + patch.object(check_application, "run_check", return_value=[check_result]): + payload = check_application.auto_fix(self.root, ["java"]) + self.assertNotIn(secret, json.dumps(payload)) + self.assertEqual("formatter", payload["fix_results"][0]["execution_trace"][0]["program"]) + self.assertEqual("formatter", payload["check"][0]["execution_trace"][0]["program"]) + def test_mixed_zsh_fix_rechecks_the_shell_plan_without_touching_other_files(self): self.put("a.sh", "GOOD") self.put("b.sh", "BAD") @@ -77,6 +139,8 @@ def test_mixed_zsh_fix_rechecks_the_shell_plan_without_touching_other_files(self 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(["check", "check", "fix", "fix", "recheck", "recheck", "recheck"], + [item["phase"] for item in result["execution_trace"]]) 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()) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index f494a82..70f5d5e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -72,6 +72,54 @@ def _read_until(proc: subprocess.Popen, want_id: int, timeout: float = 30.0) -> @unittest.skipUnless(MCP_AVAILABLE, "mcp SDK not installed (pip install -r requirements.txt)") class McpServerTests(unittest.TestCase): + def test_java_multi_command_trace_survives_stdio_tool_call(self): + with tempfile.TemporaryDirectory(prefix="cg-mcp-trace-") as tmp: + root = Path(tmp).resolve() + secret = "CG_TEST_SECRET_TOKEN_4c98f1" + (root / "src").mkdir() + (root / "src/A.java").write_text("class A {}", encoding="utf-8") + (root / "pom.xml").write_text( + "4.0.0demo" + "app1", encoding="utf-8") + commands = [ + [sys.executable, "-c", "import sys; print('first check ' + sys.argv[1])", + f"--token={secret}"], + [sys.executable, "-c", "import sys; print('second failed'); sys.exit(1)"], + [sys.executable, "-c", "from pathlib import Path; Path('unexpected').touch()"], + ] + (root / "codeguard.json").write_text( + json.dumps({"java": {"commands": commands}}), encoding="utf-8") + 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"}) + _send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { + "name": "check_code_style", "arguments": {"path": tmp, "languages": ["java"]}}}) + payload = json.loads(_read_until(proc, 2)["result"]["content"][0]["text"]) + self.assertEqual("FAIL", payload[0]["status"]) + trace = payload[0]["execution_trace"] + self.assertEqual([0, 1], [item["exit_code"] for item in trace]) + self.assertEqual(["check", "check"], [item["phase"] for item in trace]) + self.assertEqual([1, 2], [item["number"] for item in trace]) + self.assertEqual([Path(sys.executable).name] * 2, + [item["program"] for item in trace]) + self.assertNotIn(secret, json.dumps(payload)) + self.assertFalse((root / "unexpected").exists()) + self.assertIn("first check", Path(payload[0]["log_path"]).read_text()) + 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_bad_project_configuration_is_unverified_and_recoverable(self): """显式 languages 不能绕过配置;坏请求不损坏长驻 stdio 服务。""" proc = _spawn_mcp()