feat: add project-review workflow (whole-project / single-feature review)
Adds the project-review workflow for code review independent of the git diff. The scope is parsed from the user instruction: 全面/整个项目 -> whole-project (score every source file), otherwise feature + target keyword (locate the code with semantic search + graph queries). - scoring_tools.py: score_review_func gains all_files=True to score every source file in the graph via store.get_all_files() - main.py: score_review_tool gains all_files param; registers the project_review MCP prompt (prompts 6->7) - prompts.py: project_review_prompt(scope, target) with whole-project and feature branches (fixed a precedence bug that truncated the feature text) - skills.py + skills/project-review/: new read-only project-review skill with shared checklists - .opencode/command/code-review-graph-project-review.md: slash command - tests: test_project_review.py (prompt rendering), TestProjectReviewPrompt, skill count assertions 5->6, all_files wiring checks - docs: prompts (6->7) + project-review entries across COMMANDS, CLAUDE, README (+localized), INDEX, architecture, LLM-OPTIMIZED-REFERENCE, CHANGELOG
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Tests for the project-review workflow (whole-project / feature scope)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastmcp.prompts.prompt import Message # noqa: E402
|
||||
|
||||
from code_review_graph.prompts import project_review_prompt # noqa: E402
|
||||
|
||||
|
||||
def _text(msg: Message) -> str:
|
||||
return msg.content.text
|
||||
|
||||
|
||||
class TestProjectReviewPromptRendering:
|
||||
def test_whole_project_renders_full_workflow(self):
|
||||
text = _text(project_review_prompt()[0])
|
||||
# Header + shared steps 1-4 must all be present.
|
||||
assert "## Project Review Workflow" in text
|
||||
assert "scope=whole-project" in text
|
||||
assert "1. Call" in text
|
||||
assert "4. Locate high-risk" in text
|
||||
# whole-project branch uses all_files=True.
|
||||
assert "all_files=True" in text
|
||||
assert "get_knowledge_gaps" in text
|
||||
|
||||
def test_feature_renders_feature_branch(self):
|
||||
text = _text(project_review_prompt(scope="feature", target="checkout")[0])
|
||||
assert "scope=feature" in text
|
||||
assert "target=checkout" in text
|
||||
# Shared steps 1-4 must still be present (no truncation).
|
||||
assert "4. Locate high-risk" in text
|
||||
# Feature branch: semantic search + impact radius, not all_files.
|
||||
assert "semantic_search_nodes" in text
|
||||
assert "get_impact_radius" in text
|
||||
assert "all_files=True" not in text
|
||||
|
||||
def test_default_scope_is_whole_project(self):
|
||||
text = _text(project_review_prompt()[0])
|
||||
assert "scope=whole-project" in text
|
||||
|
||||
def test_read_only_present(self):
|
||||
text = _text(project_review_prompt()[0])
|
||||
assert "READ-ONLY" in text
|
||||
|
||||
def test_both_branches_have_preamble(self):
|
||||
assert "get_minimal_context" in _text(project_review_prompt()[0])
|
||||
assert "get_minimal_context" in _text(
|
||||
project_review_prompt(scope="feature", target="auth")[0]
|
||||
)
|
||||
|
||||
def test_both_branches_end_with_report_step(self):
|
||||
whole = _text(project_review_prompt()[0])
|
||||
feat = _text(project_review_prompt(scope="feature", target="x")[0])
|
||||
assert "generate_report" in whole
|
||||
assert "generate_report" in feat
|
||||
@@ -7,6 +7,7 @@ from code_review_graph.prompts import (
|
||||
debug_issue_prompt,
|
||||
onboard_developer_prompt,
|
||||
pre_merge_check_prompt,
|
||||
project_review_prompt,
|
||||
review_changes_prompt,
|
||||
unified_review_prompt,
|
||||
)
|
||||
@@ -214,6 +215,58 @@ class TestUnifiedReviewPrompt:
|
||||
assert "FAIL" in _text(result[0])
|
||||
|
||||
|
||||
class TestProjectReviewPrompt:
|
||||
def test_returns_list_with_messages(self):
|
||||
result = project_review_prompt()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_message_has_role_and_content(self):
|
||||
result = project_review_prompt()
|
||||
for msg in result:
|
||||
assert isinstance(msg, Message)
|
||||
assert msg.role == "user"
|
||||
assert _text(msg)
|
||||
|
||||
def test_default_whole_project_scope(self):
|
||||
result = project_review_prompt()
|
||||
text = _text(result[0])
|
||||
assert "whole-project" in text
|
||||
assert "all_files=True" in text
|
||||
|
||||
def test_feature_scope_with_target(self):
|
||||
result = project_review_prompt(scope="feature", target="payment")
|
||||
text = _text(result[0])
|
||||
assert "scope=feature" in text
|
||||
assert "target=payment" in text
|
||||
|
||||
def test_mentions_architecture_scan(self):
|
||||
result = project_review_prompt()
|
||||
text = _text(result[0])
|
||||
assert "get_architecture_overview" in text
|
||||
assert "list_communities" in text
|
||||
|
||||
def test_mentions_high_risk_scan(self):
|
||||
result = project_review_prompt()
|
||||
text = _text(result[0])
|
||||
assert "get_knowledge_gaps" in text
|
||||
assert "get_hub_nodes" in text
|
||||
|
||||
def test_feature_mentions_semantic_search(self):
|
||||
result = project_review_prompt(scope="feature", target="auth")
|
||||
text = _text(result[0])
|
||||
assert "semantic_search_nodes" in text
|
||||
assert "get_impact_radius" in text
|
||||
|
||||
def test_mentions_read_only(self):
|
||||
result = project_review_prompt()
|
||||
assert "READ-ONLY" in _text(result[0])
|
||||
|
||||
def test_mentions_generate_report(self):
|
||||
result = project_review_prompt()
|
||||
assert "generate_report" in _text(result[0])
|
||||
|
||||
|
||||
class TestTokenEfficiencyPreamble:
|
||||
"""All prompts should include the token efficiency preamble."""
|
||||
|
||||
@@ -241,3 +294,7 @@ class TestTokenEfficiencyPreamble:
|
||||
def test_unified_review_has_preamble(self):
|
||||
result = unified_review_prompt()
|
||||
assert "get_minimal_context" in _text(result[0])
|
||||
|
||||
def test_project_review_has_preamble(self):
|
||||
result = project_review_prompt()
|
||||
assert "get_minimal_context" in _text(result[0])
|
||||
|
||||
+111
-19
@@ -1,4 +1,4 @@
|
||||
"""Tests for the unified-review HTML report tool."""
|
||||
"""Tests for the unified-review HTML/Markdown report tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,13 +9,14 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from code_review_graph.scoring import build_report_data # noqa: E402
|
||||
from code_review_graph.scoring import ( # noqa: E402
|
||||
build_report_data,
|
||||
render_markdown_report,
|
||||
)
|
||||
from code_review_graph.tools.scoring_tools import ( # noqa: E402
|
||||
_load_report_template,
|
||||
generate_report_func,
|
||||
)
|
||||
|
||||
|
||||
def _review_data() -> dict:
|
||||
return {
|
||||
"scope": "change-level",
|
||||
@@ -24,6 +25,8 @@ def _review_data() -> dict:
|
||||
"files": "app.py, main.py",
|
||||
"baseline": "generic",
|
||||
"verdict": "\u2705 PASS",
|
||||
"quality_score": 7.0,
|
||||
"counts": {"critical": 1, "informational": 2},
|
||||
"metrics": {
|
||||
"sql_risk": {"value": 0, "grade": "good", "note": "heuristic"},
|
||||
"exception_coverage": {"value": 33.33, "grade": "warn", "note": "heuristic"},
|
||||
@@ -44,6 +47,11 @@ def _review_data() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _mkroot(tmp_path: Path) -> Path:
|
||||
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestBuildReportData:
|
||||
def test_normalises_review_data(self):
|
||||
data = build_report_data(_review_data())
|
||||
@@ -55,6 +63,11 @@ class TestBuildReportData:
|
||||
assert data["issues"][0]["location"] == "src/app.py:42"
|
||||
assert "requirement_coverage" in data["llm_judged"]
|
||||
|
||||
def test_quality_score_and_counts_carried(self):
|
||||
data = build_report_data(_review_data())
|
||||
assert data["quality_score"] == 7.0
|
||||
assert data["counts"] == {"critical": 1, "informational": 2}
|
||||
|
||||
def test_empty_findings(self):
|
||||
rd = _review_data()
|
||||
rd["findings"] = []
|
||||
@@ -76,29 +89,108 @@ class TestLoadTemplate:
|
||||
assert template.startswith("<!DOCTYPE html>")
|
||||
|
||||
|
||||
class TestRenderMarkdownReport:
|
||||
def test_renders_chinese_structure(self):
|
||||
md = render_markdown_report(_review_data())
|
||||
assert "# 代码审查报告" in md
|
||||
assert "结论" in md
|
||||
assert "PR 质量分" in md and "7.0/10" in md
|
||||
assert "客观指标" in md
|
||||
assert "SQL 注入风险" in md
|
||||
assert "异常分支覆盖" in md
|
||||
assert "问题清单" in md
|
||||
assert "🟡 主要" in md
|
||||
assert "置信度 7/10" in md
|
||||
assert "修复建议" in md
|
||||
assert "需要人工审查" in md
|
||||
assert "Payment callback idempotency" in md
|
||||
assert "需 LLM 判断的指标" in md
|
||||
|
||||
def test_grade_localisation(self):
|
||||
rd = _review_data()
|
||||
rd["metrics"] = {
|
||||
"sql_risk": {"value": 0, "grade": "good"},
|
||||
"vulnerability_risk": {"value": 2, "grade": "fail"},
|
||||
"high_risk_density": {"value": None, "grade": "na"},
|
||||
}
|
||||
md = render_markdown_report(rd)
|
||||
assert "良好" in md
|
||||
assert "不合格" in md
|
||||
assert "不适用" in md
|
||||
|
||||
def test_empty_report(self):
|
||||
md = render_markdown_report({})
|
||||
assert "# 代码审查报告" in md
|
||||
assert "未发现问题" in md
|
||||
|
||||
|
||||
class TestGenerateReport:
|
||||
def test_writes_self_contained_html(self, tmp_path):
|
||||
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
|
||||
out = tmp_path / "sub" / "report.html"
|
||||
def test_both_writes_html_and_md(self, tmp_path):
|
||||
root = _mkroot(tmp_path)
|
||||
result = generate_report_func(
|
||||
_review_data(),
|
||||
output_path=str(out),
|
||||
repo_root=str(tmp_path),
|
||||
output_path="sub/report",
|
||||
repo_root=str(root),
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert Path(result["output_path"]).is_file()
|
||||
html = Path(result["output_path"]).read_text(encoding="utf-8")
|
||||
assert "{{REPORT_DATA}}" not in html
|
||||
assert "\u2705 PASS" in html
|
||||
assert "N+1" in html
|
||||
assert isinstance(result["output_path"], list)
|
||||
assert len(result["files"]) == 2
|
||||
html = root / "sub" / "report.html"
|
||||
md = root / "sub" / "report.md"
|
||||
assert html.is_file()
|
||||
assert md.is_file()
|
||||
html_text = html.read_text(encoding="utf-8")
|
||||
md_text = md.read_text(encoding="utf-8")
|
||||
assert "{{REPORT_DATA}}" not in html_text
|
||||
assert "代码审查报告" in html_text
|
||||
assert "\u2705 PASS" in html_text
|
||||
assert "N+1" in html_text
|
||||
assert "# 代码审查报告" in md_text
|
||||
|
||||
def test_markdown_only(self, tmp_path):
|
||||
root = _mkroot(tmp_path)
|
||||
result = generate_report_func(
|
||||
_review_data(),
|
||||
output_path="md-only",
|
||||
repo_root=str(root),
|
||||
format="markdown",
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert len(result["files"]) == 1
|
||||
assert str(result["output_path"]).endswith("md-only.md")
|
||||
assert (root / "md-only.md").is_file()
|
||||
assert not (root / "md-only.html").exists()
|
||||
|
||||
def test_html_only(self, tmp_path):
|
||||
root = _mkroot(tmp_path)
|
||||
result = generate_report_func(
|
||||
_review_data(),
|
||||
output_path="html-only",
|
||||
repo_root=str(root),
|
||||
format="html",
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert len(result["files"]) == 1
|
||||
assert str(result["output_path"]).endswith("html-only.html")
|
||||
assert (root / "html-only.html").is_file()
|
||||
assert not (root / "html-only.md").exists()
|
||||
|
||||
def test_default_output_path_is_repo_root(self, tmp_path):
|
||||
# repo_root must look like a project root: create .code-review-graph.
|
||||
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
|
||||
result = generate_report_func(_review_data(), repo_root=str(tmp_path))
|
||||
root = _mkroot(tmp_path)
|
||||
result = generate_report_func(_review_data(), repo_root=str(root))
|
||||
assert result["status"] == "ok"
|
||||
assert result["output_path"].endswith("code-review-report.html")
|
||||
assert Path(result["output_path"]).is_file()
|
||||
assert (root / "code-review-report.html").is_file()
|
||||
assert (root / "code-review-report.md").is_file()
|
||||
|
||||
def test_invalid_format_errors(self, tmp_path):
|
||||
root = _mkroot(tmp_path)
|
||||
result = generate_report_func(
|
||||
_review_data(),
|
||||
repo_root=str(root),
|
||||
format="xml",
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
assert "xml" in result["error"]
|
||||
|
||||
def test_handles_missing_repo_root(self, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -118,12 +118,13 @@ class TestGenerateSkills:
|
||||
assert result.is_dir()
|
||||
assert result == tmp_path / ".claude" / "skills"
|
||||
|
||||
def test_creates_five_skill_subdirs(self, tmp_path):
|
||||
def test_creates_six_skill_subdirs(self, tmp_path):
|
||||
skills_dir = generate_skills(tmp_path)
|
||||
subdirs = sorted(f.name for f in skills_dir.iterdir() if f.is_dir())
|
||||
assert subdirs == [
|
||||
"debug-issue",
|
||||
"explore-codebase",
|
||||
"project-review",
|
||||
"refactor-safely",
|
||||
"review-changes",
|
||||
"unified-review",
|
||||
@@ -153,6 +154,7 @@ class TestGenerateSkills:
|
||||
for skill_name in (
|
||||
"debug-issue",
|
||||
"explore-codebase",
|
||||
"project-review",
|
||||
"refactor-safely",
|
||||
"review-changes",
|
||||
"unified-review",
|
||||
@@ -169,7 +171,7 @@ class TestGenerateSkills:
|
||||
result = generate_skills(tmp_path, skills_dir=custom)
|
||||
assert result == custom
|
||||
assert result.is_dir()
|
||||
assert len(list(result.iterdir())) == 5
|
||||
assert len(list(result.iterdir())) == 6
|
||||
|
||||
def test_skill_content_includes_get_minimal_context(self, tmp_path):
|
||||
"""Every skill template must reference get_minimal_context."""
|
||||
@@ -194,7 +196,7 @@ class TestGenerateSkills:
|
||||
generate_skills(tmp_path)
|
||||
generate_skills(tmp_path)
|
||||
skills_dir = tmp_path / ".claude" / "skills"
|
||||
assert len(list(skills_dir.iterdir())) == 5
|
||||
assert len(list(skills_dir.iterdir())) == 6
|
||||
|
||||
|
||||
class TestGenerateHooksConfig:
|
||||
@@ -859,8 +861,10 @@ class TestCodeBuddyPlatform:
|
||||
assert {path.name for path in skills_root.iterdir()} == {
|
||||
"debug-issue",
|
||||
"explore-codebase",
|
||||
"project-review",
|
||||
"refactor-safely",
|
||||
"review-changes",
|
||||
"unified-review",
|
||||
}
|
||||
for skill_dir in skills_root.iterdir():
|
||||
content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
@@ -31,6 +31,19 @@ class TestToolRegistration:
|
||||
):
|
||||
assert hasattr(m, name), f"{name} not exposed by main module"
|
||||
|
||||
def test_project_review_prompt_exposed(self):
|
||||
import code_review_graph.main as m
|
||||
|
||||
assert hasattr(m, "project_review"), "project_review prompt not exposed"
|
||||
|
||||
def test_score_review_has_all_files_param(self):
|
||||
import inspect
|
||||
|
||||
import code_review_graph.main as m
|
||||
|
||||
sig = inspect.signature(m.score_review_tool)
|
||||
assert "all_files" in sig.parameters
|
||||
|
||||
|
||||
class TestDocsSections:
|
||||
def test_unified_review_section(self):
|
||||
@@ -61,7 +74,23 @@ class TestGenerateSkills:
|
||||
assert "get_minimal_context" in content
|
||||
assert "detail_level" in content
|
||||
|
||||
def test_project_review_generated(self, tmp_path):
|
||||
from code_review_graph.skills import generate_skills
|
||||
|
||||
skills_dir = generate_skills(tmp_path)
|
||||
skill_file = skills_dir / "project-review" / "SKILL.md"
|
||||
assert skill_file.is_file()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "all_files=True" in content
|
||||
assert "get_minimal_context" in content
|
||||
assert "detail_level" in content
|
||||
|
||||
def test_uninstall_knows_unified_review(self):
|
||||
from code_review_graph.uninstall import _generated_skill_slugs
|
||||
|
||||
assert "unified-review" in _generated_skill_slugs()
|
||||
|
||||
def test_uninstall_knows_project_review(self):
|
||||
from code_review_graph.uninstall import _generated_skill_slugs
|
||||
|
||||
assert "project-review" in _generated_skill_slugs()
|
||||
|
||||
Reference in New Issue
Block a user