chore: sync local changes, add Chinese docs and opencode config

This commit is contained in:
AuraK Developer
2026-08-31 11:37:03 +08:00
parent 307d2fd471
commit ecc55158c1
81 changed files with 7645 additions and 144 deletions
+19
View File
@@ -4,6 +4,25 @@
### Added
- **Coverage-driven deep-read planning** for whole-project reviews. New
`deep_read_plan_tool` greedily selects the highest-risk files needed to
close the risk-weighted coverage gap, groups them by directory so each
batch maps to one parallel sub-agent, and returns the priority file list.
`coverage_tool` now also returns `remaining_weight_to_target` and
`priority_deep_read_files` so the reviewing agent knows exactly what still
needs to be read to reach the target.
- **Configurable coverage gate.** `coverage_tool` / `compute_coverage`
gained a `gate` parameter: `high_risk` (default, G3 semantics),
`overall` (all source files), or `both` (overall **and** high-risk must
meet the target). Weight computation was factored into `_file_weights`
and is now shared by `compute_coverage` and `deep_read_plan`.
- **Cross-round incremental coverage index.** `save_coverage_index_tool`
persists the deep-read file list to `.code-review-graph/coverage-index.json`
with per-file SHAs. `coverage_tool(include_prior=True)` and
`deep_read_plan_tool(include_prior=True)` merge files whose SHA is still
current, so later review rounds only re-read files that actually changed
or are new instead of starting from zero each time.
- Added the **unified-review** skill that fuses CRG graph context with the
ai-code-review three-layer scoring methodology and the gstack-review
fix-first workflow. The skill is read-only: every finding waits for a
+566
View File
@@ -0,0 +1,566 @@
# code-review-graph 功能文档
> **停止燃烧 token,让 AI 审查真正的代码。**
>
> code-review-graph 是一个本地优先的**代码审查引擎**:以 Tree-sitter 知识图谱为底座,
> 为 AI 编程助手提供精准的代码上下文与结构化的审查工作流——从 diff 影响面分析、
> 客观量化评分、并行深读覆盖度门禁,到最终的双格式审查报告,全流程只读、可审计。
- 当前版本:v2.5.1(本地定制版)
- 语言要求:Python 3.10+
- 运行形态:本地源码部署(venv / uv),MCP stdio 服务接入 AI 编程工具
- 数据形态:图谱存储于仓库本地 `.code-review-graph/`(SQLite),零遥测,源码不出本机
---
## 目录
1. [工具概述](#1-工具概述)
2. [核心原理](#2-核心原理)
3. [代码审查工作流](#3-代码审查工作流)
4. [MCP 工具(37 个)](#4-mcp-工具37-个)
5. [MCP Prompts7 个)](#5-mcp-prompts7-个)
6. [CLI 命令参考](#6-cli-命令参考)
7. [opencode 本机集成现状](#7-opencode-本机集成现状)
8. [配置与环境变量](#8-配置与环境变量)
9. [已知局限](#9-已知局限)
---
## 1. 工具概述
AI 编程工具执行代码审查时,往往反复扫描大量文件造成严重 token 浪费,且缺乏统一的
审查方法论与量化标准。code-review-graph 的解决方式是:
- 使用 **Tree-sitter** 将仓库解析为知识图谱:节点(函数/类/导入/测试)+ 边(调用/继承/测试覆盖)
- 审查时通过图谱查询计算需要读取的**最小文件集合**(Blast-radius 分析)
- 在图谱之上提供**三套开箱即用的审查工作流**(diff 统一审查 / 全项目审查 / 单功能审查)
- 用**客观指标评分**(SQL 风险、异常分支覆盖、冗余率等)替代"凭感觉审完"
- 用**并行深读管线 + 覆盖度门禁**保证"审过了"不是由感觉决定,而是有数据支撑
- 最终产出自包含的中文 HTML + Markdown 双格式审查报告
核心特性一览:
| 特性 | 说明 |
| --- | --- |
| 本地优先 | 图谱存本机 SQLite,无需外部数据库;云端 embedding 为显式可选 |
| 增量更新 | 只重解析变更文件(SHA-256 比对),后续更新秒级完成 |
| 零遥测 | 不向任何外部服务发送源码 |
| 多语言 | 40+ 编程语言,支持自定义语言扩展 |
| 三种审查入口 | unified-reviewdiff/ project-review(全项目)/ project-review(单功能) |
| 客观量化 | 5 项 Layer-2 指标 + good/warn/fail 分级,blocker 一票否决 |
| 覆盖度门禁 | 文件数口径 + 行覆盖 + 语义单元三件套,双目标(全库 ≥85% 且高风险 ≥95%) |
| 双格式报告 | 自包含中文 HTML(浏览器直开)+ Markdown(入库/PR 复用),全程只读不改代码 |
---
## 2. 核心原理
### 2.1 架构管线
```
仓库 → Tree-sitter 解析 → SQLite 图谱 → Blast-radius 分析 → 最小审查集
detect_changes 风险评分 → score_review 客观指标
deep_read 并行深读 → coverage 门禁 → dedupe 合并
generate_reportHTML + Markdown
```
### 2.2 从代码到图谱
`code-review-graph build` 用 Tree-sitter 把源码解析为 AST,提取:
- **节点**:函数、类、导入语句、测试函数等代码实体
- **边**:调用关系、继承关系、测试覆盖关系等结构联系
图谱把「代码长什么样」升级为「代码之间有什么关系」,这是后续一切审查能力的底座。
解析完成后图谱存储在仓库本地 `.code-review-graph/graph.db`
### 2.3 Blast-radius(爆炸半径)分析
文件发生变更时,图谱追踪所有可能受影响的调用方、依赖方与测试:
- 变更波及面(谁调用了它、谁依赖它、哪些测试覆盖它)
- AI 只读取受影响文件而非扫描整个项目,token 消耗显著下降
### 2.4 增量更新
每次保存文件或 git commit 时计算变更文件的 SHA-256 哈希,只重新解析变化的文件,
再按图谱关系局部更新相关节点。配合 watch 模式 / 平台 hooks / crg-daemon 守护进程,
图谱始终贴近当前代码状态,不需要"用一次就过期"的全量重建。
---
## 3. 代码审查工作流
### 3.1 三种审查入口对比
| 维度 | unified-reviewdiff 审查) | project-review(全项目) | project-review(单功能) |
| --- | --- | --- | --- |
| **审查对象** | git diff(默认 `HEAD~1..HEAD`) | 图谱内全部源文件 | 目标功能/模块相关文件 + 影响面 |
| **触发指令** | "审查"、"检查代码"、"review" | "对项目代码进行全面审查"、"全面审查"、"整个项目" | "审查支付功能的代码"、"审查 auth 模块" |
| **入口** | `/code-review-graph-unified-review` | `/code-review-graph-project-review` | `/code-review-graph-project-review` |
| **核心工具** | `detect_changes` + `score_review` | `score_review(all_files=True)` + 深读管线 | `semantic_search` + `get_impact_radius` + `score_review` |
| **典型场景** | 合并前检查本次改动 | 代码质量体检、架构审计、发布前全面体检 | 上线前审查某个功能 |
三者共享同一套评分/去重/报告工具,流程结构一致:
**图谱上下文 → 客观评分 → 链路分解 → 去重 → 人工裁决 → 报告**,区别仅在范围获取方式。
### 3.2 unified-review:三层统一审查(diff
```
触发 → 范围/档位判定 → [图谱上下文] → Layer 1 八分类 + CRITICAL 检查 → [score_review 量化]
→ [specialist 并行派发] → [dedupe 合并去重] → 人工裁决(只读)→ 验收门禁 → [HTML + Markdown 报告] → 持久化
```
| 步骤 | 内容 |
| --- | --- |
| Step 0 范围/档位 | 读 `.code-review.yaml` 确定 tierfast / standard / strict);检测语言/框架加载对应 checklist;判定 change/file/service/chain 级范围 |
| Step 1 图谱上下文 | `build_or_update_graph``get_review_context`blast radius + 源码片段)→ `detect_changes`(风险分 + 测试缺口 + 受影响流) |
| Step 2 Layer 1 链路分解 | 八分类逐类检查(接口/业务/数据/工具/错误处理/安全/性能/可观测性),叠加 CRITICAL 五类(SQL 数据安全、竞态并发、LLM 信任边界、Shell 注入、枚举完整性) |
| Step 3 Layer 2 量化评分 | `score_review_tool` 计算 5 项客观指标;需求覆盖/逻辑对齐等由 LLM 判断(`llm_judged` 标注,无需求文档时 0.5× 降权) |
| Step 4 specialist 派发 | diff ≥ 50 行时并行派发 testing/maintainability/security/performance/data-migration/api-contract 子代理;security 与 data-migration 为保险型永不 gate |
| Step 5 合并去重 | `dedupe_findings_tool``path:line:category` 指纹合并、多源确认置信 +1、计算 PR 质量分、抑制历史已跳过项 |
| Step 6 人工裁决 | **只读**。每条 finding 带 severity + 置信度 + file:line + 修复建议,按 severity 批量呈现;blocker 不可批量跳过 |
| Step 7 验收门禁 | 任一 🔴 blocker → verdict `❌ FAIL`;分类 Ready / Needs Fix / Unusable |
| Step 8 报告 | `generate_report_tool`(默认 `format="both"`)产出中文报告 |
| Step 9 持久化 | 记录审查结果供后续去重抑制,不可用时静默跳过 |
### 3.3 project-review:全项目深读管线
全项目审查的关键难题是**覆盖度不能靠感觉**。主上下文无法逐文件深读数百个文件,
必须走并行子代理深读流水线:
```
a. deep_read_plan_tool(target_coverage=85)
→ 按风险权重贪心选出待深读文件并分组
b. 按组并行派发 explore 子代理,每个子代理完整深读该组全部文件,
逐文件返回 read_ranges / semantic_units / findings[](每条 finding 必须带 file:line 证据)
c. 三件套质量门禁(coverage_tool gate="both+line"):
① 单元完整性:semantic_units 与图谱单元一一对应
② 行覆盖:union(read_ranges) / 真实行数 ≥ 95%
③ 防伪抽验:主代理抽样回读比对,防子代理假读
d. line_gap unit_gap 非空 → 补轮重读直至清空,禁止静默降级
e. 未达标 → 按 priority_deep_read_files 补一轮,循环至达标
f. 报告生成后 save_coverage_index_tool 写跨轮覆盖索引(file + SHA + ranges),
下轮审查 include_prior=True 自动复用未变更文件,实现增量累积
```
**覆盖度双口径门禁**
- 全库覆盖 = 已深读文件数 / 全部源文件数(目标 ≥85%)
- 高风险覆盖 = 已深读高风险文件数 / 信号点名文件数(目标 ≥95%)
- `target_reached=false` → 报告顶部标 🔴 覆盖不足;覆盖率结果完整透传进报告 `## 覆盖度` 区块
其余步骤(架构全景 → 高风险定位 → `score_review(all_files=True)` → 八分类 + CRITICAL →
dedupe → 人工裁决 → 报告)与 unified-review 同构。
单功能(feature)流程则先用 `semantic_search_nodes(query=target)` 定位代码,
`query_graph(children_of)` 聚合文件、`get_impact_radius` 扩影响面,
之后聚焦这些文件走同一套评分与裁决流程。
### 3.4 报告成果物
每次审查默认产出两份中文报告(`generate_report_tool format="both"`):
- **`code-review-report.html`** — 自包含单文件(内联样式、零外部依赖),浏览器直接打开。
含结论(PASS/FAIL)、PR 质量分、客观指标表、问题清单(severity + 置信度 + 位置 + 修复建议)、
覆盖度区块、人工审查清单
- **`code-review-report.md`** — Markdown 版,便于 git 提交、PR 描述、内部文档复用
`format="html"` / `format="markdown"` 可单独产出;`output_path` 指定输出基础路径
(推荐归档到 `docs/reviews/{name}-review-{YYYY-MM-DD-HHMMSS}`,避免同名覆盖且便于追溯)。
**报告均为只读产物**:审查本身不修改任何代码,所有修复决定由人工确认后执行。
---
## 4. MCP 工具(37 个)
图谱构建完成后,AI 助手通过 MCP 自动使用以下工具(共 37 个,按类分组)。
### 4.1 图谱核心与上下文(12)
| 工具 | 描述 |
| --- | --- |
| `build_or_update_graph_tool` | 构建或增量更新图谱 |
| `run_postprocess_tool` | 重跑流检测、社区检测与 FTS 索引 |
| `get_minimal_context_tool` | 超紧凑上下文(~100 token),任何任务的第一个调用 |
| `get_impact_radius_tool` | 变更文件的爆炸半径分析 |
| `get_review_context_tool` | token 优化的审查上下文 + 结构摘要 + 源码片段 |
| `detect_changes_tool` | **风险评分的变更影响分析**:diff 映射到受影响函数/流/测试缺口,产出风险分与优先审查建议 |
| `query_graph_tool` | 关系模式查询:callers_of / callees_of / imports_of / tests_for / inheritors_of 等 |
| `traverse_graph_tool` | 从任意节点 BFS/DFS 探索,带深度与 token 预算 |
| `semantic_search_nodes_tool` | 向量语义搜索代码实体(需先 embed),无向量时回退 FTS 关键词匹配 |
| `embed_graph_tool` | 为全部图节点计算向量 embeddinglocal/openai/google/minimax/voyage |
| `list_graph_stats_tool` | 图谱规模与健康状况统计 |
| `find_large_functions_tool` | 查找超过行数阈值的函数/类/文件(巨型文件审计) |
### 4.2 执行流与社区架构(11)
| 工具 | 描述 |
| --- | --- |
| `list_flows_tool` | 按关键度排序列出执行流(从入口点追踪的调用链) |
| `get_flow_tool` | 查看单个执行流的完整调用路径 |
| `get_affected_flows_tool` | 查找受变更文件影响的用户级执行路径 |
| `list_communities_tool` | 列出 Leiden 算法聚类出的代码社区 |
| `get_community_tool` | 查看单个社区详情(规模/内聚度/成员) |
| `get_architecture_overview_tool` | 从社区结构生成架构总览与耦合警告 |
| `get_hub_nodes_tool` | 找出连接最多的节点(架构热点,改动爆炸半径大) |
| `get_bridge_nodes_tool` | 通过介数中心性找出架构瓶颈(桥接节点) |
| `get_knowledge_gaps_tool` | 识别孤立节点、未测试热点、薄弱社区等结构弱点 |
| `get_surprising_connections_tool` | 检测意外耦合:跨社区/跨语言/外围到 Hub 的边 |
| `get_suggested_questions_tool` | 从图谱分析自动生成审查问题(桥/hub/惊喜耦合驱动) |
### 4.3 审查分析与报告(8
| 工具 | 描述 |
| --- | --- |
| `score_review_tool` | **客观 Layer-2 指标**:SQL 风险、异常分支覆盖、冗余率、高风险密度、漏洞启发式五项,带 good/warn/fail 分级与证据;支持 `all_files=True` 全量评分;`llm_judged` 字段列出需 LLM 补判项 |
| `dedupe_findings_tool` | **Finding 合并去重**:按 `path:line:category` 指纹合并、多源置信 +1(上限 10)、低置信移附录、抑制历史已跳过项、计算 PR 质量分 `max(0, 10 - (critical×2 + informational×0.5))` |
| `generate_report_tool` | **报告生成**:渲染为自包含中文 HTML 与 Markdown(默认双格式),自动渲染结论/指标表/问题清单/覆盖度区块 |
| `refactor_tool` | 重命名预览、框架感知死代码检测、社区驱动重构建议 |
| `apply_refactor_tool` | 应用此前预览过的重构(精确字符串替换) |
| `generate_wiki_tool` | 从社区结构生成 markdown wiki`.code-review-graph/wiki/` |
| `get_wiki_page_tool` | 获取特定 wiki 页面 |
| `get_docs_section_tool` | 获取内置 LLM 优化文档的指定章节(usage/review-pr/troubleshooting 等) |
### 4.4 深读管线、覆盖度与多仓(6)
| 工具 | 描述 |
| --- | --- |
| `deep_read_plan_tool` | 生成按风险权重贪心分组的深读计划(groups + planned_files + estimated_batches),支持增量排除上轮已读文件 |
| `coverage_tool` | **覆盖度三件套门禁**:文件数口径 + 行覆盖 + 语义单元覆盖三重计算;`gate="both+line"` 时四项 AND 判定;返回缺口文件与补读优先级队列;`include_prior=True` 合并跨轮索引实现增量 |
| `save_coverage_index_tool` | 持久化深读名单到 `.code-review-graph/coverage-index.json`file + per-file SHA + ranges),供下轮审查增量复用 |
| `community_health_tool` | 检查 nodes.community_id 归属健康度,归属率 <90% 时提示先 postprocess 再算覆盖度,防止失真 |
| `list_repos_tool` | 列出多仓注册表中已注册仓库 |
| `cross_repo_search_tool` | 跨所有已注册仓库搜索代码实体,按仓内 rank 交错返回 |
---
## 5. MCP Prompts7 个)
MCP 协议层的 7 个预构建工作流模板,支持 MCP prompts 的客户端将其呈现为 "/" 可调用命令。
全部强制 token 效率模式:先调 `get_minimal_context`,默认 `detail_level="minimal"`
| "/" 可调用命令 | 功能 | 参数 |
| --- | --- | --- |
| `/review_changes` | 提交前审查:`detect_changes` + 受影响执行流 + 测试缺口 | `base="HEAD~1"` |
| `/architecture_map` | 架构文档:社区、执行流、Mermaid 图 + 耦合警告 | 无 |
| `/debug_issue` | 引导式调试:语义搜索 + 调用链追踪 + 执行流定位根因 | `description=""` |
| `/onboard_developer` | 新成员入职指南:统计、架构、关键执行流 | 无 |
| `/pre_merge_check` | 合并前就绪检查:风险评分、测试缺口、死代码 | `base="HEAD~1"` |
| `/unified_review` | 三层统一审查(只读):图谱上下文 → 客观评分 → 人工裁决 → 去重 → 双格式报告。任何 blocker 判 FAIL | `base="HEAD~1"``tier="standard"`fast/standard/strict |
| `/project_review` | 项目级审查(非 diff):全项目或单功能,由用户自然语言指令决定范围,只读 | `scope="whole-project"``target=""` |
---
## 6. CLI 命令参考
### 6.1 安装与部署
```bash
code-review-graph install # 自动检测并配置所有支持的 AI 平台(MCP/hooks/skills/规则注入)
code-review-graph install --platform <name> # 仅配置单个平台
code-review-graph install --dry-run # 预览动作不写入
code-review-graph init # install 别名
code-review-graph uninstall --dry-run # 对称卸载预览(只移除本工具拥有的组件)
code-review-graph uninstall --keep-data # 移除集成但保留图谱数据库
code-review-graph uninstall --all-repos # 同时清理所有已注册仓库
```
支持平台:Codex、Claude Code、CodeBuddy Code、Cursor、Windsurf、Zed、Continue、
OpenCode、Antigravity、Gemini CLI、Qwen、Qoder、Kiro、GitHub Copilot(含 CLI)。
### 6.2 图谱构建与维护
```bash
code-review-graph build # 全量解析整个代码库
code-review-graph build --skip-flows # 跳过流检测
code-review-graph build --skip-postprocess # 跳过后处理(流/社区/FTS
code-review-graph update # 增量更新(仅变更文件)
code-review-graph update --base <ref> # 指定 git diff 基线
code-review-graph update --brief # 刷新后打印 Token Savings 面板
code-review-graph update --verify # 用 tiktoken 交叉校验 token 估算
code-review-graph postprocess # 重跑后处理
code-review-graph postprocess --no-flows # 跳过流检测
code-review-graph postprocess --no-communities# 跳过社区检测
code-review-graph postprocess --no-fts # 跳过 FTS 重建
code-review-graph watch # 监听文件变更自动增量更新
code-review-graph status [--json] # 图谱统计
code-review-graph forget <path> [--dry-run] # 从图谱移除已解析文件(免全量重建)
code-review-graph embed --provider local # 计算 embedding--model all-MiniLM-L6-v2
```
通用参数:绝大多数命令支持 `--repo <root>` 显式指定仓库根(默认自动探测);
`--data-dir` 可覆盖图谱数据目录。
### 6.3 分析与审查
```bash
code-review-graph detect-changes --brief # 风险面板 + token 节省估算(只读)
code-review-graph detect-changes --base <ref> # 指定 diff 基线(默认 HEAD~1
code-review-graph detect-changes --churn # 风险评分加入变更频率因子
code-review-graph impact --files <path>... # 变更爆炸半径(--depth/--max-results/--base
code-review-graph query <pattern> <target> # 图谱关系查询(callers_of/callees_of/imports_of 等)
code-review-graph search <query> [--kind] # FTS/语义混合搜索图谱实体
code-review-graph dead-code [--kind] [--file-pattern] [--json] # 死代码检测
code-review-graph large-functions --min-lines 150 [--path] [--kind] # 巨型函数/文件审计
code-review-graph refactor rename --old-name X --new-name Y # 重命名预览
code-review-graph refactor dead_code|suggest # 死代码/重构建议
```
### 6.4 结构浏览
```bash
code-review-graph flows [--limit 50] [--kind] [--sort] # 执行流列表
code-review-graph flow --id 3 [--source] # 单个执行流详情(可含源码片段)
code-review-graph communities [--min-size] [--sort] # 社区列表
code-review-graph community --id 71 [--members] # 单个社区详情
code-review-graph architecture [--detail-level minimal|standard] # 架构总览
code-review-graph visualize [--format json|graphml|svg|obsidian|cypher] [--serve]
code-review-graph wiki [--force] # 从社区生成 markdown wiki
```
### 6.5 多仓与服务
```bash
code-review-graph register <path> [--alias] # 注册仓库到多仓注册表
code-review-graph unregister <path_or_alias> # 移除注册
code-review-graph repos # 列出已注册仓库
code-review-graph eval [--benchmark ...] [--all] [--report] # 评估基准
code-review-graph serve # 启动 MCP stdio 服务
code-review-graph serve --repo <root> # 指定仓库根
code-review-graph serve --http --host 127.0.0.1 --port 5555 # Streamable HTTP 模式
code-review-graph serve --tools t1,t2 # 仅暴露部分 MCP 工具(token 受限环境)
code-review-graph serve --auto-watch # 服务期间自动增量更新
code-review-graph mcp # serve 别名
```
### 6.6 多仓守护进程(crg-daemon
编辑器不支持 hooks 或希望后台保持多仓图谱新鲜时使用,随主程序一并安装:
```bash
crg-daemon add ~/project-a --alias proj-a # 注册监听(写 ~/.code-review-graph/watch.toml
crg-daemon start # 后台启动,每 30s 健康检查并重启死掉的 watcher
crg-daemon status # 查看守护进程与各仓库 watcher 状态
crg-daemon logs --repo proj-a --follow # 追踪某仓库日志
crg-daemon stop # 停止守护进程与全部 watcher
```
也支持 `code-review-graph daemon start|stop|restart|status|logs|add|remove`
---
## 7. opencode 本机集成现状
本机采用**源码部署**方式:工具代码位于 `D:\code-review-graph\code-review-graph-main`
venv 内的可执行文件被 MCP 配置以绝对路径引用。
### 7.1 MCP 配置(两处)
```json
// D:\AuraSpace\.mcp.json(项目级)
{
"mcpServers": {
"code-review-graph": {
"command": "D:\\code-review-graph\\code-review-graph-main\\.venv\\Scripts\\code-review-graph.exe",
"args": ["serve", "--repo", "D:\\AuraSpace"],
"cwd": "D:\\AuraSpace",
"type": "stdio"
}
}
}
```
```json
// %USERPROFILE%\.config\opencode\opencode.json(全局级,节选)
{
"mcp": {
"code-review-graph": {
"type": "local",
"command": [
"D:\\code-review-graph\\code-review-graph-main\\.venv\\Scripts\\code-review-graph.exe",
"serve", "--repo", "D:\\AuraSpace"
],
"timeout": 600000,
"env": { "CRG_REPO_ROOT": "D:\\AuraSpace" }
}
}
}
```
### 7.2 已安装的 skills`%USERPROFILE%\.config\opencode\skills\`9 个)
| Skill | 功能 |
| --- | --- |
| `build-graph` | 构建/重建代码图谱 |
| `explore-codebase` | 图谱结构探索导航 |
| `debug-issue` | 图谱引导式调试 |
| `refactor-safely` | 依赖分析驱动的安全重构 |
| `review-changes` | 变更审查(change detection + impact |
| `review-delta` | 自上次提交以来的增量审查 |
| `review-pr` | PR/分支完整审查 |
| `unified-review` | 三层统一审查:CRG 图谱上下文 + ai-code-review 评分方法论 + gstack-review 流程 |
| `project-review` | 项目级审查(全项目/单功能):并行深读管线 + 三件套覆盖度门禁 + 双格式报告 |
其中 `project-review` 附带 PowerShell 校验脚本(verify-report / verify-line-coverage /
verify-spot-check.ps1)与 Python 聚合脚本(aggregate_deep_read.py),
skill 文档内引用了绝对路径,迁移机器时需同步修改。
### 7.3 已安装的斜杠命令(`command\`,5 个)
| 命令 | 功能 |
| --- | --- |
| `/code-review-graph-build-graph` | 构建/更新图谱 |
| `/code-review-graph-unified-review` | diff 三层统一审查(只读) |
| `/code-review-graph-project-review` | 项目级审查:全项目或单功能(只读) |
| `/code-review-graph-review-pr` | PR/branch diff 审查 |
| `/code-review-graph-review-delta` | 自上次提交以来的变更审查 |
### 7.4 本地部署步骤(新机器/重建环境)
```powershell
# 前置:Python 3.10+ 与 uv
cd D:\code-review-graph\code-review-graph-main
uv sync # 按 uv.lock 重建 .venv(国内网络先设 UV_INDEX_URL 镜像)
.\.venv\Scripts\code-review-graph.exe --version # 应输出 2.5.x
```
**配置 opencode.json(全局 MCP 注册)**
文件位于 `%USERPROFILE%\.config\opencode\opencode.json`,将 code-review-graph 段加入 `mcp` 对象
(若已有 `plugin``playwright` 等其他配置项则保留不动):
```json
"code-review-graph": {
"type": "local",
"command": [
"D:\\code-review-graph\\code-review-graph-main\\.venv\\Scripts\\code-review-graph.exe",
"serve", "--repo", "D:\\AuraSpace"
],
"timeout": 600000,
"env": { "CRG_REPO_ROOT": "D:\\AuraSpace" }
}
```
按新机器实际修改 3 处:`command[0]` 的 exe 绝对路径、`--repo` 的仓库根、`env.CRG_REPO_ROOT`
项目级 `.mcp.json`(可选)参考 §7.1。
然后核对 §7.1 的 `.mcp.json` 与本节 opencode.json 中的绝对路径与新机一致,
复制 7.2/7.3 的 skills 与 commands,重启 opencode 即生效。
> **使用前提**:先构建图谱(首次 `code-review-graph build` 或 `/code-review-graph-build-graph`),
> 否则图谱工具返回 `not_ready`。
---
## 8. 配置与环境变量
### 8.1 排除路径(`.code-review-graphignore`
在仓库根目录创建 `.code-review-graphignore` 排除已跟踪文件的索引:
```
generated/**
*.generated.ts
vendor/**
node_modules/**
```
> git 仓库中默认只索引被跟踪文件(`git ls-files`),gitignore 文件自动跳过。
> `.code-review-graphignore` 用于排除已跟踪文件或 git 不可用的场景。
### 8.2 可选依赖组
```bash
pip install "code-review-graph[embeddings]" # 本地向量 embeddingsentence-transformers
pip install "code-review-graph[google-embeddings]" # Google Gemini embeddings
pip install "code-review-graph[communities]" # 社区检测(igraph
pip install "code-review-graph[enrichment]" # Python 调用解析富化(Jedi
pip install "code-review-graph[eval]" # 评估基准
pip install "code-review-graph[wiki]" # Wiki 生成 + LLM 摘要(ollama
pip install "code-review-graph[all]" # 全部可选依赖
```
源码部署时对应 `uv sync --extra <name>` 或在 venv 内 `pip install -e .[<name>]`
### 8.3 环境变量
| 变量 | 描述 | 默认 |
| --- | --- | --- |
| `CRG_REPO_ROOT` | 显式指定仓库根(opencode 全局配置中使用) | - |
| `CRG_DATA_DIR` | 覆盖图谱数据库与生成产物的目录 | - |
| `CRG_GIT_TIMEOUT` | Git 操作超时(秒) | `30` |
| `CRG_TOOLS` | serve 时暴露的 MCP 工具允许列表(逗号分隔) | - |
| `CRG_TOOL_TIMEOUT` | 有界 MCP 工具的可选超时(秒,`0` 禁用) | `0` |
| `CRG_MAX_IMPACT_NODES` | 影响分析中最多包含的节点数 | `500` |
| `CRG_MAX_IMPACT_DEPTH` | 爆炸半径分析搜索深度 | `2` |
| `CRG_MAX_BFS_DEPTH` | 图谱遍历最大深度 | `15` |
| `CRG_MAX_SEARCH_RESULTS` | 搜索结果数上限 | `20` |
| `CRG_MAX_CHANGED_FUNCS` | 单次变更报告分析的最大变更函数数 | `500` |
| `CRG_MAX_TRANSITIVE_FRONTIER` | 传递调用方/被调用方展开的最大前沿大小 | `50` |
| `CRG_RECURSE_SUBMODULES` | 设为 `1`/`true`/`yes` 时包含 git 子模块文件 | - |
| `CRG_SERIAL_PARSE` | 设为 `1` 禁用并行解析(调试用) | - |
| `NO_COLOR` | 设置后禁用终端 ANSI 颜色 | - |
**Embedding 相关**(可选功能,全部默认关闭):
| 变量 | 描述 | 默认 |
| --- | --- | --- |
| `CRG_EMBEDDING_MODEL` | 本地 embedding 默认模型 | `all-MiniLM-L6-v2` |
| `GOOGLE_API_KEY` | Google Gemini embedding 的 API key | - |
| `MINIMAX_API_KEY` | MiniMax embedding 的 API key | - |
| `VOYAGE_API_KEY` / `CRG_VOYAGE_*` | Voyage embedding 密钥与模型/维度/批大小等参数 | `voyage-code-3` |
| `CRG_OPENAI_BASE_URL` / `CRG_OPENAI_API_KEY` / `CRG_OPENAI_MODEL` | OpenAI 兼容端点(真实 OpenAI、Azure、new-api、LiteLLM、vLLM、LocalAI 等) | - |
| `CRG_ACCEPT_CLOUD_EMBEDDINGS` | 显式确认后抑制云端 embedding 出口警告 | - |
| `CRG_ALLOW_REMOTE_CODE` | 允许需要 `trust_remote_code=True` 的 HuggingFace 模型 | `0` |
隐私说明:embedding 只处理标识符、签名、结构上下文与首段 docstring 摘要,
**不传输函数体**;例行构建默认不刷新 embedding,刷新需显式传 provider + model。
### 8.4 语言覆盖
解析器覆盖函数、类、导入、调用点、继承与测试检测:
- **Web**JavaScript / TypeScript / TSX、PHP、Ruby、Vue/Svelte SFC、Astro、Blade
- **后端**Python、Go、Java、C/C++、C#、VB.NET、Kotlin、Scala、Elixir、R
- **系统**Rust、Zig、Objective-C、Nix、Shell、Verilog/SystemVerilog、SQL
- **移动**Swift、Kotlin、Objective-C
- **脚本**Perl / Perl XS、Lua/Luau、PowerShell、Julia、ReScript、GDScript
- **配置/结构**Terraform/OpenTofu`.tf`)、Ansible playbook/role/task、Solidity、Dart
- **笔记本**Jupyter / Databricks`.ipynb`
> 通用 YAML 不视为源码。PHP 项目额外获得 Composer PSR-4 解析、Blade 引用与
> Laravel Route/Eloquent 语义边(基于证据门控)。
### 8.5 自定义语言(无需改代码)
在仓库 `.code-review-graph/languages.toml` 将扩展名映射到 `tree_sitter_language_pack`
中任意已捆绑语法,并声明函数/类/导入/调用的 tree-sitter 节点类型:
```toml
[languages.erlang]
extensions = [".erl"]
grammar = "erlang"
function_node_types = ["function_clause"]
class_node_types = ["record_decl"]
import_node_types = ["import_attribute"]
call_node_types = ["call"]
```
通用 tree-sitter 遍历器即可完成提取;内置语言永远不会被覆盖。
详见本仓库 `docs/CUSTOM_LANGUAGES.md`
- **如何验证工具正常工作?** `code-review-graph status``detect-changes --brief`
或在 MCP 客户端中查看 code-review-graph 服务是否列出工具。
- **图谱何时需要重建?** 日常由 watch/hooks/crg-daemon 自动增量维护;仅在切换分支大范围
变更、怀疑图谱过期或升级版本后建议全量 `build`
- **审查会修改我的代码吗?** 不会。三种审查工作流全程只读,报告落盘于
`docs/reviews/`(或指定的 `output_path`),修复决定始终由人工执行。
- **MCP 报 not_ready?** 该仓库尚未构建图谱,先运行一次 `build`
+729
View File
@@ -0,0 +1,729 @@
# 代码审查功能文档
> 代码审查能力由**两个独立工作流**组成:`unified-review`diff 审查)与 `project-review`(项目级审查)。本文聚焦**审查工作流**的操作细节(流程、覆盖度机制、客观指标、报告产物、实操踩坑)。
>
> 📖 **文档分工**
> - **本文**`CODE_REVIEW_GUIDE_ZH.md`)— 审查流程 / 指标解读 / 覆盖度机制 / 报告产物 / 踩坑记录
> - **工具总览**`CODE_REVIEW_GRAPH_ZH.md`)— 工具定位、MCP 工具清单、CLI 命令、配置与部署、opencode 集成现状
---
## 目录
1. [两种审查工作流](#1-两种审查工作流)
2. [unified-reviewdiff 代码审查](#2-unified-reviewdiff-代码审查)
3. [project-review:项目级代码审查](#3-project-review项目级代码审查)
4. [并行深读流水线](#36-并行深读流水线whole-project-强制)
5. [审查调用的 MCP 工具](#4-审查调用的-mcp-工具)
6. [5 项客观指标详解](#5-5-项客观指标详解)
7. [审查 Skill 体系](#6-审查-skill-体系)
8. [审查报告成果物](#7-审查报告成果物)
9. [快速上手](#8-快速上手)
---
## 1. 两种审查工作流
| 维度 | unified-review | project-review |
| --- | --- | --- |
| **审查对象** | git diff(默认 `HEAD~1..HEAD`| 全部源文件 / 单个功能 |
| **范围来源** | 自动检测 git diff | 由用户自然语言指令决定 |
| **opencode 命令** | `/code-review-graph-unified-review` | `/code-review-graph-project-review` |
| **MCP prompt** | `/code-review-graph:unified_review` | `/code-review-graph:project_review` |
**共同特性**
- **只读**:不修改代码,所有修复由人工裁决
- **客观评分**:统一走 `score_review_tool` 的 5 项量化指标
- **去重合并**:统一走 `dedupe_findings_tool`
- **双格式报告**:默认产出中文 HTML + Markdown
- **验收门禁**:任一 🔴 blocker → 判定 `❌ FAIL`
---
## 2. unified-reviewdiff 代码审查
### 2.1 执行流程
| 步骤 | 做了什么 | 起到什么功能 |
| --- | --- | --- |
| **Step 0 范围** | 固定 `standard` 档位(全层审查);检测语言/框架;判定 change/file/service/chain 级范围 | 决定审查深度与范围粒度,避免对简单改动做全量审查,对高风险模块自动升级审查强度 |
| **Step 1 图谱上下文** | `build_or_update_graph` 确保图谱最新;`get_review_context` 拿 blast radius + 源码片段;`detect_changes` 拿风险分、测试缺口、受影响流 | 让 AI 只读变更波及的最小文件集,建立"改了什么、影响了谁"的全貌,token 高效 |
| **Step 2 Layer 1 链路分解** | 八分类检查(接口/业务/数据/工具/错误处理/安全/性能/可观测性)+ gstack CRITICAL 五类(SQL 数据安全、竞态并发、LLM 信任边界、Shell 注入、枚举完整性) | 逐类排查 AI 易漏的高风险缺陷(注入、竞态、信任边界、枚举遗漏),建立分类清单 |
| **Step 3 Layer 2 量化评分** | `score_review_tool` 计算 5 项客观指标(SQL 风险/异常分支/冗余率/高风险密度/漏洞启发式);需求覆盖、逻辑对齐等由 LLM 判断(`llm_judged` 标注) | 用数据支撑评分而非主观判断:指标带 good/warn/fail 分级与证据,明确哪些需 LLM 补判 |
| **Step 4 specialist 派发** | diff ≥ 50 行时并行派发 testing/maintainability/security/performance/data-migration/api-contract 子代理,各自独立审查 | 用"多个专注专家并行独立审查"覆盖单一视角盲区,多专家确认的 finding 置信度更高 |
| **Step 5 合并去重** | `dedupe_findings_tool``path:line:category` 指纹合并、多源确认置信 +1、低置信抑制、计算 PR 质量分 | 把主审查 + 各 specialist 的 findings 合并成唯一清单,去除重复噪音,量化整体质量 |
| **Step 6 人工裁决** | **只读**。每条 finding 带 severity + 置信度 + file:line + 修复建议,按 severity 批量呈现,逐个决定修/不修 | 把关决策权交给人工,防止 AI 擅自改码;blocker 不可批量跳过,确保高风险项被确认 |
| **Step 7 验收门禁** | 任一 🔴 blocker → verdict `❌ FAIL`;分类 Ready / Needs Fix / Unusable | 给出明确的合并/上线结论,任何阻塞项都阻止通过,避免带病合并 |
| **Step 8 报告** | `generate_report_tool``format="both"`)产出中文 HTML + Markdown 报告 | 生成可分发、可存档的审查成果物,供 PR 描述、文档、评审留痕 |
| **Step 9 持久化** | 若 `gstack-review-log` 可用则记录审查结果,否则静默跳过 | 供跨次审查去重抑制与 /ship 识别,形成审查历史 |
### 2.2 档位
审查默认运行在 `standard` 档位(全层审查:Layer 1 链路分解 + Layer 2 客观评分 + 报告)。`tier` 参数支持 `fast` / `standard` / `strict` 三档,其中 `fast` / `strict``.code-review.yaml` 配置或 MCP prompt 调用时指定;`/code-review-graph-unified-review` 命令固定 `standard`。覆盖度目标固定为双目标:全库 ≥85% **且** 高风险 ≥95%project-review 三件套口径见 §3.5.4)。
---
## 3. project-review:项目级代码审查
### 3.1 范围解析(Step 0
| 用户指令 | 解析结果 |
| --- | --- |
| "对项目代码进行全面审查" / "全面审查" / "整个项目" | `scope="whole-project"` |
| "对支付功能的代码进行审查" / "审查 auth 模块" | `scope="feature"`, `target=<关键词>` |
### 3.2 全项目流程(whole-project
| 步骤 | 做了什么 | 起到什么功能 |
| --- | --- | --- |
| **Step 0 范围解析** | 从用户指令解析出 `scope="whole-project"` | 明确本次审查是整个项目而非 diff/单功能 |
| **Step 1 图谱就绪 + 最小上下文** | `build_or_update_graph_tool()` 确保图谱最新;`get_minimal_context_tool(task="project review")` 拿节点/边/社区/风险概览 | 保证后续查询基于最新代码结构;最小上下文把全库概览压缩为几百 token,避免主上下文被 500+ 文件淹没 |
| **Step 2 架构全景** | `get_architecture_overview` + `list_communities`(模块地图) | 先建立全局模块结构认知,为后续定位高风险区域提供上下文 |
| **Step 3 高风险定位** | `get_knowledge_gaps`(未测试热点/孤立节点)+ `get_hub_nodes`(热点)+ `get_bridge_nodes`(瓶颈)+ `find_large_functions`(超大函数)+ `get_surprising_connections`(异常耦合) | 图论分析(节点度、介数中心性、社区划分、测试覆盖边),不读代码内容——它能告诉你"这个函数被 20 处调用,改动影响大",但说不出代码写得如何。 |
| **Step 4 全量客观评分** | `score_review_tool(all_files=True)` 评分全部源文件;`community_health_tool()` 前置检查图谱健康 | 对每个源文件跑 5 项客观指标,文本质量扫描(_iter_source_lines 读文件逐行匹配正则),不关心调用关系——它能告诉你"这个文件异常处理薄弱",但看不出它是架构瓶颈。社区归属率 <90% 需先 `postprocess` 重建,否则覆盖度失真。 |
| **Step 5 并行深读流水线** | `deep_read_plan_tool(target_coverage=85, include_prior=True)` 生成风险加权分组 → 每次并行派发 4-6 个 explore 子代理分组深读(每组 ≤40 文件)→ 子代理返回 `deep_read_files` + findings(每条必须带 file:line 证据)→ `coverage_tool(deep_read_files=<全清单>, gate="both", include_prior=True)` 复算 | 主上下文不逐文件读 500+ 文件,改为子代理流水线分摊。**这是 whole-project 覆盖达标的唯一可行路径**。深读计划按风险权重贪心,85% 全库计划先选满全部高风险文件,天然逼近高风险 95%。详见 §3.6 |
| **Step 6 覆盖度门禁与补轮** | 双目标未达标(全库 <85% 或 高风险 <95%)→ 按 `coverage_tool` 返回的 `priority_deep_read_files` 补派 1-3 个子代理;`silent_files` 随机抽 15% 深读(G2),发现 ≥1 major 则升级全量 | 可量化兜底"该读的都读了",防止审查深度由感觉决定 |
| **Step 7 合并去重** | `dedupe_findings_tool` 合并去重 + PR 质量分 | 统一 findings 清单,量化整体质量 |
| **Step 8 人工裁决 + 门禁** | 只读裁决;任一 🔴 blocker → `❌ FAIL` | 人工把关 + 明确的审查结论 |
| **Step 9 报告 + 自检** | `generate_report_tool(review_data=..., output_path="docs/reviews/...")` 产出 HTML + MarkdownStep 9.5 打开生成的 md 自检(问题清单数、三要素、客观指标表);Step 9.6 跑 `verify-report.ps1` 命名自检 | 全项目体检报告,可分发存档;自检防"空报告/漏命名"回归 |
| **Step 10 持久化** | `save_coverage_index_tool(deep_read_files=<本轮全量深读清单>)` 写跨轮覆盖索引(file + per-file SHA | 下一轮 `include_prior=True` 自动复用未变更文件,多轮后增量归零即全覆盖 |
### 3.3 单功能流程(feature
| 步骤 | 做了什么 | 起到什么功能 |
| --- | --- | --- |
| **Step 0 范围解析** | 从用户指令解析出 `scope="feature"`, `target=<关键词>` | 明确只审查目标功能,缩小范围 |
| **Step 1 图谱就绪** | `build_or_update_graph_tool()` + `get_minimal_context_tool` | 保证查询基于最新代码 |
| **Step 2 定位功能代码** | `semantic_search_nodes(query=target)`(语义定位)+ `query_graph(children_of, target=<模块>)`(模块展开)→ 聚合功能涉及文件 `files=[...]` | 把"功能关键词"转化为具体文件清单,确定审查对象 |
| **Step 3 影响面分析** | `get_impact_radius(changed_files=files)` | 找出该功能波及的调用方/依赖方,评估改动影响范围 |
| **Step 4 客观评分** | `score_review_tool(changed_files=files + 影响文件)` | 对功能代码跑 5 项客观指标 |
| **Step 5 链路分解 + 深读** | Layer 1 八分类 + CRITICAL 检查(聚焦功能文件);文件多时也可用子代理分组深读 | 逐类排查功能内的高风险缺陷 |
| **Step 6 覆盖度计算** | `coverage_tool(deep_read_files=<功能文件>, gate="both")` 复算 | 功能级覆盖兜底 |
| **Step 7 合并去重** | `dedupe_findings_tool` 合并去重 + PR 质量分 | 统一 findings 清单 |
| **Step 8 人工裁决 + 门禁** | 只读裁决;任一 🔴 blocker → `❌ FAIL` | 人工把关 + 结论 |
| **Step 9 报告 + 自检** | `generate_report_tool(format="both")`md 自检 + verify-report.ps1 | 功能审查报告,防回归 |
| **Step 10 持久化** | `save_coverage_index_tool` 写覆盖索引 | 跨轮增量复用 |
### 3.4 `all_files` 参数
`score_review_tool(all_files=True)` 评分图谱内**全部源文件**,忽略 `changed_files` 与 git diff,用于全项目审查。默认 `False`
### 3.5 覆盖度保障机制
> 目标:保证"该深读的都读了,不该漏的没漏",用可量化的方式兜底非热点文件。本节是 project-review 的增强约定,unified-review 可复用(把"深读名单"理解为"变更文件 + 高风险文件")。
#### 3.5.1 三层覆盖模型
| 层 | 对应步骤 | 覆盖范围 | 手段 |
| --- | --- | --- | --- |
| L1 全量机械扫描 | Step 4 `all_files=True` | **100% 文件** × 5 项指标 | 正则启发式,查机械性风险 |
| L2 并集信号 → 深读名单 | Step 3 + 本节 | 任一信号点名的文件 | 多信号并集,无上限 |
| L3 深度审查 | Step 5.5 子代理深读 | 名单内全部文件 | 逐文件通读(大文件分段),产出带 file:line 证据的 finding |
#### 3.5.2 深读名单(并集信号,无上限)
```text
进名单 = 任一命中:
① 拓扑热点:hub / bridge / large_functions / knowledge_gaps 标记
② Step 4 指标 fail 或 warn 的文件
③ churn 热点(≥3 次提交)
④ 测试缺口热点(untested_hotspots
任一命中 → 必须深读(无上限)
未被任何信号点名 → 记入"未深读文件清单",在报告中显式列出
```
关键原则:**不覆盖不可怕,不知道没覆盖才可怕**。报告必须附带"未深读文件清单",把覆盖边界显式化。
#### 3.5.3 静默文件抽检
- 从未被信号点名的文件("静默文件")随机抽 **15%** 深读
- **升级规则**:抽检中发现 ≥1 个 major → 该文件升级为全量深读,并触发同社区/同类文件追加抽检一轮
#### 3.5.4 覆盖度度量(文件数口径 + 三件套质量口径,`gate="both+line"`
**由引擎 `coverage_tool` 自动计算**`code-review-graph` v2.5.0+,三件套口径 v2.5.1 起),审查代理只需传入 `deep_read_files`(本轮实际深读清单)、`gate` 与三件套数据(`file_read_ranges` / `file_semantic_units`)。
```text
双重文件数口径:
全库覆盖 coverage_pct = 已深读文件数 / 全部源文件数
高风险覆盖 high_risk_coverage_pct = 已深读高风险文件数 / 信号点名文件数
三件套质量口径(gate="both+line" 时生效):
① 单元完整性 unit_coverage_pct = 语义单元无缺口的深读文件数 / 深读文件数
语义单元 = 图谱 Function/Class/Test 节点;子代理上报 semantic_units 与其差集必须为空
巨型文件豁免(最大单元行占比 >80%,如 migrations.rs run_migrations 98%)→ 仅按行覆盖校验
② 行覆盖 line_coverage_pct = 行覆盖达标的深读文件数 / 深读文件数
分子 = union(read_ranges);分母 = 真实文件行数(非图谱 line_end,±1 偏差已修正)
③ 防伪抽验(主代理执行,非引擎) = 每组抽 2 文件 × 2-3 单元回读比对 note,每波 ≤40 次;
结果落盘 spot_check_*.json 并注入 review_data.spot_check(顶层字段),
由 verify-spot-check.ps1 强制校验"已执行"(引擎无法防假读,抽样是唯一手段)
gate 取值:
"overall" — 只看全库口径
"high_risk" — 只看高风险口径
"both" — 全库 + 高风险都要达标(保持原语义,向后兼容)
"both+line" — both + 行覆盖 ≥95% + 单元完整性无缺口(whole-project 新默认,见下)
```
**目标值(固定 standard 档位)**`gate="both+line"` 时要求**全库 ≥85% 且 高风险 ≥95% 且 行覆盖 ≥95% 且 单元完整性无缺口**(`line_target=95``unit_target=100` 差集为空)。`target_reached=false` → 报告顶部标 🔴 覆盖不足,并给出 `remaining_files_to_target`(还差几个文件)与 `priority_deep_read_files`(按风险权重降序的待深读清单)驱动补轮;`both+line` 额外返回 `line_gap_files` / `unit_gap_files` / `unit_exempt_files` 定位具体缺口文件。
**增量语义(include_prior=True**`save_coverage_index_tool` 在每轮报告后写 `.code-review-graph/coverage-index.json`v2:相对路径 → per-file SHA + `ranges` 行区间)。SHA 来源为图谱 `nodes.file_hash`(一次 SQL 查询,零 subprocess——已修复 v2.5.0 逐文件 `git hash-object` 超时问题)。下一轮 `include_prior=True` 时自动复用 SHA 未变文件的深读状态**及行区间**,只要求重读新增/变更文件,多轮后增量归零即全覆盖。
> ⚠️ **图谱同 range 多节点噪声豁免**:TS/TSX 图谱可能对**同一行**解析出多个箭头函数节点(如 `const a = ..., x = ...` 各占一行但 range 相同),导致单元完整性差集永远非空(one-to-one 匹配下无解)。处理原则:**仅当该文件行覆盖已达 100%(或 ≥95%)**,判定为图谱解析噪声,将该文件从 `unit_gap` 中豁免并在报告显式标注"图谱同 range 多节点豁免",不要反复补读。`unit_gap_files` 的判定须结合具体 uncovered 单元名与行号人工确认是否噪声(对比源码该行是否有多个符号)。
> ⚠️ **0 字节空文件处理**:深读计划可能选中空文件(如 `web/src/components/evm/EvmConfigModal.tsx`0 字节且无引用)。引擎对空文件报 `unreadable/empty file` 计入 `line_gap_files`。处理:验证文件字节数与引用情况,确认死文件后从深读清单移除,并作为一条 finding(死代码残留)记录在报告中。
#### 3.5.5 三道闸门
- **G1 名单完整性**:名单外文件须确认"被评估过"而非"被忽略",未被任何信号点名的文件记入**未深读文件清单**并在报告显式列出
- **G1.5 三件套质量门禁复核**(v2.5.1+):报告前复核三件套聚合结果——`line_gap_files unit_gap_files` 必须为空(已补读至空);**图谱同 range 多节点噪声豁免的文件须在报告中显式列出**(每文件注明"行覆盖已 100%,图谱同 range 多节点豁免"),0 字节空文件须从清单移除并单列 finding;`verified_files``coverage_tool``deep_read_files` 一致;防伪抽验记录(抽了几组/几个单元/有无假读)附入报告,并由 `verify-spot-check.ps1`Step 8.8)强制校验
- **G2 抽检执行**:用 `coverage_tool` 返回的 `silent_files` 随机抽 15% 深读;抽检中发现 ≥1 个 major → 该文件升级为全量深读,并触发同社区/同类文件追加抽检一轮;抽检记录附入报告(抽了几份、发现几个 major、有无升级)
- **G3 覆盖度收尾**`coverage_tool` 自动计算,`gate="both+line"` 时全库 ≥85% 且 高风险 ≥95% 且 行覆盖 ≥95% 且 单元完整性无缺口才算达标,低于目标在报告顶部告警
#### 3.5.6 执行前的前置检查
运行覆盖度流程前先确认图谱健康——调用 `community_health_tool`
1. `attribution_pct` = `nodes.community_id` 非空 / 非 File 节点数 的百分比
2. `needs_postprocess=true`(归属率 <90%)→ 先跑 `code-review-graph postprocess` 重建社区归属,再开始审查
> 已知问题:`incremental_detect_communities` 在 nodes 重建后可能因 `community_id` 全 NULL 而判定"无社区受影响"跳过写回,导致 `communities.size` 与 `nodes.community_id` 失同步。`community_health_tool` 可检出该状态,遇此情况用全量 `postprocess` 修复。
#### 3.5.7 报告覆盖度透传(防 0/0 渲染)
`coverage_tool` 的返回值必须**完整透传**进 `generate_report_tool``review_data.coverage`(全部字段:`coverage_pct`/`high_risk_coverage_pct`/`grade`/`deep_read_count`/`total_files`/`high_risk_total_files`/`high_risk_deep_count`/`deep_read_weight`/`total_weight`/`target_reached`/`target`/`gate`/`remaining_files_to_target`/`remaining_weight_to_target`/`priority_deep_read_files`/`uncovered_files`/`silent_files`/`note`)。**不要手挑子集**——`build_report_data` 只读固定键名,缺字段会导致报告覆盖度区块渲染成 `0/0``N/A`。报告会自动渲染 `## 覆盖度` 区块。
---
### 3.6 并行深读流水线(whole-project 强制)
> 主上下文无法逐文件深读 500+ 文件;必须用并行 explore 子代理分组深读,否则全库覆盖永远卡在 2-5%。覆盖率为**文件数口径**,且每个深读文件需通过**三件套质量门禁**(单元完整性 / 行覆盖 / 防伪抽验,见 §3.5.4)。
### 3.6.1 流程
```text
a. deep_read_plan_tool(target_coverage=85, include_prior=True)
→ 返回 groups[{name, weight, files[]}] + remaining_files
(引擎按风险权重 + 目录贪心分组,每组 ≤40 文件;include_prior 排除上轮已深读未变更文件)
b. 按组并行派发 explore 子代理(每次 4-6 个,分多批),每组深读全部文件,返回(落盘到临时目录):
- outputs[]:每文件含 path / total_lines / read_ranges[[s,e]..] /
semantic_units[{range,kind,name,note}] / findings[]
- findings[]:每条含 severity/category/confidence/file:line/message/fix
【证据铁律】无 file:line 证据的条目视为未深读 → 该文件须重读
c. 每波子代理完成后跑三件套门禁(引擎原生支持):
coverage_tool(deep_read_files=<verified_files>, gate="both+line",
file_read_ranges=<{rel:[[s,e]..]}>, file_semantic_units=<{rel:[...]}>)
→ line_gap_files unit_gap_files → 补读队列;verified → 计入本轮 deep_read_files
(引擎不可用时兜底:python skills/project-review/scripts/aggregate_deep_read.py <repo_root> <落盘目录>
d. 防伪抽验:每组抽 2 文件、该文件抽 2-3 个语义单元回读比对 note(每波 ≤40 次);
结果落盘 spot_check_<batch>.json,抽到假读 → 该组重读并升级抽验率
e. 主代理合并全部 verified_files(去重)→
coverage_tool(deep_read_files=<全清单>, gate="both+line", include_prior=True) 复算
include_prior 自动复用跨轮索引中 SHA 未变文件及其 read_ranges
f. 未达标(文件数 <85%/<95% 或行覆盖 <95% 或单元有缺口)→ 对 priority_deep_read_files / line_gap_files / unit_gap_files 补派 1-3 个子代理 → 循环直至全达标
g. G2:对 silent_files 随机抽 15% 深读(见 §3.5.5)
h. 报告生成前:聚合全部 spot_check_*.json → 注入 review_data.spot_check(顶层字段)
i. 报告生成 + 命名自检后:verify-spot-check.ps1Step 8.8)→ save_coverage_index_tool(deep_read_files=<verified_files>, file_read_ranges=<ranges>) 写跨轮索引
```
### 3.6.2 子代理分组参考(AuraSpace 实测,约 508 文件)
| # | 组 | 约文件数 |
| --- | --- | --- |
| 1-2 | `server/src/api`1/2、2/2 | 60 |
| 3-4 | `server/src/services`1/2、2/2 | 66 |
| 5 | `server/src/domain` + `deepwiki` + `infrastructure` | 88 |
| 6 | `server/tests` + `bin` + `models` | 17 |
| 7-8 | `web/src/views`1/2、2/2 | 60 |
| 9-11 | `web/src/components`1/3、2/3、3/3 | 110 |
| 12 | `web/src/store` + `utils` + `api` | 50 |
| 13 | `web/tests` + `scripts` + SQL | 57 |
> 实际分组以 `deep_read_plan_tool` 返回为准;每次并行 **4-6 个**子代理,其余排队,避免 MCP 并发压力与上下文风暴。
### 3.6.3 质量控制与防伪
| 风险 | 对策 |
| --- | --- |
| 子代理"声称读了"但没真读 | 强制三件套字段(total_lines/read_ranges/semantic_units+ findings 带行号证据;聚合脚本按①单元差集+②行并集双校验;主代理③抽样回读 |
| 子代理漏读文件 | outputs 与分组清单 diff,漏读文件计入覆盖率缺口,触发补轮 |
| 子代理宽 range 冒充全读 | 单元完整性 one-to-one 匹配:一个上报 range 只能覆盖一个单元,无法用整文件 range 覆盖所有单元 |
| 各子代理口径不一 | 统一八分类 + CRITICAL 子轮 + severity/confidence 标准(子代理 prompt 模板) |
| 增量掩盖新代码 | `include_prior=True` 按 per-file SHA 判定;变更文件自动失效重读 |
| 并发压力 | 每批 4-6 个并行,其余排队;batch_size 40 控制单组体量 |
| 主上下文被大 JSON 撑爆 | 落盘机制:子代理写临时文件,主代理只读聚合摘要 |
### 3.6.4 "深读"的含义边界
- **文件级保证(可量化)**`deep_read_files` 是文件数口径,覆盖 85% 表示 85% 的文件被"读过"(高风险子集要求 95%)
- **行级保证(v2.5.1 起可量化)**:`read_ranges` 的并集 / 真实文件行数 ≥95%;分母为真实文件行数(图谱 `line_end` 有 ±1 偏差,已弃用)。子代理对每个文件上报 `total_lines` + `read_ranges` + `semantic_units`,聚合脚本/引擎逐文件校验
- **单元级保证(v2.5.1 起可量化)**:`semantic_units` 与图谱 Function/Class/Test 节点差集必须为空(one-to-one 匹配,宽 range 不能冒充);巨型文件(最大单元行占比 >80%)豁免,仅按行覆盖校验
- **防伪机制**findings 必须带 `file:line` 证据;主代理每轮 ≤15 次单元回读(引擎无法防假读,抽样是唯一手段);抽到假读 → 该组重读并升级抽验率
### 3.6.5 跨轮增量(多轮累积)
- 每轮报告后 `save_coverage_index_tool``.code-review-graph/coverage-index.json`v2:相对路径 → per-file SHA + `ranges` 行区间)
- SHA 来源为图谱 `nodes.file_hash`(一次 SQL 查询,零 subprocess;已修复 v2.5.0 逐文件 `git hash-object` 超时)
- 下一轮 `deep_read_plan_tool` / `coverage_tool``include_prior=True` 自动复用 SHA 未变文件及其行区间 → 增量任务 = 新增文件 + 变更文件
- 多轮后增量归零即实现全库全覆盖,避免每轮从 2-5% 起步
### 3.6.6 行级覆盖率执行步骤(新窗口 / 全新审查)
> 本节给出"行级覆盖率如何落地到一次实际审查"的**分步操作**。三件套门禁中,行覆盖是唯一有精确数值的维度(`union(read_ranges) / 真实行数 ≥95%`),本节聚焦它的完整执行链路;单元完整性(差集=空)与防伪抽验在 §3.6.1/§3.6.3 已述。
**行级覆盖率的口径(引擎 `scoring.py::compute_coverage`**
```text
行覆盖率(每文件) = |union(read_ranges)| / len(Path(repo/f).read_text().splitlines())
行覆盖达标 = 行覆盖率 >= 95% line_target,可参数覆盖)
line_coverage_pct = 行覆盖达标的深读文件数 / 深读文件数
line_gap_files = 行覆盖率 <95% 的文件 [path, coverage_pct, total_lines, covered_lines]
```
- **分母 = 真实文件行数**(读取源文件,`_real_line_count` 带缓存)——**不用图谱 `line_end`**(实测 ±1 偏差)
- **分子 = `read_ranges` 并集**——子代理实际上报的读取区间(分段读即天然分段)
- **无 ranges 上报的文件**:行覆盖视为通过(不设硬卡),仅依赖单元完整性 + 抽验
**执行步骤(主代理在全新会话中)**
```text
Step 0 前置(新窗口)
- 确认无残留进程:Get-Process code-review-graph → 无输出
- 首次调用 get_minimal_context_tool 会拉起 MCP serve(加载新引擎,含 both+line
Step 1 建图 + 概览
- build_or_update_graph_tool() 图谱最新
- get_minimal_context_tool(task="project review") 全库概览
Step 2 架构 + 高风险扫描(同旧流程,产出风险权重信号)
Step 3 客观评分
- score_review_tool(all_files=True) + community_health_tool(前置健康检查)
Step 4 深读分组
- deep_read_plan_tool(target_coverage=85, include_prior=True)
→ groups[{name, weight, files[]}](≤40/组)
Step 5 并行派发子代理(严格模式)
- 每次 4-6 个 explore 子代理,prompt 见 references/deep-read-pipeline.md §二
- 每文件必须返回 total_lines / read_ranges[[s,e]..] / semantic_units[{range,kind,name,note}]
- semantic_units 必须逐一列出图谱全部单元(含 Props/interface/小函数,严格模式)
- 结果落盘到临时目录(勿回传大 JSON)
Step 6 聚合脚本(每波后)
- python skills/project-review/scripts/aggregate_deep_read.py <repo_root> <落盘目录>
- 输出 verified_files / line_gap_files / unit_gap_files / unit_exempt_files
- 行覆盖判定就在这里:line_gap_files 列出所有 <95% 的文件
Step 7 引擎门禁(行级覆盖率正式数值)
- coverage_tool(deep_read_files=<verified_files>, gate="both+line",
file_read_ranges=<{rel:[[s,e]..]}>, file_semantic_units=<{rel:[...]}>)
- 返回 line_coverage_pct / unit_coverage_pct / line_gap_files / unit_gap_files / unit_exempt_files
- ⚠️ 必须传 file_read_ranges/file_semantic_units,否则行/单元覆盖不计算
Step 8 补轮(禁止降级)
- line_gap_files unit_gap_files 非空 → 对缺口文件重派子代理补报/重读
- 禁止回退 gate="both" 静默跳过三件套;必须补至空,或报告显式标注 🔴
Step 9 报告
- generate_report_tool(review_data.coverage = coverage_tool 全量透传)
- "## 覆盖度" 区块须含行覆盖/单元覆盖(line_coverage_pct/unit_coverage_pct
Step 10 持久化
- save_coverage_index_tool(deep_read_files=<verified_files>, file_read_ranges=<ranges>)
- 写 v2 索引(sha + rangesSHA 来自 nodes.file_hash<1s
```
**新窗口首轮验证(Step 0 后建议先跑一次冒烟)**
```text
coverage_tool(deep_read_files=["server/src/services/auth_service.rs"],
gate="both+line",
file_read_ranges={"server/src/services/auth_service.rs": [[1,66]]},
file_semantic_units={"server/src/services/auth_service.rs": [全部 6 单元]})
→ 期望 line_coverage_pct=100, unit_coverage_pct=100, line_gap_files=[]
(即 V2.1 E2E 验证通过,确认引擎加载了新代码)
```
**常见坑**
- 子代理漏 `read_ranges` → 该文件行覆盖不计算(不算 gap 但也不验证),依赖单元完整性兜底;严格模式应要求 read_ranges 覆盖整文件
- 大文件分段时 read_ranges 各段要有重叠边界或连续覆盖,避免 1 行缝隙拉低覆盖率
- 全库 508 文件行覆盖聚合约 1-2s(分母读取带缓存);`save_coverage_index` 用 file_hash 后 <1s(不再有逐文件 git subprocess 超时)
### 3.6.7 实测踩坑与绕行方案(2026-08-18 全库审查实战记录)
> 本节是 508 文件全库审查(`full-project-review-2026-08-18-144500`)实测中暴露的、文档正文未覆盖的坑与对应解法。深读流水线各环节的坑按出现顺序记录。
| # | 环节 | 坑 | 症状 | 解法 |
| --- | --- | --- | --- | --- |
| 1 | 子代理输出落盘 | 子代理 JSON 是**容器格式** `{"group":..., "outputs":[{path, read_ranges, semantic_units, findings}]}`,而 `aggregate_deep_read.py` 期望**单文件记录**(每条记录的 `path` 在顶层) | 聚合脚本 `verified=0`、全部文件被跳过,且无任何报错 | 先**解包**:读容器 → 把 `outputs` 数组逐条拆成独立 JSON 文件(`batchN_idx_<path>.json`)→ 再跑聚合脚本。可用一段 Python 脚本完成,见下 |
| 2 | 聚合脚本 | 子代理用 `write` 写 JSON 可能带 **UTF-8 BOM** | 报错 `Unexpected UTF-8 BOM (decode using utf-8-sig)` | 聚合前批量剥 BOM:读字节,若前 3 字节为 `EF BB BF` 则写入 `bytes[3:]` |
| 3 | 单元完整性补读 | 首轮聚合 `unit_gap` 可达 **92 个文件**(子代理漏报 serde `visit_*` 小方法、几行的 struct/Props interface | `unit_gap_files` 非空 → 三件套门禁不通过 | **不要整文件重读**。生成 `gap_manifest.json`(含每文件 `uncovered` 单元及精确 range),派 1-2 个"补读子代理"按 range 精准读取缺失单元、产出 `supplement.json`,主代理按 `(path, range)` 合并回原 payload 后再聚合 |
| 4 | 单元完整性 | 图谱对**同一行解析出多个箭头函数节点**(如 `AgentConfigPanel.tsx``a`/`x` 同 range 420`DataPermissionManagement.tsx``d`/`childrenOf` 同 range 76 | 该文件 `total_units` 高但永远 `covered_units` 差 1-3 个,**one-to-one 匹配不可能满足**(一个 range 只能匹配一个单元) | 判断为图谱解析噪声:文件行覆盖已 100% 即视为已深读,在报告中显式标注"该文件存在图谱同 range 多节点,单元完整性豁免";不要反复重读 |
| 5 | 深读清单 | 深读计划可能选中 **0 字节空文件**(如 `web/src/components/evm/EvmConfigModal.tsx` | `line_gap_files``unreadable/empty file`,且该文件无任何引用(死文件残留) | 验证文件大小与引用:空文件 + 无引用 → 从深读清单移除,作为一条 finding(死代码)单独记录 |
| 6 | 补读子代理 | 补读子代理若只返回"补了 X 个单元"的**一句话**而不落盘补充 JSON | 主代理拿不到补充的 semantic_units,无法合并 | 强制补读子代理也必须**落盘** `supplement*.json`(结构化 range+note),主代理脚本合并后重跑聚合 |
| 7 | MCP 大 JSON | `dedupe_findings_tool` / `generate_report_tool` 直传 400+ 条 findings 时 **MCP JSON 解析失败**`Invalid escape character` / `Expected '}'`) | 工具调用直接报 invalid input,报告无法生成 | **绕行**:直接在 Python 里调用引擎函数 `code_review_graph.tools.generate_report_func(review_data, output_path=...)` / `coverage_func(...)``sys.path.insert(0, <CRG 引擎路径>)` 后 import),输出与 MCP 工具完全一致(见 §4.5) |
| 8 | 报告统计 | MD 报告的"问题统计"只读 `counts`**`critical` / `informational` 键**(不读 blocker/major/minor | 传 `{"blocker":20,"major":141,"minor":315}` 时统计显示 "20 严重 · 0 次要"major 数丢失 | `counts``{"critical":<blocker数>, "major":<major数>, "informational":<minor数>}` 才能正确渲染 |
**解包 + 剥 BOM 参考脚本(主代理每轮可复用)**
```python
# 1) 剥 BOM
for f in glob.glob(dir + "/*.json"):
b = Path(f).read_bytes()
if b[:3] == b"\xef\xbb\xbf":
Path(f).write_bytes(b[3:])
# 2) 解包 outputs → 单文件记录
data = json.load(open(batch, encoding="utf-8"))
for i, rec in enumerate(data["outputs"]):
out = dir / f"{basename}_{i}_{rec['path'].replace('/','_')}.json"
out.write_text(json.dumps(rec, ensure_ascii=False), encoding="utf-8")
```
**合并 supplement 参考脚本**
```python
bypath = defaultdict(list) # path -> [semantic_units...]
for s in supp1 + supp2: bypath[s["path"]] += s["semantic_units"]
for rec in payloads: # 每个单文件记录
if rec["path"] in bypath:
have = {tuple(u["range"]): u for u in rec.get("semantic_units", [])}
for u in bypath[rec["path"]]:
have.setdefault(tuple(u["range"]), u) # 按 range 幂等合并
rec["semantic_units"] = list(have.values())
```
**教训**:深读流水线的"三件套门禁"把质量控制在引擎侧,但**子代理与主代理之间的数据契约(容器格式、BOM、补读落盘)是纯人工编排**,任何一处契约不一致都会让 verified 计数归零或三件套不达标。建议每波子代理完成后先跑一次聚合脚本看 `verified` 数,而非等到全部结束才校验。
---
## 4. 审查调用的 MCP 工具
### 4.1 核心审查工具
| 工具 | 功能 |
| --- | --- |
| `score_review_tool` | **客观 Layer-2 审查指标**:SQL 风险、异常分支覆盖、代码冗余率、高风险场景密度、漏洞启发式 5 项,每项带 good/warn/fail 分级、阈值与证据。支持 `all_files` 全量评分、`include_churn``llm_judged` 列出需 LLM 补判的指标 |
| `dedupe_findings_tool` | **Finding 合并去重**:按 `path:line:category` 指纹合并;多源确认置信 +1(上限 10);低置信移附录或抑制;计算 PR 质量分 `max(0, 10 - (critical×2 + informational×0.5))`;支持抑制历史已跳过项 |
| `generate_report_tool` | **审查报告生成**:中文 HTML + Markdown(默认 `format="both"`);`output_path` 传归档目标文件名(含 `docs/reviews/` 前缀,不含扩展名) |
| `coverage_tool` | **覆盖度计算**v2.5.0 / 三件套 v2.5.1):传入 `deep_read_files` + `gate`(默认 `high_risk`,可选 `overall` / `both` / `both+line`),引擎自动算双重文件数口径(全库 / 高风险)、未深读清单、静默文件清单、达标状态与待补深读优先级。`gate="both+line"` 时额外支持 `file_read_ranges` / `file_semantic_units`,返回行/单元覆盖三件套(`line_coverage_pct` / `unit_coverage_pct` / `line_gap_files` / `unit_gap_files` / `unit_exempt_files` |
| `deep_read_plan_tool` | **深读分组规划**v2.5.0):`deep_read_plan_tool(target_coverage=85, include_prior=True, batch_size=40)`**无 `gate` 参数**),按风险权重 + 目录贪心生成 `groups[{name, weight, files[]}]` + `remaining_files`,主代理据此并行派发子代理 |
| `save_coverage_index_tool` | **跨轮覆盖索引持久化**v2.5.0 / v2 索引):把本轮深读清单(file + per-file SHA + 可选 `ranges` 行区间)写入 `.code-review-graph/coverage-index.json`,供下一轮 `include_prior=True` 增量复用。v2.5.1 起 SHA 来源为图谱 `nodes.file_hash`(零 subprocess,根治逐文件 `git hash-object` 超时) |
| `community_health_tool` | **社区归属健康检查**v2.4.0+):返回 `nodes.community_id` 归属率与 `needs_postprocess`,前置检查用 |
### 4.2 图谱上下文工具
| 工具 | 用途 |
| --- | --- |
| `build_or_update_graph_tool` | 确保图谱最新 |
| `get_minimal_context_tool` | 全库概览压缩(节点/边/社区/风险/受影响的流),project-review 主上下文第一步 |
| `get_review_context_tool` | 变更文件 + blast radius + 源码片段 + 审查指引 |
| `detect_changes_tool` | 风险评分 + 变更函数 + 测试缺口 + 受影响流 |
| `get_affected_flows_tool` | 受影响执行流 |
| `query_graph_tool` | 调用方/被调用方/测试/继承查询 |
### 4.3 图谱全景工具(project-review 用)
| 工具 | 用途 |
| --- | --- |
| `get_architecture_overview_tool` | 社区耦合总览 |
| `list_communities_tool` | 模块清单 |
| `get_knowledge_gaps_tool` | 结构弱点:孤立节点、未测试热点 |
| `get_hub_nodes_tool` | 最连接节点(架构热点) |
| `get_bridge_nodes_tool` | 架构瓶颈 |
| `find_large_functions_tool` | 超大函数/类 |
| `get_surprising_connections_tool` | 意外跨社区耦合 |
| `semantic_search_nodes_tool` | 按关键词定位代码(单功能) |
| `get_impact_radius_tool` | 单功能的 blast radius |
### 4.4 工具调用链
```text
unified-reviewdiff:
build_or_update_graph → get_review_context → detect_changes
→ score_review_tool → [specialist 子代理] → dedupe_findings_tool → generate_report_tool
project-review(全项目):
build_or_update_graph → get_minimal_context → 架构全景 + 高风险定位(6 个图谱工具)
→ score_review_tool(all_files=True) + community_health_tool(前置检查)
→ deep_read_plan_tool → [4-6 并行 explore 子代理 × N 批] → coverage_tool(gate="both+line")
→ [未达标]补轮 → [G2]silent 抽检 15%
→ dedupe_findings_tool → generate_report_tool(coverage 完整透传)
→ verify-report.ps1(命名自检) → save_coverage_index_tool(跨轮索引)
project-review(单功能):
build_or_update_graph → get_minimal_context → semantic_search_nodes / query_graph 定位功能文件
→ get_impact_radius → score_review_tool(changed_files=<功能文件>)
→ coverage_tool(deep_read_files=<功能文件>) → dedupe_findings_tool → generate_report_tool
```
### 4.5 引擎函数直调绕行(MCP 大 JSON 失败时的兜底)
`dedupe_findings_tool` / `generate_report_tool` / `coverage_tool` 通过 MCP 传输 JSON 参数,当 findings 超过数百条(如全库审查 476 条)时可能因 **MCP 参数解析失败**`Invalid escape character` / `Expected '}'`)而无法调用。此时可**绕开 MCP 直调引擎 Python 函数**,输出与 MCP 工具完全一致:
```python
import sys, json
sys.path.insert(0, r"D:\code-review-graph\code-review-graph-main") # CRG 引擎本地路径
import code_review_graph.tools as t
# 等价于 generate_report_tool
res = t.generate_report_func(
review_data, # 与 MCP 版同构的 review_data dict
output_path=r"D:\AuraSpace\docs\reviews\full-project-review-2026-08-18-144500",
repo_root=r"D:\AuraSpace",
format="both",
)
# 等价于 coverage_tool(gate="both+line")
res = t.coverage_func(
deep_read_files=paths,
gate="both+line",
file_read_ranges=ranges_map, # {rel: [[s,e]..]}
file_semantic_units=units_map, # {rel: [{range,kind,name,note}..]}
repo_root=r"D:\AuraSpace",
)
```
> 触发条件:工具调用返回 invalid input / JSON Parse error,且 payload 确认超过 MCP 承载上限(实测 ≥400 条 findings 时出现)。直调入口统一为 `code_review_graph.tools` 下的同名函数;`coverage_func` 的签名见 §3.6.6 的 `compute_coverage` 说明。
### 4.6 深读子代理类型选择
深读子代理使用 **`explore` 类型**(与 project-review skill 及实际审查一致)。子代理需**写 JSON 落盘**回传三件套数据(容器格式 `{group, outputs[]}`,每条含 path/read_ranges/semantic_units/findings),因此派发前须确认子代理工具集包含 **write 落盘能力**。若子代理无法落盘,主代理拿不到三件套数据,verified 计数会归零。
---
## 5. 5 项客观指标详解
`score_review_tool`unified-review Step 3 / project-review Step 4)计算 5 项客观 Layer-2 指标。这些指标由**静态正则启发式**实现(源码:`code_review_graph/scoring.py`),扫描变更文件的行级文本(`_iter_source_lines`),不执行代码、不理解语义,因此**会产生误报和漏报,需人工核实后再行动**。阈值定义在 `scoring.py``THRESHOLDS`
### 5.1 指标总览
| 指标 | 值类型 | 评级方向 | good | warn | fail |
| --- | --- | --- | --- | --- | --- |
| `SQL 注入风险` | 命中行数 | 越低越好 | `= 0` | `1-2` | `≥ 3` |
| `异常分支覆盖` | 异常行占比 % | 越高越好 | `≥ 50%` | `30%50%` | `< 30%` |
| `代码冗余率` | 重复行占比 % | 越低越好 | `≤ 10%` | `10%20%` | `> 20%` |
| `high_risk_density` | 覆盖行占比 % | 越高越好 | `≥ 90%` | `≥ 70%` | 其余;无高风险行则为 `na` |
| `vulnerability_risk` | 命中行数 | 越低越好 | `= 0` | `1-2` | `≥ 2` |
**整体客观评级取 5 项中最差项**`worst`,任一 fail → 整体 FAIL;否则任一 warn → WARN;全 good → GOOD)。
### 5.2 这些指标有什么用处
核心用途一句话:**把"凭感觉"的审查,变成"有证据、有优先级"的审查清单**。它们不是给代码打分的考试成绩,而是**审查者的导航仪**——告诉你这份代码里"哪些行值得停下来细看",并给这个判断一个可复现的数字证据。
| 指标 | 实际帮你做什么 | 没有它的代价 |
| --- | --- | --- |
| `SQL 注入风险` | 直接给出行号,逐个确认"这些是不是注入点",其余 SQL 不用看 | 要么漏看注入,要么浪费时间通读所有 SQL |
| `异常分支覆盖` | 一眼知道错误处理厚薄,决定是否要重点查边界 | 不知道这模块错误处理是优是劣,全靠碰运气 |
| `代码冗余率` | 告诉你有没有"复制粘贴三份"的代码,决定是否值得重构 | 靠肉眼扫重复,扫不全 |
| `high_risk_density` | 标出所有含并发/事务/权限/缓存的行,提醒"这些别跳过" | 容易忽略数据一致性风险点 |
| `vulnerability_risk` | 机械扫硬编码密钥/XSS/shell 特征,做安全快筛 | 靠人工记忆检查常见漏洞模式 |
**为什么这个设计关键**:审查最贵的成本是**注意力**。一份 diff 或一个功能可能上千行,逐行细看不现实。这 5 项指标把注意力压缩到三件事:
- **哪些行**evidence 给出 file:line
- **怀疑什么**sql/vulnerability 给出关键词模式)
- **薄弱在哪里**exception_coverage 低 = 错误处理该补查)
**注意"触发器而非结论"**:命中 ≠ 缺陷。每个指标都带 `evidence` 供逐一人工核实,`score_review_tool` 的 note 反复强调这一点(如 `sql_risk=1` 可能是 JSX 误报)。
**与 git diff 无关**:指标扫描的是传入 `changed_files` 清单的**文件全文**,不依赖 git diff。unified-review 的清单来自 git diffproject-review 的清单来自用户指令 → `semantic_search`/`query_graph` 聚合(feature)或 `all_files=True`whole-project),二者喂给指标的只是不同的文件来源,指标引擎同一套。project-review 的只读、不碰 git 约束天然成立。
**分工边界**:指标只覆盖"机械化风险",抓不住 IDOR、越权、竞态、LLM 信任边界等业务逻辑缺陷——那些由 Layer 1 人工链路分解 + specialist 子代理覆盖(见 §5.3 与 §5.2.5 注意)。
### 5.3 各指标收集方式与评分标准
#### 5.3.1 `sql_risk` — SQL 注入风险
- **收集方式**:用 6 条正则扫描每行,匹配字符串插值/未参数化 SQL 特征:`"SELECT ..." + 变量``f"...{var}"` 含 SQL、`exec("SELECT...``WHERE x = '" + var``.format(...)` 含 SQL、`% "..." % (...)` 含 SQL 等。
- **评级**:计数命中行数,0 → good1-2 → warn,≥3 → fail。
- **注意**:命中只是警告不是证据。前端 JSX/模板字符串含 SQL 字样易误报(如后端明明全部参数化绑定,报告仍可能报 1 处来自 `.tsx` 的"风险"),需逐一确认上下文,必要时跑 `EXPLAIN` 评估性能。
#### 5.3.2 `exception_coverage` — 异常分支覆盖
- **收集方式**:用 10 条正则统计"异常/错误路径"行数(`try``except``catch(``raise``throw``if err != nil``if ... error``.catch(``else { return None` 等)。
- **计算**`异常行 / 总行 × 100%`。经验基线来自"每 2 条正常路径约有 1 条异常路径"。
- **评级**:≥50% → good30%-50% → warn<30% → fail。
- **注意**:阈值命名易误解——`warn_min=50` 实际是 good 的及格线,`good_min=30` 是 warn 的下限。该指标对 `?` 一元错误传播(Rust)、`Result` 链等"无显式异常关键字"的代码会低估,需人工补查。
#### 5.3.3 `redundancy_rate` — 代码冗余率
- **收集方式**:将每行标准化为签名(去空白、数字→`N`、字符串→`"s"`),统计**在 ≥3 处出现相同签名**的行数。
- **计算**`重复行 / 总行 × 100%`
- **评级**:≤10% → good10%-20% → warn>20% → fail。
- **注意**:签名长度 <24 字符的短行被忽略。重复的函数参数签名(如 `State(state)`/`Extension(claims)`)会高频命中,属结构性信号而非缺陷。
#### 5.3.4 `high_risk_density` — 高风险场景密度
- **收集方式**:用 7 条正则标记"高关注行":并发(async/await/thread/mutex)、事务(transaction/commit/rollback)、原子性(atomic/race)、SQL 关键字、锁、缓存失效(cache/invalidate/evict)。
- **计算**`覆盖行 / 相关行 × 100%`——因所有相关行都被计入"覆盖",实际**恒为 100%**。无高风险行时返回 `na`(不适用),不计入整体评级。
- **评级**:≥90% → good,≥70% → warn,其余 fail。
- **重要**:这是**审查关注信号,不是正确性评分**——只提示"哪些行含并发/事务/安全模式需要人工验证",值高不表示代码好。
#### 5.3.5 `vulnerability_risk` — 漏洞风险
- **收集方式**:用 6 条正则扫描 OWASP/密钥特征:硬编码 `password/api_key/secret/token``eval/exec(``subprocess(shell=True)``innerHTML/dangerouslySetInnerHTML/v-html``<script>`/`onerror=`/`javascript:` 等。
- **评级**:计数命中行数,0 → good1-2 → warn,≥2 → fail。
- **注意**:这只是**文本级启发式**,真实漏洞需依赖扫描器补充(`npm audit``pip-audit``govulncheck`)。业务逻辑型安全缺陷(如 IDOR、越权、竞态)**检不出**,须由 Layer 1 人工审查 + specialist 子代理覆盖。
### 5.4 与 LLM 判断指标的边界
以下 5 项指标**不进入** `score_review_tool` 的客观计算,而是列在返回的 `llm_judged` 字段,须由审查代理在 Layer 1 链路分解中人工/LLM 补判:
`requirement_coverage`(需求覆盖)、`logic_alignment`(逻辑对齐)、`llm_trust_boundary`LLM 信任边界)、`shell_injection`Shell 注入)、`enum_completeness`(枚举完整性)。
### 5.5 实测示例
issues 功能 feature 审查(`issues-feature-review-2026-08-10`)实测结果:
| 指标 | 值 | 评级 | 人工核实结论 |
| --- | --- | --- | --- |
| `sql_risk` | 1 | warn | **误报**:命中的是 `IssuesView.tsx` 的 JSX,后端全部参数化绑定,实际无注入 |
| `exception_coverage` | 0.39% | fail | 异常路径占比极低,错误多统一走 `internal_error` 返回 500 泛化信息,符合人工审查发现的错误处理不足 |
| `redundancy_rate` | 9.74% | good | 临界值;issue_api.rs 存在大量重复的 `State/Extension` 签名与权限校验模板 |
| `high_risk_density` | 100% | good | 仅提示含事务/并发/权限模式的行,需人工验证 |
| `vulnerability_risk` | 0 | good | 文本扫描无命中;真正的 IDOR 漏洞由人工 Layer 1 审查捕获 |
---
## 6. 审查 Skill 体系
### 6.1 核心 skill
| Skill | 对应工作流 | 位置 |
| --- | --- | --- |
| `unified-review` | diff 三层统一审查 | `skills/unified-review/SKILL.md` |
| `project-review` | 项目级审查 | `skills/project-review/SKILL.md` |
### 6.2 references 检查清单
两个 skill 共享一套 `references/`
```text
references/
├── review-checklist.md # 通用审查清单(Layer 1 八分类 + CRITICAL 五类)
├── common-mistakes.md # 常见审查错误
├── report-template.html # 报告模板
├── manual-review/ # 高风险模块人工审查清单
│ ├── payment.md order.md inventory.md permission.md
│ └── distributed-lock.md data-migration.md
└── specialists/ # specialist 子代理检查清单
├── testing.md maintainability.md security.md performance.md
└── data-migration.md api-contract.md red-team.md
```
### 6.3 旧 skill
`review-changes` / `review-delta` / `review-pr` 保留供兼容,提供轻量审查;unified-review / project-review 为增强版。
---
## 7. 审查报告成果物
| 产物 | 文件 | 内容 |
| --- | --- | --- |
| HTML 报告 | `code-review-report.html` | 自包含单文件:结论、PR 质量分、覆盖度、客观指标表、问题清单(severity + 置信度 + 位置 + 修复建议)、人工审查清单、LLM 判断指标 |
| Markdown 报告 | `code-review-report.md` | 同内容 Markdown 版,便于 git 提交 / PR 描述复用 |
参数:`format``html` / `markdown` / `both`,默认 `both`)。**报告均为只读产物**。
> ⚠️ **review_data.counts 键名陷阱**MD 报告的"问题统计"行只读 `critical` / `informational` 两个键(`counts` dict 里的 `blocker`/`major`/`minor` 会被忽略)。传 `{"blocker":20,"major":141,"minor":315}` 会渲染成 "20 严重 · 0 次要"major 数丢失。正确写法:`{"critical":20, "major":141, "informational":315}`。HTML 报告不受影响(读全量键)。
>
> ⚠️ **MCP 大 JSON 兜底**findings 超数百条时 MCP 传参可能解析失败,可直调引擎函数生成报告(见 §4.5),输出与 MCP 工具一致。
>
> ⚠️ **review_data.metrics 格式陷阱**`generate_report_tool` 的 `metrics` 值**必须是 `{grade, value, note}` 字典**(如 `{"sql_risk": {"grade":"good","value":0,"note":"全部参数化"}}`)。若传扁平标量(如 `"sql_risk": 3`),`build_report_data` 会静默丢弃,报告的"客观指标"表格不渲染。生成后自检必须确认指标表存在。
>
> ⚠️ **findings 字段名陷阱**`build_report_data` 只读 `findings` 键(不读 `issues`);每条只读 `path`+`line`(合成 `location`)、`message`(或 `summary`)、`fix`、`severity`、`category`、`confidence`。用错键名(如 `title`/`detail`/`issues`)会导致问题清单缺失或只剩类别+位置。
>
> ️ **HTML `<script>` 安全转义**:引擎在注入 `const data = {...}` 前会把 `<` 全部转义为 `\u003c`v2.5.0 修复,`scoring_tools.py`)。因此 finding 文本里即使含字面 `</script>`、`<!--` 等序列也不会截断报告脚本;JS 解析后还原为 `<`,渲染时再转成 `&lt;`,显示不受影响。**人工无需转义 finding 文案**。
### 7.1 生成后自检(防空报告回归)
1. 打开生成的 `.md`,确认 `## 问题清单(N``N` == findings 数量(不为 0
2. 每条 issue 同时含**问题描述**、**位置**、**修复建议** 三要素(位置形如 `` `server/...:111` ``
3. **`## 客观指标` 表格存在且非空**(缺失多为 metrics 传成扁平标量)
4. 若发现缺描述/缺修复建议/问题数=0 → 修正 `review_data` 后**重新调用** `generate_report_tool` 覆盖
### 7.2 归档命名规范
审查报告归档到 `docs/reviews/`,文件名格式:`{范围}-review-{YYYY-MM-DD-HHMMSS}.{md,html}`(时间戳精确到时分秒,避免同日多次审查互相覆盖)。
| 审查类型 | 范围值示例 | 示例文件名 |
| --- | --- | --- |
| 全项目审查 | `full-project` | `full-project-review-2026-08-12-141343.html` |
| 功能审查 | `{功能}-feature` | `evm-feature-review-2026-08-06-151522.md` |
| 变更级审查 | `pr-{branch}` | `pr-dev-init-xwj-v0.1-review-2026-08-06-151522.md` |
`generate_report_tool``output_path` 直接传 `docs/reviews/{文件名}`(不含扩展名)。**不传 `output_path` 会默认写到仓库根目录 `code-review-report.*`,属违规命名**,需在命名自检中检出并修复。
### 7.3 命名自检(verify-report.ps1
生成完成后对仓库运行命名自检(脚本独立于 code-review-graph CLI,任何版本可用):
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-report.ps1" -Repo D:\AuraSpace
```
- 退出码 **0** → 通过:所有报告都在 `docs/reviews/` 且文件名带 `-YYYY-MM-DD-HHMMSS` 后缀
- 退出码 **1** → 存在根目录残留 `code-review-report.*`。用 `-Fix` 自动归档,或重新以正确 `output_path` 调用 `generate_report_tool` 覆盖,然后重跑脚本确认退出码 0
- 脚本列出的 historic naming warnings 无需处理(仅提示),但本次生成的报告必须满足规范
### 7.4 行级覆盖自检(verify-line-coverage.ps1,防"无行覆盖还全绿"
v2.5.1 起,whole-project/feature 报告必须含**行覆盖**字段(引擎 fail-closed:缺三件套数据 → 计入 line/unit gap → `target_reached=false`)。生成完成后对仓库运行行覆盖自检:
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-line-coverage.ps1" -Repo D:\AuraSpace
```
- 退出码 **0** → 通过:报告 `## 覆盖度` 区块含**行覆盖**字段且 ≥95%(`gate="both+line"` 已跑、三件套数据完整)
- 退出码 **1** → 阻塞:报告无行覆盖字段(漏跑 `both+line` 或漏传三件套数据)或行覆盖 <95%。**必须**补数据重新生成报告后重跑,直到退出码 0
### 7.5 防伪抽验自检(verify-spot-check.ps1,防"漏抽验还全绿"
whole-project / feature 审查**必须**执行防伪抽验(三件套③)并注入 `review_data.spot_check`(顶层字段)。生成完成后对仓库运行:
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-spot-check.ps1" -Repo D:\AuraSpace
```
- 退出码 **0** → 通过:报告 `## 覆盖度` 区块含**防伪抽验**字段且单元数 >0
- 退出码 **1** → 阻塞:报告无防伪抽验字段或渲染"未执行 🔴"。**必须**补抽验(每组 2 文件 × 2-3 单元,每波 ≤40 次)+ 注入 `spot_check` + 重新生成报告后重跑,直到退出码 0
- **诚实声明**:该脚本只能验证抽验**声明完整性**,无法验证主代理是否真读了文件(引擎防伪能力的已知边界)
- **命名自检(Step 8.6)、行覆盖自检(Step 8.7)、防伪抽验自检(Step 8.8)三脚本必须全过**才算审查完成
> 该脚本检测的是报告渲染后的 `## 覆盖度` 区块:引擎渲染模板对缺失行覆盖会显示"行覆盖:未执行 🔴",脚本据此拦截。
---
## 8. 快速上手
### 前提:构建图谱
```bash
code-review-graph build # 或 /code-review-graph-build-graph
```
### 三种审查用法
```text
# 1. 审查本次 git diffunified-review
/code-review-graph-unified-review
# 2. 全项目代码体检(project-review
/code-review-graph-project-review 对项目代码进行全面审查
# 3. 审查单个功能(project-review
/code-review-graph-project-review 审查支付功能的代码
```
### MCP prompt 直接调用
```text
/code-review-graph:unified_review base="HEAD~1"
/code-review-graph:project_review scope="whole-project" target=""
/code-review-graph:project_review scope="feature" target="payment"
```
### 全项目审查的深读约定(v2.5.1)
- 全项目审查必须走**并行深读流水线**(§3.6):`deep_read_plan_tool` 分组 → 4-6 个并行 explore 子代理深读(三件套字段落盘)→ 每波 `coverage_tool(gate="both+line", file_read_ranges=..., file_semantic_units=...)` 三件套门禁 → 未达标补轮 → **防伪抽验每组 2 文件 × 2-3 单元,每波 ≤40 次**(结果落盘 spot_check_*.json 并注入 `review_data.spot_check`
- 覆盖度目标(三件套):全库 ≥85% **且** 高风险 ≥95% **且** 行覆盖 ≥95% **且** 单元完整性无缺口(`gate="both+line"``gate="both"` 保持旧双目标语义)
- 报告生成后执行 **三自检**`verify-report.ps1`(命名)→ `verify-line-coverage.ps1`(行覆盖)→ `verify-spot-check.ps1`(防伪抽验)→ `save_coverage_index_tool`v2 索引含 rangesSHA 来自 nodes.file_hash)写跨轮索引
### 修复流程(人工裁决)
1. 审查产出 findingsseverity + 置信度 + file:line + 修复建议)
2. 按 severity 批量呈现,逐个决定:**修 / 不修 / 自己改**
3. 🔴 blocker 不可批量跳过
4. 确认后人工执行修复,重新审查验证
+25
View File
@@ -612,6 +612,31 @@ code-review-graph embed --provider voyage --model voyage-code-3
> and `--embedding-model`; cloud choices may transmit this source-derived text
> and incur API cost.
#### Progress Notifications & Timeouts
Long-running tools (`coverage_tool`, `deep_read_plan_tool`, `score_review_tool`,
`detect_changes_tool`) run their work in a worker thread while the MCP event
loop periodically emits `notifications/progress` (default every 15s). MCP
clients such as opencode that set `resetTimeoutOnProgress` therefore never hit
the default 60s request timeout, even on repositories with hundreds of files.
The engine also accepts an optional `progress_cb` callback for real per-file /
per-metric progress; clients that do not request a progress token are unaffected
(notifications are skipped silently).
As a server-side backstop, `CRG_TOOL_TIMEOUT` (seconds) bounds these tools and
returns a readable error instead of letting the client time out. On the client
side you can also raise the MCP request timeout, e.g. in opencode:
```jsonc
"mcp": {
"code-review-graph": {
"type": "local",
"command": ["...", "serve", "--repo", "/path/to/repo"],
"timeout": 600000
}
}
```
#### Tool Filtering
CRG exposes 31 MCP tools by default. In token-constrained environments, you can
+1 -1
View File
@@ -8,7 +8,7 @@ from .context_savings import (
format_context_savings,
)
__version__ = "2.3.7"
__version__ = "2.4.0"
__all__ = [
"__version__",
+97 -1
View File
@@ -43,6 +43,11 @@
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
details.reviewed { margin:.5rem 0; border:1px solid var(--border);
border-radius:6px; padding:.4rem .8rem; }
details.reviewed summary { cursor:pointer; font-weight:600; }
details.reviewed ul { margin:.4rem 0 0; padding-left:1.2rem; }
details.reviewed li { margin:.15rem 0; }
</style>
</head>
<body>
@@ -61,6 +66,23 @@ function verdictClass(v) {
return v.includes("PASS") ? "pass" : "fail";
}
// Collapsible list of the reviewed files (native <details>/<summary>, no JS).
// Accepts an array OR a comma-separated string (agents pass both). Falls
// back to the flat ``files`` string when nothing structured is given.
function renderReviewedFiles(data) {
let arr = data.reviewed_files;
if (typeof arr === "string") {
arr = arr.split(",").map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(arr) || !arr.length) {
return data.files ? `<p><b>文件:</b> ${esc(data.files)}</p>` : "";
}
return `<details class="reviewed">
<summary>审查文件 (${arr.length}) <span class="muted">点击展开/收起</span></summary>
<ul>${arr.map(f => `<li><code>${esc(f)}</code></li>`).join("")}</ul>
</details>`;
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
@@ -84,9 +106,83 @@ let html = `<h1>代码审查报告</h1>
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>文件:</b> ${esc(data.files)}</p>`;
html += renderReviewedFiles(data);
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const cov = data.coverage || {};
// The coverage section renders whenever file-count coverage OR line/unit
// coverage was computed. gate="line+unit" (feature reviews) returns
// coverage_pct=null, so the 全库/高风险 rows are skipped and only the
// line/unit rows + spot-check render.
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const isLineOnly = cov.coverage_pct == null;
const covOk = cov.target_reached;
const covCls = covOk ? "good" : "fail";
const covStatus = covOk ? "✅ 达标" : "🔴 覆盖不足";
const oTarget = cov.overall_target ?? cov.target ?? "N/A";
const hTarget = cov.high_risk_target ?? cov.target ?? "N/A";
html += `<h2>覆盖度</h2>
<p><span class="verdict ${covCls}">${covStatus}</span></p>`;
if (!isLineOnly) {
html += `<p><b>全库覆盖:</b> ${esc(cov.coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.deep_read_count ?? "N/A")}/${esc(cov.total_files ?? "N/A")}(目标 ${esc(oTarget)}%</p>
<p><b>高风险覆盖:</b> ${esc(cov.high_risk_coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.high_risk_deep_count ?? "N/A")}/${esc(cov.high_risk_total_files ?? "N/A")}(目标 ${esc(hTarget)}%</p>`;
}
html += renderLineUnit(cov);
html += renderSpotCheck(data.spot_check);
if (!isLineOnly && (cov.uncovered_files || []).length) {
html += `<p class="muted"><b>未深读文件:</b> ${esc(cov.uncovered_files.length)} 个(静默文件 ${esc((cov.silent_files || []).length)} 个)</p>`;
}
}
// Line / unit coverage (three-piece suite items 1-2). Fail-closed: a
// missing line/unit coverage renders "未执行 🔴" so reviews that skipped
// gate="both+line" or the three-piece data are visible, never silent green.
function renderLineUnit(cov) {
const linePct = cov.line_coverage_pct;
const unitPct = cov.unit_coverage_pct;
const lineTarget = cov.line_target ?? 95.0;
const unitTarget = cov.unit_target ?? 100.0;
const lineGap = (cov.line_gap_files || []).length;
const unitGap = (cov.unit_gap_files || []).length;
const missing = (cov.missing_data_files || []).length;
let s = "";
if (linePct == null || unitPct == null) {
s += `<p><b>行覆盖:</b> 未执行 🔴 <span class="muted">(coverage_tool 未用 gate="both+line" 或未传三件套数据)</span></p>`;
} else {
const lineOk = linePct >= lineTarget && lineGap === 0;
const unitOk = unitPct >= unitTarget && unitGap === 0;
s += `<p><b>行覆盖:</b> ${esc(linePct)}% — 目标 ${esc(lineTarget)}%(缺口 ${esc(lineGap)} 文件)${lineOk ? "✅" : "🔴"}</p>`;
s += `<p><b>单元覆盖:</b> ${esc(unitPct)}% — 目标 ${esc(unitTarget)}%(缺口 ${esc(unitGap)} 文件)${unitOk ? "✅" : "🔴"}</p>`;
}
if (missing) {
s += `<p class="muted"><b>三件套数据缺失:</b> ${esc(missing)} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)</p>`;
}
return s;
}
// Anti-fake spot check (three-piece suite item 3). Fail-closed: a
// missing/incomplete spot_check renders "未执行 🔴" so reviews that
// skipped the sampled re-read are visible instead of silently green.
function renderSpotCheck(spot) {
if (!spot) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(主代理未回读任何语义单元;Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check)</span></p>`;
}
const groups = spot.groups_sampled;
const files = spot.files_sampled;
const units = spot.units_sampled;
const fake = spot.fake_read_found || 0;
const rereread = spot.groups_rereread || [];
if (!units) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(spot_check 已上报但单元数为 0)</span></p>`;
}
const mark = (fake || rereread.length) ? "🔴 发现假读" : "✅";
let s = `<p><b>防伪抽验:</b> 抽样 ${esc(files)} 文件 / ${esc(units)} 单元 / ${esc(groups)} 组,假读 ${esc(fake)} ${mark}</p>`;
if (rereread.length) {
s += `<p class="muted">因假读重读组:${esc(rereread.join(", "))}</p>`;
}
return s;
}
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
+15 -2
View File
@@ -11,7 +11,7 @@ import os
import re
import subprocess
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
from .flows import get_affected_flows
@@ -204,12 +204,20 @@ def _parse_numstat(log_text: str) -> dict[str, int]:
def compute_file_churn(
repo_root: str,
window_days: int | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, int]:
"""Count commits touching each file over a trailing window.
Returns an empty mapping when the window is invalid or Git cannot be
queried. Renames are deliberately not followed: churn belongs to the path
that existed in each commit.
Args:
repo_root: Repository root.
window_days: Trailing window; defaults to ``CRG_CHURN_WINDOW_DAYS``.
progress_cb: Optional ``(fraction, message)`` progress callback; the
single blocking step (``git log --numstat``) reports stage 0 before
and 1 after it runs.
"""
if window_days is None:
raw_window = os.environ.get("CRG_CHURN_WINDOW_DAYS", "90")
@@ -224,6 +232,8 @@ def compute_file_churn(
if window_days <= 0:
return {}
if progress_cb is not None:
progress_cb(0.0, "computing git churn")
try:
result = subprocess.run(
[
@@ -257,7 +267,10 @@ def compute_file_churn(
logger.warning("git log error: %s", exc)
return {}
return _parse_numstat(result.stdout)
parsed = _parse_numstat(result.stdout)
if progress_cb is not None:
progress_cb(1.0, "git churn done")
return parsed
# ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
# LLM-OPTIMIZED REFERENCE -- code-review-graph v2.3.6
# LLM-OPTIMIZED REFERENCE -- code-review-graph v2.4.0
AI coding agents: Read ONLY the exact `<section>` you need. Never load the whole file.
@@ -29,7 +29,32 @@ Full three-layer review: 1) get_minimal_context_tool + build_or_update_graph_too
</section>
<section name="project-review">
Whole-project or feature review (not diff-based): 1) get_minimal_context_tool + build_or_update_graph_tool; 2) get_architecture_overview_tool + list_communities_tool for the module map; 3) get_knowledge_gaps_tool + get_hub_nodes_tool + get_bridge_nodes_tool + find_large_functions_tool + get_surprising_connections_tool for high-risk areas; 4) whole-project: score_review_tool(all_files=True); feature: semantic_search_nodes_tool + query_graph_tool(children_of) to locate files, then score_review_tool(changed_files) + get_impact_radius_tool; 5) dedupe_findings_tool; 6) READ-ONLY adjudication; 7) generate_report_tool (format=both). Parse scope from the user instruction (全面/整个项目 -> whole-project, else feature + target). Target: <=12 tool calls, <=1800 tokens.
Whole-project or feature code review (not git-diff based). Parse scope: 全面/整个项目/所有/all -> whole-project; else feature + target keyword. review_data.scope MUST be exactly "whole-project" / "feature" / "change-level" — never a feature name like "evm" (the verify scripts rely on it).
WORKFLOW:
1) get_minimal_context_tool + build_or_update_graph_tool
2) get_architecture_overview_tool + list_communities_tool (module map)
3) get_knowledge_gaps_tool + get_hub_nodes_tool + get_bridge_nodes_tool + find_large_functions_tool + get_surprising_connections_tool (whole-project hotspots)
4) scoring: whole-project -> score_review_tool(all_files=True); feature -> semantic_search_nodes_tool(query=<target>) + query_graph_tool(pattern="children_of", target=<target>) to locate files, then score_review_tool(changed_files=<files>) + get_impact_radius_tool(changed_files=<files>)
5) dedupe_findings_tool(findings=<all raw findings>)
6) READ-ONLY adjudication (present findings by severity; fix/skip per batch; 🔴 blockers cannot be batch-skipped)
COVERAGE SELF-CHECK (mandatory before report):
- community_health_tool(): if needs_postprocess=true run code-review-graph postprocess first
- coverage_tool REQUIRES the three-piece deep-read data: pass file_read_ranges={<rel>:[[s,e],...]} AND file_semantic_units={<rel>:[{"range":[s,e],"kind":..,"name":..},...]} (record these while deep-reading each file). Without them the line/unit coverage is FAIL-CLOSED to 0% (each file listed in line_gap_files/missing_data_files).
- feature review: gate="line+unit" (line coverage >=95% + unit completeness gap-free ONLY; coverage_pct/high_risk_coverage_pct are null — no file-count/high-risk gate)
- whole-project: gate="both+line" (overall >=85% AND high-risk >=95% AND line >=95% AND unit gap-free)
- G1 confirm the deep-read list is complete; G2 spot-check 15% of silent_files (>=1 major found -> promote to full deep-read)
- G3 REREAD LOOP (HARD REQUIREMENT): if target_reached=false (line coverage <95% or unit gaps exist) you MUST NOT generate the report yet. Re-deep-read the files listed in line_gap_files / unit_gap_files / missing_data_files (read the missing line ranges / semantic units), then re-run coverage_tool until target_reached=true. A file you cannot fully read must be REMOVED from deep_read_files (the line+unit gate does not count file numbers — only files actually read to >=95% belong there). After generating the report run verify-line-coverage.ps1; exit 1 means re-read and regenerate.
generate_report_tool(review_data=..., output_path="docs/reviews/{name}-review-{YYYY-MM-DD-HHMMSS}"):
- MUST pass reviewed_files (an ARRAY of paths OR a comma-separated string — both are auto-normalised; report header renders a collapsible <details> list). files (if any) is a comma-separated STRING, never a list/array.
- TRANSMIT THE FULL coverage_tool RESULT VERBATIM into review_data.coverage (ALL fields: coverage_pct, high_risk_coverage_pct, grade, deep_read_count, total_files, high_risk_total_files, high_risk_deep_count, deep_read_weight, total_weight, target_reached, target, overall_target, high_risk_target, gate, line_coverage_pct, unit_coverage_pct, line_gap_files, unit_gap_files, unit_exempt_files, missing_data_files, remaining_files_to_target, remaining_weight_to_target, priority_deep_read_files, uncovered_files, silent_files, note) — do NOT hand-pick a subset, else counts render 0/0 and gap/missing hints disappear.
- metrics contains ONLY the five objective keys (sql_risk, exception_coverage, redundancy_rate, high_risk_density, vulnerability_risk); each entry MUST carry note (copy from the score_review_tool return value), do NOT mix in blast_radius / objective_grade / llm_judged.
- findings use message/fix fields (path + line synthesize location); counts use critical/informational keys.
- format="both" (writes .html + .md). Full schema: project-review skill SKILL.md + references/report-schema.md.
Target: <=14 tool calls, <=2000 tokens.
</section>
<section name="score-review">
@@ -38,7 +63,7 @@ score_review_tool returns objective metrics (sql_risk, exception_coverage, redun
<section name="commands">
Core MCP tools: get_minimal_context_tool, detect_changes_tool, get_review_context_tool, get_impact_radius_tool, query_graph_tool, semantic_search_nodes_tool, get_architecture_overview_tool, get_affected_flows_tool, list_flows_tool, list_communities_tool, refactor_tool, build_or_update_graph_tool, run_postprocess_tool, embed_graph_tool, list_graph_stats_tool, get_docs_section_tool
Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool
Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool, coverage_tool, community_health_tool
MCP prompts (7): review_changes, architecture_map, debug_issue, onboard_developer, pre_merge_check, unified_review, project_review
Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr, unified-review, project-review
CLI: code-review-graph [install|init|build|update|status|watch|visualize|serve|mcp|wiki|detect-changes|postprocess|embed|register|unregister|repos|eval|daemon]
+315 -47
View File
@@ -14,10 +14,13 @@ import asyncio
import logging
import os
import sys
import threading
import time
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
from fastmcp import FastMCP
from fastmcp.server.context import Context
from . import incremental as _incremental
from .graph import GraphStore
@@ -34,8 +37,11 @@ from .prompts import (
from .tools import (
apply_refactor_func,
build_or_update_graph,
community_health_func,
coverage_func,
cross_repo_search_func,
dedupe_findings_func,
deep_read_plan_func,
detect_changes_func,
embed_graph,
find_large_functions,
@@ -62,6 +68,7 @@ from .tools import (
query_graph,
refactor_func,
run_postprocess,
save_coverage_index_func,
score_review_func,
semantic_search_nodes,
traverse_graph_func,
@@ -91,6 +98,115 @@ def _resolve_repo_root(repo_root: Optional[str]) -> Optional[str]:
return repo_root if repo_root else _default_repo_root
class _ProgressSink:
"""Thread-safe progress channel from a worker thread back to the event loop.
The worker thread (``asyncio.to_thread``) calls the ``progress_cb`` that
the engine functions accept; each call stores the latest ``(fraction,
message)`` under a lock. The event-loop coroutine reads it via
:meth:`snapshot` on every heartbeat so ``Context.report_progress`` runs in
the MCP request context (where the contextvar / progress token lives) —
never from the worker thread.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._fraction = 0.0
self._message: Optional[str] = None
def progress_cb(self, fraction: float, message: Optional[str]) -> None:
with self._lock:
self._fraction = fraction
self._message = message
def snapshot(self) -> tuple[float, Optional[str]]:
with self._lock:
return self._fraction, self._message
#: Default interval (seconds) between MCP progress notifications. Must stay well
#: below the MCP SDK's 60s request timeout so opencode's
#: ``resetTimeoutOnProgress`` keeps the request alive on large repos.
_PROGRESS_HEARTBEAT = 15.0
async def _run_with_progress(
ctx: Context,
fn: Callable[..., Any],
*args: Any,
heartbeat: float = _PROGRESS_HEARTBEAT,
tool_timeout: int = 0,
provenance_root: Optional[str] = None,
**kwargs: Any,
) -> Any:
"""Run a blocking tool body in a thread while keeping the MCP client alive.
The MCP SDK default request timeout is 60s; opencode resets it whenever a
``notifications/progress`` arrives (``resetTimeoutOnProgress``). This
helper runs ``fn`` via ``asyncio.to_thread`` and, in the event-loop
coroutine (which is inside the MCP request context, so ``request_ctx`` /
``progressToken`` are available), periodically calls
``ctx.report_progress``. If ``fn``'s engine accepts a ``progress_cb``, a
thread-safe sink relays real progress; otherwise a heartbeat keeps the
connection alive regardless.
``provenance_root``, when given, wraps the result with
:func:`with_provenance` (called from the worker thread).
``tool_timeout`` (seconds, ``CRG_TOOL_TIMEOUT``; 0 = disabled) is a
server-side backstop that returns a readable error dict instead of letting
the client time out.
"""
loop = asyncio.get_running_loop()
sink = _ProgressSink()
# Bridge: if the target accepts progress_cb, wire the thread-safe sink in.
try:
import inspect as _inspect
accepts_cb = "progress_cb" in _inspect.signature(fn).parameters
except (TypeError, ValueError):
accepts_cb = False
if accepts_cb:
kwargs["progress_cb"] = sink.progress_cb
def _worker() -> Any:
result = fn(*args, **kwargs)
result = with_provenance(result, provenance_root)
return result
async def _run() -> Any:
task = asyncio.create_task(asyncio.to_thread(_worker))
try:
while not task.done():
if ctx is not None:
fraction, message = sink.snapshot()
await ctx.report_progress(fraction, 1, message or "processing...")
try:
await asyncio.wait_for(asyncio.shield(task), timeout=heartbeat)
except asyncio.TimeoutError:
pass
return task.result()
except Exception:
task.cancel()
raise
if tool_timeout > 0:
try:
return await asyncio.wait_for(_run(), timeout=tool_timeout)
except asyncio.TimeoutError:
message = (
f"tool timed out after {tool_timeout}s (CRG_TOOL_TIMEOUT). "
"Increase CRG_TOOL_TIMEOUT or reduce the review scope."
)
error_response = {"status": "error", "error": message, "summary": message}
if provenance_root is not None:
return await asyncio.to_thread(with_provenance, error_response, provenance_root)
return error_response
return await _run()
mcp = FastMCP(
"code-review-graph",
instructions=(
@@ -642,6 +758,7 @@ async def detect_changes_tool(
max_depth: int = 2,
repo_root: Optional[str] = None,
detail_level: str = "standard",
ctx: Context = None,
) -> dict:
"""Detect changes and produce risk-scored, priority-ordered review guidance.
@@ -649,9 +766,9 @@ async def detect_changes_tool(
flows, communities, and test coverage gaps. Returns risk scores and
prioritized review items. Replaces get_review_context for change-aware reviews.
Offloaded to a thread via ``asyncio.to_thread`` — runs `git diff`
subprocesses and BFS traversals that can take several seconds on
large repos. See: #46, #136.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
base: Git ref to diff against. Default: HEAD~1.
@@ -663,32 +780,14 @@ async def detect_changes_tool(
token-efficient summary. Default: standard.
"""
root = _resolve_repo_root(repo_root)
def _run() -> dict:
return with_provenance(detect_changes_func(
base=base, changed_files=changed_files,
include_source=include_source, max_depth=max_depth,
repo_root=root, detail_level=detail_level,
), root)
coro = asyncio.to_thread(_run)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
if tool_timeout > 0:
try:
return await asyncio.wait_for(coro, timeout=tool_timeout)
except asyncio.TimeoutError:
message = (
f"detect_changes_tool timed out after {tool_timeout}s. "
"Reduce scope with CRG_MAX_CHANGED_FUNCS / CRG_MAX_TRANSITIVE_FRONTIER, "
"or increase CRG_TOOL_TIMEOUT."
)
error_response = {
"status": "error",
"error": message,
"summary": message,
}
return await asyncio.to_thread(with_provenance, error_response, root)
return await coro
return await _run_with_progress(
ctx, detect_changes_func,
base=base, changed_files=changed_files,
include_source=include_source, max_depth=max_depth,
repo_root=root, detail_level=detail_level,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
@@ -699,17 +798,19 @@ async def score_review_tool(
repo_root: Optional[str] = None,
detail_level: str = "standard",
all_files: bool = False,
ctx: Context = None,
) -> dict:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability) that back the unified-review scoring. LLM-judged
metrics (requirement coverage, logic alignment, trust boundaries) are
reported in ``llm_judged`` for the calling agent to fill in.
vulnerability) that back the unified-review scoring. These five
objective metrics are the full report metric set; ``llm_judged`` is
returned empty for backward compatibility.
Offloaded to a thread via ``asyncio.to_thread`` — runs `git log`
subprocesses and graph queries that can take several seconds.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
changed_files: Files to score (auto-detected from git diff if
@@ -724,15 +825,14 @@ async def score_review_tool(
whole-project reviews (default: False).
"""
root = _resolve_repo_root(repo_root)
def _run() -> dict:
return with_provenance(score_review_func(
changed_files=changed_files, base=base,
include_churn=include_churn, repo_root=root,
detail_level=detail_level, all_files=all_files,
), root)
return await asyncio.to_thread(_run)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, score_review_func,
changed_files=changed_files, base=base,
include_churn=include_churn, repo_root=root,
detail_level=detail_level, all_files=all_files,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
@@ -763,6 +863,175 @@ def dedupe_findings_tool(
)
@mcp.tool()
async def coverage_tool(
deep_read_files: list,
all_files: bool = True,
include_churn: bool = True,
gate: str = "high_risk",
include_prior: bool = False,
file_read_ranges: Optional[dict] = None,
file_semantic_units: Optional[dict] = None,
repo_root: Optional[str] = None,
ctx: Context = None,
) -> dict:
"""Compute file-count review coverage for deep-read files.
Coverage = number of deep-read files / number of all source files.
Per-file risk weight (worst metric grade + graph topology hits +
normalised git churn) still ranks the priority deep-read list so the
highest-risk files are read first. Backs the project-review Step 7.5
coverage self-check (G3).
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths). Required.
all_files: When True (default), the denominator is every source
file in the graph (whole-project semantics).
include_churn: Include git-churn as the w3 weight term.
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
``"both"`` (both overall and high-risk must meet the target) /
``"both+line"`` (both + line/unit three-piece gate) /
``"line+unit"`` (line + unit only, feature reviews; file-count
/ high-risk coverage skipped and returned as ``None``).
include_prior: When True, merge the cross-round coverage index
(files whose SHA is unchanged) into the deep-read set.
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
sub-agent actually read per deep-read file. REQUIRED for
gate="both+line"/"line+unit" — without it those files are
FAIL-CLOSED as line gaps (line coverage 0%).
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
reported semantic units per deep-read file (same gates).
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with coverage_pct, grade, deep_read_count, total_files,
target_reached, target, remaining_files_to_target,
priority_deep_read_files, uncovered_files, silent_files and note.
``silent_files`` feeds the G2 spot-check sampling.
"""
root = _resolve_repo_root(repo_root)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, coverage_func,
deep_read_files=deep_read_files, all_files=all_files,
include_churn=include_churn, gate=gate,
include_prior=include_prior,
file_read_ranges=file_read_ranges or {},
file_semantic_units=file_semantic_units or {},
repo_root=root,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
async def deep_read_plan_tool(
repo_root: Optional[str] = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
include_prior: bool = False,
deep_read_files: Optional[list] = None,
ctx: Context = None,
) -> dict:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects the highest-risk files not yet deep-read until the
file-count overall coverage target is reached, then groups them by
parent directory (≤ ``batch_size`` per group) so each group can be
dispatched to one parallel sub-agent. With ``include_prior``, files
already deep-read in earlier rounds (coverage index, SHA unchanged)
are excluded so incremental reviews only re-read what actually
changed or is new.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
repo_root: Repository root path. Auto-detected if omitted.
target_coverage: Overall coverage target percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
include_prior: Exclude files already covered by the cross-round
coverage index from the plan.
deep_read_files: Files already deep-read this round.
Returns:
Dict with current_coverage_pct, current/target/remaining file
counts, planned_files (priority-ordered), directory groups and
estimated_batches.
"""
root = _resolve_repo_root(repo_root)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, deep_read_plan_func,
repo_root=root, target_coverage=target_coverage,
batch_size=batch_size, include_churn=include_churn,
include_prior=include_prior, deep_read_files=deep_read_files,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
def save_coverage_index_tool(
deep_read_files: list,
file_read_ranges: Optional[dict] = None,
file_semantic_units: Optional[dict] = None,
repo_root: Optional[str] = None,
) -> dict:
"""Persist the deep-read file list to the cross-round coverage index.
Records each file's per-file SHA at HEAD so a later review can tell
which previously-deep-read files are still current (SHA unchanged)
and which are stale and must be re-read. Call at the end of every
review round (after the report is generated).
Args:
deep_read_files: Files deep-read this round (relative or absolute).
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
persist so a later review can reuse line coverage of
SHA-unchanged files.
file_semantic_units: Optional {rel_path: [{range,kind,name},...]}
semantic units to persist likewise.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with index_path, entry count and head_sha.
"""
return save_coverage_index_func(
deep_read_files=deep_read_files,
file_read_ranges=file_read_ranges or {},
file_semantic_units=file_semantic_units or {},
repo_root=repo_root,
)
@mcp.tool()
def community_health_tool(
repo_root: Optional[str] = None,
) -> dict:
"""Check node->community attribution health (nodes.community_id).
Detects the desync where ``communities.size`` is correct but
``nodes.community_id`` is mostly NULL (e.g. after an incremental
rebuild). Returns ``needs_postprocess``; the review skill should run
``code-review-graph postprocess`` when True before computing coverage.
Args:
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with total_nodes, attributed_nodes, non_file_nodes,
attribution_pct, needs_postprocess and note.
"""
return community_health_func(repo_root=repo_root)
@mcp.tool()
async def generate_report_tool(
review_data: dict,
@@ -1135,18 +1404,17 @@ def pre_merge_check(base: str = "HEAD~1") -> list[dict]:
@mcp.prompt()
def unified_review(base: str = "HEAD~1", tier: str = "standard") -> list[dict]:
def unified_review(base: str = "HEAD~1") -> list[dict]:
"""Three-layer unified review (CRG graph context + scoring + dedupe + report).
Fuses graph context with the objective scoring metrics, finding merge,
and the standalone HTML report. READ-ONLY: every finding waits for a
manual fix decision.
manual fix decision. Runs at the fixed standard tier (all layers).
Args:
base: Git ref to diff against. Default: HEAD~1.
tier: Review tier (fast / standard / strict). Default: standard.
"""
return unified_review_prompt(base=base, tier=tier)
return unified_review_prompt(base=base)
@mcp.prompt()
+3 -18
View File
@@ -163,7 +163,6 @@ def pre_merge_check_prompt(base: str = "HEAD~1") -> list[Message]:
def unified_review_prompt(
base: str = "HEAD~1",
tier: str = "standard",
) -> list[Message]:
"""Three-layer unified review workflow (READ-ONLY).
@@ -174,24 +173,11 @@ def unified_review_prompt(
Args:
base: Git ref to diff against. Default: HEAD~1.
tier: Review tier. "fast" (Layer 1 + blockers only),
"standard" (all layers), "strict" (full + per-item
confirmation). Default: standard.
"""
tier_notes = {
"fast": (
"fast tier: run Layers 1 and the blocker check only; "
"skip Layer 2 metrics and the report."
),
"strict": (
"strict tier: full review; every blocker and major finding "
"needs per-item user confirmation before it is recorded."
),
}.get(tier, "standard tier: run all layers.")
return _user(
f"{_TOKEN_EFFICIENCY_PREAMBLE}\n"
f"## Unified Review Workflow (base={base}, tier={tier})\n"
f"{tier_notes}\n"
f"## Unified Review Workflow (base={base})\n"
"Standard tier: run all layers.\n"
"**READ-ONLY.** Present every finding for a manual fix decision. "
"Never modify code, commit, or push.\n"
'1. Call `get_minimal_context(task="unified review")` for the '
@@ -202,8 +188,7 @@ def unified_review_prompt(
"files, risk score, test gaps and affected flows.\n"
'4. Call `score_review(detail_level="standard")` for the '
"objective metrics (sql_risk, exception_coverage, redundancy, "
"high-risk density, vulnerability). Trust the tool grades; "
"LLM-judged metrics are in `llm_judged`.\n"
"high-risk density, vulnerability). Trust the tool grades.\n"
"5. Review the changed source (Layer 1 chain decomposition) and "
"produce findings with severity (blocker/major/minor), "
"confidence (1-10), file:line and a proposed fix.\n"
+1130 -36
View File
@@ -2,10 +2,9 @@
Implements the objectively computable Layer-2 metrics from the
ai-code-review methodology as code, plus the git-history / graph risk
factors used by the gstack-review workflow. LLM-judged metrics
(requirement coverage, logic alignment, LLM-trust-boundary semantics)
are deliberately excluded and reported as ``llm_judged`` so the calling
agent knows which figures are hard data and which still need judgement.
factors used by the gstack-review workflow. The report metric set
consists solely of the five objective heuristic metrics computed here;
``llm_judged`` is returned empty for backward compatibility.
The three public entry points are:
@@ -22,7 +21,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from .changes import (
compute_file_churn,
@@ -32,6 +31,7 @@ from .changes import (
from .constants import SECURITY_KEYWORDS
from .graph import GraphStore
from .parser import normalize_file_path
from collections import Counter
# ---------------------------------------------------------------------------
# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
@@ -235,8 +235,8 @@ def compute_sql_risk(
"grade": _grade("sql_risk", float(count)),
"thresholds": THRESHOLDS["sql_risk"],
"evidence": locations[:20],
"note": "Heuristic scan for string-interpolated SQL. "
"Confirm each location before fixing; run EXPLAIN for performance risk.",
"note": "字符串拼接 SQL 的启发式扫描。修复前请逐一确认每个位置;"
"用 EXPLAIN 评估性能风险。",
}
@@ -265,8 +265,7 @@ def compute_exception_coverage(
"exception_path_lines": exc,
"normal_path_lines": normal,
},
"note": "Heuristic ratio of exception/error-path lines. "
"Review edge cases and error handling manually.",
"note": "异常/错误路径行的启发式占比。请人工复核边界条件与错误处理。",
}
@@ -281,8 +280,8 @@ def compute_redundancy_rate(
"grade": _grade("redundancy_rate", rate),
"thresholds": THRESHOLDS["redundancy_rate"],
"evidence": blocks[:20],
"note": "Heuristic duplicate-block rate (normalised lines appearing in "
">=3 places). Confirm before extracting shared logic.",
"note": "重复代码块启发式占比(规范化行在 >=3 处出现)。"
"抽取公共逻辑前请确认。",
}
@@ -307,8 +306,8 @@ def compute_high_risk_density(
"grade": "na",
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {},
"note": "No concurrency/transaction/data-integrity patterns detected "
"in the diff -- mark as N/A unless the agent finds a gap.",
"note": "变更中未检出并发/事务/数据一致性模式——"
"除非审查发现缺口,标记为 N/A。",
}
covered = 0
for _rel, line, _no in relevant:
@@ -330,8 +329,7 @@ def compute_high_risk_density(
for rel, line, no in relevant[:20]
],
},
"note": "Density of concurrency/transaction/security patterns. "
"This is a review-attention signal, not a correctness score.",
"note": "并发/事务/安全模式密度。属审查注意力信号,非正确性评分。",
}
@@ -355,10 +353,8 @@ def compute_vulnerability_heuristic(
"grade": _grade("vulnerability_risk", float(count)),
"thresholds": THRESHOLDS["vulnerability_risk"],
"evidence": locations[:20],
"note": "Heuristic OWASP/secret-pattern scan. Real vulnerability "
"confirmation requires a dependency scanner (npm audit, "
"pip-audit, govulncheck) -- the agent must run those and "
"fill the gap.",
"note": "OWASP/密钥模式的启发式扫描。真实漏洞需依赖扫描器"
"npm audit、pip-audit、govulncheck)确认。",
}
@@ -428,8 +424,8 @@ def compute_risk_factors(
"churn": churn,
"cross_community_edges": cross_community[:20],
"hub_dependencies": hub_dependencies[:20],
"note": "Structural risk factors. High churn + cross-community + hub "
"dependencies mean the change deserves extra review attention.",
"note": "结构性风险因子。高变更频率 + 跨社区耦合 + 中枢依赖"
"意味着该改动需要额外审查关注。",
}
@@ -438,6 +434,7 @@ def score_review(
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute all objective Layer-2 metrics for a set of changed files.
@@ -446,18 +443,27 @@ def score_review(
repo_root: Repository root.
changed_files: Changed file paths relative to ``repo_root``.
include_churn: Include git-churn risk factors.
progress_cb: Optional ``(fraction, message)`` progress callback;
invoked once per metric (fraction = k/5).
Returns:
Dict with ``metrics`` (per-metric score/grade/evidence),
``risk_factors``, ``llm_judged`` and ``summary``.
"""
metrics = {
"sql_risk": compute_sql_risk(changed_files, repo_root),
"exception_coverage": compute_exception_coverage(changed_files, repo_root),
"redundancy_rate": compute_redundancy_rate(changed_files, repo_root),
"high_risk_density": compute_high_risk_density(changed_files, repo_root),
"vulnerability_risk": compute_vulnerability_heuristic(changed_files, repo_root),
metric_fns = {
"sql_risk": compute_sql_risk,
"exception_coverage": compute_exception_coverage,
"redundancy_rate": compute_redundancy_rate,
"high_risk_density": compute_high_risk_density,
"vulnerability_risk": compute_vulnerability_heuristic,
}
metrics: dict[str, Any] = {}
for idx, (name, fn) in enumerate(metric_fns.items()):
if progress_cb is not None:
progress_cb(idx / len(metric_fns), f"computing {name}")
metrics[name] = fn(changed_files, repo_root)
if progress_cb is not None:
progress_cb(1.0, "metrics done")
risk_factors = compute_risk_factors(
store, repo_root, changed_files, include_churn=include_churn,
@@ -491,13 +497,7 @@ def score_review(
"summary": "\n".join(summary_parts),
"metrics": metrics,
"risk_factors": risk_factors,
"llm_judged": [
"requirement_coverage",
"logic_alignment",
"llm_trust_boundary",
"shell_injection",
"enum_completeness",
],
"llm_judged": [],
"objective_grade": worst,
}
@@ -607,6 +607,34 @@ def dedupe_findings(
# ---------------------------------------------------------------------------
def _normalise_coverage(coverage: Any) -> dict[str, Any] | None:
"""Normalise the ``coverage`` block passed into the HTML report feed.
If ``coverage`` is not a dict (e.g. an AI agent passed ``True`` or a
partial subset), return ``None`` so the report renders no coverage
section rather than crashing. When a dict is given, keep it as-is so
every field the agent chose to transmit survives; the HTML/Markdown
templates provide ``N/A`` fallbacks for missing counts so a partial
transmission never shows a misleading ``0/0``.
"""
if not isinstance(coverage, dict) or not coverage:
return None
return coverage
#: The five objective metrics produced by score_review_tool. Any other key
#: an agent passes in review_data.metrics (e.g. blast_radius, objective_grade,
#: llm_judged leftovers) is filtered out so the report only renders the
#: canonical five rows.
_OBJECTIVE_METRIC_KEYS: frozenset[str] = frozenset({
"sql_risk",
"exception_coverage",
"redundancy_rate",
"high_risk_density",
"vulnerability_risk",
})
def build_report_data(
review_data: dict[str, Any],
) -> dict[str, Any]:
@@ -617,11 +645,33 @@ def build_report_data(
``tier``, ``scope`` fields. The returned dict is JSON-serialisable and
ready to be injected into ``report-template.html`` as ``{{REPORT_DATA}}``.
"""
# Normalise ``files``: agents sometimes pass a list instead of a
# comma-separated string. Accept both; a list is joined so the report
# never renders Python/JSON list syntax.
raw_files = review_data.get("files", "")
files_text = (
", ".join(str(f) for f in raw_files)
if isinstance(raw_files, list)
else raw_files
)
# ``reviewed_files`` drives the collapsible <details> list. Agents may
# pass it as an array OR as a comma-separated string; accept both (a
# string is split so the Markdown renderer never iterates char-by-char).
# Fall back to the ``files`` array when nothing structured was passed.
reviewed_files = review_data.get("reviewed_files") or []
if isinstance(reviewed_files, str):
reviewed_files = [
p.strip() for p in reviewed_files.split(",") if p.strip()
]
elif not reviewed_files and isinstance(raw_files, list):
reviewed_files = [str(f) for f in raw_files]
data: dict[str, Any] = {
"scope": review_data.get("scope", "change-level"),
"tier": review_data.get("tier", "standard"),
"timestamp": review_data.get("timestamp", ""),
"files": review_data.get("files", ""),
"files": files_text,
"reviewed_files": reviewed_files,
"baseline": review_data.get("baseline", "generic"),
"verdict": review_data.get("verdict", "❌ FAIL"),
"quality_score": review_data.get("quality_score"),
@@ -630,10 +680,16 @@ def build_report_data(
"issues": [],
"manual_review": review_data.get("manual_review", []),
"llm_judged": review_data.get("llm_judged", []),
"coverage": _normalise_coverage(review_data.get("coverage")),
"spot_check": _normalise_coverage(review_data.get("spot_check")),
}
# Objective metrics only: filter out stray keys (blast_radius,
# objective_grade, ...) so the report always renders the canonical five.
metrics = review_data.get("metrics") or {}
for name, m in metrics.items():
if name not in _OBJECTIVE_METRIC_KEYS:
continue
if isinstance(m, dict):
data["metrics"][name] = {
"grade": m.get("grade"),
@@ -719,7 +775,7 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
if data.get("timestamp"):
lines.append(f"- **生成时间**{data['timestamp']}")
if data.get("files"):
if data.get("files") and not (data.get("reviewed_files") or []):
lines.append(f"- **文件**{data['files']}")
qs = data.get("quality_score")
if qs is not None:
@@ -732,6 +788,26 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
lines.append("")
# Reviewed files (collapsible). Renders from the structured
# ``reviewed_files`` array when present; otherwise the flat ``files``
# string is used (as the meta line above). Defensive: if a raw string
# ever slips through (build_report_data already normalises it), split it
# so we never iterate a string char-by-char.
reviewed = data.get("reviewed_files") or []
if isinstance(reviewed, str):
reviewed = [p.strip() for p in reviewed.split(",") if p.strip()]
if reviewed:
lines.append(f"**审查文件({len(reviewed)} 个)**")
lines.append("")
lines.append("<details>")
lines.append(f"<summary>点击展开 / 收起({len(reviewed)} 个文件)</summary>")
lines.append("")
for f in reviewed:
lines.append(f"- `{f}`")
lines.append("")
lines.append("</details>")
lines.append("")
# Objective metrics
metrics = data.get("metrics") or {}
if metrics:
@@ -749,6 +825,101 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append(f"| {label} | {value_text} | {grade_text} | {note} |")
lines.append("")
# Coverage. For gate="both+line" (whole-project) the file-count and
# high-risk rows render; for gate="line+unit" (feature reviews) the
# engine returns coverage_pct=None so only the line/unit rows plus the
# fail-closed status line render.
coverage = data.get("coverage") or {}
if coverage:
pct = coverage.get("coverage_pct")
hr_pct = coverage.get("high_risk_coverage_pct")
grade = coverage.get("grade") or "na"
grade_text = _GRADE_LABELS.get(grade, grade)
deep_read = coverage.get("deep_read_count", "N/A")
total = coverage.get("total_files", "N/A")
hr_total = coverage.get("high_risk_total_files", "N/A")
hr_deep = coverage.get("high_risk_deep_count", "N/A")
overall_target = coverage.get("overall_target", coverage.get("target", "N/A"))
hr_target = coverage.get("high_risk_target", coverage.get("target", "N/A"))
reached = coverage.get("target_reached", False)
status = "✅ 达标" if reached else "🔴 覆盖不足"
uncovered = coverage.get("uncovered_files") or []
silent = coverage.get("silent_files") or []
# Line / unit coverage (gate="both+line" / "line+unit"): fail-closed.
# A missing value means the review never ran the line-coverage gate -
# surface it explicitly instead of silently omitting the field.
line_pct = coverage.get("line_coverage_pct")
unit_pct = coverage.get("unit_coverage_pct")
line_target = coverage.get("line_target", 95.0)
unit_target = coverage.get("unit_target", 100.0)
line_gap_n = len(coverage.get("line_gap_files") or [])
unit_gap_n = len(coverage.get("unit_gap_files") or [])
missing_n = len(coverage.get("missing_data_files") or [])
lines.append("## 覆盖度\n")
if pct is not None:
lines.append(
f"- **覆盖度(全库)**{pct}% — 已深读 {deep_read}/{total} 个源文件"
f"(目标 {overall_target}%"
)
lines.append(
f"- **覆盖度(高风险)**{hr_pct}% — 已深读 {hr_deep}/{hr_total} 个高风险文件"
f"(目标 {hr_target}%{status}"
)
else:
lines.append(
f"- **状态**{status}gate=\"line+unit\":仅行/单元覆盖,不做文件数覆盖检查)"
)
if line_pct is None or unit_pct is None:
lines.append("- **行覆盖**:未执行 🔴(coverage_tool 未用 gate=\"both+line\" 或未传三件套数据)")
else:
line_ok = "" if line_pct >= line_target and line_gap_n == 0 else "🔴"
unit_ok = "" if unit_pct >= unit_target and unit_gap_n == 0 else "🔴"
lines.append(
f"- **行覆盖**{line_pct}% — 目标 {line_target}%(缺口 {line_gap_n} 文件){line_ok}"
)
lines.append(
f"- **单元覆盖**{unit_pct}% — 目标 {unit_target}%(缺口 {unit_gap_n} 文件){unit_ok}"
)
if missing_n:
lines.append(
f"- **三件套数据缺失**{missing_n} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)"
)
if uncovered:
lines.append(
f"- **未深读文件**{len(uncovered)}"
f"(静默文件 {len(silent)} 个)"
)
lines.append("")
# Anti-fake spot check (three-piece suite item 3). Fail-closed: a
# missing/incomplete spot_check renders "未执行 🔴" so reviews that
# skipped the sampled re-read are visible instead of silently green.
spot = data.get("spot_check")
if spot:
groups = spot.get("groups_sampled")
files = spot.get("files_sampled")
units = spot.get("units_sampled")
fake = spot.get("fake_read_found", 0)
rereread = spot.get("groups_rereread") or []
if units:
mark = "🔴 发现假读" if (fake or rereread) else ""
lines.append(
f"- **防伪抽验**:抽样 {files} 文件 / {units} 单元 / {groups} 组,"
f"假读 {fake}{mark}"
)
if rereread:
lines.append(
f" - 因假读重读组:{', '.join(rereread)}"
)
else:
lines.append("- **防伪抽验**:未执行 🔴(spot_check 已上报但单元数为 0")
else:
lines.append(
"- **防伪抽验**:未执行 🔴(主代理未回读任何语义单元;"
"Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check"
)
lines.append("")
# Issues
issues = data.get("issues") or []
lines.append(f"## 问题清单({len(issues)}\n")
@@ -790,3 +961,926 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append("")
return "\n".join(lines).strip() + "\n"
# ---------------------------------------------------------------------------
# Coverage computation (review coverage of the whole project)
# ---------------------------------------------------------------------------
#: Coverage targets (percentage of source files deep-read). Fixed to the
#: single standard tier; the fast/strict tiers were removed. Per-gate
#: targets: ``overall`` is the whole-project file-count target, ``high_risk``
#: the signal-flagged subset target. ``gate="both"`` requires both to pass.
COVERAGE_TARGETS: dict[str, dict[str, float]] = {
"standard": {
"overall": 85.0,
"high_risk": 95.0,
},
}
def _coverage_targets(tier: str = "standard") -> tuple[float, float]:
"""Resolve the ``(overall, high_risk)`` target pair for a tier.
Backwards-compatible: a legacy flat float value (e.g. ``95.0``) is
treated as applying to *both* gates. A per-gate dict is honoured as-is.
"""
cfg = COVERAGE_TARGETS.get(tier, COVERAGE_TARGETS.get("standard", {}))
if isinstance(cfg, dict):
return (
float(cfg.get("overall", 85.0)),
float(cfg.get("high_risk", 95.0)),
)
value = float(cfg)
return value, value
#: Subdirectories excluded from the coverage denominator (non-source).
COVERAGE_EXCLUDE_DIRS: tuple[str, ...] = (
"docs/",
"test-output/",
"tests/",
"scripts/",
"node_modules/",
".git/",
)
#: Weight of each metric grade for the w1 term (worst grade wins per file).
_GRADE_WEIGHT: dict[str, float] = {
"fail": 3.0,
"warn": 2.0,
"good": 1.0,
"na": 0.5,
}
def _is_source_file(rel_path: str) -> bool:
"""Heuristic filter for source files vs. docs/tests/generated output."""
normalized = rel_path.replace("\\", "/").lower()
if any(normalized.startswith(d) for d in COVERAGE_EXCLUDE_DIRS):
return False
if normalized.endswith(
(".bak", ".clean", ".debug1", ".fullbak", ".tmp", ".map")
):
return False
return True
def _per_file_w1(file_rel: str, repo_root: Path) -> float:
"""Compute the risk grade (w1 term) for a single source file.
Uses the per-file SQL / vulnerability / redundancy heuristic scans.
``exception_coverage`` is deliberately excluded: on a per-file basis it
is ~always ``fail`` for real-world modules (most files have few
explicit error branches), which gives w1 zero discriminative power.
``high_risk_density`` is excluded too (it is ~always 100% for any file
containing SQL/async/transaction markers). ``repo_root`` is resolved
against when ``file_rel`` is not absolute.
"""
raw = file_rel.replace("\\", "/")
candidate = Path(file_rel)
if not candidate.is_absolute():
candidate = repo_root / raw
if not candidate.is_file():
return 0.0
try:
lines = candidate.read_text(
encoding="utf-8", errors="replace",
).splitlines()
except OSError:
return 0.0
if not lines:
return 0.0
line_items = [(raw, line, i) for i, line in enumerate(lines, start=1)]
sql_hits = _count_matching(line_items, _SQL_RISK_PATTERNS)
sql_grade = _grade("sql_risk", float(sql_hits))
vuln_hits = _count_matching(line_items, _VULNERABILITY_PATTERNS)
vuln_grade = _grade("vulnerability_risk", float(vuln_hits))
sig_count: dict[str, int] = Counter()
for _p, line, _n in line_items:
sig = _normalized_signature(line)
if len(sig) >= 24:
sig_count[sig] += 1
dup_lines = sum(c for c in sig_count.values() if c >= 3)
redundancy_rate = (dup_lines / len(line_items) * 100.0)
redund_grade = _grade("redundancy_rate", redundancy_rate)
grades = [
g for g in (sql_grade, vuln_grade, redund_grade)
if g != "na"
]
if not grades:
return _GRADE_WEIGHT["na"]
worst = max(grades, key=lambda g: _GRADE_WEIGHT.get(g, 0.0))
return _GRADE_WEIGHT.get(worst, _GRADE_WEIGHT["na"])
def _topology_hits(store: GraphStore, file_rel: str) -> float:
"""Count graph topology signal hits (w2 term) for a file's nodes.
Uses hub degree (>= 10 incoming calls) and untested hotspot flags as
lightweight proxies; avoids re-running the top-N truncated tools so the
w2 term is computed over the whole graph, not the first N nodes.
"""
abs_path = normalize_file_path(Path(file_rel))
nodes = store.get_nodes_by_file(abs_path)
if not nodes:
return 0.0
hits = 0
for n in nodes:
edges = store.get_edges_by_target(n.qualified_name)
incoming = [
e for e in edges
if e.kind in ("CALLS", "REFERENCES", "IMPLEMENTS")
]
if len(incoming) >= 10:
hits += 1
if not n.is_test and len(incoming) >= 5 and not _has_tested_by(store, n.qualified_name):
hits += 0.5
return hits
def _has_tested_by(store: GraphStore, qualified_name: str) -> bool:
try:
edges = store.get_edges_by_target(qualified_name)
return any(e.kind == "TESTED_BY" for e in edges)
except Exception:
return False
def _file_weights(
store: GraphStore,
repo_root: Path,
source_files: list[str],
source_abs: list[str],
churn_map: dict[str, int],
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, dict[str, Any]]:
"""Compute the risk weight ``w = w1(grade) + w2(topology) + w3(churn)``
for every source file, plus its high-risk flag.
Shared by :func:`compute_coverage` and :func:`deep_read_plan` so both
derive weights from exactly the same model. Keys are the normalized
absolute paths used by the graph identity.
Args:
store: Open graph store.
repo_root: Repository root.
source_files: Source file paths relative to ``repo_root``.
source_abs: Parallel list of normalized absolute paths.
churn_map: Per-file commit counts.
progress_cb: Optional ``(fraction, message)`` progress callback,
invoked every 50 files.
"""
weights: dict[str, dict[str, Any]] = {}
max_churn = max(churn_map.values()) if churn_map else 1
total = max(len(source_files), 1)
for idx, (f, f_abs) in enumerate(zip(source_files, source_abs)):
if progress_cb is not None and idx % 50 == 0:
progress_cb(idx / total, f"computing risk weights ({idx}/{total})")
w1 = _per_file_w1(f_abs, repo_root)
w2 = _topology_hits(store, f_abs)
rel_for_churn = f.lstrip("/").replace("\\", "/")
raw_churn = churn_map.get(f, churn_map.get(rel_for_churn, 0))
w3 = (raw_churn / max_churn) if max_churn else 0.0
is_high_risk = (w1 >= 2.0 or w2 > 0.0 or raw_churn >= 3)
weights[f_abs] = {
"w": w1 + w2 + w3,
"w1": w1,
"w2": w2,
"w3": w3,
"raw_churn": raw_churn,
"is_high_risk": is_high_risk,
}
if progress_cb is not None:
progress_cb(1.0, "risk weights done")
return weights
def _real_line_count(repo_root: Path, rel: str, cache: dict) -> int:
"""Real line count of a source file, cached. Independent of graph node
``line_end`` (verified to have a +-1 skew vs actual file length)."""
if rel in cache:
return cache[rel]
try:
n = len((repo_root / rel).read_text(encoding="utf-8", errors="replace").splitlines())
except OSError:
n = 0
cache[rel] = n
return n
def _union_len(ranges: list[list[int]]) -> int:
"""Covered line count of a list of inclusive [s,e] ranges (merged)."""
if not ranges:
return 0
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return sum(e - s + 1 for s, e in merged)
def _graph_semantic_units(store: GraphStore, root: Path, rel: str) -> list[dict]:
"""Graph semantic-unit nodes (Function/Class/Test) of a file."""
if not hasattr(store, "_conn"):
return []
q = (root / rel).as_posix()
try:
rows = store._conn.execute(
"SELECT kind, name, line_start, line_end FROM nodes "
"WHERE file_path = ? AND kind IN ('Function','Class','Test') "
"ORDER BY line_start",
(q,),
).fetchall()
except Exception:
return []
out = []
for r in rows:
try:
out.append(
{
"kind": r["kind"],
"name": r["name"],
"line_start": int(r["line_start"]),
"line_end": int(r["line_end"]),
}
)
except (KeyError, TypeError):
continue
return out
def _is_giant_file(graph_units: list[dict], real_lines: int) -> bool:
"""Unit-exempt when the largest unit spans >80% of the file's lines.
A single huge function (e.g. migrations.rs run_migrations = 98% of the
file) makes unit-completeness meaningless, so such files are checked on
line coverage only. Small files with 2-3 ordinary units are NOT exempt:
they must still cover every unit."""
if not graph_units or real_lines <= 0:
return False
largest = max(u["line_end"] - u["line_start"] + 1 for u in graph_units)
return (largest / real_lines) > 0.8
def _unit_overlap(a: list[int], b: list[int]) -> int:
lo, hi = max(a[0], b[0]), min(a[1], b[1])
return max(0, hi - lo + 1)
def _unit_covered(
graph_unit: dict,
read_ranges: list[list[int]],
unit_ranges: list[list[int]],
matched: set[int],
) -> bool:
"""A graph unit is covered iff one reported unit range matches exactly
(preferred) or overlaps >=80% of the graph unit span, is not already
claimed by a higher-overlap unit (one-to-one), and >=80% of the graph
unit's lines fall inside union(read_ranges)."""
gs, ge = graph_unit["line_start"], graph_unit["line_end"]
gspan = max(1, ge - gs + 1)
exact = [i for i, (s, e) in enumerate(unit_ranges) if s == gs and e == ge]
if exact:
idx = exact[0]
if idx in matched:
return False
matched.add(idx)
else:
best_idx, best_overlap = None, 0
for i, (s, e) in enumerate(unit_ranges):
ov = _unit_overlap([gs, ge], [s, e])
if ov > best_overlap:
best_overlap, best_idx = ov, i
if best_idx is None or best_idx in matched:
return False
if best_overlap / gspan < 0.8:
return False
matched.add(best_idx)
in_union = 0
for s, e in _merge_ranges(read_ranges):
lo, hi = max(gs, s), min(ge, e)
if lo <= hi:
in_union += hi - lo + 1
return (in_union / gspan) >= 0.8
def _merge_ranges(ranges: list[list[int]]) -> list[list[int]]:
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges or []):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def _unit_gaps(
graph_units: list[dict],
reported_units: list[dict],
read_ranges: list[list[int]],
) -> list[dict]:
"""Return graph units not covered by the reported semantic units."""
unit_ranges = [
[int(u.get("range", [0, 0])[0]), int(u.get("range", [0, 0])[1])]
for u in reported_units
]
matched: set[int] = set()
gaps = []
for u in graph_units:
if not _unit_covered(u, read_ranges, unit_ranges, matched):
gaps.append(
{
"name": u["name"],
"range": [u["line_start"], u["line_end"]],
}
)
return gaps
def compute_coverage(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str],
include_churn: bool = True,
gate: str = "high_risk",
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
line_target: float = 95.0,
unit_target: float = 100.0,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / total number of source files.
The overall coverage uses every source file as the denominator; the
high-risk coverage uses only the signal-flagged subset. Per-file risk
weights (w = w1 grade + w2 topology + w3 churn) are still computed and
used to rank ``priority_deep_read_files`` so the highest-risk files
are read first, but the coverage percentage itself is file-count based.
Also returns the list of files never touched by any deep-read /
signal (``silent_files``) for G2 spot-check sampling, the uncovered
files list, and (new) the file count still needed to reach the target
plus the priority deep-read file list that would close that gap.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths).
include_churn: Include git-churn as the w3 weight term.
gate: Coverage gate mode. ``"high_risk"`` (default) gates on the
signal-flagged subset only; ``"overall"`` gates on all source
files; ``"both"`` requires *both* the overall and the high-risk
coverage to meet the target; ``"both+line"`` additionally
requires per-file line coverage >= ``line_target`` and unit
completeness (gap-free) per file; ``"line+unit"`` (feature
reviews) checks ONLY line coverage and unit completeness -
file-count / high-risk coverage are not computed and
``coverage_pct`` / ``high_risk_coverage_pct`` return ``None``.
file_read_ranges: Optional mapping {rel_path: [[s,e], ...]} of the
line ranges a sub-agent actually read per deep-read file.
Used for the line-coverage gate (denominator = real file line
count). Absent file => line coverage treated as satisfied.
file_semantic_units: Optional mapping {rel_path: [{"range":[s,e],
"kind":.., "name":..}, ...]} reported per deep-read file.
Used for the unit-completeness gate (gap-free vs graph units).
Absent file => unit completeness treated as satisfied.
line_target: Min per-file line coverage percent for gate="both+line".
unit_target: Min unit completeness percent (gap-free share).
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with coverage_pct, high_risk_coverage_pct, deep_read_count,
total_files, deep_read_weight, total_weight, target_reached,
target (high-risk target, back-compat), overall_target,
high_risk_target, remaining_files_to_target (file-count gap) and the
compatible remaining_weight_to_target (risk-weight gap),
priority_deep_read_files, uncovered_files, silent_files and grade.
With gate="both+line" also returns line_coverage_pct,
unit_coverage_pct, line_gap_files, unit_gap_files,
unit_exempt_files, missing_data_files.
With gate="line+unit" same line/unit fields plus
``gate="line+unit"``; ``coverage_pct``/``high_risk_coverage_pct``
are ``None`` because file-count coverage is not computed.
FAIL-CLOSED (v2.5.2): a deep-read file with no file_read_ranges
(or no file_semantic_units when the graph has units) is recorded
as a line/unit gap and listed in missing_data_files. Missing data
therefore makes target_reached=false instead of silently passing.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"coverage_pct": 0.0,
"grade": "na",
"deep_read_count": 0,
"total_files": 0,
"deep_read_weight": 0.0,
"total_weight": 0.0,
"target_reached": False,
"target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"overall_target": COVERAGE_TARGETS.get("standard", {}).get("overall", 85.0),
"high_risk_target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"remaining_files_to_target": 0.0,
"remaining_weight_to_target": 0.0,
"priority_deep_read_files": [],
"uncovered_files": [],
"silent_files": [],
"note": "No source files found in graph.",
}
# Resolve relative paths against repo_root so graph identity matches.
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
# Normalise deep-read list against the graph's file paths.
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
# Line / unit coverage data (gate="both+line" / "line+unit").
line_gap_files: list[dict] = []
unit_gap_files: list[dict] = []
unit_exempt_files: list[dict] = []
missing_data_files: list[dict] = []
_line_cache: dict[str, int] = {}
_line_tot = 0
_line_cov = 0
_unit_tot = 0
_unit_cov = 0
if gate in ("both+line", "line+unit"):
root_str = str(repo_root).replace("\\", "/").rstrip("/")
for f, f_abs in zip(source_files, source_abs):
if f_abs not in deep_read_set:
continue
# rel is relative to repo_root (sub-agents report relative paths)
rel = f.replace("\\", "/")
if rel.startswith(root_str + "/"):
rel = rel[len(root_str) + 1:]
_line_tot += 1
_unit_tot += 1
# --- line coverage ---
ranges = (file_read_ranges or {}).get(rel) or (
file_read_ranges or {}
).get(f_abs)
real_lines = _real_line_count(repo_root, rel, _line_cache)
if ranges and real_lines > 0:
covered = _union_len(ranges)
pct = covered / real_lines * 100.0
_line_cov += 1 if pct >= line_target else 0
if pct < line_target:
line_gap_files.append(
{
"path": rel,
"coverage_pct": round(pct, 1),
"total_lines": real_lines,
"covered_lines": covered,
}
)
else:
# FAIL-CLOSED: a deep-read file without read_ranges (or an
# empty/unreadable file) cannot be verified - record a gap
# instead of silently treating it as satisfied. Without this
# branch, gate="both+line" would report target_reached=true
# while line_coverage_pct stays 0 (silent green).
line_gap_files.append(
{
"path": rel,
"coverage_pct": 0.0,
"reason": (
"missing read_ranges" if not ranges
else "unreadable/empty file"
),
}
)
missing_data_files.append(
{"path": rel, "field": "file_read_ranges"}
)
# --- unit completeness ---
units = (file_semantic_units or {}).get(rel) or (
file_semantic_units or {}
).get(f_abs)
g_units = _graph_semantic_units(store, repo_root, rel)
if g_units and units is None:
# FAIL-CLOSED: graph has semantic units but the sub-agent
# reported none - cannot verify completeness, record a gap.
unit_gap_files.append(
{
"path": rel,
"reason": "missing semantic_units",
}
)
missing_data_files.append(
{"path": rel, "field": "file_semantic_units"}
)
elif g_units and units is not None:
giant = _is_giant_file(g_units, real_lines)
if giant:
unit_exempt_files.append(
{
"path": rel,
"reason": (
"giant-file: units=%d largest_span=%.0f%%"
% (
len(g_units),
100
* max(u["line_end"] - u["line_start"] + 1 for u in g_units)
/ max(1, real_lines),
)
),
}
)
_unit_tot -= 1 # exempt from unit gate
else:
uncovered = _unit_gaps(g_units, units, ranges or [])
if uncovered:
unit_gap_files.append(
{
"path": rel,
"total_units": len(g_units),
"covered_units": len(g_units) - len(uncovered),
"uncovered": uncovered,
}
)
else:
_unit_cov += 1
unit_coverage_pct = (_unit_cov / _unit_tot * 100.0) if _unit_tot else 100.0
line_coverage_pct = (_line_cov / _line_tot * 100.0) if _line_tot else 100.0
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
# Single pass: compute overall weight (all source files) and the
# high-risk subset weight (files flagged by any signal).
total_weight = 0.0
deep_read_weight = 0.0
high_risk_total = 0.0
high_risk_deep = 0.0
high_risk_deep_count = 0
high_risk_files: list[str] = []
uncovered_files: list[str] = []
silent_files: list[str] = []
for f, f_abs in zip(source_files, source_abs):
wi = weights[f_abs]
w = wi["w"]
total_weight += w
if f_abs in deep_read_set:
deep_read_weight += w
if wi["is_high_risk"]:
high_risk_total += w
high_risk_files.append(f)
if f_abs in deep_read_set:
high_risk_deep += w
high_risk_deep_count += 1
elif f_abs not in deep_read_set:
# Not high-risk and not deep-read: silent candidate.
if wi["w1"] < 2.0 and wi["w2"] == 0.0 and wi["raw_churn"] < 3:
silent_files.append(f)
uncovered_files = [
f for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
total_files = len(source_files)
deep_read_count = len(deep_read_set)
coverage_pct = (
(deep_read_count / total_files * 100.0)
if total_files else 0.0
)
high_risk_total_files = len(high_risk_files)
high_risk_pct = (
(high_risk_deep_count / high_risk_total_files * 100.0)
if high_risk_total_files else 0.0
)
overall_target, high_risk_target = _coverage_targets()
overall_ok = coverage_pct >= overall_target
high_risk_ok = (
(high_risk_pct >= high_risk_target) if high_risk_total_files else overall_ok
)
if gate == "overall":
target_reached = overall_ok
gate_pct = coverage_pct
gate_target = overall_target
elif gate == "both":
target_reached = overall_ok and high_risk_ok
gate_pct = min(coverage_pct, high_risk_pct) if high_risk_total_files else coverage_pct
gate_target = min(overall_target, high_risk_target)
elif gate == "both+line":
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = overall_ok and high_risk_ok and quality_ok
gate_pct = (
min(coverage_pct, high_risk_pct, line_coverage_pct, unit_coverage_pct)
if high_risk_total_files
else min(coverage_pct, line_coverage_pct, unit_coverage_pct)
)
gate_target = min(overall_target, high_risk_target, line_target, unit_target)
elif gate == "line+unit":
# Feature reviews: check ONLY line coverage + unit completeness.
# File-count / high-risk coverage is deliberately not part of the
# gate (coverage_pct / high_risk_coverage_pct are returned as None).
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = quality_ok
gate_pct = min(line_coverage_pct, unit_coverage_pct)
gate_target = min(line_target, unit_target)
else: # "high_risk" (default)
target_reached = high_risk_ok
gate_pct = high_risk_pct
gate_target = high_risk_target
grade = (
"good" if target_reached
else ("warn" if gate_pct >= gate_target * 0.8 else "fail")
)
remaining_files_to_target = max(
0.0, total_files * overall_target / 100.0 - deep_read_count
)
remaining_weight_to_target = max(
0.0, total_weight * overall_target / 100.0 - deep_read_weight
)
priority_deep_read_files = [
{"path": f_abs, "weight": round(weights[f_abs]["w"], 3)}
for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
priority_deep_read_files.sort(key=lambda e: e["weight"], reverse=True)
return {
"status": "ok",
"coverage_pct": round(coverage_pct, 1) if gate != "line+unit" else None,
"high_risk_coverage_pct": round(high_risk_pct, 1) if gate != "line+unit" else None,
"grade": grade,
"deep_read_count": deep_read_count,
"total_files": total_files,
"high_risk_total_files": high_risk_total_files,
"high_risk_deep_count": high_risk_deep_count,
"deep_read_weight": round(deep_read_weight, 2),
"total_weight": round(total_weight, 2),
"target_reached": target_reached,
"target": high_risk_target,
"overall_target": overall_target,
"high_risk_target": high_risk_target,
"gate": gate,
"line_coverage_pct": round(line_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"unit_coverage_pct": round(unit_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"line_gap_files": line_gap_files if gate in ("both+line", "line+unit") else [],
"unit_gap_files": unit_gap_files if gate in ("both+line", "line+unit") else [],
"unit_exempt_files": unit_exempt_files if gate in ("both+line", "line+unit") else [],
"missing_data_files": missing_data_files if gate in ("both+line", "line+unit") else [],
"remaining_files_to_target": round(remaining_files_to_target, 2),
"remaining_weight_to_target": round(remaining_weight_to_target, 2),
"priority_deep_read_files": priority_deep_read_files,
"uncovered_files": uncovered_files,
"silent_files": silent_files,
"note": (
"行/单元门禁(feature):仅要求行覆盖 >= "
f"{line_target}% 且单元完整性无缺口;coverage_pct / "
"high_risk_coverage_pct 为 None(未做文件数/高风险覆盖检查)。"
) if gate == "line+unit" else (
"双重覆盖口径:全库 = 深读文件数 / 全部源文件数;高风险 = 深读 / "
"信号点名文件数。G3 门禁口径可配置(high_risk/overall/both)。"
"每文件风险权重(w = w1 指标分 + w2 拓扑 + w3 变更频率)仍用于"
"排序 priority_deep_read_files;低于目标 => 审查覆盖不足。"
),
}
def deep_read_plan(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str] | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
prior_covered: set[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects files not yet deep-read until the file-count coverage
target is reached, preferring the highest-risk (highest-weight) files
first, then groups them by parent directory so each batch can be
dispatched to one parallel sub-agent.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files already deep-read this round.
target_coverage: Target overall coverage percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
prior_covered: Absolute paths already deep-read in prior rounds
(from the coverage index); excluded from the plan.
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with current/remaining file counts (primary) plus compatible
weight counts, the greedy gap-closing file list and the directory
groups ready for sub-agent dispatch.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"current_coverage_pct": 0.0,
"current_files": 0,
"target_files": 0,
"remaining_files": 0,
"current_weight": 0.0,
"target_weight": 0.0,
"remaining_weight": 0.0,
"planned_files": [],
"planned_weight": 0.0,
"groups": [],
"estimated_batches": 0,
"gate": "overall",
}
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
if prior_covered:
deep_read_set |= {_abs(p) for p in prior_covered if _abs(p) in source_abs_set}
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
total_weight = sum(wi["w"] for wi in weights.values())
total_files = len(source_files)
current_files = len(deep_read_set)
current_weight = sum(
weights[f_abs]["w"]
for f_abs in source_abs_set
if f_abs in deep_read_set and f_abs in weights
)
current_pct = (current_files / total_files * 100.0) if total_files else 0.0
target_files = total_files * target_coverage / 100.0
remaining_files = max(0.0, target_files - current_files)
target_weight = total_weight * target_coverage / 100.0
remaining_weight = max(0.0, target_weight - current_weight)
# Greedy: pick highest-weight uncovered files until the gap is closed.
candidates = sorted(
(
{"path": f_abs, "weight": weights[f_abs]["w"]}
for f_abs in source_abs
if f_abs not in deep_read_set and f_abs in weights
),
key=lambda e: e["weight"],
reverse=True,
)
planned: list[str] = []
accrued_weight = 0.0
for cand in candidates:
if len(planned) >= remaining_files:
break
planned.append(cand["path"])
accrued_weight += cand["weight"]
# Group by parent directory, keeping group order by total weight desc.
from collections import OrderedDict
by_dir: "OrderedDict[str, list[str]]" = OrderedDict()
for p in planned:
d = Path(p).parent.name or "."
by_dir.setdefault(d, []).append(p)
groups: list[dict[str, Any]] = []
for d, files in by_dir.items():
g_weight = sum(weights[_abs(f)]["w"] for f in files)
for i in range(0, len(files), batch_size):
chunk = files[i : i + batch_size]
groups.append(
{
"name": d,
"weight": round(g_weight, 2),
"files": chunk,
"batch_index": i // batch_size,
}
)
return {
"status": "ok",
"target_coverage": target_coverage,
"current_coverage_pct": round(current_pct, 1),
"current_files": current_files,
"target_files": round(target_files, 2),
"remaining_files": round(remaining_files, 2),
"current_weight": round(current_weight, 2),
"target_weight": round(target_weight, 2),
"remaining_weight": round(remaining_weight, 2),
"planned_files": planned,
"planned_weight": round(accrued_weight, 2),
"groups": groups,
"estimated_batches": len(groups),
"gate": "overall",
}
def check_community_health(store: GraphStore) -> dict[str, Any]:
"""Detect community/node attribution desync (nodes.community_id NULL).
The communities table may carry a correct ``size`` while
``nodes.community_id`` is stale (e.g. after an incremental build that
rebuilt nodes but skipped community re-attribution). Returns the
non-null ratio and a ``needs_postprocess`` flag so the review skill
can trigger ``postprocess`` before computing coverage.
Args:
store: Open graph store (caller owns and closes it).
Returns:
Dict with total_nodes, attributed_nodes, attribution_pct,
needs_postprocess and note.
"""
try:
total = store._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
attributed = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE community_id IS NOT NULL"
).fetchone()[0]
non_file = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE kind != 'File'"
).fetchone()[0]
except Exception as exc: # pragma: no cover
return {
"status": "error",
"needs_postprocess": True,
"note": f"Community health check failed: {exc}",
}
ratio = (attributed / non_file * 100.0) if non_file else 0.0
# A fully healthy graph has ~100% attribution; allow a small delta for
# nodes without community membership.
needs = ratio < 90.0
return {
"status": "ok",
"total_nodes": total,
"attributed_nodes": attributed,
"non_file_nodes": non_file,
"attribution_pct": round(ratio, 1),
"needs_postprocess": needs,
"note": (
"nodes.community_id attribution ratio. Low ratio => run "
"`code-review-graph postprocess` to re-attach members."
),
}
+3 -10
View File
@@ -750,12 +750,8 @@ _SKILLS: dict[str, dict[str, str]] = {
'- ALWAYS start with `get_minimal_context(task="unified review")`. '
"Use `detail_level=\"minimal\"` on all calls; escalate to "
'"standard" only when a metric or finding needs evidence.\n\n'
"### Step 0 - Scope and tier\n"
"Read `.code-review.yaml` at the repo root (default tier "
"`standard`). Tiers: `fast` (Layer-1 + blockers only), "
"`standard` (all layers), `strict` (full + every blocker/major "
"fix needs per-item user confirmation). Single-invocation "
"overrides: `快速审查` → fast, `严格审查` → strict.\n"
"### Step 0 - Scope\n"
"Review always runs at the fixed `standard` tier (all layers).\n"
"Detect the project language/framework and the review scope "
"(change/file/service/chain level). Declare both in the report "
"header.\n\n"
@@ -778,10 +774,7 @@ _SKILLS: dict[str, dict[str, str]] = {
"### Step 3 - Layer 2: Quantitative scoring\n"
"Call `score_review_tool()` for the objective metrics (SQL risk, "
"exception coverage, redundancy, high-risk density, "
"vulnerability heuristic). The remaining metrics (requirement "
"coverage, logic alignment, trust boundaries) are judged by you "
"from the requirements doc or a generic baseline; without a "
"requirements doc halve their weight in the verdict.\n\n"
"vulnerability heuristic); these five are the full metric set.\n\n"
"### Step 4 - Specialist dispatch (gstack, diff >= 50 lines)\n"
"When the diff has 50+ changed lines, dispatch specialist "
"subagents in parallel via the Agent/task tool, each with a "
+11 -1
View File
@@ -1,6 +1,6 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 31 tools:
Exposes 33 tools:
1. build_or_update_graph - full or incremental build
2. get_impact_radius - blast radius from changed files
3. query_graph - predefined graph queries
@@ -32,6 +32,8 @@ Exposes 31 tools:
29. score_review - objective Layer-2 review metrics for changed files
30. dedupe_findings - fingerprint dedup + confidence merge for findings
31. generate_report - render the HTML and/or Markdown review report
32. coverage - risk-weighted deep-read coverage (project-review G3)
33. community_health - nodes.community_id attribution health check
"""
from __future__ import annotations
@@ -108,8 +110,12 @@ from .review import (
# -- scoring (unified-review) ------------------------------------------------
from .scoring_tools import (
community_health_func,
coverage_func,
dedupe_findings_func,
deep_read_plan_func,
generate_report_func,
save_coverage_index_func,
score_review_func,
)
@@ -157,6 +163,10 @@ __all__ = [
"score_review_func",
"dedupe_findings_func",
"generate_report_func",
"coverage_func",
"deep_read_plan_func",
"save_coverage_index_func",
"community_health_func",
# analysis_tools
"get_bridge_nodes_func",
"get_hub_nodes_func",
+415 -7
View File
@@ -12,18 +12,165 @@ build_report_data) into the three MCP tools consumed by the
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from ..incremental import get_changed_files, get_staged_and_unstaged
from ..scoring import (
build_report_data,
check_community_health,
compute_coverage,
dedupe_findings,
deep_read_plan,
render_markdown_report,
score_review,
)
from ._common import _get_store, _error_response
#: Persistent cross-round deep-read index (relative path -> per-file SHA).
COVERAGE_INDEX_REL = ".code-review-graph/coverage-index.json"
def _git_head_sha(repo_root: Path) -> str | None:
"""Current git HEAD SHA (None when not a repo / git unavailable)."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _git_file_sha(repo_root: Path, rel_path: str) -> str | None:
"""Blob SHA of the *current working-tree* content of ``rel_path``.
Uses ``git hash-object`` (content hash, does not touch the index) so an
uncommitted edit still counts as stale. Returns None when the file does
not exist in the working tree or git is unavailable.
"""
try:
result = subprocess.run(
["git", "hash-object", rel_path],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _coverage_index_path(repo_root: Path) -> Path:
return repo_root / COVERAGE_INDEX_REL
def _load_coverage_index(repo_root: Path) -> list[str]:
"""Return the relative paths whose per-file SHA still matches HEAD.
Files that changed since the last review are automatically excluded
(stale), so an incremental round never masks new code with old findings.
SHA check uses the graph's ``nodes.file_hash`` when the graph is
current, else falls back to a single batched ``git hash-object``
invocation (never one subprocess per file, see #46/#136).
"""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return []
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
entries = payload.get("entries") or {}
if not entries:
return []
return [
rel
for rel, info in entries.items()
if _index_entry_current(repo_root, rel, info)
]
def _load_coverage_index_full(
repo_root: Path,
) -> tuple[list[str], dict[str, list[list[int]]]]:
"""Load (current rel paths, their persisted read ranges)."""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return [], {}
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return [], {}
entries = payload.get("entries") or {}
if not entries:
return [], {}
current, ranges = [], {}
for rel, info in entries.items():
if _index_entry_current(repo_root, rel, info):
current.append(rel)
if info.get("ranges"):
ranges[rel] = info["ranges"]
return current, ranges
def _index_entry_current(repo_root: Path, rel: str, info: dict) -> bool:
"""True when a persisted entry's SHA matches the current file content."""
if not (repo_root / rel).is_file():
return False
expected = info.get("sha")
if not expected:
return False
# Prefer graph file_hash (single SQL fetch, no subprocess). Fall back to
# a batched git hash-object for files not in the graph.
graph_hash = _graph_file_hashes(repo_root).get(rel)
if graph_hash:
return graph_hash == expected
return _git_file_sha(repo_root, rel) == expected
_GRAPH_HASH_CACHE: dict[str, dict[str, str]] = {}
def _graph_file_hashes(repo_root: Path) -> dict[str, str]:
"""All File-node content hashes for a repo, cached (one SQL fetch)."""
key = str(repo_root)
if key in _GRAPH_HASH_CACHE:
return _GRAPH_HASH_CACHE[key]
result: dict[str, str] = {}
try:
store, root = _get_store(str(repo_root))
try:
rows = store._conn.execute(
"SELECT file_path, file_hash FROM nodes WHERE kind='File'"
).fetchall()
for r in rows:
fp = str(r["file_path"])
rel = fp.replace("\\", "/")
if rel.startswith(str(root).replace("\\", "/") + "/"):
rel = rel[len(str(root).replace("\\", "/")) + 1:].replace("/", "\\")
rel = rel.replace("\\", "/")
result[rel] = r["file_hash"] or ""
finally:
store.close()
except Exception:
result = {}
_GRAPH_HASH_CACHE[key] = result
return result
try:
from importlib.resources import files as _pkg_files # Python 3.9+
@@ -44,13 +191,14 @@ def score_review_func(
repo_root: str | None = None,
detail_level: str = "standard",
all_files: bool = False,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability). LLM-judged metrics are listed in ``llm_judged`` so the
calling agent knows what still needs judgement.
vulnerability). These five objective metrics form the full report
metric set; ``llm_judged`` is returned empty for compatibility.
Args:
changed_files: Files to score (auto-detected from git diff if
@@ -63,6 +211,8 @@ def score_review_func(
all_files: When True, score every source file in the graph,
ignoring ``changed_files`` and the git diff. Used for
whole-project reviews (default: False).
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
"""
store, root = _get_store(repo_root)
try:
@@ -85,6 +235,7 @@ def score_review_func(
root,
changed_files,
include_churn=include_churn,
progress_cb=progress_cb,
)
if detail_level == "minimal":
@@ -154,6 +305,238 @@ def dedupe_findings_func(
return _error_response(str(exc))
# ---------------------------------------------------------------------------
# Tool: coverage
# ---------------------------------------------------------------------------
def coverage_func(
deep_read_files: list[str],
all_files: bool = True,
include_churn: bool = True,
gate: str = "high_risk",
include_prior: bool = False,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
repo_root: str | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / number of all source files.
The high-risk coverage uses only the signal-flagged subset (also
file-count based). Per-file risk weights still rank the priority
deep-read list so the highest-risk files are read first. This backs
the project-review Step 7.5 coverage self-check (G3).
Args:
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths). Required.
all_files: When True, the denominator is every source file in the
graph (default: True, whole-project semantics).
include_churn: Include git-churn as the w3 weight term.
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
``"both"`` (both overall and high-risk must meet the target) /
``"both+line"`` (both + per-file line coverage >= line_target
and unit-completeness gap-free) / ``"line+unit"`` (line + unit
only, feature reviews; file-count / high-risk coverage are
skipped and returned as ``None``).
include_prior: When True, merge the cross-round coverage index
(files whose SHA is unchanged since the last review) into the
deep-read set.
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
sub-agent actually read per deep-read file (for gate="both+line").
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
reported semantic units per deep-read file (gate="both+line").
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with coverage_pct, grade, deep_read_count, total_files,
target_reached, target, remaining_files_to_target,
priority_deep_read_files, uncovered_files, silent_files and note.
``silent_files`` feeds the G2 spot-check sampling. For
``gate="both+line"`` / ``"line+unit"`` also returns
line_coverage_pct, unit_coverage_pct, line_gap_files,
unit_gap_files, unit_exempt_files.
"""
store, root = _get_store(repo_root)
try:
deep_read = list(deep_read_files or [])
ranges = dict(file_read_ranges or {})
units = dict(file_semantic_units or {})
if include_prior:
prior, prior_ranges = _load_coverage_index_full(root)
deep_read += prior
for rel, rr in prior_ranges.items():
ranges.setdefault(rel, rr)
return compute_coverage(
store,
root,
deep_read_files=deep_read,
include_churn=include_churn,
gate=gate,
file_read_ranges=ranges,
file_semantic_units=units,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def deep_read_plan_func(
repo_root: str | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
include_prior: bool = False,
deep_read_files: list[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects the highest-risk files not yet deep-read until the
file-count overall coverage target is reached, then groups them by
parent directory (≤ ``batch_size`` per group) so each group can be
dispatched to one parallel sub-agent.
Args:
repo_root: Repository root. Auto-detected if omitted.
target_coverage: Overall coverage target percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
include_prior: Exclude files already covered by the cross-round
coverage index (SHA unchanged) from the plan.
deep_read_files: Files already deep-read this round.
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with current_coverage_pct, current/target/remaining file
counts (plus compatible weight counts), planned_files
(priority-ordered), directory groups and estimated_batches.
"""
store, root = _get_store(repo_root)
try:
prior = set(_load_coverage_index(root)) if include_prior else None
return deep_read_plan(
store,
root,
deep_read_files=deep_read_files,
target_coverage=target_coverage,
batch_size=batch_size,
include_churn=include_churn,
prior_covered=prior,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def save_coverage_index_func(
deep_read_files: list[str],
repo_root: str | None = None,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
) -> dict[str, Any]:
"""Persist the deep-read file list to the cross-round coverage index.
Records each file's per-file SHA at HEAD so a later review can tell
which previously-deep-read files are still current (SHA unchanged)
and which are stale and must be re-read.
Version 2: SHA comes from the graph's ``nodes.file_hash`` (one SQL
query, zero subprocesses) instead of one ``git hash-object`` per file.
Line ranges (``file_read_ranges``) are persisted so a later review can
reuse the line coverage of SHA-unchanged files.
Args:
deep_read_files: Files deep-read this round (relative or absolute).
repo_root: Repository root. Auto-detected if omitted.
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
persist per file (index v2, reused by include_prior).
file_semantic_units: Optional per-file semantic units; persisted
for informational purposes (unit completeness is recomputed
against the live graph each round, so this is advisory only).
Returns:
Dict with index_path, entry count and head_sha.
"""
store, root = _get_store(repo_root)
store.close()
file_hashes = _graph_file_hashes(root)
def _hash_for(rel: str) -> str | None:
return file_hashes.get(rel) or _git_file_sha(root, rel)
entries: dict[str, dict[str, Any]] = {}
for f in deep_read_files or []:
p = Path(f)
rel = str(p.relative_to(root)) if p.is_absolute() else str(p)
rel = rel.replace("\\", "/")
entry: dict[str, Any] = {"sha": _hash_for(rel) or _git_file_sha(root, rel)}
ranges = (file_read_ranges or {}).get(rel)
if ranges:
entry["ranges"] = ranges
units = (file_semantic_units or {}).get(rel)
if units:
entry["units"] = units
entries[rel] = entry
payload = {
"version": 2,
"last_updated": datetime.now().isoformat(timespec="seconds"),
"head_sha": _git_head_sha(root),
"entries": entries,
}
index_path = _coverage_index_path(root)
try:
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
return _error_response(f"Failed to write coverage index: {exc}")
return {
"status": "ok",
"index_path": str(index_path),
"entries": len(entries),
"head_sha": payload["head_sha"],
}
def community_health_func(
repo_root: str | None = None,
) -> dict[str, Any]:
"""Check node->community attribution health (nodes.community_id).
Detects the desync where ``communities.size`` is correct but
``nodes.community_id`` is mostly NULL (e.g. after an incremental
rebuild). Returns ``needs_postprocess``; the review skill should run
``code-review-graph postprocess`` when True before computing coverage.
Args:
repo_root: Repository root. Auto-detected if omitted.
Returns:
Dict with total_nodes, attributed_nodes, non_file_nodes,
attribution_pct, needs_postprocess and note.
"""
store, root = _get_store(repo_root)
try:
return check_community_health(store)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: generate_report
# ---------------------------------------------------------------------------
@@ -206,8 +589,30 @@ const el = document.getElementById("report");
let html = `<h1>Code Review Report</h1>
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
&middot; Tier: ${data.tier || "standard"} &middot; Scope: ${data.scope || "change-level"}</p>`;
if (data.files) html += `<p><b>Files:</b> ${data.files}</p>`;
if (data.files || (data.reviewed_files && data.reviewed_files.length)) {
if (Array.isArray(data.reviewed_files) && data.reviewed_files.length) {
html += `<details><summary><b>Files (${data.reviewed_files.length})</b></summary><ul>`
+ data.reviewed_files.map(f => `<li><code>${f}</code></li>`).join("")
+ `</ul></details>`;
} else {
html += `<p><b>Files:</b> ${data.files}</p>`;
}
}
if (data.summary) html += `<p>${data.summary}</p>`;
const cov = data.coverage || {};
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const covOk = cov.target_reached;
let c = `<p><b>Coverage:</b> ${covOk ? "target reached" : "coverage insufficient"}`;
if (cov.coverage_pct != null) {
c += ` &middot; overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
+ ` &middot; high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
}
if (cov.line_coverage_pct != null) {
c += ` &middot; line ${cov.line_coverage_pct}% &middot; unit ${cov.unit_coverage_pct}%`;
}
c += `</p>`;
html += c;
}
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th></tr>`;
for (const [k, m] of Object.entries(data.metrics || {})) {
html += `<tr><td>${k}</td><td>${m.value ?? "N/A"}</td>
@@ -275,9 +680,12 @@ def generate_report_func(
written: list[dict[str, Any]] = []
if format in ("html", "both"):
template = _load_report_template()
rendered = template.replace(
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
)
# Escape "<" as "\u003c" so finding text can never close the
# surrounding <script> tag (e.g. a literal "</script>" in a
# message/fix). JSON keeps it a valid escape; the JS template
# string re-parses it as "<" before esc() HTML-escapes it.
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
rendered = template.replace("{{REPORT_DATA}}", payload)
html_path = base.with_suffix(".html")
html_path.parent.mkdir(parents=True, exist_ok=True)
html_path.write_text(rendered, encoding="utf-8")
@@ -0,0 +1,39 @@
---
description: Build or rebuild the code review knowledge graph for this project.
agent: build
---
# Build Graph
Build or incrementally update the persistent code knowledge graph for this repository.
$ARGUMENTS
## Steps
1. **Check graph status** by calling the `list_graph_stats_tool` MCP tool.
- If the graph has never been built (last_updated is null), proceed with a full build.
- If the graph exists, proceed with an incremental update.
2. **Build the graph** by calling the `build_or_update_graph_tool` MCP tool:
- For first-time setup: `build_or_update_graph_tool(full_rebuild=True)`
- For updates: `build_or_update_graph_tool()` (incremental by default)
3. **Verify** by calling `list_graph_stats_tool` again and report the results:
- Number of files parsed
- Number of nodes and edges created
- Languages detected
- Any errors encountered
## When to Use
- First time setting up the graph for a repository
- After major refactoring or branch switches
- If the graph seems stale or out of sync
- The graph auto-updates via hooks on edit/commit, so manual builds are rarely needed
## Notes
- The graph is stored as a SQLite database (`.code-review-graph/graph.db`) in the repo root
- Binary files, generated files, and patterns in `.code-review-graphignore` are skipped
- If the graph tools are unavailable, fall back to `code-review-graph build` / `code-review-graph status` via the shell
@@ -0,0 +1,42 @@
---
description: Whole-project or single-feature code review (not diff-based) using graph-wide analysis and objective scoring.
agent: build
---
# Project Review
Review the entire codebase or a single feature/module, independent of the git diff. The scope is driven by your instruction.
$ARGUMENTS
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="project-review")` for the optimized workflow.
## Steps
1. **Parse the scope** from the user instruction:
- "对项目代码进行全面审查" / "全面审查" / "整个项目" → `scope=whole-project`
- "审查 <功能/模块> 的代码" (e.g. payment, auth) → `scope=feature`, target=<keyword>
2. **Ensure the graph is current** by calling `build_or_update_graph_tool()`.
3. **Map the architecture** by calling `get_architecture_overview_tool(detail_level="minimal")` and `list_communities_tool(detail_level="minimal")`.
4. **Scan high-risk areas** (whole-project): `get_knowledge_gaps_tool()`, `get_hub_nodes_tool()`, `get_bridge_nodes_tool()`, `find_large_functions_tool()`, `get_surprising_connections_tool()`.
5. **Score objectively**:
- whole-project: `score_review_tool(all_files=True)` — every source file in the graph
- feature: `semantic_search_nodes_tool(query=<target>)` + `query_graph_tool(pattern="children_of", target=<target>)` to locate files, then `score_review_tool(changed_files=<files>)` + `get_impact_radius_tool(changed_files=<files>)`
6. **Review the code** (Layer 1 chain decomposition): eight categories + gstack CRITICAL sub-pass. Produce findings with severity (blocker/major/minor), confidence (1-10), file:line, and proposed fix.
7. **Merge findings** by calling `dedupe_findings_tool(findings=<your findings>)`.
8. **Generate the report** by calling `generate_report_tool(review_data=<verdict, scope, metrics, merged findings>)` — writes `code-review-report.html` and `code-review-report.md` (default `format="both"`).
9. **Report** the verdict (✅ PASS / ❌ FAIL), severity counts, each issue with confidence + fix, and manual-review items.
## Important Rules
- **READ-ONLY.** This workflow never modifies code, commits, or pushes. Every finding waits for a manual fix decision.
- **Any blocker → verdict ❌ FAIL**, regardless of other scores.
- This is **not** a diff review. For diff-based review use `/code-review-graph-unified-review`.
@@ -0,0 +1,47 @@
---
description: Review only changes since last commit using impact analysis and blast-radius detection.
agent: build
---
# Review Delta
Perform a focused, token-efficient code review of only the changed code and its blast radius.
$ARGUMENTS
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="review-delta")` for the optimized workflow. Use ONLY changed nodes + 2-hop neighbors in context.
## Steps
1. **Ensure the graph is current** by calling `build_or_update_graph_tool()` (incremental update).
2. **Get review context** by calling `get_review_context_tool()`. This returns:
- Changed files (auto-detected from git diff)
- Impacted nodes and files (blast radius)
- Source code snippets for changed areas
- Review guidance (test coverage gaps, wide impact warnings, inheritance concerns)
3. **Analyze the blast radius** by reviewing the `impacted_nodes` and `impacted_files` in the context. Focus on:
- Functions whose callers changed (may need signature/behavior verification)
- Classes with inheritance changes (Liskov substitution concerns)
- Files with many dependents (high-risk changes)
4. **Perform the review** using the context. For each changed file:
- Review the source snippet for correctness, style, and potential bugs
- Check if impacted callers/dependents need updates
- Verify test coverage using `query_graph_tool(pattern="tests_for", target=<function_name>)`
- Flag any untested changed functions
5. **Report findings** in a structured format:
- **Summary**: One-line overview of the changes
- **Risk level**: Low / Medium / High (based on blast radius)
- **Issues found**: Bugs, style issues, missing tests
- **Blast radius**: List of impacted files/functions
- **Recommendations**: Actionable suggestions
## Advantages Over Full-Repo Review
- Only sends changed + impacted code to the model (5-10x fewer tokens)
- Automatically identifies blast radius without manual file searching
- Provides structural context (who calls what, inheritance chains)
- Flags untested functions automatically
@@ -0,0 +1,67 @@
---
description: Review a PR or branch diff using the knowledge graph for full structural context with blast-radius analysis.
agent: build
---
# Review PR
Perform a comprehensive code review of a pull request or branch diff using the knowledge graph.
$ARGUMENTS
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="review-pr")` for the optimized workflow. Never include full files unless explicitly asked.
## Steps
1. **Identify the changes** for the PR:
- If a PR number or branch is provided in $ARGUMENTS, use `git diff main...<branch>` to get changed files
- Otherwise auto-detect from the current branch vs main/master
2. **Update the graph** by calling `build_or_update_graph_tool(base="main")` to ensure the graph reflects the current state.
3. **Get the full review context** by calling `get_review_context_tool(base="main")`:
- This uses `main` (or the specified base branch) as the diff base
- Returns all changed files across all commits in the PR
4. **Analyze impact** by calling `get_impact_radius_tool(base="main")`:
- Review the blast radius across the entire PR
- Identify high-risk areas (widely depended-upon code)
5. **Deep-dive each changed file**:
- Read the full source of files with significant changes
- Use `query_graph_tool(pattern="callers_of", target=<func>)` for high-risk functions
- Use `query_graph_tool(pattern="tests_for", target=<func>)` to verify test coverage
- Check for breaking changes in public APIs
6. **Generate structured review output**:
```
## PR Review: <title>
### Summary
<1-3 sentence overview>
### Risk Assessment
- **Overall risk**: Low / Medium / High
- **Blast radius**: X files, Y functions impacted
- **Test coverage**: N changed functions covered / M total
### File-by-File Review
#### <file_path>
- Changes: <description>
- Impact: <who depends on this>
- Issues: <bugs, style, concerns>
### Missing Tests
- <function_name> in <file> - no test coverage found
### Recommendations
1. <actionable suggestion>
2. <actionable suggestion>
```
## Tips
- For large PRs, focus on the highest-impact files first (most dependents)
- Use `semantic_search_nodes_tool` to find related code the PR might have missed
- Check if renamed/moved functions have updated all callers
@@ -0,0 +1,43 @@
---
description: Run the three-layer unified review (CRG graph context + score_review + dedupe_findings + generate_report).
agent: build
---
# Unified Review
Run the three-layer unified code review using the MCP prompt workflow.
$ARGUMENTS
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="unified-review")` for the optimized workflow.
## Steps
1. **Load the workflow** by calling the `unified_review` MCP prompt (or the `unified-review` skill). This drives the full READ-ONLY review pipeline.
2. **Ensure the graph is current** by calling `build_or_update_graph_tool()`.
3. **Get the review context** by calling `get_review_context_tool()` — changed files, blast radius, source snippets.
4. **Detect changes** by calling `detect_changes_tool()` — risk score, changed functions, test gaps, affected flows.
5. **Score objectively** by calling `score_review_tool()` — SQL risk, exception coverage, redundancy, high-risk density, vulnerability heuristic (good/warn/fail grades). LLM-judged metrics are in `llm_judged`.
6. **Review the changed code** (Layer 1 chain decomposition): interface, business, data, utility, error handling, security, performance, observability. Produce findings with severity (blocker/major/minor), confidence (1-10), file:line, and proposed fix.
7. **Merge findings** by calling `dedupe_findings_tool(findings=<your findings>)` — fingerprint dedup, multi-source confidence boost, PR quality score.
8. **Generate the report** by calling `generate_report_tool(review_data=<verdict, tier, scope, metrics, merged findings>)` — writes `code-review-report.html` and `code-review-report.md` (default `format="both"`).
9. **Report** the verdict (✅ PASS / ❌ FAIL), severity counts, each issue with confidence + fix, and manual-review items.
## Important Rules
- **READ-ONLY.** This workflow never modifies code, commits, or pushes. Every finding waits for a manual fix decision.
- **Any blocker → verdict ❌ FAIL**, regardless of other scores.
- Tier (fast / standard / strict) comes from `.code-review.yaml` at the repo root, or the `tier` argument.
## Tips
- For large diffs (50+ lines), dispatch specialist subagents (testing, maintainability, security, performance, data-migration, api-contract) in parallel before dedupe.
- Security and data-migration are insurance specialists — always run even when silent.
+40
View File
@@ -0,0 +1,40 @@
---
description: 用自然语言为 code-checker 新增一条规则(语义/声明式/Python 兜底)并注册生效
agent: build
---
# rulegen
为 code-checker 新增一条代码规则。先加载 rulegen skill 并按其流程执行(若可用)。
用户要新增的规则描述:
$ARGUMENTS
## 前置
- 确认当前工作目录是 code-checker 项目(含 `tools/rulegen.py``docs/rulegen/prompt-template.md`)。若不是,先切换到该项目根目录或使用其绝对路径执行。
- Windows 下若无 `python` 命令,使用 `py`
## 执行步骤
1. 读取提示词模板 `docs/rulegen/prompt-template.md`,按其 schema 与全部约定执行。
2. 判断规则类型(三选一):
- **semantic(语义)**:需理解代码意图(如 catch 块日志、XSS 转义)→ 只生成 `rule.yaml`,必含 `check_prompt` + `message_zh`
- **declarative(声明式机械)**:行级正则可表达 → `rule.yaml``pattern`(**必须用 YAML 单引号**,如 `pattern: 'Thread\\.sleep\\s*\\('`
- **python(机械 Python 兜底)**:需跨行/AST 逻辑,且是 Java(JSP 不支持)→ `rule.yaml` + `checker.py`(继承 `BaseChecker``CATEGORIES` 用新类别)
3.`rules/_staging/<rule-slug>/` 下生成 `rule.yaml`(必要时含 `checker.py``sample.<java|jsp>``negative.<java|jsp>`)。
4. 依次执行(在项目根目录;退出码 0=OK / 1=失败,失败先修正再重试,**不要**强制跳过):
```
python tools/rulegen.py validate rules/_staging/<rule-slug>
python tools/rulegen.py smoke rules/_staging/<rule-slug> # 语义规则跳过
python tools/rulegen.py register rules/_staging/<rule-slug> --dry-run
python tools/rulegen.py register rules/_staging/<rule-slug>
```
5. 注册完成后向用户报告:规则 ID 与类型、落盘文件(`rules/custom/<lang>-rules.yaml`python 类另含 `check-engine/custom_checkers/`)、校验/冒烟摘要。
## 纪律
- **不修改** `rules/java-rules.yaml` / `rules/jsp-rules.yaml` / `rules/messages.yaml` 等交付文件;只写 `rules/custom/` 与 `check-engine/custom_checkers/`。
- ID 编号写 `-001` 即可,`register` 会自动重编号。
- 生成后不做 git 提交。
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"superpowers@git+https://github.com/obra/superpowers.git",
"D:/software/Clawd on Desk/resources/app.asar.unpacked/hooks/opencode-plugin"
],
"mcp": {
"playwright": {
"type": "local",
"command": [
"npx",
"-y",
"@playwright/mcp"
],
"enabled": true
},
"code-review-graph": {
"type": "local",
"command": [
"D:\\code-review-graph\\code-review-graph-main\\.venv\\Scripts\\code-review-graph.exe",
"serve",
"--repo",
"D:\\AuraSpace"
],
"timeout": 600000,
"env": {
"CRG_REPO_ROOT": "D:\\AuraSpace"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
---
name: build-graph
description: Build or update the code review knowledge graph. Run this first to initialize, or let hooks keep it updated automatically.
argument-hint: "[full]"
---
# Build Graph
Build or incrementally update the persistent code knowledge graph for this repository.
## Steps
1. **Check graph status** by calling the `list_graph_stats_tool` MCP tool.
- If the graph has never been built (last_updated is null), proceed with a full build.
- If the graph exists, proceed with an incremental update.
2. **Build the graph** by calling the `build_or_update_graph_tool` MCP tool:
- For first-time setup: `build_or_update_graph_tool(full_rebuild=True)`
- For updates: `build_or_update_graph_tool()` (incremental by default)
3. **Verify** by calling `list_graph_stats_tool` again and report the results:
- Number of files parsed
- Number of nodes and edges created
- Languages detected
- Any errors encountered
## When to Use
- First time setting up the graph for a repository
- After major refactoring or branch switches
- If the graph seems stale or out of sync
- The graph auto-updates via hooks on edit/commit, so manual builds are rarely needed
## Notes
- The graph is stored as a SQLite database (`.code-review-graph/graph.db`) in the repo root
- Binary files, generated files, and patterns in `.code-review-graphignore` are skipped
- Supported languages: Python, TypeScript/JavaScript, Vue, Go, Rust, Java, Scala, C#, Ruby, Kotlin, Swift, PHP, Solidity, C/C++
+27
View File
@@ -0,0 +1,27 @@
---
name: debug-issue
description: Systematically debug issues using graph-powered code navigation
---
## Debug Issue
Use the knowledge graph to systematically trace and debug issues.
### Steps
1. Use `semantic_search_nodes_tool` to find code related to the issue.
2. Use `query_graph_tool` with `callers_of` and `callees_of` to trace call chains.
3. Use `get_flow` to see full execution paths through suspected areas.
4. Run `detect_changes_tool` to check if recent changes caused the issue.
5. Use `get_impact_radius_tool` on suspected files to see what else is affected.
### Tips
- Check both callers and callees to understand the full context.
- Look at affected flows to find the entry point that triggers the bug.
- Recent changes are the most common source of new issues.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
@@ -0,0 +1,28 @@
---
name: explore-codebase
description: Navigate and understand codebase structure using the knowledge graph
---
## Explore Codebase
Use the code-review-graph MCP tools to explore and understand the codebase.
### Steps
1. Run `list_graph_stats` to see overall codebase metrics.
2. Run `get_architecture_overview_tool` for high-level community structure.
3. Use `list_communities_tool` to find major modules, then `get_community` for details.
4. Use `semantic_search_nodes_tool` to find specific functions or classes.
5. Use `query_graph_tool` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
6. Use `list_flows` and `get_flow` to understand execution paths.
### Tips
- Start broad (stats, architecture) then narrow down to specific areas.
- Use `children_of` on a file to see all its functions and classes.
- Use `find_large_functions` to identify complex code.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
@@ -0,0 +1,273 @@
---
name: project-review
description: Whole-project or single-feature code review (not diff-based) using graph-wide analysis and objective scoring
---
# Project Review
Review the entire codebase or a single feature/module, independent of the git diff. Two scopes, driven by the user's instruction:
- **whole-project**: "对项目代码进行全面审查", "全面审查", "整个项目" → review every source file in the graph.
- **feature**: "审查 <功能/模块> 的代码" (e.g. payment, auth) → review only the code related to the target.
**This skill is READ-ONLY.** Every finding is presented to the user for a manual fix decision. Never apply code changes, commit, or push.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="project review")`. Use `detail_level="minimal"` on all calls; escalate to `"standard"` only when a metric or finding needs evidence.
- **Whole-project deep-read is a sub-agent pipeline, not a main-context loop.** 主上下文不逐文件深读 500+ 文件;改为 Step 5.5 并行子代理分组深读。主上下文预算 ≤14 calls + N 子代理(见 Step 5.5)。
## Step 0 - Parse the scope
Read the user's instruction and set scope: whole-project (contains 全面/整个项目/所有/all) or feature + target (extract the feature/module keyword). Declare both in the report header.
> **快速参考**:审查 agent 的精简流程指引来自 `get_docs_section(section="project-review")`(引擎 `docs/LLM-OPTIMIZED-REFERENCE.md`),该文档已同步 review_data schema`reviewed_files` 数组、coverage 全字段透传含三件套字段、metrics 带 note、仅五客观指标)。完整 schema 以本 SKILL.md 与 `references/report-schema.md` 为准。
## Step 1 - Graph ready
1. Call `build_or_update_graph_tool()` to ensure the graph is current.
2. Call `get_minimal_context_tool(task="project review")` for stats and community overview.
## Step 2 - Architecture map
Call `get_architecture_overview_tool(detail_level="minimal")` and `list_communities_tool(detail_level="minimal")` to map the module structure.
## Step 3 - High-risk scan (whole-project)
Call `get_knowledge_gaps_tool()`, `get_hub_nodes_tool()`, `get_bridge_nodes_tool()`, `find_large_functions_tool()` and `get_surprising_connections_tool()` to locate hotspots, chokepoints, untested areas and odd coupling.
## Step 4 - Objective scoring
- whole-project: `score_review_tool(all_files=True)` scores every source file in the graph.
- feature: locate the target files with `semantic_search_nodes_tool(query=<target>)` and `query_graph_tool(pattern="children_of", target=<target>)`, then `score_review_tool(changed_files=<files>)` and `get_impact_radius_tool(changed_files=<files>)` for the blast radius.
- **指标集约束**:报告的 `metrics` 只透传 `score_review_tool` 返回的五个客观指标(`sql_risk` / `exception_coverage` / `redundancy_rate` / `high_risk_density` / `vulnerability_risk`)。**禁止**手工追加 `requirement_coverage` / `logic_alignment` / `llm_trust_boundary` / `shell_injection` / `enum_completeness` 等 LLM 判定指标;`review_data.llm_judged` 一律传空 `[]`
## Step 5 - Chain decomposition
Inspect the scored code across eight categories (interface, business, data, utility, error handling, security, performance, observability) and apply the gstack CRITICAL sub-pass (SQL & Data Safety, Race Conditions, LLM Output Trust Boundary, Shell Injection, Enum Completeness). Mark each ✅ / ⚠️ / —.
## Step 5.5 - 并行深读流水线(whole-project 强制,覆盖率达标的唯一可行路径)
> 引擎 v2.5.0 新增 `deep_read_plan_tool` / `save_coverage_index_tool``coverage_tool` 增加 `gate` / `include_prior`。主上下文无法逐文件深读 500+ 文件,必须用并行子代理分组深读,否则全库覆盖永远卡在 2-5%。覆盖率为**文件数口径**:已深读文件数 / 总文件数。
**目标**:全库覆盖 ≥85% **且** 高风险覆盖 ≥95%(双目标 `gate="both"`standard 档位)**且**每个深读文件通过三件套质量门禁(①单元完整性=100% ②行覆盖≥95% ③防伪抽验≤15次/轮,见 references/deep-read-pipeline.md §〇)。深读计划按风险权重贪心,85% 的全库计划会先选满全部高风险文件,天然同时逼近高风险 95%。
**流程**
```
a. 调 deep_read_plan_tool(gate="overall", target_coverage=85, include_prior=True)
→ 返回分组清单 groups[{name, weight, files[]}] + remaining_files
include_prior=True 自动排除上一轮已深读且未变更的文件,实现增量)
b. 按组并行派发 explore 子代理(每次 4-6 个并行,分多批)。
每个子代理深读该组**全部**文件,返回(**落盘到临时目录,勿回传大 JSON**):
- outputs[]:每文件含 path / total_lines / read_ranges[[s,e]..] /
semantic_units[{range,kind,name,note}] / findings[]
- findings[]:每条含 severity/category/confidence/file:line/message/fix
**无 file:line 证据的条目视为未深读**(该文件须重读)
prompt 模板见 references/deep-read-pipeline.md
⚠️ **禁止用 `DEEPREAD_CONFIRM: <路径|总行数|已读范围|单元数>` 单行摘要替代结构化 JSON**:
主代理必须拿到每文件的 `read_ranges`(分段数组)与 `semantic_units`(逐单元
{range,kind,name})并落盘,否则 coverage_tool 无法做行/单元门禁,报告会
"无行覆盖" 且引擎 fail-closed 会全部判缺口。子代理 prompt 必须直接复制
deep-read-pipeline.md §二 的模板,不得自创简化格式。
c. 每波子代理完成后跑三件套门禁(**B 阶段已落地,引擎原生支持**):
- 主代理首选:coverage_tool(deep_read_files=<verified_files>, gate="both+line",
file_read_ranges=<{rel:[[s,e]..]}>, file_semantic_units=<{rel:[{range,kind,name}]}>)
→ 引擎返回 line_coverage_pct / unit_coverage_pct / line_gap_files / unit_gap_files / unit_exempt_files
- 引擎不可用时的兜底:python skills/project-review/scripts/aggregate_deep_read.py <repo_root> <落盘目录>
- line_gap unit_gap → 补读队列;verified → 计入本轮 deep_read_files
d. 防伪抽验(**强制,每波必做,三件套③**):
- 每波子代理完成后立即执行,与 c 门禁同步;**每组抽 2 个文件、每文件抽 2-3 个语义单元**回读比对 note(每波 ≤40 次 read,全审查约 50-72 次分摊到各波)
- 回读:用 read 打开源文件对应 range,比对子代理上报的 semantic_units.note 是否与实际内容相符
- 结果落盘 `spot_check_<batch>.json`schemagroups_sampled / files_sampled / units_sampled / fake_read_found / groups_rereread / samples[{group,file,unit,range,note_match,in_read_ranges}]
- 抽到假读 → 该组重读并升级抽验率(同组改抽 2 文件×3 单元)
- ⚠️ 各波 spot_check 必须聚合后在 Step 8 注入 `review_data.spot_check`(顶层字段),否则报告渲染"未执行 🔴"且 verify-spot-check.ps1 exit 1
e. 主代理汇总全部 verified_files(去重)→
coverage_tool(deep_read_files=<verified_files>, gate="both+line", include_prior=True) 复算
include_prior 自动复用跨轮索引中 SHA 未变的文件及其 read_ranges
f. 未达标(文件数 <85%/<95% 或 行覆盖 <95% 或 单元有缺口)→
对 coverage 返回的 priority_deep_read_files / line_gap_files / unit_gap_files 补一轮 → 循环直至全达标
⚠️ **禁止降级**unit_gap_files 或 line_gap_files 非空时,**不得**改回 `gate="both"` 静默跳过三件套;
必须补轮重读至空,或显式在报告中标注"三件套未达标 🔴"并列出缺口文件。
g. G2:对 silent_files 抽 15% 深读(见 Step 7.5
h. 报告生成后:save_coverage_index_tool(deep_read_files=<verified_files>, file_read_ranges=<{rel:[[s,e]..]}>)
→ 写 .code-review-graph/coverage-index.jsonv2file + per-file SHA + rangesSHA 来自 nodes.file_hash
```
**约束**
- 子代理数量:whole-project 508 文件 ≈ 14-16 组(后端 6 组 / 前端 8 组,见 references/deep-read-pipeline.md 分组表)。
- 质量控制(三件套,必须全部通过才算深读):
- ① 单元完整性:`semantic_units` 与图谱单元差集 = 空(one-to-one 匹配,宽 range 不能冒充;**严格模式**Props interface / Type / 小函数也必须逐一列出)。巨型文件(**仅当**最大单元行占比>80%)豁免,按行覆盖校验。
- ② 行覆盖:`union(read_ranges)` / 真实行数 ≥95%(分母为真实文件行数,非图节点 line_end——后者有 ±1 偏差)。
- ③ 防伪抽验:主代理**每波必做**,每组抽 2 文件 × 2-3 单元,每波 ≤40 次 read;结果落盘 spot_check 并注入报告。引擎无法防假读,抽样是唯一手段。
- 每条 finding 必须带 `file:line` 证据。
- 若引擎工具 `deep_read_plan_tool` 不可用(旧版本),退化为:先 `coverage_tool``priority_deep_read_files` 手动分组派发。
- 增量语义:`include_prior=True` 时,变更文件(SHA 不同)自动失效需重读,防止"上轮读过"掩盖新代码。
> 🔒 **诚实声明(三件套③的防伪边界)**:防伪抽验的**真实性**(主代理是否真读了文件)引擎与脚本**均无法验证**——`verify-spot-check.ps1` 只能验证**声明完整性**(抽了、单元数>0、range 落在 read_ranges 内)。真实回读依赖主代理行为准则。此为引擎防伪能力的已知边界(deep-read-pipeline.md §〇)。
## Step 6 - Manual adjudication (READ-ONLY)
Present every finding with severity (🔴 blocker / 🟡 major / 🔵 minor), confidence (1-10), file:line and a proposed fix. Group by severity and ask the user per batch: fix / skip / self-fix. 🔴 blockers cannot be batch-skipped. **Do not modify code.**
## Step 7 - Acceptance gate
Any 🔴 blocker → verdict `❌ FAIL`. Classify each finding as Ready / Needs Fix / Unusable.
## Step 7.5 - 覆盖度自检(必须执行,防"审完了"由感觉决定)
`CODE_REVIEW_GUIDE_ZH.md` §3.5 的覆盖度保障机制,报告前完成三件事:
1. **深读名单完整性(G1**:确认名单外文件是"被评估过"而非"被忽略";未被任何信号点名的文件记入 **"未深读文件清单"**,在报告中显式列出。
2. **静默抽检(G2**:调用 `coverage_tool` 取返回的 `silent_files`(未被任何信号点名的文件),随机抽 **15%** 深读;发现 ≥1 major → 该文件升级全量深读,并同社区/同类追加抽检一轮。抽检记录附入报告(抽了几份 / 几个 major / 有无升级)。
3. **三件套质量门禁复核(G1.5**:报告前复核 Step 5.5 的三件套聚合结果——`line_gap_files unit_gap_files` 必须为空(已补读至空);`verified_files``coverage_tool``deep_read_files` 一致;防伪抽验记录(抽了几组/几个单元/有无假读)附入报告。
4. **覆盖度计算(G3**
- **whole-project**:调用 `coverage_tool(deep_read_files=<本轮实际深读文件>, gate="both+line")`,引擎自动计算**三重口径**(文件数口径 + 三件套质量口径):
- **全库覆盖** = `coverage_pct`(已深读文件数 / 全部源文件数)
- **高风险覆盖** = `high_risk_coverage_pct`(已深读高风险文件数 / 信号点名文件数)
- **双口径门禁(G3**`gate="both+line"``target_reached` 要求**全库 ≥85% 且 高风险 ≥95% 且 行覆盖 ≥95% 且 单元完整性无缺口**(标准档位,三件套 AND)。`gate="both"` 保持原语义(仅前两项)。`target_reached=false` → 报告顶部标 🔴 覆盖不足。
- **缺口信息**:返回值含 `remaining_files_to_target`(还差多少文件数)与 `priority_deep_read_files`(按风险权重降序的待深读文件)——据此驱动 Step 5.5 的补轮深读。
- **增量**`include_prior=True` 合并跨轮覆盖索引(.code-review-graph/coverage-index.json)中 SHA 未变的已深读文件。
- **feature(单功能)**:调用 `coverage_tool(deep_read_files=<本轮实际深读文件>, gate="line+unit", file_read_ranges=<ranges>, file_semantic_units=<units>)`——**只做行覆盖 ≥95% + 单元完整性无缺口**,**不做全库/高风险文件数覆盖检查**(引擎返回 `coverage_pct`/`high_risk_coverage_pct``null`,报告只渲染行/单元覆盖区块,不渲染全库/高风险行)。`target_reached=false`(行/单元未达标)→ **禁止生成报告**,执行补读闭环(见下)直至达标。
- ⚠️ **三件套数据必传(行覆盖非 0 的前提)**:feature 主上下文深读每个文件时,必须**边读边累积** `file_read_ranges={<rel>:[[s,e],...]}`(已读行区间)与 `file_semantic_units={<rel>:[{"range":[s,e],"kind","name"},...]}`(文件内语义单元),再传给 `coverage_tool`。**不传则 fail-closed 行覆盖=0%**,报告标"行覆盖 0%"且 `verify-line-coverage.ps1` exit 1 拦截。
- 🔁 **补读闭环(硬性,未达标禁止出报告)**:若 `target_reached=false`**不得生成报告**。必须对 `line_gap_files`/`unit_gap_files`/`missing_data_files` 列出的文件补读缺失行区间/语义单元,重跑 `coverage_tool`,直至 `target_reached=true`。无法读全的文件**移出** `deep_read_files`(line+unit 不看文件数,只保留真正读满 ≥95% 的文件)。报告生成后跑 `verify-line-coverage.ps1`exit 1 → 补读重新生成。
将覆盖度结果**完整透传**到 `review_data.coverage`(直接把 `coverage_tool` 返回值全部字段传入:coverage_pct/high_risk_coverage_pct/grade/deep_read_count/total_files/high_risk_total_files/high_risk_deep_count/deep_read_weight/total_weight/target_reached/target/overall_target/high_risk_target/gate/**line_coverage_pct/unit_coverage_pct/line_gap_files/unit_gap_files/unit_exempt_files/missing_data_files**/remaining_files_to_target/remaining_weight_to_target/priority_deep_read_files/uncovered_files/silent_files/note),不要手挑子集,否则计数字段渲染为 0/0 或 N/A。报告会自动渲染 `## 覆盖度` 区块(含行/单元覆盖)。**若 `line_coverage_pct` 为 null(未跑 both+line 或未传三件套),报告会显式标"行覆盖:未执行 🔴",且 verify-line-coverage.ps1 会 exit 1 拦截。** **feature 走 `gate="line+unit"` 时 `coverage_pct`/`high_risk_coverage_pct` 为 `null` 属正常,勿因 null 误判为失败;`reviewed_files` 必须传本轮深读文件数组,报告顶部以可折叠列表(details/summary)展示审查文件。**
**前置健康检查**:调用 `community_health_tool`,若 `needs_postprocess=true`nodes.community_id 归属率 <90%),先 `code-review-graph postprocess` 重建社区归属再计算,否则覆盖度失真。
## Step 7.6 - 覆盖度自检验证(必须执行,防"优化没生效")
> 当 `coverage_tool` / `community_health_tool` / `score_review_tool` / `dedupe_findings_tool` / `generate_report_tool` / `deep_read_plan_tool` / `save_coverage_index_tool` **任一工具不可用**,或报告缺 `## 覆盖度` 区块时,按本步骤自检。机制未生效时禁止用 CLI 兜底继续审查,先修复环境。详见 `docs/plans/review-coverage-guarantee-plan.md` 第八章。
1. **环境自检(阻断)**:确认可用工具含上述 7 件套;缺失 → 检查本地源码 `D:\code-review-graph\code-review-graph-main` 版本为 2.5.0`opencode.json``.mcp.json` 均指向 `.venv\Scripts\code-review-graph.exe`(非 `uv run`/`uvx`)。
2. **进程自检**`Get-Process code-review-graph, uv` 应无残留 serve/uv 进程;残留进程会锁 `.venv\Scripts\code-review-graph.exe`,导致 `uv run``os error 32` → MCP 起不来。清理:`taskkill /PID <pid> /T /F`
3. **引擎自检**`community_health_tool()` 应返回 `needs_postprocess=false``attribution_pct>=90``coverage_tool(deep_read_files=[...], gate="both+line", file_read_ranges=<ranges>, file_semantic_units=<units>)` 应返回 `coverage_pct`/`high_risk_coverage_pct`/`line_coverage_pct`/`unit_coverage_pct`/`line_gap_files`/`unit_gap_files`/`unit_exempt_files`/`target_reached`/`remaining_weight_to_target`/`priority_deep_read_files`/`uncovered_files`/`silent_files` 全字段;`deep_read_plan_tool` 应返回 `groups`/`planned_files`
4. **恢复路径**:杀残留进程 → `uv sync``D:\code-review-graph\code-review-graph-main`)修复 venv → 重启 opencode 新会话 → 重新执行本步骤 1-3 直到 PASS。
## Step 8 - Report
Call `generate_report_tool(review_data=<REQUIRED SCHEMA>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`).
> **报告生成后(Step 8.6 命名自检通过后)必须执行**:
> `save_coverage_index_tool(deep_read_files=<本轮全量深读清单>, file_read_ranges=<{rel:[[s,e]..]}>)` —— 写跨轮覆盖索引 `.code-review-graph/coverage-index.json`v2file + per-file SHA + rangesSHA 来自 nodes.file_hash)。下一轮审查 `include_prior=True` 时自动复用未变更文件及其行区间,实现增量覆盖,多轮后增量归零即全覆盖。
### ⚠️ review_data 精确 Schema(必须严格遵循,否则报告只剩类别/位置)
工具在 `scoring.py::build_report_data` 中**只读取以下键**。用错键名/字段名会静默丢内容。
顶层键(均为可选项,但 `findings` 缺了就没有问题清单):
| 顶层键 | 值类型 | 说明 |
|---|---|---|
| `verdict` | `"PASS"` / `"FAIL"` | 结论 |
| `scope` | str | **只允许** `"whole-project"` / `"feature"` / `"change-level"`。❌ 禁止传功能名(如 `"evm"`)——verify 脚本按此判定是否检查行覆盖/抽验,传错会绕过门禁 |
| `tier` | str | 如 `"standard"` |
| `timestamp` | str | 如 `"2026-08-06T15:24:47"` |
| `files` | str | 审查文件列表(逗号分隔,兼容字段) |
| **`reviewed_files`** | **list[str]** | **本轮审查的文件数组**(如 `["server/src/api/evm_api.rs", ...]`;**也可传逗号分隔字符串,引擎会自动拆分**)。whole-project/feature 必传;报告顶部以可折叠列表(details/summary)展示,缺省时回退 `files` 逗号拆分 |
| `baseline` | str | git SHA |
| `quality_score` | int/float | 0-10 |
| `counts` | dict | 如 `{"blocker":0,"major":3,"minor":5}`MD 只读 `critical`/`informational` 键) |
| `metrics` | dict | `{指标名: {grade, value, note}}`**值必须是 dict****`note` 必传**(透传 `score_review_tool` 返回的 note/evidence,否则指标"说明"列为空)。❌ 传扁平标量(如 `"sql_risk": 3`)会被静默丢弃。**仅含五个客观指标**sql_risk/exception_coverage/redundancy_rate/high_risk_density/vulnerability_risk,见 Step 4 指标集约束);引擎会过滤 `blast_radius`/`objective_grade` 等非五指标键 |
| **`findings`** | **list[dict]** | **问题清单(必须用 `findings`,不能用 `issues`** |
| `manual_review` | list[str] | 人工复核项 |
| `llm_judged` | list[str] | 保留兼容字段,**一律置空 `[]`**,不再新增 LLM 判定指标 |
| `spot_check` | dict | **防伪抽验记录(顶层字段,非嵌 coverage)**。whole-project/feature 必传,否则报告渲染"防伪抽验:未执行 🔴"且 verify-spot-check.ps1 exit 1。schema`{groups_sampled, files_sampled, units_sampled, fake_read_found, groups_rereread, samples:[{group,file,unit,range,note_match,in_read_ranges}]}` |
| `summary` | str | 审查摘要 |
`findings` 每条**必须**的字段:
| 字段 | 说明 | 反例(会导致丢失) |
|---|---|---|
| `path` | 文件路径(与 `line` 合成 `location` | ❌ 传合并字符串 `location` |
| `line` | 行号(int,与 `path` 合成 `location` | ❌ 省略 `line` 导致无位置 |
| `message` | 问题描述(工具读 `summary``message` | ❌ 用 `title`/`detail` |
| `fix` | 修复建议 | ❌ 用 `title`/`detail` |
| `severity` | `"blocker"`/`"major"`/`"minor"` | |
| `category` | 如 `"business"`/`"security"` | |
| `confidence` | int 1-10 | |
### 完整示例(可直接替换占位复制)
```json
{"verdict": "PASS", "scope": "feature", "tier": "standard", "timestamp": "2026-08-06T15:24:47",
"files": "server/src/api/evm_api.rs, server/src/domain/evm.rs",
"reviewed_files": ["server/src/api/evm_api.rs", "server/src/domain/evm.rs"],
"baseline": "<sha>", "quality_score": 7.5,
"counts": {"blocker": 0, "major": 1, "minor": 0},
"metrics": {"sql_risk": {"value": 0, "grade": "good", "note": "全部参数化,无注入风险。"}},
"findings": [
{"path": "server/src/services/workflow_service.rs", "line": 111,
"severity": "major", "category": "business", "confidence": 8,
"message": "workflow 更新 completion_percentage 后未失效 EVM 缓存,5 分钟 TTL 内显示过期数据。",
"fix": "在 update_state 中调用 EvmService::invalidate_evm_cache(project_id, None)。"}
],
"manual_review": ["确认 list_evm_cases 无 data_scope 是否为有意设计"],
"summary": "发现 1 个 major。SQL 全部参数化无注入风险,无 blocker,结论 PASS。"}
```
**常见错误(自查清单):**
- ❌ 用 `issues` 键 → 工具只读 `findings`,结果 `问题清单(0`
- ❌ finding 用 `title`/`detail` → 工具读 `message`/`fix`,结果只剩类别+位置
- ❌ finding 直接传 `location` 字符串 → 工具由 `path`+`line` 合成,结果位置为空
- ❌ metrics 传扁平标量(`"sql_risk": 0`)→ 工具要求 `{grade, value, note}` dict
### Step 8.5 - 生成后自检(必须执行,防止空报告回归)
生成报告后**必须**打开生成的 `.md` 文件验证,全部通过才算完成:
1. `## 问题清单(N` 中 N == findings 数量(不是 0
2. 每条 issue 同时含**问题描述**、**位置**、**修复建议** 三要素(位置形如 `` `server/...:111` ``
3. **`## 客观指标` 表格存在且非空**(若传了 metrics)——若缺失,多为 `metrics` 值不是 `{grade, value, note}` dict(扁平标量被静默丢弃),需修正后重新生成
4. **`## 客观指标` 表恰好 5 行**`sql_risk` / `exception_coverage` / `redundancy_rate` / `high_risk_density` / `vulnerability_risk`)——出现其他指标名说明手工注入了 LLM 判定指标,违反 Step 4 指标集约束,需移除后重新生成
5. 若发现缺描述/缺修复建议/问题数=0 → 用上述 schema 修正 `review_data` 后**重新调用** `generate_report_tool` 覆盖,直到通过
### Step 8.6 - 报告命名自检(必须执行,防止报告落错位置/缺时间戳)
生成完成后,对仓库运行命名自检脚本(脚本独立于 code-review-graph CLI,任何版本可用):
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-report.ps1" -Repo <repo_root>
```
- 退出码 **0** → 通过:所有报告都在 `docs/reviews/` 且文件名带 `-YYYY-MM-DD-HHMMSS` 后缀。
- 退出码 **1** → 存在根目录残留 `code-review-report.*`(漏传 `output_path` 的强信号)。用 `-Fix` 自动归档,或直接以正确 `output_path="docs/reviews/{name}-review-{YYYY-MM-DD-HHMMSS}"` **重新调用** `generate_report_tool` 覆盖,然后重跑脚本确认退出码 0。
- 脚本列出的 historic naming warnings 无需处理(仅提示),但**本次生成的报告**必须满足规范。
### Step 8.7 - 行级覆盖自检(必须执行,防止"无行覆盖还全绿")
生成完成后,对最新报告运行行覆盖自检脚本(独立于 code-review-graph CLI,任何版本可用):
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-line-coverage.ps1" -Repo <repo_root>
```
- 退出码 **0** → 通过:whole-project/feature 报告含**行覆盖**字段且 ≥95%(三件套数据完整)。
- 退出码 **1** → 阻塞:报告覆盖度区块**没有行覆盖**(漏跑 `gate="both+line"`/`line+unit` 或漏传三件套)或行覆盖 <95%。必须补读(Step 7.5 补读闭环)后重新生成报告,再重跑,直到退出码 0。
- ⚠️ **scope 判定**:脚本只对 `change-level` 跳过;自定义 scope 值(如 `"evm"``"issues"`)只要报告含 `## 覆盖度` 区块,同样按行覆盖 ≥95% 检查——`scope` 传功能名不会绕过门禁。
### Step 8.8 - 防伪抽验自检(必须执行,防止"漏抽验还全绿")
whole-project / feature 审查**必须**执行防伪抽验(三件套③),并校验报告含抽验记录。生成完成后对最新报告运行:
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-spot-check.ps1" -Repo <repo_root>
```
- 退出码 **0** → 通过:报告覆盖度区块含**防伪抽验**字段且单元数 >0(Step 5.5 抽验已执行并注入 `review_data.spot_check`)。
- 退出码 **1** → 阻塞:报告无防伪抽验字段,或渲染"未执行 🔴"。必须补抽验(Step 5.5 d+ 注入 `spot_check` + 重新生成报告后重跑,直到退出码 0。
- ⚠️ **scope 判定**:同 Step 8.7——只对 `change-level` 跳过;自定义 scope 值(如 `"evm"`)含 `## 覆盖度` 区块时同样检查抽验。
- **诚实声明**:该脚本只能验证抽验**声明完整性**(抽了、单元数>0),无法验证主代理是否真读了文件——真实回读依赖行为准则,此为引擎防伪能力的已知边界。
- **Step 8.6(命名)、Step 8.7(行覆盖)、Step 8.8(防伪抽验)三脚本必须全过**才算审查完成。
**Archive naming:** output the report to `docs/reviews/` with `output_path=<base>/{scope}-review-{YYYY-MM-DD-HHMMSS}` (e.g. `docs/reviews/evm-feature-review-2026-08-06-151522`) so the filename carries an exact timestamp and avoids overwriting same-day reviews. Scope value examples: `full-project` (whole project), `{feature}-feature` (feature), `pr-{branch}` (change-level). **不传 `output_path` 时工具默认写到仓库根目录 `code-review-report.*`——这是违规命名,必须在 Step 8.6 用 `verify-report.ps1` 检出并修复。**
更详细的字段对照和完整模板见 `references/report-schema.md`
## Output Format
`Project Review: N issues (X blocker, Y major, Z minor) — verdict: ✅ PASS / ❌ FAIL`. List each issue with severity, confidence, file:line, problem, and proposed fix.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="project review")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to `"standard"` when minimal is insufficient.
- **whole-project 深读是子代理流水线(Step 5.5),不是主上下文循环。** 主上下文预算 ≤14 tool calls;深读通过并行 explore 子代理(每次 4-6 个)完成,每个子代理返回 `deep_read 确认清单 + findings[]`(带 file:line 证据)。目标:每轮全库覆盖 ≥80% 且高风险 ≥80%gate="both"),并通过跨轮索引增量累积。
@@ -0,0 +1,19 @@
# Unified Review — Common Mistakes
- **Skipping graph context** — always run `get_minimal_context` first; CRG
context is what makes the review token-efficient and blast-radius aware.
- **Rushing to fix** — this skill is READ-ONLY. Present findings, wait for
user decision. Never apply fixes, commit, or push.
- **Forgetting the fixed standard tier** — reviews always run at the
`standard` tier (all layers); don't assume fast/strict behaviour.
- **Judging metrics without evidence** — `score_review_tool` outputs are
heuristics. Cite the evidence, and let the LLM confirm SQL/exception/vuln
findings before presenting them as facts.
- **Missing manual-review modules** — payment, order, inventory, permission,
distributed-lock, data-migration always require a manual review checklist.
- **Forgetting enum completeness reads OUTSIDE the diff** — grep sibling
values, then read each consumer; in-diff review alone is insufficient.
- **Batch-skipping blockers** — 🔴 blockers cannot be batch-skipped; each
needs an explicit user decision.
- **Not producing the report** — always call `generate_report_tool` at the
end and present the text report inline.
@@ -0,0 +1,168 @@
# 并行深读流水线(Step 5.5 操作手册)
> 目的:让 whole-project 审查达到全库覆盖 ≥85% 且高风险 ≥95%(双目标 gate="both",文件数口径)**且**每个深读文件通过三件套质量门禁(单元完整性 / 行覆盖 / 防伪抽验)。
> 核心:主上下文不逐文件深读,改用并行 explore 子代理分组深读,每个子代理返回
> 「深读确认清单 + semantic_units + read_ranges + findings(带 file:line 证据)」,结果**落盘**给聚合脚本校验。
## 〇、三件套质量门禁(V2.1,每个深读文件必须通过)
| # | 门禁 | 定义 | 阈值 | 角色 | 防伪性 |
|---|---|---|---|---|---|
| ① | **单元完整性** | `semantic_units` 与图谱单元节点差集 = 空 | **100%(硬)** | 防漏读(跳过后半段) | 引擎/脚本自动核验 |
| ② | **行覆盖** | `union(read_ranges)` / 真实行数 | **≥95%** | 防"每单元只读一行" | 低(自报粒度) |
| ③ | **防伪抽验** | 主代理回读抽样单元 | **每组 2 文件 × 2-3 单元/文件,每波 ≤40 次** | 防假读(抄清单不读) | 唯一防假读手段 |
判定规则:
- ① 单元被覆盖 = 图谱单元 `[ls,le]` 与上报 `semantic_units` 中某 range **精确匹配**(优先)或**重叠 ≥80%**(兜底),且该单元 ≥80% 行落在 `union(read_ranges)` 内;一个上报 range 只能匹配一个单元(one-to-one),宽 range 无法覆盖多个单元。
- 巨型文件豁免 ①:`单元数<3``最大单元行占比>80%`(如 migrations.rs run_migrations 6200/6331=98%)→ 该文件仅按 ② 行覆盖校验,引擎/脚本返回 `unit_exempt_files`
- ② 分母 = **真实文件行数**(读取源文件,非图节点 line_end——已验证 ±1 偏差),行数缓存。
- ③ 预算:**每波 ≤40 次 read**(每波 4-6 组 × 2 文件 × 3 单元 ≈ 24-36,留余量);每组抽 2 文件、每文件抽 2-3 单元回读比对 note;抽到假读 → 该组重读并升级抽验率。结果**必须落盘 `spot_check_<batch>.json`** 并在 Step 8 聚合注入 `review_data.spot_check`(顶层字段),否则报告渲染"防伪抽验:未执行 🔴"且 verify-spot-check.ps1 exit 1。
> **诚实声明**:引擎无法防假读。① 防漏单元、② 防读不全、③(抽样)才是唯一防假读的手段,且为预算化而非全量。verify-spot-check.ps1 只能验证抽验**声明完整性**(抽了、单元数>0、range 落在 read_ranges 内),无法验证主代理是否真读了文件。
## 一、分组规则
以 AuraSpace 实测分组表为基准(约 508 源文件 → 14-16 组,每组 ≤40 文件):
| # | 组(目录) | 约文件数 | 子代理职责 |
|---|---|---|---|
| 1 | `server/src/api`1/2 | 30 | 后端 API 层前半 |
| 2 | `server/src/api`2/2 | 31 | 后端 API 层后半 |
| 3 | `server/src/services`(1/2) | 33 | 核心业务服务前半 |
| 4 | `server/src/services`(2/2) | 33 | 核心业务服务后半 |
| 5 | `server/src/domain` | 43 | 领域模型/DTO |
| 6 | `server/src/deepwiki` + `infrastructure` | 25 | DeepWiki + 基础设施 |
| 7 | `server/tests` + `bin` + `models` | 14 | 测试与工具二进制 |
| 8 | `web/src/views`(1/2 | 34 | 前端视图前半 |
| 9 | `web/src/views`(2/2 | 33 | 前端视图后半 |
| 10 | `web/src/components`1/3 | 38 | 前端组件(issues/evm/ci |
| 11 | `web/src/components`2/3 | 38 | 前端组件(docs/wiki/time-log/settings |
| 12 | `web/src/components`3/3 | 37 | 前端组件(ppt/agent/opencode/common/ui |
| 13 | `web/src/store` + `utils` + `api` | 31 | 状态管理/工具函数/API 客户端 |
| 14 | `web/src/hooks` + `services` + `generated` + `web/tests` | 大 | 前端其余(可分 2 组) |
> 实际以 `deep_read_plan_tool(gate="overall", target_coverage=85, include_prior=True)`
> 返回的 `groups` 为准(引擎按风险权重贪心 + 目录分组,数量随仓库变化)。
> 每次并行派发 **4-6 个子代理**,其余排队分批,避免 MCP 并发压力与上下文风暴。
## 二、子代理 Prompt 模板(V2.1,含三件套协议)
对每个组派发如下 prompt(替换 `<GROUP>` / `<FILE_LIST>` / `<输出目录>` / `<临时输出文件>`):
```
你是一个代码审查深读子代理。请深读以下 <GROUP> 组的全部文件:
<FILE_LIST(每行一个相对路径)>
要求:
1. 逐文件用 read 读取完整内容(文件大则分段读),**不要跳过任何文件**。
2. 对每个文件,从八个类别审视:interface / business / data / utility /
error handling / security / performance / observability,并叠加
CRITICAL 子轮:SQL 与数据安全、竞态条件、LLM 输出信任边界、Shell 注入、枚举完备性。
3. 只报告**真实问题**(严重度 blocker/major/minor,置信度 1-10)。
每条 finding 必须含 file:line 证据(实际读取到的行),没有证据视为未深读。
4. 良好实践、无问题的文件,在 deep_read 确认清单中标注,不产出 finding。
5. **每个文件必须填写质量字段**(这是硬性要求,缺失视为未深读):
- total_lines: 文件真实总行数(从 read 输出得知)
- read_ranges: 实际读取的行区间数组 [[s,e],...](分段读即天然区间),
闭区间,并集应覆盖你读过的每一行
- semantic_units: 你实际读过的每个语义单元(函数/类/接口)摘要,
{"range":[s,e],"kind":"Function|Class|Test","name":"<符号名>","note":"<≤60字摘要>"}
**严格模式(强制)**:必须**逐一列出该文件图谱中的全部语义单元**,
包括:
- 所有 Function(含私有/辅助小函数)
- 所有 Class / interface / Type(含 Props 接口、仅数行的小接口——如
`MermaidBlockProps`3 行)、`NavEntry`5 行)也必须单独列出)
- 不得把 Props interface / 小接口并入父组件或跳过
- 一个语义单元 = 一个 semantic_units 条目,range 取该符号的实际
[line_start, line_end]
跳过任一图谱单元 = 漏读,会被单元完整性差集校验检出(unit_gap)→ 该文件须补报重读
输出(结构化,仅此格式),写入 <临时输出文件>(JSON 文件,不要回传大文本):
{
"group": "<GROUP>",
"outputs": [
{
"path": "相对路径",
"total_lines": <int>,
"read_ranges": [[s,e], ...],
"semantic_units": [{"range":[s,e],"kind":"...","name":"...","note":"..."}],
"findings": [
{"path": "...", "line": <int>, "severity": "blocker|major|minor",
"category": "security|business|data|...", "confidence": <1-10>,
"message": "问题描述(含具体行内容证据)", "fix": "修复建议"}
]
}
],
"summary": "本组一句话结论(主要风险点)"
}
```
深读确认 = 该组文件全部出现在 outputs 中,且每个都有 `total_lines`/`read_ranges`/`semantic_units`
> **禁止退化格式**:不得把子代理输出简化为 `DEEPREAD_CONFIRM: <路径|总行数|已读范围|单元数>` 单行文本。
> 那会使主代理拿不到分段 `read_ranges` 与逐单元 `semantic_units`,导致 coverage_tool 三件套门禁
> 无法执行、报告"无行覆盖"。子代理 prompt 必须使用本节模板原样复制。
## 三、输出 Schema(子代理 → 落盘 → 主代理)
- **落盘机制(强制)**:子代理把上述 JSON 写入主代理指定的临时目录(如
`C:\Users\ADMINI~1\AppData\Local\Temp\opencode\review\batchN.json`)。
**不要把 1-2MB JSON 回传主上下文**——主代理只读聚合结果摘要。
- 每波子代理完成后,主代理运行聚合脚本:
```
python skills/project-review/scripts/aggregate_deep_read.py <repo_root> <输出目录>
```
返回 `verified_files / line_gap_files / unit_gap_files / unit_exempt_files / summary`。
## 四、主代理收尾流程
```
1. 每波子代理完成后(不等全部结束):
aggregate_deep_read.py <repo_root> <落盘目录>
2. 汇总三件套判定:
- line_gap_files unit_gap_files → 补读队列(下波派发,禁止跳过)
- unit_exempt_files → 仅按行覆盖校验(已由脚本处理)
- verified_files → 计入本轮 deep_read_files
3. 防伪抽验(**强制,每波必做**):对每组抽 **2 文件**、每文件抽 2-3 个语义单元,
回读源文件对应行比对 semantic_units.note。每波 ≤40 次 read。抽到假读 → 该组重读并升级抽验率。
结果落盘 `spot_check_<batch>.json`schemagroups_sampled / files_sampled / units_sampled /
fake_read_found / groups_rereread / samples[{group,file,unit,range,note_match,in_read_ranges}])。
4. 全部达标后,三件套数据传入引擎 coverage_tool(B 阶段引擎原生支持):
coverage_tool(deep_read_files=<verified_files>, gate="both+line",
file_read_ranges=<{rel:[[s,e]..]}>, file_semantic_units=<{rel:[...]}>)
→ 引擎返回 line_coverage_pct / unit_coverage_pct / line_gap_files / unit_gap_files / unit_exempt_files
⚠️ 禁止降级:unit_gap_files 或 line_gap_files 非空时,**不得**改回 gate="both" 静默跳过;
必须补轮重读至空,或在报告中显式标注"三件套未达标 🔴"并列出缺口文件。
5. 未达标(文件数 <85%/<95% 或行覆盖 <95% 或单元有缺口)→
按 priority_deep_read_files / line_gap_files / unit_gap_files 补一轮(可再派 1-3 个子代理)。
6. G2:对 silent_files 随机抽 15% 深读(本轮未覆盖的静默文件)。
7. 报告生成前:聚合全部 `spot_check_*.json` → 注入 `review_data.spot_check`(顶层字段)。
8. G3:三件套 + 文件数双口径达标 → 报告生成(覆盖度区块须含行/单元覆盖 + 防伪抽验)→
跑 verify-spot-check.ps1Step 8.8)→ save_coverage_index_tool(deep_read_files=<verified_files>, file_read_ranges=<ranges>) 写 v2 索引。
```
## 五、质量控制与防伪
| 风险 | 对策 |
|---|---|
| 子代理"声称读了"但没真读 | 强制 `total_lines`/`read_ranges`/`semantic_units` 三字段 + findings 带行号;聚合脚本按①差集+②行并集双校验;主代理③抽样回读 |
| 子代理漏读文件 | outputs 与分组清单 diff,漏读计入覆盖率缺口,触发补轮 |
| 子代理宽 range 冒充全读 | ① one-to-one 匹配:一个上报 range 只能覆盖一个单元,无法用整文件 range 覆盖所有单元 |
| 子代理漏报小单元(Props interface / Type / 小函数) | 严格模式:semantic_units 必须逐一列出图谱全部单元(含 3-5 行的小接口);unit_gap 非空 → 该文件补报重读,不得视为已深读 |
| 子代理各自为政口径不一 | 统一八类 + CRITICAL 子轮 + severity/confidence 标准(见上模板) |
| 增量掩盖新代码 | `include_prior=True` 按 per-file SHA 判定;变更文件自动失效重读 |
| 并发压力 | 每批 4-6 个并行,其余排队;batch_size 40 控制单组体量 |
| 主上下文被大 JSON 撑爆 | 落盘机制:子代理写临时文件,主代理只读聚合摘要 |
## 六、跨轮增量(多轮累积)
- 每轮报告后 `save_coverage_index_tool` 写 `.code-review-graph/coverage-index.json`
(相对路径 → per-file SHA)。
- 下一轮 `deep_read_plan_tool(include_prior=True)` / `coverage_tool(include_prior=True)`
自动复用 SHA 未变文件 → 增量任务 = 新增文件 + 变更文件。
- 多轮后增量归零即实现全库全覆盖,避免每轮从 2-5% 起步。
- 引擎 B 阶段(`compute_coverage` 支持 `file_read_ranges`/`file_semantic_units``gate="both+line"`**已落地**。主代理优先用
`coverage_tool(deep_read_files=..., gate="both+line", file_read_ranges=..., file_semantic_units=...)`
做三件套门禁(引擎返回 `line_coverage_pct`/`unit_coverage_pct`/`line_gap_files`/`unit_gap_files`/`unit_exempt_files`)。
A 阶段聚合脚本 `scripts/aggregate_deep_read.py` 保留作独立校验兜底(引擎不可用时的替代),两者判定逻辑一致。
@@ -0,0 +1,12 @@
# Data Migration — Manual Review Checklist
High-risk module: schema/data migration requires human confirmation.
- [ ] Migration is idempotent and re-runnable
- [ ] Forward and rollback paths both defined and tested
- [ ] Backfill is batched / resumable for large tables
- [ ] Data type / precision changes do not silently truncate
- [ ] Nullability and default changes safe for existing rows
- [ ] Migration ordering across shards / replicas is consistent
- [ ] Application deploys compatibly with both old and new schema (expand/contract)
- [ ] Irreversible operations are flagged with a documented reason
@@ -0,0 +1,11 @@
# Distributed Lock — Manual Review Checklist
High-risk module: distributed-lock changes require architecture confirmation.
- [ ] Lock has a TTL / expiry — no permanent deadlock after crash
- [ ] Lock release is atomic and ownership-checked (compare-and-delete)
- [ ] Lock scope is correct (key includes the right entity identifiers)
- [ ] Renewal / watchdog exists for long critical sections
- [ ] Locking order is consistent across paths (no lock-ordering deadlock)
- [ ] Fencing tokens / version check prevents stale-holder writes
- [ ] Fail-open vs fail-closed behavior is intentional and documented
@@ -0,0 +1,11 @@
# Inventory Module — Manual Review Checklist
High-risk module: stock/inventory changes require human confirmation.
- [ ] Stock decrement is atomic (conditional UPDATE, not read-then-write)
- [ ] Oversell prevented: `UPDATE ... SET qty = qty - ? WHERE qty >= ?`
- [ ] Reservation vs. deduction semantics are consistent
- [ ] Concurrent orders cannot both reserve the last unit
- [ ] Restock/return increments handled correctly
- [ ] Inventory events are idempotent (retry-safe)
- [ ] Async stock updates propagate to downstream (warehouse, carts) safely
@@ -0,0 +1,12 @@
# Order Module — Manual Review Checklist
High-risk module: order lifecycle changes require human confirmation.
- [ ] State machine transitions are atomic (`WHERE status = ?` updates)
- [ ] Cancellation / timeout / expiry paths complete all side effects
- [ ] Order idempotency key prevents duplicate order creation
- [ ] Price/lock snapshot captured at order time, not at payment time
- [ ] Partial fulfillment / split-shipment handled
- [ ] Negative or inconsistent totals impossible
- [ ] Concurrent edits (cart + order) do not corrupt state
- [ ] Audit trail: every status change logged with reason
@@ -0,0 +1,14 @@
# Payment Module — Manual Review Checklist
High-risk module: payment changes require human confirmation for every
blocker/major fix.
- [ ] Callback idempotency: a duplicated webhook/callback does not double-charge
- [ ] Amounts stored as fixed-point (integers/cents), never floats
- [ ] Currency codes and precision handled correctly
- [ ] Provider signature / HMAC verification on callbacks
- [ ] Refund logic: correct reversal, no double-refund
- [ ] Failure path: payment timeout, declined, retry semantics
- [ ] Transaction boundary spans charge + order-state update
- [ ] Sensitive data (PAN, tokens) never logged or masked on output
- [ ] Ledger/journal entries are append-only and auditable
@@ -0,0 +1,11 @@
# Permission Module — Manual Review Checklist
High-risk module: authorization changes require product/human confirmation.
- [ ] Every endpoint/action enforces the intended permission — no default-allow
- [ ] Role hierarchy / scoping (tenant, org, user) is consistent
- [ ] Object-level permissions checked on read AND write
- [ ] Deny-before-allow ordering is safe
- [ ] Permission checks cannot be bypassed via IDs, query params, or bulk ops
- [ ] New permission/role values handled by all consumers (enum completeness)
- [ ] Sensitive actions audited with actor + target
@@ -0,0 +1,174 @@
# generate_report_tool 入参 Schema(权威参考)
> 依据 `code-review-graph` 源码 `code_review_graph/scoring.py::build_report_data`
> 与 `render_markdown_report` 反推的精确约定。**任何偏离都会导致报告静默丢内容。**
## 1. 顶层键
工具从 `review_data` dict 中只读取以下键(`scoring.py:620-633`):
| 键 | 类型 | 默认值 | 作用 |
|---|---|---|---|
| `scope` | str | `"change-level"` | 审查范围 |
| `tier` | str | `"standard"` | 审查档位 |
| `timestamp` | str | `""` | 生成时间 |
| `files` | str | `""` | 审查文件(逗号分隔字符串,兼容字段) |
| `reviewed_files` | list[str] | `[]` | 本轮审查文件数组;报告顶部以可折叠列表(details/summary)展示,缺省回退 `files` |
| `baseline` | str | `"generic"` | git 基线 SHA |
| `verdict` | str | `"❌ FAIL"` | 结论,`"PASS"``"FAIL"` |
| `quality_score` | int/float | `None` | PR 质量分 |
| `counts` | dict | `{}` | 严重度统计 |
| `metrics` | dict | `{}` | 客观指标(值必须是 dict) |
| **`findings`** | **list[dict]** | `[]` | **问题清单** |
| `manual_review` | list[str] | `[]` | 人工复核项 |
| `llm_judged` | list[str] | `[]` | 保留兼容字段,**一律置空**;不再新增 LLM 判定指标 |
| `summary` | str | `""` | 摘要(仅 HTML 渲染) |
## 2. findings 条目字段(scoring.py:645-658
工具对每条 finding 做如下映射:
```python
data["issues"].append({
"severity": f.get("severity", "minor"),
"category": f.get("category", ""),
"message": f.get("summary", f.get("message", "")), # summary 优先,其次 message
"location": f"{f.get('path','')}:{f.get('line','')}" # 仅当 line 非空
if f.get("line") else str(f.get("path","")),
"confidence": f.get("confidence"),
"fix": f.get("fix", ""),
})
```
| 字段 | 说明 | 若不传会怎样 |
|---|---|---|
| `path` | 文件路径 | 与 `line` 合成 `location`;仅传 path 也可显示路径 |
| `line` | 行号 | **line 为空则 location 只有 path**(无 `:行号` |
| `message` | 问题描述 | 缺失 → 报告只剩类别/位置 |
| `summary` | 问题描述(优先级高于 message) | 同上 |
| `fix` | 修复建议 | 缺失 → 无修复建议段 |
| `severity` | `blocker`/`major`/`minor`(也接受 `critical`/`warn`/`informational` | 默认 `minor` |
| `category` | 如 `business`/`security`/`data` 等 | 默认空 |
| `confidence` | int 1-10 | 缺失则不显示置信度 |
## 3. metrics 结构(scoring.py:636-643
`metrics` 的值必须是 **dict**,每项支持 `grade`/`value`/`note`/`evidence`。**`note` 必传**(透传 `score_review_tool` 返回的 note/evidence),否则报告"说明"列为空。
```json
"metrics": {
"sql_risk": {"value": 0, "grade": "good", "note": "全部参数化查询,无注入风险。"},
"exception_coverage": {"value": 0, "grade": "fail", "note": "Rust Result 误报。"}
}
```
MD 报告显示标签来自硬编码映射:`sql_risk`→SQL 注入风险、`exception_coverage`→异常分支覆盖、`redundancy_rate`→代码冗余率、`high_risk_density`→高风险场景密度、`vulnerability_risk`→漏洞风险。其他指标名直接显示原名。**指标表应仅含上述五个客观指标**(指标集约束见 SKILL.md Step 4),禁止手工注入 `requirement_coverage`/`logic_alignment`/`llm_trust_boundary`/`shell_injection`/`enum_completeness` 等 LLM 判定指标;**也不要混入 `blast_radius`/`objective_grade` 等键——`build_report_data` 会按五指标白名单过滤,非五指标键一律丢弃。**
## 4. counts 说明(scoring.py:727-732
MD 报告的问题统计行只读 `counts.critical``counts.informational`
```python
f"- **问题统计**{counts.get('critical', 0)} 严重 · {counts.get('informational', 0)} 次要"
```
因此若想统计正确,`counts` 需用 `critical`/`informational` 键(或将 major/minor 数量合入)。
## 4b. coverage 字段(Step 7.5 G3,引擎 v2.5.0
`review_data.coverage` 直接透传 `coverage_tool` 返回值**全部字段**(不要手挑子集,否则计数字段渲染 0/0 或 N/A):
| 字段 | 说明 |
|---|---|
| `coverage_pct` | 全库覆盖(已深读文件数 / 全部源文件数,**文件数口径**);`gate="line+unit"`feature)时为 `None` |
| `high_risk_coverage_pct` | 高风险覆盖(已深读高风险文件数 / 信号点名文件数,文件数口径);`gate="line+unit"`feature)时为 `None` |
| `grade` | good/warn/fail |
| `deep_read_count` / `total_files` | 已深读数 / 总源文件数 |
| `high_risk_total_files` / `high_risk_deep_count` | 高风险文件数 / 已深读高风险数 |
| `deep_read_weight` / `total_weight` | 已深读权重 / 总权重(兼容保留,仅用于排序参考) |
| `target_reached` / `target` | 门禁结果 / 高风险目标值(gate="both" 时需全库 ≥85% 且 高风险 ≥95% 才 Truestandard=85/95 |
| `overall_target` / `high_risk_target` | 双目标值:全库 85% / 高风险 95%(报告据此分别显示) |
| `gate` | 门禁口径(high_risk/overall/both/both+line/line+unit |
| `remaining_files_to_target` | 距全库目标还差多少文件数(**驱动补轮的主字段**) |
| `remaining_weight_to_target` | 距目标还差多少权重(兼容保留) |
| `priority_deep_read_files` | 按风险权重降序的待深读文件(`[{path, weight}]` |
| `uncovered_files` | 未深读文件清单(G1 |
| `silent_files` | 静默文件清单(G2 抽检源) |
| `note` | 引擎口径说明 |
> 增量:`coverage_tool(include_prior=True)` 合并跨轮索引 `.code-review-graph/coverage-index.json`
> 中 SHA 未变的已深读文件。报告后必须 `save_coverage_index_tool` 写索引供下轮复用。
### 4b-1. feature(单功能)审查:`gate="line+unit"`
feature 覆盖度门禁**只保留行级覆盖 + 单元覆盖**,不做全库/高风险文件数覆盖检查:
```json
"coverage": {
"gate": "line+unit",
"coverage_pct": null,
"high_risk_coverage_pct": null,
"line_coverage_pct": 100.0,
"unit_coverage_pct": 100.0,
"line_gap_files": [],
"unit_gap_files": [],
"target_reached": true
}
```
- `coverage_pct` / `high_risk_coverage_pct``null` 是**预期行为**(不做文件数覆盖检查),勿误判失败。
- `target_reached` 只由行覆盖 ≥95% + 单元完整性无缺口决定。
- 报告 `## 覆盖度` 区块只渲染行/单元覆盖与状态行,不渲染全库/高风险行。
- 必须同时传 `reviewed_files`(本轮深读文件数组),报告顶部以可折叠列表展示。
## 5. 完整可复制模板
```json
{
"verdict": "PASS",
"scope": "feature",
"target": "EVM",
"tier": "standard",
"timestamp": "2026-08-06T15:24:47",
"files": "server/src/api/evm_api.rs, server/src/services/evm_service.rs",
"baseline": "f4235d00008a0de651ad8988adc1bca27ede1fb2",
"quality_score": 7.5,
"counts": {"blocker": 0, "major": 3, "minor": 5},
"metrics": {
"sql_risk": {"value": 0, "grade": "good", "note": "全部参数化查询,无注入风险。"}
},
"findings": [
{
"path": "server/src/services/workflow_service.rs",
"line": 111,
"severity": "major",
"category": "business",
"confidence": 8,
"message": "progressive 模式下 EV 计算依赖 workflow_states.completion_percentage,更新后未失效 EVM 缓存,5 分钟 TTL 内显示过期数据。",
"fix": "在 update_state / create_state / delete_state 中调用 EvmService::invalidate_evm_cache(project_id, None)。"
}
],
"manual_review": [
"list_evm_cases 无 data_scope 是否为有意设计(需产品/权限负责人确认)"
],
"summary": "发现 3 个 major 与 5 个 minor。SQL 全部参数化无注入风险,无 blocker,结论 PASS。"
}
```
## 6. 错误用法 → 现象对照表
| 错误用法 | 代码示例 | 现象 |
|---|---|---|
| 用 `issues` 键 | `{"issues": [...]}` | `问题清单(0`,条目全丢 |
| finding 用 `title`/`detail` | `{"title": "...", "detail": "..."}` | 只剩类别+位置,描述/修复为空 |
| 传合并 `location` | `{"location": "a.rs:5"}` | 位置为空(工具只认 path+line) |
| metrics 传扁平标量 | `"sql_risk": 0` | 指标表空(要求 dict |
| `counts` 用 major/minor | `{"counts":{"major":3}}` | MD 统计行显示 0 严重·0 次要 |
## 7. 生成后自检清单(Step 8.5)
生成报告后必须打开 `.md` 验证:
- [ ] `## 问题清单(N`N == findings 条数,且 > 0
- [ ] 每条 issue 同时含描述 + 位置(`` `path:line` ``+ 修复建议
- [ ] 若任一缺失 → 修正 `review_data` 字段后重新调用 `generate_report_tool` 覆盖
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>代码审查报告</title>
<style>
:root { --border:#d0d7de; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a;
--good:#1a7f37; --warn:#9a6700; --fail:#cf222e; --na:#57606a;
--blocker:#cf222e; --major:#9a6700; --minor:#57606a;
--critical:#cf222e; --informational:#0969da; }
* { box-sizing: border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin:0; padding:2rem 1rem; color:var(--fg); background:#fff; line-height:1.55; }
.wrap { max-width:960px; margin:0 auto; }
h1 { font-size:1.5rem; margin:0 0 .25rem; }
h2 { font-size:1.1rem; margin:1.5rem 0 .5rem; padding-bottom:.25rem;
border-bottom:1px solid var(--border); }
.meta { color:var(--muted); font-size:.9rem; margin-bottom:1.5rem; }
.verdict { display:inline-block; padding:.25rem .75rem; border-radius:20px;
font-weight:700; font-size:.95rem; }
.verdict.pass { background:#dafbe1; color:var(--good); }
.verdict.fail { background:#ffebe9; color:var(--fail); }
table { border-collapse:collapse; width:100%; margin:.75rem 0; }
th,td { border:1px solid var(--border); padding:.45rem .6rem; text-align:left;
font-size:.9rem; vertical-align:top; }
th { background:var(--bg); }
.grade.good { color:var(--good); font-weight:600; }
.grade.warn { color:var(--warn); font-weight:600; }
.grade.fail { color:var(--fail); font-weight:600; }
.grade.na { color:var(--na); }
.issue { margin:.6rem 0; padding:.65rem .8rem; border:1px solid var(--border);
border-radius:6px; background:#fff; }
.issue .tag { display:inline-block; padding:.1rem .5rem; border-radius:10px;
font-size:.75rem; font-weight:700; color:#fff; margin-right:.5rem; }
.tag.blocker, .tag.critical { background:var(--blocker); }
.tag.major, .tag.warn { background:var(--major); }
.tag.minor, .tag.informational { background:var(--minor); }
.issue .cat { font-weight:600; }
.issue .loc { color:var(--muted); font-size:.85rem; margin-top:.2rem; }
.issue .fix { margin-top:.35rem; font-size:.88rem; background:var(--bg);
padding:.4rem .6rem; border-radius:4px; }
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
details.reviewed { margin:.5rem 0; border:1px solid var(--border);
border-radius:6px; padding:.4rem .8rem; }
details.reviewed summary { cursor:pointer; font-weight:600; }
details.reviewed ul { margin:.4rem 0 0; padding-left:1.2rem; }
details.reviewed li { margin:.15rem 0; }
</style>
</head>
<body>
<div class="wrap" id="app"></div>
<script>
const data = {{REPORT_DATA}};
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
})[c]);
}
function verdictClass(v) {
v = String(v || "").toUpperCase();
return v.includes("PASS") ? "pass" : "fail";
}
// Collapsible list of the reviewed files (native <details>/<summary>, no JS).
// Accepts an array OR a comma-separated string (agents pass both). Falls
// back to the flat ``files`` string when nothing structured is given.
function renderReviewedFiles(data) {
let arr = data.reviewed_files;
if (typeof arr === "string") {
arr = arr.split(",").map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(arr) || !arr.length) {
return data.files ? `<p><b>文件:</b> ${esc(data.files)}</p>` : "";
}
return `<details class="reviewed">
<summary>审查文件 (${arr.length}) <span class="muted">点击展开/收起</span></summary>
<ul>${arr.map(f => `<li><code>${esc(f)}</code></li>`).join("")}</ul>
</details>`;
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
redundancy_rate: "代码冗余率",
high_risk_density: "高风险场景密度",
vulnerability_risk: "漏洞风险",
};
const gradeLabels = { good: "良好", warn: "警告", fail: "不合格", na: "不适用" };
const sevLabels = {
blocker: "🔴 阻塞", critical: "🔴 严重",
major: "🟡 主要", warn: "🟡 主要",
minor: "🔵 次要", informational: "🔵 次要",
};
let html = `<h1>代码审查报告</h1>
<div class="meta">
<span class="verdict ${verdictClass(data.verdict)}">${esc(data.verdict || "无结论")}</span>
&nbsp; 档位: <code>${esc(data.tier || "standard")}</code>
&nbsp; 范围: <code>${esc(data.scope || "change-level")}</code>
${data.baseline ? `&nbsp; 基线: <code>${esc(data.baseline)}</code>` : ""}
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
html += renderReviewedFiles(data);
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const cov = data.coverage || {};
// The coverage section renders whenever file-count coverage OR line/unit
// coverage was computed. gate="line+unit" (feature reviews) returns
// coverage_pct=null, so the 全库/高风险 rows are skipped and only the
// line/unit rows + spot-check render.
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const isLineOnly = cov.coverage_pct == null;
const covOk = cov.target_reached;
const covCls = covOk ? "good" : "fail";
const covStatus = covOk ? "✅ 达标" : "🔴 覆盖不足";
const oTarget = cov.overall_target ?? cov.target ?? "N/A";
const hTarget = cov.high_risk_target ?? cov.target ?? "N/A";
html += `<h2>覆盖度</h2>
<p><span class="verdict ${covCls}">${covStatus}</span></p>`;
if (!isLineOnly) {
html += `<p><b>全库覆盖:</b> ${esc(cov.coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.deep_read_count ?? "N/A")}/${esc(cov.total_files ?? "N/A")}(目标 ${esc(oTarget)}%</p>
<p><b>高风险覆盖:</b> ${esc(cov.high_risk_coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.high_risk_deep_count ?? "N/A")}/${esc(cov.high_risk_total_files ?? "N/A")}(目标 ${esc(hTarget)}%</p>`;
}
html += renderLineUnit(cov);
html += renderSpotCheck(data.spot_check);
if (!isLineOnly && (cov.uncovered_files || []).length) {
html += `<p class="muted"><b>未深读文件:</b> ${esc(cov.uncovered_files.length)} 个(静默文件 ${esc((cov.silent_files || []).length)} 个)</p>`;
}
}
// Line / unit coverage (three-piece suite items 1-2). Fail-closed: a
// missing line/unit coverage renders "未执行 🔴" so reviews that skipped
// gate="both+line" or the three-piece data are visible, never silent green.
function renderLineUnit(cov) {
const linePct = cov.line_coverage_pct;
const unitPct = cov.unit_coverage_pct;
const lineTarget = cov.line_target ?? 95.0;
const unitTarget = cov.unit_target ?? 100.0;
const lineGap = (cov.line_gap_files || []).length;
const unitGap = (cov.unit_gap_files || []).length;
const missing = (cov.missing_data_files || []).length;
let s = "";
if (linePct == null || unitPct == null) {
s += `<p><b>行覆盖:</b> 未执行 🔴 <span class="muted">(coverage_tool 未用 gate="both+line" 或未传三件套数据)</span></p>`;
} else {
const lineOk = linePct >= lineTarget && lineGap === 0;
const unitOk = unitPct >= unitTarget && unitGap === 0;
s += `<p><b>行覆盖:</b> ${esc(linePct)}% — 目标 ${esc(lineTarget)}%(缺口 ${esc(lineGap)} 文件)${lineOk ? "✅" : "🔴"}</p>`;
s += `<p><b>单元覆盖:</b> ${esc(unitPct)}% — 目标 ${esc(unitTarget)}%(缺口 ${esc(unitGap)} 文件)${unitOk ? "✅" : "🔴"}</p>`;
}
if (missing) {
s += `<p class="muted"><b>三件套数据缺失:</b> ${esc(missing)} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)</p>`;
}
return s;
}
// Anti-fake spot check (three-piece suite item 3). Fail-closed: a
// missing/incomplete spot_check renders "未执行 🔴" so reviews that
// skipped the sampled re-read are visible instead of silently green.
function renderSpotCheck(spot) {
if (!spot) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(主代理未回读任何语义单元;Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check)</span></p>`;
}
const groups = spot.groups_sampled;
const files = spot.files_sampled;
const units = spot.units_sampled;
const fake = spot.fake_read_found || 0;
const rereread = spot.groups_rereread || [];
if (!units) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(spot_check 已上报但单元数为 0)</span></p>`;
}
const mark = (fake || rereread.length) ? "🔴 发现假读" : "✅";
let s = `<p><b>防伪抽验:</b> 抽样 ${esc(files)} 文件 / ${esc(units)} 单元 / ${esc(groups)} 组,假读 ${esc(fake)} ${mark}</p>`;
if (rereread.length) {
s += `<p class="muted">因假读重读组:${esc(rereread.join(", "))}</p>`;
}
return s;
}
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
html += `<h2>客观指标</h2><table><tr><th>指标</th><th>数值</th><th>评级</th><th>说明</th></tr>`;
for (const k of mkeys) {
const m = metrics[k] || {};
const g = m.grade || "na";
const label = metricLabels[k] || k;
const gradeText = gradeLabels[g] || g;
html += `<tr>
<td>${esc(label)}</td>
<td>${esc(m.value ?? "N/A")}</td>
<td class="grade ${esc(g)}">${esc(gradeText)}</td>
<td class="muted">${esc(m.note || "")}</td>
</tr>`;
}
html += `</table>`;
}
const issues = data.issues || [];
html += `<h2>问题清单 (${issues.length})</h2>`;
if (!issues.length) {
html += `<p class="muted">未发现问题。</p>`;
}
for (const i of issues) {
const sev = (i.severity || "minor").toLowerCase();
const sevLabel = sevLabels[sev] || i.severity;
html += `<div class="issue">
<span class="tag ${esc(sev)}">${esc(sevLabel)}</span>
<span class="cat">${esc(i.category)}</span>
${esc(i.message || "")}
${i.confidence != null ? `<span class="muted">(置信度 ${esc(i.confidence)}/10)</span>` : ""}
${i.location ? `<div class="loc">→ ${esc(i.location)}</div>` : ""}
${i.fix ? `<div class="fix"><b>修复建议:</b> ${esc(i.fix)}</div>` : ""}
</div>`;
}
const manual = data.manual_review || [];
if (manual.length) {
html += `<h2>需要人工审查</h2><ul>`;
for (const m of manual) html += `<li>${esc(m)}</li>`;
html += `</ul>`;
}
const judged = data.llm_judged || [];
if (judged.length) {
html += `<p class="muted"><b>需 LLM 判断的指标:</b> ${judged.map(esc).join(", ")}</p>`;
}
document.getElementById("app").innerHTML = html;
</script>
</body>
</html>
@@ -0,0 +1,82 @@
# Unified Review — Generic Checklist
Reference for the Layer-1 chain decomposition and gstack CRITICAL sub-pass.
Load the language-specific checklist when available (`java-spring.md`,
`python-django.md`, `python-fastapi.md`, `node-express.md`, `go-gin.md`,
`csharp-dotnet.md`, `rust.md`, `php-laravel.md`, `ruby-rails.md`); otherwise
use this generic list.
## Layer 1 — eight categories
For each changed area mark ✅ Clean / ⚠️ Issues Found / — N/A.
1. **Interface** — parameter validation, response conventions, HTTP status
codes, rate limiting, API versioning, protocol correctness
2. **Business** — logic aligns with requirements, state machine correctness,
idempotency design, distributed locks
3. **Data** — SQL injection, query performance, index usage, transaction
boundaries, cache invalidation
4. **Utility** — input validity, no side effects, error return values,
date/time timezone handling
5. **Error handling** — exception classification, fallback logic, error
message sanitization, retry with backoff
6. **Security** — AuthN/AuthZ, sensitive data masking, permission control,
CSRF/XSS prevention
7. **Performance** — N+1 queries, caching strategy, connection pooling,
batch operations, blocking in async paths
8. **Observability** — structured logging with correlation IDs, metrics,
health checks
## gstack CRITICAL sub-pass (highest severity)
### SQL & Data Safety
- String interpolation in SQL — use parameterized queries
- TOCTOU check-then-set — use atomic `WHERE` + update
- Bypassing model validations for direct DB writes
- N+1 queries — missing eager loading
### Race Conditions & Concurrency
- Read-check-write without uniqueness constraint / duplicate-key retry
- find-or-create without a unique DB index
- Status transitions not atomic (`WHERE old_status = ? UPDATE ...`)
- Unsafe HTML rendering on user-controlled data
### LLM Output Trust Boundary
- LLM-generated values (emails, URLs, names) written to DB without format
validation
- Structured tool output accepted without type/shape checks
- LLM-generated URLs fetched without an allowlist (SSRF)
- LLM output stored in knowledge bases without sanitization (stored prompt
injection)
### Shell Injection
- `subprocess` with `shell=True` AND interpolated command strings
- `os.system()` with variable interpolation
- `eval()`/`exec()` on LLM-generated code without sandboxing
### Enum & Value Completeness
- New enum/status/tier values: read (not just grep) every consumer that
switches/filters/displays the value
- Check allowlists and `case`/`if-elsif` chains for fall-through
## Suppressions — do NOT flag
- Harmless redundancy that aids readability
- "Add a comment explaining a threshold" — thresholds drift
- Consistency-only changes
- Anything already addressed in the diff
## Severity
- 🔴 **blocker** — must fix before merge (injection, secrets, missing
transaction, auth bypass) → verdict FAIL
- 🟡 **major** — should fix before merge (missing validation, missing
fallback, N+1, unmasked data)
- 🔵 **minor** — can optimize later (naming, duplicate code, comments)
## Confidence calibration
- 9-10 verified by reading specific code
- 7-8 high-confidence pattern match
- 5-6 medium — show with caveat
- 3-4 low — move to appendix
- 1-2 speculation — suppress unless severity would be P0
Every finding: `[SEVERITY] (confidence: N/10) file:line — problem → fix`.
@@ -0,0 +1,13 @@
# API Contract Specialist
Focus: API and interface contract changes in the diff.
- [ ] Breaking changes to public endpoints (paths, params, response shape)
- [ ] Versioning compatibility (deprecations, fallbacks)
- [ ] Request/response validation matches the schema
- [ ] Error response shape is consistent
- [ ] Authentication/authorization behavior unchanged for existing consumers
- [ ] Renamed/moved functions: all callers updated
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"api-contract","summary":"...","fix":"...","source":"api-contract"}`
@@ -0,0 +1,15 @@
# Data Migration Specialist
Focus: database schema and data migrations in the diff.
- [ ] Migration idempotent and re-runnable
- [ ] Forward + rollback paths defined
- [ ] Backfill batched / resumable
- [ ] Type/precision changes do not truncate data
- [ ] Nullability/default changes safe for existing rows
- [ ] Application deploy compatible with old + new schema (expand/contract)
Insurance specialist — always runs, even when silent.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"data-migration","summary":"...","fix":"...","source":"data-migration"}`
@@ -0,0 +1,16 @@
# Maintainability Specialist
Focus: code quality and maintainability issues.
- [ ] Dead code / unreachable branches / unused variables
- [ ] Magic numbers → named constants
- [ ] Overcomplicated abstractions (indirection without payoff)
- [ ] Copy-paste blocks that should be shared (only when it aids clarity)
- [ ] Functions too large / doing too much
- [ ] Stale comments contradicting the code
Suppress: harmless redundancy that aids readability, comment-on-threshold
requests, consistency-only changes.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"maintainability","summary":"...","fix":"...","source":"maintainability"}`
@@ -0,0 +1,14 @@
# Performance Specialist
Focus: performance and resource efficiency in the diff.
- [ ] N+1 queries — missing eager loading
- [ ] Unindexed lookups in hot loops
- [ ] O(n×m) lookups in views/loops
- [ ] Blocking calls in async paths (sync subprocess, requests, sleep)
- [ ] Connection pool exhaustion, unbounded retries
- [ ] Bundle/asset size regressions (frontend)
- [ ] Redundant recomputation / missing caching
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"performance","summary":"...","fix":"...","source":"performance"}`
@@ -0,0 +1,18 @@
# Red Team Specialist (conditional)
Focus: find what the primary and specialist reviewers MISSED. Only dispatched
when the diff is large (>200 lines) or a specialist found a critical issue.
Think like an attacker and a chaos engineer:
- [ ] Cross-cutting concerns the specialist checklists do not cover
- [ ] Integration boundary failures (service-to-service, module-to-module)
- [ ] Failure modes: what breaks in production under load, restart, partial
failure
- [ ] Silent data corruption paths (wrong results without errors)
- [ ] Error handling that swallows failures
- [ ] Trust boundary violations
- [ ] Race conditions and edge cases the primary review missed
Be adversarial. No compliments — just the problems. Tag findings with
`"source":"red-team"`. Output `NO FINDINGS` when nothing new is found.
@@ -0,0 +1,17 @@
# Security Specialist
Focus: security vulnerabilities in the diff.
- [ ] SQL injection (string interpolation, parameterized queries)
- [ ] AuthN/AuthZ bypasses, missing permission checks
- [ ] XSS (unsafe HTML rendering on user data)
- [ ] Sensitive data exposure / missing masking in logs and responses
- [ ] SSRF (fetching user/LLM-controlled URLs without allowlist)
- [ ] Command injection (`shell=True` + interpolation)
- [ ] Hardcoded secrets / credentials
- [ ] CSRF / missing rate limiting on auth endpoints
Insurance specialist — always runs, even when silent.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"security","summary":"...","fix":"...","source":"security"}`
@@ -0,0 +1,14 @@
# Testing Specialist
Focus: test coverage gaps and tests that would catch the issues found.
- [ ] Every changed function has at least a happy-path test
- [ ] Negative/error paths tested (invalid input, failure branches)
- [ ] Edge cases mirror the happy-path structure
- [ ] If the fix for a finding can be caught by a test, propose a minimal
`test_stub` (framework-detected: jest/vitest/rspec/pytest/go-test)
- [ ] Integration coverage for critical flows (DB, external calls)
- [ ] No assertion-only tests that pass trivially
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"testing","summary":"...","fix":"...","test_stub":"...","source":"testing"}`
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""Aggregate per-file deep-read quality (V2.1 three-part gate).
A-stage implementation run by the main agent after each sub-agent wave.
Reads sub-agent output JSON files that were written to disk (not returned
into the main context), computes the three-part gate per file:
unit completeness : sub-agent semantic_units must cover every graph unit
(Function/Class/Test node) of the file. Hard, 100%.
Giant files (units<3 or max unit >80% of lines) are
exempt from this gate (unit_exempt_files).
line coverage : |union(read_ranges)| / real line count >= 95%.
Denominator is the REAL file line count read once
(cached), NOT graph node line_end (verified +-1 skew).
anti-fake budget : reported to the main agent; only a sampled re-read
can detect fake reads (engine cannot).
Usage:
python aggregate_deep_read.py <repo_root> <out_dir>
<repo_root> repository root (for graph + real file reads)
<out_dir> directory containing sub-agent result JSON files
(each: {"path": rel, "total_lines": int,
"read_ranges": [[s,e],...],
"semantic_units": [{"range":[s,e],"kind","name"}],
"findings": [...]})
Outputs to stdout:
{status, line_gap_files[], unit_gap_files[], unit_exempt_files[],
verified_files[], summary}
"""
import json
import sys
from pathlib import Path
def _abs(root: Path, rel: str) -> Path:
p = Path(rel)
return p if p.is_absolute() else root / p
def _real_line_count(root: Path, rel: str, cache: dict) -> int:
"""Real file line count, cached. Independent of graph node line_end."""
if rel in cache:
return cache[rel]
p = _abs(root, rel)
try:
n = len(p.read_text(encoding="utf-8", errors="replace").splitlines())
except OSError:
n = 0
cache[rel] = n
return n
def _union_len(ranges: list) -> int:
"""Covered line count of a list of inclusive [s,e] ranges."""
if not ranges:
return 0
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return sum(e - s + 1 for s, e in merged)
def _graph_units(store, root: Path, rel: str) -> list[dict]:
"""Graph semantic-unit nodes (Function/Class/Test) with line ranges."""
abs_path = _abs(root, rel).as_posix().replace("/", "\\")
q = abs_path.replace("\\", "/")
try:
rows = store._conn.execute(
"SELECT kind, name, line_start, line_end FROM nodes "
"WHERE file_path = ? AND kind IN ('Function','Class','Test') "
"ORDER BY line_start",
(q,),
).fetchall()
except Exception:
return []
out = []
for r in rows:
try:
out.append(
{
"kind": r["kind"],
"name": r["name"],
"line_start": int(r["line_start"]),
"line_end": int(r["line_end"]),
}
)
except (KeyError, TypeError):
continue
return out
def _overlap(a: list, b: list) -> int:
lo, hi = max(a[0], b[0]), min(a[1], b[1])
return max(0, hi - lo + 1)
def _unit_covered(
graph_unit: dict,
read_ranges: list,
unit_ranges: list,
matched_uids: set,
) -> bool:
"""A graph unit is covered iff one reported unit range (a) overlaps its
range by >=80% of the graph unit's span, AND (b) is not already claimed by
a higher-overlap graph unit (one-to-one matching), AND (c) >=80% of the
graph unit's lines fall inside union(read_ranges).
The one-to-one rule stops a single wide range (e.g. the whole file) from
covering every unit by pretending to be all of them."""
gs, ge = graph_unit["line_start"], graph_unit["line_end"]
gspan = max(1, ge - gs + 1)
# exact-range match wins (handles nested/overlapping graph units)
exact = [i for i, (s, e) in enumerate(unit_ranges) if s == gs and e == ge]
if exact:
best_idx = exact[0]
if best_idx in matched_uids:
return False
matched_uids.add(best_idx)
else:
best_idx, best_overlap = None, 0
for i, (s, e) in enumerate(unit_ranges):
ov = _overlap([gs, ge], [s, e])
if ov > best_overlap:
best_overlap, best_idx = ov, i
if best_idx is None or best_idx in matched_uids:
return False
if best_overlap / gspan < 0.8:
return False
matched_uids.add(best_idx)
in_union = 0
for s, e in _merge_ranges(read_ranges):
lo, hi = max(gs, s), min(ge, e)
if lo <= hi:
in_union += hi - lo + 1
if (in_union / gspan) < 0.8:
return False
matched_uids.add(best_idx)
return True
def _merge_ranges(ranges: list) -> list[list[int]]:
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges or []):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def _is_giant_file(graph_units: list, real_lines: int) -> bool:
"""unit-exempt when the largest unit spans >80% of the file's lines.
Aligned with the engine (scoring.py _is_giant_file): small files with a
few ordinary units are NOT exempt - they must cover every unit. A single
huge function (e.g. migrations.rs run_migrations = 98%) makes unit
completeness meaningless, so such files are checked on line coverage only.
"""
if not graph_units or real_lines <= 0:
return False
largest = max(u["line_end"] - u["line_start"] + 1 for u in graph_units)
return (largest / real_lines) > 0.8
def _load_subagent_files(out_dir: Path) -> list[dict]:
payloads = []
for f in sorted(out_dir.glob("*.json")):
try:
payloads.append(json.loads(f.read_text(encoding="utf-8")))
except (OSError, ValueError) as exc:
print(json.dumps({"status": "error", "file": str(f), "error": str(exc)}))
return payloads
def aggregate(repo_root: str, out_dir: str):
root = Path(repo_root)
out = Path(out_dir)
# engine import path: prefer CRG_ENGINE env, else known local checkout
engine = (
__import__("os").environ.get("CRG_ENGINE")
or r"D:\code-review-graph\code-review-graph-main"
)
sys.path.insert(0, engine)
from code_review_graph.tools._common import _get_store
store, root2 = _get_store(repo_root)
cache: dict = {}
verified: list[str] = []
line_gap: list[dict] = []
unit_gap: list[dict] = []
exempt: list[dict] = []
seen: set = set()
for payload in _load_subagent_files(out):
rel = (payload.get("path") or "").replace("\\", "/")
if not rel or rel in seen:
continue
seen.add(rel)
ranges = payload.get("read_ranges") or []
units = payload.get("semantic_units") or []
reported_total = payload.get("total_lines")
real = _real_line_count(root, rel, cache)
if real == 0:
continue
line_cov = _union_len(ranges) / real
g_units = _graph_units(store, root, rel)
giant = _is_giant_file(g_units, real)
unit_ranges = [tuple(map(int, (u.get("range") or [0, 0])[:2])) for u in units]
# ① unit completeness
if g_units and not giant:
matched_uids: set = set()
uncovered = [
{"name": u["name"], "range": [u["line_start"], u["line_end"]]}
for u in g_units
if not _unit_covered(u, ranges, unit_ranges, matched_uids)
]
if uncovered:
unit_gap.append(
{
"path": rel,
"total_units": len(g_units),
"covered_units": len(g_units) - len(uncovered),
"uncovered": uncovered,
}
)
# a unit gap also fails the file
continue
elif g_units and giant:
exempt.append(
{
"path": rel,
"reason": "giant-file: units=%d largest_span=%.0f%%"
% (len(g_units), 100 * max(u["line_end"] - u["line_start"] + 1 for u in g_units) / real),
}
)
# ② line coverage (applies to every file)
if line_cov < 0.95:
line_gap.append(
{
"path": rel,
"coverage_pct": round(line_cov * 100, 1),
"total_lines": real,
"covered_lines": _union_len(ranges),
"reported_total": reported_total,
}
)
continue
verified.append(rel)
store.close()
result = {
"status": "ok",
"verified_files": sorted(verified),
"line_gap_files": sorted(line_gap, key=lambda x: x["coverage_pct"]),
"unit_gap_files": sorted(unit_gap, key=lambda x: x["total_units"]),
"unit_exempt_files": sorted(exempt, key=lambda x: x["path"]),
"summary": (
f"verified={len(verified)} line_gap={len(line_gap)} "
f"unit_gap={len(unit_gap)} unit_exempt={len(exempt)} "
f"(unit_exempt are checked on line coverage only)"
),
}
print(json.dumps(result, ensure_ascii=False, indent=1))
if __name__ == "__main__":
if len(sys.argv) != 3:
print(__doc__)
sys.exit(2)
aggregate(sys.argv[1], sys.argv[2])
@@ -0,0 +1,151 @@
<#
.SYNOPSIS
Verify that a code-review report carries a line-coverage gate (fail-closed).
.DESCRIPTION
Enforces the "three-piece suite" guarantee defined in project-review/SKILL.md
Step 8.7: whole-project / feature review reports MUST contain a line coverage
(xingfugai) field in their coverage section, and its value MUST be >= the
configured target (default 95%). A missing field means the review never ran
coverage_tool(gate="both+line") (or forgot the three-piece data) and the
report would otherwise look green with no line coverage.
Scope handling: only whole-project and feature reviews are gated; other
scopes (e.g. change-level) are skipped.
Exit codes: 0 = line coverage present and >= target (or scope not gated);
1 = blocking violation (missing line coverage or below target).
.PARAMETER Repo
Repository root. Defaults to the current directory.
.PARAMETER Report
Optional relative path (under docs/reviews/) to a specific report file.
Defaults to the newest *.md report in docs/reviews/.
.PARAMETER Target
Minimum line-coverage percentage. Default 95.
.EXAMPLE
powershell -File verify-line-coverage.ps1 -Repo D:\AuraSpace
powershell -File verify-line-coverage.ps1 -Repo D:\AuraSpace -Report full-project-review-2026-08-17-101500.md
#>
param(
[string]$Repo = "",
[string]$Report = "",
[double]$Target = 95.0
)
$ErrorActionPreference = "Stop"
function Write-Step([string]$msg) { Write-Host " $msg" -ForegroundColor DarkGray }
function Write-Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Bad([string]$msg) { Write-Host " [!!] $msg" -ForegroundColor Red }
function Write-Warn([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
# Resolve repo root ---------------------------------------------------------
if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = (Get-Location).Path }
$root = (Resolve-Path -LiteralPath $Repo).Path
$reviewsDir = Join-Path $root "docs\reviews"
Write-Host "verify-line-coverage: repo = $root (target $Target%)"
Write-Host ""
# Locate the report --------------------------------------------------------
$reportPath = ""
if (-not [string]::IsNullOrWhiteSpace($Report)) {
$reportPath = Join-Path $reviewsDir $Report
if (-not (Test-Path -LiteralPath $reportPath)) {
Write-Bad "report not found: $reportPath"
exit 1
}
} else {
if (-not (Test-Path -LiteralPath $reviewsDir)) {
Write-Warn "docs/reviews/ does not exist - no report to verify"
Write-Host "RESULT: PASS (no report)" -ForegroundColor Green
exit 0
}
$reportPath = Get-ChildItem -LiteralPath $reviewsDir -File |
Where-Object { $_.Extension -eq ".md" } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $reportPath) {
Write-Warn "no .md report in docs/reviews/ - nothing to verify"
Write-Host "RESULT: PASS (no report)" -ForegroundColor Green
exit 0
}
}
Write-Step "checking report: $reportPath"
# Parse scope from header (line like: 档位:standard · 范围:whole-project · 基线:...)
$content = [System.IO.File]::ReadAllText($reportPath, [System.Text.Encoding]::UTF8)
$scope = ""
$scopeLine = ($content -split "`n" | Where-Object { $_ -match "范围" } | Select-Object -First 1)
if ($scopeLine -and $scopeLine -match "范围\**[:]\s*([^\s·*]+)") {
$scope = $Matches[1].Trim()
}
# Scope gate: only change-level reviews are skipped. whole-project / feature
# AND any custom scope value (e.g. scope="evm") that still produced a coverage
# section must be checked for line coverage.
if ($scope -eq "change-level") {
Write-Warn "scope = '$scope' is not gated on line coverage (change-level only)"
Write-Host "RESULT: PASS (scope not gated)" -ForegroundColor Green
exit 0
}
Write-Step "scope = '$scope' (gated)"
# Parse ## 覆盖度 section
$covSection = ""
$lines = $content -split "`r?`n"
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match "^##\s*\**覆盖度\**") {
$j = $i + 1
$buf = @()
while ($j -lt $lines.Count -and -not ($lines[$j] -match "^##\s")) {
$buf += $lines[$j]; $j++
}
$covSection = $buf -join "`n"
break
}
}
if (-not $covSection) {
Write-Bad "no coverage section found - line coverage cannot be verified"
exit 1
}
# Line-coverage field present?
$lineLine = ($covSection -split "`n" | Where-Object { $_ -match "行覆盖" } | Select-Object -First 1)
if (-not $lineLine) {
Write-Bad "coverage section has NO line-coverage field - gate='both+line' was not run (or three-piece data missing)"
Write-Step "coverage section:"
foreach ($l in ($covSection -split "`n" | Where-Object { $_.Trim() })) { Write-Step " $($l.Trim())" }
exit 1
}
# "未执行" marker means gate never ran
if ($lineLine -match "未执行") {
Write-Bad "line coverage = NOT RUN (gate='both+line' not run or three-piece data missing)"
Write-Step $lineLine.Trim()
exit 1
}
# Extract percentage (行覆盖:99.8% — 目标 95.0%(缺口 0 文件)✅)
if ($lineLine -match "行覆盖\**[:]\s*([0-9.]+)\s*%") {
$pct = [double]$Matches[1]
if ($pct -lt $Target) {
Write-Bad "line coverage $pct% < target $Target%"
Write-Step $lineLine.Trim()
exit 1
}
Write-Ok "line coverage = $pct% (>= $Target%)"
if ($lineLine -match "缺口\s*([0-9]+)\s*文件" -and [int]$Matches[1] -gt 0) {
Write-Warn "line-coverage gap files present: $($Matches[1])"
}
Write-Host "RESULT: PASS - line coverage verified" -ForegroundColor Green
exit 0
}
Write-Bad "unable to parse line-coverage percentage from: $($lineLine.Trim())"
exit 1
@@ -0,0 +1,124 @@
<#
.SYNOPSIS
Verify / repair code-review report file naming compliance.
.DESCRIPTION
Enforces the archive convention defined in project-review/SKILL.md:
every review report must live under <repo>/docs/reviews/ and be named
<name>-review-YYYY-MM-DD-HHMMSS.{md,html} (exact timestamp suffix).
Two modes:
(default) verify - scan and report non-compliance; exit 0 = clean, 1 = violations.
-Fix - move stray root-level code-review-report.* files into
docs/reviews/ with a timestamp suffix, then verify.
Violations (blocking, exit 1): stray root-level code-review-report.* files.
Warnings (non-blocking): docs/reviews/ files not carrying a -YYYY-MM-DD-HHMMSS
suffix. Historic files are only reported, never renamed automatically.
.PARAMETER Repo
Repository root. Defaults to the current directory.
.PARAMETER Fix
Repair blocking violations automatically (archive stray root reports).
.EXAMPLE
powershell -File verify-report.ps1 -Repo D:\AuraSpace
powershell -File verify-report.ps1 -Repo D:\AuraSpace -Fix
#>
param(
[string]$Repo = "",
[switch]$Fix
)
$ErrorActionPreference = "Stop"
function Write-Step([string]$msg) { Write-Host " $msg" -ForegroundColor DarkGray }
function Write-Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Bad([string]$msg) { Write-Host " [!!] $msg" -ForegroundColor Red }
function Write-Warn([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
# Resolve repo root ---------------------------------------------------------
if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = (Get-Location).Path }
$root = (Resolve-Path -LiteralPath $Repo).Path
$reviewsDir = Join-Path $root "docs\reviews"
$timestampRe = '^(.+?-review)-\d{4}-\d{2}-\d{2}-\d{6}\.(md|html)$'
Write-Host "verify-report: repo = $root"
Write-Host ""
# Phase 0 - optional repair -------------------------------------------------
if ($Fix) {
Write-Host "Fix mode: archiving stray root-level code-review-report.*"
$stray = Get-ChildItem -LiteralPath $root -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^code-review-report\.(md|html)$' }
if ($stray) {
New-Item -ItemType Directory -Path $reviewsDir -Force | Out-Null
foreach ($f in $stray) {
$ts = $f.LastWriteTime.ToString("yyyy-MM-dd-HHmmss")
$target = Join-Path $reviewsDir "report-review-$ts$($f.Extension)"
if (Test-Path -LiteralPath $target) {
$ts2 = $f.LastWriteTime.ToString("yyyy-MM-dd-HHmmss-fff")
$target = Join-Path $reviewsDir "report-review-$ts2$($f.Extension)"
}
Move-Item -LiteralPath $f.FullName -Destination $target -Force
Write-Ok "archived $($f.Name) -> $target"
}
} else {
Write-Ok "no stray root-level reports to archive"
}
Write-Host ""
}
# Phase 1 - verify root has no stray reports (BLOCKING) ---------------------
$violations = @()
$warnings = @()
Write-Host "1. Root-level report files (blocking):"
$rootStray = Get-ChildItem -LiteralPath $root -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^code-review-report\.(md|html)$' }
if ($rootStray) {
foreach ($f in $rootStray) {
Write-Bad "stray root report: $($f.FullName)"
$violations += $f.FullName
}
} else {
Write-Ok "no stray root-level code-review-report.* files"
}
Write-Host ""
# Phase 2 - verify docs/reviews naming (WARNING only) -----------------------
Write-Host "2. docs/reviews/*.{md,html} naming (should carry -YYYY-MM-DD-HHMMSS):"
if (-not (Test-Path -LiteralPath $reviewsDir)) {
Write-Ok "docs/reviews/ does not exist yet - nothing to verify"
} else {
$reports = Get-ChildItem -LiteralPath $reviewsDir -File |
Where-Object { $_.Extension -in @(".md", ".html") }
if (-not $reports) {
Write-Ok "no report files present"
} else {
foreach ($f in $reports) {
if ($f.Name -match $timestampRe) {
Write-Ok $f.Name
} else {
Write-Warn "non-conforming name (historic, not auto-fixed): $($f.Name)"
$warnings += $f.FullName
}
}
}
}
Write-Host ""
# Verdict -------------------------------------------------------------------
if ($violations.Count -gt 0) {
Write-Host "RESULT: FAIL ($($violations.Count) blocking violation(s))" -ForegroundColor Red
exit 1
} else {
if ($warnings.Count -gt 0) {
Write-Host "RESULT: PASS ($($warnings.Count) non-blocking historic naming warning(s) - rename manually if desired)" -ForegroundColor Green
} else {
Write-Host "RESULT: PASS - all review reports are archived under docs/reviews/ with timestamp suffixes" -ForegroundColor Green
}
exit 0
}
@@ -0,0 +1,152 @@
<#
.SYNOPSIS
Verify that a code-review report carries an anti-fake spot-check (fail-closed).
.DESCRIPTION
Enforces the "three-piece suite" item 3 (anti-fake spot check) defined in
project-review/SKILL.md Step 8.8: whole-project / feature review reports
MUST contain a "防伪抽验" field in their coverage section, and it must NOT
be "未执行" (i.e. the main agent must have sampled re-read semantic units
from the sub-agent batches). A missing field means the review never ran
the anti-fake spot check and the report would otherwise look green with no
anti-fake guarantee.
Scope handling: only whole-project and feature reviews are gated (they use
the sub-agent deep-read pipeline); change-level reviews skip.
Exit codes: 0 = spot check present and non-zero; 1 = blocking violation
(missing spot check or reported as 未执行 / zero units).
.PARAMETER Repo
Repository root. Defaults to the current directory.
.PARAMETER Report
Optional relative path (under docs/reviews/) to a specific report file.
Defaults to the newest *.md report in docs/reviews/.
.EXAMPLE
powershell -File verify-spot-check.ps1 -Repo D:\AuraSpace
powershell -File verify-spot-check.ps1 -Repo D:\AuraSpace -Report full-project-review-2026-08-18-144500.md
#>
param(
[string]$Repo = "",
[string]$Report = ""
)
$ErrorActionPreference = "Stop"
function Write-Step([string]$msg) { Write-Host " $msg" -ForegroundColor DarkGray }
function Write-Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
function Write-Bad([string]$msg) { Write-Host " [!!] $msg" -ForegroundColor Red }
function Write-Warn([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow }
# Resolve repo root ---------------------------------------------------------
if ([string]::IsNullOrWhiteSpace($Repo)) { $Repo = (Get-Location).Path }
$root = (Resolve-Path -LiteralPath $Repo).Path
$reviewsDir = Join-Path $root "docs\reviews"
Write-Host "verify-spot-check: repo = $root"
Write-Host ""
# Locate the report --------------------------------------------------------
$reportPath = ""
if (-not [string]::IsNullOrWhiteSpace($Report)) {
$reportPath = Join-Path $reviewsDir $Report
if (-not (Test-Path -LiteralPath $reportPath)) {
Write-Bad "report not found: $reportPath"
exit 1
}
} else {
if (-not (Test-Path -LiteralPath $reviewsDir)) {
Write-Warn "docs/reviews/ does not exist - no report to verify"
Write-Host "RESULT: PASS (no report)" -ForegroundColor Green
exit 0
}
$reportPath = Get-ChildItem -LiteralPath $reviewsDir -File |
Where-Object { $_.Extension -eq ".md" } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $reportPath) {
Write-Warn "no .md report in docs/reviews/ - nothing to verify"
Write-Host "RESULT: PASS (no report)" -ForegroundColor Green
exit 0
}
}
Write-Step "checking report: $reportPath"
# Parse scope from header (line like: 档位:standard · 范围:whole-project · 基线:...)
$content = [System.IO.File]::ReadAllText($reportPath, [System.Text.Encoding]::UTF8)
$scope = ""
$scopeLine = ($content -split "`n" | Where-Object { $_ -match "范围" } | Select-Object -First 1)
if ($scopeLine -and $scopeLine -match "范围\**[:]\s*([^\s·*]+)") {
$scope = $Matches[1].Trim()
}
# Scope gate: only change-level reviews are skipped. whole-project / feature
# AND any custom scope value (e.g. scope="evm") that still produced a coverage
# section must be checked for the anti-fake spot check.
if ($scope -eq "change-level") {
Write-Warn "scope = '$scope' is not gated on anti-fake spot check (change-level only)"
Write-Host "RESULT: PASS (scope not gated)" -ForegroundColor Green
exit 0
}
Write-Step "scope = '$scope' (gated)"
# Parse ## 覆盖度 section
$covSection = ""
$lines = $content -split "`r?`n"
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match "^##\s*\**覆盖度\**") {
$j = $i + 1
$buf = @()
while ($j -lt $lines.Count -and -not ($lines[$j] -match "^##\s")) {
$buf += $lines[$j]; $j++
}
$covSection = $buf -join "`n"
break
}
}
if (-not $covSection) {
Write-Bad "no coverage section found - anti-fake spot check cannot be verified"
exit 1
}
# Anti-fake spot check field present?
$spotLine = ($covSection -split "`n" | Where-Object { $_ -match "防伪抽验" } | Select-Object -First 1)
if (-not $spotLine) {
Write-Bad "coverage section has NO anti-fake spot-check field - Step 5.5 spot check was not run (or spot_check not passed to generate_report_tool)"
Write-Step "coverage section:"
foreach ($l in ($covSection -split "`n" | Where-Object { $_.Trim() })) { Write-Step " $($l.Trim())" }
exit 1
}
# "未执行" marker means spot check never ran
if ($spotLine -match "未执行") {
Write-Bad "anti-fake spot check = NOT RUN (no semantic-unit re-read sampled)"
Write-Step $spotLine.Trim()
exit 1
}
# Present and non-zero: extract sampled counts (防伪抽验:抽样 X 文件 / Y 单元 / Z 组,假读 N ✅)
if ($spotLine -match "抽样\s*(\d+)\s*文件\s*/\s*(\d+)\s*单元") {
$filesSampled = [int]$Matches[1]
$unitsSampled = [int]$Matches[2]
if ($unitsSampled -le 0) {
Write-Bad "anti-fake spot check reported but 0 units sampled"
Write-Step $spotLine.Trim()
exit 1
}
Write-Ok "anti-fake spot check = $filesSampled files / $unitsSampled units sampled"
if ($spotLine -match "假读\s*(\d+)") {
$fake = [int]$Matches[1]
if ($fake -gt 0) {
Write-Warn "fake reads detected: $fake - confirm affected groups were re-read"
}
}
Write-Host "RESULT: PASS - anti-fake spot check verified" -ForegroundColor Green
exit 0
}
Write-Bad "unable to parse anti-fake spot-check counts from: $($spotLine.Trim())"
exit 1
@@ -0,0 +1,28 @@
---
name: refactor-safely
description: Plan and execute safe refactoring using dependency analysis
---
## Refactor Safely
Use the knowledge graph to plan and execute refactoring with confidence.
### Steps
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
5. After changes, run `detect_changes_tool` to verify the refactoring impact.
### Safety Checks
- Always preview before applying (rename mode gives you an edit list).
- Check `get_impact_radius_tool` before major refactors.
- Use `get_affected_flows_tool` to ensure no critical paths are broken.
- Run `find_large_functions` to identify decomposition targets.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
@@ -0,0 +1,29 @@
---
name: review-changes
description: Perform a structured code review using change detection and impact
---
## Review Changes
Perform a thorough, risk-aware code review using the knowledge graph.
### Steps
1. Run `detect_changes_tool` to get risk-scored change analysis.
2. Run `get_affected_flows_tool` to find impacted execution paths.
3. For each high-risk function, run `query_graph_tool` with pattern="tests_for" to check test coverage.
4. Run `get_impact_radius_tool` to understand the blast radius.
5. For any untested changes, suggest specific test cases.
### Output Format
Provide findings grouped by risk level (high/medium/low) with:
- What changed and why it matters
- Test coverage status
- Suggested improvements
- Overall merge recommendation
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
@@ -0,0 +1,46 @@
---
name: review-delta
description: Review only changes since last commit using impact analysis. Token-efficient delta review with automatic blast-radius detection.
argument-hint: "[file or function name]"
---
# Review Delta
Perform a focused, token-efficient code review of only the changed code and its blast radius.
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="review-delta")` for the optimized workflow. Use ONLY changed nodes + 2-hop neighbors in context.
## Steps
1. **Ensure the graph is current** by calling `build_or_update_graph_tool()` (incremental update).
2. **Get review context** by calling `get_review_context_tool()`. This returns:
- Changed files (auto-detected from git diff)
- Impacted nodes and files (blast radius)
- Source code snippets for changed areas
- Review guidance (test coverage gaps, wide impact warnings, inheritance concerns)
3. **Analyze the blast radius** by reviewing the `impacted_nodes` and `impacted_files` in the context. Focus on:
- Functions whose callers changed (may need signature/behavior verification)
- Classes with inheritance changes (Liskov substitution concerns)
- Files with many dependents (high-risk changes)
4. **Perform the review** using the context. For each changed file:
- Review the source snippet for correctness, style, and potential bugs
- Check if impacted callers/dependents need updates
- Verify test coverage using `query_graph_tool(pattern="tests_for", target=<function_name>)`
- Flag any untested changed functions
5. **Report findings** in a structured format:
- **Summary**: One-line overview of the changes
- **Risk level**: Low / Medium / High (based on blast radius)
- **Issues found**: Bugs, style issues, missing tests
- **Blast radius**: List of impacted files/functions
- **Recommendations**: Actionable suggestions
## Advantages Over Full-Repo Review
- Only sends changed + impacted code to the model (5-10x fewer tokens)
- Automatically identifies blast radius without manual file searching
- Provides structural context (who calls what, inheritance chains)
- Flags untested functions automatically
+66
View File
@@ -0,0 +1,66 @@
---
name: review-pr
description: Review a PR or branch diff using the knowledge graph for full structural context. Outputs a structured review with blast-radius analysis.
argument-hint: "[PR number or branch name]"
---
# Review PR
Perform a comprehensive code review of a pull request or branch diff using the knowledge graph.
**Token optimization:** Before starting, call `get_docs_section_tool(section_name="review-pr")` for the optimized workflow. Never include full files unless explicitly asked.
## Steps
1. **Identify the changes** for the PR:
- If a PR number or branch is provided, use `git diff main...<branch>` to get changed files
- Otherwise auto-detect from the current branch vs main/master
2. **Update the graph** by calling `build_or_update_graph_tool(base="main")` to ensure the graph reflects the current state.
3. **Get the full review context** by calling `get_review_context_tool(base="main")`:
- This uses `main` (or the specified base branch) as the diff base
- Returns all changed files across all commits in the PR
4. **Analyze impact** by calling `get_impact_radius_tool(base="main")`:
- Review the blast radius across the entire PR
- Identify high-risk areas (widely depended-upon code)
5. **Deep-dive each changed file**:
- Read the full source of files with significant changes
- Use `query_graph_tool(pattern="callers_of", target=<func>)` for high-risk functions
- Use `query_graph_tool(pattern="tests_for", target=<func>)` to verify test coverage
- Check for breaking changes in public APIs
6. **Generate structured review output**:
```
## PR Review: <title>
### Summary
<1-3 sentence overview>
### Risk Assessment
- **Overall risk**: Low / Medium / High
- **Blast radius**: X files, Y functions impacted
- **Test coverage**: N changed functions covered / M total
### File-by-File Review
#### <file_path>
- Changes: <description>
- Impact: <who depends on this>
- Issues: <bugs, style, concerns>
### Missing Tests
- <function_name> in <file> - no test coverage found
### Recommendations
1. <actionable suggestion>
2. <actionable suggestion>
```
## Tips
- For large PRs, focus on the highest-impact files first (most dependents)
- Use `semantic_search_nodes_tool` to find related code the PR might have missed
- Check if renamed/moved functions have updated all callers
@@ -0,0 +1,69 @@
---
name: unified-review
description: Three-layer unified code review fusing CRG graph context with ai-code-review scoring methodology and gstack-review fix-first workflow
---
# Unified Review
Perform a three-layer, read-only code review that fuses:
- **CRG graph context** (blast radius, test gaps, affected flows)
- **ai-code-review methodology** (Layer-1 chain decomposition, Layer-2 quantitative scoring, Layer-3 acceptance)
- **gstack-review workflow** (confidence calibration, fix-first, specialist subagents, review-log persistence)
**This skill is READ-ONLY.** Every finding is presented to the user for a manual fix decision. Never apply code changes, commit, or push.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="unified review")`. Use `detail_level="minimal"` on all calls; escalate to `"standard"` only when a metric or finding needs evidence.
## Step 0 - Scope
Review always runs at the fixed `standard` tier (all layers).
Detect the project language/framework and the review scope (change/file/service/chain level). Declare both in the report header.
## Step 1 - Graph context (CRG)
1. Call `build_or_update_graph_tool()` to ensure the graph is current.
2. Call `get_review_context_tool()` for changed files, blast radius, source snippets and review guidance.
3. Call `detect_changes_tool()` for risk-scored change analysis, test gaps and affected flows.
## Step 2 - Layer 1: Chain decomposition (ai-code-review)
Inspect the changed code across eight categories: interface, business, data, utility, error handling, security, performance, observability. Mark each `✅ Clean / ⚠️ Issues Found / — N/A`. Apply the gstack CRITICAL categories as a sub-pass: SQL & Data Safety, Race Conditions & Concurrency, LLM Output Trust Boundary, Shell Injection, and Enum & Value Completeness. Enum completeness requires reading code OUTSIDE the diff (Grep for sibling values, then Read each consumer).
## Step 3 - Layer 2: Quantitative scoring
Call `score_review_tool()` for the objective metrics (SQL risk, exception coverage, redundancy, high-risk density, vulnerability heuristic). The remaining metrics (requirement coverage, logic alignment, trust boundaries) are judged by you from the requirements doc or a generic baseline; without a requirements doc halve their weight in the verdict.
## Step 4 - Specialist dispatch (gstack, diff >= 50 lines)
When the diff has 50+ changed lines, dispatch specialist subagents in parallel via the Agent/task tool, each with a fresh context and its own checklist: testing, maintainability, security, performance, data-migration, api-contract. Security and data-migration always run (insurance). Collect each specialist's JSON findings.
## Step 5 - Merge and dedupe
Call `dedupe_findings_tool(findings=<all raw findings>)` to merge by fingerprint (`path:line:category`), boost multi-source confidence (+1, cap 10), route low-confidence findings to the appendix, and compute the PR quality score.
## Step 6 - Manual adjudication (READ-ONLY)
Present every merged finding with its severity (🔴 blocker / 🟡 major / 🔵 minor), confidence (1-10), file:line and a proposed fix. Group by severity and ask the user per batch: fix / skip / self-fix. 🔴 blockers cannot be batch-skipped. Record skipped findings for prior-review suppression on the next run. **Do not modify code.**
## Step 7 - Acceptance gate (ai-code-review)
Any 🔴 blocker → verdict `❌ FAIL` regardless of other scores. Classify each finding as Ready / Needs Fix / Unusable. Verify the change does not deviate from requirements or architecture conventions.
## Step 8 - Report
Call `generate_report_tool(review_data=<collected verdict, metrics, findings, tier, scope>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`). Also present the text report inline.
## Step 9 - Persistence (optional)
If the `gstack-review-log` binary is available, record the review outcome (status, counts, quality score, per-finding actions). If it is unavailable, skip silently.
## Output Format
`Unified Review: N issues (X blocker, Y major, Z minor) — verdict: ✅ PASS / ❌ FAIL`. List each issue with severity, confidence, file:line, problem, and proposed fix. List manual-review items (payment, order, inventory, permission, distributed-lock, data-migration) explicitly.
## Token Efficiency Rules
- ALWAYS start with `get_minimal_context(task="unified review")` before any other graph tool.
- Use `detail_level="minimal"` on all calls. Only escalate to `"standard"` when minimal is insufficient.
- Target: complete a unified review in ≤8 tool calls and ≤1200 total output tokens.
@@ -0,0 +1,19 @@
# Unified Review — Common Mistakes
- **Skipping graph context** — always run `get_minimal_context` first; CRG
context is what makes the review token-efficient and blast-radius aware.
- **Rushing to fix** — this skill is READ-ONLY. Present findings, wait for
user decision. Never apply fixes, commit, or push.
- **Ignoring tier** — read `.code-review.yaml`. `fast` skips Layer 2/3;
`strict` requires per-item confirmation for every blocker/major.
- **Judging metrics without evidence**`score_review_tool` outputs are
heuristics. Cite the evidence, and let the LLM confirm SQL/exception/vuln
findings before presenting them as facts.
- **Missing manual-review modules** — payment, order, inventory, permission,
distributed-lock, data-migration always require a manual review checklist.
- **Forgetting enum completeness reads OUTSIDE the diff** — grep sibling
values, then read each consumer; in-diff review alone is insufficient.
- **Batch-skipping blockers** — 🔴 blockers cannot be batch-skipped; each
needs an explicit user decision.
- **Not producing the report** — always call `generate_report_tool` at the
end and present the text report inline.
@@ -0,0 +1,12 @@
# Data Migration — Manual Review Checklist
High-risk module: schema/data migration requires human confirmation.
- [ ] Migration is idempotent and re-runnable
- [ ] Forward and rollback paths both defined and tested
- [ ] Backfill is batched / resumable for large tables
- [ ] Data type / precision changes do not silently truncate
- [ ] Nullability and default changes safe for existing rows
- [ ] Migration ordering across shards / replicas is consistent
- [ ] Application deploys compatibly with both old and new schema (expand/contract)
- [ ] Irreversible operations are flagged with a documented reason
@@ -0,0 +1,11 @@
# Distributed Lock — Manual Review Checklist
High-risk module: distributed-lock changes require architecture confirmation.
- [ ] Lock has a TTL / expiry — no permanent deadlock after crash
- [ ] Lock release is atomic and ownership-checked (compare-and-delete)
- [ ] Lock scope is correct (key includes the right entity identifiers)
- [ ] Renewal / watchdog exists for long critical sections
- [ ] Locking order is consistent across paths (no lock-ordering deadlock)
- [ ] Fencing tokens / version check prevents stale-holder writes
- [ ] Fail-open vs fail-closed behavior is intentional and documented
@@ -0,0 +1,11 @@
# Inventory Module — Manual Review Checklist
High-risk module: stock/inventory changes require human confirmation.
- [ ] Stock decrement is atomic (conditional UPDATE, not read-then-write)
- [ ] Oversell prevented: `UPDATE ... SET qty = qty - ? WHERE qty >= ?`
- [ ] Reservation vs. deduction semantics are consistent
- [ ] Concurrent orders cannot both reserve the last unit
- [ ] Restock/return increments handled correctly
- [ ] Inventory events are idempotent (retry-safe)
- [ ] Async stock updates propagate to downstream (warehouse, carts) safely
@@ -0,0 +1,12 @@
# Order Module — Manual Review Checklist
High-risk module: order lifecycle changes require human confirmation.
- [ ] State machine transitions are atomic (`WHERE status = ?` updates)
- [ ] Cancellation / timeout / expiry paths complete all side effects
- [ ] Order idempotency key prevents duplicate order creation
- [ ] Price/lock snapshot captured at order time, not at payment time
- [ ] Partial fulfillment / split-shipment handled
- [ ] Negative or inconsistent totals impossible
- [ ] Concurrent edits (cart + order) do not corrupt state
- [ ] Audit trail: every status change logged with reason
@@ -0,0 +1,14 @@
# Payment Module — Manual Review Checklist
High-risk module: payment changes require human confirmation for every
blocker/major fix.
- [ ] Callback idempotency: a duplicated webhook/callback does not double-charge
- [ ] Amounts stored as fixed-point (integers/cents), never floats
- [ ] Currency codes and precision handled correctly
- [ ] Provider signature / HMAC verification on callbacks
- [ ] Refund logic: correct reversal, no double-refund
- [ ] Failure path: payment timeout, declined, retry semantics
- [ ] Transaction boundary spans charge + order-state update
- [ ] Sensitive data (PAN, tokens) never logged or masked on output
- [ ] Ledger/journal entries are append-only and auditable
@@ -0,0 +1,11 @@
# Permission Module — Manual Review Checklist
High-risk module: authorization changes require product/human confirmation.
- [ ] Every endpoint/action enforces the intended permission — no default-allow
- [ ] Role hierarchy / scoping (tenant, org, user) is consistent
- [ ] Object-level permissions checked on read AND write
- [ ] Deny-before-allow ordering is safe
- [ ] Permission checks cannot be bypassed via IDs, query params, or bulk ops
- [ ] New permission/role values handled by all consumers (enum completeness)
- [ ] Sensitive actions audited with actor + target
@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>代码审查报告</title>
<style>
:root { --border:#d0d7de; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a;
--good:#1a7f37; --warn:#9a6700; --fail:#cf222e; --na:#57606a;
--blocker:#cf222e; --major:#9a6700; --minor:#57606a;
--critical:#cf222e; --informational:#0969da; }
* { box-sizing: border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin:0; padding:2rem 1rem; color:var(--fg); background:#fff; line-height:1.55; }
.wrap { max-width:960px; margin:0 auto; }
h1 { font-size:1.5rem; margin:0 0 .25rem; }
h2 { font-size:1.1rem; margin:1.5rem 0 .5rem; padding-bottom:.25rem;
border-bottom:1px solid var(--border); }
.meta { color:var(--muted); font-size:.9rem; margin-bottom:1.5rem; }
.verdict { display:inline-block; padding:.25rem .75rem; border-radius:20px;
font-weight:700; font-size:.95rem; }
.verdict.pass { background:#dafbe1; color:var(--good); }
.verdict.fail { background:#ffebe9; color:var(--fail); }
table { border-collapse:collapse; width:100%; margin:.75rem 0; }
th,td { border:1px solid var(--border); padding:.45rem .6rem; text-align:left;
font-size:.9rem; vertical-align:top; }
th { background:var(--bg); }
.grade.good { color:var(--good); font-weight:600; }
.grade.warn { color:var(--warn); font-weight:600; }
.grade.fail { color:var(--fail); font-weight:600; }
.grade.na { color:var(--na); }
.issue { margin:.6rem 0; padding:.65rem .8rem; border:1px solid var(--border);
border-radius:6px; background:#fff; }
.issue .tag { display:inline-block; padding:.1rem .5rem; border-radius:10px;
font-size:.75rem; font-weight:700; color:#fff; margin-right:.5rem; }
.tag.blocker, .tag.critical { background:var(--blocker); }
.tag.major, .tag.warn { background:var(--major); }
.tag.minor, .tag.informational { background:var(--minor); }
.issue .cat { font-weight:600; }
.issue .loc { color:var(--muted); font-size:.85rem; margin-top:.2rem; }
.issue .fix { margin-top:.35rem; font-size:.88rem; background:var(--bg);
padding:.4rem .6rem; border-radius:4px; }
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
</style>
</head>
<body>
<div class="wrap" id="app"></div>
<script>
const data = {{REPORT_DATA}};
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
})[c]);
}
function verdictClass(v) {
v = String(v || "").toUpperCase();
return v.includes("PASS") ? "pass" : "fail";
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
redundancy_rate: "代码冗余率",
high_risk_density: "高风险场景密度",
vulnerability_risk: "漏洞风险",
};
const gradeLabels = { good: "良好", warn: "警告", fail: "不合格", na: "不适用" };
const sevLabels = {
blocker: "🔴 阻塞", critical: "🔴 严重",
major: "🟡 主要", warn: "🟡 主要",
minor: "🔵 次要", informational: "🔵 次要",
};
let html = `<h1>代码审查报告</h1>
<div class="meta">
<span class="verdict ${verdictClass(data.verdict)}">${esc(data.verdict || "无结论")}</span>
&nbsp; 档位: <code>${esc(data.tier || "standard")}</code>
&nbsp; 范围: <code>${esc(data.scope || "change-level")}</code>
${data.baseline ? `&nbsp; 基线: <code>${esc(data.baseline)}</code>` : ""}
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>文件:</b> ${esc(data.files)}</p>`;
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
html += `<h2>客观指标</h2><table><tr><th>指标</th><th>数值</th><th>评级</th><th>说明</th></tr>`;
for (const k of mkeys) {
const m = metrics[k] || {};
const g = m.grade || "na";
const label = metricLabels[k] || k;
const gradeText = gradeLabels[g] || g;
html += `<tr>
<td>${esc(label)}</td>
<td>${esc(m.value ?? "N/A")}</td>
<td class="grade ${esc(g)}">${esc(gradeText)}</td>
<td class="muted">${esc(m.note || "")}</td>
</tr>`;
}
html += `</table>`;
}
const issues = data.issues || [];
html += `<h2>问题清单 (${issues.length})</h2>`;
if (!issues.length) {
html += `<p class="muted">未发现问题。</p>`;
}
for (const i of issues) {
const sev = (i.severity || "minor").toLowerCase();
const sevLabel = sevLabels[sev] || i.severity;
html += `<div class="issue">
<span class="tag ${esc(sev)}">${esc(sevLabel)}</span>
<span class="cat">${esc(i.category)}</span>
${esc(i.message || "")}
${i.confidence != null ? `<span class="muted">(置信度 ${esc(i.confidence)}/10)</span>` : ""}
${i.location ? `<div class="loc">→ ${esc(i.location)}</div>` : ""}
${i.fix ? `<div class="fix"><b>修复建议:</b> ${esc(i.fix)}</div>` : ""}
</div>`;
}
const manual = data.manual_review || [];
if (manual.length) {
html += `<h2>需要人工审查</h2><ul>`;
for (const m of manual) html += `<li>${esc(m)}</li>`;
html += `</ul>`;
}
const judged = data.llm_judged || [];
if (judged.length) {
html += `<p class="muted"><b>需 LLM 判断的指标:</b> ${judged.map(esc).join(", ")}</p>`;
}
document.getElementById("app").innerHTML = html;
</script>
</body>
</html>
@@ -0,0 +1,82 @@
# Unified Review — Generic Checklist
Reference for the Layer-1 chain decomposition and gstack CRITICAL sub-pass.
Load the language-specific checklist when available (`java-spring.md`,
`python-django.md`, `python-fastapi.md`, `node-express.md`, `go-gin.md`,
`csharp-dotnet.md`, `rust.md`, `php-laravel.md`, `ruby-rails.md`); otherwise
use this generic list.
## Layer 1 — eight categories
For each changed area mark ✅ Clean / ⚠️ Issues Found / — N/A.
1. **Interface** — parameter validation, response conventions, HTTP status
codes, rate limiting, API versioning, protocol correctness
2. **Business** — logic aligns with requirements, state machine correctness,
idempotency design, distributed locks
3. **Data** — SQL injection, query performance, index usage, transaction
boundaries, cache invalidation
4. **Utility** — input validity, no side effects, error return values,
date/time timezone handling
5. **Error handling** — exception classification, fallback logic, error
message sanitization, retry with backoff
6. **Security** — AuthN/AuthZ, sensitive data masking, permission control,
CSRF/XSS prevention
7. **Performance** — N+1 queries, caching strategy, connection pooling,
batch operations, blocking in async paths
8. **Observability** — structured logging with correlation IDs, metrics,
health checks
## gstack CRITICAL sub-pass (highest severity)
### SQL & Data Safety
- String interpolation in SQL — use parameterized queries
- TOCTOU check-then-set — use atomic `WHERE` + update
- Bypassing model validations for direct DB writes
- N+1 queries — missing eager loading
### Race Conditions & Concurrency
- Read-check-write without uniqueness constraint / duplicate-key retry
- find-or-create without a unique DB index
- Status transitions not atomic (`WHERE old_status = ? UPDATE ...`)
- Unsafe HTML rendering on user-controlled data
### LLM Output Trust Boundary
- LLM-generated values (emails, URLs, names) written to DB without format
validation
- Structured tool output accepted without type/shape checks
- LLM-generated URLs fetched without an allowlist (SSRF)
- LLM output stored in knowledge bases without sanitization (stored prompt
injection)
### Shell Injection
- `subprocess` with `shell=True` AND interpolated command strings
- `os.system()` with variable interpolation
- `eval()`/`exec()` on LLM-generated code without sandboxing
### Enum & Value Completeness
- New enum/status/tier values: read (not just grep) every consumer that
switches/filters/displays the value
- Check allowlists and `case`/`if-elsif` chains for fall-through
## Suppressions — do NOT flag
- Harmless redundancy that aids readability
- "Add a comment explaining a threshold" — thresholds drift
- Consistency-only changes
- Anything already addressed in the diff
## Severity
- 🔴 **blocker** — must fix before merge (injection, secrets, missing
transaction, auth bypass) → verdict FAIL
- 🟡 **major** — should fix before merge (missing validation, missing
fallback, N+1, unmasked data)
- 🔵 **minor** — can optimize later (naming, duplicate code, comments)
## Confidence calibration
- 9-10 verified by reading specific code
- 7-8 high-confidence pattern match
- 5-6 medium — show with caveat
- 3-4 low — move to appendix
- 1-2 speculation — suppress unless severity would be P0
Every finding: `[SEVERITY] (confidence: N/10) file:line — problem → fix`.
@@ -0,0 +1,13 @@
# API Contract Specialist
Focus: API and interface contract changes in the diff.
- [ ] Breaking changes to public endpoints (paths, params, response shape)
- [ ] Versioning compatibility (deprecations, fallbacks)
- [ ] Request/response validation matches the schema
- [ ] Error response shape is consistent
- [ ] Authentication/authorization behavior unchanged for existing consumers
- [ ] Renamed/moved functions: all callers updated
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"api-contract","summary":"...","fix":"...","source":"api-contract"}`
@@ -0,0 +1,15 @@
# Data Migration Specialist
Focus: database schema and data migrations in the diff.
- [ ] Migration idempotent and re-runnable
- [ ] Forward + rollback paths defined
- [ ] Backfill batched / resumable
- [ ] Type/precision changes do not truncate data
- [ ] Nullability/default changes safe for existing rows
- [ ] Application deploy compatible with old + new schema (expand/contract)
Insurance specialist — always runs, even when silent.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"data-migration","summary":"...","fix":"...","source":"data-migration"}`
@@ -0,0 +1,16 @@
# Maintainability Specialist
Focus: code quality and maintainability issues.
- [ ] Dead code / unreachable branches / unused variables
- [ ] Magic numbers → named constants
- [ ] Overcomplicated abstractions (indirection without payoff)
- [ ] Copy-paste blocks that should be shared (only when it aids clarity)
- [ ] Functions too large / doing too much
- [ ] Stale comments contradicting the code
Suppress: harmless redundancy that aids readability, comment-on-threshold
requests, consistency-only changes.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"maintainability","summary":"...","fix":"...","source":"maintainability"}`
@@ -0,0 +1,14 @@
# Performance Specialist
Focus: performance and resource efficiency in the diff.
- [ ] N+1 queries — missing eager loading
- [ ] Unindexed lookups in hot loops
- [ ] O(n×m) lookups in views/loops
- [ ] Blocking calls in async paths (sync subprocess, requests, sleep)
- [ ] Connection pool exhaustion, unbounded retries
- [ ] Bundle/asset size regressions (frontend)
- [ ] Redundant recomputation / missing caching
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"performance","summary":"...","fix":"...","source":"performance"}`
@@ -0,0 +1,18 @@
# Red Team Specialist (conditional)
Focus: find what the primary and specialist reviewers MISSED. Only dispatched
when the diff is large (>200 lines) or a specialist found a critical issue.
Think like an attacker and a chaos engineer:
- [ ] Cross-cutting concerns the specialist checklists do not cover
- [ ] Integration boundary failures (service-to-service, module-to-module)
- [ ] Failure modes: what breaks in production under load, restart, partial
failure
- [ ] Silent data corruption paths (wrong results without errors)
- [ ] Error handling that swallows failures
- [ ] Trust boundary violations
- [ ] Race conditions and edge cases the primary review missed
Be adversarial. No compliments — just the problems. Tag findings with
`"source":"red-team"`. Output `NO FINDINGS` when nothing new is found.
@@ -0,0 +1,17 @@
# Security Specialist
Focus: security vulnerabilities in the diff.
- [ ] SQL injection (string interpolation, parameterized queries)
- [ ] AuthN/AuthZ bypasses, missing permission checks
- [ ] XSS (unsafe HTML rendering on user data)
- [ ] Sensitive data exposure / missing masking in logs and responses
- [ ] SSRF (fetching user/LLM-controlled URLs without allowlist)
- [ ] Command injection (`shell=True` + interpolation)
- [ ] Hardcoded secrets / credentials
- [ ] CSRF / missing rate limiting on auth endpoints
Insurance specialist — always runs, even when silent.
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"security","summary":"...","fix":"...","source":"security"}`
@@ -0,0 +1,14 @@
# Testing Specialist
Focus: test coverage gaps and tests that would catch the issues found.
- [ ] Every changed function has at least a happy-path test
- [ ] Negative/error paths tested (invalid input, failure branches)
- [ ] Edge cases mirror the happy-path structure
- [ ] If the fix for a finding can be caught by a test, propose a minimal
`test_stub` (framework-detected: jest/vitest/rspec/pytest/go-test)
- [ ] Integration coverage for critical flows (DB, external calls)
- [ ] No assertion-only tests that pass trivially
Output JSON lines:
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"testing","summary":"...","fix":"...","test_stub":"...","source":"testing"}`
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "code-review-graph"
version = "2.3.7"
version = "2.5.1"
description = "Local-first knowledge graph for token-efficient code review through MCP and CLI"
readme = {file = "README.md", content-type = "text/markdown"}
license = "MIT"
+31 -1
View File
@@ -49,9 +49,39 @@ Present every finding with severity (🔴 blocker / 🟡 major / 🔵 minor), co
Any 🔴 blocker → verdict `❌ FAIL`. Classify each finding as Ready / Needs Fix / Unusable.
## Step 7.5 - 覆盖度自检(必须执行,防"审完了"由感觉决定)
报告前完成三件事(机制未生效时禁止用 CLI 兜底继续审查):
1. **G1 深读名单完整性**:确认名单外文件是"被评估过"而非"被忽略";未被任何信号点名的文件记入 **"未深读文件清单"**,在报告中显式列出。
2. **G2 静默抽检**:调用 `coverage_tool` 取返回的 `silent_files`(未被任何信号点名的文件),随机抽 **15%** 深读;发现 ≥1 major → 该文件升级全量深读,并同社区/同类追加抽检一轮。抽检记录附入报告(抽了几份 / 几个 major / 有无升级)。
3. **G3 覆盖度计算**:调用 `coverage_tool(deep_read_files=<本轮实际深读文件>, gate="both+line")`,引擎自动计算**三重口径**(文件数口径 + 三件套质量口径):
- **全库覆盖** = `coverage_pct`(已深读文件数 / 全部源文件数)
- **高风险覆盖** = `high_risk_coverage_pct`(已深读 / 信号点名文件,文件数口径)
- **行/单元覆盖** = `line_coverage_pct` / `unit_coverage_pct`gate="both+line" 时的三件套质量口径)
- **feature(单功能)审查**:改用 `gate="line+unit"`——**只做行覆盖 ≥95% + 单元完整性无缺口**,不做全库/高风险文件数覆盖检查(`coverage_pct`/`high_risk_coverage_pct``null`,报告只渲染行/单元覆盖,不渲染全库/高风险行)。`target_reached=false` → 报告顶部标 🔴 覆盖不足。
将覆盖度结果**完整透传**到 `review_data.coverage`(直接把 `coverage_tool` 返回值全部字段传入:coverage_pct/high_risk_coverage_pct/grade/deep_read_count/total_files/high_risk_total_files/high_risk_deep_count/deep_read_weight/total_weight/target_reached/target/uncovered_files/silent_files/note),不要手挑子集,否则计数字段渲染为 0/0 或 N/A。报告会自动渲染 `## 覆盖度` 区块。
**前置健康检查**:调用 `community_health_tool`,若 `needs_postprocess=true`nodes.community_id 归属率 <90%),先 `code-review-graph postprocess` 重建社区归属再计算,否则覆盖度失真。
## Step 8 - Report
Call `generate_report_tool(review_data=<verdict, scope, metrics, findings>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`).
Call `generate_report_tool(review_data=<verdict, scope, metrics, findings>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`). Pass `reviewed_files: [path, ...]` (the deep-read file array) — the report header renders it as a collapsible `<details>` list (falls back to the flat `files` string when absent).
**Archive naming (REQUIRED):** always pass `output_path="docs/reviews/{name}-review-{YYYY-MM-DD-HHMMSS}"` (e.g. `docs/reviews/evm-feature-review-2026-08-06-151522`). Omitting `output_path` writes to `<repo_root>/code-review-report.*` which is a naming violation.
## Step 8.6 - Report naming self-check (REQUIRED)
After generating, run the naming verifier (standalone script, independent of the CRG CLI):
```powershell
powershell -File "C:\Users\Administrator\.config\opencode\skills\project-review\verify-report.ps1" -Repo <repo_root>
```
- Exit 0 → pass: all reports under `docs/reviews/` carry a `-YYYY-MM-DD-HHMMSS` suffix.
- Exit 1 → stray root `code-review-report.*` detected. Fix by re-calling `generate_report_tool` with the correct `output_path`, or run with `-Fix` to auto-archive, then re-verify.
- Historic non-conforming names in `docs/reviews/` are warnings only — do not rename them.
## Output Format
@@ -43,6 +43,11 @@
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
details.reviewed { margin:.5rem 0; border:1px solid var(--border);
border-radius:6px; padding:.4rem .8rem; }
details.reviewed summary { cursor:pointer; font-weight:600; }
details.reviewed ul { margin:.4rem 0 0; padding-left:1.2rem; }
details.reviewed li { margin:.15rem 0; }
</style>
</head>
<body>
@@ -61,6 +66,23 @@ function verdictClass(v) {
return v.includes("PASS") ? "pass" : "fail";
}
// Collapsible list of the reviewed files (native <details>/<summary>, no JS).
// Accepts an array OR a comma-separated string (agents pass both). Falls
// back to the flat ``files`` string when nothing structured is given.
function renderReviewedFiles(data) {
let arr = data.reviewed_files;
if (typeof arr === "string") {
arr = arr.split(",").map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(arr) || !arr.length) {
return data.files ? `<p><b>文件:</b> ${esc(data.files)}</p>` : "";
}
return `<details class="reviewed">
<summary>审查文件 (${arr.length}) <span class="muted">点击展开/收起</span></summary>
<ul>${arr.map(f => `<li><code>${esc(f)}</code></li>`).join("")}</ul>
</details>`;
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
@@ -84,9 +106,83 @@ let html = `<h1>代码审查报告</h1>
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>文件:</b> ${esc(data.files)}</p>`;
html += renderReviewedFiles(data);
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const cov = data.coverage || {};
// The coverage section renders whenever file-count coverage OR line/unit
// coverage was computed. gate="line+unit" (feature reviews) returns
// coverage_pct=null, so the 全库/高风险 rows are skipped and only the
// line/unit rows + spot-check render.
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const isLineOnly = cov.coverage_pct == null;
const covOk = cov.target_reached;
const covCls = covOk ? "good" : "fail";
const covStatus = covOk ? "✅ 达标" : "🔴 覆盖不足";
const oTarget = cov.overall_target ?? cov.target ?? "N/A";
const hTarget = cov.high_risk_target ?? cov.target ?? "N/A";
html += `<h2>覆盖度</h2>
<p><span class="verdict ${covCls}">${covStatus}</span></p>`;
if (!isLineOnly) {
html += `<p><b>全库覆盖:</b> ${esc(cov.coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.deep_read_count ?? "N/A")}/${esc(cov.total_files ?? "N/A")}(目标 ${esc(oTarget)}%</p>
<p><b>高风险覆盖:</b> ${esc(cov.high_risk_coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.high_risk_deep_count ?? "N/A")}/${esc(cov.high_risk_total_files ?? "N/A")}(目标 ${esc(hTarget)}%</p>`;
}
html += renderLineUnit(cov);
html += renderSpotCheck(data.spot_check);
if (!isLineOnly && (cov.uncovered_files || []).length) {
html += `<p class="muted"><b>未深读文件:</b> ${esc(cov.uncovered_files.length)} 个(静默文件 ${esc((cov.silent_files || []).length)} 个)</p>`;
}
}
// Line / unit coverage (three-piece suite items 1-2). Fail-closed: a
// missing line/unit coverage renders "未执行 🔴" so reviews that skipped
// gate="both+line" or the three-piece data are visible, never silent green.
function renderLineUnit(cov) {
const linePct = cov.line_coverage_pct;
const unitPct = cov.unit_coverage_pct;
const lineTarget = cov.line_target ?? 95.0;
const unitTarget = cov.unit_target ?? 100.0;
const lineGap = (cov.line_gap_files || []).length;
const unitGap = (cov.unit_gap_files || []).length;
const missing = (cov.missing_data_files || []).length;
let s = "";
if (linePct == null || unitPct == null) {
s += `<p><b>行覆盖:</b> 未执行 🔴 <span class="muted">(coverage_tool 未用 gate="both+line" 或未传三件套数据)</span></p>`;
} else {
const lineOk = linePct >= lineTarget && lineGap === 0;
const unitOk = unitPct >= unitTarget && unitGap === 0;
s += `<p><b>行覆盖:</b> ${esc(linePct)}% — 目标 ${esc(lineTarget)}%(缺口 ${esc(lineGap)} 文件)${lineOk ? "✅" : "🔴"}</p>`;
s += `<p><b>单元覆盖:</b> ${esc(unitPct)}% — 目标 ${esc(unitTarget)}%(缺口 ${esc(unitGap)} 文件)${unitOk ? "✅" : "🔴"}</p>`;
}
if (missing) {
s += `<p class="muted"><b>三件套数据缺失:</b> ${esc(missing)} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)</p>`;
}
return s;
}
// Anti-fake spot check (three-piece suite item 3). Fail-closed: a
// missing/incomplete spot_check renders "未执行 🔴" so reviews that
// skipped the sampled re-read are visible instead of silently green.
function renderSpotCheck(spot) {
if (!spot) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(主代理未回读任何语义单元;Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check)</span></p>`;
}
const groups = spot.groups_sampled;
const files = spot.files_sampled;
const units = spot.units_sampled;
const fake = spot.fake_read_found || 0;
const rereread = spot.groups_rereread || [];
if (!units) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(spot_check 已上报但单元数为 0)</span></p>`;
}
const mark = (fake || rereread.length) ? "🔴 发现假读" : "✅";
let s = `<p><b>防伪抽验:</b> 抽样 ${esc(files)} 文件 / ${esc(units)} 单元 / ${esc(groups)} 组,假读 ${esc(fake)} ${mark}</p>`;
if (rereread.length) {
s += `<p class="muted">因假读重读组:${esc(rereread.join(", "))}</p>`;
}
return s;
}
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
+1 -1
View File
@@ -53,7 +53,7 @@ Any 🔴 blocker → verdict `❌ FAIL` regardless of other scores. Classify eac
## Step 8 - Report
Call `generate_report_tool(review_data=<collected verdict, metrics, findings, tier, scope>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`). Also present the text report inline.
Call `generate_report_tool(review_data=<collected verdict, metrics, findings, tier, scope>)` to write `code-review-report.html` and `code-review-report.md` (default `format="both"`). Pass `reviewed_files: [path, ...]` (changed/reviewed files) so the report header lists them in a collapsible `<details>` list. Also present the text report inline.
## Step 9 - Persistence (optional)
@@ -43,6 +43,11 @@
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
details.reviewed { margin:.5rem 0; border:1px solid var(--border);
border-radius:6px; padding:.4rem .8rem; }
details.reviewed summary { cursor:pointer; font-weight:600; }
details.reviewed ul { margin:.4rem 0 0; padding-left:1.2rem; }
details.reviewed li { margin:.15rem 0; }
</style>
</head>
<body>
@@ -61,6 +66,23 @@ function verdictClass(v) {
return v.includes("PASS") ? "pass" : "fail";
}
// Collapsible list of the reviewed files (native <details>/<summary>, no JS).
// Accepts an array OR a comma-separated string (agents pass both). Falls
// back to the flat ``files`` string when nothing structured is given.
function renderReviewedFiles(data) {
let arr = data.reviewed_files;
if (typeof arr === "string") {
arr = arr.split(",").map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(arr) || !arr.length) {
return data.files ? `<p><b>文件:</b> ${esc(data.files)}</p>` : "";
}
return `<details class="reviewed">
<summary>审查文件 (${arr.length}) <span class="muted">点击展开/收起</span></summary>
<ul>${arr.map(f => `<li><code>${esc(f)}</code></li>`).join("")}</ul>
</details>`;
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
@@ -84,9 +106,83 @@ let html = `<h1>代码审查报告</h1>
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>文件:</b> ${esc(data.files)}</p>`;
html += renderReviewedFiles(data);
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const cov = data.coverage || {};
// The coverage section renders whenever file-count coverage OR line/unit
// coverage was computed. gate="line+unit" (feature reviews) returns
// coverage_pct=null, so the 全库/高风险 rows are skipped and only the
// line/unit rows + spot-check render.
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const isLineOnly = cov.coverage_pct == null;
const covOk = cov.target_reached;
const covCls = covOk ? "good" : "fail";
const covStatus = covOk ? "✅ 达标" : "🔴 覆盖不足";
const oTarget = cov.overall_target ?? cov.target ?? "N/A";
const hTarget = cov.high_risk_target ?? cov.target ?? "N/A";
html += `<h2>覆盖度</h2>
<p><span class="verdict ${covCls}">${covStatus}</span></p>`;
if (!isLineOnly) {
html += `<p><b>全库覆盖:</b> ${esc(cov.coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.deep_read_count ?? "N/A")}/${esc(cov.total_files ?? "N/A")}(目标 ${esc(oTarget)}%</p>
<p><b>高风险覆盖:</b> ${esc(cov.high_risk_coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.high_risk_deep_count ?? "N/A")}/${esc(cov.high_risk_total_files ?? "N/A")}(目标 ${esc(hTarget)}%</p>`;
}
html += renderLineUnit(cov);
html += renderSpotCheck(data.spot_check);
if (!isLineOnly && (cov.uncovered_files || []).length) {
html += `<p class="muted"><b>未深读文件:</b> ${esc(cov.uncovered_files.length)} 个(静默文件 ${esc((cov.silent_files || []).length)} 个)</p>`;
}
}
// Line / unit coverage (three-piece suite items 1-2). Fail-closed: a
// missing line/unit coverage renders "未执行 🔴" so reviews that skipped
// gate="both+line" or the three-piece data are visible, never silent green.
function renderLineUnit(cov) {
const linePct = cov.line_coverage_pct;
const unitPct = cov.unit_coverage_pct;
const lineTarget = cov.line_target ?? 95.0;
const unitTarget = cov.unit_target ?? 100.0;
const lineGap = (cov.line_gap_files || []).length;
const unitGap = (cov.unit_gap_files || []).length;
const missing = (cov.missing_data_files || []).length;
let s = "";
if (linePct == null || unitPct == null) {
s += `<p><b>行覆盖:</b> 未执行 🔴 <span class="muted">(coverage_tool 未用 gate="both+line" 或未传三件套数据)</span></p>`;
} else {
const lineOk = linePct >= lineTarget && lineGap === 0;
const unitOk = unitPct >= unitTarget && unitGap === 0;
s += `<p><b>行覆盖:</b> ${esc(linePct)}% — 目标 ${esc(lineTarget)}%(缺口 ${esc(lineGap)} 文件)${lineOk ? "✅" : "🔴"}</p>`;
s += `<p><b>单元覆盖:</b> ${esc(unitPct)}% — 目标 ${esc(unitTarget)}%(缺口 ${esc(unitGap)} 文件)${unitOk ? "✅" : "🔴"}</p>`;
}
if (missing) {
s += `<p class="muted"><b>三件套数据缺失:</b> ${esc(missing)} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)</p>`;
}
return s;
}
// Anti-fake spot check (three-piece suite item 3). Fail-closed: a
// missing/incomplete spot_check renders "未执行 🔴" so reviews that
// skipped the sampled re-read are visible instead of silently green.
function renderSpotCheck(spot) {
if (!spot) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(主代理未回读任何语义单元;Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check)</span></p>`;
}
const groups = spot.groups_sampled;
const files = spot.files_sampled;
const units = spot.units_sampled;
const fake = spot.fake_read_found || 0;
const rereread = spot.groups_rereread || [];
if (!units) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(spot_check 已上报但单元数为 0)</span></p>`;
}
const mark = (fake || rereread.length) ? "🔴 发现假读" : "✅";
let s = `<p><b>防伪抽验:</b> 抽样 ${esc(files)} 文件 / ${esc(units)} 单元 / ${esc(groups)} 组,假读 ${esc(fake)} ${mark}</p>`;
if (rereread.length) {
s += `<p class="muted">因假读重读组:${esc(rereread.join(", "))}</p>`;
}
return s;
}
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
+421
View File
@@ -0,0 +1,421 @@
"""Tests for coverage computation (compute_coverage / check_community_health)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from code_review_graph.parser import normalize_file_path
from code_review_graph.scoring import ( # noqa: E402
_is_source_file,
build_report_data,
check_community_health,
compute_coverage,
deep_read_plan,
render_markdown_report,
)
class _Node:
def __init__(self, qualified_name, file_path, is_test=False, kind="Function"):
self.qualified_name = qualified_name
self.file_path = file_path
self.is_test = is_test
self.kind = kind
class _Edge:
def __init__(self, kind, source_qualified, target_qualified):
self.kind = kind
self.source_qualified = source_qualified
self.target_qualified = target_qualified
class _Store:
"""Minimal fake GraphStore for coverage unit tests."""
def __init__(self, files, nodes, edges):
self._files = list(files)
self._nodes = nodes
self._edges = edges
def get_all_files(self):
return list(self._files)
def get_nodes_by_file(self, file_path):
return [n for n in self._nodes if n.file_path == file_path]
def get_edges_by_target(self, qualified_name):
return [e for e in self._edges if e.target_qualified == qualified_name]
@pytest.fixture
def tiny_repo(tmp_path: Path) -> Path:
"""A tiny repo with two source files + one non-source file."""
Path(tmp_path, "src").mkdir(exist_ok=True)
Path(tmp_path, "src", "a.py").write_text(
"def a():\n"
" try:\n"
" return 1\n"
" except Exception as e:\n"
" raise ValueError(str(e))\n",
encoding="utf-8",
)
Path(tmp_path, "src", "b.py").write_text(
"def b():\n return 2\n",
encoding="utf-8",
)
Path(tmp_path, "docs").mkdir(exist_ok=True)
Path(tmp_path, "docs", "README.md").write_text(
"# docs\n",
encoding="utf-8",
)
return tmp_path
def test_is_source_file_excludes_non_source():
assert _is_source_file("src/app.py") is True
assert _is_source_file("docs/README.md") is False
assert _is_source_file("tests/test_x.py") is False
assert _is_source_file("scripts/build.py") is False
assert _is_source_file("src/app.py.bak") is False
def test_compute_coverage_returns_expected_shape(tiny_repo):
files = [
"src/a.py",
"src/b.py",
]
nodes = [
_Node("D:/repo/src/a.py::a", "src/a.py"),
_Node("D:/repo/src/b.py::b", "src/b.py"),
]
store = _Store(files, nodes, [])
result = compute_coverage(
store,
tiny_repo,
deep_read_files=["src/a.py"],
include_churn=False,
)
assert result["status"] == "ok"
assert result["total_files"] == 2
assert result["deep_read_count"] == 1
assert 0.0 <= result["coverage_pct"] <= 100.0
assert 0.0 <= result["high_risk_coverage_pct"] <= 100.0
assert result["target"] == 95.0
assert result["overall_target"] == 85.0
assert result["high_risk_target"] == 95.0
assert result["target_reached"] is False # 1/2 = 50% < 95%
assert result["uncovered_files"] == ["src/b.py"]
# b.py is clean (no sql/vuln/redundancy signals, no topology, no churn)
# => w1 = good(1) < 2.0 and no signals => it IS a silent file.
assert result["silent_files"] == ["src/b.py"]
def test_compute_coverage_full_deep_read_reaches_target(tiny_repo):
files = ["src/a.py", "src/b.py"]
store = _Store(files, [], [])
result = compute_coverage(
store,
tiny_repo,
deep_read_files=["src/a.py", "src/b.py"],
include_churn=False,
)
assert result["coverage_pct"] == 100.0
# tiny_repo files carry no sql/vuln/redundancy signal => no high-risk
# files => gate falls back to overall coverage (100%) => reached.
assert result["target_reached"] is True
assert result["grade"] == "good"
def test_compute_coverage_excludes_docs_from_denominator(tiny_repo):
files = ["src/a.py", "src/b.py", "docs/README.md"]
store = _Store(files, [], [])
result = compute_coverage(
store,
tiny_repo,
deep_read_files=["src/a.py"],
include_churn=False,
)
assert result["total_files"] == 2 # docs excluded
assert "docs/README.md" not in result["uncovered_files"]
def test_check_community_health_fake_store():
class _Conn:
def execute(self, _sql):
return _Cursor()
class _Cursor:
def fetchone(self):
return (4,)
store = type("S", (), {"_conn": _Conn()})()
# Our fake always returns 4 for every count; just ensure it runs.
result = check_community_health(store)
assert "status" in result
def test_report_renders_coverage_section():
rd = {
"verdict": "PASS",
"scope": "whole-project",
"tier": "standard",
"timestamp": "2026-08-11T00:00:00Z",
"files": "src/a.py",
"metrics": {"sql_risk": {"value": 0, "grade": "good", "note": "ok"}},
"findings": [],
"coverage": {
"coverage_pct": 7.1,
"high_risk_coverage_pct": 62.0,
"grade": "good",
"deep_read_count": 24,
"total_files": 508,
"high_risk_total_files": 205,
"high_risk_deep_count": 14,
"target_reached": True,
"target": 95.0,
"uncovered_files": ["src/c.py"],
"silent_files": ["src/d.py"],
},
}
md = render_markdown_report(rd)
assert "## 覆盖度" in md
assert "7.1%" in md
assert "62.0%" in md
assert "✅ 达标" in md
data = build_report_data(rd)
assert data["coverage"]["coverage_pct"] == 7.1
assert data["coverage"]["high_risk_coverage_pct"] == 62.0
def test_report_marks_insufficient_coverage():
rd = {
"verdict": "FAIL",
"scope": "whole-project",
"tier": "standard",
"timestamp": "2026-08-11T00:00:00Z",
"coverage": {
"coverage_pct": 3.0,
"high_risk_coverage_pct": 30.0,
"grade": "fail",
"deep_read_count": 5,
"total_files": 508,
"high_risk_total_files": 205,
"high_risk_deep_count": 3,
"target_reached": False,
"target": 95.0,
"uncovered_files": [],
"silent_files": [],
},
}
md = render_markdown_report(rd)
assert "🔴 覆盖不足" in md
assert "30.0%" in md
@pytest.fixture
def risk_repo(tmp_path: Path) -> Path:
"""a.py carries an SQL-risk signal (fail w1), the rest are clean."""
Path(tmp_path, "src").mkdir(exist_ok=True)
Path(tmp_path, "src", "a.py").write_text(
"def a():\n"
" sql = 'SELECT * FROM users WHERE id=' + str(uid)\n"
" return sql\n",
encoding="utf-8",
)
for name in ("b", "c", "d"):
Path(tmp_path, "src", f"{name}.py").write_text(
f"def {name}():\n return 2\n",
encoding="utf-8",
)
return tmp_path
def test_coverage_gate_overall_vs_high_risk_vs_both(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _Store(files, [], [])
# Deep-read only the risky file: overall file-count 25%, high-risk 100%.
base = dict(store=store, repo_root=risk_repo,
deep_read_files=["src/a.py"], include_churn=False)
overall = compute_coverage(gate="overall", **base)
assert overall["target_reached"] is False # 25% < 85%
high_risk = compute_coverage(gate="high_risk", **base)
assert high_risk["target_reached"] is True # 100% >= 95%
both = compute_coverage(gate="both", **base)
assert both["target_reached"] is False # overall fails
assert both["gate"] == "both"
def test_coverage_returns_gap_fields(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _Store(files, [], [])
result = compute_coverage(
store, risk_repo,
deep_read_files=["src/a.py"],
include_churn=False,
gate="both",
)
# File-count gap: 4 files, 1 deep-read, overall target 85% => 4*0.85-1 = 2.4.
assert result["total_files"] == 4
assert result["remaining_files_to_target"] == 2.4
# Compatible risk-weight gap: a.py w1=2 (warn, one SQL hit), others
# w1=1 => total=5, overall target 85% => 4.25 - 2.0 = 2.25.
assert result["total_weight"] == 5.0
assert result["remaining_weight_to_target"] == 2.25
prio = result["priority_deep_read_files"]
assert [p["path"] for p in prio] == [
normalize_file_path(str(Path(risk_repo, "src", f"{n}.py")))
for n in ("b", "c", "d")
]
assert all(p["weight"] == 1.0 for p in prio)
def test_deep_read_plan_greedy_and_groups(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _Store(files, [], [])
plan = deep_read_plan(
store, risk_repo,
deep_read_files=["src/a.py"],
target_coverage=85.0,
batch_size=40,
include_churn=False,
)
# File-count: total=4, current=1, target=3.4, remaining=2.4 -> pick
# the 3 uncovered files b, c, d (weights equal, any order is fine but
# the greedy loop fills up to the remaining count).
assert plan["current_files"] == 1
assert plan["target_files"] == 3.4
assert plan["remaining_files"] == 2.4
assert len(plan["planned_files"]) == 3
planned = [normalize_file_path(str(Path(risk_repo, "src", f"{n}.py")))
for n in ("b", "c", "d")]
assert plan["planned_files"] == planned
assert plan["estimated_batches"] == 1
assert plan["groups"][0]["name"] == "src"
assert len(plan["groups"][0]["files"]) == 3
def test_deep_read_plan_excludes_prior_covered(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _Store(files, [], [])
prior = {normalize_file_path(str(Path(risk_repo, "src", "b.py")))}
plan = deep_read_plan(
store, risk_repo,
deep_read_files=["src/a.py"],
target_coverage=85.0,
include_churn=False,
prior_covered=prior,
)
# b already covered by a prior round => current=2 files, remaining =
# 4*0.85 - 2 = 1.4 -> pick c and d (2 files).
assert plan["current_files"] == 2
assert plan["remaining_files"] == 1.4
assert len(plan["planned_files"]) == 2
planned = [normalize_file_path(str(Path(risk_repo, "src", f"{n}.py")))
for n in ("c", "d")]
assert plan["planned_files"] == planned
def test_coverage_dual_target_overall_ok_high_risk_not(risk_repo):
"""Overall 85% reached but high-risk 95% not reached => gate=both fails."""
repo = risk_repo.parent / "dual_target_repo"
repo.mkdir(exist_ok=True)
src = repo / "src"
src.mkdir(exist_ok=True)
(src / "risky.py").write_text(
"def r():\n sql = 'SELECT * FROM users WHERE id=' + str(uid)\n return sql\n",
encoding="utf-8",
)
clean = [f"f{i}.py" for i in range(9)]
for name in clean:
(src / name).write_text(f"def {name}():\n return 1\n", encoding="utf-8")
files = ["src/risky.py"] + [f"src/{n}" for n in clean]
store = _Store(files, [], [])
deep = [f"src/{n}" for n in clean] # 9 clean files, skip risky
res = compute_coverage(
store, repo,
deep_read_files=deep,
include_churn=False,
gate="both",
)
assert res["total_files"] == 10
assert res["deep_read_count"] == 9
assert res["coverage_pct"] == 90.0 # >= 85% overall target
assert res["high_risk_coverage_pct"] == 0.0 # < 95% high-risk target
assert res["overall_target"] == 85.0
assert res["high_risk_target"] == 95.0
assert res["target_reached"] is False # both gate: high-risk fails
assert res["grade"] == "fail"
res_overall = compute_coverage(
store, repo,
deep_read_files=deep,
include_churn=False,
gate="overall",
)
assert res_overall["target_reached"] is True
def _write_coverage_index(repo: Path, entries: dict[str, str]) -> Path:
"""Write a coverage-index.json mapping rel path -> SHA."""
payload = {"version": 1, "last_updated": "2026-08-14T00:00:00", "entries": {
rel: {"sha": sha} for rel, sha in entries.items()
}}
index = repo / ".code-review-graph" / "coverage-index.json"
index.parent.mkdir(parents=True, exist_ok=True)
index.write_text(json.dumps(payload), encoding="utf-8")
return index
def _blob_sha(repo: Path, rel: str) -> str:
import subprocess
out = subprocess.run(
["git", "hash-object", rel],
capture_output=True, text=True, stdin=subprocess.DEVNULL,
cwd=str(repo), timeout=15,
)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def test_load_coverage_index_batch(tmp_path: Path) -> None:
"""Batch git hash-object path returns only SHA-stable files."""
repo = tmp_path
(repo / ".git").mkdir(parents=True)
# A git index is required for hash-object of tracked/untracked files to
# work in the same way; an empty repo is enough (hash-object works on any
# existing file path in the working tree).
(repo / "a.txt").write_text("alpha", encoding="utf-8")
(repo / "b.txt").write_text("beta", encoding="utf-8")
sha_a = _blob_sha(repo, "a.txt")
_write_coverage_index(repo, {"a.txt": sha_a, "b.txt": "0000000000000000000000000000000000000000"})
from code_review_graph.tools.scoring_tools import _load_coverage_index
covered = _load_coverage_index(repo)
assert covered == ["a.txt"] # only a.txt's SHA still matches
def test_load_coverage_index_skips_missing_file(tmp_path: Path) -> None:
"""A deleted indexed file must not truncate the batch or be returned."""
repo = tmp_path
(repo / ".git").mkdir(parents=True)
(repo / "a.txt").write_text("alpha", encoding="utf-8")
sha_a = _blob_sha(repo, "a.txt")
_write_coverage_index(repo, {"a.txt": sha_a, "gone.txt": sha_a})
from code_review_graph.tools.scoring_tools import _load_coverage_index
covered = _load_coverage_index(repo)
assert covered == ["a.txt"]
+332
View File
@@ -0,0 +1,332 @@
"""Tests for the V2.1 line/unit coverage gate (gate="both+line").
Covers: per-file line coverage thresholds, unit-completeness (gap-free),
giant-file exemption, the four-state gate enum, and the v2 coverage index
round-trip (nodes.file_hash based, ranges persisted, v1 fallback).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from code_review_graph.scoring import compute_coverage # noqa: E402
from code_review_graph.tools.scoring_tools import ( # noqa: E402
_coverage_index_path,
save_coverage_index_func,
)
class _Row:
def __init__(self, d):
self._d = d
def __getitem__(self, k):
return self._d[k]
class _Cursor:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return [_Row(r) for r in self._rows]
def fetchone(self):
return _Row(self._rows[0]) if self._rows else None
class _Conn:
def __init__(self, units):
self.units = units
def execute(self, sql, params=None):
if "kind IN ('Function','Class','Test')" in sql:
# returns dict-like rows with .keys() for dict(row) conversion
return _Cursor([dict(u) for u in self.units])
if "kind='File'" in sql:
return _Cursor([])
return _Cursor([])
class _Node:
def __init__(self, qualified_name, file_path, is_test=False, kind="Function"):
self.qualified_name = qualified_name
self.file_path = file_path
self.is_test = is_test
self.kind = kind
class _Store:
def __init__(self, files, units=None):
self._files = list(files)
self._conn = _Conn(units or [])
self._nodes = [
_Node("D:/repo/" + f, f) for f in self._files
]
def get_all_files(self):
return list(self._files)
def get_nodes_by_file(self, file_path):
return [n for n in self._nodes if n.file_path == file_path]
def get_edges_by_target(self, qualified_name):
return []
@pytest.fixture
def repo(tmp_path: Path) -> Path:
Path(tmp_path, "src").mkdir(exist_ok=True)
Path(tmp_path, ".git").mkdir(exist_ok=True) # satisfy _get_store repo-root check
Path(tmp_path, "src", "a.py").write_text(
"\n".join(f"# line {i}" for i in range(1, 41)),
encoding="utf-8",
)
Path(tmp_path, "src", "b.py").write_text(
"def b():\n return 2\n",
encoding="utf-8",
)
return tmp_path
def _mk_store_units(rel, units):
"""Graph semantic units for a file: {name: [s,e]}."""
return [
{"kind": "Function", "name": name, "line_start": s, "line_end": e}
for name, (s, e) in units.items()
]
def test_line_coverage_four_tiers(repo):
files = ["src/a.py", "src/b.py"]
a_units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, a_units)
a_rel = "src/a.py"
def cov(ranges):
return compute_coverage(
store, repo, deep_read_files=["src/a.py"],
include_churn=False, gate="both+line",
file_read_ranges={a_rel: ranges},
file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]},
)
r = cov([[1, 20]]) # 50% of 40 lines
assert r["line_gap_files"][0]["coverage_pct"] == 50.0
assert r["target_reached"] is False
r = cov([[1, 40]]) # 100%
assert r["line_gap_files"] == []
assert r["line_coverage_pct"] == 100.0
def test_unit_gap_detected(repo):
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 20], "b": [21, 40]})
store = _Store(files, units)
a_rel = "src/a.py" # sub-agents report relative paths
# report only unit a, missing unit b
r = compute_coverage(
store, repo, deep_read_files=["src/a.py"], include_churn=False,
gate="both+line",
file_read_ranges={a_rel: [[1, 40]]},
file_semantic_units={a_rel: [{"range": [1, 20], "kind": "Function", "name": "a"}]},
)
assert len(r["unit_gap_files"]) == 1
assert r["unit_gap_files"][0]["covered_units"] == 1
assert r["unit_gap_files"][0]["total_units"] == 2
def test_unit_exempt_giant_file(repo):
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"big": [1, 40]})
store = _Store(files, units)
a_rel = "src/a.py" # relative path as sub-agents report
r = compute_coverage(
store, repo, deep_read_files=["src/a.py"], include_churn=False,
gate="both+line",
file_read_ranges={a_rel: [[1, 2]]},
file_semantic_units={a_rel: [{"range": [1, 2], "kind": "Function", "name": "big"}]},
)
assert len(r["unit_exempt_files"]) == 1 # single unit, span>80% -> exempt
assert r["unit_gap_files"] == []
def test_gate_both_line_requires_quality(repo):
files = ["src/a.py", "src/b.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
a_rel = "src/a.py"
b_rel = "src/b.py"
# full file-count coverage but only 50% line coverage on a.py
r = compute_coverage(
store, repo,
deep_read_files=["src/a.py", "src/b.py"], include_churn=False,
gate="both+line",
file_read_ranges={a_rel: [[1, 20]], b_rel: [[1, 2]]},
file_semantic_units={
a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}],
b_rel: [],
},
)
assert r["coverage_pct"] == 100.0
assert r["line_coverage_pct"] < 100.0
assert r["target_reached"] is False # quality gate fails despite 100% file coverage
def test_gate_both_unchanged_back_compat(repo):
files = ["src/a.py", "src/b.py"]
store = _Store(files)
r = compute_coverage(
store, repo, deep_read_files=["src/a.py", "src/b.py"],
include_churn=False, gate="both",
)
assert r["gate"] == "both"
assert "line_gap_files" in r # present but empty (only filled for both+line)
assert r["line_gap_files"] == []
def test_fail_closed_missing_read_ranges(repo):
"""Deep-read file with NO read_ranges must fail (not silently pass).
Guards the v2.5.2 fail-closed fix: previously a deep-read file without
file_read_ranges was neither counted as covered nor recorded as a gap,
so gate="both+line" returned target_reached=true while line_coverage_pct
stayed 0 (silent green). Now it must be a line gap + missing_data.
"""
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
r = compute_coverage(
store, repo, deep_read_files=["src/a.py"], include_churn=False,
gate="both+line",
# NO file_read_ranges / file_semantic_units passed at all
)
assert r["line_coverage_pct"] == 0.0
assert len(r["line_gap_files"]) == 1
assert r["line_gap_files"][0]["reason"] == "missing read_ranges"
assert any(m["field"] == "file_read_ranges" for m in r["missing_data_files"])
assert r["target_reached"] is False
def test_fail_closed_missing_semantic_units(repo):
"""Graph has units but file_semantic_units is absent -> unit gap."""
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
r = compute_coverage(
store, repo, deep_read_files=["src/a.py"], include_churn=False,
gate="both+line",
file_read_ranges={"src/a.py": [[1, 40]]},
# file_semantic_units omitted
)
assert r["unit_coverage_pct"] == 0.0
assert len(r["unit_gap_files"]) == 1
assert r["unit_gap_files"][0]["reason"] == "missing semantic_units"
assert any(m["field"] == "file_semantic_units" for m in r["missing_data_files"])
assert r["target_reached"] is False
def test_fail_closed_full_data_passes(repo):
"""With complete three-piece data the file is verified (no false gap)."""
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
r = compute_coverage(
store, repo, deep_read_files=["src/a.py"], include_churn=False,
gate="both+line",
file_read_ranges={"src/a.py": [[1, 40]]},
file_semantic_units={"src/a.py": [{"range": [1, 40], "kind": "Function", "name": "a"}]},
)
assert r["line_coverage_pct"] == 100.0
assert r["line_gap_files"] == []
assert r["unit_gap_files"] == []
assert r["missing_data_files"] == []
def test_gate_line_unit_only_checks_line_and_unit(repo):
"""gate=\"line+unit\" (feature) checks ONLY line + unit completeness.
File-count coverage (1 of 2 files = 50% < 85%) and high-risk coverage
are NOT part of the gate: coverage_pct/high_risk_coverage_pct are None
and target_reached reflects the line/unit quality gate alone.
"""
files = ["src/a.py", "src/b.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
a_rel = "src/a.py"
r = compute_coverage(
store, repo,
deep_read_files=["src/a.py"], include_churn=False,
gate="line+unit",
file_read_ranges={a_rel: [[1, 40]]},
file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]},
)
assert r["gate"] == "line+unit"
assert r["coverage_pct"] is None
assert r["high_risk_coverage_pct"] is None
assert r["line_coverage_pct"] == 100.0
assert r["unit_coverage_pct"] == 100.0
assert r["target_reached"] is True
assert r["grade"] == "good"
def test_gate_line_unit_fails_on_line_gap(repo):
"""A line gap below target still fails gate=\"line+unit\"."""
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 40]})
store = _Store(files, units)
a_rel = "src/a.py"
r = compute_coverage(
store, repo,
deep_read_files=["src/a.py"], include_churn=False,
gate="line+unit",
file_read_ranges={a_rel: [[1, 20]]}, # 50% of 40 lines
file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]},
)
assert r["line_coverage_pct"] == 0.0 # 0 of 1 files pass the >=95% line gate
assert r["line_gap_files"][0]["coverage_pct"] == 50.0
assert r["target_reached"] is False
assert r["grade"] == "fail"
def test_gate_line_unit_fails_on_unit_gap(repo):
"""A missing semantic unit fails gate=\"line+unit\"."""
files = ["src/a.py"]
units = _mk_store_units("src/a.py", {"a": [1, 20], "b": [21, 40]})
store = _Store(files, units)
a_rel = "src/a.py"
r = compute_coverage(
store, repo,
deep_read_files=["src/a.py"], include_churn=False,
gate="line+unit",
file_read_ranges={a_rel: [[1, 40]]},
file_semantic_units={a_rel: [{"range": [1, 20], "kind": "Function", "name": "a"}]},
)
assert len(r["unit_gap_files"]) == 1
assert r["target_reached"] is False
def test_coverage_index_v2_roundtrip(repo, monkeypatch):
files = ["src/a.py"]
a_rel = "src/a.py"
r = save_coverage_index_func(
deep_read_files=[a_rel],
repo_root=str(repo),
file_read_ranges={a_rel: [[1, 2]]},
file_semantic_units={a_rel: [{"range": [1, 2], "kind": "Function", "name": "a"}]},
)
assert r["status"] == "ok"
index_path = _coverage_index_path(repo)
assert index_path.is_file()
payload = json.loads(index_path.read_text(encoding="utf-8"))
assert payload["version"] == 2
entry = payload["entries"][a_rel]
assert entry["ranges"] == [[1, 2]]
assert entry["units"]
+26 -8
View File
@@ -207,9 +207,17 @@ class TestLongRunningToolsAreAsync:
)
def test_heavy_tool_source_uses_to_thread(self):
"""Defense in depth: the source of every heavy tool wrapper must
literally call asyncio.to_thread so we don't accidentally turn
a tool async without offloading the blocking work."""
"""Defense in depth: every heavy tool wrapper must offload its
blocking work via ``asyncio.to_thread`` either directly or through
the shared ``_run_with_progress`` helper so we don't accidentally
turn a tool async without offloading the work; that would hang the
stdio event loop on Windows. See #46, #136."""
helper = inspect.getsource(crg_main._run_with_progress)
assert "asyncio.to_thread" in helper, (
"_run_with_progress must call asyncio.to_thread to offload "
"blocking work; otherwise Windows MCP clients will hang. "
"See #46, #136."
)
for tool_name in self.HEAVY_TOOLS:
fn = getattr(crg_main, tool_name, None)
assert fn is not None, f"{tool_name} not found on module"
@@ -217,10 +225,12 @@ class TestLongRunningToolsAreAsync:
# through the wrapper to find the underlying source.
underlying = getattr(fn, "fn", None) or fn
source = inspect.getsource(underlying)
assert "asyncio.to_thread" in source, (
f"{tool_name} must call asyncio.to_thread to offload its "
f"blocking work; otherwise Windows MCP clients will hang. "
f"See #46, #136."
assert (
"asyncio.to_thread" in source or "_run_with_progress" in source
), (
f"{tool_name} must offload its blocking work via "
f"asyncio.to_thread or _run_with_progress; otherwise Windows "
f"MCP clients will hang. See #46, #136."
)
@pytest.mark.parametrize("tool_name,impl_name", HEAVY_TOOL_IMPLS.items())
@@ -361,11 +371,19 @@ class TestGraphBackedToolProvenanceCoverage:
self, category, tool_names,
):
assert tool_names, f"{category} must name at least one tool"
helper = inspect.getsource(crg_main._run_with_progress)
assert "with_provenance" in helper, (
"_run_with_progress must attach graph provenance for tools that "
"route through it"
)
for tool_name in tool_names:
tool = getattr(crg_main, tool_name, None)
assert tool is not None, f"{category}: missing {tool_name}"
underlying = getattr(tool, "fn", None) or tool
assert "with_provenance" in inspect.getsource(underlying), (
source = inspect.getsource(underlying)
assert (
"with_provenance" in source or "_run_with_progress" in source
), (
f"{category}: {tool_name} does not attach graph provenance"
)
+223
View File
@@ -0,0 +1,223 @@
"""Tests for the MCP progress bridge (_run_with_progress) and engine progress_cb.
Covers:
- Engine functions accept and invoke ``progress_cb`` (compute_coverage /
deep_read_plan / score_review / compute_file_churn).
- The event-loop heartbeat helper ``_run_with_progress`` emits MCP progress
notifications and relays real progress from the worker thread.
- ``CRG_TOOL_TIMEOUT`` server-side backstop returns a readable error dict.
"""
from __future__ import annotations
import asyncio
import sys
import time
from pathlib import Path
from typing import Any, Callable, Optional
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from code_review_graph.scoring import ( # noqa: E402
compute_coverage,
deep_read_plan,
score_review,
)
class _FakeContext:
"""Minimal stand-in for fastmcp Context with a recording report_progress."""
def __init__(self) -> None:
self.calls: list[tuple[float, Optional[float], Optional[str]]] = []
async def report_progress(
self, progress: float, total: Optional[float] = None, message: Optional[str] = None
) -> None:
self.calls.append((progress, total, message))
class _FakeStore:
"""Minimal GraphStore stub covering what coverage/score use."""
def __init__(self, files: list[str]) -> None:
self._files = list(files)
def get_all_files(self) -> list[str]:
return list(self._files)
def get_nodes_by_file(self, file_path: str):
return []
def get_edges_by_target(self, qualified_name: str):
return []
def get_community_ids_by_qualified_names(self, qualified_names):
return {}
def get_edges_by_source(self, qualified_name: str):
return []
def get_communities(self, limit=None):
return []
def get_edges(self, kind=None, limit=None):
return []
@pytest.fixture
def risk_repo(tmp_path: Path) -> Path:
"""a.py carries an SQL-risk signal (fail w1), the rest are clean."""
Path(tmp_path, "src").mkdir(exist_ok=True)
Path(tmp_path, "src", "a.py").write_text(
"def a():\n"
" sql = 'SELECT * FROM users WHERE id=' + str(uid)\n"
" return sql\n",
encoding="utf-8",
)
for name in ("b", "c", "d"):
Path(tmp_path, "src", f"{name}.py").write_text(
f"def {name}():\n return 2\n",
encoding="utf-8",
)
return tmp_path
def _record_progress(records: list[tuple[float, Optional[str]]]) -> Callable[[float, Optional[str]], None]:
def cb(fraction: float, message: Optional[str]) -> None:
records.append((fraction, message))
return cb
# ---------------------------------------------------------------------------
# Engine progress_cb
# ---------------------------------------------------------------------------
def test_compute_coverage_invokes_progress_cb(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _FakeStore(files)
records: list[tuple[float, Optional[str]]] = []
result = compute_coverage(
store, risk_repo,
deep_read_files=["src/a.py"],
include_churn=False,
progress_cb=_record_progress(records),
)
assert result["status"] == "ok"
# Weights report every 50 files (>=1 call) plus a final "done".
assert len(records) >= 1
assert records[-1][0] == 1.0
assert "done" in (records[-1][1] or "").lower()
def test_deep_read_plan_invokes_progress_cb(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _FakeStore(files)
records: list[tuple[float, Optional[str]]] = []
plan = deep_read_plan(
store, risk_repo,
deep_read_files=["src/a.py"],
target_coverage=85.0,
include_churn=False,
progress_cb=_record_progress(records),
)
assert plan["status"] == "ok"
assert len(records) >= 1
assert records[-1][0] == 1.0
def test_score_review_invokes_progress_cb(risk_repo):
store = _FakeStore([])
records: list[tuple[float, Optional[str]]] = []
result = score_review(
store, risk_repo,
changed_files=["src/a.py", "src/b.py", "src/c.py", "src/d.py"],
include_churn=False,
progress_cb=_record_progress(records),
)
assert result["status"] == "ok"
# One report per metric (5) + final done.
assert len(records) >= 5
assert records[-1][0] == 1.0
# ---------------------------------------------------------------------------
# _run_with_progress heartbeat helper
# ---------------------------------------------------------------------------
def test_run_with_progress_emits_heartbeat_and_real_progress(risk_repo):
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def slow_coverage(deep_read_files, repo_root, progress_cb=None, **kw):
for i in range(3):
time.sleep(0.05)
if progress_cb:
progress_cb(i / 3.0, f"step {i}")
return compute_coverage(
_FakeStore(["src/a.py", "src/b.py"]),
repo_root,
deep_read_files=deep_read_files,
include_churn=False,
progress_cb=progress_cb,
)
result = asyncio.run(
_run_with_progress(
ctx, slow_coverage,
deep_read_files=["src/a.py"], repo_root=risk_repo,
heartbeat=0.02, tool_timeout=0,
)
)
assert result["status"] == "ok"
# Heartbeat + engine progress: at least one notification, and a real
# (non-"processing...") message from the worker surfaced through.
assert len(ctx.calls) >= 1
messages = [m for (_, _, m) in ctx.calls if m]
assert any("step" in m for m in messages), f"real progress not relayed: {messages}"
def test_run_with_progress_timeout_returns_error(risk_repo):
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def forever(**kw):
time.sleep(5)
return {"status": "ok"}
result = asyncio.run(
_run_with_progress(
ctx, forever,
heartbeat=0.01, tool_timeout=1,
)
)
assert result["status"] == "error"
assert "timeout" in (result.get("error") or "").lower()
def test_run_with_progress_relays_when_no_progress_cb_param():
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def plain(**kw):
time.sleep(0.2)
return {"status": "ok", "value": 42}
result = asyncio.run(
_run_with_progress(
ctx, plain,
heartbeat=0.02, tool_timeout=0,
)
)
assert result["status"] == "ok" and result["value"] == 42
# Pure heartbeat notifications (no real progress) still fire to keep the
# client timeout reset.
assert len(ctx.calls) >= 1
+3 -4
View File
@@ -186,13 +186,12 @@ class TestUnifiedReviewPrompt:
result = unified_review_prompt()
text = _text(result[0])
assert "HEAD~1" in text
assert "standard tier" in text
assert "Standard tier" in text
def test_custom_base_and_tier(self):
result = unified_review_prompt(base="develop", tier="strict")
def test_custom_base(self):
result = unified_review_prompt(base="develop")
text = _text(result[0])
assert "base=develop" in text
assert "strict tier" in text
def test_mentions_score_review(self):
result = unified_review_prompt()
+135
View File
@@ -81,6 +81,61 @@ class TestBuildReportData:
assert data["verdict"] == "\u274c FAIL"
assert data["issues"] == []
def test_reviewed_files_carried(self):
rd = _review_data()
rd["reviewed_files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.py", "src/b.py"]
def test_reviewed_files_default_empty(self):
data = build_report_data(_review_data())
assert data["reviewed_files"] == []
def test_files_list_normalised_to_string(self):
rd = _review_data()
rd["files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["files"] == "src/a.py, src/b.py"
def test_reviewed_files_fallback_from_files_list(self):
rd = _review_data()
rd["files"] = ["src/a.py", "src/b.py"]
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.py", "src/b.py"]
# explicit reviewed_files wins over the files-list fallback
rd["reviewed_files"] = ["src/a.py"]
assert build_report_data(rd)["reviewed_files"] == ["src/a.py"]
def test_reviewed_files_string_split(self):
"""A comma-separated STRING reviewed_files must be split into an array
(agents pass both formats). Guards against iterating the string
char-by-char (which rendered 's','e','r'... as fake files)."""
rd = _review_data()
rd["reviewed_files"] = "src/a.rs, src/b.rs, "
data = build_report_data(rd)
assert data["reviewed_files"] == ["src/a.rs", "src/b.rs"]
md = render_markdown_report(rd)
assert "src/a.rs" in md
assert "src/b.rs" in md
# no single-char file entries
assert "- `s`" not in md
assert "265" not in md
def test_metrics_filters_non_objective_keys(self):
rd = _review_data()
rd["metrics"]["blast_radius"] = {
"direct_changed_nodes": 101,
"impacted_nodes": 13,
}
rd["metrics"]["objective_grade"] = "fail"
data = build_report_data(rd)
assert "blast_radius" not in data["metrics"]
assert "objective_grade" not in data["metrics"]
assert set(data["metrics"]) == {
"sql_risk",
"exception_coverage",
}
class TestLoadTemplate:
def test_loads_package_asset(self):
@@ -123,6 +178,45 @@ class TestRenderMarkdownReport:
assert "# 代码审查报告" in md
assert "未发现问题" in md
def test_line_unit_coverage_skips_file_count_rows(self):
"""gate=\"line+unit\" (feature) must NOT render the 全库/高风险 rows."""
rd = {
"scope": "feature",
"tier": "standard",
"reviewed_files": ["src/a.py", "src/b.py"],
"coverage": {
"coverage_pct": None,
"high_risk_coverage_pct": None,
"grade": "good",
"target_reached": True,
"gate": "line+unit",
"line_coverage_pct": 100.0,
"unit_coverage_pct": 100.0,
"line_gap_files": [],
"unit_gap_files": [],
},
}
md = render_markdown_report(rd)
assert "## 覆盖度" in md
assert "**状态**" in md
assert "100.0%" in md
assert "覆盖度(全库)" not in md
assert "覆盖度(高风险)" not in md
def test_reviewed_files_collapsible(self):
rd = {
"scope": "feature",
"tier": "standard",
"reviewed_files": ["src/a.py", "src/b.py"],
}
md = render_markdown_report(rd)
assert "审查文件(2 个)" in md
assert "<details>" in md
assert "点击展开 / 收起" in md
assert "src/a.py" in md
# flat "文件" meta line is skipped when reviewed_files is present
assert "- **文件**" not in md
class TestGenerateReport:
def test_both_writes_html_and_md(self, tmp_path):
@@ -147,6 +241,47 @@ class TestGenerateReport:
assert "N+1" in html_text
assert "# 代码审查报告" in md_text
def test_script_closing_sequence_is_escaped(self, tmp_path):
"""Literal "</script>" in finding text must not break the HTML.
A literal ``</script>`` inside the injected JSON data would close the
surrounding <script> tag early, corrupting the report. The JSON payload
must escape every "<" as "\\u003c" so the browser parses it as a plain
string while the JS template re-renders it as "<".
"""
root = _mkroot(tmp_path)
rd = _review_data()
rd["findings"] = [
{
"severity": "major",
"category": "security",
"message": 'literal </script> opener',
"path": "src/app.py",
"line": 42,
"confidence": 8,
"fix": "escape </script> before embedding",
},
]
result = generate_report_func(
rd,
output_path="script-safe",
repo_root=str(root),
)
assert result["status"] == "ok"
html_text = (root / "script-safe.html").read_text(encoding="utf-8")
# The injected JSON payload must not contain a bare closing tag.
data_block = html_text.split("const data = ", 1)[1].split(";", 1)[0]
assert "</script" not in data_block
# It must keep the JSON escape so the value round-trips as "<".
assert "\\u003c/script" in data_block
# The real template closing tag must still be present exactly once.
assert html_text.count("</script>") == 1
# Finding text is intact after JSON decoding.
import json as _json
decoded = _json.loads(data_block)
assert "literal </script> opener" in decoded["issues"][0]["message"]
def test_markdown_only(self, tmp_path):
root = _mkroot(tmp_path)
result = generate_report_func(
+13
View File
@@ -139,6 +139,19 @@ class TestVulnerabilityHeuristic:
assert result["value"] >= 1
assert result["grade"] in ("warn", "fail")
def test_note_is_chinese(self, tmp_path):
"""The metric note must follow the report language (Chinese), not
English. Technical proper nouns (OWASP, npm audit) may remain."""
Path(tmp_path, "s.py").write_text(
"def f():\n return 1\n",
encoding="utf-8",
)
result = compute_vulnerability_heuristic(["s.py"], tmp_path)
assert result["note"]
assert "启发式" in result["note"]
# the body is Chinese, not the old English sentence
assert "Heuristic OWASP/secret-pattern scan" not in result["note"]
# ---------------------------------------------------------------------------
# dedupe_findings
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ wheels = [
[[package]]
name = "code-review-graph"
version = "2.3.7"
version = "2.5.1"
source = { editable = "." }
dependencies = [
{ name = "fastmcp" },