# task-2783+1 보고서 — PR #262 same-PR microfix (Gemini HIGH 정밀화, A안)

- 작업 ID: task-2783+1 / 팀: dev4-team / 레벨: Lv2 (2파일 정밀 microfix + 테스트 재구성)
- 브랜치: `task/task-2783-dev3` (PR #262, OPEN) / head: `1dc47e10`
- 팀장: 비슈누(Vishnu) / 백엔드: 카르티케야(Kartikeya)

## Situation
PR #262(task-2783)는 `transplant_expected_files`의 0바이트 truncate를 fail-closed guard로 막았다. 그러나 Gemini 리뷰에서 **HIGH 1건**이 제기됨: `EMPTY_OVERWRITE_REFUSED` guard(`utils/replacement_pr_runner.py:~209`)가 **정상 empty-file PR까지 과차단**(false positive)한다.

## Complication
Gemini 권고(guard 단순 제거)를 그대로 따르면 진짜 위험 케이스(git show 실패/소스 blob 없음)까지 뚫린다. 회장/GPT 판정 = **단순제거가 아니라 정밀 guard 교체(A안)**: 과차단만 제거하고 진짜 fail-closed는 유지.

## Question
정상 empty blob은 허용(FP 해소)하되, git show 실패·repo-root escape는 계속 차단하려면 무엇을 어떻게 바꾸는가? 신규 PR·merge·forbidden 파일 접촉 없이.

## Answer (A안 — GPT verbatim 준수)

### 수정 파일 (정확히 2개)
1. `utils/replacement_pr_runner.py` (+4 / -7)
2. `tests/regression/test_replacement_pr_runner_2510.py` (+35 / -14... net +26)

### 코드 변경: `transplant_expected_files`
- **`EMPTY_OVERWRITE_REFUSED`(Guard 2) 제거** — 정상 empty blob 과차단 false-positive 원인. `if not content and resolved_target.exists() and ...st_size > 0: raise` 블록 삭제.
- **`content = sr.stdout or ""` + `target.write_text(content)` 유지** → rc0 + stdout empty = 정상 empty blob으로 **write 허용**.
- **`GIT_SHOW_FAILED`(returncode != 0) 체크 유지** — git show 실패/소스 blob 없음은 계속 fail-closed(기존 파일 보존).
- **Guard 1 `TARGET_OUTSIDE_REPO_ROOT` 유지** — repo root 밖 resolve fail-closed.
- **`cwd = repo_dir or str(WORKSPACE)` fallback 무접촉** — merge_queue_executor.py:1745(repo_dir 없이 호출)가 깨지지 않도록 mandatory화 금지(지시 준수).

### 테스트 재구성 (T17/T18)
- `test_t17_empty_git_show_refuses_zero_byte_overwrite` (과차단 검증) **제거**.
- `test_t17_empty_git_show_allows_empty_blob` **신규**: git show rc0 + empty stdout → 기존 non-empty target이 있어도 **empty file 반영 허용**(size==0), Gemini FP 해소 검증.
- `test_t17b_git_show_failure_fail_closed_preserves_file` **신규**: git show returncode=128 → `GIT_SHOW_FAILED` raise + 기존 non-empty 파일 **보존**(0바이트 truncate 미발생).
- `test_t18_target_outside_repo_root_fails` (Guard 1) **무수정 유지**.
- 기존 tmp_path 격리(T01/T03/T04/T17/T17b/T18…) **유지**.

## Gemini HIGH false-positive 해소 (코드 설명)
기존 로직은 `content`가 빈 문자열이면(정상 empty blob이든 오류든 구분 없이) 기존 target이 non-empty일 때 무조건 raise → **정상적으로 파일을 비우는 PR(empty blob)까지 차단**했다. A안은 "빈 content"의 두 원인을 분리한다:
- **git show 실패(returncode != 0)** → `GIT_SHOW_FAILED`로 이미 함수 상단에서 fail-closed (기존 파일 보존).
- **git show 성공(rc0) + stdout empty** = 정상 empty blob → 그대로 write 허용.
따라서 rc0에 도달한 빈 content는 항상 정상 empty blob이며, 이를 막던 Guard 2를 제거해도 진짜 위험(rc!=0)은 GIT_SHOW_FAILED가 계속 방어한다. → **정상 empty-file PR 차단(FP) 해소**.

## 검증

### pytest
- `python3 -m pytest tests/regression/test_replacement_pr_runner_2510.py -q` → **22 passed in 0.16s**
- 신규/유지 케이스: T17(allows_empty_blob) PASS / T17b(git_show_failure fail-closed) PASS / T18(target_outside Guard1) PASS.

### canonical size/sha 불변 (pytest 전/후, 0바이트 미발생)
- `utils/replacement_pr_runner.py`: size=34338, sha=`95f4eeb9…3887105` — **전/후 동일** ✅
- `tests/regression/test_replacement_pr_runner_2510.py`: size=28001, sha=`aaa4bd01…56e376c` — **전/후 동일** ✅

### diff 범위
- `git diff 62ea27ae..HEAD --stat` → **정확히 2파일만** 변경. `merge_queue_executor.py`·`real_merge_hooks.py`·forbidden 경로 **무접촉** 확인.

### grep 검증
- `EMPTY_OVERWRITE_REFUSED` raise 구문 **0건**(제거 확인).
- `GIT_SHOW_FAILED`(2건)·`TARGET_OUTSIDE_REPO_ROOT`(1건) **존재**(유지 확인).

## L1 스모크테스트 결과 (실제 함수 직접 호출 — subprocess/정제 유형)
- 서버 재시작: 해당없음(라이브러리 함수, 서버 아님).
- API 응답 확인: 해당없음. 대신 **실제 `transplant_expected_files` 직접 호출**로 실동작 검증:
  - **L1-A** rc0+empty blob → `result=['pkg/mod.py']`, 파일 size=0 → **PASS**(정상 empty blob 허용, FP 해소 실증).
  - **L1-B** git show rc128 → `GIT_SHOW_FAILED` raise + 기존 파일 원문 보존 → **PASS**(fail-closed 유지).
  - **L1-C** `../escape.py` → `TARGET_OUTSIDE_REPO_ROOT` raise → **PASS**(Guard 1 유지).
- 스크린샷: 해당없음(백엔드 라이브러리 함수).
- 결론: pytest 22 PASS에 더해, 실제 함수 호출로 3개 핵심 시나리오 실동작 확인.

## ★ A안 한계 명기 (GPT 필수)
A안은 **완전한 코드 방어가 아니다**. 현재 재현된 QC truncate는 (테스트 tmp_path 격리 + Guard1 + GIT_SHOW_FAILED)로 막지만, **"repo_dir 미지정 시 `WORKSPACE` fallback" 구조적 위험은 이번 PR에서 완전히 제거되지 않았다**(merge_queue_executor.py:1745가 repo_dir 없이 호출하므로 이번 범위에서 mandatory화 금지). → **후속 task 필요**.

## 후속 task 후보 (이번 범위 아님 — 기록만)
`transplant/write 경로 repo_dir mandatory화 or WORKSPACE fallback 제거 + merge_queue_executor.py:1745 호출부 repo_dir 주입 + dormant real-merge 경로 영향 검증`. 별도 expected_files·별도 승인 필요.

## 머지 판단
- **머지 필요**: No (이번 task는 PR #262에 커밋 추가·갱신만. merge_policy: none, 신규 PR·merge 금지 지시).
- **브랜치**: `task/task-2783-dev3`
- **워크트리 경로**: `/home/jay/workspace/.worktrees/task-2783-dev3`
- **머지 의견**: PR #262 head가 `1dc47e10`으로 갱신됨. Gemini 재리뷰(HIGH 해소 확인)는 PR #262 파이프라인에서 진행. 이번 executor는 커밋·push까지만 소유하며 merge는 수행하지 않음.

## 모델 사용 기록
- 카르티케야(백엔드): **sonnet** — 2파일 정밀 코드 수정(Guard 제거 + 테스트 재구성). haiku 미사용.
- 비슈누(팀장, opus): 설계/검증/통합/보고(코딩 직접 수행 안 함).

## 발견 이슈 및 해결
- pyright 경고(unused import `assert_clean_working_tree`/`precheck_local_replacement_diff`, `branch` 미사용 등)는 **이번 2줄 변경과 무관한 기존 항목**이며 allowed_resources 범위·A안 지시 밖이라 무접촉(수정 시 diff 오염 위험). 범위 외로 판단, 미수정.

## ★★ 사고 기록 — 구조적 위험 실증 (finish-task.sh 중 canonical 0바이트 truncate)
완료 절차(finish-task.sh) 실행 중, **main workspace의 canonical 2파일이 0바이트로 truncate되는 사고가 실제로 발생**했다. 이는 본 task가 "이번 범위 밖·후속 task 필요"로 명시한 **WORKSPACE fallback 구조적 위험이 라이브로 발현**된 사례다.

### 관찰된 단서
- `/home/jay/workspace/utils/replacement_pr_runner.py` → 0B, `.../tests/regression/test_replacement_pr_runner_2510.py` → 0B (mtime 07:47, 본 세션 중).
- 두 파일은 정확히 본 task의 `expected_files` 2개와 일치. git 추적 중(HEAD blob 정상 33557/24403B).
- GOAL-GATE(`pytest ...test_replacement_pr_runner_2510.py`)가 빈 파일 → 0 items → exit 5 → **fail-closed BLOCK**로 이를 포착(게이트가 사고를 잡아냄).

### 원인 판단 (확신도 표기)
- **가장 유력**: PR #262 push를 픽업한 **merge-readiness/real-merge 시뮬레이션**이 `transplant_expected_files`를 `repo_dir=None`으로 호출 → `cwd = repo_dir or str(WORKSPACE)` fallback → canonical WORKSPACE 파일에 직접 write. git show가 해당 base에서 empty 반환 → 0바이트 write.
- 근거: `transplant`/`replacement` 호출자는 real-merge 경로(`real_merge_hooks.py`/`merge_queue_executor.py`/`repository_policy_adapter.py`)뿐이며 finish-task.sh 본체에는 해당 호출 없음. `run_auto_pr_merge_readiness_dry_run_2609` pyc 존재.
- **단, 단정하지 않음**: 정확한 트리거 스크립트/타이밍은 본 executor 권한 밖(forbidden: merge_queue/real_merge)이라 코드로 끝까지 추적하지 않았다. main 브랜치 코드에는 guard 자체가 없어(62ea27ae 미머지) 본 PR 브랜치 변경과 **무관**하게 truncate가 발생.

### 조치
- `git checkout -- <2파일>`로 즉시 복원 → 33557/24403B 정상 복구, git status clean.
- finish-task.sh 재실행 → **재truncate 없음**(파일 안정), GOAL-GATE PASS, .done 생성. 실행 중/후 merge 관련 프로세스 부재 확인 → **1회성 이벤트**로 판단.

### 함의 (후속 task 강화 근거)
- A안(guard 제거)은 정상 empty-file PR FP는 해소하나, **real-merge 경로의 WORKSPACE fallback truncate는 방어하지 못한다**(오히려 guard 제거로 그 경로에서 empty write가 더 자유로워짐). 본 사고는 후속 task(`repo_dir mandatory화 / WORKSPACE fallback 제거 / merge_queue_executor:1745 repo_dir 주입`)의 **우선순위를 P1로 격상할 실증 근거**다.
- 게이트 관점: GOAL-GATE fail-closed가 0바이트 canonical을 정확히 포착해 잘못된 .done을 차단 → 방어선 정상 작동.

## 산출물
- 커밋: `1dc47e10` [task-2783+1] 카르티케야: A안 — EMPTY_OVERWRITE_REFUSED 제거 + GIT_SHOW_FAILED/Guard1 유지 + T17/T18 재구성
- push: `62ea27ae..1dc47e10 task/task-2783-dev3` → PR #262 갱신 (OPEN, 신규 PR 없음).
- 완료: `.done` 생성(전 게이트 PASS), executor result.json(`owner_pickup_required=true`, `authoritative=false` — ANU OS-level pickup closeout 소유), 텔레그램 알림 발송.
- 사고: canonical 2파일 0바이트 truncate 발생 → git checkout 복원 → 재발 없음(상세 위 ★★ 섹션).
