test(harness): reorganize regression suites and preserve contract coverage
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""Actual collector invocation coverage, separate from documentation contracts."""
|
||||
from pathlib import Path
|
||||
import importlib.util
|
||||
import unittest
|
||||
|
||||
from harness.tests._support.paths import HARNESS
|
||||
|
||||
|
||||
class ActualCollectorTests(unittest.TestCase):
|
||||
def test_actual_collector_calls_synthetic_callback_with_one_page_argument(self):
|
||||
import importlib.util
|
||||
import inspect
|
||||
path = HARNESS / 'agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py'
|
||||
spec = importlib.util.spec_from_file_location('readback_invocation_support', path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def pages(page):
|
||||
calls.append(page)
|
||||
return {'items': [{'id': page}], 'total_count': 2, 'has_more': page < 2}
|
||||
|
||||
inspect.signature(pages).bind(1)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(module.collect_pages(pages, 'id', page_size=1), [{'id': 1}, {'id': 2}])
|
||||
self.assertEqual(calls, [1, 2])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Documentation contracts only; runtime collector coverage is separate."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from harness.tests._support.paths import HARNESS
|
||||
DISCOVERY = HARNESS / 'agents/spec/skills/gitea-discovery-review/SKILL.md'
|
||||
IMPLEMENTATION = HARNESS / 'agents/spec/skills/gitea-implementation-spec-review/SKILL.md'
|
||||
README = HARNESS / 'docs/discovery/README.md'
|
||||
|
||||
|
||||
class PriorityReviewContracts(unittest.TestCase):
|
||||
|
||||
def test_issue_body_provider_revision_documentation_contract(self):
|
||||
access = (HARNESS / 'operations/gitea-access.md').read_text()
|
||||
contract = access.split('### Issue body update의 provider-derived readback', 1)[-1]
|
||||
for required in ('content_version', 'updated_at'):
|
||||
self.assertIn(required, contract)
|
||||
authoring = (HARNESS / 'agents/spec/skills/gitea-discovery-authoring/SKILL.md').read_text()
|
||||
for text in (DISCOVERY.read_text(), authoring):
|
||||
self.assertIn('Issue body update의 provider-derived readback', text)
|
||||
self.assertIn('content_version', text)
|
||||
self.assertIn('post-comment snapshot을 body update 직전 baseline', authoring)
|
||||
|
||||
|
||||
def test_rework_report_documentation_contract_requires_categories_and_counting_rules(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
section = text.split('### Named test 근거와 재작업 보고', 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('해소:', '잔여:', '신규:', '문서 보완:', '해당 없음'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
|
||||
def test_changed_readback_helper_documentation_contract_is_conditional_and_precreate(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
heading = '### 비정본·변경된 readback helper 호출 계약'
|
||||
self.assertIn(heading, text, 'conditional readback invocation guidance is missing')
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('native create 이전', 'inspect.signature', '.bind('):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
step = text.split('17. 호출 전에', 1)[1].split('\n18.', 1)[0]
|
||||
self.assertIn('비정본·변경된 readback helper 호출 계약', step)
|
||||
self.assertLess(step.index('비정본·변경된 readback helper 호출 계약'), step.index('Native review create를'))
|
||||
|
||||
def test_discovery_checker_documentation_contract_preserves_bytes_and_bounded_transformations(self):
|
||||
text = DISCOVERY.read_text()
|
||||
heading = '### 임시·변경된 Discovery checker 준비'
|
||||
self.assertIn(heading, text, 'bounded Discovery checker preparation guidance is missing')
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('LF/CRLF', 'splitlines', 'flags='):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
step = text.split('11. trusted workflow directory', 1)[1].split('\n12.', 1)[0]
|
||||
self.assertIn('임시·변경된 Discovery checker 준비', step)
|
||||
|
||||
def test_local_links_resolve_and_procedures_reference_new_rules(self):
|
||||
for path in (DISCOVERY, IMPLEMENTATION, README):
|
||||
text = path.read_text()
|
||||
for link in re.findall('\\]\\(([^)]+)\\)', text):
|
||||
if not link.startswith(('http:', 'https:', '#')):
|
||||
self.assertTrue((path.parent / link.split('#')[0]).exists(), link)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+4
-4
@@ -5,7 +5,7 @@ import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from harness.tests.spec import test_implementation_spec_review_support as fixture
|
||||
from harness.tests._support import spec as fixture
|
||||
|
||||
|
||||
class RequestedReviewerProjectionTests(unittest.TestCase):
|
||||
@@ -168,9 +168,9 @@ class RequestedReviewerProjectionTests(unittest.TestCase):
|
||||
def test_document_declares_version_default_and_narrow_collection_scope(self):
|
||||
skill = fixture.MODPATH.parent.parent / 'SKILL.md'
|
||||
text = skill.read_text()
|
||||
for token in ('requested_reviewers_presentation_order', '1.27.3',
|
||||
'생략하면 `false`', 'Raw before/after snapshot', 'POST 재시도 금지'):
|
||||
with self.subTest(token=token): self.assertIn(token, text)
|
||||
for token in ('requested_reviewers_presentation_order', '1.27.3'):
|
||||
with self.subTest(token=token):
|
||||
self.assertIn(token, text)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1
-1
@@ -4,7 +4,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from harness.tests.spec import test_implementation_spec_review_support as fixture
|
||||
from harness.tests._support import spec as fixture
|
||||
|
||||
|
||||
class SourceIssueReferenceProjectionTests(unittest.TestCase):
|
||||
@@ -0,0 +1,134 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
||||
|
||||
|
||||
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 ('<!--\n' + valid + '\n-->', 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 ('<div>\n' + valid + '</div>',
|
||||
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
|
||||
valid.replace('**검증 분류**', '<span>**검증 분류**</span>')):
|
||||
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'):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
|
||||
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_cache_enforces_root_boundary_uid_and_dangling_locks(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 path in (rs, owner, repo, repo / "locks"):
|
||||
path.chmod(0o700)
|
||||
os.chmod(root, 0o777)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
os.chmod(root, 0o700)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3, uid=os.getuid() + 10000)
|
||||
(repo / "locks" / "issue-3.lock").symlink_to(root / "missing")
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
(repo / "locks" / "issue-3.lock").unlink()
|
||||
alias = root / "alias"; alias.symlink_to(ops.parent, target_is_directory=True)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(alias / "operations" / "repository-cache.md", "acme", "repo", 3)
|
||||
|
||||
def test_cache_group_membership_includes_other_primary_accounts(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
role = SimpleNamespace(pw_name="role", pw_uid=os.getuid(), pw_gid=111)
|
||||
other = SimpleNamespace(pw_name="other", pw_uid=os.getuid() + 1, pw_gid=111)
|
||||
group = SimpleNamespace(gr_gid=111, gr_mem=["role"])
|
||||
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")
|
||||
rs = root / "repositories"; repo = rs / "acme" / "repo"
|
||||
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
|
||||
for path in (rs / "acme", repo, repo / "locks"):
|
||||
path.chmod(0o700)
|
||||
rs.chmod(0o770)
|
||||
role.pw_gid = other.pw_gid = group.gr_gid = rs.stat().st_gid
|
||||
with patch.object(mod.grp, "getgrgid", return_value=group), \
|
||||
patch.object(mod.pwd, "getpwuid", return_value=role), \
|
||||
patch.object(mod.pwd, "getpwall", return_value=[role, other]):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,223 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
||||
|
||||
|
||||
class CodecTests(unittest.TestCase):
|
||||
|
||||
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_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<!--", body)
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
unordered = json.loads(json.dumps(r, sort_keys=True))
|
||||
self.assertEqual(mod.render_review(unordered, paths, declaration={"mode": "none"}), body)
|
||||
|
||||
def test_strict_codec_rejects_unrenderable_and_sensitive_human_values(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
for field, value in (("verdict", []), ("conclusion", "trailing "),
|
||||
("conclusion", "line\u2028break"), ("conclusion", "ftp://example.test")):
|
||||
r = result(); r[field] = value
|
||||
with self.subTest(field=field, value=value), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths)
|
||||
for field in ("criterion", "contract", "evidence", "reason"):
|
||||
r = result(); r["criteria"][0][field] = "secretvalue"
|
||||
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
||||
r = result(); r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["secretvalue"]
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.loads_json('{"n":1e999}')
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(result(), "src/a.py")
|
||||
|
||||
def test_parser_rejects_forged_marker_and_body_drift(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
body = mod.render_review(result(), paths, declaration={"mode": "none"})
|
||||
for bad in (body.replace("issue=3", "issue=4"), body.replace("증거", "다른 증거"),
|
||||
body.replace("\n", "\r\n"), body + "\n", body.replace("correlation=", "other=")):
|
||||
with self.subTest(body=bad[-100:]), self.assertRaises(mod.ValidationError):
|
||||
mod.parse_review(bad, paths)
|
||||
|
||||
def test_codec_roundtrips_delimiter_bearing_paths_and_prose(self):
|
||||
r = result(); r["criteria"][0]["implementation_paths"] = ["src/a, b.py", "src/c; d.py"]
|
||||
r["criteria"][0]["evidence"] = "확인; 모두 — 일치"
|
||||
r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["하나, 둘", "세 가지"]
|
||||
paths = {"src/a, b.py", "src/c; d.py", "tests/test_a.py"}
|
||||
self.assertEqual(mod.parse_review(mod.render_review(r, paths, declaration={"mode": "none"}), paths), r)
|
||||
|
||||
def test_read_only_replay_uses_recursive_exact_json_types(self):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.verify_unchanged({"nested": [True]}, {"nested": [1]})
|
||||
|
||||
def test_pagination_is_one_based_and_rejects_short_continuations(self):
|
||||
calls = []
|
||||
pages = [{"items": [{"id": 1}, {"id": 2}], "total_count": 3, "has_more": True},
|
||||
{"items": [{"id": 3}], "total_count": 3, "has_more": False}]
|
||||
def fetch(page):
|
||||
calls.append(page)
|
||||
return pages[page - 1]
|
||||
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), [{"id": 1}, {"id": 2}, {"id": 3}])
|
||||
self.assertEqual(calls, [1, 2])
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda _: {"items": [{"id": 1}], "total_count": 3, "has_more": True}, "id", page_size=2)
|
||||
|
||||
def test_pagination_validates_all_declared_metadata_and_identity_types(self):
|
||||
bad_pages = [
|
||||
{"items": [{"id": 1}], "total_count": 0, "has_more": True},
|
||||
{"items": [{"id": 1}, {"id": 2}], "total_count": 2, "has_more": True},
|
||||
{"items": [{"id": []}], "has_more": False},
|
||||
{"items": [{"id": 1.5}], "has_more": False},
|
||||
{"items": [{"id": 0}], "has_more": False},
|
||||
{"items": [{"id": True}], "has_more": False},
|
||||
{"items": [{"id": 1}, {"id": 1}], "has_more": False},
|
||||
{"items": None, "has_more": False},
|
||||
{"items": [], "has_more": True},
|
||||
]
|
||||
for page in bad_pages:
|
||||
with self.subTest(page=bad_pages.index(page)), self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda _: page, "id", page_size=2, max_pages=2)
|
||||
pages = [{"items": [{"id": 1}], "total_count": 2, "has_more": True},
|
||||
{"items": [{"id": 2}], "total_count": 3, "has_more": False}]
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda i: pages[i - 1], "id", page_size=1)
|
||||
self.assertEqual(mod.collect_pages(lambda _: {"items": None, "total_count": 0, "has_more": False}, "id"), [])
|
||||
|
||||
def test_cli_errors_are_sanitized_without_tracebacks_or_echoed_values(self):
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
base = [sys.executable, str(MODPATH)]
|
||||
for args in (["unknown-secret-value"], ["check-cache", "--issue", "secret-value"],
|
||||
["check-unchanged", "--before", "secret-value", "--after", "missing"]):
|
||||
p = subprocess.run(base + args, capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertNotIn("secret-value", p.stderr + p.stdout)
|
||||
self.assertNotIn("Traceback", p.stderr + p.stdout)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
||||
path = Path(td) / "pages.json"
|
||||
path.write_text(json.dumps([{ "items": [{"id": 1}], "has_more": True, "total_count": 2 }]))
|
||||
p = subprocess.run(base + ["check-pages", "--pages", str(path), "--key", "id"],
|
||||
capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertNotIn("Traceback", p.stderr)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
|
||||
def test_target_identifiers_preserve_dots_but_reject_marker_breaking_whitespace(self):
|
||||
r = result(); paths = {"src/a.py", "tests/test_a.py"}
|
||||
r["target"].update(owner="acme.name", repository="repo.name")
|
||||
body = mod.render_review(r, paths, declaration={"mode": "none"})
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
r["target"]["owner"] = "acme name"
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths)
|
||||
|
||||
def test_rendered_paths_cannot_inject_markdown_html_or_mentions(self):
|
||||
r = result(); path = "src/<script>@user`[link].py"
|
||||
r["criteria"][0]["implementation_paths"] = [path]
|
||||
paths = {path, "tests/test_a.py"}
|
||||
body = mod.render_review(r, paths, declaration={"mode": "none"})
|
||||
self.assertNotIn("<script>", body)
|
||||
self.assertNotIn("@user", body)
|
||||
self.assertNotIn("`[link]", body)
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
|
||||
def test_strict_types_limits_and_duplicate_nested_keys(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
for bad_json in ('{"a":1,"a":2}', '{"a":NaN}',
|
||||
'{"outer":{"x":1,"x":2}}', '{"x":Infinity}', '{"x":-Infinity}'):
|
||||
with self.subTest(bad_json=bad_json), self.assertRaises(mod.ValidationError):
|
||||
mod.loads_json(bad_json)
|
||||
cases = []
|
||||
r = result(); r["target"]["pull"] = False; cases.append(r)
|
||||
r = result(); r["criteria"].append(copy.deepcopy(r["criteria"][0])); cases.append(r)
|
||||
r = result(); r["criteria"][0]["contract"] = "a" * 4001; cases.append(r)
|
||||
r = result(); r["criteria"][0]["criterion"] = "a" * 201; cases.append(r)
|
||||
r = result(); r["criteria"][0]["result"] = {}; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] *= 2; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] = ["/absolute"]; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] = ["tests/../file.py"]; cases.append(r)
|
||||
r = result(); r["extra"] = None; cases.append(r)
|
||||
r = result(); del r["scope"]; cases.append(r)
|
||||
for value in cases:
|
||||
with self.subTest(case=cases.index(value)), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(value, paths)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from harness.tests._support.paths import ROOT
|
||||
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
||||
|
||||
|
||||
class PublicationTests(unittest.TestCase):
|
||||
|
||||
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_publication_requires_exact_readback_not_only_review_id(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for field, value in (("body", "changed"), ("official", False), ("submitted_at", "bad")):
|
||||
exact = copy.deepcopy(review); exact[field] = value
|
||||
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, exact, r, paths)
|
||||
|
||||
def test_publication_rejects_unrelated_drift_and_bad_projections(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
cases = []
|
||||
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("review_comments", 4),
|
||||
("requested_reviewers", [{"id": 43}]), ("head", {"sha": "d" * 40})):
|
||||
bad = copy.deepcopy(after); bad["pr"][field] = value; cases.append(bad)
|
||||
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("title", "changed")):
|
||||
bad = copy.deepcopy(after); bad["issue"][field] = value; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"][0]["stale"] = 0; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"][0]["state"] = "PENDING"; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"].append(copy.deepcopy(review)); cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["timeline"][0]["type"] = "comment"; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["timeline"][0]["review_id"] = True; cases.append(bad)
|
||||
for bad in cases:
|
||||
with self.subTest(snapshot=cases.index(bad)), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, bad, bad["reviews"][0], r, paths)
|
||||
|
||||
def test_publication_binds_snapshot_repository_issue_and_head(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for section, field, value in (("issue", "number", 4), ("issue", "repository", {"full_name": "wrong/repo"}),
|
||||
("pr", "head", {"sha": "d" * 40}), ("pr", "number", 8)):
|
||||
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
||||
b[section][field] = a[section][field] = value
|
||||
with self.subTest(section=section, field=field), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(b, a, review, r, paths)
|
||||
|
||||
def test_publication_preserves_previous_reviews_and_bounds_supersession(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
old = copy.deepcopy(review); old.update(id=10, body="previous", commit_id="d" * 40,
|
||||
state="REQUEST_CHANGES", updated_at="2026-10-03T00:00:00Z")
|
||||
before["reviews"] = [old]; after["reviews"].insert(0, copy.deepcopy(old))
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
after["reviews"][0].update(official=False, dismissed=True, updated_at=review["submitted_at"])
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
contract = {"version": {"version": "1.27.3"}, "supersession_same_author_only": True,
|
||||
"already_dismissed_review_timestamp_projection": True}
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
for mutation in ("body", "user", "missing"):
|
||||
a = copy.deepcopy(after)
|
||||
if mutation == "missing": a["reviews"].pop(0)
|
||||
elif mutation == "user": a["reviews"][0]["user"] = {"id": 43}
|
||||
else: a["reviews"][0]["body"] = "tampered"
|
||||
with self.subTest(mutation=mutation), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, a, review, r, paths, contract)
|
||||
old["official"] = False; old["dismissed"] = True
|
||||
after["reviews"][0] = copy.deepcopy(old); after["reviews"][0]["updated_at"] = review["submitted_at"]
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
contract["already_dismissed_review_timestamp_projection"] = False
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
|
||||
def test_publication_preserves_collection_identity_and_normalizes_only_files_order(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
second = {"filename": "tests/test_a.py", "status": "added", "additions": 2}
|
||||
before["files"].append(second); after["files"].insert(0, copy.deepcopy(second))
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
after["files"].append(copy.deepcopy(second))
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
|
||||
def test_cli_verify_publication_and_rejects_raw_crlf_body(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
||||
root = Path(td)
|
||||
for name, value in (("before", before), ("after", after), ("exact", review),
|
||||
("result", r), ("paths", sorted(paths)), ("inline", [])):
|
||||
(root / (name + ".json")).write_text(json.dumps(value, ensure_ascii=False))
|
||||
cmd = [sys.executable, str(MODPATH), "verify-publication", "--before", str(root / "before.json"),
|
||||
"--after", str(root / "after.json"), "--exact-review", str(root / "exact.json"),
|
||||
"--result", str(root / "result.json"), "--allowed-paths", str(root / "paths.json"),
|
||||
"--inline-comments", str(root / "inline.json"), "--reviewer-id", "42",
|
||||
"--expected-pr-url", "https://example.test/acme/repo/pulls/7"]
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
self.assertEqual(json.loads(p.stdout)["review_id"], 11)
|
||||
body_path = root / "body.md"; body_path.write_bytes(review["body"].replace("\n", "\r\n").encode())
|
||||
p = subprocess.run([sys.executable, str(MODPATH), "parse", "--body", str(body_path),
|
||||
"--allowed-paths", str(root / "paths.json")], capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
|
||||
def test_publication_accepts_causal_event_timestamp_without_forcing_clock_equality(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
event = after["timeline"][0]
|
||||
event["created_at"] = event["updated_at"] = "2026-10-03T01:24:01Z"
|
||||
after["pr"]["updated_at"] = event["created_at"]
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
|
||||
def test_publication_requires_independent_canonical_pr_url_anchor(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for foreign in ("https://evil.test/acme/repo/pulls/7", "https://example.test/prefix/acme/repo/pulls/7"):
|
||||
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
||||
b["pr"]["html_url"] = b["pr"]["url"] = foreign
|
||||
a["pr"]["html_url"] = a["pr"]["url"] = foreign
|
||||
a["reviews"][0]["pull_request_url"] = foreign
|
||||
a["timeline"][0]["pull_request_url"] = foreign
|
||||
with self.subTest(url=foreign), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(b, a, a["reviews"][0], r, paths)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,584 +0,0 @@
|
||||
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 ('<!--\n' + valid + '\n-->', 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 ('<div>\n' + valid + '</div>',
|
||||
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
|
||||
valid.replace('**검증 분류**', '<span>**검증 분류**</span>')):
|
||||
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<!--", body)
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
unordered = json.loads(json.dumps(r, sort_keys=True))
|
||||
self.assertEqual(mod.render_review(unordered, paths, declaration={"mode": "none"}), body)
|
||||
|
||||
def test_strict_codec_rejects_unrenderable_and_sensitive_human_values(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
for field, value in (("verdict", []), ("conclusion", "trailing "),
|
||||
("conclusion", "line\u2028break"), ("conclusion", "ftp://example.test")):
|
||||
r = result(); r[field] = value
|
||||
with self.subTest(field=field, value=value), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths)
|
||||
for field in ("criterion", "contract", "evidence", "reason"):
|
||||
r = result(); r["criteria"][0][field] = "secretvalue"
|
||||
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
||||
r = result(); r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["secretvalue"]
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.loads_json('{"n":1e999}')
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(result(), "src/a.py")
|
||||
|
||||
def test_parser_rejects_forged_marker_and_body_drift(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
body = mod.render_review(result(), paths, declaration={"mode": "none"})
|
||||
for bad in (body.replace("issue=3", "issue=4"), body.replace("증거", "다른 증거"),
|
||||
body.replace("\n", "\r\n"), body + "\n", body.replace("correlation=", "other=")):
|
||||
with self.subTest(body=bad[-100:]), self.assertRaises(mod.ValidationError):
|
||||
mod.parse_review(bad, paths)
|
||||
|
||||
def test_codec_roundtrips_delimiter_bearing_paths_and_prose(self):
|
||||
r = result(); r["criteria"][0]["implementation_paths"] = ["src/a, b.py", "src/c; d.py"]
|
||||
r["criteria"][0]["evidence"] = "확인; 모두 — 일치"
|
||||
r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["하나, 둘", "세 가지"]
|
||||
paths = {"src/a, b.py", "src/c; d.py", "tests/test_a.py"}
|
||||
self.assertEqual(mod.parse_review(mod.render_review(r, paths, declaration={"mode": "none"}), paths), r)
|
||||
|
||||
|
||||
def test_cache_enforces_root_boundary_uid_and_dangling_locks(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 path in (rs, owner, repo, repo / "locks"):
|
||||
path.chmod(0o700)
|
||||
os.chmod(root, 0o777)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
os.chmod(root, 0o700)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3, uid=os.getuid() + 10000)
|
||||
(repo / "locks" / "issue-3.lock").symlink_to(root / "missing")
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
(repo / "locks" / "issue-3.lock").unlink()
|
||||
alias = root / "alias"; alias.symlink_to(ops.parent, target_is_directory=True)
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(alias / "operations" / "repository-cache.md", "acme", "repo", 3)
|
||||
|
||||
def test_cache_group_membership_includes_other_primary_accounts(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
role = SimpleNamespace(pw_name="role", pw_uid=os.getuid(), pw_gid=111)
|
||||
other = SimpleNamespace(pw_name="other", pw_uid=os.getuid() + 1, pw_gid=111)
|
||||
group = SimpleNamespace(gr_gid=111, gr_mem=["role"])
|
||||
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")
|
||||
rs = root / "repositories"; repo = rs / "acme" / "repo"
|
||||
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
|
||||
for path in (rs / "acme", repo, repo / "locks"):
|
||||
path.chmod(0o700)
|
||||
rs.chmod(0o770)
|
||||
role.pw_gid = other.pw_gid = group.gr_gid = rs.stat().st_gid
|
||||
with patch.object(mod.grp, "getgrgid", return_value=group), \
|
||||
patch.object(mod.pwd, "getpwuid", return_value=role), \
|
||||
patch.object(mod.pwd, "getpwall", return_value=[role, other]):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
||||
|
||||
|
||||
def test_publication_accepts_gitea_nested_identity(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11)
|
||||
|
||||
def test_publication_requires_exact_readback_not_only_review_id(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for field, value in (("body", "changed"), ("official", False), ("submitted_at", "bad")):
|
||||
exact = copy.deepcopy(review); exact[field] = value
|
||||
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, exact, r, paths)
|
||||
|
||||
def test_publication_rejects_unrelated_drift_and_bad_projections(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
cases = []
|
||||
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("review_comments", 4),
|
||||
("requested_reviewers", [{"id": 43}]), ("head", {"sha": "d" * 40})):
|
||||
bad = copy.deepcopy(after); bad["pr"][field] = value; cases.append(bad)
|
||||
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("title", "changed")):
|
||||
bad = copy.deepcopy(after); bad["issue"][field] = value; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"][0]["stale"] = 0; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"][0]["state"] = "PENDING"; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["reviews"].append(copy.deepcopy(review)); cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["timeline"][0]["type"] = "comment"; cases.append(bad)
|
||||
bad = copy.deepcopy(after); bad["timeline"][0]["review_id"] = True; cases.append(bad)
|
||||
for bad in cases:
|
||||
with self.subTest(snapshot=cases.index(bad)), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, bad, bad["reviews"][0], r, paths)
|
||||
|
||||
def test_publication_binds_snapshot_repository_issue_and_head(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for section, field, value in (("issue", "number", 4), ("issue", "repository", {"full_name": "wrong/repo"}),
|
||||
("pr", "head", {"sha": "d" * 40}), ("pr", "number", 8)):
|
||||
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
||||
b[section][field] = a[section][field] = value
|
||||
with self.subTest(section=section, field=field), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(b, a, review, r, paths)
|
||||
|
||||
def test_publication_preserves_previous_reviews_and_bounds_supersession(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
old = copy.deepcopy(review); old.update(id=10, body="previous", commit_id="d" * 40,
|
||||
state="REQUEST_CHANGES", updated_at="2026-10-03T00:00:00Z")
|
||||
before["reviews"] = [old]; after["reviews"].insert(0, copy.deepcopy(old))
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
after["reviews"][0].update(official=False, dismissed=True, updated_at=review["submitted_at"])
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
contract = {"version": {"version": "1.27.3"}, "supersession_same_author_only": True,
|
||||
"already_dismissed_review_timestamp_projection": True}
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
for mutation in ("body", "user", "missing"):
|
||||
a = copy.deepcopy(after)
|
||||
if mutation == "missing": a["reviews"].pop(0)
|
||||
elif mutation == "user": a["reviews"][0]["user"] = {"id": 43}
|
||||
else: a["reviews"][0]["body"] = "tampered"
|
||||
with self.subTest(mutation=mutation), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, a, review, r, paths, contract)
|
||||
old["official"] = False; old["dismissed"] = True
|
||||
after["reviews"][0] = copy.deepcopy(old); after["reviews"][0]["updated_at"] = review["submitted_at"]
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
contract["already_dismissed_review_timestamp_projection"] = False
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths, contract)
|
||||
|
||||
def test_publication_preserves_collection_identity_and_normalizes_only_files_order(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
second = {"filename": "tests/test_a.py", "status": "added", "additions": 2}
|
||||
before["files"].append(second); after["files"].insert(0, copy.deepcopy(second))
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
after["files"].append(copy.deepcopy(second))
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
|
||||
def test_read_only_replay_uses_recursive_exact_json_types(self):
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.verify_unchanged({"nested": [True]}, {"nested": [1]})
|
||||
|
||||
|
||||
def test_pagination_is_one_based_and_rejects_short_continuations(self):
|
||||
calls = []
|
||||
pages = [{"items": [{"id": 1}, {"id": 2}], "total_count": 3, "has_more": True},
|
||||
{"items": [{"id": 3}], "total_count": 3, "has_more": False}]
|
||||
def fetch(page):
|
||||
calls.append(page)
|
||||
return pages[page - 1]
|
||||
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), [{"id": 1}, {"id": 2}, {"id": 3}])
|
||||
self.assertEqual(calls, [1, 2])
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda _: {"items": [{"id": 1}], "total_count": 3, "has_more": True}, "id", page_size=2)
|
||||
|
||||
def test_pagination_validates_all_declared_metadata_and_identity_types(self):
|
||||
bad_pages = [
|
||||
{"items": [{"id": 1}], "total_count": 0, "has_more": True},
|
||||
{"items": [{"id": 1}, {"id": 2}], "total_count": 2, "has_more": True},
|
||||
{"items": [{"id": []}], "has_more": False},
|
||||
{"items": [{"id": 1.5}], "has_more": False},
|
||||
{"items": [{"id": 0}], "has_more": False},
|
||||
{"items": [{"id": True}], "has_more": False},
|
||||
{"items": [{"id": 1}, {"id": 1}], "has_more": False},
|
||||
{"items": None, "has_more": False},
|
||||
{"items": [], "has_more": True},
|
||||
]
|
||||
for page in bad_pages:
|
||||
with self.subTest(page=bad_pages.index(page)), self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda _: page, "id", page_size=2, max_pages=2)
|
||||
pages = [{"items": [{"id": 1}], "total_count": 2, "has_more": True},
|
||||
{"items": [{"id": 2}], "total_count": 3, "has_more": False}]
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.collect_pages(lambda i: pages[i - 1], "id", page_size=1)
|
||||
self.assertEqual(mod.collect_pages(lambda _: {"items": None, "total_count": 0, "has_more": False}, "id"), [])
|
||||
|
||||
def test_cli_errors_are_sanitized_without_tracebacks_or_echoed_values(self):
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
base = [sys.executable, str(MODPATH)]
|
||||
for args in (["unknown-secret-value"], ["check-cache", "--issue", "secret-value"],
|
||||
["check-unchanged", "--before", "secret-value", "--after", "missing"]):
|
||||
p = subprocess.run(base + args, capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertNotIn("secret-value", p.stderr + p.stdout)
|
||||
self.assertNotIn("Traceback", p.stderr + p.stdout)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
||||
path = Path(td) / "pages.json"
|
||||
path.write_text(json.dumps([{ "items": [{"id": 1}], "has_more": True, "total_count": 2 }]))
|
||||
p = subprocess.run(base + ["check-pages", "--pages", str(path), "--key", "id"],
|
||||
capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertNotIn("Traceback", p.stderr)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
|
||||
|
||||
def test_target_identifiers_preserve_dots_but_reject_marker_breaking_whitespace(self):
|
||||
r = result(); paths = {"src/a.py", "tests/test_a.py"}
|
||||
r["target"].update(owner="acme.name", repository="repo.name")
|
||||
body = mod.render_review(r, paths, declaration={"mode": "none"})
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
r["target"]["owner"] = "acme name"
|
||||
with self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(r, paths)
|
||||
|
||||
def test_rendered_paths_cannot_inject_markdown_html_or_mentions(self):
|
||||
r = result(); path = "src/<script>@user`[link].py"
|
||||
r["criteria"][0]["implementation_paths"] = [path]
|
||||
paths = {path, "tests/test_a.py"}
|
||||
body = mod.render_review(r, paths, declaration={"mode": "none"})
|
||||
self.assertNotIn("<script>", body)
|
||||
self.assertNotIn("@user", body)
|
||||
self.assertNotIn("`[link]", body)
|
||||
self.assertEqual(mod.parse_review(body, paths), r)
|
||||
|
||||
def test_strict_types_limits_and_duplicate_nested_keys(self):
|
||||
paths = {"src/a.py", "tests/test_a.py"}
|
||||
for bad_json in ('{"outer":{"x":1,"x":2}}', '{"x":Infinity}', '{"x":-Infinity}'):
|
||||
with self.subTest(bad_json=bad_json), self.assertRaises(mod.ValidationError):
|
||||
mod.loads_json(bad_json)
|
||||
cases = []
|
||||
r = result(); r["target"]["pull"] = False; cases.append(r)
|
||||
r = result(); r["criteria"].append(copy.deepcopy(r["criteria"][0])); cases.append(r)
|
||||
r = result(); r["criteria"][0]["contract"] = "a" * 4001; cases.append(r)
|
||||
r = result(); r["criteria"][0]["criterion"] = "a" * 201; cases.append(r)
|
||||
r = result(); r["criteria"][0]["result"] = {}; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] *= 2; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] = ["/absolute"]; cases.append(r)
|
||||
r = result(); r["criteria"][0]["test_paths"] = ["tests/../file.py"]; cases.append(r)
|
||||
r = result(); r["extra"] = None; cases.append(r)
|
||||
r = result(); del r["scope"]; cases.append(r)
|
||||
for value in cases:
|
||||
with self.subTest(case=cases.index(value)), self.assertRaises(mod.ValidationError):
|
||||
mod.validate_result(value, paths)
|
||||
|
||||
def test_cli_verify_publication_and_rejects_raw_crlf_body(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
||||
root = Path(td)
|
||||
for name, value in (("before", before), ("after", after), ("exact", review),
|
||||
("result", r), ("paths", sorted(paths)), ("inline", [])):
|
||||
(root / (name + ".json")).write_text(json.dumps(value, ensure_ascii=False))
|
||||
cmd = [sys.executable, str(MODPATH), "verify-publication", "--before", str(root / "before.json"),
|
||||
"--after", str(root / "after.json"), "--exact-review", str(root / "exact.json"),
|
||||
"--result", str(root / "result.json"), "--allowed-paths", str(root / "paths.json"),
|
||||
"--inline-comments", str(root / "inline.json"), "--reviewer-id", "42",
|
||||
"--expected-pr-url", "https://example.test/acme/repo/pulls/7"]
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
self.assertEqual(p.returncode, 0, p.stderr)
|
||||
self.assertEqual(json.loads(p.stdout)["review_id"], 11)
|
||||
body_path = root / "body.md"; body_path.write_bytes(review["body"].replace("\n", "\r\n").encode())
|
||||
p = subprocess.run([sys.executable, str(MODPATH), "parse", "--body", str(body_path),
|
||||
"--allowed-paths", str(root / "paths.json")], capture_output=True, text=True, env=env)
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
||||
|
||||
|
||||
def test_publication_accepts_causal_event_timestamp_without_forcing_clock_equality(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
event = after["timeline"][0]
|
||||
event["created_at"] = event["updated_at"] = "2026-10-03T01:24:01Z"
|
||||
after["pr"]["updated_at"] = event["created_at"]
|
||||
verify_fixture(before, after, review, r, paths)
|
||||
|
||||
|
||||
def test_publication_requires_independent_canonical_pr_url_anchor(self):
|
||||
before, after, review, r, paths = publication_fixture()
|
||||
for foreign in ("https://evil.test/acme/repo/pulls/7", "https://example.test/prefix/acme/repo/pulls/7"):
|
||||
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
||||
b["pr"]["html_url"] = b["pr"]["url"] = foreign
|
||||
a["pr"]["html_url"] = a["pr"]["url"] = foreign
|
||||
a["reviews"][0]["pull_request_url"] = foreign
|
||||
a["timeline"][0]["pull_request_url"] = foreign
|
||||
with self.subTest(url=foreign), self.assertRaises(mod.ValidationError):
|
||||
verify_fixture(b, a, a["reviews"][0], r, paths)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,250 +0,0 @@
|
||||
"""Documentation contracts and local synthetic controls; not agent/live execution."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
HARNESS = Path(__file__).resolve().parents[2]
|
||||
DISCOVERY = HARNESS / 'agents/spec/skills/gitea-discovery-review/SKILL.md'
|
||||
IMPLEMENTATION = HARNESS / 'agents/spec/skills/gitea-implementation-spec-review/SKILL.md'
|
||||
README = HARNESS / 'docs/discovery/README.md'
|
||||
|
||||
|
||||
class PriorityReviewContracts(unittest.TestCase):
|
||||
|
||||
def test_issue_body_provider_revision_documentation_contract(self):
|
||||
access = (HARNESS / 'operations/gitea-access.md').read_text()
|
||||
contract = access.split('### Issue body update의 provider-derived readback', 1)[-1]
|
||||
for required in ('matching provider contract', 'content_version', '정확히 1',
|
||||
'baseline과 readback', '정수', 'boolean', 'pre-write drift',
|
||||
'자동 재write하지 않는다', 'updated_at', '보호 필드',
|
||||
'uncertain/partial', '새 runtime checker'):
|
||||
self.assertIn(required, contract)
|
||||
authoring = (HARNESS / 'agents/spec/skills/gitea-discovery-authoring/SKILL.md').read_text()
|
||||
for text in (DISCOVERY.read_text(), authoring):
|
||||
self.assertIn('Issue body update의 provider-derived readback', text)
|
||||
self.assertIn('content_version', text)
|
||||
self.assertIn('자동 재write하지 않는다', text)
|
||||
self.assertIn('post-comment snapshot을 body update 직전 baseline', authoring)
|
||||
|
||||
def test_scope_classification_and_optional_nonblocking(self):
|
||||
text = README.read_text()
|
||||
for value in ('핵심 요청:', '기존 호환성:', '선택적 확장:',
|
||||
'명시적으로 채택되지 않은 선택적 확장은', 'blocking OQ로 승격하지 않는다'):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_all_oq_options_preserve_mandatory_contracts(self):
|
||||
text = README.read_text()
|
||||
oq_rules = text.split('## OQ(${open_questions}) 작성 방법', 1)[1].split('```markdown', 1)[0]
|
||||
for value in ('추천안을 포함한 모든 선택지',
|
||||
'원본 요청의 필수 범위, 확정된 결정과 기존 호환성 불변조건을 보존',
|
||||
'미검증 전제를 확정된 사실로 취급하지 않는다',
|
||||
'요구 철회나 기존 계약 변경',
|
||||
'일반 구현 대안과 구분하고 변경 대상과 영향을 명시'):
|
||||
with self.subTest(rule=value):
|
||||
self.assertIn(value, oq_rules)
|
||||
|
||||
def test_compatibility_user_decisions_and_existing_oq_preserved(self):
|
||||
text = DISCOVERY.read_text()
|
||||
for value in ('기존 보안·호환성 제약은 선택적으로 낮추지',
|
||||
'미선택 OQ를 임의 삭제·해결하지', '선택을 보존하고 사용자 판단',
|
||||
'사용자 선택이 필요한 결정 공백과 구분'):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_experiment_verdict_boundary_and_parent_check(self):
|
||||
text = DISCOVERY.read_text()
|
||||
for value in ('시험한 방식의 실패:', '소스로 확인된 제약:', '요구 충족 불가능:',
|
||||
'미검증 또는 판단 유보', '시험하지 않은 대안·확인 한계',
|
||||
'원본 실험·공식 소스에 직접 대조', '일부 prototype 실패만으로'):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_document_delegate_inputs_and_fail_closed_result_adoption(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
for value in ('byte parity', '실제 내용을 delegate context로 제공',
|
||||
'대상 저장소 README/TEMPLATE를 작성 정책으로 대신 사용하지',
|
||||
'산출물별 의무표:', 'pinned baseline 문서 및 전체 AC',
|
||||
'이전 finding의 승인 근거와 최소 보완 요청',
|
||||
'입력이 부족하면 main이 보완하거나 직접 검토',
|
||||
'delegate의 PASS를 최종 판정으로 채택하지'):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_structure_semantics_and_preserved_contract_not_conflated(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
for value in ('구조 적합성(', '의미 적합성(', 'exact section·line',
|
||||
'구조 통과만으로 의미를 승인', '반복 기재 부재만으로 의미 누락',
|
||||
'제품 저장소의 새 산출물이나 result schema 변경을 요구하지'):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_named_test_documentation_contract_requires_call_assertion_cross_check(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
heading = '### Named test 근거와 재작업 보고'
|
||||
self.assertIn(heading, text)
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('exact reviewed head', '실제 호출 인수와 assertions',
|
||||
'검증하는 경우와 검증하지 않는 경우', '정적 확인과 실제 실행',
|
||||
'테스트 이름이나 PASS 수치만으로', '확인 한계'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
def test_rework_report_documentation_contract_requires_categories_and_counting_rules(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
section = text.split('### Named test 근거와 재작업 보고', 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('해소:', '잔여:', '신규:', '문서 보완:', '해당 없음',
|
||||
'같은 원인의 구현·문서 finding', '중복 집계하지 않는다',
|
||||
'승인 계약 위반', 'blocker로 승격하지 않는다',
|
||||
'result schema나 renderer', '자동 assertion 해석기'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
def test_writer_family_documentation_contract_requires_bounded_path_evidence(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
heading = '### Writer-family criterion closure checklist'
|
||||
self.assertIn(heading, text)
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('기존 승인 계약', 'monetary/current-cache', 'create/update/import',
|
||||
'실제 존재', '정규화', 'unknown과 zero', 'mutation/no-write',
|
||||
'downstream', 'exact reviewed head', '`evidence`·`reason`',
|
||||
'family 전체', '비적용', '잔여 finding', '새 금액 정책',
|
||||
'모든 입력 조합', '전체 suite 재실행', '자동 runtime checker',
|
||||
'제품 실행 금지', 'legacy financial-authority'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
|
||||
def test_changed_readback_helper_documentation_contract_is_conditional_and_precreate(self):
|
||||
text = IMPLEMENTATION.read_text()
|
||||
heading = '### 비정본·변경된 readback helper 호출 계약'
|
||||
self.assertIn(heading, text, 'conditional readback invocation guidance is missing')
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('비정본', '변경된', 'native create 이전',
|
||||
'정본 adapter를 변경 없이', '중복 signature 검사를 강제하지',
|
||||
'inspect.signature', '.bind(', '실행하지', 'partial',
|
||||
'인증', 'pagination completeness', 'read-only',
|
||||
'새 runtime gate', 'source revision'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
step = text.split('17. 호출 전에', 1)[1].split('\n18.', 1)[0]
|
||||
self.assertIn('비정본·변경된 readback helper 호출 계약', step)
|
||||
self.assertLess(step.index('비정본·변경된 readback helper 호출 계약'),
|
||||
step.index('Native review create를'))
|
||||
|
||||
def test_discovery_checker_documentation_contract_preserves_bytes_and_bounded_transformations(self):
|
||||
text = DISCOVERY.read_text()
|
||||
heading = '### 임시·변경된 Discovery checker 준비'
|
||||
self.assertIn(heading, text, 'bounded Discovery checker preparation guidance is missing')
|
||||
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
|
||||
for rule in ('임시·변경된', 'LF/CRLF', 'splitlines', '원본 bytes',
|
||||
'payload', 'hash', 'flags=', 'section·paragraph',
|
||||
'OQ ID', '정확 문구', 'prefix', '일괄 면제하지',
|
||||
'replay', '외부 write 없이', '사용자 선택',
|
||||
'공통 semantic validator', '부분 완료', 'synthetic'):
|
||||
with self.subTest(rule=rule):
|
||||
self.assertIn(rule, section)
|
||||
step = text.split('11. trusted workflow directory', 1)[1].split('\n12.', 1)[0]
|
||||
self.assertIn('임시·변경된 Discovery checker 준비', step)
|
||||
|
||||
def test_local_links_resolve_and_procedures_reference_new_rules(self):
|
||||
for path in (DISCOVERY, IMPLEMENTATION, README):
|
||||
text = path.read_text()
|
||||
for link in re.findall(r'\]\(([^)]+)\)', text):
|
||||
if not link.startswith(('http:', 'https:', '#')):
|
||||
self.assertTrue((path.parent / link.split('#')[0]).exists(), link)
|
||||
self.assertIn('8. 범위 분류·blocking 판단과 기술적 결론의 경계를 먼저 적용', DISCOVERY.read_text())
|
||||
self.assertIn('11. 문서 검토 입력과 구조·의미 분리 절차', IMPLEMENTATION.read_text())
|
||||
|
||||
|
||||
class SyntheticDiscoveryCheckerControls(unittest.TestCase):
|
||||
"""Small local primitive examples, not a shared Discovery validator."""
|
||||
|
||||
def test_synthetic_logical_headings_accept_crlf_without_rewriting_bytes(self):
|
||||
import hashlib
|
||||
lf = b'## Scope\nbody\n## Questions\nnone\n'
|
||||
crlf = lf.replace(b'\n', b'\r\n')
|
||||
original_digest = hashlib.sha256(crlf).digest()
|
||||
self.assertIsNone(re.search(r'^## Scope$', crlf.decode(), flags=re.MULTILINE))
|
||||
expected = ['## Scope', '## Questions']
|
||||
for raw in (lf, crlf):
|
||||
headings = [line for line in raw.decode().splitlines() if line.startswith('## ')]
|
||||
self.assertEqual(headings, expected)
|
||||
for malformed in (lf.replace(b'## Scope', b'## Scopes'),
|
||||
lf + b'## Scope\n', lf.replace(b'## Questions\n', b'')):
|
||||
headings = [line for line in malformed.decode().splitlines() if line.startswith('## ')]
|
||||
self.assertNotEqual(headings, expected)
|
||||
self.assertEqual(hashlib.sha256(crlf).digest(), original_digest)
|
||||
self.assertEqual(crlf, b'## Scope\r\nbody\r\n## Questions\r\nnone\r\n')
|
||||
|
||||
def test_synthetic_exact_paragraph_exception_does_not_hide_other_oq_residue(self):
|
||||
old = 'OQ-1: legacy introduction'
|
||||
replacement = old + '; explicit approved reselection'
|
||||
paragraphs = {'decision': replacement, 'question': 'OQ-2: unrelated question'}
|
||||
self.assertIn(old, paragraphs['decision']) # Naive global substring false positive.
|
||||
self.assertEqual(paragraphs['decision'], replacement)
|
||||
residue = {key: value for key, value in paragraphs.items()
|
||||
if old in value and not (key == 'decision' and value == replacement)}
|
||||
self.assertEqual(residue, {})
|
||||
paragraphs['question'] = old + '; unresolved reference in OQ-2'
|
||||
residue = {key: value for key, value in paragraphs.items()
|
||||
if old in value and not (key == 'decision' and value == replacement)}
|
||||
self.assertEqual(set(residue), {'question'})
|
||||
self.assertEqual(paragraphs['decision'], replacement)
|
||||
|
||||
def test_synthetic_self_containing_exact_replacement_replays_without_duplication(self):
|
||||
old = b'Legacy introduction'
|
||||
replacement = old + b'; approved detail'
|
||||
naive_replay = replacement.replace(old, replacement)
|
||||
self.assertNotEqual(naive_replay, replacement)
|
||||
# Approved exact paragraph fixture only; no general normalization policy.
|
||||
def fixture_transform(paragraph):
|
||||
if paragraph == replacement:
|
||||
return paragraph
|
||||
if paragraph == old:
|
||||
return replacement
|
||||
raise ValueError('ambiguous paragraph fixture')
|
||||
|
||||
first = fixture_transform(old)
|
||||
self.assertEqual(first, replacement)
|
||||
self.assertEqual(fixture_transform(first), first)
|
||||
with self.assertRaisesRegex(ValueError, 'ambiguous paragraph fixture'):
|
||||
fixture_transform(old + b'; unrelated edit')
|
||||
|
||||
|
||||
class SyntheticReadbackInvocationTests(unittest.TestCase):
|
||||
"""Local Python binding/collector controls, not a production pre-create gate."""
|
||||
|
||||
def test_synthetic_signature_binding_rejects_extra_argument_without_callback_execution(self):
|
||||
import inspect
|
||||
calls = []
|
||||
|
||||
def pages(page, *, endpoint='reviews'):
|
||||
calls.append((page, endpoint))
|
||||
|
||||
signature = inspect.signature(pages)
|
||||
bound = signature.bind(1, endpoint='reviews')
|
||||
self.assertEqual(bound.arguments, {'page': 1, 'endpoint': 'reviews'})
|
||||
self.assertEqual(calls, [])
|
||||
with self.assertRaises(TypeError):
|
||||
signature.bind(1, 50)
|
||||
with self.assertRaises(TypeError):
|
||||
signature.bind(1, page_size=50)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_actual_collector_calls_synthetic_callback_with_one_page_argument(self):
|
||||
import importlib.util
|
||||
import inspect
|
||||
path = HARNESS / 'agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py'
|
||||
spec = importlib.util.spec_from_file_location('readback_invocation_support', path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def pages(page):
|
||||
calls.append(page)
|
||||
return {'items': [{'id': page}], 'total_count': 2, 'has_more': page < 2}
|
||||
|
||||
inspect.signature(pages).bind(1)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(module.collect_pages(pages, 'id', page_size=1), [{'id': 1}, {'id': 2}])
|
||||
self.assertEqual(calls, [1, 2])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user