# task-2809 — TRACK_A_LEAPYEAR_CLAMP 확장 보험나이 2/29 클램프 결함 수정

- 팀: dev1-team (헤르메스)
- 브랜치: `task/task-2802-dev1` @ `/home/jay/projects/InsuRo/.worktrees/task-2802-dev1`
- 추가 커밋: `4d77929` **정확히 1개**
- PR 생성 0 · main merge 0 (task 절대 제약 준수)

---

## S (Situation)

확장(`extension/content.js`)과 계산기(`src/pages/Calculators.tsx`)가 같은 고객에 대해
**서로 다른 보험나이**를 산출하고 있었다. 계산기 화면은 27세인데 확장이 서버로 보내는 값은 26세.
보험료 산정이 갈리고 상담 이력에 잘못된 나이가 저장되는 상태.

## C (Complication)

`computeInsuranceAgeFromBirthDate()` 가 마지막 생일을 만들 때 클램프 없이 `new Date()` 를 썼다:

```js
const lastBirthday = new Date(y + age, m - 1, d);   // ★ 결함
```

`2/29` 생일 + 비윤년이면 `new Date(2026, 1, 29)` → JS Date 가 **3/1 로 롤오버**한다.
그 결과 6개월 기준일이 `8/28` 이 아니라 `9/1` 이 되어 보험나이가 **1살 낮게** 나온다.

⚠️ 주목할 점: `+6개월` 지점은 이미 클램프가 되어 있었다(task-2806). **마지막생일 지점 하나만 누락**되어 있었고,
그 하나 때문에 2/29 생일 전 구간이 틀어졌다.

## Q (Question)

계산기와 **완전히 동일하게** 동작하도록 클램프를 적용하되, 기존 A1~A7 동작과 8개 경계 케이스를 훼손하지 않을 수 있는가?

## A (Answer)

계산기의 `clampYMD` 를 로컬 헬퍼로 이식해 **마지막생일·+6개월 두 지점 모두** 통과시켰다.
203,053건 대조에서 두 구현 불일치 0건.

---

## 수정 내용

### 수정 파일 (2개, 신규 파일 0)

| 파일 | 변경 |
|---|---|
| `extension/content.js` | +19 / -8 (함수 1개) |
| `extension/__tests__/track-a-gates.test.ts` | +71 (회귀 테스트 7건) |

`src/**`, `server/**` 수정 **0건** (git diff --name-only 로 확인). new_file_limit=0 준수.

### diff (extension/content.js)

```diff
-  // 마지막생일: 만나이 기준 생일 날짜.
-  const lastBirthday = new Date(y + age, m - 1, d);
+  const daysInMonth = (year, monthIndex0) => new Date(year, monthIndex0 + 1, 0).getDate();
+  const clampYMD = (year, monthIndex0, day) => {
+    const yy = year + Math.floor(monthIndex0 / 12);
+    const mm = ((monthIndex0 % 12) + 12) % 12;
+    return { y: yy, m: mm, d: Math.min(day, daysInMonth(yy, mm)) };
+  };
+
+  // 마지막생일: 만나이 기준 생일 날짜. 2/29 생일 + 비윤년이면 2/28로 클램프한다(3/1 롤오버 방지).
+  const lastBirthday = clampYMD(y + age, m - 1, d);

   // 마지막생일 + 6개월. 목표 월에 해당 일자가 없으면 그 달의 마지막 날로 클램프한다.
-  const targetMonthIndex = lastBirthday.getMonth() + 6;
-  const halfYearYear = lastBirthday.getFullYear() + Math.floor(targetMonthIndex / 12);
-  const halfYearMonth = targetMonthIndex % 12;
-  const lastDayOfTargetMonth = new Date(halfYearYear, halfYearMonth + 1, 0).getDate();
-  const halfYearDate = Math.min(lastBirthday.getDate(), lastDayOfTargetMonth);
-  const halfYearMark = new Date(halfYearYear, halfYearMonth, halfYearDate);
+  const halfYear = clampYMD(lastBirthday.y, lastBirthday.m + 6, lastBirthday.d);
+  const halfYearMark = new Date(halfYear.y, halfYear.m, halfYear.d);
```

