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 ACClassificationTests(unittest.TestCase):
TEMPLATE = ROOT / 'harness/docs/requirements/TEMPLATE.md'
def body(self):
return ('## 7. 인수 기준\n\n### REQ-001.01-AC01 — example\n\n'
'**판정 기준**\n\nexample\n\n**검증**\n\nexample\n\n'
'**검증 분류**\n\n- 수준: unit\n- 백엔드: 없음\n- 케이스: normal\n\n'
'**상태 불변조건**\n\n없음\n\n## 8. 오류 및 충돌 계약\n\n없음\n')
def check(self, body):
import hashlib
self.assertTrue(callable(getattr(mod, 'check_ac_classification', None)),
'trusted-template AC classification checker missing')
return mod.check_ac_classification(body, hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest())
def test_valid_canonical_ac_classification(self):
out = self.check(self.body())
self.assertEqual(out['ac_ids'], ['REQ-001.01-AC01'])
self.assertEqual(out['checked_fields'], ['수준', '백엔드', '케이스'])
self.assertTrue(out['read_only'])
def test_missing_empty_duplicate_and_placeholder_fields_rejected_per_ac(self):
valid = self.body()
self.check(valid)
for changed in (valid.replace('- 수준: unit\n', ''),
valid.replace('- 백엔드: 없음', '- 백엔드: '),
valid.replace('- 케이스: normal', '- 케이스: ${verification_cases}'),
valid.replace('- 수준: unit', '- 수준: unit\n- 수준: unit'),
valid.replace('**검증 분류**', '**검증**'),
valid.replace('**검증 분류**', '**검증 분류**\n\n**검증 분류**')):
with self.subTest(changed=changed):
with self.assertRaises(mod.ValidationError):
self.check(changed)
def test_other_ac_and_fenced_examples_cannot_supply_missing_field(self):
good = self.body().split('## 8.', 1)[0]
bad = good.replace('AC01', 'AC02').replace('- 수준: unit\n', '')
self.check(good + good.replace('AC01', 'AC02').split('\n\n', 1)[1] + '## 8. 오류 및 충돌 계약\n')
for extra in ('', '```markdown\n- 수준: unit\n```\n', '~~~\n- 수준: unit\n~~~\n'):
with self.subTest(extra=extra):
with self.assertRaises(mod.ValidationError):
self.check(good + bad.split('\n\n', 1)[1].replace('**상태 불변조건**', extra + '**상태 불변조건**') + '## 8. 오류 및 충돌 계약\n')
def test_ambiguous_hidden_sections_and_template_drift_fail_closed(self):
valid = self.body()
self.check(valid)
import hashlib
with self.assertRaises(mod.ValidationError):
mod.check_ac_classification(valid, '0' * 64)
for bad in ('', valid + '\n```unclosed',
valid.replace('### REQ', '#### REQ'),
valid.replace('## 8. 오류 및 충돌 계약', '## 7. 인수 기준'),
valid.replace('- 수준: unit', ' - 수준: unit')):
with self.subTest(bad=bad):
with self.assertRaises(mod.ValidationError):
self.check(bad)
crlf = valid.replace('\n', '\r\n')
self.assertEqual(self.check(crlf)['document_sha256'], hashlib.sha256(crlf.encode()).hexdigest())
def test_cli_read_only_exact_bytes_digest_and_sanitized_failure(self):
import hashlib
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'req.md'
raw = self.body().replace('\n', '\r\n').encode()
path.write_bytes(raw)
os.chmod(path, 0o600)
before = (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino)
argv = [sys.executable, '-B', str(MODPATH), 'check-ac-classification',
'--document', str(path), '--expected-document-sha256', hashlib.sha256(raw).hexdigest(),
'--expected-template-sha256', hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()]
actual = subprocess.run(argv, capture_output=True, text=True, timeout=10)
self.assertEqual(actual.returncode, 0, actual.stderr)
self.assertEqual(json.loads(actual.stdout)['document_sha256'], hashlib.sha256(raw).hexdigest())
self.assertEqual(before, (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino))
argv[-3] = '0' * 64
bad = subprocess.run(argv, capture_output=True, text=True, timeout=10)
self.assertEqual(bad.returncode, 2)
self.assertEqual(json.loads(bad.stderr), {'error': 'invalid input'})
self.assertEqual(bad.stdout, '')
def test_supported_scope_rejects_hidden_html_and_noncanonical_ac_heading(self):
valid = self.body()
self.check(valid)
for bad in ('
\n' + valid + '
',
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
valid.replace('**검증 분류**', '**검증 분류**')):
with self.subTest(bad=bad):
with self.assertRaises(mod.ValidationError):
self.check(bad)
def test_file_admission_rejects_symlink_fifo_invalid_utf8_and_oversize(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
valid = root / 'valid.md'
valid.write_text(self.body())
self.assertEqual(mod._classification_bytes(valid), valid.read_bytes())
alias = root / 'alias.md'
alias.symlink_to(valid)
fifo = root / 'fifo'
os.mkfifo(fifo)
invalid = root / 'invalid.md'
invalid.write_bytes(b'\xff')
large = root / 'large.md'
large.write_bytes(b'x' * 1048577)
for path in (alias, fifo, invalid, large, root):
with self.subTest(path=path.name):
with self.assertRaises(mod.ValidationError):
mod._classification_bytes(path)
def test_documentation_contract_scopes_checker_and_binds_digests(self):
text = (MODPATH.parent.parent / 'SKILL.md').read_text()
self.assertIn('### AC 검증 분류의 read-only 구조 검사', text)
section = text.split('### AC 검증 분류의 read-only 구조 검사', 1)[1].split('\n## ', 1)[0]
for rule in ('check-ac-classification', '--expected-document-sha256',
'--expected-template-sha256', '전체 문서 적합성', '의미',
'없음', '미확인', '코드 fence', '새 schema', '단일 행'):
self.assertIn(rule, section)
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