From 2d08473a3ba6f5cb6e71c9b8cd4cb1ce83c5bce0 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 17 Jun 2026 07:38:17 -0500 Subject: [PATCH 1/2] fix(search): ignore empty-string label in search_graph name_pattern path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_where_basic guarded the label filter on NULL only (`if (params->label)`), so a search passing label="" appended a literal `n.label = ''` clause that matches no node — silently returning zero results. The BM25 query path already treats an empty label as absent, so name_pattern/qn_pattern searches and BM25 searches behaved inconsistently for the same `"label":""` payload (issue #481): agents fell back to grep because structural class/service discovery returned nothing. Guard the clause on `params->label && params->label[0]` so an empty-string label is a no-op, identical to an omitted label and to the BM25 path. Adds store_search_empty_label_ignored: name_pattern + label="" returns the same match as name_pattern alone, while a non-empty label still filters. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shane McCarron --- src/store/store.c | 7 ++++++- tests/test_store_search.c | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/store/store.c b/src/store/store.c index c237332e..b9d1599b 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2348,7 +2348,12 @@ static int search_where_basic(const cbm_search_params_t *params, char *where, in *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->project); } - if (params->label) { + /* Ignore an empty-string label: it is non-NULL but should behave like an + * omitted label (no filter), matching the BM25 query path. Without the + * params->label[0] guard, name_pattern/qn_pattern searches that pass + * label="" append `n.label = ''`, which matches no node and silently + * returns zero results (issue #481). */ + if (params->label && params->label[0]) { snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, bind_buf); where_bind_text(binds, bind_idx, params->label); diff --git a/tests/test_store_search.c b/tests/test_store_search.c index ded121a2..a9a3698a 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -86,6 +86,48 @@ TEST(store_search_by_name_pattern) { PASS(); } +/* ── Empty-string label is ignored (issue #481) ────────────────── */ + +/* An empty-string label must behave like an omitted label (no filter), not be + * applied as a literal `n.label = ''` that matches nothing. Previously a + * name_pattern/qn_pattern search passing label="" returned zero results, while + * the BM25 query path ignored the empty label — an inconsistency that made + * structural class/service discovery silently fail. */ +TEST(store_search_empty_label_ignored) { + int64_t ids[3]; + cbm_store_t *s = setup_search_store(ids); + + /* name_pattern + label="" must match the same as name_pattern alone. */ + cbm_search_params_t empty_label = {.project = "test", + .name_pattern = ".*Submit.*", + .label = "", + .min_degree = -1, + .max_degree = -1}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, &empty_label, &out); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(out.count, 1); + ASSERT_STR_EQ(out.results[0].node.name, "SubmitOrder"); + cbm_store_search_free(&out); + + /* A non-empty label still filters: ".*Order.*" matches three names but only + * OrderService is a Class. */ + cbm_search_params_t cls = {.project = "test", + .name_pattern = ".*Order.*", + .label = "Class", + .min_degree = -1, + .max_degree = -1}; + cbm_search_output_t out2 = {0}; + rc = cbm_store_search(s, &cls, &out2); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(out2.count, 1); + ASSERT_STR_EQ(out2.results[0].node.name, "OrderService"); + cbm_store_search_free(&out2); + + cbm_store_close(s); + PASS(); +} + /* ── Search by file pattern ─────────────────────────────────────── */ TEST(store_search_by_file_pattern) { @@ -1234,6 +1276,7 @@ TEST(store_impact_summary_empty) { SUITE(store_search) { RUN_TEST(store_search_by_label); RUN_TEST(store_search_by_name_pattern); + RUN_TEST(store_search_empty_label_ignored); RUN_TEST(store_search_by_file_pattern); RUN_TEST(store_search_file_pattern_substring_issue200); RUN_TEST(store_search_pagination); From 19a0fc56908ba298e7c518bc39c34a431e25736c Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Wed, 17 Jun 2026 07:50:33 -0500 Subject: [PATCH 2/2] =?UTF-8?q?test(search):=20address=20QA=20round=201=20?= =?UTF-8?q?=E2=80=94=20cover=20qn=5Fpattern=20+=20empty=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA round 1 noted store_search_empty_label_ignored asserted only the name_pattern path though the comment referenced qn_pattern too. Add a qn_pattern + label="" assertion so a future change that special-cased the qn_pattern branch can't silently reintroduce the empty-label bug. Both paths share search_where_basic, so this locks in the shared guard. Test-only; no behavior change. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shane McCarron --- tests/test_store_search.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_store_search.c b/tests/test_store_search.c index a9a3698a..459c1c24 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -124,6 +124,20 @@ TEST(store_search_empty_label_ignored) { ASSERT_STR_EQ(out2.results[0].node.name, "OrderService"); cbm_store_search_free(&out2); + /* qn_pattern shares the same WHERE builder, so empty label must be ignored + * there too. */ + cbm_search_params_t qn = {.project = "test", + .qn_pattern = ".*SubmitOrder", + .label = "", + .min_degree = -1, + .max_degree = -1}; + cbm_search_output_t out3 = {0}; + rc = cbm_store_search(s, &qn, &out3); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_EQ(out3.count, 1); + ASSERT_STR_EQ(out3.results[0].node.name, "SubmitOrder"); + cbm_store_search_free(&out3); + cbm_store_close(s); PASS(); }