feat(v0.17.0/PR-B/B1): C typedef extractor + parser_version bump + orphan purge cascade

closure of HOTFIXES 2026-05-21. C typedef-wrapped anonymous
struct/enum/union 이 typedef alias 이름으로 symbol unit 방출.

- crates/kebab-parse-code/src/c.rs: type_definition 분기 추가.
  inner anonymous struct_specifier / enum_specifier / union_specifier
  탐지 → declarator field 의 type_identifier 재귀 추출 → synthetic
  unit (typedef alias). named inner aggregate / plain alias 는
  기존대로 glue. PARSER_VERSION code-c-v1 → code-c-v2.
  recover_typedef_alias + extract_typedef_alias_name helper 추가.

- crates/kebab-store-sqlite/src/store.rs: 두 helper 신규
  (parser_version bump cascade 용 doc-id 기반 orphan purge).
  - stale_chunk_ids_for_workspace_path_except_doc_id(workspace_path,
    keep_doc_id) — sister of stale_chunk_ids_at, doc_id 기반.
  - purge_document_at_workspace_path_except_doc_id(workspace_path,
    keep_doc_id) — CASCADE document/chunks 제거, assets 보존.
  keep_doc_id="" 가 "모든 doc 제거" 사용.

- crates/kebab-app/src/lib.rs: try_skip_unchanged 의 parser_mismatch
  분기에서 purge_workspace_path_for_parser_bump 호출. helper 가
  app.vector() 로 lazy 접근 + delete_by_chunk_ids + SQLite document
  row 제거. Ok(None) 반환 전 cleanup 끝나서 caller 의 새 INSERT 시
  idx_docs_workspace_path UNIQUE 충돌 회피.

- tests:
  - c.rs unit tests 4 신규 — typedef_struct_emits_unit /
    typedef_enum_emits_unit / typedef_union_emits_unit /
    typedef_to_existing_type_stays_glue (negative).
  - tier1_c_ingest_searchable: parser_version assertion code-c-v1 →
    code-c-v2.
- 회귀: bytes-edit 경로 (asset_id 변경) 의 기존 purge_orphan_at_workspace_path
  + purge_vector_orphans_for_workspace_path 는 그대로 — 신규 분기와
  공존, 기존 test 모두 PASS.

미해결 (Risks): nested typedef (typedef struct { struct {...} inner; }
Outer;) 의 inner 익명 struct 는 여전히 glue — v2 의 1차 범위는
top-level typedef alias 만.

cargo test --workspace --no-fail-fast -j 1 + clippy 통과.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 14:15:32 +00:00
parent 67559fb3ce
commit 93ddece111
4 changed files with 273 additions and 14 deletions

View File

@@ -464,6 +464,74 @@ impl SqliteStore {
}
Ok(out)
}
/// v0.17.0 PR-B: sister of [`Self::stale_chunk_ids_at`] for the
/// `parser_version` bump cascade. When `doc_id` depends on
/// `parser_version` (design §9) and an extractor ships a new
/// `PARSER_VERSION`, the next ingest computes a fresh `doc_id` for
/// the *same* `(workspace_path, asset_id)` pair. The existing
/// asset_id-keyed [`Self::stale_chunk_ids_at`] does NOT fire (same
/// asset), so the legacy `chunks` rows and their LanceDB shadows
/// would orphan. This helper queries by `workspace_path` instead,
/// excluding the freshly-computed `keep_doc_id` so a re-entry
/// during the same ingest doesn't re-sweep the new row.
///
/// Caller usage: pass the *new* `doc_id` if known; pass an empty
/// string when called before the new INSERT (the case in
/// `try_skip_unchanged`) — all existing docs at `workspace_path`
/// are then collected as stale.
pub fn stale_chunk_ids_for_workspace_path_except_doc_id(
&self,
workspace_path: &str,
keep_doc_id: &str,
) -> Result<Vec<kebab_core::ChunkId>> {
let conn = self.lock_conn();
let mut stmt = conn
.prepare(
"SELECT c.chunk_id
FROM chunks c
INNER JOIN documents d ON c.doc_id = d.doc_id
WHERE d.workspace_path = ?1 AND d.doc_id != ?2",
)
.map_err(StoreError::from)?;
let rows = stmt
.query_map(params![workspace_path, keep_doc_id], |row| {
row.get::<_, String>(0)
})
.map_err(StoreError::from)?;
let mut out: Vec<kebab_core::ChunkId> = Vec::new();
for row in rows {
let id = row.map_err(StoreError::from)?;
out.push(kebab_core::ChunkId(id));
}
Ok(out)
}
/// v0.17.0 PR-B: sweep the SQLite document chain (`documents` →
/// `blocks` / `chunks` / `embedding_records` via CASCADE) for every
/// row at `workspace_path` whose `doc_id` differs from `keep_doc_id`.
/// Pair with [`Self::stale_chunk_ids_for_workspace_path_except_doc_id`]
/// — caller fetches the chunk_ids first, hands them to
/// `VectorStore::delete_by_chunk_ids`, then calls this sweep.
/// `assets` row is preserved (same bytes, same asset_id — only the
/// derived `doc_id` changed).
///
/// `keep_doc_id = ""` deletes every doc at `workspace_path`
/// (semantics mirror the sister helper above — used by
/// `try_skip_unchanged` before the new INSERT exists).
pub fn purge_document_at_workspace_path_except_doc_id(
&self,
workspace_path: &str,
keep_doc_id: &str,
) -> Result<()> {
let conn = self.lock_conn();
conn.execute(
"DELETE FROM documents WHERE workspace_path = ?1 AND doc_id != ?2",
params![workspace_path, keep_doc_id],
)
.map_err(StoreError::from)?;
Ok(())
}
}
/// Sweep stale `assets` + `documents` + downstream rows when the file