From d0d9461d7519595aa417554ef6251884304909cf Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 13:20:54 +0900 Subject: [PATCH 01/11] =?UTF-8?q?docs(spec):=20v0.3.2=20cleanup=20cut=20de?= =?UTF-8?q?sign=20=E2=80=94=20=EC=9E=A0=EC=9E=AC=20bug=204=20+=20cosmetic?= =?UTF-8?q?=206=20+=20=EA=B8=B0=EB=A1=9D=20=EC=A0=95=EB=A6=AC=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backlog 잔여 23건 audit 결과: - 잠재 bug 4건: vocabSet COLLATE / time-dep test flake / PII reason / KST inline 5 callsite - cosmetic 6건: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on / OllamaSettingsModal 폐기 audit / .catch debug log - 기록 정리 2건: v0.2.2 stale memory 폐기 / v024-backlog 갱신 - 보류: data-dependent 9 + future-proof 2 (dogfood 후 재평가) 목표 단위 710 → 약 720 (+10), typecheck 0 --- .../specs/2026-05-10-v032-cleanup-design.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-v032-cleanup-design.md diff --git a/docs/superpowers/specs/2026-05-10-v032-cleanup-design.md b/docs/superpowers/specs/2026-05-10-v032-cleanup-design.md new file mode 100644 index 0000000..e0c44e8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-v032-cleanup-design.md @@ -0,0 +1,320 @@ +# v0.3.2 — Cleanup Cut Design + +**작성일:** 2026-05-10 +**선행 문서:** + +- `docs/superpowers/v024-backlog.md` (잔여 backlog audit) +- `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` (stale memory — 본 cut 에서 폐기) +- `docs/superpowers/strategy/v028plus-roadmap.md` + +**Cut 라벨:** v0.3.2 — patch (기능 추가 X, 잠재 bug fix + cosmetic + 기록 정리) + +--- + +## 1. Cut 정체성 + +기능 추가 X. backlog 잔여 23건 중 **잠재 bug 4건 + cosmetic 6건 + 기록 정리 2건 = 12건** 일괄 처리. data-dependent 항목 (telemetry 분포 의존) 과 cross-cutting refactor (TrayController class / Banner CSS variables) 는 dogfood 후 재평가. Cut F (v0.3.1) + Cut E (v0.3.0) 종합 dogfood ≥1주 soak 진입을 위한 baseline 정리. + +--- + +## 2. 범위 + +| 항목 | 결정 | +|---|---| +| **포함 카테고리** | 잠재 bug + cosmetic + 기록 정리 | +| **보류 카테고리** | data-dependent (9건) + cross-cutting refactor (4건) | +| **테스트 +** | 단위 710 → 약 720 (+10), typecheck 0 | +| **schema 변경** | 없음 (m007 이후) | +| **단일 PR** | v0.3.2 — 12 항목 = 7~8 commit (카테고리별 묶음) | + +--- + +## 3. 잠재 bug fix (4건) + +### 3-1. `vocabSet` COLLATE NOCASE 정합 (#31) + +**현재 코드** (`src/main/ai/AiWorker.ts` `processJob`): + +```ts +const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N); +const vocabSet = new Set(vocab); +// ... +if (vocabSet.has(tagName)) { ... } // strict-eq, DB COLLATE NOCASE 와 충돌 가능 +``` + +**문제**: vocab pool 확장 시 (사용자가 `'Design'` 같은 capital case 추가하면) `getTagIdByName('Design')` 은 COLLATE NOCASE 로 매치되지만 `vocabSet.has('Design')` strict-eq 는 lowercase 만 등록된 set 에 miss → tagId 있는데 vocab hit 0 → silently skip. + +**수정**: + +```ts +const vocab = await this.repo.getTopUsedTags(VOCAB_TOP_N); +const vocabSet = new Set(vocab.map((v) => v.toLowerCase())); +// ... +if (vocabSet.has(tagName.toLowerCase())) { ... } +``` + +**테스트 (3건 신규)**: + +- 대문자 vocab + lowercase AI tag → hit +- lowercase vocab + 대문자 AI tag → hit +- 동일 lowercase → hit (회귀) + +### 3-2. Time-dependent test flake fix + +**문제**: `NoteRevisions.test.ts` 의 v1 capture 가 `repo.create()` 호출 → `NoteRepository.create` 가 `new Date()` (NOW) 로 `created_at` / `edited_at` 박음. v2 는 fixed `2026-05-10T00:00:00Z` 명시 주입. 시스템 시계가 `2026-05-10T00:00:00Z` 초과 시 v1.edited_at > v2.edited_at → DESC ordering 깨짐. `upsertFromSync.test.ts` 도 동일 패턴. + +**수정**: `NoteRepository.create(input, now?: Date)` 추가 (기존 `setStatus(id, status, reason, now: Date)` / `updateRawText(id, text, now: Date)` 패턴 정합). + +```ts +// src/main/repository/NoteRepository.ts +create(input: CreateNoteInput, now: Date = new Date()): Note { + const ts = now.toISOString(); + // 기존 INSERT 의 createdAt / updatedAt / edited_at 모두 ts 사용 + // ... +} +``` + +**기존 호출자 무영향** — production 코드는 `now` 생략 → 기본 `new Date()` 동일 동작. + +**테스트 (5건 회복 + 2건 신규)**: + +- `NoteRevisions.test.ts` 의 4 testcase v1 capture 도 fixed 시간 (`'2026-05-09T00:00:00Z'`) 주입 → v2 (`'2026-05-10T00:00:00Z'`) 이전 보장 +- `upsertFromSync.test.ts` 의 v1 capture 도 fixed 시간 주입 +- `NoteRepository.create` default 시간 (`now` 생략 시 `new Date()`) 단위 1 +- `NoteRepository.create` 명시 주입 단위 1 + +### 3-3. PII reason 마스킹 (#39) + +**현재 코드** (`src/main/ai/LocalOllamaProvider.ts` `healthCheck`): + +```ts +} catch (e) { + this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable: ${(e as Error).message}` } }); +} +``` + +**문제**: `err.message` 안에 `http://192.168.x.x:11434/api/tags` 같은 LAN endpoint URL 포함 가능 → telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용 흔하게 만들어 노출 경로 확대. + +**수정**: error class 분류 + host 마스킹. + +```ts +function classifyFetchError(e: unknown): 'timeout' | 'network' | 'dns' | 'other' { + const msg = (e as Error).message?.toLowerCase() ?? ''; + if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout'; + if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network'; + if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns'; + return 'other'; +} + +// emit +const reason = classifyFetchError(e); +this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason } }); +``` + +`AiFailedReason` zod enum (`'unreachable' | 'schema' | 'timeout' | 'other'`) 와 별개 — `ollama_unreachable.payload.reason` 만 신규 enum 도입 또는 기존 union 확장. spec 단계 결정: **기존 `'unreachable'` 그대로 유지**, 신규 enum 추가 X (단순화). reason 변환 후 prefix 만 변경: + +```ts +const cls = classifyFetchError(e); +this.telemetry?.emit({ kind: 'ollama_unreachable', payload: { reason: `unreachable:${cls}` } }); +``` + +**테스트 (4건 신규)**: + +- `ECONNREFUSED` → `unreachable:network` +- `ETIMEDOUT` → `unreachable:timeout` +- `ENOTFOUND` → `unreachable:dns` +- 그 외 → `unreachable:other` + +### 3-4. KST_OFFSET_MS inline duplication 5 callsite → import (#19) + +**현재**: canonical `src/shared/util/kstDate.ts` 가 있는데 5 callsite inline duplicate. + +| 파일 | 라인 | 처리 | +|---|---|---| +| `src/main/repository/NoteRepository.ts:1042` | inline `const KST_OFFSET_MS = ...` | `import { KST_OFFSET_MS } from '@shared/util/kstDate.js'` | +| `src/main/repository/ftsHelpers.ts:18` | 동 | 동 | +| `src/main/services/BackupService.ts:6` | 동 | 동 | +| `src/main/services/ContinuityService.ts:4` | 동 | 동 | +| `src/renderer/inbox/components/NoteCard.tsx:30` | 동 | 동 (renderer alias 경계 X — `@shared/...` 양쪽 import 가능) | + +**테스트**: 단위 추가 없음 — 기존 회귀 검사. `kstDate.ts` 의 export 가 동일 값 (9 \* 60 \* 60 \* 1000) 이므로 동작 무변화. + +--- + +## 4. Cosmetic / readability (6건) + +### 4-1. 탭 ARIA 패턴 정정 (#14) + +`aria-pressed` 는 toggle 버튼용. 본 UI 의 탭 (Inbox / 휴지통 / 회고 등) 은 `role="tab"` + `aria-selected` canonical. screen reader 동작 OK 였지만 a11y audit canonical 정정. + +**파일**: `src/renderer/inbox/App.tsx` (탭 컨테이너) — grep 으로 `aria-pressed` 위치 확정 후 수정. + +**테스트**: 단위 추가 없음 (기존 RTL 단위가 selectable 검증). 단 `aria-selected="true"` 관련 assertion 1건 추가 검증. + +### 4-2. `loadExpired()` 미사용 제거 (#18) + +`store.ts` 의 `loadExpired()` action 이 `loadInitial`/`refreshMeta` 가 inline fetch 하면서 사용 안 함. App.tsx 호출 0건. test 만 exercise. + +**처리**: dead-code 제거. 관련 test 도 제거. + +### 4-3. AiWorker per-tag emit `Promise.all` 병렬화 (#32) + +**현재**: + +```ts +for (const tag of new Set(...)) { + await this.telemetry.emit({ kind: 'tag_vocab_hit' or 'miss', ... }); // serial +} +``` + +**수정**: + +```ts +await Promise.all( + Array.from(new Set(...)).map((tag) => this.telemetry.emit({ ... })) +); +``` + +**Risk**: emit 순서 변경 — telemetry 파일 라인 순서 의존 단위 없음 확인. `ai_succeeded` emit 도 serial 이지만 본 cut 은 **per-tag emit 만** 변경 (tag_vocab_hit / tag_vocab_miss). `ai_succeeded` 는 serial 유지 (per-tag 와 다른 호출 시점). + +**테스트**: 회귀 단위 PASS 확인. 신규 단위 추가 없음 (병렬 동작 자체 검증은 unit 무리). + +### 4-4. `emitRecallShown` / `emitRecallSnoozed` `ipcMain.handle → on` (#36) + +`fire-and-forget` 정책 호출 측 (RecallBanner) → return value 사용 안 함. canonical pattern: `ipcMain.on` (return value 없음). + +**파일**: `src/main/ipc/inboxApi.ts` 또는 telemetry IPC 정의 위치. `emitRecallShown` / `emitRecallSnoozed` 만 `handle → on` migration. 호출 측 (`window.api.emitRecallShown` 등) 의 `Promise` 시그니처 그대로 (preload 가 `ipcRenderer.send`). + +**Risk**: `ipcMain.handle` 의 return value 의존 호출자 grep 으로 0 확인. + +**테스트 (1건 신규)**: `vision-ipc.test.ts` 패턴 정합 — `ipcRenderer.send` 호출 검증. + +### 4-5. OllamaSettingsModal 폐기 확인 (#41+#42) + +**현재 audit**: `grep "OllamaSettingsModal" src/` → 0건. v0.2.7 cut 에서 이미 폐기됨 (memory: "OllamaSettingsModal 제거 + onOpenOllamaSettings 채널 cleanup"). + +**처리**: backlog 항목 닫기만. **코드 변경 0**. v024-backlog.md 의 #41/#42 처리 이력 ✅ 추가. + +### 4-6. Telemetry `.catch(() => {})` silent → debug log (#20) + +**현재**: `CaptureService.listExpired` / `trashExpiredBatch` 가 `.catch(() => {})` 로 silent. + +**수정**: + +```ts +this.telemetry.emit(...).catch((e) => { + this.logger.debug('telemetry.emit.failed', { reason: String(e) }); +}); +``` + +`logger.debug` (project pattern) — production noise 0, 디버그 시 reproduce 가능. + +**테스트**: 단위 추가 없음. 회귀 PASS. + +--- + +## 5. 기록 정리 (2건) + +### 5-1. v0.2.2 stale memory 폐기 + +`~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md` — 6건 모두 v0.2.3~v0.2.9 cut 들에서 처리됨 (Ollama 회복 / AI 영속 큐 / 태그 vocab / 휴지통 / 만료 추천 / RecallBanner). 8일 stale. + +**처리**: + +- 파일 삭제 +- `MEMORY.md` 의 line 8 (`- [v0.2.2 feedback]...`) 제거 + +### 5-2. v024-backlog.md 갱신 + +처리 이력 table 에 신규 12건 entry 추가: + +```markdown +| #14 (탭 ARIA) | ✅ 처리 | v0.3.2 | +| #18 (loadExpired 미사용) | ✅ 처리 | v0.3.2 | +| #19 (KST inline 5 callsite 잔여) | ✅ 처리 | v0.3.2 | +| #20 (.catch silent → debug log) | ✅ 처리 | v0.3.2 | +| #31 (vocabSet COLLATE) | ✅ 처리 | v0.3.2 | +| #32 (per-tag Promise.all) | ✅ 처리 | v0.3.2 | +| #36 (recall IPC handle→on) | ✅ 처리 | v0.3.2 | +| #39 (PII reason 마스킹) | ✅ 처리 | v0.3.2 | +| #41+#42 (OllamaSettingsModal 폐기) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit | +| time-dependent test flake | ✅ 처리 | v0.3.2 | +``` + +총 처리 22 → 32, 잔여 23 → 11 (data-dependent 9 + future-proof 2). + +--- + +## 6. 보류 항목 (dogfood 후 재평가) + +### data-dependent (9건) + +- #25 HealthChecker `inFlight` manual emit ordering — dogfood soak 결과 결정 (1초 윈도우 dedup 필요 여부) +- #29 `getTopUsedTags(20)` magic number → `VOCAB_TOP_N` 모듈 상수 (이미 #29 처리, 튜닝 자체는 telemetry 후) +- #30 `getTopUsedTags` SQL-side 필터 (overfetch+slice vs `GLOB`) — vocab pool 확장 결정 +- #33 `PROMPT_VERSION` telemetry payload 추가 — prompt 튜닝 후 hit-rate 추적 시 +- #35 `recall_shown` per-banner-lifetime dedup — telemetry 빈도 보고 +- #40 Settings 저장 vs HealthChecker race — visible 빈도 확인 +- #16 per-note 영구 삭제 telemetry 빈도 — 거의 0 이면 bulk emptyTrash 만 (UX 단순화) +- #28 `unreachableBackoffStep` job-level — multi-provider 도입 시 +- recall_shown lifetime 영속 마커 (data-dependent #35 와 일부 중복) + +### future-proof / cross-cutting (4건) + +- #27 `refreshTrayFailedCount` exported singleton → TrayController class — multi-window 가설 검증 X +- #24+#41 Banner CSS variables — modal 폐기로 일부 자연 소멸 + 잔여 4 banner 의 hardcode 색상은 단일 dogfood UX 영향 0 +- #37 NoteCard `id="note-${id}"` ref-forwarding — search 결과 scroll 등 신규 surface 등장 시 +- #28 단일 카운터 vs job-level — multi-provider 진입 시점 + +--- + +## 7. 테스트 전략 + +| 영역 | 단위 | +|---|---| +| `vocabSet` lowercase normalize | 3 (대/소문자 vocab × AI tag matrix) | +| `NoteRepository.create(now)` param | 2 (default / 명시 주입) | +| `NoteRevisions.test.ts` flake fix | 4 testcase 회복 (v1 capture 시간 주입) | +| `upsertFromSync.test.ts` flake fix | 2 testcase 회복 | +| PII reason classification | 4 (network/timeout/dns/other) | +| KST inline → import migration | 회귀 PASS 확인 (단위 +0) | +| 탭 ARIA `aria-selected` | 1 신규 assertion | +| `loadExpired` 제거 | 회귀 (test 같이 제거) | +| AiWorker `Promise.all` 회귀 | 회귀 PASS | +| recall IPC `on` migration | 1 신규 (`ipcRenderer.send` 검증) | +| Telemetry `.catch` debug log | 회귀 PASS | + +목표: 단위 710 → **약 720** (+10 신규, -2 제거 [`loadExpired` test], net +8). typecheck 0. + +--- + +## 8. Risk + +| Risk | 대응 | +|---|---| +| `NoteRepository.create(now)` signature 변경 호출자 영향 | optional + default = `new Date()`. 기존 호출자 0 무영향. typecheck 가 누락 catch | +| KST inline → import 회귀 (값 다른 정의) | canonical export 값 (9 \* 60 \* 60 \* 1000) 동일 검증. 기존 단위 회귀 PASS = 알고리즘 동일 | +| `ipcMain.handle → on` migration 시 return value 의존 호출자 누락 | grep 으로 호출자 enumerated. preload 시그니처 그대로 (`Promise`) — 호출 측 무수정 | +| AiWorker `Promise.all` 으로 emit 순서 변경 | telemetry 파일 라인 순서 의존 단위 0 확인. file-append round-trip 만 줄어듦 | +| PII 마스킹으로 디버그 어려움 | error class enum + production reproduce 시 dev 환경에서 stack 그대로 노출 (telemetry 만 마스킹) | +| time-dependent fix 시 다른 시간 의존 단위 발견 | grep `new Date()` in `repo` methods → `create` 외 다른 메서드 audit. 발견 시 spec 갱신 X (cleanup cut 외 deferred) | + +--- + +## 9. v0.3.2 후 + +**Cut G** (v0.3.3 또는 v0.4.0 — F25 사이드바 + notebook_id) 진입 전: + +- v0.3.2 release → main → tag → Windows exe + Gitea release +- macOS host 핸드오프: dist:mac dmg + dist:linux AppImage/deb (v0.3.0 + v0.3.1 + v0.3.2 누적 backlog) +- **다기기 종합 dogfood ≥1주 soak**: + - sync (Cut E): 충돌 빈도 / 인증 흐름 / interval 적정성 / NTP 단조 가정 + - vision (Cut F): 이미지 capture 빈도 / 한국어 정확도 / capability detection 정확도 + - cleanup (Cut 본 v0.3.2): 잠재 bug 회귀 X 확인 +- soak 후 신규 발견 + data-dependent 9건 일괄 triage → Cut G brainstorm 진입 + +**Cut G 가설** (재검증): + +1. inbox 단일 view 의 정보 밀도 한계 (현재 노트 누적 N건 = 스크롤 부담) +2. notebook 카테고리 = 분류 hint vs 단일 inbox + 태그 필터로 충분 +3. 사이드바 = 새 surface = 기존 정책 (트레이 deemphasis / SettingsPage 우선) 재고 From 4deb7775f3db9ded19bcb25c2daf2142c0d3a007 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 13:36:05 +0900 Subject: [PATCH 02/11] docs(plan): v0.3.2 cleanup cut implementation plan (8 tasks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec: 2026-05-10-v032-cleanup-design.md Tasks: time-dep test fix / vocabSet COLLATE / PII reason / KST migration / 탭 ARIA + loadExpired / Promise.all / recall IPC + .catch / 기록 정리 목표 단위 710 → 약 720 (+10 신규, -2 제거), typecheck 0 --- .../plans/2026-05-10-v032-cleanup.md | 1039 +++++++++++++++++ 1 file changed, 1039 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-v032-cleanup.md diff --git a/docs/superpowers/plans/2026-05-10-v032-cleanup.md b/docs/superpowers/plans/2026-05-10-v032-cleanup.md new file mode 100644 index 0000000..1a2d1a0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-v032-cleanup.md @@ -0,0 +1,1039 @@ +# v0.3.2 Cleanup Cut Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** v024-backlog 잔여 23건 중 잠재 bug 4 + cosmetic 6 + 기록 정리 2 = 12건 일괄 처리 + time-dependent test flake fix. dogfood baseline 정리. + +**Architecture:** 기능 추가 X. 기존 production 코드 수정 + 단위 테스트 보강. canonical helper 재활용 (`src/shared/util/kstDate.ts`). 8 task = 8 commit (TDD per task). 단일 PR. + +**Tech Stack:** TypeScript 5 / Electron 41 / vitest / better-sqlite3 / undici / zod / RTL + +**Spec:** [docs/superpowers/specs/2026-05-10-v032-cleanup-design.md](../specs/2026-05-10-v032-cleanup-design.md) + +--- + +## File Structure + +| File | Task | 변경 | +|---|---|---| +| `src/main/repository/NoteRepository.ts` | 1, 4 | `create(input, now?: Date)` signature + KST import | +| `src/main/repository/ftsHelpers.ts` | 4 | KST import | +| `src/main/services/BackupService.ts` | 4 | KST import | +| `src/main/services/ContinuityService.ts` | 4 | KST import | +| `src/renderer/inbox/components/NoteCard.tsx` | 4 | KST import | +| `src/main/ai/AiWorker.ts` | 2, 6 | vocabSet COLLATE + per-tag Promise.all | +| `src/main/ai/LocalOllamaProvider.ts` | 3 | classifyFetchError + reason mask | +| `src/renderer/inbox/App.tsx` | 5 | 탭 `role="tab"` + `aria-selected` | +| `src/renderer/inbox/store.ts` | 5 | `loadExpired` action 제거 | +| `src/main/ipc/inboxApi.ts` | 7 | recall handle→on | +| `src/preload/index.ts` | 7 | recall ipcRenderer.send | +| `src/shared/types.ts` | 7 | recall return `void` (Promise X) | +| `src/main/services/CaptureService.ts` | 7 | telemetry `.catch` → debug log | +| `tests/unit/NoteRevisions.test.ts` | 1 | v1 capture 시간 주입 | +| `tests/unit/NoteRepository.upsertFromSync.test.ts` | 1 | 동 | +| `tests/unit/NoteRepository.test.ts` | 1 | create now param 단위 | +| `tests/unit/AiWorker.test.ts` | 2, 6 | vocabSet 3 + Promise.all 회귀 | +| `tests/unit/LocalOllamaProvider.test.ts` | 3 | classifyFetchError 4 | +| `tests/unit/App.test.tsx` | 5 | aria-selected assertion | +| `tests/unit/store.expired.test.ts` | 5 | `loadExpired` test 제거 | +| `tests/unit/recall-ipc.test.ts` (신규) | 7 | ipcRenderer.send 검증 | +| `package.json` | 8 | 0.3.1 → 0.3.2 | +| `docs/superpowers/v024-backlog.md` | 8 | 처리 이력 갱신 | +| `~/.claude/projects/.../memory/project_v022_feedback.md` | 8 | 삭제 | +| `~/.claude/projects/.../memory/MEMORY.md` | 8 | 라인 제거 | + +--- + +## Task 1: Time-dependent test flake fix + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts:82-105` (create signature) +- Modify: `tests/unit/NoteRevisions.test.ts:22-105` (v1 capture 시간 주입 4 testcase) +- Modify: `tests/unit/NoteRepository.upsertFromSync.test.ts:10-93` (v1 capture 시간 주입 2 testcase) +- Test: `tests/unit/NoteRepository.test.ts` (now param 단위 +2) + +### Step 1: Write the failing test (create now param) + +Add to `tests/unit/NoteRepository.test.ts` end of describe block: + +```ts +it('create accepts explicit now param', () => { + const fixed = new Date('2026-05-09T10:00:00.000Z'); + const { id } = repo.create({ rawText: 'hello' }, fixed); + const note = repo.findById(id)!; + expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z'); + expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z'); +}); + +it('create defaults now to new Date when omitted', () => { + const before = Date.now(); + const { id } = repo.create({ rawText: 'hello' }); + const after = Date.now(); + const note = repo.findById(id)!; + const ts = new Date(note.createdAt).getTime(); + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); +}); +``` + +### Step 2: Run test to verify it fails + +``` +npx vitest run tests/unit/NoteRepository.test.ts -t "create accepts explicit now param" +``` + +Expected: FAIL with `Expected 1 arguments, but got 2` typecheck error or runtime ignore of 2nd arg. + +### Step 3: Update `NoteRepository.create` signature + +Modify `src/main/repository/NoteRepository.ts:82-105`: + +```ts +create(input: CreateNoteInput, now: Date = new Date()): { id: string } { + const id = uuidv7(); + const ts = now.toISOString(); + const aiStatus: AiStatus = input.aiStatus ?? 'pending'; + const tx = this.db.transaction(() => { + this.db + .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, ts, ts); + this.db + .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by) + VALUES (?, ?, ?, 'capture')`) + .run(id, input.rawText, ts); + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, ts); + } + }); + tx(); + return { id }; +} +``` + +### Step 4: Run new tests to verify they pass + +``` +npx vitest run tests/unit/NoteRepository.test.ts -t "create accepts explicit now param" +npx vitest run tests/unit/NoteRepository.test.ts -t "create defaults now" +``` + +Expected: PASS + +### Step 5: Update flake testcases — `NoteRevisions.test.ts` + +Modify `tests/unit/NoteRevisions.test.ts` — find every `repo.create(...)` followed by a `repo.updateRawText(..., new Date('2026-05-10T00:00:00Z'))` and pass v1 capture time `new Date('2026-05-09T00:00:00Z')` (a day before v2): + +Change all of these patterns: + +```ts +const { id } = repo.create({ rawText: 'v1' }); +// ... +repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z')); +``` + +To: + +```ts +const v1At = new Date('2026-05-09T00:00:00Z'); +const { id } = repo.create({ rawText: 'v1' }, v1At); +// ... +repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z')); +``` + +Also fix line 30 expectation if it's checking v1 timestamp — change to `'2026-05-09T00:00:00.000Z'` if comparing `notes.updated_at` after `updateRawText`. Actually `updated_at` after `updateRawText` should be the v2 time. Keep `'2026-05-10T00:00:00.000Z'` as-is. + +Find line 40: `expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');` — `revs.at(1)` (second element, DESC ordered) should be the OLDER revision = v1 capture. Change expected to `'2026-05-09T00:00:00.000Z'`. + +Apply same pattern to all 4 testcases (lines 22, 45, 57, 77, 105 cluster). + +### Step 6: Update flake testcases — `upsertFromSync.test.ts` + +Modify `tests/unit/NoteRepository.upsertFromSync.test.ts:10-93` — locate the `created = repo.create(...)` calls. Pass explicit time `new Date('2026-05-09T00:00:00Z')`: + +```ts +const created = repo.create({ rawText: 'baseline' }, new Date('2026-05-09T00:00:00Z')); +``` + +This ensures `created.updatedAt = '2026-05-09T00:00:00.000Z'` < `'2026-05-10T00:00:00Z'` (sync input) → sync wins consistently regardless of system clock. + +### Step 7: Run full suite to verify all pass + +``` +npx vitest run tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.upsertFromSync.test.ts tests/unit/NoteRepository.test.ts +``` + +Expected: PASS — flake testcases now deterministic. + +### Step 8: Commit + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.upsertFromSync.test.ts +git commit -m "$(cat <<'EOF' +fix(v032): NoteRepository.create now param + time-dep test flake fix + +- create(input, now?: Date) signature 추가 (기존 setStatus/updateRawText 패턴 정합) +- NoteRevisions.test.ts 4 testcase v1 capture 시간 명시 주입 (2026-05-09T00:00:00Z) +- upsertFromSync.test.ts 2 testcase v1 capture 시간 명시 주입 +- 시스템 시계가 2026-05-10T00:00:00Z 초과 시 DESC ordering 깨지던 회귀 회복 + +backlog: time-dependent flake (Cut F audit 발견) +EOF +)" +``` + +--- + +## Task 2: vocabSet COLLATE NOCASE 정합 (#31) + +**Files:** + +- Modify: `src/main/ai/AiWorker.ts:189-191` +- Test: `tests/unit/AiWorker.test.ts` (+3) + +### Step 1: Write 3 failing tests + +Add to `tests/unit/AiWorker.test.ts` (end of vocab describe block, or add new describe): + +```ts +describe('vocab COLLATE NOCASE', () => { + it('hits when vocab has lowercase and AI returns capital', async () => { + // existing test setup pattern: mock repo.getTopUsedTags returns ['design'] + // mock provider returns tags: ['Design'] + // assert telemetry emit kind === 'tag_vocab_hit' (not miss) + // ... follow existing AiWorker.test.ts test setup ... + }); + + it('hits when vocab has capital and AI returns lowercase', async () => { + // repo.getTopUsedTags returns ['Design'] + // provider returns tags: ['design'] + // assert tag_vocab_hit + }); + + it('still hits when both vocab and AI tag are same lowercase (regression)', async () => { + // repo.getTopUsedTags returns ['design'] + // provider returns tags: ['design'] + // assert tag_vocab_hit + }); +}); +``` + +Use the existing `AiWorker.test.ts` test setup — mock `repo`, `holder`, `telemetry`. Spy on `telemetry.emit` and assert kinds. + +### Step 2: Run tests to verify they fail + +``` +npx vitest run tests/unit/AiWorker.test.ts -t "COLLATE" +``` + +Expected: FAIL — first 2 tests assert `tag_vocab_hit` but current code emits `tag_vocab_miss` due to strict-eq. + +### Step 3: Implement vocabSet lowercase normalize + +Modify `src/main/ai/AiWorker.ts:189-191`: + +```ts +const vocabSet = new Set(vocab.map((v) => v.toLowerCase())); +for (const tagName of new Set(res.tags)) { + if (vocabSet.has(tagName.toLowerCase())) { + const tagId = this.repo.getTagIdByName(tagName); + if (tagId !== null) { + await this.telemetry.emit({ + kind: 'tag_vocab_hit', + payload: { tagId, vocabSize: vocab.length } + }).catch(() => {}); + } + } else { + await this.telemetry.emit({ + kind: 'tag_vocab_miss', + payload: { vocabSize: vocab.length } + }).catch(() => {}); + } +} +``` + +### Step 4: Run tests to verify pass + +``` +npx vitest run tests/unit/AiWorker.test.ts -t "COLLATE" +``` + +Expected: PASS — all 3 tests green. + +### Step 5: Run full AiWorker.test.ts to verify no regression + +``` +npx vitest run tests/unit/AiWorker.test.ts +``` + +Expected: All existing tests still PASS. + +### Step 6: Commit + +```bash +git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts +git commit -m "$(cat <<'EOF' +fix(v032): AiWorker vocabSet COLLATE NOCASE 정합 (#31) + +DB tags.name 가 COLLATE NOCASE 인데 vocabSet 은 strict-eq 였음 → +대문자/소문자 vocab 과 AI tag 가 다를 때 silently skip. + +vocab.toLowerCase() + tagName.toLowerCase() 양쪽 normalize 로 정합. +EOF +)" +``` + +--- + +## Task 3: PII reason 마스킹 (#39) + +**Files:** + +- Modify: `src/main/ai/LocalOllamaProvider.ts:113-124` +- Test: `tests/unit/LocalOllamaProvider.test.ts` (+4) + +### Step 1: Write 4 failing tests + +Add to `tests/unit/LocalOllamaProvider.test.ts`: + +```ts +describe('healthCheck PII reason masking', () => { + it('classifies ECONNREFUSED as network', async () => { + const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' }); + // mock undici request to throw Error('connect ECONNREFUSED 192.168.1.5:11434') + vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('connect ECONNREFUSED 192.168.1.5:11434')); + const r = await provider.healthCheck(); + expect(r).toEqual({ ok: false, reason: 'unreachable:network' }); + }); + + it('classifies AbortError/timeout as timeout', async () => { + const provider = new LocalOllamaProvider({ endpoint: 'http://localhost:11434' }); + vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('The operation was aborted due to timeout')); + const r = await provider.healthCheck(); + expect(r).toEqual({ ok: false, reason: 'unreachable:timeout' }); + }); + + it('classifies ENOTFOUND as dns', async () => { + const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' }); + vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND nonexistent.local')); + const r = await provider.healthCheck(); + expect(r).toEqual({ ok: false, reason: 'unreachable:dns' }); + }); + + it('falls back to other for unknown errors', async () => { + const provider = new LocalOllamaProvider({ endpoint: 'http://localhost:11434' }); + vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('something weird happened')); + const r = await provider.healthCheck(); + expect(r).toEqual({ ok: false, reason: 'unreachable:other' }); + }); +}); +``` + +(Adjust `vi.spyOn` based on existing `LocalOllamaProvider.test.ts` mock pattern — likely `vi.mock('undici')` at top with `request: vi.fn()`.) + +### Step 2: Run tests to verify they fail + +``` +npx vitest run tests/unit/LocalOllamaProvider.test.ts -t "PII reason masking" +``` + +Expected: FAIL — current code returns `unreachable: connect ECONNREFUSED 192.168.1.5:11434` (full message including IP). + +### Step 3: Add `classifyFetchError` helper + apply + +Modify `src/main/ai/LocalOllamaProvider.ts` — add helper at module top (after imports, line 7-ish): + +```ts +function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' { + const msg = ((e as Error)?.message ?? '').toLowerCase(); + if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout'; + if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network'; + if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns'; + return 'other'; +} +``` + +Modify `healthCheck()` line 121-123: + +```ts +} catch (err) { + const cls = classifyFetchError(err); + return { ok: false, reason: `unreachable:${cls}` }; +} +``` + +### Step 4: Run tests to verify pass + +``` +npx vitest run tests/unit/LocalOllamaProvider.test.ts -t "PII reason masking" +``` + +Expected: PASS — all 4 tests green. + +### Step 5: Run full LocalOllamaProvider.test.ts to verify no regression + +``` +npx vitest run tests/unit/LocalOllamaProvider.test.ts +``` + +Expected: existing tests still PASS. Existing tests checking `reason: 'unreachable: ...'` may break — update them to match new format `unreachable:other` or `unreachable:network`. + +### Step 6: Commit + +```bash +git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts +git commit -m "$(cat <<'EOF' +fix(v032): healthCheck reason PII 마스킹 (#39) + +err.message 안에 LAN endpoint URL (예: 192.168.x.x:11434) 이 포함될 수 +있어 telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN +사용을 흔하게 만들어 노출 경로 확대. + +classifyFetchError 로 error class 분류 (network/timeout/dns/other) 후 +reason: 'unreachable:{class}' 형태만 emit. host/IP 노출 0. +EOF +)" +``` + +--- + +## Task 4: KST inline 5 callsite migration (#19) + +**Files:** + +- Modify: `src/main/repository/NoteRepository.ts:1042-1047` (delete inline + import) +- Modify: `src/main/repository/ftsHelpers.ts:18-29` +- Modify: `src/main/services/BackupService.ts:6-10` +- Modify: `src/main/services/ContinuityService.ts:4-15` +- Modify: `src/renderer/inbox/components/NoteCard.tsx:30-31` + +### Step 1: NoteRepository.ts migration + +Replace `src/main/repository/NoteRepository.ts:1042-1047`: + +Before: +```ts +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const kstNow = new Date(now.getTime() + KST_OFFSET_MS); +const kstYear = kstNow.getUTCFullYear(); +const kstMonth = kstNow.getUTCMonth(); +const kstDate = kstNow.getUTCDate(); +const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS; +``` + +After: +```ts +const kstNow = new Date(now.getTime() + KST_OFFSET_MS); +const kstYear = kstNow.getUTCFullYear(); +const kstMonth = kstNow.getUTCMonth(); +const kstDate = kstNow.getUTCDate(); +const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS; +``` + +Add at top of file (with other imports): +```ts +import { KST_OFFSET_MS } from '../../shared/util/kstDate.js'; +``` + +### Step 2: ftsHelpers.ts migration + +Modify `src/main/repository/ftsHelpers.ts:18`: + +Before: +```ts +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +``` + +Replace with import: +```ts +import { KST_OFFSET_MS } from '../../shared/util/kstDate.js'; +``` + +(Remove the `const` line.) + +### Step 3: BackupService.ts migration + +Modify `src/main/services/BackupService.ts:6`: + +Before: +```ts +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +``` + +Replace with import (added to imports at top): +```ts +import { KST_OFFSET_MS } from '../../shared/util/kstDate.js'; +``` + +### Step 4: ContinuityService.ts migration + +Modify `src/main/services/ContinuityService.ts:4`: + +Same pattern — replace local const with import. + +### Step 5: NoteCard.tsx migration + +Modify `src/renderer/inbox/components/NoteCard.tsx:30-31`: + +Before: +```tsx +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const k = new Date(Date.now() + KST_OFFSET_MS); +``` + +After: +```tsx +const k = new Date(Date.now() + KST_OFFSET_MS); +``` + +Add import at top: +```tsx +import { KST_OFFSET_MS } from '@shared/util/kstDate.js'; +``` + +(Use the renderer alias `@shared/...` as already used elsewhere in renderer.) + +### Step 6: Verify no other inline duplicates remain + +``` +git grep -n "KST_OFFSET_MS = 9" -- src/ +``` + +Expected: only `src/shared/util/kstDate.ts:6` (canonical export) — no inline duplicates. + +### Step 7: Run full suite to verify regression + +``` +npm run typecheck +npm test +``` + +Expected: typecheck 0 errors, all existing tests PASS (algorithm identical, just import path). + +### Step 8: Commit + +```bash +git add src/main/repository/NoteRepository.ts src/main/repository/ftsHelpers.ts src/main/services/BackupService.ts src/main/services/ContinuityService.ts src/renderer/inbox/components/NoteCard.tsx +git commit -m "$(cat <<'EOF' +refactor(v032): KST_OFFSET_MS inline → @shared/util/kstDate import (#19) + +5 callsite (NoteRepository, ftsHelpers, BackupService, ContinuityService, +NoteCard) 모두 canonical export 로 정리. 알고리즘 동일 (9 * 60 * 60 * 1000), +회귀 PASS 검증. + +v0.2.6 commit 3cfa60b 가 4 callsite migrate 했지만 5 callsite 잔여. +Cut F audit 에서 발견. +EOF +)" +``` + +--- + +## Task 5: 탭 ARIA + loadExpired 제거 (#14, #18) + +**Files:** + +- Modify: `src/renderer/inbox/App.tsx:107-115` (탭 button → role="tab" + aria-selected) +- Modify: `src/renderer/inbox/store.ts:61, 238-241` (loadExpired 제거) +- Modify: `tests/unit/App.test.tsx` (+1 aria-selected assertion) +- Modify: `tests/unit/store.expired.test.ts:55-58` (loadExpired test 제거) + +### Step 1: Write failing test for aria-selected + +Add to `tests/unit/App.test.tsx` (end of describe — find existing tab navigation tests): + +```ts +it('inbox tab has aria-selected="true" when active', async () => { + // existing render setup ... + const inboxTab = screen.getByRole('tab', { name: /Inbox/ }); + expect(inboxTab).toHaveAttribute('aria-selected', 'true'); +}); +``` + +If existing test was using `aria-pressed`, update those references too — they should query by `role="tab"` going forward. + +### Step 2: Run test to verify it fails + +``` +npx vitest run tests/unit/App.test.tsx -t "aria-selected" +``` + +Expected: FAIL — current `aria-pressed` doesn't expose `role="tab"`. + +### Step 3: Update App.tsx tab buttons + +Modify `src/renderer/inbox/App.tsx:107-115`: + +Before: +```tsx +