Skip to content

Commit 58cd6c4

Browse files
feat(search): index prose content for BM25 full-text search
Section nodes (markdown) and Module nodes (YAML/JSON) previously exposed only their heading/name to BM25, so search_graph could not match the prose body or a config description. Index that text so content is searchable. - store: add a `body` column to the nodes_fts FTS5 table; new cbm_store_fts_rebuild() drops+recreates the table (upgrading legacy 4-column databases) and backfills `body` from each node's docstring, guarded by json_valid() against malformed-JSON rows - pipeline: both FTS backfill sites now call cbm_store_fts_rebuild() - mcp: stop excluding Section/Module from BM25 results (they rank below code symbols, so existing result ordering is preserved) - internal/cbm: capture the markdown section body beneath each heading (#518) and promote top-level description/summary/purpose values onto the file's Module node (#519), reusing the existing docstring property - tests: 7 extraction cases + 3 store FTS cases Closes #518 Closes #519 Signed-off-by: ShauryaaSharma <shauryasofficial27@gmail.com>
1 parent 34efbc0 commit 58cd6c4

8 files changed

Lines changed: 561 additions & 52 deletions

File tree

internal/cbm/extract_defs.c

Lines changed: 227 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2834,7 +2834,8 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
28342834
// --- Class definition extraction ---
28352835

28362836
// Push a simple class definition (used by config language extractors).
2837-
static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label) {
2837+
static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, const char *label,
2838+
const char *docstring) {
28382839
CBMArena *a = ctx->arena;
28392840
CBMDefinition def;
28402841
memset(&def, 0, sizeof(def));
@@ -2845,6 +2846,7 @@ static void push_simple_class_def(CBMExtractCtx *ctx, TSNode node, char *name, c
28452846
def.start_line = ts_node_start_point(node).row + TS_LINE_OFFSET;
28462847
def.end_line = ts_node_end_point(node).row + TS_LINE_OFFSET;
28472848
def.is_exported = true;
2849+
def.docstring = docstring; // section body for Markdown (#518); NULL otherwise
28482850
cbm_defs_push(&ctx->result->defs, a, def);
28492851
}
28502852

@@ -2943,6 +2945,57 @@ static char *extract_markdown_heading_name(CBMArena *a, TSNode node, const char
29432945
return trim_heading_name(name);
29442946
}
29452947

2948+
// Max bytes of Markdown section body captured for BM25 content search (#518).
2949+
enum { CBM_MD_SECTION_BODY_MAX = 500 };
2950+
2951+
// Capture the prose body beneath a Markdown heading so BM25 can search the
2952+
// content, not just the heading text (#518). In the tree-sitter-markdown grammar
2953+
// each heading lives inside a `section` node that also holds the body blocks and
2954+
// any nested subsections; the body is the source span between the heading and
2955+
// either the first nested subsection or the end of the section. Returns NULL when
2956+
// there is no enclosing section or no body text. The result is trimmed and capped
2957+
// at CBM_MD_SECTION_BODY_MAX bytes (without splitting a UTF-8 sequence).
2958+
static char *extract_markdown_section_body(CBMArena *a, TSNode heading, const char *source) {
2959+
TSNode parent = ts_node_parent(heading);
2960+
if (ts_node_is_null(parent) || strcmp(ts_node_type(parent), "section") != 0) {
2961+
return NULL;
2962+
}
2963+
uint32_t body_start = ts_node_end_byte(heading);
2964+
uint32_t body_end = ts_node_end_byte(parent);
2965+
// Stop at the first nested subsection — it gets its own Section node + body.
2966+
uint32_t cc = ts_node_child_count(parent);
2967+
for (uint32_t i = 0; i < cc; i++) {
2968+
TSNode ch = ts_node_child(parent, i);
2969+
if (ts_node_start_byte(ch) >= body_start && strcmp(ts_node_type(ch), "section") == 0) {
2970+
body_end = ts_node_start_byte(ch);
2971+
break;
2972+
}
2973+
}
2974+
// Trim surrounding whitespace/newlines (UTF-8 lead/continuation bytes are all
2975+
// >= 0x80, so a byte-wise <= ' ' test never cuts a multi-byte character).
2976+
while (body_start < body_end && (unsigned char)source[body_start] <= ' ') {
2977+
body_start++;
2978+
}
2979+
while (body_end > body_start && (unsigned char)source[body_end - 1] <= ' ') {
2980+
body_end--;
2981+
}
2982+
if (body_end <= body_start) {
2983+
return NULL;
2984+
}
2985+
size_t len = (size_t)(body_end - body_start);
2986+
if (len > CBM_MD_SECTION_BODY_MAX) {
2987+
len = CBM_MD_SECTION_BODY_MAX;
2988+
// Back off so the cap never splits a UTF-8 multi-byte sequence.
2989+
while (len > 0 && ((unsigned char)source[body_start + len] & 0xC0) == 0x80) {
2990+
len--;
2991+
}
2992+
if (len == 0) {
2993+
return NULL;
2994+
}
2995+
}
2996+
return cbm_arena_strndup(a, source + body_start, len);
2997+
}
2998+
29462999
// INI: extract section name from section node.
29473000
static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) {
29483001
uint32_t nc = ts_node_child_count(node);
@@ -2996,6 +3049,7 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char
29963049
CBMArena *a = ctx->arena;
29973050
char *name = NULL;
29983051
const char *label = "Class";
3052+
const char *docstring = NULL;
29993053

30003054
if (ctx->language == CBM_LANG_TOML &&
30013055
(strcmp(kind, "table") == 0 || strcmp(kind, "table_array_element") == 0)) {
@@ -3008,14 +3062,15 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char
30083062
(strcmp(kind, "atx_heading") == 0 || strcmp(kind, "setext_heading") == 0)) {
30093063
name = extract_markdown_heading_name(a, node, kind, ctx->source);
30103064
label = "Section";
3065+
docstring = extract_markdown_section_body(a, node, ctx->source); // #518
30113066
} else if (ctx->language == CBM_LANG_HCL && strcmp(kind, "block") == 0) {
30123067
name = find_hcl_block_name(a, node, ctx->source);
30133068
} else {
30143069
return false;
30153070
}
30163071

30173072
if (name && name[0]) {
3018-
push_simple_class_def(ctx, node, name, label);
3073+
push_simple_class_def(ctx, node, name, label, docstring);
30193074
}
30203075
return true;
30213076
}
@@ -5514,6 +5569,174 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
55145569
}
55155570
}
55165571

