From b2c7feebce49ca6113258bc8fe68eebe16efb4b9 Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:28:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E5=BD=92=E5=B1=9E=E5=B9=B6=E5=BB=B6=E5=90=8E?= =?UTF-8?q?=20Stop=20=E7=8A=B6=E6=80=81=E7=A1=AE=E8=AE=A4=20(v0.14.1)?= 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 +- docs/current-architecture.md | 4 +- hooks/stop_summary.py | 5 +- kimi.plugin.json | 2 +- .../refactor-codeguard-architecture/design.md | 13 ++++- .../specs/execution-kernel/spec.md | 11 +++- .../refactor-codeguard-architecture/tasks.md | 2 +- .../verification.md | 8 +++ scripts/check_architecture.py | 6 +- scripts/codeguard/baseline.py | 8 ++- scripts/codeguard/session_application.py | 32 ++++++++-- scripts/codeguard/storage.py | 9 +++ scripts/codeguard/verdict.py | 30 +++++++--- tests/test_gate_hardening_batch2.py | 32 ++++++++++ tests/test_session_application.py | 58 +++++++++++++++++++ 17 files changed, 200 insertions(+), 32 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 372e5be..bb57553 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.0" + "ref": "v0.14.1" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.0", + "version": "0.14.1", "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.0/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.1/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.0/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.1/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f3160ef..7006275 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.0+codex.20260923", + "version": "0.14.1+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 1a96311..78c52bb 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.0", + "version": "0.14.1", "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 13844ff..27d1fc7 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -35,8 +35,8 @@ flowchart TD | `bin/codeguard`、`scripts/run_check.py`、`scripts/fix.py` | CLI 参数、输出与 MCP stdio 注册 | 不复制扫描器或判定逻辑 | | `hooks/` | 宿主 JSON、会话事件、通知与退出协议 | 五类 Hook 将检查编排委托应用服务;入口保留旧导入兼容面及 fail-open 协议 | | `scripts/codeguard/check_application.py`、`language_check.py` | 语言选择、检查与修复请求、MCP 工具分发 | 不导入 MCP SDK;旧 `run_per_language.py` 仅再导出 | -| `startup_application.py`、`session_application.py`、`prompt_application.py`、`git_guard_application.py`、`save_application.py` | 五类 Hook 的盘点、状态消费、软提示、Git 守卫及保存反馈编排 | 返回结构化结果,不打印或调用宿主通知;Hook 控制输出、通知与 fail-open。软/硬提示与保存统计在成功输出后确认;Stop 的状态消费仍先于 stdout,输出故障时可能丢失总结 | -| `gate.py`、`gate_checks.py`、`repository_policy.py`、`baseline.py` | Git 门禁、单语言结果、安全规则及有证据的存量比较 | 只有准确基线失败且每条诊断及次数覆盖当前结果才可豁免 | +| `startup_application.py`、`session_application.py`、`prompt_application.py`、`git_guard_application.py`、`save_application.py` | 五类 Hook 的盘点、状态消费、软提示、Git 守卫及保存反馈编排 | 返回结构化结果,不打印或调用宿主通知;Hook 控制输出、通知与 fail-open。Stop 在 stdout 刷新后才确认消费;若同时有新统计,为避免丢记录,后续 Stop 可能重复展示旧统计 | +| `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` 只解析参数和呈现报告 | diff --git a/hooks/stop_summary.py b/hooks/stop_summary.py index 72b1263..b624946 100755 --- a/hooks/stop_summary.py +++ b/hooks/stop_summary.py @@ -45,8 +45,11 @@ def main(payload: dict | None = None) -> int: def _main(payload: dict | None = None) -> int: - for line in session_application.consume_summary(LEGACY_STATE_FILE, Path.cwd()): + prepared = session_application.prepare_summary(LEGACY_STATE_FILE, Path.cwd()) + for line in prepared.lines: print(line) + sys.stdout.flush() + prepared.acknowledge() return 0 diff --git a/kimi.plugin.json b/kimi.plugin.json index a9ad206..23c8e94 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.0", + "version": "0.14.1", "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 9e630be..5955c10 100644 --- a/openspec/changes/refactor-codeguard-architecture/design.md +++ b/openspec/changes/refactor-codeguard-architecture/design.md @@ -80,8 +80,13 @@ flowchart TD `storage` 只提供带进程锁的原子读改写、消费和有界 JSONL;`hook_state` 保存归属与业务字段, hooks 只负责取得 payload、进入会话作用域和输出。使用稳定旁路锁文件,避免替换数据 inode 后 -两个进程各锁不同文件。统计、冷却和绕过明细使用同一事务边界;Stop 原子取出当前作用域, -不会再在输出后删除另一进程刚写入的计数。无 session_id 继续旧共享状态,明确不保证会话隔离。 +两个进程各锁不同文件。统计、冷却和绕过明细使用同一事务边界;Stop 只处理当前作用域, +不会在确认输出后删除另一进程刚写入的计数。无 session_id 继续旧共享状态,明确不保证会话隔离。 + +Stop 的宿主入口先准备原子可读的状态快照,成功写入并刷新 stdout 后,在同一旁路锁内仅当状态 +仍等于该快照时删除。输出失败保留统计供重试;并发新写入时保留整份状态而不冒险相减, +后续 Stop 可能重复展示旧统计,但不会丢掉未展示的新统计。旧直接消费接口继续兼容, +不用于宿主 Stop 交付路径。 事件去重不等于权限缓存。观察 Hook 成功返回后才登记完成,异常允许重试;UPS 有 skipped 时 保守地不登记。硬门禁只重放已完成拒绝,放行、未验证和执行中一律不复用;并发副本允许 @@ -129,7 +134,9 @@ flowchart TD 这不仅改变文件位置:并发 worker 不再共享备注列表,也不依赖 ContextVar 隐式跨线程传播;审计在 调用线程记录原 worktree、实际 execution_root 与 argv。基线工具或当前结果为未知时不进入豁免, -避免文本相似覆盖执行失败。Java 原有项目感知计划继续复用,详细解析/规划分离留到 task 3.4。 +避免文本相似覆盖执行失败。逐条基线诊断比较保留文件归属、描述与出现次数,只忽略同文件的 +行列号漂移;基线临时文件路径映射回原仓相对路径,不得用另一文件的同文本违规抵扣当前文件。 +Java 原有项目感知计划继续复用,详细解析/规划分离留到 task 3.4。 迁移测试时发现旧 fixture 修改模块全局函数且不恢复,造成后续语言检测污染;改为显式语言参数和 有作用域的注册表覆盖。低层 timeout 故障仍在实际进程边界注入。真实替换测试还暴露了 scope 对 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 784937d..c42d203 100644 --- a/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md +++ b/openspec/changes/refactor-codeguard-architecture/specs/execution-kernel/spec.md @@ -61,6 +61,7 @@ ### Requirement: Hook state SHALL preserve concurrent and session ownership 统计、绕过明细、冷却与审计的读改写 MUST 在进程锁内完成并原子替换文件。宿主提供 session_id 时,统计 MUST 绑定会话与当前 worktree;Stop MUST 只消费该作用域的记录,并与并发写入互斥。无 session_id 时保留旧共享状态兼容,MUST 明示不能证明跨会话隔离。去重 MUST NOT 把未完成或 UNVERIFIED 的检查当成已完成检查。 +Stop MUST 在宿主输出成功刷新后才确认消费;输出失败时记录必须保留供重试。若确认前有并发新写入,允许后续 Stop 重复展示旧统计,但不得删除未展示的新记录。 #### Scenario: Concurrent writers preserve all increments - **WHEN** 多个 Hook 进程同时更新同一统计、绕过记录或审计日志 @@ -70,6 +71,10 @@ - **WHEN** 两个会话或两个 worktree 同时有检查记录,其中一个收到 Stop - **THEN** 只汇总并清理当前作用域,其余记录保持可供各自 Stop 消费 +#### Scenario: Stop output fails or state changes during delivery +- **WHEN** Stop 已准备总结但宿主 stdout 写入或刷新失败,或在输出期间有新统计写入 +- **THEN** 输出失败时旧统计可在重试中再次展示;并发新统计不得被确认消费误删,允许为了不丢记录而在后续 Stop 重复展示旧统计 + #### Scenario: Retry follows an unverified save check - **WHEN** 相同文件未变化,上次保存检查未能获得结论,紧接着再次触发 - **THEN** 再次执行检查,不因此前写入的去重标记而静默跳过 @@ -93,7 +98,7 @@ ### Requirement: Gate application SHALL separate checks from host presentation 门禁应用 MUST 不依赖 hooks 导入或宿主 SDK。单个语言检查 MUST 返回独立结果,汇总 MUST 保持输入顺序,不用共享可变备注列表连接并发检查。准确快照的审计 MUST 保留原 worktree 和会话归属,实际执行目录只作为执行上下文,不冒充项目身份。基线比较遇到未验证工具结果 MUST NOT 产生存量豁免。 -基线豁免 MUST 比较逐条诊断内容及出现次数,且基线检查器本身 MUST 确认失败;规则码集合或仅有文本输出不足以证明当前发现已存在。 +基线豁免 MUST 比较逐条诊断内容、出现次数及可识别的文件归属,且基线检查器本身 MUST 确认失败;规则码集合或仅有文本输出不足以证明当前发现已存在。基线临时文件路径可以映射回被检查的仓库相对路径,行列号移动不应单独取消同文件的存量豁免;不同文件的同文本诊断不得互相抵扣。 #### Scenario: Exact snapshot audit retains ownership - **WHEN** 带会话 ID 的提交门禁从临时 Git 快照执行检查 @@ -107,6 +112,10 @@ - **WHEN** 基线检查器返回成功码却打印诊断、同一规则新增第二处违规,或规则码相同但诊断内容变化 - **THEN** 当前违规不得获得存量豁免;只有基线自身确认失败且逐条诊断及出现次数覆盖当前结果时才能豁免 +#### Scenario: Identical diagnostics belong to different files +- **WHEN** 当前输出将同一规则和描述归于另一文件,而基线输出归于被检查文件;或者 ShellCheck 的 `In line N` 段属于另一文件 +- **THEN** 不得以文本相同授予存量豁免;被检查文件自身仅行列号变化时仍可保留有证据的旧发现豁免 + ### Requirement: Language services SHALL have explicit input and lifetime 语言注册表 MUST 由同一校验逻辑验证后派生识别与命令表,非法形状和重复 ID 不得静默覆盖。 diff --git a/openspec/changes/refactor-codeguard-architecture/tasks.md b/openspec/changes/refactor-codeguard-architecture/tasks.md index 4eb3c4d..f1eeee0 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 检查。 - [ ] 5.4 全量单测/真实 hook 回归/ruff/语言 schema/vendor 离线与在线/本 change strict 和逐条规格追溯通过。 -- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.13.0 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但后续工作树改动仍未发布。宿主安装运行独立记录,未授权不得改宿主。 +- [ ] 5.5 按仓规升级版本、生成市场元数据、授权的 PR/CI/不可变 tag/Release 闭环;v0.14.0 阶段版本已完成插件/市场双仓合并、PR/main CI、tag/Release,但后续工作树改动仍未发布。宿主安装运行独立记录,未授权不得改宿主。 - [ ] 5.6 完成全目标审计,代码/规格/证据一致后同步并归档 change,才可将整体优化标记完成。 diff --git a/openspec/changes/refactor-codeguard-architecture/verification.md b/openspec/changes/refactor-codeguard-architecture/verification.md index eae139e..43e3243 100644 --- a/openspec/changes/refactor-codeguard-architecture/verification.md +++ b/openspec/changes/refactor-codeguard-architecture/verification.md @@ -162,3 +162,11 @@ - 临时 CodeGraph 快照重新同步了本批改动并追踪三个新应用服务的 Hook 调用点;源仓未初始化索引。`scripts/check_architecture.py` 声明并约束新内核模块,隔离导入测试阻止其反向加载宿主 Hook/MCP。 - 使用本机已有、带 MCP SDK 的 `/opt/anaconda3/bin/python3` 3.13.5 完整执行 **454 unittest,全部通过、0 skipped**;`tests/run_all.py` **142/0/0**。Homebrew Python 3.14 缺 MCP SDK 时同一套测试仅执行 451 项、跳过 3 项,故不将该运行作为完整证明。ruff、架构门禁、语言 schema 57 项/11 规则、vendor 离线与在线、本 change strict、diff whitespace 通过;技能 lock 和受管技能未改。 - 本地入口边界已满足 task 4.3。Stop 会先消费会话状态再输出,若 stdout 故障可能丢失该次总结;三宿主安装现场、Windows、联网漏洞库及所有语言真实工具链仍待独立验收。本批源码在本记录时仍未 bump/提交/推送/发布,不把 v0.13.0 的远端证据套用到新改动。 + +## 第十五批实际验证:诊断归属与 Stop 交付确认 + +- v0.14.0 已经插件 PR #46、市场 PR #2/#3 合并到两个 `main`;插件 PR 和合并后 `main` 的 `skills-check` 通过,不可变 tag/Release 指向插件合并提交 `0e699b85b0cb3abaf6a248e04121ecae152ba508`,市场三宿主清单及中英文导航均为 0.14.0。该发布证据只覆盖上一批代码,本批源码尚未发布。 +- 临时 CodeGraph 索引同步后追出 `check_language → baseline_stale_finding → finding_instances`;原提取器剥掉文件位置。两项真实 Git/检查器测试先 RED:其它文件的同文本 `F401` 与 ShellCheck `In line N` 诊断均被误豁免。现在比较文件归属、描述、出现次数,把基线临时路径映射回原仓相对路径;同文件只移动行号仍允许有证据的豁免。CodeGraph 的受影响测试映射返回空,故以实际源码调用和目标测试作为覆盖证据,不将空映射当无测试。 +- Stop 原逻辑在输出前 `take_json`,故故障注入先 RED:stdout 写入失败时会话记录已被删除。现新增准备结果和输出后确认;写入或刷新失败时记录保留可重试。若准备后有并发新写入,锁内快照不一致就不消费,允许下一次重复展示旧统计而不删除新记录。旧 `consume_summary` 兼容接口保留,Hook 改用 `prepare_summary`。两种输出故障和并发写入均有目标测试。 +- 本机现有 `/opt/anaconda3/bin/python3` 3.13.5 完整执行 **460 unittest,全部通过、0 skipped**;`tests/run_all.py` **142/0/0**。ruff、架构门禁、语言 schema 57 项/11 规则、vendor 离线与在线、本 change strict、diff whitespace 均通过;受管技能及 lock 未改。上述为本地证据,远端 CI/版本/市场需在本批发布后另验。 +- 这两处消除了已复现的假豁免与输出前丢总结;仍不声称完整 Shell 解释、三宿主现场、Windows、在线漏洞库、全部工具链或 OpenSpec 5.4/5.6 的全目标审计完成。stdout 刷新成功不等于宿主最终消费确认,因此异常重试可出现重复总结;该取舍优先保证不丢统计。 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 2277b9a..58936a1 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -23,7 +23,8 @@ "codeguard.execution"}, "codeguard": {"__future__"}, "codeguard.models": {"__future__", "dataclasses", "pathlib", "typing"}, - "codeguard.verdict": {"__future__", "re", "collections", "pathlib", "codeguard.models"}, + "codeguard.verdict": {"__future__", "re", "posixpath", "collections", "collections.abc", + "pathlib", "codeguard.models"}, "codeguard.execution": {"__future__", "os", "subprocess", "collections", "pathlib", "codeguard.models"}, # 报告当前仍拥有诊断日志落盘;不将其误称为无 IO 的纯模型。 "codeguard.reporting": {"__future__", "re", "json", "pathlib", "hashlib", "tempfile", @@ -57,7 +58,8 @@ "codeguard.check_application": {"__future__", "pathlib", "scope", "codeguard.config", "codeguard.discovery", "codeguard.java_analysis", "codeguard.language_check", "codeguard.registry"}, - "codeguard.session_application": {"__future__", "pathlib", "codeguard.execution", + "codeguard.session_application": {"__future__", "collections.abc", "dataclasses", "pathlib", + "codeguard.execution", "codeguard.hook_state", "codeguard.repository_policy", "codeguard.storage"}, "codeguard.startup_application": {"__future__", "re", "dataclasses", "pathlib", diff --git a/scripts/codeguard/baseline.py b/scripts/codeguard/baseline.py index 6ab89d8..c245c47 100644 --- a/scripts/codeguard/baseline.py +++ b/scripts/codeguard/baseline.py @@ -83,7 +83,9 @@ def baseline_stale_finding( pre = _git(project_root, "show", f"{baseline_ref}:{rel_path}") if pre is None: return None - new_sigs = finding_instances(output) + canonical = Path(rel_path).as_posix() + current_aliases = {canonical: canonical, str(project_root / rel_path): canonical} + new_sigs = finding_instances(output, path_aliases=current_aliases) if not new_sigs: return None import tempfile as _tf @@ -99,7 +101,9 @@ def baseline_stale_finding( 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 "")) + baseline_aliases = {str(tmp): canonical, tmp.name: canonical} + base_sigs = finding_instances((proc.stdout or "") + (proc.stderr or ""), + path_aliases=baseline_aliases) if not base_sigs: return None return ("存量问题(基线同命令同工具已存在)" if diff --git a/scripts/codeguard/session_application.py b/scripts/codeguard/session_application.py index 479008e..cdc2bc2 100644 --- a/scripts/codeguard/session_application.py +++ b/scripts/codeguard/session_application.py @@ -1,12 +1,26 @@ """会话结束应用服务:消费本会话统计,返回不依赖宿主的展示行。""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass, field from pathlib import Path from .execution import execute from .hook_state import state_path from .repository_policy import skip_gate_via_git_config -from .storage import read_json, take_json +from .storage import consume_if_unchanged, read_json + + +@dataclass(frozen=True) +class StopSummary: + """已准备但未确认交付的会话总结。""" + + lines: tuple[str, ...] + _on_delivered: Callable[[], bool] = field(repr=False, compare=False) + + def acknowledge(self) -> bool: + """宿主成功刷新输出后才消费;并发写入时保留整份状态供后续展示。""" + return self._on_delivered() def load_state(legacy_state_file: Path) -> dict: @@ -50,11 +64,19 @@ def skipgate_left_enabled(project_root: Path) -> bool: return False -def consume_summary(legacy_state_file: Path, project_root: Path) -> tuple[str, ...]: - """原子消费当前作用域状态,并返回需要由宿主输出的消息。""" - state = take_json(state_path(legacy_state_file)) +def prepare_summary(legacy_state_file: Path, project_root: Path) -> StopSummary: + """准备总结而不先消费;确认前状态仍可由失败的宿主输出重试。""" + path = state_path(legacy_state_file) + state = read_json(path) lines = [summarize(state)] if skipgate_left_enabled(project_root): lines.append("[codeguard] ⚠️ git config codeguard.skipGate 仍为 true——该仓门禁处于豁免状态;" "若绕过已完成,请执行 git config --unset codeguard.skipGate 恢复") - return tuple(lines) + return StopSummary(tuple(lines), lambda: consume_if_unchanged(path, state)) + + +def consume_summary(legacy_state_file: Path, project_root: Path) -> tuple[str, ...]: + """兼容旧直接消费接口;宿主 Hook 必须改用 prepare_summary 后确认。""" + prepared = prepare_summary(legacy_state_file, project_root) + prepared.acknowledge() + return prepared.lines diff --git a/scripts/codeguard/storage.py b/scripts/codeguard/storage.py index ac8824a..fd29dff 100644 --- a/scripts/codeguard/storage.py +++ b/scripts/codeguard/storage.py @@ -88,6 +88,15 @@ def take_json(path: Path) -> dict: return value +def consume_if_unchanged(path: Path, expected: dict) -> bool: + """输出完成后只消费原样快照;并发新写入优先保留以避免丢计数。""" + with locked(path): + if read_json(path) != expected: + return False + path.unlink(missing_ok=True) + return True + + def append_jsonl(path: Path, entry: dict, limit: int) -> None: """有界审计日志,保留窗口内并发写入不互相覆盖。""" with locked(path): diff --git a/scripts/codeguard/verdict.py b/scripts/codeguard/verdict.py index 750b746..0a89289 100644 --- a/scripts/codeguard/verdict.py +++ b/scripts/codeguard/verdict.py @@ -1,8 +1,10 @@ """纯判定内核:不依赖宿主、进程执行、注册表或持久化。""" from __future__ import annotations +import posixpath import re from collections import Counter +from collections.abc import Mapping from pathlib import Path PASS, FAIL, UNVERIFIED, SKIPPED, PLANNED = "PASS", "FAIL", "UNVERIFIED", "SKIPPED", "PLANNED" @@ -50,28 +52,40 @@ def _strip_dialect_blocks(output: str) -> str: _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*") +_LOCATION_RE = re.compile(r"^(?P.+?\.[A-Za-z0-9]+):\d+(?::\d+)?:\s*") +_SHELLCHECK_LOCATION_RE = re.compile(r"^In (?P.+?) line \d+:?$") -def finding_instances(output: str) -> Counter[str]: - """按诊断内容保留重复次数;不能用规则码集合授予存量豁免。 +def finding_instances(output: str, *, path_aliases: Mapping[str, str] | None = None + ) -> Counter[tuple[str, str]]: + """按文件归属、诊断内容和次数保留发现,忽略会随编辑漂移的行列号。 只接纳带规则码或文件位置的诊断行。无法识别的输出交给调用方保持 未豁免,避免把工具的统计摘要误当作一条已证明的旧问题。 """ - findings: Counter[str] = Counter() + aliases = {posixpath.normpath(path.replace("\\", "/")): canonical + for path, canonical in (path_aliases or {}).items()} + findings: Counter[tuple[str, str]] = Counter() + context_path = "" for line in output.splitlines(): raw = line.strip() if not raw: continue - located = bool(_LOCATION_RE.match(raw)) - description = _LOCATION_RE.sub("", raw) + heading = _SHELLCHECK_LOCATION_RE.match(raw) + if heading: + source = posixpath.normpath(heading.group("path").replace("\\", "/")) + context_path = aliases.get(source, source) + continue + location = _LOCATION_RE.match(raw) + source = posixpath.normpath(location.group("path").replace("\\", "/")) if location else "" + identity = aliases.get(source, source) if location else context_path + description = raw[location.end():] if location else raw description = re.sub(r"^\^--\s*", "", description).strip() - if not _DIAGNOSTIC_CODE_RE.search(description) and not located: + if not _DIAGNOSTIC_CODE_RE.search(description) and not location: continue description = re.sub(r"\s+", " ", description) if description: - findings[description] += 1 + findings[(identity, description)] += 1 return findings diff --git a/tests/test_gate_hardening_batch2.py b/tests/test_gate_hardening_batch2.py index 1975009..cbb182e 100644 --- a/tests/test_gate_hardening_batch2.py +++ b/tests/test_gate_hardening_batch2.py @@ -144,6 +144,38 @@ def test_same_rule_with_different_diagnostic_is_not_stale(self): self.assertIsNone(gate_lib.baseline_stale_finding( self.root, "lib.sh", checker, "lib.sh:1:1: F401 `new` imported but unused")) + def test_same_diagnostic_in_another_file_is_not_stale(self): + self.put("lib.sh", "BAD\n") + self.put("other.sh", "BAD\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + checker = [sys.executable, "-c", + "import sys; print(f'{sys.argv[1]}:1:1: F401 same issue'); sys.exit(1)", "{file}"] + self.assertIsNone(gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, "other.sh:9:1: F401 same issue")) + + def test_shellcheck_heading_from_another_file_is_not_stale(self): + self.put("lib.sh", "BAD\n") + self.put("other.sh", "BAD\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + checker = [sys.executable, "-c", + ("import sys; print(f'In {sys.argv[1]} line 1:\\n" + "^-- SC2086 (warning): quote this'); sys.exit(1)"), "{file}"] + current = "In other.sh line 9:\n^-- SC2086 (warning): quote this" + self.assertIsNone(gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, current)) + + def test_same_file_diagnostic_survives_line_shift(self): + self.put("lib.sh", "BAD\n") + _git(self.root, "add", "-A") + _git(self.root, "commit", "-qm", "base") + checker = [sys.executable, "-c", + "import sys; print(f'{sys.argv[1]}:1:1: F401 same issue'); sys.exit(1)", "{file}"] + stale = gate_lib.baseline_stale_finding( + self.root, "lib.sh", checker, "lib.sh:99:4: F401 same issue") + self.assertEqual("存量问题(基线同命令同工具已存在)", stale) + def test_successful_baseline_command_cannot_prove_a_failure(self): self.put("lib.sh", "BAD\n") _git(self.root, "add", "-A") diff --git a/tests/test_session_application.py b/tests/test_session_application.py index 97da722..274dba7 100644 --- a/tests/test_session_application.py +++ b/tests/test_session_application.py @@ -3,6 +3,7 @@ import contextlib import io +import json import os import subprocess import sys @@ -13,6 +14,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "hooks")) class SessionApplicationTests(unittest.TestCase): @@ -51,6 +53,62 @@ def test_skipgate_reminder_is_a_separate_result_line(self): self.assertEqual(2, len(lines)) self.assertIn("仍为 true", lines[1]) + def test_stop_output_failure_keeps_statistics_for_retry(self): + import stop_summary + + class BrokenOutput: + def write(self, _value): + raise OSError("host output unavailable") + + self.home.mkdir() + state = self.home / "session_state.json" + state.write_text('{"python":{"total":2,"passed":1,"failed":1,"auto_fixed":0}}', + encoding="utf-8") + with patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + with patch.object(stop_summary.sys, "stdout", BrokenOutput()), self.assertRaises(OSError): + stop_summary.main({}) + self.assertEqual(2, json.loads(state.read_text())["python"]["total"]) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(0, stop_summary.main({})) + self.assertIn("共 2 次检查", output.getvalue()) + self.assertFalse(state.exists()) + + def test_stop_flush_failure_keeps_statistics_for_retry(self): + import stop_summary + + class BrokenFlush(io.StringIO): + def flush(self): + raise OSError("host flush unavailable") + + self.home.mkdir() + state = self.home / "session_state.json" + state.write_text('{"python":{"total":1,"passed":1,"failed":0,"auto_fixed":0}}', + encoding="utf-8") + with patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + with patch.object(stop_summary.sys, "stdout", BrokenFlush()), self.assertRaises(OSError): + stop_summary.main({}) + self.assertEqual(1, json.loads(state.read_text())["python"]["total"]) + + def test_concurrent_write_during_delivery_is_not_deleted(self): + from codeguard.hook_state import bump_state + from codeguard.session_application import prepare_summary + + self.home.mkdir() + state = self.home / "session_state.json" + state.write_text('{"python":{"total":1,"passed":1,"failed":0,"auto_fixed":0}}', + encoding="utf-8") + with patch.dict(os.environ, {"CODEGUARD_HOME": str(self.home)}): + prepared = prepare_summary(ROOT / ".session_state.json", self.repo) + self.assertIn("共 1 次检查", prepared.lines[0]) + bump_state(state, "python", True) + prepared.acknowledge() + self.assertEqual(2, json.loads(state.read_text())["python"]["total"]) + retry = prepare_summary(ROOT / ".session_state.json", self.repo) + self.assertIn("共 2 次检查", retry.lines[0]) + retry.acknowledge() + self.assertFalse(state.exists()) + if __name__ == "__main__": unittest.main()