From 0d8b279e4bef6f69858960dbc8f4723cd724552a Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sun, 5 Jul 2026 00:09:50 -0400 Subject: [PATCH] feat(store): list symbol scope qns Add a bounded store helper that expands imported symbol QNs to exact and dotted-member QNs in project scope, preserving input order and reporting truncation. Use it in the scoped cross-LSP diagnostic so persisted Python defs are discovered from the imported Logger scope instead of a hand-built member list, while proving OtherLogger remains excluded. Validation: make -f Makefile.cbm -j16 build/c/test-runner; CBM_ONLY_SUITE=store_nodes; CBM_ONLY_SUITE=pipeline; focused store/pipeline tests; scripts/check-source-safety.sh; git diff --check. Signed-off-by: Andrew Hundt --- src/store/store.c | 146 +++++++++++++++++++++++++++++++++++++++ src/store/store.h | 9 +++ tests/test_pipeline.c | 65 +++++++++++------ tests/test_store_nodes.c | 88 +++++++++++++++++++++++ 4 files changed, 286 insertions(+), 22 deletions(-) diff --git a/src/store/store.c b/src/store/store.c index 4d0617bd5..2213d486d 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -405,6 +405,18 @@ static int store_append_text(char ***items, int *count, int *cap, const char *te return CBM_STORE_OK; } +static bool store_text_array_contains(char *const *items, int count, const char *text) { + if (!items || !text) { + return false; + } + for (int i = 0; i < count; i++) { + if (items[i] && strcmp(items[i], text) == 0) { + return true; + } + } + return false; +} + static int store_grow_array(cbm_store_t *s, void **items, int *cap, size_t item_size, const char *op, bool zero_new) { if (!items || !cap || item_size == 0 || *cap <= 0 || *cap > INT_MAX / ST_GROWTH) { @@ -2070,6 +2082,140 @@ int cbm_store_find_nodes_by_qns(cbm_store_t *s, const char *project, const char return CBM_STORE_OK; } +int cbm_store_list_symbol_scope_qns_by_qns(cbm_store_t *s, const char *project, + const char **qns, int qn_count, int max_qns, + char ***out, int *count, bool *out_truncated) { + if (!out || !count) { + return CBM_STORE_ERR; + } + *out = NULL; + *count = 0; + if (out_truncated) { + *out_truncated = false; + } + if (!s || !s->db || !project || max_qns <= 0) { + return CBM_STORE_ERR; + } + if (qn_count <= 0) { + return CBM_STORE_OK; + } + if (!qns) { + return CBM_STORE_ERR; + } + + int cap = max_qns < ST_INIT_CAP_8 ? max_qns : ST_INIT_CAP_8; + char **items = malloc((size_t)cap * sizeof(*items)); + if (!items) { + store_set_error(s, "list_symbol_scope_qns out of memory"); + return CBM_STORE_ERR; + } + + int n = 0; + int offset = 0; + bool truncated = false; + const int sqlite_bind_limit = sqlite3_limit(s->db, SQLITE_LIMIT_VARIABLE_NUMBER, -1); + const int qn_bind_limit = + sqlite_bind_limit > SKIP_ONE ? (sqlite_bind_limit - SKIP_ONE) / PAIR_LEN : SKIP_ONE; + static const char prefix[] = "WITH q(ord, qn) AS (VALUES "; + static const char suffix[] = + "), candidates AS (" + " SELECT q.ord," + " CASE WHEN n.qualified_name = q.qn THEN 0 ELSE 1 END AS member_rank," + " n.qualified_name" + " FROM q JOIN nodes n ON n.project = ?" + " AND (n.qualified_name = q.qn" + /* '/' is the byte after '.', so this is a bounded prefix scan for qn + '.'. */ + " OR (n.qualified_name >= q.qn || '.' AND n.qualified_name < q.qn || '/'))" + ") SELECT qualified_name FROM candidates ORDER BY ord, member_rank, qualified_name;"; + + while (offset < qn_count && !truncated) { + char sql[ST_SQL_BUF]; + int pos = snprintf(sql, sizeof(sql), "%s", prefix); + if (pos < 0 || pos >= (int)sizeof(sql)) { + store_free_text_array(items, n); + return CBM_STORE_ERR; + } + + int chunk_end = offset; + int bind_count = 0; + for (; chunk_end < qn_count && bind_count < qn_bind_limit; chunk_end++) { + if (!qns[chunk_end]) { + continue; + } + static const char row_sql[] = "(?,?)"; + int extra = (bind_count > 0 ? 1 : 0) + (int)SLEN(row_sql) + (int)SLEN(suffix); + if (pos + extra + ST_IN_CLAUSE_MARGIN >= (int)sizeof(sql)) { + break; + } + if (bind_count > 0) { + sql[pos++] = ','; + } + memcpy(sql + pos, row_sql, SLEN(row_sql)); + pos += (int)SLEN(row_sql); + bind_count++; + } + if (bind_count == 0) { + offset++; + continue; + } + if (pos + (int)SLEN(suffix) >= (int)sizeof(sql)) { + store_free_text_array(items, n); + return CBM_STORE_ERR; + } + memcpy(sql + pos, suffix, SLEN(suffix) + 1); + + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + store_set_error_sqlite(s, "list_symbol_scope_qns prepare"); + store_free_text_array(items, n); + return CBM_STORE_ERR; + } + + int bind_col = SKIP_ONE; + for (int i = offset; i < chunk_end; i++) { + if (!qns[i]) { + continue; + } + sqlite3_bind_int(stmt, bind_col++, i); + bind_text(stmt, bind_col++, qns[i]); + } + bind_text(stmt, bind_col, project); + + int step_rc = SQLITE_OK; + while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *candidate = (const char *)sqlite3_column_text(stmt, 0); + if (!candidate || store_text_array_contains(items, n, candidate)) { + continue; + } + if (n >= max_qns) { + truncated = true; + break; + } + int rc = store_append_text(&items, &n, &cap, candidate); + if (rc != CBM_STORE_OK) { + sqlite3_finalize(stmt); + store_free_text_array(items, n); + return rc; + } + } + if (step_rc != SQLITE_DONE && !truncated) { + store_set_error_sqlite(s, "list_symbol_scope_qns step"); + sqlite3_finalize(stmt); + store_free_text_array(items, n); + return CBM_STORE_ERR; + } + sqlite3_finalize(stmt); + offset = chunk_end; + } + + *out = items; + *count = n; + if (out_truncated) { + *out_truncated = truncated; + } + return CBM_STORE_OK; +} + static int collect_nodes_from_stmt(cbm_store_t *s, sqlite3_stmt *stmt, const char *op, cbm_node_t **out, int *count) { if (!out || !count) { diff --git a/src/store/store.h b/src/store/store.h index 746336f98..c4d3ab1cc 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -532,6 +532,15 @@ int cbm_store_find_node_ids_by_qns(cbm_store_t *s, const char *project, const ch int cbm_store_find_nodes_by_qns(cbm_store_t *s, const char *project, const char **qns, int qn_count, cbm_node_t **out, int *count); +/* Candidate scope expansion for bounded resolver context. + * Returns exact QNs plus member QNs below each input QN ("Type.member"), + * preserving first input order and skipping duplicates/missing/null inputs. + * max_qns must be positive; out_truncated is optional. Caller frees each + * returned string and the array. */ +int cbm_store_list_symbol_scope_qns_by_qns(cbm_store_t *s, const char *project, + const char **qns, int qn_count, int max_qns, + char ***out, int *count, bool *out_truncated); + /* Count nodes in project. Returns count or CBM_STORE_ERR. */ int cbm_store_count_nodes(cbm_store_t *s, const char *project); int cbm_store_count_nodes_scoped(cbm_store_t *s, const char *project, const char *path); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1cd3e9e59..6049faed1 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -2136,6 +2136,18 @@ static bool pipeline_resolved_call_contains(const CBMResolvedCallArray *arr, return false; } +static bool pipeline_text_array_contains(char *const *items, int count, const char *needle) { + if (!items || !needle) { + return false; + } + for (int i = 0; i < count; i++) { + if (items[i] && strcmp(items[i], needle) == 0) { + return true; + } + } + return false; +} + static void pipeline_restore_workers_env(bool had_workers, const char *saved_workers) { if (had_workers) { cbm_setenv("CBM_WORKERS", saved_workers, 1); @@ -12142,7 +12154,7 @@ TEST(incremental_overlay_python_receiver_type_gap_uses_full_reindex) { } TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp) { - enum { PIPELINE_PERSISTED_DEF_CAP = 3 }; + enum { PIPELINE_PERSISTED_SCOPE_CAP = CBM_SZ_8 }; if (setup_incremental_repo() != 0) { FAIL("setup failed"); } @@ -12209,30 +12221,38 @@ TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp) { cbm_store_t *store = cbm_store_open_path(g_incr_dbpath); ASSERT_NOT_NULL(store); - char *provider_qns[PIPELINE_PERSISTED_DEF_CAP] = { - cbm_pipeline_fqn_compute(project, "provider.py", "Logger"), - cbm_pipeline_fqn_compute(project, "provider.py", "Logger.log"), - cbm_pipeline_fqn_compute(project, "provider.py", "OtherLogger.log"), - }; - for (int i = 0; i < PIPELINE_PERSISTED_DEF_CAP; i++) { - ASSERT_NOT_NULL(provider_qns[i]); - } - const char *provider_qn_view[PIPELINE_PERSISTED_DEF_CAP] = { - provider_qns[0], - provider_qns[1], - provider_qns[2], - }; + char *import_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger"); + char *provider_log_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger.log"); + char *other_log_qn = cbm_pipeline_fqn_compute(project, "provider.py", "OtherLogger.log"); + ASSERT_NOT_NULL(import_qn); + ASSERT_NOT_NULL(provider_log_qn); + ASSERT_NOT_NULL(other_log_qn); + + const char *scope_inputs[] = {import_qn}; + char **candidate_qns = NULL; + int candidate_qn_count = 0; + bool candidate_truncated = true; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + store, project, scope_inputs, CBM_ALLOC_ONE, PIPELINE_PERSISTED_SCOPE_CAP, + &candidate_qns, &candidate_qn_count, &candidate_truncated), + CBM_STORE_OK); + ASSERT_FALSE(candidate_truncated); + ASSERT_EQ(candidate_qn_count, PAIR_LEN); + ASSERT_TRUE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, import_qn)); + ASSERT_TRUE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, provider_log_qn)); + ASSERT_FALSE(pipeline_text_array_contains(candidate_qns, candidate_qn_count, other_log_qn)); + cbm_node_t *provider_nodes = NULL; int provider_node_count = 0; - ASSERT_EQ(cbm_store_find_nodes_by_qns(store, project, provider_qn_view, - PIPELINE_PERSISTED_DEF_CAP, &provider_nodes, + ASSERT_EQ(cbm_store_find_nodes_by_qns(store, project, (const char **)candidate_qns, + candidate_qn_count, &provider_nodes, &provider_node_count), CBM_STORE_OK); - ASSERT_EQ(provider_node_count, PIPELINE_PERSISTED_DEF_CAP); + ASSERT_EQ(provider_node_count, PAIR_LEN); CBMArena persisted_arena; cbm_arena_init(&persisted_arena); - CBMLSPDef persisted_defs[PIPELINE_PERSISTED_DEF_CAP]; + CBMLSPDef persisted_defs[PIPELINE_PERSISTED_SCOPE_CAP]; memset(persisted_defs, 0, sizeof(persisted_defs)); int persisted_count = 0; for (int i = 0; i < provider_node_count; i++) { @@ -12253,9 +12273,7 @@ TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp) { (size_t)persisted_count * sizeof(*all_defs)); char *module_qn = cbm_pipeline_fqn_module(project, "service.py"); - char *import_qn = cbm_pipeline_fqn_compute(project, "provider.py", "Logger"); ASSERT_NOT_NULL(module_qn); - ASSERT_NOT_NULL(import_qn); const char *imp_names[] = {"Logger"}; const char *imp_qns[] = {import_qn}; CBMArena out_arena; @@ -12269,11 +12287,14 @@ TEST(pipeline_persisted_python_defs_feed_scoped_cross_lsp) { cbm_arena_destroy(&out_arena); free(import_qn); + free(provider_log_qn); + free(other_log_qn); free(module_qn); free(all_defs); - for (int i = 0; i < PIPELINE_PERSISTED_DEF_CAP; i++) { - free(provider_qns[i]); + for (int i = 0; i < candidate_qn_count; i++) { + free(candidate_qns[i]); } + free(candidate_qns); cbm_arena_destroy(&persisted_arena); free(own_defs); free(changed_modules[0]); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 829f702f9..f801a7da7 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -5190,6 +5190,93 @@ TEST(store_find_nodes_by_qns_returns_full_rows_in_input_order) { PASS(); } +TEST(store_list_symbol_scope_qns_by_qns_expands_exact_and_members) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cbm_store_upsert_project(s, "test", "/tmp/test") == CBM_STORE_OK); + ASSERT_TRUE(cbm_store_upsert_project(s, "other", "/tmp/other") == CBM_STORE_OK); + + cbm_node_t cls = {.project = "test", + .label = "Class", + .name = "Logger", + .qualified_name = "test.provider.Logger"}; + cbm_node_t debug = {.project = "test", + .label = "Method", + .name = "debug", + .qualified_name = "test.provider.Logger.debug"}; + cbm_node_t log = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.Logger.log"}; + cbm_node_t sibling = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.LoggerExtra.log"}; + cbm_node_t unrelated = {.project = "test", + .label = "Method", + .name = "log", + .qualified_name = "test.provider.OtherLogger.log"}; + cbm_node_t other_project = {.project = "other", + .label = "Method", + .name = "trace", + .qualified_name = "test.provider.Logger.trace"}; + ASSERT_TRUE(cbm_store_upsert_node(s, &cls) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &debug) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &log) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &sibling) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &unrelated) > 0); + ASSERT_TRUE(cbm_store_upsert_node(s, &other_project) > 0); + + const char *scopes[] = {"test.provider.Logger", "missing.Q", NULL, "test.provider.Logger"}; + char **qns = NULL; + int count = 0; + bool truncated = true; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), CBM_SZ_16, &qns, + &count, &truncated), + CBM_STORE_OK); + ASSERT_FALSE(truncated); + ASSERT_EQ(count, 3); + ASSERT_STR_EQ(qns[0], "test.provider.Logger"); + ASSERT_STR_EQ(qns[1], "test.provider.Logger.debug"); + ASSERT_STR_EQ(qns[2], "test.provider.Logger.log"); + for (int i = 0; i < count; i++) { + free(qns[i]); + } + free(qns); + + qns = NULL; + count = 0; + truncated = false; + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), PAIR_LEN, &qns, + &count, &truncated), + CBM_STORE_OK); + ASSERT_TRUE(truncated); + ASSERT_EQ(count, PAIR_LEN); + ASSERT_STR_EQ(qns[0], "test.provider.Logger"); + ASSERT_STR_EQ(qns[1], "test.provider.Logger.debug"); + for (int i = 0; i < count; i++) { + free(qns[i]); + } + free(qns); + + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns( + s, "test", scopes, (int)(sizeof(scopes) / sizeof(scopes[0])), 0, &qns, &count, + NULL), + CBM_STORE_ERR); + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns(s, "test", NULL, 1, CBM_SZ_16, &qns, + &count, NULL), + CBM_STORE_ERR); + ASSERT_EQ(cbm_store_list_symbol_scope_qns_by_qns(s, "test", NULL, 0, CBM_SZ_16, &qns, + &count, NULL), + CBM_STORE_OK); + ASSERT_EQ(count, 0); + + cbm_store_close(s); + PASS(); +} + /* ── Integrity check tests ──────────────────────────────────────── */ TEST(store_integrity_clean) { @@ -5927,6 +6014,7 @@ SUITE(store_nodes) { RUN_TEST(store_pragma_settings); RUN_TEST(store_find_node_ids_by_qns); RUN_TEST(store_find_nodes_by_qns_returns_full_rows_in_input_order); + RUN_TEST(store_list_symbol_scope_qns_by_qns_expands_exact_and_members); RUN_TEST(store_node_null_project); RUN_TEST(store_node_null_qn); RUN_TEST(store_node_empty_strings);