Files
kebab/crates/kebab-store-sqlite/tests/ingest_report_snapshot.rs
altair823 7a49c8a29b feat(kebab-normalize): p9-fb-07 markdown title fallback chain
`kebab-normalize::derive_title(frontmatter_title, blocks, file_stem)` 가
다음 단계로 비어있지 않은 첫 결과를 사용:

1. frontmatter `title` (trim 후)
2. 첫 H1 텍스트
3. 첫 H2 텍스트
4. 첫 Paragraph (Quote / List / Code / Table / ImageRef 제외) 의 첫 80 자
5. 파일 stem (확장자 제외)
6. (sentinel) `"untitled"` — 위 다섯 단계가 모두 blank 인 병적 케이스

선택된 문자열은 NFC 정규화. 빈 문자열은 절대 반환하지 않음.

`build_canonical_document` 가 metadata lift 직후 helper 호출. 기존 단순
lift 로직 (metadata.user["title"] → CanonicalDocument.title) 은 fallback
chain 의 1 단계 입력으로 자리 이동.

`KEBAB_PARSE_MD_VERSION` 상수를 `pulldown-cmark-0.x` → `md-frontmatter-v2`
로 bump. parser_version 변경 → §4.2 doc_id 입력 변화 → 기존 markdown
doc 의 `doc_id` 갱신, 다음 ingest 시 idempotent upsert 로 자동 재처리
(design §9 cascade). `kebab-store-sqlite` 의 snapshot fixture 도 같은
literal 로 갱신.

기존 M7 정책 ("metadata.user[\"title\"] = '' 가 빈 title 로 lift") 은
폐기. 빈 문자열 입력은 fallback chain 을 타고 file stem 까지 떨어진다.
spec p9-fb-07 line 37: "빈 문자열 반환 금지".

테스트 (kebab-normalize):
- 8 개 단위 테스트 (각 fallback 단계 + NFC + sentinel)
- `build_canonical_document` 통합 테스트 2 개 (H1 / file stem)
- 기존 M7 테스트 2 개를 새 정책에 맞춰 갱신

문서:
- README: `kebab ingest` 행에 "title 자동 채움" 안내 + 기존 doc 도
  다음 ingest 에서 갱신
- HANDOFF: 2026-05-03 머지 후 발견 entry
- spec status: `planned` → `in_progress`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:22:34 +00:00

100 lines
3.6 KiB
Rust

//! Snapshot test pinning the JSON wire form of `kebab_core::IngestReport`
//! for an inline fixture run. The store crate doesn't (yet) write
//! IngestReports — that's `kb-app`'s job — but the wire schema lives in
//! `kb-core`, and we want a determinism pin that fails loudly if the
//! shape drifts.
//!
//! Set `UPDATE_SNAPSHOTS=1` to re-bake the baseline.
use std::path::PathBuf;
use kebab_core::{
AssetId, ChunkerVersion, DocumentId, IngestItem, IngestItemKind, IngestReport,
ParserVersion, SourceScope, WorkspacePath,
};
use serde_json::Value;
fn baseline_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("snapshots")
.join("ingest_report.snapshot.json")
}
fn fixture_report() -> IngestReport {
IngestReport {
scope: SourceScope {
root: PathBuf::from("/home/u/KB"),
include: vec!["**/*.md".into()],
exclude: vec![".git/**".into()],
},
scanned: 3,
new: 2,
updated: 1,
skipped: 0,
errors: 0,
duration_ms: 187,
items: Some(vec![
IngestItem {
kind: IngestItemKind::New,
doc_id: Some(DocumentId("a".repeat(32))),
doc_path: WorkspacePath::new("notes/alpha.md".into()).unwrap(),
asset_id: Some(AssetId("a".repeat(32))),
byte_len: Some(1234),
block_count: Some(7),
chunk_count: Some(3),
parser_version: Some(ParserVersion("md-frontmatter-v2".into())),
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
warnings: vec![],
error: None,
},
IngestItem {
kind: IngestItemKind::Updated,
doc_id: Some(DocumentId("b".repeat(32))),
doc_path: WorkspacePath::new("notes/beta.md".into()).unwrap(),
asset_id: Some(AssetId("b".repeat(32))),
byte_len: Some(2048),
block_count: Some(12),
chunk_count: Some(5),
parser_version: Some(ParserVersion("md-frontmatter-v2".into())),
chunker_version: Some(ChunkerVersion("md-heading-v1".into())),
warnings: vec!["malformed frontmatter".into()],
error: None,
},
]),
}
}
#[test]
fn ingest_report_wire_form_is_stable() {
let report = fixture_report();
let actual = serde_json::to_value(&report).unwrap();
let baseline = match std::fs::read_to_string(baseline_path()) {
Ok(s) => s,
Err(_) if std::env::var("UPDATE_SNAPSHOTS").is_ok() => {
std::fs::create_dir_all(baseline_path().parent().unwrap()).unwrap();
let pretty = serde_json::to_string_pretty(&actual).unwrap();
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
return;
}
Err(e) => panic!(
"missing baseline {}; run with UPDATE_SNAPSHOTS=1: {e}",
baseline_path().display()
),
};
let expected: Value = serde_json::from_str(&baseline).unwrap();
if actual != expected {
if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
let pretty = serde_json::to_string_pretty(&actual).unwrap();
std::fs::write(baseline_path(), format!("{pretty}\n")).unwrap();
return;
}
let pretty = serde_json::to_string_pretty(&actual).unwrap();
panic!(
"ingest_report snapshot drift\n\
--- expected ({}) ---\n{baseline}\n\
--- actual ---\n{pretty}",
baseline_path().display()
);
}
}