From d46f5c4a09c08e7400273f75cec5b8d883d58813 Mon Sep 17 00:00:00 2001 From: gitea-spec-agent Date: Sun, 11 Oct 2026 11:30:59 +0000 Subject: [PATCH] [verified] Handle requested reviewer presentation order --- .../gitea-implementation-spec-review/SKILL.md | 5 +- .../scripts/review_support.py | 20 +- .../test_requested_reviewer_projection.py | 176 ++++++++++++++++++ 3 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 harness/tests/spec/test_requested_reviewer_projection.py diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md index 5174c15..3fff811 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -220,7 +220,9 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r - `validate-result`, `render`, `parse`는 기존 strict schema, canonical body, digest·correlation과 round-trip을 검증한다. `parse`에는 `--body`와 `--allowed-paths`를 전달한다. 구분자·markup 때문에 모호한 list 항목만 JSON string quoting/escaping으로 원래 값을 보존하며 구분자·markup이 없는 기존 항목의 본문 형식은 유지한다. 출력은 stdout으로 반환하므로 기존 artifact 보존 규칙에 따라 main이 저장한다. - `check-cache --contract-path <읽은 repository-cache.md> --owner --repository --issue `는 기존 canonical cache의 경로별 permission·symlink만 검사한다. Cache 생성·repair·lock·Git fetch와 bare/remote/ref 확인은 기존 절차에 남는다. - `collect_pages`는 1부터 시작하는 page 번호로 main의 read-only callback을 호출하고 반환된 `items`, `total_count`, `has_more`를 검증한다. API adapter는 provider의 실제 metadata와 확인된 effective page size를 이 envelope와 `page_size`에 대응하고 null collection은 count와 종료 근거로 empty임이 증명될 때만 전달한다. 저장된 page envelope 배열은 `check-pages --pages --key [--page-size ] [--max-pages ]`로 검증한다. -- `verify-publication --before --after --exact-review --result --allowed-paths --reviewer-id --inline-comments --expected-pr-url [--provider-contract ]`는 single native create의 exact review·collection·timeline과 제한된 field projection을 대조한다. `expected_pr_url`은 snapshot에서 자동 추출하지 않고 main이 canonical authenticated access로 따로 확인한 exact PR URL을 제공하며 host·설치 prefix를 포함해 exact-match한다. Timeline은 ID·type·review ID·author·body·timestamp binding과 기존 event 보존을 검증하며 provider 전체 wire schema 확인은 기존 main 절차에 남는다. Snapshot은 `issue`, `issue_comments`, `pr`, `pr_comments`, `reviews`, `timeline`, `files`를 포함한다. Provider 계약을 생략하면 기존 review 변경을 허용하지 않는다. 현재 projection 계약은 `version.version=1.27.3`, boolean `supersession_same_author_only`·`already_dismissed_review_timestamp_projection`이며 main이 서버 버전별 authoritative 근거를 검증한 뒤 제공한다. `official` 정책은 main이 따로 판정하고 확인된 boolean `expected_official`을 계약에 제공한 경우 helper도 대조한다. 동일 `PENDING` ID submit 복구는 기존 절차로 검증하며 이 helper의 single-create 검증으로 대체하지 않는다. +- `verify-publication --before --after --exact-review --result --allowed-paths --reviewer-id --inline-comments --expected-pr-url [--provider-contract ]`는 single native create의 exact review·collection·timeline과 제한된 field projection을 대조한다. `expected_pr_url`은 snapshot에서 자동 추출하지 않고 main이 canonical authenticated access로 따로 확인한 exact PR URL을 제공하며 host·설치 prefix를 포함해 exact-match한다. Timeline은 ID·type·review ID·author·body·timestamp binding과 기존 event 보존을 검증하며 provider 전체 wire schema 확인은 기존 main 절차에 남는다. Snapshot은 `issue`, `issue_comments`, `pr`, `pr_comments`, `reviews`, `timeline`, `files`를 포함한다. Provider 계약을 생략하면 기존 review 변경을 허용하지 않는다. 현재 projection 계약은 `version.version=1.27.3`, boolean `supersession_same_author_only`·`already_dismissed_review_timestamp_projection`·`requested_reviewers_presentation_order`이며 main이 서버 버전별 authoritative 근거를 검증한 뒤 제공한다. 각 projection flag는 생략하면 `false`이며 bool 이외의 값과 미지원 버전은 거절한다. `official` 정책은 main이 따로 판정하고 확인된 boolean `expected_official`을 계약에 제공한 경우 helper도 대조한다. 동일 `PENDING` ID submit 복구는 기존 절차로 검증하며 이 helper의 single-create 검증으로 대체하지 않는다. +- `requested_reviewers_presentation_order=true`는 `version.version="1.27.3"`에 결합된 opt-in이다. Write 전에 main이 authenticated version 및 해당 provider의 latest non-dismissed review/update-time 기반 presentation 순서 근거를 확인·보존한 경우에만 제공한다. 버전 문자열이나 caller flag 자체는 인증·provider 동작의 증명이 아니다. 이 flag에서만 `requested_reviewers`를 duplicate-free positive stable account ID로 비교한다: 기존 membership과 각 record의 모든 nested field·type을 정확히 보존하고, 추가는 verified review author ID 하나만 허용하며 그 새 record 전체 JSON이 exact review의 `user` record와 정확히 같아야 한다(이미 목록에 있으면 추가 없이 순서 교환 가능). 기존 reviewer 제거, 다른 account 추가, 중복/invalid ID, record field 변경은 차단한다. Flag가 없거나 false이면 기존 ordered 비교를 유지한다. Raw before/after snapshot은 정렬·재작성하지 않으며 criteria, historical reviews, timeline, comments 및 다른 배열의 순서 계약을 완화하지 않는다. Publication 실패 후 POST 재시도 금지와 exact target/head/body/author/cardinality 검증은 그대로 유지한다. +- 예시 provider-contract JSON: `{"version":{"version":"1.27.3"},"requested_reviewers_presentation_order":true}`. 기존 supersession/retouch/official 정책이 필요하면 각각 independently verified 값을 명시적으로 유지한다. 정본 계약 위치는 이 문서이며 기존 문서를 update-in-place한다; 회차별 artifact에서 별도 verifier나 snapshot normalization으로 우회하지 않는다. - `check-unchanged --before --after `는 read-only replay의 exact snapshot 동일성을 확인한다. Helper 성공은 호출자가 실제 write를 하지 않았다는 증명이나 최종 리뷰 완료를 단독으로 의미하지 않는다. - Helper는 credential을 발견하거나 network·외부 write를 수행하지 않고 acceptance 의미·evidence 유효성·verdict를 결정하지 않는다. 실제 secret 부재는 main이 별도로 확인하며 알려진 민감값은 Python API의 `sensitive_values`로 메모리 안에서만 전달한다. 입력 오류는 nonzero로 중단하고 입력 본문·credential을 error에 출력하지 않는다. - 회귀 검증은 `terminal(command="PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/spec -p 'test_implementation_spec_review_support.py' -v")`로 실행한다. Schema/body/provider 처리 변경 시 같은 schema의 저장된 실제 wire artifact로 render·parse byte 일치와 publication·read-only replay도 검증한다. Helper 검증을 위해 새 외부 write를 하지 않으며 과거 artifact replay를 fresh authenticated readback으로 보고하지 않는다. @@ -250,6 +252,7 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r 새 최종 review는 `stale=false`, `dismissed=false`여야 한다. `official`은 고정된 `false`를 요구하지 않고 authoritative provider 계약·조회된 권한·review 정책과 대조해 판정하며, 그 값만으로 작성자·본문·head·최종 state 검증을 대체하지 않는다. Baseline 변경은 절차 20의 검증된 파생 projection만 제외하고 보존한다. 19. Review create가 timeout·오류 또는 malformed response이면 blind retry하지 않는다. Complete review collection과 candidate review comments를 한 번 authoritative reconciliation해 baseline 대비 exact result가 하나이고 verdict-compatible 최종 state와 inline comment 부재가 확인될 때만 제출 완료로 판정한다. Exact owned `PENDING` review 하나가 확인되면 그 ID를 보존하고 partial publication으로 기록한다. 같은 review 제출이 승인된 경우에만, submit schema·성공 status 및 identity·Issue contract·PR base/head·changed files·review baseline·canonical body를 새로 검증하고(이미 인과가 증명된 해당 review의 파생 projection만 제외), 동일 ID를 명시적 verdict로 한 번 submit한 뒤 절차 18과 20의 readback을 적용한다. 새 create는 하지 않으며 submit 응답이 불확실하면 동일 ID를 authoritative readback해 최종 제출을 확인하거나 uncertain/partial로 중단한다. Candidate가 없거나 여러 개·경쟁 review·drift가 있으면 복구 write 없이 중단한다. 20. Source Issue와 PR을 다시 읽는다. Body·title·state·labels·assignees·milestone·comments, PR base·head·changed files·merge state와 source content는 불변이어야 한다. 새 create는 baseline 대비 신규 review record가 정확히 하나여야 하고 기존 `PENDING` 복구는 추가 record 없이 같은 ID를 유지해야 하며, record 수와 timeline event 수를 구분한다. 단, Gitea가 exact native review create 또는 승인된 동일 ID submit의 파생 projection으로만 다음을 변경한 경우는 허용한다: Issue와 PR `updated_at`이 verified review `submitted_at` 또는 동일 review ID·author의 verified create/submit timeline event timestamp로 갱신됨, PR `review_comments`가 top-level review 생성으로 baseline보다 정확히 1 증가함(동일 ID submit 복구 시 추가 증가는 확인된 provider 계약과 해당 review timeline event로 인과가 증명된 경우에만 허용), `requested_reviewers`에 verified review author stable account ID가 정확히 추가됨. `submitted_at`이 draft 생성 시각을 유지할 수 있으므로 최종 state와 submit event를 별도로 검증한다. `comments: []` 계약은 projection count와 별개로 authoritative `get_review_comments`가 0개여야 한다. 이 파생 변경과 아래의 제한된 기존 review 대체 projection은 각 write 전후 snapshot과 exact review ID·author·timestamp로 인과를 증명해야 하며 허용 목록 밖의 field 변경, 기존 reviewer 제거, 추가 account 또는 승인되지 않은 write call이 있으면 fail-closed한다. 검증된 submit은 별도 write로 기록하되 provider 파생 field 갱신을 직접 Issue/PR mutation으로 보고하지 않는다. + `requested_reviewers` 순서 변경은 위의 `requested_reviewers_presentation_order` 계약을 write 전에 확인한 경우에만 presentation-only로 허용한다. Stable ID 기준으로 모든 기존 record를 정확히 보존하며 이미 포함된 게시자의 위치 변경 또는 게시자 하나의 추가와 함께 발생한 기존 membership의 순서 변경만 제외한다. Raw snapshots를 유지하고, 기존 reviewer 제거·다른 account 추가·shared record 변경과 다른 배열의 순서 변경은 이 계약으로 허용하지 않는다. 기존 review 대체 projection은 write 전에 authoritative provider 계약으로 대체 조건·대상·필드를 확인해 기록한 경우에만 허용한다. 동일 PR·동일 stable author의 baseline상 `official=true`, `dismissed=false`인 이전 review에서 `official: true→false`, `dismissed: false→true`, 해당 native create/submit event 시각으로의 `updated_at` 변경만 허용하며, 영향받은 모든 ID를 읽어 기존 ID·body·commit·state·author·stale와 나머지 필드 및 record 보존을 검증한다. 신규 review/event cardinality와 해당 write의 인과도 별도로 확인한다. Timestamp 일치나 과거 관측만으로 대체를 추정하지 않으며 다른 작성자·다른 필드 변경, 계약 미확인 또는 인과 불명은 `partial`/fail-closed로 기록하고 추가 write를 중단한다. 기존 flag를 직접 복원·dismiss하거나 과거 실행 결과를 소급 성공으로 바꾸지 않는다. 별도로, 동일 PR·동일 stable author의 baseline상 이미 `dismissed=true`인 이전 approve/reject review는 서버 버전에 결합된 authoritative provider 계약으로 bulk-update 대상과 timestamp 동작을 write 전에 확인한 경우에만 해당 native create/submit event에 인과가 증명된 `updated_at` 갱신만 허용한다. 기존 `official`·`dismissed` 값과 ID·body·commit·state·author·stale 및 모든 나머지 필드·record는 그대로 보존하고 영향받은 모든 ID와 신규 review/event cardinality를 readback한다. Timestamp 일치만으로 인과를 추정하지 않으며 계약·인과가 미확인되면 write 전 차단하거나 write 후 `partial`/fail-closed로 중단한다. 21. 동일 state를 read-only replay해 stable deduplication identity의 canonical review를 재사용하며 `writes=[]`인지 확인한다. diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py index 5e0f3b3..2c7dda5 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py @@ -525,11 +525,12 @@ def _target_snapshot(snapshot, target): def _projection_contract(contract): if contract is None: - return False, False, None + return False, False, None, False if type(contract) is not dict or type(contract.get("version")) is not dict or contract["version"].get("version") != "1.27.3": _fail("unsupported provider projection contract") flags = [] - for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection"): + for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection", + "requested_reviewers_presentation_order"): value = contract.get(key, False) if type(value) is not bool: _fail("invalid provider projection contract") @@ -537,7 +538,7 @@ def _projection_contract(contract): official = contract.get("expected_official") if official is not None and type(official) is not bool: _fail("invalid provider review policy") - return flags[0], flags[1], official + return flags[0], flags[1], official, flags[2] def verify_publication(before, after, exact_review, result, allowed_paths, reviewer_id, inline_comments, provider_contract=None, *, expected_pr_url): @@ -553,7 +554,7 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie _fail("snapshot PR URL mismatch") if _user_id(before["pr"]) == reviewer_id: _fail("reviewer is PR author") - supersede, retouch, expected_official = _projection_contract(provider_contract) + supersede, retouch, expected_official, reviewer_presentation_order = _projection_contract(provider_contract) old_reviews, reviews = _index(before["reviews"]), _index(after["reviews"]) if type(exact_review) is not dict: _fail("invalid exact review") @@ -604,7 +605,16 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie _fail("review count projection mismatch") old_requested = _index(bpr.get("requested_reviewers")) requested = _index(apr.get("requested_reviewers")) - if not _same(bpr["requested_reviewers"], apr["requested_reviewers"]): + if reviewer_presentation_order: + # Only this explicitly version-bound collection has presentation order. + if set(old_requested) - set(requested) or (set(requested) - set(old_requested)) - {reviewer_id}: + _fail("requested reviewers projection mismatch") + for old_id, old in old_requested.items(): + if not _same(old, requested[old_id]): + _fail("previous requested reviewers changed") + if reviewer_id in set(requested) - set(old_requested) and not _same(requested[reviewer_id], exact_review.get("user")): + _fail("new requested reviewer user mismatch") + elif not _same(bpr["requested_reviewers"], apr["requested_reviewers"]): if reviewer_id in old_requested or set(requested) - set(old_requested) != {reviewer_id} or set(old_requested) - set(requested): _fail("requested reviewers projection mismatch") if not _same([x for x in apr["requested_reviewers"] if x["id"] != reviewer_id], bpr["requested_reviewers"]): diff --git a/harness/tests/spec/test_requested_reviewer_projection.py b/harness/tests/spec/test_requested_reviewer_projection.py new file mode 100644 index 0000000..4e4c61c --- /dev/null +++ b/harness/tests/spec/test_requested_reviewer_projection.py @@ -0,0 +1,176 @@ +import copy +import json +import subprocess +import sys +import tempfile +from pathlib import Path +import unittest +from harness.tests.spec import test_implementation_spec_review_support as fixture + + +class RequestedReviewerProjectionTests(unittest.TestCase): + def snapshots(self): + before, after, review, result, paths = fixture.publication_fixture() + rich_user = { + 'id': 42, + 'login': 'spec-agent', + 'full_name': 'Spec Agent', + 'metadata': { + 'active': True, + 'roles': ['reviewer', {'scope': 'implementation-spec'}], + }, + } + review['user'] = copy.deepcopy(rich_user) + after['reviews'][0]['user'] = copy.deepcopy(rich_user) + after['timeline'][0]['user'] = copy.deepcopy(rich_user) + people = [copy.deepcopy(rich_user), + {'id': 7, 'login': 'other', 'metadata': {'active': False, + 'roles': ['observer']}}] + before['pr']['requested_reviewers'] = copy.deepcopy(people) + after['pr']['requested_reviewers'] = copy.deepcopy(people) + return before, after, review, result, paths + + def contract(self): + return {'version': {'version': '1.27.3'}, + 'requested_reviewers_presentation_order': True} + + def test_existing_reviewer_permutation_requires_explicit_versioned_contract(self): + before, after, review, result, paths = self.snapshots() + fixture.verify_fixture(before, after, review, result, paths, self.contract()) + after['pr']['requested_reviewers'].reverse() + try: + actual = fixture.verify_fixture(before, after, review, result, paths, self.contract()) + except fixture.mod.ValidationError as exc: + self.fail('versioned presentation-only permutation was rejected: ' + str(exc)) + self.assertEqual(actual['review_id'], 11) + + + def test_missing_false_or_unrelated_projection_flags_remain_order_strict(self): + before, after, review, result, paths = self.snapshots() + after['pr']['requested_reviewers'].reverse() + for contract in (None, {'version': {'version': '1.27.3'}}, + {'version': {'version': '1.27.3'}, 'requested_reviewers_presentation_order': False}, + {'version': {'version': '1.27.3'}, 'supersession_same_author_only': True}): + with self.subTest(contract=contract), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, after, review, result, paths, contract) + + def test_new_reviewer_addition_can_reorder_preserved_members(self): + before, after, review, result, paths = self.snapshots() + old = [{'id': 5, 'login': 'five'}, {'id': 7, 'login': 'seven'}] + before['pr']['requested_reviewers'] = copy.deepcopy(old) + after['pr']['requested_reviewers'] = [old[1], copy.deepcopy(review['user']), old[0]] + originals = copy.deepcopy((before, after)) + self.assertEqual(fixture.verify_fixture(before, after, review, result, paths, + self.contract())['review_id'], 11) + self.assertEqual((before, after), originals) + # Without opt-in only an addition preserving previous order is allowed. + with self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, after, review, result, paths) + after['pr']['requested_reviewers'] = old + [copy.deepcopy(review['user'])] + fixture.verify_fixture(before, after, review, result, paths) + + def test_opt_in_new_reviewer_must_match_exact_review_user_record(self): + before, after, review, result, paths = self.snapshots() + old = [{'id': 5, 'login': 'five'}, {'id': 7, 'login': 'seven'}] + before['pr']['requested_reviewers'] = copy.deepcopy(old) + expected = copy.deepcopy(review['user']) + for forged in ( + {'id': 42}, + {'id': 42, 'login': 'forged', 'metadata': expected['metadata']}, + {**expected, 'extra': 'unexpected'}, + {**expected, 'metadata': {'active': True, 'roles': ['reviewer', {'scope': 'other'}]}}, + {'id': 42, 'login': expected['login'], 'metadata': expected['metadata'], 'name': None}, + ): + bad = copy.deepcopy(after) + bad['pr']['requested_reviewers'] = [old[1], forged, old[0]] + with self.subTest(forged=forged), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, bad, review, result, paths, self.contract()) + + def test_removals_unknown_additions_and_all_existing_record_drift_fail(self): + before, after, review, result, paths = self.snapshots() + changes = [] + for people in ([{'id': 42}], after['pr']['requested_reviewers'] + [{'id': 43}], + after['pr']['requested_reviewers'] + [copy.deepcopy(after['pr']['requested_reviewers'][0])]): + bad = copy.deepcopy(after); bad['pr']['requested_reviewers'] = people; changes.append(bad) + for field in ('login', 'metadata', 'extra'): + bad = copy.deepcopy(after); bad['pr']['requested_reviewers'][0][field] = 'changed'; changes.append(bad) + bad = copy.deepcopy(after); del bad['pr']['requested_reviewers'][0]['metadata']; changes.append(bad) + for bad in changes: + with self.subTest(case=changes.index(bad)), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, bad, review, result, paths, self.contract()) + bad_before = copy.deepcopy(before) + bad_before['pr']['requested_reviewers'].append(copy.deepcopy(bad_before['pr']['requested_reviewers'][0])) + with self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(bad_before, after, review, result, paths, self.contract()) + + def test_contract_version_and_flag_types_fail_closed(self): + before, after, review, result, paths = self.snapshots() + for value in (None, 0, 1, 'true', [], {}): + contract = self.contract(); contract['requested_reviewers_presentation_order'] = value + with self.subTest(flag=value), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, after, review, result, paths, contract) + for version in ('1.27.4', '1.27.3+patched', None, True): + contract = self.contract(); contract['version']['version'] = version + with self.subTest(version=version), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, after, review, result, paths, contract) + + def test_invalid_id_types_and_null_collections_fail_closed(self): + before, after, review, result, paths = self.snapshots() + for value in (True, 0, -1, '42', 42.0, None): + bad = copy.deepcopy(after); bad['pr']['requested_reviewers'][0]['id'] = value + with self.subTest(id=value), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(before, bad, review, result, paths, self.contract()) + for side in ('before', 'after'): + b, a = copy.deepcopy(before), copy.deepcopy(after) + (b if side == 'before' else a)['pr']['requested_reviewers'] = None + with self.subTest(side=side), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(b, a, review, result, paths, self.contract()) + + def test_other_meaningful_orders_remain_strict(self): + before, after, review, result, paths = self.snapshots() + comments = [{'id': 1, 'body': 'one'}, {'id': 2, 'body': 'two'}] + for field in ('issue_comments', 'pr_comments'): + b, a = copy.deepcopy(before), copy.deepcopy(after) + b[field] = copy.deepcopy(comments); a[field] = list(reversed(comments)) + with self.subTest(field=field), self.assertRaises(fixture.mod.ValidationError): + fixture.verify_fixture(b, a, review, result, paths, self.contract()) + # Historical review order must stay intact, even with reviewer opt-in. + for ident in (8, 9): + old = copy.deepcopy(review); old['id'] = ident + before['reviews'].append(old); after['reviews'].insert(0, copy.deepcopy(old)) + with self.assertRaisesRegex(fixture.mod.ValidationError, 'previous review order'): + fixture.verify_fixture(before, after, review, result, paths, self.contract()) + + def test_cli_uses_opt_in_without_mutating_input_files(self): + before, after, review, result, paths = self.snapshots() + after['pr']['requested_reviewers'].reverse() + with tempfile.TemporaryDirectory(dir='/opt/data/cache/scratch') as td: + root = Path(td) + values = {'before': before, 'after': after, 'exact-review': review, + 'result': result, 'allowed-paths': list(paths), + 'inline-comments': [], 'provider-contract': self.contract()} + args = [sys.executable, '-B', str(fixture.MODPATH), 'verify-publication'] + for name, value in values.items(): + path = root / (name + '.json'); path.write_text(json.dumps(value)) + args += ['--' + name, str(path)] + args += ['--reviewer-id', '42', '--expected-pr-url', 'https://example.test/acme/repo/pulls/7'] + original = {p.name: p.read_bytes() for p in root.iterdir()} + checked = subprocess.run(args, capture_output=True, text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stderr) + self.assertEqual(json.loads(checked.stdout)['review_id'], 11) + self.assertEqual({p.name: p.read_bytes() for p in root.iterdir()}, original) + path = root / 'provider-contract.json' + contract = self.contract(); contract['requested_reviewers_presentation_order'] = False + path.write_text(json.dumps(contract)) + denied = subprocess.run(args, capture_output=True, text=True, timeout=15) + self.assertNotEqual(denied.returncode, 0) + + 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) + +if __name__ == '__main__': + unittest.main()