From a21970f4f52802205172e17618dd1f89f7690856 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Fri, 3 Jul 2026 19:41:10 -0400 Subject: [PATCH] feat(mcp): enable overlay relationship search Use the active overlay graph read model for search_graph relationship-only requests while keeping connected-neighbor enrichment on canonical ids until that path is separately implemented. Add active_edges CTE support to cbm_store_search_overlay_view so relationship filters, entry-point filters, and degree filters can use visible canonical edges plus latest ready owned overlay edges keyed by qualified name. Make search degree-filter wrapping fail explicitly on allocation or SQL truncation instead of silently truncating generated SQL. Validation: CBM_ONLY_SUITE=store_nodes make -f Makefile.cbm -j8 test (/private/tmp/cbm-pan83-overlay-relationship-store-20260703T2043.log, 93 passed); CBM_ONLY_SUITE=mcp build/c/test-runner (/private/tmp/cbm-pan83-overlay-relationship-mcp-20260703T2048.log, 146 passed); make -f Makefile.cbm -j8 cbm (/private/tmp/cbm-pan83-overlay-relationship-product-build-20260703T2048.log); bash scripts/check-source-safety.sh (/private/tmp/cbm-pan83-overlay-relationship-source-safety-20260703T2048.log); git diff --check (/private/tmp/cbm-pan83-overlay-relationship-diff-check-20260703T2048.log). Signed-off-by: Andrew Hundt --- src/mcp/mcp.c | 38 ++++--- src/store/store.c | 223 ++++++++++++++++++++++++++++++--------- tests/test_mcp.c | 78 ++++++++++++++ tests/test_store_nodes.c | 99 +++++++++++++++++ 4 files changed, 373 insertions(+), 65 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index ee4de765b..97907a594 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -118,6 +118,7 @@ static void add_response_warning(yyjson_mut_doc *doc, yyjson_mut_val *root, cons #define CBM_MCP_FRESHNESS_STALE_WITH_WARNING "stale_with_warning" #define CBM_MCP_FRESHNESS_READ_MODEL_KEY "read_model" #define CBM_MCP_FRESHNESS_READ_MODEL_OVERLAY_ACTIVE_NODES "overlay_active_nodes" +#define CBM_MCP_FRESHNESS_READ_MODEL_OVERLAY_ACTIVE_GRAPH "overlay_active_graph" #define CBM_MCP_EXACT_DELTA_KEY "exact_delta" static yyjson_mut_val *ensure_response_freshness(yyjson_mut_doc *doc, yyjson_mut_val *root) { @@ -270,7 +271,7 @@ static void add_overlay_node_read_view_summary(yyjson_mut_doc *doc, yyjson_mut_v static void add_overlay_active_node_search_freshness( yyjson_mut_doc *doc, yyjson_mut_val *root, - const cbm_store_overlay_node_view_summary_t *summary) { + const cbm_store_overlay_node_view_summary_t *summary, bool uses_active_edges) { if (!summary || summary->active_file_tombstones <= 0) { return; } @@ -279,7 +280,9 @@ static void add_overlay_active_node_search_freshness( return; } yyjson_mut_obj_add_str(doc, freshness, CBM_MCP_FRESHNESS_READ_MODEL_KEY, - CBM_MCP_FRESHNESS_READ_MODEL_OVERLAY_ACTIVE_NODES); + uses_active_edges + ? CBM_MCP_FRESHNESS_READ_MODEL_OVERLAY_ACTIVE_GRAPH + : CBM_MCP_FRESHNESS_READ_MODEL_OVERLAY_ACTIVE_NODES); yyjson_mut_obj_add_int(doc, freshness, "overlay_ready_generations", summary->overlay_ready_generations); yyjson_mut_obj_add_int(doc, freshness, "active_file_tombstones", @@ -290,10 +293,15 @@ static void add_overlay_active_node_search_freshness( summary->overlay_owned_nodes_visible); yyjson_mut_obj_add_int(doc, freshness, "total_nodes_visible", summary->total_nodes_visible); - add_response_warning(doc, root, - "search_graph graph mode used overlay active node rows; edge traversal, " - "FTS query mode, and trace results remain canonical until separately " - "enabled."); + add_response_warning( + doc, root, + uses_active_edges + ? "search_graph graph mode used overlay active node and relationship rows; " + "connected-neighbor enrichment, FTS query mode, and trace results remain " + "canonical until separately enabled." + : "search_graph graph mode used overlay active node rows; connected-neighbor " + "enrichment, FTS query mode, and trace results remain canonical until " + "separately enabled."); } static void add_pipeline_exact_delta_stats(yyjson_mut_doc *doc, yyjson_mut_val *root, @@ -3729,9 +3737,12 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { cbm_store_get_overlay_node_view_summary(store, project, &overlay_summary) == CBM_STORE_OK && overlay_summary.active_file_tombstones > 0; - bool overlay_edge_semantics_requested = - relationship || include_connected || exclude_entry_points; - bool overlay_search_used = overlay_ready_for_nodes && !overlay_edge_semantics_requested; + bool overlay_active_edges_requested = + relationship || exclude_entry_points || min_degree >= 0 || max_degree >= 0 || + (sort_by && (strcmp(sort_by, "degree") == 0 || strcmp(sort_by, "calls") == 0 || + strcmp(sort_by, "linkrank") == 0)); + bool overlay_requires_canonical_edge_ids = include_connected; + bool overlay_search_used = overlay_ready_for_nodes && !overlay_requires_canonical_edge_ids; if (overlay_search_used) { cbm_store_search_overlay_view(store, ¶ms, &out); } else { @@ -3761,12 +3772,13 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { out.node_degree_stale); add_dirty_file_freshness(doc, root, store, project); if (overlay_search_used) { - add_overlay_active_node_search_freshness(doc, root, &overlay_summary); - } else if (overlay_ready_for_nodes && overlay_edge_semantics_requested) { + add_overlay_active_node_search_freshness(doc, root, &overlay_summary, + overlay_active_edges_requested); + } else if (overlay_ready_for_nodes && overlay_requires_canonical_edge_ids) { add_response_warning(doc, root, "overlay rows are ready, but this search_graph request used " - "canonical graph rows because relationship/connected traversal " - "needs canonical edge ids until overlay edge reads are enabled."); + "canonical graph rows because connected-neighbor enrichment " + "needs canonical edge ids until overlay connected reads are enabled."); } if (search_graph_uses_route_derived_graph(label, relationship) && cbm_store_derived_view_is_stale(store, project, CBM_STORE_DERIVED_VIEW_ROUTES)) { diff --git a/src/store/store.c b/src/store/store.c index 5773f3e28..0c9e4759a 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -6022,24 +6022,41 @@ static char *make_like_hint(const char *literal) { return buf; } -static void search_apply_degree_filter(char *sql, size_t sql_sz, const cbm_search_params_t *p) { +static int search_apply_degree_filter(cbm_store_t *s, char *sql, size_t sql_sz, + const cbm_search_params_t *p) { bool has_degree_filter = (p->min_degree >= 0 || p->max_degree >= 0); if (!has_degree_filter) { - return; + return CBM_STORE_OK; + } + char *inner_sql = malloc(sql_sz); + if (!inner_sql) { + store_set_error(s, "search degree filter out of memory"); + return CBM_STORE_ERR; + } + int n = snprintf(inner_sql, sql_sz, "%s", sql); + if (n < 0 || (size_t)n >= sql_sz) { + free(inner_sql); + store_set_error(s, "search degree filter input SQL truncated"); + return CBM_STORE_ERR; } - char inner_sql[CBM_SZ_4K]; - snprintf(inner_sql, sizeof(inner_sql), "%s", sql); if (p->min_degree >= 0 && p->max_degree >= 0) { - snprintf(sql, sql_sz, - "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d AND (in_deg + out_deg) <= %d", - inner_sql, p->min_degree, p->max_degree); + n = snprintf(sql, sql_sz, + "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d " + "AND (in_deg + out_deg) <= %d", + inner_sql, p->min_degree, p->max_degree); } else if (p->min_degree >= 0) { - snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d", inner_sql, - p->min_degree); + n = snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d", + inner_sql, p->min_degree); } else { - snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) <= %d", inner_sql, - p->max_degree); + n = snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) <= %d", + inner_sql, p->max_degree); } + free(inner_sql); + if (n < 0 || (size_t)n >= sql_sz) { + store_set_error(s, "search degree filter SQL truncated"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; } /* Append a WHERE clause fragment, joining with AND if not the first. */ @@ -6267,7 +6284,27 @@ static void search_where_advanced(const cbm_search_params_t *params, char *where char excl_clause[CBM_SZ_512]; search_build_exclude_labels(params->exclude_labels, binds, bind_idx, excl_clause, (int)sizeof(excl_clause)); - (void)where_append(where, where_sz, *wlen, nparams, excl_clause); + *wlen = where_append(where, where_sz, *wlen, nparams, excl_clause); + } +} + +static void search_where_overlay_edges(const cbm_search_params_t *params, char *where, + int where_sz, int *wlen, int *nparams, + search_bind_t *binds, int *bind_idx) { + if (params->relationship) { + char rel_clause[CBM_SZ_256]; + snprintf(rel_clause, sizeof(rel_clause), + "EXISTS(SELECT 1 FROM active_edges e WHERE " + "(e.source_qn = n.qualified_name OR e.target_qn = n.qualified_name) " + "AND e.type = ?%d)", + *bind_idx + SKIP_ONE); + *wlen = where_append(where, where_sz, *wlen, nparams, rel_clause); + where_bind_text(binds, bind_idx, params->relationship); + } + if (params->exclude_entry_points) { + *wlen = where_append(where, where_sz, *wlen, nparams, + "EXISTS(SELECT 1 FROM active_edges e " + "WHERE e.target_qn = n.qualified_name)"); } } @@ -6282,6 +6319,79 @@ static int search_build_where(const cbm_search_params_t *params, char *where, in return nparams; } +static bool search_overlay_needs_active_edges(const cbm_search_params_t *params) { + if (!params) { + return false; + } + if (params->relationship || params->exclude_entry_points || + params->min_degree >= 0 || params->max_degree >= 0) { + return true; + } + return params->sort_by && + (strcmp(params->sort_by, "degree") == 0 || + strcmp(params->sort_by, "calls") == 0 || + strcmp(params->sort_by, "linkrank") == 0); +} + +static int search_build_active_overlay_cte(char *buf, size_t buf_sz, bool include_edges) { + int n = snprintf(buf, buf_sz, + "WITH active_files AS (" + " SELECT t.project, t.rel_path, MAX(t.overlay_generation) AS overlay_generation" + " FROM overlay_tombstones t" + " JOIN overlay_generations g" + " ON g.project = t.project AND g.overlay_generation = t.overlay_generation" + " WHERE g.status = ?1 AND t.entity_kind = ?2 AND t.active = %d" + " GROUP BY t.project, t.rel_path" + "), active_nodes AS (" + " SELECT n.id, n.project, n.label, n.name, n.qualified_name, n.file_path," + " n.start_line, n.end_line, n.properties" + " FROM nodes n" + " WHERE NOT EXISTS (SELECT 1 FROM active_files af" + " WHERE af.project = n.project AND af.rel_path = n.file_path)" + " UNION ALL" + " SELECT %d AS id, n.project, n.label, n.name, n.qualified_name, n.file_path," + " n.start_line, n.end_line, n.properties" + " FROM overlay_nodes n" + " JOIN active_files af" + " ON af.project = n.project AND af.rel_path = n.rel_path" + " AND af.overlay_generation = n.overlay_generation" + " WHERE n.owned = %d" + ")", + STORE_OVERLAY_TOMBSTONE_ACTIVE, CBM_STORE_NO_NODE_ID, + STORE_OVERLAY_ROW_OWNED); + if (n < 0 || (size_t)n >= buf_sz) { + return CBM_STORE_ERR; + } + size_t used = (size_t)n; + if (!include_edges) { + n = snprintf(buf + used, buf_sz - used, " "); + return (n >= 0 && used + (size_t)n < buf_sz) ? CBM_STORE_OK : CBM_STORE_ERR; + } + n = snprintf(buf + used, buf_sz - used, + ", active_edges AS (" + " SELECT s.qualified_name AS source_qn, t.qualified_name AS target_qn, e.type" + " FROM edges e" + " JOIN nodes s ON s.id = e.source_id" + " JOIN nodes t ON t.id = e.target_id" + " WHERE NOT EXISTS (SELECT 1 FROM active_files af" + " WHERE af.project = s.project AND af.rel_path = s.file_path)" + " AND NOT EXISTS (SELECT 1 FROM active_files af" + " WHERE af.project = t.project AND af.rel_path = t.file_path)" + " UNION ALL" + " SELECT e.source_qn, e.target_qn, e.type" + " FROM overlay_edges e" + " JOIN active_files af" + " ON af.project = e.project AND af.rel_path = e.rel_path" + " AND af.overlay_generation = e.overlay_generation" + " WHERE e.owned = %d" + ") ", + STORE_OVERLAY_ROW_OWNED); + if (n < 0 || used + (size_t)n >= buf_sz) { + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + static int search_execute_sql(cbm_store_t *s, const char *sql, const char *count_sql, search_bind_t *binds, int bind_idx, search_like_pool_t *like_pool, bool use_pagerank, @@ -6483,7 +6593,10 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear /* Degree filters — upstream helper wraps in subquery on in_deg/out_deg. */ bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); - search_apply_degree_filter(sql, sizeof(sql), params); + if (search_apply_degree_filter(s, sql, sizeof(sql), params) != CBM_STORE_OK) { + like_pool_free(&like_pool); + return CBM_STORE_ERR; + } /* Count query — stripped of per-row edge subqueries for the common (no-degree-filter) * case, since we only need the row count, not in_deg/out_deg. The degree-filter @@ -6604,46 +6717,39 @@ int cbm_store_search_overlay_view(cbm_store_t *s, const cbm_search_params_t *par bool use_pagerank = (!params->sort_by || strcmp(params->sort_by, "relevance") == 0) && !out->pagerank_stale; + bool use_active_edges = search_overlay_needs_active_edges(params); + const char *in_degree_expr = + use_active_edges + ? "(SELECT COUNT(*) FROM active_edges e WHERE e.target_qn = n.qualified_name)" + : "(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id)"; + const char *out_degree_expr = + use_active_edges + ? "(SELECT COUNT(*) FROM active_edges e WHERE e.source_qn = n.qualified_name)" + : "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id)"; + char select_with_pr[CBM_SZ_512]; + char select_without_pr[CBM_SZ_512]; + snprintf(select_with_pr, sizeof(select_with_pr), + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, " + "%s AS in_deg, %s AS out_deg, COALESCE(pr.rank, 0.0) AS pr_rank ", + in_degree_expr, out_degree_expr); + snprintf(select_without_pr, sizeof(select_without_pr), + "SELECT n.id, n.project, n.label, n.name, n.qualified_name, " + "n.file_path, n.start_line, n.end_line, n.properties, " + "%s AS in_deg, %s AS out_deg ", + in_degree_expr, out_degree_expr); const char *select_cols = - use_pagerank - ? "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) AS in_deg, " - "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id) AS out_deg, " - "COALESCE(pr.rank, 0.0) AS pr_rank " - : "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) AS in_deg, " - "(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id) AS out_deg "; + use_pagerank ? select_with_pr : select_without_pr; const char *from_join = use_pagerank ? "FROM active_nodes n LEFT JOIN pagerank pr ON pr.node_id = n.id" : "FROM active_nodes n"; - char active_cte[CBM_SZ_4K]; - snprintf(active_cte, sizeof(active_cte), - "WITH active_files AS (" - " SELECT t.project, t.rel_path, MAX(t.overlay_generation) AS overlay_generation" - " FROM overlay_tombstones t" - " JOIN overlay_generations g" - " ON g.project = t.project AND g.overlay_generation = t.overlay_generation" - " WHERE g.status = ?1 AND t.entity_kind = ?2 AND t.active = %d" - " GROUP BY t.project, t.rel_path" - "), active_nodes AS (" - " SELECT n.id, n.project, n.label, n.name, n.qualified_name, n.file_path," - " n.start_line, n.end_line, n.properties" - " FROM nodes n" - " WHERE NOT EXISTS (SELECT 1 FROM active_files af" - " WHERE af.project = n.project AND af.rel_path = n.file_path)" - " UNION ALL" - " SELECT %d AS id, n.project, n.label, n.name, n.qualified_name, n.file_path," - " n.start_line, n.end_line, n.properties" - " FROM overlay_nodes n" - " JOIN active_files af" - " ON af.project = n.project AND af.rel_path = n.rel_path" - " AND af.overlay_generation = n.overlay_generation" - " WHERE n.owned = %d" - ") ", - STORE_OVERLAY_TOMBSTONE_ACTIVE, CBM_STORE_NO_NODE_ID, STORE_OVERLAY_ROW_OWNED); + char active_cte[ST_SQL_BUF]; + if (search_build_active_overlay_cte(active_cte, sizeof(active_cte), use_active_edges) != + CBM_STORE_OK) { + store_set_error(s, "search_overlay_view active CTE SQL truncated"); + return CBM_STORE_ERR; + } char where[CBM_SZ_2K] = ""; search_bind_t binds[ST_SEARCH_MAX_BINDS]; @@ -6651,11 +6757,21 @@ int cbm_store_search_overlay_view(cbm_store_t *s, const cbm_search_params_t *par int bind_idx = 0; where_bind_text(binds, &bind_idx, CBM_STORE_OVERLAY_STATUS_READY); where_bind_text(binds, &bind_idx, CBM_STORE_OVERLAY_TOMBSTONE_FILE); + cbm_search_params_t active_params = *params; + if (use_active_edges) { + active_params.relationship = NULL; + active_params.exclude_entry_points = false; + } int nparams = - search_build_where(params, where, (int)sizeof(where), binds, &bind_idx, &like_pool); + search_build_where(&active_params, where, (int)sizeof(where), binds, &bind_idx, &like_pool); + int wlen = (int)strlen(where); + if (use_active_edges) { + search_where_overlay_edges(params, where, (int)sizeof(where), &wlen, &nparams, binds, + &bind_idx); + } - char sql[CBM_SZ_4K]; - char count_sql[CBM_SZ_4K]; + char sql[ST_SQL_BUF]; + char count_sql[ST_SQL_BUF]; if (nparams > 0) { snprintf(sql, sizeof(sql), "%s%s %s WHERE %s", active_cte, select_cols, from_join, where); snprintf(count_sql, sizeof(count_sql), "%sSELECT COUNT(*) FROM active_nodes n WHERE %s", @@ -6667,7 +6783,10 @@ int cbm_store_search_overlay_view(cbm_store_t *s, const cbm_search_params_t *par } bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); - search_apply_degree_filter(sql, sizeof(sql), params); + if (search_apply_degree_filter(s, sql, sizeof(sql), params) != CBM_STORE_OK) { + like_pool_free(&like_pool); + return CBM_STORE_ERR; + } if (has_degree_filter) { snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM (%s)", sql); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index e51241ca6..c18600cb5 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -903,6 +903,83 @@ TEST(tool_search_graph_uses_overlay_active_node_rows) { PASS(); } +TEST(tool_search_graph_uses_overlay_active_relationship_rows) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + const char *proj = "search-overlay-relationship"; + ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/search-overlay-relationship"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, proj); + + cbm_node_t old_main = {.project = proj, + .label = "Function", + .name = "old_main", + .qualified_name = "search.relationship.old_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_node_t stable = {.project = proj, + .label = "Function", + .name = "stable", + .qualified_name = "search.relationship.stable", + .file_path = "stable.c", + .properties_json = "{}"}; + int64_t old_main_id = cbm_store_upsert_node(st, &old_main); + int64_t stable_id = cbm_store_upsert_node(st, &stable); + ASSERT_GT(old_main_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = proj, + .source_id = old_main_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(st, &old_edge), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(st, proj, 1, &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = proj, + .label = "Function", + .name = "new_main", + .qualified_name = "search.relationship.new_main", + .file_path = "main.c", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "search.relationship.new_main", + .target_qn = "search.relationship.stable", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = proj, + .rel_path = "main.c", + .generation = 1, + .nodes = &new_main, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(st, &delta, overlay_generation), + CBM_STORE_OK); + + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":148,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\"," + "\"arguments\":{\"project\":\"search-overlay-relationship\"," + "\"relationship\":\"CALLS\",\"sort_by\":\"name\"," + "\"limit\":5}}}"); + ASSERT_NOT_NULL(resp); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + ASSERT_NOT_NULL(strstr(inner, "new_main")); + ASSERT_NOT_NULL(strstr(inner, "stable")); + ASSERT_NULL(strstr(inner, "old_main")); + ASSERT_NOT_NULL(strstr(inner, "\"read_model\":\"overlay_active_graph\"")); + ASSERT_NOT_NULL(strstr(inner, "overlay active node and relationship rows")); + + free(inner); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + static bool mcp_test_upsert_fts_node(cbm_store_t *st, const char *project, const char *label, const char *name, const char *qualified_name, const char *file_path) { @@ -4228,6 +4305,7 @@ SUITE(mcp) { RUN_TEST(tool_search_graph_warns_on_stale_route_view); RUN_TEST(tool_search_graph_reports_dirty_metadata_without_hiding_canonical_rows); RUN_TEST(tool_search_graph_uses_overlay_active_node_rows); + RUN_TEST(tool_search_graph_uses_overlay_active_relationship_rows); RUN_TEST(tool_search_graph_query_reports_dirty_metadata_without_hiding_results); RUN_TEST(tool_search_graph_query_sees_file_delta_fts_updates); RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index adc4852f6..9336b7bfd 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -1701,6 +1701,104 @@ TEST(store_search_overlay_view_matches_full_rebuild_oracle) { PASS(); } +TEST(store_search_overlay_view_uses_active_relationship_edges) { + enum { BASE_GENERATION = 1 }; + cbm_store_t *live = cbm_store_open_memory(); + cbm_store_t *oracle = cbm_store_open_memory(); + ASSERT_NOT_NULL(live); + ASSERT_NOT_NULL(oracle); + ASSERT_EQ(cbm_store_upsert_project(live, "test", "/tmp/test"), CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(oracle, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t old_main = {.project = "test", + .label = "Function", + .name = "old_main", + .qualified_name = "test.old_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_node_t stable = {.project = "test", + .label = "Function", + .name = "stable", + .qualified_name = "test.stable", + .file_path = "stable.go", + .properties_json = "{}"}; + int64_t old_main_id = cbm_store_upsert_node(live, &old_main); + int64_t stable_id = cbm_store_upsert_node(live, &stable); + ASSERT_GT(old_main_id, 0); + ASSERT_GT(stable_id, 0); + cbm_edge_t old_edge = {.project = "test", + .source_id = old_main_id, + .target_id = stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(live, &old_edge), 0); + ASSERT_GT(cbm_store_upsert_node(oracle, &stable), 0); + + int64_t overlay_generation = 0; + ASSERT_EQ(cbm_store_reserve_overlay_generation(live, "test", BASE_GENERATION, + &overlay_generation), + CBM_STORE_OK); + cbm_node_t new_main = {.project = "test", + .label = "Function", + .name = "new_main", + .qualified_name = "test.new_main", + .file_path = "main.go", + .properties_json = "{}"}; + cbm_store_delta_edge_t new_edge = {.source_qn = "test.new_main", + .target_qn = "test.stable", + .type = "CALLS", + .properties_json = "{}", + .derived_kind = CBM_STORE_DERIVED_KIND_DIRECT}; + cbm_store_file_delta_t delta = {.project = "test", + .rel_path = "main.go", + .generation = BASE_GENERATION, + .nodes = &new_main, + .node_count = 1, + .edges = &new_edge, + .edge_count = 1}; + ASSERT_EQ(cbm_store_publish_overlay_file_delta(live, &delta, overlay_generation), + CBM_STORE_OK); + int64_t new_main_id = cbm_store_upsert_node(oracle, &new_main); + ASSERT_GT(new_main_id, 0); + int64_t oracle_stable_id = 0; + cbm_node_t oracle_stable = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(oracle, "test", "test.stable", &oracle_stable), + CBM_STORE_OK); + oracle_stable_id = oracle_stable.id; + cbm_node_free_fields(&oracle_stable); + cbm_edge_t oracle_edge = {.project = "test", + .source_id = new_main_id, + .target_id = oracle_stable_id, + .type = "CALLS"}; + ASSERT_GT(cbm_store_insert_edge(oracle, &oracle_edge), 0); + + cbm_search_params_t params = {.project = "test", + .relationship = "CALLS", + .sort_by = "name", + .limit = 10, + .min_degree = 0, + .max_degree = -1}; + cbm_search_output_t active = {0}; + cbm_search_output_t expected = {0}; + ASSERT_EQ(cbm_store_search_overlay_view(live, ¶ms, &active), CBM_STORE_OK); + ASSERT_EQ(cbm_store_search(oracle, ¶ms, &expected), CBM_STORE_OK); + ASSERT_EQ(active.total, expected.total); + ASSERT_EQ(active.count, expected.count); + ASSERT_EQ(active.count, 2); + ASSERT_STR_EQ(active.results[0].node.name, expected.results[0].node.name); + ASSERT_STR_EQ(active.results[1].node.name, expected.results[1].node.name); + ASSERT_STR_EQ(active.results[0].node.name, "new_main"); + ASSERT_STR_EQ(active.results[1].node.name, "stable"); + ASSERT_EQ(active.results[0].node.id, CBM_STORE_NO_NODE_ID); + ASSERT_GT(active.results[1].in_degree, 0); + ASSERT_GT(active.results[0].out_degree, 0); + + cbm_store_search_free(&active); + cbm_store_search_free(&expected); + cbm_store_close(live); + cbm_store_close(oracle); + PASS(); +} + TEST(store_owner_metadata_crud) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -4440,6 +4538,7 @@ SUITE(store_nodes) { RUN_TEST(store_find_nodes_by_file_overlay_view_returns_latest_ready_rows); RUN_TEST(store_search_overlay_view_without_ready_overlay_matches_canonical_search); RUN_TEST(store_search_overlay_view_matches_full_rebuild_oracle); + RUN_TEST(store_search_overlay_view_uses_active_relationship_edges); RUN_TEST(store_owner_metadata_crud); RUN_TEST(store_rebuild_file_delta_owners_derives_from_graph); RUN_TEST(store_import_export_metadata_crud);