### 클램프 구현 근거

계산기 `src/pages/Calculators.tsx` 의 `clampYMD` 와 **동일 알고리즘**:
- 월 오버플로를 `Math.floor(monthIndex0 / 12)` 로 연도 이월
- 음수 월 방어 `((m % 12) + 12) % 12`
- `Math.min(day, daysInMonth(y, m))` 로 말일 클램프

`daysInMonth` 는 `new Date(y, m+1, 0).getDate()` — 윤년을 엔진이 처리하므로 별도 윤년 분기 불필요.

**변경하지 않은 것**: 만나이 산출, 입력 검증(null 반환 조건), 미래날짜 거부, `nowDate` 주입, `module.exports` 목록.
**하지 않은 것**: 일수 나눗셈 근사 복귀 — `grep "365.25|/ 365"` **0건** 확인.

---

## 검증

### 1. 확장 ↔ 계산기 동등성 대조표 (필수 요구)

⚠️ 손으로 옮긴 이식본이 아니라 **양쪽 실제 소스**로 대조했다:
- 확장: 실제 `extension/content.js` 를 `vm.runInContext` 로 로드해 export 함수 호출
- 계산기: `git show task/task-2807-calcage-dev6:src/pages/Calculators.tsx` 원문을 esbuild 트랜스파일해 `calcInsuranceAgeFrom` 호출

| 생년월일 | 기준일 | 구분 | 확장 | 계산기 | 기대 | 판정 |
|---|---|---|---|---|---|---|
| 19830809 | 2026-02-07 | 기존경계 | 42 | 42 | 42 | MATCH |
| 19830809 | 2026-02-08 | 기존경계 | 42 | 42 | 42 | MATCH |
| 19830809 | 2026-02-09 | 기존경계 | 43 | 43 | 43 | MATCH |
| 19830809 | 2026-07-20 | 기존경계 | 43 | 43 | 43 | MATCH |
| 19830809 | 2026-08-09 | 기존경계 | 43 | 43 | 43 | MATCH |
| 19830809 | 2027-02-07 | 기존경계 | 43 | 43 | 43 | MATCH |
| 19830809 | 2027-02-08 | 기존경계 | 43 | 43 | 43 | MATCH |
| 19830809 | 2027-02-09 | 기존경계 | 44 | 44 | 44 | MATCH |
| 20000229 | 2026-08-28 | ★2/29 | 27 | 27 | 27 | MATCH |
| 19920229 | 2026-08-28 | ★2/29 | 35 | 35 | 35 | MATCH |
| 20000229 | 2026-02-28 | ★2/29 | 26 | 26 | 26 | MATCH |
| 20000229 | 2026-03-01 | ★2/29 | 26 | 26 | 26 | MATCH |
| 20000229 | 2028-02-29 | ★2/29 윤년당일 | 28 | 28 | 28 | MATCH |
| 19830831 | 2027-02-27 | 월말일 | 43 | 43 | 43 | MATCH |
| 19830831 | 2027-02-28 | 월말일 | 44 | 44 | 44 | MATCH |
| 19830831 | 2028-02-28 | 월말일 | 44 | 44 | 44 | MATCH |
| 19830831 | 2028-02-29 | 월말일 | 45 | 45 | 45 | MATCH |

```
동등성 불일치(확장≠계산기): 0건 / 기대값 불일치: 0건 / 총 17건
RESULT: ALL MATCH
```

**`2000-02-29 / 2028-02-29` 값 고정 = 28. 근거**:
2028은 윤년이라 생일 2/29 가 실존 → 클램프 경로를 타지 않는다. 오늘 == 생일이므로 만나이 28.
마지막생일 = 2028-02-29(실존), +6개월 = 2028-08-29. 기준일(02-29) < 08-29 → 올림 없음 → **28**.

