Files
deusdata--codebase-memory-mcp/tests/test_store_nodes.c
Martin Vogel 43ecc0988c feat(dbt): extract dbt model lineage from Jinja-templated SQL
A dbt model is an ordinary .sql file whose dependencies are written
{{ ref('other_model') }} or {{ source('group','table') }}, never as
literal table names. The SQL grammar cannot read those -- FROM {{ ref(
'x') }} is a parse error to it -- so the dependency structure of an
entire dbt project was invisible to the graph.

extract_dbt.c runs as a sub-extractor inside the normal indexing
pipeline. Per qualifying file it emits one Model definition (named by
the file stem, dbt's own model identity) plus one usage per ref()/
source() call; pass_usages resolves those into model -> relation
lineage edges like any other reference.

Model is a relation label alongside Table and View, which is what makes
the rest work without new plumbing: registry seeding, the central
relation veto, the incremental surface hash, search ranking and the
architecture queries all pick it up from cbm_label_is_relation. Sharing
one label class also means a model's source('raw','customers') resolves
onto a Table declared in a plain DDL migration in the same repository,
so dbt lineage and SQL lineage form one graph rather than two, while
the veto keeps model names out of every non-lineage consumer.

The pass is self-gating: SQL files only, and only those carrying a real
ref()/source() call. The dbt builtins are themselves the evidence, which
is cheaper than a dbt_project.yml lookup and more precise -- templated
SQL that is not dbt (an Airflow {{ ds }} parameter) produces no Model
node and no usages even inside a dbt repository.

{% macro %} definitions are deliberately excluded: the vendored
tree-sitter-jinja2 grammar has no node types for {% %} statements, so
they can only be recovered by a hand-written scanner that cannot see
comments or find {% endmacro %} for a correct span. Filed as follow-up
rather than approximated.

Tests cover the lineage, the last-string-argument semantics of both
builtins, the gate (against a plain-SQL control extraction), plain DDL
staying untouched, and an end-to-end pipeline case asserting model ->
model across files, model -> Table onto plain DDL, and cross-language
isolation. Disabling the pass reddens three of them; removing Model from
the relation set breaks lineage outright.

Implements the lineage half of #575.

Co-authored-by: alexisperinger-ux <alexis.peringer@iss-stoxx.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-20 10:23:48 +02:00

2273 lines
84 KiB
C

/*
* test_store_nodes.c — Tests for store schema, project CRUD, and node CRUD.
*
* Ported from internal/store/store_test.go (TestOpenMemory, TestNodeCRUD,
* TestNodeDedup, TestProjectCRUD, TestUpsertNodeBatch, etc.)
*/
#include "test_framework.h"
#include <store/store.h>
#include <foundation/constants.h>
#include <cbm.h>
#include <sqlite3.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ── Label allowlist / SQL drift guard ──────────────────────────── */
/* CONTRACT PIN. `cbm_label_is_type_like()` is documented in cbm.h as the single
* source of truth for type-like labels, "instead of scattering
* `|| strcmp(label,\"Struct\")==0` across the tree". A SQL string literal cannot
* call it, so four queries in store.c and the BM25 ranking in mcp.c hardcoded
* their own label lists — and silently stopped matching once Struct (Rust, Go,
* Swift, D) began being emitted. get_architecture and vector search dropped
* every struct in the project; search_code under-ranked them.
*
* This pins the SQL mirrors to the C predicate in BOTH directions, so the next
* type-like label fails here instead of quietly shrinking query results. */
TEST(sql_label_allowlists_match_cbm_label_is_type_like) {
/* Every label the C predicate accepts must appear in the SQL fragment. */
static const char *const type_like[] = {"Class", "Struct", "Interface",
"Enum", "Type", "Trait"};
for (size_t i = 0; i < sizeof(type_like) / sizeof(type_like[0]); i++) {
ASSERT_TRUE(cbm_label_is_type_like(type_like[i]));
char quoted[64];
snprintf(quoted, sizeof(quoted), "'%s'", type_like[i]);
ASSERT_NOT_NULL(strstr(CBM_SQL_TYPE_LIKE_LABELS, quoted));
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, quoted));
}
/* And nothing the predicate rejects may be smuggled into the type-like
* fragment — otherwise the SQL would widen past the C contract. */
static const char *const not_type_like[] = {"Function", "Method", "Module",
"File", "Folder", "Variable"};
for (size_t i = 0; i < sizeof(not_type_like) / sizeof(not_type_like[0]); i++) {
ASSERT_FALSE(cbm_label_is_type_like(not_type_like[i]));
char quoted[64];
snprintf(quoted, sizeof(quoted), "'%s'", not_type_like[i]);
ASSERT_NULL(strstr(CBM_SQL_TYPE_LIKE_LABELS, quoted));
}
/* The callable fragment carries exactly Function and Method on top. */
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, "'Function'"));
ASSERT_NOT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, "'Method'"));
PASS();
}
/* Same drift guard for the relation labels (Table/View — SQL data lineage).
* Relations are registry symbols but deliberately NOT type-like: the default
* cbm_registry_resolve vetoes them, so a code identifier sharing a table's
* name never binds into the lineage layer. */
TEST(sql_relation_labels_match_cbm_label_is_relation) {
static const char *const relations[] = {"Table", "View", "Model"};
for (size_t i = 0; i < sizeof(relations) / sizeof(relations[0]); i++) {
ASSERT_TRUE(cbm_label_is_relation(relations[i]));
ASSERT_TRUE(cbm_label_is_registry_symbol(relations[i]));
ASSERT_FALSE(cbm_label_is_type_like(relations[i]));
char quoted[64];
snprintf(quoted, sizeof(quoted), "'%s'", relations[i]);
ASSERT_NOT_NULL(strstr(CBM_SQL_RELATION_LABELS, quoted));
/* Relations must NOT ride in the callable/type fragments — the arch
* queries opt in explicitly by appending CBM_SQL_RELATION_LABELS. */
ASSERT_NULL(strstr(CBM_SQL_CALLABLE_OR_TYPE_LABELS, quoted));
}
/* cbm_label_is_registry_symbol covers exactly the seeded families. */
ASSERT_TRUE(cbm_label_is_registry_symbol("Function"));
ASSERT_TRUE(cbm_label_is_registry_symbol("Method"));
ASSERT_TRUE(cbm_label_is_registry_symbol("Class"));
ASSERT_TRUE(cbm_label_is_registry_symbol("Variable"));
ASSERT_TRUE(cbm_label_is_registry_symbol("Field"));
ASSERT_FALSE(cbm_label_is_registry_symbol("Module"));
ASSERT_FALSE(cbm_label_is_registry_symbol("File"));
ASSERT_FALSE(cbm_label_is_relation("Class"));
ASSERT_FALSE(cbm_label_is_relation(NULL));
PASS();
}
/* ── Schema / Open / Close ──────────────────────────────────────── */
TEST(store_open_memory) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_close(s);
PASS();
}
TEST(store_close_null) {
cbm_store_close(NULL); /* should not crash */
PASS();
}
TEST(store_open_memory_twice) {
cbm_store_t *s1 = cbm_store_open_memory();
cbm_store_t *s2 = cbm_store_open_memory();
ASSERT_NOT_NULL(s1);
ASSERT_NOT_NULL(s2);
/* independent databases */
cbm_store_close(s1);
cbm_store_close(s2);
PASS();
}
/* ── Project CRUD ───────────────────────────────────────────────── */
TEST(store_project_crud) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Create */
int rc = cbm_store_upsert_project(s, "myproject", "/home/user/myproject");
ASSERT_EQ(rc, CBM_STORE_OK);
/* Get */
cbm_project_t p = {0};
rc = cbm_store_get_project(s, "myproject", &p);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(p.name, "myproject");
ASSERT_STR_EQ(p.root_path, "/home/user/myproject");
ASSERT_NOT_NULL(p.indexed_at);
cbm_project_free_fields(&p);
/* List */
cbm_project_t *projects = NULL;
int count = 0;
rc = cbm_store_list_projects(s, &projects, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(projects[0].name, "myproject");
cbm_store_free_projects(projects, count);
/* Get non-existent */
cbm_project_t p2 = {0};
rc = cbm_store_get_project(s, "nonexistent", &p2);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
TEST(store_project_update) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/old/path");
/* Update root path */
cbm_store_upsert_project(s, "test", "/new/path");
cbm_project_t p = {0};
cbm_store_get_project(s, "test", &p);
ASSERT_STR_EQ(p.root_path, "/new/path");
cbm_project_free_fields(&p);
/* Should still be 1 project */
cbm_project_t *projects = NULL;
int count = 0;
cbm_store_list_projects(s, &projects, &count);
ASSERT_EQ(count, 1);
cbm_store_free_projects(projects, count);
cbm_store_close(s);
PASS();
}
TEST(store_project_delete) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
int rc = cbm_store_delete_project(s, "test");
ASSERT_EQ(rc, CBM_STORE_OK);
cbm_project_t p = {0};
rc = cbm_store_get_project(s, "test", &p);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
/* ── Node CRUD ──────────────────────────────────────────────────── */
TEST(store_node_crud) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert node */
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Foo",
.qualified_name = "test.main.Foo",
.file_path = "main.go",
.start_line = 10,
.end_line = 20,
.properties_json = "{\"signature\":\"func Foo(x int) error\"}"};
int64_t id = cbm_store_upsert_node(s, &n);
ASSERT_GT(id, 0);
/* Find by QN */
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn(s, "test", "test.main.Foo", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "Foo");
ASSERT_STR_EQ(found.label, "Function");
ASSERT_STR_EQ(found.file_path, "main.go");
ASSERT_EQ(found.start_line, 10);
ASSERT_EQ(found.end_line, 20);
ASSERT_NOT_NULL(found.properties_json);
cbm_node_free_fields(&found);
/* Find by ID */
cbm_node_t found2 = {0};
rc = cbm_store_find_node_by_id(s, id, &found2);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found2.qualified_name, "test.main.Foo");
cbm_node_free_fields(&found2);
/* Find by name */
cbm_node_t *nodes = NULL;
int count = 0;
rc = cbm_store_find_nodes_by_name(s, "test", "Foo", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "Foo");
cbm_store_free_nodes(nodes, count);
/* Count */
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
cbm_store_close(s);
PASS();
}
TEST(store_node_dedup) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert same QN twice — should update, not duplicate */
cbm_node_t n1 = {
.project = "test", .label = "Function", .name = "Foo", .qualified_name = "test.main.Foo"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "Foo",
.qualified_name = "test.main.Foo",
.properties_json = "{\"updated\":true}"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
/* Verify it was updated */
cbm_node_t found = {0};
cbm_store_find_node_by_qn(s, "test", "test.main.Foo", &found);
ASSERT_NOT_NULL(found.properties_json);
/* Should contain "updated" */
ASSERT(strstr(found.properties_json, "updated") != NULL);
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
TEST(store_node_find_by_label) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t n2 = {.project = "test", .label = "Class", .name = "B", .qualified_name = "test.B"};
cbm_node_t n3 = {
.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_label(s, "test", "Function", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
cbm_store_free_nodes(nodes, count);
rc = cbm_store_find_nodes_by_label(s, "test", "Class", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "B");
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
TEST(store_node_find_by_file) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "A",
.qualified_name = "test.A",
.file_path = "main.go"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "B",
.qualified_name = "test.B",
.file_path = "util.go"};
cbm_node_t n3 = {.project = "test",
.label = "Function",
.name = "C",
.qualified_name = "test.C",
.file_path = "main.go"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_file(s, "test", "main.go", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
TEST(store_node_find_not_found) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn(s, "test", "nonexistent", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
rc = cbm_store_find_node_by_id(s, 99999, &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
TEST(store_node_count_empty) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 0);
cbm_store_close(s);
PASS();
}
TEST(store_node_delete_by_file) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "A",
.qualified_name = "test.A",
.file_path = "main.go"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "B",
.qualified_name = "test.B",
.file_path = "util.go"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_delete_nodes_by_file(s, "test", "main.go");
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
cbm_store_close(s);
PASS();
}
TEST(store_node_delete_by_label) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t n2 = {.project = "test", .label = "Class", .name = "B", .qualified_name = "test.B"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_delete_nodes_by_label(s, "test", "Function");
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
cbm_store_close(s);
PASS();
}
/* ── Batch operations ───────────────────────────────────────────── */
TEST(store_node_batch_upsert) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Create 150 nodes */
cbm_node_t nodes[150];
int64_t ids[150];
char names[150][32];
char qns[150][64];
for (int i = 0; i < 150; i++) {
snprintf(names[i], sizeof(names[i]), "func_%d", i);
snprintf(qns[i], sizeof(qns[i]), "test.pkg.func_%d", i);
nodes[i] = (cbm_node_t){
.project = "test",
.label = "Function",
.name = names[i],
.qualified_name = qns[i],
.file_path = "pkg.go",
.start_line = i * 10,
.end_line = i * 10 + 9,
};
}
int rc = cbm_store_upsert_node_batch(s, nodes, 150, ids);
ASSERT_EQ(rc, CBM_STORE_OK);
/* Verify all IDs are non-zero */
for (int i = 0; i < 150; i++) {
ASSERT_GT(ids[i], 0);
}
/* Verify count */
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 150);
/* Re-upsert should not duplicate */
int64_t ids2[150];
rc = cbm_store_upsert_node_batch(s, nodes, 150, ids2);
ASSERT_EQ(rc, CBM_STORE_OK);
cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 150);
/* IDs should be the same */
for (int i = 0; i < 150; i++) {
ASSERT_EQ(ids[i], ids2[i]);
}
cbm_store_close(s);
PASS();
}
TEST(store_node_batch_empty) {
cbm_store_t *s = cbm_store_open_memory();
int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL);
ASSERT_EQ(rc, CBM_STORE_OK);
cbm_store_close(s);
PASS();
}
/* ── Cascade delete ─────────────────────────────────────────────── */
TEST(store_cascade_delete) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Create nodes and an edge */
cbm_node_t n1 = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t n2 = {
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
int64_t id1 = cbm_store_upsert_node(s, &n1);
int64_t id2 = cbm_store_upsert_node(s, &n2);
cbm_edge_t e = {.project = "test", .source_id = id1, .target_id = id2, .type = "CALLS"};
cbm_store_insert_edge(s, &e);
/* Delete project — should cascade */
cbm_store_delete_project(s, "test");
int ncnt = cbm_store_count_nodes(s, "test");
int ecnt = cbm_store_count_edges(s, "test");
ASSERT_EQ(ncnt, 0);
ASSERT_EQ(ecnt, 0);
cbm_store_close(s);
PASS();
}
/* ── File hashes ────────────────────────────────────────────────── */
TEST(store_file_hash_crud) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Upsert */
int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512);
ASSERT_EQ(rc, CBM_STORE_OK);
/* Get */
cbm_file_hash_t *hashes = NULL;
int count = 0;
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(hashes[0].rel_path, "main.go");
ASSERT_STR_EQ(hashes[0].sha256, "abc123");
ASSERT_EQ(hashes[0].mtime_ns, 1000000);
ASSERT_EQ(hashes[0].size, 512);
cbm_store_free_file_hashes(hashes, count);
/* Update */
rc = cbm_store_upsert_file_hash(s, "test", "main.go", "def456", 2000000, 1024);
ASSERT_EQ(rc, CBM_STORE_OK);
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(hashes[0].sha256, "def456");
ASSERT_EQ(hashes[0].mtime_ns, 2000000);
cbm_store_free_file_hashes(hashes, count);
/* Delete single */
rc = cbm_store_delete_file_hash(s, "test", "main.go");
ASSERT_EQ(rc, CBM_STORE_OK);
rc = cbm_store_get_file_hashes(s, "test", &hashes, &count);
ASSERT_EQ(count, 0);
cbm_store_free_file_hashes(hashes, count);
cbm_store_close(s);
PASS();
}
TEST(store_lsp_surface_round_trip) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Empty project: OK + zero rows — the "no surface data, route to full
* rebuild" signal, and the upgrade path for pre-table databases. */
cbm_lsp_surface_row_t *rows = NULL;
int count = CBM_NOT_FOUND;
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_lsp_surfaces(rows, count);
/* Batch with the field shapes the codec will produce: a real defs array,
* an empty surface, a binary bloom (with embedded NUL), and no bloom. */
const unsigned char bloom[] = {0x01, 0x00, 0xfe, 0x7f, 0x00, 0xab};
cbm_lsp_surface_row_t in[] = {
{.project = "test",
.rel_path = "pkg/a.go",
.surface_sha = "sha-a",
.defs_json = "[{\"qn\":\"pkg.A\",\"sn\":\"A\",\"label\":\"Function\"}]",
.ref_bloom = bloom,
.ref_bloom_len = (int)sizeof(bloom),
.config_ctx = "cfg-1"},
{.project = "test",
.rel_path = "pkg/b.go",
.surface_sha = "sha-b",
.defs_json = "[]",
.ref_bloom = NULL,
.ref_bloom_len = 0,
.config_ctx = ""},
};
ASSERT_EQ(cbm_store_upsert_lsp_surface_batch(s, in, 2), CBM_STORE_OK);
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 2);
/* ORDER BY rel_path: a.go before b.go. */
ASSERT_STR_EQ(rows[0].rel_path, "pkg/a.go");
ASSERT_STR_EQ(rows[0].surface_sha, "sha-a");
ASSERT_STR_EQ(rows[0].defs_json, "[{\"qn\":\"pkg.A\",\"sn\":\"A\",\"label\":\"Function\"}]");
ASSERT_STR_EQ(rows[0].config_ctx, "cfg-1");
ASSERT_EQ(rows[0].ref_bloom_len, (int)sizeof(bloom));
ASSERT_TRUE(rows[0].ref_bloom != NULL && memcmp(rows[0].ref_bloom, bloom, sizeof(bloom)) == 0);
ASSERT_STR_EQ(rows[1].rel_path, "pkg/b.go");
ASSERT_STR_EQ(rows[1].defs_json, "[]");
ASSERT_EQ(rows[1].ref_bloom_len, 0);
ASSERT_TRUE(rows[1].ref_bloom == NULL);
cbm_store_free_lsp_surfaces(rows, count);
/* Upsert-on-conflict replaces the whole row, including dropping a bloom. */
cbm_lsp_surface_row_t update = {.project = "test",
.rel_path = "pkg/a.go",
.surface_sha = "sha-a2",
.defs_json = "[]",
.ref_bloom = NULL,
.ref_bloom_len = 0,
.config_ctx = ""};
ASSERT_EQ(cbm_store_upsert_lsp_surface_batch(s, &update, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_STR_EQ(rows[0].surface_sha, "sha-a2");
ASSERT_EQ(rows[0].ref_bloom_len, 0);
cbm_store_free_lsp_surfaces(rows, count);
/* Project-scoped delete removes everything. */
ASSERT_EQ(cbm_store_delete_lsp_surfaces(s, "test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_get_lsp_surfaces(s, "test", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_lsp_surfaces(rows, count);
cbm_store_close(s);
PASS();
}
TEST(store_dependent_files_lookup) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* a.py and c.py each call into b.py; b.py also references itself, and
* d.py sits apart. Dependents of {b.py} must be exactly {a.py, c.py}:
* the self-reference is not a dependent, d.py is unrelated. */
const char *files[] = {"a.py", "b.py", "c.py", "d.py"};
int64_t ids[4] = {0};
for (int i = 0; i < 4; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "test.%s.fn", files[i]);
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "fn",
.qualified_name = qn,
.file_path = files[i],
.start_line = 1,
.end_line = 2,
.properties_json = "{}"};
ids[i] = cbm_store_upsert_node(s, &n);
ASSERT_TRUE(ids[i] > 0);
}
const int64_t edge_pairs[][2] = {{ids[0], ids[1]}, {ids[2], ids[1]}, {ids[1], ids[1]}};
for (int i = 0; i < 3; i++) {
cbm_edge_t e = {.project = "test",
.source_id = edge_pairs[i][0],
.target_id = edge_pairs[i][1],
.type = "CALLS",
.properties_json = "{}"};
ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0);
}
/* Structural container noise: a Folder node (placeholder file_path)
* with a CONTAINS_FILE edge into b.py must never surface as a
* dependent — it is not a source file and cannot be re-resolved. */
cbm_node_t folder = {.project = "test",
.label = "Folder",
.name = "pkg",
.qualified_name = "test.pkg",
.file_path = "{}",
.start_line = 0,
.end_line = 0,
.properties_json = "{}"};
int64_t folder_id = cbm_store_upsert_node(s, &folder);
ASSERT_TRUE(folder_id > 0);
cbm_edge_t contains = {.project = "test",
.source_id = folder_id,
.target_id = ids[1],
.type = "CONTAINS_FILE",
.properties_json = "{}"};
ASSERT_TRUE(cbm_store_insert_edge(s, &contains) > 0);
const char *targets[] = {"b.py"};
char **deps = NULL;
int dep_count = 0;
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets, 1, &deps, &dep_count),
CBM_STORE_OK);
ASSERT_EQ(dep_count, 2);
bool saw_a = false;
bool saw_c = false;
for (int i = 0; i < dep_count; i++) {
saw_a = saw_a || strcmp(deps[i], "a.py") == 0;
saw_c = saw_c || strcmp(deps[i], "c.py") == 0;
}
ASSERT_TRUE(saw_a && saw_c);
cbm_store_free_dependent_files(deps, dep_count);
/* No inbound edges: an isolated target has no dependents. */
const char *targets_d[] = {"d.py"};
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets_d, 1, &deps, &dep_count),
CBM_STORE_OK);
ASSERT_EQ(dep_count, 0);
cbm_store_free_dependent_files(deps, dep_count);
/* A target list already containing the dependent excludes it: asking for
* dependents of {a.py, b.py} returns only c.py. */
const char *targets_ab[] = {"a.py", "b.py"};
ASSERT_EQ(cbm_store_get_dependent_files(s, "test", targets_ab, 2, &deps, &dep_count),
CBM_STORE_OK);
ASSERT_EQ(dep_count, 1);
ASSERT_STR_EQ(deps[0], "c.py");
cbm_store_free_dependent_files(deps, dep_count);
cbm_store_close(s);
PASS();
}
TEST(store_file_hash_upsert_rejects_null_required_fields) {
/* Pins the API contract that `cbm_store_upsert_file_hash` returns
* CBM_STORE_ERR (not silent OK) when a NOT NULL column would receive
* SQL NULL. This is the failure mode that
* `pipeline_incremental.c:persist_hashes` checks for and logs as
* `incremental.persist_hash_failed`. If this contract ever changes
* (e.g. the schema relaxes NOT NULL on rel_path or sha256), the
* downstream warning becomes silent and the orphaned-node bug class
* can re-emerge. Track that change here, not just in the consumer. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Sanity: a fully-valid upsert returns OK. */
int rc = cbm_store_upsert_file_hash(s, "test", "main.go", "abc123", 1000000, 512);
ASSERT_EQ(rc, CBM_STORE_OK);
/* NULL sha256 violates NOT NULL on file_hashes.sha256 → must return ERR. */
rc = cbm_store_upsert_file_hash(s, "test", "other.go", NULL, 2000000, 1024);
ASSERT_EQ(rc, CBM_STORE_ERR);
/* NULL rel_path violates NOT NULL on file_hashes.rel_path → must return ERR. */
rc = cbm_store_upsert_file_hash(s, "test", NULL, "deadbeef", 3000000, 2048);
ASSERT_EQ(rc, CBM_STORE_ERR);
/* NULL project violates NOT NULL on file_hashes.project → must return ERR. */
rc = cbm_store_upsert_file_hash(s, NULL, "third.go", "cafebabe", 4000000, 4096);
ASSERT_EQ(rc, CBM_STORE_ERR);
/* The valid row from earlier must still be present — partial-failure
* policy: a single bad upsert does not corrupt or remove other rows. */
cbm_file_hash_t *hashes = NULL;
int count = 0;
cbm_store_get_file_hashes(s, "test", &hashes, &count);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(hashes[0].rel_path, "main.go");
cbm_store_free_file_hashes(hashes, count);
cbm_store_close(s);
PASS();
}
/* ── Properties JSON round-trip ─────────────────────────────────── */
TEST(store_node_properties_json) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Bar",
.qualified_name = "test.Bar",
.properties_json = "{\"visibility\":\"public\",\"is_entry_point\":true}"};
cbm_store_upsert_node(s, &n);
cbm_node_t found = {0};
cbm_store_find_node_by_qn(s, "test", "test.Bar", &found);
ASSERT_NOT_NULL(found.properties_json);
ASSERT(strstr(found.properties_json, "visibility") != NULL);
ASSERT(strstr(found.properties_json, "public") != NULL);
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
TEST(store_node_null_properties) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* NULL properties should default to "{}" */
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Baz",
.qualified_name = "test.Baz",
.properties_json = NULL};
cbm_store_upsert_node(s, &n);
cbm_node_t found = {0};
cbm_store_find_node_by_qn(s, "test", "test.Baz", &found);
ASSERT_NOT_NULL(found.properties_json);
ASSERT_STR_EQ(found.properties_json, "{}");
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── File overlap ───────────────────────────────────────────────── */
TEST(store_find_by_file_overlap) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t na = {.project = "test",
.label = "Function",
.name = "funcA",
.qualified_name = "test.main.funcA",
.file_path = "main.go",
.start_line = 1,
.end_line = 10};
cbm_node_t nb = {.project = "test",
.label = "Function",
.name = "funcB",
.qualified_name = "test.main.funcB",
.file_path = "main.go",
.start_line = 12,
.end_line = 25};
cbm_node_t nc = {.project = "test",
.label = "Function",
.name = "funcC",
.qualified_name = "test.main.funcC",
.file_path = "other.go",
.start_line = 1,
.end_line = 50};
/* Module node should be excluded from overlap results */
cbm_node_t nm = {.project = "test",
.label = "Module",
.name = "main",
.qualified_name = "test.main",
.file_path = "main.go",
.start_line = 1,
.end_line = 100};
cbm_store_upsert_node(s, &na);
cbm_store_upsert_node(s, &nb);
cbm_store_upsert_node(s, &nc);
cbm_store_upsert_node(s, &nm);
/* Overlap with funcA (lines 5-8) */
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 5, 8, &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "funcA");
cbm_store_free_nodes(nodes, count);
/* Overlap spanning funcA and funcB (lines 8-15) */
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 8, 15, &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
cbm_store_free_nodes(nodes, count);
/* No overlap (lines 26-30) */
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "main.go", 26, 30, &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_nodes(nodes, count);
/* Different file */
rc = cbm_store_find_nodes_by_file_overlap(s, "test", "other.go", 1, 50, &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "funcC");
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
/* ── QN suffix ─────────────────────────────────────────────────── */
TEST(store_find_by_qn_suffix_single) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "HandleRequest",
.qualified_name = "test.cmd.server.main.HandleRequest"};
cbm_store_upsert_node(s, &n);
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "main.HandleRequest", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "HandleRequest");
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
TEST(store_find_by_qn_suffix_no_match) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {
.project = "test", .label = "Function", .name = "Foo", .qualified_name = "test.main.Foo"};
cbm_store_upsert_node(s, &n);
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "main.Bar", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
TEST(store_find_by_qn_suffix_multiple) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "Run",
.qualified_name = "test.cmd.server.Run"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "Run",
.qualified_name = "test.cmd.worker.Run"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "Run", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
TEST(store_find_by_qn_suffix_dot_boundary) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "HandleRequest",
.qualified_name = "test.main.HandleRequest"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "MyHandleRequestHelper",
.qualified_name = "test.main.MyHandleRequestHelper"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
/* Should only match the one with ".HandleRequest" suffix, not partial word */
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_qn_suffix(s, "test", "HandleRequest", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(nodes[0].name, "HandleRequest");
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
/* ── Node degree ───────────────────────────────────────────────── */
TEST(store_node_degree) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t na = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t nb = {
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
cbm_node_t nc = {
.project = "test", .label = "Function", .name = "C", .qualified_name = "test.C"};
int64_t idA = cbm_store_upsert_node(s, &na);
int64_t idB = cbm_store_upsert_node(s, &nb);
int64_t idC = cbm_store_upsert_node(s, &nc);
/* A->B (CALLS), A->C (CALLS), B->C (CALLS), A->C (USAGE — not counted) */
cbm_edge_t e1 = {.project = "test", .source_id = idA, .target_id = idB, .type = "CALLS"};
cbm_edge_t e2 = {.project = "test", .source_id = idA, .target_id = idC, .type = "CALLS"};
cbm_edge_t e3 = {.project = "test", .source_id = idB, .target_id = idC, .type = "CALLS"};
cbm_edge_t e4 = {.project = "test", .source_id = idA, .target_id = idC, .type = "USAGE"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
cbm_store_insert_edge(s, &e3);
cbm_store_insert_edge(s, &e4);
int inA, outA, inB, outB, inC, outC;
cbm_store_node_degree(s, idA, &inA, &outA);
ASSERT_EQ(inA, 0);
ASSERT_EQ(outA, 2);
cbm_store_node_degree(s, idB, &inB, &outB);
ASSERT_EQ(inB, 1);
ASSERT_EQ(outB, 1);
cbm_store_node_degree(s, idC, &inC, &outC);
ASSERT_EQ(inC, 2);
ASSERT_EQ(outC, 0);
cbm_store_close(s);
PASS();
}
/* ── File hash batch ───────────────────────────────────────────── */
TEST(store_file_hash_batch) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_file_hash_t hashes[3] = {
{.project = "test", .rel_path = "a.go", .sha256 = "h1", .mtime_ns = 1000, .size = 100},
{.project = "test", .rel_path = "b.go", .sha256 = "h2", .mtime_ns = 2000, .size = 200},
{.project = "test", .rel_path = "c.go", .sha256 = "h3", .mtime_ns = 3000, .size = 300},
};
int rc = cbm_store_upsert_file_hash_batch(s, hashes, 3);
ASSERT_EQ(rc, CBM_STORE_OK);
cbm_file_hash_t *stored = NULL;
int count = 0;
rc = cbm_store_get_file_hashes(s, "test", &stored, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 3);
cbm_store_free_file_hashes(stored, count);
/* Update hashes (should not duplicate) */
hashes[0].sha256 = "updated";
hashes[0].mtime_ns = 9000;
rc = cbm_store_upsert_file_hash_batch(s, hashes, 3);
ASSERT_EQ(rc, CBM_STORE_OK);
rc = cbm_store_get_file_hashes(s, "test", &stored, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 3);
/* Verify updated value */
int found = 0;
for (int i = 0; i < count; i++) {
if (strcmp(stored[i].rel_path, "a.go") == 0) {
ASSERT_STR_EQ(stored[i].sha256, "updated");
ASSERT_EQ(stored[i].mtime_ns, 9000);
found = 1;
}
}
ASSERT_TRUE(found);
cbm_store_free_file_hashes(stored, count);
cbm_store_close(s);
PASS();
}
/* Guard for the persist-tail switch (pipeline.c dump_and_persist_hashes) from a
* per-file cbm_store_upsert_file_hash loop to a single cbm_store_upsert_file_hash_batch:
* both paths must yield IDENTICAL file_hashes rows (same tuples, same upsert/replace
* semantics — only the transaction boundary differs). Uses sha256="" exactly as the
* persist path does. */
TEST(store_file_hash_batch_equals_loop) {
cbm_file_hash_t rows[4] = {
{.project = "p", .rel_path = "a.c", .sha256 = "", .mtime_ns = 111, .size = 10},
{.project = "p", .rel_path = "b/c.c", .sha256 = "", .mtime_ns = 222, .size = 20},
{.project = "p", .rel_path = "d.rs", .sha256 = "", .mtime_ns = 333, .size = 30},
{.project = "p", .rel_path = "e.py", .sha256 = "", .mtime_ns = 444, .size = 40},
};
/* Store A: the original per-file loop. */
cbm_store_t *a = cbm_store_open_memory();
cbm_store_upsert_project(a, "p", "/tmp/p");
for (int i = 0; i < 4; i++) {
ASSERT_EQ(cbm_store_upsert_file_hash(a, rows[i].project, rows[i].rel_path, rows[i].sha256,
rows[i].mtime_ns, rows[i].size),
CBM_STORE_OK);
}
/* Store B: the batched path. */
cbm_store_t *b = cbm_store_open_memory();
cbm_store_upsert_project(b, "p", "/tmp/p");
ASSERT_EQ(cbm_store_upsert_file_hash_batch(b, rows, 4), CBM_STORE_OK);
cbm_file_hash_t *ha = NULL, *hb = NULL;
int ca = 0, cb = 0;
ASSERT_EQ(cbm_store_get_file_hashes(a, "p", &ha, &ca), CBM_STORE_OK);
ASSERT_EQ(cbm_store_get_file_hashes(b, "p", &hb, &cb), CBM_STORE_OK);
ASSERT_EQ(ca, 4);
ASSERT_EQ(cb, 4);
/* Compare as sets (get_file_hashes has no ORDER BY). */
for (int i = 0; i < ca; i++) {
int found = 0;
for (int j = 0; j < cb; j++) {
if (strcmp(ha[i].rel_path, hb[j].rel_path) == 0) {
ASSERT_STR_EQ(ha[i].sha256, hb[j].sha256);
ASSERT_EQ(ha[i].mtime_ns, hb[j].mtime_ns);
ASSERT_EQ(ha[i].size, hb[j].size);
found = 1;
break;
}
}
ASSERT_TRUE(found);
}
cbm_store_free_file_hashes(ha, ca);
cbm_store_free_file_hashes(hb, cb);
cbm_store_close(a);
cbm_store_close(b);
PASS();
}
/* ── Find edges by URL path ────────────────────────────────────── */
TEST(store_find_edges_by_url_path) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t ns = {
.project = "test", .label = "Function", .name = "caller", .qualified_name = "test.caller"};
cbm_node_t nt = {.project = "test",
.label = "Function",
.name = "handler",
.qualified_name = "test.handler"};
int64_t srcID = cbm_store_upsert_node(s, &ns);
int64_t tgtID = cbm_store_upsert_node(s, &nt);
cbm_edge_t e = {.project = "test",
.source_id = srcID,
.target_id = tgtID,
.type = "HTTP_CALLS",
.properties_json = "{\"url_path\":\"/api/orders/create\",\"confidence\":0.8}"};
cbm_store_insert_edge(s, &e);
/* Search for edges containing "orders" */
cbm_edge_t *edges = NULL;
int count = 0;
int rc = cbm_store_find_edges_by_url_path(s, "test", "orders", &edges, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT(strstr(edges[0].properties_json, "/api/orders/create") != NULL);
cbm_store_free_edges(edges, count);
/* Search for non-matching */
rc = cbm_store_find_edges_by_url_path(s, "test", "users", &edges, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_edges(edges, count);
cbm_store_close(s);
PASS();
}
/* ── Restore from ──────────────────────────────────────────────── */
TEST(store_restore_from) {
/* Create in-memory source store with data */
cbm_store_t *src = cbm_store_open_memory();
cbm_store_upsert_project(src, "test", "/tmp/test");
for (int i = 0; i < 10; i++) {
char name[32], qn[64];
snprintf(name, sizeof(name), "Func%d", i);
snprintf(qn, sizeof(qn), "test.main.Func%d", i);
cbm_node_t n = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "main.go",
.start_line = i * 10,
.end_line = i * 10 + 5};
cbm_store_upsert_node(src, &n);
}
/* Create destination store */
cbm_store_t *dst = cbm_store_open_memory();
/* Restore: copy from src → dst */
int rc = cbm_store_restore_from(dst, src);
ASSERT_EQ(rc, CBM_STORE_OK);
/* Verify data survived */
cbm_node_t found = {0};
rc = cbm_store_find_node_by_qn(dst, "test", "test.main.Func5", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "Func5");
cbm_node_free_fields(&found);
int cnt = cbm_store_count_nodes(dst, "test");
ASSERT_EQ(cnt, 10);
cbm_store_close(src);
cbm_store_close(dst);
PASS();
}
/* ── Pragma settings ───────────────────────────────────────────── */
TEST(store_pragma_settings) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Just verify we can open and the store works — pragma settings
* are verified by the fact that the store functions correctly. */
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {
.project = "test", .label = "Function", .name = "X", .qualified_name = "test.X"};
int64_t id = cbm_store_upsert_node(s, &n);
ASSERT_TRUE(id > 0);
cbm_store_close(s);
PASS();
}
TEST(store_find_node_ids_by_qns) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert two nodes */
cbm_node_t na = {
.project = "test", .label = "Function", .name = "A", .qualified_name = "test.A"};
cbm_node_t nb = {
.project = "test", .label = "Function", .name = "B", .qualified_name = "test.B"};
int64_t id1 = cbm_store_upsert_node(s, &na);
int64_t id2 = cbm_store_upsert_node(s, &nb);
ASSERT_TRUE(id1 > 0);
ASSERT_TRUE(id2 > 0);
/* Batch lookup: 2 found + 1 missing */
const char *qns[] = {"test.A", "test.B", "test.missing"};
int64_t ids[3];
int found = cbm_store_find_node_ids_by_qns(s, "test", qns, 3, ids);
ASSERT_EQ(found, 2);
ASSERT_EQ(ids[0], id1);
ASSERT_EQ(ids[1], id2);
ASSERT_EQ(ids[2], 0); /* missing → 0 */
/* Empty batch */
int found2 = cbm_store_find_node_ids_by_qns(s, "test", NULL, 0, ids);
ASSERT_EQ(found2, 0);
cbm_store_close(s);
PASS();
}
/* ── Integrity check tests ──────────────────────────────────────── */
TEST(store_integrity_clean) {
/* A fresh store with correct data should pass integrity check */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "test-proj", "/tmp/test");
ASSERT_TRUE(cbm_store_check_integrity(s));
cbm_store_close(s);
PASS();
}
TEST(store_integrity_empty) {
/* An empty store (no project rows) should pass — 0 rows is fine */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_TRUE(cbm_store_check_integrity(s));
cbm_store_close(s);
PASS();
}
TEST(store_integrity_corrupt_bad_path) {
/* Simulate corruption: root_path is a numeric string (not a real path).
* This matches the real corruption where node IDs ended up in root_path. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
sqlite3 *db = cbm_store_get_db(s);
sqlite3_exec(db,
"INSERT INTO projects (name, indexed_at, root_path) "
"VALUES ('some-project', '2024-01-01', '826');",
NULL, NULL, NULL);
ASSERT_FALSE(cbm_store_check_integrity(s));
cbm_store_close(s);
PASS();
}
TEST(store_integrity_windows_lowercase_drive_issue367) {
/* Windows drive letters may be lower- or upper-case; a lowercase drive
* path must NOT be treated as corrupt. Previously the check only accepted
* 'A'..'Z', so "c:/repo" was flagged and the DB auto-deleted (#227/#367). */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "lc-drive", "c:/Users/dev/repo");
ASSERT_TRUE(cbm_store_check_integrity(s));
cbm_store_close(s);
PASS();
}
TEST(store_integrity_corrupt_too_many_rows) {
/* Simulate corruption: >5 rows in projects table */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
sqlite3 *db = cbm_store_get_db(s);
for (int i = 0; i < 10; i++) {
char sql[256];
snprintf(sql, sizeof(sql),
"INSERT INTO projects (name, indexed_at, root_path) "
"VALUES ('proj-%d', '2024-01-01', '/tmp/%d');",
i, i);
sqlite3_exec(db, sql, NULL, NULL, NULL);
}
ASSERT_FALSE(cbm_store_check_integrity(s));
cbm_store_close(s);
PASS();
}
/* ── Quarantine verdict (#1206, #1037) ──────────────────────────────
*
* The bool check above cannot answer the only question the quarantine path
* actually asks: "is this database damaged, or did I just lose a lock race?"
* Answering "damaged" to the second question is what made concurrent instances
* rename each other's HEALTHY databases to .corrupt (#1206). These bind the
* three-way verdict so that behaviour cannot come back. */
TEST(store_integrity_verdict_healthy_is_ok) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "healthy-proj", "/tmp/healthy");
ASSERT_EQ(cbm_store_check_integrity_verdict(s), CBM_INTEGRITY_OK);
cbm_store_close(s);
PASS();
}
TEST(store_integrity_verdict_real_corruption_is_corrupt) {
/* Structural damage the shallow check can see: node IDs landing in
* root_path. This one MUST be quarantinable — a verdict that never says
* CORRUPT would protect broken databases instead of users. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
sqlite3 *db = cbm_store_get_db(s);
sqlite3_exec(db,
"INSERT INTO projects (name, indexed_at, root_path) "
"VALUES ('broken', '2024-01-01', '826');",
NULL, NULL, NULL);
ASSERT_EQ(cbm_store_check_integrity_verdict(s), CBM_INTEGRITY_CORRUPT);
cbm_store_close(s);
PASS();
}
TEST(store_integrity_verdict_unopenable_is_transient_not_corrupt) {
/* A handle we could not open tells us NOTHING about the file's contents.
* Reporting CORRUPT here is how a database nobody could read got renamed
* and rebuilt from scratch (#1206) — the destructive answer to a question
* that was never asked. */
ASSERT_EQ(cbm_store_check_integrity_verdict(NULL), CBM_INTEGRITY_TRANSIENT);
PASS();
}
TEST(store_integrity_null_check) {
/* NULL store should return false (not crash) */
ASSERT_FALSE(cbm_store_check_integrity(NULL));
PASS();
}
/* ── Edge case: NULL / empty field handling ────────────────────── */
TEST(store_node_null_project) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
/* Upsert with NULL project — should fail gracefully */
cbm_node_t n = {
.project = NULL, .label = "Function", .name = "Foo", .qualified_name = "null.Foo"};
int64_t id = cbm_store_upsert_node(s, &n);
/* Either returns error or silently succeeds; must not crash */
(void)id;
cbm_store_close(s);
PASS();
}
TEST(store_node_null_qn) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Upsert with NULL qualified_name */
cbm_node_t n = {.project = "test", .label = "Function", .name = "Bar", .qualified_name = NULL};
int64_t id = cbm_store_upsert_node(s, &n);
/* Must not crash regardless of return value */
(void)id;
cbm_store_close(s);
PASS();
}
TEST(store_node_empty_strings) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Upsert with all fields as empty strings */
cbm_node_t n = {.project = "test",
.label = "",
.name = "",
.qualified_name = "",
.file_path = "",
.start_line = 0,
.end_line = 0,
.properties_json = ""};
int64_t id = cbm_store_upsert_node(s, &n);
/* Should succeed — empty strings are valid */
ASSERT_GT(id, 0);
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn(s, "test", "", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "");
ASSERT_STR_EQ(found.label, "");
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── Edge case: not-found lookups ──────────────────────────────── */
TEST(store_find_by_id_not_found) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_id(s, 999999, &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
/* Negative ID should also be not found */
rc = cbm_store_find_node_by_id(s, -1, &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
TEST(store_find_by_qn_not_found) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert a node so the store is non-empty */
cbm_node_t n = {
.project = "test", .label = "Function", .name = "Exists", .qualified_name = "test.Exists"};
cbm_store_upsert_node(s, &n);
/* Search for a non-existent QN */
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn(s, "test", "test.DoesNotExist", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
/* Wrong project */
rc = cbm_store_find_node_by_qn(s, "other-project", "test.Exists", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
/* ── Edge case: cross-project lookups ──────────────────────────── */
TEST(store_find_by_qn_any_cross_project) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "proj-a", "/tmp/a");
cbm_store_upsert_project(s, "proj-b", "/tmp/b");
cbm_node_t na = {.project = "proj-a",
.label = "Function",
.name = "SharedFunc",
.qualified_name = "proj-a.main.SharedFunc"};
cbm_node_t nb = {.project = "proj-b",
.label = "Class",
.name = "Widget",
.qualified_name = "proj-b.pkg.Widget"};
cbm_store_upsert_node(s, &na);
cbm_store_upsert_node(s, &nb);
/* find_node_by_qn_any finds without project filter */
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn_any(s, "proj-a.main.SharedFunc", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "SharedFunc");
ASSERT_STR_EQ(found.project, "proj-a");
cbm_node_free_fields(&found);
rc = cbm_store_find_node_by_qn_any(s, "proj-b.pkg.Widget", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "Widget");
ASSERT_STR_EQ(found.project, "proj-b");
cbm_node_free_fields(&found);
/* Non-existent QN */
rc = cbm_store_find_node_by_qn_any(s, "nonexistent.Nope", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
TEST(store_find_by_name_any_cross_project) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "proj-a", "/tmp/a");
cbm_store_upsert_project(s, "proj-b", "/tmp/b");
/* Same name in two projects */
cbm_node_t na = {.project = "proj-a",
.label = "Function",
.name = "Init",
.qualified_name = "proj-a.main.Init"};
cbm_node_t nb = {.project = "proj-b",
.label = "Function",
.name = "Init",
.qualified_name = "proj-b.main.Init"};
cbm_node_t nc = {.project = "proj-b",
.label = "Function",
.name = "Other",
.qualified_name = "proj-b.main.Other"};
cbm_store_upsert_node(s, &na);
cbm_store_upsert_node(s, &nb);
cbm_store_upsert_node(s, &nc);
/* find_nodes_by_name_any should find both "Init" across projects */
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_name_any(s, "Init", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
cbm_store_free_nodes(nodes, count);
/* Name that doesn't exist */
rc = cbm_store_find_nodes_by_name_any(s, "Nonexistent", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
/* ── Edge case: empty result sets ──────────────────────────────── */
TEST(store_find_by_file_no_match) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Foo",
.qualified_name = "test.Foo",
.file_path = "main.go"};
cbm_store_upsert_node(s, &n);
/* Search for a file that has no nodes */
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_file(s, "test", "nonexistent.go", &nodes, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_nodes(nodes, count);
cbm_store_close(s);
PASS();
}
/* ── Edge case: batch upsert boundary ─────────────────────────── */
TEST(store_node_batch_upsert_zero) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Batch upsert with count=0 should succeed, do nothing */
int rc = cbm_store_upsert_node_batch(s, NULL, 0, NULL);
ASSERT_EQ(rc, CBM_STORE_OK);
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 0);
cbm_store_close(s);
PASS();
}
TEST(store_node_batch_upsert_100) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t nodes[100];
int64_t ids[100];
char names[100][32];
char qns[100][64];
for (int i = 0; i < 100; i++) {
snprintf(names[i], sizeof(names[i]), "stress_%d", i);
snprintf(qns[i], sizeof(qns[i]), "test.stress.stress_%d", i);
nodes[i] = (cbm_node_t){.project = "test",
.label = "Function",
.name = names[i],
.qualified_name = qns[i],
.file_path = "stress.go",
.start_line = i,
.end_line = i + 1};
}
int rc = cbm_store_upsert_node_batch(s, nodes, 100, ids);
ASSERT_EQ(rc, CBM_STORE_OK);
/* All IDs should be positive */
for (int i = 0; i < 100; i++)
ASSERT_GT(ids[i], 0);
/* IDs should all be unique */
for (int i = 0; i < 100; i++)
for (int j = i + 1; j < 100; j++)
ASSERT_NEQ(ids[i], ids[j]);
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 100);
/* Verify a few random lookups */
cbm_node_t found = {0};
rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_0", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "stress_0");
cbm_node_free_fields(&found);
rc = cbm_store_find_node_by_qn(s, "test", "test.stress.stress_99", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.name, "stress_99");
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── Edge case: delete then verify remaining ──────────────────── */
TEST(store_delete_by_label_verify_remaining) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {
.project = "test", .label = "Function", .name = "FuncA", .qualified_name = "test.FuncA"};
cbm_node_t n2 = {
.project = "test", .label = "Class", .name = "ClassB", .qualified_name = "test.ClassB"};
cbm_node_t n3 = {
.project = "test", .label = "Function", .name = "FuncC", .qualified_name = "test.FuncC"};
cbm_node_t n4 = {
.project = "test", .label = "Method", .name = "MethodD", .qualified_name = "test.MethodD"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
cbm_store_upsert_node(s, &n4);
/* Delete all Functions */
int rc = cbm_store_delete_nodes_by_label(s, "test", "Function");
ASSERT_EQ(rc, CBM_STORE_OK);
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 2);
/* Class and Method should remain */
cbm_node_t found = {0};
rc = cbm_store_find_node_by_qn(s, "test", "test.ClassB", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.label, "Class");
cbm_node_free_fields(&found);
rc = cbm_store_find_node_by_qn(s, "test", "test.MethodD", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.label, "Method");
cbm_node_free_fields(&found);
/* Deleted ones should be gone */
rc = cbm_store_find_node_by_qn(s, "test", "test.FuncA", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
rc = cbm_store_find_node_by_qn(s, "test", "test.FuncC", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
TEST(store_delete_by_file_verify_remaining) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "A",
.qualified_name = "test.A",
.file_path = "delete_me.go"};
cbm_node_t n2 = {.project = "test",
.label = "Function",
.name = "B",
.qualified_name = "test.B",
.file_path = "keep_me.go"};
cbm_node_t n3 = {.project = "test",
.label = "Function",
.name = "C",
.qualified_name = "test.C",
.file_path = "delete_me.go"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
int rc = cbm_store_delete_nodes_by_file(s, "test", "delete_me.go");
ASSERT_EQ(rc, CBM_STORE_OK);
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
/* Only keep_me.go node should remain */
cbm_node_t found = {0};
rc = cbm_store_find_node_by_qn(s, "test", "test.B", &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.file_path, "keep_me.go");
cbm_node_free_fields(&found);
/* Deleted nodes should be gone */
rc = cbm_store_find_node_by_qn(s, "test", "test.A", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
rc = cbm_store_find_node_by_qn(s, "test", "test.C", &found);
ASSERT_EQ(rc, CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
/* ── Edge case: upsert dedup with field changes ───────────────── */
TEST(store_node_upsert_updates_fields) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert initial node */
cbm_node_t n1 = {.project = "test",
.label = "Function",
.name = "MyFunc",
.qualified_name = "test.MyFunc",
.file_path = "old.go",
.start_line = 1,
.end_line = 10,
.properties_json = "{\"version\":1}"};
int64_t id1 = cbm_store_upsert_node(s, &n1);
ASSERT_GT(id1, 0);
/* Upsert same QN with changed fields */
cbm_node_t n2 = {.project = "test",
.label = "Method",
.name = "MyFunc",
.qualified_name = "test.MyFunc",
.file_path = "new.go",
.start_line = 50,
.end_line = 60,
.properties_json = "{\"version\":2}"};
int64_t id2 = cbm_store_upsert_node(s, &n2);
ASSERT_EQ(id1, id2); /* Same ID — updated, not duplicated */
/* Count should still be 1 */
int cnt = cbm_store_count_nodes(s, "test");
ASSERT_EQ(cnt, 1);
/* Verify fields were updated */
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_id(s, id1, &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.label, "Method");
ASSERT_STR_EQ(found.file_path, "new.go");
ASSERT_EQ(found.start_line, 50);
ASSERT_EQ(found.end_line, 60);
ASSERT(strstr(found.properties_json, "version") != NULL);
ASSERT(strstr(found.properties_json, "2") != NULL);
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── Edge case: long qualified name ───────────────────────────── */
TEST(store_node_long_qn) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Build a 1200-char qualified name */
char long_qn[1201];
memset(long_qn, 'a', 1200);
long_qn[0] = 't'; /* make it look like a dotted path */
for (int i = 50; i < 1200; i += 50)
long_qn[i] = '.';
long_qn[1200] = '\0';
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "LongName",
.qualified_name = long_qn,
.file_path = "big.go"};
int64_t id = cbm_store_upsert_node(s, &n);
ASSERT_GT(id, 0);
/* Should be retrievable by QN */
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_qn(s, "test", long_qn, &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_STR_EQ(found.qualified_name, long_qn);
ASSERT_STR_EQ(found.name, "LongName");
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── Edge case: properties JSON with special characters ────────── */
TEST(store_node_properties_special_chars) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* JSON with quotes, backslashes, unicode, newlines */
const char *props = "{\"desc\":\"line1\\nline2\","
"\"path\":\"C:\\\\Users\\\\test\","
"\"emoji\":\"\\u2603\","
"\"nested\":{\"key\":\"val with \\\"quotes\\\"\"}}";
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "SpecialFunc",
.qualified_name = "test.SpecialFunc",
.properties_json = props};
int64_t id = cbm_store_upsert_node(s, &n);
ASSERT_GT(id, 0);
cbm_node_t found = {0};
int rc = cbm_store_find_node_by_id(s, id, &found);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_NOT_NULL(found.properties_json);
/* Round-trip should preserve the JSON exactly */
ASSERT_STR_EQ(found.properties_json, props);
cbm_node_free_fields(&found);
cbm_store_close(s);
PASS();
}
/* ── Edge case: delete from non-existent project/file ─────────── */
TEST(store_delete_nodes_nonexistent) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Insert one node */
cbm_node_t n = {.project = "test",
.label = "Function",
.name = "Survivor",
.qualified_name = "test.Survivor",
.file_path = "main.go"};
cbm_store_upsert_node(s, &n);
/* Delete by non-existent file — should succeed but delete nothing */
int rc = cbm_store_delete_nodes_by_file(s, "test", "ghost.go");
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
/* Delete by non-existent label */
rc = cbm_store_delete_nodes_by_label(s, "test", "Interface");
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
/* Delete by non-existent project */
rc = cbm_store_delete_nodes_by_project(s, "no-such-project");
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(cbm_store_count_nodes(s, "test"), 1);
cbm_store_close(s);
PASS();
}
/* ── Edge case: count nodes for unknown project ───────────────── */
TEST(store_count_nodes_unknown_project) {
cbm_store_t *s = cbm_store_open_memory();
/* No project created — count should be 0 */
int cnt = cbm_store_count_nodes(s, "ghost-project");
ASSERT_EQ(cnt, 0);
cbm_store_close(s);
PASS();
}
/* ── Index coverage (#963) ──────────────────────────────────────── */
/* Round-trip + deleted-file prune + shadow miss-graph materialization +
* empty-replace wipe. The prune keys off file_hashes (the live-file set), so
* a row for a file with no hash row must not survive the replace. */
TEST(store_coverage_roundtrip_prune_shadow) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
cbm_store_upsert_file_hash(s, "test", "src/a.py", "", 1, 10);
cbm_coverage_row_t rows[] = {
{.rel_path = "src/a.py", .kind = "parse_partial", .detail = "4-7"},
{.rel_path = "gone.py", .kind = "oversized", .detail = "too big"},
/* By-design rows (#963): neither has a file_hashes row, yet both must
* SURVIVE the deleted-file prune (deliberately-unindexed paths never
* have hashes) — and must stay OUT of the shadow miss graph. */
{.rel_path = "secret.py", .kind = "not_indexed_file", .detail = "gitignore"},
{.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"},
};
ASSERT_EQ(cbm_store_coverage_replace(s, "test", rows, 4), CBM_STORE_OK);
cbm_coverage_row_t *got = NULL;
int n = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "test", &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 3); /* gone.py pruned — no file_hashes row; by-design rows kept */
int saw_partial = 0;
int saw_by_design = 0;
for (int i = 0; i < n; i++) {
if (strcmp(got[i].kind, "parse_partial") == 0) {
saw_partial++;
}
if (strncmp(got[i].kind, "not_indexed", 11) == 0) {
saw_by_design++;
}
}
ASSERT_EQ(saw_partial, 1);
ASSERT_EQ(saw_by_design, 2);
cbm_store_free_coverage(got, n);
/* Shadow miss-graph materialized under "test::missed": FAILURES only —
* Project → Folder(src) → File(a.py){kind,detail}; the by-design rows do
* not appear. */
cbm_node_t *nodes = NULL;
int nc = 0;
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
ASSERT_EQ(nc, 1);
ASSERT_STR_EQ(nodes[0].file_path, "src/a.py");
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"kind\":\"parse_partial\""));
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"detail\":\"4-7\""));
cbm_store_free_nodes(nodes, nc);
nodes = NULL;
nc = 0;
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "Folder", &nodes, &nc),
CBM_STORE_OK);
ASSERT_EQ(nc, 1);
ASSERT_STR_EQ(nodes[0].name, "src");
cbm_store_free_nodes(nodes, nc);
/* Empty replace clears the table AND wipes the shadow graph. */
ASSERT_EQ(cbm_store_coverage_replace(s, "test", NULL, 0), CBM_STORE_OK);
got = NULL;
n = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "test", &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 0);
free(got);
nodes = NULL;
nc = 0;
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "test::missed", "File", &nodes, &nc), CBM_STORE_OK);
ASSERT_EQ(nc, 0);
cbm_store_free_nodes(nodes, nc);
cbm_store_close(s);
PASS();
}
TEST(store_coverage_targeted_path_and_scope_lookup) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-targeted", "/tmp/coverage-targeted"),
CBM_STORE_OK);
/* Failure rows need live-file hash records so coverage_replace does not
* correctly prune them as deleted. By-design exclusions have no hashes. */
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src/partial.c", "", 10, 20),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src/skipped.c", "", 11, 21),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-targeted", "src2/other.c", "", 12, 22),
CBM_STORE_OK);
cbm_coverage_row_t rows[] = {
{.rel_path = "src/partial.c", .kind = "parse_partial", .detail = "7-9"},
{.rel_path = "src/skipped.c", .kind = "oversized", .detail = "too large"},
{.rel_path = "src2/other.c", .kind = "extract", .detail = "parser failed"},
{.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"},
{.rel_path = "secret.c", .kind = "not_indexed_file", .detail = "gitignore"},
};
ASSERT_EQ(cbm_store_coverage_replace(s, "coverage-targeted", rows, 5), CBM_STORE_OK);
cbm_coverage_row_t *got = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(s, "coverage-targeted", "src/partial.c", &got, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(got[0].rel_path, "src/partial.c");
ASSERT_STR_EQ(got[0].kind, "parse_partial");
ASSERT_STR_EQ(got[0].detail, "7-9");
cbm_store_free_coverage(got, count);
/* A file below an excluded directory inherits that directory row. */
got = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(s, "coverage-targeted", "generated/nested/file.c", &got,
&count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(got[0].rel_path, "generated");
ASSERT_STR_EQ(got[0].kind, "not_indexed_dir");
cbm_store_free_coverage(got, count);
/* Prefixes must stop at path-segment boundaries. */
got = NULL;
count = 0;
ASSERT_EQ(
cbm_store_coverage_get_path(s, "coverage-targeted", "generated2/file.c", &got, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_coverage(got, count);
got = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_scope(s, "coverage-targeted", "src", &got, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_STR_EQ(got[0].rel_path, "src/partial.c");
ASSERT_STR_EQ(got[1].rel_path, "src/skipped.c");
cbm_store_free_coverage(got, count);
/* A scope nested below an excluded directory still reports its ancestor. */
got = NULL;
count = 0;
ASSERT_EQ(
cbm_store_coverage_get_scope(s, "coverage-targeted", "generated/nested", &got, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(got[0].rel_path, "generated");
cbm_store_free_coverage(got, count);
cbm_file_hash_t hash = {0};
ASSERT_EQ(cbm_store_get_file_hash(s, "coverage-targeted", "src/partial.c", &hash),
CBM_STORE_OK);
ASSERT_STR_EQ(hash.project, "coverage-targeted");
ASSERT_STR_EQ(hash.rel_path, "src/partial.c");
ASSERT_EQ(hash.mtime_ns, 10);
ASSERT_EQ(hash.size, 20);
cbm_store_clear_file_hash(&hash);
ASSERT_NULL(hash.project);
ASSERT_NULL(hash.rel_path);
ASSERT_NULL(hash.sha256);
ASSERT_EQ(cbm_store_get_file_hash(s, "coverage-targeted", "missing.c", &hash),
CBM_STORE_NOT_FOUND);
cbm_store_clear_file_hash(&hash);
cbm_store_close(s);
PASS();
}
TEST(store_coverage_meta_zero_row_truncation_and_delete) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-meta", "/tmp/coverage-meta"), CBM_STORE_OK);
cbm_coverage_meta_t write_meta = {
.generation = "generation-42",
.index_mode = "fast",
.recording_status = "truncated",
.ignored_files_stored = 2000,
.ignored_files_total = 2501,
.coverage_version = 1,
.hash_records_complete = false,
};
/* Metadata is meaningful even when the authoritative miss set is empty. */
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-meta", NULL, 0, &write_meta),
CBM_STORE_OK);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-meta", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_coverage(rows, count);
cbm_coverage_meta_t got = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_OK);
ASSERT_STR_EQ(got.project, "coverage-meta");
ASSERT_STR_EQ(got.generation, "generation-42");
ASSERT_NOT_NULL(got.recorded_at);
ASSERT_STR_EQ(got.index_mode, "fast");
ASSERT_STR_EQ(got.recording_status, "truncated");
ASSERT_EQ(got.ignored_files_stored, 2000);
ASSERT_EQ(got.ignored_files_total, 2501);
ASSERT_EQ(got.coverage_version, 1);
ASSERT_FALSE(got.hash_records_complete);
cbm_store_coverage_meta_clear(&got);
ASSERT_NULL(got.project);
ASSERT_NULL(got.generation);
ASSERT_NULL(got.recorded_at);
ASSERT_NULL(got.index_mode);
ASSERT_NULL(got.recording_status);
/* Replacing through the compatibility wrapper clears possibly-stale meta. */
ASSERT_EQ(cbm_store_coverage_replace(s, "coverage-meta", NULL, 0), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_NOT_FOUND);
cbm_store_coverage_meta_clear(&got);
/* Recreate metadata and a missed-graph node, then project deletion must
* remove the table rows, metadata, and the derived ::missed project. */
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-meta", "bad.c", "", 1, 1), CBM_STORE_OK);
cbm_coverage_row_t failure = {.rel_path = "bad.c", .kind = "parse_partial", .detail = "1-2"};
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-meta", &failure, 1, &write_meta),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_delete_project(s, "coverage-meta"), CBM_STORE_OK);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-meta", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_store_free_coverage(rows, count);
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-meta", &got), CBM_STORE_NOT_FOUND);
cbm_store_coverage_meta_clear(&got);
cbm_project_t shadow = {0};
ASSERT_EQ(cbm_store_get_project(s, "coverage-meta::missed", &shadow), CBM_STORE_NOT_FOUND);
cbm_project_free_fields(&shadow);
cbm_store_close(s);
PASS();
}
TEST(store_coverage_replace_rejects_invalid_row_arguments) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-invalid", "/tmp/coverage-invalid"),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-invalid", "kept.c", "", 1, 1), CBM_STORE_OK);
cbm_coverage_row_t kept = {.rel_path = "kept.c", .kind = "parse_partial", .detail = "3-4"};
cbm_coverage_meta_t original_meta = {
.generation = "before-invalid-call",
.index_mode = "full",
.recording_status = "complete",
.coverage_version = 1,
.hash_records_complete = true,
};
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", &kept, 1, &original_meta),
CBM_STORE_OK);
cbm_coverage_meta_t replacement_meta = original_meta;
replacement_meta.generation = "must-not-commit";
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", &kept, -1, &replacement_meta),
CBM_STORE_ERR);
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-invalid", NULL, 1, &replacement_meta),
CBM_STORE_ERR);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-invalid", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].rel_path, "kept.c");
ASSERT_STR_EQ(rows[0].detail, "3-4");
cbm_store_free_coverage(rows, count);
cbm_coverage_meta_t got = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-invalid", &got), CBM_STORE_OK);
ASSERT_STR_EQ(got.generation, "before-invalid-call");
cbm_store_coverage_meta_clear(&got);
cbm_store_close(s);
PASS();
}
TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "coverage-shadow", "/tmp/coverage-shadow"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-shadow", "old.c", "", 1, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, "coverage-shadow", "new.c", "", 2, 2), CBM_STORE_OK);
cbm_coverage_row_t old_row = {
.rel_path = "old.c", .kind = "parse_partial", .detail = "old-detail"};
cbm_coverage_meta_t old_meta = {
.generation = "old-generation",
.index_mode = "full",
.recording_status = "complete",
.coverage_version = 1,
.hash_records_complete = true,
};
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-shadow", &old_row, 1, &old_meta),
CBM_STORE_OK);
int old_shadow_nodes = cbm_store_count_nodes(s, "coverage-shadow::missed");
ASSERT_GT(old_shadow_nodes, 0);
/* Force only the derived-view rebuild to fail. The authoritative rows,
* metadata, and prior shadow graph must remain one atomic generation. */
ASSERT_EQ(cbm_store_exec(s, "CREATE TRIGGER fail_missed_insert BEFORE INSERT ON nodes "
"WHEN NEW.project = 'coverage-shadow::missed' "
"BEGIN SELECT RAISE(ABORT, 'forced missed shadow failure'); END;"),
CBM_STORE_OK);
cbm_coverage_row_t new_row = {
.rel_path = "new.c", .kind = "parse_partial", .detail = "new-detail"};
cbm_coverage_meta_t new_meta = old_meta;
new_meta.generation = "new-generation";
ASSERT_EQ(cbm_store_coverage_replace_ex(s, "coverage-shadow", &new_row, 1, &new_meta),
CBM_STORE_ERR);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, "coverage-shadow", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].rel_path, "old.c");
ASSERT_STR_EQ(rows[0].detail, "old-detail");
cbm_store_free_coverage(rows, count);
cbm_coverage_meta_t got = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(s, "coverage-shadow", &got), CBM_STORE_OK);
ASSERT_STR_EQ(got.generation, "old-generation");
cbm_store_coverage_meta_clear(&got);
ASSERT_EQ(cbm_store_count_nodes(s, "coverage-shadow::missed"), old_shadow_nodes);
cbm_store_close(s);
PASS();
}
SUITE(store_nodes) {
RUN_TEST(store_coverage_roundtrip_prune_shadow);
RUN_TEST(store_coverage_targeted_path_and_scope_lookup);
RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete);
RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments);
RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails);
RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like);
RUN_TEST(sql_relation_labels_match_cbm_label_is_relation);
RUN_TEST(store_open_memory);
RUN_TEST(store_close_null);
RUN_TEST(store_open_memory_twice);
RUN_TEST(store_integrity_clean);
RUN_TEST(store_integrity_empty);
RUN_TEST(store_integrity_corrupt_bad_path);
RUN_TEST(store_integrity_windows_lowercase_drive_issue367);
RUN_TEST(store_integrity_corrupt_too_many_rows);
RUN_TEST(store_integrity_verdict_healthy_is_ok);
RUN_TEST(store_integrity_verdict_real_corruption_is_corrupt);
RUN_TEST(store_integrity_verdict_unopenable_is_transient_not_corrupt);
RUN_TEST(store_integrity_null_check);
RUN_TEST(store_project_crud);
RUN_TEST(store_project_update);
RUN_TEST(store_project_delete);
RUN_TEST(store_node_crud);
RUN_TEST(store_node_dedup);
RUN_TEST(store_node_find_by_label);
RUN_TEST(store_node_find_by_file);
RUN_TEST(store_node_find_not_found);
RUN_TEST(store_node_count_empty);
RUN_TEST(store_node_delete_by_file);
RUN_TEST(store_node_delete_by_label);
RUN_TEST(store_node_batch_upsert);
RUN_TEST(store_node_batch_empty);
RUN_TEST(store_cascade_delete);
RUN_TEST(store_file_hash_crud);
RUN_TEST(store_lsp_surface_round_trip);
RUN_TEST(store_dependent_files_lookup);
RUN_TEST(store_file_hash_upsert_rejects_null_required_fields);
RUN_TEST(store_node_properties_json);
RUN_TEST(store_node_null_properties);
RUN_TEST(store_find_by_file_overlap);
RUN_TEST(store_find_by_qn_suffix_single);
RUN_TEST(store_find_by_qn_suffix_no_match);
RUN_TEST(store_find_by_qn_suffix_multiple);
RUN_TEST(store_find_by_qn_suffix_dot_boundary);
RUN_TEST(store_node_degree);
RUN_TEST(store_file_hash_batch);
RUN_TEST(store_file_hash_batch_equals_loop);
RUN_TEST(store_find_edges_by_url_path);
RUN_TEST(store_restore_from);
RUN_TEST(store_pragma_settings);
RUN_TEST(store_find_node_ids_by_qns);
RUN_TEST(store_node_null_project);
RUN_TEST(store_node_null_qn);
RUN_TEST(store_node_empty_strings);
RUN_TEST(store_find_by_id_not_found);
RUN_TEST(store_find_by_qn_not_found);
RUN_TEST(store_find_by_qn_any_cross_project);
RUN_TEST(store_find_by_name_any_cross_project);
RUN_TEST(store_find_by_file_no_match);
RUN_TEST(store_node_batch_upsert_zero);
RUN_TEST(store_node_batch_upsert_100);
RUN_TEST(store_delete_by_label_verify_remaining);
RUN_TEST(store_delete_by_file_verify_remaining);
RUN_TEST(store_node_upsert_updates_fields);
RUN_TEST(store_node_long_qn);
RUN_TEST(store_node_properties_special_chars);
RUN_TEST(store_delete_nodes_nonexistent);
RUN_TEST(store_count_nodes_unknown_project);
}