feat(v0.17.0/PR-C): code_lang_chunk_breakdown additive wire field

closure of HOTFIXES 2026-05-22 "code_lang_breakdown chunk granularity"
LOW. Chunk-level companion of the existing doc-count metric.

- crates/kebab-store-sqlite/src/store.rs: code_lang_chunk_breakdown()
  method. chunks INNER JOIN documents → COUNT(c.chunk_id) GROUP BY
  metadata_json.code_lang, NULL skipped. BTreeMap<String, u32>.
  + lib unit test code_lang_chunk_breakdown_counts_chunks_not_docs
  (1 rust doc + 3 chunks → rust=3 chunks vs rust=1 doc).
- crates/kebab-app/src/schema.rs: Stats.code_lang_chunk_breakdown
  additive field + collect_stats builder. tests_stats_ext 의
  stats_includes_code_lang_and_repo_breakdown_fields 가 신규 필드도
  검증.
- docs/wire-schema/v1/schema.schema.json: 신규 additive 필드
  명세 + 기존 code_lang_breakdown / repo_breakdown description
  정정 ("code chunk count" → "doc count", Gemini round 2 권고).
- tasks/HOTFIXES.md: 2026-05-24 PR-C closure entry.

wire additive, schema_version bump 불필요. v0.16.x 호출 호환.
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 16:08:28 +00:00
parent ff9d5f5f86
commit 0def913abd
4 changed files with 178 additions and 2 deletions

View File

@@ -892,6 +892,45 @@ impl SqliteStore {
Ok(out)
}
/// v0.17.0 PR-C: per-code-language **chunk** count for
/// `schema.v1.stats`. Companion to [`Self::code_lang_breakdown`] —
/// that one returns *document* counts. Stats observers wanting
/// indexing-pressure granularity (a single PDF spec → 200 chunks,
/// vs a single Rust file → 5 chunks) need the chunk-level view.
///
/// SQL joins `chunks → documents`, reads
/// `metadata_json->'$.code_lang'` on the doc side, groups by the
/// language, and skips rows where `code_lang IS NULL`. Returns
/// `BTreeMap<String, u32>` mirroring the doc-count helper above
/// so callers can serialize both with the same shape.
pub fn code_lang_chunk_breakdown(
&self,
) -> anyhow::Result<std::collections::BTreeMap<String, u32>> {
use anyhow::Context;
let conn = self.read_conn();
let mut stmt = conn
.prepare(
"SELECT json_extract(d.metadata_json, '$.code_lang') AS cl, \
COUNT(c.chunk_id) \
FROM chunks c \
INNER JOIN documents d ON c.doc_id = d.doc_id \
WHERE cl IS NOT NULL \
GROUP BY cl",
)
.context("prepare code_lang_chunk_breakdown")?;
let rows = stmt
.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)? as u32))
})
.context("query code_lang_chunk_breakdown")?;
let mut out = std::collections::BTreeMap::new();
for row in rows {
let (k, v) = row.context("read code_lang_chunk_breakdown row")?;
out.insert(k, v);
}
Ok(out)
}
/// p10-1A-2 follow-up (dogfooding 2026-05-20): per-repo doc count for
/// `schema.v1`.
///
@@ -1041,6 +1080,108 @@ mod tests {
assert_eq!(bd.len(), 1, "expected exactly 1 entry, got: {bd:?}");
}
/// v0.17.0 PR-C: `code_lang_chunk_breakdown` counts *chunks* (not
/// docs) grouped by `documents.metadata_json.code_lang`. Differs
/// from `code_lang_breakdown` (doc count) by joining `chunks` and
/// summing chunk rows so one Rust file with 3 chunks reports
/// `rust=3` here vs `rust=1` in the doc-count helper.
///
/// Uses a side rusqlite connection (FK enforcement off) so a single
/// doc + multiple chunks fixture can be inserted without standing
/// up `assets` companions.
#[test]
fn code_lang_chunk_breakdown_counts_chunks_not_docs() {
let (dir, store) = open_fresh_store();
let db_path = dir.path().join("kebab.sqlite");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.pragma_update(None, "foreign_keys", "OFF").unwrap();
// 1 Rust doc + 3 chunks → chunk_breakdown rust=3 / doc_breakdown rust=1.
conn.execute(
"INSERT INTO documents (
doc_id, asset_id, workspace_path,
source_type, trust_level, parser_version,
doc_version, schema_version,
metadata_json, provenance_json,
created_at, updated_at
) VALUES (
'doc-rust-1', 'asset-1', 'src/main.rs',
'reference', 'primary', 'test-v1',
1, 1,
'{\"code_lang\":\"rust\"}', '{}',
'2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z'
)",
[],
)
.unwrap();
for i in 0..3u32 {
conn.execute(
"INSERT INTO chunks (
chunk_id, doc_id, text, heading_path_json, section_label,
source_spans_json, token_estimate, chunker_version,
policy_hash, block_ids_json, created_at
) VALUES (?, 'doc-rust-1', ?, '[]', NULL, '[]', 0, 'cv1', 'h', '[]', '2024-01-01T00:00:00Z')",
rusqlite::params![format!("rust-chunk-{i:0>26}"), format!("body {i}")],
)
.unwrap();
}
// 1 markdown doc + 1 chunk → code_lang = null → must be skipped.
conn.execute(
"INSERT INTO documents (
doc_id, asset_id, workspace_path,
source_type, trust_level, parser_version,
doc_version, schema_version,
metadata_json, provenance_json,
created_at, updated_at
) VALUES (
'doc-md-1', 'asset-2', 'notes/readme.md',
'markdown', 'primary', 'test-v1',
1, 1,
'{\"code_lang\":null}', '{}',
'2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z'
)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO chunks (
chunk_id, doc_id, text, heading_path_json, section_label,
source_spans_json, token_estimate, chunker_version,
policy_hash, block_ids_json, created_at
) VALUES ('md-chunk-00000000000000000000000', 'doc-md-1', 'm', '[]', NULL, '[]', 0, 'cv1', 'h', '[]', '2024-01-01T00:00:00Z')",
[],
)
.unwrap();
drop(conn);
let chunk_bd = store.code_lang_chunk_breakdown().unwrap();
assert_eq!(
chunk_bd.get("rust"),
Some(&3u32),
"expected rust=3 chunks (1 doc × 3 chunks): {chunk_bd:?}"
);
assert!(
!chunk_bd.contains_key("null"),
"null code_lang must be skipped: {chunk_bd:?}"
);
assert_eq!(
chunk_bd.len(),
1,
"expected exactly 1 language entry: {chunk_bd:?}"
);
// Sanity: the existing doc-count helper still returns 1 for rust,
// proving the two metrics differ as intended.
let doc_bd = store.code_lang_breakdown().unwrap();
assert_eq!(
doc_bd.get("rust"),
Some(&1u32),
"doc-count helper unchanged: {doc_bd:?}"
);
}
/// p10-1A-2 follow-up: `repo_breakdown` counts docs by
/// `metadata_json.repo`.
///