Compare commits

..
18 Commits
Author SHA1 Message Date
gitea-spec-agent 3759fcbde3 [verified] Add narrowed reviewer timeout evidence recipe 2026-10-12 22:30:10 +00:00
gitea-spec-agent 33b0e44421 [verified] Clarify Issue body provider-derived revision readback contract 2026-10-12 22:10:55 +00:00
gitea-spec-agent c1f0034300 [verified] Check trusted-template AC verification classification 2026-10-12 16:28:55 +00:00
gitea-spec-agent b687af03ec [verified] Document bounded Discovery checker preparation 2026-10-12 16:11:48 +00:00
gitea-spec-agent a4ead04664 [verified] Connect conditional review observability checklist 2026-10-12 15:59:57 +00:00
gitea-spec-agent 20cc7858f6 [verified] Clarify changed readback helper invocation preflight 2026-10-12 15:35:46 +00:00
gitea-spec-agent e5339694ab [verified] Document conditional pytest evidence summary recipe 2026-10-12 15:19:46 +00:00
gitea-spec-agent 14b27bb708 [verified] Gate mirror fetch on attached worktree topology 2026-10-12 14:21:50 +00:00
gitea-spec-agent cc4fbd0008 [verified] Add bounded writer-family closure checklist 2026-10-12 13:48:46 +00:00
gitea-spec-agent b24632d65d [verified] Document read control responses and script preparation 2026-10-12 13:06:17 +00:00
gitea-spec-agent 613a7ae24a [verified] Document dependency provenance and probe JSON framing 2026-10-12 12:10:32 +00:00
gitea-spec-agent 64b5746879 [verified] Define re-review convergence escalation contract 2026-10-12 11:51:19 +00:00
gitea-spec-agent d6b70a24b2 [verified] Clarify named-test evidence and rework reporting 2026-10-12 08:23:27 +00:00
gitea-spec-agent 32a3c1ad44 [verified] Document tracked bounded evidence execution 2026-10-12 08:02:25 +00:00
gitea-spec-agent 62d2262b27 [verified] Document empty review-comments readback without count header 2026-10-12 07:51:32 +00:00
gitea-spec-agent 1e842a6e43 [verified] Define non-file Git credential recipe 2026-10-12 07:27:04 +00:00
gitea-spec-agent d4db3f6a8e [verified] Add executable reviewer evidence manifest recipe 2026-10-12 06:49:44 +00:00
gitea-spec-agent 4acc28563c [verified] Expose canonical broker roles in CLI and docs 2026-10-12 06:01:42 +00:00
24 changed files with 1491 additions and 12 deletions
@@ -196,6 +196,19 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
모든 baseline, 통합·precommit 및 readback 검증은 `scripts/evidence_manifest.py`와 `templates/evidence-plan.json`을 사용해 exact argv, exit code, 제한된 output, 허용된 환경, source revision, 파일 hash와 표준 JSON-pointer claims를 기록한다. 명령은 argv 배열만 허용하고 명시된 최소 child-environment allowlist에서 실행하며 output 상한을 넘는 process는 중단한다. Secret·header·private-key·password 값이나 plan 금지 패턴이 발견되면 redaction하지 않고 fail-closed한다. `run`은 baseline부터 최종 precommit까지 사용하고, `validate`는 제품 tree의 evidence exclusion 없이 strict clean checkout에서 PR claim 생성 전에 실행한다. 새 기록은 external verification root의 v2이며 exact current HEAD와 source blob·hash를 다시 검증한다. 과거 evidence-only descendant는 명시적 archived v1 read-only 호환 경로에 한정한다. 수동으로 매칭되지 않는 PR 성공·검증 주장을 만들지 않는다. Comment publish 전 remote immutable artifact의 digest를 authoritative readback으로 비교한다. 모든 baseline, 통합·precommit 및 readback 검증은 `scripts/evidence_manifest.py`와 `templates/evidence-plan.json`을 사용해 exact argv, exit code, 제한된 output, 허용된 환경, source revision, 파일 hash와 표준 JSON-pointer claims를 기록한다. 명령은 argv 배열만 허용하고 명시된 최소 child-environment allowlist에서 실행하며 output 상한을 넘는 process는 중단한다. Secret·header·private-key·password 값이나 plan 금지 패턴이 발견되면 redaction하지 않고 fail-closed한다. `run`은 baseline부터 최종 precommit까지 사용하고, `validate`는 제품 tree의 evidence exclusion 없이 strict clean checkout에서 PR claim 생성 전에 실행한다. 새 기록은 external verification root의 v2이며 exact current HEAD와 source blob·hash를 다시 검증한다. 과거 evidence-only descendant는 명시적 archived v1 read-only 호환 경로에 한정한다. 수동으로 매칭되지 않는 PR 성공·검증 주장을 만들지 않는다. Comment publish 전 remote immutable artifact의 digest를 authoritative readback으로 비교한다.
### Conditional pytest evidence recipe
pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한다. unittest 예시나 제품의 pytest 설정을 일괄 변경하지 않고, 승인된 interpreter·pytest 버전·plugin 및 기존 test target을 사용한다. 아래 JSON argv의 `{python}`과 `{tests}`를 exact interpreter와 승인 target으로 치환한다. 여러 target은 각각 argv 원소로 명시하고 shell 문자열로 합치지 않는다.
```json
["{python}", "-B", "-m", "pytest", "-o", "addopts=", "--color=no", "-rA", "{tests}"]
```
- 이 recipe는 command-local `addopts=`로 프로젝트의 quiet 누적 옵션을 해제하여 최종 summary를 보존하는 예시다. 기본적으로 `PYTEST_ADDOPTS`는 기존 child-environment allowlist에 없으며 전달하지 않는다. Quiet 자체가 항상 summary를 누락시키는 원인이라고 단정하지 않는다. 프로젝트 `addopts`의 필수 실행·plugin 옵션은 사전에 확인하여 필요한 옵션을 승인된 argv에 명시적으로 옮긴다. 필수 semantics를 보존할 수 없으면 recipe를 그대로 적용하지 않고 `evidence_gap`으로 남긴다. Warning 필터·skip 조건·선택 대상·제품 config 파일은 변경하지 않는다.
- 실제 승인 환경에서 먼저 최종 passed/skipped/failed 및 warning 요약의 보존 여부를 확인한다. pytest가 생략하는 zero category를 출력된 수치처럼 보고하거나 임의로 0으로 채우지 않는다. 이전 실행의 수치나 progress 문자 개수로 이번 실행 합계를 보충하지 않는다. Recipe만으로 모든 pytest 버전·plugin의 output 보존을 보장하지 않는다.
- 기존 evidence plan의 finite `timeout_seconds`, `max_output_bytes`, expected exit code와 process cleanup을 유지하고 exact argv·환경·source SHA·현재 실행 output을 기록한다. `timeout`·`output_limit`·불완전 summary이면 수치 claim은 `evidence_gap`이다. 명령이 완료되고 exit 0인 사실과 정확한 수치 보고의 증거는 구분한다. 승인 없이 재실행하거나 output 한도를 늘리지 않는다.
- 별도의 parser, runner, manifest schema 또는 pytest 설치 의무를 추가하지 않는다. Synthetic regression은 격리된 pytest interpreter에서 실제 pass/skip/warning과 failure 출력 및 기존 bounded reader의 보존을 검증한다. Pytest가 없는 test interpreter에서는 실행 회귀가 명시적으로 skip되므로 문서 계약 테스트만 통과한 결과를 실제 pytest 검증으로 보고하지 않는다. Synthetic test의 plugin autoload 차단은 fixture 격리용이며 제품 실행 정책이 아니다.
### 1. Target, identity와 review 고정 ### 1. Target, identity와 review 고정
1. Exact target identifier를 검증하고 broker `serve`의 ready 응답과 public `request` API로 Issue lease를 확인한다. 1. Exact target identifier를 검증하고 broker `serve`의 ready 응답과 public `request` API로 Issue lease를 확인한다.
@@ -273,7 +286,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
### 7. 기존 PR branch 전달 ### 7. 기존 PR branch 전달
1. Write 직전에 remote branch와 PR head가 snapshot의 reviewed head와 여전히 exact-match하는지 확인한다. 1. Write 직전에 remote branch와 PR head가 snapshot의 reviewed head와 여전히 exact-match하는지 확인한다.
2. 첫 delivery push 전에 승인된 noninteractive credential capability를 preflight하고 필요한 temporary askpass helper를 제한된 권한으로 준비한다. Credential은 출력하지 않고 주입하며 interactive prompt를 비활성화하고 push 시도 뒤 성공·실패와 무관하게 helper를 확정적으로 제거한다. 그 후 canonical mirror remote를 변경하지 않고 explicit repository URL과 refspec으로 기존 Issue 전용 branch에 fast-forward non-force push한다. Named mirror remote를 사용해야 하면 `operations/repository-cache.md`에 따라 해당 push 호출에서만 `remote.<name>.mirror=false`를 적용한다. 2. 첫 delivery push 전에 승인된 noninteractive credential capability를 preflight하고 공통 [non-file Git credential helper](../../../../operations/repository-cache.md#non-file-git-credential-helper)를 role-owned short-lived child process에서 사용한다. Credential은 Git의 private protocol pipe로만 공급하고 출력·파일·config에 저장하지 않으며 interactive/다른 helper fallback을 비활성화한다. 성공·실패와 무관하게 child process를 wait/reap하여 credential 환경의 종료를 확인한다. 그 후 canonical mirror remote를 변경하지 않고 explicit repository URL과 refspec으로 기존 Issue 전용 branch에 fast-forward non-force push한다. Named mirror remote를 사용해야 하면 `operations/repository-cache.md`에 따라 해당 push 호출에서만 `remote.<name>.mirror=false`를 적용한다.
3. Push 결과만 신뢰하지 않고 remote branch SHA와 PR head SHA가 final local HEAD와 exact-match하는지 readback한다. 3. Push 결과만 신뢰하지 않고 remote branch SHA와 PR head SHA가 final local HEAD와 exact-match하는지 readback한다.
4. PR body 갱신이 필요한 경우 write 직전에 PR 전체 필드, review·comment·commit collection cardinality와 authenticated impl identity를 다시 읽는다. Current body의 `Closes #<issue-number>`와 기존 구현 내용을 보존하고 재작업 review ID·reviewed head·finding별 조치·최종 검증과 unresolved limitation만 반영한 exact body payload를 명시해 한 번만 호출한다. 성공 응답만 신뢰하지 않고 fresh PR과 관련 collection을 읽어 PR author stable account ID, exact body payload, `updated_at`, base·head·commit/ref, state·draft·merge 상태, labels·assignees·reviewer와 collection cardinality를 baseline과 비교한다. Exact body와 provider가 해당 body update로 파생한 `updated_at` 외의 변경은 승인되지 않았으며 인과를 증명할 수 없으면 partial로 중단한다. 4. PR body 갱신이 필요한 경우 write 직전에 PR 전체 필드, review·comment·commit collection cardinality와 authenticated impl identity를 다시 읽는다. Current body의 `Closes #<issue-number>`와 기존 구현 내용을 보존하고 재작업 review ID·reviewed head·finding별 조치·최종 검증과 unresolved limitation만 반영한 exact body payload를 명시해 한 번만 호출한다. 성공 응답만 신뢰하지 않고 fresh PR과 관련 collection을 읽어 PR author stable account ID, exact body payload, `updated_at`, base·head·commit/ref, state·draft·merge 상태, labels·assignees·reviewer와 collection cardinality를 baseline과 비교한다. Exact body와 provider가 해당 body update로 파생한 `updated_at` 외의 변경은 승인되지 않았으며 인과를 증명할 수 없으면 partial로 중단한다.
5. PR changed files, commits, state, merge 상태, statuses와 workflow runs를 다시 읽는다. Local test, commit status와 Actions run을 별도 사실로 기록한다. 5. PR changed files, commits, state, merge 상태, statuses와 workflow runs를 다시 읽는다. Local test, commit status와 Actions run을 별도 사실로 기록한다.
@@ -368,6 +381,16 @@ PR/결과 보고에는 stage/run-id, source SHA, manifest·plan digest, 실제 c
Remote branch/PR head를 authoritative하게 읽어 source SHA·exact artifact와 결합하고 cleanup 전 실제 record readback을 확인한다. Helper의 local validate가 Gitea readback을 대신하지 않는다. Checkout 제거와 verification 폐기는 분리하며 완료 기록은 보존한다. 기존 v1은 `validate-legacy`의 read-only 호환 경로만 사용하고 새 publication proof로 사용하지 않는다. Remote branch/PR head를 authoritative하게 읽어 source SHA·exact artifact와 결합하고 cleanup 전 실제 record readback을 확인한다. Helper의 local validate가 Gitea readback을 대신하지 않는다. Checkout 제거와 verification 폐기는 분리하며 완료 기록은 보존한다. 기존 v1은 `validate-legacy`의 read-only 호환 경로만 사용하고 새 publication proof로 사용하지 않는다.
### Tracked bounded background evidence execution
Full suite가 포함되어 foreground tool 시간창을 넘을 것으로 예상되는 evidence plan은 기존 실행 도구의 **tracked background** 작업으로 시작한다. 새 scheduler나 process manager를 만들지 않는다. 실행 직전에 exact run-id, source SHA, plan digest, output path와 command를 기록하고 시작 응답의 process/session handle을 결합해 보존한다. Handle이나 PID만으로 source/admission 또는 실행 성공을 판정하지 않는다. Broker Issue lease와 기존 admission 조건은 그대로 유지하며, lease를 유지·검증할 수 없으면 외부 handoff를 차단한다.
Background도 각 command의 `timeout_seconds`를 유지하고, main이 plan의 command deadline·정리 예산·실행 overhead를 고려해 유한한 outer deadline을 명시한다. 기존 도구의 tracked wait/poll·완료 통지를 사용하되 outer deadline 만료 시에는 기존 bounded process-group cleanup과 wait/reap 절차를 적용한다. Notification이나 manifest 파일의 존재만으로 완료를 선언하지 않는다. 실행 도구가 필요한 추적·deadline·정리 capability를 제공하지 않으면 시작하지 않고 blocker를 보고한다. Background가 세션 종료 이후의 지속성을 보장하지 않는다.
Foreground 응답 timeout이나 통지 누락은 실제 evidence process 종료 또는 제품 assertion 실패의 증거가 아니다. 자동 retry하지 않는다. 재실행 판단 전에 보존한 handle로 기존 실행의 상태·실제 exit code·process 종료와 cleanup을 확인하고, 같은 run-id의 manifest를 exact source SHA·plan digest·output path에 결합해 canonical `validate`로 확인한다. Validation은 process 종료·cleanup 확인을 대신하지 않는다. Tool 관측과 artifact가 상충하거나 실행 중·상태 불명·cleanup 미확인이면 `pending/partial`로 유지하고 중복 실행하지 않는다. 같은 실행의 상태 확인과 기록 정리만 계속하며 완료된 suite를 다시 수행하지 않는다.
종료·cleanup이 확인된 성공은 기존 evidence를 검증해 재사용한다. 실패는 실제 exit code와 bounded 부분 출력·termination 기록을 보존한다. Timeout·출력 초과·cleanup 실패를 TDD RED로 보고하지 않는다. 실패한 manifest가 `validate`를 통과하지 못하더라도 그 자체를 재실행 허가로 삼지 않는다. 기존 실행이 종료되고 cleanup이 확인된 실패에 대해서만 별도 승인 후 새 run-id로 실행한다. 기존 artifact를 덮어쓰지 않는다. 이 절차는 workflow 문서 계약이며 evidence runner/schema, lease/TTL, publication journal 및 자동 retry 정책을 변경하지 않는다.
Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다. Command deadline 계약: 새 plan의 각 command는 `timeout_seconds`를 명시한다. Boolean·문자열·누락·비유한 값·0 이하·3600 초 초과를 실행 전에 거절하며 암묵적인 무제한 실행을 허용하지 않는다. Monotonic wall-clock deadline은 무출력 대기, stdout EOF 이후 실행, leader 종료 뒤 descendant가 유지한 pipe까지 적용한다. 각 command는 별도 session/process group에서 실행하고 timeout·출력 초과·예외 시 해당 process group을 TERM → 제한된 grace → KILL로 정리하고 direct child를 bounded wait로 회수한다. Leader가 종료됐어도 같은 group의 살아 있는 descendant를 남기지 않는다. 이것은 sandbox가 아니며 별도 session으로 이탈한 descendant의 containment나 grandchild 직접 회수를 보장하지 않는다.
`evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다. `evidence-manifest/v3`는 실제 timeout, `execution_status`, `termination`의 `term_sent`·`kill_sent`·`direct_child_reaped`·`group_cleanup_completed` boolean 기록과 안전한 bounded 부분 출력을 결합한다. 새 claim pointer는 기존 6개에 timeout·실행 상태·종료 기록을 추가한 9개다. Linux `/proc`로 같은 group의 non-zombie 프로세스를 확인하며 확인·정리에 실패하면 `cleanup_failed`로 차단한다. TERM grace는 0.25 초, KILL 후 group 종료·direct-child 회수 예산은 0.5 초이며 오류 fallback wait도 0.1 초로 제한한다. 새 command의 `max_output_bytes`는 0–1048576 범위의 non-boolean 정수만 허용한다. `execution_status`는 `completed|timeout|output_limit|cleanup_failed|descendant_cleanup`이다. 정상 종료 leader 뒤의 살아 있는 descendant를 신호로 정리한 경우도 `descendant_cleanup` 실패이며, `completed`에는 TERM/KILL 신호가 있을 수 없다. Timeout·출력 한도 초과·정리 실패는 expected exit code와 같아도 성공이 아니며 **TDD RED** 또는 제품 assertion 실패로 분류하지 않는다. 해당 실행은 실패로 보존하고 뒤의 command를 실행하지 않으며, 실행된 prefix의 claim만 기록한다. Partial output에도 기존 secret 탐지를 적용하고 인증정보 유출은 fail-closed한다. 자동 재실행은 하지 않으며 새로 승인된 실행에는 새 run-id를 사용한다.
@@ -34,6 +34,7 @@ Bootstrap 이후 code review는 canonical [Repository cache 운영 지침](../..
- Exact `owner/repository`, source Issue와 PR을 지정해 코드 리뷰 또는 재리뷰를 요청받았을 때 사용한다. - Exact `owner/repository`, source Issue와 PR을 지정해 코드 리뷰 또는 재리뷰를 요청받았을 때 사용한다.
- 작업마다 `read_file`로 [Gitea access](../../../../operations/gitea-access.md)와 [Repository cache](../../../../operations/repository-cache.md)를 읽는다. 인증·경로·권한·잠금은 이 공통 지침을 따른다. - 작업마다 `read_file`로 [Gitea access](../../../../operations/gitea-access.md)와 [Repository cache](../../../../operations/repository-cache.md)를 읽는다. 인증·경로·권한·잠금은 이 공통 지침을 따른다.
- 시작 시 아래 조건부 observability 정책 확인과 종료 기록 절차로 현재 role에 적용되는 정책 여부를 확인한다.
- 현재 Issue의 승인된 Discovery가 계약 정본이다. 필요한 문서 규칙만 harness의 `docs/`에서 읽는다. - 현재 Issue의 승인된 Discovery가 계약 정본이다. 필요한 문서 규칙만 harness의 `docs/`에서 읽는다.
- 구현, Discovery 수정, merge와 release에는 사용하지 않는다. - 구현, Discovery 수정, merge와 release에는 사용하지 않는다.
@@ -89,6 +90,34 @@ Cleanup이 미확인이라는 이유로 완료한 검토·전체 테스트·Nati
안전한 대여가 불가능하면 CI·기존 근거를 사용하고 직접 실행 불가 사유를 보고한다. 필수 검증 근거가 부족하면 승인하지 않는다. 독립성은 reviewer의 판단과 직접 확인에 있으며 별도 checkout 자체가 필수는 아니다. 안전한 대여가 불가능하면 CI·기존 근거를 사용하고 직접 실행 불가 사유를 보고한다. 필수 검증 근거가 부족하면 승인하지 않는다. 독립성은 reviewer의 판단과 직접 확인에 있으며 별도 checkout 자체가 필수는 아니다.
### 장시간 suite의 timeout evidence recipe
이미 승인된 reviewer 직접 실행 중 장시간 suite가 필요한 경우에만 다음 recipe를 기존 bounded execution·artifact 계약에 연결한다. 새 runner·streaming framework·자동 retry·schema를 추가하지 않으며 pytest를 강제하지 않는다.
- 실행 시작부터 승인된 artifact 경로에 해당 run-id·source revision·명령·실행 환경·유한한 deadline과 연결된 로그를 보존한다. 기존 승인된 capture/file-output 수단을 사용하고 redaction·권한·출력 한도·deadline을 유지한다. 단순 stdout 파일 redirect만으로 이 보호가 구현됐다고 주장하지 않는다. 안전한 보존을 확인할 수 없으면 실행을 시작하지 않고 evidence_gap으로 보고한다. 기존 source·plan·output binding과 no-clobber 계약을 유지한다.
- 결과에는 exit status, timeout·출력 잘림, 관측 시간, 실제 종료·reap·process-group cleanup과 미확인 상태를 구분한다. Tool timeout이나 로그 마지막 행은 process 종료의 증거가 아니다. Running/unknown이면 suite를 자동 재실행하지 않는다. 기존 bounded termination·cleanup 절차를 적용하고 필수 검증 미완료는 미검증/partial로 남긴다. 중간 PASS 출력이나 일부 JUnit을 전체 suite 성공으로 승격하지 않는다.
- Timeout 로그·실패 결과를 덮어쓰지 않는다. 필요한 재실행은 이전 실행과 관련 작업의 실제 종료·cleanup을 확인하고 기존 승인 범위 및 lease·head·source 검사를 다시 통과한 뒤 새 run-id로 수행한다. 종료·cleanup을 확인할 수 없으면 재실행하지 않는다. 무조건적인 전체 재실행이나 자동 retry 권한을 추가하지 않는다.
- 다음 유한한 budget은 동일 입력 또는 비교 가능한 source·환경·fixture의 관측 시간과 명시한 여유 근거로 설정한다. Timeout 시점은 완료 소요시간이 아니라 하한임을 기록하고, 필요하면 기존 승인 범위의 bounded 진단 근거를 사용한다. 비교가 불가능하면 불확실성을 명시하고 임의의 시간 증가를 측정된 개선이라고 보고하지 않는다. Budget 증가로 원래 timeout 기록을 삭제하거나 pass/fail을 바꾸지 않는다.
- JUnit은 test runner가 이미 지원하고 이번 검증에 필요한 경우에만 run별 승인된 artifact 경로로 보존한다. Timeout 중 완성된 JUnit이 생성된다고 가정하지 않는다. 누락·malformed·truncation은 증거 한계로 기록하며 JUnit 유무가 실제 exit·종료·전체 collection 검증을 대신하지 않는다. 제품 설정·plugin·dependency를 바꾸거나 JUnit을 필수 공통 format으로 강제하지 않는다.
이 문서 계약은 실행 계획과 보고의 지침이며 실제 streaming·JUnit 생성·process cleanup 또는 agent 준수를 검증하지 않는다. 원래 후보의 범용 실행 자동화·suite별 streaming/JUnit 구현 전체가 완료됐다고 주장하지 않고 부분 완료로 유지한다.
### Dependency provenance와 probe JSON framing
기존 승인 환경을 사용할 수 없고 별도로 승인된 환경 준비가 필요한 경우, 실제 interpreter 경로·버전, 검증에 관련된 실제 resolved dependency 버전, source revision과 dependency/lockfile·설정 출처를 기존 evidence의 환경·command·확인 한계 기록에 결합한다. 실제 설치 상태를 조회한 값과 요구사항의 버전 범위를 구분하고, 실패한 resolution·collection의 command·exit code·안전한 bounded output을 보존한다. Baseline API/dependency 호환성은 기존 승인 환경·검증된 기록 또는 승인된 read-only 근거로 비교하며 제품 회귀, upstream/baseline drift와 reviewer 환경 오류를 구분한다. 비교할 수 없으면 확인 한계를 보고하고 필수 검증 부족은 기존 미검증/blocked 기준을 적용한다. 이 지침은 새 venv·checkout 생성이나 임의 설치·pinning 권한을 부여하지 않으며 source·requirements·lockfile을 바꾸거나 새 dependency manager·claim schema를 만들지 않는다.
JSON probe는 실행 전에 producer와 consumer가 고유 marker와 record 경계를 합의한다. 예를 들어 합의한 marker prefix 뒤에 single-line JSON을 쓰는 경우, consumer는 정확한 prefix를 가진 행이 하나뿐인지 확인하고 그 행의 전체 payload를 strict JSON decode한다. 정확히 하나의 완전한 JSON record만 채택하고 누락·중복·malformed·truncation은 차단한다. 전체 stdout이 순수 JSON이라고 가정하거나 임의의 brace 구간·마지막 JSON 비슷한 행을 골라 복구하지 않는다. 합의된 framing이 없는 기존 출력은 자동 추정으로 성공 처리하지 않는다.
Marker 밖의 warning과 exit status, timeout·출력 한도·termination 상태는 안전한 bounded context로 별도 보존·검토한다. 유효 JSON 한 개만으로 실패 exit 또는 미완료 실행을 성공으로 바꾸지 않는다. Parser/framing·환경 오류는 제품 assertion 실패나 TDD RED로 보고하지 않고 필수 근거 부족은 기존 판정 기준으로 처리한다. 모든 capture/artifact에는 기존 credential redaction·유출 fail-closed 규칙을 적용하며 marker가 인증·provenance·secret 안전성을 보장한다고 주장하지 않는다. 새 generic parser나 manifest schema는 추가하지 않는다.
### Programmatic read control response와 script 준비
Programmatic reader는 첫 content-bearing 응답의 실제 내용을 exact 경로, source revision(적용 가능한 경우) 또는 독립 확인한 byte hash, 읽은 범위·완전성과 함께 보존한다. `content` 접근 전에 응답 shape와 content 반환 여부·타입을 확인한다. 본문 없는 `unchanged/dedup` 응답은 control response이지 빈 파일이나 새 source 내용이 아니다. 현재 입력의 identity·revision/hash·필요 범위와 보존된 내용의 binding이 확인된 경우에만 재사용하고, tool의 unchanged 표시만으로 다른 process의 cache나 미보존 내용을 추정하지 않는다. Paginated 일부 내용은 전체 파일 근거로 승격하지 않는다.
보존된 내용이 없거나 binding이 불명확하면 현재 허용된 bounded read-only 조회로 exact 입력을 다시 확인한다. 조회 수단·범위·출처를 검증할 수 없으면 `evidence_gap`으로 중단하며 missing content를 빈 문자열이나 fabricated 내용으로 대체하지 않는다. 지원하지 않는 force-read parameter를 만들거나 전역 dedup을 해제하지 않고 stale cache를 재사용하지 않는다. 이 규칙은 새로운 read wrapper·cache subsystem을 요구하지 않는다.
Script 준비에는 검증된 source 파일과 typed JSON 입력을 우선하고 다중 quoted Python command로 내용을 재조립하지 않는다. 필요한 Python을 준비한 경우 그 exact bytes를 실행·로그 append·외부 action 전에 side-effect 없는 compile로 확인한다. Compile은 문법 검사일 뿐 안전성·인증·실행 성공의 증거가 아니므로 기존 scope·source·deadline·redaction·실행 provenance 검증은 별도로 유지한다. 문법 오류는 준비 실패로 보존하고 제품 assertion 실패나 TDD RED로 분류하지 않는다. Credential 값은 source·입력 artifact·오류·log에 저장하지 않는다. 이 절차는 review role의 commit/push·credential·새 transport 권한을 확장하지 않는다. 기존 reviewer의 source 수정·commit/push 금지는 유지하며, 검증 통과 자체가 외부 write 승인을 의미하지 않는다.
## 판정과 기록 ## 판정과 기록
- `approve`: 전체 diff, 관련 코드·테스트와 필요한 실행 근거를 검토했고 미해결 중요 결함이나 필수 검증 공백이 없다. 승인 전 필수로 분류한 matrix 행은 모두 통과하거나 근거 있는 비적용이어야 한다. 필수 행에 실패·미검증 또는 설명 없는 비적용이 남으면 승인하지 않는다. 전체 파일 열람이나 전체 테스트 통과만으로 이 조건을 충족했다고 간주하지 않는다. 선택적 검증의 미완료는 한계로 보고하며 자동으로 변경 요청하지 않는다. - `approve`: 전체 diff, 관련 코드·테스트와 필요한 실행 근거를 검토했고 미해결 중요 결함이나 필수 검증 공백이 없다. 승인 전 필수로 분류한 matrix 행은 모두 통과하거나 근거 있는 비적용이어야 한다. 필수 행에 실패·미검증 또는 설명 없는 비적용이 남으면 승인하지 않는다. 전체 파일 열람이나 전체 테스트 통과만으로 이 조건을 충족했다고 간주하지 않는다. 선택적 검증의 미완료는 한계로 보고하며 자동으로 변경 요청하지 않는다.
@@ -113,11 +142,29 @@ Finding마다 `F-001` 같은 ID, 파일·행, 발생 조건, 영향·심각도,
재리뷰에서 새 차단 finding을 추가할 때는 계약·변경 경로와의 관계 및 이번 승인 전에 해결해야 하는 이유를 입증한다. 발견 시점이나 severity만으로 차단하지 않는다. 재리뷰에서 새 차단 finding을 추가할 때는 계약·변경 경로와의 관계 및 이번 승인 전에 해결해야 하는 이유를 입증한다. 발견 시점이나 severity만으로 차단하지 않는다.
### 재리뷰 수렴과 에스컬레이션
재리뷰는 이전 finding의 수정 matrix, source delta와 영향 경로를 중심으로 진행한다. 미변경 evidence는 이전 source와 현재 head와 실행 입력(코드·설정·fixture·dependency·실행 도구)의 관계를 확인한 유효 범위에서만 재사용하고, 과거 실행을 현재 head의 새 실행으로 표현하지 않는다. Delta 밖의 실제 승인 계약 위반도 무시하지 않으며 새 blocker에는 위 late-finding 근거 규칙을 적용한다.
Main reviewer는 이전 review/finding과 현재 exact head를 대조해, 미변경 코드의 첫 리뷰 누락 blocker가 재검토 회차에서 재차 반복되는지 확인한다. 이는 단순히 같은 finding이 아직 미해결인 경우나 새 변경이 만든 결함과 구분한다. 반복이 확인되면 자동 rework handoff를 중단하고 소유자에게 exact head·계약, 관련 review/finding ID와 영향 경로, 남은 findings·확인 한계 및 최소 closure plan을 묶어 보고한다. 근거가 확인 불가이면 반복이 입증됐다고 단정하지 말고 수렴 판단의 불확실성을 보고하며 자동 handoff 재개 결정을 소유자에게 요청한다.
Closure plan은 필요한 수정·검증 범위와 종료 조건을 명시하고, 소유자의 명시적 조정·승인 전에는 자동 handoff를 재개하지 않는다. 이는 임의의 N회 후 자동 승인 규칙이 아니며 blocker를 면제하거나 finding을 삭제·dismiss하는 조건도 아니다. 기존 verdict와 현재 head의 spec·code pass 조건을 유지하고, 안전한 검토가 불가능하면 기존 blocked/partial 규칙으로 보고한다. 소유자의 조정은 승인 계약의 묵시적 축소나 추가 POST 권한을 의미하지 않는다. 기존 journal, lease 및 cleanup·불확실한 publication의 readback 계약을 유지한다. 새 counter·상태 저장·자동 반복 탐지기·publisher 변경은 추가하지 않는다.
## 조건부 observability 정책 확인과 종료 기록
시작 시 현재 review role의 실제 설정과 그 설정이 참조하는 정책의 적용 범위를 확인한다. Credential이나 설정 전체 내용을 출력·복사하지 않고 필요한 정책 참조·적용 조건만 읽는다. 다른 profile의 설정·정책을 가져오거나 spec 정책을 review 정책으로 대신 적용하지 않는다. 과거 후보의 policy 경로·다른 환경의 경로를 추정하지 않고, 누락된 정책 파일을 임의 생성하거나 전역 설정을 바꾸지 않는다.
- 정책 참조가 없음을 실제 설정에서 확인하고 이번 작업이 명시적으로 현재 role에 비적용임을 확인한 경우에만 `not_applicable`과 확인 근거를 남긴다. 참조된 정책이 누락되었거나 설정·정책을 읽을 수 없거나 적용 여부가 모호하면 비적용으로 처리하지 않는다. 이는 정책 확인 실패이며 그 원인·확인 한계를 보고한다. 명시적으로 확인된 비적용은 허용되지만, missing/unreadable/ambiguous configuration or reference는 fail-closed로 처리한다. 정책이 적용되면 해당 적용 정책이 요구하는 정확한 기록 경로·필드·시점·redaction·append·lock·readback·최종 보고 조건만 따른다. 이 지침 자체가 새 logger, 공통 schema 또는 고정 policy 경로를 정의하지 않는다.
- 최종 보고 전에 적용 정책의 요구 항목을 대조하고 정책이 요구하는 보고 항목에 한정한다. run 기록·run-id, 개선 후보 수·목록, 일일 요약과 같은 항목은 적용 정책이 요구할 때만 누락 없이 기록·검증한다. 새로 append한 기록의 적용 skill·상태·evidence 참조와 요청된 run-id·후보 수를 정책이 정한 readback으로 확인한다. 후보 수를 요구하지 않으면 후보 수를 보고 항목으로 만들지 않으며, 요구된 후보 수가 실제로 0임을 검증한 경우에만 0건을 명시하고 정책 부재나 확인 불가 때문에 임의로 0건을 쓰지 않는다. Append·lock·redaction 요구가 있으면 기존 기록을 truncate하거나 credential·secret 값을 저장하지 않는다. Source skill 수정과 정책 로그 작성은 별개 절차이며 기록을 위해 source skill을 추가 수정하지 않는다.
- 기존 main 결과 artifact에 정책 확인 근거·적용 상태와 기록 완료/실패/미확인/비적용을 남기고 review verdict, publication, cleanup 상태와 분리한다. 실패·읽기 불가·부분 기록을 완료로 표현하지 않으며 source·문제 없음 판정이나 실제 native 제출 상태를 로그 성공 여부로 바꾸지 않는다. 기록 오류 때문에 테스트나 native POST를 반복하지 않는다. 적용 정책이 추가 중단 조건을 명시하면 그 조건은 따르되 새 제품 finding이나 게시 권한을 만들어내지 않는다.
- 최종 보고에는 적용 정책이 요구한 항목에 한정해 policy·skill·기록 경로·기록·readback 여부·요청된 후보 수와 실패·한계 또는 비적용 이유를 redacted 형태로 명시한다. 적용 정책이 요구하지 않은 후보 수나 세부 필드는 추정해 추가하지 않으며, 최소한의 정책 확인 상태·적용 상태·오류 보고만 허용한다. 자동 기록이 이미 있다면 이번 run과 출처·내용이 결합됐는지 확인하여 정책이 허용한 범위에서 재사용하고 중복 append하지 않는다. 확인되지 않은 자동 기록 성공은 추정하지 않는다. 이 문서 계약 테스트는 지침의 존재만 검증하며 실제 append·정책 탐색·runtime agent 준수를 입증하지 않는다.
## 주의 및 완료 보고 ## 주의 및 완료 보고
- Source·test·Discovery 수정, commit·push·merge, Issue 변경, review dismiss, release·deployment를 하지 않는다. - Source·test·Discovery 수정, commit·push·merge, Issue 변경, review dismiss, release·deployment를 하지 않는다.
- PR의 성공 주장, delegate 요약, process exit 또는 native state만으로 완료를 주장하지 않는다. HTTP/framework test를 실제 browser 검증으로 표현하지 않는다. - PR의 성공 주장, delegate 요약, process exit 또는 native state만으로 완료를 주장하지 않는다. HTTP/framework test를 실제 browser 검증으로 표현하지 않는다.
- 보고에는 exact target·head·contract·spec review ID, 검토 범위, findings·verdict, 실제 실행과 재사용 근거, 미검증 범위, native review ID/state/readback 또는 미게시 이유, cleanup·partial 상태를 포함한다. - 보고에는 exact target·head·contract·spec review ID, 검토 범위, findings·verdict, 실제 실행과 재사용 근거, 미검증 범위, native review ID/state/readback 또는 미게시 이유, cleanup·partial 상태를 포함한다.
- 최종 보고에는 위 조건부 observability 절차의 정책 확인 상태·적용 여부·기록 상태·오류 또는 비적용 이유를 항상 보고하고, 세부 항목은 적용 정책이 요구한 경우에만 기록 경로·개선 후보 수 및 기타 상세 필드를 보고한다.
- clean review는 검토 범위에서 중요 결함을 발견하지 못했다는 의미이며 무결함의 증명은 아니다. - clean review는 검토 범위에서 중요 결함을 발견하지 못했다는 의미이며 무결함의 증명은 아니다.
@@ -50,6 +50,12 @@ The request callback is `authenticated_request(relative_api_path, payload=None)
`ApiAdapter.snapshot(target)` collects repository/Issue/PR metadata, identity/spec identity, all changed files, commits, reviews, historical review comments, Issue/PR comments and head statuses. Use this same collector for the baseline and subsequent snapshots. Every collection records contiguous 50-item pages, declared totals and Link headers; duplicates, inconsistent totals and truncated enumeration fail closed. A saved older collector snapshot may be replayed if it provides these complete facts, but differently shaped collectors must not be mixed for live source-drift comparison. `ApiAdapter.snapshot(target)` collects repository/Issue/PR metadata, identity/spec identity, all changed files, commits, reviews, historical review comments, Issue/PR comments and head statuses. Use this same collector for the baseline and subsequent snapshots. Every collection records contiguous 50-item pages, declared totals and Link headers; duplicates, inconsistent totals and truncated enumeration fail closed. A saved older collector snapshot may be replayed if it provides these complete facts, but differently shaped collectors must not be mixed for live source-drift comparison.
### Empty review-comments readback without a count header
For `GET /repos/{owner}/{repository}/pulls/{pull}/reviews/{review_id}/comments`, a successful direct JSON `[]` response without `X-Total-Count` is valid evidence of zero comments only when the exact review has integer `comments_count=0` and the complete collection agrees. Preserve the actual pagination evidence as `declared_total=None`; never fabricate a zero header or infer an empty response from an unavailable/malformed response or a missing/null count. Use `ApiAdapter.snapshot` for baseline and subsequent collections and `verify_publication` with the exact review readback.
This is not a general pagination bypass: preserve contiguous pages, identity uniqueness, observed totals and Link headers. A `rel=next` continuation must be followed to completion; a final page still advertising next, an unresolved full 50-item page without a total, conflicting totals, count mismatch or unexpected inline comments blocks verification. Endpoints supplying `X-Total-Count` retain total/cardinality checks. A readback failure is not authorization for POST blind retry: retain the same journal and reconcile the uncertain attempt through authoritative reads.
### Broker response preflight before publication ### Broker response preflight before publication
Use the [operation-specific broker response contract](../../../../../operations/repository-cache.md#operation-specific-broker-responses). `renew` supplies only `status`/`deadline`; read `repository_held` and identity from a separate `status` request. Python uses `ttl=`, not the wire/CLI name `ttl_seconds`. Keep the admitted handle and the retained canonical `harness/lib/managed_lock_broker.py` module as `broker`; do not read its token or load another profile's helper. Use the [operation-specific broker response contract](../../../../../operations/repository-cache.md#operation-specific-broker-responses). `renew` supplies only `status`/`deadline`; read `repository_held` and identity from a separate `status` request. Python uses `ttl=`, not the wire/CLI name `ttl_seconds`. Keep the admitted handle and the retained canonical `harness/lib/managed_lock_broker.py` module as `broker`; do not read its token or load another profile's helper.
@@ -137,6 +143,33 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/impl -v
Run the test commands from the canonical harness repository root. The existing spec suite requires `/opt/data/cache/scratch`: prepare that directory only if absent after confirming a nonsymlink, role-owned, non-group/world-writable parent, and require mode 0700 for the scratch child. Preserve existing shared parent permissions; do not chmod/chown them to satisfy a test. Use isolated/local runner variables and properly quoted command arguments rather than reusing mutable interpreter globals across review steps. Keep local stub/loopback transport checks, real temporary Git/worktree/lease checks, and saved production-wire replay clearly distinguished from a live Gitea mutation test. Introducing this helper does not authorize changing any existing product PR verdict, source, Issue state, merge or deployment. Run the test commands from the canonical harness repository root. The existing spec suite requires `/opt/data/cache/scratch`: prepare that directory only if absent after confirming a nonsymlink, role-owned, non-group/world-writable parent, and require mode 0700 for the scratch child. Preserve existing shared parent permissions; do not chmod/chown them to satisfy a test. Use isolated/local runner variables and properly quoted command arguments rather than reusing mutable interpreter globals across review steps. Keep local stub/loopback transport checks, real temporary Git/worktree/lease checks, and saved production-wire replay clearly distinguished from a live Gitea mutation test. Introducing this helper does not authorize changing any existing product PR verdict, source, Issue state, merge or deployment.
## Reviewer verification evidence recipe
This is the existing common evidence helper, not the `native_review.py preflight/verify` publisher replay interface. Use the canonical helper at `$HARNESS_ROOT/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py`; do not create a reviewer wrapper or copy it into another profile. The historical v2 recipe gap is addressed using the current `evidence-manifest/v3` contract.
First complete authenticated cache/worktree admission and retain the current review-role Issue broker lease. Prepare a fresh stage/run directory using the existing `verification_root.py prepare` procedure in the [canonical repository-cache contract](../../../../../operations/repository-cache.md). That procedure authenticates the live handle and Issue identity; the evidence helper does not acquire or certify a lease. Derive these absolute paths from the admitted harness/Issue identity, not from an arbitrary output directory:
- `HARNESS_ROOT`: the admitted harness root containing `harness/operations/repository-cache.md`.
- `REPO_ROOT`: `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/checkout`.
- `VERIFICATION_RUN_ROOT`: `$HARNESS_ROOT/repositories/$OWNER/$REPOSITORY/worktrees/issue-$ISSUE/verification/$RUN_ID`.
Require a **clean checkout**, **no source/evidence exclusions**, and prepared owner-only `0700` verification/run directories. Place the reviewed command plan at `$VERIFICATION_RUN_ROOT/evidence-plan.json` with mode `0600` before running; adapt the existing [evidence-plan template](../../../../impl/skills/gitea-issue-rework/templates/evidence-plan.json) to the actual reviewer commands, finite `timeout_seconds`, output bounds, source `hash_paths`, claims and limitations. `hash_paths` are checkout-relative, while plan/output/manifest CLI arguments are **run-relative**. Do not put artifacts in the checkout, weaken permissions, change the clean-tree check, or normalize rejected absolute artifact filenames. All roots/contract paths below are absolute; the three artifact filenames are not:
```sh
python3 -B "$HARNESS_ROOT/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py" run \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$REPO_ROOT" --artifact-root "$VERIFICATION_RUN_ROOT" \
--plan evidence-plan.json --output evidence-manifest.json
python3 -B "$HARNESS_ROOT/harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py" validate \
--contract-path "$HARNESS_ROOT/harness/operations/repository-cache.md" \
--repo-root "$REPO_ROOT" --artifact-root "$VERIFICATION_RUN_ROOT" \
--manifest evidence-manifest.json
```
Run `validate` only after `run` succeeds, using the same roots and exact source HEAD. A nonzero result, timeout or overflow blocks adoption; retain bounded failure evidence and do not automatically rerun the suite. Existing manifests are not overwritten. Local validation binds the current source, plan and actual command records; it is not live Gitea readback, reviewer approval, Native publication verification or cleanup completion. Preserve the fresh stage/run ID, source SHA, plan/manifest digests, actual results/limitations and verified reviewer-access method under the existing reporting/cleanup contract.
Archived v2 uses only `validate-archived-v2` read-only compatibility; archived v1 uses only `validate-legacy`. Neither upgrades old records to v3 deadline/cleanup guarantees nor creates fresh publication evidence. Do not migrate archived manifests, add exclusions or change schemas to make the example pass.
## Canonical native-review journal ## Canonical native-review journal
Native code-review publication의 정본 journal root는 `<harness-root>/harness/.runtime/native-review-journals`이다. Canonical publisher의 물리적인 harness 위치에서 결정하며 caller의 run-id, 임의 journal 경로, 환경 변수 변경으로 다른 registry를 선택하지 않는다. `canonical_journal_path(plan)`은 검증된 plan의 표시용 경로 계산일 뿐 인증·게시 권한의 증명이 아니다. 실제 registry claim 전에 현재 role identity와 held Issue lease 및 source/delegation gate를 검증한다. Native code-review publication의 정본 journal root는 `<harness-root>/harness/.runtime/native-review-journals`이다. Canonical publisher의 물리적인 harness 위치에서 결정하며 caller의 run-id, 임의 journal 경로, 환경 변수 변경으로 다른 registry를 선택하지 않는다. `canonical_journal_path(plan)`은 검증된 plan의 표시용 경로 계산일 뿐 인증·게시 권한의 증명이 아니다. 실제 registry claim 전에 현재 role identity와 held Issue lease 및 source/delegation gate를 검증한다.
@@ -109,3 +109,6 @@ comment는 다음 형식을 사용한다. `<original-body>`에는 write 직전 G
- dry-run/local 검증과 실제 Gitea write 결과 - dry-run/local 검증과 실제 Gitea write 결과
프로세스 exit나 API write 응답만으로 성공을 주장하지 않는다. Authoritative readback이 하나라도 실패하면 partial 또는 failure로 보고한다. 프로세스 exit나 API write 응답만으로 성공을 주장하지 않는다. Authoritative readback이 하나라도 실패하면 partial 또는 failure로 보고한다.
Issue body 교체에는 공통 access 지침의 **Issue body update의 provider-derived readback**을 적용한다. 13번의 post-comment snapshot을 body update 직전 baseline으로 사용한다. Comment 작성의 정상 파생 변경은 해당 comment operation의 확인된 계약으로 별도 대조하며 body update의 revision 증가 규칙을 comment write에 적용하지 않는다. `content_version`의 노출·matching provider contract 근거를 기록하고 15번의 다른 snapshot 필드 비교는 보호 필드 보존과 확인된 operation별 provider-derived 변경 검증으로 구분한다. Write 전 drift 검사는 유지하며 불확실한 결과를 완료로 추정하거나 자동 재write하지 않는다.
@@ -66,6 +66,15 @@ Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](
일부 prototype 실패만으로 사용자 요구·URL·dependency 정책 변경을 필수 선택으로 제안하지 않는다. 실험 기록과 기술 delegate 결과에는 대상 요구사항, 시험한 후보·버전·환경, 성공·실패 관찰, 시험하지 않은 대안·확인 한계와 결론 적용 범위를 포함한다. Main은 delegate의 불가능 결론을 원본 실험·공식 소스에 직접 대조한 뒤 채택하며, delegate summary만으로 결정하지 않는다. 일부 prototype 실패만으로 사용자 요구·URL·dependency 정책 변경을 필수 선택으로 제안하지 않는다. 실험 기록과 기술 delegate 결과에는 대상 요구사항, 시험한 후보·버전·환경, 성공·실패 관찰, 시험하지 않은 대안·확인 한계와 결론 적용 범위를 포함한다. Main은 delegate의 불가능 결론을 원본 실험·공식 소스에 직접 대조한 뒤 채택하며, delegate summary만으로 결정하지 않는다.
### 임시·변경된 Discovery checker 준비
검증용 임시·변경된 checker를 준비하는 경우에만 다음 대조를 외부 write 없이 수행한다. 이미 검증된 정본 checker에 중복 검사나 새 runtime gate를 추가하는 요구가 아니다. 기존 사용자 선택·OQ cardinality·승인된 결정·입력 gate와 exact readback 계약은 그대로 유지한다.
- Heading·section 검사는 LF/CRLF의 논리적 행을 구분 없이 대조한다. `splitlines()` 등으로 비교용 view를 만들되 원본 bytes, revised payload와 hash 입력은 따로 보존한다. Text reader의 자동 newline 변환이나 비교용 view의 재직렬화로 payload를 덮어쓰지 않는다. 빈 행·heading 문자열·순서·중복·누락을 임의로 보정하지 않는다. Regex가 필요하면 `flags=`를 명시하며 `re.split`의 positional maxsplit과 flags를 혼동하지 않는다. LF/CRLF 동등성은 줄끝 표현에 한정하고 본문 의미·marker·공백을 일괄 정규화하지 않는다.
- Stale prose 검사는 승인된 변경의 exact section·paragraph, OQ ID와 정확 문구 및 기대 replacement를 함께 대조한다. 새 replacement가 기존 도입 prefix를 의도적으로 포함하거나 승인된 재선택 문구를 남겼다는 이유만으로 substring 존재를 stale로 단정하지 않는다. 해당 위치의 exact replacement와 승인 근거를 확인한 경우에만 그 의도적 보존을 인정하고 다른 위치의 실제 잔여 문구·다른 OQ를 일괄 면제하지 않는다. Global substring 제거·전역 prefix 무시로 검사를 약화하지 않는다. 근거·위치·선택 의미가 모호하면 정상이나 stale로 추정하지 않고 해당 검증을 미확인/partial로 남기며 게시하지 않는다.
- 임시 checker의 LF/CRLF 정상 대조와 malformed 대조, 의도적 prefix 보존과 다른 위치의 실제 잔여 문구, self-containing replacement의 로컬 replay를 준비한다. 승인된 동일 입력·pinned source에서 재적용 결과가 exact payload와 같은지 확인하고 반복 전파·중복 replacement이면 준비 실패로 차단한다. 이 로컬 대조는 기존 authoritative readback 후 replay를 대체하지 않는다. User 결정 전파의 의미 적합성은 main이 별도로 판단하며 checker가 OQ를 자동 선택·삭제하거나 새로운 결정 정책을 만들지 않는다.
- 문서 계약과 작은 synthetic primitive 대조는 checker 작성 시의 실패 조건을 설명하는 근거일 뿐 실제 checker 전체·agent 실행·live Issue 수정의 검증이 아니다. 공통 semantic validator·정규화 엔진·publisher·schema를 추가하지 않는다. 원래 공유 deterministic validator 제안 전체는 이 보완만으로 종료하지 않고 부분 완료로 유지한다.
## 절차 ## 절차
1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 단일 안전 경로 요소인지 검증한다. `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다. 1. 요청에서 exact `owner`, `repository`, Issue 번호를 추출하고 단일 안전 경로 요소인지 검증한다. `operations/repository-cache.md`를 새로 읽고 canonical cache 경로를 계산·검증한다.
@@ -78,7 +87,7 @@ Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](
8. 범위 분류·blocking 판단과 기술적 결론의 경계를 먼저 적용하고 각 OQ의 선택 수를 판정한다. 선택된 결정이 특정 API·dependency·runtime·배포 기능에 의존하면 pinned 저장소와 적용 버전의 공식 문서·소스를 조사해 지원 여부와 구현 경로를 확인한다. 조사만으로 판단할 수 없고 실행 실험이 필요하면 기술적 전제 검증을 미완료로 남긴다. 선택된 결정을 관련 모든 섹션에 전파하고, 새 모순·누락으로 새 사용자 결정이 필요할 때만 OQ 검토 규칙에 따라 새 OQ를 추가한다. 8. 범위 분류·blocking 판단과 기술적 결론의 경계를 먼저 적용하고 각 OQ의 선택 수를 판정한다. 선택된 결정이 특정 API·dependency·runtime·배포 기능에 의존하면 pinned 저장소와 적용 버전의 공식 문서·소스를 조사해 지원 여부와 구현 경로를 확인한다. 조사만으로 판단할 수 없고 실행 실험이 필요하면 기술적 전제 검증을 미완료로 남긴다. 선택된 결정을 관련 모든 섹션에 전파하고, 새 모순·누락으로 새 사용자 결정이 필요할 때만 OQ 검토 규칙에 따라 새 OQ를 추가한다.
9. `문서 영향 및 산출물`을 README 정책에 따라 전부 다시 판정한다. 요구사항명세서의 exact ID·경로·제목·포함할 내용·제외할 내용과 다른 문서의 구체적 변경 계약을 현재 결정과 pinned SHA에 맞춘다. 9. `문서 영향 및 산출물`을 README 정책에 따라 전부 다시 판정한다. 요구사항명세서의 exact ID·경로·제목·포함할 내용·제외할 내용과 다른 문서의 구체적 변경 계약을 현재 결정과 pinned SHA에 맞춘다.
10. 조사 종료 시 Gitea default-branch SHA와 local ref가 pinned SHA와 여전히 일치하는지 확인하고 `release_repository` request를 보낸다. Issue lease는 유지한다. 10. 조사 종료 시 Gitea default-branch SHA와 local ref가 pinned SHA와 여전히 일치하는지 확인하고 `release_repository` request를 보낸다. Issue lease는 유지한다.
11. trusted workflow directory에 revised body artifact를 만들고 marker, H2 순서, placeholder 부재, OQ ID·checkbox, 결정 전파, 문서 영향, repository evidence와 credential 부재를 검증한다. 11. trusted workflow directory에 revised body artifact를 만들고 임시·변경된 checker를 준비하면 위 임시·변경된 Discovery checker 준비 절차를 적용한 뒤 marker, H2 순서, placeholder 부재, OQ ID·checkbox, 결정 전파, 문서 영향, repository evidence와 credential 부재를 검증한다.
12. revised body가 current body와 exact-match하면 comment와 body write 없이 no-op으로 종료한다. 12. revised body가 current body와 exact-match하면 comment와 body write 없이 no-op으로 종료한다.
13. write 직전에 Issue와 comments를 다시 읽는다. snapshot의 body·`updated_at` 또는 comment baseline이 drift했으면 외부 write 없이 중단한다. 13. write 직전에 Issue와 comments를 다시 읽는다. snapshot의 body·`updated_at` 또는 comment baseline이 drift했으면 외부 write 없이 중단한다.
14. snapshot의 body·`updated_at`·comment baseline과 비본문 필드가 불변일 때 revised body artifact의 exact bytes로 Issue body 하나만 교체한다. title, state, labels, assignees, milestone은 write payload에 포함하지 않는다. 14. snapshot의 body·`updated_at`·comment baseline과 비본문 필드가 불변일 때 revised body artifact의 exact bytes로 Issue body 하나만 교체한다. title, state, labels, assignees, milestone은 write payload에 포함하지 않는다.
@@ -113,3 +122,6 @@ Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](
- partial 또는 fail-closed 상태 - partial 또는 fail-closed 상태
프로세스 exit나 API write 응답만으로 성공을 주장하지 않는다. Authoritative readback 또는 replay 검증이 하나라도 실패하면 completed로 보고하지 않는다. 프로세스 exit나 API write 응답만으로 성공을 주장하지 않는다. Authoritative readback 또는 replay 검증이 하나라도 실패하면 completed로 보고하지 않는다.
Issue body 교체의 snapshot과 readback에는 공통 access 지침의 **Issue body update의 provider-derived readback**을 적용한다. `content_version` 등 revision의 노출·matching provider contract 근거를 기록하고, write 직전 drift는 계속 차단한다. 15번의 비본문 필드 불변성은 보호 필드 보존과 확인된 operation별 provider-derived 변경 검증을 뜻하며 모든 snapshot 필드의 blanket equality 또는 blanket exclusion이 아니다. Readback이 불확실하거나 필수 검증이 실패하면 completed로 보고하거나 자동 재write하지 않는다.
@@ -109,6 +109,18 @@ reason: <bounded Korean explanation>
- 실패 경로, fail-closed, no-call·no-write boundary와 credential 비노출 조건은 positive-path test만으로 충족됐다고 판정하지 않는다. - 실패 경로, fail-closed, no-call·no-write boundary와 credential 비노출 조건은 positive-path test만으로 충족됐다고 판정하지 않는다.
- 현재 harness에 spec-review 전용 clean worktree 계약이 없으므로 구현 Issue worktree를 spec role로 재사용하거나 임시 clone·checkout을 만들지 않는다. Reviewer가 독립 실행하지 못했다는 사실은 확인 한계로 보고하고 구현자의 결함이나 자동 `indeterminate`로 전환하지 않는다. 승인 계약이 독립 실행을 명시적으로 요구하거나 위 추가 검증 조건으로 필수 인수 판단이 어려운 경우에만 해당 조건과 최소 보완 범위를 특정한다. - 현재 harness에 spec-review 전용 clean worktree 계약이 없으므로 구현 Issue worktree를 spec role로 재사용하거나 임시 clone·checkout을 만들지 않는다. Reviewer가 독립 실행하지 못했다는 사실은 확인 한계로 보고하고 구현자의 결함이나 자동 `indeterminate`로 전환하지 않는다. 승인 계약이 독립 실행을 명시적으로 요구하거나 위 추가 검증 조건으로 필수 인수 판단이 어려운 경우에만 해당 조건과 최소 보완 범위를 특정한다.
### Writer-family criterion closure checklist
기존 승인 계약의 monetary/current-cache 인수 조건이 여러 writer에 적용되는 경우에만 이 reviewer checklist를 사용한다. exact reviewed head에서 실제 존재하고 해당 계약에 적용되는 create/update/import 및 관련 writer 경로를 식별하고, 없는 API나 계약 밖의 writer를 새 요구사항으로 만들지 않는다. 한 경로의 수정·정상 test만으로 family 전체 criterion을 해소하지 않는다.
각 적용 경로를 기존 criterion의 `evidence`·`reason`에 다음 근거와 대응한다. 새 schema나 별도 제품 문서는 필요하지 않다.
- 승인 계약의 exact 근거와 현재 구현 위치: 입력 정규화, unknown과 zero 구분 및 승인된 값의 해석이 해당 writer에 어떻게 적용되는지 확인한다. Blank/null/zero 등의 정책은 해당 승인 계약에서 가져오며 reviewer가 새 금액 정책을 정하지 않는다.
- mutation/no-write 경계: 승인된 정상·오류 처리의 실제 변경 위치와 부작용 경계, 관련 test의 호출·assertions 또는 검증된 실행 근거를 확인한다. 정적 확인과 실행 evidence는 기존 provenance 규칙대로 구분한다.
- Downstream 연결: 해당 writer가 만든 값이 승인 계약의 소비 경로·현재 cache에 도달하는 위치와 근거를 대응한다. 영향 없음이나 비적용은 계약·경로에 근거한 구체적인 이유로 기록하고, 확인하지 못한 경로를 비적용으로 숨기지 않는다.
Family closure는 적용 writer 모두의 기존 필수 인수 조건이 근거로 충족될 때만 선언한다. 잔여 finding에는 exact 승인 계약, 현재 source 위치와 downstream 관계, 필요한 최소 보완을 유지한다. 별도 실행 기록 부재나 checklist 표현 부재만으로 새 blocker를 만들지 않으며 기존 `satisfied|unsatisfied|indeterminate` 판정 규칙을 적용한다. 모든 입력 조합·Cartesian 검증이나 전체 suite 재실행을 요구하지 않는다. Spec reviewer의 제품 실행 금지와 기존 evidence 재사용 조건은 유지한다. 자동 runtime checker·result schema·publisher는 추가하지 않으며 별도의 legacy financial-authority 호환성 checklist까지 확장하지 않는다.
## Verdict 규칙 ## Verdict 규칙
### `approve` ### `approve`
@@ -240,14 +252,14 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다. 8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다.
9. `release_repository` request를 보내되 Issue lease는 유지한다. 9. `release_repository` request를 보내되 Issue lease는 유지한다.
10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다. 10. Acceptance matrix 작성 전에 구현자 실행 보고와 상세 실행 기록의 출처 및 확인 범위를 구분한다. Revision이 식별된 기존 evidence는 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 상세 기록이나 revision 정보의 부재만으로 새 검증 gate를 만들지 않는다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다. 11. 문서 검토 입력과 구조·의미 분리 절차로 trusted 계약·산출물별 의무표를 준비하고 모든 문서 의무를 대조한다. 정본 형식 요구사항의 AC 검증 분류는 아래 read-only 구조 검사로 보조 확인하고 비지원 형식·의미 판단은 main이 직접 대조한다. 위임 시 해당 입력 전체를 제공한다. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다. 12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다. 13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다. 14. 여기까지는 read-only preflight다. Exact review 요청이 native review write 권한을 부여하지 않았거나 preflight가 완전하지 않으면 종료하며 review create 또는 다른 mutation을 수행하지 않는다.
15. Write 직전에 Issue 전체 필드·comments, PR details·base·head·changed files와 전체 review baseline을 다시 읽는다. Contract snapshot, base target, head SHA, changed-file set 또는 baseline이 drift하면 native review를 쓰지 않는다. 15. Write 직전에 Issue 전체 필드·comments, PR details·base·head·changed files와 전체 review baseline을 다시 읽는다. Contract snapshot, base target, head SHA, changed-file set 또는 baseline이 drift하면 native review를 쓰지 않는다.
`commit.files`처럼 순서에 의미가 없는 collection은 filename 중복을 먼저 거부하고 filename·status 기준으로 정규화해 비교한다. 순서 차이만 제외하고 모든 항목·필드 및 exact blob/hunk는 보존·대조하며, 모든 배열을 일괄 set 처리하지 않는다. Criteria 순서와 의미 있는 event 순서는 유지한다. `commit.files`처럼 순서에 의미가 없는 collection은 filename 중복을 먼저 거부하고 filename·status 기준으로 정규화해 비교한다. 순서 차이만 제외하고 모든 항목·필드 및 exact blob/hunk는 보존·대조하며, 모든 배열을 일괄 set 처리하지 않는다. Criteria 순서와 의미 있는 event 순서는 유지한다.
16. Stable reviewer account ID, head, contract digest와 result digest가 모두 같은 canonical review가 정확히 하나 이미 있으면 exact review와 review comments를 authoritative하게 readback한다. Verdict-compatible 최종 state와 stale/dismissed 상태까지 검증된 경우에만 write 없는 no-op으로 종료한다. `PENDING`이면 완료나 새 create로 진행하지 않고 절차 19의 복구 조건을 적용한다. 같은 binding의 다른 결과 또는 중복 review가 있으면 fail-closed한다. 16. Stable reviewer account ID, head, contract digest와 result digest가 모두 같은 canonical review가 정확히 하나 이미 있으면 exact review와 review comments를 authoritative하게 readback한다. Verdict-compatible 최종 state와 stale/dismissed 상태까지 검증된 경우에만 write 없는 no-op으로 종료한다. `PENDING`이면 완료나 새 create로 진행하지 않고 절차 19의 복구 조건을 적용한다. 같은 binding의 다른 결과 또는 중복 review가 있으면 fail-closed한다.
17. 호출 전에 선택한 adapter의 실제 요청 schema와 endpoint별 성공 status를 검증하며 확인할 수 없으면 게시하지 않는다. Native review create를 `body=<canonical body>`, `comments=[]`, `commit_id=<exact head SHA>`와 명시적 verdict-compatible 요청 필드로 정확히 한 번 호출한다. MCP는 tool schema의 `state` 등 실제 인자를 사용하고, 직접 REST는 서버 schema의 `event=<APPROVED|REQUEST_CHANGES|COMMENT>`를 사용한다. MCP 인자를 REST payload에 복사하거나 모든 POST의 성공 status를 `201`로 가정하지 않는다. REST review endpoint의 `200` 등 확인된 성공 status만 인정하고 authoritative 응답의 `state`는 기존 verdict mapping과 exact-match해야 한다. 17. 호출 전에 선택한 adapter의 실제 요청 schema와 endpoint별 성공 status를 검증하며 확인할 수 없으면 게시하지 않는다. 비정본·변경된 readback helper를 사용하면 아래 비정본·변경된 readback helper 호출 계약을 먼저 적용한다. Native review create를 `body=<canonical body>`, `comments=[]`, `commit_id=<exact head SHA>`와 명시적 verdict-compatible 요청 필드로 정확히 한 번 호출한다. MCP는 tool schema의 `state` 등 실제 인자를 사용하고, 직접 REST는 서버 schema의 `event=<APPROVED|REQUEST_CHANGES|COMMENT>`를 사용한다. MCP 인자를 REST payload에 복사하거나 모든 POST의 성공 status를 `201`로 가정하지 않는다. REST review endpoint의 `200` 등 확인된 성공 status만 인정하고 authoritative 응답의 `state`는 기존 verdict mapping과 exact-match해야 한다.
18. Create 응답 성공만 신뢰하지 않는다. `get_review`, complete `get_reviews` pagination과 exact review의 `get_review_comments`를 다시 읽어 baseline 보존과 exact 대상 review record 하나(새 create면 신규 ID 하나, 기존 `PENDING` 복구면 같은 ID 유지), stable author account ID·body·state·commit·timestamp·stale/dismissed 상태 및 inline comment가 0개임을 검증한다. `PENDING`은 제출 완료가 아니며 절차 19의 복구 조건을 적용한다. 18. Create 응답 성공만 신뢰하지 않는다. `get_review`, complete `get_reviews` pagination과 exact review의 `get_review_comments`를 다시 읽어 baseline 보존과 exact 대상 review record 하나(새 create면 신규 ID 하나, 기존 `PENDING` 복구면 같은 ID 유지), stable author account ID·body·state·commit·timestamp·stale/dismissed 상태 및 inline comment가 0개임을 검증한다. `PENDING`은 제출 완료가 아니며 절차 19의 복구 조건을 적용한다.
새 최종 review는 `stale=false`, `dismissed=false`여야 한다. `official`은 고정된 `false`를 요구하지 않고 authoritative provider 계약·조회된 권한·review 정책과 대조해 판정하며, 그 값만으로 작성자·본문·head·최종 state 검증을 대체하지 않는다. Baseline 변경은 절차 20의 검증된 파생 projection만 제외하고 보존한다. 새 최종 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 없이 중단한다. 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 없이 중단한다.
@@ -260,6 +272,23 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
모든 종료 경로(정상·no-op·검증 실패·API 오류·예외·cancel)는 canonical broker cleanup을 `finally`에서 수행한다: 보유한 `release_repository`, `release`, tracked broker wait/reap, `probe`의 `free=true` readback 순서를 확인한다. Write 후 응답과 authoritative readback snapshot은 판정 assertion 전에 credential 없이 먼저 보존하고, 검증 실패 사유는 별도로 기록한다. 불확실한 POST는 절차 19만 적용하고 후속 write를 중단한다. 종료 시 final report를 authoritative 결과로 확정하며 native review 제출 상태와 절차 전체 상태를 분리한다. Progress artifact를 사용했다면 최종 상태·review ID·잔여 오류를 갱신하거나 stage·timestamp·final report 참조를 갖춘 중간 snapshot임을 명시한다. Cleanup 실패도 final report에 남기고 완료로 보고하지 않는다. 모든 종료 경로(정상·no-op·검증 실패·API 오류·예외·cancel)는 canonical broker cleanup을 `finally`에서 수행한다: 보유한 `release_repository`, `release`, tracked broker wait/reap, `probe`의 `free=true` readback 순서를 확인한다. Write 후 응답과 authoritative readback snapshot은 판정 assertion 전에 credential 없이 먼저 보존하고, 검증 실패 사유는 별도로 기록한다. 불확실한 POST는 절차 19만 적용하고 후속 write를 중단한다. 종료 시 final report를 authoritative 결과로 확정하며 native review 제출 상태와 절차 전체 상태를 분리한다. Progress artifact를 사용했다면 최종 상태·review ID·잔여 오류를 갱신하거나 stage·timestamp·final report 참조를 갖춘 중간 snapshot임을 명시한다. Cleanup 실패도 final report에 남기고 완료로 보고하지 않는다.
### 비정본·변경된 readback helper 호출 계약
비정본 helper, 교체·변경된 adapter 또는 workflow-local pagination callback을 사용할 때만 native create 이전에 게시 후 사용할 readback 호출 형태를 확인한다. 정본 adapter를 변경 없이 사용하는 경로에는 중복 signature 검사를 강제하지 않는다. 기존 요청 schema·identity·source·lease 검증과 complete readback은 그대로 유지한다.
- Main은 실제 사용할 helper의 source revision 또는 digest, 문서화된 interface와 caller의 positional·keyword 인수를 대조한다. Python callable의 확인된 signature는 `inspect.signature(readback_callable).bind(*planned_args, **planned_kwargs)`로 점검할 수 있다. 이 점검에서 callback 본문이나 native create를 실행하지 않는다. `collect_pages`의 callback은 `fetch_page(page)`이며 page size는 collector의 별도 인수다. 다른 helper에도 이 형태를 임의로 복사하지 않는다.
- Signature 조회 불가, wrapper의 variadic 인수 또는 `__signature__` 등으로 실제 interface를 확인할 수 없는 경우 binding 통과만 신뢰하지 않는다. 승인된 interface·변경 source와 bounded synthetic fixture의 호출 근거를 대조한다. 불일치·근거 부족이면 native create하지 않고 blocked/partial과 최소 보완 이유를 기록한다. 실제 네트워크를 호출하거나 trial create로 signature를 시험하지 않는다.
- 이는 호출 형태의 확인일 뿐 인증·provider wire schema·pagination completeness·callback 내부 동작이나 향후 네트워크 성공의 증명이 아니다. Write 뒤 readback이 실패하면 응답과 정확한 대상 근거를 보존하고 기존 read-only reconciliation으로 확인하거나 uncertain/partial로 중단한다. Signature 문제를 이유로 native create를 반복하지 않는다.
- 새 runtime gate·범용 interface 검사기·adapter·schema·journal을 추가하지 않는다. 문서 계약 회귀는 지침의 존재만 확인하고, synthetic binding/collector 회귀는 로컬 호출 동작만 확인한다. 이를 실제 agent 실행이나 live publication 검증으로 보고하지 않는다.
### AC 검증 분류의 read-only 구조 검사
Trusted requirements README/TEMPLATE와 exact reviewed-head 요구사항 snapshot을 준비한 뒤 `scripts/review_support.py check-ac-classification --document <immutable-req-snapshot> --expected-document-sha256 <independently-pinned-document-digest> --expected-template-sha256 <independently-pinned-trusted-template-digest>`를 실행한다. Expected digest는 검사 대상 파일을 그 자리에서 다시 hash해 승인하는 값이 아니라 기존 trusted input·source revision에서 독립 고정한 값이어야 한다. 정본 TEMPLATE는 helper와 같은 harness의 `docs/requirements/TEMPLATE.md`만 읽으며 제품 TEMPLATE나 임의 파일로 대체하지 않는다. CLI는 document·template의 exact UTF-8 bytes digest를 대조하고 원본을 수정하지 않는다. Digest 일치는 파일 결속일 뿐 reviewed-head 실행 provenance나 의미 인증을 대신하지 않는다.
- 현재 정본 형식의 `## 7. 인수 기준`에서 `## 8. 오류 및 충돌 계약`까지 flat H3 `REQ-NNN.XX-ACYY — 제목`별 `**검증 분류**` 하나와 수준·백엔드·케이스의 단일 행 필드를 확인한다. 빈 값·중복 필드·중복 AC ID·필드의 미치환 placeholder는 차단한다. 다른 AC 또는 코드 fence 안의 example로 누락 필드를 충족하지 않는다. `없음`은 기존 계약이 허용하는 값이며 새 backend enum·검증 수준 정책을 만들지 않는다.
- 지원 범위는 canonical flat block과 LF/CRLF에 한정한다. Multiline 분류값·들여쓴/중첩 heading·HTML/HTML comment·불완전 fence·모호한 section 구조는 자동 보정하지 않고 unsupported/미확인으로 남겨 main이 exact source와 trusted 계약을 직접 대조한다. 비지원 형식의 실패를 의미 결함으로 자동 판정하지 않는다. 입력은 1MiB 이하 regular UTF-8 파일만 읽으며 최종 symlink·FIFO는 거부한다. Ancestor 경로 admission과 authenticated source·revision binding은 기존 main 책임이다.
- Exit 0과 AC ID·checked fields·digest 출력은 해당 분류의 구조 검사 통과만 뜻한다. 전체 문서 적합성, AC ID 연속성·traceability, 필드값의 의미·검증 수준 적절성·실제 backend/test 실행·AC 충족은 기존 main 검토에 남긴다. Exit 2는 sanitized 오류이며 raw 입력이나 credential을 출력하지 않는다. 기존 result의 `evidence`·`reason`에 검사 범위·결과·미확인 이유를 기록하고 새 schema·제품 문서 자동 수정·publisher gate를 추가하지 않는다.
## 문서 검토 입력과 구조·의미 분리 ## 문서 검토 입력과 구조·의미 분리
문서 검토를 main이 수행하거나 delegate에 위임하기 전에 다음 입력을 trusted workflow에 준비한다. 이 준비는 제품 저장소의 새 산출물이나 result schema 변경을 요구하지 않는다. 문서 검토를 main이 수행하거나 delegate에 위임하기 전에 다음 입력을 trusted workflow에 준비한다. 이 준비는 제품 저장소의 새 산출물이나 result schema 변경을 요구하지 않는다.
@@ -312,6 +341,19 @@ terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-r
Review write 응답, process exit, PR body의 test 주장, delegate 보고 또는 native state만으로 완료를 주장하지 않는다. Exact review body·commit binding과 authoritative collection readback, 외부 상태 불변성과 replay가 모두 확인된 경우에만 완료로 판정한다. Review write 응답, process exit, PR body의 test 주장, delegate 보고 또는 native state만으로 완료를 주장하지 않는다. Exact review body·commit binding과 authoritative collection readback, 외부 상태 불변성과 replay가 모두 확인된 경우에만 완료로 판정한다.
### Named test 근거와 재작업 보고
Named test를 근거로 인용하는 main은 exact reviewed head의 실제 호출 인수와 assertions를 직접 대조하고, 검증하는 경우와 검증하지 않는 경우를 구분한다. Setup·fixture·parameterization과 replay 등 관련 호출 순서도 확인하여 테스트 이름이나 PASS 수치만으로 coverage를 추정하지 않는다. 정적 확인과 실제 실행의 출처·revision·확인 한계를 분리해 기록하며, 코드에서 assertion을 확인한 사실을 reviewer가 실행한 결과로 표현하지 않는다. 이전 revision의 실행 evidence 재사용은 기존 Acceptance matrix의 binding 기준을 적용하고, 이 규칙만으로 새 일괄 재실행 gate를 만들지 않는다.
재작업 재검토의 최종 보고는 다음 네 범주를 구분하고 항목이 없으면 `해당 없음`을 명시한다. 각 항목은 기존 criterion의 `evidence`·`reason`과 finding ID(있는 경우), exact head의 확인 위치에 연결한다.
- 해소: 이전 finding이 충족된 근거와 확인 범위.
- 잔여: 이전 finding 중 아직 충족하지 못한 승인 계약과 최소 보완.
- 신규: 이번 재검토에서 발견된 finding, 승인 계약·변경/영향 경로와의 관계 및 지금 차단해야 하는 이유. 단순히 늦게 발견됐다는 이유로 면제하거나 차단하지 않는다.
- 문서 보완: 설명·추적성 보완과 실제 승인 계약 위반을 구분한다. 문서 보완만으로 blocker로 승격하지 않는다. 문서 자체가 필수 승인 계약을 위반하면 해당 근거와 잔여/신규 판정을 명시한다.
같은 원인의 구현·문서 finding은 연결해 설명하고 동일 미해결 원인을 중복 집계하지 않는다. 서로 독립된 계약 위반은 임의로 합치거나 숨기지 않으며, 보고 수치에는 집계 단위를 명시한다. 이 규칙은 main의 근거 대조와 보고 분류이며 result schema나 renderer, publisher를 바꾸거나 자동 assertion 해석기·새 제품 문서·일괄 재실행 gate를 요구하지 않는다.
## External Issue verification evidence boundary ## External Issue verification evidence boundary
제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`다. 구현·재작업의 `evidence-manifest/v3`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다. 제품 검증 실행 evidence의 정본은 공통 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 `<harness-root>/repositories/<owner>/<repo>/worktrees/issue-<number>/verification/<run-id>`다. 구현·재작업의 `evidence-manifest/v3`와 plan/output을 read-only로 소비하며 제품 저장소에 회차별 증빙 파일을 새로 요구하지 않는다. 기존 genuine 정본 문서와 fixture의 존재 자체를 결함으로 판정하거나 삭제를 요구하지 않는다.
@@ -642,6 +642,119 @@ def verify_publication(before, after, exact_review, result, allowed_paths, revie
return {"review_id": rid, "state": expected, "review_records_added": 1, return {"review_id": rid, "state": expected, "review_records_added": 1,
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True} "timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
def _classification_bytes(path):
"""Bounded regular-file read; caller independently admits source provenance."""
fd = None
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
if not stat.S_ISREG(os.fstat(fd).st_mode):
_fail("classification input unavailable")
with os.fdopen(fd, "rb") as stream:
fd = None
raw = stream.read(1048577)
if len(raw) > 1048576:
_fail("classification input too large")
raw.decode("utf-8")
return raw
except (OSError, UnicodeError, ValueError):
_fail("classification input unavailable")
finally:
if fd is not None:
os.close(fd)
def _classification_lines(text):
if type(text) is not str or len(text.encode("utf-8")) > 1048576:
_fail("invalid classification text")
text = text.replace("\r\n", "\n")
if any(c in text for c in ("\r", "\x00", "\u2028", "\u2029")):
_fail("unsupported classification text")
visible, fence = [], None
for line in text.split("\n"):
match = re.fullmatch(r" {0,3}(`{3,}|~{3,})(.*)", line)
if fence is not None:
if match and match[1][0] == fence[0] and len(match[1]) >= fence[1] and not match[2].strip():
fence = None
continue
if match:
if match[1][0] == "`" and "`" in match[2]:
_fail("ambiguous classification fence")
fence = (match[1][0], len(match[1]))
continue
if '<!--' in line or '-->' in line or re.search(r'</?[A-Za-z][^>]*>', line):
_fail("unsupported classification HTML")
visible.append(line)
if fence is not None:
_fail("unterminated classification fence")
return visible
def check_ac_classification(body, expected_template_sha256):
"""Check only canonical section-7 AC classification, not full compliance."""
if type(expected_template_sha256) is not str or not HEX64.fullmatch(expected_template_sha256):
_fail("invalid trusted template digest")
template = _classification_bytes(Path(__file__).resolve().parents[5] / "docs/requirements/TEMPLATE.md")
if hashlib.sha256(template).hexdigest() != expected_template_sha256:
_fail("trusted template drift")
template_text = template.decode("utf-8")
try:
template_block = template_text.split("## 7. 인수 기준\n", 1)[1].split("## 8.", 1)[0]
classification = template_block.split("**검증 분류**\n", 1)[1].split("**상태 불변조건**", 1)[0]
except IndexError:
_fail("unsupported trusted template")
fields = re.findall(r"^- ([^:]+): \$\{[a-z_]+\}$", classification, flags=re.M)
if fields != ["수준", "백엔드", "케이스"]:
_fail("unsupported trusted template")
lines = _classification_lines(body)
section = "## 7. 인수 기준"
if lines.count(section) != 1:
_fail("ambiguous AC section")
start = lines.index(section) + 1
end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines))
if end == len(lines) or lines[end] != "## 8. 오류 및 충돌 계약":
_fail("unsupported AC section boundary")
blocks, current = [], None
for line in lines[start:end]:
if line.startswith("### "):
match = re.fullmatch(r"### (REQ-[0-9]{3}\.[0-9]{2}-AC[0-9]{2}) — (\S.*)", line)
if not match:
_fail("unsupported AC heading")
current = (match[1], [])
blocks.append(current)
elif current is None:
if line.strip():
_fail("unsupported AC preamble")
else:
if line.lstrip().startswith("#"):
_fail("ambiguous AC heading")
current[1].append(line)
ids = [item[0] for item in blocks]
if not ids or len(ids) != len(set(ids)):
_fail("missing or duplicate AC")
for _, block in blocks:
marker = "**검증 분류**"
if block.count(marker) != 1:
_fail("missing or duplicate AC classification")
pos = block.index(marker) + 1
stop = next((i for i in range(pos, len(block)) if block[i].startswith("**")), len(block))
values = {}
for line in block[pos:stop]:
if not line.strip():
continue
match = re.fullmatch(r"- ([^:]+): (.*)", line)
if not match or match[1] not in fields or match[1] in values:
_fail("ambiguous AC classification field")
value = match[2].strip()
if not value or "${" in value:
_fail("empty or placeholder AC classification")
values[match[1]] = value
if set(values) != set(fields):
_fail("missing AC classification field")
return {"ac_ids": ids, "checked_fields": fields, "read_only": True,
"template_sha256": expected_template_sha256,
"document_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest()}
def _read(path): def _read(path):
try: try:
with open(path, encoding="utf-8", newline="") as stream: with open(path, encoding="utf-8", newline="") as stream:
@@ -670,6 +783,10 @@ def main(argv=None):
q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int) q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int)
q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True) q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True)
q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000) q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000)
q=sub.add_parser("check-ac-classification")
q.add_argument("--document", required=True)
q.add_argument("--expected-document-sha256", required=True)
q.add_argument("--expected-template-sha256", required=True)
q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True) q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True)
q=sub.add_parser("verify-publication"); q=sub.add_parser("verify-publication");
for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True) for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True)
@@ -703,6 +820,11 @@ def main(argv=None):
items = collect_pages(f, a.key, a.page_size, a.max_pages) items = collect_pages(f, a.key, a.page_size, a.max_pages)
if len(used) != len(pages): _fail("extra saved pages") if len(used) != len(pages): _fail("extra saved pages")
print(json.dumps(items,ensure_ascii=False,sort_keys=True)) print(json.dumps(items,ensure_ascii=False,sort_keys=True))
elif a.cmd=="check-ac-classification":
raw = _classification_bytes(a.document)
if type(a.expected_document_sha256) is not str or not HEX64.fullmatch(a.expected_document_sha256) or hashlib.sha256(raw).hexdigest() != a.expected_document_sha256:
_fail("classification document drift")
print(json.dumps(check_ac_classification(raw.decode("utf-8"), a.expected_template_sha256), ensure_ascii=False, sort_keys=True))
elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))})) elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))}))
else: else:
print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True)) print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True))
+1 -1
View File
@@ -473,7 +473,7 @@ def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="command", required=True) sub = p.add_subparsers(dest="command", required=True)
s = sub.add_parser("serve") s = sub.add_parser("serve")
s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle"); s.add_argument("--socket"); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0) s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True, choices=sorted(ROLES)); s.add_argument("--run-id", required=True); s.add_argument("--handle"); s.add_argument("--socket"); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0)
r = sub.add_parser("request") r = sub.add_parser("request")
r.add_argument("--handle", required=True); r.add_argument("--action", required=True, choices=["status", "renew", "acquire_repository", "release_repository", "release"]); r.add_argument("--ttl-seconds", type=float) r.add_argument("--handle", required=True); r.add_argument("--action", required=True, choices=["status", "renew", "acquire_repository", "release_repository", "release"]); r.add_argument("--ttl-seconds", type=float)
check = sub.add_parser("probe") check = sub.add_parser("probe")
+107
View File
@@ -0,0 +1,107 @@
"""Read-only worktree topology gate; caller owns admission and repository lock.
Reuse the harness inventory binder's bounded, fixed-Git reader and pinned,
no-symlink directory opening. This is not a standalone cache authenticator.
"""
import argparse
import json
import os
import re
if __package__:
from . import test_inventory_binding as binding
else:
import test_inventory_binding as binding
class PrefetchError(ValueError):
pass
def parse_worktrees(raw):
if type(raw) is not bytes or not raw or not raw.endswith(b'\0\0'):
raise PrefetchError('invalid_worktree_listing')
records = []
seen = set()
for chunk in raw[:-2].split(b'\0\0'):
fields = chunk.split(b'\0')
if not fields or not fields[0].startswith(b'worktree '):
raise PrefetchError('invalid_worktree_listing')
try:
path = fields[0][9:].decode('utf-8')
except UnicodeError:
raise PrefetchError('invalid_worktree_listing') from None
if not path or not os.path.isabs(path) or path != os.path.normpath(path) or path in seen:
raise PrefetchError('invalid_worktree_listing')
seen.add(path)
tokens = fields[1:]
if tokens == [b'bare']:
records.append({'path': path, 'kind': 'bare'})
continue
if len(tokens) < 2 or not re.fullmatch(rb'HEAD [0-9a-f]{40}|HEAD [0-9a-f]{64}', tokens[0]):
raise PrefetchError('invalid_worktree_listing')
branch = None
if tokens[1].startswith(b'branch refs/heads/'):
try:
branch = tokens[1][7:].decode('utf-8')
except UnicodeError:
raise PrefetchError('invalid_worktree_listing') from None
parts = branch.split('/')
if (not branch[len('refs/heads/'):] or '..' in branch or '@{' in branch
or re.search(r'[\x00-\x20\x7f~^:?*\[\\\\]', branch)
or any(not p or p.startswith('.') or p.endswith(('.', '.lock')) for p in parts)):
raise PrefetchError('invalid_worktree_listing')
elif tokens[1] != b'detached':
raise PrefetchError('invalid_worktree_listing')
optional = tokens[2:]
keys = [x.split(b' ', 1)[0] for x in optional]
if any(x not in (b'locked', b'prunable') for x in keys) or len(set(keys)) != len(keys):
raise PrefetchError('invalid_worktree_listing')
record = {'path': path, 'kind': 'attached' if branch is not None else 'detached'}
if branch is not None:
record['branch'] = branch
records.append(record)
if sum(x['kind'] == 'bare' for x in records) != 1:
raise PrefetchError('invalid_worktree_listing')
return records
def inspect_repository(git_dir):
fd = None
try:
fd = binding._canonical_git_dir(git_dir)
pinned = '/proc/self/fd/' + str(fd)
bare = binding._git(pinned, ['rev-parse', '--is-bare-repository'], check=True, git_fd=fd)
if bare is None or bare.stdout != b'true\n':
raise PrefetchError('not_bare_repository')
listing = binding._git(pinned, ['worktree', 'list', '--porcelain', '-z'], check=True, git_fd=fd)
if listing is None:
raise PrefetchError('git_read_unavailable')
records = parse_worktrees(listing.stdout)
attached = [{'branch': x['branch'], 'path': x['path']} for x in records if x['kind'] == 'attached']
return {'status': 'block' if attached else 'pass', 'attached_worktrees': attached,
'reason': 'attached_worktree' if attached else 'no_attached_worktrees'}
except PrefetchError:
raise
except (binding.BindingError, OSError, ValueError, TypeError):
raise PrefetchError('git_read_unavailable') from None
finally:
if fd is not None:
os.close(fd)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--git-dir', required=True, help='Already-admitted canonical absolute bare mirror')
args = parser.parse_args()
try:
result = inspect_repository(args.git_dir)
except PrefetchError as error:
print(json.dumps({'status': 'block', 'attached_worktrees': [], 'reason': str(error)}))
return 2
print(json.dumps(result, ensure_ascii=True))
return 0 if result['status'] == 'pass' else 1
if __name__ == '__main__':
raise SystemExit(main())
+9
View File
@@ -40,6 +40,15 @@
- 성공 response만으로 완료를 주장하지 않는다. Exact target을 fresh readback하고 author stable account ID, payload, timestamp, commit/ref와 collection cardinality를 baseline과 비교한다. - 성공 response만으로 완료를 주장하지 않는다. Exact target을 fresh readback하고 author stable account ID, payload, timestamp, commit/ref와 collection cardinality를 baseline과 비교한다.
- Timeout, malformed response 또는 connection loss 뒤에는 같은 mutation을 재호출하기 전에 complete collection을 한 번 읽어 결과를 reconciliation한다. 결과가 없거나 여러 개이거나 경쟁 mutation과 구분할 수 없으면 uncertain/partial로 중단한다. - Timeout, malformed response 또는 connection loss 뒤에는 같은 mutation을 재호출하기 전에 complete collection을 한 번 읽어 결과를 reconciliation한다. 결과가 없거나 여러 개이거나 경쟁 mutation과 구분할 수 없으면 uncertain/partial로 중단한다.
### Issue body update의 provider-derived readback
- 이 계약은 승인된 Issue body update에만 적용한다. 현재 origin의 실제 server version·endpoint/update 경로와 독립적으로 확인한 matching provider contract를 기존 snapshot/근거에 연결한다. 보관 소스나 snapshot 자체의 version 주장만으로 현재 서버 계약을 확정하지 않는다. Revision 필드의 API 노출 여부도 확인하며, 내부 모델 필드가 transport에 항상 노출된다고 추정하지 않는다.
- Write 직전 fresh snapshot은 target identity와 body, `updated_at`, comment baseline 및 보호 필드(title·state·labels·assignees·milestone 등)를 승인 baseline과 비교한다. 노출된 revision도 비교하여 pre-write drift를 차단한다. Provider 파생 변경 허용은 write 이후에만 적용하며 drift gate나 기존 조건부 update를 완화하지 않는다.
- Readback의 body는 실제 전송 artifact의 exact bytes와 같아야 한다. 보호 필드와 해당 workflow의 complete comment baseline 계약은 그대로 검증한다. `content_version`이 해당 authenticated transport의 baseline과 readback에 제공되고 matching contract가 이 단일 body update에서 정확히 1 증가를 보장하면, 두 값이 boolean이 아닌 비음수 정수인지 확인하고 after == before + 1을 검증한다. 같은 body의 no-op에는 증가를 요구하지 않는다.
- Revision이 누락·malformed이거나 증가가 0 또는 2 이상이거나 계약이 미확인이면 one-step 검증 성공으로 추정하지 않는다. Provider가 필드를 노출하지 않는 확인된 계약이면 비노출·검증 한계를 기록하고 workflow의 나머지 필수 검증을 적용한다. 필수 revision 근거가 부족하거나 경쟁 mutation과 구분할 수 없으면 uncertain/partial로 중단한다. `updated_at` 등 다른 필드도 같은 operation의 확인된 인과·타입·변경 규칙으로 각각 대조하며 일괄 비교 제외하지 않는다. Timestamp만으로 경쟁 변경이 없다고 주장하지 않는다.
- 정상 파생 증가와 무관한 mutation을 구분하여 fresh readback으로 reconciliation한다. Body가 일치해도 다른 필수 검증이 실패하면 completed가 아니다. Timeout·오류·불일치 뒤에는 자동 재write하지 않는다. 보존된 baseline·payload·현재 결과로 판정하고 불확실하면 uncertain/partial로 보고한다. 별도 승인된 재개에서도 fresh identity·lease·snapshot·drift gate를 다시 통과한다.
- 이는 기존 adapter를 사용하는 main의 비교 절차이며 새 runtime checker·publisher·projection framework 또는 자동 retry를 추가하지 않는다. 문서 계약 테스트 통과는 실제 adapter 준수나 live 서버 동작을 검증한 것이 아니다.
## Credential 안전 ## Credential 안전
- Credential 값은 role profile의 secret store에만 저장하고 repository, harness 문서와 workflow artifact에는 저장하지 않는다. - Credential 값은 role profile의 secret store에만 저장하고 repository, harness 문서와 workflow artifact에는 저장하지 않는다.
+32 -6
View File
@@ -90,12 +90,14 @@ Broker 응답은 action별 계약이다. 다른 action의 필드가 있다고
### Canonical command templates ### Canonical command templates
`serve --role`은 기존 `ROLES`의 정확한 값 `gitea-spec-agent`, `gitea-impl-agent`, `gitea-review-agent`만 허용한다. `spec`/`impl`/`review` 축약, 대소문자·공백 변형 및 다른 role은 허용하지 않는다. `serve --help`와 invalid-choice 진단에서 같은 목록을 표시하며 잘못된 값은 runtime 생성 전에 exit 2로 거절한다. 아래는 spec 실행 예제이며 impl/review 호출자는 자신의 정확한 canonical role을 사용한다. Role 문자열은 같은 UID 내 workflow metadata이며 authorization 또는 추가 권한을 부여하지 않는다. 기존 filesystem UID 보안 경계와 constructor 검증은 그대로 유지한다.
```sh ```sh
# Runtime paths come only from broker ready JSON; never create them or read the token. # Runtime paths come only from broker ready JSON; never create them or read the token.
python3 <harness-root>/harness/lib/managed_lock_broker.py serve \ python3 <harness-root>/harness/lib/managed_lock_broker.py serve \
--root <harness-root> \ --root <harness-root> \
--owner <owner> --repository <repository> --issue <issue-number> \ --owner <owner> --repository <repository> --issue <issue-number> \
--role <role> --run-id <workflow-run-id> \ --role gitea-spec-agent --run-id <workflow-run-id> \
--ttl-seconds <ttl-seconds> --ttl-seconds <ttl-seconds>
# Parse only public paths from ready JSON; do not print token-bearing fields. # Parse only public paths from ready JSON; do not print token-bearing fields.
HANDLE=<ready-json-HANDLE> HANDLE=<ready-json-HANDLE>
@@ -151,10 +153,15 @@ python3 <harness-root>/harness/lib/managed_lock_broker.py probe \
1. Issue workflow는 broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request한다. 별도 승인된 non-Issue workflow는 해당 workflow에 명시된 상위 lock과 repository lock 순서를 따른다. 1. Issue workflow는 broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request한다. 별도 승인된 non-Issue workflow는 해당 workflow에 명시된 상위 lock과 repository lock 순서를 따른다.
2. target과 `repository.git`의 path, ownership, permissions와 bare 상태를 다시 검증한다. 2. target과 `repository.git`의 path, ownership, permissions와 bare 상태를 다시 검증한다.
3. `origin`의 canonical owner/repository identity가 요청과 일치하는지 확인한다. URL 문자열의 credential이나 transport hostname만으로 identity를 판정하지 않는다. 3. `origin`의 canonical owner/repository identity가 요청과 일치하는지 확인한다. URL 문자열의 credential이나 transport hostname만으로 identity를 판정하지 않는다.
4. 명시적으로 검증한 origin에서 mirror refs를 prune/fetch한다. 임의 remote 교체, force reset 또는 다른 repository 재사용은 금지한다. 4. 같은 repository lock을 유지한 상태에서 fetch 직전에 아래 read-only preflight를 실행하고 exit 0인 경우에만 다음 단계로 진행한다. `ADMITTED_GIT_DIR`은 위 path·bare·remote admission에서 확인한 exact canonical absolute `repository.git` 경로이며 helper의 PASS가 admission·인증·lock 소유 증명을 대신하지 않는다.
5. Gitea에서 default branch와 SHA를 다시 읽고 local ref와 일치하는지 확인한다. ```sh
6. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다. python3 -B "$HARNESS_ROOT/harness/lib/repository_prefetch.py" --git-dir "$ADMITTED_GIT_DIR"
7. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다. ```
Helper는 canonical harness의 `test_inventory_binding.py`에 있는 기존 fixed `/usr/bin/git`, no-symlink directory FD와 bounded reader를 재사용한다. `rev-parse --is-bare-repository`와 `worktree list --porcelain -z`만 조회하며 조회별 5초·64 MiB 출력 한도를 유지한다. Bare 자체의 record와 detached linked worktree는 attached branch로 오인하지 않는다. 등록된 linked worktree가 branch에 붙어 있으면 branch 이름 규칙과 무관하게 모든 exact branch·worktree path를 JSON `attached_worktrees`로 반환해 exit 1로 차단한다. 경로의 공백·개행은 NUL 구분으로 보존하고 JSON에서 escape한다. 조회 실패·malformed·지원하지 않는 결과는 안전한 reason code와 exit 2로 차단하며 Git stderr는 출력하지 않는다. Attached 상태의 자동 repair, detach·reset·worktree prune·ref 삭제·force fetch는 금지하고 운영자에게 해당 경로를 보고한다. 실패는 fetch를 승인하지 않으며 기존 lock/cleanup 계약대로 종료한다. Cooperative lock 아래의 pre-fetch 관찰이지 외부 프로세스의 무단 변경에 대한 sandbox나 원자적 fetch 보장이 아니다.
5. Preflight PASS 뒤 명시적으로 검증한 origin에서 mirror refs를 prune/fetch한다. 임의 remote 교체, force reset 또는 다른 repository 재사용은 금지한다.
6. Gitea에서 default branch와 SHA를 다시 읽고 local ref와 일치하는지 확인한다.
7. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다.
8. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다.
## Issue worktree 모델 ## Issue worktree 모델
@@ -277,11 +284,30 @@ Runtime 실행에서 만든 server·합성 DB·임시 credential은 해당 workf
- credential 값은 role profile의 `.env`에만 저장하고 mode `0600`을 요구한다. - credential 값은 role profile의 `.env`에만 저장하고 mode `0600`을 요구한다.
- worker에게 credential 값 또는 credential-bearing URL을 전달하지 않는다. - worker에게 credential 값 또는 credential-bearing URL을 전달하지 않는다.
- Git 인증이 필요하면 short-lived askpass 또는 동등한 child-process mechanism을 사용하고 종료 시 제거한다. - Git 인증은 아래 canonical non-file child-process mechanism을 우선 사용한다. 직접 실행하는 askpass 파일을 mode `0600`으로 만드는 모순을 피하며, 실행 권한을 추가하거나 credential 파일 권한을 완화하지 않는다.
- inherited credential, 다른 role의 credential 또는 interactive prompt로 fallback하지 않는다. - inherited credential, 다른 role의 credential 또는 interactive prompt로 fallback하지 않는다.
- 인증에 실패하면 public clone이나 unauthenticated API·content read를 initialized mirror의 증거로 대체하지 않고 중단한다. - 인증에 실패하면 public clone이나 unauthenticated API·content read를 initialized mirror의 증거로 대체하지 않고 중단한다.
- Worktree source, state, Git config, reflog message, commit, patch, test artifact 또는 push refspec에 credential을 기록하지 않는다. - Worktree source, state, Git config, reflog message, commit, patch, test artifact 또는 push refspec에 credential을 기록하지 않는다.
### Non-file Git credential helper
이 패턴은 기존 Git credential protocol을 사용하는 POSIX child-process recipe이며 새 push adapter나 credential 저장소가 아니다. Lock 및 secret-bearing 파일의 기존 mode `0600` 계약은 유지한다. mode `0600` askpass 파일을 직접 실행하거나 chmod로 우회하지 않으며 이 패턴은 helper 파일을 만들지 않는다.
Main caller는 자신의 승인된 role profile `.env`에서 지정된 token 변수만 private child 안에서 읽고 role account identity와 exact HTTPS repository URL·branch/refspec·권한을 독립 검증한다. 다른 profile·ambient credential을 선택하지 않는다. 인증된 username과 token을 각각 child-only `GIT_ROLE_USERNAME`/`GIT_ROLE_TOKEN`에 넣으며 빈 값, CR/LF/NUL이 포함된 값은 주입 전에 거절한다. 이 변수는 새 영구 설정이나 `.env` key가 아니다. Parent/shared shell에 export하거나 값·request header·raw exception을 기록하지 않는다.
Child는 `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=/bin/false`, `SSH_ASKPASS=/bin/false`를 명시하고 승인된 최소 환경만 전달한다. 아래 첫 empty `credential.helper`는 inherited helper 목록을 지우고 다음 helper는 `get`에서만 값을 Git private protocol pipe로 공급한다. `store`/`erase`는 아무 값도 출력하거나 저장하지 않는다. Token은 argv·URL·Git config에 들어가지 않으며 모든 `-c`는 해당 호출에만 적용된다. Missing 변수는 실패하며 interactive나 다른 helper로 fallback하지 않는다.
```sh
git -c credential.helper= \
-c 'credential.helper=!f() { case "$1" in get) test -n "${GIT_ROLE_USERNAME:-}" && test -n "${GIT_ROLE_TOKEN:-}" && printf "%s\n" "username=$GIT_ROLE_USERNAME" "password=$GIT_ROLE_TOKEN";; store|erase) :;; *) return 1;; esac; }; f' \
-c credential.interactive=false -c remote.origin.mirror=false \
push --dry-run "$VERIFIED_REPOSITORY_URL" "$VERIFIED_REFSPEC"
```
`VERIFIED_REPOSITORY_URL`은 credential 없는 exact 승인 URL이고 `VERIFIED_REFSPEC`은 승인된 branch의 explicit non-force refspec이다. Canonical mirror remote/config를 변경하지 않는다. 먼저 위 exact destination dry-run으로 capability를 확인한 뒤 별도로 승인된 실제 push에서만 같은 credential option/env와 refspec을 사용한다. Dry-run은 실제 write 성공의 증명이 아니며 push 뒤 authoritative branch/PR readback과 fetch/local SHA 비교가 필요하다. 오류·timeout은 기존 reconcile 규칙을 따르고 automatic push retry하지 않는다.
Helper stdout은 Git 전용 pipe다. `git credential fill`의 credential-bearing stdout을 terminal, tool output, evidence 또는 log로 내보내지 않는다. Local synthetic protocol 검증은 이를 private child memory로만 capture하고 결과 boolean/exit code만 보고한다. Parent는 child의 전체 수명에 finite deadline을 적용하고 성공·실패·cancel 모두 wait/reap하여 credential 환경의 종료를 확인한다. 값이 담긴 파일/config를 만들지 않으므로 helper-file 삭제 단계도 없다. same-UID 환경은 cooperative trust이며 같은 UID의 악성 프로세스로부터 환경변수를 격리한다는 보장은 아니다. Network 인증·TLS·permission·lease/adoption 검증은 기존 caller 책임이며 이 recipe 자체가 증명하지 않는다.
## Fail-closed 조건 ## Fail-closed 조건
다음 중 하나라도 발생하면 cache를 사용하거나 자동 복구하지 않는다. 다음 중 하나라도 발생하면 cache를 사용하거나 자동 복구하지 않는다.
@@ -0,0 +1,108 @@
"""Local credential protocol checks only; no real credentials or network."""
import os
import secrets
import shlex
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
POLICY = ROOT / "harness/operations/repository-cache.md"
REWORK = ROOT / "harness/agents/impl/skills/gitea-issue-rework/SKILL.md"
HEADING = "### Non-file Git credential helper"
class GitCredentialRecipeTests(unittest.TestCase):
def recipe(self):
policy = POLICY.read_text(encoding="utf-8")
self.assertTrue(HEADING in policy, "canonical non-file credential recipe is missing")
section = policy.split(HEADING, 1)[1].split("\n## ", 1)[0]
block = section.split("```sh\n", 1)[1].split("```", 1)[0]
return section, shlex.split(block.replace("\\\n", ""))
def test_canonical_nonfile_recipe_preserves_permission_and_delivery_contract(self):
section, command = self.recipe()
self.assertEqual(command[0], "git")
self.assertEqual(command[1:3], ["-c", "credential.helper="])
helper = command[4]
self.assertTrue(helper.startswith("credential.helper=!"))
self.assertIn("GIT_ROLE_USERNAME", helper)
self.assertIn("GIT_ROLE_TOKEN", helper)
self.assertIn("credential.interactive=false", command)
self.assertIn("remote.origin.mirror=false", command)
self.assertEqual(command[-4:], ["push", "--dry-run", "$VERIFIED_REPOSITORY_URL", "$VERIFIED_REFSPEC"])
for required in ("0600", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "SSH_ASKPASS=/bin/false",
"same-UID", "store", "erase", "stdout", "readback"):
self.assertIn(required, section)
policy = POLICY.read_text(encoding="utf-8")
self.assertIn("Lock 파일과 임시 credential helper는 실행 role 소유와 mode `0600`을 요구한다", policy)
delivery = REWORK.read_text(encoding="utf-8").split("### 7. 기존 PR branch 전달", 1)[1].split("###", 1)[0]
self.assertIn("#non-file-git-credential-helper", delivery)
self.assertNotIn("필요한 temporary askpass helper", delivery)
def test_nonfile_get_missing_variables_and_store_erase_do_not_persist_credentials(self):
_, documented = self.recipe()
options = documented[:documented.index("push")]
with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch")) as d:
root = Path(d)
token = secrets.token_hex(24)
env = {"PATH": os.environ["PATH"], "HOME": str(root), "GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0",
"GIT_ASKPASS": "/bin/false", "SSH_ASKPASS": "/bin/false",
"GIT_ROLE_USERNAME": "gitea-impl-agent", "GIT_ROLE_TOKEN": token}
initialized = subprocess.run(["git", "init", "-q"], cwd=root, env=env,
capture_output=True, timeout=10)
self.assertEqual(initialized.returncode, 0)
config = root / ".git/config"
before, mode = config.read_bytes(), stat.S_IMODE(config.stat().st_mode)
request = "protocol=https\nhost=example.invalid\n\n"
def invoke(action, packet=request, child_env=env):
# Values travel only in the private child env/protocol, never argv.
self.assertFalse(any(token in arg for arg in options))
result = subprocess.run([*options, "credential", action], cwd=root, env=child_env,
input=packet, text=True, capture_output=True, timeout=10)
self.assertFalse(token in result.stderr, "credential exposed outside private protocol")
return result
valid = invoke("fill")
self.assertEqual(valid.returncode, 0)
fields = dict(line.split("=", 1) for line in valid.stdout.splitlines() if "=" in line)
self.assertTrue(fields.get("username") == env["GIT_ROLE_USERNAME"], "username binding mismatch")
self.assertTrue(fields.get("password") == token, "synthetic credential binding mismatch")
for missing in ("GIT_ROLE_TOKEN", "GIT_ROLE_USERNAME"):
with self.subTest(missing=missing):
incomplete = dict(env)
incomplete.pop(missing)
rejected = invoke("fill", child_env=incomplete)
self.assertNotEqual(rejected.returncode, 0)
self.assertTrue(rejected.stdout == "", "failed credential lookup emitted data")
packet = ("protocol=https\nhost=example.invalid\nusername=" + env["GIT_ROLE_USERNAME"]
+ "\npassword=" + token + "\n\n")
for action in ("approve", "reject"):
with self.subTest(action=action):
result = invoke(action, packet=packet)
self.assertEqual(result.returncode, 0)
self.assertTrue(result.stdout == "", "store/erase emitted credential data")
self.assertTrue(config.read_bytes() == before, "Git config changed")
self.assertEqual(stat.S_IMODE(config.stat().st_mode), mode)
self.assertFalse(token.encode() in config.read_bytes(), "credential persisted")
self.assertEqual(sorted(path.name for path in root.iterdir()), [".git"])
def test_mode0600_direct_askpass_fails_without_permission_relaxation(self):
with tempfile.TemporaryDirectory(dir=os.environ.get("TMPDIR", "/opt/data/cache/scratch")) as d:
root = Path(d)
askpass = root / "askpass.sh"
askpass.write_text("#!/bin/sh\nexit 0\n")
askpass.chmod(0o600)
env = {"PATH": os.environ["PATH"], "HOME": str(root), "GIT_CONFIG_NOSYSTEM": "1",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": str(askpass)}
result = subprocess.run(["git", "-c", "credential.helper=", "credential", "fill"],
cwd=root, env=env, input="protocol=https\nhost=example.invalid\n\n",
text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 128)
self.assertIn("Permission denied", result.stderr)
self.assertTrue(result.stdout == "", "failed credential lookup emitted data")
self.assertEqual(stat.S_IMODE(askpass.stat().st_mode), 0o600)
@@ -147,6 +147,63 @@ class ManagedLockBrokerTests(unittest.TestCase):
self.assertFalse(sock.exists()) self.assertFalse(sock.exists())
self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock")) self.assertTrue(probe_free(root / "repositories/alice/ledger/locks/issue-7.lock"))
def test_serve_parser_accepts_only_canonical_roles(self):
import contextlib
import io
roles = {"gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"}
self.assertEqual(broker.ROLES, roles)
with tempfile.TemporaryDirectory() as d:
root = Path(d) / "not-created"
args = ["serve", "--root", str(root), "--owner", "alice", "--repository", "ledger",
"--issue", "7", "--run-id", "role-probe", "--ttl-seconds", "30"]
for role in sorted(roles):
with self.subTest(role=role):
self.assertEqual(broker.parser().parse_args([*args, "--role", role]).role, role)
for role in ("spec", "impl", "review", "gitea-release-agent", "Gitea-spec-agent", " gitea-spec-agent", "gitea-spec-agent "):
with self.subTest(role=role):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as rejected:
broker.parser().parse_args([*args, "--role", role])
self.assertEqual(rejected.exception.code, 2)
self.assertIn("invalid choice", stderr.getvalue())
for allowed in roles:
self.assertIn(allowed, stderr.getvalue())
help_text = io.StringIO()
with contextlib.redirect_stdout(help_text), self.assertRaises(SystemExit) as helped:
broker.parser().parse_args(["serve", "--help"])
self.assertEqual(helped.exception.code, 0)
for role in roles:
self.assertIn(role, help_text.getvalue())
self.assertFalse(root.exists())
# The direct Python constructor retains its independent role guard.
with self.assertRaisesRegex(ValueError, "invalid role"):
broker.Broker(Namespace(role="spec"))
def test_invalid_serve_role_cli_reports_choices_before_runtime_creation(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d) / "not-created"
result = subprocess.run(
[sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "alice",
"--repository", "ledger", "--issue", "7", "--role", "spec", "--run-id", "role-probe",
"--ttl-seconds", "30"], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 2)
self.assertEqual(result.stdout, "")
self.assertFalse(root.exists())
self.assertIn("invalid choice", result.stderr)
for role in ("gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"):
self.assertIn(role, result.stderr)
self.assertNotIn("error: ValueError", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_canonical_role_values_and_example_are_documented(self):
doc = (Path(__file__).parents[2] / "operations/repository-cache.md").read_text()
section = doc.split("### Canonical command templates", 1)[1].split("## Lock 규칙", 1)[0]
for role in ("gitea-spec-agent", "gitea-impl-agent", "gitea-review-agent"):
self.assertIn(f"`{role}`", section)
self.assertIn("--role gitea-spec-agent --run-id", section)
self.assertIn("spec`/`impl`/`review", section)
self.assertIn("authorization", section)
def test_constructor_rejects_lexical_root_and_handle_aliases(self): def test_constructor_rejects_lexical_root_and_handle_aliases(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
root = Path(d); ensure_cache(root) root = Path(d); ensure_cache(root)
@@ -0,0 +1,86 @@
"""Conditional pytest recipe: documentation contract and real synthetic execution.
Run with an independently admitted pytest interpreter for execution coverage.
No dependency installation occurs in this test or the production workflow.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
SKILL = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/SKILL.md'
HEADING = '### Conditional pytest evidence recipe'
class PytestEvidenceRecipeTests(unittest.TestCase):
def recipe(self):
text = SKILL.read_text()
self.assertIn(HEADING, text, 'conditional pytest recipe is missing')
section = text.split(HEADING, 1)[1].split('\n### ', 1)[0]
return section, json.loads(section.split('```json\n', 1)[1].split('\n```', 1)[0])
def test_documentation_contract_limits_recipe_to_existing_pytest_projects(self):
section, argv = self.recipe()
for rule in ['pytest를 이미 사용하는', 'PYTEST_ADDOPTS', 'plugin',
'evidence_gap', '이전 실행', '0으로', 'schema', 'unittest',
'warning', 'timeout', 'output_limit']:
self.assertIn(rule, section)
self.assertIsInstance(argv, list)
self.assertEqual(argv[:4], ['{python}', '-B', '-m', 'pytest'])
@unittest.skipUnless(importlib.util.find_spec('pytest'), 'admitted pytest interpreter required')
def test_real_recipe_preserves_pass_skip_warning_summary_under_quiet_project_config(self):
_, argv = self.recipe()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'pytest.ini').write_text('[pytest]\naddopts = -qq\n')
(root / 'test_sample.py').write_text(
'import pytest, warnings\n'
'def test_pass():\n warnings.warn("synthetic warning", UserWarning)\n'
'@pytest.mark.skip(reason="synthetic skip")\ndef test_skip():\n pass\n')
control = self.invoke([sys.executable, '-B', '-m', 'pytest', 'test_sample.py'], root)
self.assertEqual(control['status'], 'completed')
self.assertEqual(control['exit_code'], 0)
self.assertNotIn('1 passed', control['output'])
actual = self.invoke(self.expand(argv), root)
self.assertEqual(actual['status'], 'completed')
self.assertEqual(actual['exit_code'], 0)
self.assertRegex(actual['output'], r'1 passed, 1 skipped, 1 warning')
self.assertNotIn('\x1b[', actual['output'])
@unittest.skipUnless(importlib.util.find_spec('pytest'), 'admitted pytest interpreter required')
def test_real_recipe_preserves_failure_summary_and_exit_code(self):
_, argv = self.recipe()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'pytest.ini').write_text('[pytest]\naddopts = -qq\n')
(root / 'test_sample.py').write_text('def test_fail():\n assert False, "synthetic failure"\n')
actual = self.invoke(self.expand(argv), root)
self.assertEqual(actual['status'], 'completed')
self.assertEqual(actual['exit_code'], 1)
self.assertIn('1 failed', actual['output'])
def expand(self, argv):
return [part.replace('{python}', sys.executable).replace('{tests}', 'test_sample.py') for part in argv]
def invoke(self, argv, root):
import importlib.util
path = ROOT / 'harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py'
spec = importlib.util.spec_from_file_location('pytest_recipe_evidence', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
env = {key: os.environ[key] for key in ['PATH', 'HOME', 'TMPDIR', 'LANG'] if key in os.environ}
# Isolate synthetic tests from unrelated globally installed pytest plugins.
env['PYTEST_DISABLE_PLUGIN_AUTOLOAD'] = '1'
result = module.run_bounded(argv, root, env, 65536, 30)
return {'status': result['status'], 'exit_code': result['exit_code'],
'output': result['output'].decode('utf-8', 'replace')}
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,150 @@
"""Synthetic local Git pre-fetch gate checks; no provider/network operations."""
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / 'harness/lib/repository_prefetch.py'
class RepositoryPrefetchTests(unittest.TestCase):
def git(self, *args):
result = subprocess.run(['/usr/bin/git', '-c', 'core.hooksPath=/dev/null', *map(str, args)],
env=self.env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR', '/opt/data/cache/scratch'))
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.env = {'PATH': '/usr/bin:/bin', 'HOME': str(self.root), 'GIT_CONFIG_NOSYSTEM': '1',
'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_TERMINAL_PROMPT': '0'}
self.seed = self.root / 'seed'
self.bare = self.root / 'mirror.git'
self.git('init', '-q', self.seed)
self.git('-C', self.seed, 'config', 'user.name', 'Synthetic')
self.git('-C', self.seed, 'config', 'user.email', 'fixture@example.invalid')
(self.seed / 'a').write_text('synthetic')
self.git('-C', self.seed, 'add', 'a')
self.git('-C', self.seed, 'commit', '-qm', 'synthetic')
self.git('clone', '--mirror', self.seed, self.bare)
def gate(self):
self.assertTrue(SCRIPT.is_file(), 'canonical read-only prefetch helper is missing')
result = subprocess.run(['python3', '-B', str(SCRIPT), '--git-dir', str(self.bare)],
env=self.env, capture_output=True, text=True, timeout=15)
return result.returncode, json.loads(result.stdout)
def test_bare_only_passes_real_git_read_only(self):
before = self.git('--git-dir', self.bare, 'show-ref')
code, value = self.gate()
self.assertEqual(code, 0)
self.assertEqual(value, {'status': 'pass', 'attached_worktrees': [], 'reason': 'no_attached_worktrees'})
self.assertEqual(self.git('--git-dir', self.bare, 'show-ref'), before)
def test_attached_blocks_exact_branch_and_path_without_mutation(self):
checkout = self.root / 'attached space\npath'
self.git('--git-dir', self.bare, 'worktree', 'add', '-b', 'issue-fixture', checkout, 'HEAD')
self.git('--git-dir', self.bare, 'worktree', 'lock', checkout)
state = checkout.parent / 'state.json'
state.write_text('sentinel')
before = self.fingerprint(self.bare, checkout, state)
code, value = self.gate()
self.assertEqual(code, 1)
self.assertEqual(value, {'status': 'block', 'attached_worktrees': [
{'branch': 'refs/heads/issue-fixture', 'path': str(checkout)}], 'reason': 'attached_worktree'})
self.assertEqual(self.fingerprint(self.bare, checkout, state), before)
def test_detached_locked_worktree_passes_without_mutation(self):
checkout = self.root / 'detached'
self.git('--git-dir', self.bare, 'worktree', 'add', '--detach', '--lock', checkout, 'HEAD')
before = self.fingerprint(self.bare, checkout)
code, value = self.gate()
self.assertEqual(code, 0)
self.assertEqual(value['attached_worktrees'], [])
self.assertEqual(self.fingerprint(self.bare, checkout), before)
def test_mixed_worktrees_report_all_attached_without_branch_name_policy(self):
detached = self.root / 'detached'
self.git('--git-dir', self.bare, 'worktree', 'add', '--detach', detached, 'HEAD')
expected = []
for number, branch in enumerate(['feature/normal', 'issue-fixture']):
checkout = self.root / ('attached-' + str(number))
self.git('--git-dir', self.bare, 'worktree', 'add', '-b', branch, checkout, 'HEAD')
expected.append({'branch': 'refs/heads/' + branch, 'path': str(checkout)})
code, value = self.gate()
self.assertEqual(code, 1)
self.assertEqual(sorted(value['attached_worktrees'], key=lambda x: x['path']), expected)
def test_unavailable_nonbare_and_symlink_input_fail_closed(self):
self.assertEqual(self.gate()[0], 0)
original = self.bare
link = self.root / 'alias'
link.symlink_to(original, target_is_directory=True)
for target in [self.root / 'absent', self.seed / '.git', link]:
with self.subTest(target=str(target)):
self.bare = target
code, value = self.gate()
self.assertEqual(code, 2)
self.assertEqual(value['status'], 'block')
self.bare = original
# Raw Git stderr must not become diagnostic evidence.
(original / 'config').write_text('secret-like fixture invalid config [')
result = subprocess.run(['python3', '-B', str(SCRIPT), '--git-dir', str(original)],
env=self.env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 2)
self.assertNotIn('secret-like', result.stdout + result.stderr)
def test_malformed_nul_listing_is_rejected_not_treated_as_empty(self):
from harness.lib import repository_prefetch as module
raw = self.git('--git-dir', self.bare, 'worktree', 'list', '--porcelain', '-z').encode()
self.assertEqual(module.parse_worktrees(raw)[0]['kind'], 'bare')
for bad in [b'', raw[:-1], raw + raw, raw.replace(b'bare', b'unknown'),
b'worktree /x\0HEAD ' + b'a' * 40 + b'\0detached\0\0',
raw + b'worktree /x\0HEAD ' + b'a' * 40 + b'\0branch refs/heads/../bad\0\0']:
with self.subTest(raw=bad):
with self.assertRaises(module.PrefetchError):
module.parse_worktrees(bad)
def test_reader_failure_and_malformed_output_fail_closed_after_valid_control(self):
from harness.lib import repository_prefetch as module
from unittest.mock import patch
self.assertEqual(module.inspect_repository(str(self.bare))['status'], 'pass')
bare = subprocess.CompletedProcess([], 0, b'true\n')
bad = subprocess.CompletedProcess([], 0, b'invalid secret-like fixture')
for responses in ([bare, bad], [bare, None]):
with self.subTest(responses=str(responses)):
with patch.object(module.binding, '_git', side_effect=responses):
with self.assertRaises(module.PrefetchError) as error:
module.inspect_repository(str(self.bare))
self.assertNotIn('secret-like', str(error.exception))
with patch.object(module.binding, '_git', side_effect=module.binding.BindingError('secret-like fixture')):
with self.assertRaisesRegex(module.PrefetchError, '^git_read_unavailable$'):
module.inspect_repository(str(self.bare))
def test_documentation_contract_requires_gate_before_fetch(self):
text = (ROOT / 'harness/operations/repository-cache.md').read_text()
section = text.split('## 기존 cache 갱신', 1)[1].split('\n## ', 1)[0]
self.assertIn('repository_prefetch.py', section)
self.assertLess(section.index('repository_prefetch.py'), section.index('prune/fetch'))
for rule in ['--git-dir', 'repository lock', '자동 repair', 'exit 0', 'exit 1', 'exit 2']:
self.assertIn(rule, section)
def fingerprint(self, *roots):
import hashlib
result = {}
for root in roots:
for path in ([root] if root.is_file() else root.rglob('*')):
if path.is_file():
st = path.stat()
result[str(path)] = (hashlib.sha256(path.read_bytes()).hexdigest(),
st.st_mode, st.st_mtime_ns, st.st_ino)
return result
if __name__ == '__main__':
unittest.main()
@@ -54,6 +54,22 @@ class VerificationContractDocumentationTests(unittest.TestCase):
self.assertIn("-B", plan["commands"][0]["argv"]) self.assertIn("-B", plan["commands"][0]["argv"])
self.assertIn("PYTHONDONTWRITEBYTECODE", plan["command_env_allowlist"]) self.assertIn("PYTHONDONTWRITEBYTECODE", plan["command_env_allowlist"])
def test_full_suite_tracked_background_reconciles_before_retry(self):
text = SKILLS['rework'].read_text()
heading = '### Tracked bounded background evidence execution'
self.assertIn(heading, text)
section = text.split(heading, 1)[1].split('\n### ', 1)[0]
for rule in ['foreground tool 시간창', 'tracked background',
'process/session handle', 'run-id', 'source SHA', 'plan digest',
'output path', 'timeout_seconds', '유한한 outer deadline',
'manifest', 'validate', 'cleanup', 'pending/partial',
'중복 실행하지 않는다', '새 run-id', '별도 승인',
'TDD RED', '세션 종료 이후의 지속성을 보장하지 않는다']:
with self.subTest(rule=rule):
self.assertIn(rule, section)
self.assertIn('자동 retry하지 않는다', section)
self.assertIn('기존 artifact를 덮어쓰지 않는다', section)
def test_command_deadline_and_archive_policy_is_explicit(self): def test_command_deadline_and_archive_policy_is_explicit(self):
import json import json
template = ROOT / "harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json" template = ROOT / "harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json"
@@ -0,0 +1,110 @@
"""Reviewer recipe contracts; no live provider or publication calls."""
import json
import shlex
import stat
import string
import subprocess
import sys
import unittest
from pathlib import Path
from harness.tests.impl import test_evidence_manifest as fixtures
ROOT = Path(__file__).resolve().parents[3]
REFERENCE = ROOT / "harness/agents/review/skills/gitea-code-review/references/native-review.md"
HELPER_RELATIVE = "harness/agents/impl/skills/gitea-issue-rework/scripts/evidence_manifest.py"
HEADING = "## Reviewer verification evidence recipe"
class NativeEvidenceRecipeTests(unittest.TestCase):
def recipe(self):
text = REFERENCE.read_text(encoding="utf-8")
self.assertTrue(HEADING in text, "reviewer evidence-manifest recipe is missing")
return text.split(HEADING, 1)[1].split("\n## ", 1)[0]
def test_current_recipe_preserves_canonical_path_and_archive_contracts(self):
section = self.recipe()
for required in (HELPER_RELATIVE, "verification_root.py prepare", "evidence-manifest/v3",
"validate-archived-v2", "read-only", "run-relative", "clean checkout",
"no source/evidence exclusions", "0700", "0600"):
self.assertIn(required, section)
block = section.split("```sh\n", 1)[1].split("```", 1)[0]
lines = block.replace("\\\n", "").splitlines()
commands = [shlex.split(line) for line in lines if line.strip() and not line.lstrip().startswith("#")]
self.assertEqual(len(commands), 2)
self.assertEqual([command[:4] for command in commands],
[["python3", "-B", "$HARNESS_ROOT/" + HELPER_RELATIVE, "run"],
["python3", "-B", "$HARNESS_ROOT/" + HELPER_RELATIVE, "validate"]])
for command in commands:
self.assertEqual(command[command.index("--contract-path") + 1],
"$HARNESS_ROOT/harness/operations/repository-cache.md")
self.assertEqual(command[command.index("--repo-root") + 1], "$REPO_ROOT")
self.assertEqual(command[command.index("--artifact-root") + 1], "$VERIFICATION_RUN_ROOT")
self.assertEqual(commands[0][commands[0].index("--plan") + 1], "evidence-plan.json")
self.assertEqual(commands[0][commands[0].index("--output") + 1], "evidence-manifest.json")
self.assertEqual(commands[1][commands[1].index("--manifest") + 1], "evidence-manifest.json")
def test_documented_commands_run_validate_and_reject_absolute_artifact_paths(self):
section = self.recipe()
fixture = fixtures.EvidenceManifestTests()
try:
fixture.setUp()
# Install exact current helper bytes into an isolated canonical-shaped
# synthetic harness. No fork, wrapper, live lease or provider is used.
source = ROOT / HELPER_RELATIVE
helper = fixture.root / HELPER_RELATIVE
helper.parent.mkdir(parents=True)
helper.write_bytes(source.read_bytes())
self.assertEqual(helper.read_bytes(), source.read_bytes())
fixture.plan().rename(fixture.artifacts / "evidence-plan.json")
variables = {"HARNESS_ROOT": str(fixture.root), "REPO_ROOT": str(fixture.repo),
"VERIFICATION_RUN_ROOT": str(fixture.artifacts)}
block = section.split("```sh\n", 1)[1].split("```", 1)[0]
lines = string.Template(block).substitute(variables).replace("\\\n", "").splitlines()
commands = [shlex.split(line) for line in lines if line.strip() and not line.lstrip().startswith("#")]
self.assertEqual(len(commands), 2)
self.assertEqual([command[:4] for command in commands],
[["python3", "-B", str(helper), "run"],
["python3", "-B", str(helper), "validate"]])
for command in commands:
command[0] = sys.executable
def invoke(command):
return subprocess.run(command, cwd=fixture.repo, text=True, capture_output=True, timeout=15)
run = invoke(commands[0])
self.assertEqual(run.returncode, 0, run.stderr)
manifest_path = fixture.artifacts / "evidence-manifest.json"
manifest = json.loads(manifest_path.read_text())
self.assertEqual(manifest["schema"], "evidence-manifest/v3")
self.assertEqual(manifest["source_revision"], fixtures.run_git(fixture.repo, "rev-parse", "HEAD").stdout.strip())
self.assertEqual(manifest["plan"]["path"], "evidence-plan.json")
self.assertEqual(manifest["commands"][0]["argv"], ["git", "show", "HEAD:input.txt"])
self.assertEqual(manifest["commands"][0]["exit_code"], 0)
self.assertEqual(manifest["commands"][0]["output"], "hello\n")
validated = invoke(commands[1])
self.assertEqual(validated.returncode, 0, validated.stderr)
before = manifest_path.read_bytes()
mode = stat.S_IMODE(manifest_path.stat().st_mode)
self.assertEqual(mode, 0o600)
for flag, base_command, filename in (
("--plan", commands[0], "evidence-plan.json"),
("--output", commands[0], "bad-output.json"),
("--manifest", commands[1], "evidence-manifest.json")):
with self.subTest(flag=flag):
invalid = list(base_command)
invalid[invalid.index(flag) + 1] = str(fixture.artifacts / filename)
if flag == "--plan":
# Use an absent output so collision cannot mask the path rejection.
invalid[invalid.index("--output") + 1] = "bad-plan.json"
rejected = invoke(invalid)
self.assertEqual(rejected.returncode, 2)
self.assertIn("artifact path must be run-relative", rejected.stderr)
self.assertEqual(manifest_path.read_bytes(), before)
self.assertEqual(stat.S_IMODE(manifest_path.stat().st_mode), mode)
self.assertFalse((fixture.artifacts / "bad-plan.json").exists())
self.assertFalse((fixture.artifacts / "bad-output.json").exists())
self.assertEqual(fixtures.run_git(fixture.repo, "status", "--porcelain").stdout, "")
finally:
if hasattr(fixture, "tmp"):
fixture.tearDown()
@@ -189,6 +189,87 @@ class NativeReviewTests(unittest.TestCase):
m.publish(p, before, api, lease, self.canonical(m, p), declaration={'mode': 'none'}) m.publish(p, before, api, lease, self.canonical(m, p), declaration={'mode': 'none'})
self.assertNotIn('secret transport detail', str(error.exception)) self.assertNotIn('secret transport detail', str(error.exception))
self.assertEqual(api.posts, 1) self.assertEqual(api.posts, 1)
def test_empty_review_comments_no_header_contract_is_explicit(self):
text = (ROOT / 'harness/agents/review/skills/gitea-code-review/references/native-review.md').read_text()
self.assertIn('### Empty review-comments readback without a count header', text)
section = text.split('### Empty review-comments readback without a count header', 1)[1].split('\n### ', 1)[0]
for rule in ['X-Total-Count', 'comments_count=0', 'JSON `[]`',
'declared_total=None', 'rel=next', 'blind retry']:
self.assertIn(rule, section)
def test_api_adapter_empty_comments_without_header_verifies_publication(self):
m = self.module()
p, before, after, exact = publication_fixture()
def collect(source, comments=None, headers=None):
calls = []
def request(path, payload=None):
self.assertIsNone(payload, 'readback regression must be GET-only')
calls.append(path)
direct = {'/user': p['reviewer'], '/users/gitea-spec-agent': p['spec_reviewer'],
'/repos/acme/repo': source['repository'],
'/repos/acme/repo/issues/3': source['issue'],
'/repos/acme/repo/pulls/7': source['pr'],
'/repos/acme/repo/pulls/7/reviews/10': exact}
if path in direct:
return direct[path], {}, 200
from urllib.parse import urlsplit
endpoint = urlsplit(path).path
for name, suffix in [('files', '/pulls/7/files'), ('commits', '/pulls/7/commits'),
('reviews', '/pulls/7/reviews'), ('issue_comments', '/issues/3/comments'),
('pr_comments', '/issues/7/comments'),
('statuses', '/statuses/' + p['target']['head'])]:
if endpoint == '/repos/acme/repo' + suffix:
return copy.deepcopy(source[name]), {'X-Total-Count': str(len(source[name]))}, 200
if '/reviews/' in endpoint and endpoint.endswith('/comments'):
return ([] if comments is None else comments), ({} if headers is None else headers), 200
raise AssertionError('unexpected synthetic readback endpoint')
api = m.ApiAdapter(request, origin=m.ORIGIN + '/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN')
snapshot = api.snapshot(p['target'])
self.assertEqual(calls[0], '/user')
self.assertTrue(any('/reviews/' in path and '/comments?' in path for path in calls))
return api, snapshot
_, initial = collect(before)
api, final = collect(after)
m.validate_snapshot(p, initial)
m.validate_snapshot(p, final)
authoritative = api.read_review(p['target'], exact['id'])
m.verify_publication(p, initial, final, authoritative)
for snapshot in [initial, final]:
for rid in snapshot['review_comments']:
self.assertEqual(snapshot['review_comments'][rid], [])
self.assertEqual(snapshot['pagination']['review_comments_' + rid],
[{'page': 1, 'count': 0, 'declared_total': None, 'link': None}])
# The admitted control above distinguishes intentional rejection from
# a broken fixture. Missing headers never waive the review count or
# the complete-collection and newly-created no-inline-comment checks.
for count in [1, None, True]:
with self.subTest(count=count):
drift = copy.deepcopy(final)
drift['reviews'][-1]['comments_count'] = count
with self.assertRaisesRegex(m.ValidationError, 'review comment cardinality mismatch'):
m.validate_snapshot(p, drift)
_, unexpected = collect(after, comments=[{'id': 999}])
for review in unexpected['reviews']:
review['comments_count'] = 1
m.validate_snapshot(p, unexpected)
exact_with_inline = copy.deepcopy(authoritative)
exact_with_inline['comments_count'] = 1
with self.assertRaisesRegex(m.ValidationError, 'exact review binding mismatch'):
m.verify_publication(p, initial, unexpected, exact_with_inline)
with self.assertRaisesRegex(m.ValidationError, 'truncated pages'):
collect(after, headers={'X-Total-Count': '1'})
truncated = copy.deepcopy(final)
truncated['pagination']['review_comments_' + str(exact['id'])][0]['link'] = '<next>; rel=next'
with self.assertRaisesRegex(m.ValidationError, 'truncated pagination'):
m.validate_snapshot(p, truncated)
with self.assertRaisesRegex(m.ValidationError, 'truncated pagination'):
m.check_pages([{'id': i + 1} for i in range(50)],
[{'page': 1, 'count': 50, 'declared_total': None, 'link': None}])
def test_api_adapter_checks_identity_first_and_collects_all_pages(self): def test_api_adapter_checks_identity_first_and_collects_all_pages(self):
m = self.module() m = self.module()
self.assertTrue(callable(getattr(m, 'ApiAdapter', None)), 'REST bridge is missing') self.assertTrue(callable(getattr(m, 'ApiAdapter', None)), 'REST bridge is missing')
@@ -0,0 +1,26 @@
"""Static documentation contracts, not agent behavior or automatic loop detection."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
SKILL = ROOT / 'harness/agents/review/skills/gitea-code-review/SKILL.md'
class ReviewConvergenceDocumentationTests(unittest.TestCase):
def test_documentation_contract_requires_recurrence_escalation_and_bound_evidence(self):
text = SKILL.read_text()
heading = '### 재리뷰 수렴과 에스컬레이션'
self.assertIn(heading, text)
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
for rule in ('수정 matrix', 'source delta', '영향 경로',
'head와 실행 입력', '미변경 코드', '첫 리뷰 누락',
'재차 반복', '자동 rework handoff를 중단', '소유자',
'남은 findings', 'closure plan', '확인 불가',
'자동 승인', 'blocker를 면제', '현재 head의 spec·code pass',
'새 counter', '자동 반복 탐지기', '기존 journal'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,46 @@
"""Documentation contracts only: no policy discovery/log append/agent execution."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
SKILL = ROOT / 'harness/agents/review/skills/gitea-code-review/SKILL.md'
HEADING = '## 조건부 observability 정책 확인과 종료 기록'
class ReviewObservabilityDocumentationTests(unittest.TestCase):
def test_documentation_contract_requires_active_role_policy_and_conditional_scope(self):
text = SKILL.read_text()
self.assertIn(HEADING, text, 'conditional observability guidance is missing')
section = text.split(HEADING, 1)[1].split('\n## ', 1)[0]
for rule in ('현재 review role', '실제 설정', '적용 범위', 'not_applicable',
'추정하지', '다른 profile', 'spec 정책', '정책 확인 실패',
'참조된 정책이 누락', '비적용으로 처리하지 않는다',
'정책 참조가 없음을 실제 설정에서 확인',
'새 logger', '전역 설정', 'schema'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
self.assertNotIn('/opt/policies/', section)
startup = text.split('## 사용 시점과 정본', 1)[1].split('\n## ', 1)[0]
self.assertIn('조건부 observability 정책 확인과 종료 기록', startup)
def test_documentation_contract_requires_separate_logging_status_and_final_report(self):
text = SKILL.read_text()
section = text.split(HEADING, 1)[1].split('\n## ', 1)[0]
for rule in ('최종 보고 전에', 'run 기록', '개선 후보', '일일 요약',
'새로 append한', 'readback', 'credential', '0건',
'완료/실패/미확인/비적용', 'review verdict', 'publication', 'cleanup',
'테스트나 native POST를 반복하지', '기록 경로', '실패·한계',
'출처·내용이 결합', '문서 계약', '실제 append',
'정책이 요구하는 보고 항목에 한정',
'후보 수를 요구하지 않으면', '임의로 0건'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
final = text.split('## 주의 및 완료 보고', 1)[1].split('\n## ', 1)[0]
self.assertIn('조건부 observability', final)
self.assertIn('세부 항목은 적용 정책이 요구한 경우에만', final)
self.assertNotIn('기록 상태·경로와 적용 정책', final)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,53 @@
"""Static documentation contracts; no dependency resolver or probe parser is executed."""
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[3]
SKILL = ROOT / 'harness/agents/review/skills/gitea-code-review/SKILL.md'
class ReviewProbeDocumentationTests(unittest.TestCase):
def test_documentation_contract_requires_dependency_provenance_and_failure_preservation(self):
text = SKILL.read_text()
heading = '### Dependency provenance와 probe JSON framing'
self.assertIn(heading, text)
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
for rule in ('별도로 승인된', '실제 interpreter', 'resolved dependency 버전',
'source revision', 'lockfile', '실패한 resolution·collection',
'baseline', '제품 회귀', '새 venv', '설치·pinning 권한',
'기존 evidence'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
def test_documentation_contract_requires_exactly_one_framed_record_and_safe_errors(self):
section = SKILL.read_text().split('### Dependency provenance와 probe JSON framing', 1)[1].split('\n## ', 1)[0]
for rule in ('producer와 consumer', '고유 marker', '정확히 하나의 완전한 JSON record',
'누락·중복·malformed·truncation', '전체 stdout', 'brace',
'warning', 'exit status', 'TDD RED', 'timeout', '출력 한도',
'redaction', 'generic parser'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
def test_documentation_contract_requires_bound_content_and_control_response_handling(self):
text = SKILL.read_text()
heading = '### Programmatic read control response와 script 준비'
self.assertIn(heading, text)
section = text.split(heading, 1)[1].split('\n## ', 1)[0]
for rule in ('첫 content-bearing 응답', 'exact 경로', 'source revision',
'hash', '읽은 범위', '응답 shape', 'control response',
'unchanged/dedup', '보존된 내용', 'evidence_gap',
'force-read', '전역 dedup', 'stale cache'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
def test_documentation_contract_requires_compile_before_execution_and_preserves_authority(self):
section = SKILL.read_text().split('### Programmatic read control response와 script 준비', 1)[1].split('\n## ', 1)[0]
for rule in ('typed JSON', '다중 quoted', 'compile', '실행·로그 append·외부 action 전에',
'문법 검사', '안전성·인증·실행 성공', 'credential',
'commit/push', 'review role', '권한을 확장하지 않는다', 'TDD RED'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,22 @@
"""Reviewer timeout recipe documentation only; not a runner/runtime test."""
from pathlib import Path
import unittest
SKILL = Path(__file__).resolve().parents[3] / 'harness/agents/review/skills/gitea-code-review/SKILL.md'
HEADING = '### 장시간 suite의 timeout evidence recipe'
class TimeoutEvidenceRecipeDocumentationTests(unittest.TestCase):
def test_conditional_recipe_preserves_evidence_without_automatic_retry(self):
text = SKILL.read_text()
self.assertIn(HEADING, text)
section = text.split(HEADING, 1)[1].split('\n### ', 1)[0].split('\n## ', 1)[0]
for rule in ('장시간 suite', '실행 시작부터', '승인된 artifact 경로',
'source revision', 'run-id', '명령', 'deadline', 'redaction',
'출력 한도', 'exit status', 'timeout', '실제 종료', 'cleanup',
'새 run-id', '덮어쓰지 않는다', '자동 재실행하지 않는다',
'유한한 budget', '관측 시간', '여유 근거', '동일 입력',
'JUnit', '이미 지원', '완성된 JUnit', 'pytest를 강제하지',
'새 runner', 'streaming framework', '미검증', '문서 계약'):
with self.subTest(rule=rule):
self.assertIn(rule, section)
self.assertIn('lease·head·source', section)
@@ -61,6 +61,128 @@ def verify_fixture(before, after, review, r, paths, contract=None):
expected_pr_url="https://example.test/acme/repo/pulls/7") 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): class SupportTests(unittest.TestCase):
def test_loads_json_rejects_duplicate_and_nonfinite(self): def test_loads_json_rejects_duplicate_and_nonfinite(self):
with self.assertRaises(mod.ValidationError): with self.assertRaises(mod.ValidationError):
@@ -1,4 +1,4 @@
"""Static contract regressions; these do not prove LLM execution behavior.""" """Documentation contracts and local synthetic controls; not agent/live execution."""
from pathlib import Path from pathlib import Path
import re import re
import unittest import unittest
@@ -10,6 +10,22 @@ README = HARNESS / 'docs/discovery/README.md'
class PriorityReviewContracts(unittest.TestCase): 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): def test_scope_classification_and_optional_nonblocking(self):
text = README.read_text() text = README.read_text()
for value in ('핵심 요청:', '기존 호환성:', '선택적 확장:', for value in ('핵심 요청:', '기존 호환성:', '선택적 확장:',
@@ -58,6 +74,73 @@ class PriorityReviewContracts(unittest.TestCase):
'제품 저장소의 새 산출물이나 result schema 변경을 요구하지'): '제품 저장소의 새 산출물이나 result schema 변경을 요구하지'):
self.assertIn(value, text) 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): def test_local_links_resolve_and_procedures_reference_new_rules(self):
for path in (DISCOVERY, IMPLEMENTATION, README): for path in (DISCOVERY, IMPLEMENTATION, README):
text = path.read_text() text = path.read_text()
@@ -68,5 +151,100 @@ class PriorityReviewContracts(unittest.TestCase):
self.assertIn('11. 문서 검토 입력과 구조·의미 분리 절차', IMPLEMENTATION.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__': if __name__ == '__main__':
unittest.main() unittest.main()