chore: sync local changes, add Chinese docs and opencode config
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 Prompts(7 个)](#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-review(diff)/ 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_report(HTML + 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-review(diff 审查) | 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` 确定 tier(fast / 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` | 为全部图节点计算向量 embedding(local/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 Prompts(7 个)
|
||||
|
||||
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]" # 本地向量 embedding(sentence-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`。
|
||||
|
||||
@@ -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-review:diff 代码审查](#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-review:diff 代码审查
|
||||
|
||||
### 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 + Markdown;Step 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.ps1(Step 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 + ranges,SHA 来自 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-review(diff):
|
||||
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 diff,project-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 → good,1-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% → good,30%-50% → warn,<30% → fail。
|
||||
- **注意**:阈值命名易误解——`warn_min=50` 实际是 good 的及格线,`good_min=30` 是 warn 的下限。该指标对 `?` 一元错误传播(Rust)、`Result` 链等"无显式异常关键字"的代码会低估,需人工补查。
|
||||
|
||||
#### 5.3.3 `redundancy_rate` — 代码冗余率
|
||||
- **收集方式**:将每行标准化为签名(去空白、数字→`N`、字符串→`"s"`),统计**在 ≥3 处出现相同签名**的行数。
|
||||
- **计算**:`重复行 / 总行 × 100%`。
|
||||
- **评级**:≤10% → good,10%-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 → good,1-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 解析后还原为 `<`,渲染时再转成 `<`,显示不受影响。**人工无需转义 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 diff(unified-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 索引含 ranges,SHA 来自 nodes.file_hash)写跨轮索引
|
||||
|
||||
### 修复流程(人工裁决)
|
||||
|
||||
1. 审查产出 findings(severity + 置信度 + file:line + 修复建议)
|
||||
2. 按 severity 批量呈现,逐个决定:**修 / 不修 / 自己改**
|
||||
3. 🔴 blocker 不可批量跳过
|
||||
4. 确认后人工执行修复,重新审查验证
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ from .context_savings import (
|
||||
format_context_savings,
|
||||
)
|
||||
|
||||
__version__ = "2.3.7"
|
||||
__version__ = "2.4.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
· Tier: ${data.tier || "standard"} · 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 += ` · overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
|
||||
+ ` · high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
|
||||
}
|
||||
if (cov.line_coverage_pct != null) {
|
||||
c += ` · line ${cov.line_coverage_pct}% · 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.
|
||||
@@ -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 提交。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++
|
||||
@@ -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`(schema:groups_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.json(v2:file + per-file SHA + ranges,SHA 来自 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`(v2:file + per-file SHA + ranges,SHA 来自 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`(schema:groups_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.ps1(Step 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% 才 True;standard=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 => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
})[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>
|
||||
档位: <code>${esc(data.tier || "standard")}</code>
|
||||
范围: <code>${esc(data.scope || "change-level")}</code>
|
||||
${data.baseline ? ` 基线: <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
|
||||
@@ -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 => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
})[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>
|
||||
档位: <code>${esc(data.tier || "standard")}</code>
|
||||
范围: <code>${esc(data.scope || "change-level")}</code>
|
||||
${data.baseline ? ` 基线: <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
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user