feat(kebab-config + kebab-app): p9-fb-05 workspace.root path policy #76
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3571,6 +3571,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -52,6 +52,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-14)** — TUI color theme module. `kebab-tui::theme::{Theme, Role, Palette}` 신규 — 16 개 Role (BorderActive/Title/Path/ModeLexical/ModeVector/ModeHybrid/Selected/Hint/Heading/Warning/Error/Success/CitationMarker/Bullet/Body/BorderInactive) 을 dark + light 두 팔레트가 exhaustive match 로 매핑. 모든 Pane (library/search/ask/inspect/run/error_popup) 의 inline `Style::default().fg(Color::*)` 호출이 `theme.style(Role::X)` 로 격리됨. `Config.ui.theme: String` (default `"dark"`) 신규. `App.theme: Theme` 가 `App::new` 에서 `Theme::from_name(&config.ui.theme)` 로 build — 알 수 없는 값은 dark fallback (config 가 typo 로 죽지 않음). `T` 키 runtime toggle 은 mode machine (p9-fb-12) 미진행이라 skip — config 만으로 결정. p9-fb-11 (ask markdown render) 의 Theme 의존성 unblock. spec: `tasks/p9/p9-fb-14-tui-color-theme.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-11)** — TUI Ask 답변 본문 markdown 렌더. `kebab-tui::markdown::render(text, &Theme) -> Vec<Line<'static>>` 신규 — `pulldown-cmark = "0.13"` 위에서 inline (bold/italic/strikethrough/inline code/link)·block (heading H1-H6, ordered/unordered list with nesting, fenced code block, table, blockquote `▎`, horizontal rule) 변환. heading H1/H2 = `Role::Heading`, H3+ = `Role::Title`, link = `Role::CitationMarker + UNDERLINE`, code = `Role::Hint`. ask `push_turn_lines` 가 grounded 답변에서만 markdown 렌더; refusal (`Role::Warning`) / streaming (`Role::Hint`) 은 raw 로 두어 role color 시그널 보존. CLI `kebab ask` 출력은 raw markdown 그대로 (terminal 호환성). 매 frame 재 parse — pulldown 토크나이저가 µs/KB 라 비용 무시. spec: `tasks/p9/p9-fb-11-ask-markdown-render.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-08)** — TUI search async worker + generation counter. 기존 200ms debounce 후 `kebab_app::search_with_config` 동기 호출이 vector/hybrid 모드 50-200ms 동안 UI freeze 시키던 문제 해소. `SearchState` 에 `generation: u64` + `worker_thread: Option<JoinHandle>` + `worker_rx: Option<Receiver<SearchWorkerMessage>>` 신규. `fire_search` 가 spawn 만 하고 즉시 return — worker 가 별 thread 에서 검색 후 `(generation, Result)` 를 channel 로 post. run loop 가 매 tick `poll_worker` 로 try_recv, generation 일치 시 hits 적용 / 불일치 시 silently 폐기 (사용자가 더 빠르게 타이핑하면 stale 결과 자동 drop). debounce_due 가 `searching && last_query == 현 input` 케이스 추가 skip — in-flight worker 의 결과 기다리는 동안 동일 query 재 spawn 안 함. spec: `tasks/p9/p9-fb-08-search-debounce.md`.
|
||||
- **2026-05-03 P9 도그푸딩 후속 (p9-fb-05)** — `workspace.root` path policy 명확화. `kebab_config::expand_path_with_base(raw, data_dir, base_dir) -> PathBuf` 신규 — 기존 `expand_path` (tilde + env 만) 위에 relative path resolution 추가, 절대/`~`/`${VAR}` 입력은 base_dir 무시. `Config.source_dir: Option<PathBuf>` 필드 (`#[serde(skip)]`) 신규 — `from_file` / `load` 가 `path.parent()` 로 stamp. `Config::resolve_workspace_root()` helper 가 `expand_path_with_base(&workspace.root, "", source_dir.unwrap_or(cwd))` 호출. kebab-app + kebab-source-fs 의 모든 `workspace.root` 사용 사이트가 `cfg.resolve_workspace_root()` 로 통일 — kebab-source-fs 의 fork 된 `expand_tilde` 헬퍼는 제거 (kebab-app 의 `storage.data_dir` 한 곳만 남음, P+ 통일 caveat). `kebab init` 가 생성하는 `config.toml` 위에 path policy 안내 헤더 코멘트 자동 prepend (절대/tilde/env/상대 + 상대 base = config dir). spec: `tasks/p9/p9-fb-05-config-path-policy.md`.
|
||||
|
||||
## 다음 task 후보
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ flowchart TB
|
||||
- `--config <path>` flag — 임시 워크스페이스 / 격리 테스트 시 사용. CLI / TUI 모두 honor.
|
||||
- `KEBAB_*` env — 일부 키 override (`KEBAB_RAG_SCORE_GATE`, `KEBAB_EVAL_GOLDEN`, `KEBAB_COMMIT_HASH` 등).
|
||||
- XDG layout: `~/.config/kebab/`, `~/.local/share/kebab/`, `~/.cache/kebab/`, `~/.local/state/kebab/`.
|
||||
- `workspace.root` 경로 형식: 절대 (`/foo/bar`) / tilde (`~/KnowledgeBase`, default) / env (`${XDG_DATA_HOME}/kebab`) / 상대 (`./notes`, `notes`, `../shared/x`) 모두 가능. **상대 경로의 base 는 config.toml 자체가 위치한 디렉토리** — 사용자의 `cwd` 와 무관 (`--config /tmp/cfg.toml` + `root = "kb"` → `/tmp/kb`). p9-fb-05 정책.
|
||||
|
||||
config 예시는 [docs/SMOKE.md](docs/SMOKE.md) 의 `/tmp/kebab-smoke/config.toml` 블록 참조.
|
||||
|
||||
|
||||
@@ -123,13 +123,36 @@ pub fn init_workspace(force: bool) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
let workspace_root = expand_tilde(&kebab_config::Config::defaults().workspace.root);
|
||||
let workspace_root = kebab_config::Config::defaults().resolve_workspace_root();
|
||||
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)?;
|
||||
std::fs::write(&cfg_path, toml_text)?;
|
||||
// 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.
|
||||
#
|
||||
# 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)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -881,7 +904,9 @@ fn ingest_one_image_asset(
|
||||
// `~` / `${XDG_…}` expansion via the same helper the markdown
|
||||
// path uses, so a `~/KnowledgeBase` workspace.root resolves
|
||||
// identically across all media (HOTFIXES 2026-05-02 P9-4 follow-up).
|
||||
let workspace_root = expand_tilde(&app.config.workspace.root);
|
||||
// p9-fb-05: relative `workspace.root` resolves against the config
|
||||
// file's directory (Config.source_dir), not the user's cwd.
|
||||
let workspace_root = app.config.resolve_workspace_root();
|
||||
let ctx = ExtractContext {
|
||||
asset,
|
||||
workspace_root: &workspace_root,
|
||||
@@ -1185,7 +1210,9 @@ fn ingest_one_pdf_asset(
|
||||
|
||||
let extract_config = kebab_core::ExtractConfig::default();
|
||||
// `~` / `${XDG_…}` expansion (HOTFIXES 2026-05-02 P9-4 follow-up).
|
||||
let workspace_root = expand_tilde(&app.config.workspace.root);
|
||||
// p9-fb-05: relative `workspace.root` resolves against the config
|
||||
// file's directory (Config.source_dir), not the user's cwd.
|
||||
let workspace_root = app.config.resolve_workspace_root();
|
||||
let ctx = ExtractContext {
|
||||
asset,
|
||||
workspace_root: &workspace_root,
|
||||
|
||||
@@ -15,3 +15,6 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
# p9-fb-05: warn-log when current_dir() fails (chroot, deleted cwd)
|
||||
# during workspace.root resolution.
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod paths;
|
||||
pub use paths::expand_path;
|
||||
pub use paths::{expand_path, expand_path_with_base};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
@@ -32,6 +32,20 @@ pub struct Config {
|
||||
/// `dark`).
|
||||
#[serde(default = "UiCfg::defaults")]
|
||||
pub ui: UiCfg,
|
||||
/// p9-fb-05: directory of the on-disk config file this `Config`
|
||||
/// was loaded from, if any. Populated by `Config::from_file` /
|
||||
/// `Config::load` — never serialized (`#[serde(skip)]`). Used by
|
||||
/// `expand_path_with_base` to resolve relative `workspace.root`
|
||||
/// against the config file's location instead of the user's
|
||||
/// `cwd` (so `--config /tmp/cfg.toml` + `root = "kb"` reads
|
||||
/// `/tmp/kb` no matter where the user invoked from).
|
||||
|
|
||||
///
|
||||
/// `pub(crate)` so external callers can't break the
|
||||
/// "stamped only by from_file/load" invariant by hand. Use
|
||||
/// [`Config::with_source_dir`] for tests / programmatic
|
||||
/// construction that need a specific `source_dir`.
|
||||
#[serde(skip)]
|
||||
pub(crate) source_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -290,9 +304,61 @@ impl Config {
|
||||
},
|
||||
image: ImageCfg::defaults(),
|
||||
ui: UiCfg::defaults(),
|
||||
// p9-fb-05: defaults are not loaded from disk, so no
|
||||
// source_dir. Relative `workspace.root` (rare with
|
||||
// defaults) falls back to caller `cwd` via the
|
||||
// `unwrap_or_else(...)` in `expand_path_with_base`
|
||||
// sites — see kebab-app's resolve_workspace_root.
|
||||
source_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// p9-fb-05: read-only accessor for the source-file directory
|
||||
/// (where `from_file` / `load` stamped it). Returns `None` for
|
||||
/// `Config::defaults()` and other in-memory constructions.
|
||||
pub fn source_dir(&self) -> Option<&Path> {
|
||||
self.source_dir.as_deref()
|
||||
}
|
||||
|
||||
/// p9-fb-05: builder for tests / programmatic callers that need
|
||||
/// to pin `source_dir` without going through `from_file`. Returns
|
||||
/// `self` so it chains: `Config::defaults().with_source_dir(p)`.
|
||||
pub fn with_source_dir(mut self, dir: PathBuf) -> Self {
|
||||
self.source_dir = Some(dir);
|
||||
self
|
||||
}
|
||||
|
||||
/// p9-fb-05: resolve `workspace.root` to an absolute `PathBuf`.
|
||||
/// Order:
|
||||
/// 1. tilde / env / `${VAR}` substitutions per [`expand_path`].
|
||||
/// 2. if still relative, join onto `source_dir` (config file's
|
||||
/// directory) when known, else `cwd`.
|
||||
///
|
||||
/// Tilde / absolute / `${VAR}`-rooted inputs ignore `source_dir`.
|
||||
/// `Config::defaults()` (which has no `source_dir`) effectively
|
||||
/// uses `cwd` for relative inputs — which is the surprising
|
||||
/// case spec p9-fb-05 calls out as a foot-gun, but it can only
|
||||
/// arise when the user is using defaults AND has a relative
|
||||
/// root, which is rare (defaults ship `~/KnowledgeBase`).
|
||||
pub fn resolve_workspace_root(&self) -> PathBuf {
|
||||
let base = self.source_dir.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|e| {
|
||||
// chroot / deleted-cwd / permission failure: log so a
|
||||
// user with an environment problem doesn't silently
|
||||
// wonder why their workspace.root resolved to "./root"
|
||||
// (which then fails at `create_dir_all` time with a
|
||||
// less obvious error).
|
||||
tracing::warn!(
|
||||
target: "kebab-config",
|
||||
error = %e,
|
||||
"current_dir() failed; falling back to '.' for workspace.root resolution"
|
||||
);
|
||||
PathBuf::from(".")
|
||||
})
|
||||
});
|
||||
paths::expand_path_with_base(&self.workspace.root, "", &base)
|
||||
}
|
||||
|
||||
/// Read config from disk and merge env overrides on top of it. If the
|
||||
/// file is missing, defaults are used (so `kb doctor` runs with no
|
||||
/// prior `kb init`).
|
||||
@@ -313,9 +379,14 @@ impl Config {
|
||||
Ok(from_disk.apply_env(&env))
|
||||
}
|
||||
|
||||
/// Parse a config from `path`. p9-fb-05: also stamps
|
||||
/// `source_dir = path.parent()` so relative `workspace.root`
|
||||
/// values resolve against the config file's directory rather
|
||||
/// than the user's `cwd`.
|
||||
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let cfg: Self = toml::from_str(&text)?;
|
||||
let mut cfg: Self = toml::from_str(&text)?;
|
||||
cfg.source_dir = path.parent().map(Path::to_path_buf);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
//! were applicable; relative paths are kept relative to the caller's
|
||||
//! CWD (not resolved here).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Expand storage-path templates. See module docs for the substitution
|
||||
/// rules.
|
||||
@@ -33,6 +33,12 @@ use std::path::PathBuf;
|
||||
/// `{data_dir}` substitution becomes a no-op in that case so the
|
||||
/// recursive shape (`data_dir = "${XDG_DATA_HOME:-…}/kb"`) resolves
|
||||
/// without producing a literal `{data_dir}` token in the output.
|
||||
///
|
||||
/// Relative paths (no leading `/`, `~`, `${VAR}`) are returned as-is —
|
||||
/// the caller's CWD is the implicit base. Use [`expand_path_with_base`]
|
||||
/// when relative paths must resolve against a specific directory (e.g.
|
||||
/// `workspace.root` in p9-fb-05 resolves against the config file's
|
||||
/// directory).
|
||||
pub fn expand_path(raw: &str, data_dir: &str) -> PathBuf {
|
||||
let mut s = raw.to_string();
|
||||
|
||||
@@ -68,6 +74,29 @@ pub fn expand_path(raw: &str, data_dir: &str) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// p9-fb-05: same substitutions as [`expand_path`], plus relative-path
|
||||
/// resolution against a caller-supplied `base_dir`. After the absolute
|
||||
/// / `~` / `${VAR}` substitutions are done, if the result is still a
|
||||
/// relative path, it's joined onto `base_dir`.
|
||||
///
|
||||
/// Used by `workspace.root` which must resolve against the config
|
||||
/// file's directory (so `--config /tmp/cfg.toml` + `root = "kb"`
|
||||
/// reads `/tmp/kb`, regardless of the user's `cwd`). Without this,
|
||||
/// the user's `cwd` at invocation time would silently change which
|
||||
/// workspace got indexed — invisible foot-gun.
|
||||
///
|
||||
/// `base_dir` is consulted only for paths that come out relative
|
||||
/// after substitutions; absolute / tilde / `${VAR}`-rooted inputs
|
||||
/// ignore it.
|
||||
pub fn expand_path_with_base(raw: &str, data_dir: &str, base_dir: &Path) -> PathBuf {
|
||||
let expanded = expand_path(raw, data_dir);
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
base_dir.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -170,6 +199,57 @@ mod tests {
|
||||
assert_eq!(p, PathBuf::from(home).join("runs"));
|
||||
}
|
||||
|
||||
// ── p9-fb-05: expand_path_with_base ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn relative_path_resolves_against_base_dir() {
|
||||
let base = Path::new("/tmp/kebab-cfg");
|
||||
assert_eq!(
|
||||
expand_path_with_base("notes", "", base),
|
||||
PathBuf::from("/tmp/kebab-cfg/notes")
|
||||
);
|
||||
assert_eq!(
|
||||
expand_path_with_base("./notes", "", base),
|
||||
PathBuf::from("/tmp/kebab-cfg/./notes")
|
||||
);
|
||||
assert_eq!(
|
||||
expand_path_with_base("../parent/x", "", base),
|
||||
PathBuf::from("/tmp/kebab-cfg/../parent/x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_ignores_base_dir() {
|
||||
let base = Path::new("/tmp/ignored-cfg");
|
||||
assert_eq!(
|
||||
expand_path_with_base("/abs/notes", "", base),
|
||||
PathBuf::from("/abs/notes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_path_ignores_base_dir() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let home = std::env::var("HOME").expect("HOME must be set in tests");
|
||||
let base = Path::new("/tmp/ignored-cfg");
|
||||
let p = expand_path_with_base("~/x", "", base);
|
||||
assert_eq!(p, PathBuf::from(home).join("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_var_path_ignores_base_dir() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let _guard = XdgGuard::capture();
|
||||
// SAFETY: lock held for the duration of this test.
|
||||
unsafe { std::env::set_var("XDG_DATA_HOME", "/xdg/data") };
|
||||
|
||||
let base = Path::new("/tmp/ignored-cfg");
|
||||
assert_eq!(
|
||||
expand_path_with_base("${XDG_DATA_HOME}/kb", "", base),
|
||||
PathBuf::from("/xdg/data/kb")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_dir_then_xdg_then_tilde_compose() {
|
||||
// Order matters: substitute `{data_dir}` (which itself contains
|
||||
|
||||
@@ -44,11 +44,11 @@ pub struct FsSourceConnector {
|
||||
|
||||
impl FsSourceConnector {
|
||||
pub fn new(config: &Config) -> Result<Self> {
|
||||
// `config.workspace.root` is a String that may contain `~` or env
|
||||
// expansions. P0-* did not yet provide a path-expansion helper in
|
||||
// kb-config; for P1-1 we expand `~` ourselves and leave `${VAR}`
|
||||
// for a follow-up. The vast majority of users hit the `~` case.
|
||||
let root = expand_tilde(&config.workspace.root);
|
||||
// p9-fb-05: tilde / env / `${VAR}` substitutions plus
|
||||
// relative-path resolution against the config file's
|
||||
// directory (Config.source_dir) — so `--config /tmp/cfg.toml`
|
||||
// + `root = "kb"` reads `/tmp/kb`, not the user's cwd.
|
||||
let root = config.resolve_workspace_root();
|
||||
|
||||
let copy_threshold_bytes = config
|
||||
.storage
|
||||
@@ -166,30 +166,11 @@ impl SourceConnector for FsSourceConnector {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(kb-config): hoist tilde + ${VAR} expansion into a kb-config helper
|
||||
// once that crate gains a path-expansion API. Today this duplicates logic
|
||||
// that P1-6 (store-sqlite) and future crates will also need.
|
||||
/// Expand a leading `~` to the current user's home directory. No-op for
|
||||
/// any other shape (absolute, relative, `${VAR}`-style).
|
||||
fn expand_tilde(s: &str) -> PathBuf {
|
||||
if let Some(rest) = s.strip_prefix("~/") {
|
||||
if let Some(home) = dirs_home() {
|
||||
return home.join(rest);
|
||||
}
|
||||
} else if s == "~" {
|
||||
if let Some(home) = dirs_home() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Tiny `dirs::home_dir`-compat shim that does NOT add the `dirs` crate to
|
||||
/// our dep set (we explicitly enumerate allowed deps in the task spec).
|
||||
/// Reads `$HOME` directly.
|
||||
fn dirs_home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
// p9-fb-05: removed local `expand_tilde` + `dirs_home` shim. The
|
||||
// canonical helper now lives in `kebab-config::resolve_workspace_root`
|
||||
// (calling `expand_path_with_base`), so this crate just delegates via
|
||||
// `Config::resolve_workspace_root` above. Keeps tilde / `${VAR}` /
|
||||
// relative path semantics consistent with kebab-app and kebab-cli.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -3,7 +3,7 @@ phase: P9
|
||||
component: kebab-config + kebab-cli (init) + README
|
||||
task_id: p9-fb-05
|
||||
title: "workspace.root path policy (relative? + init placeholder + README)"
|
||||
status: planned
|
||||
status: in_progress
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
|
||||
Reference in New Issue
Block a user
source_dir가pub이라 외부 코드가 직접 mutate 할 수 있는데,from_file/load가 stamp 하는 invariant 를 깨면resolve_workspace_root()가 잘못된 base 를 쓰게 됩니다. 두 옵션:pub(crate)로 좁히고pub fn source_dir(&self) -> Option<&Path>getter +pub fn with_source_dir(self, dir: PathBuf) -> Selfbuilder 노출 (테스트 / 프로그래마틱 사용에는 builder).pub그대로 두되 doc 마지막에 "외부 코드는 직접 set 하지 말 것;from_file/load만이 정당한 setter" 한 줄 추가.저는 (1) 이 invariant 보호 측면에서 더 명확하다고 봅니다 —
serde(skip)라 외부 source 는from_file가 stamp 하지 않은 채로는 절대 set 될 수 없어야 함.