import copy import importlib.util import json import os import stat import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[3] MODPATH = ROOT / "harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py" spec = importlib.util.spec_from_file_location("review_support", MODPATH) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) def result(verdict="approve"): return { "schema": "gitea-agent.implementation-spec-review-result/v1", "target": {"owner": "acme", "repository": "repo", "issue": 3, "pull": 7, "head": "a" * 40}, "contract_sha256": "b" * 64, "verdict": verdict, "criteria": [{"criterion": "REQ-1", "contract": "계약", "implementation_paths": ["src/a.py"], "test_paths": ["tests/test_a.py"], "evidence": "증거", "result": "satisfied", "reason": "충족"}], "scope": {"missing": [], "excess": [], "spec_conflicts": []}, "conclusion": "모든 기준을 충족했다.", } def publication_fixture(): r = result(); paths = {"src/a.py", "tests/test_a.py"} body = mod.render_review(r, paths, declaration={"mode": "none"}) url = "https://example.test/acme/repo/pulls/7" user = {"id": 42, "login": "spec-agent"} review = {"id": 11, "body": body, "commit_id": r["target"]["head"], "state": "APPROVED", "user": user, "official": True, "stale": False, "dismissed": False, "comments_count": 0, "submitted_at": "2026-10-03T01:24:00Z", "updated_at": "2026-10-03T01:24:00Z", "pull_request_url": url} before = {"issue": {"id": 3, "number": 3, "repository": {"full_name": "acme/repo"}, "updated_at": "2026-10-03T00:00:00Z", "title": "contract"}, "issue_comments": [], "pr": {"id": 7, "number": 7, "html_url": url, "url": url, "base": {"repo": {"full_name": "acme/repo"}, "sha": "c" * 40}, "head": {"sha": "a" * 40}, "user": {"id": 99}, "state": "open", "merged": False, "review_comments": 0, "updated_at": "2026-10-03T00:00:00Z", "requested_reviewers": []}, "pr_comments": [], "reviews": [], "timeline": [], "files": [{"filename": "src/a.py", "status": "modified", "additions": 1}]} after = copy.deepcopy(before); after["reviews"] = [review] after["pr"]["updated_at"] = review["submitted_at"]; after["pr"]["review_comments"] = 1 after["timeline"] = [{"id": 101, "review_id": 11, "user": user, "body": body, "type": "review", "created_at": review["submitted_at"], "updated_at": review["submitted_at"], "pull_request_url": url}] return before, after, review, r, paths def verify_fixture(before, after, review, r, paths, contract=None): return mod.verify_publication(before, after, review, r, paths, 42, [], contract, expected_pr_url="https://example.test/acme/repo/pulls/7") class SupportTests(unittest.TestCase): def test_loads_json_rejects_duplicate_and_nonfinite(self): with self.assertRaises(mod.ValidationError): mod.loads_json('{"a":1,"a":2}') with self.assertRaises(mod.ValidationError): mod.loads_json('{"a":NaN}') def test_validate_and_render_roundtrip(self): r = result() self.assertEqual(mod.validate_result(r, {"src/a.py", "tests/test_a.py"}), r) body = mod.render_review(r, {"src/a.py", "tests/test_a.py"}, declaration={"mode": "none"}) parsed = mod.parse_review(body, {"src/a.py", "tests/test_a.py"}) self.assertEqual(parsed, r) self.assertEqual(mod.render_review(parsed, {"src/a.py", "tests/test_a.py"}, declaration={"mode": "none"}), body) def test_validation_rejects_bool_path_traversal_bad_verdict(self): r = result() r["target"]["issue"] = True with self.assertRaises(mod.ValidationError): mod.validate_result(r, {"src/a.py"}) r = result(); r["criteria"][0]["implementation_paths"] = ["../x"] with self.assertRaises(mod.ValidationError): mod.validate_result(r, {"../x"}) r = result("request_changes") with self.assertRaises(mod.ValidationError): mod.validate_result(r, {"src/a.py", "tests/test_a.py"}) def test_collect_pages_requires_identity_and_reads_all_pages(self): pages = [[{"id": 1}, {"id": 2}], [{"id": 3}]] def fetch(n): return {"items": pages[n - 1], "total_count": 3, "has_more": n == 1} self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), pages[0] + pages[1]) def bad(n): return {"items": [{"id": 1}], "total_count": 2, "has_more": True} with self.assertRaises(mod.ValidationError): mod.collect_pages(bad, "id", max_pages=1) def test_verify_unchanged(self): self.assertTrue(mod.verify_unchanged({"a": [1]}, {"a": [1]})) with self.assertRaises(mod.ValidationError): mod.verify_unchanged({"a": 1}, {"a": 2}) def test_cache_validation_and_rejects_symlink(self): with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td: root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True) contract = ops / "repository-cache.md"; contract.write_text("cache", encoding="utf-8") rs = root / "repositories"; owner = rs / "acme"; repo = owner / "repo" (repo / "locks").mkdir(parents=True) (repo / "repository.git").mkdir() for p in (rs, owner, repo, repo / "locks"): os.chmod(p, 0o700) info = mod.validate_cache_paths(contract, "acme", "repo", 3) self.assertTrue(info["repository_git"].endswith("repository.git")) (repo / "locks" / "issue-3.lock").symlink_to(contract) with self.assertRaises(mod.ValidationError): mod.validate_cache_paths(contract, "acme", "repo", 3) def test_publication_accepts_one_exact_review_and_rejects_competing_event(self): before, after, review, r, paths = publication_fixture() self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11) bad = copy.deepcopy(after) bad["timeline"].append({"id": 102, "review_id": 12, "user": {"id": 42}, "body": review["body"]}) with self.assertRaises(mod.ValidationError): verify_fixture(before, bad, review, r, paths) def test_cli_check_pages_and_unchanged(self): with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td: td = Path(td); env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") pages = td / "pages.json"; pages.write_text(json.dumps([ {"items": [{"id": 1}], "total_count": 1, "has_more": False}],), encoding="utf-8") base = [sys.executable, str(MODPATH)] p = subprocess.run(base + ["check-pages", "--pages", str(pages), "--key", "id"], capture_output=True, text=True, env=env) self.assertEqual(p.returncode, 0, p.stderr) b = td / "before.json"; a = td / "after.json" b.write_text('{"x":1}', encoding="utf-8"); a.write_text('{"x":1}', encoding="utf-8") p = subprocess.run(base + ["check-unchanged", "--before", str(b), "--after", str(a)], capture_output=True, text=True, env=env) self.assertEqual(p.returncode, 0, p.stderr); self.assertIn('"unchanged": true', p.stdout) def test_cli_validate_render_parse(self): with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td: td = Path(td); rf = td / "r.json"; af = td / "a.json"; bf = td / "b.md" rf.write_text(json.dumps(result(), ensure_ascii=False), encoding="utf-8") af.write_text(json.dumps(["src/a.py", "tests/test_a.py"]), encoding="utf-8") env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") base = [sys.executable, str(MODPATH)] p = subprocess.run(base + ["validate-result", "--result", str(rf), "--allowed-paths", str(af)], text=True, capture_output=True, env=env) self.assertEqual(p.returncode, 0, p.stderr) p = subprocess.run(base + ["render", "--no-delegated-evidence", "--result", str(rf), "--allowed-paths", str(af)], text=True, capture_output=True, env=env) self.assertEqual(p.returncode, 0, p.stderr); bf.write_text(p.stdout, encoding="utf-8") p = subprocess.run(base + ["parse", "--body", str(bf), "--allowed-paths", str(af)], text=True, capture_output=True, env=env) self.assertEqual(p.returncode, 0, p.stderr) self.assertEqual(json.loads(p.stdout), result()) def test_result_codec_preserves_existing_native_format(self): r = result() paths = {"src/a.py", "tests/test_a.py"} body = mod.render_review(r, paths, declaration={"mode": "none"}) self.assertIn(" - 구현 근거: src/a.py; 증거\n", body) self.assertIn("모든 기준을 충족했다.\n\n