diff --git a/harness/agents/impl/skills/gitea-issue-rework/references/workflow.md b/harness/agents/impl/skills/gitea-issue-rework/references/workflow.md index 29fb757..5158f4b 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/references/workflow.md +++ b/harness/agents/impl/skills/gitea-issue-rework/references/workflow.md @@ -271,6 +271,62 @@ pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한 완료 조건: final local HEAD, clean state, finding별 해결 evidence와 단일 검증 기록이 일치하며, 필요한 Contract Preservation Inventory는 `missing=0`, `ambiguous=0`이다. +#### commit.files 순서 비교 계약 + +`identity-bound-unordered-commit-file-collection`: 위 drift 확인과 아래 전달 직전 preflight에서 main-owned adapter가 PR commit collection을 비교할 때 적용한다. 보존된 native response에서 같은 commit SHA와 동일한 전체 file record가 `commit.files` 배열 순서만 다르게 반환된 사례가 있다. 이는 관찰된 응답의 근거이며 모든 provider/version의 배열 의미를 보증하지 않는다. 사용 전 exact endpoint/provider scope와 현재 readback의 의미를 확인한다. + +- **별도 선행 gate:** authenticated identity, exact repository/PR/head, complete collection·pagination, truncation 부재와 필수 commit/file metadata를 기존 provider scope admission으로 먼저 검증한다. 아래 normalization **does not prove** collection completeness, authority 또는 metadata completeness이며 PASS receipt나 새 runtime gate가 아니다. 누락된 목록을 `[]`로 채우거나 count·pagination 불일치를 정렬로 해소하지 않는다. +- 원본 baseline/current response와 digest를 그대로 보존한다. 검증된 복사본에서 각 commit의 `files`만 exact `filename` 기준으로 정렬하며 전체 file record를 비교한다. filename/status 외의 추가 metadata도 삭제하거나 무시하지 않는다. commit SHA·message·parents·그 밖의 metadata와 바깥 commit 배열의 순서는 기존 exact 비교를 유지한다. +- 중복 commit SHA·filename과 malformed shape를 거부한다. `files=[]`는 앞선 admission이 확인한 실제 빈 목록일 때만 허용한다. filename·status는 비어 있지 않은 문자열 그대로 사용하며 strip, case-fold, path normalization 또는 identifier repair를 하지 않는다. 파일 경로를 읽거나 실행하는 예제가 아니다. +- 아래는 trusted adapter의 제한된 비교 예제다. Coordinator, publication wrapper 또는 모든 collection에 일괄 정렬을 추가하지 않는다. 정규화한 두 collection의 equality는 기존 보호 필드 검사 중 하나일 뿐이고 write authority를 부여하지 않는다. 실패한 preflight의 consumed authority·receipt를 보존하며, 이 규칙 때문에 자동 재시도하거나 새 coordinator로 같은 operation을 재실행하지 않는다. 별도 승인된 재개에도 fresh preflight가 필요하다. + + +```python +import copy +import re + + +def normalize_commit_files(rows): + if type(rows) is not list: + raise ValueError("commit collection must be a list") + normalized = copy.deepcopy(rows) + commit_ids = set() + for commit in normalized: + if type(commit) is not dict: + raise ValueError("commit record must be an object") + sha = commit.get("sha") + if type(sha) is not str or re.fullmatch(r"[0-9a-f]{40}", sha) is None: + raise ValueError("full lowercase commit SHA required") + if sha in commit_ids: + raise ValueError("duplicate commit SHA") + commit_ids.add(sha) + files = commit.get("files") + if type(files) is not list: + raise ValueError("complete file list required") + filenames = set() + for file in files: + if type(file) is not dict: + raise ValueError("file record must be an object") + filename = file.get("filename") + status = file.get("status") + if type(filename) is not str or not filename: + raise ValueError("nonempty literal filename required") + if type(status) is not str or not status: + raise ValueError("nonempty file status required") + if filename in filenames: + raise ValueError("duplicate filename") + filenames.add(filename) + commit["files"] = sorted(files, key=lambda file: file["filename"]) + return normalized + + +# Only after independent provider/collection admission: +# normalize_commit_files(current_commits) == normalize_commit_files(baseline_commits) +``` + + +`harness/tests/impl/test_impl_architecture_docs.py`는 이 정본 block 자체를 추출·실행하여 순서 차이만 허용되는지, 실제 metadata/identity 변화·중복·malformed input을 거부하거나 비동등으로 판정하는지, 원본과 nested value가 불변인지 검증한다. 테스트에 comparator를 복제하지 않는다. 이 실행 회귀는 비교 예제의 local contract 검증이며 live provider capability, collection admission 또는 실제 전달 성공을 대신하지 않는다. + ### 7. 기존 PR branch 전달 각 mutation은 [operation API contract](../../../OPERATIONS.md)의 `execute_rework_operation`으로 처리한다. Exact 기존 comment 재사용은 `verify_rework_reuse`의 fresh read-only 경로다. 아래 기존 보호 계약과 각 실제 readback verifier는 그대로 적용하며 coordinator 결과만으로 handoff를 판정하지 않는다. diff --git a/harness/tests/impl/test_impl_architecture_docs.py b/harness/tests/impl/test_impl_architecture_docs.py index efb6ee5..64703b8 100644 --- a/harness/tests/impl/test_impl_architecture_docs.py +++ b/harness/tests/impl/test_impl_architecture_docs.py @@ -39,5 +39,174 @@ class ImplArchitectureDocsTests(unittest.TestCase): for token in ("strict", "separately", "atomic_client_precondition=false", "residual", "neutered"): self.assertIn(token.lower(), text.lower()) +# The comparison recipe is executable documentation, not a second implementation. +import copy +import json + +COMMIT_FILES_START = "" +COMMIT_FILES_END = "" + + +def _execute_documented_commit_files_comparator(): + """Load the single trusted Python example from the canonical workflow.""" + path = ROOT / "harness/agents/impl/skills/gitea-issue-rework/references/workflow.md" + text = path.read_text() + start = text.count(COMMIT_FILES_START) + end = text.count(COMMIT_FILES_END) + if start != 1 or end != 1: + raise AssertionError("workflow must contain exactly one commit-files example boundary pair") + section = text.split(COMMIT_FILES_START, 1)[1].split(COMMIT_FILES_END, 1)[0] + snippets = re.findall(r"```python\s*\n(.*?)\n```", section, re.DOTALL) + if len(snippets) != 1: + raise AssertionError("commit-files example must contain exactly one Python fenced block") + namespace = {} + exec(compile(snippets[0], str(path) + "#commit-files-example", "exec"), namespace) + comparator = namespace.get("normalize_commit_files") + if not callable(comparator): + raise AssertionError("Python example must define normalize_commit_files(rows)") + heading = "#### commit.files 순서 비교 계약\n" + if text.count(heading) != 1: + raise AssertionError("workflow must contain one scoped commit-files comparison contract") + contract = text.split(heading, 1)[1].split("\n### 7.", 1)[0] + return comparator, contract + + +def _commit(sha, message, parent, files): + return { + "sha": sha, + "commit": {"message": message, "author": {"name": "Test Author", "email": "author@example.invalid"}}, + "url": "https://example.invalid/commit/" + sha, + "parents": [{"sha": parent, "url": "https://example.invalid/commit/" + parent}], + "verification": {"verified": True, "reason": "valid", "arbitrary": [1, {"keep": True}]}, + "files": files, + "provider_extension": {"retained": ["opaque", 7]}, + } + + +def _file(filename, status, additions): + return { + "filename": filename, + "status": status, + "additions": additions, + "deletions": 2, + "changes": additions + 2, + "patch": "@@ -1 +1 @@\n-old\n+new", + "provider_extension": {"nested": [filename, {"opaque": True}]}, + } + + +class DocumentedCommitFilesComparatorTests(unittest.TestCase): + SHA_A = "0123456789abcdef0123456789abcdef01234567" + SHA_B = "89abcdef0123456789abcdef0123456789abcdef" + PARENT_A = "1111111111111111111111111111111111111111" + PARENT_B = "2222222222222222222222222222222222222222" + + def setUp(self): + self.normalize, self.workflow = _execute_documented_commit_files_comparator() + + def fixture(self): + return [ + _commit(self.SHA_A, "first message", self.PARENT_A, [ + _file("src/zeta.py", "modified", 3), _file("docs/alpha.md", "added", 5), + ]), + _commit(self.SHA_B, "second message", self.PARENT_B, [ + _file("tests/test_beta.py", "modified", 7), _file("README.md", "added", 11), + ]), + ] + + def test_inner_file_order_is_ignored_but_outer_commit_order_is_preserved(self): + first = self.fixture() + second = copy.deepcopy(first) + for commit in second: + commit["files"].reverse() + self.assertEqual(self.normalize(first), self.normalize(second)) + self.assertNotEqual(self.normalize(first), self.normalize(list(reversed(second)))) + + def test_identity_and_all_file_metadata_remain_comparison_bound(self): + baseline = self.normalize(self.fixture()) + mutations = ( + lambda rows: rows[0]["files"][0].update(filename="src/renamed.py"), + lambda rows: rows[0]["files"][0].update(status="added"), + lambda rows: rows[0]["files"][0].update(provider_extension={"changed": True}), + lambda rows: rows[0]["files"].append(_file("new.txt", "added", 1)), + lambda rows: rows[0]["files"].pop(), + lambda rows: rows[0].update(commit={"message": "changed message", "author": {"name": "Test Author", "email": "author@example.invalid"}}), + lambda rows: rows[0]["parents"].__setitem__(0, {"sha": self.SHA_B, "url": "https://example.invalid/commit/" + self.SHA_B}), + lambda rows: rows[0].update(sha="3333333333333333333333333333333333333333"), + ) + for mutate in mutations: + with self.subTest(mutation=mutate): + changed = self.fixture() + mutate(changed) + self.assertNotEqual(baseline, self.normalize(changed)) + + def test_normalization_preserves_input_and_returns_independent_deep_copy(self): + rows = self.fixture() + before = json.dumps(rows, sort_keys=True, separators=(",", ":")) + normalized = self.normalize(rows) + self.assertEqual(before, json.dumps(rows, sort_keys=True, separators=(",", ":"))) + self.assertEqual(rows, self.fixture()) + normalized[0]["files"][0]["provider_extension"]["nested"][1]["opaque"] = False + normalized[0]["provider_extension"]["retained"].append("output-only") + self.assertTrue(rows[0]["files"][0]["provider_extension"]["nested"][1]["opaque"]) + self.assertNotIn("output-only", rows[0]["provider_extension"]["retained"]) + + def test_empty_file_list_is_valid_and_order_is_only_normalized_within_commits(self): + rows = self.fixture() + rows[0]["files"] = [] + result = self.normalize(rows) + self.assertEqual([], result[0]["files"]) + self.assertEqual([self.SHA_A, self.SHA_B], [row["sha"] for row in result]) + self.assertEqual(sorted(file["filename"] for file in rows[1]["files"]), [file["filename"] for file in result[1]["files"]]) + + def test_invalid_collection_commit_and_file_shapes_raise_value_error(self): + valid = self.fixture() + invalid_cases = [] + for value in (None, {}, "rows", (valid,)): + invalid_cases.append(value) + invalid_cases.extend(([None], ["commit"], [{"sha": self.SHA_A}], [{"sha": self.SHA_A, "files": None}], [{"sha": self.SHA_A, "files": {}}])) + for bad in invalid_cases: + with self.subTest(rows=bad): + with self.assertRaises(ValueError): + self.normalize(bad) + for bad_sha in ("", "A" * 40, "a" * 39, "g" * 40, None, 7): + with self.subTest(sha=bad_sha): + rows = self.fixture() + rows[0]["sha"] = bad_sha + with self.assertRaises(ValueError): + self.normalize(rows) + duplicate_sha = self.fixture() + duplicate_sha[1]["sha"] = duplicate_sha[0]["sha"] + with self.assertRaises(ValueError): + self.normalize(duplicate_sha) + for bad_file in (None, "file", {}, {"filename": "x"}): + with self.subTest(file=bad_file): + rows = self.fixture() + rows[0]["files"] = [bad_file] + with self.assertRaises(ValueError): + self.normalize(rows) + for key in ("filename", "status"): + for value in (None, 1, ""): + with self.subTest(key=key, value=value): + rows = self.fixture() + rows[0]["files"][0][key] = value + with self.assertRaises(ValueError): + self.normalize(rows) + duplicate_file = self.fixture() + duplicate_file[0]["files"][1]["filename"] = duplicate_file[0]["files"][0]["filename"] + with self.assertRaises(ValueError): + self.normalize(duplicate_file) + + def test_minimal_file_record_and_literal_filename_are_retained(self): + rows = [{"sha": self.SHA_A, "files": [{"filename": " dir/../literal name.txt ", "status": "modified"}]}] + self.assertEqual(rows, self.normalize(rows)) + + def test_document_states_collection_admission_is_a_separate_prerequisite(self): + self.assertIn("complete collection", self.workflow) + self.assertIn("provider scope", self.workflow) + self.assertIn("admission", self.workflow) + self.assertIn("does not prove", self.workflow) + + if __name__ == "__main__": unittest.main()