5572+
// ── Config module description promotion (#519) ──────────────────────────────
5573+
// YAML/JSON metadata files (META.yaml, skill manifests, …) carry their most
5574+
// search-relevant text in a top-level `description`/`summary`/`purpose` value.
5575+
// That value is otherwise dropped — only the key becomes a Variable node — so
5576+
// BM25 can't find a module by its description. We promote the value onto the
5577+
// file's Module node docstring, which the FTS `body` column then indexes.
5578+
5579+
// Case-insensitive ASCII equality (keys are conventionally lowercase, but accept
5580+
// "Description"/"SUMMARY" too).
5581+
static bool cfg_ci_eq(const char *x, const char *y) {
5582+
while (*x && *y) {
5583+
char cx = *x;
5584+
char cy = *y;
5585+
if (cx >= 'A' && cx <= 'Z') {
5586+
cx = (char)(cx + ('a' - 'A'));
5587+
}
5588+
if (cy >= 'A' && cy <= 'Z') {
5589+
cy = (char)(cy + ('a' - 'A'));
5590+
}
5591+
if (cx != cy) {
5592+
return false;
5593+
}
5594+
x++;
5595+
y++;
5596+
}
5597+
return *x == '\0' && *y == '\0';
5598+
}
5599+
5600+
static bool cfg_is_desc_key(const char *key) {
5601+
return key && (cfg_ci_eq(key, "description") || cfg_ci_eq(key, "summary") ||
5602+
cfg_ci_eq(key, "purpose"));
5603+
}
5604+
5605+
// Trim surrounding whitespace and strip one layer of matching quotes, in place.
5606+
static char *cfg_strip_quotes_trim(char *s) {
5607+
if (!s) {
5608+
return NULL;
5609+
}
5610+
while (*s && (unsigned char)*s <= ' ') {
5611+
s++;
5612+
}
5613+
size_t len = strlen(s);
5614+
while (len > 0 && (unsigned char)s[len - 1] <= ' ') {
5615+
s[--len] = '\0';
5616+
}
5617+
if (len >= 2 && ((s[0] == '"' && s[len - 1] == '"') || (s[0] == '\'' && s[len - 1] == '\''))) {
5618+
s[len - 1] = '\0';
5619+
s++;
5620+
}
5621+
return s;
5622+
}
5623+
5624+
// Copy at most `cap` bytes of `s`, never splitting a UTF-8 sequence; NULL if empty.
5625+
static char *cfg_arena_capped(CBMArena *a, const char *s, size_t cap) {
5626+
if (!s || !s[0]) {
5627+
return NULL;
5628+
}
5629+
size_t len = strlen(s);
5630+
if (len > cap) {
5631+
len = cap;
5632+
while (len > 0 && ((unsigned char)s[len] & 0xC0) == 0x80) {
5633+
len--;
5634+
}
5635+
if (len == 0) {
5636+
return NULL;
5637+
}
5638+
}
5639+
return cbm_arena_strndup(a, s, len);
5640+
}
5641+
5642+
// Descend stream/document/block_node to the top-level YAML block_mapping.
5643+
static TSNode cfg_find_yaml_mapping(TSNode root) {
5644+
TSNode none = {0};
5645+
TSNode cur = root;
5646+
for (int depth = 0; depth < 8; depth++) {
5647+
uint32_t n = ts_node_child_count(cur);
5648+
TSNode next = none;
5649+
bool have_next = false;
5650+
for (uint32_t i = 0; i < n; i++) {
5651+
TSNode ch = ts_node_child(cur, i);
5652+
const char *ck = ts_node_type(ch);
5653+
if (strcmp(ck, "block_mapping") == 0) {
5654+
return ch;
5655+
}
5656+
if (!have_next && (strcmp(ck, "stream") == 0 || strcmp(ck, "document") == 0 ||
5657+
strcmp(ck, "block_node") == 0)) {
5658+
next = ch;
5659+
have_next = true;
5660+
}
5661+
}
5662+
if (!have_next) {
5663+
break;
5664+
}
5665+
cur = next;
5666+
}
5667+
return none;
5668+
}
5669+
5670+
// Descend document wrappers to the top-level JSON object.
5671+
static TSNode cfg_find_json_object(TSNode root) {
5672+
TSNode none = {0};
5673+
TSNode cur = root;
5674+
for (int depth = 0; depth < 6; depth++) {
5675+
if (strcmp(ts_node_type(cur), "object") == 0) {
5676+
return cur;
5677+
}
5678+
uint32_t n = ts_node_child_count(cur);
5679+
TSNode next = none;
5680+
bool have_next = false;
5681+
for (uint32_t i = 0; i < n; i++) {
5682+
TSNode ch = ts_node_child(cur, i);
5683+
const char *ck = ts_node_type(ch);
5684+
if (strcmp(ck, "object") == 0) {
5685+
return ch;
5686+
}
5687+
if (!have_next && strcmp(ck, "document") == 0) {
5688+
next = ch;
5689+
have_next = true;
5690+
}
5691+
}
5692+
if (!have_next) {
5693+
break;
5694+
}
5695+
cur = next;
5696+
}
5697+
return none;
5698+
}
5699+
5700+
// Find a top-level description/summary/purpose value in a YAML or JSON file and
5701+
// return it (trimmed, unquoted, capped) for promotion to the Module docstring.
5702+
static const char *extract_config_module_description(CBMExtractCtx *ctx) {
5703+
if (ctx->language != CBM_LANG_YAML && ctx->language != CBM_LANG_JSON) {
5704+
return NULL;
5705+
}
5706+
CBMArena *a = ctx->arena;
5707+
TSNode mapping;
5708+
const char *pair_kind;
5709+
if (ctx->language == CBM_LANG_YAML) {
5710+
mapping = cfg_find_yaml_mapping(ctx->root);
5711+
pair_kind = "block_mapping_pair";
5712+
} else {
5713+
mapping = cfg_find_json_object(ctx->root);
5714+
pair_kind = "pair";
5715+
}
5716+
if (ts_node_is_null(mapping)) {
5717+
return NULL;
5718+
}
5719+
uint32_t n = ts_node_named_child_count(mapping);
5720+
for (uint32_t i = 0; i < n; i++) {
5721+
TSNode pair = ts_node_named_child(mapping, i);
5722+
if (strcmp(ts_node_type(pair), pair_kind) != 0) {
5723+
continue;
5724+
}
5725+
TSNode key = ts_node_child_by_field_name(pair, TS_FIELD("key"));
5726+
TSNode val = ts_node_child_by_field_name(pair, TS_FIELD("value"));
5727+
if (ts_node_is_null(key) || ts_node_is_null(val)) {
5728+
continue;
5729+
}
5730+
const char *key_txt = cfg_strip_quotes_trim(cbm_node_text(a, key, ctx->source));
5731+
if (!cfg_is_desc_key(key_txt)) {
5732+
continue;
5733+
}
5734+
char *val_txt = cfg_strip_quotes_trim(cbm_node_text(a, val, ctx->source));
5735+
return cfg_arena_capped(a, val_txt, CBM_MD_SECTION_BODY_MAX);
5736+
}
5737+
return NULL;
5738+
}
5739+
55175740
void cbm_extract_definitions(CBMExtractCtx *ctx) {
55185741
const CBMLangSpec *spec = cbm_lang_spec(ctx->language);
55195742
if (!spec) {
@@ -5533,6 +5756,8 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) {
55335756
mod.end_line = ts_node_end_point(ctx->root).row + TS_LINE_OFFSET;
55345757
mod.is_exported = true;
55355758
mod.is_test = ctx->result->is_test_file;
5759+
// Promote a YAML/JSON top-level description onto the Module for BM25 (#519).
5760+
mod.docstring = extract_config_module_description(ctx);
55365761
cbm_defs_push(&ctx->result->defs, a, mod);
55375762

55385763
// Walk AST for function/class definitions

src/mcp/mcp.c

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,11 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
13341334
") fts "
13351335
"JOIN nodes n ON n.id = fts.rowid "
13361336
"WHERE n.project = ?2 "
1337-
" AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project') "
1337+
/* Section and Module are searchable (#518/#519): their FTS `body` column
1338+
* carries markdown section prose and YAML/JSON description text. They
1339+
* rank below code symbols (no boost above), so code results stay first.
1340+
* File/Folder/Variable/Project remain excluded as structural noise. */
1341+
" AND n.label NOT IN ('File','Folder','Variable','Project') "
13381342
" AND (?6 IS NULL OR n.file_path LIKE ?6) "
13391343
"ORDER BY rank "
13401344
"LIMIT ?3 OFFSET ?4";
@@ -1359,17 +1363,17 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
13591363
* Uses the identical subquery structure so the FTS5 early-exit applies here too. */
13601364
int total = 0;
13611365
{
1362-
const char *count_sql =
1363-
"SELECT COUNT(*) FROM ("
1364-
" SELECT fts.rowid FROM ("
1365-
" SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1"
1366-
" ORDER BY bm25(nodes_fts) LIMIT ?3"
1367-
" ) fts "
1368-
" JOIN nodes n ON n.id = fts.rowid "
1369-
" WHERE n.project = ?2 "
1370-
" AND n.label NOT IN ('File','Folder','Module','Section','Variable','Project')"
1371-
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
1372-
")";
1366+
const char *count_sql = "SELECT COUNT(*) FROM ("
1367+
" SELECT fts.rowid FROM ("
1368+
" SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1"
1369+
" ORDER BY bm25(nodes_fts) LIMIT ?3"
1370+
" ) fts "
1371+
" JOIN nodes n ON n.id = fts.rowid "
1372+
" WHERE n.project = ?2 "
1373+
/* Mirror the label filter in the main query above (#518/#519). */
1374+
" AND n.label NOT IN ('File','Folder','Variable','Project')"
1375+
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
1376+
")";
13731377
sqlite3_stmt *cs = NULL;
13741378
if (sqlite3_prepare_v2(db, count_sql, BM25_SQL_AUTO_LEN, &cs, NULL) == SQLITE_OK) {
13751379
sqlite3_bind_text(cs, BM25_BIND_QUERY, fts_query, BM25_SQL_AUTO_LEN,

src/pipeline/pipeline.c

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -877,20 +877,11 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
877877
}
878878
}
879879

880-
/* FTS5 backfill: populate nodes_fts with camelCase-split names.
881-
* Contentless FTS5 requires the special 'delete-all' command instead of
882-
* DELETE FROM to wipe prior rows (there's no underlying content table).
883-
* Falls back to plain names if cbm_camel_split is unavailable (which
884-
* shouldn't happen because we always register it, but we stay defensive). */
885-
cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
886-
if (cbm_store_exec(hash_store,
887-
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
888-
"SELECT id, cbm_camel_split(name), qualified_name, label, file_path "
889-
"FROM nodes;") != CBM_STORE_OK) {
890-
cbm_store_exec(hash_store,
891-
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
892-
"SELECT id, name, qualified_name, label, file_path FROM nodes;");
893-
}
880+
/* FTS5 backfill: rebuild nodes_fts with camelCase-split names and prose
881+
* bodies (markdown sections, YAML/JSON descriptions, docstrings) so BM25
882+
* matches content as well as identifiers (#518/#519). cbm_store_fts_rebuild
883+
* also upgrades legacy 4-column tables to the schema carrying `body`. */
884+
cbm_store_fts_rebuild(hash_store);
894885

895886
cbm_store_close(hash_store);
896887
cbm_log_info("pass.timing", "pass", "persist_hashes", "files", itoa_buf(file_count));

src/pipeline/pipeline_incremental.c

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -649,17 +649,9 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *
649649

650650
/* FTS5 rebuild after incremental dump. The btree dump path bypasses
651651
* any triggers that could have kept nodes_fts synchronized, so we
652-
* rebuild from the nodes table here. See the full-dump path in
653-
* pipeline.c for the matching logic. */
654-
cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
655-
if (cbm_store_exec(hash_store,
656-
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
657-
"SELECT id, cbm_camel_split(name), qualified_name, label, file_path "
658-
"FROM nodes;") != CBM_STORE_OK) {
659-
cbm_store_exec(hash_store,
660-
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) "
661-
"SELECT id, name, qualified_name, label, file_path FROM nodes;");
662-
}
652+
* rebuild from the nodes table here (also indexing prose bodies for
653+
* content search — see cbm_store_fts_rebuild and #518/#519). */
654+
cbm_store_fts_rebuild(hash_store);
663655

664656
cbm_store_close(hash_store);
665657
}

0 commit comments

Comments
 (0)