From 65b84256486dcc7ce9f8148b1becefe287a62756 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:53:56 -0700 Subject: [PATCH 1/2] fix: count INHERITS/IMPLEMENTS edges in search_graph degree search_graph reported in_degree/out_degree of 0 for Class nodes that have real INHERITS edges, because the degree computation in both cbm_store_node_degree (Cypher virtual properties) and cbm_store_search (result population and min_degree/max_degree filter) only counted CALLS (and USAGE) edges. A min_degree filter therefore silently excluded well-connected classes. Broaden the edge-type predicate in both surfaces to IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS') so search_graph and Cypher report the same edge-type-agnostic degree and stay consistent. Add regression tests covering: a parent class with N INHERITS children, combined CALLS+INHERITS degree without double counting, min_degree now including inheritance-only classes, and isolated nodes still reporting zero. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/store/store.c | 10 ++- tests/test_store_search.c | 180 +++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 5 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index b9d1599b..c644390f 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -1789,7 +1789,8 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou *in_deg = 0; *out_deg = 0; - const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND type = 'CALLS'"; + const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND " + "type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, in_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { sqlite3_bind_int64(stmt, SKIP_ONE, node_id); @@ -1799,7 +1800,8 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou sqlite3_finalize(stmt); } - const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND type = 'CALLS'"; + const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND " + "type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')"; if (sqlite3_prepare_v2(s->db, out_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { sqlite3_bind_int64(stmt, SKIP_ONE, node_id); if (sqlite3_step(stmt) == SQLITE_ROW) { @@ -2452,9 +2454,9 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear const char *select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " "n.file_path, n.start_line, n.end_line, n.properties, " "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND " - "e.type IN ('CALLS', 'USAGE')) AS in_deg, " + "e.type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')) AS in_deg, " "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND " - "e.type IN ('CALLS', 'USAGE')) AS out_deg "; + "e.type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')) AS out_deg "; char where[CBM_SZ_2K] = ""; search_bind_t binds[ST_SEARCH_MAX_BINDS]; diff --git a/tests/test_store_search.c b/tests/test_store_search.c index 459c1c24..fc8bff56 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -224,6 +224,15 @@ TEST(store_search_pagination) { /* ── Search with degree filter ──────────────────────────────────── */ +static int search_result_index_by_name(const cbm_search_output_t *out, const char *name) { + for (int i = 0; i < out->count; i++) { + if (out->results[i].node.name && strcmp(out->results[i].node.name, name) == 0) { + return i; + } + } + return -1; +} + TEST(store_search_degree_filter) { int64_t ids[3]; cbm_store_t *s = setup_search_store(ids); @@ -239,7 +248,7 @@ TEST(store_search_degree_filter) { ASSERT_EQ(out.count, 2); cbm_store_search_free(&out); - /* max_degree = 0 should find nodes with no CALLS edges */ + /* max_degree = 0 should find nodes with no counted degree edges */ params.min_degree = -1; /* no min */ params.max_degree = 0; /* only zero-degree nodes */ params.label = "Function"; @@ -253,6 +262,171 @@ TEST(store_search_degree_filter) { PASS(); } +TEST(store_search_degree_counts_inherits) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t parent = {.project = "test", + .label = "Class", + .name = "AbstractAttachmentDto", + .qualified_name = "test.AbstractAttachmentDto"}; + int64_t parent_id = cbm_store_upsert_node(s, &parent); + ASSERT_GT(parent_id, 0); + + const char *child_names[] = {"AttachmentDtoA", "AttachmentDtoB", "AttachmentDtoC"}; + const char *child_qns[] = {"test.AttachmentDtoA", "test.AttachmentDtoB", + "test.AttachmentDtoC"}; + for (int i = 0; i < 3; i++) { + cbm_node_t child = {.project = "test", + .label = "Class", + .name = child_names[i], + .qualified_name = child_qns[i]}; + int64_t child_id = cbm_store_upsert_node(s, &child); + ASSERT_GT(child_id, 0); + cbm_edge_t edge = {.project = "test", + .source_id = child_id, + .target_id = parent_id, + .type = "INHERITS"}; + ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); + } + + int in_deg = 0; + int out_deg = 0; + cbm_store_node_degree(s, parent_id, &in_deg, &out_deg); + ASSERT_EQ(in_deg, 3); + ASSERT_EQ(out_deg, 0); + + cbm_search_params_t params = { + .project = "test", .label = "Class", .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(rc, CBM_STORE_OK); + int parent_idx = search_result_index_by_name(&out, "AbstractAttachmentDto"); + ASSERT_GTE(parent_idx, 0); + ASSERT_EQ(out.results[parent_idx].in_degree, 3); + ASSERT_EQ(out.results[parent_idx].out_degree, 0); + cbm_store_search_free(&out); + + cbm_store_close(s); + PASS(); +} + +TEST(store_search_degree_calls_plus_inherits_no_double_count) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t child = {.project = "test", + .label = "Class", + .name = "AttachmentDto", + .qualified_name = "test.AttachmentDto"}; + cbm_node_t parent = {.project = "test", + .label = "Class", + .name = "BaseAttachmentDto", + .qualified_name = "test.BaseAttachmentDto"}; + cbm_node_t fn = {.project = "test", + .label = "Function", + .name = "normalizeAttachment", + .qualified_name = "test.normalizeAttachment"}; + int64_t child_id = cbm_store_upsert_node(s, &child); + int64_t parent_id = cbm_store_upsert_node(s, &parent); + int64_t fn_id = cbm_store_upsert_node(s, &fn); + ASSERT_GT(child_id, 0); + ASSERT_GT(parent_id, 0); + ASSERT_GT(fn_id, 0); + + cbm_edge_t calls = { + .project = "test", .source_id = child_id, .target_id = fn_id, .type = "CALLS"}; + cbm_edge_t inherits = { + .project = "test", .source_id = child_id, .target_id = parent_id, .type = "INHERITS"}; + ASSERT_GT(cbm_store_insert_edge(s, &calls), 0); + ASSERT_GT(cbm_store_insert_edge(s, &inherits), 0); + + int in_deg = 0; + int out_deg = 0; + cbm_store_node_degree(s, child_id, &in_deg, &out_deg); + ASSERT_EQ(in_deg, 0); + ASSERT_EQ(out_deg, 2); + + cbm_search_params_t params = { + .project = "test", .label = "Class", .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(rc, CBM_STORE_OK); + int child_idx = search_result_index_by_name(&out, "AttachmentDto"); + ASSERT_GTE(child_idx, 0); + ASSERT_EQ(out.results[child_idx].in_degree, 0); + ASSERT_EQ(out.results[child_idx].out_degree, 2); + cbm_store_search_free(&out); + + cbm_store_close(s); + PASS(); +} + +TEST(store_search_min_degree_includes_inherits_only) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t child = {.project = "test", + .label = "Class", + .name = "InheritanceOnlyChild", + .qualified_name = "test.InheritanceOnlyChild"}; + cbm_node_t parent = {.project = "test", + .label = "Class", + .name = "InheritanceOnlyParent", + .qualified_name = "test.InheritanceOnlyParent"}; + int64_t child_id = cbm_store_upsert_node(s, &child); + int64_t parent_id = cbm_store_upsert_node(s, &parent); + ASSERT_GT(child_id, 0); + ASSERT_GT(parent_id, 0); + + cbm_edge_t edge = { + .project = "test", .source_id = child_id, .target_id = parent_id, .type = "INHERITS"}; + ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); + + cbm_search_params_t params = { + .project = "test", .label = "Class", .min_degree = 1, .max_degree = -1}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(rc, CBM_STORE_OK); + ASSERT_GTE(search_result_index_by_name(&out, "InheritanceOnlyParent"), 0); + cbm_store_search_free(&out); + + cbm_store_close(s); + PASS(); +} + +TEST(store_search_isolated_node_zero_degree) { + cbm_store_t *s = cbm_store_open_memory(); + cbm_store_upsert_project(s, "test", "/tmp/test"); + + cbm_node_t node = {.project = "test", + .label = "Class", + .name = "LonelyClass", + .qualified_name = "test.LonelyClass"}; + int64_t node_id = cbm_store_upsert_node(s, &node); + ASSERT_GT(node_id, 0); + + int in_deg = 0; + int out_deg = 0; + cbm_store_node_degree(s, node_id, &in_deg, &out_deg); + ASSERT_EQ(in_deg, 0); + ASSERT_EQ(out_deg, 0); + + cbm_search_params_t params = { + .project = "test", .label = "Class", .min_degree = -1, .max_degree = -1}; + cbm_search_output_t out = {0}; + int rc = cbm_store_search(s, ¶ms, &out); + ASSERT_EQ(rc, CBM_STORE_OK); + int idx = search_result_index_by_name(&out, "LonelyClass"); + ASSERT_GTE(idx, 0); + ASSERT_EQ(out.results[idx].in_degree, 0); + ASSERT_EQ(out.results[idx].out_degree, 0); + cbm_store_search_free(&out); + + cbm_store_close(s); + PASS(); +} + /* ── Search all (no filters) ────────────────────────────────────── */ TEST(store_search_all) { @@ -1295,6 +1469,10 @@ SUITE(store_search) { RUN_TEST(store_search_file_pattern_substring_issue200); RUN_TEST(store_search_pagination); RUN_TEST(store_search_degree_filter); + RUN_TEST(store_search_degree_counts_inherits); + RUN_TEST(store_search_degree_calls_plus_inherits_no_double_count); + RUN_TEST(store_search_min_degree_includes_inherits_only); + RUN_TEST(store_search_isolated_node_zero_degree); RUN_TEST(store_search_all); RUN_TEST(store_search_exclude_labels); RUN_TEST(store_search_case_insensitive); From 0714e4634672cc7945d3931b963caad8202259ba Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:03:10 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20self-review=20findings=20?= =?UTF-8?q?=E2=80=94=20keep=20node=5Fdegree=20CALLS-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cbm_store_node_degree backs the MCP get_code_snippet callers/callees counts and the Cypher in_degree/out_degree virtual property, which are deliberately CALLS-scoped. Restore it to CALLS-only and scope the INHERITS/IMPLEMENTS broadening to cbm_store_search alone, which is the search_graph surface issue #558 is about. Adjust the new tests so node_degree assertions expect CALLS-only counts while the search_graph result assertions expect the broadened structural degree. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- src/store/store.c | 6 ++---- tests/test_store_search.c | 13 +++++-------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index c644390f..995f6e85 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -1789,8 +1789,7 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou *in_deg = 0; *out_deg = 0; - const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND " - "type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')"; + const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND type = 'CALLS'"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, in_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { sqlite3_bind_int64(stmt, SKIP_ONE, node_id); @@ -1800,8 +1799,7 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou sqlite3_finalize(stmt); } - const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND " - "type IN ('CALLS', 'USAGE', 'INHERITS', 'IMPLEMENTS')"; + const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND type = 'CALLS'"; if (sqlite3_prepare_v2(s->db, out_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) { sqlite3_bind_int64(stmt, SKIP_ONE, node_id); if (sqlite3_step(stmt) == SQLITE_ROW) { diff --git a/tests/test_store_search.c b/tests/test_store_search.c index fc8bff56..5cbf1a19 100644 --- a/tests/test_store_search.c +++ b/tests/test_store_search.c @@ -274,8 +274,7 @@ TEST(store_search_degree_counts_inherits) { ASSERT_GT(parent_id, 0); const char *child_names[] = {"AttachmentDtoA", "AttachmentDtoB", "AttachmentDtoC"}; - const char *child_qns[] = {"test.AttachmentDtoA", "test.AttachmentDtoB", - "test.AttachmentDtoC"}; + const char *child_qns[] = {"test.AttachmentDtoA", "test.AttachmentDtoB", "test.AttachmentDtoC"}; for (int i = 0; i < 3; i++) { cbm_node_t child = {.project = "test", .label = "Class", @@ -283,17 +282,15 @@ TEST(store_search_degree_counts_inherits) { .qualified_name = child_qns[i]}; int64_t child_id = cbm_store_upsert_node(s, &child); ASSERT_GT(child_id, 0); - cbm_edge_t edge = {.project = "test", - .source_id = child_id, - .target_id = parent_id, - .type = "INHERITS"}; + cbm_edge_t edge = { + .project = "test", .source_id = child_id, .target_id = parent_id, .type = "INHERITS"}; ASSERT_GT(cbm_store_insert_edge(s, &edge), 0); } int in_deg = 0; int out_deg = 0; cbm_store_node_degree(s, parent_id, &in_deg, &out_deg); - ASSERT_EQ(in_deg, 3); + ASSERT_EQ(in_deg, 0); ASSERT_EQ(out_deg, 0); cbm_search_params_t params = { @@ -345,7 +342,7 @@ TEST(store_search_degree_calls_plus_inherits_no_double_count) { int out_deg = 0; cbm_store_node_degree(s, child_id, &in_deg, &out_deg); ASSERT_EQ(in_deg, 0); - ASSERT_EQ(out_deg, 2); + ASSERT_EQ(out_deg, 1); cbm_search_params_t params = { .project = "test", .label = "Class", .min_degree = -1, .max_degree = -1};