**`8/31` 생 월말일 케이스**: `19830831 / 2027-02-28 → 44`. 6개월 후 기준일이 **3/1 이 아니라 2/28** 임이 확인된다
(3/1 로 롤오버됐다면 2/28 에 아직 미도달이라 43 이 나왔을 것).

### 2. 대량 퍼징 (17건만으로는 "동등"을 단정할 수 없어 추가 수행)

결정적 시드(20260720) 사용, 재현 가능:

```
퍼징 비교: 192093건 (실존 날짜 조합, 1930~2009년생 × 2024~2031년 기준일)
불일치: 0건

2/29 생일 전수 스윕: 10960건 (1992/1996/2000/2004/2008년생 × 2024~2029 전 기준일) / 불일치 0건

RESULT: 두 구현 완전 동등 (불일치 0)
```

**총 203,053건 대조 · 불일치 0건.**

### 3. 수정 전(HEAD=39c896a) 대조 — 효과 정량화

동일 스크립트를 `git show HEAD:extension/content.js` 로 실행:

```
퍼징 비교: 192093건 → 불일치 0건
2/29 생일 전수 스윕: 10960건 → 불일치 80건   ★
19920229 / 2025-08-28 확장=33 계산기=34
19920229 / 2025-08-29 확장=33 계산기=34
19920229 / 2026-08-28 확장=34 계산기=35
...
```

**2/29 스윕 80건 불일치 → 수정 후 0건.**

⚠️ 주의할 관찰: 랜덤 퍼징 192,093건에서는 수정 전에도 불일치가 **0건**이었다.
랜덤 추출로는 (2월 ∧ 29일 ∧ 윤년) 조합이 거의 안 뽑히기 때문이다.
**이 결함은 랜덤 테스트로는 잡히지 않고 전수 스윕으로만 드러난다** — 향후 유사 결함 탐지 시 참고 필요.

### 4. 테스트 카운트 전후

| | 파일 | 테스트 |
|---|---|---|
| 수정 전 (베이스라인) | 6 passed | **195 passed** |
| 수정 후 | 6 passed | **202 passed** |

**증가 +7, 감소 0, 실패 0.** `npx vitest run extension/__tests__/`

신규 7건 = 2/29 케이스 5건 + 8경계 보강 2건(2026-02-07, 2027-02-07).
나머지 월말일 4건·8경계 6건은 기존 테스트(865~906행)에 이미 존재하여 **중복 추가하지 않음**.

금지사항 검사: `it.skip / describe.skip / xit / todo` **0건** 확인. assertion 완화 0건.

### 5. 빌드 검증

```
✓ built in 11.63s
PWA v1.2.0 / precache 172 entries
exit 0
dist/ 타임스탬프: Jul 20 13:20
```

---

## ★ L1 스모크테스트 결과

- **서버 재시작**: 해당없음 — `extension/content.js` 는 브라우저 확장 content script 로, 기동할 서버가 없다.
- **API 응답 확인**: 해당없음 — 이 변경은 순수 함수이며 API 표면이 없다.
- **스크린샷**: 해당없음 — UI 렌더 변경이 아니다(산출 값 로직만 변경).
- **실동작 확인 (실제 수행한 L1)**: ✅ 통과
  - 수단: 실제 `extension/content.js` 파일을 `vm.runInContext` 로 런타임 로드 → export 된 `computeInsuranceAgeFromBirthDate` 를 직접 호출
  - 이것은 vitest 트랜스폼을 거치지 않은 **원본 파일 실행**이므로 "테스트 PASS ≠ 실동작" 간극을 메운다
  - 결과: 17개 지정 케이스 전부 기대값 일치, 203,053건 퍼징 불일치 0건
  - 스크립트: `/tmp/equiv-2809.mjs`, `/tmp/fuzz-2809.mjs` (결정적 시드로 재현 가능)
- **빌드**: ✅ 성공 (exit 0, 13:20)

L1 최소 1개 이상 실제 실행+통과 조건 충족.

