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 <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-05 00:09:50 -04:00
parent dd199278dc
commit 0d8b279e4b
4 changed files with 286 additions and 22 deletions
+146
View File
@@ -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) {
+9
View File
@@ -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);
+43 -22
View File
@@ -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]);
+88
View File
@@ -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);