From 2064d2006ca482bf359b953ac741f60239d5315c Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 30 Jun 2026 11:29:52 -0400 Subject: [PATCH] feat(store): add import metadata helpers Add symbol_exports and import_refs store APIs for exact-delta planning, including export listing and importer path lookup through indexed metadata. Keep the slice additive: no pipeline wiring, no incremental default change, no MCP or CLI API change, and no canonical graph schema behavior change. Validation: make -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=store_nodes ./build/c/test-runner; CBM_ONLY_SUITE=store_search ./build/c/test-runner; CBM_ONLY_SUITE=store_bulk ./build/c/test-runner; CBM_ONLY_SUITE=sqlite_writer ./build/c/test-runner; CBM_ONLY_SUITE=tool_consolidation ./build/c/test-runner; git diff --check; bash scripts/check-source-safety.sh. Signed-off-by: Andrew Hundt --- src/store/store.c | 191 +++++++++++++++++++++++++++++++++++++++ src/store/store.h | 29 ++++++ tests/test_store_nodes.c | 106 ++++++++++++++++++++++ 3 files changed, 326 insertions(+) diff --git a/src/store/store.c b/src/store/store.c index a43b4e32d..35486ed10 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -149,6 +149,13 @@ struct cbm_store { sqlite3_stmt *stmt_upsert_edge_owner; sqlite3_stmt *stmt_delete_node_owners_by_file; sqlite3_stmt *stmt_delete_edge_owners_by_file; + sqlite3_stmt *stmt_upsert_symbol_export; + sqlite3_stmt *stmt_delete_symbol_exports_by_file; + sqlite3_stmt *stmt_list_symbol_exports_by_file; + sqlite3_stmt *stmt_upsert_import_ref; + sqlite3_stmt *stmt_delete_import_refs_by_file; + sqlite3_stmt *stmt_list_import_ref_paths_by_target; + sqlite3_stmt *stmt_list_import_ref_paths_for_export_file; }; /* ── Public accessor ────────────────────────────────────────────── */ @@ -222,6 +229,43 @@ static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const c return *slot; } +static int store_collect_text_column(cbm_store_t *s, sqlite3_stmt *stmt, const char *op, + char ***out, int *count) { + if (!out || !count || !stmt) { + return CBM_STORE_ERR; + } + *out = NULL; + *count = 0; + + int cap = ST_INIT_CAP_8; + int n = 0; + char **arr = malloc((size_t)cap * sizeof(char *)); + int rc = SQLITE_OK; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *text = (const char *)sqlite3_column_text(stmt, 0); + if (!text) { + continue; + } + if (n >= cap) { + cap *= ST_GROWTH; + arr = safe_realloc(arr, (size_t)cap * sizeof(char *)); + } + arr[n++] = heap_strdup(text); + } + if (rc != SQLITE_DONE) { + for (int i = 0; i < n; i++) { + free(arr[i]); + } + free(arr); + store_set_error_sqlite(s, op); + return CBM_STORE_ERR; + } + + *out = arr; + *count = n; + return CBM_STORE_OK; +} + /* Get ISO-8601 timestamp. */ static void iso_now(char *buf, size_t sz) { time_t t = time(NULL); @@ -949,6 +993,13 @@ void cbm_store_close(cbm_store_t *s) { finalize_stmt(&s->stmt_upsert_edge_owner); finalize_stmt(&s->stmt_delete_node_owners_by_file); finalize_stmt(&s->stmt_delete_edge_owners_by_file); + finalize_stmt(&s->stmt_upsert_symbol_export); + finalize_stmt(&s->stmt_delete_symbol_exports_by_file); + finalize_stmt(&s->stmt_list_symbol_exports_by_file); + finalize_stmt(&s->stmt_upsert_import_ref); + finalize_stmt(&s->stmt_delete_import_refs_by_file); + finalize_stmt(&s->stmt_list_import_ref_paths_by_target); + finalize_stmt(&s->stmt_list_import_ref_paths_for_export_file); /* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes. * Prevents ASan false-positive leaks from sqlite3 internal state. */ @@ -2020,6 +2071,146 @@ int cbm_store_delete_edge_owners_by_file(cbm_store_t *s, const char *project, return CBM_STORE_OK; } +int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project, + const char *qualified_name, const char *rel_path, + int64_t node_id, int64_t generation) { + sqlite3_stmt *stmt = prepare_cached( + s, &s->stmt_upsert_symbol_export, + "INSERT INTO symbol_exports (project, qualified_name, rel_path, node_id, generation) " + "VALUES (?1, ?2, ?3, ?4, ?5) " + "ON CONFLICT(project, qualified_name, rel_path) DO UPDATE SET " + "node_id=?4, generation=?5;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, qualified_name); + bind_text(stmt, ST_COL_3, rel_path); + if (node_id > CBM_STORE_NO_NODE_ID) { + sqlite3_bind_int64(stmt, ST_COL_4, node_id); + } else { + sqlite3_bind_null(stmt, ST_COL_4); + } + sqlite3_bind_int64(stmt, ST_COL_5, generation); + if (sqlite3_step(stmt) != SQLITE_DONE) { + store_set_error_sqlite(s, "upsert_symbol_export"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +int cbm_store_delete_symbol_exports_by_file(cbm_store_t *s, const char *project, + const char *rel_path) { + sqlite3_stmt *stmt = + prepare_cached(s, &s->stmt_delete_symbol_exports_by_file, + "DELETE FROM symbol_exports WHERE project = ?1 AND rel_path = ?2;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, rel_path); + if (sqlite3_step(stmt) != SQLITE_DONE) { + store_set_error_sqlite(s, "delete_symbol_exports_by_file"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +int cbm_store_list_symbol_exports_by_file(cbm_store_t *s, const char *project, + const char *rel_path, char ***out, int *count) { + sqlite3_stmt *stmt = prepare_cached( + s, &s->stmt_list_symbol_exports_by_file, + "SELECT qualified_name FROM symbol_exports " + "WHERE project = ?1 AND rel_path = ?2 ORDER BY qualified_name;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, rel_path); + return store_collect_text_column(s, stmt, "list_symbol_exports_by_file", out, count); +} + +int cbm_store_upsert_import_ref(cbm_store_t *s, const char *project, const char *rel_path, + const char *import_text, const char *local_name, + const char *target_qn, int64_t generation) { + sqlite3_stmt *stmt = + prepare_cached(s, &s->stmt_upsert_import_ref, + "INSERT INTO import_refs (project, rel_path, import_text, local_name, " + "target_qn, generation) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " + "ON CONFLICT(project, rel_path, import_text, local_name) DO UPDATE SET " + "target_qn=?5, generation=?6;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, rel_path); + bind_text(stmt, ST_COL_3, import_text); + bind_text(stmt, ST_COL_4, safe_str(local_name)); + bind_text(stmt, ST_COL_5, safe_str(target_qn)); + sqlite3_bind_int64(stmt, ST_COL_6, generation); + if (sqlite3_step(stmt) != SQLITE_DONE) { + store_set_error_sqlite(s, "upsert_import_ref"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +int cbm_store_delete_import_refs_by_file(cbm_store_t *s, const char *project, + const char *rel_path) { + sqlite3_stmt *stmt = + prepare_cached(s, &s->stmt_delete_import_refs_by_file, + "DELETE FROM import_refs WHERE project = ?1 AND rel_path = ?2;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, rel_path); + if (sqlite3_step(stmt) != SQLITE_DONE) { + store_set_error_sqlite(s, "delete_import_refs_by_file"); + return CBM_STORE_ERR; + } + return CBM_STORE_OK; +} + +int cbm_store_list_import_ref_paths_by_target(cbm_store_t *s, const char *project, + const char *target_qn, char ***out, + int *count) { + sqlite3_stmt *stmt = + prepare_cached(s, &s->stmt_list_import_ref_paths_by_target, + "SELECT DISTINCT rel_path FROM import_refs " + "WHERE project = ?1 AND target_qn = ?2 ORDER BY rel_path;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, target_qn); + return store_collect_text_column(s, stmt, "list_import_ref_paths_by_target", out, count); +} + +int cbm_store_list_import_ref_paths_for_export_file(cbm_store_t *s, const char *project, + const char *export_rel_path, char ***out, + int *count) { + sqlite3_stmt *stmt = prepare_cached( + s, &s->stmt_list_import_ref_paths_for_export_file, + "SELECT DISTINCT i.rel_path FROM import_refs i " + "JOIN symbol_exports e ON e.project = i.project AND e.qualified_name = i.target_qn " + "WHERE e.project = ?1 AND e.rel_path = ?2 AND i.rel_path != e.rel_path " + "ORDER BY i.rel_path;"); + if (!stmt) { + return CBM_STORE_ERR; + } + + bind_text(stmt, ST_COL_1, project); + bind_text(stmt, ST_COL_2, export_rel_path); + return store_collect_text_column(s, stmt, "list_import_ref_paths_for_export_file", out, count); +} + /* ── FindNodesByFileOverlap ─────────────────────────────────────── */ int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path, diff --git a/src/store/store.h b/src/store/store.h index f88322cc1..966184323 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -29,6 +29,7 @@ typedef struct cbm_store cbm_store_t; #define CBM_STORE_INDEX_STATUS_COMPLETE "complete" #define CBM_STORE_DERIVED_STATUS_STALE "stale" #define CBM_STORE_DERIVED_KIND_DIRECT "direct" +#define CBM_STORE_NO_NODE_ID 0 /* ── Data structures ────────────────────────────────────────────── */ @@ -454,6 +455,34 @@ int cbm_store_delete_node_owners_by_file(cbm_store_t *s, const char *project, int cbm_store_delete_edge_owners_by_file(cbm_store_t *s, const char *project, const char *rel_path); +int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project, + const char *qualified_name, const char *rel_path, + int64_t node_id, int64_t generation); + +int cbm_store_delete_symbol_exports_by_file(cbm_store_t *s, const char *project, + const char *rel_path); + +/* Caller frees each returned string and the array. */ +int cbm_store_list_symbol_exports_by_file(cbm_store_t *s, const char *project, + const char *rel_path, char ***out, int *count); + +int cbm_store_upsert_import_ref(cbm_store_t *s, const char *project, const char *rel_path, + const char *import_text, const char *local_name, + const char *target_qn, int64_t generation); + +int cbm_store_delete_import_refs_by_file(cbm_store_t *s, const char *project, + const char *rel_path); + +/* Caller frees each returned string and the array. */ +int cbm_store_list_import_ref_paths_by_target(cbm_store_t *s, const char *project, + const char *target_qn, char ***out, + int *count); + +/* Caller frees each returned string and the array. */ +int cbm_store_list_import_ref_paths_for_export_file(cbm_store_t *s, const char *project, + const char *export_rel_path, char ***out, + int *count); + /* ── Search ─────────────────────────────────────────────────────── */ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_search_output_t *out); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 490797269..58d149a08 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -38,6 +38,16 @@ static int store_count_metadata_owners(cbm_store_t *s, int edge, const char *pro return count; } +static void store_free_string_array(char **items, int count) { + if (!items) { + return; + } + for (int i = 0; i < count; i++) { + free(items[i]); + } + free(items); +} + TEST(store_open_memory) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -691,6 +701,101 @@ TEST(store_owner_metadata_crud) { PASS(); } +TEST(store_import_export_metadata_crud) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + + cbm_node_t node = {.project = "test", + .label = "Function", + .name = "Helper", + .qualified_name = "test.lib.Helper", + .file_path = "lib.go", + .properties_json = "{}"}; + int64_t node_id = cbm_store_upsert_node(s, &node); + ASSERT_GT(node_id, 0); + + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Helper", "lib.go", node_id, 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_symbol_export(s, "test", "test.lib.Unresolved", "lib.go", + CBM_STORE_NO_NODE_ID, 1), + CBM_STORE_OK); + + char **items = NULL; + int count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 2); + ASSERT_STR_EQ(items[0], "test.lib.Helper"); + ASSERT_STR_EQ(items[1], "test.lib.Unresolved"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper", + "test.lib.Helper", 1), + CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "other.go", "test.other", "Other", + "test.other.Other", 1), + CBM_STORE_OK); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_for_export_file(s, "test", "lib.go", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_upsert_import_ref(s, "test", "caller.go", "test.lib", "Helper", + "test.lib.Renamed", 2), + CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Helper", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 1); + ASSERT_STR_EQ(items[0], "caller.go"); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_delete_import_refs_by_file(s, "test", "caller.go"), CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_import_ref_paths_by_target(s, "test", "test.lib.Renamed", &items, + &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + ASSERT_EQ(cbm_store_delete_symbol_exports_by_file(s, "test", "lib.go"), CBM_STORE_OK); + items = NULL; + count = 0; + ASSERT_EQ(cbm_store_list_symbol_exports_by_file(s, "test", "lib.go", &items, &count), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + store_free_string_array(items, count); + + cbm_store_close(s); + PASS(); +} + /* ── Properties JSON round-trip ─────────────────────────────────── */ TEST(store_node_properties_json) { @@ -1802,6 +1907,7 @@ SUITE(store_nodes) { RUN_TEST(store_file_hash_upsert_rejects_null_required_fields); RUN_TEST(store_file_state_crud); RUN_TEST(store_owner_metadata_crud); + RUN_TEST(store_import_export_metadata_crud); RUN_TEST(store_node_properties_json); RUN_TEST(store_node_null_properties); RUN_TEST(store_find_by_file_overlap);