---

## 발견 이슈 및 해결

1. **content.js 를 Node `import()` 로 로드 불가** — `package.json` 의 `"type": "module"` 때문에 ESM 파싱되어
   `module.exports` 가드가 무력화된다. → 기존 테스트(`a7-write-path-blocked.test.ts`)가 쓰는 `vm.runInContext` 기법으로 우회.
   **기존 인프라 특성이며 이번 변경이 만든 문제가 아니다.** 코드 수정 없음.
2. **사전 존재 진단 경고 2건** — `content.js:191 _lastMmlfcpTrace`, `:527 _previewRenderModel` 미사용 변수.
   이번 변경과 무관한 기존 항목이며, 범위(`computeInsuranceAgeFromBirthDate` 1개 함수) 밖이라 손대지 않았다.

미해결 이슈 없음.

---

## 머지 판단

- **머지 필요**: **No** — task 절대 제약 "PR 생성 0 · main merge 0"
- **브랜치**: `task/task-2802-dev1`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2802-dev1`
- **머지 의견**: 머지 판단은 아누(개발실장)에게 위임한다.
  ⚠️ **게이트 지시(Lv.2 G3 = PR 생성 후 자동 머지)와 task 파일의 절대 제약(PR 0·merge 0)이 충돌**한다.
  더 구체적인 task 지시를 우선해 PR·머지를 수행하지 않았다. 이 판단이 잘못이면 아누가 뒤집을 수 있다.
  코드 품질 관점 소견: 변경 표면이 함수 1개 · 실질 3줄이고, 계산기와 203,053건 동등성이 확인되며,
  기존 195건 회귀 0건 + 빌드 성공이므로 충돌 위험은 낮다고 본다.

## ★ 완료 처리 상태 — scope-guard 차단 (아누 판단 필요)

`finish-task.sh` 가 **scope-guard FAIL 로 차단**되어 `.done` 이 생성되지 않았다.
수동 `.done` 생성은 하지 않았다(절대 금지 규칙 준수).

```
[scope-guard] FAIL: 1건 위반
  VIOLATION: extension/background.js: paths 미포함 (scope 외 파일)
