from __future__ import annotations import json import pathlib import shutil import subprocess import sys import tempfile import unittest SKILL_ROOT = pathlib.Path(__file__).resolve().parents[1] REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] SCRIPT = SKILL_ROOT / "scripts" / "review_context_builder.py" TMP_ROOT = REPO_ROOT / "tmp" / "review-context-builder-tests" def run_cli(*args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(SCRIPT), *args], text=True, capture_output=True, check=False, ) def write_text(path: pathlib.Path, text: str = "content") -> pathlib.Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return path def write_json(path: pathlib.Path, data: object) -> pathlib.Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") return path def load_manifest(output_dir: pathlib.Path) -> dict[str, object]: return json.loads((output_dir / "review-file-manifest.json").read_text(encoding="utf-8")) class ReviewContextBuilderTest(unittest.TestCase): def setUp(self) -> None: TMP_ROOT.mkdir(parents=True, exist_ok=True) self.tmp_dir = tempfile.TemporaryDirectory(dir=TMP_ROOT) self.project_root = pathlib.Path(self.tmp_dir.name) / "project" self.output_dir = pathlib.Path(self.tmp_dir.name) / "outputs" self.project_root.mkdir() def tearDown(self) -> None: self.tmp_dir.cleanup() @classmethod def tearDownClass(cls) -> None: if TMP_ROOT.exists(): shutil.rmtree(TMP_ROOT) def run_builder(self, *extra_args: str) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]: result = run_cli( "--project-root", str(self.project_root), "--review-id", "round-05-local-pass", "--output-dir", str(self.output_dir), *extra_args, ) manifest = load_manifest(self.output_dir) context = (self.output_dir / "review-context.md").read_text(encoding="utf-8") return result, manifest, context def test_complete_context_build_indexes_configured_sources(self) -> None: write_text(self.project_root / "README.md", "# Project\n") write_text(self.project_root / "reports" / "gate-run-report.md", "# Gate report\n") write_text(self.project_root / "decisions" / "owner-decision.md", "# Decision\n") metadata = write_json( pathlib.Path(self.tmp_dir.name) / "metadata.json", { "review_goal": "check readiness", "non_goals": ["do not decide approval"], "open_questions": ["is evidence complete?"], "target_reviewer": "local reviewer", }, ) result, manifest, context = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", "--metadata", str(metadata), ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(manifest["review_id"], "round-05-local-pass") self.assertEqual(manifest["context_profile"], "generic") self.assertEqual( manifest["files_included"], [ {"path": "README.md", "size_bytes": 10, "source_root": "."}, {"path": "decisions/owner-decision.md", "size_bytes": 11, "source_root": "."}, {"path": "reports/gate-run-report.md", "size_bytes": 14, "source_root": "."}, ], ) self.assertEqual(manifest["metadata"]["review_goal"], "check readiness") self.assertEqual(manifest["known_non_goals"], ["do not decide approval"]) self.assertEqual(manifest["open_questions_for_reviewer_or_agent"], ["is evidence complete?"]) self.assertIn("reports/gate-run-report.md", manifest["important_reports"]) self.assertIn("decisions/owner-decision.md", manifest["prior_decision_references"]) self.assertIn("This context is an index. It does not replace reading source files.", context) def test_missing_source_root_is_blocking_but_outputs_manifest(self) -> None: result, manifest, context = self.run_builder("--source-root", "missing") self.assertEqual(result.returncode, 1) self.assertEqual(manifest["machine_readable_summary"]["status"], "FAIL") self.assertEqual(manifest["missing_source_roots"], ["missing"]) self.assertIn("Missing source root", manifest["blocking_errors"][0]) self.assertIn("missing", context) def test_large_files_are_excluded_with_reason(self) -> None: write_text(self.project_root / "small.md", "small") write_text(self.project_root / "large.md", "0123456789") result, manifest, _ = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", "--max-file-bytes", "5", ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual([item["path"] for item in manifest["files_included"]], ["small.md"]) self.assertEqual( manifest["files_excluded"], [{"path": "large.md", "reason": "size_exceeds_limit", "size_bytes": 10}], ) def test_manifest_path_normalization_uses_project_relative_posix_paths(self) -> None: nested = write_text(self.project_root / "docs" / "nested" / "report.md", "report") result, manifest, _ = self.run_builder("--source-root", str(nested), "--include-pattern", "**/*.md") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(manifest["source_roots"], ["docs/nested/report.md"]) self.assertEqual(manifest["files_included"][0]["path"], "docs/nested/report.md") self.assertNotIn("\\", manifest["files_included"][0]["path"]) def test_utf8_paths_and_chinese_filenames_are_preserved(self) -> None: write_text(self.project_root / "材料" / "审查报告.md", "# 报告\n") result, manifest, context = self.run_builder( "--source-root", "材料", "--include-pattern", "**/*.md", ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(manifest["files_included"][0]["path"], "材料/审查报告.md") self.assertIn("材料/审查报告.md", context) def test_stable_output_ordering_across_repeated_runs(self) -> None: write_text(self.project_root / "zeta.md", "z") write_text(self.project_root / "alpha.md", "a") write_text(self.project_root / "nested" / "beta.md", "b") first_result, first_manifest, first_context = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", ) second_result, second_manifest, second_context = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", ) self.assertEqual(first_result.returncode, 0, first_result.stderr) self.assertEqual(second_result.returncode, 0, second_result.stderr) self.assertEqual(first_manifest, second_manifest) self.assertEqual(first_context, second_context) self.assertEqual([item["path"] for item in first_manifest["files_included"]], ["alpha.md", "nested/beta.md", "zeta.md"]) def test_metadata_passthrough_does_not_add_ccra_fields(self) -> None: metadata = write_json( pathlib.Path(self.tmp_dir.name) / "metadata.json", {"custom_key": "custom value", "round_id": "R05"}, ) write_text(self.project_root / "note.md", "note") result, manifest, _ = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", "--metadata", str(metadata), ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(manifest["metadata"], {"custom_key": "custom value", "round_id": "R05"}) self.assertNotIn("review_round", manifest) self.assertNotIn("local_pass", manifest) def test_exclude_patterns_remove_matching_files(self) -> None: write_text(self.project_root / "include.md", "include") write_text(self.project_root / "scratch" / "exclude.md", "exclude") result, manifest, _ = self.run_builder( "--source-root", ".", "--include-pattern", "**/*.md", "--exclude-pattern", "scratch/**", ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual([item["path"] for item in manifest["files_included"]], ["include.md"]) self.assertEqual( manifest["files_excluded"], [{"path": "scratch/exclude.md", "reason": "exclude_pattern"}], ) if __name__ == "__main__": unittest.main()