diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 707c6b0..4823167 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.12" + "ref": "v0.14.13" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.12", + "version": "0.14.13", "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.12/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.13/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.12/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.13/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f1c2c6d..6a2f449 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.12+codex.20260923", + "version": "0.14.13+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 4b42fd6..9daa777 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.12", + "version": "0.14.13", "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/docs/current-architecture.md b/docs/current-architecture.md index 8fdc26e..2fb7914 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -28,7 +28,8 @@ flowchart TD `execution` 只记录进程证据,不把非零退出自动认定为代码违规。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 Git 准确快照的二进制读取独立于文本检查执行器:先严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 -`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 先以 `fingerprint.file_content` 对仓内改动文件采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;复检后无法再次采集身份时保留实际执行证据但不宣称已确认 `fixed`。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 +`language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查的完整输出,可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 +`run_fix` 的历史 `fixed` 字段仍表示 formatter 命令以 0 退出,供旧调用方决定是否复检;它本身不是内容变化证明。CLI `fix` 因而只报告 formatter 执行成功与文件变化未验证,不再把退出码 0 呈现为已确认修复。 Git 命令的仓库定位与拟暂存解析共用入口传入的 cwd;显式 `cd`/`git -C` 无法绑定 Git 工作树时,硬门禁给出目标未验证并阻断,不退回到调用者仓或无关子仓。未给出显式目标且调用目录非仓时仍保留 workspace 子仓兜底。静态命令解析不等于完整 Shell 执行模拟。 workspace 兜底产生的每个子仓是合成目标,暂存观察也绑定该子仓根目录;不能继续以非 Git 的 workspace cwd 解析,从而把脏子仓的已知违规变成未知放行。 一条命令链中的 Git 副作用先绑定为仓库与操作的有序对,再按仓库聚合检查面:纯 push 不读取该仓未提交的 index,同仓 commit→push 则保留已有 HEAD 与拟提交内容的并集。仓库级、内联及链式 `skipGate` 均按目标仓与操作顺序生效;已存在的仓库配置也可被同链先行 `git config --unset` 取消。链式设置跨 `||`、`;` 或换行时无法证明生效,门禁要求拆分命令;写入其它文件的 config 设置也不构成本仓豁免。语法、仓库归属、间接脚本与拟暂存分析共用引号/转义感知切分;参数文本中的控制符不能合成豁免。解析能力仍限于受支持的静态 Shell 形态。 diff --git a/kimi.plugin.json b/kimi.plugin.json index f498b07..65b12fc 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.12", + "version": "0.14.13", "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/design.md b/openspec/changes/refactor-codeguard-architecture/design.md index 40c100d..a81d9e6 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -60,6 +60,8 @@ flowchart TD 三个通过真实进程复现的缺口同时收敛:Java 计划的 JAVA_HOME 不再被调用方丢弃;混合 shell/zsh 的第一项跳过不会掩盖 formatter 成功并阻止复检;delta >50 回退全量却无项目命令时明确 UNVERIFIED,不把 `{file}` 替换为空执行。以上遵循原来的范围和可信度契约,不扩大自动修复权限。 +`run_fix` 的兼容 `fixed` 字段历史上是 formatter 成功退出标志,`run_check(fix=True)` 仍据此决定是否复检;它不是内容差异。CLI 展示层不改写这一兼容字段,但只报告 formatter 已执行成功、内容变化未验证,不把进程退出码包装为文件已修复。MCP 顶层的 `fixed` 另由有界文件身份比较证明;内层旧字段的最终迁移需单独处理兼容性。 + ### 4. 标准化由自动化约束维持 真实进程测试覆盖超时/非零/编码/路径与副作用;真实 Git 测试覆盖内容快照;原生 stdio MCP 测试覆盖协议。架构门禁检查依赖方向和循环依赖,不以文件行数或类数量作为合格标准。所有既有测试保留行为断言;若私有导入变化,更新测试必须说明原契约对应的新边界。 @@ -68,8 +70,8 @@ flowchart TD 本轮审计发现 CLI/MCP 与 Git 门禁均直接写原始诊断日志:前者位于项目 `out`,后者使用可预测的临时文件名。保留各自的日志路径和呈现职责,但由 `storage` 的统一私有原子文本写入负责落盘;应用不得再用普通 `write_text` 跟随预置链接或留下默认可读权限。日志故障只影响日志证据的可用性,不得覆盖检查结论;这不是完整的对抗性文件系统沙箱。 -MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保留执行所需的原始命令与 stderr,但 JSON 只允许修复状态、退出码及收敛后的执行元数据。formatter 的 stderr 尾部写入项目 `out/.codeguard-fix.log`,成功落盘才公开私有日志路径;日志不可写时不回退输出原文。此边界不修改 CLI 的本地诊断契约,也不把私有日志路径当成宿主已读取的证明。 -修复范围的前后观察复用 `fingerprint.file_content` 的流式散列和 64 MiB/10,000 文件预算;应用层先确认路径在根内、拒绝直接符号链接、设备和循环路径,再允许 `run_fix`。读取故障或身份超限不会靠整份 `read_bytes` 兜底;修复后观察失败仍返回已执行的修复与复检结果,但顶层 `fixed` 不可据此宣称成功。架构依赖门禁显式允许应用服务依赖 fingerprint 基础设施,不增加反向依赖。 +MCP 的 `auto_fix` 还必须在应用层单独投影公开结果:`run_fix` 保留执行所需的原始命令与 stderr,内部 `fixed` 维持退出码成功的兼容语义;JSON 用 `formatter_succeeded` 表示这个事实,逐项 `fixed` 只表示唯一可归因且复检后稳定的内容变化。多 formatter 执行时可以保留顶层观察到的变化,但逐项不猜测归属;失败命令后的部分改写整体转 UNVERIFIED。公开面只允许修复状态、退出码及收敛后的执行元数据。formatter 的 stderr 尾部写入项目 `out/.codeguard-fix.log`,成功落盘才公开私有日志路径;日志不可写时不回退输出原文。此边界不修改 CLI 的本地诊断契约,也不把私有日志路径当成宿主已读取的证明。 +修复范围的前后观察复用 `fingerprint.file_content` 的流式散列和 64 MiB/10,000 文件预算;应用层先确认路径在根内、拒绝直接符号链接、设备和循环路径,再允许 `run_fix`。读取故障或身份超限不会靠整份 `read_bytes` 兜底。身份分别在修复前、formatter 返回后、检查器返回后采集:`fixed` 只来自 formatter 阶段且在复检后仍保留的变化;检查器改写目标文件或任一后续身份不可靠时,顶层 UNVERIFIED 且不宣称已确认修复,同时保留实际修复与复检证据。架构依赖门禁显式允许应用服务依赖 fingerprint 基础设施,不增加反向依赖。 ### 5. 命令语法、仓库观察与路径政策分离 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 9117169..f252e88 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -46,7 +46,7 @@ 检查计划 MUST 显式区分 repo、delta 与 save,保存物化后的命令、工作目录和逐命令环境覆盖。执行 MUST 按序进行,首个非零结果终止普通检查批次,保留已执行证据;未执行的命令不得计为成功。空计划不得生成 PASS。Java 原生计划给出的环境覆盖 MUST 在子进程中生效,不改变宿主环境。修复后的检查 MUST 复用原计划,不重新扩大范围。Git 门禁的逐文件基线豁免是独立策略,不由通用执行器决定。 语言检查与修复的应用结果 MUST 保留实际运行的每条命令身份、目录、退出码、故障标识和有界输出摘要;MCP 适配不得只保留终止命令,但其公开执行轨迹 MUST 只包含阶段、序号、程序名、退出码、故障标识和输出长度,不得新增暴露原始 argv、命令环境覆盖或任意检查器输出。失败日志 MUST 保留同一检查批次中此前已执行命令的完整输出,不能只记录最后一条。证据不得把尚未运行的计划命令写成已执行。 -MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出码、安全元数据和可用诊断路径,不得因复制内部修复字典而回显 formatter 原始 argv 或 stderr。实际修复诊断仍应在可写时以私有原子日志保存;日志写入失败不得改变修复和复检结论,也不得回退为公开原文。 +MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出码、安全元数据和可用诊断路径,不得因复制内部修复字典而回显 formatter 原始 argv 或 stderr。内部兼容 `fixed` 只表示 formatter 命令成功退出;公开结果 MUST 将此事实单独标为 `formatter_succeeded`,公开的逐项 `fixed` 只有在内容变化可唯一归因于这一条成功执行、且复检后身份稳定时才为真。多条 formatter 执行、读取故障或已知失败后有内容变化时,不得把全局差异猜给某条成功命令。实际修复诊断仍应在可写时以私有原子日志保存;日志写入失败不得改变修复和复检结论,也不得回退为公开原文。 #### Scenario: Java commands use the selected JDK - **WHEN** Java 项目计划选择 JAVA_HOME,且声明多条权威检查命令 @@ -64,6 +64,18 @@ MCP `auto_fix` 的 `fix_results` 公开面 MUST 同样只保留状态、退出 - **WHEN** `auto_fix` 的 formatter 命令及 stderr 包含凭据文本 - **THEN** MCP 整个修复结果不包含该文本或原始命令参数;可用时返回仅当前用户可读的诊断日志路径,日志不可用时仍不泄漏原文 +#### Scenario: A no-op formatter exits successfully +- **WHEN** formatter 退出码为 0,但修复前、修复后和复检后的目标内容身份相同 +- **THEN** `formatter_succeeded=true`,公开 `fix_results[].fixed=false`,并表明内容变化已验证 + +#### Scenario: Multiple formatters run but a global change cannot be attributed +- **WHEN** 两条 formatter 都执行,最终只有全局文件身份变化,无法可靠分配给其中一条 +- **THEN** 顶层仍可报告已观察到的变化,但逐项 `fixed` 不猜测归属,逐项明确未验证 + +#### Scenario: A failed formatter partially changes a file +- **WHEN** 唯一 formatter 退出非零但已改写目标文件 +- **THEN** 保留失败及复检证据,整体 UNVERIFIED 且不声称已确认修复 + ### Requirement: Diagnostic logs SHALL be private and atomic CLI/MCP 失败日志与 Git 门禁截断诊断日志可能包含检查器的原始敏感输出。写入 MUST 使用同一基础设施边界,创建的文件在支持 POSIX 权限的平台上 MUST 仅允许当前用户读写;替换 MUST 原子可见,不能跟随预置的日志文件符号链接覆盖其它文件。默认 `out` 日志目录若本身是符号链接,MUST 不向链接目标写入。日志写入失败 MUST 不掩盖已经得出的检查状态,且不得返回并不存在的日志路径。既有日志路径与内容格式保持不变;这不承诺抵御并发替换父目录的完整文件系统竞态。 @@ -82,19 +94,23 @@ CLI/MCP 失败日志与 Git 门禁截断诊断日志可能包含检查器的原 ### Requirement: CLI fix SHALL account for every repair outcome -同一语言可同时产生跳过子范围与实际 formatter 结果。CLI MUST 遍历应用返回的每条结果,而不是按语言数截断;任何实际 formatter 失败或不可验证结果 MUST 不得被同语言的 SKIPPED 提示掩盖为成功。既有逐条提示与 dry-run 行为保持可见。 +同一语言可同时产生跳过子范围与实际 formatter 结果。CLI MUST 遍历应用返回的每条结果,而不是按语言数截断;任何实际 formatter 失败或不可验证结果 MUST 不得被同语言的 SKIPPED 提示掩盖为成功。既有逐条提示与 dry-run 行为保持可见。formatter 退出码 0 仅证明命令执行成功;若 CLI 未采集修复前后内容身份,MUST NOT 将它呈现为已经确认文件发生修复。 #### Scenario: Mixed shell and zsh changes with a failing formatter - **WHEN** Git 改动同时包含 `.zsh` 与 `.sh`,前者安全跳过 formatter,后者 formatter 实际运行并失败 - **THEN** CLI 同时显示跳过说明与 formatter 失败,退出非零;不得只处理第一条 SKIPPED 结果 +#### Scenario: Formatter exits successfully without changing a file +- **WHEN** CLI 对已规范的改动文件运行 formatter,进程退出码为 0,但内容保持不变 +- **THEN** CLI 仅报告 formatter 执行成功且文件变化未验证,不宣称 `fixed` + #### Scenario: No executable commands - **WHEN** 计划没有任何可执行命令 - **THEN** 规划调用方返回 SKIPPED、PLANNED 或 UNVERIFIED;执行器拒绝将空计划解释为成功 ### Requirement: MCP auto-fix SHALL observe changed files within a bounded safe scope -MCP `auto_fix` 在运行 formatter 之前 MUST 验证所有拟修复路径仍位于项目根内、不是符号链接或特殊文件,并以有总量预算的流式内容身份记录修复前状态,不得整份载入任意大小文件。路径逃逸、读取故障、并发替换或超预算 MUST 明确 UNVERIFIED 且不启动修复。修复后身份无法可靠采集时 MUST 保留实际修复与复检证据,但不得声称已确认 `fixed`。已确认的内容或权限变化仍按既有 `fixed` 字段呈现。 +MCP `auto_fix` 在运行 formatter 之前 MUST 验证所有拟修复路径仍位于项目根内、不是符号链接或特殊文件,并以有总量预算的流式内容身份记录修复前状态,不得整份载入任意大小文件。路径逃逸、读取故障、并发替换或超预算 MUST 明确 UNVERIFIED 且不启动修复。修复后身份无法可靠采集时 MUST 保留实际修复与复检证据,但不得声称已确认 `fixed`。`fixed` 只可归因于 formatter 已产生且复检后仍保留的目标文件变化;若复检本身改变这些文件,MUST 保留实际执行证据并将整体结果标为 UNVERIFIED,不可把检查器副作用当作已确认修复。已确认的内容或权限变化仍按既有 `fixed` 字段呈现。 #### Scenario: Changed path escapes through a parent symlink - **WHEN** Git 改动路径经父目录符号链接指向仓外普通文件 @@ -104,6 +120,10 @@ MCP `auto_fix` 在运行 formatter 之前 MUST 验证所有拟修复路径仍位 - **WHEN** 改动文件超出身份预算、读取失败或被并发替换 - **THEN** 修复前故障不启动 formatter;修复后故障保留已执行结果但不宣称 `fixed` +#### Scenario: Checker changes a repair target after formatter returns +- **WHEN** formatter 返回后目标文件身份可采集,但随后的检查器又改动该文件 +- **THEN** `auto_fix` 保留修复与检查执行证据,整体标为 UNVERIFIED,`fixed` 不宣称已确认 + ### Requirement: Hook state SHALL preserve concurrent and session ownership 统计、绕过明细、冷却与审计的读改写 MUST 在进程锁内完成并原子替换文件。宿主提供 session_id 时,统计 MUST 绑定会话与当前 worktree;Stop MUST 只消费该作用域的记录,并与并发写入互斥。无 session_id 时保留旧共享状态兼容,MUST 明示不能证明跨会话隔离。去重 MUST NOT 把未完成或 UNVERIFIED 的检查当成已完成检查。 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 92cebf7..9903cdb 100644 --- a/openspec/changes/refactor-codeguard-architecture/tasks.md +++ b/openspec/changes/refactor-codeguard-architecture/tasks.md @@ -33,6 +33,6 @@ - [x] 5.1 增加自动架构依赖/循环检查并接入 CI,临时源码树注入反向依赖/函数内导入/相对导入环后确实拒绝;本地已验证,远端 CI 待发布阶段。不以文件行数当合格标准。 - [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 和逐条规格追溯通过;第二十六批本地 537 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十五批已发布 v0.14.11(插件 PR #57、市场 PR #15、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。第二十六批对象列表与 MCP 修复边界改动尚在隔离 worktree,未 bump/推送/发布。宿主安装运行独立记录,未授权不得改宿主。 +- [x] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过;第二十七批本地 546 项单测、143 项真实 Hook 回归、57 项/11 规则,证据见 verification.md。仅证明本地契约,不等同三宿主现场验收。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;第二十六批已发布 v0.14.12(插件 PR #58、市场 PR #16、源码 PR/main CI、注释 tag/正式 Release 均核对;市场 PR 无 CI 检查)。后续 MCP 修复归因与 CLI 成功输出收敛仅在隔离 worktree,未 bump/推送/发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index eba63e9..3a4df29 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -273,3 +273,13 @@ - 同批跨入口 CodeGraph 审查沿 `mcp_tool_payload → auto_fix → run_fix/run_check` 发现,旧 `auto_fix` 在作用域校验前对 Git 改动路径 `read_bytes`:父目录符号链接可先读仓外文件,大文件无内存预算,读取故障直接越过 MCP 结果边界。新增三种故障注入先 RED(仓外读取、超预算缺 UNVERIFIED、修复后身份故障缺 UNVERIFIED),另覆盖循环链接。应用服务现复用 `fingerprint.file_content` 的流式身份与 64 MiB/10,000 文件预算,修复前不可靠则不运行 formatter;修复后不可靠仍保留修复/复检证据,但顶层 `fixed=false` 且明确 UNVERIFIED。架构白名单只增加应用到 fingerprint 基础设施的有向依赖,反向导入与环仍受检。 - 补充后全量单测 **537/537、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构检查、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。MCP `auto_fix` 的私有日志/公开投影及真实 formatter 回归仍通过;这不等于三宿主实际安装或恶意并发文件系统的完整沙箱证明。 + +- 第二十六批随后发布为 v0.14.12:源码 PR #58 合并到 `fd0f771360c5ea27dbfc0ae5ab54ffdb5a0598ca`,市场 PR #16 合并到 `a648cdc704ab10d31dd2c3d8acb24b9b84af1ae1`;源码 PR 和合并后 main 的 `vendor-check` 成功。受保护注释 tag `v0.14.12` 解引用到源码 main 合并提交,正式 GitHub Release 非 draft、非 prerelease;市场 Codex/ZCode/Kimi 清单与中英文导航为 0.14.12。市场 PR 无 CI 检查,不称其 CI 已通过。源码本地 main 已快进且干净;市场原工作区保留已有未提交修改,远端 main 单独核对。本次发布不等于三宿主已安装运行,也不覆盖以下新候选。 + +## 第二十七批本地验证:MCP 修复归因与复检副作用 + +- 从 v0.14.12 的同树临时 CodeGraph 索引追踪 `mcp_tool_payload → auto_fix → run_fix/run_check → fingerprint.file_content`。旧实现仅在修复与复检都完成后取第二份身份;检查器自己改写文件时,顶层 `fixed` 会错误归因给 formatter。目标测试先 RED(缺 `UNVERIFIED`),改为修复前、formatter 后、复检后三次有界身份观察。 +- formatter 后或复检后身份不可验证,以及检查器在复检期间改动目标文件时,保留实际修复和检查执行证据,顶层 `fixed=false` 且 `UNVERIFIED`。正向覆盖 formatter 真正改变文件而只读复检;反向覆盖检查器单独改写、把 formatter 变化还原、第三次观察故障,并由真实检查器子进程复现副作用。目标 9 项通过。 +- `/opt/anaconda3/bin/python3` 3.13.5(MCP SDK 可用)完整执行 **542 unittest、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构依赖/循环、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。Homebrew Python 3.14.3 执行同套测试有 4 项依赖条件跳过,不能替代完整证明。受管技能及 lock 未改;本项已在隔离 worktree 本地提交为 `73873d5`,未 bump、推送或发布,三宿主已安装运行、Windows、在线 CVE 与大型 Maven/Gradle 项目仍无本批证据。 +- 继续沿 `fix.py → language_check.run_fix → run_check(fix=True)` 审计:`run_fix.fixed` 历史上等于 formatter 退出码 0,供兼容调用方决定是否复检,却被 CLI 直接打印成 `✅ fixed`。真实临时 Git 仓的已规范 Python 改动文件经 Ruff 无改动返回 0,新增黑盒测试先 RED(CLI 虚称 fixed),现 CLI 仅报告 formatter 执行成功且文件变化未验证,旧字段与自动复检策略保持不变。MCP 顶层 `fixed` 仍使用独立身份比较;内层兼容字段的语义迁移需另行审计。最终本地完整单测 **543/543、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构门禁、语言 schema **57 项/11 规则**、vendor 离线与在线、本 change strict、diff whitespace 均通过。本项已在隔离 worktree 本地提交为 `e1406de`,未 bump、推送或发布。 +- 再沿同一 CodeGraph 路径审查 `run_fix → auto_fix → _public_fix_results`:内部 `fixed=rc==0` 被直接拷进 MCP 逐项结果,导致 no-op formatter、多个 formatter 或失败后部分改写的公开修复结论不可信。新增三个身份/归因测试先 RED;公开结果保留 `formatter_succeeded` 命令事实,并且仅唯一成功执行且复检后身份稳定时才声明逐项 `fixed`。多 formatter 不猜归属;失败且内容变化则整体 UNVERIFIED。完整单测 **546/546、0 skipped**,真实 Hook 回归 **143/0/0**;Ruff、架构检查、语言 schema **57 项/11 规则**、vendor 离线与在线、OpenSpec strict、diff whitespace 通过。内部兼容字段与复检策略未变;此处仍仅是隔离 worktree 的本地证据,未证明三宿主安装运行或版本发布。 diff --git a/scripts/codeguard/check_application.py b/scripts/codeguard/check_application.py index 4c1d088..f194bdf 100644 --- a/scripts/codeguard/check_application.py +++ b/scripts/codeguard/check_application.py @@ -68,20 +68,35 @@ def result_envelope(results: list[dict]) -> list[dict]: return envelope -def _public_fix_results(root: Path, results: list[dict]) -> list[dict]: - """仅公开修复状态;原始 formatter 诊断进入私有日志。""" +def _formatter_attempts(results: list[dict]) -> list[int]: + """辨认实际执行的 formatter,跳过计划/豁免/范围拒绝结果。""" + return [index for index, item in enumerate(results) + if ("exit_code" in item or item.get("fixed") is True) + and not item.get("skipped") and not item.get("dry_run") + and item.get("status") not in ("PLANNED", "SKIPPED", "UNVERIFIED")] + + +def _public_fix_results(root: Path, results: list[dict], + *, confirmed_change: bool | None = None) -> list[dict]: + """命令成功与已证实修复分列;原始 formatter 诊断进入私有日志。""" public = [] diagnostics = [] - for number, item in enumerate(results, start=1): + attempts = _formatter_attempts(results) + attributable = attempts[0] if len(attempts) == 1 and results[attempts[0]].get("fixed") else None + for index, item in enumerate(results): safe = {key: item[key] for key in ( - "language", "fixed", "status", "exit_code", "note", "reason", "error", "skipped", "dry_run", + "language", "status", "exit_code", "note", "reason", "error", "skipped", "dry_run", ) if key in item} + if "fixed" in item: + safe["formatter_succeeded"] = bool(item["fixed"]) + safe["change_verified"] = confirmed_change is not None and index == attributable + safe["fixed"] = bool(safe["change_verified"] and confirmed_change) if "execution_trace" in item: safe["execution_trace"] = _public_trace(item["execution_trace"]) tail = item.get("stderr_tail") if isinstance(tail, str) and tail: safe["stderr_chars"] = len(tail) - diagnostics.append((len(public), f"===== {item.get('language', '')} fix #{number} =====\n{tail}")) + diagnostics.append((len(public), f"===== {item.get('language', '')} fix #{index + 1} =====\n{tail}")) public.append(safe) if diagnostics: path = root / "out" / ".codeguard-fix.log" @@ -135,16 +150,34 @@ def auto_fix(root: Path, languages: list[str]) -> dict: "reason": "改动文件路径或内容身份不可验证(链接、读取故障或超预算);未执行自动修复", "check": []} fix_results = run_fix(languages, root, files=files) + try: + after_fix = _repair_identity(root, files) + except (OSError, TypeError, ValueError): + after_fix = None results = run_check(languages, root, files=files, log_dir=root / "out") - public_fixes = _public_fix_results(root, fix_results) checks = result_envelope(results) + if after_fix is None: + return {"fixed": False, "status": "UNVERIFIED", + "reason": "修复后的文件身份不可验证,不能确认内容变化", + "fix_results": _public_fix_results(root, fix_results), "check": checks} try: - after = _repair_identity(root, files) + after_check = _repair_identity(root, files) except (OSError, TypeError, ValueError): return {"fixed": False, "status": "UNVERIFIED", - "reason": "修复后的文件身份不可验证,不能确认内容变化", - "fix_results": public_fixes, "check": checks} - return {"fixed": before != after, "fix_results": public_fixes, "check": checks} + "reason": "复检后的文件身份不可验证,不能确认修复结果", + "fix_results": _public_fix_results(root, fix_results), "check": checks} + if after_fix != after_check: + return {"fixed": False, "status": "UNVERIFIED", + "reason": "复检期间目标文件发生变化,检查结论无法绑定最终内容", + "fix_results": _public_fix_results(root, fix_results), "check": checks} + changed = before != after_fix + attempts = _formatter_attempts(fix_results) + if changed and (not attempts or not all(fix_results[index].get("fixed") for index in attempts)): + return {"fixed": False, "status": "UNVERIFIED", + "reason": "formatter 未全部成功,目标文件虽变化但不能确认为修复完成", + "fix_results": _public_fix_results(root, fix_results), "check": checks} + return {"fixed": changed, + "fix_results": _public_fix_results(root, fix_results, confirmed_change=changed), "check": checks} 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 b473265..44211a4 100644 --- a/scripts/codeguard/language_check.py +++ b/scripts/codeguard/language_check.py @@ -163,6 +163,8 @@ def run_fix(languages: list[str], project_root: Path, `files` 非空 = 仅修复这些文件(fix.py 缺省 delta 模式);某语言在改动 里没有文件 → 该语言整行跳过(skipped 标记),绝不动仓里其它存量文件。 + 兼容字段 `fixed` 表示 formatter 命令成功退出,未观察内容变化;调用方 + 不得仅凭它对用户宣称文件已经修复,MCP 顶层另行比较内容身份。 """ if files is not None and any( (project_root / f).is_symlink() or not (project_root / f).resolve().is_relative_to(project_root.resolve()) diff --git a/scripts/fix.py b/scripts/fix.py index 50620d7..7e293e7 100644 --- a/scripts/fix.py +++ b/scripts/fix.py @@ -72,7 +72,8 @@ def main() -> int: print(f" {lang:12s} would run: {' '.join(r.get('command') or [])}") continue if r.get("fixed"): - print(f" {lang:12s} \u2705 fixed") + # 兼容字段 fixed 表示 formatter 以 0 退出,不能证明文件确实改变。 + print(f" {lang:12s} \u2705 formatter 执行成功(文件变化未验证)") else: print(f" {lang:12s} \u274c failed: exit={r.get('exit_code')}") if r.get("stderr_tail"): diff --git a/tests/test_cli_fix_reporting.py b/tests/test_cli_fix_reporting.py new file mode 100644 index 0000000..7b94b94 --- /dev/null +++ b/tests/test_cli_fix_reporting.py @@ -0,0 +1,43 @@ +"""CLI 修复输出不得把 formatter 退出码误称为已修改文件。""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(shutil.which("ruff"), "real Ruff checker is required") +class CliFixReportingTests(unittest.TestCase): + def test_noop_formatter_does_not_claim_file_was_fixed(self): + with tempfile.TemporaryDirectory(prefix="cg-fix-report-") as temporary: + root = Path(temporary) / "project" + root.mkdir() + + def git(*args: str) -> None: + subprocess.run(["git", *args], cwd=root, check=True, capture_output=True) + + git("init", "-q") + git("config", "user.email", "test@example.invalid") + git("config", "user.name", "CodeGuard Test") + target = root / "sample.py" + target.write_text("value = 1\n", encoding="utf-8") + git("add", "sample.py") + git("-c", "core.hooksPath=/dev/null", "commit", "-qm", "base") + target.write_text("value = 2\n", encoding="utf-8") + + result = subprocess.run( + [sys.executable, str(PLUGIN / "scripts" / "fix.py"), "--lang", "python", str(root)], + cwd=root, env={**os.environ, "CODEGUARD_HOME": str(Path(temporary) / "state")}, + capture_output=True, text=True, timeout=30, check=False, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("执行成功", result.stdout) + self.assertIn("文件变化未验证", result.stdout) + self.assertNotIn("✅ fixed", result.stdout) + self.assertEqual("value = 2\n", target.read_text(encoding="utf-8")) diff --git a/tests/test_mcp_autofix_identity.py b/tests/test_mcp_autofix_identity.py index 41e4086..e0a446b 100644 --- a/tests/test_mcp_autofix_identity.py +++ b/tests/test_mcp_autofix_identity.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) -from codeguard import check_application +from codeguard import check_application, language_check class McpAutoFixIdentityTests(unittest.TestCase): @@ -77,11 +77,161 @@ def observe(_path, _budget): payload = check_application.auto_fix(self.root, ["python"]) self.assertEqual("UNVERIFIED", payload["status"]) self.assertFalse(payload["fixed"]) - self.assertEqual(True, payload["fix_results"][0]["fixed"]) + self.assertFalse(payload["fix_results"][0]["fixed"]) + self.assertTrue(payload["fix_results"][0]["formatter_succeeded"]) + self.assertFalse(payload["fix_results"][0]["change_verified"]) self.assertEqual("PASS", payload["check"][0]["status"]) fix.assert_called_once() check.assert_called_once() + def test_checker_side_effect_does_not_count_as_formatter_fix(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + + def check_mutates(*_args, **_kwargs): + target.write_text("value = 2\n", encoding="utf-8") + return [{"language": "python", "passed": True, "status": "PASS", "exit_code": 0}] + + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", return_value=[ + {"language": "python", "fixed": True, "exit_code": 0}]), + patch.object(check_application, "run_check", side_effect=check_mutates)): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["fixed"]) + self.assertEqual("PASS", payload["check"][0]["status"]) + + def test_formatter_change_survives_read_only_checker(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + + def formatter(*_args, **_kwargs): + target.write_text("value = 2\n", encoding="utf-8") + return [{"language": "python", "fixed": True, "exit_code": 0}] + + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", side_effect=formatter), + patch.object(check_application, "run_check", return_value=[ + {"language": "python", "passed": True, "status": "PASS", "exit_code": 0}])): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertTrue(payload["fixed"]) + self.assertNotIn("status", payload) + self.assertTrue(payload["fix_results"][0]["fixed"]) + self.assertTrue(payload["fix_results"][0]["formatter_succeeded"]) + self.assertTrue(payload["fix_results"][0]["change_verified"]) + + def test_noop_formatter_success_is_not_publicly_fixed(self): + (self.root / "changed.py").write_text("value = 1\n", encoding="utf-8") + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", return_value=[ + {"language": "python", "fixed": True, "exit_code": 0}]), + patch.object(check_application, "run_check", return_value=[ + {"language": "python", "passed": True, "status": "PASS", "exit_code": 0}])): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertFalse(payload["fixed"]) + self.assertTrue(payload["fix_results"][0]["formatter_succeeded"]) + self.assertFalse(payload["fix_results"][0]["fixed"]) + self.assertTrue(payload["fix_results"][0]["change_verified"]) + + def test_multiple_formatters_do_not_guess_per_language_change(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + (self.root / "changed.sh").write_text("echo ok\n", encoding="utf-8") + + def formatter(*_args, **_kwargs): + target.write_text("value = 2\n", encoding="utf-8") + return [{"language": lang, "fixed": True, "exit_code": 0} + for lang in ("python", "shell")] + + with (patch.object(check_application, "changed_files", return_value=["changed.py", "changed.sh"]), + patch.object(check_application, "run_fix", side_effect=formatter), + patch.object(check_application, "run_check", return_value=[ + {"language": "python", "passed": True, "status": "PASS", "exit_code": 0}])): + payload = check_application.auto_fix(self.root, ["python", "shell"]) + self.assertTrue(payload["fixed"]) + self.assertEqual([False, False], [item["fixed"] for item in payload["fix_results"]]) + self.assertEqual([True, True], [item["formatter_succeeded"] for item in payload["fix_results"]]) + self.assertEqual([False, False], [item["change_verified"] for item in payload["fix_results"]]) + + def test_failed_formatter_partial_mutation_is_unverified(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + + def formatter(*_args, **_kwargs): + target.write_text("value = 2\n", encoding="utf-8") + return [{"language": "python", "fixed": False, "exit_code": 2}] + + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", side_effect=formatter), + patch.object(check_application, "run_check", return_value=[ + {"language": "python", "passed": True, "status": "PASS", "exit_code": 0}])): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["fixed"]) + self.assertFalse(payload["fix_results"][0]["formatter_succeeded"]) + self.assertFalse(payload["fix_results"][0]["fixed"]) + + def test_real_checker_process_mutation_is_not_verified_as_repair(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + checker = self.root / "checker.py" + checker.write_text( + "from pathlib import Path\nPath('changed.py').write_text('value = 2\\n')\n", + encoding="utf-8", + ) + command = {"lint": [sys.executable, str(checker)], "append_files": True} + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", return_value=[ + {"language": "python", "fixed": False, "exit_code": 0}]), + patch.dict(language_check.LANG_COMMANDS, {"python": command}), + patch.object(language_check, "project_uses_linter", return_value=True)): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["fixed"]) + self.assertEqual("PASS", payload["check"][0]["status"]) + self.assertEqual("value = 2\n", target.read_text(encoding="utf-8")) + + def test_checker_reverts_formatter_change_is_unverified(self): + target = self.root / "changed.py" + target.write_text("value = 1\n", encoding="utf-8") + + def formatter(*_args, **_kwargs): + target.write_text("value = 2\n", encoding="utf-8") + return [{"language": "python", "fixed": True, "exit_code": 0}] + + def check_reverts(*_args, **_kwargs): + target.write_text("value = 1\n", encoding="utf-8") + return [{"language": "python", "passed": True, "status": "PASS", "exit_code": 0}] + + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "run_fix", side_effect=formatter), + patch.object(check_application, "run_check", side_effect=check_reverts)): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["fixed"]) + self.assertEqual("value = 1\n", target.read_text(encoding="utf-8")) + + def test_post_check_identity_failure_preserves_execution(self): + (self.root / "changed.py").write_text("value = 1\n", encoding="utf-8") + observed = iter([("before", 10), ("after-fix", 10), ValueError("file changed")]) + + def observe(_path, _budget): + value = next(observed) + if isinstance(value, Exception): + raise value + return value + + with (patch.object(check_application, "changed_files", return_value=["changed.py"]), + patch.object(check_application, "file_content", side_effect=observe), + patch.object(check_application, "run_fix", return_value=[ + {"language": "python", "fixed": True, "exit_code": 0}]), + patch.object(check_application, "run_check", return_value=[ + {"language": "python", "passed": True, "status": "PASS", "exit_code": 0}])): + payload = check_application.auto_fix(self.root, ["python"]) + self.assertEqual("UNVERIFIED", payload["status"]) + self.assertFalse(payload["fixed"]) + self.assertEqual("PASS", payload["check"][0]["status"]) + if __name__ == "__main__": unittest.main()