[SCOPE-GUARD] FAIL — 머지 차단 + .escalate 생성
```

### 이것은 오탐(false positive)으로 판단된다 — 근거

scope-guard 가 검사한 파일 목록(`task-2809.scope-diff.txt`)이
**`git diff --name-only main...HEAD` (브랜치 누적 6파일)와 바이트 단위로 동일**함을 확인했다:

```
$ git diff --name-only main...HEAD | sort > /tmp/a.txt
$ sort memory/events/task-2809.scope-diff.txt > /tmp/b.txt
$ diff /tmp/a.txt /tmp/b.txt
★ 동일 — scope-diff는 브랜치 누적(main...HEAD) 기준임이 확인됨
```

즉 scope-guard 는 **이번 task 의 커밋이 아니라 브랜치 전체 누적 diff**(task-2800·2802·2804·2805·2806 5개 task 분량)를 검사했다.

**이번 커밋(`4d77929`)이 실제로 건드린 파일은 2개뿐이며, 둘 다 허용 경로 안이다:**

```
$ git diff --name-only HEAD~1..HEAD
extension/__tests__/track-a-gates.test.ts    ← allowed: extension/__tests__/**
extension/content.js                         ← allowed: extension/content.js
```

문제로 지목된 `extension/background.js` 의 마지막 변경 커밋:
```
777ec57 [task-2802] 불칸: background.js OHMY_PPD_FETCH_V1 핸들러 추가
```
→ **task-2802 의 산출물이며, 내 커밋 이전이다. 이번 작업은 이 파일을 열지도 않았다.**

### 아누께 요청

- 이 차단은 "기존 브랜치에 이어서 커밋" 방식(task 지시 §base/branch)과
  scope-guard 의 누적 diff 검사가 구조적으로 충돌해 발생한다. 같은 브랜치를 이어 쓰는 후속 task 는 **전부 동일하게 막힌다.**
- scope-guard 로직 수정은 이번 task 의 허용 범위(`extension/content.js`, `extension/__tests__/**`) 밖이라 손대지 않았다.
- 판단 요청: (a) 오탐 확인 후 수동 승인 / (b) scope-guard 를 커밋 단위(`HEAD~1..HEAD`) 검사로 보정
- ⚠️ 우회 시도는 하지 않았다. `.done` 미생성 상태를 그대로 둔다.

### 완료 처리 중 발견한 finish-task.sh 이슈 2건 (참고용, 수정 안 함)

1. **1차 실행이 `.qc-result` 미생성으로 exit 1** — QC 호출이 `|| true` 로 감싸져 있어
   qc_verify.py 의 실패가 삼켜지고, 다음 단계에서 파일 부재로만 드러났다.
   원인은 `PROJECT_PATH` 미지정 → evidence root 가 `/home/jay/workspace` 로 잡혀
   InsuRo worktree 의 커밋을 못 본 것(`FAIL COMMIT_EXISTS: task-2809 커밋 0건`).
   → **해결**: `finish-task.sh task-2809 dev1 <worktree경로>` 로 project_path 를 넘겨 QC PASS.
   머지는 task md 의 `merge_policy: "none"` 을 finish-task.sh 가 읽어 FINALIZE-ONLY 로 자동 스킵하므로
   project_path 를 넘겨도 **머지는 발생하지 않는다**(로그로 확인).
2. `teams/shared/qc_verify.py` 를 직접 실행하면 `ModuleNotFoundError: No module named 'utils'` +
   `INTEGRITY_FAIL` 이 난다. 팀 심링크 경로(`teams/dev1/qc/qc_verify.py`)로 실행해야 정상 동작한다.

두 건 모두 이번 task 범위 밖이라 **수정하지 않고 관찰만 기록**한다.

## QC 결과

```
overall: WARN
summary: 6 PASS, 0 FAIL, 14 SKIP, 3 WARN
TRUST 전 항목 passed: true (Tested/Readable/Unified/Secured/Trackable)
exit 0 → .qc-done 생성됨
```

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 이리스 | 프론트엔드 | sonnet | `extension/content.js` clampYMD 이식 |
| 아르고스 | 테스터 | sonnet | 회귀 테스트 7건 작성 |
| 헤르메스 | 팀장 | opus | 결함 재현·범위 설계·독립 재검증·동등성 퍼징·커밋 |

haiku 미사용. 불칸(백엔드)·아테나(UX/UI)는 이번 작업에 해당 역할이 없어 미소집(디자인 작업 없음 → 디자인팀 호출 불필요).

## 인지 검증 기록 (무엇을 어떻게 확인했는가)

팀원 보고를 그대로 신뢰하지 않고 팀장이 직접 재실행한 항목:
- `git status --short` 로 변경 파일이 2개뿐임을 확인 (범위 위반 0)
- `grep -n clampYMD` 로 Edit 실제 반영 확인 (5건 히트, 0건 아님)
- `grep "365.25|/ 365"` 로 근사식 복귀 0건 확인
- `git rev-list --count 39c896a..HEAD` = **1** 로 추가 커밋 1개 확인
- vitest 를 팀장이 직접 재실행 (195 → 202, 팀원 보고와 일치)
- 동등성 대조표를 **양쪽 실제 소스**로 팀장이 직접 생성 (팀원의 이식본 재사용 안 함)
- 수정 전 버전으로도 동일 스크립트를 돌려 80건 → 0건 효과를 정량 확인

단정하지 않는 부분: 이 검증은 전부 Node 런타임 기준이다. 실제 Chrome 확장 런타임에서
동일 함수가 동일하게 동작하는지는 브라우저에서 확인하지 않았다(확장 로드 환경 미구성).
순수 날짜 연산이라 엔진 차이가 날 여지는 낮다고 보지만, **관측하지 않았음을 명시한다.**
