From 6bbb8f854b9f52b242df07b98a91152d8b79ae93 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 31 May 2026 11:34:19 +0000 Subject: [PATCH] =?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 전용으로 한정.