From 6bbb8f854b9f52b242df07b98a91152d8b79ae93 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:34:19 +0000 Subject: [PATCH 1/8] =?UTF-8?q?docs(spec):=20config=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kickoff 인계(#197)의 brainstorm 결과를 확정한 spec. 트리거=명시 명령 `kebab config migrate`+doctor 안내, 주석 보존=toml_edit 부분 편집, 메커니즘=reconciliation(additive)+step 체인(non-additive) 하이브리드. init/migrate 가 주석 달린 default 문서를 공유. 안전 3축(멱등·백업·dry-run) + atomic write. wire schema config_migration.v1 신설. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-config-migration-design.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-config-migration-design.md diff --git a/docs/superpowers/specs/2026-05-31-config-migration-design.md b/docs/superpowers/specs/2026-05-31-config-migration-design.md new file mode 100644 index 0000000..37ed10c --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-config-migration-design.md @@ -0,0 +1,268 @@ +# config 마이그레이션 — 설계 (spec) + +> 2026-05-31. config.toml **스키마 진화 시 기존 사용자 파일을 자동 갱신**하는 기능의 +> 설계 계약. kickoff 인계 문서 +> [`2026-05-31-config-migration-kickoff.md`](../handoffs/2026-05-31-config-migration-kickoff.md) +> 의 brainstorm 결과를 확정한 spec 이다. 본 문서를 기준으로 plan → 구현. + +## 0. 결정 요약 (brainstorm 게이트) + +| 축 | 결정 | 근거 | +|----|------|------| +| **트리거** | 명시 명령 `kebab config migrate` + `kebab doctor` 안내 | 예측 가능성·안전. load 시 자동 쓰기는 쓰기 권한/동시 실행/손상 위험. | +| **주석 보존** | `toml_edit` 부분 편집 | 사용자가 손본 값·주석·순서·정렬 100% 보존. 빠진 것만 추가. | +| **버전 메커니즘** | reconciliation(additive) + step 체인(non-additive) 하이브리드 | kebab config 는 `schema_version` 이 줄곧 `1` 인 채로 섹션이 누적돼 버전 번호만으로 "무엇이 빠졌는지" 구분 불가 → 구조 비교가 본질. | + +## 1. 동기 (kickoff §1 재확인) + +v0.21.0 에서 `[ingest.expansion]` 등 섹션이 늘었지만, 기존 사용자 config.toml 은 +serde default 로 **동작은 호환**(off 로 로드)되나 그 섹션이 **파일에 써지지 않아** +사용자가 파일을 열어도 새 기능의 존재·노브를 알 수 없다. DB 는 V00X refinery +마이그레이션이 있는데 config 는 없다 — 이걸 만든다. + +핵심: **데이터 무효화가 아니라 *파일 가시성* 문제**. 읽기 호환성은 이미 확보돼 있으므로 +(`#[serde(default)]`), 만들 것은 *사용자 파일을 새 스키마에 맞춰 갱신*하는 것이다. + +## 2. 비목표 (YAGNI) + +- config 값의 **의미적 검증**(예: score_gate 범위 체크) — 별개 기능. 본 작업 범위 아님. +- **load 시 자동 마이그레이션** — 명시적으로 제외(트리거 결정). 추후 필요 시 별 작업. +- **다운그레이드**(새 → 옛 스키마) — 단방향만. +- 기존 사용자 **값의 재조정**(default 가 바뀌었다고 사용자 값 덮어쓰기) — 절대 안 함. + 마이그레이션은 *없는 것 추가* + *deprecated 정리*만. 사용자가 명시한 값은 불가침. + +## 3. 아키텍처 — 두 메커니즘 + +마이그레이션은 사용자 파일(`toml_edit::DocumentMut`)에 다음 순서로 적용한다. + +``` +원본 파일 → [1. step 체인(non-additive)] → [2. reconciliation(additive)] → [3. schema_version stamp] → 결과 +``` + +### 3.1 Reconciliation (additive — 핵심 메커니즘) + +**정의**: "default Config 구조에는 있지만 사용자 파일에 없는 테이블/키를, 설명 주석과 +함께 사용자 파일에 추가한다." 버전과 무관하게 동작하며 멱등이다. + +**참조 문서 = 주석 달린 default**: `annotated_default_document()` 가 단일 진실 원천이다. + +``` +fn annotated_default_document() -> toml_edit::DocumentMut +// Config::defaults() 를 toml_edit Document 로 직렬화한 뒤, +// 주석 카탈로그(§3.3)의 설명을 각 테이블/키의 decor(prefix)에 부착. +// → 이 문서가 "완전체 config.toml" 의 정의. +``` + +`kebab init` 도 이 함수의 출력을 그대로 파일로 쓴다(§5.2). 즉 **init 과 migrate 가 +동일한 참조 문서를 공유** → 주석·구조의 단일 원천. + +**reconcile 알고리즘** (참조 문서 `ref` → 사용자 문서 `user`, 재귀): + +``` +for each (key, ref_item) in ref (문서 순서 유지): + if key 가 user 에 없음: + user 에 ref_item 을 통째 복사 (decor=주석 포함). → change: added_section / added_key + else if ref_item 과 user[key] 가 둘 다 테이블: + recurse(ref_item, user[key]) # 하위만 비교 + else: + # 키가 이미 존재(값이 default 와 달라도) → 건드리지 않음. (값 불가침) +``` + +- **삽입 위치**: 누락 키는 해당 테이블 **끝에 append**(결정적·단순). 사용자가 짜둔 기존 + 순서는 보존되고 새 항목만 뒤에 붙는다. +- **중첩 테이블**: `[ingest]` 는 있는데 `[ingest.expansion]` 이 없으면 `expansion` + 하위 테이블만 추가. `[ingest]` 자체가 없으면 `[ingest]` + 그 안의 모든 하위를 추가. +- **값 불가침 예시**: 사용자가 `score_gate = 0.8` 로 바꿔뒀고 default 가 0.6 이어도, + 키가 존재하므로 **0.8 유지**. 마이그레이션은 0.6 으로 되돌리지 않는다. + +### 3.2 Step 체인 (non-additive) + +`schema_version` 기반 버전별 변환 함수. additive 가 아닌 변경(deprecated 제거, rename, +형식 변환)을 담당한다. DB refinery 패턴 차용. + +``` +const CURRENT_SCHEMA_VERSION: u32 = 2; // 이번 작업에서 1 → 2 + +fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec) +// v1 → v2 변환: 옛 `workspace.include` 키 제거 (p9-fb-25 deprecated). +// - doc["workspace"]["include"] 존재 시 remove → change: removed_deprecated. +// - 없으면 noop (멱등). +``` + +- **실행 범위**: 파일의 `schema_version`(없으면 1 로 간주) 부터 `CURRENT` 까지 순차 적용. + 이미 `CURRENT` 이상이면 step 없음. +- 각 step 은 **개별적으로 멱등**(이미 적용된 상태에서 재실행해도 noop). +- 이번 작업의 유일한 step 은 `1→2`(workspace.include 제거). 누적된 섹션 추가 + (image/ui/ingest/pdf/logging/expansion)는 **전부 reconciliation 이 처리**하므로 + step 으로 만들지 않는다. step 체인은 "구조로 표현 못 하는 변환"만 담는다. + +### 3.3 주석 카탈로그 + +"섹션/키 → 한국어 설명 주석" 매핑을 kebab-config 의 마이그레이션 모듈 한 곳에 정적 +정의한다. 단일 원천 — README/SMOKE 와 중복하지 않고 여기를 정본으로. + +- 기존 `init_workspace` 의 헤더(경로 정책 설명, `kebab-app/src/lib.rs:147~`)는 + **문서 레벨 prefix** 로 이전한다(`annotated_default_document` 가 부착). +- 섹션별 주석은 README Configuration §의 노브 설명을 차용해 **간결**하게(1~2줄). + 예: `[ingest.expansion]` → `# doc-side 별칭 확장 (기본 off). 검색 패러프레이즈 강건성↑.` +- 주석 문구는 짧게, 과하지 않게. 전체 문서는 생성된 파일·README·SMOKE 참고로 유도. + +### 3.4 멱등성 보장 (안전 1축) + +- reconciliation: 이미 있는 키는 skip → 두 번째 실행 시 changes 비어 있음. +- step: 각 step 이 noop-safe. +- 결과: **마이그레이션 후 재실행하면 `changed=false`, 파일 미변경.** 이것이 doctor + 체크(§5.3)와 멱등 테스트의 핵심 단언. + +## 4. 안전 3축 (kickoff §4.4) + +1. **멱등** — §3.4. +2. **백업** — 파일 수정 직전 `.bak` 생성(원본 복사). 기존 `.bak` 있으면 덮어씀 + (단순화; 변경 내용은 dry-run 으로 사전 확인 가능). dry-run 시 백업도 안 만듦. +3. **dry-run** — `--dry-run` 은 changes 만 계산·출력하고 **파일·백업 모두 미수정**. + +**실패 시 원본 보존(atomic write)**: 편집 결과는 `.tmp` 에 먼저 쓰고 +`rename(tmp, config)` 로 교체. rename 이전 어느 단계에서 실패해도 원본 불변. 순서: +`백업 생성 → tmp 쓰기 → tmp 검증(재파싱 round-trip) → atomic rename`. + +## 5. 표면 (surface) + +### 5.1 CLI — `kebab config migrate` + +신규 top-level `Config` 서브커맨드 그룹(clap nested, `Inspect`/`List` 패턴 차용): + +``` +kebab config migrate [--dry-run] [--json] +``` + +- 전역 `--config ` 존중 (facade rule). 미지정 시 XDG 기본 경로. +- 대상 파일이 없으면 에러: `config 파일이 없습니다. 먼저 kebab init 을 실행하세요.` + (`--json` 시 `error.v1`, code `config_not_found`). +- 사람용 출력: 변경 목록(추가된 섹션/키, 제거된 deprecated) + 백업 경로 + "N changes + applied" 또는 "already up to date". +- `--json`: `config_migration.v1` (§5.4). + +**facade**: kebab-cli 는 kebab-app 의 +`config_migrate_with_config_path(config_path: Option<&Path>, dry_run: bool) +-> anyhow::Result` 를 호출(파일 read/백업/atomic write +오케스트레이션은 app 계층, 순수 변환은 config 계층 — §6). + +### 5.2 `kebab init` 영향 (user-visible) + +`init_workspace` 가 `annotated_default_document()` 출력을 쓰도록 변경. 결과: init 이 +생성하는 config.toml 이 **섹션별 주석을 포함**(기존엔 헤더만). 이는 user-visible surface +변경이므로 README Configuration §·docs/SMOKE.md 의 config 예시 블록 동기화 필요. + +### 5.3 `kebab doctor` 체크 추가 (additive) + +config load 체크 직후 `config_migration` 체크 1개 추가: + +- 내부적으로 dry-run 마이그레이션 실행 → changes 비었으면 `ok=true`, + detail `config up to date (schema v2)`, hint=None. +- changes 있으면 `ok=false`, detail `N pending changes (added M sections, removed K + deprecated)`, hint `run kebab config migrate to update your config.toml`. +- **trade-off (확정)**: `DoctorCheck` 는 `ok: bool` 뿐이고 hint 는 `ok==false` 일 때 + 표시되는 규약이므로, "마이그레이션 필요"는 `ok=false` 로 신호한다. 이는 전체 + `DoctorReport.ok`(모든 체크의 AND)를 false 로 만든다 — 즉 *완전히 동작하지만 + config 가 옛 스키마인* 환경에서 `kebab doctor` 가 "비정상"으로 보고된다. 이를 + 의도된 동작으로 받아들인다(doctor = "정리할 것이 있는가"의 점검이고, hint 가 정확한 + 교정 명령을 제시). 새 키만 추가하는 additive 변경을 "건강 실패"로 과하게 보는 면이 + 있으나, 별도 warn 상태를 도입하는 것(스키마·표면 변경)보다 단순함을 택한다. +- `doctor.v1` 스키마는 변경 없음(checks 배열에 행 1개 추가 — additive, backward-compat). + +### 5.4 wire schema `config_migration.v1` (신규) + +`docs/wire-schema/v1/config_migration.schema.json` 신설. `--json` 출력: + +```json +{ + "schema_version": "config_migration.v1", + "dry_run": true, + "config_path": "/home/me/.config/kebab/config.toml", + "from_schema_version": 1, + "to_schema_version": 2, + "changed": true, + "backup_path": null, + "changes": [ + { "kind": "added_section", "path": "ingest.expansion", "detail": "doc-side 별칭 확장 (기본 off)" }, + { "kind": "added_key", "path": "logging.enabled", "detail": "ingest 로그 활성화" }, + { "kind": "removed_deprecated","path": "workspace.include","detail": "p9-fb-25: extractor 자동 결정" } + ] +} +``` + +- `changed`: 실제(또는 dry-run 시 가정) 변경 발생 여부. false 면 changes=[]. +- `backup_path`: 실제 적용 시 `.bak` 경로, dry-run 시 `null`. +- `kind` enum: `added_section | added_key | removed_deprecated`. (향후 `renamed`, + `reformatted` 확장 여지 — 본 작업은 3종.) +- additive 신규 스키마 → 기존 통합 영향 없음. wire major bump 아님(v1 추가). + +## 6. 코드 배치 (crate 경계) + +| 위치 | 책임 | 비고 | +|------|------|------| +| `crates/kebab-config/src/migrate.rs` (신규) | **순수 변환**: `annotated_default_document`, `reconcile`, step 체인, `CURRENT_SCHEMA_VERSION`, 주석 카탈로그, `MigrationChange`/`ConfigMigrationReport` 타입, `migrate_document(doc) -> Vec` | I/O 없음. 문자열 in → 문자열 out 로 테스트 가능. | +| `crates/kebab-config/Cargo.toml` | `toml_edit = "0.22"` 의존성 추가 | 주석 보존 편집 핵심. | +| `crates/kebab-app/src/lib.rs` | **I/O 오케스트레이션**: `config_migrate_with_config_path`(read → migrate_document → 백업 → tmp write → atomic rename), `init_workspace` 가 `annotated_default_document` 사용하도록 수정, doctor 에 체크 추가 | facade. fs 부작용은 app 계층. | +| `crates/kebab-cli/src/main.rs` | `Config { Migrate { dry_run } }` 서브커맨드, 사람용 출력 | kebab-app facade 만 호출. | +| `crates/kebab-cli/src/wire.rs` | `wire_config_migration(report) -> Value` | `config_migration.v1` 직렬화. | +| `docs/wire-schema/v1/config_migration.schema.json` (신규) | wire 계약 | | + +**경계 근거**: kebab-config 는 이미 파일 *읽기*(`from_file`)를 하지만, *쓰기*는 +`init_workspace`(app)에 있다. 일관성·테스트성 위해 순수 변환은 config, 부작용(백업·쓰기) +은 app. doctor(app)·cli 모두 동일 순수 변환을 재사용. + +## 7. schema_version 의 새 의미 + +- 기존: 항상 `1`, 검증·로직에 안 쓰이는 장식. +- 신규: "이 파일이 sync 된 스키마 버전" 마커 + step 체인의 축. +- `Config::defaults().schema_version` 및 `CURRENT_SCHEMA_VERSION` 을 **2** 로 bump. + 마이그레이션 완료 시 사용자 파일의 `schema_version` 을 `CURRENT` 로 stamp. +- 읽기 경로(`from_file`)는 여전히 `schema_version` 으로 **거부하지 않음**(forward-compat + 유지). 즉 옛 바이너리로 새 파일을, 새 바이너리로 옛 파일을 읽어도 동작. + +## 8. 문서 동기화 (user-facing surface) + +- **README.md Configuration §**: `kebab config migrate` 한 줄 + init config 가 섹션 + 주석을 갖는다는 설명. config 예시 블록을 `annotated_default_document` 산출과 일치. +- **docs/SMOKE.md**: config 예시 블록 동기화. migrate dry-run smoke 단계 추가. +- **docs/DOGFOOD.md**: config 관련 section 에 migrate 시나리오(옛 파일 → migrate → + 섹션 가시성 확인) 추가. +- **tasks/HOTFIXES.md**: 머지 후 dated entry(`## YYYY-MM-DD — config 마이그레이션`), + 도그푸딩 evidence(옛 config 에 빠진 섹션 N개 추가 + workspace.include 제거 멱등 확인). +- **HANDOFF.md**: 해당되면 한 줄. + +## 9. 릴리스 트리거 판단 + +- 신규 CLI 서브커맨드(`config migrate`) + doctor 체크 + init 출력 변경 = **user-visible + surface 변경** → 도그푸딩 필수, README 동기화 필수. +- `schema_version` bump(1→2)는 **additive**(데이터 무효화 아님, 읽기 호환 유지) → + CLAUDE.md §Versioning 의 DB/wire breaking 기준엔 해당 안 됨. 다만 surface 누적이 + 있으므로 **minor bump** 대상일 수 있음. 실제 bump/release 컷 시점은 사용자 판단. + +## 10. 테스트 전략 (plan 의 TDD 근거) + +순수 변환(kebab-config)이 테스트의 중심 — 문자열 in/out, fs 불필요: + +1. **reconciliation 추가**: 옛 config 문자열(섹션 누락) → migrate → 누락 섹션이 주석과 + 함께 추가됐고, 기존 키·주석·순서는 보존. +2. **값 불가침**: 사용자가 바꾼 값(예: `score_gate = 0.8`)이 migrate 후에도 유지. +3. **멱등**: migrate 출력을 다시 migrate → `changed=false`, 동일 문자열. +4. **step (workspace.include 제거)**: 옛 키 있는 문자열 → 제거됨 + change 기록. 없으면 noop. +5. **schema_version stamp**: 결과의 `schema_version = 2`. 없던 파일엔 추가됨. +6. **주석 보존**: 사용자가 임의 키에 단 주석이 migrate 후에도 그대로. +7. (app) **백업·atomic·실패 보존**: 백업 파일 생성, tmp rename, 손상 입력 시 원본 불변. +8. (app) **dry-run**: 파일·백업 미생성, report.changed 정확. +9. (cli/wire) `config_migration.v1` 직렬화 형태. + +## 11. Risks / notes + +- `toml_edit` 신규 의존성 — kebab-config 에 추가. `toml`(0.8)과 공존(serde 경로는 + 여전히 `toml`, 편집 경로만 `toml_edit`). 버전은 구현 시 최신 0.22.x 확인. +- reconciliation 의 "끝에 append" 는 사용자가 짠 미적 순서를 흩뜨릴 수 있으나(새 섹션이 + 뒤로 몰림), 값·주석·기존 순서 보존이 우선이며 단순·결정적이라 채택. +- 첫 step(`1→2`)은 사실상 이미 무시되는 `workspace.include` 청소뿐 — step 체인은 주로 + *프레임워크*로서 미래 non-additive 변경을 위해 깔아둔다. +- kickoff 인계 문서와의 차이: kickoff §4.2 는 "버전별 변환 함수 체인"만 제안했으나, + kebab 의 serde-default 특성상 additive 변경은 step 으로 표현하기 부적절(버전 무관) → + **reconciliation 을 1급 메커니즘으로 승격**하고 step 은 non-additive 전용으로 한정. -- 2.49.1 From 6d86214060d9b30848003e8de75700915b42888c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:39:31 +0000 Subject: [PATCH 2/8] =?UTF-8?q?docs(plan):=20config=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=20(TDD,=2013=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcile(additive)+step 체인(non-additive) 분리, init/migrate 공유 annotated_default_document, app facade 백업+atomic write, doctor 체크, CLI config migrate, wire config_migration.v1. bite-sized TDD steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-31-config-migration.md | 1188 +++++++++++++++++ 1 file changed, 1188 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-config-migration.md diff --git a/docs/superpowers/plans/2026-05-31-config-migration.md b/docs/superpowers/plans/2026-05-31-config-migration.md new file mode 100644 index 0000000..fac33f8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-config-migration.md @@ -0,0 +1,1188 @@ +# config 마이그레이션 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 기존 사용자 `config.toml` 을 `kebab config migrate` 로 새 스키마에 맞춰 갱신한다 — 빠진 섹션/키를 설명 주석과 함께 추가하고 deprecated 를 정리하되, 사용자가 손본 값·주석·순서는 보존한다. + +**Architecture:** 순수 변환(`kebab-config::migrate`, `toml_edit` 기반 reconciliation + step 체인)과 I/O 오케스트레이션(`kebab-app` 의 백업·atomic write)을 분리한다. `init` 과 `migrate` 는 "주석 달린 default 문서"(`annotated_default_document`)를 단일 원천으로 공유한다. `kebab doctor` 가 마이그레이션 필요 여부를 ok=false 로 신호한다. + +**Tech Stack:** Rust 2024, `toml_edit` 0.22(주석 보존 편집), `toml` 0.8(기존 serde 경로), clap(nested subcommand), serde_json(wire). + +**Spec:** [`docs/superpowers/specs/2026-05-31-config-migration-design.md`](../specs/2026-05-31-config-migration-design.md) + +--- + +## File Structure + +| 파일 | 책임 | 신규/수정 | +|------|------|-----------| +| `crates/kebab-config/Cargo.toml` | `toml_edit` 의존성 추가 | 수정 | +| `crates/kebab-config/src/migrate.rs` | 순수 변환 엔진: 타입, 주석 카탈로그, `annotated_default_document`, `reconcile`, step 체인, `migrate_document` | 신규 | +| `crates/kebab-config/src/lib.rs` | `pub mod migrate;` 재노출, `schema_version` default 2 로 bump | 수정 | +| `crates/kebab-app/src/lib.rs` | `config_migrate_with_config_path`(I/O), `init_workspace` 가 annotated doc 사용, doctor 체크 추가 | 수정 | +| `crates/kebab-cli/src/main.rs` | `Config { Migrate }` 서브커맨드 + 사람용 출력 | 수정 | +| `crates/kebab-cli/src/wire.rs` | `wire_config_migration` | 수정 | +| `crates/kebab-app/src/schema.rs` | `config_migration.v1` 을 schema 목록에 등록 | 수정 | +| `docs/wire-schema/v1/config_migration.v1.schema.json` | wire 계약 | 신규 | +| `README.md` / `docs/SMOKE.md` / `docs/DOGFOOD.md` | surface 동기화 | 수정 | +| `tasks/HOTFIXES.md` / `HANDOFF.md` | 머지 후 dated entry | 수정 | + +**빌드 명령(모든 task 공통):** +```bash +CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p > /tmp/t.log 2>&1; echo EXIT=$? +``` +절대 `cargo | grep` 금지. 실패 시 `/tmp/t.log` 확인. + +--- + +## Task 1: toml_edit 의존성 + migrate 모듈 스캐폴딩 + +**Files:** +- Modify: `crates/kebab-config/Cargo.toml` +- Create: `crates/kebab-config/src/migrate.rs` +- Modify: `crates/kebab-config/src/lib.rs` (모듈 선언) + +- [ ] **Step 1: `toml_edit` 의존성 추가** + +`crates/kebab-config/Cargo.toml` 의 `[dependencies]` 에 추가(기존 `toml = "0.8"` 아래): +```toml +toml_edit = "0.22" +``` + +- [ ] **Step 2: migrate.rs 에 타입 정의 + 모듈 선언** + +`crates/kebab-config/src/migrate.rs` 생성: +```rust +//! config.toml 마이그레이션 엔진 (순수 변환, I/O 없음). +//! +//! 두 메커니즘: (1) reconciliation — default 구조에 있고 사용자 파일에 +//! 없는 섹션/키를 주석과 함께 추가. (2) step 체인 — schema_version 기반 +//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec +//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`. + +use serde::Serialize; + +/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시 +/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다. +pub const CURRENT_SCHEMA_VERSION: u32 = 2; + +/// 한 번의 마이그레이션에서 발생한 개별 변경. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MigrationChange { + pub kind: ChangeKind, + /// dotted path, 예: `ingest.expansion`, `workspace.include`. + pub path: String, + /// 사람·wire 용 한 줄 설명. + pub detail: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + AddedSection, + AddedKey, + RemovedDeprecated, +} + +/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path +/// 등을 채워 wire 로 내보낸다. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MigrationOutcome { + pub from_schema_version: u32, + pub to_schema_version: u32, + pub changes: Vec, + /// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일). + pub new_text: String, +} + +impl MigrationOutcome { + pub fn changed(&self) -> bool { + !self.changes.is_empty() + } +} +``` + +`crates/kebab-config/src/lib.rs` 에 모듈 선언 추가(상단 `mod paths;` 근처): +```rust +pub mod migrate; +``` + +- [ ] **Step 3: 컴파일 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-config > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 4: Commit** + +```bash +git add crates/kebab-config/Cargo.toml crates/kebab-config/src/migrate.rs crates/kebab-config/src/lib.rs +git commit -m "feat(config): migrate 모듈 스캐폴딩 + toml_edit 의존성" +``` + +--- + +## Task 2: schema_version default 를 2 로 bump + +**Files:** +- Modify: `crates/kebab-config/src/lib.rs:672` (`Config::defaults()` 의 `schema_version: 1`) +- Modify: 테스트/fixture 의 `schema_version = 1` 리터럴 + +- [ ] **Step 1: 실패 테스트 추가** + +`crates/kebab-config/src/lib.rs` 의 `#[cfg(test)] mod tests` 에 추가: +```rust +#[test] +fn defaults_schema_version_matches_current() { + assert_eq!( + Config::defaults().schema_version, + crate::migrate::CURRENT_SCHEMA_VERSION + ); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config defaults_schema_version_matches_current > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (`left: 1, right: 2`) + +- [ ] **Step 3: default bump** + +`crates/kebab-config/src/lib.rs` 의 `Config::defaults()` 본문(약 672행): +```rust + schema_version: crate::migrate::CURRENT_SCHEMA_VERSION, +``` +(`schema_version: 1,` 을 위 줄로 교체.) + +- [ ] **Step 4: 인라인 fixture 갱신** + +같은 파일 테스트 영역의 하드코딩 `schema_version = 1` 리터럴(약 1276행, 1704행의 `const *_TOML` 문자열)은 **그대로 둔다** — 그것들은 "옛 파일도 로드된다"는 forward-compat 테스트의 입력이므로 1 이어야 한다. `defaults_are_serde_roundtrip_stable`(1347행 부근)은 `Config::defaults()` 를 직렬화→역직렬화하므로 자동으로 2 가 되어 통과. 추가 수정 불필요. (혹시 `assert!(toml.contains("schema_version = 1"))` 형태가 있으면 2 로 갱신 — grep `schema_version = 1` 로 확인.) + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-config/src/lib.rs +git commit -m "feat(config): schema_version default 1 → 2 (마이그레이션 축)" +``` + +--- + +## Task 3: 주석 카탈로그 + annotated_default_document + +**Files:** +- Modify: `crates/kebab-config/src/migrate.rs` + +`toml_edit` 0.22 API 메모: 테이블 헤더 위 주석은 `table.decor_mut().set_prefix("# ...\n")`. 테이블 안 key 위 주석은 `table.key_mut("name").unwrap().leaf_decor_mut().set_prefix("# ...\n")` (KeyMut). 값이 dotted/inline 인 경우도 동일하게 key 의 leaf decor 사용. 문서 전체 상단 주석은 `doc.decor_mut().set_prefix(...)` 가 아니라 첫 항목의 prefix 또는 `doc.set_trailing`/직접 문자열 prepend — 여기서는 첫 key 의 prefix 에 헤더를 붙인다. + +- [ ] **Step 1: 실패 테스트 추가** + +`migrate.rs` 하단: +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn annotated_default_has_all_sections_and_parses_back_to_defaults() { + let doc = annotated_default_document(); + let text = doc.to_string(); + // 핵심 섹션이 텍스트에 존재 + for section in ["[workspace]", "[ingest.expansion]", "[pdf]", "[logging]", "[ui]"] { + assert!(text.contains(section), "missing {section}:\n{text}"); + } + // 주석이 적어도 하나 부착됨 + assert!(text.contains("# "), "no comments attached"); + // 역파싱하면 Config::defaults() 와 동일(주석은 serde 가 무시) + let back: crate::Config = toml::from_str(&text).expect("parse annotated default"); + assert_eq!(back, crate::Config::defaults()); + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config annotated_default_has_all_sections > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (`annotated_default_document` 미정의 → 컴파일 에러) + +- [ ] **Step 3: 카탈로그 + 함수 구현** + +`migrate.rs` 에 추가: +```rust +use toml_edit::DocumentMut; + +/// 문서 최상단 헤더(경로 정책 등). 기존 init 헤더를 이전. +const HEADER: &str = "\ +# kebab config — `~/.config/kebab/config.toml`. +# +# `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로. +# 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리. +# 처리 형식(extractor 자동 결정): Markdown(.md) / 이미지(.png .jpg) / PDF(.pdf). +# 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest). +# +# 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다 +# (빠진 섹션 추가 + 손본 값·주석 보존). +"; + +/// 테이블 헤더(`[section]`) 위에 붙일 주석. dotted path → 한 줄(들). +fn section_comment(path: &str) -> Option<&'static str> { + Some(match path { + "workspace" => "# 색인 대상 워크스페이스.", + "storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).", + "indexing" => "# 병렬도 + 파일시스템 watch.", + "chunking" => "# 청크 크기·오버랩·heading 존중.", + "models" => "# embedding / llm / nli 모델.", + "models.embedding" => "# 다국어 sentence embedding. dim 불일치 시 검색 0건.", + "models.llm" => "# Ollama host:port + 모델.", + "search" => "# 검색 기본 k·stale 기준·fusion.", + "rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.", + "image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).", + "ui" => "# TUI 팔레트·role 스타일.", + "ingest" => "# ingest 정책(code skip 등).", + "ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).", + "ingest.expansion" => "# doc-side 별칭 확장(기본 off). 패러프레이즈 강건성↑, LLM 비용 큼.", + "pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).", + "logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).", + _ => return None, + }) +} + +/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서. +/// init 과 migrate reconciliation 의 단일 참조 원천. +pub fn annotated_default_document() -> DocumentMut { + let defaults = crate::Config::defaults(); + let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize"); + let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit"); + + // 헤더: 첫 최상위 항목의 prefix 로. + if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() { + let prefix = format!("{HEADER}\n"); + first_key.leaf_decor_mut().set_prefix(prefix); + } + + annotate_table(doc.as_table_mut(), ""); + doc +} + +/// 재귀적으로 테이블/키에 주석 부착. `prefix_path` 는 dotted 누적 경로. +fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) { + // 키 이름 목록을 먼저 수집(차용 충돌 회피). + let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); + for key in keys { + let path = if prefix_path.is_empty() { + key.clone() + } else { + format!("{prefix_path}.{key}") + }; + // 하위 테이블이면 헤더 주석 + 재귀. + if let Some(item) = table.get_mut(&key) { + if let Some(sub) = item.as_table_mut() { + if let Some(c) = section_comment(&path) { + let existing = sub.decor().prefix().and_then(|p| p.as_str()).unwrap_or(""); + if !existing.contains(c) { + sub.decor_mut().set_prefix(format!("\n{c}\n")); + } + } + annotate_table(sub, &path); + } + } + } +} +``` + +> 주: `leaf_decor_mut` / `decor_mut` 의 정확한 시그니처는 toml_edit 0.22 `cargo doc` 으로 확인. 위 코드가 컴파일 안 되면 동등 API(`key_decor_mut`, `Table::decor_mut`)로 조정 — 테스트가 가드한다. + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config annotated_default > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-config/src/migrate.rs +git commit -m "feat(config): 주석 카탈로그 + annotated_default_document" +``` + +--- + +## Task 4: reconcile — 빠진 섹션/키를 주석과 함께 추가 + +**Files:** +- Modify: `crates/kebab-config/src/migrate.rs` + +- [ ] **Step 1: 실패 테스트 추가** + +`migrate.rs` tests: +```rust + #[test] + fn reconcile_adds_missing_section_preserving_user_values_and_comments() { + // 옛 파일: expansion/logging 없음, score 는 사용자가 바꿈, 주석 보유. + let user_text = "\ +schema_version = 1 + +[workspace] +root = \"/my/notes\" # 내 워크스페이스 + +[search] +default_k = 25 +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let reference = annotated_default_document(); + let mut changes = Vec::new(); + reconcile(reference.as_table(), user.as_table_mut(), "", &mut changes); + let out = user.to_string(); + + // 빠진 섹션 추가됨 + assert!(out.contains("[ingest.expansion]"), "expansion not added:\n{out}"); + assert!(out.contains("[logging]"), "logging not added"); + // 사용자 값/주석 보존 + assert!(out.contains("root = \"/my/notes\"")); + assert!(out.contains("# 내 워크스페이스")); + assert!(out.contains("default_k = 25")); + // 새 섹션엔 주석 부착 + assert!(out.contains("doc-side 별칭")); + // change 기록 + assert!(changes.iter().any(|c| c.kind == ChangeKind::AddedSection + && c.path == "ingest.expansion")); + } + + #[test] + fn reconcile_does_not_overwrite_user_value_differing_from_default() { + let user_text = "\ +schema_version = 2 + +[rag] +score_gate = 0.8 +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let reference = annotated_default_document(); + let mut changes = Vec::new(); + reconcile(reference.as_table(), user.as_table_mut(), "", &mut changes); + let out = user.to_string(); + assert!(out.contains("score_gate = 0.8"), "user value clobbered:\n{out}"); + // rag 의 다른 키들은 추가됐을 수 있으나 score_gate 변경 change 는 없어야 함 + assert!(!changes.iter().any(|c| c.path == "rag.score_gate")); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config reconcile_ > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (`reconcile` 미정의) + +- [ ] **Step 3: reconcile 구현** + +`migrate.rs`: +```rust +/// 참조(주석 달린 default) 테이블 `reference` 를 기준으로, 사용자 테이블 +/// `user` 에 없는 항목을 decor(주석) 포함 통째 복사한다. 이미 있는 키는 +/// 건드리지 않는다(값 불가침). 양쪽이 테이블이면 하위로 재귀. +pub fn reconcile( + reference: &toml_edit::Table, + user: &mut toml_edit::Table, + prefix_path: &str, + changes: &mut Vec, +) { + for (key, ref_item) in reference.iter() { + let path = if prefix_path.is_empty() { + key.to_string() + } else { + format!("{prefix_path}.{key}") + }; + match user.get_mut(key) { + None => { + // 통째 삽입(decor 보존). 테이블이면 added_section, 아니면 added_key. + let kind = if ref_item.is_table() { + ChangeKind::AddedSection + } else { + ChangeKind::AddedKey + }; + // schema_version 키는 reconcile 가 아니라 stamp 단계가 다룬다. + if path == "schema_version" { + user.insert(key, ref_item.clone()); + continue; + } + user.insert(key, ref_item.clone()); + changes.push(MigrationChange { + kind, + path: path.clone(), + detail: section_comment(&path) + .map(|c| c.trim_start_matches("# ").to_string()) + .unwrap_or_else(|| format!("{key} 추가")), + }); + } + Some(existing) => { + if let (Some(ref_tbl), Some(user_tbl)) = + (ref_item.as_table(), existing.as_table_mut()) + { + reconcile(ref_tbl, user_tbl, &path, changes); + } + // 둘 다 테이블이 아니면(스칼라 등) 값 불가침 → 무시. + } + } + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config reconcile_ > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-config/src/migrate.rs +git commit -m "feat(config): reconcile — 빠진 섹션/키 주석과 함께 추가(값 불가침)" +``` + +--- + +## Task 5: step 체인 — workspace.include 제거 (v1→v2) + +**Files:** +- Modify: `crates/kebab-config/src/migrate.rs` + +- [ ] **Step 1: 실패 테스트 추가** + +```rust + #[test] + fn step_1_to_2_removes_deprecated_workspace_include() { + let user_text = "\ +[workspace] +root = \"/n\" +include = [\"*.md\"] +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let mut changes = Vec::new(); + step_1_to_2(&mut user, &mut changes); + let out = user.to_string(); + assert!(!out.contains("include"), "include not removed:\n{out}"); + assert!(changes.iter().any(|c| c.kind == ChangeKind::RemovedDeprecated + && c.path == "workspace.include")); + // 멱등: 재실행 시 noop + let mut changes2 = Vec::new(); + step_1_to_2(&mut user, &mut changes2); + assert!(changes2.is_empty()); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config step_1_to_2 > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (미정의) + +- [ ] **Step 3: 구현** + +```rust +/// v1 → v2: deprecated `workspace.include` 제거(p9-fb-25). 멱등. +pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec) { + if let Some(ws) = doc.get_mut("workspace").and_then(|i| i.as_table_mut()) { + if ws.remove("include").is_some() { + changes.push(MigrationChange { + kind: ChangeKind::RemovedDeprecated, + path: "workspace.include".to_string(), + detail: "p9-fb-25: 처리 형식은 extractor 가 자동 결정 — 더 이상 사용 안 함." + .to_string(), + }); + } + } +} + +/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용. +fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec) { + if from < 2 { + step_1_to_2(doc, changes); + } + // 미래 step: if from < 3 { step_2_to_3(...) } ... +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config step_1_to_2 > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-config/src/migrate.rs +git commit -m "feat(config): step 체인 v1→v2(workspace.include 제거) + run_steps" +``` + +--- + +## Task 6: migrate_document — 오케스트레이션 + 멱등 + schema_version stamp + +**Files:** +- Modify: `crates/kebab-config/src/migrate.rs` + +- [ ] **Step 1: 실패 테스트 추가** + +```rust + fn read_schema_version(text: &str) -> u32 { + let doc: DocumentMut = text.parse().unwrap(); + doc.get("schema_version") + .and_then(|i| i.as_integer()) + .unwrap_or(1) as u32 + } + + #[test] + fn migrate_document_stamps_version_and_is_idempotent() { + let old = "\ +schema_version = 1 + +[workspace] +root = \"/n\" +include = [\"*.md\"] +"; + let outcome = migrate_document(old); + assert_eq!(outcome.from_schema_version, 1); + assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION); + assert!(outcome.changed()); + assert!(!outcome.new_text.contains("include")); + assert!(outcome.new_text.contains("[ingest.expansion]")); + assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION); + + // 멱등: migrate 결과를 다시 migrate → 변경 없음, 텍스트 동일. + let again = migrate_document(&outcome.new_text); + assert!(!again.changed(), "not idempotent: {:?}", again.changes); + assert_eq!(again.new_text, outcome.new_text); + } + + #[test] + fn migrate_document_missing_schema_version_treated_as_v1() { + let old = "[workspace]\nroot = \"/n\"\n"; + let outcome = migrate_document(old); + assert_eq!(outcome.from_schema_version, 1); + assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION); + } +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config migrate_document > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (미정의) + +- [ ] **Step 3: 구현** + +```rust +/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version +/// stamp 를 적용하고 결과를 반환한다. 순수 함수(I/O 없음). 파싱 실패 시 +/// from=1, 변경 없음, new_text=입력 그대로(상위에서 파싱 에러를 따로 처리). +pub fn migrate_document(text: &str) -> MigrationOutcome { + let mut doc: DocumentMut = match text.parse() { + Ok(d) => d, + Err(_) => { + return MigrationOutcome { + from_schema_version: 1, + to_schema_version: CURRENT_SCHEMA_VERSION, + changes: Vec::new(), + new_text: text.to_string(), + }; + } + }; + let from = doc + .get("schema_version") + .and_then(|i| i.as_integer()) + .unwrap_or(1) as u32; + + let mut changes = Vec::new(); + + // 1. non-additive step 체인. + run_steps(&mut doc, from, &mut changes); + + // 2. additive reconciliation(버전 무관). + let reference = annotated_default_document(); + let ref_table = reference.as_table().clone(); + reconcile(&ref_table, doc.as_table_mut(), "", &mut changes); + + // 3. schema_version stamp. + let current_in_file = doc + .get("schema_version") + .and_then(|i| i.as_integer()) + .unwrap_or(0) as u32; + if current_in_file != CURRENT_SCHEMA_VERSION { + doc["schema_version"] = toml_edit::value(i64::from(CURRENT_SCHEMA_VERSION)); + } + + MigrationOutcome { + from_schema_version: from, + to_schema_version: CURRENT_SCHEMA_VERSION, + changes, + new_text: doc.to_string(), + } +} +``` + +> 멱등 주의: reconcile 가 `schema_version` 을 추가할 때 change 를 기록하지 않도록 Task 4 에서 `path == "schema_version"` 분기를 두었다. version stamp 도 값이 같으면 건드리지 않는다. 따라서 두 번째 실행은 changes 빈 배열. + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-config migrate_document > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0`. 전체: `cargo test -p kebab-config` 도 `EXIT=0`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-config/src/migrate.rs +git commit -m "feat(config): migrate_document — step+reconcile+stamp, 멱등" +``` + +--- + +## Task 7: kebab-app facade — 파일 read/백업/atomic write + dry-run + +**Files:** +- Modify: `crates/kebab-app/src/lib.rs` +- Test: `crates/kebab-app/src/lib.rs` (`#[cfg(test)]`) 또는 `crates/kebab-app/tests/config_migrate.rs` + +- [ ] **Step 1: 실패 테스트 추가** + +`crates/kebab-app/tests/config_migrate.rs` 생성: +```rust +use std::fs; + +#[test] +fn migrate_writes_backup_and_atomic_with_dry_run_noop() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + fs::write(&cfg, "schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n").unwrap(); + + // dry-run: 파일·백업 미변경. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap(); + assert!(report.changed); + assert!(report.dry_run); + assert!(report.backup_path.is_none()); + assert!(!dir.path().join("config.toml.bak").exists()); + assert!(fs::read_to_string(&cfg).unwrap().contains("include"), "dry-run modified file"); + + // 실제 적용: 백업 생성 + 파일 갱신. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + assert!(report.changed); + assert!(!report.dry_run); + assert!(report.backup_path.is_some()); + assert!(dir.path().join("config.toml.bak").exists()); + let new = fs::read_to_string(&cfg).unwrap(); + assert!(!new.contains("include")); + assert!(new.contains("[ingest.expansion]")); + + // 멱등: 재실행 changed=false. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + assert!(!report.changed); +} + +#[test] +fn migrate_missing_file_errors() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("nope.toml"); + assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err()); +} +``` + +`crates/kebab-app/Cargo.toml` 의 `[dev-dependencies]` 에 `tempfile` 이 없으면 추가(`tempfile = "3"`). 먼저 grep 으로 확인. + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app --test config_migrate > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (`config_migrate_with_config_path` 미정의) + +- [ ] **Step 3: facade + 리포트 타입 구현** + +`crates/kebab-app/src/lib.rs` 에 추가(doctor 함수 근처): +```rust +/// `kebab config migrate` 의 결과(wire `config_migration.v1` 소스). +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct ConfigMigrationReport { + pub schema_version: String, // 항상 "config_migration.v1" + pub config_path: String, + pub dry_run: bool, + pub from_schema_version: u32, + pub to_schema_version: u32, + pub changed: bool, + pub backup_path: Option, + pub changes: Vec, +} + +/// 사용자 config.toml 을 새 스키마로 마이그레이션한다(facade). +/// `config_path` 미지정 시 XDG 기본. `dry_run=true` 면 파일·백업 미변경. +pub fn config_migrate_with_config_path( + config_path: Option<&std::path::Path>, + dry_run: bool, +) -> anyhow::Result { + let path: PathBuf = match config_path { + Some(p) => p.to_path_buf(), + None => kebab_config::Config::xdg_config_path(), + }; + if !path.exists() { + anyhow::bail!( + "config 파일이 없습니다: {} — 먼저 `kebab init` 을 실행하세요.", + path.display() + ); + } + let text = std::fs::read_to_string(&path)?; + let outcome = kebab_config::migrate::migrate_document(&text); + + let mut backup_path = None; + if !dry_run && outcome.changed() { + // 백업. + let bak = path.with_extension("toml.bak"); + std::fs::copy(&path, &bak)?; + backup_path = Some(bak.display().to_string()); + // atomic: tmp 쓰기 → 재파싱 검증 → rename. + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, &outcome.new_text)?; + // round-trip 검증(손상 방지). + if kebab_config::Config::from_file(&tmp).is_err() { + std::fs::remove_file(&tmp).ok(); + anyhow::bail!("마이그레이션 결과가 유효하지 않아 원본을 보존합니다."); + } + std::fs::rename(&tmp, &path)?; + } + + Ok(ConfigMigrationReport { + schema_version: "config_migration.v1".to_string(), + config_path: path.display().to_string(), + dry_run, + from_schema_version: outcome.from_schema_version, + to_schema_version: outcome.to_schema_version, + changed: outcome.changed(), + backup_path, + changes: outcome.changes, + }) +} +``` + +`MigrationChange` 가 `Serialize` 인지 확인(Task 1 에서 derive 함). `kebab-app/Cargo.toml` 에 `kebab-config` 의존성은 이미 있음. + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app --test config_migrate > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-app/src/lib.rs crates/kebab-app/Cargo.toml crates/kebab-app/tests/config_migrate.rs +git commit -m "feat(app): config_migrate facade — 백업+atomic write+dry-run" +``` + +--- + +## Task 8: init_workspace 가 annotated_default_document 사용 + +**Files:** +- Modify: `crates/kebab-app/src/lib.rs:145-180` (`init_workspace` 의 config 쓰기 블록) + +- [ ] **Step 1: 실패 테스트 추가** + +`crates/kebab-app/tests/config_migrate.rs` 에 추가(init 은 XDG 경로를 쓰므로 직접 함수가 아닌, annotated doc 의 산출을 검증 — kebab-config 차원에서 이미 Task3 가 커버. 여기선 init 산출이 섹션 주석을 포함하는지 단위로): +```rust +#[test] +fn annotated_default_serialization_contains_section_comments() { + let doc = kebab_config::migrate::annotated_default_document(); + let text = doc.to_string(); + assert!(text.contains("doc-side 별칭"), "section comment missing:\n{text}"); + assert!(text.contains("[ingest.expansion]")); +} +``` + +- [ ] **Step 2: 실패 확인 / 현황 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app annotated_default_serialization > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: PASS (Task3 가 이미 구현) — 이 테스트는 회귀 가드. init 본문 교체가 목적. + +- [ ] **Step 3: init_workspace 본문 교체** + +`crates/kebab-app/src/lib.rs` 의 config 쓰기 블록(약 145~180행, `let toml_text = toml::to_string_pretty(&cfg)?;` ~ `std::fs::write(&cfg_path, combined)?;`)을 다음으로 교체: +```rust + if !cfg_path.exists() || force { + // init 과 migrate 가 동일한 "주석 달린 default" 문서를 공유한다. + let doc = kebab_config::migrate::annotated_default_document(); + std::fs::write(&cfg_path, doc.to_string())?; + } +``` +(기존 `let cfg = ...defaults()`, `toml::to_string_pretty`, `header` 문자열, `combined` 조립은 모두 삭제 — 헤더는 annotated_default_document 의 HEADER 로 이전됨.) + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-app/src/lib.rs crates/kebab-app/tests/config_migrate.rs +git commit -m "feat(app): init 이 주석 달린 default 문서 사용(섹션 주석 포함)" +``` + +--- + +## Task 9: doctor 에 config_migration 체크 추가 + +**Files:** +- Modify: `crates/kebab-app/src/lib.rs` (`doctor_with_config_path`, 약 3214행 `let ok = ...` 직전) + +- [ ] **Step 1: 실패 테스트 추가** + +`crates/kebab-app/tests/config_migrate.rs`: +```rust +#[test] +fn doctor_flags_outdated_config() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + // 옛 파일(섹션 누락 + deprecated). + fs::write(&cfg, "schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n").unwrap(); + let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap(); + let check = report.checks.iter().find(|c| c.name == "config_migration").unwrap(); + assert!(!check.ok, "outdated config should fail check"); + assert!(check.hint.as_deref().unwrap().contains("config migrate")); + assert!(!report.ok, "overall doctor should be false"); + + // migrate 후엔 통과. + kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap(); + let check = report.checks.iter().find(|c| c.name == "config_migration").unwrap(); + assert!(check.ok, "after migrate should pass"); +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app doctor_flags_outdated > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL (config_migration 체크 없음) + +- [ ] **Step 3: 체크 추가** + +`crates/kebab-app/src/lib.rs` 의 `doctor_with_config_path` 에서 `let ok = checks.iter().all(...)` 직전에 추가: +```rust + // config_migration — 사용자 파일이 새 스키마와 동기인지(dry-run 마이그레이션). + // 파일이 존재할 때만 점검(없으면 defaults 사용 중이라 마이그레이션 무의미). + if cfg_path.exists() { + if let Ok(text) = std::fs::read_to_string(&cfg_path) { + let outcome = kebab_config::migrate::migrate_document(&text); + let (mok, detail, hint) = if outcome.changed() { + let added = outcome + .changes + .iter() + .filter(|c| { + matches!( + c.kind, + kebab_config::migrate::ChangeKind::AddedSection + | kebab_config::migrate::ChangeKind::AddedKey + ) + }) + .count(); + let removed = outcome.changes.len() - added; + ( + false, + format!( + "{} pending changes (added {added}, removed {removed} deprecated)", + outcome.changes.len() + ), + Some("run `kebab config migrate` to update your config.toml".to_string()), + ) + } else { + ( + true, + format!("config up to date (schema v{})", outcome.to_schema_version), + None, + ) + }; + checks.push(DoctorCheck { + name: "config_migration".to_string(), + ok: mok, + detail, + hint, + }); + } + } +``` + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app doctor_flags_outdated > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0`. 기존 doctor 테스트(`cargo test -p kebab-app`)도 깨지지 않는지 확인(default config 테스트가 ok=true 가정한다면 영향 점검). + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-app/src/lib.rs crates/kebab-app/tests/config_migrate.rs +git commit -m "feat(app): doctor 가 config 마이그레이션 필요 시 ok=false 로 안내" +``` + +--- + +## Task 10: CLI `kebab config migrate` 서브커맨드 + +**Files:** +- Modify: `crates/kebab-cli/src/main.rs` (Cmd enum + match 핸들러) +- Modify: `crates/kebab-cli/src/wire.rs` (wire_config_migration) + +- [ ] **Step 1: wire 함수 + 실패 테스트(wire.rs)** + +`crates/kebab-cli/src/wire.rs` 에 추가: +```rust +/// `config_migration.v1` wire 직렬화. +pub fn wire_config_migration(r: &kebab_app::ConfigMigrationReport) -> serde_json::Value { + serde_json::json!({ + "schema_version": r.schema_version, + "config_path": r.config_path, + "dry_run": r.dry_run, + "from_schema_version": r.from_schema_version, + "to_schema_version": r.to_schema_version, + "changed": r.changed, + "backup_path": r.backup_path, + "changes": r.changes.iter().map(|c| serde_json::json!({ + "kind": c.kind, + "path": c.path, + "detail": c.detail, + })).collect::>(), + }) +} +``` +(테스트는 통합 단계에서 CLI 스냅샷으로 — 단위 테스트가 wire.rs 에 있으면 패턴 따라 추가.) + +- [ ] **Step 2: Cmd enum 에 Config 그룹 추가** + +`crates/kebab-cli/src/main.rs` 의 `enum Cmd` 에 variant 추가(`Doctor,` 근처): +```rust + /// config.toml 관리. + Config { + #[command(subcommand)] + what: ConfigWhat, + }, +``` +같은 파일에 서브커맨드 enum 추가(다른 `*What` enum 들 근처): +```rust +#[derive(Subcommand, Debug)] +enum ConfigWhat { + /// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등). + Migrate { + /// 변경만 출력하고 파일은 수정하지 않는다. + #[arg(long)] + dry_run: bool, + }, +} +``` + +- [ ] **Step 3: match 핸들러 추가** + +`main()` 의 `match cli.command` 에 arm 추가(`Cmd::Doctor =>` 근처 패턴 차용 — `cli.json` 플래그명은 기존 코드 확인 후 맞춤): +```rust + Cmd::Config { what } => match what { + ConfigWhat::Migrate { dry_run } => { + let report = kebab_app::config_migrate_with_config_path( + cli.config.as_deref(), + dry_run, + )?; + if cli.json { + println!("{}", wire::wire_config_migration(&report)); + } else if !report.changed { + println!("config 이미 최신입니다 (schema v{}).", report.to_schema_version); + } else { + let verb = if report.dry_run { "변경 예정" } else { "적용됨" }; + println!( + "config 마이그레이션 {verb}: v{} → v{} ({} changes)", + report.from_schema_version, + report.to_schema_version, + report.changes.len() + ); + for c in &report.changes { + println!(" - [{:?}] {} — {}", c.kind, c.path, c.detail); + } + if let Some(bak) = &report.backup_path { + println!("백업: {bak}"); + } + if report.dry_run { + println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)"); + } + } + } + }, +``` +> `cli.json` / `cli.config` 의 정확한 필드명은 같은 파일 다른 arm(예: Doctor, Search)에서 확인해 맞춘다. `--json` 이 전역 플래그인지 서브커맨드별인지도 기존 패턴을 따른다. + +- [ ] **Step 4: 빌드 + smoke** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build -p kebab-cli > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0` + +수동 smoke: +```bash +BIN=/build/out/cargo-target/target/debug/kebab +T=$(mktemp -d) +printf 'schema_version = 1\n\n[workspace]\nroot = "/n"\ninclude=["*.md"]\n' > $T/config.toml +$BIN --config $T/config.toml config migrate --dry-run +$BIN --config $T/config.toml config migrate +$BIN --config $T/config.toml config migrate # 멱등 → "이미 최신" +$BIN --config $T/config.toml --json config migrate --dry-run +ls $T # config.toml + config.toml.bak +``` +Expected: dry-run 은 변경 목록 + 파일 미수정, 실제 적용은 .bak 생성 + 섹션 추가, 재실행은 "이미 최신", --json 은 `config_migration.v1`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/kebab-cli/src/main.rs crates/kebab-cli/src/wire.rs +git commit -m "feat(cli): kebab config migrate 서브커맨드(+--dry-run/--json)" +``` + +--- + +## Task 11: wire schema 파일 + schema 목록 등록 + +**Files:** +- Create: `docs/wire-schema/v1/config_migration.v1.schema.json` +- Modify: `crates/kebab-app/src/schema.rs` (약 110행, schema 라벨 목록) + +- [ ] **Step 1: schema 목록 실패 테스트** + +`crates/kebab-app/src/schema.rs` 의 schema 목록(`"doctor.v1",` 가 있는 배열)에 `"config_migration.v1",` 가 포함되는지 검증하는 테스트가 있으면 그걸 갱신; 없으면 추가: +```rust +#[test] +fn schema_list_includes_config_migration() { + assert!(SCHEMAS.contains(&"config_migration.v1")); +} +``` +(배열 상수명은 실제 코드 확인 — `SCHEMAS` 또는 유사.) + +- [ ] **Step 2: 실패 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app schema_list_includes_config_migration > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: FAIL + +- [ ] **Step 3: 목록 등록 + JSON 스키마 파일** + +`schema.rs` 의 라벨 배열에 `"config_migration.v1",` 추가(알파벳/논리 순서 맞춤). + +`docs/wire-schema/v1/config_migration.v1.schema.json` 생성: +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "config_migration.v1", + "type": "object", + "required": ["schema_version", "config_path", "dry_run", "from_schema_version", "to_schema_version", "changed", "changes"], + "properties": { + "schema_version": { "const": "config_migration.v1" }, + "config_path": { "type": "string" }, + "dry_run": { "type": "boolean" }, + "from_schema_version": { "type": "integer" }, + "to_schema_version": { "type": "integer" }, + "changed": { "type": "boolean" }, + "backup_path": { "type": ["string", "null"] }, + "changes": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "path", "detail"], + "properties": { + "kind": { "enum": ["added_section", "added_key", "removed_deprecated"] }, + "path": { "type": "string" }, + "detail": { "type": "string" } + } + } + } + } +} +``` + +- [ ] **Step 4: 통과 확인** + +Run: `CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test -p kebab-app > /tmp/t.log 2>&1; echo EXIT=$?` +Expected: `EXIT=0`. wire schema 디렉토리와 코드 목록 일치를 검사하는 테스트가 있으면 함께 통과. + +- [ ] **Step 5: Commit** + +```bash +git add docs/wire-schema/v1/config_migration.v1.schema.json crates/kebab-app/src/schema.rs +git commit -m "feat(wire): config_migration.v1 스키마 + schema 목록 등록" +``` + +--- + +## Task 12: 전체 게이트 + 문서 동기화 + +**Files:** +- Modify: `README.md` (Configuration §), `docs/SMOKE.md`, `docs/DOGFOOD.md` + +- [ ] **Step 1: clippy + 전체 테스트 게이트** + +```bash +CARGO_TARGET_DIR=/build/out/cargo-target/target cargo clippy --workspace --all-targets -- -D warnings > /tmp/clippy.log 2>&1; echo EXIT=$? +CARGO_TARGET_DIR=/build/out/cargo-target/target cargo test --workspace --no-fail-fast -j 1 > /tmp/test.log 2>&1; echo EXIT=$? +``` +Expected: 둘 다 `EXIT=0`. 실패 시 로그 확인 후 수정. + +- [ ] **Step 2: README Configuration § 갱신** + +`README.md` Configuration 절(약 90~127행)에 `kebab config migrate` 한 줄 추가(불릿 목록): +```markdown +- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 + 설명 주석과 함께 채워 넣는다(사용자가 손본 값·주석·순서는 보존, 멱등, 자동 `.bak` 백업). + `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내. +``` +config 예시 블록은 `annotated_default_document` 산출과 큰 괴리가 없으면 유지(섹션 주석이 추가됐다는 점만 위 불릿이 설명). + +- [ ] **Step 3: docs/SMOKE.md 에 migrate 단계** + +config 예시 블록 뒤에 `config migrate --dry-run` smoke 한 단계 추가(기존 SMOKE 흐름 패턴 따라). + +- [ ] **Step 4: docs/DOGFOOD.md 시나리오 추가** + +config 관련 section 에 "옛 config(섹션 누락) → `config migrate` → 섹션 가시성 + 멱등 확인" 시나리오 추가. + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/SMOKE.md docs/DOGFOOD.md +git commit -m "docs: config migrate surface 동기화(README/SMOKE/DOGFOOD)" +``` + +--- + +## Task 13: 도그푸딩 + HOTFIXES/HANDOFF (머지 직전/직후) + +**Files:** +- Modify: `tasks/HOTFIXES.md`, `HANDOFF.md` + +- [ ] **Step 1: 실제 도그푸딩** + +릴리스 binary 로 실제 옛 config(예: v0.20 시절 `.bak` 또는 수동 축약본)에 대해 migrate 실행: +```bash +CARGO_TARGET_DIR=/build/out/cargo-target/target cargo build --release > /tmp/rel.log 2>&1; echo EXIT=$? +BIN=/build/out/cargo-target/target/release/kebab +# /build/dogfood/ 에 옛 config 준비 후 migrate dry-run → 적용 → 멱등 + doctor 확인. +``` +추가된 섹션 수, 제거된 deprecated, 멱등(2회차 "이미 최신") evidence 수집. + +- [ ] **Step 2: HOTFIXES dated entry** + +`tasks/HOTFIXES.md` 상단에 `## 2026-05-31 — config 마이그레이션` 추가: trigger, 메커니즘(reconciliation+step), 도그푸딩 evidence(추가 섹션 N개, workspace.include 제거, 멱등), known limitation(append 순서, doctor ok=false 의미). + +- [ ] **Step 3: HANDOFF 한 줄** + +`HANDOFF.md` "머지 후 발견된 버그 / 결정 (요약)" 에 config 마이그레이션 한 줄. + +- [ ] **Step 4: Commit** + +```bash +git add tasks/HOTFIXES.md HANDOFF.md +git commit -m "docs: config 마이그레이션 도그푸딩 evidence + HANDOFF" +``` + +- [ ] **Step 5: PR (gitea REST)** + +gitea-ops skill 로 `feat/config-migration` → `main` PR 생성. 리뷰 루프(round1 opus, closure verify sonnet) → 머지. + +--- + +## 마이그레이션 노트 (실행자용) + +- **버전 bump**: schema_version 은 additive(데이터 무효화 아님) → 읽기 호환 유지. workspace `Cargo.toml` binary version bump 는 surface 누적 기준 사용자 판단(CLAUDE.md §Versioning). 본 plan 은 binary version 을 건드리지 않음. +- **facade rule**: kebab-cli 는 kebab-app facade(`config_migrate_with_config_path`)만 호출. 순수 변환은 kebab-config. 위반 금지. +- **toml_edit 0.22 decor API**: `leaf_decor_mut`/`decor_mut`/`key_mut` 시그니처가 안 맞으면 `cargo doc -p toml_edit --open` 으로 확인 후 동등 API 로 조정. TDD 테스트가 회귀 가드. +- **빌드**: 항상 `CARGO_TARGET_DIR=/build/out/cargo-target/target ... > /tmp/x.log 2>&1; echo EXIT=$?`. `cargo | grep` 금지. +- **무관 변경**: `fixtures/markdown/long-section.chunks.snapshot.json` 의 기존 WIP 변경은 이 작업과 무관 — 스테이징하지 말 것. -- 2.49.1 From 4dcb4a45d6034395e159f68466a60604f00ef423 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:41:32 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat(config):=20migrate=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=8A=A4=EC=BA=90=ED=8F=B4=EB=94=A9=20+=20toml=5Fe?= =?UTF-8?q?dit=20=EC=9D=98=EC=A1=B4=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/kebab-config/Cargo.toml | 1 + crates/kebab-config/src/lib.rs | 1 + crates/kebab-config/src/migrate.rs | 47 ++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 crates/kebab-config/src/migrate.rs diff --git a/Cargo.lock b/Cargo.lock index a50a47e..52a0e3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4371,6 +4371,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit 0.22.27", "tracing", ] diff --git a/crates/kebab-config/Cargo.toml b/crates/kebab-config/Cargo.toml index 914f576..b92b1f6 100644 --- a/crates/kebab-config/Cargo.toml +++ b/crates/kebab-config/Cargo.toml @@ -15,6 +15,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } toml = "0.8" +toml_edit = "0.22" dirs = "5" # p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd) # during workspace.root resolution. diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index 568ff45..aa69ef2 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; mod paths; +pub mod migrate; pub use paths::{expand_path, expand_path_with_base}; /// Signal: `Config::from_file` / `Config::load` failed due to missing path, diff --git a/crates/kebab-config/src/migrate.rs b/crates/kebab-config/src/migrate.rs new file mode 100644 index 0000000..8985f06 --- /dev/null +++ b/crates/kebab-config/src/migrate.rs @@ -0,0 +1,47 @@ +//! config.toml 마이그레이션 엔진 (순수 변환, I/O 없음). +//! +//! 두 메커니즘: (1) reconciliation — default 구조에 있고 사용자 파일에 +//! 없는 섹션/키를 주석과 함께 추가. (2) step 체인 — schema_version 기반 +//! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec +//! `docs/superpowers/specs/2026-05-31-config-migration-design.md`. + +use serde::Serialize; + +/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시 +/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다. +pub const CURRENT_SCHEMA_VERSION: u32 = 2; + +/// 한 번의 마이그레이션에서 발생한 개별 변경. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MigrationChange { + pub kind: ChangeKind, + /// dotted path, 예: `ingest.expansion`, `workspace.include`. + pub path: String, + /// 사람·wire 용 한 줄 설명. + pub detail: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeKind { + AddedSection, + AddedKey, + RemovedDeprecated, +} + +/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path +/// 등을 채워 wire 로 내보낸다. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct MigrationOutcome { + pub from_schema_version: u32, + pub to_schema_version: u32, + pub changes: Vec, + /// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일). + pub new_text: String, +} + +impl MigrationOutcome { + pub fn changed(&self) -> bool { + !self.changes.is_empty() + } +} -- 2.49.1 From bd7c4fd7ef3d6a6f4592452c5fd72eb98ab99af6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:44:39 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat(config):=20config=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=97=94=EC=A7=84=20(re?= =?UTF-8?q?concile=20+=20step=20=EC=B2=B4=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toml_edit 0.22 의존성 추가 - migrate.rs: CURRENT_SCHEMA_VERSION=2, annotated_default_document(주석 카탈로그 공유 원천), reconcile(빠진 섹션/키 주석과 함께 추가, 값 불가침), step_1_to_2(workspace.include 제거), migrate_document(step+reconcile+stamp) - schema_version default 1 → 2 - 56 tests green, clippy -D warnings clean Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-config/src/lib.rs | 2 +- crates/kebab-config/src/migrate.rs | 355 ++++++++++++++++++++++++++++- 2 files changed, 352 insertions(+), 5 deletions(-) diff --git a/crates/kebab-config/src/lib.rs b/crates/kebab-config/src/lib.rs index aa69ef2..8e66bca 100644 --- a/crates/kebab-config/src/lib.rs +++ b/crates/kebab-config/src/lib.rs @@ -670,7 +670,7 @@ impl Config { /// Defaults per design §6.4. pub fn defaults() -> Self { Self { - schema_version: 1, + schema_version: crate::migrate::CURRENT_SCHEMA_VERSION, workspace: WorkspaceCfg { root: "~/KnowledgeBase".to_string(), exclude: vec![ diff --git a/crates/kebab-config/src/migrate.rs b/crates/kebab-config/src/migrate.rs index 8985f06..2ab5697 100644 --- a/crates/kebab-config/src/migrate.rs +++ b/crates/kebab-config/src/migrate.rs @@ -5,14 +5,14 @@ //! non-additive 변환(deprecated 제거 등). 자세한 계약은 spec //! `docs/superpowers/specs/2026-05-31-config-migration-design.md`. -use serde::Serialize; +use toml_edit::DocumentMut; /// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시 /// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다. pub const CURRENT_SCHEMA_VERSION: u32 = 2; /// 한 번의 마이그레이션에서 발생한 개별 변경. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MigrationChange { pub kind: ChangeKind, /// dotted path, 예: `ingest.expansion`, `workspace.include`. @@ -21,7 +21,7 @@ pub struct MigrationChange { pub detail: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] pub enum ChangeKind { AddedSection, @@ -31,7 +31,7 @@ pub enum ChangeKind { /// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path /// 등을 채워 wire 로 내보낸다. -#[derive(Clone, Debug, PartialEq, Serialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct MigrationOutcome { pub from_schema_version: u32, pub to_schema_version: u32, @@ -45,3 +45,350 @@ impl MigrationOutcome { !self.changes.is_empty() } } + +/// 문서 최상단 헤더(경로 정책 등). 기존 init 헤더를 이전. +const HEADER: &str = "\ +# kebab config — `~/.config/kebab/config.toml`. +# +# `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로. +# 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리. +# 처리 형식(extractor 자동 결정): Markdown(.md) / 이미지(.png .jpg) / PDF(.pdf). +# 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest). +# +# 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다 +# (빠진 섹션 추가 + 손본 값·주석 보존). +"; + +/// 테이블 헤더(`[section]`) 위에 붙일 주석. dotted path → 한 줄(들). +fn section_comment(path: &str) -> Option<&'static str> { + Some(match path { + "workspace" => "# 색인 대상 워크스페이스.", + "storage" => "# XDG 저장 경로(데이터/sqlite/벡터/에셋/모델).", + "indexing" => "# 병렬도 + 파일시스템 watch.", + "chunking" => "# 청크 크기·오버랩·heading 존중.", + "models" => "# embedding / llm / nli 모델.", + "models.embedding" => "# 다국어 sentence embedding. dim 불일치 시 검색 0건.", + "models.llm" => "# Ollama host:port + 모델.", + "models.nli" => "# NLI(groundedness) 모델.", + "search" => "# 검색 기본 k·stale 기준·fusion.", + "rag" => "# 답변 생성: prompt 템플릿·score gate·NLI.", + "image" => "# 이미지 OCR + 캡션(기본 off, asset 당 모델 호출 비용).", + "image.ocr" => "# 이미지 OCR(기본 off).", + "image.caption" => "# 이미지 캡션(기본 off).", + "ui" => "# TUI 팔레트·role 스타일.", + "ingest" => "# ingest 정책(code skip 등).", + "ingest.code" => "# code ingest skip 정책(.gitignore 자동 honor).", + "ingest.expansion" => "# doc-side 별칭 확장(기본 off). 패러프레이즈 강건성↑, LLM 비용 큼.", + "pdf" => "# PDF ingest. scanned PDF OCR 은 기본 off(page 당 cost).", + "pdf.ocr" => "# scanned PDF page-단위 OCR(기본 off).", + "logging" => "# ingest 로그(기본 on, ~/.local/state/kebab/logs).", + _ => return None, + }) +} + +/// Config::defaults() 를 직렬화 + 주석 부착한 "완전체" 문서. +/// init 과 migrate reconciliation 의 단일 참조 원천. +pub fn annotated_default_document() -> DocumentMut { + let defaults = crate::Config::defaults(); + let pretty = toml::to_string_pretty(&defaults).expect("defaults serialize"); + let mut doc: DocumentMut = pretty.parse().expect("defaults parse as toml_edit"); + + // 헤더: 첫 최상위 항목의 prefix 로. + if let Some((mut first_key, _)) = doc.as_table_mut().iter_mut().next() { + first_key.leaf_decor_mut().set_prefix(format!("{HEADER}\n")); + } + + annotate_table(doc.as_table_mut(), ""); + doc +} + +/// 재귀적으로 하위 테이블에 헤더 주석 부착. `prefix_path` 는 dotted 누적 경로. +/// annotated_default_document 는 항상 주석 없는 defaults 에서 새로 만들므로 +/// 무조건 부착해도 중복되지 않는다. +fn annotate_table(table: &mut toml_edit::Table, prefix_path: &str) { + let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); + for key in keys { + let path = if prefix_path.is_empty() { + key.clone() + } else { + format!("{prefix_path}.{key}") + }; + if let Some(item) = table.get_mut(&key) { + if let Some(sub) = item.as_table_mut() { + if let Some(c) = section_comment(&path) { + sub.decor_mut().set_prefix(format!("\n{c}\n")); + } + annotate_table(sub, &path); + } + } + } +} + +/// 참조(주석 달린 default) 테이블 `reference` 를 기준으로, 사용자 테이블 +/// `user` 에 없는 항목을 decor(주석) 포함 통째 복사한다. 이미 있는 키는 +/// 건드리지 않는다(값 불가침). 양쪽이 테이블이면 하위로 재귀. +pub fn reconcile( + reference: &toml_edit::Table, + user: &mut toml_edit::Table, + prefix_path: &str, + changes: &mut Vec, +) { + for (key, ref_item) in reference.iter() { + let path = if prefix_path.is_empty() { + key.to_string() + } else { + format!("{prefix_path}.{key}") + }; + match user.get_mut(key) { + None => { + // schema_version 키는 stamp 단계가 다룬다(change 기록 X). + if path == "schema_version" { + user.insert(key, ref_item.clone()); + continue; + } + let kind = if ref_item.is_table() { + ChangeKind::AddedSection + } else { + ChangeKind::AddedKey + }; + user.insert(key, ref_item.clone()); + changes.push(MigrationChange { + kind, + path: path.clone(), + detail: section_comment(&path).map_or_else( + || format!("{key} 추가"), + |c| c.trim_start_matches("# ").to_string(), + ), + }); + } + Some(existing) => { + if let (Some(ref_tbl), Some(user_tbl)) = + (ref_item.as_table(), existing.as_table_mut()) + { + reconcile(ref_tbl, user_tbl, &path, changes); + } + // 둘 다 테이블이 아니면(스칼라 등) 값 불가침 → 무시. + } + } + } +} + +/// v1 → v2: deprecated `workspace.include` 제거(p9-fb-25). 멱등. +pub fn step_1_to_2(doc: &mut DocumentMut, changes: &mut Vec) { + if let Some(ws) = doc.get_mut("workspace").and_then(|i| i.as_table_mut()) { + if ws.remove("include").is_some() { + changes.push(MigrationChange { + kind: ChangeKind::RemovedDeprecated, + path: "workspace.include".to_string(), + detail: "p9-fb-25: 처리 형식은 extractor 가 자동 결정 — 더 이상 사용 안 함." + .to_string(), + }); + } + } +} + +/// 파일의 schema_version(없으면 1) 부터 CURRENT 까지 step 적용. +fn run_steps(doc: &mut DocumentMut, from: u32, changes: &mut Vec) { + if from < 2 { + step_1_to_2(doc, changes); + } + // 미래 step: if from < 3 { step_2_to_3(...) } ... +} + +/// 사용자 config.toml 텍스트를 받아 step 체인 + reconciliation + version +/// stamp 를 적용하고 결과를 반환한다. 순수 함수(I/O 없음). 파싱 실패 시 +/// from=1, 변경 없음, new_text=입력 그대로(상위에서 파싱 에러를 따로 처리). +pub fn migrate_document(text: &str) -> MigrationOutcome { + let mut doc: DocumentMut = match text.parse() { + Ok(d) => d, + Err(_) => { + return MigrationOutcome { + from_schema_version: 1, + to_schema_version: CURRENT_SCHEMA_VERSION, + changes: Vec::new(), + new_text: text.to_string(), + }; + } + }; + let from = doc + .get("schema_version") + .and_then(toml_edit::Item::as_integer) + .unwrap_or(1) as u32; + + let mut changes = Vec::new(); + + // 1. non-additive step 체인. + run_steps(&mut doc, from, &mut changes); + + // 2. additive reconciliation(버전 무관). + let reference = annotated_default_document(); + let ref_table = reference.as_table().clone(); + reconcile(&ref_table, doc.as_table_mut(), "", &mut changes); + + // 3. schema_version stamp. + let current_in_file = doc + .get("schema_version") + .and_then(toml_edit::Item::as_integer) + .unwrap_or(0) as u32; + if current_in_file != CURRENT_SCHEMA_VERSION { + doc["schema_version"] = toml_edit::value(i64::from(CURRENT_SCHEMA_VERSION)); + } + + MigrationOutcome { + from_schema_version: from, + to_schema_version: CURRENT_SCHEMA_VERSION, + changes, + new_text: doc.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn annotated_default_has_all_sections_and_parses_back_to_defaults() { + let doc = annotated_default_document(); + let text = doc.to_string(); + // PdfCfg/ImageCfg/ModelsCfg/IngestCfg 는 스칼라 필드가 없어 bare + // `[pdf]` 등은 안 나오고 `[pdf.ocr]` 같은 하위 테이블만 직렬화된다. + for section in [ + "[workspace]", + "[ingest.expansion]", + "[pdf.ocr]", + "[logging]", + "[ui]", + ] { + assert!(text.contains(section), "missing {section}:\n{text}"); + } + assert!(text.contains("# "), "no comments attached"); + let back: crate::Config = toml::from_str(&text).expect("parse annotated default"); + assert_eq!(back, crate::Config::defaults()); + } + + #[test] + fn reconcile_adds_missing_section_preserving_user_values_and_comments() { + // ingest 는 code 만 있고 expansion 누락(v0.21.0 동기 시나리오), + // logging 통째 누락, score 는 사용자가 바꿈, 주석 보유. + let user_text = "\ +schema_version = 1 + +[workspace] +root = \"/my/notes\" # 내 워크스페이스 + +[search] +default_k = 25 + +[ingest.code] +skip_generated_header = true +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let reference = annotated_default_document(); + let ref_tbl = reference.as_table().clone(); + let mut changes = Vec::new(); + reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes); + let out = user.to_string(); + + // 부분 존재하는 [ingest] 에 expansion 만 주석과 함께 추가. + assert!(out.contains("[ingest.expansion]"), "expansion not added:\n{out}"); + // 통째 누락된 logging 추가. + assert!(out.contains("[logging]"), "logging not added"); + // 사용자 값/주석/기존 섹션 보존. + assert!(out.contains("root = \"/my/notes\"")); + assert!(out.contains("# 내 워크스페이스")); + assert!(out.contains("default_k = 25")); + assert!(out.contains("skip_generated_header = true")); + // 새 섹션 주석 부착. + assert!(out.contains("doc-side 별칭")); + // 부분 존재 부모로 재귀해 leaf 경로를 기록. + assert!( + changes + .iter() + .any(|c| c.kind == ChangeKind::AddedSection && c.path == "ingest.expansion"), + "changes: {changes:?}" + ); + // 통째 누락 부모는 부모 경로로 한 번 기록. + assert!( + changes + .iter() + .any(|c| c.kind == ChangeKind::AddedSection && c.path == "logging") + ); + } + + #[test] + fn reconcile_does_not_overwrite_user_value_differing_from_default() { + let user_text = "\ +schema_version = 2 + +[rag] +score_gate = 0.8 +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let reference = annotated_default_document(); + let ref_tbl = reference.as_table().clone(); + let mut changes = Vec::new(); + reconcile(&ref_tbl, user.as_table_mut(), "", &mut changes); + let out = user.to_string(); + assert!(out.contains("score_gate = 0.8"), "user value clobbered:\n{out}"); + assert!(!changes.iter().any(|c| c.path == "rag.score_gate")); + } + + #[test] + fn step_1_to_2_removes_deprecated_workspace_include() { + let user_text = "\ +[workspace] +root = \"/n\" +include = [\"*.md\"] +"; + let mut user: DocumentMut = user_text.parse().unwrap(); + let mut changes = Vec::new(); + step_1_to_2(&mut user, &mut changes); + let out = user.to_string(); + assert!(!out.contains("include"), "include not removed:\n{out}"); + assert!( + changes + .iter() + .any(|c| c.kind == ChangeKind::RemovedDeprecated && c.path == "workspace.include") + ); + let mut changes2 = Vec::new(); + step_1_to_2(&mut user, &mut changes2); + assert!(changes2.is_empty()); + } + + fn read_schema_version(text: &str) -> u32 { + let doc: DocumentMut = text.parse().unwrap(); + doc.get("schema_version") + .and_then(toml_edit::Item::as_integer) + .unwrap_or(1) as u32 + } + + #[test] + fn migrate_document_stamps_version_and_is_idempotent() { + let old = "\ +schema_version = 1 + +[workspace] +root = \"/n\" +include = [\"*.md\"] +"; + let outcome = migrate_document(old); + assert_eq!(outcome.from_schema_version, 1); + assert_eq!(outcome.to_schema_version, CURRENT_SCHEMA_VERSION); + assert!(outcome.changed()); + assert!(!outcome.new_text.contains("include")); + assert!(outcome.new_text.contains("[ingest.expansion]")); + assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION); + + let again = migrate_document(&outcome.new_text); + assert!(!again.changed(), "not idempotent: {:?}", again.changes); + assert_eq!(again.new_text, outcome.new_text); + } + + #[test] + fn migrate_document_missing_schema_version_treated_as_v1() { + let old = "[workspace]\nroot = \"/n\"\n"; + let outcome = migrate_document(old); + assert_eq!(outcome.from_schema_version, 1); + assert_eq!(read_schema_version(&outcome.new_text), CURRENT_SCHEMA_VERSION); + } +} -- 2.49.1 From b7e022a5e32cb3782746756780aa17262e59b9f0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 12:09:31 +0000 Subject: [PATCH 5/8] =?UTF-8?q?feat(app):=20config=20migrate=20facade=20+?= =?UTF-8?q?=20init=20=EC=A3=BC=EC=84=9D=20=EA=B3=B5=EC=9C=A0=20+=20doctor?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_migrate_with_config_path: 백업(.bak)+atomic write(tmp→rename)+dry-run, round-trip 검증으로 실패 시 원본 보존. ConfigMigrationReport 반환. - init_workspace 가 annotated_default_document() 사용(섹션 주석 포함). - doctor 에 config_migration 체크 추가(미동기 시 ok=false + hint). - tests/config_migrate.rs 4개(백업/atomic/dry-run/멱등/doctor) 통과. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/Cargo.toml | 1 + crates/kebab-app/src/lib.rs | 140 +++++++++++++++++------ crates/kebab-app/tests/config_migrate.rs | 82 +++++++++++++ 3 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 crates/kebab-app/tests/config_migrate.rs diff --git a/crates/kebab-app/Cargo.toml b/crates/kebab-app/Cargo.toml index 526e7b8..6c2d637 100644 --- a/crates/kebab-app/Cargo.toml +++ b/crates/kebab-app/Cargo.toml @@ -71,6 +71,7 @@ base64 = { workspace = true } rusqlite = { workspace = true } [dev-dependencies] +kebab-config = { path = "../kebab-config" } # doc-side expansion (Phase 2) Task 4: ExpansionGenerator unit tests build # MockLanguageModel (gated behind kebab-llm's `mock` feature, default OFF in # [dependencies]). Enabling it here turns it on for the test build only. diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index c1143d5..018ddb9 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -143,40 +143,10 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> { std::fs::create_dir_all(&workspace_root)?; if !cfg_path.exists() || force { - let cfg = kebab_config::Config::defaults(); - let toml_text = toml::to_string_pretty(&cfg)?; - // p9-fb-05: prepend a header comment documenting the path - // policy so a user editing this file knows what's allowed - // for `workspace.root` (and how relative paths resolve). - // The actual key lives inside `[workspace]` further down; - // we keep the explanation up top because users skim header - // comments first. - let header = "\ -# kebab config — `~/.config/kebab/config.toml`. -# -# `workspace.root` accepts: -# • absolute paths (`/home/me/KnowledgeBase`) -# • tilde (`~/KnowledgeBase`) ← default -# • env vars (`${XDG_DATA_HOME}/kebab`) -# • relative paths (`./notes`, `notes`, `../shared/x`) -# — relative paths resolve against the directory of THIS -# config file, NOT the user's `cwd` at invocation time. -# -# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음): -# • Markdown: .md -# • 이미지: .png .jpg .jpeg (OCR + caption) -# • PDF: .pdf -# 다른 확장자는 ingest 시 자동 skip + warning. 처리 대상 폴더의 -# 일부만 ingest 하고 싶으면 `kebab ingest ` 로 root 명시 -# 또는 `.kebabignore` 파일 / 본 `workspace.exclude` 로 denylist. -# -# Override individual keys at runtime with `KEBAB_*` env vars -# (e.g. `KEBAB_WORKSPACE_ROOT=/tmp/test kebab ingest`). -\n"; - let mut combined = String::with_capacity(header.len() + toml_text.len()); - combined.push_str(header); - combined.push_str(&toml_text); - std::fs::write(&cfg_path, combined)?; + // init 과 migrate 가 동일한 "주석 달린 default" 문서를 공유한다 + // (주석 카탈로그·헤더의 단일 원천 = kebab_config::migrate). + let doc = kebab_config::migrate::annotated_default_document(); + std::fs::write(&cfg_path, doc.to_string())?; } Ok(()) @@ -3211,6 +3181,48 @@ pub fn doctor_with_config_path( hint: data_hint, }); + // config_migration — 사용자 파일이 새 스키마와 동기인지(dry-run 마이그레이션). + // 파일이 존재할 때만 점검(없으면 defaults 사용 중이라 마이그레이션 무의미). + if cfg_path.exists() { + if let Ok(text) = std::fs::read_to_string(&cfg_path) { + let outcome = kebab_config::migrate::migrate_document(&text); + let (mok, detail, hint) = if outcome.changed() { + let added = outcome + .changes + .iter() + .filter(|c| { + matches!( + c.kind, + kebab_config::migrate::ChangeKind::AddedSection + | kebab_config::migrate::ChangeKind::AddedKey + ) + }) + .count(); + let removed = outcome.changes.len() - added; + ( + false, + format!( + "{} pending changes (added {added}, removed {removed} deprecated)", + outcome.changes.len() + ), + Some("run `kebab config migrate` to update your config.toml".to_string()), + ) + } else { + ( + true, + format!("config up to date (schema v{})", outcome.to_schema_version), + None, + ) + }; + checks.push(DoctorCheck { + name: "config_migration".to_string(), + ok: mok, + detail, + hint, + }); + } + } + let ok = checks.iter().all(|c| c.ok); Ok(DoctorReport { schema_version: "doctor.v1".to_string(), @@ -3227,6 +3239,66 @@ pub fn doctor() -> anyhow::Result { doctor_with_config_path(None) } +/// `kebab config migrate` 의 결과(wire `config_migration.v1` 소스). +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct ConfigMigrationReport { + /// 항상 `"config_migration.v1"`. + pub schema_version: String, + pub config_path: String, + pub dry_run: bool, + pub from_schema_version: u32, + pub to_schema_version: u32, + pub changed: bool, + pub backup_path: Option, + pub changes: Vec, +} + +/// 사용자 config.toml 을 새 스키마로 마이그레이션한다(facade). +/// `config_path` 미지정 시 XDG 기본. `dry_run=true` 면 파일·백업 미변경. +/// 안전: 변경 시 `.bak` 백업 후 tmp 에 쓰고 round-trip 검증 → atomic rename. +pub fn config_migrate_with_config_path( + config_path: Option<&std::path::Path>, + dry_run: bool, +) -> anyhow::Result { + let path: PathBuf = match config_path { + Some(p) => p.to_path_buf(), + None => kebab_config::Config::xdg_config_path(), + }; + if !path.exists() { + anyhow::bail!( + "config 파일이 없습니다: {} — 먼저 `kebab init` 을 실행하세요.", + path.display() + ); + } + let text = std::fs::read_to_string(&path)?; + let outcome = kebab_config::migrate::migrate_document(&text); + + let mut backup_path = None; + if !dry_run && outcome.changed() { + let bak = path.with_extension("toml.bak"); + std::fs::copy(&path, &bak)?; + backup_path = Some(bak.display().to_string()); + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, &outcome.new_text)?; + if kebab_config::Config::from_file(&tmp).is_err() { + std::fs::remove_file(&tmp).ok(); + anyhow::bail!("마이그레이션 결과가 유효하지 않아 원본을 보존합니다."); + } + std::fs::rename(&tmp, &path)?; + } + + Ok(ConfigMigrationReport { + schema_version: "config_migration.v1".to_string(), + config_path: path.display().to_string(), + dry_run, + from_schema_version: outcome.from_schema_version, + to_schema_version: outcome.to_schema_version, + changed: outcome.changed(), + backup_path, + changes: outcome.changes, + }) +} + /// Single-file ingest (p9-fb-31). Copies the file to /// `/_external/.` and runs the /// per-medium ingest pipeline on that single asset. Returns an diff --git a/crates/kebab-app/tests/config_migrate.rs b/crates/kebab-app/tests/config_migrate.rs new file mode 100644 index 0000000..9c6fb4e --- /dev/null +++ b/crates/kebab-app/tests/config_migrate.rs @@ -0,0 +1,82 @@ +use std::fs; + +#[test] +fn migrate_writes_backup_and_atomic_with_dry_run_noop() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + fs::write( + &cfg, + "schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude = [\"*.md\"]\n", + ) + .unwrap(); + + // dry-run: 파일·백업 미변경. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), true).unwrap(); + assert!(report.changed); + assert!(report.dry_run); + assert!(report.backup_path.is_none()); + assert!(!dir.path().join("config.toml.bak").exists()); + assert!( + fs::read_to_string(&cfg).unwrap().contains("include"), + "dry-run modified file" + ); + + // 실제 적용: 백업 생성 + 파일 갱신. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + assert!(report.changed); + assert!(!report.dry_run); + assert!(report.backup_path.is_some()); + assert!(dir.path().join("config.toml.bak").exists()); + let new = fs::read_to_string(&cfg).unwrap(); + assert!(!new.contains("include")); + assert!(new.contains("[ingest.expansion]")); + + // 멱등: 재실행 changed=false. + let report = kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + assert!(!report.changed); +} + +#[test] +fn migrate_missing_file_errors() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("nope.toml"); + assert!(kebab_app::config_migrate_with_config_path(Some(&cfg), false).is_err()); +} + +#[test] +fn annotated_default_serialization_contains_section_comments() { + let doc = kebab_config::migrate::annotated_default_document(); + let text = doc.to_string(); + assert!(text.contains("doc-side 별칭"), "section comment missing:\n{text}"); + assert!(text.contains("[ingest.expansion]")); +} + +#[test] +fn doctor_flags_outdated_config() { + let dir = tempfile::tempdir().unwrap(); + let cfg = dir.path().join("config.toml"); + fs::write( + &cfg, + "schema_version = 1\n\n[workspace]\nroot = \"/n\"\ninclude=[\"*.md\"]\n", + ) + .unwrap(); + let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap(); + let check = report + .checks + .iter() + .find(|c| c.name == "config_migration") + .unwrap(); + assert!(!check.ok, "outdated config should fail check"); + assert!(check.hint.as_deref().unwrap().contains("config migrate")); + assert!(!report.ok, "overall doctor should be false"); + + // migrate 후엔 통과. + kebab_app::config_migrate_with_config_path(Some(&cfg), false).unwrap(); + let report = kebab_app::doctor_with_config_path(Some(&cfg)).unwrap(); + let check = report + .checks + .iter() + .find(|c| c.name == "config_migration") + .unwrap(); + assert!(check.ok, "after migrate should pass"); +} -- 2.49.1 From f2cc325cf38ae67a7e04478a60f1a2907ae2e353 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 12:09:31 +0000 Subject: [PATCH 6/8] =?UTF-8?q?feat(cli):=20kebab=20config=20migrate=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=EC=BB=A4=EB=A7=A8=EB=93=9C=20+=20wire=20conf?= =?UTF-8?q?ig=5Fmigration.v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cmd::Config { Migrate { --dry-run } }, --json 시 config_migration.v1. - wire_config_migration (ConfigMigrationReport 가 schema_version 자체 보유). - schema.rs WIRE_SCHEMAS 에 config_migration.v1 등록 + JSON schema 파일. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/src/schema.rs | 1 + crates/kebab-cli/src/main.rs | 52 +++++++++++++++++++ crates/kebab-cli/src/wire.rs | 6 +++ .../v1/config_migration.v1.schema.json | 38 ++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 docs/wire-schema/v1/config_migration.v1.schema.json diff --git a/crates/kebab-app/src/schema.rs b/crates/kebab-app/src/schema.rs index d2bc396..1712cb3 100644 --- a/crates/kebab-app/src/schema.rs +++ b/crates/kebab-app/src/schema.rs @@ -108,6 +108,7 @@ const WIRE_SCHEMAS: &[&str] = &[ "doc_summary.v1", "chunk_inspection.v1", "doctor.v1", + "config_migration.v1", "ingest_report.v1", "ingest_progress.v1", "reset_report.v1", diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index 3568c32..e1c1960 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -60,6 +60,12 @@ enum Cmd { force: bool, }, + /// config.toml 관리 (스키마 마이그레이션 등). + Config { + #[command(subcommand)] + what: ConfigWhat, + }, + /// Scan the workspace and ingest new/updated documents. Ingest { /// Workspace root override. @@ -346,6 +352,16 @@ enum Cmd { }, } +#[derive(Subcommand, Debug)] +enum ConfigWhat { + /// 기존 config.toml 을 새 스키마로 마이그레이션(빠진 섹션 추가 + 멱등 + .bak 백업). + Migrate { + /// 변경만 출력하고 파일은 수정하지 않는다. + #[arg(long)] + dry_run: bool, + }, +} + #[derive(Subcommand, Debug)] enum ListWhat { /// List documents currently indexed. @@ -1310,6 +1326,42 @@ fn run(cli: &Cli) -> anyhow::Result<()> { Ok(()) } + Cmd::Config { what } => match what { + ConfigWhat::Migrate { dry_run } => { + let report = + kebab_app::config_migrate_with_config_path(cli.config.as_deref(), *dry_run)?; + if cli.json { + println!( + "{}", + serde_json::to_string(&wire::wire_config_migration(&report))? + ); + } else if !report.changed { + println!( + "config 이미 최신입니다 (schema v{}).", + report.to_schema_version + ); + } else { + let verb = if report.dry_run { "변경 예정" } else { "적용됨" }; + println!( + "config 마이그레이션 {verb}: v{} → v{} ({} changes)", + report.from_schema_version, + report.to_schema_version, + report.changes.len() + ); + for c in &report.changes { + println!(" - [{:?}] {} — {}", c.kind, c.path, c.detail); + } + if let Some(bak) = &report.backup_path { + println!("백업: {bak}"); + } + if report.dry_run { + println!("(--dry-run: 파일 미수정. 적용하려면 --dry-run 없이 재실행)"); + } + } + Ok(()) + } + }, + Cmd::Doctor => { let report = kebab_app::doctor_with_config_path(cli.config.as_deref())?; if cli.json { diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index d866a94..fc882f9 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -225,6 +225,12 @@ pub fn wire_bulk_search_item(item: &kebab_core::BulkSearchItem) -> Value { v } +/// `config_migration.v1` 직렬화. `ConfigMigrationReport` 가 `schema_version` +/// 필드를 자체 보유하므로(doctor 와 동일) 그대로 직렬화한다. +pub fn wire_config_migration(r: &kebab_app::ConfigMigrationReport) -> Value { + serde_json::to_value(r).expect("ConfigMigrationReport serializes") +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/wire-schema/v1/config_migration.v1.schema.json b/docs/wire-schema/v1/config_migration.v1.schema.json new file mode 100644 index 0000000..c5e6ea0 --- /dev/null +++ b/docs/wire-schema/v1/config_migration.v1.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "config_migration.v1", + "description": "Result of `kebab config migrate` — schema reconciliation of a user's config.toml.", + "type": "object", + "required": [ + "schema_version", + "config_path", + "dry_run", + "from_schema_version", + "to_schema_version", + "changed", + "changes" + ], + "properties": { + "schema_version": { "const": "config_migration.v1" }, + "config_path": { "type": "string" }, + "dry_run": { "type": "boolean" }, + "from_schema_version": { "type": "integer" }, + "to_schema_version": { "type": "integer" }, + "changed": { "type": "boolean" }, + "backup_path": { "type": ["string", "null"] }, + "changes": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "path", "detail"], + "properties": { + "kind": { + "enum": ["added_section", "added_key", "removed_deprecated"] + }, + "path": { "type": "string" }, + "detail": { "type": "string" } + } + } + } + } +} -- 2.49.1 From 4b4a4c0b325457987eb51cfb18dff0a3de91de09 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 12:46:45 +0000 Subject: [PATCH 7/8] =?UTF-8?q?fix(config):=20init=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20=EC=A7=80=EC=9B=90=20=ED=99=95=EC=9E=A5=EC=9E=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=AA=A9=EB=A1=9D=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit annotated_default_document 의 HEADER 가 기존 init 헤더의 '처리 가능한 형식' 상세 목록(.md / .png .jpg .jpeg / .pdf)을 보존하도록 복원. p9-fb-25 의 init_template 계약(지원 확장자 안내) 유지. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-config/src/migrate.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/kebab-config/src/migrate.rs b/crates/kebab-config/src/migrate.rs index 2ab5697..38fa892 100644 --- a/crates/kebab-config/src/migrate.rs +++ b/crates/kebab-config/src/migrate.rs @@ -52,7 +52,12 @@ const HEADER: &str = "\ # # `workspace.root` accepts: 절대 / tilde(~) / env(${VAR}) / 상대 경로. # 상대 경로의 base 는 cwd 가 아니라 THIS config 파일의 디렉토리. -# 처리 형식(extractor 자동 결정): Markdown(.md) / 이미지(.png .jpg) / PDF(.pdf). +# +# 처리 가능한 형식 (extractor 가 자동 결정 — config 에 명시할 수 없음): +# • Markdown: .md +# • 이미지: .png .jpg .jpeg (OCR + caption) +# • PDF: .pdf +# # 런타임 override: `KEBAB_*` env (예: KEBAB_WORKSPACE_ROOT=/tmp kebab ingest). # # 이 파일은 `kebab config migrate` 로 새 스키마에 맞춰 갱신할 수 있다 -- 2.49.1 From 9501edd82bc9f83a653ea47260be06aa207cc320 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 13:25:42 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs:=20config=20migrate=20surface=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20(README/HOTFIXES/HANDOFF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README Configuration 에 kebab config migrate 불릿, HOTFIXES 에 dated entry (메커니즘 + 도그푸딩 evidence 표 + 한계), HANDOFF 한 줄. lib.rs 백업 경로는 with_extension 유지(리뷰 nit: .toml config 엔 정상 동작, 회귀 위험 회피). Co-Authored-By: Claude Opus 4.8 (1M context) --- HANDOFF.md | 2 ++ README.md | 1 + tasks/HOTFIXES.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/HANDOFF.md b/HANDOFF.md index 109cdeb..a34853f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -30,6 +30,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. ## 머지 후 발견된 버그 / 결정 (요약) +- **config 마이그레이션** (2026-05-31, PR #198): `kebab config migrate` 추가 — 기존 config.toml 에 빠진 섹션을 주석과 함께 채우고 deprecated 정리(멱등·`.bak`·dry-run, 값/주석 보존). `schema_version` 1→2, `init` 도 섹션 주석 포함, doctor 에 `config_migration` 체크. 상세 HOTFIXES 동일 일자. + 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: - **2026-05-31 Phase 2 doc-side expansion 별칭(개별 dense 벡터) + 파생물 캐시(V012)** — v0.21.0 cut. 색인 시 LLM 이 청크별 별칭("같은 의미 다른 표현")을 생성, 줄별 **개별 dense 벡터**(sentinel `{chunk}#alias#N`)로 색인 (묶음 1벡터는 평균화 희석으로 회귀 → 폐기) + boilerplate 청크 skip. `[ingest.expansion]` default off. 측정(나무위키 ~1000 문서 CS corpus): 변형 일관성 14/18 → **16/18**, spread 0.222→0.111, 대조군 false-positive 별칭 무죄. 비용 병목(별칭 18문서 2.5h)은 **파생물 캐시(V012, 청크 내용 해시 키)**로 해소 — 정답 3개 cold 1879s → warm 13s **≈ 145배**, embedding+별칭 LLM 캐싱, version_key cascade 정합. search/ask 가 `kebab.sqlite`+`lancedb` 만으로 동작 → 외부 서버 색인 후 DB 만 복사하는 이식 워크플로 가능. **결정/known limitation**: grounded/refusal 판정이 부분 인용을 grounded 로 오분류(정직한 거부가 false-positive 로 집계) — 별도 개선 후보. stack·svm 설명형 2개 잔존. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-31), 측정: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`. diff --git a/README.md b/README.md index df78604..0f115cb 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn - **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer. - **`[pdf.ocr]`** — scanned PDF 의 page-단위 OCR (default off / opt-in, page 당 ~수십 초 cost). 활성화 후 v0.19 시절 색인분은 `kebab ingest --force-reingest` 로 재처리. - **`--config `** — 임시 워크스페이스 / 격리 테스트용 (CLI · TUI 모두 honor). +- **`kebab config migrate`** — 새 버전에서 추가된 config 섹션을 기존 `config.toml` 에 설명 주석과 함께 채워 넣는다 (사용자가 손본 값·주석·순서는 보존, 멱등, 변경 시 자동 `.bak` 백업). `--dry-run` 으로 변경 미리보기. `kebab doctor` 가 갱신 필요 시 안내한다. `kebab init` 으로 새로 생성되는 config.toml 도 섹션별 주석을 포함한다. - **`KEBAB_*` env** — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN` 등). - **XDG layout**: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`. diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index c219e13..57abd2b 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,36 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-31 — config 마이그레이션 (`kebab config migrate`) + +**Trigger**: config.toml 스키마가 진화해도(v0.21.0 의 `[ingest.expansion]` 등) 기존 사용자 파일은 serde default 로 *동작*만 호환될 뿐 새 섹션이 파일에 안 써져 사용자가 노브의 존재를 알 수 없었다. DB 의 V00X refinery 와 달리 config 엔 마이그레이션 메커니즘이 없어 추가. 설계 `docs/superpowers/specs/2026-05-31-config-migration-design.md`, 계획 `docs/superpowers/plans/2026-05-31-config-migration.md`, PR #198. + +### 메커니즘 + +`kebab config migrate` 가 (1) **reconciliation** — `Config::defaults()` 구조에 있고 사용자 파일에 없는 섹션/키를 주석과 함께 `toml_edit` 으로 추가(버전 무관·멱등) + (2) **step 체인** — `schema_version` 기반 non-additive 변환(첫 step v1→v2 = `workspace.include` 제거, p9-fb-25). `init` 과 migrate 가 `annotated_default_document()` 로 주석·헤더 단일 원천 공유 → init config 도 섹션 주석 보유. `schema_version` default 1→2(sync 마커+step 축). 안전 3축=멱등·백업(`.bak`, 원본 byte-identical)·dry-run + tmp atomic rename(round-trip 검증). 순수변환=`kebab-config/migrate.rs`, I/O facade=`kebab-app`. + +### 도그푸딩 evidence (v0.21.0 release 바이너리) + +옛 스키마 흉내(`schema_version=1`, `[workspace]`+`[search]`+`[rag]`, `workspace.include` 보유, 사용자가 `default_k=25`/`score_gate=0.8`+인라인 주석 손봄): + +| 시나리오 | 결과 | +|----------|------| +| `migrate --dry-run` | 22 changes 나열, **파일 미수정** | +| `migrate` | 적용 v1→v2, `.bak` **원본과 byte-identical**(diff 0) | +| 값·주석 보존 | `root="~/MyNotes" # 내가 직접 바꾼…`, `default_k=25`, `score_gate=0.8` 유지 | +| deprecated 정리 | `workspace.include` 제거(grep 0) | +| 가시화 | `[ingest.expansion]`·`[logging]`·`[pdf.ocr]` 등장 | +| 멱등 | 재실행 → `config 이미 최신입니다 (schema v2)` | +| doctor | `✓ config_migration config up to date (schema v2)` | +| `--json` | `config_migration.v1` (kind=added_section/removed_deprecated) | + +### 알려진 한계 / 결정 + +- 누락 섹션은 테이블 끝 append(순서 미보존, 값·주석·기존순서는 보존). +- 통째 누락 부모는 부모 경로 1건 기록, 부분 존재 부모는 leaf 경로 기록(재귀 깊이 차이). +- doctor 의 `config_migration` ok=false 가 전체 `DoctorReport.ok` 를 false 로 만듦(의도; hint 가 교정 명령 제시, warn 상태 미도입). +- `schema_version` bump(1→2)은 additive(데이터 무효화 아님, 읽기 호환 유지) → DB/wire breaking release 트리거 아님. 신규 CLI 서브커맨드+doctor 체크+init 출력 변경은 user-visible surface. + ## 2026-05-31 — doc-side expansion 별칭 개선 + 파생물 캐시(V012) **Trigger**: Phase 2 doc-side expansion(별칭) 효과를 실사용 규모(한국어 나무위키 ~1000 문서 CS corpus)로 검증하고, 그 과정에서 드러난 별칭 생성 비용을 "내용 해시 기반 파생물 캐시"로 해소. v0.21.0 cut. 측정 상세: `docs/superpowers/handoffs/2026-05-31-namu-wiki-alias-cache-study.md`, 설계: `docs/superpowers/specs/2026-05-31-derivation-cache-design.md`. -- 2.49.1