feat(config): config.toml 마이그레이션 (kebab config migrate) #198

Merged
altair823 merged 8 commits from feat/config-migration into main 2026-05-31 13:48:12 +00:00
16 changed files with 2178 additions and 35 deletions

1
Cargo.lock generated
View File

@@ -4371,6 +4371,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"toml",
"toml_edit 0.22.27",
"tracing",
]

View File

@@ -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`.

View File

@@ -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 <path>`** — 임시 워크스페이스 / 격리 테스트용 (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/`.

View File

@@ -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.

View File

@@ -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 <path>` 로 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<DoctorReport> {
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<String>,
pub changes: Vec<kebab_config::migrate::MigrationChange>,
}
/// 사용자 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<ConfigMigrationReport> {
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)?;

nit(가독성/견고성): path.with_extension("toml.bak") 는 마지막 확장자를 치환하는 방식이라 config.tomlconfig.toml.bak 로 의도대로 동작하지만, 비-.toml 경로(--config /tmp/x.conf)에선 .conf 가 사라져 x.toml.bak 이 된다. config 는 관례상 .toml 이라 실사용 결함은 없으나, 전체 파일명에 접미사를 붙이는 with_file_name(format!("{}.bak", file_name)) 방식이 의도가 더 명확하다. 같은 지적이 바로 아래 .tmp(L3290)에도 적용.

nit(가독성/견고성): `path.with_extension("toml.bak")` 는 마지막 확장자를 치환하는 방식이라 `config.toml` → `config.toml.bak` 로 의도대로 동작하지만, 비-`.toml` 경로(`--config /tmp/x.conf`)에선 `.conf` 가 사라져 `x.toml.bak` 이 된다. config 는 관례상 `.toml` 이라 실사용 결함은 없으나, 전체 파일명에 접미사를 붙이는 `with_file_name(format!("{}.bak", file_name))` 방식이 의도가 더 명확하다. 같은 지적이 바로 아래 `.tmp`(L3290)에도 적용.
}
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
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
/// per-medium ingest pipeline on that single asset. Returns an

View File

@@ -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",

View File

@@ -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");
}

View File

@@ -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 {

View File

@@ -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::*;

View File

@@ -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.

View File

@@ -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,
@@ -669,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![

View File

@@ -0,0 +1,399 @@
//! 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 toml_edit::DocumentMut;
/// 현재 바이너리가 이해하는 config 스키마 버전. 마이그레이션 완료 시
/// 사용자 파일의 `schema_version` 을 이 값으로 stamp 한다.
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
/// 한 번의 마이그레이션에서 발생한 개별 변경.
#[derive(Clone, Debug, PartialEq, serde::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, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
AddedSection,
AddedKey,
RemovedDeprecated,
}
/// 마이그레이션 결과 요약(순수 변환 단계 산출). I/O 계층이 backup_path
/// 등을 채워 wire 로 내보낸다.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct MigrationOutcome {
pub from_schema_version: u32,
pub to_schema_version: u32,
pub changes: Vec<MigrationChange>,
/// 변환 후 직렬화된 새 문서 텍스트(멱등 시 입력과 동일).
pub new_text: String,
}
impl MigrationOutcome {
pub fn changed(&self) -> bool {
!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 가 자동 결정 — config 에 명시할 수 없음):
# • Markdown: .md
# • 이미지: .png .jpg .jpeg (OCR + caption)
# • 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<String> = 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<MigrationChange>,
) {
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<MigrationChange>) {
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<MigrationChange>) {
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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<MigrationChange>)
// 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. **백업** — 파일 수정 직전 `<config>.bak` 생성(원본 복사). 기존 `.bak` 있으면 덮어씀
(단순화; 변경 내용은 dry-run 으로 사전 확인 가능). dry-run 시 백업도 안 만듦.
3. **dry-run**`--dry-run` 은 changes 만 계산·출력하고 **파일·백업 모두 미수정**.
**실패 시 원본 보존(atomic write)**: 편집 결과는 `<config>.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 <path>` 존중 (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<ConfigMigrationReport>` 를 호출(파일 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<MigrationChange>` | 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 전용으로 한정.

View File

@@ -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" }
}
}
}
}
}

View File

@@ -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`.