235 lines
9.0 KiB
Python
235 lines
9.0 KiB
Python
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()
|