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
1040 lines
32 KiB
Markdown
1040 lines
32 KiB
Markdown
# 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<void> 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
|
||
<button
|
||
key={t.key}
|
||
onClick={() => setView(t.key)}
|
||
aria-pressed={view === t.key}
|
||
style={tabBtnStyle(view === t.key)}
|
||
>
|
||
```
|
||
|
||
After:
|
||
```tsx
|
||
<button
|
||
key={t.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={view === t.key}
|
||
onClick={() => setView(t.key)}
|
||
style={tabBtnStyle(view === t.key)}
|
||
>
|
||
```
|
||
|
||
(Optional: wrap the tab list `<div>` with `role="tablist"`. Skip if it complicates `<select>` peer in same row — `role="tab"` standalone is acceptable for this UI.)
|
||
|
||
### Step 4: Run test to verify pass
|
||
|
||
```
|
||
npx vitest run tests/unit/App.test.tsx -t "aria-selected"
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
### Step 5: Remove `loadExpired` from store
|
||
|
||
Modify `src/renderer/inbox/store.ts`:
|
||
|
||
- Line 61: delete ` loadExpired: () => Promise<void>;`
|
||
- Lines 238-241: delete the entire `async loadExpired() { ... }` action
|
||
|
||
### Step 6: Remove `loadExpired` test
|
||
|
||
Modify `tests/unit/store.expired.test.ts:55-58` — delete the entire `it('loadExpired sets expiredCandidates from inboxApi', ...)` block.
|
||
|
||
### Step 7: Verify nothing else calls `loadExpired`
|
||
|
||
```
|
||
git grep -n "loadExpired" -- src/ tests/
|
||
```
|
||
|
||
Expected: 0 hits in `src/`. 0 hits in `tests/`.
|
||
|
||
### Step 8: Run full suite
|
||
|
||
```
|
||
npm run typecheck
|
||
npm test
|
||
```
|
||
|
||
Expected: typecheck 0, all PASS.
|
||
|
||
### Step 9: Commit
|
||
|
||
```bash
|
||
git add src/renderer/inbox/App.tsx src/renderer/inbox/store.ts tests/unit/App.test.tsx tests/unit/store.expired.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(v032): 탭 ARIA canonical + loadExpired dead-code 제거 (#14, #18)
|
||
|
||
- App.tsx 탭 button 의 aria-pressed → role="tab" + aria-selected
|
||
(canonical pattern, a11y audit 정정)
|
||
- store.ts loadExpired action + test 제거 (App.tsx 호출 0건,
|
||
loadInitial/refreshMeta 가 inline fetch — dead code)
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: AiWorker per-tag emit Promise.all 병렬화 (#32)
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/main/ai/AiWorker.ts:189-205`
|
||
- Verify: `tests/unit/AiWorker.test.ts` (회귀 PASS, +0 신규)
|
||
|
||
### Step 1: Modify the for-loop to Promise.all
|
||
|
||
Modify `src/main/ai/AiWorker.ts:189-205`:
|
||
|
||
Before:
|
||
```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(() => {});
|
||
}
|
||
}
|
||
```
|
||
|
||
After:
|
||
```ts
|
||
const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
|
||
await Promise.all(
|
||
Array.from(new Set(res.tags)).map(async (tagName) => {
|
||
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 2: Run AiWorker.test.ts to verify regression
|
||
|
||
```
|
||
npx vitest run tests/unit/AiWorker.test.ts
|
||
```
|
||
|
||
Expected: All existing tests PASS — including Task 2's COLLATE tests + vocab hit/miss counters.
|
||
|
||
### Step 3: Verify no other test depends on emit ordering
|
||
|
||
```
|
||
git grep -n "tag_vocab_hit\|tag_vocab_miss" -- tests/
|
||
```
|
||
|
||
Inspect each result — assertions should be on count or presence, not on ordering. If any test asserts `mockEmit.mock.calls[0]` strictly, that's a problem (Promise.all order is non-deterministic).
|
||
|
||
Expected: assertions use `.toContainEqual` / `.toHaveBeenCalledWith` (any-order) — not index-based.
|
||
|
||
### Step 4: Commit
|
||
|
||
```bash
|
||
git add src/main/ai/AiWorker.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
perf(v032): AiWorker per-tag emit Promise.all 병렬화 (#32)
|
||
|
||
기존 serial for-await: 3 태그 → 3 round-trip file-append.
|
||
Promise.all: 동일 결과, file-append 동시 실행 (telemetry 파일은
|
||
append-only, 순서 의존 단위 0).
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: recall IPC handle→on + telemetry .catch debug log (#36, #20)
|
||
|
||
**Files:**
|
||
|
||
- Modify: `src/main/ipc/inboxApi.ts:156-157` (handle → on)
|
||
- Modify: `src/preload/index.ts:46-47` (invoke → send)
|
||
- Modify: `src/shared/types.ts:155-156` (Promise<void> → void return)
|
||
- Modify: `src/main/services/CaptureService.ts` (.catch debug log)
|
||
- Test: `tests/unit/recall-ipc.test.ts` (신규 1)
|
||
|
||
### Step 1: Write failing test (recall IPC send)
|
||
|
||
Create `tests/unit/recall-ipc.test.ts`:
|
||
|
||
```ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { EventEmitter } from 'events';
|
||
|
||
// Stub electron.ipcMain
|
||
const ipcMain = new EventEmitter() as EventEmitter & {
|
||
on: typeof EventEmitter.prototype.on;
|
||
handle: ReturnType<typeof vi.fn>;
|
||
};
|
||
ipcMain.handle = vi.fn();
|
||
|
||
vi.mock('electron', () => ({
|
||
ipcMain,
|
||
app: { on: vi.fn(), getPath: vi.fn(() => '/tmp') },
|
||
BrowserWindow: { getAllWindows: () => [] }
|
||
}));
|
||
|
||
import { registerInboxApi } from '@main/ipc/inboxApi.js';
|
||
|
||
describe('recall IPC handle→on migration', () => {
|
||
beforeEach(() => {
|
||
ipcMain.removeAllListeners();
|
||
ipcMain.handle = vi.fn();
|
||
});
|
||
|
||
it('emitRecallShown registered with ipcMain.on (not handle)', () => {
|
||
const capture = { emitRecallShown: vi.fn(), emitRecallSnoozed: vi.fn() };
|
||
registerInboxApi({ capture } as never);
|
||
expect(ipcMain.listenerCount('inbox:emitRecallShown')).toBe(1);
|
||
expect(ipcMain.handle).not.toHaveBeenCalledWith(
|
||
'inbox:emitRecallShown', expect.anything()
|
||
);
|
||
});
|
||
|
||
it('emitRecallSnoozed registered with ipcMain.on (not handle)', () => {
|
||
const capture = { emitRecallShown: vi.fn(), emitRecallSnoozed: vi.fn() };
|
||
registerInboxApi({ capture } as never);
|
||
expect(ipcMain.listenerCount('inbox:emitRecallSnoozed')).toBe(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
Adjust the stub setup based on existing `vision-ipc.test.ts` pattern (it likely already has electron mock helpers).
|
||
|
||
### Step 2: Run test to verify it fails
|
||
|
||
```
|
||
npx vitest run tests/unit/recall-ipc.test.ts
|
||
```
|
||
|
||
Expected: FAIL — current code uses `ipcMain.handle`, so `ipcMain.listenerCount('inbox:emitRecallShown')` returns 0.
|
||
|
||
### Step 3: Update inboxApi.ts
|
||
|
||
Modify `src/main/ipc/inboxApi.ts:156-157`:
|
||
|
||
Before:
|
||
```ts
|
||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||
```
|
||
|
||
After:
|
||
```ts
|
||
ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
|
||
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });
|
||
```
|
||
|
||
### Step 4: Update preload to use `send`
|
||
|
||
Modify `src/preload/index.ts:46-47`:
|
||
|
||
Before:
|
||
```ts
|
||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||
```
|
||
|
||
After:
|
||
```ts
|
||
emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
|
||
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },
|
||
```
|
||
|
||
### Step 5: Update shared types
|
||
|
||
Modify `src/shared/types.ts:155-156`:
|
||
|
||
Before:
|
||
```ts
|
||
emitRecallShown(id: string): Promise<void>;
|
||
emitRecallSnoozed(id: string): Promise<void>;
|
||
```
|
||
|
||
After:
|
||
```ts
|
||
emitRecallShown(id: string): void;
|
||
emitRecallSnoozed(id: string): void;
|
||
```
|
||
|
||
### Step 6: Update store.ts and RecallBanner.tsx callsites
|
||
|
||
`src/renderer/inbox/store.ts:288` currently has `await inboxApi.emitRecallSnoozed(...)`. With sync return, the `await` becomes a no-op but doesn't break.
|
||
|
||
Either remove the `await` (cleaner):
|
||
```ts
|
||
inboxApi.emitRecallSnoozed(candidate.id);
|
||
```
|
||
|
||
Or leave it (no-op `await` on void).
|
||
|
||
Same for `src/renderer/inbox/components/RecallBanner.tsx:33`:
|
||
```ts
|
||
void inboxApi.emitRecallShown(candidate.id);
|
||
```
|
||
|
||
The `void` operator now applies to a `void` value — fine, no change needed.
|
||
|
||
### Step 7: Add telemetry .catch debug log to CaptureService
|
||
|
||
Locate `CaptureService` calls that emit telemetry with `.catch(() => {})`. Specifically `listExpired` and `trashExpiredBatch` (per spec #20). Read `src/main/services/CaptureService.ts` to find the locations.
|
||
|
||
Replace each `.catch(() => {})` (only those tied to `telemetry.emit`) with:
|
||
|
||
```ts
|
||
.catch((e) => { this.logger.debug('telemetry.emit.failed', { reason: String(e) }); });
|
||
```
|
||
|
||
If `CaptureService` doesn't have a `logger` field, inject it via constructor (existing pattern — many services have `logger: Logger`). Skip if no logger available and use `console.debug` fallback (project debug-log pattern).
|
||
|
||
### Step 8: Run full suite
|
||
|
||
```
|
||
npm run typecheck
|
||
npm test
|
||
```
|
||
|
||
Expected: typecheck 0, all tests PASS including new `recall-ipc.test.ts`.
|
||
|
||
### Step 9: Commit
|
||
|
||
```bash
|
||
git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/main/services/CaptureService.ts src/renderer/inbox/store.ts src/renderer/inbox/components/RecallBanner.tsx tests/unit/recall-ipc.test.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
refactor(v032): recall IPC handle→on + telemetry .catch debug log (#36, #20)
|
||
|
||
- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
|
||
(fire-and-forget honest pattern, return value 의존자 0)
|
||
- preload: ipcRenderer.invoke → send
|
||
- shared/types: Promise<void> → void
|
||
- CaptureService telemetry .catch silent → logger.debug (디버그 시
|
||
reproduce 가능)
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 기록 정리 + version bump
|
||
|
||
**Files:**
|
||
|
||
- Modify: `package.json` (version: 0.3.1 → 0.3.2)
|
||
- Modify: `docs/superpowers/v024-backlog.md` (처리 이력 갱신)
|
||
- Delete: `~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md`
|
||
- Modify: `~/.claude/projects/c--Users-rlaxo-inkling/memory/MEMORY.md` (line 8 제거)
|
||
|
||
### Step 1: Bump version
|
||
|
||
Modify `package.json` line 3:
|
||
|
||
Before:
|
||
```json
|
||
"version": "0.3.1",
|
||
```
|
||
|
||
After:
|
||
```json
|
||
"version": "0.3.2",
|
||
```
|
||
|
||
### Step 2: Verify rebuild target version updated automatically
|
||
|
||
`package.json` line 13: `"rebuild:electron": "cd node_modules/better-sqlite3 && prebuild-install --runtime=electron --target=41.3.0"` — the `target=41.3.0` is Electron version, not app version. No change.
|
||
|
||
### Step 3: Update v024-backlog.md processing history
|
||
|
||
Modify `docs/superpowers/v024-backlog.md` — find the processing history table (around lines 18-43). After the existing v0.2.7 entries, add:
|
||
|
||
```markdown
|
||
### v0.3.2 cleanup cut (2026-05-10)
|
||
|
||
| 항목 | 상태 | Cut |
|
||
|---|---|---|
|
||
| #14 (탭 ARIA `aria-pressed`→`role="tab"`) | ✅ 처리 | v0.3.2 |
|
||
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
|
||
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
|
||
| #20 (telemetry `.catch` silent → debug log) | ✅ 처리 | v0.3.2 |
|
||
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
|
||
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
|
||
| #36 (recall IPC `handle`→`on`) | ✅ 처리 | v0.3.2 |
|
||
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
|
||
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
|
||
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |
|
||
```
|
||
|
||
Update the header line:
|
||
|
||
Before:
|
||
```
|
||
**총 항목 수:** 46 (#1 stale 포함)
|
||
**잔여:** 23건 (=46 − 처리 22 − stale 1)
|
||
```
|
||
|
||
After:
|
||
```
|
||
**총 항목 수:** 46 (#1 stale 포함)
|
||
**잔여:** 13건 (=46 − 처리 32 − stale 1)
|
||
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug + cosmetic 10건)
|
||
```
|
||
|
||
### Step 4: Delete stale v0.2.2 memory file
|
||
|
||
```bash
|
||
rm ~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md
|
||
```
|
||
|
||
(On Windows PowerShell: `Remove-Item ~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md`)
|
||
|
||
### Step 5: Update MEMORY.md index
|
||
|
||
Modify `~/.claude/projects/c--Users-rlaxo-inkling/memory/MEMORY.md` — delete the line:
|
||
|
||
```markdown
|
||
- [v0.2.2 feedback](project_v022_feedback.md) — 2026-05-01 dogfood 6건: ollama 회복/AI 큐/태그 vocab/휴지통/만료 추천/저노이즈 리마인드. 미구현, 다음 brainstorm 후보
|
||
```
|
||
|
||
### Step 6: Verify everything
|
||
|
||
```
|
||
git status
|
||
git diff --stat
|
||
npm run typecheck
|
||
npm test
|
||
```
|
||
|
||
Expected:
|
||
- typecheck 0 errors
|
||
- 단위 ~720 PASS (710 + 10 신규 - 2 제거)
|
||
- staged files: package.json, docs/superpowers/v024-backlog.md
|
||
- memory files modified outside repo (not staged in git)
|
||
|
||
### Step 7: Commit release
|
||
|
||
```bash
|
||
git add package.json docs/superpowers/v024-backlog.md
|
||
git commit -m "$(cat <<'EOF'
|
||
chore(release): v0.3.2 — cleanup cut (잠재 bug + cosmetic 10 + 기록 정리)
|
||
|
||
backlog 잔여 23 → 13 (-10 처리, +1 stale 유지):
|
||
- 잠재 bug 4: vocabSet COLLATE / time-dep test flake / PII reason / KST inline
|
||
- cosmetic 6: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on
|
||
/ OllamaSettingsModal 폐기 audit / .catch debug log
|
||
|
||
기록 정리: v0.2.2 stale memory 폐기 + v024-backlog 처리 이력 갱신
|
||
|
||
단위 710 → 약 720, typecheck 0
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
After all 8 tasks:
|
||
|
||
```bash
|
||
git log --oneline | head -10
|
||
npm run typecheck
|
||
npm test 2>&1 | tail -20
|
||
```
|
||
|
||
Expected:
|
||
- 8 commits on cleanup branch
|
||
- typecheck 0
|
||
- ~720/720 PASS
|
||
|
||
PR 본문 작성 + Gitea 머지 흐름은 finishing-a-development-branch skill 진입.
|