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
198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
"""Tests for the unified-review HTML/Markdown report tool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
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",
|
|
"tier": "standard",
|
|
"timestamp": "2026-08-05T00:00:00Z",
|
|
"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"},
|
|
},
|
|
"findings": [
|
|
{
|
|
"severity": "MAJOR",
|
|
"category": "Data",
|
|
"message": "Possible N+1 query",
|
|
"path": "src/app.py",
|
|
"line": 42,
|
|
"confidence": 7,
|
|
"fix": "Add eager loading",
|
|
},
|
|
],
|
|
"manual_review": ["Payment callback idempotency"],
|
|
"llm_judged": ["requirement_coverage", "logic_alignment"],
|
|
}
|
|
|
|
|
|
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())
|
|
assert data["verdict"] == "\u2705 PASS"
|
|
assert data["tier"] == "standard"
|
|
assert data["scope"] == "change-level"
|
|
assert data["metrics"]["sql_risk"]["grade"] == "good"
|
|
assert len(data["issues"]) == 1
|
|
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"] = []
|
|
data = build_report_data(rd)
|
|
assert data["issues"] == []
|
|
|
|
def test_defaults(self):
|
|
data = build_report_data({})
|
|
assert data["scope"] == "change-level"
|
|
assert data["tier"] == "standard"
|
|
assert data["verdict"] == "\u274c FAIL"
|
|
assert data["issues"] == []
|
|
|
|
|
|
class TestLoadTemplate:
|
|
def test_loads_package_asset(self):
|
|
template = _load_report_template()
|
|
assert "{{REPORT_DATA}}" in template
|
|
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_both_writes_html_and_md(self, tmp_path):
|
|
root = _mkroot(tmp_path)
|
|
result = generate_report_func(
|
|
_review_data(),
|
|
output_path="sub/report",
|
|
repo_root=str(root),
|
|
)
|
|
assert result["status"] == "ok"
|
|
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):
|
|
root = _mkroot(tmp_path)
|
|
result = generate_report_func(_review_data(), repo_root=str(root))
|
|
assert result["status"] == "ok"
|
|
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):
|
|
generate_report_func(_review_data(), repo_root=str(tmp_path / "missing"))
|